Code/LobbyDirector.cs

LobbyDirector component that manages lobby, round and end states for the game. It tracks agents, modes, syncs state to clients, optionally spawns a map prefab, controls menu and mode selection, counts down round time, and invokes game-mode callbacks.

NetworkingFile Access
namespace LobbySystem;

/// <summary>
/// Gamemode-agnostic round and lobby lifecycle. Drives the Lobby, Active and Ended states, the mode menu,
/// the synced countdown, optional map loading and agent tracking, then hands the actual rules to the active
/// <see cref="IGameMode"/>. Host-authoritative; clients read the synced values.
/// </summary>
public sealed class LobbyDirector : Component
{
	public static LobbyDirector Current { get; private set; }

	[Property] public float RoundDuration { get; set; } = 180f;
	[Property] public float RestartDelay { get; set; } = 4f;
	[Property] public int MinPlayers { get; set; } = 2;

	/// <summary>Optional map cloned in when a round starts. Leave null to play in the lobby scene.</summary>
	[Property] public GameObject MapPrefab { get; set; }

	/// <summary>Lobby floor; its renderer hides once a round map has loaded.</summary>
	[Property] public GameObject LobbyFloor { get; set; }

	/// <summary>Turn on when you use <see cref="MapPrefab"/> so clients load it too.</summary>
	[Property, Sync( SyncFlags.FromHost )] public bool UseRoundMap { get; set; }

	[Sync( SyncFlags.FromHost )] public LobbyState State { get; set; } = LobbyState.Lobby;
	[Sync( SyncFlags.FromHost )] public int ActiveModeIndex { get; set; }
	[Sync( SyncFlags.FromHost )] public bool MenuOpen { get; set; }
	[Sync( SyncFlags.FromHost )] public int TimeLeftSeconds { get; set; }
	[Sync( SyncFlags.FromHost )] public string StatusMessage { get; set; } = "";
	[Sync( SyncFlags.FromHost )] public string Banner { get; set; } = "";
	[Sync( SyncFlags.FromHost )] public int EventPulse { get; set; }

	/// <summary>Local flag: a client opened the menu to suggest a mode to the host.</summary>
	public bool SuggestMenuOpen { get; set; }

	float _timeRemaining;
	float _restartAt;
	TimeUntil _startGrace;
	GameObject _mapInstance;
	ModelRenderer _floorRenderer;
	bool _floorResolved;

	List<ILobbyAgent> _agents = new();
	public IReadOnlyList<ILobbyAgent> Agents => _agents;
	int LiveCount => _agents.Count;

	IReadOnlyList<IGameMode> _modes;
	public IReadOnlyList<IGameMode> Modes => _modes ?? ResolveModes();
	public IGameMode ActiveMode => Modes.Count > 0 ? Modes[ Math.Clamp( ActiveModeIndex, 0, Modes.Count - 1 ) ] : null;

	IReadOnlyList<IGameMode> ResolveModes()
	{
		try
		{
			var catalog = Scene.GetAllComponents<IGameModeCatalog>().FirstOrDefault();
			_modes = catalog?.Modes ?? (IReadOnlyList<IGameMode>)Array.Empty<IGameMode>();
		}
		catch
		{
			_modes = Array.Empty<IGameMode>();
		}
		return _modes;
	}

	protected override void OnAwake() => Current = this;
	protected override void OnDestroy() { if ( Current == this ) Current = null; }

	bool _roundLive;

	/// <summary>
	/// True while a round is running. The RPC flag flips instantly for connected peers; OR-ing the synced
	/// State also catches players who join mid-round. Drive lobby-vs-round UI off this, not State.
	/// </summary>
	public bool RoundLive => _roundLive || State == LobbyState.Active;
	[Rpc.Broadcast] public void RpcSetRoundLive( bool live ) => _roundLive = live;
	void SetRoundLive( bool live ) { if ( Networking.IsActive ) RpcSetRoundLive( live ); else _roundLive = live; }

	public bool MapReady => _mapInstance.IsValid();

	TimeUntil _nextScan;
	void RefreshAgents()
	{
		if ( _nextScan > 0f && _agents.Count > 0 ) return;
		_nextScan = 0.2f;
		try
		{
			var fresh = new List<ILobbyAgent>();
			foreach ( var a in Scene.GetAllComponents<ILobbyAgent>() )
				if ( a.IsValid() ) fresh.Add( a );
			_agents = fresh;
		}
		catch
		{
			// Scene changed during the scan; keep the previous list and retry next frame.
		}
	}

	/// <summary>A spawn-point position spread by index. False until the map collision has loaded, so retry.</summary>
	public bool TryRoundSpawn( int i, out Vector3 pos )
	{
		pos = default;
		List<SpawnPoint> spawns;
		try { spawns = Scene.GetAllComponents<SpawnPoint>().Where( s => s.IsValid() ).ToList(); }
		catch { return false; }
		if ( spawns.Count == 0 ) return false;
		var sp = spawns[ ((i % spawns.Count) + spawns.Count) % spawns.Count ];
		pos = sp.WorldPosition + Vector3.Up * 20f;
		return true;
	}
	public Vector3 RoundSpawnPoint( int i ) => TryRoundSpawn( i, out var p ) ? p : Vector3.Up * 300f;

