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); the gameplay scenes (Lobby, Test Map) are loaded ADDITIVELY through NGO's scene manager so /// Main (cameras/HUD/managers) is never torn down. /// /// Two design pillars make this model work: /// 1. Clients sync ADDITIVELY (). The NGO /// default (Single) would unload the connecting client's own Main and crash the player-object scene /// migration; additive keeps the client's Main and layers the server's scenes on top. Must be set on /// the server before any client connects (auto-propagated to clients). /// 2. Players are spawned by THIS manager, not NGO auto-spawn - that would spawn into whatever scene is /// active on connect (for the host: floor-less Main, while the additive Lobby load is in flight). /// is nulled and avatars are spawned manually once the /// gameplay scene is loaded. Main holds zero network objects. /// /// Match-control AUTHORITY (may start/end the match): the host; on a DEDICATED server the first client /// to connect (told via named message, validated server-side on use, reassigned if it leaves). /// Mid-match joiners spawn as empty free-cam SPECTATOR avatars until the match ends. /// /// 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. /// Loading -> Hide -> Seek -> Resolution, then back to the lobby. Drives per-client visibility, hunter /// shooting and the match HUD countdown. Loading is the pre-match barrier: everyone is frozen (no movement, /// no countdown) until every client has spawned all match avatars AND applied their paintings, so nobody /// moves or sees a half-loaded world before Hide begins. public enum MatchSubPhase { None, Loading, 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; [Tooltip("CONVERSION mode only: a frozen, painted 'statue' spawned at the start of Resolution at the exact " + "spot / stance / painting each hider held the moment it was converted into a hunter, so everyone " + "sees where each hider was caught. Purely presentational (no controller / collision / role). Must " + "be a registered network prefab (a stripped Hider variant). Empty = statues disabled.")] [SerializeField] private GameObject statuePrefab; [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; [Tooltip("Max seconds the pre-match LOADING barrier waits for EVERY client to finish loading (all match " + "avatars spawned AND their paintings applied on every machine) before the Hide phase starts and " + "players may move. Until then everyone is frozen and the countdown is held. A safety cap only: if " + "a client hangs, the match starts anyway once this elapses. 0 = wait indefinitely (not recommended).")] [Min(0f)] [SerializeField] private float matchLoadTimeout = 20f; [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; [Tooltip("Seconds a shot hider stays lit with the X-ray glow before it is actually removed from play " + "(Classic: hidden as found; Conversion: respawned as a hunter). Gives the just-shot flash time to " + "be seen. The hider stays a live target during this window; 0 = remove instantly (no visible flash).")] [Min(0f)] [SerializeField] private float hitRevealDelay = 0.35f; [Header("Concealment reveal")] [Tooltip("A hider whose ConcealmentMeter EmbeddedPercent reaches this value (while a match is running) is " + "revealed through walls with the material's X-ray glow, so nobody can hide by burying itself deep " + "inside geometry. 0..100; 100 effectively disables it (never reached). Read owner-side per avatar.")] [Range(0f, 100f)] [SerializeField] private float embeddedRevealPercent = 80f; [Header("Radar pings")] [Tooltip("Seconds between radar sweeps during Seek. Every interval each still-un-found hider is registered " + "as a blip on every HUNTER's radar (hiders and spectators never receive them). Fires only in the " + "Seek phase. 0 disables radar pings.")] [Min(0f)] [SerializeField] private float radarPingInterval = 30f; [Tooltip("How long (seconds) each hider radar blip stays alive before it fades. Kept short relative to " + "radarPingInterval so hunters get a periodic sweep, not a constant track.")] [Min(0f)] [SerializeField] private float radarPingLifetime = 3f; /// Range of the hunter's shoot raycast (read by ). public float ShootRange => shootRange; /// Hunter shoot cooldown in seconds (read by ). public float ShootCooldown => shootCooldown; /// EmbeddedPercent [0..100] a hider must reach for the through-walls X-ray reveal to switch on /// (only while a match is running). Read by each owner's against its own /// concealment meter; baked identically on every client, so it needs no replication. public float EmbeddedRevealPercent => embeddedRevealPercent; /// Server-local phase guard. Not replicated - see class summary. public MatchPhase Phase { get; private set; } = MatchPhase.Lobby; // Replicated sub-phase mirror (set by the server's broadcast; the server sets its own copy directly). // The countdown is cosmetic/local; the authoritative transition is server-driven, so drift self-corrects. private MatchSubPhase _subPhase = MatchSubPhase.None; private float _phaseEndTime; // Time.time when the current sub-phase ends (for the HUD countdown) private float _seekStartTime; // Time.time Seek began locally (anchors the ping-countdown cadence, START-aligned) private MatchMode _localMode = MatchMode.Classic; // mirror of the server's matchMode, carried in the phase message private const string PreMatchTag = "PreMatch"; // scene props destroyed when seek starts /// 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); /// Seconds between radar ping sweeps (0 = pings disabled). Baked identically on every client /// (persistent MatchManager, no replication - like ), so the HUD can /// drive the ping countdown locally. public float RadarPingInterval => radarPingInterval; /// Seconds until the next radar ping sweep for the HUD (counts down from /// and repeats). 0 outside Seek or when pings are disabled. Computed locally from the local Seek-start + /// fixed interval - cosmetic, like (a mid-match joiner's is offset by the /// time it missed; the server's sweep timer is START-aligned the same way). public float LocalNextPingIn { get { if (_subPhase != MatchSubPhase.Seek || radarPingInterval <= 0f) return 0f; float intoInterval = (Time.time - _seekStartTime) % radarPingInterval; return radarPingInterval - intoInterval; } } 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 // Own generator, seeded from real entropy: UnityEngine.Random's engine-wide state sits at the same // point every fresh session (nothing consumed before match start), which made the "random" hunter // land on the same client every time. private readonly System.Random _roleRng = new System.Random(System.Guid.NewGuid().GetHashCode()); // The LOCAL owner's painting, parked across an avatar respawn (scene switch) by its despawning // PlayerPaintSync and consumed once by the next owner avatar - this manager is persistent, so it // bridges the despawn->spawn gap. Cleared on teardown so a fresh session never inherits it. private byte[] _carriedPaint; // Match-control authority. Server-local: which client may start/end the match. Host -> itself; // dedicated server -> the first client to connect. private bool _hasAuthorityClient; private ulong _authorityClientId; // Client-side mirror of the grant message: this machine may ask 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"; // Client -> server, right after the client connects: the sender's persistent per-install id (survives its // app restarts). Lets the server map the ephemeral NGO clientId to a stable identity - the reconnect // foundation. See PersistentPlayerId. private const string MsgIdentity = "Match_Identity"; // Server -> HUNTER clients only, on each Seek radar sweep: the still-un-found hiders' NetworkObjectIds plus // the blip lifetime. Each hunter resolves the ids locally and drops a Radar blip pointing at the hider. private const string MsgRadarPing = "Match_RadarPing"; // Pre-match LOADING barrier. Server -> all clients, once the match avatars are respawned: the target avatar // NetworkObjectIds + a generation tag; each client waits until it has spawned & paint-applied all of them. // Client -> server: the "I finished loading" reply (echoes the generation so a stale ack from a previous // barrier round is dropped). The server holds the Hide phase until every expected client has replied. private const string MsgBeginLoad = "Match_BeginLoad"; private const string MsgReady = "Match_Ready"; // Client -> server (authority only): DEBUG request to skip the rest of the current sub-phase. private const string MsgDebugSkip = "Match_DebugSkip"; // 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(); // Server-only. Hiders that were hit and are in their X-ray flash window, not yet removed. Kept out of the // seek-end check by staying in _pendingHiders; this set only dedupes a second shot during the flash. private readonly HashSet _revealingHiders = new HashSet(); // Server-only. Live NGO clientId -> persistent per-install id, reported by each client on connect. The // clientId is ephemeral (reused after a disconnect); the value is stable across that client's restarts. private readonly Dictionary _persistentIds = new Dictionary(); private Coroutine _phaseRoutine; // DEBUG: set by DebugSkipToNextSubPhase; the running phase-wait loop consumes it once to break out early, // so RunMatchPhases advances to the next SetPhaseServer without waiting out the timer/barrier. private bool _skipPhase; private readonly List _phaseRecipients = new List(); // reused scratch for the phase broadcast private readonly List _radarPingIds = new List(); // reused scratch: hider NetworkObjectIds for a sweep private readonly List _radarRecipients = new List(); // reused scratch: hunter client recipients for a sweep // Server-only pre-match loading barrier. _matchAvatarIds = the freshly respawned avatars every client must // have spawned & paint-applied; _expectedReady = the clients present when the barrier opened (a mid-load // joiner isn't required, so it can't stall the start); _readyClients = those that have reported ready. // _loadRecipients is reused scratch for the broadcast. _loadGeneration tags the round so a late ack from a // previous barrier is ignored. private readonly List _matchAvatarIds = new List(); private readonly List _loadRecipients = new List(); private readonly HashSet _expectedReady = new HashSet(); private readonly HashSet _readyClients = new HashSet(); private int _loadGeneration; // Client-side barrier state: the target avatar ids to wait for, the generation to echo back, and the running poll. private readonly List _loadTargetIds = new List(); private int _loadGenerationClient; private Coroutine _clientLoadPoll; // Server-only. Conversion statues: a snapshot of each converted hider (spot / stance / painting) captured // on the conversion, materialized as frozen statue objects only at the start of Resolution and despawned // when the match ends. Only populated in Conversion mode (ConvertToHunter is the sole capture site). private class StatueRecord { public Vector3 pos; public Quaternion rot; public int poseIndex; public byte[] paint; } private readonly List _conversionRecords = new List(); private readonly List _statues = new List(); 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); keep the reference and spawn // manually once a floored scene exists. Runtime-only mutation - 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; UnhookSessionCallbacks(); } if (Instance == this) Instance = null; } // Unhooks the per-session callbacks and named-message handlers (shared by OnDisable and OnNetStopped). private void UnhookSessionCallbacks() { _net.OnClientConnectedCallback -= OnClientConnected; _net.OnClientConnectedCallback -= OnLocalClientConnected; _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(MsgRadarPing); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgIdentity); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgBeginLoad); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReady); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgDebugSkip); } } 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; // Host -> authority right away; dedicated server -> the first client to connect claims it. _hasAuthorityClient = false; if (_net.IsHost) { _authorityClientId = _net.LocalClientId; _hasAuthorityClient = true; } if (_net.CustomMessagingManager != null) { _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRequestEndMatch, OnRequestEndMatchMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReportHit, OnReportHitMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgIdentity, OnIdentityMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReady, OnReadyMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgDebugSkip, OnDebugSkipMessage); } _subPhase = MatchSubPhase.None; _pendingHiders.Clear(); _persistentIds.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(); // Registered at client start so the handlers exist before the server sends. if (_net.CustomMessagingManager != null) { _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgAuthorityGranted, OnAuthorityGrantedMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgPhase, OnPhaseMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRadarPing, OnRadarPingMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgBeginLoad, OnBeginLoadMessage); } // Report our persistent id to the server the moment our own connection completes (host records its own // directly). Re-hook defensively in case of re-connect. _net.OnClientConnectedCallback -= OnLocalClientConnected; _net.OnClientConnectedCallback += OnLocalClientConnected; } // 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; _skipPhase = false; _hooked = false; _sceneReady = false; _hasAuthorityClient = false; _isLocalAuthority = false; Phase = MatchPhase.Lobby; ApplyPhaseLocal(MatchSubPhase.None, 0f, _localMode); _pendingHiders.Clear(); _persistentIds.Clear(); // Drop any in-flight loading-barrier state (StopAllCoroutines above already killed the polls). _expectedReady.Clear(); _readyClients.Clear(); _matchAvatarIds.Clear(); _loadTargetIds.Clear(); _clientLoadPoll = null; // Teardown unloads the gameplay scenes (destroys the statue objects); just drop our references. _statues.Clear(); _conversionRecords.Clear(); if (_net) UnhookSessionCallbacks(); 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); // Scene is up - safe to spawn the already-connected peers (host + anyone who joined mid-load). _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 // the load/respawn routine 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). Told via named message so it reads Esc locally. 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: empty SPECTATOR avatar (free-cam only); the end-of-match respawn loop turns // it 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 so a // dedicated-server match stays controllable. 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); _revealingHiders.Remove(clientId); // and out of the in-flight flash set (its resolve coroutine no-ops) // Drop it from the loading barrier too: an expected client that leaves mid-load must stop gating the // start (AllClientsReady only requires still-connected expected clients). _expectedReady.Remove(clientId); _readyClients.Remove(clientId); // Free the ephemeral clientId -> identity mapping (the clientId will be reused by a later joiner). _persistentIds.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; } } // Fired locally when OUR own client connection completes (host and pure client alike). The host is already // the server, so it records its own identity directly; a pure client reports it to the server by message. private void OnLocalClientConnected(ulong clientId) { if (_net == null || clientId != _net.LocalClientId) return; if (_net.IsServer) RegisterIdentity(_net.LocalClientId, PersistentPlayerId.Value); else SendIdentityToServer(PersistentPlayerId.Value); } // Client -> server: report this install's persistent id so the server can map it to our ephemeral clientId. private void SendIdentityToServer(string persistentId) { var cmm = _net != null ? _net.CustomMessagingManager : null; if (cmm == null || string.IsNullOrEmpty(persistentId)) return; // Grows up to the cap if a future id source is longer than the initial buffer (persistent ids are short). using (var writer = new FastBufferWriter(256, Allocator.Temp, 1024)) { writer.WriteValueSafe(persistentId); cmm.SendNamedMessage(MsgIdentity, NetworkManager.ServerClientId, writer); } } // Server handler: a client reported its persistent per-install id right after connecting. private void OnIdentityMessage(ulong senderClientId, FastBufferReader reader) { reader.ReadValueSafe(out string persistentId); if (_net == null || !_net.IsServer) return; RegisterIdentity(senderClientId, persistentId); } // Server-only. Records the ephemeral clientId -> persistent id mapping (idempotent; ignores empty ids). private void RegisterIdentity(ulong clientId, string persistentId) { if (string.IsNullOrEmpty(persistentId)) return; _persistentIds[clientId] = persistentId; Debug.Log($"[MatchManager] Client {clientId} persistent id: {persistentId}", this); } /// Server-only. The stable per-install id a client reported on connect (survives that client's /// app restarts), or null if unknown / not yet received. Foundation for reconnect recognition. public string PersistentIdOf(ulong clientId) => _persistentIds.TryGetValue(clientId, out var id) ? id : null; // 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 via partial Fisher-Yates (swap a random not-yet-picked id into // each leading slot); hunterCount 0 -> all Hiders, >= player count -> all Hunters. 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) { _conversionRecords.Clear(); // fresh match inherits no stale statue captures (EndMatch also clears) // Freeze everyone in Loading BEFORE unloading the lobby: the lobby floor vanishes a moment before the // map's is loaded, so the still-bound lobby avatars would otherwise drop into the void during the swap. // Loading gates movement, gravity AND the local own-body mesh on every client (see GameModeManager); the // readiness barrier below (BeginLoadingBarrierServer re-asserts Loading) holds that hold until every // client has the map + all match avatars ready, then Hide releases it. SetPhaseServer(MatchSubPhase.Loading, 0f); yield return SwapScenes(lobbySceneName, matchSceneName); RespawnAll(id => hunterIds.Contains(id) ? hunterPrefab : hiderPrefab, matchSpawnCenter); _pendingHiders.Clear(); _revealingHiders.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() { // Pre-match readiness barrier: hold everyone in the Loading sub-phase (frozen, no countdown) until every // client has spawned all match avatars AND applied their paintings, so nobody moves or sees a half-loaded // world before Hide. Bounded by matchLoadTimeout so a hung client can't stall the match forever. Lives in // _phaseRoutine (yielded inline), so an early Back-to-Lobby that stops that routine stops the barrier too. yield return RunLoadingBarrier(); SetPhaseServer(MatchSubPhase.Hide, hideDuration); yield return WaitPhase(hideDuration, null); SetPhaseServer(MatchSubPhase.Seek, seekDuration); yield return RunSeekPhase(seekDuration); SetPhaseServer(MatchSubPhase.Resolution, resolveDuration); // Conversion mode: materialize a frozen statue of every converted hider at its capture spot, now that // Resolution (which reveals everyone) has begun. No-op with no records / no statue prefab / in Classic. SpawnConversionStatues(); yield return WaitPhase(resolveDuration, null); _phaseRoutine = null; EndMatch(); } // Server-only. The seek wait with the radar-ping sweep folded in, so pinging lives inside _phaseRoutine // (an early Back-to-Lobby stops that routine and so stops pinging too). Ends the instant the last hider is // found - identical early-out to the plain WaitPhase(_pendingHiders.Count == 0) it replaces for Seek. private IEnumerator RunSeekPhase(float seconds) { _skipPhase = false; // drop a stale debug-skip from the previous phase; only this phase may set it float t = seconds; float pingTimer = radarPingInterval; // first sweep one interval into Seek while (t > 0f) { if (_pendingHiders.Count == 0) yield break; if (ConsumeSkip()) yield break; // DEBUG: jump straight to Resolution if (radarPingInterval > 0f) { pingTimer -= Time.deltaTime; if (pingTimer <= 0f) { BroadcastRadarPings(); pingTimer = radarPingInterval; } } t -= Time.deltaTime; yield return null; } } // 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) { _skipPhase = false; // drop a stale debug-skip from the previous phase; only this phase may set it float t = seconds; while (t > 0f) { if (until != null && until()) yield break; if (ConsumeSkip()) yield break; // DEBUG: jump to the next sub-phase t -= Time.deltaTime; yield return null; } } // DEBUG: true (once) if a skip-to-next-sub-phase was requested; consumed so a single request fires exactly // one transition. Only ever set server-side (DebugSkipServer) and only these server-run wait loops read it. private bool ConsumeSkip() { if (!_skipPhase) return false; _skipPhase = false; return true; } /// DEBUG. Instantly advances the running match to the next sub-phase /// (Loading -> Hide -> Seek -> Resolution -> back to lobby), skipping the rest of the current phase's timer /// or loading barrier. Routed through match authority: the host/server acts directly, a remote authority /// client asks the server. No-op if this machine is not the authority or no sub-phase is running. [ContextMenu("Debug/Skip To Next Sub-Phase")] public void DebugSkipToNextSubPhase() { if (_net == null) return; if (_net.IsServer) { DebugSkipServer(); return; } if (_isLocalAuthority) SendEmptyNamedMessage(MsgDebugSkip, NetworkManager.ServerClientId); } // Server-only. Flags the running phase-wait loop to break early so RunMatchPhases moves to the next sub-phase. private void DebugSkipServer() { if (_net == null || !_net.IsServer) return; if (_subPhase == MatchSubPhase.None) return; // nothing running to skip _skipPhase = true; Debug.Log($"[MatchManager] DEBUG: skipping the rest of {_subPhase}.", this); } // Server handler: an authority client asked to skip to the next sub-phase. private void OnDebugSkipMessage(ulong senderClientId, FastBufferReader reader) { if (_net == null || !_net.IsServer) return; if (!IsMatchAuthority(senderClientId)) return; DebugSkipServer(); } // --- Pre-match loading barrier --------------------------------------------------------------------- // Server-only. Opens the barrier (snapshot the just-respawned avatars + the clients that must load them, // switch everyone to Loading, tell each client which avatars to wait for), then spins until every still- // connected expected client has reported ready - or matchLoadTimeout elapses. private IEnumerator RunLoadingBarrier() { _skipPhase = false; // drop any debug-skip left over from the previous phase/match; only this phase may set it BeginLoadingBarrierServer(); float t = matchLoadTimeout; while (!AllClientsReady()) { if (ConsumeSkip()) break; // DEBUG: force the match to start now, without waiting for every client // The host is a client too and never messages itself: fold its own local readiness in directly. if (LocalClientReady(_matchAvatarIds)) _readyClients.Add(_net.LocalClientId); if (matchLoadTimeout > 0f) { t -= Time.deltaTime; if (t <= 0f) { Debug.LogWarning("[MatchManager] Load barrier timed out - starting the match anyway.", this); break; } } yield return null; } } // Server-only. Captures the barrier target (the avatars RespawnAll just created + the clients that must load // them), bumps the generation, switches everyone to Loading (duration 0 = no countdown) and broadcasts the // target avatar ids so each client can start waiting. private void BeginLoadingBarrierServer() { _matchAvatarIds.Clear(); _expectedReady.Clear(); _readyClients.Clear(); foreach (ulong id in _net.ConnectedClientsIds) { _expectedReady.Add(id); if (_net.ConnectedClients.TryGetValue(id, out var c) && c.PlayerObject) _matchAvatarIds.Add(c.PlayerObject.NetworkObjectId); } _loadGeneration++; SetPhaseServer(MatchSubPhase.Loading, 0f); BroadcastBeginLoad(); } // True once every expected client that is STILL connected has reported ready. A client that disconnected // mid-load drops out of the requirement; a mid-load joiner was never in _expectedReady, so it can't stall it. private bool AllClientsReady() { foreach (ulong id in _expectedReady) if (_net.ConnectedClients.ContainsKey(id) && !_readyClients.Contains(id)) return false; return true; } // Any machine: true once all target avatars are spawned locally AND each one's painting has been applied // (PlayerPaintSync.PaintRevealReady). The local owner's own avatar is always ready; remotes gate on the // applied paint cutoff - exactly the condition the per-client reveal uses to un-hide the body. private bool LocalClientReady(List ids) { var sm = _net != null ? _net.SpawnManager : null; if (sm == null) return false; for (int i = 0; i < ids.Count; i++) { if (!sm.SpawnedObjects.TryGetValue(ids[i], out var no) || !no) return false; var sync = no.GetComponentInChildren(true); if (sync && !sync.PaintRevealReady) return false; } return true; } // Server -> every other client: the target avatar ids + generation for this barrier round. private void BroadcastBeginLoad() { var cmm = _net != null ? _net.CustomMessagingManager : null; if (cmm == null) return; _loadRecipients.Clear(); foreach (ulong id in _net.ConnectedClientsIds) if (id != _net.LocalClientId) _loadRecipients.Add(id); if (_loadRecipients.Count == 0) return; int size = sizeof(int) + sizeof(int) + _matchAvatarIds.Count * sizeof(ulong); using (var writer = new FastBufferWriter(size, Allocator.Temp)) { writer.WriteValueSafe(_loadGeneration); writer.WriteValueSafe(_matchAvatarIds.Count); for (int i = 0; i < _matchAvatarIds.Count; i++) writer.WriteValueSafe(_matchAvatarIds[i]); cmm.SendNamedMessage(MsgBeginLoad, _loadRecipients, writer); } } // Client handler: the server opened the loading barrier. Store the target avatars + generation and start // polling; when everything is spawned & painted, reply Ready. Re-started if a fresh barrier message arrives. private void OnBeginLoadMessage(ulong senderClientId, FastBufferReader reader) { reader.ReadValueSafe(out int generation); reader.ReadValueSafe(out int count); _loadTargetIds.Clear(); for (int i = 0; i < count; i++) { reader.ReadValueSafe(out ulong id); _loadTargetIds.Add(id); } _loadGenerationClient = generation; if (_clientLoadPoll != null) StopCoroutine(_clientLoadPoll); _clientLoadPoll = StartCoroutine(ClientLoadingPoll()); } // Client-only. Polls until this machine has spawned & paint-applied every target avatar, then reports ready. // Bails without a reply if the phase leaves Loading first (match ended, or the server already moved to Hide). private IEnumerator ClientLoadingPoll() { while (_subPhase == MatchSubPhase.Loading) { if (LocalClientReady(_loadTargetIds)) { SendReadyToServer(_loadGenerationClient); break; } yield return null; } _clientLoadPoll = null; } // Client -> server: this machine finished loading (echoes the generation so a stale reply is ignored). private void SendReadyToServer(int generation) { var cmm = _net != null ? _net.CustomMessagingManager : null; if (cmm == null) return; using (var writer = new FastBufferWriter(sizeof(int), Allocator.Temp)) { writer.WriteValueSafe(generation); cmm.SendNamedMessage(MsgReady, NetworkManager.ServerClientId, writer); } } // Server handler: a client reported it finished loading. Counted only if it is for the current barrier round. private void OnReadyMessage(ulong senderClientId, FastBufferReader reader) { reader.ReadValueSafe(out int generation); if (_net == null || !_net.IsServer) return; if (generation != _loadGeneration) return; // stale ack from a previous barrier round _readyClients.Add(senderClientId); } // 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 (followers poll LocalSubPhase). // 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; _phaseEndTime = Time.time + duration; _localMode = mode; // Props staged only for the hide phase are gone the instant seek begins; anchor the local ping-countdown // cadence to the same moment (START-aligned, matching the server's sweep timer). if (changed && phase == MatchSubPhase.Seek) { _seekStartTime = Time.time; DestroyPreMatchObjects(); } } // Runs on every machine (server directly, clients via the phase message); the tagged props are plain // scene geometry, not NetworkObjects, so each machine destroys its own local copies. private static void DestroyPreMatchObjects() { var props = GameObject.FindGameObjectsWithTag(PreMatchTag); for (int i = 0; i < props.Length; i++) Destroy(props[i]); } // Sends the sub-phase + duration + mode 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) { _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); } // Server-only. One radar sweep during Seek: registers every still-un-found hider as a blip on each HUNTER's // radar. Sends the hiders' NetworkObjectIds + blip lifetime to the hunter clients; a host-hunter applies // locally (no loopback message, mirroring SetPhaseServer). Hiders and spectators never receive a ping. private void BroadcastRadarPings() { if (_net == null || !_net.IsServer || _pendingHiders.Count == 0) return; _radarPingIds.Clear(); foreach (ulong hiderClientId in _pendingHiders) if (_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject) _radarPingIds.Add(c.PlayerObject.NetworkObjectId); if (_radarPingIds.Count == 0) return; // Host-as-hunter: spawn on its own radar directly (it is excluded from the recipient list below). if (IsHunter(_net.LocalClientId)) SpawnRadarBlipsLocal(); _radarRecipients.Clear(); foreach (ulong clientId in _net.ConnectedClientsIds) if (clientId != _net.LocalClientId && IsHunter(clientId)) _radarRecipients.Add(clientId); if (_radarRecipients.Count == 0) return; var cmm = _net.CustomMessagingManager; if (cmm == null) return; int size = sizeof(float) + sizeof(int) + _radarPingIds.Count * sizeof(ulong); using (var writer = new FastBufferWriter(size, Allocator.Temp)) { writer.WriteValueSafe(radarPingLifetime); writer.WriteValueSafe(_radarPingIds.Count); for (int i = 0; i < _radarPingIds.Count; i++) writer.WriteValueSafe(_radarPingIds[i]); cmm.SendNamedMessage(MsgRadarPing, _radarRecipients, writer); } } // Host/server local path: resolve the swept hider NetworkObjectIds (in _radarPingIds) to their live // transforms and drop a blip on the local radar. Mirrors OnRadarPingMessage for the host's own hunter view. private void SpawnRadarBlipsLocal() { var radar = Radar.Instance; var spawnMgr = _net != null ? _net.SpawnManager : null; if (!radar || spawnMgr == null) return; for (int i = 0; i < _radarPingIds.Count; i++) if (spawnMgr.SpawnedObjects.TryGetValue(_radarPingIds[i], out var no) && no) radar.Spawn(no.transform, radarPingLifetime); } // Hunter-client handler: a radar sweep arrived. Resolve each hider NetworkObjectId to its live transform and // drop a blip on the local radar. Only hunter clients are sent this message (filtered server-side). private void OnRadarPingMessage(ulong senderClientId, FastBufferReader reader) { reader.ReadValueSafe(out float lifetime); reader.ReadValueSafe(out int count); var radar = Radar.Instance; var spawnMgr = _net != null ? _net.SpawnManager : null; for (int i = 0; i < count; i++) { reader.ReadValueSafe(out ulong netObjId); if (radar && spawnMgr != null && spawnMgr.SpawnedObjects.TryGetValue(netObjId, out var no) && no) radar.Spawn(no.transform, lifetime); } } /// 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: flashes its X-ray glow on every client, then (after hitRevealDelay // so the flash is actually seen) drops it from the pending set and applies the mode outcome - Classic flags // the avatar found (replicated by its NetworkPlayerSetup -> free-cam); Conversion respawns it as a Hunter. // The hider stays in _pendingHiders during the flash so seek doesn't end early on the last hit; the seek // loop ends the phase once _pendingHiders empties (no hiders left to find/convert). private void MarkHiderFound(ulong hiderClientId) { if (_revealingHiders.Contains(hiderClientId)) return; // already flashing / being resolved if (_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject) { var setup = c.PlayerObject.GetComponent(); if (setup) setup.FlashHitReveal(hitRevealDelay); // light it up on every client, now } _revealingHiders.Add(hiderClientId); StartCoroutine(ResolveHitAfterReveal(hiderClientId)); } // Server-only. Waits out the reveal flash, then removes the hider and applies the mode outcome. Split from // MarkHiderFound so the glow is visible before the avatar leaves play (Classic hide / Conversion despawn). private IEnumerator ResolveHitAfterReveal(ulong hiderClientId) { if (hitRevealDelay > 0f) yield return new WaitForSeconds(hitRevealDelay); _revealingHiders.Remove(hiderClientId); if (_net == null || !_net.IsServer) yield break; _pendingHiders.Remove(hiderClientId); // A Back-to-Lobby (EndMatch) during the flash tears the match down and respawns everyone; don't then // convert/found a stale avatar. if (Phase == MatchPhase.InMatch && _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 and respawns it as the Hunter variant for // the same client AT THE SAME position/rotation - no teleport. The owner skips its spawn placement // mid-Seek (see NetworkPlayerSetup) so the server-set position sticks; painting carries across via // the paint carry. Afterwards the client is a full hunter 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; // Snapshot the hider's spot / stance / painting NOW, before it despawns - a frozen statue is // materialized from this at the start of Resolution (see SpawnConversionStatues). CaptureConversionStatue(pos, rot, current); current.Despawn(true); var go = Instantiate(hunterPrefab, pos, rot); // The owner-authoritative transform ignores the server's Instantiate pos - hand the tagged position // to the owner, BEFORE the spawn, so it rides the replicated _spawnOverride snapshot. var setup = go.GetComponent(); if (setup) setup.MarkConversionSpawn(pos); go.GetComponent().SpawnAsPlayerObject(clientId); } // Server-only. Captures a converting hider's spot / stance / painting so a frozen statue can be // materialized from it at Resolution. Skipped entirely when no statue prefab is assigned. The painting // is captured from the SERVER's own replica (the host holds the full paint via the live stream); a // capture miss stores a null snapshot -> the statue simply stays on the blank seed look. private void CaptureConversionStatue(Vector3 pos, Quaternion rot, NetworkObject hider) { if (!statuePrefab) return; int poseIndex = 0; var pose = hider.GetComponentInChildren(true); if (pose) poseIndex = pose.CurrentIndex; byte[] paint = null; var painter = hider.GetComponentInChildren(true); if (painter) painter.TryCaptureSnapshot(out paint); _conversionRecords.Add(new StatueRecord { pos = pos, rot = rot, poseIndex = poseIndex, paint = paint }); } // Server-only. Spawns one frozen statue per captured conversion at its recorded pos/rot, handing each the // stance + painting snapshot before it spawns (server-owned, non-player). Called once at Resolution start; // the records are consumed here. No-op with no prefab / no records (Classic never records any). private void SpawnConversionStatues() { if (!statuePrefab || _conversionRecords.Count == 0) return; foreach (var rec in _conversionRecords) { var go = Instantiate(statuePrefab, rec.pos, rec.rot); var statue = go.GetComponent(); if (statue) statue.ServerInit(rec.poseIndex, rec.paint); var netObj = go.GetComponent(); if (netObj) { netObj.Spawn(); // server-owned, non-player: replicates to every client as an observer _statues.Add(netObj); } else { Debug.LogError("[MatchManager] Statue prefab has no NetworkObject - cannot spawn.", this); Destroy(go); } } _conversionRecords.Clear(); } // Server-only. Despawns every live conversion statue (match ending / back to lobby) and drops the list. private void DespawnConversionStatues() { for (int i = 0; i < _statues.Count; i++) { var s = _statues[i]; if (s && s.IsSpawned) s.Despawn(true); } _statues.Clear(); } // 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(); DespawnConversionStatues(); // drop any Resolution statues before the map unloads _conversionRecords.Clear(); // and any captures from a match ended before Resolution SetPhaseServer(MatchSubPhase.None, 0f); Phase = MatchPhase.Transitioning; Debug.Log("[MatchManager] Ending match -> returning to lobby.", this); StartCoroutine(EndMatchSequence()); } private IEnumerator EndMatchSequence() { yield return SwapScenes(matchSceneName, lobbySceneName); RespawnAll(_ => _playerPrefab, lobbySpawnCenter); Phase = MatchPhase.Lobby; Debug.Log("[MatchManager] Back in lobby.", this); } // Unloads one gameplay scene (if loaded), then additively loads the next (shared by the match // start/end sequences). private IEnumerator SwapScenes(string unloadSceneName, string loadSceneName) { var sm = _net.SceneManager; Scene old = SceneManager.GetSceneByName(unloadSceneName); if (old.IsValid() && old.isLoaded) yield return SceneOp(() => sm.UnloadScene(old), SceneEventType.UnloadEventCompleted, unloadSceneName); yield return SceneOp(() => sm.LoadScene(loadSceneName, LoadSceneMode.Additive), SceneEventType.LoadEventCompleted, loadSceneName); } // 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); // 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). 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. 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. // Scene.name allocates a fresh string per call and these are polled per frame -> cached per scene handle. public bool IsLocalSceneLobby => ActiveSceneName == lobbySceneName; public bool IsLocalSceneMap => ActiveSceneName == matchSceneName; private int _activeSceneHandle; private string _activeSceneName; private string ActiveSceneName { get { Scene s = SceneManager.GetActiveScene(); if (_activeSceneName == null || s.handle != _activeSceneHandle) { _activeSceneHandle = s.handle; _activeSceneName = s.name; } return _activeSceneName; } } // Sends a named message whose payload carries no information (a named message still needs a buffer, // so a single dummy byte is written). private void SendEmptyNamedMessage(string messageName, ulong targetClientId) { 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(messageName, targetClientId, writer); } } // Server -> client: tell the designated client it is the match authority (so it reads Esc locally). private void SendAuthorityGrant(ulong clientId) => SendEmptyNamedMessage(MsgAuthorityGranted, clientId); // 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() => SendEmptyNamedMessage(MsgRequestEndMatch, NetworkManager.ServerClientId); // 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 } }