From be6db5b0a98c70ea9550e6d54a3b12b3adf4032c Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sat, 27 Jun 2026 23:29:28 +0200 Subject: [PATCH] Add match-end, spectator-hold join, and shutdown scene teardown - Host presses Esc to end a running match: swap Test Map back to Lobby and respawn everyone as the neutral Player (server-gated, host-only). - A client joining mid-match is spectator-held (no avatar) and spawns into the lobby when the match ends. - On shutdown/disconnect, unload every gameplay scene so only Main is left loaded & active (NGO's Dispose does not unload additively-synced scenes). - Generalize RespawnAll to a prefab selector + position so match-start (role variants) and match-end (neutral Player) share one path. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/MatchManager.cs | 127 ++++++++++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 11 deletions(-) diff --git a/Assets/Scripts/MatchManager.cs b/Assets/Scripts/MatchManager.cs index 7cf5cf4..6d5bf2c 100644 --- a/Assets/Scripts/MatchManager.cs +++ b/Assets/Scripts/MatchManager.cs @@ -3,6 +3,9 @@ using System.Collections.Generic; using Unity.Netcode; using UnityEngine; using UnityEngine.SceneManagement; +#if ENABLE_INPUT_SYSTEM +using UnityEngine.InputSystem; +#endif /// /// Server-authoritative match flow. Lives on the persistent NetworkManager object in the boot scene @@ -33,6 +36,14 @@ using UnityEngine.SceneManagement; /// (strictly serialized - an NGO constraint, only one scene op in flight); then every player object is /// despawned and respawned as its role's prefab variant. /// +/// End / join / teardown: +/// - The host presses Esc -> : Test Map is swapped back to Lobby and everyone is +/// respawned as the neutral Player. Server-gated, so only the host can end the match. +/// - A client that joins MID-MATCH is spectator-held (no avatar) - NGO still streams the running map to +/// it, but it doesn't play; it spawns into the lobby when the match ends. +/// - On shutdown / disconnect () every gameplay scene is unloaded so only +/// Main is left loaded & active. NGO's Dispose does NOT unload additively-synced scenes, so we do it. +/// /// Not a NetworkBehaviour by design: every state change rides existing NGO replication (additive scene /// sync + player-object spawn/despawn), so there is no in-scene NetworkObject hash matching or custom /// RPC to get wrong. The phase here is server-local guard state; clients infer their mode set from the @@ -47,6 +58,9 @@ public class MatchManager : MonoBehaviour public static MatchManager Instance { get; private set; } [Header("Scenes (must be in Build Settings)")] + [Tooltip("Persistent boot scene that always stays loaded (NetworkManager, cameras, HUD). On a " + + "shutdown/disconnect every other scene is unloaded so this is the only one left & active.")] + [SerializeField] private string mainSceneName = "Main"; [SerializeField] private string lobbySceneName = "Lobby"; [SerializeField] private string matchSceneName = "Test Map"; @@ -70,6 +84,7 @@ public class MatchManager : MonoBehaviour private GameObject _playerPrefab; // captured from NetworkConfig.PlayerPrefab; auto-spawn is then disabled so we spawn manually into a loaded scene private bool _hooked; private bool _sceneReady; // server-only: a gameplay scene (lobby/map) is loaded & active, so it is safe to spawn avatars + private bool _cleanedUp; // guards the shutdown scene-cleanup so a host (server+client stop) doesn't run it twice private void Awake() { @@ -114,6 +129,7 @@ public class MatchManager : MonoBehaviour private void OnServerStarted() { + _cleanedUp = false; HookSceneManager(); // Keep the connecting client's already-loaded scenes (its Main) and layer ours on top. @@ -131,14 +147,44 @@ public class MatchManager : MonoBehaviour StartCoroutine(LoadLobbyRoutine()); } - private void OnClientStarted() => HookSceneManager(); + private void OnClientStarted() + { + _cleanedUp = false; + HookSceneManager(); + } + // Fired on shutdown / disconnect for both the server and the client (a host gets both, hence the + // _cleanedUp guard). Aborts any in-flight scene op and tears the session's scenes back down so only + // Main is left loaded & active - NGO's own Dispose does NOT unload additively-synced scenes. private void OnNetStopped(bool _) { + if (_cleanedUp) return; + _cleanedUp = true; + + StopAllCoroutines(); _hooked = false; _sceneReady = false; Phase = MatchPhase.Lobby; - if (_net) _net.OnClientConnectedCallback -= OnClientConnected; + if (_net) + { + _net.OnClientConnectedCallback -= OnClientConnected; + if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent; + } + + ReturnToMainOnly(); + } + + // Host-only: Esc ends the running match and sends everyone back to the lobby. Read locally here + // (Input System reads the device directly, independent of focus/other readers); guarded to the server + // so pure clients pressing Esc do nothing. + private void Update() + { + if (_net == null || !_net.IsServer || Phase != MatchPhase.InMatch) return; +#if ENABLE_INPUT_SYSTEM + if (Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame) EndMatch(); +#else + if (Input.GetKeyDown(KeyCode.Escape)) EndMatch(); +#endif } private void HookSceneManager() @@ -174,11 +220,15 @@ public class MatchManager : MonoBehaviour private void OnClientConnected(ulong clientId) { if (_net == null || !_net.IsServer) return; - // During the Lobby->Test Map swap the active scene briefly falls back to Main (no floor); defer - // and let RespawnAll spawn this joiner once the map is loaded. + // During a Lobby<->Test Map swap the active scene briefly falls back to Main (no floor); defer + // and let the respawn loop spawn this joiner once the target scene is loaded. if (!_sceneReady || Phase == MatchPhase.Transitioning) return; - Vector3 at = Phase == MatchPhase.InMatch ? matchSpawnCenter : lobbySpawnCenter; - SpawnPlayerFor(clientId, at); + // Spectator-hold: a client that joins MID-MATCH gets NO avatar - they wait. NGO still streams the + // running map to their client (unavoidable), but they don't play in it. When the host ends the + // match, EndMatchSequence's respawn loop covers every connected client, so they spawn into the + // lobby with everyone else then. + if (Phase == MatchPhase.InMatch) return; + SpawnPlayerFor(clientId, lobbySpawnCenter); } // Spawns the neutral Player avatar for a client into the currently active gameplay scene. No-op if @@ -233,12 +283,44 @@ public class MatchManager : MonoBehaviour SceneEventType.LoadEventCompleted, matchSceneName); // 3. Respawn every player as its rolled role's prefab variant. - RespawnAll(hunterId); + RespawnAll(id => (id == hunterId) ? hunterPrefab : hiderPrefab, matchSpawnCenter); Phase = MatchPhase.InMatch; Debug.Log("[MatchManager] Match started.", this); } + /// Server-only. Ends the running match: swap Test Map -> Lobby and respawn everyone as the + /// neutral lobby Player (including any spectator-held mid-match joiners). Only acts while in a match. + public void EndMatch() + { + if (_net == null || !_net.IsServer) return; + if (Phase != MatchPhase.InMatch) return; // only endable mid-match + + Phase = MatchPhase.Transitioning; + Debug.Log("[MatchManager] Ending match -> returning to lobby.", this); + StartCoroutine(EndMatchSequence()); + } + + private IEnumerator EndMatchSequence() + { + var sm = _net.SceneManager; + + // 1. Unload the match map (strictly serialized: one scene op in flight at a time). + Scene map = SceneManager.GetSceneByName(matchSceneName); + if (map.IsValid() && map.isLoaded) + yield return SceneOp(() => sm.UnloadScene(map), SceneEventType.UnloadEventCompleted, matchSceneName); + + // 2. Reload the lobby. + yield return SceneOp(() => sm.LoadScene(lobbySceneName, LoadSceneMode.Additive), + SceneEventType.LoadEventCompleted, lobbySceneName); + + // 3. Respawn everyone as the neutral lobby Player (covers held mid-match joiners too). + RespawnAll(_ => _playerPrefab, lobbySpawnCenter); + + Phase = MatchPhase.Lobby; + Debug.Log("[MatchManager] Back in lobby.", this); + } + // Runs an NGO scene op and waits for its server-side completion event. NGO rejects a new op while one // is in flight, so callers must await this before issuing the next. private IEnumerator SceneOp(System.Func op, SceneEventType completed, string sceneName) @@ -262,21 +344,44 @@ public class MatchManager : MonoBehaviour _net.SceneManager.OnSceneEvent -= OnEvt; } - private void RespawnAll(ulong hunterId) + // Despawns each connected client's current avatar (if any) and spawns the prefab chosen by + // prefabFor at the given position. Used for both lobby->match (role variants) and match->lobby + // (neutral Player); the latter also gives spectator-held joiners their first avatar. + private void RespawnAll(System.Func prefabFor, Vector3 at) { var ids = new List(_net.ConnectedClientsIds); foreach (ulong id in ids) { if (!_net.ConnectedClients.TryGetValue(id, out var client)) continue; - GameObject prefab = (id == hunterId) ? hunterPrefab : hiderPrefab; + GameObject prefab = prefabFor(id); + if (!prefab) continue; - // Despawn the lobby avatar first so the client has no stale player object when the variant + // Despawn the old avatar first so the client has no stale player object when the new one // claims the slot (avoids NGO's "already has a player object" reassign warning). NetworkObject old = client.PlayerObject; if (old) old.Despawn(true); - var go = Instantiate(prefab, matchSpawnCenter, Quaternion.identity); + var go = Instantiate(prefab, at, Quaternion.identity); go.GetComponent().SpawnAsPlayerObject(id); } } + + // Unloads every gameplay scene so only Main is left loaded & active. NGO is already gone here + // (shutdown), so this uses Unity's SceneManager, not NGO's. Main must be made active BEFORE the + // active gameplay scene is unloaded, otherwise Unity has no valid active scene to fall back to. + private void ReturnToMainOnly() + { + Scene main = SceneManager.GetSceneByName(mainSceneName); + if (main.IsValid() && main.isLoaded) SceneManager.SetActiveScene(main); + + UnloadLocalIfLoaded(lobbySceneName); + UnloadLocalIfLoaded(matchSceneName); + } + + private void UnloadLocalIfLoaded(string sceneName) + { + Scene s = SceneManager.GetSceneByName(sceneName); + if (s.IsValid() && s.isLoaded && s != SceneManager.GetActiveScene()) + SceneManager.UnloadSceneAsync(s); + } }