	void SyncRoundMap()
	{
		if ( !UseRoundMap || MapPrefab is null ) return;
		// Clone once. Toggling or destroying a MapInstance cancels its async load.
		if ( RoundLive && !_mapInstance.IsValid() )
			_mapInstance = MapPrefab.Clone( Vector3.Zero );
	}

	void SyncLobbyFloor()
	{
		if ( !_floorResolved )
		{
			_floorRenderer = LobbyFloor?.Components.Get<ModelRenderer>();
			_floorResolved = true;
		}
		if ( _floorRenderer is not null )
		{
			bool show = !MapReady;
			if ( _floorRenderer.Enabled != show ) _floorRenderer.Enabled = show;
		}
	}

	public void RequestModeMenu()
	{
		bool host = !Networking.IsActive || Networking.IsHost;
		if ( host ) { if ( MenuOpen ) RequestCloseMenu(); else RequestOpenMenu(); }
		else SuggestMenuOpen = !SuggestMenuOpen;
	}
	public void RequestOpenMenu() { if ( !Networking.IsActive ) OpenMenu(); else RpcOpenMenu(); }
	[Rpc.Broadcast] public void RpcOpenMenu() { if ( Networking.IsActive && !Networking.IsHost ) return; OpenMenu(); }
	void OpenMenu() { MenuOpen = true; State = LobbyState.Lobby; }

	public void RequestCloseMenu() { SuggestMenuOpen = false; if ( !Networking.IsActive ) CloseMenu(); else RpcCloseMenu(); }
	[Rpc.Broadcast] public void RpcCloseMenu() { if ( Networking.IsActive && !Networking.IsHost ) return; CloseMenu(); }
	void CloseMenu() { MenuOpen = false; State = LobbyState.Lobby; Banner = ""; }

	/// <summary>Called by the HUD when a mode is clicked. The host starts it; a client suggests it.</summary>
	public void PickMode( int index )
	{
		bool host = !Networking.IsActive || Networking.IsHost;
		if ( host ) ChooseMode( index );
		else { SuggestMenuOpen = false; SuggestMode( index ); }
	}
	public void ChooseMode( int index ) { MenuOpen = false; Banner = ""; StartRound( index ); }

	public string ChatLine { get; private set; } = "";
	TimeUntil _chatUntil;
	public bool ChatVisible => !string.IsNullOrEmpty( ChatLine ) && _chatUntil > 0f;
	public void SuggestMode( int index )
	{
		string who = Connection.Local?.DisplayName ?? "Someone";
		string mode = (index >= 0 && index < Modes.Count) ? Modes[index].DisplayName : "a mode";
		RpcSuggest( who, mode );
	}
	[Rpc.Broadcast] public void RpcSuggest( string who, string mode ) { ChatLine = $"{who} suggested starting {mode}"; _chatUntil = 6f; }

	protected override void OnUpdate()
	{
		RefreshAgents();
		SyncRoundMap();
		SyncLobbyFloor();

		// Only the host drives round state; clients render the synced values.
		if ( Networking.IsActive && !Networking.IsHost ) return;

		if ( MenuOpen ) { StatusMessage = "Select a game mode"; return; }

		switch ( State )
		{
			case LobbyState.Lobby:
				StatusMessage = "Waiting in the lobby";
				break;

			case LobbyState.Active:
				_timeRemaining -= Time.Delta;
				int secs = (int)MathF.Ceiling( _timeRemaining );
				if ( secs != TimeLeftSeconds ) TimeLeftSeconds = secs;
				bool graceOver = _startGrace <= 0f;
				bool earlyOver = graceOver && (LiveCount < MinPlayers || (ActiveMode?.IsRoundOver( this, _agents ) ?? false));
				if ( _timeRemaining <= 0f || earlyOver ) EndRound();
				break;

			case LobbyState.Ended:
				if ( Time.Now >= _restartAt )
				{
					Banner = "";
					State = LobbyState.Lobby;
					SetRoundLive( false );
				}
				break;
		}
	}

	/// <summary>Begin a round with the given mode index. Host only.</summary>
	public void StartRound( int modeIndex )
	{
		if ( Modes.Count == 0 ) return;
		ActiveModeIndex = Math.Clamp( modeIndex, 0, Modes.Count - 1 );

		var live = _agents.Where( a => a.IsValid() ).ToList();
		if ( live.Count < 1 ) { State = LobbyState.Lobby; return; }

		State = LobbyState.Active;
		SetRoundLive( true );
		_startGrace = 1.5f;
		_timeRemaining = RoundDuration;
		TimeLeftSeconds = (int)RoundDuration;

		foreach ( var a in live ) a.ResetForRound();
		ActiveMode?.OnRoundStart( this, live );

		StatusMessage = "";
		Banner = ActiveMode?.DisplayName ?? "";
	}

	void EndRound()
	{
		State = LobbyState.Ended;
		_timeRemaining = 0f;
		TimeLeftSeconds = 0;
		_restartAt = Time.Now + RestartDelay;
		StatusMessage = ActiveMode?.ResultText( this, _agents ) ?? "Round over!";
		Banner = "ROUND OVER";
	}

	/// <summary>Bump <see cref="EventPulse"/> to flash every HUD once.</summary>
	public void PulseEvent() => EventPulse++;
}