Files
Project-M/Assets/_Project/Scripts/Server/Combat/ReelSystem.cs
T
kronic 36950ca21d 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>
2026-07-15 00:00:41 -07:00

100 lines
4.7 KiB
C#

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();
}
}
}