BotController.cs
using Sandbox;
/// <summary>Host-controlled deathmatch opponent using the map's generated navmesh.</summary>
public sealed class BotController : Component
{
[Property] public float MoveSpeed { get; set; } = 180f;
[Property] public float SightRange { get; set; } = 1600f;
[Property] public float AttackRange { get; set; } = 900f;
[Property] public float ReactionTime { get; set; } = 0.75f;
[Property] public float AimSpread { get; set; } = 4f;
private Player _player;
private PlayerCharacterController _controller;
private NavMeshAgent _agent;
private Player _target;
private Gun _weapon;
private GrabPoint _weaponGrip;
private float _nextThink;
private float _nextRoam;
private float _canAttackAt;
private float _nextEquipAttempt;
private Vector3 _lastProgressPosition;
private float _lastProgressTime;
private Vector3 EyePosition => WorldPosition + Vector3.Up * 58f;
private static Vector3 AimPosition( Player player ) => player.WorldPosition + Vector3.Up * 44f;
protected override void OnStart()
{
_player = GetComponent<Player>();
_controller = GetComponent<PlayerCharacterController>();
if ( !Networking.IsHost )
return;
_agent = GameObject.AddComponent<NavMeshAgent>( startEnabled: false );
_agent.Flags |= ComponentFlags.NotNetworked;
_agent.Height = 64f;
_agent.Radius = 16f;
_agent.MaxSpeed = MoveSpeed;
_agent.Acceleration = 800f;
_agent.UpdatePosition = true;
_agent.UpdateRotation = false;
_agent.Enabled = true;
_controller.PlayerLookRotation = WorldRotation;
_lastProgressPosition = WorldPosition;
_lastProgressTime = Time.Now;
EquipWeapon();
}
protected override void OnUpdate()
{
if ( !Networking.IsHost || !_agent.IsValid() )
return;
if ( _player.Health.IsDead || NetworkManager.Instance is not { IsMapReady: true }
|| !Scene.NavMesh.IsEnabled || Scene.NavMesh.IsGenerating
|| DeathmatchGame.Instance is not { State: MatchState.IN_PROGRESS } )
{
_agent.Stop();
_agent.Enabled = !_player.Health.IsDead;
if ( _weaponGrip.IsValid() && _weaponGrip.Holder == _player )
_weaponGrip.SetTriggerState( false );
_controller.SyncedVelocity = Vector3.Zero;
_target = null;
return;
}
_controller.SyncedVelocity = _agent.Velocity;
if ( (!_weapon.IsValid() || !_weaponGrip.IsValid() || !_weaponGrip.IsGrabbed || _weaponGrip.Holder != _player)
&& Time.Now >= _nextEquipAttempt )
{
_nextEquipAttempt = Time.Now + 1f;
EquipWeapon();
}
if ( Time.Now >= _nextThink || (_target is not null && !CanTarget( _target )) )
{
_nextThink = Time.Now + 0.25f;
Think();
}
var facing = CanTarget( _target )
? AimPosition( _target ) - EyePosition
: _agent.Velocity;
if ( facing.WithZ( 0f ).Length > 1f )
{
WorldRotation = Rotation.Slerp( WorldRotation, Rotation.LookAt( facing.WithZ( 0f ) ), Time.Delta * 8f );
_controller.PlayerLookRotation = WorldRotation;
if ( _controller.AnimationLookAtTarget.IsValid() )
_controller.AnimationLookAtTarget.WorldPosition = EyePosition + facing.Normal * 250f;
}
UpdateWeaponInput();
}
private void UpdateWeaponInput()
{
if ( !_weapon.IsValid() || !_weaponGrip.IsValid() || !_weaponGrip.IsGrabbed || _weaponGrip.Holder != _player )
return;
if ( _weapon.IsEmpty )
_weaponGrip.Reload();
var wantsToFire = false;
if ( CanTarget( _target ) && Time.Now >= _canAttackAt )
{
var toTarget = AimPosition( _target ) - EyePosition;
if ( toTarget.Length <= AttackRange && Vector3.Dot( WorldRotation.Forward, toTarget.Normal ) >= 0.9f && CanSee( _target ) )
{
var aim = Rotation.LookAt( toTarget ).Angles();
aim.pitch += Game.Random.Float( -AimSpread, AimSpread );
aim.yaw += Game.Random.Float( -AimSpread, AimSpread );
_player.AimRay = new Ray( EyePosition, Rotation.From( aim ).Forward );
wantsToFire = _weapon.IsReadyToFire;
}
}
// Releasing during the weapon's cooldown also gives semi-auto weapons a
// fresh press. Their existing grip events decide how each trigger fires.
_weaponGrip.SetTriggerState( wantsToFire );
}
// Protected bots roam, and protected opponents are never valid combat targets.
private bool CanTarget( Player target ) =>
!_player.Health.IsInvulnerable
&& target.IsValid() && target != _player
&& target.Health.IsValid() && !target.Health.IsDead && !target.Health.IsInvulnerable;
private void Think()
{
var target = Scene.GetAll<Player>()
.Where( CanTarget )
.Where( p => p.WorldPosition.Distance( WorldPosition ) <= SightRange )
.OrderBy( p => p.WorldPosition.DistanceSquared( WorldPosition ) )
.FirstOrDefault( CanSee );
if ( target != _target )
{
_target = target;
_canAttackAt = Time.Now + ReactionTime;
if ( !_target.IsValid() )
_nextRoam = 0f;
}
if ( _target.IsValid() )
{
if ( WorldPosition.Distance( _target.WorldPosition ) < AttackRange * 0.65f )
{
_agent.Stop();
_lastProgressTime = Time.Now;
}
else
{
var destination = Scene.NavMesh.GetClosestPoint( _target.WorldPosition );
if ( destination.HasValue )
_agent.MoveTo( destination.Value );
}
}
else if ( Time.Now >= _nextRoam || !_agent.IsNavigating )
{
Roam();
}
if ( WorldPosition.Distance( _lastProgressPosition ) > 24f )
{
_lastProgressPosition = WorldPosition;
_lastProgressTime = Time.Now;
}
else if ( _agent.IsNavigating && Time.Now - _lastProgressTime > 4f )
{
Roam();
_lastProgressTime = Time.Now;
}
}
private void Roam()
{
_nextRoam = Time.Now + 6f;
var destination = Scene.NavMesh.GetRandomPoint( WorldPosition, 1200f );
if ( destination.HasValue )
_agent.MoveTo( destination.Value );
}
private bool CanSee( Player target )
{
var trace = Scene.Trace.Ray( EyePosition, AimPosition( target ) )
.UseHitboxes()
.WithoutTags( "dead", "ragdoll" )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
return !trace.Hit || trace.GameObject?.Components.GetInAncestorsOrSelf<Player>() == target;
}
private void EquipWeapon()
{
if ( _player.Inventory.Slots is null )
return;
_weaponGrip = null;
_weapon = null;
foreach ( var slot in _player.Inventory.Slots )
{
var item = slot.StoredItem;
if ( !item.IsValid() || !item.GetComponent<Gun>().IsValid() )
continue;
if ( !_player.Inventory.TryEquipToHand( item, out var grip ) )
continue;
_weaponGrip = grip;
_weapon = item.GetComponent<Gun>();
return;
}
}
}