CopBotAI.cs
using Sandbox;
using SWB.Base;
using SWB.Demo;
using SWB.Player;
using System;
using System.Collections.Generic;
using System.Linq;
// Sits alongside a DemoBot component on the same GameObject. DemoBot
// (from SWB) already gives us health, damage, death/respawn, and a
// weapon inventory for free — this component only adds the two things
// DemoBot doesn't: pathfinding movement and the decision to shoot.
//
// Key fact this relies on: Weapon.OnUpdate() skips its entire firing
// loop when Owner.IsBot is true, and CanShoot() bypasses the Input.Down
// checks for bots. That's SWB's intended hook for bot AI to drive a
// weapon directly instead of faking player input.
public sealed class CopBotAI : Component
{
[RequireComponent]
NavMeshAgent Agent { get; set; }
DemoBot _bot;
// Distance at which the cop stops closing in and just fights from
// where it's standing, instead of continuing to push toward the
// player. Tune this per-weapon later if needed.
[Property]
public float EngageRange { get; set; } = 800f;
[Property]
public float RetargetInterval { get; set; } = 0.3f;
// What cops are allowed to carry, as WeaponRegistry ClassNames. One is
// picked at random per spawn.
//
// SWB's demo player hands out ALL SIX weapons on respawn and DemoBot
// then picks one at random, so without this a cop might be holding
// anything.
//
// Pick by damage-per-second, not damage per shot - and note bots
// ignore semi-auto restrictions (Weapon.Shoot.cs bypasses the
// Input.Pressed check for them), so every weapon fires at its full
// RPM in a cop's hands. That makes the Colt (25 dmg @ 700 RPM) the
// single deadliest gun here, well above the ScarH. Veresk is the
// gentlest of the automatics; Revolver is slow enough to be fair.
[Property]
public List<string> WeaponPool { get; set; } = new() { "swb_veresk" };
// Multiplies the weapon's own damage for cops only. Applied to this
// cop's private clone of the weapon, so the same gun in the player's
// hands is untouched.
[Property]
public float DamageScale { get; set; } = 0.6f;
// Multiplies the weapon's own spread for cops only - higher is less
// accurate. Cops otherwise aim dead at your eye position every tick,
// which is far more precise than any human.
[Property]
public float SpreadScale { get; set; } = 2.5f;
// Shots fired before a cop pauses. Together with BurstPause this is
// the real damage-per-second dial - far more effective than DamageScale,
// because it controls how much lead is in the air rather than how much
// each round hurts.
[Property]
public int BurstShots { get; set; } = 3;
[Property]
public float BurstPause { get; set; } = 0.9f;
// Keep this in step with MissionSpawner.MinSpawnDistanceFromPlayers -
// they're the two halves of the same rule, one for new cops and one
// for cops coming back after being killed.
[Property]
public float MinSpawnDistanceFromPlayers { get; set; } = 2000f;
int _shotsThisBurst = 0;
TimeSince _timeSinceBurstEnded = 0;
// How long the cop must have had unbroken line of sight before it's
// allowed to fire - without this it snaps a shot off the instant LOS
// goes true, which reads as robotic/unfair rather than alert.
[Property]
public float ReactionTime { get; set; } = 0.2f;
TimeSince _timeSinceRetarget = 0;
GameObject _target;
bool _hadLineOfSight = false;
TimeSince _timeSinceLineOfSightAcquired;
// Whether we currently have a path request in flight toward _target -
// see the comment above the MoveTo() call for why this matters.
bool _isPathing = false;
// How far from the target to go looking for a navigable point when the
// target itself isn't standing on the navmesh. Roughly "how far below
// or beside you a cop will settle for". Too large and they'll path to
// somewhere unhelpfully far away; too small and they give up.
[Property]
public float NavSearchRadius { get; set; } = 512f;
// Pathing diagnostics. Solved the "cops far away don't move" bug (the
// destination wasn't on the navmesh - see NavigableDestination), so
// it's off by default now. Tick it on if cop movement ever looks wrong
// again; it logs what the agent is actually doing rather than leaving
// you to guess.
[Property]
public bool DebugPathing { get; set; } = false;
[Property]
public float DebugLogInterval { get; set; } = 1f;
TimeSince _timeSinceDebugLog = 0;
Vector3 _debugLastPosition;
bool _debugHasLastPosition = false;
// Tracks IsAlive across ticks so we can catch the dead-to-alive
// transition - see RelocateToPoliceSpawn().
bool _wasAlive = false;
protected override void OnAwake()
{
_bot = Components.Get<DemoBot>();
if ( _bot is null )
{
Log.Warning( $"{GameObject.Name}: CopBotAI requires a DemoBot component on the same GameObject." );
return;
}
// DemoBot/PlayerBase already drives this GameObject's actual
// position via its own CharacterController-based movement (it
// applies gravity and calls CharacterController.Move() every
// fixed update regardless of IsBot). NavMeshAgent's default
// behaviour is to ALSO directly overwrite the GameObject's
// position every frame — two systems fighting over the same
// Transform, which is exactly what sent bots flying out of the
// map. Turning these off makes the agent a pure pathfinder: we
// read its desired direction and feed it into the bot's own
// WishVelocity instead, so PlayerBase's proven movement code
// (gravity, ground snapping, collision) does the actual moving.
Agent.UpdatePosition = false;
Agent.UpdateRotation = false;
}
protected override void OnFixedUpdate()
{
if ( _bot is null )
return;
if ( _bot.IsAlive && !_wasAlive )
{
RelocateToPoliceSpawn();
ApplyCopLoadout();
}
_wasAlive = _bot.IsAlive;
if ( !_bot.IsAlive )
return;
// Keep the agent's internal pathing state in sync with wherever
// the CharacterController actually put us this tick.
Agent.SetAgentPosition( WorldPosition );
// Runs every tick regardless of target/LOS state, so a cop that
// loses sight of its target mid-reload still finishes reloading
// instead of getting stuck (see MaintainWeapon for why this has
// to be driven manually at all).
MaintainWeapon();
var justRetargeted = false;
if ( _timeSinceRetarget >= RetargetInterval )
{
_timeSinceRetarget = 0;
_target = FindNearestPlayer();
justRetargeted = true;
}
if ( _target is null )
{
_bot.WishVelocity = Vector3.Zero;
Agent.Stop();
_isPathing = false;
return;
}
var toTarget = _target.WorldPosition - WorldPosition;
var distance = toTarget.Length;
var hasLineOfSight = HasLineOfSight( _target );
if ( hasLineOfSight && !_hadLineOfSight )
_timeSinceLineOfSightAcquired = 0;
_hadLineOfSight = hasLineOfSight;
// Aim wherever we can see the target — this is what actually
// steers the weapon's fire direction (Shoot() reads EyeAngles).
// Aiming snaps on immediately (looks alert); only firing itself
// waits out ReactionTime below.
//
// Aim from our eyes at THEIR eyes, matching the line
// HasLineOfSight just verified. Using the root positions instead
// would aim feet-to-feet while firing from head height, which
// skews the shot - badly so when there's any height difference
// between the two.
if ( hasLineOfSight )
{
var aimAt = TargetEyePos( _target );
_bot.EyeAngles = Rotation.LookAt( aimAt - _bot.EyePos ).Angles();
}
// Close the distance until in engage range, or chase if we've
// lost sight of them. Otherwise hold position and fight.
// Agent.WishVelocity is the agent's own speed-scaled desired
// velocity along its calculated path — same field a human
// player's input would populate, just sourced from pathing
// instead of Input.AnalogMove.
var chasing = distance > EngageRange || !hasLineOfSight;
var usedFallback = false;
if ( chasing )
{
// Only request a new path when we don't have one yet or on the
// same throttled cadence as retargeting, rather than every
// fixed update - then keep following the path already in
// progress the rest of the time.
if ( !_isPathing || justRetargeted )
{
Agent.MoveTo( NavigableDestination( _target.WorldPosition ) );
_isPathing = true;
}
var pathVelocity = Agent.WishVelocity;
// Last resort when there's no usable path at all: walk straight
// at the target so a cop never just stands there. Normal
// collision still applies, so this gets stuck on walls - it's a
// floor, not a solution.
usedFallback = pathVelocity.IsNearZeroLength;
_bot.WishVelocity = usedFallback
? toTarget.WithZ( 0 ).Normal * _bot.RunSpeed
: pathVelocity;
}
else
{
Agent.Stop();
_bot.WishVelocity = Vector3.Zero;
_isPathing = false;
}
// Firing is independent of movement, so this naturally covers
// Moving-only (out of range/no LOS, handled above by not
// calling TryShoot), Shooting-only (stopped + LOS), and
// MovingAndShooting (still closing distance + already has LOS).
if ( hasLineOfSight && _timeSinceLineOfSightAcquired >= ReactionTime )
TryShoot();
if ( DebugPathing )
LogPathingState( distance, chasing, hasLineOfSight, usedFallback );
}
// Dumps everything the agent is reporting, once per DebugLogInterval.
// The two numbers that matter most:
//
// moved= how far this cop ACTUALLY travelled in world space since
// the last log. Ground truth - if this is ~0 while chasing,
// it's genuinely stuck regardless of what anything else says.
//
// agentOff= distance between where the cop really is and where the
// NavMeshAgent thinks it is. Should be ~0 because we call
// SetAgentPosition every tick. If it's large, the agent is
// pathing from the wrong place entirely - and a spawn point
// sitting slightly off the navmesh would do exactly that
// while still looking fine in the editor's path checker.
void LogPathingState( float distance, bool chasing, bool hasLineOfSight, bool usedFallback )
{
if ( _timeSinceDebugLog < DebugLogInterval )
return;
var moved = _debugHasLastPosition ? WorldPosition.Distance( _debugLastPosition ) : 0f;
_debugLastPosition = WorldPosition;
_debugHasLastPosition = true;
_timeSinceDebugLog = 0;
var agentOffset = Agent.AgentPosition.Distance( WorldPosition );
var target = Agent.TargetPosition.HasValue ? Agent.TargetPosition.Value.ToString() : "none";
Log.Info(
$"[cop] {GameObject.Name} " +
$"dist={distance:0} " +
$"chasing={chasing} " +
$"los={hasLineOfSight} " +
$"moved={moved:0.0} " +
$"agentOff={agentOffset:0.0} " +
$"navigating={Agent.IsNavigating} " +
$"agentWish={Agent.WishVelocity.Length:0.0} " +
$"agentVel={Agent.Velocity.Length:0.0} " +
$"botWish={_bot.WishVelocity.Length:0.0} " +
$"fallback={usedFallback} " +
$"syncPos={Agent.SyncAgentPosition} " +
$"tgt={target}" );
}
// PlayerBase.Respawn() sends a cop to a random generic SpawnPoint
// whenever it's called with no explicit transform - which is every
// automatic call PlayerBase makes itself: OnStart()'s initial spawn
// and OnDeath()'s 2-second delayed respawn. Trying to preempt each of
// those calls individually is fragile (each is a different async path
// with its own timing - that's what caused the original spawn-race
// bug). Reacting to the result instead is robust: any time this cop
// flips from dead to alive, Respawn() has already fully finished, so
// correcting the position here can never lose a race, and it covers
// every respawn path at once instead of needing a patch per call site.
void RelocateToPoliceSpawn()
{
// Same no-spawn perimeter the wave spawner uses. This path matters
// just as much: cops die constantly during a job, so without it
// the same cop would keep popping back into existence next to you
// however careful the initial spawn was.
var point = PoliceSpawns.Pick( Scene, MinSpawnDistanceFromPlayers );
if ( point is null )
{
Log.Warning( $"{GameObject.Name}: no GameObjects tagged 'police_spawn' found in the scene." );
return;
}
WorldPosition = point.WorldPosition;
WorldRotation = point.WorldRotation;
Agent.SetAgentPosition( WorldPosition );
}
// Replaces whatever DemoBot handed this cop with a single weapon from
// WeaponPool, then tones it down.
//
// Runs on every spawn, which matters: Inventory.Clear() destroys the
// old weapon and AddClone makes a fresh one from the registry's master
// copy, so the scales below always apply to unmodified base values
// rather than compounding on an already-scaled weapon.
void ApplyCopLoadout()
{
_shotsThisBurst = 0;
if ( _bot.Inventory is null || WeaponPool.Count == 0 )
return;
// DemoBot already handed this cop every weapon and picked one at
// random. Rather than tearing the inventory down and rebuilding it
// (which churns networked weapon objects mid-spawn), just choose
// which of the ones it already has becomes active. Items are named
// by ClassName, which is what SetActive matches on.
var className = WeaponPool[Random.Shared.Next( WeaponPool.Count )];
_bot.Inventory.SetActive( className );
var weapon = GetActiveWeapon();
if ( weapon?.Primary is null )
return;
if ( weapon.ClassName != className )
{
Log.Warning( $"{GameObject.Name}: '{className}' isn't in this bot's inventory - check it's spelled like a WeaponRegistry ClassName." );
return;
}
// Safe to scale in place every spawn: PlayerBase.Respawn() clears
// the inventory and the demo player re-clones fresh weapons from
// the registry each time, so these always start from base values
// rather than compounding on an already-scaled weapon. It's also
// this cop's own clone - the player's copy of the same gun is a
// separate instance and keeps its real numbers.
// Job difficulty stacks on top of the per-cop tuning, so the
// prefab keeps showing the baseline rather than whatever the last
// job scaled it to.
weapon.Primary.Damage *= DamageScale * ActiveMission.CopDamageScale;
weapon.Primary.Spread *= SpreadScale;
}
// Weapon.OnUpdate() only ever calls Reload() and only ever completes
// one (OnReloadFinish/OnShellReloadFinish) inside its human-input
// branch, which is skipped entirely when Owner.IsBot is true. So a bot
// has to drive both halves itself: start the reload when empty, and
// finish it once the timer's elapsed - otherwise IsReloading gets
// stuck true forever and CanShoot() fails permanently even after
// Reload() "worked".
void MaintainWeapon()
{
var weapon = GetActiveWeapon();
if ( weapon is null )
return;
if ( weapon.IsReloading )
{
if ( weapon.TimeSinceReload >= 0 )
{
if ( weapon.ShellReloading )
weapon.OnShellReloadFinish();
else
weapon.OnReloadFinish();
}
return;
}
if ( !weapon.HasAmmo() )
weapon.Reload();
}
void TryShoot()
{
var weapon = GetActiveWeapon();
if ( weapon is null || weapon.IsReloading )
{
_shotsThisBurst = 0;
return;
}
// Trigger discipline. Without this a cop holds the trigger down
// forever, which reads as a laser beam rather than someone
// shooting at you. Also gives the player a window to move.
if ( _shotsThisBurst >= BurstShots )
{
if ( _timeSinceBurstEnded < BurstPause )
return;
_shotsThisBurst = 0;
}
if ( !weapon.CanPrimaryShoot() )
return;
// THE rate limiter. CanShoot() gates firing on
// TimeSincePrimaryShoot having exceeded the weapon's RPM interval,
// and Weapon.OnUpdate resets this to 0 on every shot it fires -
// but OnUpdate skips that whole branch for bots, so driving
// Shoot() directly means resetting it here too.
//
// Leaving it unreset (as this did originally) means the timer
// stays huge, CanShoot always passes, and the cop fires once per
// tick - about 60 shots a second whatever the weapon's RPM says.
// A Colt at 25 damage becomes ~1500 DPS and empties its magazine
// into you in a tenth of a second.
weapon.TimeSincePrimaryShoot = 0;
weapon.Shoot( weapon.Primary, true );
_shotsThisBurst++;
if ( _shotsThisBurst >= BurstShots )
_timeSinceBurstEnded = 0;
}
Weapon GetActiveWeapon()
{
var weaponGO = _bot.Inventory?.Active;
return weaponGO?.GetComponent<Weapon>();
}
// NavMeshAgent.MoveTo() fails outright if the destination isn't ON the
// navmesh - it yields no velocity at all, which is what used to freeze
// every cop whenever the player stood somewhere the mesh doesn't cover
// (a roof, a ledge, an upper floor). Worse, the engine burns a long
// exhaustive search failing to find that path.
//
// Scene.NavMesh.GetClosestPoint snaps a world position to the nearest
// navigable point within a radius, so cops path to the reachable spot
// closest to you - the floor below the ledge you're on - instead of
// giving up. Null means there's nothing navigable nearby at all (or
// the navmesh hasn't generated yet), in which case we hand back the
// raw position and let the straight-line fallback deal with it.
Vector3 NavigableDestination( Vector3 worldPos )
{
return Scene.NavMesh?.GetClosestPoint( worldPos, NavSearchRadius ) ?? worldPos;
}
// target.WorldPosition is the root transform, which sits at the
// player's feet, not eye/chest height. Both line-of-sight and aiming
// want the eye position instead - it's the height a player judges
// their own exposure by, and it keeps the two consistent so a cop
// never fires along a line it didn't actually check.
Vector3 TargetEyePos( GameObject target )
{
return target.Components.Get<PlayerBase>()?.EyePos ?? target.WorldPosition;
}
bool HasLineOfSight( GameObject target )
{
var targetPos = TargetEyePos( target );
var tr = Scene.Trace.Ray( _bot.EyePos, targetPos )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
// No hit at all means a clear line; otherwise the trace needs
// to have stopped ON the target rather than something in front
// of it (a wall, another cop, etc).
return !tr.Hit || tr.GameObject?.Root == target.Root;
}
// Nearest player still in the run. Anyone eliminated or escaped is
// invisible and intangible, so shooting at them would just look like
// cops firing at nothing - Crew handles that filtering.
//
// Written as a plain loop rather than OrderBy().First(): every cop on
// the map runs this several times a second, and sorting the whole list
// to take one element allocates an array and a comparer each time for
// no benefit. Comparing squared distances also skips a square root per
// player, which is safe because squaring preserves ordering.
GameObject FindNearestPlayer()
{
GameObject nearest = null;
var nearestSqr = float.MaxValue;
foreach ( var player in Crew.ActivePlayers( Scene ) )
{
var distanceSqr = player.WorldPosition.DistanceSquared( WorldPosition );
if ( distanceSqr >= nearestSqr )
continue;
nearestSqr = distanceSqr;
nearest = player;
}
return nearest;
}
}