// Assets/_Project/Scripts/Gameplay/LobbyService.cs using Unity.Netcode; using UnityEngine; using TD.Core; using TD.Net; namespace TD.Gameplay { /// /// Scene singleton that owns lobby-wide actions: Start Match (host), /// Return to Lobby (after a match), and the "host disconnected" broadcast. /// Lives in the Lobby scene with a NetworkObject; spawns when the /// scene loads. /// /// /// What lives here vs. on PlayerMatchState. Per-player state /// (race, ready, slot, display name) lives on /// because it carries across scene transitions with the player connection. /// Lobby-wide ACTIONS (Start Match, Return to Lobby) live here because they /// are scoped to the lobby scene and don't need to persist. /// /// Start Match flow. /// /// Host clicks Start in the lobby UI. /// UI calls ; server validates: /// every PlayerMatchState has a race and IsReady. /// Server calls NetworkBootstrap.LoadSceneAsHost(SceneNames.Match). /// NGO replicates the scene load to every client. /// Match scene loads; spawns there; gameplay /// begins as usual. /// /// /// Return to Lobby flow. Triggered by the match-end overlay's /// "Retry" button. Server calls ; /// resets on every player; loads /// the Lobby scene. Race picks are preserved so players don't have to /// re-select. /// /// Host-leaves behavior (Option A). When the host quits, NGO /// shuts down the connection for every client. Clients detect the disconnect /// in (subscribed to /// NetworkManager.OnClientDisconnectCallback on the local client) /// and load the MainMenu scene locally. See roadmap §1.7-Future Steam Lobby /// Migration for the Option C upgrade (lobby persists, new host elected) /// once Steam SDK is in. /// [RequireComponent(typeof(NetworkObject))] public class LobbyService : NetworkBehaviour { // ----- Singleton -------------------------------------------------- public static LobbyService Instance { get; private set; } // ----- Lifecycle -------------------------------------------------- public override void OnNetworkSpawn() { if (Instance != null && Instance != this) { Debug.LogError("[LobbyService] Duplicate instance detected. " + "Only one LobbyService should exist per scene."); return; } Instance = this; // Entering the lobby always starts everyone un-ready. Race picks // are preserved from the previous lobby visit, but ready state // resets so each match requires explicit re-readying. if (IsServer) ResetAllReady(); } public override void OnNetworkDespawn() { if (Instance == this) Instance = null; } // ----- Public RPC API (client → server) -------------------------- /// /// Host-only request to begin the match. Validates that every connected /// player has picked a race and is ready, then transitions every peer /// to the Match scene via NGO scene management. /// [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] public void RequestStartMatchRpc(RpcParams rpcParams = default) { // Only the host's local client may start a match. We compare the // sender's clientId to the server's clientId (which == the host's // local clientId in host mode). if (rpcParams.Receive.SenderClientId != NetworkManager.Singleton.LocalClientId) { Debug.LogWarning("[LobbyService] Non-host client attempted to start the match. Ignored."); return; } if (!AreAllPlayersReady(out string reason)) { Debug.Log($"[LobbyService] Cannot start match: {reason}"); return; } NetworkBootstrap.LoadSceneAsHost(SceneNames.Match); } /// /// Anyone can request a return to the lobby (typically the host clicks /// "Retry" after a match). Server-side: resets ready state on every /// player and triggers the scene transition. Race picks are preserved. /// [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] public void RequestReturnToLobbyRpc() { // We don't gate this on host-only currently — any client retrying is // semantically equivalent (the host needs to be willing to keep // running). When dedicated server / Steam migration lands this // becomes host-only. ResetAllReady(); NetworkBootstrap.LoadSceneAsHost(SceneNames.Lobby); } // ----- Public queries -------------------------------------------- /// /// True if every connected player has picked a race AND marked themselves /// ready. Returns the reason as when false /// (for debug logs / future UI tooltips on the disabled Start button). /// public static bool AreAllPlayersReady(out string reason) { int count = 0; foreach (var pms in PlayerMatchState.AllPlayers) { count++; if (pms.RaceSelection == RaceId.None) { reason = $"Slot {pms.Slot} hasn't picked a race."; return false; } if (!pms.IsReady) { reason = $"Slot {pms.Slot} is not ready."; return false; } } if (count == 0) { reason = "No players connected."; return false; } reason = string.Empty; return true; } // ----- Server helpers -------------------------------------------- private static void ResetAllReady() { foreach (var pms in PlayerMatchState.AllPlayers) pms.SetReady(false); } } }