Player/SpectateOthers.cs

Component on the local player that lets a bled-out player spectate other players. It collects valid in-round bodies, lets the user cycle players and switch between first-person and a chase camera, and implements ICameraModifier to set the scene camera and hide the watched body and the local weapon.

NetworkingFile Access
using Sandbox;
using System;
using System.Collections.Generic;
using SWB.Shared;

namespace NZombies;

/// <summary>
/// WATCH THE PLAYERS STILL UP, THROUGH THEIR EYES, ONCE YOU HAVE BLED OUT (2026-10-05). The user: *"When a player bleeds out, they
/// should be able to spectate the other players. Meaning they no longer see themselves in first person like they do now. Instead
/// they see the first person of the other players and can cycle between each player"*.
///
/// ⛔ A BLED-OUT PLAYER USED TO STARE OUT OF THEIR OWN EMPTY SPOT. `NZPlayer.ApplyOutOfRoundBody` takes the body, the collider and
/// the physics, but the `PlayerController` stays on (`OnUpdate` publishes from it), so it went on putting the camera at its own
/// frozen eyes for the rest of the round, with the last-stand pistol still in view and still able to fire.
///
/// ⚠️ GMOD'S CONTROLS (`spectator/sv_override.lua`, `GM:PlayerDeathThink`): left click the next player, right click the previous,
/// R between their eyes and a camera behind them. GMod opens behind them (`OBS_MODE_CHASE`); the user asked for their first person,
/// so this opens there. The players watched are GMod's too: everyone still in the round, downed ones included.
///
/// ⚠️ A CAMERA MODIFIER AT ORDER 100 (`ICameraModifier`): after the player's own controller at 0, before `MwRecoilView` at 200.
/// Writing the camera from `OnUpdate` loses to the controller (`MwRecoilView`'s note). The camera's TRANSFORM is written as well as
/// the view, so what projects through `Scene.Camera` (name tags, prompts, the listener) is where the screen is looking.
///
/// ⚠️ THIS MACHINE ONLY. It lives on my own body (`NZPlayer.OnUpdate` makes it, beside `PowerupMusic`) and does nothing unless that
/// body is mine (`PlayerPresence.Mine`): every camera runs every modifier in the scene. Nothing here is networked.
///
/// ⚠️ THEIR EYES, NOT THEIR ARMS AND GUN. A viewmodel exists only on its owner's machine (weapons are `NetworkMode.Never`), so the
/// first person here is the view from their eyes, with their body (and its third-person gun) hidden so the camera is not inside a
/// head. Their arms and gun in view would need their viewmodel rebuilt on this machine, which is a job of its own.
/// </summary>
public sealed class SpectateOthers : Component, ICameraModifier
{
	/// <summary>The one on this machine, set while my body has it.</summary>
	public static SpectateOthers Instance { get; private set; }

	/// <summary>Put on the watched body in first person; the main camera leaves out anything carrying it, children included.</summary>
	const string HideTag = "nz_spectated";

	int ICameraModifier.CameraOrder => 100;

	// ⚠️ NULLABLE-BACKED, INSTRUCTIONS §1: a static's value survives a hotload, its initialiser does not re-run.
	static float? _smooth, _chaseDistance;

	/// <summary>How fast the view follows the watched player's look. Their angles arrive over the network, so a little smoothing
	/// hides the steps between updates. `nz_watch_tune`.</summary>
	public static float Smooth { get => _smooth ?? 18f; set => _smooth = value; }

	/// <summary>How far behind them the camera sits when watching from behind. `nz_watch_tune`.</summary>
	public static float ChaseDistance { get => _chaseDistance ?? 110f; set => _chaseDistance = value; }

	bool _active;
	NZPlayer _target;
	bool _behind;

	/// <summary>The watched body carrying `HideTag`, so it is the one untagged when that ends.</summary>
	GameObject _hidden;

	/// <summary>My own gun, switched off while I watch, and switched back on if it is still the one in my hands after.</summary>
	GameObject _myWeapon;

	Vector3 _pos;
	Rotation _rot;
	bool _snap = true;
	Angles _orbit;

	readonly List<NZPlayer> _targets = new();

	/// <summary>Who this machine is watching, or null when it is not spectating.</summary>
	public static NZPlayer Watching => Instance is { } s && s._active && s._target.IsValid() ? s._target : null;

