// Assets/_Project/Scripts/Gameplay/LobbyService.cs using Unity.Netcode; using UnityEngine; using TD.Core; using TD.Levels; 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; } // ----- Networked lobby state -------------------------------------- // Index into MapRegistry.Maps of the currently selected map. Server-write, // everyone-read. Default 0 (the first registered map = MapRegistry.Default). // UI subscribes to OnValueChanged to refresh the map browser highlight. // A stale index (map removed between sessions) is tolerated by MapRegistry.Get // returning null; RequestStartMatchRpc validates before loading. private readonly NetworkVariable selectedMapIndex = new NetworkVariable(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); /// /// The index of the currently selected map within . /// All clients read the same value; only the host can change it via /// . /// public int SelectedMapIndex => selectedMapIndex.Value; /// /// Exposed for UI subscription to OnValueChanged. Treat as read-only — /// mutations must go through the server via /// so the host-only gate is enforced. /// public NetworkVariable SelectedMapIndexVar => selectedMapIndex; /// /// Convenience: resolves the currently selected via /// . Returns null if the registry is missing (e.g. the /// editor was started directly in the Lobby scene) or the index is stale. /// public LevelData SelectedMap => MapRegistry.Instance != null ? MapRegistry.Instance.Get(selectedMapIndex.Value) : null; // ----- 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(); // If the current selection is invalid for any reason (registry missing, // index stale from a previous session), snap to the default. Index 0 // is already the default-default; this is a no-op except after the // registry's contents change between sessions. var registry = MapRegistry.Instance; if (registry != null && registry.Get(selectedMapIndex.Value) == null && registry.Count > 0) { selectedMapIndex.Value = 0; } } } 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 AND that the selected map can /// accommodate the current lobby's player count, then transitions every /// peer to the selected map's 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; } // Re-validate the selected map server-side. The UI greys out unselectable // maps based on lobby size, but a late join could push us above the map's // PlayerCount between the click and the RPC arriving. Defensive check. var registry = MapRegistry.Instance; if (registry == null) { Debug.LogError("[LobbyService] Cannot start match: MapRegistry.Instance is null. " + "Make sure MainMenu was loaded before the lobby (the registry " + "DontDestroyOnLoads from there)."); return; } var selected = registry.Get(selectedMapIndex.Value); if (selected == null) { Debug.LogError($"[LobbyService] Cannot start match: selected map index " + $"{selectedMapIndex.Value} is not registered."); return; } int playerCount = CountConnectedPlayers(); if (!MapRegistry.IsSelectableFor(selected, playerCount)) { Debug.Log($"[LobbyService] Cannot start match: map '{selected.MapName}' supports " + $"up to {selected.PlayerCount} players but the lobby has {playerCount}."); return; } if (string.IsNullOrEmpty(selected.SceneName)) { Debug.LogError($"[LobbyService] Cannot start match: '{selected.MapName}' has " + $"empty SceneName (ScenePath='{selected.ScenePath}'). Re-bake the level."); return; } Debug.Log($"[LobbyService] Starting match on '{selected.MapName}' " + $"(index={selectedMapIndex.Value}, scene='{selected.SceneName}', " + $"scenePath='{selected.ScenePath}', players={playerCount})."); NetworkBootstrap.LoadSceneAsHost(selected.SceneName); } /// /// Host-only request to change the selected map. Validates that the requested index /// resolves to a real map in ; if so, writes /// which replicates to every client. /// /// /// Selectability for the current lobby size is NOT enforced here — players are /// allowed to highlight an oversized map (e.g. while waiting for more friends to /// join); the actual Start Match call enforces the rule. This keeps the host's /// intent visible to everyone without preventing them from "claiming" a future map. /// [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] public void RequestSelectMapRpc(int mapIndex, RpcParams rpcParams = default) { if (rpcParams.Receive.SenderClientId != NetworkManager.Singleton.LocalClientId) { Debug.LogWarning("[LobbyService] Non-host client attempted to change the map. Ignored."); return; } var registry = MapRegistry.Instance; if (registry == null) { Debug.LogError("[LobbyService] Cannot change map: MapRegistry.Instance is null."); return; } if (registry.Get(mapIndex) == null) { Debug.LogWarning($"[LobbyService] Rejected map index {mapIndex} — not registered."); return; } selectedMapIndex.Value = mapIndex; } /// /// 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; } /// /// Current number of connected players (every in the /// static registry). Used by map selectability checks both in the UI and the server-side /// Start Match validator. /// public static int CountConnectedPlayers() { int n = 0; foreach (var _ in PlayerMatchState.AllPlayers) n++; return n; } // ----- Server helpers -------------------------------------------- private static void ResetAllReady() { foreach (var pms in PlayerMatchState.AllPlayers) pms.SetReady(false); } } }