GameLoop/RaceStandings.cs

RaceStandings component that tracks live race order and time gaps. It records per-slot progress along the racing path and checkpoint crossing times, includes optional singleplayer ghost benchmarking, and produces ordered standings with gaps or lap-down indicators.

NetworkingFile Access
using Machines.Components;
using Machines.Ghost;
using Machines.Player;
using Machines.Race;

namespace Machines.GameModes;

/// <summary>
/// Live race standings: continuous position order with checkpoint-stepped time gaps.
/// </summary>
public sealed class RaceStandings : Component
{
	public struct Standing
	{
		public Car Car;             // may be invalid after cars are destroyed (podium)
		public int Slot;
		public string Name;
		public Color Color;
		public bool IsBot;
		public bool IsLocal;
		public ulong SteamId;
		public int Position;        // 1 = leader
		public float GapToAhead;    // seconds to the car ahead (or whole laps if LapsDown); -1 if unknown/leader
		public bool LapsDown;       // true when the gap is expressed in whole laps, not seconds
		public bool IsGhost;        // true if this entry represents the ghost car
		public string GhostName;    // display name for ghost entries
	}

	// Per-slot identity captured while cars are alive, so standings survive podium destruction.
	private struct Entry
	{
		public Car Car;
		public string Name;
		public bool IsBot;
		public bool IsLocal;
		public ulong SteamId;
	}

	private RaceMode _race;

	private readonly Dictionary<int, Entry> _roster = new();
	private readonly Dictionary<int, float> _progress = new();
	private readonly Dictionary<int, float> _lastRaw = new();

	private float _ghostProgress;
	private float _ghostLastRaw;
	private bool _ghostTracking;
	private readonly Dictionary<int, List<float>> _checkpointTimes = new();

	private int _checkpointCount = -1;

	private int CheckpointCount
	{
		get
		{
			if ( _checkpointCount < 0 )
				_checkpointCount = Scene.GetAll<Checkpoint>().Count();
			return _checkpointCount;
		}
	}

	protected override void OnEnabled()
	{
		_race = GetComponent<RaceMode>();
	}

	protected override void OnUpdate()
	{
		if ( _race is null || _race.State != GameModeState.Playing )
			return;

		var path = RacingPath.Current;
		if ( !path.IsValid() )
			return;

		var line = path.Optimal;
		if ( line is null || !line.IsValid )
			return;

		var length = line.TotalLength;
		if ( length < 0.001f )
			return;

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

			var slot = car.Slot;
			_roster[slot] = new Entry
			{
				Car = car,
				Name = car.DisplayName,
				IsBot = car.IsBot,
				IsLocal = car.IsLocalPlayer,
				SteamId = car.Network.Owner is { } owner ? owner.SteamId.ValueUnsigned : 0
			};

			var raw = line.GetDistanceAtPosition( car.WorldPosition );

			if ( !_progress.TryGetValue( slot, out var prog ) )
			{
				var lap = Math.Max( 1, _race.GetPlayerState( slot ).CurrentLap );
				prog = (lap - 1) * length + raw;
			}
			else
			{
				var delta = raw - _lastRaw[slot];
				if ( delta < -length * 0.5f ) delta += length;       // crossed the seam forward
				else if ( delta > length * 0.5f ) delta -= length;   // small backward wobble across the seam
				prog += delta;
			}

			_progress[slot] = prog;
			_lastRaw[slot] = raw;
		}

