ArenaOpponent.Navigation.cs
using Sandbox;
using Sandbox.Navigation;
using System;
namespace PaintballBaddies;
public sealed partial class ArenaOpponent
{
private float stalledTime,navigationRetry,searchRemaining,searchPause;
private int searchStep;
private Vector3 searchOrigin,searchDestination;
public int NavigationRecoveries { get; private set; }
public int SearchesStarted { get; private set; }
public bool Searching => searchRemaining>0;
public Vector3 FlankDestination => flankDestination;
public string Tactic => EvadingAfterHit ? "Evade" : RelocatingCover ? "Relocate" : CoverPeeking ? "Peek"
: UsingCover ? "Shelter" : HasCoverPlan ? "Seek cover" : Flanking ? "Flank" : Searching ? "Search"
: SeesPlayer ? "Engage" : "Patrol";
internal bool Reachable(Vector3 candidate,float maxLength,out Vector3 point,out float length)
{
point=default;length=0;
var nearest=Scene.NavMesh.GetClosestPoint(candidate,40);
if(nearest is not Vector3 snapped || MathF.Abs(snapped.z-WorldPosition.z)>36)return false;
var path=Scene.NavMesh.CalculatePath(new CalculatePathRequest{Start=WorldPosition,Target=snapped,Agent=agent});
if(path.Status!=NavMeshPathStatus.Complete || path.Points.Count<1)return false;
var previous=WorldPosition;
foreach(var step in path.Points){length+=(step.Position-previous).Length;previous=step.Position;}
if(length>maxLength)return false;
point=snapped;return true;
}
private bool TryStartFlank()
{
// Perpendicular flank with forward bias adapted from Facepunch's
// CombatEngageSchedule (MIT). We additionally validate complete paths,
// destination space, firing angle and other bots' destinations.
var toTarget=(LastKnownPlayerPosition-WorldPosition).WithZ(0).Normal;
if(toTarget.Length<.5f)return false;
var perpendicular=new Vector3(-toTarget.y,toTarget.x,0);
float preferred=Game.Random.Float()>.5f ? 1 : -1,best=float.MaxValue;
Vector3 selected=default;
foreach(var sign in new[]{preferred,-preferred})
foreach(var radius in new[]{220f,360f})
{
var direction=(perpendicular*sign+toTarget*.3f).Normal;
if(!Reachable(WorldPosition+direction*radius,750,out var point,out var length))continue;
if((point-WorldPosition).Length<100 || (point-LastKnownPlayerPosition).Length<110)continue;
var space=Scene.Trace.Sphere(18,point+Vector3.Up*25,point+Vector3.Up*48).IgnoreGameObjectHierarchy(GameObject).Run();
if(space.Hit || Scene.GetAllComponents<ArenaOpponent>().Any(x=>x!=this &&
((x.WorldPosition-point).Length<85 || (x.Flanking && (x.flankDestination-point).Length<120))))continue;
var lane=Scene.Trace.Ray(point+Vector3.Up*55,LastKnownPlayerPosition+Vector3.Up*40)
.IgnoreGameObjectHierarchy(GameObject).IgnoreGameObjectHierarchy(target.GameObject).WithoutTags("paintball_debris").Run();
float cost=length+(lane.Hit ? 180 : 0)+Game.Random.Float(0,60);
if(cost>=best)continue;
best=cost;selected=point;
}
if(best==float.MaxValue){flankCooldown=2;return false;}
flankDestination=selected;flankRemaining=6;flankCooldown=Game.Random.Float(7,10);FlankSelections++;
return true;
}
private bool SearchLastContact()
{
if(!hasSeenPlayer || SeesPlayer || lostSight<4)return false;
if(searchRemaining<=0)
{
searchOrigin=LastKnownPlayerPosition;searchStep=0;searchPause=0;searchRemaining=6;SearchesStarted++;
if(!Reachable(searchOrigin,1600,out searchDestination,out _))searchDestination=WorldPosition;
}
if((searchDestination-WorldPosition).WithZ(0).Length>35){agent.MoveTo(searchDestination);return true;}
agent.Stop();searchPause+=BotCombatTuning.DecisionSeconds;
var direction=Rotation.FromYaw(WorldRotation.Angles().yaw+55).Forward;
impactLookDirection=direction;impactLookTime=.35f;
if(searchPause<.65f)return true;
searchPause=0;searchStep++;
if(searchStep>=3){hasSeenPlayer=false;searchRemaining=0;patrol=(patrol+1)%patrolPoints.Length;return false;}
var offset=Rotation.FromYaw(CharacterIndex*61+searchStep*137).Forward*(120+searchStep*60);
if(!Reachable(searchOrigin+offset,700,out searchDestination,out _))searchDestination=WorldPosition;
return true;
}
private void AdvanceNavigationRecovery(float delta)
{
navigationRetry=MathF.Max(0,navigationRetry-delta);
if(searchRemaining>0)
{
searchRemaining=MathF.Max(0,searchRemaining-delta);
if(searchRemaining==0){hasSeenPlayer=false;patrol=(patrol+1)%patrolPoints.Length;}
}
if(!agent.UpdatePosition || agent.MaxSpeed<=0 || UsingCover || CoverPeeking || navigationRetry>0){stalledTime=0;return;}
bool wantsTravel=agent.TargetPosition is Vector3 destination && (destination-WorldPosition).WithZ(0).Length>45;
stalledTime=wantsTravel && MovementSpeed<8 ? stalledTime+delta : 0;
if(stalledTime<1.5f)return;
NavigationRecoveries++;stalledTime=0;navigationRetry=2;
RememberCurrentCover();ReleaseTacticalCover();flankRemaining=0;flankCooldown=0;
if(hasSeenPlayer && TryStartFlank())agent.MoveTo(flankDestination);
else {patrol=(patrol+1)%patrolPoints.Length;agent.MoveTo(patrolPoints[patrol]);}
decisionTime=.5f;
}
}