Components/LobbyFlow.cs

LobbyFlow component driving the pre-game lobby state machine. Tracks browsing/countdown/launch states, selected game mode and map, per-connection ready and map votes, builds a map pool from resources, handles host/client input, starts a countdown and launches a scene.

NetworkingFile Access
using Machines.Resources;
using Machines.Systems;

namespace Machines.Components;

public enum LobbyState
{
	Browsing,
	Countdown,
	Launching
}

/// <summary>
/// Drives the pre-game lobby state machine. Host-spawned, networked so clients see the selected mode/map and countdown.
/// </summary>
public sealed class LobbyFlow : Component
{
	public static LobbyFlow Current { get; private set; }

	/// <summary>
	/// Current state of the lobby.
	/// </summary>
	[Sync] public LobbyState State { get; set; } = LobbyState.Browsing;

	/// <summary>
	/// Mode the host is currently browsing.
	/// </summary>
	[Sync] public GameModeType SelectedMode { get; set; } = GameModeType.Race;

	/// <summary>
	/// Resource path of the selected map; see <see cref="SelectedMap"/> for why we sync the path.
	/// </summary>
	[Sync] public string SelectedMapIdent { get; set; }

	/// <summary>
	/// Time until countdown hits zero; synced <see cref="TimeUntil"/> so it is clock-adjusted per client.
	/// </summary>
	[Sync] public TimeUntil CountdownEnds { get; set; }

	/// <summary>
	/// Countdown length in seconds.
	/// </summary>
	public const float CountdownSeconds = 3f;

	/// <summary>
	/// Ready state per connection, keyed by Connection.Id.
	/// </summary>
	[Sync] public NetDictionary<Guid, bool> Ready { get; } = new();

	/// <summary>
	/// True if the local connection is readied up.
	/// </summary>
	public bool IsLocalReady => Connection.Local is not null && Ready.TryGetValue( Connection.Local.Id, out var r ) && r;

	/// <summary>
	/// Set the calling connection's ready state.
	/// </summary>
	[Rpc.Host]
	public void SetReady( bool ready )
	{
		Ready[Rpc.Caller.Id] = ready;
	}

	/// <summary>
	/// Each connection's map vote, keyed by Connection.Id; the host's entry is the launch map.
	/// </summary>
	[Sync] public NetDictionary<Guid, string> BrowsedMaps { get; } = new();

	/// <summary>
	/// Set the caller's map vote; host's choice also becomes the launch map.
	/// </summary>
	[Rpc.Host]
	public void SetBrowsedMap( string ident )
	{
		var caller = Rpc.Caller;
		if ( caller is not null )
			BrowsedMaps[caller.Id] = ident;

		if ( caller is null || caller.IsHost )
			SelectedMapIdent = ident;
	}

	// Map pool by mode, built once on host.
	private Dictionary<GameModeType, List<MapResource>> _mapsByMode;

	/// <summary>
	/// True in single-player/editor or when hosting.
	/// </summary>
	private bool IsAuthority => !Sandbox.Networking.IsActive || Sandbox.Networking.IsHost;

	protected override void OnEnabled()
	{
		Current = this;

		if ( IsAuthority )
		{
			// Clear ready state so returning from a game doesn't re-trigger the countdown.
			Ready.Clear();

			BuildMapPool();
			SelectFirstMap();
		}
	}

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

	/// <summary>
	/// Reset to browsing and clear ready flags; call on return from a game.
	/// </summary>
	public void ResetForNewRound()
	{
		State = LobbyState.Browsing;
		Ready.Clear();
	}

	private void BuildMapPool()
	{
		_mapsByMode = new Dictionary<GameModeType, List<MapResource>>();
		foreach ( var map in ResourceLibrary.GetAll<MapResource>() )
		{
			if ( map.Hidden && !Game.IsEditor )
				continue;

			if ( !_mapsByMode.TryGetValue( map.Mode, out var list ) )
			{
				list = new List<MapResource>();
				_mapsByMode[map.Mode] = list;
			}
			list.Add( map );
		}
	}

	private void SelectFirstMap()
	{
		var maps = MapsForMode( SelectedMode );
		SelectedMapIdent = maps.FirstOrDefault()?.ResourcePath;
	}

