using ProjectM.Simulation; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Server { /// /// Server-authoritative projectile resolution: applies hits to damageable entities and expires /// projectiles past their range. Runs in the server world only /// () inside the /// , ordered so the /// projectile's is the post-move position for this tick. /// /// Hit detection is a swept planar (XZ) test: rather than checking the projectile's point /// position (which tunnels straight through a target when the per-tick step exceeds the target's /// radius — e.g. a fast projectile, or any projectile while the server is tick-batching under load), /// it reconstructs the segment the projectile traversed this tick /// ([curPos - dir*speed*dt, curPos]) and tests each target's hit radius against the closest /// point on that segment. The target hit earliest along the path (smallest segment parameter) wins. /// A target whose matches the projectile's owner is skipped (no self-hits); /// dummies carry no and are therefore always valid targets. /// /// Phase 1.7 mechanic-changer boons ride the server-only (seeded at /// spawn by AbilityFireSystem from the owner's ): PIERCE lets the projectile /// survive a hit (decrement, don't destroy), CHAIN retargets its (replicated) /// toward the next-nearest living enemy, and PULL flips the knockback heading toward the shooter. A per-projectile /// hit-set is excluded DURING target selection so a surviving projectile never re-hits a target across ticks; the /// set full (or no pierce/chain left) destroys as before. Projectiles WITHOUT the component behave exactly as /// before (destroy on hit) — graceful degradation. Exactly ONE destroy per projectile per tick is preserved. /// /// On a hit the system appends a to the target (consumed by /// HealthApplyDamageSystem). Deferring damage to a buffer lets a single tick stack hits from multiple /// projectiles. All structural changes go through an that plays back /// immediately to the (Temp allocator). /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] [UpdateAfter(typeof(ProjectileMoveSystem))] public partial struct ProjectileDamageSystem : ISystem { /// Lookup used to read a target's owner so a projectile never hits its own caster. ComponentLookup m_GhostOwnerLookup; /// RW lookup to stamp server-only knockback on a hit Husk (Husks bake KnockbackState; players/dummies don't). ComponentLookup m_KnockbackLookup; /// RW lookup to stamp the server-only homing ReelState on a Reel-flagged (HookPull) hit — the Harpooner reel. ComponentLookup m_ReelLookup; /// Knockback stamp lookup. The former BossState immunity gate went with the boss purge /// knockback-immune (A4) so a solo player can't perma-stunlock it out of its slam wind-ups. /// RW lookup for the per-projectile Phase-1.7 pierce/chain/pull state + re-hit set. ComponentLookup m_FxLookup; /// Extra forgiveness added to a target's hit radius to approximate the projectile's own size. const float k_ProjectileRadius = 0.2f; /// Max planar distance a Ricochet chain will reach for its next target (tunable). const float k_ChainRange = 8f; /// Harpooner reel pull speed (world units/sec) written into the reeled Husk's KnockbackState. const float k_ReelSpeed = 16f; /// Reel leash: max ticks the homing pull lasts before releasing (~60 ticks/sec). const uint k_ReelLeashTicks = 90u; [BurstCompile] public void OnCreate(ref SystemState state) { m_GhostOwnerLookup = state.GetComponentLookup(isReadOnly: true); m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); m_ReelLookup = state.GetComponentLookup(isReadOnly: false); m_FxLookup = state.GetComponentLookup(isReadOnly: false); // No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely. state.RequireForUpdate(); } [BurstCompile] public void OnUpdate(ref SystemState state) { m_GhostOwnerLookup.Update(ref state); m_KnockbackLookup.Update(ref state); m_ReelLookup.Update(ref state); m_FxLookup.Update(ref state); bool haveTick = SystemAPI.TryGetSingleton(out var nt); float dt = SystemAPI.Time.DeltaTime; var ecb = new EntityCommandBuffer(Allocator.Temp); // Snapshot all damageable targets once for this tick. Stable iteration order (query order). var targetEntities = new NativeList(Allocator.Temp); var targetPositions = new NativeList(Allocator.Temp); var targetRadii = new NativeList(Allocator.Temp); foreach (var (xform, hitRadius, targetEntity) in SystemAPI.Query, RefRO>() .WithAll() .WithNone() // B3: corpses are not shields - a shot passes through to living targets .WithEntityAccess()) { targetEntities.Add(targetEntity); targetPositions.Add(xform.ValueRO.Position); targetRadii.Add(hitRadius.ValueRO.Value); } foreach (var (xform, proj, owner, projectileEntity) in SystemAPI.Query, RefRW, RefRO>() .WithAll() // predicted-group convention: only simulate what this tick simulates .WithEntityAccess()) { int projOwnerId = owner.ValueRO.NetworkId; bool hasFx = m_FxLookup.HasComponent(projectileEntity); ProjectileEffectState fx = hasFx ? m_FxLookup[projectileEntity] : default; // This tick's planar travel segment: [segStart -> segEnd]. Sweeping the segment (rather // than testing only segEnd) is what prevents fast projectiles from tunnelling targets. float3 cur = xform.ValueRO.Position; float2 segEnd = new float2(cur.x, cur.z); float2 segStart = segEnd - proj.ValueRO.Direction * (proj.ValueRO.Speed * dt); float2 seg = segEnd - segStart; float segLenSq = math.lengthsq(seg); int bestIdx = -1; float bestT = float.MaxValue; for (int i = 0; i < targetEntities.Length; i++) { var target = targetEntities[i]; // Skip the caster: a target whose GhostOwner matches the projectile owner is the // shooter (or another ghost they own). Dummies have no GhostOwner, so never skipped. if (m_GhostOwnerLookup.HasComponent(target) && m_GhostOwnerLookup[target].NetworkId == projOwnerId) continue; // Phase 1.7: a surviving (pierced/chained) projectile never re-hits a target already struck — // excluded DURING selection, not post-filtered. if (hasFx && HitSetContains(in fx, target)) continue; float2 tp = new float2(targetPositions[i].x, targetPositions[i].z); // Closest point on the travel segment to the target centre. float t = segLenSq > 1e-8f ? math.saturate(math.dot(tp - segStart, seg) / segLenSq) : 0f; float2 closest = segStart + t * seg; float hitDist = targetRadii[i] + k_ProjectileRadius; if (math.distancesq(tp, closest) <= hitDist * hitDist && t < bestT) { bestT = t; bestIdx = i; } } if (bestIdx >= 0) { var hitTarget = targetEntities[bestIdx]; // Earliest target along the travel path: deal damage. ecb.AppendToBuffer(hitTarget, new DamageEvent { Amount = proj.ValueRO.Damage, SourceNetworkId = projOwnerId, SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u, }); // 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)) { bool reel = hasFx && (fx.Flags & ProjectileEffectFlag.Reel) != 0; if (reel && m_ReelLookup.HasComponent(hitTarget)) { 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 // can't be re-hit. A full hit-set is a natural cap → destroy. bool survive = false; if (hasFx) { if (fx.Hit.Length < fx.Hit.Capacity) { fx.Hit.Add(hitTarget); if (fx.PierceRemaining > 0) { fx.PierceRemaining = (byte)(fx.PierceRemaining - 1); survive = true; } else if (fx.ChainRemaining > 0) { int nextIdx = FindChainTarget(targetEntities, targetPositions, cur, projOwnerId, in fx); if (nextIdx >= 0) { float2 to = new float2(targetPositions[nextIdx].x - cur.x, targetPositions[nextIdx].z - cur.z); if (math.lengthsq(to) > 1e-6f) { proj.ValueRW.Direction = math.normalize(to); fx.ChainRemaining = (byte)(fx.ChainRemaining - 1); survive = true; } } } } m_FxLookup[projectileEntity] = fx; } if (survive) { // A surviving projectile still expires once it has travelled its full range. if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range) ecb.DestroyEntity(projectileEntity); continue; } ecb.DestroyEntity(projectileEntity); continue; } // Nothing hit this tick: expire the projectile once it has travelled its full range. if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range) ecb.DestroyEntity(projectileEntity); } ecb.Playback(state.EntityManager); ecb.Dispose(); targetEntities.Dispose(); targetPositions.Dispose(); targetRadii.Dispose(); } /// True when is already in the projectile's re-hit history. static bool HitSetContains(in ProjectileEffectState fx, Entity target) { for (int i = 0; i < fx.Hit.Length; i++) if (fx.Hit[i] == target) return true; return false; } /// Nearest living target to within that is neither /// the caster's own ghost nor already in the projectile's hit-set. Returns the snapshot index or -1. int FindChainTarget(in NativeList targetEntities, in NativeList targetPositions, float3 from, int projOwnerId, in ProjectileEffectState fx) { int best = -1; float bestDistSq = k_ChainRange * k_ChainRange; for (int i = 0; i < targetEntities.Length; i++) { var target = targetEntities[i]; if (m_GhostOwnerLookup.HasComponent(target) && m_GhostOwnerLookup[target].NetworkId == projOwnerId) continue; if (HitSetContains(in fx, target)) continue; float2 d = new float2(targetPositions[i].x - from.x, targetPositions[i].z - from.z); float dsq = math.lengthsq(d); if (dsq <= bestDistSq) { bestDistSq = dsq; best = i; } } return best; } } }