using ProjectM.Simulation; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Server { /// /// Phase 1.7 "Blade Dash" boon (): while a player is inside its dash blink window, /// living enemies within of the player take damage — one hit per enemy per dash. SERVER-ONLY /// (enemies are interpolated ghosts the client never predicts — mirrors the melee cleave / cone / projectile-damage /// pattern), inside the predicted group after (dash state committed) and before /// HealthApplyDamageSystem (the DamageEvent drains the same tick). Enemies carry no DashState, so the /// dash-i-frame negation branch in HealthApplyDamageSystem is skipped — harmless. /// /// Dedup is keyed to (which is TickUtil.NonZero(now) on every dash and has /// NO reliable clear edge on a release server): is cleared whenever the current /// StartTick differs from . Server-only ⇒ no rollback, so persisting the /// accumulator across ticks is safe. A per-tick radius test (run every blink tick) approximates the swept path; the /// per-tick dash step (<~0.6u) is well inside the radius, so a thin enemy is not tunnelled. Hit-set overflow stops /// adding (a possible re-hit on a very crowded dash — accepted v1 cap). /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] [UpdateAfter(typeof(DashSystem))] [UpdateBefore(typeof(HealthApplyDamageSystem))] public partial struct DashTrailDamageSystem : ISystem { const float k_Radius = 1.6f; // planar hit radius around the dashing player (tunable) const float k_Damage = 12f; // per-enemy damage for a dash pass (tunable) [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); } [BurstCompile] public void OnUpdate(ref SystemState state) { var nt = SystemAPI.GetSingleton(); var serverTick = nt.ServerTick; if (!serverTick.IsValid) return; // Snapshot living enemies once (positions + radii + entities), stable query order. var enemyEntities = new NativeList(Allocator.Temp); var enemyPositions = new NativeList(Allocator.Temp); var enemyRadii = new NativeList(Allocator.Temp); foreach (var (tx, hr, hp, te) in SystemAPI.Query, RefRO, RefRO>() .WithAll().WithNone().WithEntityAccess()) { if (hp.ValueRO.Current <= 0f) continue; enemyEntities.Add(te); enemyPositions.Add(tx.ValueRO.Position); enemyRadii.Add(hr.ValueRO.Value); } if (enemyEntities.Length == 0) { enemyEntities.Dispose(); enemyPositions.Dispose(); enemyRadii.Dispose(); return; } uint stamp = TickUtil.NonZero(serverTick.TickIndexForValidTick); var ecb = new EntityCommandBuffer(Allocator.Temp); foreach (var (xform, dash, trail, owner, fx) in SystemAPI.Query, RefRO, RefRW, RefRO, RefRO>() .WithAll()) { if ((fx.ValueRO.Flags & BoonFlag.DashTrail) == 0) continue; uint startRaw = dash.ValueRO.StartTick; if (startRaw == 0u) continue; // never dashed // Inside the blink (i-frame) window [StartTick, IFrameUntilTick)? var startTick = new NetworkTick(startRaw); var untilTick = new NetworkTick(dash.ValueRO.IFrameUntilTick); bool dashing = startTick.IsValid && untilTick.IsValid && !startTick.IsNewerThan(serverTick) && untilTick.IsNewerThan(serverTick); if (!dashing) continue; // New dash → reset the per-dash hit set (StartTick changes every dash; no reliable DashState clear). if (trail.ValueRO.LastStartTick != startRaw) { trail.ValueRW.Hit.Clear(); trail.ValueRW.LastStartTick = startRaw; } float3 p = xform.ValueRO.Position; int ownerId = owner.ValueRO.NetworkId; for (int i = 0; i < enemyEntities.Length; i++) { var enemy = enemyEntities[i]; if (HitContains(trail.ValueRO, enemy)) continue; float2 d = new float2(enemyPositions[i].x - p.x, enemyPositions[i].z - p.z); float reach = k_Radius + enemyRadii[i]; if (math.lengthsq(d) > reach * reach) continue; if (trail.ValueRO.Hit.Length >= trail.ValueRO.Hit.Capacity) break; // hit-cap: never damage an enemy we can't record (else re-hit every tick) ecb.AppendToBuffer(enemy, new DamageEvent { Amount = k_Damage, SourceNetworkId = ownerId, // a real player id (legit Charger whiff-punish credit) SourceTick = stamp, }); if (trail.ValueRO.Hit.Length < trail.ValueRO.Hit.Capacity) trail.ValueRW.Hit.Add(enemy); } } ecb.Playback(state.EntityManager); ecb.Dispose(); enemyEntities.Dispose(); enemyPositions.Dispose(); enemyRadii.Dispose(); } static bool HitContains(in DashTrailState trail, Entity e) { for (int i = 0; i < trail.Hit.Length; i++) if (trail.Hit[i] == e) return true; return false; } } }