CopBotAI.cs
using Sandbox;
using SWB.Base;
using SWB.Demo;
using SWB.Player;
using SWB.Shared;
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;
// How fast a cop can swing its aim, in degrees per second.
//
// Aim used to snap instantly onto the target, which had a nasty side
// effect: a cop could fire on the same tick it acquired you, with its
// muzzle still pointing at the wall it had just come around. Those
// rounds go into the scenery, and from the player's end that is
// indistinguishable from cops firing at nothing.
[Property]
public float TurnSpeed { get; set; } = 540f;
// How fast the visible model pivots, in degrees per second. Slower
// than TurnSpeed on purpose - a person swings their weapon onto a
// target before their feet catch up - but not by much, because the
// model is the only part of this the player can actually see.
//
// SWB's own body rotation is far slower than this and is what made
// cops appear not to react at all; see TurnBodyTowards.
[Property]
public float BodyTurnSpeed { get; set; } = 450f;
// How far off target the aim is still allowed to be when the trigger
// is pulled, in degrees. Nothing fires while the muzzle is further out
// than this, so a cop has to finish turning before it shoots.
//
// Not zero: a little slop is what keeps cops from being pinpoint, and
// it's also what lets them fire while strafing.
[Property]
public float MaxAimErrorDegrees { get; set; } = 8f;
// Beyond this, cops hold fire even with a clear line. A cop putting
// rounds on you from the far side of the map with an SMG is
// technically correct and reads as gunfire coming out of nowhere.
[Property]
public float MaxFireRange { get; set; } = 3000f;
// How much wider a cop's first rounds are than its settled accuracy.
// 5 means the opening shots of an engagement go five times further
// off than the ones a couple of seconds later.
//
// This is what gives you a beat to react to being shot at. It reads
// much better than the two alternatives: a perfect first shot is
// unfair, and permanent inaccuracy makes cops look like they aren't
// aiming at all.
[Property]
public float OpeningSpreadMultiplier { get; set; } = 5f;
// How long a cop needs to hold sight on you before its fire is fully
// settled. Break line of sight and it starts over.
[Property]
public float AimSettleTime { get; set; } = 1.5f;
// This cop's fully-settled spread, taken from the weapon registry at
// spawn. The live value on the weapon is rewritten every shot.
float _settledSpread = 0.1f;
// Radius of the sight-check trace. Matches SWB's own default bullet
// radius so the check and the shot agree about what counts as blocked
// - a const rather than a property because they only stay in step if
// nobody tunes one of them in isolation.
const float SightTraceRadius = 2f;
// --- Eye position sanity ----------------------------------------------
//
// PlayerBase.EyePos is Head.WorldPosition + EyeOffset, and Head is a
// bone-driven GameObject. That makes it wrong in two situations that
// both land squarely on cops:
//
// - The frame one spawns. PlayerBase parks the object at
// (0, 0, -999999) to hide it until it has loaded, and the head
// bone doesn't catch up until the model has been animated once.
// - Any time the body has been disabled, which is what ragdolling
// does. A disabled object stops being animated, so its bones
// freeze wherever they were when it died.
//
// Every sight check and every bullet starts at the eye, so when it's
// wrong the cop is seeing and shooting from inside a wall or from
// under the map. A position derived from the root transform is always
// approximately right, which beats being exactly wrong.
const float FallbackEyeHeight = 64f;
const float MaxEyeOffsetFromRoot = 128f;
bool EyePosIsSane => _bot.EyePos.Distance( WorldPosition ) <= MaxEyeOffsetFromRoot;
Vector3 SafeEyePos => EyePosIsSane
? _bot.EyePos
: WorldPosition + Vector3.Up * FallbackEyeHeight;
/// <summary>
/// Exactly the point this cop sights and fires from. Exposed so
/// diagnostics elsewhere can use the real value instead of assuming
/// one - an assumed eye position is how a report ends up describing a
/// line the cop never actually took.
/// </summary>
public Vector3 EyePosition => _bot.IsValid() ? SafeEyePos : WorldPosition;
/// <summary>
/// How far this cop's Body has drifted from its root. Body carries the
/// model, the capsule collider and the hitboxes, so anything above
/// roughly zero means what you can see and shoot is not where the cop
/// actually is.
/// </summary>
public float BodyDrift => _bot.IsValid() && _bot.Body.IsValid()
? _bot.Body.WorldPosition.Distance( WorldPosition )
: -1f;
/// <summary>True once this cop has passed its spawn alignment checks.</summary>
public bool IsSettled => _ready;
// --- Spawn placement ---------------------------------------------------
// How many spawn points to try before giving up and using one anyway.
[Property]
public int SpawnPlacementAttempts { get; set; } = 6;
// How far around a spawn marker a cop may be placed. Keeps two cops
// sent to the same marker from arriving on identical coordinates.
[Property]
public float SpawnScatterRadius { get; set; } = 96f;
// Roughly a citizen's shoulders and standing height. Used to ask
// whether there's actually room for a cop where we're about to put one.
const float SpawnClearanceRadius = 16f;
const float SpawnClearanceHeight = 68f;
// Lifts the sweep off the floor. Without it the test sphere rests on
// the ground and every spawn point reads as blocked.
const float SpawnClearanceGroundGap = 6f;
// Things that shouldn't count as "blocked" when testing for room: our
// own trigger volumes, the sky, and any corpses lying around.
static readonly string[] SpawnClearanceIgnoreTags =
{
TagsHelper.Trigger,
TagsHelper.Sky,
TagsHelper.DeadPlayer,
TagsHelper.ViewModel
};
TimeSince _timeSinceRetarget = 0;
GameObject _target;
bool _hadLineOfSight = false;
TimeSince _timeSinceLineOfSightAcquired;
// Where we last told the agent to go. A NavMeshAgent follows its own
// path by itself once given a destination, so re-issuing MoveTo every
// tick just makes it throw away the path it was halfway through and
// build a new one - which is both wasteful and worse at cornering.
Vector3 _destination;
bool _hasDestination = false;
// How far the target has to move before the path is worth rebuilding.
// Small enough that cops don't run at where you used to be, large
// enough that ordinary strafing doesn't trigger a fresh search.
[Property]
public float RepathDistance { get; set; } = 64f;
// How long the agent is allowed to report no movement at all before we
// assume its path has gone stale and ask for another one. See the
// stall handling in OnFixedUpdate.
[Property]
public float StuckTimeout { get; set; } = 0.5f;
TimeSince _timeSinceAgentMoved = 0;
// --- Movement personality ---------------------------------------------
//
// Cops that all run at identical speed along identical lines toward an
// identical point read as one object with several bodies. These three
// break that up without needing any real decision-making.
// Travel speed with no contact at all, as a fraction of full. Below 1
// because a cop crossing ground toward a reported robbery is moving
// with purpose, not sprinting flat out.
[Property]
public float AdvanceSpeedScale { get; set; } = 0.7f;
// Travel speed while the target is visible but nothing is being fired.
// This is the "we have eyes on, move up carefully" pace.
[Property]
public float CombatSpeedScale { get; set; } = 0.45f;
// Travel speed while actually engaging. Low on purpose: planting your
// feet to shoot is most of what makes a shooter look deliberate rather
// than like it's running on rails.
[Property]
public float FiringSpeedScale { get; set; } = 0.15f;
// Inside this distance cops ease off further the closer they get, so
// the final approach onto a building is a careful walk rather than the
// same pace they crossed the street at.
[Property]
public float CautionDistance { get; set; } = 1200f;
// What the caution ramp bottoms out at, as a fraction of whatever the
// state above already decided. A const because it only exists to give
// the ramp somewhere to go - CautionDistance is the dial that matters.
const float CautionSpeedFloor = 0.5f;
// Spread on EngageRange, so a squad doesn't all halt on one neat ring
// at the same distance. Rolled per cop, per spawn.
[Property]
public float EngageRangeVariation { get; set; } = 150f;
// How far to either side of the player a cop will pick its approach
// point. Bigger values spread a squad wider but send them on longer
// routes to get there.
[Property]
public float ApproachSpread { get; set; } = 150f;
// Per-cop speed jitter, as a fraction either way. 0.15 means each one
// moves somewhere between 85% and 115% of the configured speed, so a
// group strings out instead of arriving as a block.
[Property]
public float SpeedVariation { get; set; } = 0.15f;
// Distance over which the approach offset fades to nothing. A const
// rather than a property because it only has to be "far enough out
// that the spread has room to matter" - there's nothing to tune.
const float ApproachSpreadFadeDistance = 600f;
Vector3 _approachOffset;
float _speedVariation = 1f;
// Rolled fresh on every spawn, so a cop that dies and comes back isn't
// the same one behaviourally - and so a squad wiped and replaced
// doesn't rebuild itself in the same formation.
void RollMovementPersonality()
{
// Random point in a disc around the target. Square-rooting the
// radius is what keeps them evenly spread across the area: without
// it, picking radius uniformly bunches everyone toward the middle,
// because the outer rings are bigger and deserve proportionally
// more of the picks.
var angle = Random.Shared.NextSingle() * MathF.Tau;
var radius = ApproachSpread * MathF.Sqrt( Random.Shared.NextSingle() );
_approachOffset = new Vector3( MathF.Cos( angle ) * radius, MathF.Sin( angle ) * radius, 0f );
// NextSingle is 0..1, so this maps to -1..1 before scaling.
_speedVariation = 1f + (Random.Shared.NextSingle() * 2f - 1f) * SpeedVariation;
// Where this particular cop decides it's close enough and starts
// fighting instead of advancing. Varied so a squad holds at a
// spread of depths rather than every one of them stopping on the
// same circle around you.
_engageRange = MathF.Max(
EngageRange + (Random.Shared.NextSingle() * 2f - 1f) * EngageRangeVariation,
64f );
}
float _engageRange = 500f;
// 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;
// Set up on the first tick that can actually complete it, not once in
// OnAwake.
//
// This used to be an OnAwake that gave up permanently if the DemoBot
// component wasn't resolvable yet. Component initialisation order on a
// freshly cloned prefab isn't guaranteed, so that could lose - and
// when it lost it lost badly and silently. _bot stayed null forever,
// so OnFixedUpdate bailed on its first line and the cop had no brain
// at all. Worse, the two Agent lines below never ran either, leaving
// UpdatePosition at the prefab's value: the NavMeshAgent then drove
// the GameObject's transform directly, which does no collision. A cop
// that never thinks and walks through walls - which is precisely the
// broken spawn, and precisely why it only happened to some of them.
//
// Retrying costs a null check per tick and cannot fail permanently.
bool _initialised = false;
bool EnsureInitialised()
{
_bot ??= Components.Get<DemoBot>();
if ( _bot is null )
return false;
if ( _initialised )
return true;
_initialised = true;
MatchAgentToBotMovement();
return true;
}
// 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 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.
//
// Asserted every tick rather than once at startup. It's two bool
// writes, and the cost of it being wrong even briefly is a cop that
// teleports along its path through solid geometry.
void KeepAgentOffTheTransform()
{
// Guarded because this runs before every other check in the tick,
// including during a scene change - at which point the agent may
// already have been torn down while this component gets one last
// update. That was the null reference after "Mission failed".
if ( !Agent.IsValid() )
return;
Agent.UpdatePosition = false;
Agent.UpdateRotation = false;
}
// The agent's steering output is capped at its own MaxSpeed, and we
// feed that output straight into the bot as a wish velocity - so the
// agent's speed setting silently becomes the cop's top speed.
//
// The prefab had it at 120 while PlayerBase.RunSpeed is 290. Cops were
// moving slower than a WALKING player (160), which is why they could
// never close on anyone who kept moving. Taking the number from the
// bot means the two can't drift apart again when either is tuned.
void MatchAgentToBotMovement()
{
var authoredSpeed = Agent.MaxSpeed;
Agent.MaxSpeed = _bot.RunSpeed;
// Acceleration is in units per second squared, so leaving it while
// raising the top speed would mean cops take proportionally longer
// to get up to it. Scaling by the same ratio keeps whatever
// ramp-up feel the prefab was authored with.
if ( authoredSpeed > 0f )
Agent.Acceleration *= Agent.MaxSpeed / authoredSpeed;
}
protected override void OnFixedUpdate()
{
if ( !EnsureInitialised() )
return;
KeepAgentOffTheTransform();
// The cop prefab is network spawned, so this component exists on
// every client as well as the host. IsProxy is true on the
// machines that don't own it - they receive its position over the
// network and must not run a second copy of its brain, or two
// machines end up pathing the same cop to two different places and
// fighting over the result.
//
// The host owns every cop, so this is the only guard needed.
if ( IsProxy )
return;
// First breath: put this cop where it belongs and kit it out.
//
// This only ever runs once now. See DespawnOnDeath for why a cop
// never gets a second life.
if ( _bot.IsAlive && !_wasAlive )
{
RelocateToPoliceSpawn();
ApplyCopLoadout();
RollMovementPersonality();
}
// Last breath: hand the corpse over and take this object out.
if ( !_bot.IsAlive && _wasAlive )
DespawnOnDeath();
_wasAlive = _bot.IsAlive;
if ( !_bot.IsAlive )
return;
// Nothing below here runs until this cop is verifiably in one
// piece. See SettleAfterSpawn.
if ( !_ready && !SettleAfterSpawn() )
return;
// Checked before anything else this tick does, so it compares the
// position the controller actually settled on last tick with the
// one it settled on this tick.
DetectPassThrough();
// The agent isn't moving this GameObject (UpdatePosition is off),
// so it has no idea where the CharacterController actually put us
// or how fast we're really going. Feeding both back every tick is
// what keeps the engine's own path-following and crowd avoidance
// working off reality instead of off its own dead reckoning -
// without the velocity in particular, cops steer around each other
// as though everyone were standing still.
//
// Horizontal only: the CharacterController's velocity carries
// gravity on Z, and handing the agent a permanent -400 downward
// component makes its steering solve for a fall it isn't doing.
Agent.SetAgentPosition( WorldPosition );
Agent.Velocity = _bot.Velocity.WithZ( 0f );
// 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();
// Switching targets no longer needs to force a re-path: the
// destination check below notices on its own that the new target
// is somewhere else, so there's nothing to flag here.
if ( _timeSinceRetarget >= RetargetInterval )
{
_timeSinceRetarget = 0;
_target = FindNearestPlayer();
}
if ( _target is null )
{
_bot.WishVelocity = Vector3.Zero;
Agent.Stop();
_hasDestination = false;
return;
}
var toTarget = _target.WorldPosition - WorldPosition;
var distance = toTarget.Length;
var hasLineOfSight = HasLineOfSight( _target );
if ( hasLineOfSight && !_hadLineOfSight )
_timeSinceLineOfSightAcquired = 0;
// Losing sight ends the engagement rather than pausing it, so a
// cop that re-acquires you starts a fresh burst instead of
// resuming a half-finished one from thirty seconds ago.
if ( !hasLineOfSight )
_shotsThisBurst = 0;
_hadLineOfSight = hasLineOfSight;
// Aim only while the target is actually visible. A cop that has
// lost sight holds its last heading rather than tracking you
// through the wall - so stepping out of cover costs it a moment to
// swing back onto you, instead of finding it already aimed.
//
// 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.
//
// 180 means "pointing the exact opposite way", which is the right
// starting assumption when we can't see them: nothing fires.
var aimError = 180f;
if ( hasLineOfSight )
aimError = AimAt( TargetEyePos( _target ) );
// Every condition that has to hold before a trigger gets pulled,
// worked out BEFORE moving, because being in a firefight is one of
// the things that decides how fast this cop travels.
//
// seen the sight trace landed ON this player, not on a wall
// and not on nothing at all
// inRange close enough that gunfire reads as aimed at you
// reacted has held sight for ReactionTime without a break
// onTarget has finished turning and the muzzle is on them
var inRange = distance <= MaxFireRange;
var reacted = _timeSinceLineOfSightAcquired >= ReactionTime;
var onTarget = aimError <= MaxAimErrorDegrees;
// SWB fires the bullet from Owner.EyePos, not from the position we
// checked line of sight with. When the head bone is stale those
// two disagree, and the round leaves from wherever the eye is
// stuck - which on a freshly spawned or just-unragdolled cop can
// be inside a wall or under the map. Sight says clear, the bullet
// starts somewhere else entirely, and you get shot through
// geometry. Hold fire until the body agrees with itself.
var canTrustEye = EyePosIsSane;
var engaging = hasLineOfSight && inRange && reacted && onTarget && canTrustEye;
// How fast to actually travel, as a fraction of what the agent
// asked for. People don't sprint flat out through a gunfight:
// they move up fast while there's nothing shooting at them, slow
// to a walk once they're in contact, and barely shift their feet
// while they're actually putting rounds downrange.
//
// This does a second job too. The agent's steering cuts corners,
// and the faster it's allowed to go the wider it cuts - so backing
// off the throttle in contact is also what stops a cop carrying
// its own momentum into the wall beside you.
var speedScale = _speedVariation;
if ( engaging )
speedScale *= FiringSpeedScale;
else if ( hasLineOfSight )
speedScale *= CombatSpeedScale;
else
speedScale *= AdvanceSpeedScale;
// On top of the state pace, ease off with distance. This is the
// bit that reads as caution: a cop crossing open ground is moving,
// and the same cop twenty feet from the door is picking its way in.
speedScale *= MathX.Lerp(
CautionSpeedFloor, 1f,
MathX.Clamp( distance / CautionDistance, 0f, 1f ) );
// 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.
// Hold and fight once inside this cop's own standoff distance, so
// long as it can see the target. Without eyes on, keep advancing
// regardless of how close the target's last known position was -
// standing still in a corridor you can't see down isn't caution,
// it's a cop that's given up.
var chasing = distance > _engageRange || !hasLineOfSight;
var usedFallback = false;
if ( chasing )
{
// Give the agent a destination and then leave it alone. It
// runs its own path-following every tick - corners, doorways,
// keeping clear of the other cops - and MoveTo is how you
// change its mind, not how you drive it. Re-issuing it every
// frame discards the path mid-follow and asks for a new
// search, which is what made cops hug corners badly.
//
// So: only when the target has actually gone somewhere else.
//
// Each cop aims at its own point NEAR the player rather than
// at the player exactly. Everyone pathing to one identical
// point is what makes a squad move as a single file down the
// same line; giving each one its own approach spreads them
// across the space and reads as a group closing in from
// several angles. The offset fades out as they arrive, so the
// last stretch still comes at you rather than past you.
var spread = MathX.Clamp( distance / ApproachSpreadFadeDistance, 0f, 1f );
var destination = NavigableDestination( _target.WorldPosition + _approachOffset * spread );
if ( !_hasDestination || destination.Distance( _destination ) > RepathDistance )
{
Agent.MoveTo( destination );
_destination = destination;
_hasDestination = true;
}
var pathVelocity = Agent.WishVelocity;
if ( !pathVelocity.IsNearZeroLength )
_timeSinceAgentMoved = 0;
// A zero wish velocity means the engine has nothing to follow:
// no path was found, or the one it had has been invalidated.
// A single stalled tick is normal (the search is asynchronous),
// so only a sustained stall counts as one.
//
// Throw the destination away when that happens so a fresh
// request goes in next tick, and restart the clock so the new
// path gets a fair run before we call it stalled too.
if ( pathVelocity.IsNearZeroLength && _timeSinceAgentMoved > StuckTimeout )
{
_hasDestination = false;
_timeSinceAgentMoved = 0;
}
// Last resort while the path is dead: walk straight at them.
//
// Gated on line of sight, which is the whole fix for cops
// grinding into walls. Charging in a straight line at someone
// you can SEE is a reasonable thing to do and the line is by
// definition clear. Charging in a straight line at someone you
// CAN'T see means charging at whatever is between you - and
// since a dead path and a wall in the way tend to happen
// together, that's precisely when the old version would pick
// a wall and lean on it. Now a cop that's lost both its path
// and its target holds still and waits for a new path.
usedFallback = pathVelocity.IsNearZeroLength && hasLineOfSight;
_bot.WishVelocity = usedFallback
? toTarget.WithZ( 0 ).Normal * _bot.RunSpeed * speedScale
: pathVelocity * speedScale;
// Facing is set by the aim when there's something to aim at.
// When there isn't, point it along the path instead - done
// here rather than above because it needs the velocity we just
// worked out.
if ( !hasLineOfSight )
FaceTravelDirection();
}
else
{
Agent.Stop();
_bot.WishVelocity = Vector3.Zero;
_hasDestination = false;
_timeSinceAgentMoved = 0;
}
// Worked out above, before the movement block, because being in
// contact is part of what sets the pace. All four conditions are
// re-evaluated every tick, so any of them going false stops the
// shooting immediately - mid-burst included.
//
// Firing is otherwise independent of movement, which is what lets
// a cop shoot while still closing the distance.
if ( engaging )
TryShoot();
if ( DebugPathing )
LogPathingState( distance, chasing, hasLineOfSight, usedFallback, aimError );
}
// 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, float aimError )
{
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} " +
$"aimErr={aimError:0}deg " +
$"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.
GameObject point = null;
var position = Vector3.Zero;
var placed = false;
// Don't take the first answer on faith. A spawn marker can be
// nudged into a wall in the editor, and snapping it to the navmesh
// can land it on the far side of thin geometry - either way the
// CharacterController starts embedded, and a controller that
// starts embedded resolves by pushing out through the nearest
// face. That is the cop you saw walking through objects, and it's
// also why it then shot you through them: a sight trace that
// begins inside a wall is already past the surface.
//
// So try several and take one there's genuinely room to stand in.
for ( var attempt = 0; attempt < SpawnPlacementAttempts; attempt++ )
{
// Passing ourselves in so we aren't treated as occupying the
// point we're about to leave.
point = PoliceSpawns.Pick( Scene, MinSpawnDistanceFromPlayers, GameObject );
if ( point is null )
{
Log.Warning( $"{GameObject.Name}: no GameObjects tagged '{PoliceSpawns.ActiveTag}' found in the scene." );
return;
}
// Scatter around the marker instead of landing exactly on it.
// Even with the point chosen well, two cops sent to identical
// coordinates arrive inside one another, and the physics
// push-out that separates overlapping bodies is perfectly
// capable of ejecting one through a wall. A metre or two of
// jitter removes the overlap entirely.
var angle = Random.Shared.NextSingle() * MathF.Tau;
var radius = SpawnScatterRadius * MathF.Sqrt( Random.Shared.NextSingle() );
var scatter = new Vector3( MathF.Cos( angle ) * radius, MathF.Sin( angle ) * radius, 0f );
// Land ON the navmesh rather than wherever the scatter landed,
// so the agent has somewhere to path from - and so the jitter
// can't push a cop off the walkable area.
position = NavigableDestination( point.WorldPosition + scatter );
if ( IsSpawnClear( position ) )
{
placed = true;
break;
}
}
if ( !placed )
{
// Every candidate was obstructed. Spawning anyway is still
// better than a cop that never arrives, but say so - it means
// the map's spawn points want moving.
Log.Warning( $"{GameObject.Name}: no clear '{PoliceSpawns.ActiveTag}' point after " +
$"{SpawnPlacementAttempts} tries - spawning in a blocked spot. Check the map's spawn markers." );
}
WorldPosition = position;
WorldRotation = point.WorldRotation;
_skipNextMoveCheck = true;
// Closed until proven lined up - see SettleAfterSpawn.
_ready = false;
_timeSinceRelocated = 0;
// Tell the network this was a teleport, not travel.
//
// The cop prefab is network spawned, and so is every child of it -
// Body, and every bone under Body. Their transforms replicate with
// interpolation, which smooths between the last position and the
// new one. That is right for walking and wrong for a jump of this
// size: without clearing it, the body spends the next few seconds
// sliding across the map after a root that is already there, and
// since Body carries the model, the hitboxes and the capsule
// collider, what you see and what you can shoot both lag with it.
//
// PlayerBase.Respawn() does the same thing after its own teleport,
// for the same reason. We move the cop again afterwards, so it has
// to be done again here.
GameObject.Network.ClearInterpolation();
// Whatever the agent was doing before, it was doing it somewhere
// else. Start clean from where we actually are.
Agent.Stop();
Agent.SetAgentPosition( WorldPosition );
_hasDestination = false;
_timeSinceAgentMoved = 0;
}
// --- Spawn settling -----------------------------------------------------
//
// A cop is teleported to its spawn point and, until now, turned loose in
// the same tick. That is the root of a whole family of problems: a
// teleport of two thousand units does not take effect everywhere at
// once. The network transform interpolates toward the new position over
// the next few frames, the NavMeshAgent is still holding the old one,
// the CharacterController may carry velocity from before, and the model
// - which owns the hitboxes and the capsule - trails behind the root
// while all that resolves. Letting a cop walk and shoot through that
// window is what produced bodies that were not where the cop was.
//
// So it does not walk and does not shoot until every one of those
// agrees. Normally that costs a tick or two of standing still, which is
// invisible. When it does not resolve, the console says which check
// failed rather than leaving a subtly broken cop loose.
bool _ready = false;
TimeSince _timeSinceRelocated = 0;
int _settleAttempts = 0;
// How long to keep trying before giving up on a placement and picking a
// different spawn point.
const float SettleTimeout = 1f;
// How many placements to try before letting the cop go anyway. A cop
// standing still forever is worse than a slightly wrong one, but it
// should never be silent about it.
const int MaxSettleAttempts = 3;
// Tolerances. Generous enough not to trip on ordinary animation, tight
// enough that anything they let through is genuinely lined up.
const float MaxBodyDrift = 8f;
const float MaxAgentDrift = 8f;
const float MaxNavDrift = 64f;
// Re-asserts everything we can control, then checks whether the things
// we cannot control have caught up yet. Returns true once the cop is
// cleared to act.
bool SettleAfterSpawn()
{
// Stand still and hold fire while settling. Both are set every tick
// rather than once, because the agent will happily keep producing a
// wish velocity in the meantime.
_bot.WishVelocity = Vector3.Zero;
Agent.Stop();
ForceAlignment();
if ( IsLinedUp( out var problem ) )
{
_ready = true;
return true;
}
if ( _timeSinceRelocated < SettleTimeout )
return false;
// This placement is not resolving. Try a different spawn point
// rather than waiting on the same bad one forever.
_settleAttempts++;
if ( _settleAttempts < MaxSettleAttempts )
{
Log.Warning( $"{GameObject.Name}: spawn didn't settle ({problem}) - trying another point." );
RelocateToPoliceSpawn();
return false;
}
// Out of attempts. Let it go, but say so loudly - a cop released in
// this state is exactly the one that will misbehave, and this line
// is the difference between knowing that and guessing.
Log.Warning( $"{GameObject.Name}: RELEASED UNSETTLED after {MaxSettleAttempts} attempts - {problem}. " +
"Expect this cop to behave oddly." );
_ready = true;
return true;
}
// The things we can simply set, set every tick until the cop is
// released. Cheap, and idempotent once they are already correct.
void ForceAlignment()
{
if ( _bot.Body.IsValid() )
{
_bot.Body.Enabled = true;
_bot.Body.LocalPosition = Vector3.Zero;
_bot.Body.LocalRotation = Rotation.Identity;
}
if ( _bot.CharacterController.IsValid() )
_bot.CharacterController.Velocity = Vector3.Zero;
Agent.SetAgentPosition( WorldPosition );
Agent.Velocity = Vector3.Zero;
// The one that actually matters for the body lag: tells the network
// layer this was a jump, not travel, so it stops interpolating
// toward the old position. Repeated each tick because the transform
// can be written again between our teleport and this check.
GameObject.Network.ClearInterpolation();
}
// The things we cannot force, only wait for and verify.
bool IsLinedUp( out string problem )
{
problem = null;
if ( _bot.Body.IsValid() )
{
var bodyDrift = _bot.Body.WorldPosition.Distance( WorldPosition );
if ( bodyDrift > MaxBodyDrift )
{
problem = $"body is {bodyDrift:0} units from the root";
return false;
}
}
var eyeDrift = _bot.EyePos.Distance( WorldPosition );
if ( eyeDrift > MaxEyeOffsetFromRoot )
{
problem = $"eye is {eyeDrift:0} units from the root";
return false;
}
var agentDrift = Agent.AgentPosition.Distance( WorldPosition );
if ( agentDrift > MaxAgentDrift )
{
problem = $"nav agent is {agentDrift:0} units from the root";
return false;
}
var navPoint = Scene.NavMesh?.GetClosestPoint( WorldPosition, NavSearchRadius );
if ( !navPoint.HasValue )
{
problem = "no navmesh within reach";
return false;
}
var navDrift = navPoint.Value.Distance( WorldPosition );
if ( navDrift > MaxNavDrift )
{
problem = $"standing {navDrift:0} units off the navmesh";
return false;
}
if ( !IsSpawnClear( WorldPosition ) )
{
problem = "spawned inside something";
return false;
}
return true;
}
// A dead cop is retired, not recycled.
//
// SWB's PlayerBase respawns a dead player in place: ragdoll it, disable
// Body, wait two seconds, teleport it to a spawn point, re-enable
// everything. That teardown and rebuild is where every cop bug we chased
// actually lived - a stale physics shape, a body left displaced, a
// network transform still interpolating toward a position from a
// previous life. A cop on its first life was correct in every single
// test run; every misbehaving cop was on its second or later.
//
// So no cop gets a second life. This one is destroyed, MissionSpawner
// notices the gap when it prunes its list, and a brand new cop is
// cloned at a police spawn point to replace it. The replacement is a
// fresh object with no history, which is the state we know works.
//
// It also deletes a lot: there is nothing left to repair on respawn,
// because there is no respawn.
void DespawnOnDeath()
{
// Let go of the corpse before we go.
//
// CreateRagdoll builds the ragdoll as its own root object rather
// than a child, but PlayerBase.OnDestroy destroys whatever
// RagdollGO points at. Clearing the reference first leaves the body
// lying where it fell instead of having it blink out mid-death,
// and hands it to CleanUpRagdoll to remove on a timer.
var corpse = _bot.RagdollGO;
_bot.RagdollGO = null;
if ( corpse.IsValid() )
CleanUpRagdoll( corpse );
// Switch the cop off rather than destroying it outright.
//
// Disabling stops it rendering, colliding and updating - to the
// player it is gone the instant it dies - while leaving the object
// valid for PlayerBase's pending respawn to land on harmlessly.
// Destroying it here instead is what threw.
GameObject.Enabled = false;
DestroyAfterDelay();
}
// Comfortably PAST PlayerBase's own two second respawn timer.
//
// The first version of this destroyed the cop at one second, inside
// that window, on the theory that killing the object would cancel the
// pending respawn. It does not - the continuation still runs, finds
// the components it needs already torn down, and throws. That was the
// burst of null reference exceptions after a fight.
//
// Waiting it out instead is free, because the object is switched off
// the moment it dies (see DespawnOnDeath): it is invisible, collides
// with nothing and runs no code, so PlayerBase's respawn lands on a
// valid-but-inert object, does its work where nobody can see it, and
// is thrown away a moment later.
const float DespawnDelay = 3f;
// How long a body stays on the floor. Purely cosmetic; the cop object
// itself is already gone.
[Property]
public float CorpseLifetime { get; set; } = 12f;
async void DestroyAfterDelay()
{
await GameTask.DelaySeconds( DespawnDelay );
if ( GameObject.IsValid() )
GameObject.Destroy();
}
async void CleanUpRagdoll( GameObject corpse )
{
await GameTask.DelaySeconds( CorpseLifetime );
if ( corpse.IsValid() )
corpse.Destroy();
}
// --- Pass-through detection --------------------------------------------
//
// The spawn diagnostic came back clean on every cop, so whatever is
// happening isn't a broken spawn - a healthy cop walks off and goes
// through something later. This catches it in the act.
//
// Every tick, sweep from where this cop was to where it now is. The
// CharacterController is supposed to make that sweep impossible: if
// anything solid is in between, the cop just moved through it.
//
// Two possible outcomes, and they point at completely different fixes:
//
// It names something that object has collision, and the cop
// crossed it anyway - the movement is at fault.
// It never fires but you still SEE a cop cross a wall - then
// that wall has no collider on it at all, which
// is a map fix. It would also explain cops
// shooting through it, since a bullet trace
// can't hit a collider that isn't there.
[Property]
public bool DebugPassThrough { get; set; } = true;
// --- Shot verification --------------------------------------------------
//
// Checks, at the moment a cop pulls the trigger, that the shot really is
// clear - using a DIFFERENT trace to the one that authorised it.
//
// HasLineOfSight goes through SWB's own bullet trace, which deliberately
// ignores a list of tags: trigger, playerclip, passbullets, viewmodel,
// sky. If any surface in this map carries one of those - a wall built
// from a brush tagged playerclip, a prop marked passbullets - then that
// trace sails straight through it and reports a clear line in perfectly
// good faith. The bullet uses the same ignore list, so it follows.
//
// That would look exactly like what you are seeing: cops shooting
// through a specific wall, on any life, with pathing and positions all
// correct, and a pass-through detector that never fires because nothing
// ever moves through anything.
//
// So this re-traces with nothing ignored but triggers, the sky and view
// models. If that ray stops on something before reaching the target, the
// two disagree - and the object it names is the one letting cops shoot
// through it.
[Property]
public bool DebugShotVerification { get; set; } = true;
// Deliberately short: triggers and the sky are not solid, and a view
// model is a first-person prop. EVERYTHING else counts, including the
// tags SWB's bullet trace waves through.
static readonly string[] ShotVerificationIgnoreTags =
{
TagsHelper.Trigger,
TagsHelper.Sky,
TagsHelper.ViewModel
};
TimeSince _timeSinceShotWarning = 10f;
void VerifyShotIsClear( GameObject target )
{
if ( !DebugShotVerification || !target.IsValid() )
return;
// Rate limited, or a cop firing on full auto through a wall would
// print a line per bullet.
if ( _timeSinceShotWarning < 1f )
return;
var from = SafeEyePos;
var to = TargetEyePos( target );
var tr = Scene.Trace.Ray( from, to )
.WithoutTags( ShotVerificationIgnoreTags )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
// Nothing in the way, or we stopped on the target itself: the shot is
// as clear as HasLineOfSight said it was.
if ( !tr.Hit || !tr.GameObject.IsValid() || tr.GameObject.Root == target.Root )
return;
_timeSinceShotWarning = 0;
Log.Warning( $"[cop] {GameObject.Name} FIRED THROUGH '{tr.GameObject.Name}' " +
$"(root '{tr.GameObject.Root.Name}') " +
$"{from.Distance( tr.HitPosition ):0} units out of " +
$"{from.Distance( to ):0} to the target, at {tr.HitPosition}" );
}
// Chest height, so the sweep tests the wall rather than the floor.
const float PassThroughTraceHeight = 32f;
Vector3 _lastMoveCheckPosition;
bool _hasLastMoveCheck = false;
// Set by RelocateToPoliceSpawn so the one teleport we do on purpose
// isn't reported as a cop moving through the map.
bool _skipNextMoveCheck = false;
// Which life this cop is on. Reported alongside anything that goes
// wrong, because "life 1 is fine and life 2 isn't" is the single most
// useful fact we have about this bug - and if a warning never carries
// life=2, that tells us the check itself isn't reaching the broken
// case rather than that there's nothing to find.
TimeSince _timeSinceLastPassThroughLog = 10f;
void DetectPassThrough()
{
var here = WorldPosition;
if ( !_hasLastMoveCheck )
{
_lastMoveCheckPosition = here;
_hasLastMoveCheck = true;
return;
}
var from = _lastMoveCheckPosition;
_lastMoveCheckPosition = here;
if ( !DebugPassThrough )
return;
var step = from.Distance( here );
if ( step < 1f )
return;
// Only a relocation WE asked for is exempt. The previous version
// skipped any step bigger than a tick's worth of running, on the
// assumption that those were all deliberate teleports - which
// meant that if something else was teleporting a cop through a
// wall, this looked straight past the one event it was built to
// catch. That's why it never fired.
if ( _skipNextMoveCheck )
{
_skipNextMoveCheck = false;
return;
}
// A step far bigger than this cop could have run in one tick is
// worth reporting on its own, whether or not it crossed anything:
// it means something other than the CharacterController is moving
// this object, and collision never got a say.
var plausibleStep = _bot.RunSpeed * Time.Delta * 4f;
if ( step > plausibleStep && _timeSinceLastPassThroughLog >= 1f )
{
_timeSinceLastPassThroughLog = 0;
Log.Warning( $"[cop] {GameObject.Name} JUMPED {step:0} units in one tick " +
$"(max plausible {plausibleStep:0}) - something is setting its position directly. " +
$"link={Agent.IsTraversingLink} agentDrives={Agent.UpdatePosition} from={from} to={here}" );
return;
}
var tr = Scene.Trace.Ray( from + Vector3.Up * PassThroughTraceHeight, here + Vector3.Up * PassThroughTraceHeight )
.Size( SpawnClearanceRadius * 0.5f )
.WithoutTags( SpawnClearanceIgnoreTags )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
if ( !tr.Hit || tr.StartedSolid )
return;
// Rate limited: a cop stuck half inside something would otherwise
// print this every tick and bury everything else.
if ( _timeSinceLastPassThroughLog < 1f )
return;
_timeSinceLastPassThroughLog = 0;
var hitName = tr.GameObject.IsValid() ? tr.GameObject.Name : "<no gameobject>";
var rootName = tr.GameObject.IsValid() ? tr.GameObject.Root.Name : "<none>";
Log.Warning( $"[cop] {GameObject.Name} onGround={_bot.IsOnGround} moved {step:0} units THROUGH '{hitName}' " +
$"(root '{rootName}') at {tr.HitPosition} " +
$"link={Agent.IsTraversingLink} agentDrives={Agent.UpdatePosition}" );
}
// Is there actually room for a cop to stand here?
//
// Sweeps a body-sized sphere from ankle height up to head height. Two
// distinct failures both mean no: StartedSolid says the bottom of that
// sweep is already buried, Hit says something is in the way further
// up - a shelf, a counter, a doorframe.
bool IsSpawnClear( Vector3 position )
{
// Another cop standing here doesn't necessarily show up in the
// sweep below: a dead one has had its body - and with it its
// collider - disabled, so it traces as empty space right up until
// the moment it respawns into the same spot. Check by position.
foreach ( var other in Scene.GetAllComponents<CopBotAI>() )
{
if ( !other.IsValid() || other.GameObject == GameObject )
continue;
if ( other.WorldPosition.DistanceSquared( position ) < SpawnClearanceRadius * SpawnClearanceRadius * 4f )
return false;
}
// Sweep the body volume only, clear of the ground.
//
// The first version started this sphere at exactly one radius up,
// which puts its underside flush with the floor - so it reported
// solid at every legitimate spawn point in the map, every cop
// failed all three attempts, and the whole gate turned into a
// warning that fired constantly and gated nothing.
//
// Start a little above that, and stop a radius short of the top,
// so the swept volume is the space a standing body needs and
// nothing else.
var feet = position + Vector3.Up * (SpawnClearanceRadius + SpawnClearanceGroundGap);
var head = position + Vector3.Up * (SpawnClearanceHeight - SpawnClearanceRadius);
// Ignore ourselves. This is called both BEFORE a move (testing a
// candidate the cop isn't standing on yet) and AFTER one (verifying
// where it now is) - and in the second case the sweep lands on this
// cop's own capsule collider every single time. That is why every
// cop reported "spawned inside something": it was standing in
// itself.
var tr = Scene.Trace.Ray( feet, head )
.Size( SpawnClearanceRadius )
.WithoutTags( SpawnClearanceIgnoreTags )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
return !tr.StartedSolid && !tr.Hit;
}
// 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;
}
// Read the untouched numbers off the registry's master copy and
// ASSIGN, rather than multiplying whatever the clone currently
// holds.
//
// The old version multiplied in place on the assumption that every
// respawn hands out a fresh clone. That held right up until the
// spread started being rewritten per shot below - at which point
// "multiply what's there" would have compounded a cop's own
// settling spread into its permanent accuracy. Assigning from a
// known base is idempotent, so it doesn't matter how often this
// runs or what state the clone was left in.
//
// Scaling the clone is still safe for the player: their 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.
var master = WeaponRegistry.Instance?.Get( className );
weapon.Primary.Damage = (master?.Primary?.Damage ?? weapon.Primary.Damage)
* DamageScale * ActiveMission.CopDamageScale;
// Kept rather than written straight onto the weapon: TryShoot sets
// the live spread every shot, widening it at the start of an
// engagement and settling it back to this.
_settledSpread = (master?.Primary?.Spread ?? weapon.Primary.Spread) * SpreadScale;
weapon.Primary.Spread = _settledSpread;
}
// 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.
// Independent check on the shot we are about to take. Logs nothing
// unless something solid is actually in the way.
VerifyShotIsClear( _target );
// Set the live spread for THIS shot. Cops open an engagement wide
// and walk their fire in - see CurrentSpreadMultiplier.
weapon.Primary.Spread = _settledSpread * CurrentSpreadMultiplier();
weapon.TimeSincePrimaryShoot = 0;
weapon.Shoot( weapon.Primary, true );
_shotsThisBurst++;
if ( _shotsThisBurst >= BurstShots )
_timeSinceBurstEnded = 0;
}
// Widens the spread at the start of an engagement and settles it back
// to normal over AimSettleTime.
//
// Keyed off how long sight has been held unbroken, which is the same
// clock ReactionTime uses - so ducking back into cover and stepping
// out again makes a cop start its walk-on from scratch rather than
// resuming perfectly zeroed.
float CurrentSpreadMultiplier()
{
if ( AimSettleTime <= 0f )
return 1f;
var settled = MathX.Clamp( _timeSinceLineOfSightAcquired / AimSettleTime, 0f, 1f );
return MathX.Lerp( OpeningSpreadMultiplier, 1f, settled );
}
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;
}
// Turns the aim toward a world position at TurnSpeed, and reports how
// far off target the muzzle still is afterwards, in degrees.
//
// EyeAngles is what Shoot() fires along, so this is the whole of a
// cop's aiming. Turning at a rate rather than snapping is what stops
// a cop firing through the wall it is still swinging past.
float AimAt( Vector3 worldPos )
{
var toTarget = worldPos - SafeEyePos;
if ( toTarget.IsNearZeroLength )
return 0f;
return TurnTowards( Rotation.LookAt( toTarget ) );
}
// With nothing in sight there's nothing to aim at, so look where
// you're going.
//
// A cop sprinting down a street with its head and weapon still pointed
// at wherever it last saw somebody looks broken, and it's also why the
// moment it DID acquire you never read as a turn - it was already
// facing some arbitrary direction and only its bullets ever moved.
void FaceTravelDirection()
{
var travel = _bot.WishVelocity.WithZ( 0f );
if ( travel.IsNearZeroLength )
return;
TurnTowards( Rotation.LookAt( travel ) );
}
// Rotates both the aim and the visible model toward a rotation, each
// at its own rate, and reports how much of the turn is left in degrees.
float TurnTowards( Rotation desired )
{
var current = _bot.EyeAngles.ToRotation();
// Rotation.Distance is the angle between two rotations in degrees.
// Working out what fraction of the remaining turn we're allowed
// makes this a true degrees-per-second cap, rather than the
// framerate-dependent easing a plain Lerp( a, b, Time.Delta * k )
// would give - that one turns faster the better your framerate is.
var remaining = current.Distance( desired );
var maxStep = TurnSpeed * Time.Delta;
var fraction = remaining <= maxStep ? 1f : maxStep / remaining;
// Slerp, not a lerp of the Angles components. Quaternion
// interpolation always takes the SHORT way round; lerping raw
// euler angles sends a cop turning from yaw 170 to yaw -170 the
// long way, sweeping 340 degrees to cover 20 - most of a second
// spent facing away from the person it's shooting at.
var aim = Rotation.Slerp( current, desired, fraction );
_bot.EyeAngles = aim.Angles();
TurnBodyTowards( aim );
// What's left after this tick's turn. A cop that covered the whole
// angle returns 0 and is cleared to fire straight away; only one
// that's genuinely mid-swing gets held back.
return MathF.Max( remaining - maxStep, 0f );
}
// Turns the visible model.
//
// This exists because of SWB. PlayerBase.RotateBody() swings the model
// toward EyeAngles with Rotation.Lerp( ..., Time.Delta * 2f ), which
// closes about half the remaining angle every third of a second. For a
// human that's invisible - your yaw only ever creeps, because it
// follows your mouse. A bot acquires a target a hundred and fifty
// degrees off its shoulder, and at that rate the model needs well over
// a second to come round. Its bullets leave along EyeAngles the
// instant we set them, so what you see is a cop that shoots you
// without ever having looked at you.
//
// Nothing in SWB needs changing for this: RotateBody lerps toward the
// same yaw we're setting here, so whichever of the two runs last in a
// given frame, they agree on the destination and there's nothing to
// fight over.
void TurnBodyTowards( Rotation aim )
{
if ( !_bot.Body.IsValid() )
return;
// Yaw only. The model stands upright and leans its aim with the
// animation graph's AimAngle, which SWB already feeds from
// EyeAngles - pitching the whole body would double that up.
var target = new Angles( 0f, aim.Angles().yaw, 0f ).ToRotation();
var remaining = _bot.Body.WorldRotation.Distance( target );
var maxStep = BodyTurnSpeed * Time.Delta;
var fraction = remaining <= maxStep ? 1f : maxStep / remaining;
_bot.Body.WorldRotation = Rotation.Slerp( _bot.Body.WorldRotation, target, fraction );
// Put the body back on the root, every single time.
//
// Assigning a WORLD rotation to a child makes the engine recompute
// that child's LOCAL transform so its world position is preserved.
// This runs in OnFixedUpdate, which is before PlayerBase moves the
// root in the same tick - so each call re-pinned Body to where the
// cop was standing at the start of the tick, and the root then
// walked out from under it. Repeat fifty times a second and the
// body is left hundreds of units behind.
//
// That is the drift: it grew with distance travelled and froze the
// moment a cop stopped, which is exactly this and nothing else.
// SWB's own RotateBody makes the same call from OnUpdate, after the
// move, which is why players never showed it.
//
// Body carries the model, the capsule collider and the hitboxes,
// so a cop with drift is a cop whose shootable body is somewhere
// it is not - and whose line of sight is judged from there, since
// cops accept a sight hit on anything whose root is the player.
// One assignment closes it.
_bot.Body.LocalPosition = Vector3.Zero;
}
// Can this cop see the target, along a line its own bullets could
// actually travel?
//
// Uses SWB's own bullet trace instead of a hand-rolled ray, so the
// sight check and the shot obey identical rules: the same ignored tags
// (triggers, player clips, pass-bullets surfaces, view models, sky),
// the same hitboxes, the same radius. When the two disagree you get
// cops that "see" you through one of our trigger volumes and fire into
// it, or that refuse to shoot down a line a bullet would sail
// straight along. Dead players are ignored as well, so a pile of
// ragdolls doesn't hand the crew free cover.
bool HasLineOfSight( GameObject target )
{
var tr = Weapon.TraceBullet(
GameObject,
SafeEyePos,
TargetEyePos( target ),
SightTraceRadius,
Weapon.PenetrationBulletTraceIgnoreTags );
// A trace that BEGAN inside solid geometry tells us nothing. A
// sphere cast starting in a wall doesn't stop at that wall - it is
// already past the surface, so it sails on and happily reports the
// player on the other side. That is precisely how a cop embedded
// in the scenery ends up shooting you through it.
//
// There's no useful answer to give here, so give the safe one.
if ( tr.StartedSolid )
return false;
// The trace has to have stopped ON the target.
//
// This used to also accept "hit nothing at all" as a clear line,
// and that was the bug: a trace that never registers the player is
// not evidence the cop can see them, it's evidence the check
// didn't do its job. Every case we don't understand - a collider
// that isn't where we think, a target whose body has been
// disabled, a ray that slips past a capsule - came back as
// permission to open fire. Now they all come back as "hold".
return tr.Hit
&& tr.GameObject.IsValid()
&& 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;
}
}