	/// <summary>Their name, for the HUD; null when not spectating.</summary>
	public static string WatchingName => Watching is { } p ? NameOf( p ) : null;

	/// <summary>Through their eyes (true), or from behind them.</summary>
	public static bool FirstPerson => Instance is not { } s || !s._behind;

	/// <summary>Their name tag would float over the camera: the one I am looking out of (`PlayerTagsHud`).</summary>
	public static bool HidesTagOf( NZPlayer p ) => p.IsValid() && FirstPerson && Watching == p;

	protected override void OnUpdate()
	{
		var me = Components.Get<NZPlayer>( FindMode.EverythingInSelf );
		if ( !me.IsValid() || !PlayerPresence.Mine( GameObject ) )
		{
			Stop( null );
			return;
		}

		Instance = this;

		if ( !ShouldSpectate( me ) )
		{
			Stop( me );
			return;
		}

		FillTargets( me );

		// ⚠️ NOBODY TO WATCH: the old view stays. With nobody still in the round the run is over anyway (`TickBleedout`).
		if ( _targets.Count == 0 )
		{
			Stop( me );
			return;
		}

		if ( !_active )
		{
			_active = true;
			_behind = false;
			Watch( FirstUp() );
			Log.Info( $"[nz-spec] you bled out: watching {NameOf( _target )} through their eyes"
				+ " (left click next, right click previous, R from behind)" );
		}
		else if ( !_target.IsValid() || !_targets.Contains( _target ) )
		{
			// ⚠️ THE ONE I WAS WATCHING BLED OUT TOO, OR LEFT: on to whoever is still in.
			Watch( FirstUp() );
			Log.Info( $"[nz-spec] the player you watched is out: now watching {NameOf( _target )}" );
		}

		// ⚠️ NOT WHILE A CURSOR MENU IS UP, `TickWeaponSwitch`'s test: a click in the Tab map or a menu is not a request to switch.
		if ( Mouse.Visibility != MouseVisibility.Visible )
		{
			if ( Input.Pressed( InputButtonHelper.PrimaryAttack ) ) Cycle( +1 );
			else if ( Input.Pressed( InputButtonHelper.SecondaryAttack ) ) Cycle( -1 );

			if ( Input.Pressed( InputButtonHelper.Reload ) )
			{
				_behind = !_behind;
				_snap = true;
				if ( _behind ) FaceTheirWay();
				Log.Info( $"[nz-spec] watching {NameOf( _target )} {(_behind ? "from behind" : "through their eyes")}" );
			}

			if ( _behind )
			{
				_orbit += Input.AnalogLook;
				_orbit.pitch = _orbit.pitch.Clamp( -40f, 75f );
				_orbit.roll = 0f;
			}
		}

		HideMyWeapon( me );
		HideBody( _behind ? null : _target );
	}

	protected override void OnDisabled() => Stop( Components.Get<NZPlayer>( FindMode.EverythingInSelf ) );

	protected override void OnDestroy()
	{
		Stop( Components.Get<NZPlayer>( FindMode.EverythingInSelf ) );
		if ( Instance == this ) Instance = null;
	}

	/// <summary>Bled out, in a round that is still running. Not in the lobby, Creative, map spectator or behind the score screen.</summary>
	static bool ShouldSpectate( NZPlayer me )
	{
		if ( !me.IsOutOfRound || NZGame.IsCreative || NZGame.IsSpectator ) return false;

		var rounds = RoundManager.Instance;
		return rounds.IsValid() && rounds.State is RoundState.Prep or RoundState.Active;
	}

	/// <summary>Everyone else still in the round, downed included as in GMod, in a fixed order so next and previous stay put.</summary>
	void FillTargets( NZPlayer me )
	{
		_targets.Clear();

		foreach ( var go in PlayerSpawner.AllBodies() )
		{
			if ( !go.IsValid() || !go.Active ) continue;

			var p = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
			if ( !p.IsValid() || p == me || p.IsOutOfRound ) continue;

			_targets.Add( p );
		}

		_targets.Sort( ById );
	}

	static int ById( NZPlayer a, NZPlayer b ) => a.GameObject.Id.CompareTo( b.GameObject.Id );

	/// <summary>Someone on their feet if anyone is, else the first in the list.</summary>
	NZPlayer FirstUp()
	{
		foreach ( var p in _targets )
			if ( !p.IsDown ) return p;

		return _targets[0];
	}

