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:
2026-07-15 00:00:41 -07:00
parent 8f070ff83c
commit 36950ca21d
24 changed files with 738 additions and 20 deletions
@@ -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);
if (tgtIdx < 0 && !huskCoreAlive)
continue; // no player/structure and no Core -> nothing to seek
Entity targetEntity = tgtIdx < 0 ? Entity.Null
: (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]);
float3 targetPos = tgtIdx < 0 ? corePos
: (tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[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 decoy, no player/structure, and no Core -> nothing to seek
targetEntity = tgtIdx < 0 ? Entity.Null
: (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]);
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,17 +176,31 @@ 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 pull = hasFx && (fx.Flags & ProjectileEffectFlag.Pull) != 0;
float2 kdir = pull ? -proj.ValueRO.Direction : proj.ValueRO.Direction;
m_KnockbackLookup[hitTarget] = new KnockbackState
bool reel = hasFx && (fx.Flags & ProjectileEffectFlag.Reel) != 0;
if (reel && m_ReelLookup.HasComponent(hitTarget))
{
Dir = kdir,
Speed = Tuning.KnockbackSpeed,
UntilTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)),
};
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;
m_KnockbackLookup[hitTarget] = new KnockbackState
{
Dir = kdir,
Speed = Tuning.KnockbackSpeed,
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
@@ -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