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;
		}
	}
}