GameLoop/BaseGameMode.cs

Abstract base game mode component for a racing-style game. Manages game state machine (waiting, countdown, playing, game over, podium), player spawning (real and bot), slot management, timing, and transitions including returning to a lobby and broadcasting chat messages.

NetworkingFile AccessNative Interop
using Machines.Components;
using Machines.Events;
using Machines.Resources;

namespace Machines.GameModes;

/// <summary>
/// Abstract base for game modes: state machine, player spawning, lifecycle hooks.
/// </summary>
public abstract class BaseGameMode : Component
{
	/// <summary>
	/// The currently active game mode instance.
	/// </summary>
	public static BaseGameMode Current { get; private set; }

	/// <summary>
	/// Map resource for this scene, auto-resolved from the active scene if null.
	/// </summary>
	[Property]
	public MapResource Map { get; set; }

	/// <summary>
	/// When true, empty slots are filled with bot players.
	/// </summary>
	[ConVar( "game_fill_bots", Saved = true, Flags = ConVarFlags.Replicated | ConVarFlags.GameSetting )]
	public static bool FillWithBots { get; set; } = false;

	/// <summary>
	/// Max players (real + bots), derived from <see cref="MapResource"/>.
	/// </summary>
	public int MaxPlayers => Map?.MaxPlayers ?? 4;

	/// <summary>
	/// Prefab to spawn for each player.
	/// </summary>
	[Property]
	public GameObject PlayerPrefab { get; set; }

	/// <summary>
	/// Scene to load when returning to the lobby.
	/// </summary>
	protected SceneFile LobbyScene => ResourceLibrary.Get<SceneFile>( "scenes/minimal.scene" );

	/// <summary>
	/// Podium stage in the scene (found even when disabled). Enabled during the Podium state.
	/// </summary>
	public GameObject PodiumObject => Scene.Components.GetInDescendants<PodiumStage>( includeDisabled: true )?.GameObject;

	/// <summary>
	/// Current state of the game mode.
	/// </summary>
	[Sync]
	public GameModeState State { get; set; } = GameModeState.WaitingForPlayers;

	/// <summary>
	/// Timer tracking when the current timed state ends.
	/// </summary>
	[Sync]
	public TimeUntil StateEndsAt { get; set; }

	/// <summary>
	/// Maximum duration of the waiting-for-players phase in seconds.
	/// </summary>
	[ConVar( "game_waiting_time", Saved = true, Min = 0, Max = 120, Flags = ConVarFlags.Replicated | ConVarFlags.GameSetting )]
	public static float WaitingDuration { get; set; } = 30f;

	/// <summary>
	/// Reduced waiting duration once all expected players have joined.
	/// </summary>
	[Property]
	public float WaitingReducedDuration { get; set; } = 5f;

	/// <summary>
	/// Duration of the countdown phase in seconds.
	/// </summary>
	[ConVar( "game_countdown_time", Saved = true, Min = 0, Max = 10, Flags = ConVarFlags.Replicated | ConVarFlags.GameSetting )]
	public static float CountdownDuration { get; set; } = 3;

	/// <summary>
	/// Duration of the podium phase in seconds.
	/// </summary>
	[Property]
	public float PodiumDuration { get; set; } = 15f;

	/// <summary>
	/// Pause on the game-over state before moving to the podium, so the finish can breathe.
	/// </summary>
	[Property]
	public float GameOverDuration { get; set; } = 3f;

	/// <summary>
	/// Seconds remaining in the current timed state.
	/// </summary>
	public float TimeRemaining => MathF.Max( 0f, StateEndsAt );

	/// <summary>
	/// Spawned player GameObjects, keyed by slot index.
	/// </summary>
	protected Dictionary<int, GameObject> SpawnedPlayers { get; } = new();

	/// <summary>
	/// Connections seen this round, for late-join detection.
	/// </summary>
	private readonly HashSet<Guid> _knownConnections = new();

	/// <summary>
	/// Whether bots have already been spawned this round.
	/// </summary>
	private bool _botsSpawned;

	protected override void OnEnabled()
	{
		Current = this;
		ResolveMapResource();

		// Podium should start disabled; correct it before OnStart runs.
		MirrorPodiumToState();

		if ( State == GameModeState.WaitingForPlayers )
			StateEndsAt = WaitingDuration;
	}

