using ProjectM.Simulation; using Unity.Mathematics; using Unity.Physics; namespace ProjectM.Server { /// /// Shared server-side collide-and-slide mover for OWNERLESS enemy ghosts (extracted from EnemyAISystem so the /// boss brain reuses ONE copy — a fix to the tunnelling sweep or wall-glance reaches every enemy at once). /// Both EnemyAISystem's three passes and BossAISystem call . Pure given the CollisionWorld; /// Burst-safe (non-generic closest-hit SphereCast, per the CLAUDE.md generic-collector hazard). /// public static class EnemyMoveUtil { /// Collide-and-slide sphere-cast for server-authoritative enemy movement: sweep the intended step /// against the static environment (boundary ring + landmarks + cover rocks + player-built walls) and stop at / /// glance along wall hits. Runs up to TWO slide iterations so a concave pocket (a cover rock meeting the /// boundary rim) glances along BOTH surfaces instead of dead-stopping at the first (a single iteration left /// enemies parked in pockets). Y is held flat (top-down movement plane). public 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; const float skin = 0.05f; var cw = physics.CollisionWorld; float3 dir = delta / dist; if (!cw.SphereCast(from, radius, dir, dist, out var hit, filter)) return to; float allowed = math.max(0f, hit.Fraction * dist - skin); float3 pos = from + dir * allowed; pos.y = from.y; float3 remaining = to - pos; for (int i = 0; i < 2; i++) { float3 slide = EnemyAIMath.SlideVelocity(remaining, hit.SurfaceNormal); float slideDist = math.length(slide); if (slideDist < 1e-5f) break; float3 sdir = slide / slideDist; if (cw.SphereCast(pos, radius, sdir, slideDist, out var hit2, filter)) { float sAllowed = math.max(0f, hit2.Fraction * slideDist - skin); pos += sdir * sAllowed; remaining = slide - sdir * sAllowed; // carry the still-unused motion into the next glance hit = hit2; } else { pos += slide; break; } } pos.y = from.y; return pos; } /// Push a sphere at out of any static env/structure it overlaps, along the /// nearest surface normal (flattened to XZ; a near-vertical floor/ceiling normal is ignored so movers aren't /// shoved off their plane). Fixes the "spawned or shoved INTO a rock -> SphereCast fraction ~0 -> frozen /// forever" class: the collide-and-slide sweep can never move a mover that STARTS already penetrating, so we /// un-embed it first. The correction is capped at one radius/tick so it eases out over a couple of ticks /// rather than teleporting. Stateless; called once per enemy per tick before seeking. public static float3 Depenetrate(in PhysicsWorldSingleton physics, float3 pos, float radius, CollisionFilter filter) { var cw = physics.CollisionWorld; var input = new PointDistanceInput { Position = pos, MaxDistance = radius + 0.5f, Filter = filter }; if (!cw.CalculateDistance(input, out var hit) || hit.Distance >= radius) return pos; float3 n = hit.SurfaceNormal; n.y = 0f; float len = math.length(n); if (len < 1e-5f) return pos; // floor/ceiling normal -> don't shove the mover off its movement plane float push = math.min(radius - hit.Distance, radius); // cap so a deep embed eases out over ticks, no pop float3 outp = pos + (n / len) * push; outp.y = pos.y; return outp; } } }