Player/Spectator.cs

A spectator camera component for a game. It implements a free-fly camera for dead or late-joining players, lets the local client move and look around, and can cycle to follow living Player components or stop following to return to free camera.

NetworkingFile Access
using System;
using System.Linq;
using System.Collections.Generic;
using Sandbox;

namespace BrickJam;

/// <summary>
/// Free-fly spectator camera that can cycle through living players. Scene-System port of the
/// legacy <c>Spectator : AnimatedEntity</c> (+ <c>Spectator.Controller</c>).
///
/// Spawned as a connection-owned pawn by <see cref="MansionGame"/> for dead players and late joiners
/// (see <c>MansionGame.Spectator.cs</c>); reclaimed at the next level's <c>RespawnAll</c>.
/// </summary>
[Title( "Spectator" )]
[Category( "Player" )]
public sealed class Spectator : Component
{
	/// <summary>The local (owning) spectator pawn, or null when the local client isn't spectating.</summary>
	public static Spectator Local { get; private set; }

	[Property] public CameraComponent Camera { get; set; }

	[Property] public float WalkSpeed { get; set; } = 200f;
	[Property] public float RunSpeed { get; set; } = 350f;

	[Property, InputAction] public string RunButton { get; set; } = "run";
	[Property, InputAction] public string JumpButton { get; set; } = "jump";

	public Player Following { get; private set; }

	private Angles inputAngles;
	private Vector3 velocity;
	private int lastFollowIndex = -1;

	public Rotation InputRotation => inputAngles.ToRotation();

	protected override void OnStart()
	{
		// Only the owning client views through (and drives) their own spectator camera.
		if ( Camera.IsValid() )
			Camera.Enabled = !IsProxy;

		if ( !IsProxy )
			Local = this;
	}

	protected override void OnDestroy()
	{
		if ( Local == this )
			Local = null;
	}

	protected override void OnUpdate()
	{
		if ( IsProxy )
			return;

		inputAngles += Input.AnalogLook;
		inputAngles = inputAngles.WithPitch( Math.Clamp( inputAngles.pitch, -89.9f, 89.9f ) );

		if ( Input.Pressed( "StopFollowing" ) )
			StopFollowing();
		else if ( Input.Pressed( "FollowNext" ) || (Following is not null && !Following.IsValid()) )
			FollowNext();
		else if ( Input.Pressed( "FollowPrevious" ) )
			FollowPrevious();

		if ( Following.IsValid() )
			UpdateFollowCamera();
		else
			UpdateFreeCamera();
	}

	private void UpdateFreeCamera()
	{
		var wishSpeed = Input.Down( RunButton ) ? RunSpeed : WalkSpeed;
		var wishVelocity = Input.AnalogMove.WithZ( Input.Down( JumpButton ) ? 1f : 0f ).Normal
			* InputRotation * wishSpeed;

		var accel = wishVelocity.LengthSquared > velocity.LengthSquared ? 15f : 5f;
		velocity = velocity.LerpTo( wishVelocity, accel * Time.Delta );

		WorldPosition += velocity * Time.Delta;

		if ( Camera.IsValid() )
		{
			Camera.WorldPosition = WorldPosition;
			Camera.WorldRotation = InputRotation;
			Camera.FieldOfView = Screen.CreateVerticalFieldOfView( 60f );
		}
	}

	private void UpdateFollowCamera()
	{
		if ( !Camera.IsValid() )
			return;

		Camera.WorldPosition = Following.EyePosition;
		Camera.WorldRotation = Following.InputRotation;
		Camera.FieldOfView = Screen.CreateVerticalFieldOfView( 60f );
	}

	private List<Player> LivingPlayers()
		=> Scene.GetAllComponents<Player>().Where( p => p.IsAlive ).ToList();

	private void FollowNext()
	{
		var players = LivingPlayers();
		if ( players.Count == 0 ) { StopFollowing(); return; }

		var index = Following is null ? 0 : players.IndexOf( Following );
		lastFollowIndex = index == -1 ? Math.Max( lastFollowIndex, 0 ) % players.Count : (index + 1) % players.Count;
		Following = players[lastFollowIndex];
	}

	private void FollowPrevious()
	{
		var players = LivingPlayers();
		if ( players.Count == 0 ) { StopFollowing(); return; }

		var index = Following is null ? players.Count - 1 : players.IndexOf( Following );
		lastFollowIndex = index == -1
			? Math.Max( lastFollowIndex, 0 ) % players.Count
			: (index == 0 ? players.Count - 1 : index - 1) % players.Count;
		Following = players[lastFollowIndex];
	}

	private void StopFollowing()
	{
		if ( Following.IsValid() )
		{
			WorldPosition = Following.EyePosition;
			inputAngles = Following.InputRotation.Angles();
		}

		Following = null;
		lastFollowIndex = -1;
	}
}