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
@@ -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