	/// <summary>
	/// Auto-resolve Map from the active scene if not set in the editor.
	/// </summary>
	private void ResolveMapResource()
	{
		if ( Map is not null )
			return;

		var activeScene = Game.ActiveScene?.Source;
		if ( activeScene is null )
			return;

		Map = ResourceLibrary.GetAll<MapResource>()
			.FirstOrDefault( m => m.Scene == activeScene );
	}

	protected override void OnDisabled()
	{
		if ( Current == this )
			Current = null;
	}

	/// <summary>
	/// True if we have authority (host or offline/dev mode).
	/// </summary>
	private bool IsAuthority => !Networking.IsActive || Networking.IsHost;

	/// <summary>
	/// True when there's only one real player - used to gate singleplayer-only features (e.g. ghosts).
	/// </summary>
	protected bool IsSinglePlayer => Connection.All.Count <= 1;

	protected override void OnUpdate()
	{
		// Mirror podium state each frame (avoids missed one-shot RPC on late joiners).
		MirrorPodiumToState();

		if ( !IsAuthority )
			return;

		// Forget gone connections so a rejoin re-triggers OnPlayerJoined.
		_knownConnections.RemoveWhere( id => !Connection.All.Any( c => c.Id == id ) );

		// Detect newly joined players
		foreach ( var connection in Connection.All )
		{
			if ( _knownConnections.Add( connection.Id ) )
				OnPlayerJoined( connection );
		}

		switch ( State )
		{
			case GameModeState.WaitingForPlayers:
				TickWaiting();
				break;
			case GameModeState.Countdown:
				OnStateTick( State );
				if ( StateEndsAt <= 0f )
					TransitionTo( GameModeState.Playing );
				break;
			case GameModeState.Playing:
				OnStateTick( State );
				break;
			case GameModeState.GameOver:
				OnStateTick( State );
				if ( StateEndsAt <= 0f )
					TransitionTo( GameModeState.Podium );
				break;
			case GameModeState.Podium:
				OnStateTick( State );
				if ( StateEndsAt <= 0f )
					ReturnToLobby();
				break;
		}
	}

	/// <summary>
	/// Transition to a new state, calling lifecycle hooks.
	/// </summary>
	protected void TransitionTo( GameModeState newState )
	{
		var oldState = State;
		State = newState;

		OnStateExit( oldState );
		OnStateEnter( newState );

		Scene.RunEvent<IGameStateChanged>( x => x.OnGameStateChanged( oldState, newState ) );
	}

	/// <summary>
	/// Tick for WaitingForPlayers: spawns cars and transitions to countdown when timer expires.
	/// </summary>
	protected virtual void TickWaiting()
	{
		SpawnAllPlayers();

		// Fill remaining slots with bots once real players have spawned.
		if ( !_botsSpawned && FillWithBots && SpawnedPlayers.Count > 0 )
		{
			SpawnBotPlayers();
			_botsSpawned = true;

			// Randomize the grid as soon as the field is full so the player isn't parked on pole.
			ShuffleStartingGrid();
		}

		if ( AreAllPlayersReady() )
		{
			if ( StateEndsAt > WaitingReducedDuration )
				StateEndsAt = WaitingReducedDuration;
		}

		if ( StateEndsAt <= 0f )
		{
			TransitionTo( GameModeState.Countdown );
		}
	}

	/// <summary>
	/// Returns true when all expected players have a car and MinPlayers is met.
	/// </summary>
	protected virtual bool AreAllPlayersReady()
	{
		if ( !Networking.IsActive )
			return true;

		var realCars = SpawnedPlayers.Values.Count( go => go.IsValid() && !(go.GetComponent<Player.Car>()?.IsBot ?? false) );
		var minRequired = Map?.MinPlayers ?? 1;

		// Connections beyond MaxPlayers are spectators; don't block countdown.
		return realCars >= minRequired && realCars >= Math.Min( Connection.All.Count, MaxPlayers );
	}

	/// <summary>
	/// Called on state entry. Override to add custom behavior.
	/// </summary>
	protected virtual void OnStateEnter( GameModeState state )
	{
		switch ( state )
		{
			case GameModeState.WaitingForPlayers:
				StateEndsAt = WaitingDuration;
				break;
			case GameModeState.Countdown:
				SpawnAllPlayers();
				ShuffleStartingGrid();
				StateEndsAt = CountdownDuration;
				break;
			case GameModeState.Playing:
				break;
			case GameModeState.GameOver:
				StateEndsAt = GameOverDuration;
				break;
			case GameModeState.Podium:
				StateEndsAt = PodiumDuration;
				break;
		}
	}

