using System.Collections; 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 /// (Main), which stays loaded the whole session (NetworkManager, cameras, HUD). The gameplay scenes /// (Lobby, Test Map) are loaded ADDITIVELY through NGO's scene manager so Main is never torn down. /// /// Two design pillars make the persistent-Main + additive-gameplay model actually work: /// /// 1. Clients sync ADDITIVELY ( = /// ). The NGO default (Single) would unload every scene the /// connecting client already has - including its own Main (cameras/HUD/managers) - and reload the /// server's active scene in single mode, which also crashes the player-object scene migration /// (MoveGameObjectToScene "Destination scene is not valid"). Additive keeps the client's Main and /// layers the server's scenes on top. Must be set on the server before any client connects; it is /// auto-propagated to clients via the synchronize event. /// /// 2. Players are spawned by THIS manager, not by NGO auto-spawn. NGO would auto-spawn the player the /// instant a peer connects, into whatever scene is active right then - for the host that is Main /// (the additive Lobby load is still in flight), which has no floor, so the host falls into the /// void. We disable auto-spawn (null out ) and instead /// spawn the neutral Player prefab into the gameplay scene only once it is loaded, so avatars always /// land on a floor. Main therefore holds zero network objects. /// /// Flow: server start -> additive sync mode set -> Lobby loaded additively -> Player spawned for every /// connected peer (full mode set, including First Person). Late joiners spawn on connect into the /// already-loaded scene. The host walks into the Start Match trigger -> : one /// random connected client is rolled Hunter, the rest Hider; the Lobby is unloaded and Test Map loaded /// (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 spawns as a SPECTATOR: a dedicated EMPTY prefab (no body/collider) /// bound to free-cam-only, so it flies around the running map and watches but does not play. It /// respawns as a neutral lobby Player 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 /// spawned avatar (), not from a replicated phase, so there /// is no spawn-vs-phase timing race. /// [DisallowMultipleComponent] public class MatchManager : MonoBehaviour { public enum MatchPhase { Lobby, Transitioning, InMatch } 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"; [Header("Role prefab variants (must be registered network prefabs)")] [SerializeField] private GameObject hunterPrefab; [SerializeField] private GameObject hiderPrefab; [Tooltip("Empty free-cam-only avatar spawned for a client that joins MID-MATCH. No body/collider - the " + "free-cam rig is a separate scene object. Must be a registered network prefab.")] [SerializeField] private GameObject spectatorPrefab; [Header("Spawn")] [Tooltip("World position lobby avatars are spawned at (raised a touch above the floor so gravity " + "settles them). Per-client ring fan-out is applied on top (NetworkPlayerSetup.PlaceOnSpawnRing).")] [SerializeField] private Vector3 lobbySpawnCenter = new Vector3(0f, 1f, 0f); [Tooltip("World position the respawned match avatars are placed at on the match map; per-client ring " + "fan-out is applied on top (NetworkPlayerSetup.PlaceOnSpawnRing).")] [SerializeField] private Vector3 matchSpawnCenter = new Vector3(0f, 1f, 0f); /// Server-local phase guard. Not replicated - see class summary. public MatchPhase Phase { get; private set; } = MatchPhase.Lobby; private NetworkManager _net; 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 // Role pick uses its OWN generator, seeded once from real entropy. UnityEngine.Random is a single // engine-wide generator whose state at match-start can sit at the same point every fresh play // session (nothing random consumed before it), which made the "random" hunter land on the same // client every time. An independent System.Random decouples the pick from that shared global state. private readonly System.Random _roleRng = new System.Random(System.Guid.NewGuid().GetHashCode()); private void Awake() { if (Instance && Instance != this) { Destroy(this); return; } Instance = this; _net = GetComponent(); if (!_net) _net = FindFirstObjectByType(); // Disable NGO auto-spawn (decided purely by PlayerPrefab != null on both host and remote paths). // We keep the reference and spawn manually once a floored scene exists. Runtime-only mutation - // the scene asset still carries PlayerPrefab, so there is no asset diff. if (_net != null) { _playerPrefab = _net.NetworkConfig.PlayerPrefab; _net.NetworkConfig.PlayerPrefab = null; } } private void OnEnable() { if (!_net) _net = FindFirstObjectByType(); if (!_net) return; _net.OnServerStarted += OnServerStarted; _net.OnClientStarted += OnClientStarted; _net.OnServerStopped += OnNetStopped; _net.OnClientStopped += OnNetStopped; } private void OnDisable() { if (_net) { _net.OnServerStarted -= OnServerStarted; _net.OnClientStarted -= OnClientStarted; _net.OnServerStopped -= OnNetStopped; _net.OnClientStopped -= OnNetStopped; _net.OnClientConnectedCallback -= OnClientConnected; if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent; } if (Instance == this) Instance = null; } private void OnServerStarted() { _cleanedUp = false; HookSceneManager(); // Keep the connecting client's already-loaded scenes (its Main) and layer ours on top. // Must run before any client synchronizes; auto-propagates to clients. See class summary. if (_net.SceneManager != null) _net.SceneManager.SetClientSynchronizationMode(LoadSceneMode.Additive); Phase = MatchPhase.Lobby; _sceneReady = false; // Spawn late joiners on connect (server-side callback). Re-hook defensively in case of re-host. _net.OnClientConnectedCallback -= OnClientConnected; _net.OnClientConnectedCallback += OnClientConnected; StartCoroutine(LoadLobbyRoutine()); } 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.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() { if (_hooked || _net == null || _net.SceneManager == null) return; _net.SceneManager.OnSceneEvent += OnSceneEvent; _hooked = true; } // Keep the freshly loaded gameplay scene the ACTIVE scene on every machine (lighting / skybox / // instantiation context) - NGO additive loads don't promote the active scene on their own. private void OnSceneEvent(SceneEvent e) { if (e.SceneEventType != SceneEventType.LoadComplete) return; if (e.SceneName != lobbySceneName && e.SceneName != matchSceneName) return; if (e.Scene.IsValid() && e.Scene.isLoaded) SceneManager.SetActiveScene(e.Scene); } private IEnumerator LoadLobbyRoutine() { yield return SceneOp(() => _net.SceneManager.LoadScene(lobbySceneName, LoadSceneMode.Additive), SceneEventType.LoadEventCompleted, lobbySceneName); // Lobby is loaded and active on the server: now it is safe to spawn the already-connected // peers (the host, plus anyone who connected while the load was in flight). _sceneReady = true; foreach (ulong id in new List(_net.ConnectedClientsIds)) SpawnFor(id, _playerPrefab, lobbySpawnCenter); } // Server-side: a peer finished connecting. If a gameplay scene is already up, spawn it now; // otherwise LoadLobbyRoutine (lobby) or RespawnAll (mid-transition) covers it (the PlayerObject // guard in SpawnFor prevents a double spawn). private void OnClientConnected(ulong clientId) { if (_net == null || !_net.IsServer) return; // 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; // Mid-match joiner: spawn an empty SPECTATOR avatar (free-cam only) into the running map. They can // fly around and watch but don't play. When the host ends the match, EndMatchSequence's respawn // loop covers every connected client, so they turn into a neutral lobby Player with everyone else. if (Phase == MatchPhase.InMatch) { SpawnFor(clientId, spectatorPrefab, matchSpawnCenter); return; } SpawnFor(clientId, _playerPrefab, lobbySpawnCenter); } // Spawns the given prefab as a client's player object into the currently active gameplay scene. No-op // if the client already owns a player object (double-spawn guard) or has since disconnected. private void SpawnFor(ulong clientId, GameObject prefab, Vector3 at) { if (!prefab) { Debug.LogError("[MatchManager] Spawn prefab missing - cannot spawn.", this); return; } if (!_net.ConnectedClients.TryGetValue(clientId, out var client)) return; if (client.PlayerObject) return; var go = Instantiate(prefab, at, Quaternion.identity); go.GetComponent().SpawnAsPlayerObject(clientId); } /// Server-only. Starts the match: roll roles, swap Lobby -> Test Map, respawn as variants. /// Safe to call repeatedly (only acts once, while still in the lobby phase). public void StartMatch() { if (_net == null || !_net.IsServer) return; if (Phase != MatchPhase.Lobby) return; // double-start / not-in-lobby guard if (!hunterPrefab || !hiderPrefab) { Debug.LogError("[MatchManager] Role prefabs not assigned - cannot start match.", this); return; } var ids = new List(_net.ConnectedClientsIds); if (ids.Count == 0) return; Phase = MatchPhase.Transitioning; ulong hunterId = ids[_roleRng.Next(ids.Count)]; Debug.Log($"[MatchManager] Starting match. Hunter = client {hunterId}, {ids.Count} player(s).", this); StartCoroutine(MatchSequence(hunterId)); } private IEnumerator MatchSequence(ulong hunterId) { var sm = _net.SceneManager; // 1. Unload the lobby (strictly serialized: one scene op in flight at a time). Scene lobby = SceneManager.GetSceneByName(lobbySceneName); if (lobby.IsValid() && lobby.isLoaded) yield return SceneOp(() => sm.UnloadScene(lobby), SceneEventType.UnloadEventCompleted, lobbySceneName); // 2. Load the match map. yield return SceneOp(() => sm.LoadScene(matchSceneName, LoadSceneMode.Additive), SceneEventType.LoadEventCompleted, matchSceneName); // 3. Respawn every player as its rolled role's prefab variant. 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) { bool done = false; void OnEvt(SceneEvent e) { if (e.SceneEventType == completed && e.SceneName == sceneName) done = true; } _net.SceneManager.OnSceneEvent += OnEvt; SceneEventProgressStatus status = op(); if (status != SceneEventProgressStatus.Started) { Debug.LogWarning($"[MatchManager] Scene op on '{sceneName}' returned {status}.", this); _net.SceneManager.OnSceneEvent -= OnEvt; yield break; } while (!done) yield return null; _net.SceneManager.OnSceneEvent -= OnEvt; } // 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 = prefabFor(id); if (!prefab) continue; // 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, 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); // The local avatar is gone; drop the mode cycle back to a player-less state so the persistent // orbit input stops re-locking the cursor (and any FreeCam/First-Person takeover is undone). Without // this the OS cursor stays captured back in the empty Main scene. var modes = FindFirstObjectByType(FindObjectsInactive.Include); if (modes) modes.ReleaseToNoPlayer(); } private void UnloadLocalIfLoaded(string sceneName) { Scene s = SceneManager.GetSceneByName(sceneName); if (s.IsValid() && s.isLoaded && s != SceneManager.GetActiveScene()) SceneManager.UnloadSceneAsync(s); } }