Components/Camera/SpectatorCamera.cs

Spectator camera component for late-joining players. It picks a car to spectate, allows switching targets with input, smoothly follows the selected car from a top-down framing (fixed or adaptive heading) and provides camera pose values (position, heading, FOV).

Networking
using Machines.GameModes;
using Machines.Player;

namespace Machines.Components;

/// <summary>
/// Spectator camera for late joiners
/// </summary>
public sealed class SpectatorCamera : CameraBehaviour
{
	[Property, Group( "Framing" )]
	public float PitchAngle { get; set; } = 55f;

	[Property, Group( "Framing" )]
	public float Height { get; set; } = 500f;

	[Property, Group( "Framing" )]
	public bool FixedAngle { get; set; } = false;

	[Property, Group( "Framing" ), Range( 0f, 360f ), ShowIf( "FixedAngle", true )]
	public float Angle { get; set; } = 90f;

	[Property, Group( "Framing" )]
	public float FieldOfView { get; set; } = 60f;

	[Property, Group( "Smoothing" )]
	public float LerpSpeed { get; set; } = 4f;

	[Property, Group( "Smoothing" )]
	public float HeadingLerpSpeed { get; set; } = 5f;

	public override int Priority => 20;

	/// <summary>
	/// Display name of the player currently being spectated (used by the HUD).
	/// </summary>
	public string TargetName => _target.IsValid() ? _target.DisplayName : "";

	private Car _target;
	private int _targetIndex;
	private Vector3 _currentPosition;
	private Vector3 _currentHeading;
	private bool _initialized;

	public override bool WantsControl
	{
		get
		{
			var mode = BaseGameMode.Current;
			if ( !mode.IsValid() || mode.State != GameModeState.Playing )
				return false;

			return !Car.Local.IsValid();
		}
	}

	public override void OnActivated( CameraPose from )
	{
		_initialized = false;
		_targetIndex = 0;
		PickNextTarget( 0 );
	}

	public override CameraPose Evaluate( CameraPose current )
	{
		HandleTargetSwitching();

		if ( !_target.IsValid() )
			PickNextTarget( 0 );

		if ( !_target.IsValid() )
			return current;

		return FollowTarget();
	}

	private void HandleTargetSwitching()
	{
		if ( Input.Pressed( "MenuRight" ) )
			PickNextTarget( 1 );
		else if ( Input.Pressed( "MenuLeft" ) )
			PickNextTarget( -1 );
	}

	private void PickNextTarget( int direction )
	{
		var cars = GetSpectateTargets();
		if ( cars.Count == 0 )
		{
			_target = null;
			return;
		}

		_targetIndex = (_targetIndex + direction + cars.Count) % cars.Count;
		_target = cars[_targetIndex];
	}

	/// <summary>
	/// All valid cars to spectate, sorted by slot index for stable ordering.
	/// </summary>
	private List<Car> GetSpectateTargets()
	{
		var cars = new List<Car>();
		foreach ( var car in Scene.GetAllComponents<Car>() )
		{
			if ( car.IsValid() && car.Slot >= 0 )
				cars.Add( car );
		}

		cars.Sort( ( a, b ) => a.Slot.CompareTo( b.Slot ) );
		return cars;
	}

	private CameraPose FollowTarget()
	{
		var targetPos = _target.WorldPosition;

		Vector3 heading;
		if ( FixedAngle )
		{
			var rad = MathX.DegreeToRadian( Angle );
			heading = new Vector3( MathF.Cos( rad ), MathF.Sin( rad ), 0f );
		}
		else
		{
			heading = RacingLineTangent( targetPos, _target.WorldRotation.Forward.WithZ( 0f ) );
		}

		if ( !_initialized )
		{
			_currentPosition = targetPos;
			_currentHeading = heading;
			_initialized = true;
		}

		var dt = Time.Delta;
		_currentPosition = Vector3.Lerp( _currentPosition, targetPos, dt * LerpSpeed );

		if ( FixedAngle )
			_currentHeading = heading;
		else
			_currentHeading = Vector3.Lerp( _currentHeading, heading, dt * HeadingLerpSpeed ).Normal;

		if ( _currentHeading.LengthSquared < 0.001f )
			_currentHeading = Vector3.Forward;

		return TopDown( _currentPosition, _currentHeading, PitchAngle, Height, FieldOfView );
	}
}