		// Track ghost progress (singleplayer only)
		var ghost = GhostPlayer.Current;
		if ( ghost.IsValid() && ghost.IsPlaying && IsSinglePlayer )
		{
			var ghostRaw = line.GetDistanceAtPosition( ghost.WorldPosition );

			if ( !_ghostTracking )
			{
				_ghostProgress = ghostRaw;
				_ghostTracking = true;
			}
			else
			{
				var delta = ghostRaw - _ghostLastRaw;
				if ( delta < -length * 0.5f ) delta += length;
				else if ( delta > length * 0.5f ) delta -= length;
				_ghostProgress += delta;
			}

			_ghostLastRaw = ghostRaw;
		}
		else
		{
			_ghostTracking = false;
		}
	}

	private bool IsSinglePlayer => Connection.All.Count <= 1;

	/// <summary>
	/// Reset all tracked progress and gap history.
	/// </summary>
	public void ResetStandings()
	{
		_roster.Clear();
		_progress.Clear();
		_lastRaw.Clear();
		_checkpointTimes.Clear();
		_checkpointCount = -1;
		_ghostProgress = 0f;
		_ghostLastRaw = 0f;
		_ghostTracking = false;
	}

	/// <summary>
	/// Record the time a car crossed a checkpoint (one call per valid in-order crossing).
	/// </summary>
	public void RecordCheckpoint( int slotIndex )
	{
		if ( !_checkpointTimes.TryGetValue( slotIndex, out var times ) )
		{
			times = new List<float>();
			_checkpointTimes[slotIndex] = times;
		}

		times.Add( Time.Now );
	}

	/// <summary>
	/// Total checkpoints crossed: (lap-1)*count + passed this lap. Used for lap-down gap detection only.
	/// </summary>
	private int PassedCount( int slotIndex )
	{
		var n = CheckpointCount;
		if ( n <= 0 )
			return 0;

		var state = _race is null ? default : _race.GetPlayerState( slotIndex );
		var lap = Math.Max( 1, state.CurrentLap );
		return (lap - 1) * n + state.NextCheckpointIndex;
	}

	private float ContinuousProgress( int slotIndex )
	{
		return _progress.TryGetValue( slotIndex, out var p ) ? p : 0f;
	}

	/// <summary>
	/// Race order with time gaps to the car ahead. Finished cars rank first; rest by continuous progress. Gaps update per checkpoint.
	/// </summary>
	public List<Standing> GetStandings()
	{
		PlayerRaceState State( int slot ) => _race is null ? default : _race.GetPlayerState( slot );

		var ordered = _roster
			.OrderByDescending( kv => State( kv.Key ).HasFinished )
			.ThenBy( kv => State( kv.Key ).HasFinished ? State( kv.Key ).FinishTime : 0f )
			.ThenByDescending( kv => ContinuousProgress( kv.Key ) )
			.ToList();

		// Build a combined list including the ghost if active in singleplayer
		var ghost = GhostPlayer.Current;
		var includeGhost = _ghostTracking && ghost.IsValid() && ghost.IsPlaying && IsSinglePlayer;

		var result = new List<Standing>( ordered.Count + (includeGhost ? 1 : 0) );

		// Ghost is a benchmark only - always rank it behind real cars.
		int ghostInsertAt = includeGhost ? ordered.Count : -1;

		int pos = 1;
		for ( var i = 0; i < ordered.Count; i++ )
		{
			if ( includeGhost && i == ghostInsertAt )
			{
				result.Add( new Standing
				{
					Slot = -1,
					Position = pos++,
					GapToAhead = -1f,
					LapsDown = false,
					IsGhost = true,
					GhostName = ghost.PlayerName ?? "Ghost"
				} );
			}

			var (slot, entry) = ordered[i];
			var standing = new Standing
			{
				Car = entry.Car,
				Slot = slot,
				Name = entry.Name,
				Color = PlayerColors.GetColor( slot ),
				IsBot = entry.IsBot,
				IsLocal = entry.IsLocal,
				SteamId = entry.SteamId,
				Position = pos++,
				GapToAhead = -1f,
				LapsDown = false
			};

			if ( standing.Position > 1 && i > 0 )
				standing.GapToAhead = ComputeGap( ordered[i - 1].Key, slot, out standing.LapsDown );

			result.Add( standing );
		}

		// Ghost goes at the end
		if ( includeGhost && ghostInsertAt >= ordered.Count )
		{
			result.Add( new Standing
			{
				Slot = -1,
				Position = pos,
				GapToAhead = -1f,
				LapsDown = false,
				IsGhost = true,
				GhostName = ghost.PlayerName ?? "Ghost"
			} );
		}

		return result;
	}

	/// <summary>
	/// Gap to the car ahead at their last shared checkpoint. Returns whole laps if a lap or more behind.
	/// </summary>
	private float ComputeGap( int aheadSlot, int behindSlot, out bool lapsDown )
	{
		lapsDown = false;

		var n = CheckpointCount;
		var pAhead = PassedCount( aheadSlot );
		var pBehind = PassedCount( behindSlot );

		if ( n > 0 && pAhead - pBehind >= n )
		{
			lapsDown = true;
			return (pAhead - pBehind) / n; // whole laps down
		}

		// Use trailing car's most recent checkpoint as reference.
		if ( pBehind <= 0 )
			return -1f;

		if ( _checkpointTimes.TryGetValue( aheadSlot, out var aheadTimes ) && aheadTimes.Count >= pBehind &&
			 _checkpointTimes.TryGetValue( behindSlot, out var behindTimes ) && behindTimes.Count >= pBehind )
		{
			return MathF.Max( 0f, behindTimes[pBehind - 1] - aheadTimes[pBehind - 1] );
		}

		return -1f;
	}
}