using System; using ProjectI.Combat; using UnityEngine; using UnityEngine.AI; namespace ProjectI.Enemies { public enum EnemySearchVisibility { Visible, Blocked, Unknown } // All scene queries use captured local facts and world geometry. There is no target transform here. internal sealed class EnemyInvestigationPlanner { private readonly EnemyActorContext actor; private readonly EnemyMovementExecutor movement; private readonly EnemyPerception perception; private readonly EnemyPositionClaims claims; private readonly EnemyQueryBudget paths, probes; private readonly EnemyMoveLimits limits; private readonly double claimSeconds; private readonly Vector3 localEye; private readonly Quaternion localEyeRotation; private readonly float sightRange, fieldOfView; private readonly float preferredMinimum, preferredMaximum; private readonly RaycastHit[] hits = new RaycastHit[48]; private readonly Collider[] geometry = new Collider[128]; // Actor layers are excluded before physics-buffer admission, so hidden bodies cannot saturate world probes. private readonly int worldMask = ~LayerMask.GetMask("Players", "CombatTargets"); private EnemyInvestigationEpisode active; private EnemyActionToken request; private int viewIndex, sampleIndex, routeStage, signature; private int generation { get => active.generationIndex; set => active.generationIndex = value; } private int weightIndex { get => active.weightedCount; set => active.weightedCount = value; } private bool generated { get => active.generationComplete; set => active.generationComplete = value; } private bool weighted { get => active.weightsComplete; set => active.weightsComplete = value; } private bool candidatesBuilt { get => active.candidatesComplete; set => active.candidatesComplete = value; } private NavMeshPath firstLeg; private Vector3 origin, lookPoint; public int ProbeCount { get; private set; } public int PathCount { get; private set; } public int GeometrySignature => signature; #if UNITY_EDITOR || DEVELOPMENT_BUILD private bool measureQueries; internal bool AllocationCounterAvailable { get; private set; } internal bool MeasureQueries { get => measureQueries; set { measureQueries = value; if (!value) return; long before = GC.GetAllocatedBytesForCurrentThread(); var calibration = new byte[1024]; AllocationCounterAvailable = GC.GetAllocatedBytesForCurrentThread() - before >= calibration.Length; GC.KeepAlive(calibration); } } private bool measuringPlanning; private int measuredFrame = -1; private double frameMilliseconds; public double MaximumFrameMilliseconds { get; private set; } public long AllocatedBytes { get; private set; } public double CurrentFrameMilliseconds => measuredFrame == Time.frameCount ? frameMilliseconds : 0; private void RecordMeasurement(long start, long allocation) { if (measuredFrame != Time.frameCount) { measuredFrame = Time.frameCount; frameMilliseconds = 0; } frameMilliseconds += (System.Diagnostics.Stopwatch.GetTimestamp() - start) * 1000d / System.Diagnostics.Stopwatch.Frequency; MaximumFrameMilliseconds = Math.Max(MaximumFrameMilliseconds, frameMilliseconds); AllocatedBytes += Math.Max(0, GC.GetAllocatedBytesForCurrentThread() - allocation); } #endif internal EnemyInvestigationPlanner(EnemyActorContext actor, EnemyMovementExecutor movement, EnemyPerception perception, EnemyPositionClaims claims, EnemyQueryBudget paths, EnemyQueryBudget probes, EnemyMoveLimits limits, double claimSeconds) { this.actor = actor; this.movement = movement; this.perception = perception; this.claims = claims; this.paths = paths; this.probes = probes; this.limits = limits; this.claimSeconds = claimSeconds; localEye = perception.LocalEye; sightRange = perception.SightRange; fieldOfView = perception.FieldOfView; localEyeRotation = perception.LocalEyeRotation; preferredMinimum = movement.definition.preferredRangeMinimum; preferredMaximum = movement.definition.preferredRangeMaximum; } internal void Cancel() { paths.Cancel(request.Sequence); probes.Cancel(request.Sequence); movement.Cancel(request); request = default; } internal void Clear() { Cancel(); active = null; } internal bool TryGeometrySignature(EnemyInvestigationEpisode episode, out int value) { #if UNITY_EDITOR || DEVELOPMENT_BUILD if (MeasureQueries && !measuringPlanning) { long start = System.Diagnostics.Stopwatch.GetTimestamp(), allocation = GC.GetAllocatedBytesForCurrentThread(); try { return GeometrySignatureCore(episode, out value); } finally { RecordMeasurement(start, allocation); } } #endif return GeometrySignatureCore(episode, out value); } private bool GeometrySignatureCore(EnemyInvestigationEpisode episode, out int value) { value = 17; float radius = episode.Settings.SearchRadius + episode.Settings.ViewpointStandOff + Vector3.Distance(episode.Anchor, episode.StartingView) + 2; int count = Physics.OverlapSphereNonAlloc(episode.Anchor, radius, geometry, worldMask, QueryTriggerInteraction.Ignore); if (count == geometry.Length) return false; unchecked { for (int i = 0; i < count; i++) { var collider = geometry[i]; if (ActorCollider(collider)) continue; var bounds = collider.bounds; value += collider.GetEntityId().GetHashCode() * 397 ^ bounds.center.GetHashCode() ^ bounds.size.GetHashCode() ^ collider.transform.rotation.GetHashCode(); } } return true; } private static bool ActorCollider(Collider collider) => collider.GetComponentInParent() != null || collider.GetComponentInParent() != null; internal EnemySearchVisibility Probe(Vector3 eye, Vector3 forward, Vector3 point, ulong requester, double now) { #if UNITY_EDITOR || DEVELOPMENT_BUILD if (MeasureQueries && !measuringPlanning) { long start = System.Diagnostics.Stopwatch.GetTimestamp(), allocation = GC.GetAllocatedBytesForCurrentThread(); try { return ProbeCore(eye, forward, point, requester, now); } finally { RecordMeasurement(start, allocation); } } #endif return ProbeCore(eye, forward, point, requester, now); } private EnemySearchVisibility ProbeCore(Vector3 eye, Vector3 forward, Vector3 point, ulong requester, double now) { if (!probes.TrySpend(requester, Time.frameCount, now)) return EnemySearchVisibility.Unknown; ProbeCount++; Vector3 delta = point - eye; if (!EnemyObservationRules.Finite(eye) || !EnemyObservationRules.Finite(forward) || !EnemyObservationRules.Finite(point) || delta.sqrMagnitude > sightRange * sightRange || forward.sqrMagnitude < .000001f || fieldOfView < 360 && Vector3.Angle(forward, delta) > fieldOfView * .5f) return EnemySearchVisibility.Blocked; if (delta.sqrMagnitude < .000001f) return EnemySearchVisibility.Visible; int count = Physics.RaycastNonAlloc(eye, delta.normalized, hits, delta.magnitude, worldMask, QueryTriggerInteraction.Ignore); if (count == hits.Length) return EnemySearchVisibility.Unknown; for (int i = 0; i < count; i++) if (!ActorCollider(hits[i].collider)) return EnemySearchVisibility.Blocked; return EnemySearchVisibility.Visible; } private static float Horizontal(Vector3 a, Vector3 b) => new Vector2(a.x - b.x, a.z - b.z).magnitude; private bool Project(EnemyInvestigationEpisode episode, Vector3 requested, float radius, out Vector3 point) { point = default; return movement.Safety.TryProjectSurface(requested, out point, true) && movement.Safety.Clear(point, false) && Horizontal(point, episode.Anchor) <= radius + .001f && Mathf.Abs(point.y - episode.Anchor.y) <= episode.Settings.MaximumVerticalOffset; } private static bool Duplicate(EnemyInvestigationEpisode episode, Vector3 point, bool hypothesis) { int count = hypothesis ? episode.hypothesisCount : episode.viewpointCount; for (int i = 0; i < count; i++) if (Vector3.Distance(hypothesis ? episode.hypotheses[i].Point : episode.viewpoints[i].Point, point) < episode.Settings.MergeDistance) return true; return false; } private static Vector3 RawHypothesis(EnemyInvestigationEpisode episode, int index) { if (index == 0) return episode.Anchor; bool prediction = episode.Velocity.sqrMagnitude > .000001f && episode.Settings.PredictionSeconds > 0; if (prediction && index == 1) return episode.Anchor + Vector3.ClampMagnitude(episode.Velocity * episode.Settings.PredictionSeconds, episode.Settings.SearchRadius); int slot = index - (prediction ? 2 : 1); int direction = slot % 4; float radius = episode.Settings.SearchRadius * (slot / 4 % 2 == 0 ? .5f : 1); Vector3 axis = episode.Axis, side = Vector3.Cross(Vector3.up, axis); Vector3 offset = direction == 0 ? axis : direction == 1 ? -axis : direction == 2 ? side : -side; if (slot >= 8) offset = Quaternion.AngleAxis(45, Vector3.up) * offset; return episode.Anchor + offset * radius; } private void Select(EnemyInvestigationEpisode episode) { if (ReferenceEquals(active, episode)) return; Cancel(); active = episode; viewIndex = sampleIndex = routeStage = 0; } private bool SpendProbe(double now) { if (!probes.TrySpend(request.Sequence, Time.frameCount, now)) return false; ProbeCount++; return true; } private static void NextCover(EnemyInvestigationEpisode episode) { episode.coverIndex++; episode.coverStage = episode.coverGroundStep = episode.coverGroundSteps = episode.coverScanSteps = 0; episode.coverWall = null; } private static Vector3 CoverDirection(EnemyInvestigationEpisode episode) { Vector3 centre = episode.Anchor + Vector3.ClampMagnitude(episode.Velocity * episode.Settings.PredictionSeconds, episode.Settings.SearchRadius); Vector3 forward = centre + Vector3.up * EnemyTargetingRules.TorsoHeight - episode.ObservedFrom; forward = forward.sqrMagnitude > .000001f ? forward.normalized : episode.Axis; if (episode.coverIndex == 0) return forward; Vector3 right = Vector3.Cross(Vector3.up, forward); right = right.sqrMagnitude > .000001f ? right.normalized : Vector3.right; Vector3 up = Vector3.Cross(forward, right).normalized; float fraction = episode.coverIndex / (float)(episode.Settings.CoverFanSampleCount - 1); float cosine = Mathf.Lerp(1, Mathf.Cos(episode.Settings.CoverFanHalfAngleDegrees * Mathf.Deg2Rad), fraction); float azimuth = (episode.coverIndex - 1) * 137.50776f * Mathf.Deg2Rad; return forward * cosine + (right * Mathf.Cos(azimuth) + up * Mathf.Sin(azimuth)) * Mathf.Sqrt(Mathf.Max(0, 1 - cosine * cosine)); } private static bool ClipCoverRay(Vector3 origin, Vector3 direction, Vector3 centre, float radius, ref float start, ref float end) { Vector2 offset = new Vector2(origin.x - centre.x, origin.z - centre.z), ray = new Vector2(direction.x, direction.z); float a = ray.sqrMagnitude, b = Vector2.Dot(offset, ray), discriminant = b * b - a * (offset.sqrMagnitude - radius * radius); if (a < .000001f || discriminant < 0) return false; float root = Mathf.Sqrt(discriminant); start = Mathf.Max(start, (-b - root) / a); end = Mathf.Min(end, (-b + root) / a); return end > start; } private EnemyPlanStatus TryCoverHypothesis(EnemyInvestigationEpisode episode, double now, out Vector3 point) { point = default; var settings = episode.Settings; if (episode.coverIndex >= settings.CoverFanSampleCount || settings.CoverFanRadius <= 0 || settings.CoverFanHalfAngleDegrees <= 0 || episode.coverCount >= settings.MaximumHypotheses / 2) { episode.coverComplete = true; return EnemyPlanStatus.NoLegalPosition; } var build = NavMesh.GetSettingsByID(actor.Owner.AgentType); float walkable = Mathf.Cos(build.agentSlope * Mathf.Deg2Rad) - .01f; if (episode.coverStage == 0) { episode.coverRay = CoverDirection(episode); Vector3 prediction = Vector3.ClampMagnitude(episode.Velocity * settings.PredictionSeconds, settings.SearchRadius); float start = 0, end = float.PositiveInfinity; if (!ClipCoverRay(episode.ObservedFrom, episode.coverRay, episode.Anchor + prediction, settings.CoverFanRadius, ref start, ref end) || !ClipCoverRay(episode.ObservedFrom, episode.coverRay, episode.Anchor, settings.SearchRadius, ref start, ref end)) { NextCover(episode); return EnemyPlanStatus.NoLegalPosition; } if (!SpendProbe(now)) return EnemyPlanStatus.Deferred; int count = Physics.RaycastNonAlloc(episode.ObservedFrom, episode.coverRay, hits, end, worldMask, QueryTriggerInteraction.Ignore); if (count == hits.Length) { #if UNITY_EDITOR episode.RecordCoverRay(episode.ObservedFrom + episode.coverRay * end, EnemyInvestigationEpisode.CoverRayOutcome.Saturated); #endif return EnemyPlanStatus.Deferred; } float nearest = float.PositiveInfinity; RaycastHit wall = default; for (int i = 0; i < count; i++) if (!ActorCollider(hits[i].collider) && hits[i].distance < nearest) { nearest = hits[i].distance; wall = hits[i]; } #if UNITY_EDITOR episode.RecordCoverRay(nearest == float.PositiveInfinity ? episode.ObservedFrom + episode.coverRay * end : wall.point, nearest == float.PositiveInfinity ? EnemyInvestigationEpisode.CoverRayOutcome.Miss : Mathf.Abs(wall.normal.y) >= walkable ? EnemyInvestigationEpisode.CoverRayOutcome.OtherWorldHit : EnemyInvestigationEpisode.CoverRayOutcome.WallHit); #endif // A wall can start before the local region; its admitted far-side ground must still lie inside it. if (nearest == float.PositiveInfinity || Mathf.Abs(wall.normal.y) >= walkable) { NextCover(episode); return EnemyPlanStatus.NoLegalPosition; } episode.coverWall = wall.collider; episode.coverEntry = nearest; episode.coverDistance = nearest + .002f; episode.coverLimit = end; episode.coverFirstExit = episode.coverExit = -1; episode.coverScanSteps = 0; episode.coverStage = 1; } while (true) { if (episode.coverStage == 1) { float continuation = Mathf.Max(.75f, actor.Owner.Navigation.radius * 2 + .25f); float limit = episode.coverFirstExit < 0 ? episode.coverLimit : Mathf.Min(episode.coverLimit, episode.coverFirstExit + continuation); if (episode.coverScanSteps >= 32 || episode.coverDistance >= limit) { NextCover(episode); return EnemyPlanStatus.NoLegalPosition; } if (!SpendProbe(now)) return EnemyPlanStatus.Deferred; float next = Mathf.Min(episode.coverDistance + .25f, limit); Vector3 from = episode.ObservedFrom + episode.coverRay * next; // Reverse only this short segment: a miss is not proof of empty space inside a collider. int count = Physics.RaycastNonAlloc(from, -episode.coverRay, hits, next - episode.coverDistance + .004f, worldMask, QueryTriggerInteraction.Ignore); if (count == hits.Length) return EnemyPlanStatus.Deferred; // Establish the struck wall's exit first; nearby adjoining pieces may extend its clearance band. for (int pass = 0; pass < 2; pass++) for (int i = 0; i < count; i++) { var hit = hits[i]; float distance = next - hit.distance; if (pass == 0 && hit.collider != episode.coverWall || pass == 1 && (hit.collider == episode.coverWall || episode.coverFirstExit < 0)) continue; if (ActorCollider(hit.collider) || Mathf.Abs(hit.normal.y) >= walkable || Vector3.Dot(hit.normal, episode.coverRay) <= .001f || distance <= episode.coverEntry + .004f || distance <= episode.coverExit) continue; if (episode.coverFirstExit < 0) episode.coverFirstExit = distance; episode.coverExit = distance; episode.coverExitPoint = hit.point; episode.coverExitNormal = hit.normal; } episode.coverDistance = next; episode.coverScanSteps++; if (episode.coverExit < 0) continue; episode.coverRequested = from; episode.coverRequested.y = episode.Anchor.y; episode.coverGroundSteps = Mathf.Max(1, Mathf.CeilToInt(Horizontal(episode.Anchor, from) / .5f)); if (episode.coverGroundSteps > 32) { NextCover(episode); return EnemyPlanStatus.NoLegalPosition; } episode.coverGroundStep = 0; episode.coverSurface = episode.Anchor; episode.coverStage = 2; } while (episode.coverStage == 2 && episode.coverGroundStep < episode.coverGroundSteps) { if (!SpendProbe(now)) return EnemyPlanStatus.Deferred; Vector3 sample = Vector3.Lerp(episode.Anchor, episode.coverRequested, (episode.coverGroundStep + 1f) / episode.coverGroundSteps); sample.y = episode.coverSurface.y; float reach = Mathf.Min(actor.Owner.Navigation.height * .7f, build.agentClimb + Mathf.Tan(build.agentSlope * Mathf.Deg2Rad) * .5f + .15f); int count = Physics.RaycastNonAlloc(sample + Vector3.up * reach, Vector3.down, hits, reach * 2, worldMask, QueryTriggerInteraction.Ignore); if (count == hits.Length) return EnemyPlanStatus.Deferred; float nearest = float.PositiveInfinity; Vector3 ground = default; for (int i = 0; i < count; i++) { if (ActorCollider(hits[i].collider) || hits[i].normal.y < walkable) continue; if (hits[i].distance < nearest) { nearest = hits[i].distance; ground = hits[i].point; } } if (nearest == float.PositiveInfinity || Mathf.Abs(ground.y - episode.Anchor.y) > settings.MaximumVerticalOffset) { NextCover(episode); return EnemyPlanStatus.NoLegalPosition; } // Follow local ramp/stair support with bounded height changes instead of starting above the wall. episode.coverSurface = ground; episode.coverGroundStep++; } if (episode.coverStage == 2) { Vector3 predicted = episode.Anchor + Vector3.ClampMagnitude(episode.Velocity * settings.PredictionSeconds, settings.SearchRadius); if (!Project(episode, episode.coverSurface, settings.SearchRadius, out point) || Mathf.Abs(point.y - episode.coverSurface.y) > .2f || Horizontal(point, episode.coverRequested) > .2f || Horizontal(point, predicted) > settings.CoverFanRadius + .001f || Vector3.Dot(point + Vector3.up * EnemyTargetingRules.TorsoHeight - episode.coverExitPoint, episode.coverExitNormal) <= .02f) { episode.coverStage = 1; continue; } episode.coverSurface = point; episode.coverStage = 3; } if (!SpendProbe(now)) return EnemyPlanStatus.Deferred; Vector3 delta = episode.coverSurface + Vector3.up * EnemyTargetingRules.TorsoHeight - episode.ObservedFrom; int hitCount = Physics.RaycastNonAlloc(episode.ObservedFrom, delta.normalized, hits, delta.magnitude, worldMask, QueryTriggerInteraction.Ignore); if (hitCount == hits.Length) return EnemyPlanStatus.Deferred; float first = float.PositiveInfinity; Collider obstruction = null; for (int i = 0; i < hitCount; i++) if (!ActorCollider(hits[i].collider) && hits[i].distance < first) { first = hits[i].distance; obstruction = hits[i].collider; } // The initial wall and its far-side exit establish provenance. A stair or // adjoining surface may occlude the grounded torso before that same wall. if (obstruction == null) { episode.coverStage = 1; continue; } point = episode.coverSurface; return EnemyPlanStatus.Ready; } } private void ResetEvaluation(int currentSignature) { if (active != null && signature != currentSignature && !weighted) { weightIndex = 0; active.initialWeight = 0; if (!generated) { generation = 0; active.hypothesisCount = 0; Array.Clear(active.hypotheses, 0, active.hypotheses.Length); active.coverIndex = active.coverGroundStep = active.coverGroundSteps = active.coverStage = active.coverCount = 0; active.coverComplete = active.hasPendingHypothesis = active.pendingCover = false; #if UNITY_EDITOR active.ClearCoverRays(); #endif } } signature = currentSignature; origin = actor.Owner.transform.position; viewIndex = sampleIndex = routeStage = 0; firstLeg = null; foreach (var view in active.viewpoints) if (view != null) { view.Coverage = 0; view.Gain = view.Score = view.RouteLength = 0; view.Route = null; view.RefinedOrientation = false; } } internal EnemyPlanStatus TryPlan(EnemyInvestigationEpisode episode, EnemyBehaviorContext context, double now, out EnemyMovePlan plan) { #if UNITY_EDITOR || DEVELOPMENT_BUILD if (MeasureQueries) { long start = System.Diagnostics.Stopwatch.GetTimestamp(), allocation = GC.GetAllocatedBytesForCurrentThread(); measuringPlanning = true; try { return TryPlanCore(episode, context, now, out plan); } finally { measuringPlanning = false; RecordMeasurement(start, allocation); } } #endif return TryPlanCore(episode, context, now, out plan); } private EnemyPlanStatus TryPlanCore(EnemyInvestigationEpisode episode, EnemyBehaviorContext context, double now, out EnemyMovePlan plan) { plan = null; if (!actor.IsValid || context == null || !ReferenceEquals(context.Actor, actor) || now != actor.Now) return EnemyPlanStatus.InvalidContext; episode.Refresh(now); if (episode.Terminal) return EnemyPlanStatus.NoLegalPosition; Select(episode); if (!TryGeometrySignature(episode, out int currentSignature)) return EnemyPlanStatus.Deferred; if (request.Sequence == 0) { request = movement.IssueTokenServer(); if (request.Sequence == 0) return EnemyPlanStatus.InvalidContext; ResetEvaluation(currentSignature); } else if (currentSignature != signature || Vector3.Distance(origin, actor.Owner.transform.position) > .2f) ResetEvaluation(currentSignature); var settings = episode.Settings; int primary = episode.Velocity.sqrMagnitude > .000001f && settings.PredictionSeconds > 0 ? 2 : 1; int nonCoverAttempts = primary + settings.RadialSampleCount; while (!generated && episode.hypothesisCount < settings.MaximumHypotheses && (generation < nonCoverAttempts || !episode.coverComplete)) { if (!episode.hasPendingHypothesis) { Vector3 candidate; bool cover = generation >= primary && !episode.coverComplete; if (cover) { var status = TryCoverHypothesis(episode, now, out candidate); if (status == EnemyPlanStatus.Deferred) return status; if (status != EnemyPlanStatus.Ready) continue; } else if (generation >= nonCoverAttempts) break; else if (!Project(episode, RawHypothesis(episode, generation), settings.SearchRadius, out candidate)) { generation++; continue; } if (Duplicate(episode, candidate, true)) { if (cover) NextCover(episode); else generation++; continue; } episode.pendingHypothesis = candidate; episode.pendingCover = cover; episode.hasPendingHypothesis = true; } if (!paths.TrySpend(request.Sequence, Time.frameCount, now)) return EnemyPlanStatus.Deferred; PathCount++; Vector3 point = episode.pendingHypothesis; if (episode.pendingCover) NextCover(episode); else generation++; episode.hasPendingHypothesis = false; var path = new NavMeshPath(); if (!NavMesh.CalculatePath(episode.Anchor, point, movement.Safety.Filter, path) || path.status != NavMeshPathStatus.PathComplete) continue; var hypothesis = new EnemyInvestigationEpisode.Hypothesis { Point = point, Weight = 1 }; #if UNITY_EDITOR hypothesis.FromWallHit = episode.pendingCover; #endif episode.hypotheses[episode.hypothesisCount++] = hypothesis; if (episode.pendingCover) episode.coverCount++; } generated = true; if (episode.hypothesisCount == 0) { episode.Finish(EnemyInvestigationReason.NoEligibleGround); Cancel(); return EnemyPlanStatus.NoLegalPosition; } while (!weighted && weightIndex < episode.hypothesisCount) { var sample = episode.hypotheses[weightIndex]; Vector3 delta = sample.Point - episode.Anchor; delta.y = 0; var visibility = Probe(episode.ObservedFrom, (sample.Point + Vector3.up * EnemyTargetingRules.TorsoHeight - episode.ObservedFrom).normalized, sample.Point + Vector3.up * EnemyTargetingRules.TorsoHeight, request.Sequence, now); if (visibility == EnemySearchVisibility.Unknown) return EnemyPlanStatus.Deferred; float near = Mathf.Clamp01(1 - delta.magnitude / settings.SearchRadius); float alignment = episode.Velocity.sqrMagnitude > .000001f && delta.sqrMagnitude > .000001f ? Mathf.Max(0, Vector3.Dot(delta.normalized, episode.Velocity.normalized)) : 0; sample.Weight = 1 + (visibility == EnemySearchVisibility.Blocked ? near * settings.NearCoverWeight : 0) + alignment * settings.DirectionWeight; episode.initialWeight += sample.Weight; weightIndex++; } weighted = true; if (!candidatesBuilt) { AddView(episode, episode.StartingView, true); // Belief samples must not crowd out lateral views that can expose the cover itself. Vector3 side = Vector3.Cross(Vector3.up, episode.Axis) * (settings.SearchRadius + settings.ViewpointStandOff); AddView(episode, episode.Anchor + side, false); AddView(episode, episode.Anchor - side, false); for (int i = 0; i < episode.hypothesisCount; i++) AddView(episode, episode.hypotheses[i].Point, false); for (int i = 0; i < 16 && episode.viewpointCount < settings.MaximumCandidates; i++) { float angle = i % 2 == 0 ? i / 2 * 45 : -(i / 2 + 1) * 45; AddView(episode, episode.Anchor + Quaternion.AngleAxis(angle, Vector3.up) * episode.Axis * (settings.SearchRadius + settings.ViewpointStandOff), false); } candidatesBuilt = true; } while (viewIndex < episode.viewpointCount) { var view = episode.viewpoints[viewIndex]; if (view.Exhausted || view.Failed && (now < view.FailedAt + settings.BlockedRetrySeconds || signature == view.FailedGeometry)) { NextView(); continue; } if (sampleIndex == 0) lookPoint = view.RefinedOrientation ? view.LookPoint : HighestUnchecked(episode); Quaternion rotation = Facing(view.Point, lookPoint); Vector3 eye = view.Point + rotation * localEye; while (sampleIndex < episode.hypothesisCount) { int index = sampleIndex; if ((episode.inspectedMask & (1u << index)) != 0) { sampleIndex++; continue; } var visibility = Probe(eye, rotation * localEyeRotation * Vector3.forward, episode.hypotheses[index].Point + Vector3.up * EnemyTargetingRules.TorsoHeight, request.Sequence, now); if (visibility == EnemySearchVisibility.Unknown) return EnemyPlanStatus.Deferred; if (visibility == EnemySearchVisibility.Visible) view.Coverage |= 1u << index; sampleIndex++; } view.Gain = Gain(episode, view.Coverage); if (view.Gain < settings.MinimumInformationGain) { NextView(); continue; } if (!view.RefinedOrientation) { view.LookPoint = HighestCovered(episode, view.Coverage); view.RefinedOrientation = true; if (Vector3.Distance(view.LookPoint, lookPoint) > .01f) { view.Coverage = 0; sampleIndex = 0; return EnemyPlanStatus.Deferred; } } if (!paths.TrySpend(request.Sequence, Time.frameCount, now)) return EnemyPlanStatus.Deferred; PathCount++; var route = new NavMeshPath(); Vector3 from = routeStage == 2 ? episode.Anchor : origin; Vector3 destination = routeStage == 1 ? episode.Anchor : view.Point; bool native = NavMesh.CalculatePath(from, destination, movement.Safety.Filter, route) && route.status == NavMeshPathStatus.PathComplete; var bounded = new EnemyMoveLimits(limits.Speed, Mathf.Min(limits.TravelDistance, episode.TravelRemaining), Math.Min(limits.Seconds, episode.Deadline - now)); var preview = new EnemyMovePlan(EnemyPlanStatus.Ready, request, default, actor.Owner.AgentType, actor.Owner.Navigation.areaMask, from, destination, route, bounded, now); bool safe = native && movement.Safety.Route(preview, false); if (routeStage == 0 && !safe && Vector3.Distance(origin, episode.Anchor) > .3f && Vector3.Distance(view.Point, episode.Anchor) > .3f) { routeStage = 1; continue; } if (routeStage == 1 && safe) { firstLeg = route; routeStage = 2; continue; } if (!safe) { NextView(); continue; } if (routeStage == 2) { var a = firstLeg.corners; var b = route.corners; if (a.Length + b.Length > 64) { NextView(); continue; } var points = new Vector3[a.Length + b.Length]; Array.Copy(a, points, a.Length); Array.Copy(b, 0, points, a.Length, b.Length); preview = new EnemyMovePlan(request, default, actor.Owner.AgentType, actor.Owner.Navigation.areaMask, origin, view.Point, points, bounded, now, null); if (!movement.Safety.Route(preview, false)) { NextView(); continue; } } float length = RouteLength(preview); if (length > bounded.TravelDistance + .001f || length / bounded.Speed > bounded.Seconds) { NextView(); continue; } view.Route = preview; view.RouteLength = length; view.Score = view.Gain - settings.TravelCostWeight * length / settings.MaximumSearchTravelDistance; NextView(); } int best = -1; EnemyPositionClaim claim = default; uint considered = 0; bool useful = false; for (int attempt = 0; attempt < episode.viewpointCount; attempt++) { best = -1; for (int i = 0; i < episode.viewpointCount; i++) { if ((considered & (1u << i)) != 0) continue; var view = episode.viewpoints[i]; if (view.Route == null || view.Gain < settings.MinimumInformationGain) continue; if (view.RouteLength > Mathf.Min(limits.TravelDistance, episode.TravelRemaining) + .001f || view.RouteLength / limits.Speed > Math.Min(limits.Seconds, episode.Deadline - now)) continue; if (best < 0 || view.Score > episode.viewpoints[best].Score + .00001f || Mathf.Abs(view.Score - episode.viewpoints[best].Score) <= .00001f && (view.RouteLength < episode.viewpoints[best].RouteLength - .00001f || Mathf.Abs(view.RouteLength - episode.viewpoints[best].RouteLength) <= .00001f && CombatPenalty(view.Point, episode.Anchor) < CombatPenalty(episode.viewpoints[best].Point, episode.Anchor))) best = i; } if (best < 0) break; useful = true; considered |= 1u << best; if (movement.Safety.Supported(episode.viewpoints[best].Point) && movement.Safety.Clear(episode.viewpoints[best].Point, false) && claims.TryAcquire(request, episode.viewpoints[best].Point, actor.Owner.Navigation.radius, now, claimSeconds, out claim)) break; best = -1; } if (best < 0) { if (useful) return EnemyPlanStatus.Deferred; episode.Finish(EnemyInvestigationReason.NoUsefulViewpoint); Cancel(); return EnemyPlanStatus.NoLegalPosition; } var chosen = episode.viewpoints[best]; var finalLimits = new EnemyMoveLimits(limits.Speed, Mathf.Min(limits.TravelDistance, episode.TravelRemaining), Math.Min(limits.Seconds, episode.Deadline - now)); var corners = new Vector3[chosen.Route.CornerCount]; for (int i = 0; i < corners.Length; i++) corners[i] = chosen.Route.Corner(i); // Travel retains personal-evidence facing; inspection owns a fresh hypothesis-facing action after arrival. plan = new EnemyMovePlan(request, claim, actor.Owner.AgentType, actor.Owner.Navigation.areaMask, origin, chosen.Point, corners, finalLimits, now, episode.Anchor + Vector3.up * EnemyTargetingRules.TorsoHeight); episode.LookPoint = chosen.LookPoint; episode.ActiveGain = chosen.Gain; episode.ActiveScore = chosen.Score; episode.PendingCandidate = best; paths.Cancel(request.Sequence); probes.Cancel(request.Sequence); request = default; return EnemyPlanStatus.Ready; } private float CombatPenalty(Vector3 point, Vector3 anchor) { float distance = Vector3.Distance(point, anchor); return Mathf.Max(0, preferredMinimum - distance) + Mathf.Max(0, distance - preferredMaximum); } private void NextView() { viewIndex++; sampleIndex = routeStage = 0; firstLeg = null; } private void AddView(EnemyInvestigationEpisode episode, Vector3 requested, bool starting) { if (episode.viewpointCount >= episode.Settings.MaximumCandidates) return; Vector3 point; if (starting) { if (!movement.Safety.TryProjectSurface(requested, out point, true) || !movement.Safety.Clear(point, false)) return; } else if (!Project(episode, requested, episode.Settings.SearchRadius + episode.Settings.ViewpointStandOff, out point)) return; if (!Duplicate(episode, point, false)) episode.viewpoints[episode.viewpointCount++] = new EnemyInvestigationEpisode.Viewpoint { Point = point }; } private static Quaternion Facing(Vector3 from, Vector3 to) { var delta = to - from; delta.y = 0; return delta.sqrMagnitude < .000001f ? Quaternion.identity : Quaternion.LookRotation(delta); } private static Vector3 HighestUnchecked(EnemyInvestigationEpisode episode) => HighestCovered(episode, ~episode.inspectedMask); private static Vector3 HighestCovered(EnemyInvestigationEpisode episode, uint mask) { int best = -1; for (int i = 0; i < episode.hypothesisCount; i++) if ((mask & (1u << i)) != 0 && (best < 0 || episode.hypotheses[i].Weight > episode.hypotheses[best].Weight)) best = i; return (best < 0 ? episode.Anchor : episode.hypotheses[best].Point) + Vector3.up * EnemyTargetingRules.TorsoHeight; } private static float Gain(EnemyInvestigationEpisode episode, uint mask) { float sum = 0; for (int i = 0; i < episode.hypothesisCount; i++) if ((mask & (1u << i) & ~episode.inspectedMask) != 0) sum += episode.hypotheses[i].Weight; return episode.initialWeight > 0 ? sum / episode.initialWeight : 0; } internal static float RouteLength(EnemyMovePlan plan) { if (plan.CornerCount == 0) return float.PositiveInfinity; float sum = Vector3.Distance(plan.Origin, plan.Corner(0)) + Vector3.Distance(plan.Destination, plan.Corner(plan.CornerCount - 1)); for (int i = 1; i < plan.CornerCount; i++) sum += Vector3.Distance(plan.Corner(i - 1), plan.Corner(i)); return sum; } } }