using System; using System.Collections.Generic; using ProjectI.Combat; using UnityEngine; using UnityEngine.AI; namespace ProjectI.Enemies { public sealed partial class EnemyPositioning { private enum AllyCorridor { Clear, Ally, Blocked, Unknown } private readonly Collider[] allyOverlaps = new Collider[48]; private readonly RaycastHit[] allyHits = new RaycastHit[48]; private readonly List allyRoutes = new List(4); private ulong allyBudgetSpawn; private int allyMaximumAttempts, allyAttempts, allyCandidate; private float allyTravelLimit, allySecondsLimit, allyReservedTravel, allyReservedSeconds, allyActionTravel, allyAcceptedProgress; private double allyBudgetDeadline, nextAllyAttempt, allyActionDeadline, allyActionSeconds; private Vector3 allyOrigin, allyDirection, allyRetreat, allyMuzzle, allyEye, allyQueryOrigin; private ulong consumedAllyRelease; private EnemyObservation allyEvidence; private PrototypeEnemy allyBlocker; private ulong allyBlockerSpawn; private Vector3[] allyCandidates; private EnemyActionToken allyToken, allyProbeToken; private EnemyMovementContext allyContext; #if UNITY_EDITOR || DEVELOPMENT_BUILD private string allyTrace = ""; #endif [System.Diagnostics.Conditional("UNITY_EDITOR"), System.Diagnostics.Conditional("DEVELOPMENT_BUILD")] private void TraceAlly(string message) { #if UNITY_EDITOR || DEVELOPMENT_BUILD if (movement.CaptureNativeMovement && allyTrace.Length < 2048) allyTrace += message + "\n"; #endif } internal bool AllyRepositionActive => actor != null && allyToken.IsValidFor(actor); private void CaptureAllySettings() { if (allyBudgetSpawn == actor.SpawnIdentity) return; allyBudgetSpawn = actor.SpawnIdentity; allyMaximumAttempts = definition.allyRepositionAttempts; allyTravelLimit = definition.allyRepositionTravelDistance; allySecondsLimit = definition.allyRepositionSeconds; ResetAllyAllowance(); } private void ResetAllyAllowance() { allyAttempts = 0; allyReservedTravel = allyReservedSeconds = 0; allyBudgetDeadline = nextAllyAttempt = 0; allyDirection = allyRetreat = Vector3.zero; allyAcceptedProgress = 0; } private void CancelAllyReposition() { budget?.Cancel(allyToken.Sequence); claims?.Release(allyToken); movement?.Cancel(allyToken); CancelAllyProbe(); allyToken = default; allyContext = null; allyCandidates = null; allyRoutes.Clear(); if (actor != null) nextAllyAttempt = Math.Max(nextAllyAttempt, actor.Now + Math.Max(.5, PlanningInterval)); } private void CancelAllyProbe() { budget?.Cancel(allyProbeToken.Sequence); allyProbeToken.Invalidate(); allyProbeToken = default; } private bool AllyScope(PrototypeEnemy other) { var peer = other != null ? other.GetComponent()?.Actor : null; return peer != null && peer.IsValid && peer.ReadyForActionsServer && other != actor.Owner && peer.Network == actor.Network && peer.Encounter == actor.Encounter && peer.Stage == actor.Stage && peer.StageDefinition == actor.StageDefinition && peer.RunId == actor.RunId && peer.StageId == actor.StageId; } private bool HasAllyInScope() { foreach (var enemy in PrototypeEnemy.ActiveEnemies) if (AllyScope(enemy)) return true; return false; } private bool IgnoreAllyProbe(Collider collider) { if (collider.transform.IsChildOf(transform)) return true; var player = collider.GetComponentInParent(); return player != null && player.NetworkManager == actor.Network && player.NetworkObjectId == allyEvidence.Target.NetworkObjectId && player.SpawnLifetime == allyEvidence.Target.SpawnLifetime; } private AllyCorridor ProbeAllyCorridor(Vector3 origin, float radius, out PrototypeEnemy blocker) { blocker = null; Vector3 delta = allyEvidence.Position - origin; if (!EnemyObservationRules.Finite(origin) || delta.sqrMagnitude < .000001f) return AllyCorridor.Unknown; int count = Physics.OverlapSphereNonAlloc(origin, radius, allyOverlaps, ~0, QueryTriggerInteraction.Ignore); if (count == allyOverlaps.Length) return AllyCorridor.Unknown; for (int i = 0; i < count; i++) { if (IgnoreAllyProbe(allyOverlaps[i])) continue; var enemy = allyOverlaps[i].GetComponentInParent(); if (!AllyScope(enemy)) return AllyCorridor.Blocked; blocker = enemy; } if (blocker != null) return AllyCorridor.Ally; count = Physics.SphereCastNonAlloc(origin, radius, delta.normalized, allyHits, delta.magnitude, ~0, QueryTriggerInteraction.Ignore); if (count == allyHits.Length) return AllyCorridor.Unknown; Collider nearest = null; float distance = float.PositiveInfinity; for (int i = 0; i < count; i++) if (!IgnoreAllyProbe(allyHits[i].collider) && allyHits[i].distance < distance) { nearest = allyHits[i].collider; distance = allyHits[i].distance; } if (nearest == null) return AllyCorridor.Clear; blocker = nearest.GetComponentInParent(); return AllyScope(blocker) ? AllyCorridor.Ally : AllyCorridor.Blocked; } private bool AllyGoalClear(Vector3 point) { Vector3 direction = allyEvidence.Position - point; direction.y = 0; Vector3 localEyeForward = perception.LocalEyeRotation * Vector3.forward; float eyeYaw = Mathf.Atan2(localEyeForward.x, localEyeForward.z) * Mathf.Rad2Deg; Quaternion facing = direction.sqrMagnitude > .000001f ? Quaternion.LookRotation(direction) * Quaternion.Euler(0, -eyeYaw, 0) : transform.rotation; Vector3 muzzlePoint = point + facing * allyMuzzle; return Vector3.Distance(muzzlePoint, allyEvidence.Position) <= range && ProbeAllyCorridor(point + facing * allyEye, .015f, out _) == AllyCorridor.Clear && ProbeAllyCorridor(muzzlePoint, clearance, out _) == AllyCorridor.Clear; } internal bool AllyAuthorityCurrentServer(EnemyActionToken action, EnemyObservation evidence, double until, double now) => actor != null && actor.IsValid && actor.ReadyForActionsServer && !actor.Status.Rooted && !actor.Status.Stunned && !AlertAttackActive() && action.Equals(allyToken) && action.IsValidFor(actor) && evidence.Equals(allyEvidence) && now < until && until <= allyActionDeadline && until <= allyBudgetDeadline && AllyScope(allyBlocker) && allyBlocker.SpawnIdentity == allyBlockerSpawn && !movement.HasUnexpectedDisplacement && (allyCandidates == null || Vector3.Distance(transform.position, allyQueryOrigin) <= .35f) && evidence.Kind == EnemyObservationKind.PersonalVisual && evidence.Source.Equals(actor.Owner.Identity) && evidence.SourceSpawnIdentity == actor.SpawnIdentity && now >= evidence.ObservedAt && perception.TryGetSnapshot(evidence.Target, now, out var snapshot) && snapshot.Personal.HasValue && snapshot.Personal.Value.ObservedAt >= evidence.ObservedAt && until <= evidence.ObservedAt + snapshot.EvidenceLifetimeSeconds && perception.TryGetSelectedTarget(now, out var selected) && selected.Equals(evidence.Target); internal bool AllyRouteAllowedServer(EnemyMovePlan plan) => plan.Token.Equals(allyToken) && plan.Limits.TravelDistance <= allyActionTravel + .001f && plan.Limits.Seconds <= allyActionSeconds && plan.ApproachRouteLength <= plan.Limits.TravelDistance + .001f && Vector3.Distance(allyOrigin, plan.ApproachGoal) <= Mathf.Min(2, allyTravelLimit * .5f) + .1f; private void TickAllyReposition(double now) { if (allyContext != null) { if (!allyContext.IsCurrent(now) || allyCandidates == null && movement.AcceptedPlan?.Token.Equals(allyToken) != true) CancelAllyReposition(); else if (allyCandidates != null) ContinueAllyReposition(now); if (AllyRepositionActive) return; } var bolt = GetComponent(); var director = actor.Encounter.PressureDirector; if (allyBudgetDeadline > 0 && now >= allyBudgetDeadline && bolt != null && bolt.CombatReleaseSequence > consumedAllyRelease && bolt.CombatReleasedAt > allyBudgetDeadline && director != null && director.TryInspectEngagementServer(actor, out var receipt) && receipt.Phase == EnemyEngagementPhase.Fight && receipt.CapturedAt > allyBudgetDeadline && now < receipt.ValidUntil && receipt.Target.HasValue && bolt.CombatReleaseTarget.Equals(receipt.Target.Value)) { consumedAllyRelease = bolt.CombatReleaseSequence; ResetAllyAllowance(); } if (now < nextAllyAttempt || allyAttempts >= allyMaximumAttempts || allyBudgetDeadline > 0 && now >= allyBudgetDeadline || allyReservedTravel >= allyTravelLimit || allyReservedSeconds >= allySecondsLimit || director == null || !director.RequiresMovementContextServer(actor) || !actor.ReadyForActionsServer || actor.Status.Rooted || actor.Status.Stunned || bolt == null || !bolt.isActiveAndEnabled || AlertAttackActive() || HasAcceptedTravel || movement.HasObservation || movement.StraightMoveActive || movement.HasPendingAction || candidates != null || investigationPlanner?.PendingContext != null || ActiveInvestigation != null && !ActiveInvestigation.Terminal) { CancelAllyProbe(); return; } if (!HasAllyInScope()) { CancelAllyProbe(); nextAllyAttempt = now + Math.Max(.5, PlanningInterval); return; } if (!perception.TryGetSelectedTarget(now, out var target) || !perception.TryGetSnapshot(target, now, out var snapshot) || !snapshot.Personal.HasValue || !snapshot.ReactionReady) { CancelAllyProbe(); return; } var evidence = snapshot.Personal.Value; if (!evidence.Source.Equals(actor.Owner.Identity) || evidence.SourceSpawnIdentity != actor.SpawnIdentity || evidence.Kind != EnemyObservationKind.PersonalVisual || now >= evidence.ObservedAt + snapshot.EvidenceLifetimeSeconds ) { CancelAllyProbe(); return; } Vector3 eye = transform.position + transform.rotation * perception.LocalEye; Vector3 actualMuzzle = muzzleTransform != null ? muzzleTransform.position : transform.TransformPoint(muzzle); if (Vector3.Distance(actualMuzzle, evidence.Position) > range || Vector3.Angle(transform.rotation * perception.LocalEyeRotation * Vector3.forward, evidence.Position - eye) > perception.FieldOfView * .5f) { CancelAllyProbe(); return; } if (!allyProbeToken.IsValidFor(actor)) allyProbeToken = new EnemyActionToken(actor); if (!budget.TrySpend(allyProbeToken.Sequence, Time.frameCount, now)) return; CancelAllyProbe(); nextAllyAttempt = now + Math.Max(.5, PlanningInterval); if (!movement.Safety.Legal(transform.position)) return; allyEvidence = evidence; var sight = ProbeAllyCorridor(eye, .015f, out var eyeBlocker); var lane = ProbeAllyCorridor(actualMuzzle, clearance, out var muzzleBlocker); if (sight == AllyCorridor.Blocked || sight == AllyCorridor.Unknown || lane == AllyCorridor.Blocked || lane == AllyCorridor.Unknown || sight != AllyCorridor.Ally && lane != AllyCorridor.Ally) return; if (!director.TryCaptureCycleServer(actor, out var cycle)) return; allyBlocker = eyeBlocker != null ? eyeBlocker : muzzleBlocker; allyBlockerSpawn = allyBlocker.SpawnIdentity; if (allyBudgetDeadline == 0) { allyBudgetDeadline = Math.Min(now + allySecondsLimit * 2, evidence.ObservedAt + snapshot.EvidenceLifetimeSeconds); allyOrigin = transform.position; allyRetreat = allyOrigin - allyBlocker.transform.position; allyRetreat.y = 0; allyRetreat = allyRetreat.normalized * .5f; } allyQueryOrigin = transform.position; allyMuzzle = transform.InverseTransformPoint(actualMuzzle); allyEye = perception.LocalEye; allyActionSeconds = Math.Min(allySecondsLimit / allyMaximumAttempts, allySecondsLimit - allyReservedSeconds); allyActionTravel = allyTravelLimit - allyReservedTravel; allyActionDeadline = Math.Min(Math.Min(allyBudgetDeadline, evidence.ObservedAt + snapshot.EvidenceLifetimeSeconds), now + allyActionSeconds); if (allyActionDeadline <= now + PlanningInterval) return; Vector3 forward = evidence.Position - allyOrigin; forward.y = 0; Vector3 right = forward.sqrMagnitude > .000001f ? Vector3.Cross(Vector3.up, forward.normalized) : transform.right; float stride = Mathf.Min(2, allyTravelLimit * .5f); allyCandidates = allyDirection.sqrMagnitude > .000001f ? new[] { allyOrigin + allyDirection * stride * .5f, allyOrigin + allyDirection * stride } : new[] { allyOrigin + right * stride * .5f, allyOrigin - right * stride * .5f, allyOrigin + Vector3.ClampMagnitude(right * stride + allyRetreat, stride), allyOrigin + Vector3.ClampMagnitude(-right * stride + allyRetreat, stride) }; allyToken = movement.IssueTokenServer(); allyAttempts++; allyCandidate = 0; allyRoutes.Clear(); allyContext = new EnemyMovementContext(actor, allyToken, EnemyMovementContextKind.AllyReposition, cycle, evidence, allyActionDeadline); ContinueAllyReposition(now); } // Reconstructing a duration from a deadline can round above the original allocation. private double RemainingAllySeconds(double now) => Math.Min(allyActionSeconds, allyActionDeadline - now); private void ContinueAllyReposition(double now) { var safety = movement.Safety; while (allyCandidate < allyCandidates.Length) { if (!budget.TrySpend(allyToken.Sequence, Time.frameCount, now)) return; Vector3 requested = allyCandidates[allyCandidate++]; if (allyDirection.sqrMagnitude > .000001f && Vector3.Dot(requested - allyOrigin, allyDirection) < allyAcceptedProgress - .01f) continue; if (!safety.TryProject(requested, out var point) || Vector3.Distance(requested, point) > .2f || allyDirection.sqrMagnitude > .000001f && Vector3.Dot(point - allyOrigin, allyDirection) < allyAcceptedProgress - .01f || Vector3.Distance(transform.position, point) < .25f || !AllyGoalClear(point) || !safety.Legal(point)) { TraceAlly("candidate geometry/lane rejected " + requested); continue; } var path = new NavMeshPath(); if (!NavMesh.CalculatePath(transform.position, point, safety.Filter, path) || path.status != NavMeshPathStatus.PathComplete) continue; var remaining = new EnemyMoveLimits(limits.Speed, allyTravelLimit - allyReservedTravel, RemainingAllySeconds(now)); var preview = new EnemyMovePlan(EnemyPlanStatus.Ready, allyToken, default, actor.Owner.AgentType, actor.Owner.Navigation.areaMask, transform.position, point, path, remaining, now, allyEvidence.Position, pursuitEpisodeId: allyContext.PursuitEpisodeId, evidence: allyEvidence, context: allyContext); bool supported = safety.RouteWithTravel(preview, true, out float travel); if (!supported || travel < .25f || travel > remaining.TravelDistance || travel > limits.Speed * Math.Max(0, remaining.Seconds - PlanningInterval)) { TraceAlly($"candidate route rejected supported={supported} travel={travel} seconds={remaining.Seconds} goal={point}"); continue; } TraceAlly("candidate route ready " + point); allyRoutes.Add(preview); } allyRoutes.Sort((a, b) => a.RouteLength.CompareTo(b.RouteLength)); foreach (var preview in allyRoutes) { if (!AllyGoalClear(preview.Destination) || !safety.RouteWithTravel(preview, true, out float travel) || !claims.TryAcquire(allyToken, preview.Destination, actor.Owner.Navigation.radius, now, claimSeconds, out var claim)) { TraceAlly("candidate recheck/claim rejected " + preview.Destination); continue; } float tails = Vector3.Distance(preview.Origin, preview.Corner(0)) + Vector3.Distance(preview.Corner(preview.CornerCount - 1), preview.Destination); float reservation = Mathf.Max(travel, preview.RouteLength) + tails + .05f; if (reservation > allyTravelLimit - allyReservedTravel) { claims.Release(allyToken); continue; } allyActionTravel = reservation; var bounded = new EnemyMovePlan(preview, preview.CopyCorners(), preview.Destination, new EnemyMoveLimits(limits.Speed, reservation, preview.Limits.Seconds), claim); var authority = allyContext.BindRoute(bounded); var plan = new EnemyMovePlan(bounded, bounded.CopyCorners(), bounded.Destination, bounded.Limits, claim, context: authority); #if UNITY_EDITOR || DEVELOPMENT_BUILD if (movement.CaptureNativeMovement) TraceAlly($"acceptance gates now={now:R} seconds={plan.Limits.Seconds:R}/{allyActionSeconds:R} until={plan.ValidUntil:R}/{authority.ExpiresAt:R} token={plan.Token.IsValidFor(actor)} current={authority.IsCurrent(now)} authorized={authority.Authorizes(plan)}"); #endif var status = movement.TryAccept(plan); TraceAlly("movement acceptance " + status); if (status != EnemyPlanStatus.Ready) break; allyContext = authority; // Reserve the whole accepted action, so cancellation cannot refund a new sidestep. allyReservedTravel += reservation; allyReservedSeconds += (float)allyActionSeconds; if (allyDirection.sqrMagnitude < .000001f) { allyDirection = preview.Destination - allyOrigin; allyDirection.y = 0; allyDirection.Normalize(); } allyAcceptedProgress = Mathf.Max(allyAcceptedProgress, Vector3.Dot(preview.Destination - allyOrigin, allyDirection)); budget.Cancel(allyToken.Sequence); allyCandidates = null; allyRoutes.Clear(); return; } nextAllyAttempt = now + Math.Max(.5, PlanningInterval); CancelAllyReposition(); } } }