GameLoop/RaceMode.cs

RaceMode gamemode for a lap-based race. Tracks per-player PlayerRaceState (checkpoints, laps, times), handles checkpoint hits, lap completion, ghost recording/spawning, standings, finish countdown and state transitions.

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

namespace Machines.GameModes;

/// <summary>
/// Per-player race state tracked by the gamemode.
/// </summary>
public struct PlayerRaceState
{
	public int NextCheckpointIndex;
	public int CurrentLap;
	public float LapStartTime;
	public float LastLapTime;
	public float BestLapTime;
	public bool HasFinished;
	public float FinishTime;

	public static PlayerRaceState Create()
	{
		return new PlayerRaceState
		{
			NextCheckpointIndex = 0,
			CurrentLap = 1,
			LapStartTime = Time.Now,
			LastLapTime = 0f,
			BestLapTime = float.MaxValue,
			HasFinished = false,
			FinishTime = 0f
		};
	}
}

/// <summary>
/// Lap-based racing mode: checkpoint progression, ghost spawning, per-player race state.
/// </summary>
public sealed class RaceMode : BaseGameMode, ICheckpointListener
{
	/// <summary>
	/// Number of laps to complete the race (min 1).
	/// </summary>
	[ConVar( "game_total_laps", Saved = true, Min = 1, Max = 6, Flags = ConVarFlags.Replicated | ConVarFlags.GameSetting )]
	public static int TotalLaps { get; set; } = 4;

	/// <summary>
	/// Seconds remaining for the field after the first finisher crosses the line.
	/// </summary>
	[Property]
	public float FinishCountdownDuration { get; set; } = 60f;

	/// <summary>
	/// True once the first-finisher countdown has begun.
	/// </summary>
	[Sync]
	public bool FinishCountdownActive { get; set; }

	/// <summary>
	/// When the post-finish countdown expires. Valid only while <see cref="FinishCountdownActive"/> is true.
	/// </summary>
	[Sync]
	public TimeUntil FinishCountdownEndsAt { get; set; }

	/// <summary>
	/// Seconds left on the post-finish countdown (0 when inactive or expired).
	/// </summary>
	public float FinishCountdownRemaining => FinishCountdownActive ? MathF.Max( 0f, FinishCountdownEndsAt ) : 0f;

	/// <summary>
	/// Slot of the player that finished first. -1 = no winner yet.
	/// </summary>
	[Sync]
	public int WinnerSlot { get; set; } = -1;

	/// <summary>
	/// Display name of the first finisher (persisted because cars are destroyed at podium).
	/// </summary>
	[Sync]
	public string WinnerName { get; set; } = "";

	/// <summary>
	/// Race start time for the timer display.
	/// </summary>
	[Sync]
	public float RaceStartTime { get; set; }

	/// <summary>
	/// Per-player race state, keyed by slot index.
	/// </summary>
	[Sync]
	public NetDictionary<int, PlayerRaceState> PlayerStates { get; } = new();

	private GhostPlayer _ghost;
	private GhostRecording _pendingGhostRecording;
	private float _pendingGhostLapTime;

	/// <summary>
	/// Ghost manager component for spawning/despawning ghosts.
	/// </summary>
	[RequireComponent]
	public GhostManager GhostManager { get; set; }

	/// <summary>
	/// Live standings: continuous position and checkpoint-stepped gaps.
	/// </summary>
	[RequireComponent]
	public RaceStandings Standings { get; set; }

	/// <summary>
	/// Get the race state for a player by slot index.
	/// </summary>
	public PlayerRaceState GetPlayerState( int slotIndex )
	{
		return PlayerStates.TryGetValue( slotIndex, out var state ) ? state : default;
	}

	protected override void OnEnabled()
	{
		base.OnEnabled();

		// Preload ghost during WaitingForPlayers so it's ready when the race starts.
		if ( State == GameModeState.WaitingForPlayers )
		{
			_ = PreloadLeaderboardGhost();
		}
	}

