using System.Collections; using System.Collections.Generic; using Unity.Collections; using Unity.Netcode; using UnityEngine; using UnityEngine.SceneManagement; /// /// 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 -> : a /// configurable number of random connected clients are rolled Hunter (, /// default 1), 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. /// /// Match-control AUTHORITY: the client allowed to start (walk into the trigger) and end the match. /// A host has its own player, so the host is the authority. A DEDICATED server (StartServer, no local /// player) hands authority to the FIRST client that connects, so a server nobody plays on is still /// controllable. The id is server-local; the designated remote client is told via a named message so it /// can ask the server (which validates the sender) to end. If the authority leaves, it is reassigned to /// another connected client. /// /// End / join / teardown: /// - The match authority ends the match via -> /// (driven by the online session panel's "Back to Lobby" button - Esc opens that panel rather than /// ending directly). Test Map is swapped back to Lobby and everyone is respawned as the neutral Player. /// The host/server ends directly; a remote authority client requests it over a named message that only /// the authority's id is honored for. A non-authority client's panel button is a plain disconnect. /// - 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 } /// The in-match sub-phase, replicated to every client over a named message (not via NGO /// NetworkVariables - this is a MonoBehaviour by design, see class summary). None = no match running. /// Hide -> Seek -> Resolution, then back to the lobby. Drives per-client visibility, hunter shooting /// and the match HUD countdown. public enum MatchSubPhase { None, Hide, Seek, Resolution } /// How the running match behaves. Chosen server-side () and replicated /// to every client alongside the sub-phase (so visibility is right from the first Hide frame and late /// joiners sync it). Differs only in hider visibility and what a hit does: /// /// Classic - hiders hidden from hunters during Hide only; a hit marks the hider FOUND /// (body hidden for all, owner drops to free-cam spectating). /// Conversion - hiders are hidden from EACH OTHER too (Hide and Seek); during Seek only /// hunters see hiders; a hit CONVERTS the hider into a Hunter (respawned as the Hunter variant in place), /// so it keeps playing on the hunters' side. Resolution reveals everyone in both modes. /// public enum MatchMode { Classic, Conversion } 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("Roles")] [Tooltip("How many randomly chosen players become Hunters at match start; everyone else is a Hider. " + "0 = all Hiders; a value at or above the player count makes everyone a Hunter. Default 1.")] [Min(0)] [SerializeField] private int hunterCount = 1; [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); [Header("Match mode")] [Tooltip("Classic = hiders are visible to each other, a hit marks them FOUND (free-cam spectating). " + "Conversion = hiders are hidden from each other too (only hunters see them during Seek), and a " + "hit CONVERTS the hider into a Hunter that keeps playing. Replicated to clients on match start.")] [SerializeField] private MatchMode matchMode = MatchMode.Classic; [Header("Match phases (seconds)")] [Tooltip("HIDE phase length. Hunters move freely but cannot see the hiders (hider meshes are hidden on " + "hunter clients); hiders scatter and hide.")] [Min(0f)] [SerializeField] private float hideDuration = 30f; [Tooltip("SEEK phase length. Hunters can now see the hiders and shoot them. Ends early the moment every " + "hider has been found.")] [Min(0f)] [SerializeField] private float seekDuration = 120f; [Tooltip("RESOLUTION phase length. Nothing happens here yet; after it elapses everyone returns to the lobby.")] [Min(0f)] [SerializeField] private float resolveDuration = 8f; [Header("Hunter shooting")] [Tooltip("Max distance (metres) of the hunter's camera-centre shoot raycast.")] [Min(0f)] [SerializeField] private float shootRange = 100f; [Tooltip("Minimum seconds between hunter shots (left-click cooldown).")] [Min(0f)] [SerializeField] private float shootCooldown = 0.4f; /// Range of the hunter's shoot raycast (read by ). public float ShootRange => shootRange; /// Hunter shoot cooldown in seconds (read by ). public float ShootCooldown => shootCooldown; /// Server-local phase guard. Not replicated - see class summary. public MatchPhase Phase { get; private set; } = MatchPhase.Lobby; // --- Replicated sub-phase mirror (every machine, set by the server's broadcast; the server sets its // own copy directly in SetPhaseServer). Cosmetic countdown runs off the local clock from the broadcast // duration; the authoritative transition is always server-driven, so any drift self-corrects on the // next phase message. --- private MatchSubPhase _subPhase = MatchSubPhase.None; private float _phaseEndTime; // Time.time when the current sub-phase ends (for the HUD countdown) private float _phaseDuration; private MatchMode _localMode = MatchMode.Classic; // mirror of the server's matchMode, carried in the phase message /// True while a match sub-phase (Hide/Seek/Resolution) is running on this machine. public bool MatchActive => _subPhase != MatchSubPhase.None; /// This machine's current sub-phase (mirror of the server's broadcast). public MatchSubPhase LocalSubPhase => _subPhase; /// The running match's mode (mirror of the server's choice). Drives per-client hider visibility. public MatchMode LocalMatchMode => _localMode; /// Seconds left in the current sub-phase for the HUD (clamped to 0; cosmetic). public float LocalPhaseRemaining => Mathf.Max(0f, _phaseEndTime - Time.time); /// Total length of the current sub-phase (for a HUD progress bar, if wanted). public float LocalPhaseDuration => _phaseDuration; /// Raised after the local sub-phase changes (argument = the new sub-phase). HUD/visibility follow it. public event System.Action SubPhaseChanged; 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()); // Painting carry across an avatar respawn (scene switch). Per-machine: the LOCAL owner's painting, // parked here by its despawning PlayerPaintSync (that component is gone before the replacement spawns) // and consumed once by the next owner avatar. This manager is persistent in Main, so it bridges the // despawn->spawn gap. Cleared on teardown (ReturnToMainOnly) so a fresh session never inherits a // previous one's painting; a normal respawn keeps Main loaded, so the parked copy survives it. private byte[] _carriedPaint; // Match-control authority. Server-local: which client may start/end the match. Host -> the host // itself (set at server start); dedicated server -> the first client to connect (set on connect). private bool _hasAuthorityClient; private ulong _authorityClientId; // Client-side mirror, set by the grant message below: this machine's local client is the authority, // so it reads Esc and asks the server to end the match. private bool _isLocalAuthority; // Named messages (CustomMessagingManager). One bridges server->authority-client ("you have authority"), // the other client->server ("please end the match"); the server only honors it from the authority id. private const string MsgAuthorityGranted = "Match_AuthorityGranted"; private const string MsgRequestEndMatch = "Match_RequestEndMatch"; // Server -> all clients: the sub-phase changed (byte phase + float duration). Client -> server: a hunter // reports a hit on a hider (ulong target client id); the server validates and marks the hider found. private const string MsgPhase = "Match_Phase"; private const string MsgReportHit = "Match_ReportHit"; // Server-only sub-phase machine. _pendingHiders holds the hiders not yet found; seek ends the instant it // empties. _phaseRoutine is the running Hide->Seek->Resolution coroutine (stopped if the match is ended // early via the authority's Back-to-Lobby). private readonly HashSet _pendingHiders = new HashSet(); private Coroutine _phaseRoutine; private readonly List _phaseRecipients = new List(); // reused scratch for the phase broadcast 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; } // Placeholder on-screen match HUD (phase + countdown + hunter crosshair). Added at runtime so it // needs no scene/prefab authoring; trivially swapped for a proper Canvas later. Persistent like this. if (!GetComponent()) gameObject.AddComponent(); } 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; _net.OnClientDisconnectCallback -= OnClientDisconnected; if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent; if (_net.CustomMessagingManager != null) { _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit); } } 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; // Match-control authority. A host has its own player, so it is the authority right away. A // dedicated server has no local player; the first client to connect claims it (OnClientConnected). _hasAuthorityClient = false; if (_net.IsHost) { _authorityClientId = _net.LocalClientId; _hasAuthorityClient = true; } if (_net.CustomMessagingManager != null) { _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRequestEndMatch, OnRequestEndMatchMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReportHit, OnReportHitMessage); } _subPhase = MatchSubPhase.None; _pendingHiders.Clear(); // Spawn late joiners on connect (server-side callback). Re-hook defensively in case of re-host. _net.OnClientConnectedCallback -= OnClientConnected; _net.OnClientConnectedCallback += OnClientConnected; _net.OnClientDisconnectCallback -= OnClientDisconnected; _net.OnClientDisconnectCallback += OnClientDisconnected; StartCoroutine(LoadLobbyRoutine()); } private void OnClientStarted() { _cleanedUp = false; _isLocalAuthority = false; HookSceneManager(); // Listen for the server granting this machine match authority (dedicated-server case) and for the // sub-phase broadcasts. Registered here (client start) so the handlers exist before the server sends. if (_net.CustomMessagingManager != null) { _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgAuthorityGranted, OnAuthorityGrantedMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgPhase, OnPhaseMessage); } } // 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(); _phaseRoutine = null; _hooked = false; _sceneReady = false; _hasAuthorityClient = false; _isLocalAuthority = false; Phase = MatchPhase.Lobby; ApplyPhaseLocal(MatchSubPhase.None, 0f, _localMode); _pendingHiders.Clear(); if (_net) { _net.OnClientConnectedCallback -= OnClientConnected; _net.OnClientDisconnectCallback -= OnClientDisconnected; if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent; if (_net.CustomMessagingManager != null) { _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit); } } ReturnToMainOnly(); } 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; // Dedicated-server case: the first client to connect becomes the match authority (a host already // claimed it at server start, so this only fires for a server with no local player). Tell the // client over a named message so it reads Esc locally; the trigger picks it up server-side. if (!_hasAuthorityClient) { _authorityClientId = clientId; _hasAuthorityClient = true; SendAuthorityGrant(clientId); Debug.Log($"[MatchManager] Client {clientId} is now the match authority.", this); } // 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); // Sync the joiner's HUD/visibility to the running sub-phase + mode (remaining time as the duration). BroadcastPhase(_subPhase, LocalPhaseRemaining, matchMode, clientId, false); return; } SpawnFor(clientId, _playerPrefab, lobbySpawnCenter); } // Server-side: if the match authority leaves, hand authority to another connected client (lowest // remaining id) so a dedicated-server match stays controllable. No-op in the host case (the host // holds authority and only "leaves" on shutdown, which teardown handles). private void OnClientDisconnected(ulong clientId) { if (_net == null || !_net.IsServer) return; // A hider that leaves mid-match can never be found - drop it so "all hiders found" can still trigger. _pendingHiders.Remove(clientId); if (!_hasAuthorityClient || clientId != _authorityClientId) return; if (_net.IsHost && clientId == _net.LocalClientId) return; // host shutting down; teardown handles it _hasAuthorityClient = false; foreach (ulong id in _net.ConnectedClientsIds) { if (id == clientId) continue; // the leaving client may still be listed momentarily _authorityClientId = id; _hasAuthorityClient = true; SendAuthorityGrant(id); Debug.Log($"[MatchManager] Match authority reassigned to client {id}.", this); break; } } // 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; // Roll N distinct random Hunters (clamped to the player count); everyone else is a Hider. // hunterCount 0 -> all Hiders, >= player count -> all Hunters. Partial Fisher-Yates: swap a // random not-yet-picked id into each leading slot, then take that slot as a Hunter. int wanted = Mathf.Clamp(hunterCount, 0, ids.Count); var hunterIds = new HashSet(); for (int i = 0; i < wanted; i++) { int j = i + _roleRng.Next(ids.Count - i); (ids[i], ids[j]) = (ids[j], ids[i]); hunterIds.Add(ids[i]); } Debug.Log($"[MatchManager] Starting match. {hunterIds.Count} hunter(s), " + $"{ids.Count - hunterIds.Count} hider(s), {ids.Count} player(s).", this); StartCoroutine(MatchSequence(hunterIds)); } private IEnumerator MatchSequence(HashSet hunterIds) { 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 => hunterIds.Contains(id) ? hunterPrefab : hiderPrefab, matchSpawnCenter); // The hiders the seek phase has to find: every connected client that wasn't rolled a hunter. _pendingHiders.Clear(); foreach (ulong id in _net.ConnectedClientsIds) if (!hunterIds.Contains(id)) _pendingHiders.Add(id); Phase = MatchPhase.InMatch; Debug.Log("[MatchManager] Match started.", this); // Drive Hide -> Seek -> Resolution -> lobby. Held in a field so an early Back-to-Lobby can stop it. _phaseRoutine = StartCoroutine(RunMatchPhases()); } // Server-only. Runs the three sub-phases back to back, then returns everyone to the lobby. Hide and // Resolution are fixed-length; Seek bails the instant the last hider is found (or its timer runs out). private IEnumerator RunMatchPhases() { SetPhaseServer(MatchSubPhase.Hide, hideDuration); yield return WaitPhase(hideDuration, null); SetPhaseServer(MatchSubPhase.Seek, seekDuration); yield return WaitPhase(seekDuration, () => _pendingHiders.Count == 0); SetPhaseServer(MatchSubPhase.Resolution, resolveDuration); yield return WaitPhase(resolveDuration, null); _phaseRoutine = null; EndMatch(); // swaps the map back to the lobby and respawns everyone as the neutral Player } // Waits up to 'seconds' of server real time, returning early the moment 'until' (if supplied) is true. private IEnumerator WaitPhase(float seconds, System.Func until) { float t = seconds; while (t > 0f) { if (until != null && until()) yield break; t -= Time.deltaTime; yield return null; } } // Server-only. Applies the sub-phase to the server's own mirror and broadcasts it to every other client. private void SetPhaseServer(MatchSubPhase phase, float duration) { ApplyPhaseLocal(phase, duration, matchMode); BroadcastPhase(phase, duration, matchMode, 0, true); } // Sets this machine's sub-phase mirror + countdown + mode and notifies followers (HUD / visibility). // Shared by the server's SetPhaseServer and a client's OnPhaseMessage handler. private void ApplyPhaseLocal(MatchSubPhase phase, float duration, MatchMode mode) { bool changed = _subPhase != phase; _subPhase = phase; _phaseDuration = duration; _phaseEndTime = Time.time + duration; _localMode = mode; if (changed) SubPhaseChanged?.Invoke(phase); } // Sends a sub-phase (byte) + its remaining duration (float) + the match mode (byte) to one client, or to // all clients except the server itself (which applied it directly in SetPhaseServer, so it must not // double-apply via loopback). private void BroadcastPhase(MatchSubPhase phase, float duration, MatchMode mode, ulong targetClient, bool toAll) { var cmm = _net != null ? _net.CustomMessagingManager : null; if (cmm == null) return; using (var writer = new FastBufferWriter(sizeof(byte) + sizeof(float) + sizeof(byte), Allocator.Temp)) { writer.WriteValueSafe((byte)phase); writer.WriteValueSafe(duration); writer.WriteValueSafe((byte)mode); if (toAll) { // Every client except the server itself (the host already applied it directly in // SetPhaseServer, so it must not double-apply via loopback). One send, no writer reuse. _phaseRecipients.Clear(); foreach (ulong id in _net.ConnectedClientsIds) if (id != _net.LocalClientId) _phaseRecipients.Add(id); if (_phaseRecipients.Count > 0) cmm.SendNamedMessage(MsgPhase, _phaseRecipients, writer); } else { cmm.SendNamedMessage(MsgPhase, targetClient, writer); } } } // Client handler: the server broadcast a sub-phase change (or sync on join). private void OnPhaseMessage(ulong senderClientId, FastBufferReader reader) { reader.ReadValueSafe(out byte phase); reader.ReadValueSafe(out float duration); reader.ReadValueSafe(out byte mode); ApplyPhaseLocal((MatchSubPhase)phase, duration, (MatchMode)mode); } /// Client-side: the local hunter's shoot raycast hit the hider owned by . /// Routes to the server, which validates phase/role/found-state. On a host this IS the server, so it /// applies directly. No-op outside the seek phase. public void ReportHit(ulong targetClientId) { if (_net == null || _subPhase != MatchSubPhase.Seek) return; if (_net.IsServer) { if (_pendingHiders.Contains(targetClientId) && IsHunter(_net.LocalClientId)) MarkHiderFound(targetClientId); return; } var cmm = _net.CustomMessagingManager; if (cmm == null) return; using (var writer = new FastBufferWriter(sizeof(ulong), Allocator.Temp)) { writer.WriteValueSafe(targetClientId); cmm.SendNamedMessage(MsgReportHit, NetworkManager.ServerClientId, writer); } } // Server handler: a hunter client reports its shoot raycast hit a hider. Trusts the client's raycast for // the hit itself (a co-op hide & seek, not a competitive shooter); the server gates phase, shooter role // and the target still being an un-found hider. private void OnReportHitMessage(ulong senderClientId, FastBufferReader reader) { reader.ReadValueSafe(out ulong targetClientId); if (_net == null || !_net.IsServer) return; if (_subPhase != MatchSubPhase.Seek) return; if (!_pendingHiders.Contains(targetClientId)) return; // not a live (un-found) hider if (!IsHunter(senderClientId)) return; // only a hunter may shoot MarkHiderFound(targetClientId); } // Server-only. Resolves a hit on a hider for the active mode, and drops it from the pending set; the seek // loop ends the phase early once that empties (no hiders left to find/convert). Classic flags the avatar // found (replicated by its NetworkPlayerSetup -> free-cam); Conversion respawns it as a Hunter. private void MarkHiderFound(ulong hiderClientId) { _pendingHiders.Remove(hiderClientId); if (_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject) { if (matchMode == MatchMode.Conversion) ConvertToHunter(hiderClientId, c.PlayerObject); else { var setup = c.PlayerObject.GetComponent(); if (setup) setup.ServerMarkFound(); } } string verb = matchMode == MatchMode.Conversion ? "converted to hunter" : "found"; Debug.Log($"[MatchManager] Hider {hiderClientId} {verb} - {_pendingHiders.Count} hider(s) left.", this); } // Server-only (Conversion mode). Despawns the tagged hider's avatar and respawns it as the Hunter prefab // variant for the same client, AT THE SAME position/rotation - it converts where it was tagged, no teleport. // The fresh Hunter's owner skips its spawn-placement because the match is mid-Seek (see NetworkPlayerSetup), // so the server-set position sticks. Same despawn-then-spawn pattern as RespawnAll; the owner's painting // carries across via the per-machine paint carry. After this the client plays as a full hunter (size, // sprint, first-person, shooting) with no special-case runtime state. private void ConvertToHunter(ulong clientId, NetworkObject current) { if (!hunterPrefab) { Debug.LogError("[MatchManager] Hunter prefab missing - cannot convert hider.", this); return; } Vector3 pos = current.transform.position; Quaternion rot = current.transform.rotation; current.Despawn(true); var go = Instantiate(hunterPrefab, pos, rot); // Hand the tagged world position to the owner: the owner-authoritative transform ignores the server's // Instantiate pos, so without this the converted hunter snaps to the prefab origin (0,0,0). Must be set // BEFORE the spawn so OnNetworkSpawn can push it into the replicated _spawnOverride snapshot. var setup = go.GetComponent(); if (setup) setup.MarkConversionSpawn(pos); go.GetComponent().SpawnAsPlayerObject(clientId); } // True if the client's current avatar is a Hunter role variant (used to gate shooting server-side). private bool IsHunter(ulong clientId) { if (!_net.ConnectedClients.TryGetValue(clientId, out var c) || !c.PlayerObject) return false; var setup = c.PlayerObject.GetComponent(); return setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter; } /// 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 // Stop the sub-phase machine if the match is being ended early (authority's Back-to-Lobby) and clear // the replicated sub-phase so every client drops its match HUD/visibility before the scene swaps. if (_phaseRoutine != null) { StopCoroutine(_phaseRoutine); _phaseRoutine = null; } _pendingHiders.Clear(); SetPhaseServer(MatchSubPhase.None, 0f); 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() { // A real disconnect/shutdown is not a respawn: drop any parked painting so a fresh session // (re-host / reconnect) never restores a previous match's painting onto its first avatar. _carriedPaint = null; 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); } /// Owner-side: park the local painting so the avatar a scene switch spawns next can restore it. public void StoreCarriedPaint(byte[] data) => _carriedPaint = data; /// Owner-side: take (and clear) the parked painting for the freshly spawned avatar, or null if none. public byte[] TakeCarriedPaint() { byte[] d = _carriedPaint; _carriedPaint = null; return d; } /// True if the given client currently holds match-control authority (may start and end the match). public bool IsMatchAuthority(ulong clientId) => _hasAuthorityClient && clientId == _authorityClientId; /// True if THIS machine holds match authority - the host (whose own client id is the authority) /// or the granted authority client on a dedicated server. Drives the online panel's Disconnect button: /// on the map the authority's button means "Back to Lobby" (end the match); everyone else disconnects. public bool IsLocalMatchAuthority { get { if (_net == null) return false; if (_net.IsServer) return IsMatchAuthority(_net.LocalClientId); return _isLocalAuthority; } } /// Ends the running match for everyone (back to the Lobby), routed through authority: the /// host/server ends directly; a remote authority client asks the server. No-op if this machine is not /// the authority or no match is running. Replaces the old Esc handler - now driven by the panel button. public void RequestEndMatch() { if (_net == null) return; if (_net.IsServer) { EndMatch(); return; } // EndMatch itself guards Phase == InMatch if (_isLocalAuthority) RequestEndMatchFromServer(); } // Local active-scene kind. Every peer sets its active scene to Lobby/Map as those load (see OnSceneEvent) // and back to Main on teardown, so a local UI can tell where it is without the server-local Phase. public bool IsLocalSceneMain => SceneManager.GetActiveScene().name == mainSceneName; public bool IsLocalSceneLobby => SceneManager.GetActiveScene().name == lobbySceneName; public bool IsLocalSceneMap => SceneManager.GetActiveScene().name == matchSceneName; // Server -> client: tell the designated client it is the match authority (so it reads Esc locally). private void SendAuthorityGrant(ulong clientId) { var cmm = _net != null ? _net.CustomMessagingManager : null; if (cmm == null) return; using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp)) { writer.WriteByteSafe(1); // payload unused; a named message still needs a buffer cmm.SendNamedMessage(MsgAuthorityGranted, clientId, writer); } } // Client handler: this machine has been granted match authority. private void OnAuthorityGrantedMessage(ulong senderClientId, FastBufferReader reader) { _isLocalAuthority = true; Debug.Log("[MatchManager] This client is the match authority: walk into the Start Match trigger to begin, Esc to end.", this); } // Client -> server: ask the server to end the match. Only honored from the authority client (and only // while a match is running - EndMatch guards the phase). private void RequestEndMatchFromServer() { var cmm = _net != null ? _net.CustomMessagingManager : null; if (cmm == null) return; using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp)) { writer.WriteByteSafe(1); cmm.SendNamedMessage(MsgRequestEndMatch, NetworkManager.ServerClientId, writer); } } // Server handler: an authority client asked to end the match. private void OnRequestEndMatchMessage(ulong senderClientId, FastBufferReader reader) { if (_net == null || !_net.IsServer) return; if (!IsMatchAuthority(senderClientId)) return; EndMatch(); // guards Phase == InMatch itself } }