Player/Bot/PlayerBotController.cs
using System.Threading;
/// <summary>
/// Takes control over a player.
/// </summary>
public partial class PlayerBotController : Component
{
[ConVar( "sbdm.bots.notarget" )]
public static bool NoTarget { get; set; } = false;
[RequireComponent]
public NavMeshAgent MeshAgent { get; set; }
[RequireComponent]
public PlayerController Controller { get; set; }
[RequireComponent]
public Player Player { get; set; }
[RequireComponent]
public PlayerInventory Inventory { get; set; }
protected override void OnEnabled()
{
base.OnEnabled();
// We handle our GOs position manually/via physics controller
MeshAgent.UpdatePosition = false;
MeshAgent.UpdateRotation = false;
MeshAgent.MaxSpeed = Controller.RunSpeed;
RunBehaviour();
}
private async void RunBehaviour()
{
if ( MeshAgent == null || IsProxy )
return;
while ( Scene.NavMesh.IsGenerating )
{
await Task.DelaySeconds( 1 );
}
while ( this.IsValid() && Enabled )
{
await RunSelector(
GameObject.EnabledToken,
Combat,
Roam,
Idle
);
}
}
private async Task<bool> Idle( CancellationToken token )
{
Log.Trace( $"Bot {GameObject} failed to perform any behaviour, idling for a bit" );
await Task.DelaySeconds( 2, token );
return true;
}
protected override void OnFixedUpdate()
{
if ( MeshAgent == null || IsProxy )
return;
SyncNavAgentWithPhysics();
FindEnemies();
}
private void SyncNavAgentWithPhysics()
{
if ( NoTarget )
{
Controller.WishVelocity = Vector3.Zero;
return;
}
Controller.WishVelocity = MeshAgent.WishVelocity;
// We may have some desync between agent and player position
// this can happen because the physics simulation takes precedence over the navmesh navigation
if ( WorldPosition.WithZ( 0 ).DistanceSquared( MeshAgent.AgentPosition.WithZ( 0 ) ) > MeshAgent.Radius * MeshAgent.Radius )
{
MeshAgent.SetAgentPosition( WorldPosition );
}
// Check z independently, we may are more lenient with desync in z
if ( MathF.Abs( WorldPosition.z - MeshAgent.AgentPosition.z ) > MeshAgent.Height * MeshAgent.Height )
{
MeshAgent.SetAgentPosition( WorldPosition );
}
}
private async Task<bool> TryDivertForConsumable(
CancellationToken token,
Vector3? mainTarget = null,
Predicate<BasePickup> pickupFilter = null )
{
// find nearby coffins first
var veryCloseCoffins = FindCoffinsInRadius( 200f );
var coffinTarget = veryCloseCoffins.FirstOrDefault();
while ( coffinTarget.IsValid() && !token.IsCancellationRequested )
{
MeshAgent.MoveTo( coffinTarget.WorldPosition );
await Task.FixedUpdate();
}
if ( token.IsCancellationRequested )
return false;
bool IsConsumableAndInLOS( BasePickup pickup )
{
if ( pickup is not AmmoPickup && pickup is not HealthPickup && pickup is not ArmourPickup )
return false;
if ( pickupFilter != null && !pickupFilter( pickup ) )
{
return false;
}
var trace = Scene.Trace.Ray( WorldPosition, pickup.WorldPosition + Vector3.Up * 5f )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
return !trace.Hit || trace.GameObject == pickup.GameObject;
}
var veryCloseConsumables = FindPickupsInRadius<BasePickup>( 400f, IsConsumableAndInLOS );
var consumablePickupTarget = veryCloseConsumables.FirstOrDefault();
if ( consumablePickupTarget == null )
return false;
var consumablePickupPosition = GetClosestPointOnPickupToNavMesh( consumablePickupTarget, MeshAgent.Radius );
var mainTargetCloser = mainTarget.HasValue && consumablePickupTarget.WorldPosition.DistanceSquared( WorldPosition ) > mainTarget.Value.DistanceSquared( WorldPosition );
while ( IsPickupValid( consumablePickupTarget ) && !token.IsCancellationRequested && !mainTargetCloser )
{
mainTargetCloser = mainTarget.HasValue && consumablePickupTarget.WorldPosition.DistanceSquared( WorldPosition ) > mainTarget.Value.DistanceSquared( WorldPosition );
MeshAgent.MoveTo( consumablePickupPosition );
await Task.FixedUpdate();
}
if ( token.IsCancellationRequested )
return false;
// Recursively attempt to pick up other nearby consumables
if ( !mainTargetCloser )
{
await TryDivertForConsumable( token, mainTarget );
}
return true;
}
private Player _currentTarget;
private TimeSince _timeSinceTargetSwitch = 0;
private Player FindAndUpdateTarget()
{
// select target
var shouldTrySwitchTarget = _timeSinceTargetSwitch > 5.0f || !IsCurrentTargetValid();
if ( shouldTrySwitchTarget )
{
if ( _relevantEnemies.Count > 0 )
{
_timeSinceTargetSwitch = 0;
// pick last seen enemy
_currentTarget = _relevantEnemies.OrderBy( x => _lastSeenEnemies[x].Relative ).First();
}
}
if ( !IsCurrentTargetValid() )
{
_currentTarget = null;
}
return _currentTarget;
}
bool IsCurrentTargetValid()
{
if ( !_currentTarget.IsValid() ) return false;
if ( !_relevantEnemies.Contains( _currentTarget ) ) return false;
if ( !_lastSeenEnemies.TryGetValue( _currentTarget, out TimeSince value ) || value > 5f ) return false;
if ( _currentTarget.Health <= 0 ) return false;
return true;
}
private HashSet<Player> _relevantEnemies = new();
private Dictionary<Player, TimeSince> _lastSeenEnemies = new();
private void FindEnemies()
{
var allPlayers = Scene.GetAllComponents<Player>();
// Filter out players not in LOS
var playersInLOS = allPlayers.Where( potentialEnemy =>
{
var basicFilter = potentialEnemy != Player && potentialEnemy.Health >= 0 && potentialEnemy.WorldPosition.DistanceSquared( WorldPosition ) < 750f * 750f;
if ( !basicFilter ) return false;
var trace = Scene.Trace.Ray( Controller.EyePosition, potentialEnemy.Controller.EyePosition )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
return trace.Hit && trace.GameObject == potentialEnemy.GameObject;
} ).ToHashSet();
// Update relevant enemies and last seen times
foreach ( var enemy in playersInLOS )
{
_lastSeenEnemies[enemy] = 0;
_relevantEnemies.Add( enemy );
}
// Remove enemies not seen for over 5 seconds
foreach ( var enemy in _lastSeenEnemies )
{
if ( enemy.Value > 5 )
{
_relevantEnemies.Remove( enemy.Key );
}
}
}
private bool IsPickupValid( BasePickup pickup )
{
return pickup.IsValid() && pickup.IsPickupEnabled && pickup.CanPickup( Player, Inventory );
}
/// <summary>
/// This looks more complicated that it is.
/// Basically we just want to find the point of the pickup collider that is closest to the navmesh.
/// </summary>
Vector3 GetClosestPointOnPickupToNavMesh( BasePickup pickup, float checkRadius )
{
// Check if the pickup's position is directly on the navmesh
var navMeshPoint = Scene.NavMesh.GetClosestPoint( pickup.WorldPosition, checkRadius );
if ( navMeshPoint.HasValue && (navMeshPoint.Value - pickup.WorldPosition).LengthSquared < checkRadius * checkRadius )
{
// Fast path: Return the pickup's position if it's close to the navmesh
return pickup.WorldPosition;
}
var localBounds = pickup.Collider.LocalBounds;
var localCorners = localBounds.Corners;
Vector3 closestPoint = pickup.WorldPosition;
float minDistanceSquared = float.MaxValue;
foreach ( var localPoint in localCorners )
{
var worldPoint = pickup.WorldTransform.PointToLocal( localPoint );
navMeshPoint = Scene.NavMesh.GetClosestPoint( worldPoint, checkRadius );
if ( navMeshPoint.HasValue )
{
float distanceSquared = (worldPoint - navMeshPoint.Value).LengthSquared;
if ( distanceSquared < minDistanceSquared )
{
minDistanceSquared = distanceSquared;
closestPoint = worldPoint;
}
}
}
return closestPoint;
}
bool IsPickupReachable( BasePickup pickup )
{
// Avoid targeting pickups that are not on the navmesh or are unreachable
float checkRadius = MeshAgent.Radius;
// Find the point on the pickup closest to the navmesh
Vector3 closestPointOnPickup = GetClosestPointOnPickupToNavMesh( pickup, checkRadius );
return IsPositionReachable( closestPointOnPickup );
}
bool IsPositionReachable( Vector3 Position )
{
float checkRadius = MeshAgent.Radius;
var navMeshPoint = Scene.NavMesh.GetClosestPoint( Position, checkRadius );
// If there's no valid navmesh point near the pickup, it's unreachable
if ( !navMeshPoint.HasValue || (navMeshPoint.Value - Position).LengthSquared > checkRadius * checkRadius )
{
return false;
}
var path = Scene.NavMesh.CalculatePath( new Sandbox.Navigation.CalculatePathRequest()
{
Agent = MeshAgent,
Start = WorldPosition,
Target = navMeshPoint.Value
} );
if ( path.Status != Sandbox.Navigation.NavMeshPathStatus.Complete )
{
return false;
}
// Check if the end of the path is close enough to the navmesh point near the pickup
if ( (path.Points.Last().Position - navMeshPoint.Value).LengthSquared > checkRadius * checkRadius )
{
return false;
}
return true;
}
IEnumerable<T> FindPickupsInRadius<T>( float radius, Predicate<T> pred ) where T : BasePickup
{
return Scene.GetAll<T>()
.Where(
x => x.IsValid() &&
x.IsPickupEnabled && x.CanPickup( Player, Inventory ) &&
x.WorldPosition.DistanceSquared( WorldPosition ) < radius * radius &&
pred( x ) &&
IsPickupReachable( x )
)
.OrderBy( x => x.WorldPosition.DistanceSquared( WorldPosition ) );
}
IEnumerable<Coffin> FindCoffinsInRadius( float radius )
{
return
Scene.GetAll<Coffin>()
.Where(
x => x.IsValid() &&
x.WorldPosition.DistanceSquared( WorldPosition ) < radius * radius &&
IsPositionReachable( x.WorldPosition )
)
.OrderBy( x => x.WorldPosition.DistanceSquared( WorldPosition ) );
}
/// <summary>
/// Executes tasks in sequence until one returns true.
/// </summary>
private async Task<bool> RunSelector( CancellationToken token, params Func<CancellationToken, Task<bool>>[] tasks )
{
foreach ( var task in tasks )
{
if ( token.IsCancellationRequested )
return false;
if ( await task( token ) )
{
return true;
}
}
return false;
}
/// <summary>
/// Executes tasks in parallel with different cancellation/success modes.
/// </summary>
private async Task<bool> RunParallel( CancellationToken externalToken, params Func<CancellationToken, Task<bool>>[] tasks )
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource( externalToken );
var token = cts.Token;
var taskList = tasks.Select( task => task( token ) ).ToList();
// Await the first task to complete
var firstCompletedTask = await Task.WhenAny( taskList );
// Cancel the other tasks
cts.Cancel();
try
{
// Get the result of the first completed task
bool result = await firstCompletedTask;
// This ensures that all task exceptions are observed
await Task.WhenAll( taskList );
return result;
}
catch ( OperationCanceledException )
{
// Handle task cancellation
return firstCompletedTask.Result;
}
catch ( Exception )
{
// Handle any exceptions from the tasks
return firstCompletedTask.Result;
}
}
}