	void Cycle( int dir )
	{
		if ( _targets.Count < 2 ) return;

		var i = _targets.IndexOf( _target );
		i = i < 0 ? 0 : ((i + dir) % _targets.Count + _targets.Count) % _targets.Count;

		Watch( _targets[i] );
		Log.Info( $"[nz-spec] watching {NameOf( _target )}" );
	}

	void Watch( NZPlayer p )
	{
		_target = p;
		_snap = true;
		if ( _behind ) FaceTheirWay();
	}

	/// <summary>Start the camera behind them, looking where they look.</summary>
	void FaceTheirWay()
	{
		var ctrl = ControllerOf( _target );
		_orbit = new Angles( 15f, ctrl.IsValid() ? ctrl.EyeAngles.yaw : 0f, 0f );
	}

	void ICameraModifier.ModifyCamera( CameraComponent camera, ref CameraView view )
	{
		if ( !_active || !camera.IsValid() || camera != Scene.Camera ) return;

		var ctrl = ControllerOf( _target );
		if ( !ctrl.IsValid() ) return;

		// ⚠️ THEIR EYES THE WAY THE ENGINE PUTS THEM (`MoveMode.CalculateEyeTransform`): the body's position, up by its height
		// less the eye's distance from the top, turned by `EyeAngles`. `IsDucking` and `EyeAngles` are synced from the owner, so
		// this works on a copy of their body; a downed player's copy is ducked (`TickDownedMirror`), which puts the view on the floor
		// with them.
		var eye = ctrl.WorldPosition + Vector3.Up * (ctrl.CurrentHeight - ctrl.EyeDistanceFromTop);

		Vector3 pos;
		Rotation rot;

		if ( !_behind )
		{
			pos = eye;
			rot = ctrl.EyeAngles.ToRotation();
		}
		else
		{
			// ⚠️ THE CONTROLLER'S OWN THIRD-PERSON TRACE, ROUGHLY: back from just above their eyes, stopped short of walls, ignoring
			// their own body and whatever their controller tells its own camera to ignore.
			var pivot = eye + Vector3.Up * 6f;
			rot = _orbit.ToRotation();
			var tr = Scene.Trace.Ray( pivot, pivot - rot.Forward * ChaseDistance )
				.Radius( 6f )
				.IgnoreGameObjectHierarchy( _target.GameObject )
				.WithoutTags( ctrl.CameraCollisionIgnore )
				.Run();
			pos = tr.EndPosition;
		}

		if ( _snap )
		{
			_snap = false;
			_pos = pos;
			_rot = rot;
		}
		else
		{
			var k = 1f - MathF.Exp( -Smooth * Time.Delta );
			_rot = Rotation.Slerp( _rot, rot, k );

			// ⚠️ THROUGH THEIR EYES: ACROSS EXACTLY, UP AND DOWN SMOOTHED. Their position is already interpolated, but a duck arrives
			// as one step in `IsDucking`, and their own camera eases that (`_eyez`) where a copy of their body cannot. From behind,
			// all of it is smoothed, so the camera does not jump where the trace meets a wall.
			_pos = _behind
				? Vector3.Lerp( _pos, pos, k )
				: new Vector3( pos.x, pos.y, MathX.Lerp( _pos.z, pos.z, 1f - MathF.Exp( -12f * Time.Delta ) ) );
		}

		view.Position = _pos;
		view.Rotation = _rot;
		camera.WorldPosition = _pos;
		camera.WorldRotation = _rot;
	}

	/// <summary>
	/// Hide the watched body from the main camera (null: hide none).
	///
	/// ⛔ NOT `viewer` AND NOT `RenderType`. `NZPlayers.Control` takes `viewer` off every body that is not mine and puts any
	/// non-mine renderer back to `ShadowRenderType.On`, twice a second, both load-bearing for cloned bodies. A tag of our own is
	/// left alone, and tags reach children, so the clothing and the third-person gun go with the body.
	/// </summary>
	void HideBody( NZPlayer who )
	{
		var body = BodyOf( who );
		var cam = Scene.Camera;

		if ( body != _hidden )
		{
			if ( _hidden.IsValid() ) _hidden.Tags.Remove( HideTag );
			_hidden = body;
			if ( _hidden.IsValid() ) _hidden.Tags.Add( HideTag );
		}

		if ( cam.IsValid() && _hidden.IsValid() && !cam.RenderExcludeTags.Has( HideTag ) ) cam.RenderExcludeTags.Add( HideTag );
	}

