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. /// /// On a hit the system appends a to the target (consumed by /// HealthApplyDamageSystem) and destroys the projectile. 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) — keeping this server-only, once-per-tick system /// self-contained and plain-world testable without a separate ECB system. /// [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; /// Extra forgiveness added to a target's hit radius to approximate the projectile's own size. const float k_ProjectileRadius = 0.2f; [BurstCompile] public void OnCreate(ref SystemState state) { m_GhostOwnerLookup = state.GetComponentLookup(isReadOnly: true); m_KnockbackLookup = 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); 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() .WithEntityAccess()) { targetEntities.Add(targetEntity); targetPositions.Add(xform.ValueRO.Position); targetRadii.Add(hitRadius.ValueRO.Value); } foreach (var (xform, proj, owner, projectileEntity) in SystemAPI.Query, RefRO, RefRO>() .WithEntityAccess()) { int projOwnerId = owner.ValueRO.NetworkId; // 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; 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) { // Earliest target along the travel path: deal damage and consume the projectile. ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent { Amount = proj.ValueRO.Damage, SourceNetworkId = projOwnerId, SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u, }); var hitTarget = targetEntities[bestIdx]; if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget)) { m_KnockbackLookup[hitTarget] = new KnockbackState { Dir = proj.ValueRO.Direction, Speed = Tuning.KnockbackSpeed, UntilTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), }; } 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(); } } }