Components/Camera/GameplayCamera.cs

Camera component for the racing gameplay. Computes a smooth top-down camera pose that frames player cars, handles zoom, FOV boosts (speed, drift, dash), heading smoothing (including snapping or fixed angle), lead offset and teleport compensation.

File Access
using Machines.Events;
using Machines.GameModes;
using Machines.Player;
using Machines.Race;

namespace Machines.Components;

/// <summary>
/// The main racing camera
/// </summary>
public sealed class GameplayCamera : CameraBehaviour, ICarTeleportListener
{
	[Property, Group( "Smoothing" )]
	public float LerpSpeed { get; set; } = 3f;

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

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

	/// <summary>
	/// Chord length for heading sampling; wider = smoother but cuts corners more.
	/// </summary>
	[Property, Group( "Smoothing" )]
	public float HeadingChord { get; set; } = 150f;

	/// <summary>
	/// Distance ahead to sample heading so the camera anticipates corners.
	/// </summary>
	[Property, Group( "Smoothing" )]
	public float HeadingLookAhead { get; set; } = 200f;

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

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

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

	/// <summary>
	/// Lerp speed for the lead offset (higher = snappier).
	/// </summary>
	[Property, Group( "Smoothing" )]
	public float LeadLerpSpeed { get; set; } = 3f;

	[Property, Group( "Zoom" )]
	public float MinHeight { get; set; } = 400f;

	[Property, Group( "Zoom" )]
	public float MaxHeight { get; set; } = 2000f;

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

	/// <summary>
	/// FOV used to compute framing height only; not the render FOV (see <see cref="BaseFieldOfView"/>).
	/// </summary>
	[Property, Group( "Framing" )]
	public float FramingFieldOfView { get; set; } = 60f;

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

	/// <summary>
	/// Frame all players (including bots/remote) instead of local only.
	/// </summary>
	[Property, Group( "Framing" )]
	public bool IncludeAllPlayers { get; set; } = false;

	/// <summary>
	/// Use a fixed world heading (<see cref="Angle"/>) instead of following the track.
	/// </summary>
	[Property, Group( "Framing" )]
	public bool FixedAngle { get; set; } = false;

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

	/// <summary>
	/// Units ahead of the player group the camera leads, along their facing.
	/// </summary>
	[Property, Group( "Framing" ), Range( 0f, 600f )]
	public float LeadDistance { get; set; } = 200f;

	/// <summary>
	/// Additional lead added at full speed, on top of <see cref="LeadDistance"/>.
	/// </summary>
	[Property, Group( "Framing" ), Range( 0f, 300f )]
	public float LeadSpeedBonus { get; set; } = 50f;

	/// <summary>
	/// Base render FOV at rest; speed and drift bonuses stack on top.
	/// </summary>
	[Property, Group( "FOV" )]
	public float BaseFieldOfView { get; set; } = 60f;

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

	[Property, Group( "FOV" )]
	public float SpeedFovMaxSpeed { get; set; } = 800f;

	[Property, Group( "FOV" )]
	public float FovLerpSpeed { get; set; } = 3f;

	/// <summary>
	/// Instant FOV kick on dash, decaying back at <see cref="FovPunchDecay"/> rate.
	/// </summary>
	[Property, Group( "FOV" )]
	public float DashFovPunch { get; set; } = 10f;

	/// <summary>
	/// Extra FOV while drifting.
	/// </summary>
	[Property, Group( "FOV" )]
	public float DriftFovBoost { get; set; } = 6f;

	/// <summary>
	/// Extra FOV during the post-drift boost window.
	/// </summary>
	[Property, Group( "FOV" )]
	public float DriftBoostFov { get; set; } = 8f;

	/// <summary>
	/// Rate at which the FOV punch rises to its peak (higher = snappier).
	/// </summary>
	[Property, Group( "FOV" )]
	public float FovPunchAttack { get; set; } = 6f;

	/// <summary>
	/// Rate at which FOV punches decay back to rest (higher = snappier).
	/// </summary>
	[Property, Group( "FOV" )]
	public float FovPunchDecay { get; set; } = 2f;

	/// <summary>
	/// Height multiplier while drifting; pulls in so drift feels faster.
	/// </summary>
	[Property, Group( "Zoom" ), Range( 0.7f, 1f )]
	public float DriftZoomScale { get; set; } = 0.92f;

