using ProjectM.Simulation; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Physics; using Unity.Transforms; namespace ProjectM.Server { /// /// Server-authoritative Husk AI: each tick every Husk seeks the nearest LIVING player and strikes on /// contact. Husks are OWNERLESS INTERPOLATED ghosts (not predicted), so this runs SERVER-ONLY in the plain /// — writing directly (replicated to clients /// by the stock LocalTransform default variant; no hand-written [GhostField]). Ordered /// [UpdateAfter(PredictedSimulationSystemGroup)] (the predicted group is OrderFirst, so UpdateBefore is ignored) so a contact appended this /// tick is drained the following tick by (which runs inside the predicted /// group on the server). No Simulate filter: interpolated ghosts are not predicted and the server has /// no rollback, so every Husk advances exactly once per tick. Movement/attack math is the pure, deterministic /// ; server fixed-step SystemAPI.Time.DeltaTime is correct here (not the /// rollback loop). Structural-free: the only deferred op is appending to the player's DamageEvent buffer. /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateAfter(typeof(PredictedSimulationSystemGroup))] public partial struct EnemyAISystem : ISystem { [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly())); } [BurstCompile] public void OnUpdate(ref SystemState state) { // Snapshot living player targets once this tick (stable query order). var playerEntities = new NativeList(Allocator.Temp); var playerPositions = new NativeList(Allocator.Temp); foreach (var (xform, health, entity) in SystemAPI.Query, RefRO>() .WithAll() .WithEntityAccess()) { if (health.ValueRO.Current <= 0f) continue; // don't chase or strike a corpse playerEntities.Add(entity); playerPositions.Add(xform.ValueRO.Position); } if (playerEntities.Length == 0) { playerEntities.Dispose(); playerPositions.Dispose(); return; } float dt = SystemAPI.Time.DeltaTime; var serverTick = SystemAPI.GetSingleton().ServerTick; uint now = serverTick.TickIndexForValidTick; var ecb = new EntityCommandBuffer(Allocator.Temp); bool havePhysics = SystemAPI.TryGetSingleton(out var physics); uint envMask = SystemAPI.TryGetSingleton(out var worldCol) ? worldCol.EnvironmentMask : 0u; var envFilter = new CollisionFilter { BelongsTo = ~0u, CollidesWith = envMask, GroupIndex = 0 }; bool sweep = havePhysics && envMask != 0u; const float SweepRadius = 0.5f; // collide-and-slide sphere radius for Husk movement foreach (var (xform, stats, cooldown, knockback, windup) in SystemAPI.Query, RefRO, RefRW, RefRW, RefRW>() .WithAll()) { float3 pos = xform.ValueRO.Position; // Knockback overrides seek/strike for its window — EnemyAISystem stays the SOLE writer of Position. var kb = knockback.ValueRO; if (kb.UntilTick != 0) { var kbTick = new NetworkTick(kb.UntilTick); if (kbTick.IsValid && kbTick.IsNewerThan(serverTick)) { float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt); kpos.y = pos.y; if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter); xform.ValueRW.Position = kpos; windup.ValueRW.WindUpUntilTick = 0; // a recoiling Husk does not wind up continue; // recoiling: skip seek + strike this tick } knockback.ValueRW.UntilTick = 0; // window elapsed } // Nearest living player (planar XZ). int best = -1; float bestSq = float.MaxValue; for (int i = 0; i < playerPositions.Length; i++) { float2 d = playerPositions[i].xz - pos.xz; float sq = math.lengthsq(d); if (sq < bestSq) { bestSq = sq; best = i; } } float3 targetPos = playerPositions[best]; // Seek: stop just inside strike range so the Husk holds position to attack. float stopDistance = stats.ValueRO.AttackRange * 0.9f; float3 vel = EnemyAIMath.SeekVelocity(pos, targetPos, stats.ValueRO.MoveSpeed, stopDistance); float3 newPos = pos + vel * dt; newPos.y = pos.y; // hold the movement plane if (sweep) newPos = SweptMove(in physics, pos, newPos, SweepRadius, envFilter); xform.ValueRW.Position = newPos; // Face the target (planar) for presentation. float3 toTarget = targetPos - pos; toTarget.y = 0f; if (math.lengthsq(toTarget) > 1e-6f) xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(toTarget), math.up()); // Two-phase strike with a telegraph wind-up: commit a wind-up when first in-range + cooldown-ready, // then strike when it elapses. WindUpUntilTick is a [GhostField] so the client can cue the ~0.3s // tell; leaving range mid-windup cancels it. Tuning.AttackWindupTicks = 0/1 -> near-instant (legacy). bool inRange = EnemyAIMath.InAttackRange(pos, targetPos, stats.ValueRO.AttackRange); uint windRaw = windup.ValueRO.WindUpUntilTick; if (windRaw != 0) { if (!inRange) { windup.ValueRW.WindUpUntilTick = 0; // target left range -> cancel the wind-up } else { var windTick = new NetworkTick(windRaw); if (!(windTick.IsValid && windTick.IsNewerThan(serverTick))) { ecb.AppendToBuffer(playerEntities[best], new DamageEvent { Amount = stats.ValueRO.AttackDamage, SourceNetworkId = -1, // environment / Husk, not a player }); uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks); cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cooldownTicks); windup.ValueRW.WindUpUntilTick = 0; } } } else if (inRange) { uint nextRaw = cooldown.ValueRO.NextAttackTick; bool ready = true; if (nextRaw != 0) { var nextTick = new NetworkTick(nextRaw); if (nextTick.IsValid && nextTick.IsNewerThan(serverTick)) ready = false; } if (ready) { uint windupTicks = (uint)math.max(1, Tuning.AttackWindupTicks); windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + windupTicks); } } } ecb.Playback(state.EntityManager); ecb.Dispose(); playerEntities.Dispose(); playerPositions.Dispose(); } // Swept collide-and-slide for server-authoritative Husk movement: sphere-cast the intended step against // the static environment (boundary ring + landmarks) and stop at / glance along the first wall hit. Closest- // hit SphereCast is non-generic -> Burst-safe (CLAUDE.md generic-collector hazard avoided). Y is held flat. static float3 SweptMove(in PhysicsWorldSingleton physics, float3 from, float3 to, float radius, CollisionFilter filter) { float3 delta = to - from; delta.y = 0f; float dist = math.length(delta); if (dist < 1e-5f) return to; float3 dir = delta / dist; const float skin = 0.05f; var cw = physics.CollisionWorld; if (!cw.SphereCast(from, radius, dir, dist, out var hit, filter)) return to; float allowed = math.max(0f, hit.Fraction * dist - skin); float3 stop = from + dir * allowed; stop.y = from.y; // Slide the unused motion along the wall, then sweep the slide so we don't tunnel a second wall. float3 slide = EnemyAIMath.SlideVelocity(to - stop, hit.SurfaceNormal); float slideDist = math.length(slide); if (slideDist < 1e-5f) return stop; float3 sdir = slide / slideDist; float3 result = cw.SphereCast(stop, radius, sdir, slideDist, out var hit2, filter) ? stop + sdir * math.max(0f, hit2.Fraction * slideDist - skin) : stop + slide; result.y = from.y; return result; } } }