lobby/LobbyManager.cs

LobbyManager component in the lobby scene. It watches PlayerReadyState components, starts a synchronized countdown when at least MinPlayers are ready, triggers a screen fade during the final seconds, cancels if someone un-readies, and instructs the host to load the gameplay scene for everyone.

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>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;

	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 states = Scene.GetAllComponents<PlayerReadyState>().ToList();
		bool areAllPlayersReady = states.Count >= MinPlayers && states.All(s => s.IsReady);

		if (areAllPlayersReady && !IsLaunching)
		{
			BroadcastCountdownStart(LaunchSeconds);
		}
		else if (!areAllPlayersReady && 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);
	}
}