	/// <summary>
	/// Pitch offset while drifting (negative = flatter, more horizon).
	/// </summary>
	[Property, Group( "Framing" ), Range( -20f, 20f )]
	public float DriftPitchOffset { get; set; } = -6f;

	private Vector3 _currentPosition;
	private float _currentHeight;
	private float _currentBaseZ;
	private Vector3 _currentHeading;
	private Vector2 _lastRawCenter;
	private Vector2 _travelVel;
	private float _lastTrackDistance = -1f;
	private bool _initialized;
	private float _currentFovBoost;
	private float _fovPunch;
	private float _fovPunchTarget;
	private float _currentPitchOffset;
	private bool _wasDashing;

	// Drives the camera while racing; other behaviours outrank it when they apply.
	public override bool WantsControl
	{
		get
		{
			var mode = BaseGameMode.Current;
			if ( !mode.IsValid() )
				return false;

			return mode.State is GameModeState.Countdown or GameModeState.Playing or GameModeState.GameOver;
		}
	}

	public override void OnActivated( CameraPose from )
	{
		// Snap framing on first frame; the director cross-fades from `from`.
		_initialized = false;
	}

	// A car teleported (e.g. through a portal)
	public void OnCarTeleported( Car car, Vector3 deltaPosition, float deltaYaw )
	{
		if ( !_initialized )
			return;

		var flat = new Vector2( deltaPosition.x, deltaPosition.y );
		_currentPosition += new Vector3( flat.x, flat.y, 0f );
		_currentBaseZ += deltaPosition.z;
		_lastRawCenter += flat;
		_currentHeading = Rotation.FromYaw( deltaYaw ) * _currentHeading;

		_travelVel = Vector2.Zero;
		_lastTrackDistance = -1f;
	}

	public override CameraPose Evaluate( CameraPose current )
	{
		return ComputeFraming( out var pose ) ? pose : current;
	}

