LANTERN P1 Group B: 5-Spark systems — Aoe/Hitscan dispatch + reel/zone/decoy
Aoe/Hitscan archetype dispatch in AbilityFireSystem (both-worlds cooldown; server-only ecb.Instantiate — the client-never-instantiates invariant; ZoneEffect vs DecoyTag chosen by prefab membership). Harpooner REEL as a homing state (ReelState + ReelSystem re-aim the hit Husk's KnockbackState toward the caster's live position each tick; EnemyAISystem stays sole Position writer), stamped by ProjectileDamageSystem on a Reel-flag hit. ZonePulseSystem (enemy-only periodic AoE, caster attribution, no friendly fire, Vortex pull, born-0 lazy-stamp). Decoy aggro in EnemyAISystem + DecoySystem despawn. AbilityDefBlob gains EffectFlags (Reel) + DurationTicks. +3 ZonePulseSystem EditMode tests (497 green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,8 @@ namespace ProjectM.Authoring
|
||||
Archetype = (byte)def.Archetype,
|
||||
AutoTargetConeRadians = math.radians(def.AutoTargetConeDegrees),
|
||||
CooldownTicks = def.CooldownTicks,
|
||||
EffectFlags = (byte)(def.Reel ? ProjectileEffectFlag.Reel : (byte)0),
|
||||
DurationTicks = def.EffectDurationTicks,
|
||||
Name = def.DisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ namespace ProjectM.Authoring
|
||||
|
||||
[Header("Timing")]
|
||||
[Min(1)] public int CooldownTicks = 12;
|
||||
[Header("Spark effect (LANTERN)")]
|
||||
[Tooltip("HookPull: home the hit target to the caster (the Harpooner reel).")]
|
||||
public bool Reel = false;
|
||||
[Min(0), Tooltip("Aoe/zone + decoy lifetime in ticks (0 = system default).")]
|
||||
public int EffectDurationTicks = 0;
|
||||
|
||||
[Header("Prefab (baked into the prefab buffer, not the blob)")]
|
||||
public GameObject ProjectilePrefab;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for a decoy-wisp ghost (the LANTERN Aoe/spawn Spark). Bakes an inert <see cref="DecoyTag"/>
|
||||
/// (lifetime stamped at spawn) plus the damageable set (Health + DamageEvent buffer) so Husks path to and
|
||||
/// "kill" it — drawing aggro off the players (EnemyAISystem targets DecoyTag entities). NO HitRadius: the
|
||||
/// decoy is NOT a projectile target, so the caster's own shots pass through it. <c>DecoySystem</c> despawns
|
||||
/// it on Health <= 0 or lifetime elapse. Ghost setup (interpolated, ownerless) is inherited by duplicating
|
||||
/// an existing ownerless interpolated ghost, so position replicates via the stock LocalTransform variant.
|
||||
/// </summary>
|
||||
public class DecoyAuthoring : MonoBehaviour
|
||||
{
|
||||
[Min(1f), Tooltip("Decoy hit points — how much aggro/melee it soaks before popping.")]
|
||||
public float MaxHealth = 60f;
|
||||
|
||||
private class DecoyBaker : Baker<DecoyAuthoring>
|
||||
{
|
||||
public override void Bake(DecoyAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
AddComponent<DecoyTag>(entity); // ExpireTick stamped at spawn by AbilityFireSystem
|
||||
AddComponent(entity, new Health { Current = authoring.MaxHealth, Max = authoring.MaxHealth });
|
||||
AddBuffer<DamageEvent>(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fbd3e612f2e1e0a4abbd5d665943e9f2
|
||||
@@ -51,6 +51,7 @@ namespace ProjectM.Authoring
|
||||
});
|
||||
AddComponent(entity, new EnemyAttackCooldown { NextAttackTick = 0 });
|
||||
AddComponent<KnockbackState>(entity); // server-only recoil state (zero = not knocked)
|
||||
AddComponent<ReelState>(entity); // server-only Harpooner-reel homing state (baked inert; stamped on a Reel-flag hit; NOT a GhostField)
|
||||
AddComponent(entity, new EnemyNavState { LastPos = float.MaxValue }); // server-only anti-stuck nav state (not replicated); sentinel LastPos forces a first-tick reset
|
||||
AddComponent<AttackWindup>(entity); // replicated telegraph signal (zero = not winding up)
|
||||
// Slice 1 (Feature C): client-safe baked telegraph metadata. EnemyBaker is the SOLE writer of
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for a player-cast ZONE ghost (Vortex / LightZone — the LANTERN Aoe/zone Sparks). Bakes an inert
|
||||
/// <see cref="ZoneEffect"/>; the runtime values (caster / radius / damage / lifetime) are stamped at spawn by
|
||||
/// <c>AbilityFireSystem</c> — only the Vortex FLAG is authored here (so <c>AbilityFireSystem</c> can pick
|
||||
/// ZoneEffect vs DecoyTag by prefab membership, and the pull behaviour is baked-in). The prefab's ghost setup
|
||||
/// (GhostAuthoringComponent: interpolated, ownerless) is inherited by DUPLICATING an existing ownerless
|
||||
/// interpolated ghost, so it replicates to all clients via the stock LocalTransform variant (no hand-written
|
||||
/// <c>[GhostField]</c>). <c>GetEntity(Dynamic)</c> gives a runtime-mutable LocalTransform for the spawn override.
|
||||
/// </summary>
|
||||
public class ZoneAuthoring : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Vortex: pull enemies toward the zone centre each tick (in addition to the periodic damage).")]
|
||||
public bool Vortex = false;
|
||||
|
||||
private class ZoneBaker : Baker<ZoneAuthoring>
|
||||
{
|
||||
public override void Bake(ZoneAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
AddComponent(entity, new ZoneEffect
|
||||
{
|
||||
Flags = authoring.Vortex ? ZoneEffectFlag.Vortex : (byte)0,
|
||||
// caster / radius / damage / NextTick / ExpireTick are seeded at spawn by AbilityFireSystem.
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 491130dca5b97114cac9f21582092329
|
||||
@@ -0,0 +1,45 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY decoy-wisp lifecycle: despawns a decoy ghost when its Health is spent (Husks "killed" it) or
|
||||
/// its <see cref="DecoyTag.ExpireTick"/> lifetime elapses. Server-authoritative ghost despawn (the client's
|
||||
/// copy is removed by GhostSendSystem — never DestroyEntity a ghost client-side). The decoy draws aggro via
|
||||
/// <c>EnemyAISystem</c>'s decoy target set; this system only ends its life. Presence-gated on DecoyTag.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
public partial struct DecoySystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<DecoyTag>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid) return;
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (decoy, hp, e) in
|
||||
SystemAPI.Query<RefRO<DecoyTag>, RefRO<Health>>().WithEntityAccess())
|
||||
{
|
||||
bool expired = decoy.ValueRO.ExpireTick != 0u
|
||||
&& !new NetworkTick(decoy.ValueRO.ExpireTick).IsNewerThan(serverTick);
|
||||
if (hp.ValueRO.Current <= 0f || expired)
|
||||
ecb.DestroyEntity(e);
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 355ca71da85a0f444bb9c312c4dc4643
|
||||
@@ -73,6 +73,17 @@ namespace ProjectM.Server
|
||||
structurePositions.Add(sx.ValueRO.Position);
|
||||
structureRegions.Add(sr.ValueRO.Region);
|
||||
}
|
||||
// LANTERN decoy-wisp aggro: a decoy draws Husks like a fake target. Region-agnostic gather (gym).
|
||||
var decoyEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var decoyPositions = new NativeList<float3>(Allocator.Temp);
|
||||
foreach (var (dx, dh, de) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
|
||||
.WithAll<DecoyTag>().WithEntityAccess())
|
||||
{
|
||||
if (dh.ValueRO.Current <= 0f) continue; // a spent decoy is being despawned; don't aggro a corpse
|
||||
decoyEntities.Add(de);
|
||||
decoyPositions.Add(dx.ValueRO.Position);
|
||||
}
|
||||
|
||||
// END-1: the Engine Core is a FALLBACK target. When no living player/structure remains, undefended
|
||||
// Husks march on the base heart (PlotCenter) so the base can be overrun instead of the swarm idling.
|
||||
@@ -80,7 +91,7 @@ namespace ProjectM.Server
|
||||
&& SystemAPI.TryGetSingleton<CoreIntegrity>(out var coreInteg) && coreInteg.Current > 0;
|
||||
float3 corePos = coreAlive ? BaseGridMath.PlotCenter(SystemAPI.GetSingleton<BaseAnchor>()) : float3.zero;
|
||||
|
||||
if (playerEntities.Length == 0 && structureEntities.Length == 0 && !coreAlive)
|
||||
if (playerEntities.Length == 0 && structureEntities.Length == 0 && decoyEntities.Length == 0 && !coreAlive)
|
||||
{
|
||||
playerEntities.Dispose();
|
||||
playerPositions.Dispose();
|
||||
@@ -88,12 +99,15 @@ namespace ProjectM.Server
|
||||
structureEntities.Dispose();
|
||||
structurePositions.Dispose();
|
||||
structureRegions.Dispose();
|
||||
decoyEntities.Dispose();
|
||||
decoyPositions.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
float dt = SystemAPI.Time.DeltaTime;
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
float decoyAggroSq = 30f * 30f; // LANTERN decoy-wisp aggro radius (world units, squared)
|
||||
// Live feel knobs (MC-0): one read, guarded at use. Server-only — clients never simulate enemies.
|
||||
var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
|
||||
float structAggro = math.max(0f, tune.StructureAggroWeight);
|
||||
@@ -147,12 +161,30 @@ namespace ProjectM.Server
|
||||
// EB-1 fortress aggro: nearest of players (weight 1) + structures (StructureAggroWeight) — a wall/
|
||||
// turret is the preferred target unless a player is in the way (closer after weighting).
|
||||
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, huskRegion, structAggro, out bool tgtIsStruct, out int tgtIdx);
|
||||
// Decoy aggro (LANTERN): a decoy-wisp within range is the PREFERRED target -- overrides player/structure/core.
|
||||
int decoyIdx = -1;
|
||||
float decoyBestSq = decoyAggroSq;
|
||||
for (int di = 0; di < decoyPositions.Length; di++)
|
||||
{
|
||||
float dsq = math.distancesq(pos.xz, decoyPositions[di].xz);
|
||||
if (dsq <= decoyBestSq) { decoyBestSq = dsq; decoyIdx = di; }
|
||||
}
|
||||
Entity targetEntity;
|
||||
float3 targetPos;
|
||||
if (decoyIdx >= 0)
|
||||
{
|
||||
targetEntity = decoyEntities[decoyIdx];
|
||||
targetPos = decoyPositions[decoyIdx];
|
||||
}
|
||||
else
|
||||
{
|
||||
if (tgtIdx < 0 && !huskCoreAlive)
|
||||
continue; // no player/structure and no Core -> nothing to seek
|
||||
Entity targetEntity = tgtIdx < 0 ? Entity.Null
|
||||
continue; // no decoy, no player/structure, and no Core -> nothing to seek
|
||||
targetEntity = tgtIdx < 0 ? Entity.Null
|
||||
: (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]);
|
||||
float3 targetPos = tgtIdx < 0 ? corePos
|
||||
targetPos = tgtIdx < 0 ? corePos
|
||||
: (tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx]);
|
||||
}
|
||||
|
||||
// Seek: stop just inside strike range so the Husk holds position to attack.
|
||||
float stopDistance = stats.ValueRO.AttackRange * 0.9f;
|
||||
@@ -665,6 +697,8 @@ namespace ProjectM.Server
|
||||
structureEntities.Dispose();
|
||||
structurePositions.Dispose();
|
||||
structureRegions.Dispose();
|
||||
decoyEntities.Dispose();
|
||||
decoyPositions.Dispose();
|
||||
}
|
||||
|
||||
// Swept collide-and-slide for server-authoritative enemy movement — delegates to the shared
|
||||
|
||||
@@ -49,6 +49,9 @@ namespace ProjectM.Server
|
||||
/// <summary>RW lookup to stamp server-only knockback on a hit Husk (Husks bake KnockbackState; players/dummies don't).</summary>
|
||||
ComponentLookup<KnockbackState> m_KnockbackLookup;
|
||||
|
||||
/// <summary>RW lookup to stamp the server-only homing ReelState on a Reel-flagged (HookPull) hit — the Harpooner reel.</summary>
|
||||
ComponentLookup<ReelState> m_ReelLookup;
|
||||
|
||||
/// <summary>Read-only lookup so a BOSS (BossState) is skipped by the knockback stamp — the boss is
|
||||
/// knockback-immune (A4) so a solo player can't perma-stunlock it out of its slam wind-ups.</summary>
|
||||
ComponentLookup<BossState> m_BossLookup;
|
||||
@@ -62,11 +65,18 @@ namespace ProjectM.Server
|
||||
/// <summary>Max planar distance a Ricochet chain will reach for its next target (tunable).</summary>
|
||||
const float k_ChainRange = 8f;
|
||||
|
||||
/// <summary>Harpooner reel pull speed (world units/sec) written into the reeled Husk's KnockbackState.</summary>
|
||||
const float k_ReelSpeed = 16f;
|
||||
|
||||
/// <summary>Reel leash: max ticks the homing pull lasts before releasing (~60 ticks/sec).</summary>
|
||||
const uint k_ReelLeashTicks = 90u;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
|
||||
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
||||
m_ReelLookup = state.GetComponentLookup<ReelState>(isReadOnly: false);
|
||||
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
|
||||
m_FxLookup = state.GetComponentLookup<ProjectileEffectState>(isReadOnly: false);
|
||||
|
||||
@@ -79,6 +89,7 @@ namespace ProjectM.Server
|
||||
{
|
||||
m_GhostOwnerLookup.Update(ref state);
|
||||
m_KnockbackLookup.Update(ref state);
|
||||
m_ReelLookup.Update(ref state);
|
||||
m_BossLookup.Update(ref state);
|
||||
m_FxLookup.Update(ref state);
|
||||
|
||||
@@ -165,8 +176,21 @@ namespace ProjectM.Server
|
||||
SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u,
|
||||
});
|
||||
|
||||
// Knockback (Phase 1.7: PULL flips the heading toward the shooter when the owner's boon is set).
|
||||
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget) && !m_BossLookup.HasComponent(hitTarget))
|
||||
// Knockback / REEL. Reel (HookPull) stamps the HOMING ReelState (ReelSystem re-aims toward the
|
||||
// caster's live position each tick); otherwise the classic frozen knockback (PULL flips toward the shooter).
|
||||
if (haveTick && m_KnockbackLookup.HasComponent(hitTarget) && !m_BossLookup.HasComponent(hitTarget))
|
||||
{
|
||||
bool reel = hasFx && (fx.Flags & ProjectileEffectFlag.Reel) != 0;
|
||||
if (reel && m_ReelLookup.HasComponent(hitTarget))
|
||||
{
|
||||
m_ReelLookup[hitTarget] = new ReelState
|
||||
{
|
||||
CasterNetworkId = projOwnerId,
|
||||
Speed = k_ReelSpeed,
|
||||
ExpireTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + k_ReelLeashTicks),
|
||||
};
|
||||
}
|
||||
else if (Tuning.KnockbackSpeed > 0f)
|
||||
{
|
||||
bool pull = hasFx && (fx.Flags & ProjectileEffectFlag.Pull) != 0;
|
||||
float2 kdir = pull ? -proj.ValueRO.Direction : proj.ValueRO.Direction;
|
||||
@@ -177,6 +201,7 @@ namespace ProjectM.Server
|
||||
UntilTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1.7: pierce/chain let the projectile SURVIVE; else it is consumed. Record the target so it
|
||||
// can't be re-hit. A full hit-set is a natural cap → destroy.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY Harpooner reel driver. For every Husk carrying an active <see cref="ReelState"/> (stamped by
|
||||
/// <see cref="ProjectileDamageSystem"/> on a Reel-flagged HookPull hit), it re-aims the Husk's
|
||||
/// <see cref="KnockbackState"/> toward the CASTER's CURRENT position every tick — so the pull HOMES (unlike
|
||||
/// the frozen-Dir pull flag). <see cref="EnemyAISystem"/> then DRIVES that re-aimed knockback, staying the
|
||||
/// SOLE writer of the Husk's Position; this system writes only KnockbackState.Dir/Speed/UntilTick (never a
|
||||
/// position). Runs in the plain <see cref="SimulationSystemGroup"/> AFTER the predicted group (so THIS tick's
|
||||
/// stamp is visible) and BEFORE <see cref="EnemyAISystem"/>. Releases (clears both states) when the target is
|
||||
/// within release range, the leash (<see cref="ReelState.ExpireTick"/>) elapses, or the caster is gone.
|
||||
/// Bosses never reel (they don't carry KnockbackState/ReelState — knockback-immune, A4). Ticks via
|
||||
/// <see cref="TickUtil.NonZero"/>; compared with <see cref="NetworkTick"/> only.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(EnemyAISystem))]
|
||||
public partial struct ReelSystem : ISystem
|
||||
{
|
||||
const float k_ReleaseDistance = 1.6f; // stop reeling once the target is this close to the caster (world units)
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<ReelState>()));
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid) return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
|
||||
// Snapshot living players by NetworkId (<=4 in co-op; linear lookup is cheaper than a hashmap).
|
||||
var casterIds = new NativeList<int>(Allocator.Temp);
|
||||
var casterPos = new NativeList<float3>(Allocator.Temp);
|
||||
foreach (var (owner, lt, hp) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>, RefRO<LocalTransform>, RefRO<Health>>().WithAll<PlayerTag>())
|
||||
{
|
||||
if (hp.ValueRO.Current <= 0f) continue;
|
||||
casterIds.Add(owner.ValueRO.NetworkId);
|
||||
casterPos.Add(lt.ValueRO.Position);
|
||||
}
|
||||
|
||||
float releaseSq = k_ReleaseDistance * k_ReleaseDistance;
|
||||
|
||||
foreach (var (reel, knockback, lt) in
|
||||
SystemAPI.Query<RefRW<ReelState>, RefRW<KnockbackState>, RefRO<LocalTransform>>()
|
||||
.WithAll<EnemyTag>().WithNone<Dying>())
|
||||
{
|
||||
uint expire = reel.ValueRO.ExpireTick;
|
||||
bool active = expire != 0u && new NetworkTick(expire).IsNewerThan(serverTick);
|
||||
if (!active)
|
||||
{
|
||||
// Leash elapsed (or never reeling): release so EnemyAISystem resumes seek next tick.
|
||||
if (expire != 0u) { reel.ValueRW.ExpireTick = 0u; knockback.ValueRW.UntilTick = 0u; }
|
||||
continue;
|
||||
}
|
||||
|
||||
int idx = -1;
|
||||
for (int i = 0; i < casterIds.Length; i++)
|
||||
if (casterIds[i] == reel.ValueRO.CasterNetworkId) { idx = i; break; }
|
||||
if (idx < 0) { reel.ValueRW.ExpireTick = 0u; knockback.ValueRW.UntilTick = 0u; continue; } // caster gone
|
||||
|
||||
float3 pos = lt.ValueRO.Position;
|
||||
float2 to = casterPos[idx].xz - pos.xz;
|
||||
if (math.lengthsq(to) <= releaseSq)
|
||||
{
|
||||
// Arrived at the caster: release (don't jitter on top of the player).
|
||||
reel.ValueRW.ExpireTick = 0u;
|
||||
knockback.ValueRW.UntilTick = 0u;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Home: re-aim the knockback toward the caster's LIVE position for exactly this tick; EnemyAISystem
|
||||
// applies it. Re-stamped every tick while reeling so it tracks the caster as they move.
|
||||
float2 dir = math.normalize(to);
|
||||
knockback.ValueRW.Dir = dir;
|
||||
knockback.ValueRW.Speed = reel.ValueRO.Speed;
|
||||
knockback.ValueRW.UntilTick = TickUtil.NonZero(now + 1u);
|
||||
}
|
||||
|
||||
casterIds.Dispose();
|
||||
casterPos.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 63b7300c466d5a945a7ce275769efea3
|
||||
@@ -0,0 +1,133 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY player-cast zone driver (Vortex / LightZone — the LANTERN Aoe/zone Sparks). Borrows the
|
||||
/// <see cref="GeyserEruptSystem"/> skeleton — the INVERTED invalid-tick guard (a born-0 <see cref="ZoneEffect.NextTick"/>
|
||||
/// is lazy-stamped born-correct, never storm-fires) and the <c>= now + period</c> reschedule — but is
|
||||
/// ENEMY-ONLY (NO friendly fire), stamps <see cref="DamageEvent.SourceNetworkId"/> = the caster's NetworkId
|
||||
/// (kills credit the caster — NOT the -1 environment convention), and has NO region gate (a no-world gym).
|
||||
/// A <see cref="ZoneEffectFlag.Vortex"/> zone ALSO writes a gentle inward <see cref="KnockbackState"/> every
|
||||
/// tick (EnemyAISystem drives it, sole Position writer), so runs BEFORE EnemyAISystem. DestroyEntity when
|
||||
/// <see cref="ZoneEffect.ExpireTick"/> elapses (server-authoritative ghost despawn). Ticks via
|
||||
/// <see cref="TickUtil.NonZero"/>; compared with <see cref="NetworkTick"/> only.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(EnemyAISystem))]
|
||||
public partial struct ZonePulseSystem : ISystem
|
||||
{
|
||||
const uint k_PulsePeriodTicks = 30u; // damage cadence (~0.5s at 60 ticks/sec)
|
||||
const float k_VortexPullSpeed = 4f; // gentle inward drift for a Vortex (world units/sec)
|
||||
const float k_VortexDeadzoneSq = 0.25f; // don't re-aim (or NaN) an enemy already at the zone centre
|
||||
|
||||
ComponentLookup<KnockbackState> m_KnockbackLookup;
|
||||
ComponentLookup<BossState> m_BossLookup;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<ZoneEffect>();
|
||||
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
||||
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid) return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
uint stamp = TickUtil.NonZero(now);
|
||||
uint reschedule = TickUtil.NonZero(now + k_PulsePeriodTicks);
|
||||
m_KnockbackLookup.Update(ref state);
|
||||
m_BossLookup.Update(ref state);
|
||||
|
||||
// Living enemies once this tick (entities + positions; stable query order).
|
||||
var enemyEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var enemyPositions = new NativeList<float3>(Allocator.Temp);
|
||||
foreach (var (lt, hp, e) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
|
||||
.WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess())
|
||||
{
|
||||
if (hp.ValueRO.Current <= 0f) continue;
|
||||
enemyEntities.Add(e);
|
||||
enemyPositions.Add(lt.ValueRO.Position);
|
||||
}
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (zone, lt, zoneEntity) in
|
||||
SystemAPI.Query<RefRW<ZoneEffect>, RefRO<LocalTransform>>().WithEntityAccess())
|
||||
{
|
||||
var ze = zone.ValueRO;
|
||||
|
||||
// Lifetime: despawn the zone ghost when the leash elapses (server-authoritative).
|
||||
if (ze.ExpireTick != 0u && !new NetworkTick(ze.ExpireTick).IsNewerThan(serverTick))
|
||||
{
|
||||
ecb.DestroyEntity(zoneEntity);
|
||||
continue;
|
||||
}
|
||||
|
||||
float3 center = lt.ValueRO.Position;
|
||||
float radiusSq = ze.Radius * ze.Radius;
|
||||
bool vortex = (ze.Flags & ZoneEffectFlag.Vortex) != 0;
|
||||
|
||||
// Vortex pull: gentle inward drift EVERY tick (re-stamped, one tick; EnemyAISystem applies it).
|
||||
if (vortex)
|
||||
{
|
||||
for (int i = 0; i < enemyEntities.Length; i++)
|
||||
{
|
||||
float2 to = center.xz - enemyPositions[i].xz;
|
||||
float d2 = math.lengthsq(to);
|
||||
if (d2 > radiusSq || d2 <= k_VortexDeadzoneSq) continue;
|
||||
var e = enemyEntities[i];
|
||||
if (!m_KnockbackLookup.HasComponent(e) || m_BossLookup.HasComponent(e)) continue;
|
||||
m_KnockbackLookup[e] = new KnockbackState
|
||||
{
|
||||
Dir = math.normalize(to),
|
||||
Speed = k_VortexPullSpeed,
|
||||
UntilTick = TickUtil.NonZero(now + 1u),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Damage pulse: INVERTED invalid-tick guard (a born-0 NextTick lazy-stamps born-correct, never storms).
|
||||
uint next = ze.NextTick;
|
||||
if (next == 0u || !new NetworkTick(next).IsValid)
|
||||
{
|
||||
zone.ValueRW.NextTick = reschedule;
|
||||
continue;
|
||||
}
|
||||
if (new NetworkTick(next).IsNewerThan(serverTick)) continue; // still counting down
|
||||
|
||||
for (int i = 0; i < enemyEntities.Length; i++)
|
||||
{
|
||||
if (math.distancesq(enemyPositions[i].xz, center.xz) > radiusSq) continue;
|
||||
ecb.AppendToBuffer(enemyEntities[i], new DamageEvent
|
||||
{
|
||||
Amount = ze.DamagePerPulse,
|
||||
SourceNetworkId = ze.CasterNetworkId,
|
||||
SourceTick = stamp,
|
||||
});
|
||||
}
|
||||
zone.ValueRW.NextTick = reschedule;
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
enemyEntities.Dispose();
|
||||
enemyPositions.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f96f56094eb9d4498f9e2da9d819e2a
|
||||
@@ -25,6 +25,8 @@ namespace ProjectM.Simulation
|
||||
public float AutoTargetRange;
|
||||
public float AutoTargetConeRadians;
|
||||
public int CooldownTicks;
|
||||
public byte EffectFlags; // ProjectileEffectFlag bits stamped at spawn (the Reel bit ⇒ HookPull homes the hit target to the caster). Config, not replicated.
|
||||
public int DurationTicks; // Aoe/zone + decoy lifetime, ticks (0 = system default). Reel leash is a system const.
|
||||
public FixedString64Bytes Name;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ namespace ProjectM.Simulation
|
||||
///
|
||||
/// Determinism/idempotency: gated behind IsFirstTimeFullyPredictingTick so a rollback re-sim never
|
||||
/// double-spawns. No wall-clock, no System.Random. Predict-spawn is reserved for the Projectile archetype;
|
||||
/// Cone applies its effect server-only (cooldown predicted both worlds). Aoe/Hitscan/movement archetypes
|
||||
/// are handled elsewhere (steps 3/4) and fall through here. Auto-target stays server-only + gamepad-only.
|
||||
/// Cone/Aoe/Hitscan apply their effect SERVER-ONLY (cooldown predicted on both worlds); the Aoe archetype
|
||||
/// instantiates its interpolated ghost server-only. Movement (Blink) falls through to BlinkSystem. Auto-target stays server-only + gamepad-only.
|
||||
/// </summary>
|
||||
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(PlayerAimSystem))]
|
||||
@@ -46,9 +46,16 @@ namespace ProjectM.Simulation
|
||||
BufferLookup<AbilitySocket> m_SocketLookup;
|
||||
ComponentLookup<SocketCooldown> m_SocketCdLookup;
|
||||
BufferLookup<EffectiveSocketStats> m_EffSocketLookup;
|
||||
// Prefab-membership + baked transform of an Aoe spawn prefab, read on the PREFAB entity to pick
|
||||
// ZoneEffect vs DecoyTag and preserve baked scale. RO; the actual Instantiate is server-only.
|
||||
ComponentLookup<ZoneEffect> m_ZoneLookup;
|
||||
ComponentLookup<DecoyTag> m_DecoyLookup;
|
||||
ComponentLookup<LocalTransform> m_LtLookup;
|
||||
|
||||
/// <summary>~9 degree gap between adjacent Split-Shot projectiles (tunable).</summary>
|
||||
const float k_ForkSpreadRad = 0.157f;
|
||||
const float k_AoeCastAhead = 2.5f; // world units ahead of the caster to drop an Aoe/zone/decoy ghost
|
||||
const float k_HitscanHalfWidth = 0.6f; // hitscan beam half-width (world units)
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
@@ -61,6 +68,9 @@ namespace ProjectM.Simulation
|
||||
m_SocketLookup = state.GetBufferLookup<AbilitySocket>(isReadOnly: true);
|
||||
m_SocketCdLookup = state.GetComponentLookup<SocketCooldown>(isReadOnly: false);
|
||||
m_EffSocketLookup = state.GetBufferLookup<EffectiveSocketStats>(isReadOnly: true);
|
||||
m_ZoneLookup = state.GetComponentLookup<ZoneEffect>(isReadOnly: true);
|
||||
m_DecoyLookup = state.GetComponentLookup<DecoyTag>(isReadOnly: true);
|
||||
m_LtLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
@@ -85,6 +95,9 @@ namespace ProjectM.Simulation
|
||||
m_SocketLookup.Update(ref state);
|
||||
m_SocketCdLookup.Update(ref state);
|
||||
m_EffSocketLookup.Update(ref state);
|
||||
m_ZoneLookup.Update(ref state);
|
||||
m_DecoyLookup.Update(ref state);
|
||||
m_LtLookup.Update(ref state);
|
||||
|
||||
// Server-only LIVING-enemy target set (auto-target assist + Cone cleave), collected once.
|
||||
var candidatePositions = new NativeList<float3>(Allocator.Temp);
|
||||
@@ -170,7 +183,74 @@ namespace ProjectM.Simulation
|
||||
cdDirty = true;
|
||||
continue;
|
||||
}
|
||||
// Aoe/Hitscan (steps 3) and movement/Blink (step 4) are not dispatched here yet.
|
||||
// AOE / ZONE / DECOY (decoy-wisp, vortex, light-zone): predict the cooldown on BOTH worlds;
|
||||
// instantiate the interpolated ownerless ghost SERVER-ONLY -- the client must NEVER instantiate it (Build Spec §5).
|
||||
if (archetype == (byte)AbilityArchetype.Aoe)
|
||||
{
|
||||
if (isServer)
|
||||
{
|
||||
Entity aoePrefab = Entity.Null;
|
||||
for (int i = 0; i < abilityPrefabs.Length; i++)
|
||||
if (abilityPrefabs[i].Id == sparkId) { aoePrefab = abilityPrefabs[i].Prefab; break; }
|
||||
if (aoePrefab != Entity.Null)
|
||||
{
|
||||
float2 aFace = facing.ValueRO.Direction;
|
||||
aFace = math.lengthsq(aFace) < 1e-6f ? new float2(0f, 1f) : math.normalize(aFace);
|
||||
float3 spawnPos = xform.ValueRO.Position + new float3(aFace.x, 0f, aFace.y) * k_AoeCastAhead;
|
||||
spawnPos.y = xform.ValueRO.Position.y;
|
||||
uint expire = adef.DurationTicks > 0
|
||||
? TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)adef.DurationTicks) : 0u;
|
||||
var bakedLt = m_LtLookup.HasComponent(aoePrefab) ? m_LtLookup[aoePrefab] : LocalTransform.Identity;
|
||||
var effect = ecb.Instantiate(aoePrefab);
|
||||
ecb.SetComponent(effect, bakedLt.WithPosition(spawnPos));
|
||||
if (m_ZoneLookup.HasComponent(aoePrefab))
|
||||
ecb.SetComponent(effect, new ZoneEffect
|
||||
{
|
||||
CasterNetworkId = owner.ValueRO.NetworkId,
|
||||
Radius = math.max(0.1f, es.Range),
|
||||
DamagePerPulse = es.Damage,
|
||||
NextTick = 0u, // lazy-stamped born-correct by ZonePulseSystem (never storm-fires)
|
||||
ExpireTick = expire,
|
||||
Flags = m_ZoneLookup[aoePrefab].Flags, // Vortex bit baked on the prefab
|
||||
});
|
||||
else if (m_DecoyLookup.HasComponent(aoePrefab))
|
||||
ecb.SetComponent(effect, new DecoyTag { ExpireTick = expire });
|
||||
}
|
||||
}
|
||||
cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks)));
|
||||
cdDirty = true;
|
||||
continue;
|
||||
}
|
||||
// HITSCAN: no ghost on either world; predict the cooldown both worlds; server-only swept-beam damage.
|
||||
// (No Phase-1 Spark uses Hitscan yet; the branch completes the archetype dispatch per Build Spec §5.)
|
||||
if (archetype == (byte)AbilityArchetype.Hitscan)
|
||||
{
|
||||
if (isServer)
|
||||
{
|
||||
float2 hFace = facing.ValueRO.Direction;
|
||||
hFace = math.lengthsq(hFace) < 1e-6f ? new float2(0f, 1f) : math.normalize(hFace);
|
||||
float hRange = math.max(0.1f, es.Range);
|
||||
uint hStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
|
||||
for (int hi = 0; hi < coneTargets.Length; hi++)
|
||||
{
|
||||
float2 rel = coneTargetPos[hi].xz - xform.ValueRO.Position.xz;
|
||||
float t = math.dot(rel, hFace);
|
||||
if (t < 0f || t > hRange) continue;
|
||||
float2 perp = rel - hFace * t;
|
||||
if (math.lengthsq(perp) > k_HitscanHalfWidth * k_HitscanHalfWidth) continue;
|
||||
ecb.AppendToBuffer(coneTargets[hi], new DamageEvent
|
||||
{
|
||||
Amount = es.Damage,
|
||||
SourceNetworkId = owner.ValueRO.NetworkId,
|
||||
SourceTick = hStamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks)));
|
||||
cdDirty = true;
|
||||
continue;
|
||||
}
|
||||
// MOVEMENT (Blink) + any other non-Projectile: fall through (BlinkSystem owns the movement socket's cooldown row).
|
||||
if (archetype != (byte)AbilityArchetype.Projectile)
|
||||
continue;
|
||||
|
||||
@@ -197,7 +277,7 @@ namespace ProjectM.Simulation
|
||||
|
||||
byte pierce = bfx.Pierce;
|
||||
byte chain = bfx.Chain;
|
||||
byte projFlags = pull ? ProjectileEffectFlag.Pull : (byte)0;
|
||||
byte projFlags = (byte)((pull ? ProjectileEffectFlag.Pull : 0) | adef.EffectFlags);
|
||||
int shots = 1 + math.min((int)bfx.Fork, 8);
|
||||
|
||||
for (int s = 0; s < shots; s++)
|
||||
|
||||
@@ -30,6 +30,8 @@ namespace ProjectM.Simulation
|
||||
/// <summary>Bit masks for <see cref="ProjectileEffectState.Flags"/>.</summary>
|
||||
public static class ProjectileEffectFlag
|
||||
{
|
||||
/// <summary>bit1 = Reel (HookPull): stamp the homing <see cref="ReelState"/> toward the CASTER instead of a frozen knockback.</summary>
|
||||
public const byte Reel = 2;
|
||||
public const byte Pull = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY homing reel on a Husk hit by a Reel-flagged (HookPull) projectile — the Harpooner's signature
|
||||
/// verb. A sibling of <see cref="KnockbackState"/>/<see cref="LungeState"/> (NOT a <c>[GhostField]</c>: the
|
||||
/// reeled position replicates via the stock LocalTransform variant, so it adds no replicated surface). Unlike
|
||||
/// the frozen-Dir pull flag, this HOMES: <c>ReelSystem</c> re-aims the Husk's <see cref="KnockbackState"/>
|
||||
/// toward the caster's CURRENT position every server tick until the target is within release range or
|
||||
/// <see cref="ExpireTick"/> elapses — <c>EnemyAISystem</c> stays the SOLE Position writer (it drives the
|
||||
/// re-aimed knockback; ReelSystem only writes the Dir/Speed/UntilTick). Baked inert on Husk variants
|
||||
/// (EnemyAuthoring). Stores the caster's <see cref="Unity.NetCode.NetworkId"/> (not an Entity) so ReelSystem
|
||||
/// resolves the live caster position via a per-tick player-by-NetworkId gather. Ticks via
|
||||
/// <see cref="TickUtil.NonZero"/>; compared with <see cref="Unity.NetCode.NetworkTick"/> only.
|
||||
/// </summary>
|
||||
public struct ReelState : IComponentData
|
||||
{
|
||||
/// <summary>NetworkId of the reeling caster; ReelSystem homes toward this player's live position.</summary>
|
||||
public int CasterNetworkId;
|
||||
|
||||
/// <summary>Reel pull speed (world units/sec) written into KnockbackState.Speed each tick.</summary>
|
||||
public float Speed;
|
||||
|
||||
/// <summary>Raw tick the reel leash ends (NonZero). <c>0</c> = not reeling; active while .IsNewerThan(serverTick).</summary>
|
||||
public uint ExpireTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f6d64d0ff96ddd44a95c296e89442e6f
|
||||
@@ -0,0 +1,54 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY periodic-AoE state on a player-cast zone ghost (Vortex / LightZone) — the LANTERN Aoe/zone
|
||||
/// archetype. NOT a <c>[GhostField]</c>: the zone is a server-spawned INTERPOLATED ownerless ghost whose
|
||||
/// position replicates via the stock LocalTransform variant; only its damage schedule lives here, server-side.
|
||||
/// <c>ZonePulseSystem</c> borrows the <c>GeyserEruptSystem</c> skeleton (the INVERTED invalid-tick guard so a
|
||||
/// born-0 tick never storm-fires + the <c>= now + period</c> reschedule) but is ENEMY-ONLY (no friendly fire),
|
||||
/// stamps <see cref="DamageEvent.SourceNetworkId"/> = <see cref="CasterNetworkId"/> (kills credit the caster,
|
||||
/// feeding KillRewardSystem — NOT the -1 environment convention), and has NO region gate (a no-world gym).
|
||||
/// DestroyEntity when <see cref="ExpireTick"/> elapses.
|
||||
/// </summary>
|
||||
public struct ZoneEffect : IComponentData
|
||||
{
|
||||
/// <summary>NetworkId of the casting player (damage/kill attribution).</summary>
|
||||
public int CasterNetworkId;
|
||||
|
||||
/// <summary>Planar (XZ) damage radius, world units.</summary>
|
||||
public float Radius;
|
||||
|
||||
/// <summary>Damage dealt to each living enemy in radius per pulse.</summary>
|
||||
public float DamagePerPulse;
|
||||
|
||||
/// <summary>Raw next-pulse tick (NonZero). <c>0</c> = unscheduled → lazy-stamped born-correct (GeyserErupt H2 rule, never storm-fires).</summary>
|
||||
public uint NextTick;
|
||||
|
||||
/// <summary>Raw tick the zone despawns (NonZero). Active while .IsNewerThan(serverTick).</summary>
|
||||
public uint ExpireTick;
|
||||
|
||||
/// <summary>See <see cref="ZoneEffectFlag"/>. bit0 = Vortex (also pull enemies toward the zone center each pulse).</summary>
|
||||
public byte Flags;
|
||||
}
|
||||
|
||||
/// <summary>Bit masks for <see cref="ZoneEffect.Flags"/>.</summary>
|
||||
public static class ZoneEffectFlag
|
||||
{
|
||||
public const byte Vortex = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SERVER-ONLY decoy-wisp state (the Aoe/spawn Spark) — a server-spawned INTERPOLATED ownerless ghost that
|
||||
/// draws Husk aggro. <c>EnemyAISystem</c> gathers DecoyTag entities into its target set so Husks path to and
|
||||
/// strike the decoy (soaking hits via its Health/DamageEvent buffer); it despawns on <c>Health <= 0</c> or
|
||||
/// when <see cref="ExpireTick"/> elapses (both handled where EnemyAISystem already iterates decoys). Position
|
||||
/// replicates via the stock LocalTransform variant — NOT a <c>[GhostField]</c>.
|
||||
/// </summary>
|
||||
public struct DecoyTag : IComponentData
|
||||
{
|
||||
/// <summary>Raw tick the decoy despawns (NonZero). <c>0</c> = no timeout; active while .IsNewerThan(serverTick).</summary>
|
||||
public uint ExpireTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 28a356c03de9cfc4c839924c5cb14f5c
|
||||
@@ -0,0 +1,127 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="ZonePulseSystem"/> (LANTERN Vortex/LightZone).
|
||||
/// Guards the Build Spec RS-5 invariants: a player-cast zone damages ENEMIES ONLY (no friendly fire), credits
|
||||
/// the CASTER's NetworkId (kills feed KillReward), respects its radius, and despawns on lifetime elapse. The
|
||||
/// system reads a NetworkTime singleton (constructed here) and plays its ECB back immediately, so one
|
||||
/// group.Update() resolves within a single tick. [WorldSystemFilter] is ignored under manual registration.
|
||||
/// </summary>
|
||||
public class ZonePulseSystemTests
|
||||
{
|
||||
const uint kTick = 100; // "now" server tick for the fixture
|
||||
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<ZonePulseSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
|
||||
// NetworkTime singleton at a valid, non-zero ServerTick.
|
||||
var nt = world.EntityManager.CreateEntity(typeof(NetworkTime));
|
||||
world.EntityManager.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(kTick) });
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakeEnemy(EntityManager em, float3 pos, float hp = 100f)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(EnemyTag), typeof(Health), typeof(DamageEvent), typeof(LocalTransform));
|
||||
em.SetComponentData(e, new Health { Current = hp, Max = hp });
|
||||
em.SetComponentData(e, LocalTransform.FromPosition(pos));
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakeZone(EntityManager em, float3 pos, int casterId, float radius, float dmg, uint nextTick, uint expireTick, byte flags = 0)
|
||||
{
|
||||
var z = em.CreateEntity(typeof(ZoneEffect), typeof(LocalTransform));
|
||||
em.SetComponentData(z, LocalTransform.FromPosition(pos));
|
||||
em.SetComponentData(z, new ZoneEffect
|
||||
{
|
||||
CasterNetworkId = casterId,
|
||||
Radius = radius,
|
||||
DamagePerPulse = dmg,
|
||||
NextTick = nextTick,
|
||||
ExpireTick = expireTick,
|
||||
Flags = flags,
|
||||
});
|
||||
return z;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pulse_Damages_Enemies_In_Radius_Credits_Caster_And_Spares_Players()
|
||||
{
|
||||
var (world, group) = MakeWorld("ZonePulse_EnemyOnly");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var inRange = MakeEnemy(em, new float3(2f, 0f, 0f));
|
||||
var outRange = MakeEnemy(em, new float3(20f, 0f, 0f));
|
||||
|
||||
// A living player standing INSIDE the zone must take NO friendly fire.
|
||||
var player = em.CreateEntity(typeof(PlayerTag), typeof(Health), typeof(DamageEvent), typeof(LocalTransform));
|
||||
em.SetComponentData(player, new Health { Current = 100f, Max = 100f });
|
||||
em.SetComponentData(player, LocalTransform.FromPosition(new float3(1f, 0f, 0f)));
|
||||
|
||||
// NextTick == now → DUE this tick (radius 5, 10 dmg/pulse, caster 42, never expires).
|
||||
MakeZone(em, float3.zero, casterId: 42, radius: 5f, dmg: 10f, nextTick: kTick, expireTick: 0u);
|
||||
|
||||
group.Update();
|
||||
|
||||
var inBuf = em.GetBuffer<DamageEvent>(inRange);
|
||||
Assert.AreEqual(1, inBuf.Length, "An enemy inside the radius takes exactly one pulse.");
|
||||
Assert.AreEqual(10f, inBuf[0].Amount, 1e-4f, "Pulse deals DamagePerPulse.");
|
||||
Assert.AreEqual(42, inBuf[0].SourceNetworkId, "The pulse credits the caster's NetworkId (kill attribution).");
|
||||
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(outRange).Length, "An enemy outside the radius is untouched.");
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "A player in the zone takes NO friendly fire (enemy-only).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Born_Zero_NextTick_Lazy_Stamps_And_Does_Not_Storm_Fire()
|
||||
{
|
||||
var (world, group) = MakeWorld("ZonePulse_BornZero");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var enemy = MakeEnemy(em, new float3(1f, 0f, 0f));
|
||||
// NextTick 0 = unscheduled: the INVERTED guard must lazy-stamp born-correct, NOT fire this tick.
|
||||
var zone = MakeZone(em, float3.zero, casterId: 7, radius: 5f, dmg: 10f, nextTick: 0u, expireTick: 0u);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length,
|
||||
"A born-0 NextTick must lazy-stamp (never storm-fire) on the first tick.");
|
||||
Assert.AreNotEqual(0u, em.GetComponentData<ZoneEffect>(zone).NextTick,
|
||||
"NextTick must be stamped born-correct (non-zero) after the first tick.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Expired_Zone_Is_Despawned()
|
||||
{
|
||||
var (world, group) = MakeWorld("ZonePulse_Expire");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
// ExpireTick in the past (50 < now 100) → despawn this tick.
|
||||
var zone = MakeZone(em, float3.zero, casterId: 1, radius: 5f, dmg: 10f, nextTick: kTick, expireTick: 50u);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.IsFalse(em.Exists(zone), "A zone past its ExpireTick is destroyed (server-authoritative despawn).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 62c15e84019e90549a538044f708f5dd
|
||||
Reference in New Issue
Block a user