	public IReadOnlyList<MapResource> MapsForMode( GameModeType mode )
	{
		if ( _mapsByMode == null ) BuildMapPool();
		return _mapsByMode.TryGetValue( mode, out var list ) ? list : System.Array.Empty<MapResource>();
	}

	/// <summary>
	/// Selected map resolved from the pool; works before the client loads it.
	/// </summary>
	public MapResource SelectedMap => MapByPath( SelectedMapIdent );

	private MapResource MapByPath( string ident )
	{
		if ( string.IsNullOrEmpty( ident ) )
			return null;

		if ( _mapsByMode == null ) BuildMapPool();
		foreach ( var list in _mapsByMode.Values )
		{
			var match = list.FirstOrDefault( m => m.ResourcePath == ident );
			if ( match != null )
				return match;
		}

		return ResourceLibrary.Get<MapResource>( ident );
	}

	/// <summary>
	/// Map the given connection is browsing (its vote); falls back to the host's pick if unset or invalid.
	/// </summary>
	public string BrowsedIdentFor( Connection c )
	{
		if ( c is not null && BrowsedMaps.TryGetValue( c.Id, out var p ) && IsInCurrentPool( p ) )
			return p;

		return SelectedMapIdent;
	}

	private bool IsInCurrentPool( string ident )
		=> !string.IsNullOrEmpty( ident ) && MapsForMode( SelectedMode ).Any( m => m.ResourcePath == ident );

	/// <summary>
	/// Resource path of the local player's browsed map.
	/// </summary>
	public string LocalBrowsedMapIdent => BrowsedIdentFor( Connection.Local );

	/// <summary>
	/// Map the local player is browsing; the carousel centres on this.
	/// </summary>
	public MapResource LocalBrowsedMap => MapByPath( LocalBrowsedMapIdent );

	/// <summary>
	/// Connections currently browsing (voting for) the given map.
	/// </summary>
	public IReadOnlyList<Connection> VotersFor( string mapIdent )
		=> Connection.All.Where( c => BrowsedIdentFor( c ) == mapIdent ).ToList();

	protected override void OnUpdate()
	{
		// Only tick while /play is showing; prevent menu input bleeding in.
		if ( Machines.UI.MainMenuPanel.Instance is { CurrentUrl: not "/play" } )
			return;

		// Map browsing is per-player; only the lobby state machine stays host-driven.
		if ( State == LobbyState.Browsing )
			HandleMapBrowseInput();

		if ( Sandbox.Networking.IsHost )
		{
			TickHost();
		}
		else if ( Sandbox.Networking.IsActive )
		{
			TickClient();
		}
	}

	private void HandleMapBrowseInput()
	{
		if ( Input.Pressed( "MenuLeft" ) ) CycleMap( -1 );
		else if ( Input.Pressed( "MenuRight" ) ) CycleMap( +1 );
	}

	private void TickHost()
	{
		switch ( State )
		{
			case LobbyState.Browsing:
				HandleBrowsingInput();
				CheckHostReady();
				break;
			case LobbyState.Countdown:
				HandleCountdownInput();

				// Host unreadied or dropped; bail back to selection.
				if ( State == LobbyState.Countdown && !HostReady() )
				{
					State = LobbyState.Browsing;
					break;
				}

				if ( State == LobbyState.Countdown && CountdownEnds <= 0f )
					Launch();
				break;
		}
	}

	/// <summary>
	/// Client-side tick: handles ready/unready input for non-host players.
	/// </summary>
	private void TickClient()
	{
		if ( State != LobbyState.Browsing )
			return;

		if ( Input.Pressed( "MenuSelect" ) )
			ToggleReady();

		if ( Input.Pressed( "MenuBack" ) && IsLocalReady )
			SetReady( false );
	}

	/// <summary>
	/// Handle mode/map switching and ready-up while in browsing state.
	/// </summary>
	private void HandleBrowsingInput()
	{
		// Mode switching is host-only; map browsing is per-player via HandleMapBrowseInput.
		if ( !IsLocalReady )
		{
			if ( Input.Pressed( "MenuSwitchLeft" ) ) CycleMode( -1 );
			else if ( Input.Pressed( "MenuSwitchRight" ) ) CycleMode( +1 );
		}

		HandleReadyInput();
	}