	/// <summary>
	/// Smoothed gameplay pose for this frame; returns false when no players to frame.
	/// </summary>
	private bool ComputeFraming( out CameraPose pose )
	{
		pose = default;

		var players = GatherPlayers();
		if ( players.Count == 0 )
			return false;

		// Bounding box on XY plane, average Z, and average car facing.
		var min = new Vector2( float.MaxValue, float.MaxValue );
		var max = new Vector2( float.MinValue, float.MinValue );
		float avgZ = 0f;
		var forwardSum = Vector2.Zero;

		foreach ( var (p, fwd) in players )
		{
			if ( p.x < min.x ) min.x = p.x;
			if ( p.y < min.y ) min.y = p.y;
			if ( p.x > max.x ) max.x = p.x;
			if ( p.y > max.y ) max.y = p.y;
			avgZ += p.z;
			forwardSum += new Vector2( fwd.x, fwd.y );
		}

		avgZ /= players.Count;
		var carsForward = forwardSum.LengthSquared > 0.0001f ? forwardSum.Normal : Vector2.Zero;

		// Group center this frame, before any lead offset.
		var rawCenter = (min + max) * 0.5f;

		// Smoothed travel velocity (units/s) for lag compensation.
		var instTravel = Time.Delta > 0f ? (rawCenter - _lastRawCenter) / Time.Delta : Vector2.Zero;
		_lastRawCenter = rawCenter;
		_travelVel = _initialized ? Vector2.Lerp( _travelVel, instTravel, Time.Delta * LeadLerpSpeed ) : Vector2.Zero;

		// Lead offset ahead of cars, scaled up with forward speed.
		var fwdSpeed = MathF.Max( Vector2.Dot( _travelVel, carsForward ), 0f );
		var speedFrac = SpeedFovMaxSpeed > 0.001f ? MathX.Clamp( fwdSpeed / SpeedFovMaxSpeed, 0f, 1f ) : 0f;
		var leadDist = LeadDistance + LeadSpeedBonus * speedFrac;
		var lead = (leadDist > 0f && carsForward != Vector2.Zero) ? carsForward * leadDist : Vector2.Zero;

		// Feed-forward to cancel smoothing lag; strip backward component so reversing doesn't flip framing.
		var lagComp = LerpSpeed > 0.001f ? _travelVel / LerpSpeed : Vector2.Zero;
		if ( carsForward != Vector2.Zero )
		{
			var behind = MathF.Min( Vector2.Dot( lagComp, carsForward ), 0f );
			lagComp -= carsForward * behind;
		}

		var center = rawCenter + lead + lagComp;

		// Height needed to fit all players (larger axis extent).
		var extentX = (max.x - min.x) * 0.5f + Padding;
		var extentY = (max.y - min.y) * 0.5f + Padding;
		var extent = MathF.Max( extentX, extentY );

		var halfFovRad = MathX.DegreeToRadian( FramingFieldOfView * 0.5f );
		var targetHeight = (extent / MathF.Tan( halfFovRad )).Clamp( MinHeight, MaxHeight );

		// Pull in and flatten pitch while drifting.
		var localCar = Car.Local;
		var localDrifting = localCar.IsValid() && localCar.Drift.IsValid() && localCar.Drift.IsDrifting;
		if ( localDrifting )
			targetHeight *= DriftZoomScale;

		_currentPitchOffset = MathX.Lerp( _currentPitchOffset, localDrifting ? DriftPitchOffset : 0f, Time.Delta * ZoomLerpSpeed );

		// Heading: fixed yaw or track-following.
		Vector3 targetHeading;
		if ( FixedAngle )
		{
			var rad = MathX.DegreeToRadian( Angle );
			targetHeading = new Vector3( MathF.Cos( rad ), MathF.Sin( rad ), 0f );
		}
		else
		{
			targetHeading = SnapHeading( GetTrackHeading( new Vector3( center.x, center.y, 0f ) ) );
		}

		// First frame: snap.
		if ( !_initialized )
		{
			_currentPosition = new Vector3( center.x, center.y, 0f );
			_currentHeight = targetHeight;
			_currentBaseZ = avgZ;
			_currentHeading = targetHeading;
			_initialized = true;
		}

		var dt = Time.Delta;
		_currentPosition.x = MathX.Lerp( _currentPosition.x, center.x, dt * LerpSpeed );
		_currentPosition.y = MathX.Lerp( _currentPosition.y, center.y, dt * LerpSpeed );
		_currentHeight = MathX.Lerp( _currentHeight, targetHeight, dt * ZoomLerpSpeed );
		_currentBaseZ = MathX.Lerp( _currentBaseZ, avgZ, dt * LerpSpeed );

		if ( FixedAngle )
		{
			_currentHeading = targetHeading;
		}
		else
		{
			// Reject >120° flips (projection error); rate-limit the rest.
			if ( Vector3.Dot( _currentHeading, targetHeading ) < -0.5f )
				targetHeading = _currentHeading;

			_currentHeading = ClampedAngularLerp( _currentHeading, targetHeading, dt * HeadingLerpSpeed, MaxHeadingSpeed * dt );
		}

		var groundCenter = new Vector3( _currentPosition.x, _currentPosition.y, _currentBaseZ );
		pose = TopDown( groundCenter, _currentHeading, PitchAngle + _currentPitchOffset, _currentHeight, ComputeRenderFov() );
		return true;
	}

	/// <summary>
	/// Render FOV: base + speed/drift/boost bonuses + decaying dash punch.
	/// </summary>
	private float ComputeRenderFov()
	{
		var car = Car.Local;
		var speed = car.IsValid() && car.Movement.IsValid() ? MathF.Abs( car.Movement.CurrentSpeed ) : 0f;
		var drifting = car.IsValid() && car.Drift.IsValid() && car.Drift.IsDrifting;
		var driftBoosting = car.IsValid() && car.Movement.IsValid() && car.Movement.IsDriftBoosting;

		var targetBoost = (speed / SpeedFovMaxSpeed).Clamp( 0f, 1f ) * SpeedFovBoost;
		if ( drifting )
			targetBoost += DriftFovBoost;

		// Post-drift boost: widen FOV during the boost window.
		if ( driftBoosting )
			targetBoost += DriftBoostFov;

		_currentFovBoost = MathX.Lerp( _currentFovBoost, targetBoost, Time.Delta * FovLerpSpeed );

		// One-shot punch on dash rising edge, eased not snapped.
		var dashing = car.IsValid() && car.Boost.IsValid() && car.Boost.IsDashing;
		if ( dashing && !_wasDashing )
			_fovPunchTarget = MathF.Max( _fovPunchTarget, DashFovPunch );
		_wasDashing = dashing;

		_fovPunchTarget = MathX.Lerp( _fovPunchTarget, 0f, Time.Delta * FovPunchDecay );
		_fovPunch = MathX.Lerp( _fovPunch, _fovPunchTarget, Time.Delta * FovPunchAttack );

		return BaseFieldOfView + _currentFovBoost + _fovPunch;
	}

