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