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