OpponentCoverPlanner.cs
using Sandbox;
using Sandbox.Navigation;
namespace PaintballBaddies;
/// <summary>Find a physically sheltered destination relative to an observed threat.</summary>
public static class OpponentCoverPlanner
{
public static CoverSurface ShelteringSurface(GameObject actor,GameObject threat,Vector3 origin,Vector3 position)
{
var hit=actor.Scene.Trace.Ray(origin+Vector3.Up*55,position+Vector3.Up*30)
.IgnoreGameObjectHierarchy(actor).IgnoreGameObjectHierarchy(threat).Run();
return hit.GameObject?.Components.Get<CoverSurface>();
}
public static bool IsOccupied(GameObject actor,CoverSurface surface)
{
if(!surface.IsValid())return false;
return actor.Scene.GetAllComponents<CoverController>().Any(x=>x.GameObject!=actor && x.ReservedSurface==surface)
|| actor.Scene.GetAllComponents<ArenaOpponent>().Any(x=>x.GameObject!=actor && x.GameObject.Active
&& x.Components.Get<PaintballCombatant>()?.Eliminated!=true && x.HasCoverPlan && x.ReservedCover==surface);
}
public static bool IsSheltered(GameObject actor, GameObject threatActor, Vector3 observedThreat, Vector3 position)
{
var shield=actor.Scene.Trace.Ray(observedThreat+Vector3.Up*55,position+Vector3.Up*30)
.IgnoreGameObjectHierarchy(actor).IgnoreGameObjectHierarchy(threatActor).Run();
return shield.Hit && shield.GameObject.Components.Get<CoverSurface>() is not null;
}
public static bool TryPeek(GameObject actor,GameObject threatActor,Vector3 threat,Vector3 shelter,out Vector3 peek,out bool low)
{
peek=shelter;low=false;
var shield=actor.Scene.Trace.Ray(threat+Vector3.Up*55,shelter+Vector3.Up*30)
.IgnoreGameObjectHierarchy(actor).IgnoreGameObjectHierarchy(threatActor).Run();
var surface=shield.GameObject?.Components.Get<CoverSurface>();
if(!surface.IsValid())return false;
low=surface.Top-shelter.z<64;
var away=(shelter-threat).WithZ(0).Normal;
var side=Vector3.Cross(Vector3.Up,away);
var candidates=low ? new[]{shelter,shelter+side*44,shelter-side*44} : new[]{shelter+side*55,shelter-side*55,shelter+side*88,shelter-side*88};
foreach(var candidate in candidates)
{
// Match the destination to the engine's walkable surface before
// checking the firing lane; MoveTo otherwise silently stops short.
var grounded=actor.Scene.NavMesh.GetClosestPoint(candidate,32);
if(grounded is not Vector3 point || System.MathF.Abs(point.z-shelter.z)>8)continue;
var route=actor.Scene.Trace.Sphere(16,shelter+Vector3.Up*50,point+Vector3.Up*50).IgnoreGameObjectHierarchy(actor).Run();
if(route.Hit)continue;
var floor=actor.Scene.Trace.Ray(point+Vector3.Up*12,point-Vector3.Up*24).IgnoreGameObjectHierarchy(actor).Run();
if(!floor.Hit||floor.Normal.z<.8f)continue;
var line=actor.Scene.Trace.Sphere(2,point+Vector3.Up*46,threat+Vector3.Up*40)
.IgnoreGameObjectHierarchy(actor).IgnoreGameObjectHierarchy(threatActor).WithoutTags("paintball_debris").Run();
if(line.Hit)continue;
peek=point;return true;
}
return false;
}
public static bool TryFind(GameObject actor,GameObject threatActor,Vector3 observedThreat,out Vector3 destination,bool requirePeek=true,bool relocating=false)
{
destination=default;float best=float.MaxValue;
var bot=actor.Components.Get<ArenaOpponent>();
if(actor.Scene.NavMesh.IsGenerating)return false;
foreach(var surface in actor.Scene.GetAllComponents<CoverSurface>())
{
if(!surface.Enabled || !surface.GameObject.Enabled || IsOccupied(actor,surface))continue;
if(bot?.RecentlyUsed(surface)==true || (relocating && bot?.ReservedCover==surface))continue;
var away=(surface.WorldPosition-observedThreat).WithZ(0).Normal;
if(away.Length<.9f)continue;
var centre=surface.WorldPosition.WithZ(actor.WorldPosition.z+28);
var face=actor.Scene.Trace.Ray(centre+away*160,centre).IgnoreGameObjectHierarchy(actor).Run();
if(!face.Hit || face.GameObject!=surface.GameObject)continue;
var point=(face.EndPosition+away*25).WithZ(actor.WorldPosition.z);
var floor=actor.Scene.Trace.Ray(point+Vector3.Up*20,point-Vector3.Up*40).IgnoreGameObjectHierarchy(actor).Run();
if(!floor.Hit || floor.Normal.z<.8f)continue;
point=floor.EndPosition+Vector3.Up;
var grounded=actor.Scene.NavMesh.GetClosestPoint(point,32);
if(grounded is not Vector3 reachable || System.MathF.Abs(reachable.z-point.z)>8)continue;
point=reachable;
if(surface.Top-point.z<37)continue;
var body=actor.Scene.Trace.Sphere(15,point+Vector3.Up*20,point+Vector3.Up*21).IgnoreGameObjectHierarchy(actor).Run();
if(body.Hit)continue;
var shield=actor.Scene.Trace.Ray(observedThreat+Vector3.Up*55,point+Vector3.Up*30)
.IgnoreGameObjectHierarchy(actor).IgnoreGameObjectHierarchy(threatActor).Run();
if(!shield.Hit || shield.GameObject!=surface.GameObject)continue;
// Choose cover that also offers a reachable firing position, avoiding
// repeated trips into a blind pocket followed by immediate retreat.
if(requirePeek && !TryPeek(actor,threatActor,observedThreat,point,out _,out _))continue;
bool occupied=false;
foreach(var other in actor.Scene.GetAllComponents<ArenaOpponent>())
{
if(other.Components.Get<PaintballCombatant>()?.Eliminated==true)continue;
if(other.GameObject!=actor && ((other.WorldPosition-point).WithZ(0).Length<65
|| (other.HasCoverPlan && (other.CoverDestination-point).WithZ(0).Length<65)))occupied=true;
}
float distance=(point-actor.WorldPosition).WithZ(0).Length;
if(occupied || distance>(relocating ? 720 : 550) || (relocating && distance<140))continue;
var path=actor.Scene.NavMesh.CalculatePath(new CalculatePathRequest{
Start=actor.WorldPosition,Target=point,Agent=actor.Components.Get<NavMeshAgent>()});
if(path.Status!=NavMeshPathStatus.Complete || path.Points.Count<1)continue;
float routeLength=0,exposure=0;
var previous=actor.WorldPosition;
foreach(var step in path.Points)
{
routeLength+=(step.Position-previous).Length;
if(relocating || bot?.EvadingAfterHit==true)
{
var sample=(step.Position+previous)*.5f+Vector3.Up*38;
var sight=actor.Scene.Trace.Ray(observedThreat+Vector3.Up*55,sample)
.IgnoreGameObjectHierarchy(actor).IgnoreGameObjectHierarchy(threatActor).Run();
if(!sight.Hit)exposure+=(step.Position-previous).Length;
}
previous=step.Position;
}
if(routeLength>(relocating ? 1000 : 650))continue;
// Prefer shorter protected routes, but vary comparable choices and
// reward a new firing angle rather than another spot on one bunker.
float angleChange=1-Vector3.Dot((actor.WorldPosition-observedThreat).WithZ(0).Normal,
(point-observedThreat).WithZ(0).Normal);
float cost=distance+routeLength*.35f+exposure*.55f-(relocating ? angleChange*90 : 0)+Game.Random.Float(0,65);
// Avoid sheltering from one observed opponent while exposing our
// back to the other visible contestants. No unseen positions used.
if(bot is not null)
foreach(var threat in bot.VisibleThreats)
{
if((threat-observedThreat).Length<60)continue;
var danger=actor.Scene.Trace.Ray(threat+Vector3.Up*55,point+Vector3.Up*30)
.IgnoreGameObjectHierarchy(actor).WithoutTags("paintball_debris","paintball_actor").Run();
if(!danger.Hit)cost+=180;
}
if(cost>=best)continue;
best=cost;destination=point;
}
return best<float.MaxValue;
}
}