using ProjectI.Combat; using UnityEngine; using UnityEngine.AI; namespace ProjectI.Enemies { internal sealed class EnemyNavigationSafety { private readonly EnemyActorContext actor; private readonly Collider[] overlaps = new Collider[48]; private readonly RaycastHit[] hits = new RaycastHit[48]; private readonly RaycastHit[] projectionHits = new RaycastHit[48]; private readonly RaycastHit[] supportHits = new RaycastHit[48]; private readonly RaycastHit[] raisedHits = new RaycastHit[48]; private readonly Collider[] raisedOverlaps = new Collider[48]; private readonly int worldMask = ~LayerMask.GetMask("Players", "CombatTargets"); internal NavMeshQueryFilter Filter => new NavMeshQueryFilter { agentTypeID = actor.Owner.AgentType, areaMask = actor.Owner.Navigation.areaMask & ~(1 << 2) }; private NavMeshAgent Agent => actor.Owner.Navigation; private float Climb => NavMesh.GetSettingsByID(Agent.agentTypeID).agentClimb; internal EnemyNavigationSafety(EnemyActorContext actor) { this.actor = actor; } internal bool Native => actor.IsValid && Agent.agentTypeID == actor.Owner.AgentType && Agent.radius > 0 && Agent.height >= 2 * Agent.radius && EnemyObservationRules.Finite(Agent.radius) && EnemyObservationRules.Finite(Agent.height); private bool ActorCollider(Collider collider) => collider.GetComponentInParent() != null || collider.GetComponentInParent() != null; private bool Self(Collider collider) => collider.transform.IsChildOf(actor.Owner.transform); private bool Walkable(RaycastHit hit) => hit.normal.y >= Mathf.Cos(NavMesh.GetSettingsByID(Agent.agentTypeID).agentSlope * Mathf.Deg2Rad) - .01f; private bool Ground(Vector3 point, float tolerance, out float height) { height = 0; int count = Physics.RaycastNonAlloc(point + Vector3.up * (Climb + .25f), Vector3.down, hits, Climb + tolerance + .5f, worldMask, QueryTriggerInteraction.Ignore); if (count == hits.Length) return false; float best = float.PositiveInfinity; RaycastHit ground = default; for (int i = 0; i < count; i++) if (!Self(hits[i].collider) && !ActorCollider(hits[i].collider) && Walkable(hits[i]) && hits[i].distance < best) { best = hits[i].distance; ground = hits[i]; } height = ground.point.y; return best < float.PositiveInfinity && Mathf.Abs(ground.point.y - point.y) <= tolerance; } internal bool Supported(Vector3 point) { // Height detail can lie outside its coarse polygon's small query bounds (also handled by retained admission). if (!Native || !EnemyObservationRules.Finite(point) || !InsideStage(point) || !NavMesh.SamplePosition(point, out var nav, 2, Filter) || Vector3.Distance(nav.position, point) > .2f || !Ground(point, Climb + .15f, out float center)) return false; float offset = Agent.radius * .85f; if (!Ground(point + Vector3.right * offset, Climb + .15f, out float right) || !Ground(point - Vector3.right * offset, Climb + .15f, out float left) || !Ground(point + Vector3.forward * offset, Climb + .15f, out float forward) || !Ground(point - Vector3.forward * offset, Climb + .15f, out float back)) return false; // At a riser the native surface may stand above the center ray, but must remain on the supported footprint. return point.y >= center - .15f && point.y <= Mathf.Max(center, right, left, forward, back) + .15f; } private bool InsideStage(Vector3 point) { var stage = actor.StageDefinition; if (stage == null || !stage.Valid) return false; var bounds = stage.movementEnvelope; return bounds.Contains(point) && bounds.Contains(point + Vector3.up * Agent.height) && bounds.Contains(point + Vector3.right * Agent.radius) && bounds.Contains(point - Vector3.right * Agent.radius) && bounds.Contains(point + Vector3.forward * Agent.radius) && bounds.Contains(point - Vector3.forward * Agent.radius); } internal bool Clear(Vector3 point, bool includeActors = true) { float radius = Agent.radius; int count = Physics.OverlapCapsuleNonAlloc(point + Vector3.up * (radius + .04f), point + Vector3.up * (Agent.height - radius + .04f), radius, overlaps, includeActors ? ~0 : worldMask, QueryTriggerInteraction.Ignore); if (count == overlaps.Length) return false; for (int i = 0; i < count; i++) { var collider = overlaps[i]; if (Self(collider)) continue; if (!includeActors && ActorCollider(collider)) continue; // Native stair transit permits supported risers within the actor's climb; bodies never get this exception. if (!ActorCollider(collider) && collider.bounds.max.y <= point.y + Climb + .05f) continue; if (!ActorCollider(collider) && GroundOnlyOverlap(collider, point)) continue; return false; } return true; } private bool GroundOnlyOverlap(Collider collider, Vector3 point) { float radius = Agent.radius; Vector3 top = point + Vector3.up * (Agent.height - radius + .04f); // A clear upper cap plus a downward sphere sweep covers the whole capsule, // unlike a ground ray that could miss a wall/overhang higher on this collider. if ((collider.ClosestPoint(top) - top).sqrMagnitude <= radius * radius) return false; int count = Physics.SphereCastNonAlloc(top, radius, Vector3.down, supportHits, Agent.height - 2 * radius, worldMask, QueryTriggerInteraction.Ignore); if (count == supportHits.Length) return false; bool supportedContact = false; for (int i = 0; i < count; i++) { var hit = supportHits[i]; if (hit.collider != collider) continue; if (!Walkable(hit) || hit.point.y < point.y - .15f || hit.point.y > point.y + Climb + .05f) return false; supportedContact = true; } return supportedContact; } private bool SweptClear(Vector3 from, Vector3 to, bool includeActors) { if (!Clear(to, includeActors)) return false; Vector3 delta = to - from; if (delta.sqrMagnitude < .000001f) return true; float radius = Agent.radius; int count = Physics.CapsuleCastNonAlloc(from + Vector3.up * (radius + .04f), from + Vector3.up * (Agent.height - radius + .04f), radius, delta.normalized, hits, delta.magnitude, includeActors ? ~0 : worldMask, QueryTriggerInteraction.Ignore); if (count == hits.Length) return false; for (int i = 0; i < count; i++) { var collider = hits[i].collider; if (Self(collider) || !includeActors && ActorCollider(collider)) continue; if (!ActorCollider(collider) && collider.bounds.max.y <= Mathf.Max(from.y, to.y) + Climb + .05f) continue; // A ramp's remote uphill bounds do not describe this low, walkable support contact. // Vertical faces and overhangs on the very same collider still block. if (!ActorCollider(collider) && Walkable(hits[i]) && hits[i].point.y >= Mathf.Min(from.y, to.y) - .15f && hits[i].point.y <= Mathf.Max(from.y, to.y) + Climb + .05f) continue; // PhysX reports a synthetic zero-distance normal when the starting capsule // already touches supported ground. Validate both full capsules and sweep // above the allowed step band so another face on this collider cannot hide. if (!ActorCollider(collider) && hits[i].distance == 0 && GroundOnlyOverlap(collider, from) && RaisedSweepClear(collider, from, to)) continue; return false; } return true; } private bool RaisedSweepClear(Collider collider, Vector3 from, Vector3 to) { float radius = Agent.radius; Vector3 lift = Vector3.up * (Climb + .15f); Vector3 bottom = from + lift + Vector3.up * (radius + .04f); Vector3 top = from + lift + Vector3.up * (Agent.height - radius + .04f); int count = Physics.OverlapCapsuleNonAlloc(bottom, top, radius, raisedOverlaps, worldMask, QueryTriggerInteraction.Ignore); if (count == raisedOverlaps.Length) return false; for (int i = 0; i < count; i++) if (raisedOverlaps[i] == collider) return false; Vector3 delta = to - from; count = Physics.CapsuleCastNonAlloc(bottom, top, radius, delta.normalized, raisedHits, delta.magnitude, worldMask, QueryTriggerInteraction.Ignore); if (count == raisedHits.Length) return false; for (int i = 0; i < count; i++) if (raisedHits[i].collider == collider) return false; return true; } internal bool Legal(Vector3 point) => Supported(point) && Clear(point); internal bool TryProject(Vector3 requested, out Vector3 point) => TryProjectSurface(requested, out point, true) && Clear(point); internal bool TryProjectSurface(Vector3 requested, out Vector3 point, bool preserveHeight = false) => ProjectSurface(requested, out point, preserveHeight, false); // Planner destinations stay canonical; physical steps retain their bounded horizontal intent. internal bool TryProjectStep(Vector3 requested, out Vector3 point) => ProjectSurface(requested, out point, true, true); private bool ProjectSurface(Vector3 requested, out Vector3 point, bool preserveHeight, bool preserveHorizontal) { point = default; if (!Native || !EnemyObservationRules.Finite(requested)) return false; int count = Physics.RaycastNonAlloc(requested + Vector3.up * 4, Vector3.down, projectionHits, 8, worldMask, QueryTriggerInteraction.Ignore); if (count == projectionHits.Length) return false; ulong considered = 0; for (int attempt = 0; attempt < (preserveHeight ? 4 : 1); attempt++) { float nearest = float.PositiveInfinity; int best = -1; for (int i = 0; i < count; i++) { if ((considered & (1UL << i)) != 0 || ActorCollider(projectionHits[i].collider) || Self(projectionHits[i].collider) || !Walkable(projectionHits[i])) continue; float distance = preserveHeight ? Mathf.Abs(projectionHits[i].point.y - requested.y) : projectionHits[i].distance; if (distance < nearest) { nearest = distance; best = i; } } if (best < 0) return false; considered |= 1UL << best; Vector3 ground = projectionHits[best].point; Vector3 query = ground; if (preserveHeight) query.y = Mathf.Max(ground.y, requested.y); if (!NavMesh.SamplePosition(query, out var nav, 2, Filter)) continue; Vector3 projected = preserveHorizontal ? new Vector3(ground.x, nav.position.y, ground.z) : nav.position; if (new Vector2(nav.position.x - ground.x, nav.position.z - ground.z).magnitude > .2f || Mathf.Abs(nav.position.y - ground.y) > Climb + .15f || !Supported(projected) || preserveHeight && !Clear(projected, false)) continue; point = projected; return true; } return false; } internal bool Segment(Vector3 from, Vector3 to, bool occupancy) { if (!Supported(from) || !Supported(to)) return false; if (NavMesh.Raycast(from, to, out var edge, Filter)) { // Native corners can sit on a polygon seam to float precision. Retry only // an outer-edge hit at the source, 1 mm inward; physics uses the original route. Vector3 inward = edge.normal; inward.y = 0; if (edge.mask != 0 || !EnemyObservationRules.Finite(inward) || inward.sqrMagnitude < .000001f || new Vector2(edge.position.x - from.x, edge.position.z - from.z).magnitude > .001f || NavMesh.Raycast(from + inward.normalized * .001f, to + inward.normalized * .001f, out _, Filter)) return false; } int samples = Mathf.CeilToInt(Vector3.Distance(from, to) / Mathf.Min(.35f, Agent.radius)); if (samples > 180) return false; Vector3 previous = from; for (int i = 1; i <= samples; i++) { Vector3 requested = Vector3.Lerp(from, to, (float)i / samples); // Path corners omit intermediate stair heights. Resolve each horizontal sample against actual ground. if (!TryProjectStep(requested, out var surface) || new Vector2(surface.x - requested.x, surface.z - requested.z).magnitude > .2f || !SweptClear(previous, surface, occupancy)) return false; previous = surface; } return true; } internal bool Route(EnemyMovePlan plan, bool occupancy) { if (plan.RouteStatus != NavMeshPathStatus.PathComplete || plan.CornerCount == 0 || plan.CornerCount > 64 || Vector3.Distance(plan.Corner(0), plan.Origin) > .3f || Vector3.Distance(plan.Corner(plan.CornerCount - 1), plan.Destination) > .3f) return false; float length = 0; for (int i = 1; i < plan.CornerCount; i++) { length += Vector3.Distance(plan.Corner(i - 1), plan.Corner(i)); if (length > plan.Limits.TravelDistance + .01f || !Segment(plan.Corner(i - 1), plan.Corner(i), occupancy)) return false; } return true; } internal bool Lane(Vector3 destination, Vector3 capturedPoint, Vector3 muzzleOffset, float radius, float range) { Vector3 facing = capturedPoint - destination; facing.y = 0; Quaternion rotation = facing.sqrMagnitude > .001f ? Quaternion.LookRotation(facing) : actor.Owner.transform.rotation; Vector3 origin = destination + rotation * muzzleOffset; Vector3 delta = capturedPoint - origin; if (delta.sqrMagnitude > range * range || delta.sqrMagnitude < .000001f) return false; int count = Physics.OverlapSphereNonAlloc(origin, radius, overlaps, ~0, QueryTriggerInteraction.Ignore); if (count == overlaps.Length) return false; for (int i = 0; i < count; i++) if (!Self(overlaps[i]) && !ActorCollider(overlaps[i])) return false; count = Physics.SphereCastNonAlloc(origin, radius, delta.normalized, hits, delta.magnitude, ~0, QueryTriggerInteraction.Ignore); if (count == hits.Length) return false; for (int i = 0; i < count; i++) // Hypothetical evidence lane ignores dynamic actors; M4 must independently validate real release clearance. if (!Self(hits[i].collider) && !ActorCollider(hits[i].collider)) return false; return true; } internal bool CurrentLane(Vector3 origin, EnemyObservation evidence, float radius, float range) { if (!actor.IsValid) return false; var perception = actor.Owner.GetComponent(); return perception != null && perception.TryVerifyPersonalFiringSightServer(evidence.Target, actor.Now, origin, radius, range, out _); } } }