	/// <summary>
	/// Lerp two flat headings along the shortest arc, capped at <paramref name="maxDegrees"/> per call.
	/// </summary>
	private static Vector3 ClampedAngularLerp( Vector3 from, Vector3 to, float t, float maxDegrees )
	{
		var fromAngle = MathF.Atan2( from.y, from.x );
		var toAngle = MathF.Atan2( to.y, to.x );

		var delta = toAngle - fromAngle;
		if ( delta > MathF.PI ) delta -= MathF.PI * 2f;
		if ( delta < -MathF.PI ) delta += MathF.PI * 2f;

		var step = delta * MathX.Clamp( t, 0f, 1f );

		var maxRad = MathX.DegreeToRadian( maxDegrees );
		step = MathX.Clamp( step, -maxRad, maxRad );

		var angle = fromAngle + step;
		return new Vector3( MathF.Cos( angle ), MathF.Sin( angle ), 0f );
	}

	/// <summary>
	/// Flat track direction at a world point from the optimal line; falls back to +Y if none.
	/// </summary>
	private Vector3 GetTrackHeading( Vector3 sample )
	{
		var fallback = new Vector3( 0f, 1f, 0f );

		var line = RacingPath.Current?.Optimal;
		if ( line is null || !line.IsValid )
			return fallback;

		// Windowed projection to avoid snapping to a parallel section across the loop.
		var projected = line.GetDistanceAtPosition( sample, _lastTrackDistance, 800f );

		// Fall back to a full search if the windowed projection landed too far away.
		var projectedPoint = line.GetPointAtDistance( projected );
		var flatError = (projectedPoint - sample).WithZ( 0f ).Length;
		if ( flatError > 600f )
			projected = line.GetDistanceAtPosition( sample );

		_lastTrackDistance = projected;

		// Wide chord centred ahead, sampled on the spline to avoid faceting.
		var center = _lastTrackDistance + HeadingLookAhead;
		var ahead = line.GetSplinePointAtDistance( center + HeadingChord ).WithZ( 0f );
		var behind = line.GetSplinePointAtDistance( center - HeadingChord ).WithZ( 0f );
		var heading = ahead - behind;
		return heading.LengthSquared > 0.0001f ? heading.Normal : fallback;
	}

	/// <summary>
	/// Snap heading to nearest <see cref="HeadingSnapIncrement"/> if within <see cref="HeadingSnapThreshold"/> degrees.
	/// </summary>
	private Vector3 SnapHeading( Vector3 dir )
	{
		if ( HeadingSnapIncrement <= 0f || HeadingSnapThreshold <= 0f )
			return dir;

		var angle = MathX.RadianToDegree( MathF.Atan2( dir.y, dir.x ) );
		var nearest = MathF.Round( angle / HeadingSnapIncrement ) * HeadingSnapIncrement;

		if ( MathF.Abs( angle - nearest ) > HeadingSnapThreshold )
			return dir;

		var rad = MathX.DegreeToRadian( nearest );
		return new Vector3( MathF.Cos( rad ), MathF.Sin( rad ), 0f );
	}

	/// <summary>
	/// Collect positions and forward vectors of all valid spawned player cars.
	/// </summary>
	private List<(Vector3 Position, Vector3 Forward)> GatherPlayers()
	{
		var players = new List<(Vector3, Vector3)>();

		foreach ( var car in Scene.GetAllComponents<Car>() )
		{
			if ( !car.IsValid() || car.Slot < 0 )
				continue;

			if ( IncludeAllPlayers || car.IsLocalPlayer )
				players.Add( (car.WorldPosition, car.WorldRotation.Forward) );
		}

		return players;
	}
}