using ProjectM.Simulation; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Server { /// /// MC-2 — resolves hostile Spitter projectiles against PLAYERS + STRUCTURES (never other enemies — only /// PlayerTag / PlacedStructure are snapshotted, so a spit can't friendly-fire the Husks), server-only in the /// plain after (post-move position). /// SWEPT planar hit-test (the DR-018 anti-tunnelling discipline): the travel segment is rebuilt from the STORED /// (cur - Direction*LastStep), NEVER a fresh delta. REGION-FILTERED: a /// target whose .Region != the spit's Region is skipped — relevancy hides cross-region /// ghosts from CLIENTS, but the server world holds base + expedition players 1000u apart, so server damage needs /// its own guard (the missing-filter blocker the design review caught). On a hit it appends /// DamageEvent{SourceNetworkId=-1, SourceTick=now} (drained the FOLLOWING tick by the predicted /// HealthApplyDamageSystem — appending from the predicted loop would double-apply on rollback; SourceTick /// makes the dash i-frame negation correct across the 1-tick gap, so dash-through-spit works for free) and /// destroys the spit at-most-once; a spit past its Range expires. /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateAfter(typeof(EnemyProjectileMoveSystem))] public partial struct EnemyProjectileDamageSystem : ISystem { /// Extra forgiveness for the spit's own size, added to a target's hit radius. const float k_ProjectileRadius = 0.2f; /// Hit radius used for structures, which (by design) bake no HitRadius (so player shots never hit them). const float k_StructureRadius = 1.0f; [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); } [BurstCompile] public void OnUpdate(ref SystemState state) { var serverTick = SystemAPI.GetSingleton().ServerTick; if (!serverTick.IsValid) return; // mirror WaveSystem/ZoneEnemyDirectorSystem — never stamp SourceTick off an invalid tick uint now = serverTick.TickIndexForValidTick; var ecb = new EntityCommandBuffer(Allocator.Temp); // Snapshot valid targets once (stable query order). PLAYERS carry HitRadius (PlayerAuthoring); // STRUCTURES deliberately do NOT (so player projectiles never friendly-fire the base) -> a constant. var targetEntities = new NativeList(Allocator.Temp); var targetPositions = new NativeList(Allocator.Temp); var targetRadii = new NativeList(Allocator.Temp); var targetRegions = new NativeList(Allocator.Temp); foreach (var (xform, hitRadius, health, region, e) in SystemAPI.Query, RefRO, RefRO, RefRO>() .WithAll().WithEntityAccess()) { if (health.ValueRO.Current <= 0f) continue; // don't hit a corpse targetEntities.Add(e); targetPositions.Add(xform.ValueRO.Position); targetRadii.Add(hitRadius.ValueRO.Value); targetRegions.Add(region.ValueRO.Region); } foreach (var (xform, health, region, e) in SystemAPI.Query, RefRO, RefRO>() .WithAll().WithEntityAccess()) { if (health.ValueRO.Current <= 0f) continue; // skip a structure pending destroy this tick targetEntities.Add(e); targetPositions.Add(xform.ValueRO.Position); targetRadii.Add(k_StructureRadius); targetRegions.Add(region.ValueRO.Region); } var destroyed = new NativeHashSet(16, Allocator.Temp); foreach (var (xform, proj, projEntity) in SystemAPI.Query, RefRO>().WithEntityAccess()) { float3 cur = xform.ValueRO.Position; float2 segEnd = new float2(cur.x, cur.z); float2 dir = proj.ValueRO.Direction; float2 segStart = segEnd - dir * proj.ValueRO.LastStep; // stored move-step, never a fresh dt float2 seg = segEnd - segStart; float segLenSq = math.lengthsq(seg); byte projRegion = proj.ValueRO.Region; int bestIdx = -1; float bestT = float.MaxValue; for (int i = 0; i < targetEntities.Length; i++) { if (targetRegions[i] != projRegion) continue; // server-side damage region guard float2 tp = new float2(targetPositions[i].x, targetPositions[i].z); 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) { ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent { Amount = proj.ValueRO.Damage, SourceNetworkId = -1, // hostile environment, not a player SourceTick = TickUtil.NonZero(now), }); if (destroyed.Add(projEntity)) ecb.DestroyEntity(projEntity); continue; } if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range && destroyed.Add(projEntity)) ecb.DestroyEntity(projEntity); } ecb.Playback(state.EntityManager); ecb.Dispose(); destroyed.Dispose(); targetEntities.Dispose(); targetPositions.Dispose(); targetRadii.Dispose(); targetRegions.Dispose(); } } }