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