	private void CycleMode( int direction )
	{
		var modes = System.Enum.GetValues<GameModeType>();
		var idx = System.Array.IndexOf( modes, SelectedMode );
		idx = (idx + direction + modes.Length) % modes.Length;
		SelectedMode = modes[idx];
		SelectFirstMap();
		BrowsedMaps.Clear();
		Sound.Play( "tab_switch" );
	}

	private void CycleMap( int direction )
	{
		var maps = MapsForMode( SelectedMode );
		if ( maps.Count == 0 ) return;
		var current = LocalBrowsedMap;
		var idx = current == null ? 0 : maps.ToList().FindIndex( m => m.ResourcePath == current.ResourcePath );
		if ( idx < 0 ) idx = 0;
		idx = (idx + direction + maps.Count) % maps.Count;
		SetBrowsedMapLocal( maps[idx].ResourcePath );
	}

	/// <summary>
	/// Set the local player's map vote and play the switch sound.
	/// </summary>
	private void SetBrowsedMapLocal( string ident )
	{
		SetBrowsedMap( ident );
		Sound.Play( "element_switch" );
	}

	/// <summary>
	/// Select a mode directly (e.g. clicking a mode tab).
	/// </summary>
	public void SelectMode( GameModeType mode )
	{
		if ( !IsAuthority || IsLocalReady || mode == SelectedMode )
			return;

		SelectedMode = mode;
		SelectFirstMap();
		BrowsedMaps.Clear();
		Sound.Play( "tab_switch" );
	}

	/// <summary>
	/// Select a map directly (e.g. clicking a side card), sets the local vote.
	/// </summary>
	public void SelectMap( MapResource map )
	{
		if ( map == null || map.ResourcePath == LocalBrowsedMapIdent )
			return;

		if ( !MapsForMode( SelectedMode ).Any( m => m.ResourcePath == map.ResourcePath ) )
			return;

		SetBrowsedMapLocal( map.ResourcePath );
	}

	/// <summary>
	/// Toggle the local ready state; unreadying during a countdown cancels it.
	/// </summary>
	public void ToggleReady()
	{
		if ( State == LobbyState.Launching )
			return;

		var wasReady = IsLocalReady;
		SetReady( !wasReady );
		if ( !wasReady )
			Sound.Play( "button_accept" );
	}

	/// <summary>
	/// Handle ready-up/cancel input; countdown starts once the host is ready.
	/// </summary>
	private void HandleReadyInput()
	{
		if ( Input.Pressed( "MenuSelect" ) )
			ToggleReady();

		// MenuBack cancels ready, re-enabling map switching.
		if ( Input.Pressed( "MenuBack" ) && IsLocalReady )
			SetReady( false );
	}

	/// <summary>
	/// True when player count meets the map minimum and the host is ready.
	/// The host readying up starts the countdown regardless of other players.
	/// </summary>
	private bool HostReady()
	{
		var connections = Connection.All;
		if ( connections.Count == 0 ) return false;

		var map = SelectedMap;
		if ( map == null || connections.Count < map.MinPlayers ) return false;

		var host = connections.FirstOrDefault( c => c.IsHost );
		return host is not null && Ready.TryGetValue( host.Id, out var r ) && r;
	}

	private void CheckHostReady()
	{
		if ( HostReady() )
		{
			CountdownEnds = CountdownSeconds;
			State = LobbyState.Countdown;
		}
	}

	private void HandleCountdownInput()
	{
		if ( Input.Pressed( "MenuBack" ) )
		{
			Input.Clear( "MenuBack" );

			// Clear ready so we settle back in selection rather than re-triggering the countdown.
			Ready.Clear();

			State = LobbyState.Browsing;
		}
	}

	private void Launch()
	{
		State = LobbyState.Launching;

		var map = SelectedMap;
		if ( map?.Scene == null )
		{
			Log.Warning( "LobbyFlow.Launch: selected map has no scene." );
			State = LobbyState.Browsing;
			return;
		}

		var options = new SceneLoadOptions();
		options.SetScene( map.Scene );

		Game.ChangeScene( options );
	}
}