	/// <summary>
	/// My gun off while I watch.
	///
	/// ⛔ A BLED-OUT PLAYER STILL HELD THE LAST-STAND PISTOL (`ReviveAugments.OnDowned`) and nothing took it away, so it drew in front
	/// of whatever the camera showed and fired on the same left click that now picks a player. Switched off rather than taken: Quick
	/// Revive's Last Stand keeps the whole arsenal through a down, and a revive hands the held weapons back (`OnRevived`).
	/// </summary>
	void HideMyWeapon( NZPlayer me )
	{
		var gun = me.Inventory?.Active;
		if ( !gun.IsValid() || !gun.Enabled ) return;

		gun.Enabled = false;
		if ( _myWeapon != gun ) Log.Info( "[nz-spec] your gun is put away while you watch" );
		_myWeapon = gun;
	}

	/// <summary>Back in my own body: the watched one shown again, my gun back on if it is still the one in my hands.</summary>
	void Stop( NZPlayer me )
	{
		if ( !_active && _hidden is null && _myWeapon is null ) return;

		var was = _active;
		_active = false;
		_target = null;

		if ( _hidden.IsValid() ) _hidden.Tags.Remove( HideTag );
		_hidden = null;

		var cam = Scene?.Camera;
		if ( cam.IsValid() ) cam.RenderExcludeTags.Remove( HideTag );

		// ⚠️ ONLY IF IT IS STILL THE ACTIVE ONE. A revive strips the pistol and hands the real guns back, already drawn; turning
		// the old object on again would put a second gun in the hands.
		var active = me.IsValid() ? me.Inventory?.Active : null;
		if ( _myWeapon.IsValid() && active == _myWeapon && !_myWeapon.Enabled ) _myWeapon.Enabled = true;
		_myWeapon = null;

		if ( was ) Log.Info( "[nz-spec] back in your own body" );
	}

	static PlayerController ControllerOf( NZPlayer p )
		=> p.IsValid() ? p.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants ) : null;

	/// <summary>The object their body renderer is on, the same one `ThirdPersonWeapon` hangs the gun from.</summary>
	static GameObject BodyOf( NZPlayer p )
	{
		var ctrl = ControllerOf( p );
		if ( !ctrl.IsValid() ) return null;
		if ( ctrl.Renderer.IsValid() ) return ctrl.Renderer.GameObject;

		// ⚠️ `PlayerController.Renderer` CAN READ NULL (`ThirdPersonWeapon.Body`'s note): the first skinned renderer that is not a viewmodel.
		foreach ( var r in p.Components.GetAll<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )
			if ( r.IsValid() && !r.GameObject.Tags.Has( TagsHelper.ViewModel ) ) return r.GameObject;

		return null;
	}

	static string NameOf( NZPlayer p ) => p.IsValid() ? (NZPlayers.NameOf( p ) ?? p.GameObject.Name) : "nobody";

	/// <summary>`nz_watching` — am I spectating, whom, how, and who could I switch to.</summary>
	[ConCmd( "nz_watching" )]
	public static void Report()
	{
		var me = NZPlayer.Local;
		Log.Info( $"[nz-spec] out of the round: {(me.IsValid() ? me.IsOutOfRound.ToString() : "no player")}"
			+ $" · spectating: {(Watching is { } w ? $"{NameOf( w )} {(FirstPerson ? "through their eyes" : "from behind")}" : "no")}" );

		foreach ( var go in PlayerSpawner.AllBodies() )
		{
			var p = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
			if ( !p.IsValid() || p == me ) continue;
			Log.Info( $"[nz-spec]   {NameOf( p )}: {(p.IsOutOfRound ? "out too, not watchable" : p.IsDown ? "down, watchable" : "up, watchable")}" );
		}
	}

	/// <summary>`nz_watch_tune [smooth] [distance]` — how fast the view follows, and how far behind the camera sits. None reports.</summary>
	[ConCmd( "nz_watch_tune" )]
	public static void Tune( float smooth = -1f, float distance = -1f )
	{
		if ( smooth > 0f ) Smooth = smooth;
		if ( distance > 0f ) ChaseDistance = distance;
		Log.Info( $"[nz-spec] smoothing {Smooth:0.#}, behind distance {ChaseDistance:0}u   (nz_watch_tune <smooth> <distance>)" );
	}
}