	protected override void OnStateEnter( GameModeState state )
	{
		base.OnStateEnter( state );

		switch ( state )
		{
			case GameModeState.Countdown:
				AddText( "Get ready to race!" );
				break;
			case GameModeState.Playing:
				RaceStartTime = Time.Now;
				FinishCountdownActive = false;

				// Reset lap clocks so the first lap doesn't include countdown time.
				foreach ( var slot in PlayerStates.Keys.ToList() )
				{
					var ps = PlayerStates[slot];
					ps.LapStartTime = Time.Now;
					PlayerStates[slot] = ps;
				}

				Standings.ResetStandings();
				StartGhostRecording();
				SpawnPreloadedGhost();
				AddText( "GO!" );
				break;
			case GameModeState.GameOver:
				AddText( "Race Over!" );
				break;
			case GameModeState.Podium:
				if ( WinnerSlot >= 0 )
				{
					AddText( $"Winner: {WinnerName}!" );
				}
				break;
		}
	}

	protected override void OnStateExit( GameModeState state )
	{
		if ( state != GameModeState.Podium ) return;

		WinnerSlot = -1;
		WinnerName = "";
		FinishCountdownActive = false;
		DespawnGhosts();
		PlayerStates.Clear();
		Standings.ResetStandings();
	}

	protected override void SpawnAllPlayers()
	{
		base.SpawnAllPlayers();

		// Initialize race state for each car
		foreach ( var (slotIndex, go) in SpawnedPlayers )
		{
			if ( !go.IsValid() ) continue;

			PlayerStates[slotIndex] = PlayerRaceState.Create();
		}
	}

	protected override void OnPlayerJoined( Connection connection )
	{
		if ( State != GameModeState.WaitingForPlayers ) return; // mid-race joiners spectate

		base.OnPlayerJoined( connection );
	}

	/// <summary>
	/// Attach ghost recorders to non-bot players and start recording (singleplayer only).
	/// </summary>
	private void StartGhostRecording()
	{
		if ( !IsSinglePlayer ) return;

		foreach ( var (_, go) in SpawnedPlayers )
		{
			if ( !go.IsValid() )
				continue;

			var car = go.GetComponent<Car>();
			if ( car.IsValid() && car.IsBot )
				continue;

			var recorder = go.GetOrAddComponent<GhostRecorder>();
			recorder.StartRecording();
		}
	}

	protected override void OnStateTick( GameModeState state )
	{
		if ( state != GameModeState.Playing )
			return;

		// End the race when the post-finish countdown expires.
		if ( FinishCountdownActive && FinishCountdownEndsAt <= 0f )
		{
			AddText( "Time's up!" );
			TransitionTo( GameModeState.GameOver );
			return;
		}

		// End when all real players finish; bots DNF.
		var anyRealPlayers = false;
		var allRealFinished = true;
		foreach ( var (slotIndex, go) in SpawnedPlayers )
		{
			var car = go.IsValid() ? go.GetComponent<Car>() : null;
			if ( !car.IsValid() || car.IsBot )
				continue;

			anyRealPlayers = true;
			if ( !PlayerStates.TryGetValue( slotIndex, out var ps ) || !ps.HasFinished )
			{
				allRealFinished = false;
				break;
			}
		}

		if ( anyRealPlayers && allRealFinished )
		{
			TransitionTo( GameModeState.GameOver );
		}
	}

	/// <summary>
	/// Called via ICheckpointListener when a car enters a checkpoint trigger.
	/// </summary>
	public void OnCheckpointHit( Car car, Checkpoint checkpoint )
	{
		if ( car.Slot < 0 )
		{
			// Log.Info( $"Checkpoint hit but car has no slot" );
			return;
		}

		var slotIndex = car.Slot;

		if ( !PlayerStates.TryGetValue( slotIndex, out var state ) )
		{
			// Log.Info( $"{car.DisplayName} hit checkpoint {checkpoint.Index} but has no race state" );
			return;
		}

		if ( state.HasFinished )
			return;

		// Must hit checkpoints in order
		if ( checkpoint.Index != state.NextCheckpointIndex )
		{
			return;
		}

		Standings.RecordCheckpoint( slotIndex );

		GameStats.Increment( "checkpoints", car: car );

		if ( checkpoint.IsFinishLine )
		{
			CompleteLap( car, ref state );
		}
		else
		{
			state.NextCheckpointIndex = checkpoint.Index + 1;

			// Reward progress; skipped on the finish line since that grants lap points instead.
			car.Score?.RpcAdd( "Checkpoint", 10 );
		}

		PlayerStates[slotIndex] = state;
	}

