MissionPlayerState.cs
using Sandbox;
using SWB.Base;
using SWB.Player;
using System;
using System.Collections.Generic;
using System.Linq;
// Owns "am I still in this job?" for one player. Put this on the player
// prefab alongside PlayerBase.
//
// During a mission there are no respawns: going down puts you out for the
// rest of the run, watching through a teammate's eyes. Escaping does the
// same thing minus the dying. Once nobody is left in the fight, MissionTimer
// wraps up and sends everyone back to the hideout.
//
// Outside a mission (i.e. in the hideout) this does nothing and normal
// SWB respawning is left alone.
public sealed class MissionPlayerState : Component
{
public enum State
{
/// <summary>Still in the fight.</summary>
Active,
/// <summary>Went down. Out for the rest of the run.</summary>
Eliminated,
/// <summary>Got out with the money.</summary>
Escaped
}
public State Current { get; private set; } = State.Active;
public bool IsOut => Current != State.Active;
/// <summary>Who we're currently watching, if anyone.</summary>
public PlayerBase SpectateTarget { get; private set; }
PlayerBase _player;
CameraMovement _cameraMovement;
bool _wasAlive = true;
// Tracks the job starting, so a player who survived the last one is
// put back in play at the start of the next. See ResetForNewJob.
bool _wasInMission = false;
// --- Incoming fire diagnostics -----------------------------------------
//
// Every check so far has been on the COP's side, and every one of them
// has come back saying the shot was legitimate: the cop was where it
// thought it was, its eye position was right, and an independent trace
// agreed the line was clear. So this checks the other end - the one
// moment that is not in dispute, which is you losing health.
//
// When health drops it asks, of every cop on the map, whether there was
// anything solid between its eye and yours. Two possible answers:
//
// at least one CLEAR the shot was legitimate; whatever it feels
// like, that cop really could see you. The wall
// you think you are behind has a gap, or does
// not reach eye height.
//
// all BLOCKED a bullet crossed solid geometry. That is the
// real thing, and the named object is what it
// came through.
[Property]
public bool DebugIncomingFire { get; set; } = true;
int _lastHealth = -1;
// Matches the cop and player Head offsets, which are both fixed
// children at (0,0,60) - so this is where shots start and land.
const float EyeHeight = 60f;
protected override void OnAwake()
{
_player = Components.Get<PlayerBase>();
_cameraMovement = Components.GetInChildrenOrSelf<CameraMovement>();
if ( _player is null )
Log.Warning( $"{GameObject.Name}: MissionPlayerState needs a PlayerBase on the same GameObject." );
}
// Makes sure the frame is drawn from THIS player's head, by switching off
// any other scene camera that's also claiming to be the main camera.
//
// Why it's needed: scenes built from the default template come with
// their own "Main Camera" object (the hideout's sits at -300,0,100,
// underneath the floor). The player prefab brings its own camera, also
// flagged as main, at the same priority. With two equal main cameras
// the engine picks one, and which one it picks isn't something we
// control - on the first hideout load it happened to be the player's,
// coming back from the bank it was the scene's. That left the screen
// showing the underside of the hideout floor (skybox only), and SWB's
// ViewModelHandler, which copies Scene.Camera, dragged the gun camera
// down there with it. Logged proof: [cams] lines, 2026-09-25.
//
// Only touches cameras that are (a) flagged main and (b) not part of
// this player. The player's own Camera and ViewModelCamera are left
// alone, and so is anything the editor injects that isn't flagged main.
//
// Swept a few times a second rather than only when Scene.Camera is
// already wrong. A first version waited for the stray to win before
// acting, which still allowed the bad view to flash up - and the logs
// showed the stray sitting enabled for the whole of a hideout visit
// where it just happened to lose. Switching it off pre-emptively
// means the coin never gets tossed. It has to keep sweeping because
// the player object survives scene changes: a fresh scene brings a
// fresh stray camera with it.
TimeSince _sinceCameraSweep = 0;
void KeepPlayerCameraInCharge()
{
if ( _player.IsBot || !_player.Camera.IsValid() )
return;
if ( _sinceCameraSweep < 0.25f )
return;
_sinceCameraSweep = 0;
foreach ( var cam in Scene.GetAllComponents<CameraComponent>().ToList() )
{
if ( !cam.Enabled || !cam.IsMainCamera )
continue;
if ( cam.GameObject.Root == GameObject.Root )
continue;
cam.Enabled = false;
Log.Info( $"{GameObject.Name}: switched off scene camera '{cam.GameObject.Name}' - it was competing with the player's own." );
}
}
protected override void OnUpdate()
{
if ( _player is null )
return;
// A new job puts everyone back in play.
//
// Nothing here ever undid being out: Current was only ever written
// by SetOutBroadcast, the tag was only ever added, and GodMode and
// the hidden body were only ever switched on. That was survivable
// only as long as a player object never outlived the job - but a
// network-spawned player persists across the scene change, so
// escaping once left that player marked escaped, in GodMode and
// without a body capsule for every job afterwards.
//
// Driven off the timer rather than OnStart, because OnStart won't
// run again on an object that survived the scene load.
var inMission = MissionInProgress();
if ( inMission && !_wasInMission )
ResetForNewJob();
_wasInMission = inMission;
// Runs on every client, not just the owner: someone who's out has
// to be invisible on everyone's screen, not only their own.
if ( IsOut )
HideBody();
if ( IsProxy )
return;
KeepPlayerCameraInCharge();
WatchForBodyDrift();
WatchForIncomingFire();
if ( Current == State.Active )
{
// Belt and braces for the HUD: ResetForNewJob brings it back
// when a job starts, but a player who died and then went back
// to the hideout shouldn't have to wait for the next job to see
// their cash. Only writes when the value actually changes.
SetOwnHudVisible( true );
CheckForElimination();
}
else
{
EnforceOutOfPlay();
}
_wasAlive = _player.IsAlive;
}
// Owner-side. Watches the player's own body for drifting away from the
// root, continuously - not only when something shoots us.
//
// This matters more than it looks. Body carries the CapsuleCollider and
// the ModelHitboxes, so it is the only part of a player a bullet can
// actually hit, while the root is where the player is standing and
// where their camera is. Cops accept a sight hit on anything whose root
// is the player, so a drifted Body means a cop can hold a genuinely
// clear line to a capsule that is nowhere near the person - and every
// round it puts into that capsule damages a player standing safely
// behind a wall.
//
// The same drift was already proven to happen to cops after a teleport,
// caused by network interpolation catching up. The player is
// network-spawned and teleported on spawn too.
TimeSince _timeSinceDriftWarning = 10f;
const float MaxAcceptableBodyDrift = 8f;
void WatchForBodyDrift()
{
if ( !DebugIncomingFire || !_player.Body.IsValid() )
return;
var drift = _player.Body.WorldPosition.Distance( WorldPosition );
if ( drift <= MaxAcceptableBodyDrift || _timeSinceDriftWarning < 2f )
return;
_timeSinceDriftWarning = 0;
Log.Warning( $"[player] body has drifted {drift:0} units from the root " +
$"(root {WorldPosition}, body {_player.Body.WorldPosition}). " +
"Your hitbox is not where you are." );
}
// Owner-side. Notices health going down and reports what could have
// done it.
void WatchForIncomingFire()
{
if ( !DebugIncomingFire )
return;
var health = _player.Health;
// First read, or a respawn topping us back up - nothing to report.
if ( _lastHealth < 0 || health >= _lastHealth )
{
_lastHealth = health;
return;
}
var damage = _lastHealth - health;
_lastHealth = health;
ReportIncomingFire( damage );
}
// Dumps, for every cop close enough to matter, exactly what a straight
// line from its eye to yours runs into.
//
// The previous version collapsed this to one word and got it wrong. It
// called a shot CLEAR whenever the trace was not stopped by a wall -
// which included the case where the trace hit NOTHING AT ALL. A ray
// that reaches nothing has not proved a clear line, it has proved the
// check is looking in the wrong place, and reporting that as
// "legitimate shot" is worse than saying nothing.
//
// So now every cop gets a line, naming what its ray stopped on and how
// far along. Three outcomes, and they mean different things:
//
// ->YOU the ray reached you. The shot really was clear.
// ->'name' the ray stopped on that object first. A round that
// still reached you came through it.
// ->NOTHING the ray reached neither you nor any geometry. The
// trace is not measuring what we think it is, and no
// conclusion can be drawn from it.
void ReportIncomingFire( int damage )
{
var eye = WorldPosition + Vector3.Up * EyeHeight;
var lines = new List<string>();
// How far the visible/shootable body has drifted from the root.
//
// Everything a cop can actually hit - the capsule collider, the
// model hitboxes - hangs off Body. The root is where the player
// believes they are and where the camera sits. When these two part
// company, a cop shooting a legitimately visible capsule is
// shooting somewhere the player is not.
var bodyDrift = _player.Body.IsValid()
? _player.Body.WorldPosition.Distance( WorldPosition )
: -1f;
var bodyEye = (_player.Body.IsValid() ? _player.Body.WorldPosition : WorldPosition)
+ Vector3.Up * EyeHeight;
// Snapshot the list before walking it. Cops destroy themselves a
// few seconds after dying, and one doing so mid-enumeration is what
// threw while this was being written.
var cops = Scene.GetAllComponents<CopBotAI>().ToList();
foreach ( var cop in cops )
{
if ( !cop.IsValid() || !cop.GameObject.IsValid() || !cop.GameObject.Enabled )
continue;
// The cop's ACTUAL firing point, not an assumed one.
//
// This was cop.WorldPosition + 60, which is only correct if
// nothing about the cop has drifted - exactly the assumption
// under investigation. Asking the cop where it sights from
// means this report describes the line it really took.
var from = cop.EyePosition;
var distance = from.Distance( eye );
// Only the ones close enough to plausibly be the shooter -
// otherwise a long job prints a wall of text per hit.
if ( distance > 3000f )
continue;
// Ignores only what genuinely is not solid to a bullet. Corpses
// are on that list: SWB's own bullet trace skips deadPlayer, so
// counting a ragdoll as cover reported "blocked" for shots that
// really did have a clear line through one.
var tr = Scene.Trace.Ray( from, eye )
.WithoutTags( IncomingFireIgnoreTags )
.IgnoreGameObjectHierarchy( cop.GameObject )
.Run();
// The same line again, but traced exactly the way SWB traces a
// bullet - with ITS ignore list, which is not the same as ours.
//
// This is the comparison that matters. Ours ignores only things
// that genuinely are not solid. SWB's also waves through
// anything tagged playerclip or passbullets. If a wall carries
// one of those, our trace stops on it and the bullet does not -
// so the cop gets a clear line, fires, and the round arrives
// through a wall that looks and behaves solid in every other
// respect.
var bulletTr = Weapon.TraceBullet( cop.GameObject, from, eye, 2f, Weapon.BulletTraceIgnoreTags );
var strict = DescribeLine( tr );
var asBullet = DescribeLine( bulletTr );
// Only worth printing when the two disagree - that disagreement
// IS the bug, and the tag named below is the reason.
if ( strict != asBullet )
{
var blocker = tr.Hit && tr.GameObject.IsValid() ? tr.GameObject : null;
strict += $" <<< BULLET SEES: {asBullet}"
+ $" [blocker's SWB-ignored tags: {SwbIgnoredTagsOn( blocker )}]";
}
// Second line, aimed at where the BODY actually is rather than
// where the root says the player is.
//
// This is the test for the one thing that would explain a hit
// with no clear line anywhere: the capsule and hitboxes live on
// Body, and cops accept a sight hit on anything whose root is
// the player. If Body has drifted from the root, a cop can hold
// a perfectly legitimate line to a capsule that is no longer
// where the player is standing - shoot it, and the damage lands
// on a player who is behind a wall.
//
// Only printed when it disagrees with the line to the root,
// because when they agree it tells us nothing.
if ( bodyDrift > 1f )
{
var bodyTr = Scene.Trace.Ray( from, bodyEye )
.WithoutTags( IncomingFireIgnoreTags )
.IgnoreGameObjectHierarchy( cop.GameObject )
.Run();
var toBody = DescribeLine( bodyTr );
if ( toBody != DescribeLine( tr ) )
strict += $" ||| TO BODY: {toBody}";
}
// The cop's own body drift and whether it ever passed its spawn
// alignment checks, printed per cop. Body carries its capsule
// and hitboxes; a drifted one is a cop whose visible position
// and real position disagree.
var copState = $"[drift={cop.BodyDrift:0.0} settled={cop.IsSettled}"
+ $" eyeAt={from} root={cop.WorldPosition}]";
lines.Add( $"{cop.GameObject.Name} {distance:0}u {copState} -> {strict}" );
}
if ( lines.Count == 0 )
{
Log.Warning( $"[hit] -{damage}hp - no cops within 3000 units. Damage came from something else." );
return;
}
// Whether there was anything on us for a trace to land on. If this
// is False then every "NOTHING" below means the trace had no target
// to find, not that the line was clear.
var capsule = _player.BodyCollider.IsValid() && _player.BodyCollider.Enabled;
Log.Info( $"[hit] -{damage}hp capsule={capsule} bodyDrift={bodyDrift:0.0} | " + string.Join( " | ", lines ) );
}
// Four distinct outcomes, and the middle two used to be collapsed
// into one wrong word.
//
// A trace can report Hit with a NULL GameObject - that is world
// geometry which is not a GameObject. It is solid, it blocks bullets,
// and the previous version printed it as "NOTHING", which read as
// "the line was clear" when it meant the exact opposite. Any shot
// labelled NOTHING in an earlier log may well have been blocked.
// Which of the tags SWB's bullet trace skips are on this object. This
// is the answer to "why did the round go through", in one word.
static string SwbIgnoredTagsOn( GameObject go )
{
if ( !go.IsValid() )
return "n/a";
var found = Weapon.BulletTraceIgnoreTags.Where( go.Tags.Has ).ToList();
return found.Count == 0 ? "NONE" : string.Join( ",", found );
}
string DescribeLine( SceneTraceResult tr )
{
// Begins inside something solid. A sphere or ray starting in a wall
// is already past its surface, so it does not stop there and can
// report nothing at all - which is not the same as a clear line,
// and was previously indistinguishable from one in this report.
if ( tr.StartedSolid )
return "STARTED SOLID - this trace began inside geometry and cannot be trusted";
if ( !tr.Hit )
return "NOTHING AT ALL - not even you, so this trace proves nothing";
if ( !tr.GameObject.IsValid() )
return $"BLOCKED by world geometry (no GameObject) at {tr.Fraction:0.00} along";
if ( tr.GameObject.Root == GameObject )
return "YOU";
return $"BLOCKED by '{tr.GameObject.Name}' "
+ $"(root '{tr.GameObject.Root?.Name ?? "?"}') at {tr.Fraction:0.00} along";
}
// What a bullet genuinely passes through, and nothing else. Kept
// deliberately short - every tag added here is a way for this report to
// agree with a shot that should not have connected.
static readonly string[] IncomingFireIgnoreTags =
{
"trigger",
"sky",
"viewmodel",
"deadPlayer"
};
void CheckForElimination()
{
// Only alive -> dead counts, and only during an actual job. Dying
// in the hideout (or before the job starts) falls through to SWB's
// normal respawn.
if ( _player.IsAlive || !_wasAlive )
return;
if ( !MissionInProgress() )
return;
SetOut( State.Eliminated );
}
// Called by EscapeZone when this player reaches the exit.
public void MarkEscaped()
{
SetOut( State.Escaped );
}
void SetOut( State state )
{
SetOutBroadcast( (int)state );
}
// Broadcast because every client runs its own MissionTimer and reads
// these tags to decide whether anyone is still in the fight. Applied
// on the owner only, the rest of the crew would keep counting a dead
// player as alive and the job would never end for them.
[Rpc.Broadcast]
void SetOutBroadcast( int stateValue )
{
Current = (State)stateValue;
// MissionTimer and CopBotAI both read these tags to decide who
// still counts as being in the fight.
GameObject.Tags.Add( Current == State.Escaped ? "escaped" : "eliminated" );
// Only our own view needs handing over to someone else; other
// clients have their own cameras to worry about.
if ( !IsProxy && _cameraMovement is not null )
_cameraMovement.Enabled = false;
Log.Info( $"{GameObject.Name} is out of the run: {Current}." );
}
// Runs every frame rather than once, because PlayerBase.OnDeath fires
// a 2-second delayed respawn that we can't call off - it'll reset
// health and teleport the body to a spawn point regardless. Rather
// than fighting that, we let it happen and just keep re-applying the
// out-of-play state on top, which is far less fragile than trying to
// intercept an async respawn.
void EnforceOutOfPlay()
{
// Can't be hurt, and can't die a second time (which would
// otherwise cost an escaped player their loadout).
_player.GodMode = true;
// Every frame for the same reason as everything else here: SWB's
// respawn and the loadout re-equip both happen after we've gone
// out, and a one-shot would be undone by them.
SetOwnHudVisible( false );
UpdateSpectate();
}
// Invisible, and no hitbox for cops to shoot at.
//
// Only the body capsule, deliberately - blanket-disabling every
// collider would take the CharacterController's out from under
// PlayerBase.Move(), which still runs every tick on a player who's
// out. GodMode is what actually makes them unkillable; this is so
// cops' line-of-sight traces don't stop on an invisible body.
void HideBody()
{
if ( _player.BodyRenderer.IsValid() )
_player.BodyRenderer.Enabled = false;
if ( _player.BodyCollider.IsValid() )
_player.BodyCollider.Enabled = false;
}
// Undoes everything SetOut and HideBody did, so a player starting a
// new job is genuinely back in play.
//
// The body capsule matters most here. Cops' line-of-sight traces are
// required to land ON the target to count as seeing it, and that
// capsule is most of what they land on. A player carrying a disabled
// one into the next job is a player those traces pass straight
// through - so a cop reads "clear line" from somewhere it plainly
// isn't, and fires. From the receiving end that is being shot through
// a wall.
void ResetForNewJob()
{
Current = State.Active;
SpectateTarget = null;
GameObject.Tags.Remove( "escaped" );
GameObject.Tags.Remove( "eliminated" );
if ( _player.BodyRenderer.IsValid() )
_player.BodyRenderer.Enabled = true;
if ( _player.BodyCollider.IsValid() )
_player.BodyCollider.Enabled = true;
// Only the owner's own view and invulnerability to restore; other
// clients have their own cameras and their own GodMode to worry
// about.
if ( IsProxy )
return;
_player.GodMode = false;
if ( _cameraMovement is not null )
_cameraMovement.Enabled = true;
SetOwnHudVisible( true );
_spectateCamFollowing = null;
// _wasAlive seeds the alive->dead edge CheckForElimination looks
// for. Left stale from the last job, a player who died at the end
// of it would be counted out again the moment this one starts.
_wasAlive = _player.IsAlive;
}
// --- Spectator camera ------------------------------------------------
//
// A follow camera behind whoever you're watching, rather than sitting in
// their head. From inside their head you see exactly what they see but
// nothing of what's happening TO them, and every flick of their mouse
// is yours too - it's disorientating to watch. From behind you can see
// them, what they're aiming at, and what's coming at them.
// How far back the camera sits, along the direction they're aiming.
[Property]
public float SpectateDistance { get; set; } = 180f;
// How far above their eyes. Lifts the view over their shoulder so they
// aren't blocking the middle of the screen.
[Property]
public float SpectateHeight { get; set; } = 40f;
// How quickly the camera catches up with them. Higher is snappier;
// lower is floatier. Only applies to the camera swinging OUT - see
// UpdateSpectate for why pulling in never waits.
[Property]
public float SpectateSmoothing { get; set; } = 8f;
// The camera's pitch follows theirs, but only within this range.
// Someone looking straight down would otherwise put the camera
// directly overhead, and straight up would bury it in the floor.
const float SpectatePitchMin = -30f;
const float SpectatePitchMax = 45f;
// Keeps the camera a little way off walls instead of right against
// them, where it would clip into the surface.
const float SpectateCameraRadius = 8f;
Vector3 _spectateCamPos;
PlayerBase _spectateCamFollowing;
void UpdateSpectate()
{
// Target can die while we're watching them, so re-check rather
// than holding onto a corpse.
if ( !IsValidTarget( SpectateTarget ) )
SpectateTarget = FindSpectateTarget();
if ( SpectateTarget is null || !_player.Camera.IsValid() )
return;
// Orbit around their eyes, facing the way they're aiming.
// CameraMovement was switched off in SetOut, so nothing else is
// moving this camera.
var pivot = SpectateTarget.EyePos;
var aim = SpectateTarget.EyeAngles;
aim.pitch = aim.pitch.Clamp( SpectatePitchMin, SpectatePitchMax );
aim.roll = 0f;
var aimRot = aim.ToRotation();
var desired = pivot - aimRot.Forward * SpectateDistance + Vector3.Up * SpectateHeight;
// Pull the camera in if there's a wall between them and where it
// wants to be - otherwise backing into a corner would put the
// view inside the wall. Swept as a small sphere, so the camera
// stops short of the surface rather than exactly on it.
//
// Players are ignored by tag (the tag carries down to every part
// of them), so neither the person being watched nor anyone
// standing near them shoves the camera around.
var tr = Scene.Trace.Ray( pivot, desired )
.Radius( SpectateCameraRadius )
.WithoutTags( "player", "trigger", "viewmodel", "weapon", "clothing", "deadPlayer" )
.IgnoreGameObjectHierarchy( SpectateTarget.GameObject )
.Run();
var target = tr.Hit ? tr.EndPosition : desired;
// Switching who we watch (or just starting to) snaps straight
// there. Easing across the map from the last person would be a
// long, pointless swoop.
if ( _spectateCamFollowing != SpectateTarget )
{
_spectateCamFollowing = SpectateTarget;
_spectateCamPos = target;
}
else
{
// Only ease OUTWARD. If a wall has just pulled the safe spot in
// closer, going there gradually would mean spending those
// frames inside the wall - so moving closer is immediate, and
// only moving back out is smoothed.
var closer = target.Distance( pivot ) < _spectateCamPos.Distance( pivot );
_spectateCamPos = closer
? target
: _spectateCamPos.LerpTo( target, 1f - MathF.Exp( -SpectateSmoothing * Time.Delta ) );
}
// Look past them at whatever they're aiming at, rather than at the
// back of their head - keeps them low in frame and shows the
// threat they're facing.
var lookAt = pivot + aimRot.Forward * 200f;
_player.Camera.WorldPosition = _spectateCamPos;
_player.Camera.WorldRotation = Rotation.LookAt( lookAt - _spectateCamPos );
}
// Our own HUD - health, ammo, armour, cash, prompts, waypoints - goes
// away while we're out. None of it describes anything we can act on,
// and ammo and health readouts for a body that isn't in play are just
// misleading. The scene's mission banner (ELIMINATED - SPECTATING ...)
// isn't part of the player, so it stays.
//
// Faded with Opacity rather than disabling anything. Switching a
// PanelComponent or ScreenPanel off tears its panels down, and they're
// only built once in OnStart - they'd come back empty. Opacity leaves
// the whole UI alive underneath, so bringing it back is instant and
// exact.
void SetOwnHudVisible( bool visible )
{
var opacity = visible ? 1f : 0f;
foreach ( var screen in Components.GetAll<ScreenPanel>( FindMode.EverythingInSelfAndDescendants ) )
{
if ( screen.Opacity != opacity )
screen.Opacity = opacity;
}
}
bool IsValidTarget( PlayerBase target )
{
if ( !target.IsValid() )
return false;
var go = target.GameObject;
return target.IsAlive
&& !go.Tags.Has( "eliminated" )
&& !go.Tags.Has( "escaped" );
}
PlayerBase FindSpectateTarget()
{
return Scene.GetAllComponents<PlayerBase>()
.FirstOrDefault( p => p != _player && !p.IsBot && IsValidTarget( p ) );
}
static bool MissionInProgress()
{
var timer = Game.ActiveScene?.GetAllComponents<MissionTimer>().FirstOrDefault();
return timer?.MissionInProgress ?? false;
}
}