36950ca21d
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>
100 lines
4.7 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|