	private void CompleteLap( Car car, ref PlayerRaceState state )
	{
		var lapTime = Time.Now - state.LapStartTime;
		state.LastLapTime = lapTime;

		var isNewPersonalBest = lapTime < state.BestLapTime;
		if ( isNewPersonalBest )
			state.BestLapTime = lapTime;

		var lap = state.CurrentLap;

		AddText( $"{car.DisplayName} completed lap {lap} ({lapTime:F2}s)" );

		Scene.RunEvent<ILapTimeEvents>( x => x.OnLapCompleted( new LapCompleteInfo( car, lap, lapTime, isNewPersonalBest ) ) );

		// Ghost recording (skip for bots and multiplayer)
		GhostRecording lapRecording = null;
		if ( IsSinglePlayer && !car.IsBot )
		{
			var recorder = car.GetComponent<GhostRecorder>();
			if ( recorder.IsValid() )
			{
				recorder.StopRecording();

				var carPath = car.Resource.IsValid() ? car.Resource.ResourcePath : "";
				lapRecording = recorder.GetRecording( carPath, car.DisplayName );

				recorder.StartRecording(); // restart for next lap
			}
		}

		// Despawn ghost if beaten
		if ( !car.IsBot && _ghost.IsValid() && lapTime < _ghost.LapTime )
		{
			Scene.RunEvent<ILapTimeEvents>( x => x.OnGhostBeaten( new GhostBeatenInfo( car, lapTime, _ghost.LapTime, _ghost.PlayerName ?? "" ) ) );
			GhostManager.Despawn( _ghost );
			_ghost = null;
		}

		// Stats record under the local player; only submit our own new best.
		if ( car.IsLocalPlayer && isNewPersonalBest )
		{
			LapTimeService.SubmitLapTime( GetMapPath(), lapTime, lapRecording );
		}

		state.CurrentLap++;
		state.NextCheckpointIndex = 0;
		state.LapStartTime = Time.Now;

		if ( state.CurrentLap > TotalLaps )
		{
			state.HasFinished = true;
			state.FinishTime = Time.Now;

			GameStats.Increment( "races-finished", car: car );

			car.Autopilot = true; // let bot drive after finish

			if ( WinnerSlot < 0 )
			{
				WinnerSlot = car.Slot;
				WinnerName = car.DisplayName;
				AddText( $"{car.DisplayName} finishes first!" );

				GameStats.Increment( "wins", car: car );
			}

			StartFinishCountdown();
		}
	}

	/// <summary>
	/// Start the post-finish countdown. No-op if already running.
	/// </summary>
	private void StartFinishCountdown()
	{
		if ( FinishCountdownActive )
			return;

		FinishCountdownActive = true;
		FinishCountdownEndsAt = FinishCountdownDuration;
		AddText( $"Race ends in {FinishCountdownDuration:F0} seconds!" );
	}

	private void SpawnGhostFromRecording( GhostRecording recording, float lapTime )
	{
		_ghost = GhostManager.SpawnGhost( recording, lapTime );
	}
	/// <summary>
	/// Map resource path for leaderboard lookups.
	/// </summary>
	private string GetMapPath()
	{
		return GameStats.CurrentMapPath();
	}

	/// <summary>
	/// Fetch the leaderboard ghost during WaitingForPlayers so it's ready at race start.
	/// </summary>
	private async Task PreloadLeaderboardGhost()
	{
		_pendingGhostRecording = null;
		_pendingGhostLapTime = 0f;

		if ( !IsSinglePlayer )
			return;

		var mapPath = GetMapPath();
		if ( string.IsNullOrEmpty( mapPath ) )
			return;

		var entry = await LapTimeService.GetNextAbove( mapPath );
		if ( entry is null )
			return;

		var recording = await LapTimeService.FetchRecording( entry.Value );
		if ( !recording.IsValid() )
			return;

		_pendingGhostRecording = recording;
		_pendingGhostLapTime = (float)entry.Value.Value;
	}

	/// <summary>
	/// Spawn the preloaded ghost. Called on entering the Playing state.
	/// </summary>
	private void SpawnPreloadedGhost()
	{
		if ( _pendingGhostRecording is null )
			return;

		if ( !IsSinglePlayer )
			return;

		SpawnGhostFromRecording( _pendingGhostRecording, _pendingGhostLapTime );
		_pendingGhostRecording = null;
	}

	private void DespawnGhosts()
	{
		GhostManager.Despawn( _ghost );
		_ghost = null;
	}

	protected override void DespawnAllPlayers()
	{
		DespawnGhosts();
		PlayerStates.Clear();
		Standings.ResetStandings();

		base.DespawnAllPlayers();
	}
}