lobby/LobbyManager.cs

LobbyManager component in the lobby scene. It tracks PlayerReadyState components, starts or cancels a launch countdown when enough real players are ready, triggers a screen fade, and instructs all clients to load the gameplay scene when the countdown finishes. Also provides static lobby message utilities.

NetworkingFile Access
using System;
using System.Linq;

/// <summary>
/// Lives in the lobby scene. Host watches every <see cref="PlayerReadyState"/>
/// in the scene; once all connected players are ready (and we have at least
/// <see cref="MinPlayers"/>), it runs a short launch countdown and then loads
/// the gameplay scene for everyone. Walking out of the ready zone before the
/// countdown ends cancels the launch.
/// </summary>
public sealed class LobbyManager : Component
{
	/// <summary>Gameplay scene to load once everyone is ready.</summary>
	[Property] public SceneFile GameScene { get; set; }

	/// <summary>Minimum number of connected players required before the launch countdown can start.</summary>
	[Property] public int MinPlayers { get; set; } = 1;

	/// <summary>True on every client while the launch countdown is running.</summary>
	public bool IsLaunching { get; private set; }

	/// <summary>Seconds left on the launch countdown on this client. Only meaningful while <see cref="IsLaunching"/>.</summary>
	public float LaunchSecondsRemaining => MathF.Max( 0f, (float)_launchAt );

	/// <summary>Scene-wide singleton so the lobby UI can find us without a hard reference.</summary>
	public static LobbyManager Current { get; private set; }

	/// <summary>
	/// Cross-scene banner text (e.g. "host left"). Set from another scene right before
	/// loading the lobby; the lobby UI shows it until <see cref="LobbyMessageExpires"/> elapses.
	/// </summary>
	public static string LobbyMessage { get; private set; }

	/// <summary>Real-time deadline for <see cref="LobbyMessage"/>. Static so it survives the scene swap.</summary>
	public static RealTimeUntil LobbyMessageExpires { get; private set; }

	private const float LobbyMessageDuration = 8f;

	/// <summary>Show a banner in the lobby UI for the next <see cref="LobbyMessageDuration"/> seconds.</summary>
	public static void ShowLobbyMessage( string message )
	{
		LobbyMessage = message;
		LobbyMessageExpires = LobbyMessageDuration;
	}

	/// <summary>True while <see cref="LobbyMessage"/> should be rendered.</summary>
	public static bool HasActiveLobbyMessage => !string.IsNullOrEmpty( LobbyMessage ) && LobbyMessageExpires > 0f;

	/// <summary>Seconds to wait, with everyone still ready, before actually loading the game scene.</summary>
	private float LaunchSeconds { get; set; } = 3f;

	/// <summary>Seconds the screen takes to fade to black at the tail of the launch countdown. Must be &lt;= <see cref="LaunchSeconds"/>.</summary>
	private const float LaunchFadeDuration = 2f;

	private TimeUntil _launchAt;
	private bool _hasLaunched;
	private bool _hasTriggeredLaunchFade;

	private float percentReadyRequired = 0.5f; // 50% of players

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

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

	protected override void OnUpdate()
	{
		// Per-client: kick off the screen fade-out once the countdown enters the fade window.
		if ( IsLaunching && !_hasTriggeredLaunchFade && (float)_launchAt <= LaunchFadeDuration )
		{
			_hasTriggeredLaunchFade = true;
			ScreenFade.FadeOut( LaunchFadeDuration );
		}

		// Host owns the state machine; everyone runs the launch-elapsed check locally
		// (broadcast-started, so all clients hit zero at roughly the same time).
		if ( IsLaunching && (float)_launchAt <= 0f )
		{
			if ( Networking.IsHost && !_hasLaunched )
			{
				_hasLaunched = true;
				LoadGameScene();
			}
			IsLaunching = false;
		}

		if ( !Networking.IsHost ) return;
		if ( _hasLaunched ) return;

		var allReadyStates = Scene.GetAllComponents<PlayerReadyState>().ToList();
		// Bots don't count — they're always ready. Requiring the real player(s) to
		// ready-up is what actually gates the launch.
		var realPlayerStates = allReadyStates.Where( s => !s.IsBot() ).ToList();
		int readyCount = realPlayerStates.Count( s => s.IsReady );
		bool hasMajority = realPlayerStates.Count >= MinPlayers
			&& readyCount >= realPlayerStates.Count * percentReadyRequired;

		if ( hasMajority && !IsLaunching )
		{
			BroadcastCountdownStart( LaunchSeconds );
		}
		else if ( !hasMajority && IsLaunching )
		{
			BroadcastCountdownCancel();
		}
	}

	[Rpc.Broadcast]
	private void BroadcastCountdownStart( float seconds )
	{
		_launchAt = seconds;
		IsLaunching = true;
		_hasTriggeredLaunchFade = false;
	}

	[Rpc.Broadcast]
	private void BroadcastCountdownCancel()
	{
		IsLaunching = false;
		if ( _hasTriggeredLaunchFade )
		{
			_hasTriggeredLaunchFade = false;
			ScreenFade.FadeIn( LaunchFadeDuration );
		}
	}

	private void LoadGameScene()
	{
		if ( GameScene is null )
		{
			Log.Warning( "LobbyManager: GameScene is not assigned, can't launch." );
			return;
		}

		BroadcastLoadGameScene();
	}

	[Rpc.Broadcast]
	private void BroadcastLoadGameScene()
	{
		Game.ActiveScene.LoadFromFile( GameScene.ResourcePath );
	}
}