	/// <summary>
	/// Called on state exit. Override to add custom behavior.
	/// </summary>
	protected virtual void OnStateExit( GameModeState state )
	{
	}

	/// <summary>
	/// Called each frame in a given state (host only). Override for gameplay logic.
	/// </summary>
	protected virtual void OnStateTick( GameModeState state )
	{
	}

	/// <summary>
	/// Spawn a player prefab at a SpawnPoint for each connection.
	/// </summary>
	protected virtual void SpawnAllPlayers()
	{
		foreach ( var connection in Connection.All )
		{
			SpawnPlayer( connection );
		}
	}

	/// <summary>
	/// Spawn a car for a connection. No-op if already spawned, no prefab, or race is full.
	/// </summary>
	protected virtual void SpawnPlayer( Connection connection )
	{
		if ( !PlayerPrefab.IsValid() )
		{
			Log.Warning( "BaseGameMode: No PlayerPrefab assigned." );
			return;
		}

		PruneDeadEntries();

		if ( SpawnedPlayers.Values.Any( go => go.IsValid() && go.Network.OwnerId == connection.Id ) )
			return;

		if ( SpawnedPlayers.Count >= MaxPlayers ) // full, this connection spectates
			return;

		var slot = NextFreeSlot();
		var go = SpawnCarObject( slot, isBot: false );
		if ( !go.IsValid() )
			return;

		go.Name = $"Player {connection.DisplayName}";

		var car = go.GetComponent<Player.Car>();
		if ( car.IsValid() )
		{
			// Use player's chosen car if set, else keep the prefab default.
			var path = connection.GetUserData( "preferred_car" );
			var preferred = string.IsNullOrEmpty( path ) ? null : ResourceLibrary.Get<Resources.CarResource>( path );
			if ( preferred is not null )
				car.Resource = preferred;
		}

		go.NetworkSpawn( connection );
		SpawnedPlayers[slot] = go;
	}

	/// <summary>
	/// Spawn a host-owned bot car in the next free slot.
	/// </summary>
	protected virtual void SpawnBot()
	{
		if ( !PlayerPrefab.IsValid() )
			return;

		var slot = NextFreeSlot();
		var go = SpawnCarObject( slot, isBot: true );
		if ( !go.IsValid() )
			return;

		var car = go.GetComponent<Player.Car>();
		if ( car.IsValid() )
		{
			go.Name = $"Bot {car.DisplayName}";

			// Bots get a random car.
			var allCars = ResourceLibrary.GetAll<Resources.CarResource>().ToList();
			if ( allCars.Count > 0 )
				car.Resource = Game.Random.FromList( allCars );
		}

		var brainType = GetBotBrain();
		if ( brainType is not null )
			go.Components.Create( TypeLibrary.GetType( brainType ), true );

		go.NetworkSpawn();
		SpawnedPlayers[slot] = go;
	}

	/// <summary>
	/// Clone the player prefab at the slot's spawn point and assign slot/bot identity.
	/// </summary>
	private GameObject SpawnCarObject( int slot, bool isBot )
	{
		var spawnPoint = Machines.Components.SpawnPoint.ForSlot( slot );
		if ( !spawnPoint.IsValid() )
		{
			Log.Error( $"Couldn't find spawn point for slot {slot}" );
			return null;
		}

		var go = PlayerPrefab.Clone( spawnPoint.WorldPosition, spawnPoint.WorldRotation );

		var car = go.GetComponent<Player.Car>();
		if ( car.IsValid() )
		{
			car.Slot = slot;
			car.IsBot = isBot;
		}

		return go;
	}

	/// <summary>
	/// Lowest slot index with no live spawned car.
	/// </summary>
	private int NextFreeSlot()
	{
		var i = 0;
		while ( SpawnedPlayers.TryGetValue( i, out var go ) && go.IsValid() )
			i++;
		return i;
	}

	/// <summary>
	/// Randomize the starting grid: every car (players and bots alike) is shuffled across the front
	/// spawn points, packed densely from the pole. Repositions cars only (slots stay as identity);
	/// standings derive from track progress, so the physical grid order is what counts. Run once the
	/// roster is locked so players aren't always stuck on the pole next to the bots.
	/// </summary>
	protected void ShuffleStartingGrid()
	{
		if ( !IsAuthority )
			return;

		PruneDeadEntries();

		var cars = new List<Player.Car>();
		foreach ( var go in SpawnedPlayers.Values )
		{
			if ( !go.IsValid() )
				continue;

			var car = go.GetComponent<Player.Car>();
			if ( car.IsValid() )
				cars.Add( car );
		}

		if ( cars.Count <= 1 )
			return;

		var ordered = cars.OrderBy( _ => Game.Random.Float() ).ToList();

		for ( var i = 0; i < ordered.Count; i++ )
		{
			var sp = Machines.Components.SpawnPoint.ForSlot( i );
			if ( sp.IsValid() && ordered[i].Movement.IsValid() )
				ordered[i].Movement.TeleportTo( sp.WorldPosition, sp.WorldRotation );
		}
	}

