MissionPlayerState.cs
using Sandbox;
using SWB.Player;
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;

	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." );
	}

	protected override void OnUpdate()
	{
		if ( _player is null )
			return;

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

		if ( Current == State.Active )
			CheckForElimination();
		else
			EnforceOutOfPlay();

		_wasAlive = _player.IsAlive;
	}

	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;

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

	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;

		// Sit in their head and look where they're looking. CameraMovement
		// was switched off in SetOut, so nothing fights us for the camera
		// transform.
		_player.Camera.WorldPosition = SpectateTarget.EyePos;
		_player.Camera.WorldRotation = SpectateTarget.EyeAngles.ToRotation();
	}

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