	/// <summary>
	/// Remove entries for destroyed cars so their slots free up.
	/// </summary>
	private void PruneDeadEntries()
	{
		foreach ( var slot in SpawnedPlayers.Where( kv => !kv.Value.IsValid() ).Select( kv => kv.Key ).ToList() )
			SpawnedPlayers.Remove( slot );
	}

	/// <summary>
	/// Called when a new connection appears. Default: evicts a bot if needed, then spawns the player.
	/// </summary>
	protected virtual void OnPlayerJoined( Connection connection )
	{
		MakeRoomForPlayer();
		SpawnPlayer( connection );
	}

	/// <summary>
	/// Returns the brain type to attach to bots. Override per gamemode for different AI.
	/// </summary>
	protected virtual Type GetBotBrain() => typeof( Player.RacingBrain );

	/// <summary>
	/// Fill empty player slots with bots up to MaxPlayers.
	/// </summary>
	protected virtual void SpawnBotPlayers()
	{
		PruneDeadEntries();

		var botsNeeded = MaxPlayers - SpawnedPlayers.Count;
		for ( int i = 0; i < botsNeeded; i++ )
		{
			SpawnBot();
		}
	}

	/// <summary>
	/// Remove a bot to make room for a late-joining player.
	/// </summary>
	public void MakeRoomForPlayer()
	{
		if ( !IsAuthority || State != GameModeState.WaitingForPlayers )
			return;

		PruneDeadEntries();

		if ( SpawnedPlayers.Count < MaxPlayers )
			return;

		var bot = SpawnedPlayers.Values
			.Select( go => go.GetComponent<Player.Car>() )
			.Where( c => c.IsValid() && c.IsBot )
			.OrderByDescending( c => c.Slot )
			.FirstOrDefault();

		if ( !bot.IsValid() )
			return;

		var slot = bot.Slot;

		// Disable before destroy so the bot doesn't clip the joiner's spawn.
		bot.GameObject.Enabled = false;
		bot.GameObject.Destroy();

		SpawnedPlayers.Remove( slot );
	}

	/// <summary>
	/// Destroy all spawned player GameObjects.
	/// </summary>
	protected virtual void DespawnAllPlayers()
	{
		foreach ( var entry in SpawnedPlayers.Where( x => x.Value.IsValid() ) )
		{
			entry.Value.Destroy();
		}
		SpawnedPlayers.Clear();
	}

	/// <summary>
	/// Get the spawned GameObject for a given slot index.
	/// </summary>
	public GameObject GetPlayerObject( int slot )
	{
		SpawnedPlayers.TryGetValue( slot, out var go );
		return go;
	}

	/// <summary>
	/// Return to the lobby scene.
	/// </summary>
	protected virtual void ReturnToLobby()
	{
		DespawnAllPlayers();

		if ( LobbyFlow.Current != null )
			LobbyFlow.Current.ResetForNewRound();

		if ( LobbyScene.IsValid() )
		{
			var options = new SceneLoadOptions()
			{
			};
			options.SetScene( LobbyScene );

			Game.ChangeScene( options );
		}
		else
		{
			Log.Warning( "BaseGameMode: No LobbyScene assigned, cannot return to lobby." );
		}
	}

	/// <summary>
	/// Broadcast a chat message to all connected players.
	/// </summary>
	[Rpc.Broadcast( NetFlags.HostOnly )]
	public void AddText( string message )
	{
		Sandbox.Platform.Chat.AddText( message );
	}

	/// <summary>
	/// Sync podium GameObject enabled state to the current game state (runs locally, avoids missed RPCs).
	/// </summary>
	private void MirrorPodiumToState()
	{
		var podium = PodiumObject;
		if ( !podium.IsValid() )
			return;

		var shouldShow = State == GameModeState.Podium;
		if ( podium.Enabled != shouldShow )
			podium.Enabled = shouldShow;
	}
}