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 } /// Who won the finished match. Computed server-side the moment Seek ends (hunters win when no /// un-hit hider is left, hiders win when the Seek timer ran out with a survivor) and broadcast with the /// result rows. None while no result is available (match running / back in the lobby). public enum MatchWinner : byte { None, Hunters, Hiders } /// A participant's end-of-match outcome. Hiders end as Survived (still un-hit when Seek ran /// out), Found (Classic hit) or Converted (Conversion hit); Left = disconnected mid-match. public enum PlayerOutcome : byte { Hunter, Survived, Found, Converted, Left } /// One row of the match scoreboard, replicated to every client: LIVE while the match runs /// (roster at Hide start, re-sent on every hit / mid-match leave; winner still None) and frozen /// together with the winner when Seek ends. Plain data - names resolve server-side. public class PlayerResult { public string Name; public PlayerOutcome Outcome; /// Seconds into Seek the hider lasted: the hit moment for Found/Converted, the full Seek /// length for Survived. 0 (not displayed) for Hunter/Left. public float SurvivalTime; /// Display name of the hunter that found/converted this hider; "" otherwise. public string FoundBy; } 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. The result screen (winner + per-player outcomes) is shown; 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 float _localPingInterval; // mirror of the server's radarPingInterval, carried in the phase message (seeded from the inspector value in Awake) 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). Mirror of the SERVER's value, carried /// in the phase message - the host overrides its inspector default from the Match Panel settings on match /// start, so the value can no longer be assumed identical on every install. The HUD drives the ping /// countdown locally from this mirror. public float RadarPingInterval => _localPingInterval; /// 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 || _localPingInterval <= 0f) return 0f; float intoInterval = (Time.time - _seekStartTime) % _localPingInterval; return _localPingInterval - 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 + byte mode + float radar ping // interval). 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"; // Client -> server: a still-un-found hider manually requests a radar ping on itself (E key), so it blips // on every hunter's radar exactly like an interval sweep. The server gates the Seek phase and that the // sender is a live hider before emitting; the sender id is the trusted routing (no payload). private const string MsgManualPing = "Match_ManualPing"; // Server -> all clients, once Seek ends: the frozen match result (winner + one row per participant). // Also sent directly to a client that joins during Resolution, so its result screen is populated too. private const string MsgMatchResult = "Match_Result"; // 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 readiness barrier. _barrierTargets = the objects every client must have spawned & paint-applied // this round (the freshly respawned avatars pre-match, or the conversion statues at Resolution); _expectedReady // = the clients present when the barrier opened (a mid-load joiner isn't required, so it can't stall it); // _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 _barrierTargets = 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 ids to wait for, the holding phase to poll during, the generation to // echo back, and the running poll. private readonly List _loadTargetIds = new List(); private MatchSubPhase _loadActivePhaseClient; private int _loadGenerationClient; private Coroutine _clientLoadPoll; // Server-only. Statues: a snapshot of each hit hider (spot / stance / painting) captured on the hit // (Conversion convert / Classic found), materialized as frozen statue objects only at the start of // Resolution and despawned when the match ends. private class StatueRecord { public Vector3 pos; public Quaternion rot; public int poseIndex; public byte[] paint; } private readonly List _statueRecords = new List(); private readonly List _statues = new List(); // Server-only. Per-participant outcome records for the running match, keyed by clientId. Created on match // start (Hunter, or Survived as the pending-hider default), updated on hits/disconnects and frozen when // Seek ends (FinalizeMatchResults: outstanding names resolved, survivor times filled, winner computed). private class ResultRecord { public PlayerOutcome outcome; public float time; public string name; public string foundBy; } private readonly Dictionary _matchRecords = new Dictionary(); // Replicated result mirror (every machine): the finished match's winner + rows, cleared when the // sub-phase drops back to None. Read by the Resolution result screen. private readonly List _localResults = new List(); /// The finished match's winner; None while no result is available. public MatchWinner LocalWinner { get; private set; } = MatchWinner.None; /// The finished match's result rows (scoreboard order); empty while no result is available. public IReadOnlyList LocalResults => _localResults; /// Bumped whenever the local result set changes, so the UI can change-detect cheaply. public int ResultVersion { get; private set; } private void Awake() { if (Instance && Instance != this) { Destroy(this); return; } Instance = this; _localPingInterval = radarPingInterval; // pre-match HUD default; overwritten by every phase message _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) { _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; } 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(MsgManualPing); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgIdentity); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgBeginLoad); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReady); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgDebugSkip); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgMatchResult); } } 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(MsgManualPing, OnManualPingMessage); _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); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgMatchResult, OnMatchResultMessage); } // 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, _localPingInterval); _pendingHiders.Clear(); _persistentIds.Clear(); // Drop any in-flight loading-barrier state (StopAllCoroutines above already killed the polls). _expectedReady.Clear(); _readyClients.Clear(); _barrierTargets.Clear(); _loadTargetIds.Clear(); _clientLoadPoll = null; // Teardown unloads the gameplay scenes (destroys the statue objects); just drop our references. _statues.Clear(); _statueRecords.Clear(); _matchRecords.Clear(); if (_net) UnhookSessionCallbacks(); ReturnToMainOnly(); } private void HookSceneManager() { if (_hooked || !_net || _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 || !_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); // Hand it the current scoreboard too (live mid-match, frozen in Resolution). if (_localResults.Count > 0) SendMatchResult(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 || !_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); // Freeze a mid-match leaver's result row: a Found/Converted outcome stands (the hit landed), any // still-open outcome becomes Left. The name resolves NOW or never - the avatar is despawning. if (Phase == MatchPhase.InMatch && _matchRecords.TryGetValue(clientId, out var leftRec) && (leftRec.outcome == PlayerOutcome.Hunter || leftRec.outcome == PlayerOutcome.Survived)) { leftRec.outcome = PlayerOutcome.Left; if (leftRec.name == null) leftRec.name = ResolveDisplayName(clientId); // Live scoreboard update; no-op once the result is frozen (a leave during Resolution must not // rewrite the final rows). The leaver itself is mid-teardown - excluded from the send. BroadcastLiveResults(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 || 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 ? _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 || !_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 || !_net.IsServer) return; if (Phase != MatchPhase.Lobby) return; 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; ApplyHostMatchSettings(); 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)); } // Server-only, called once per match start. Pulls the host-authored match rules (Esc-menu Match Panel, // persisted via GameSettings/Settings.json) over the inspector defaults, so the machine that starts the // match decides the rules. Defensively clamped - the JSON is hand-editable. private void ApplyHostMatchSettings() { matchMode = GameSettings.MatchModeIndex == (int)MatchMode.Conversion ? MatchMode.Conversion : MatchMode.Classic; hunterCount = Mathf.Max(0, GameSettings.MatchHunterCount); hideDuration = Mathf.Max(0f, GameSettings.MatchHideDuration); seekDuration = Mathf.Max(0f, GameSettings.MatchSeekDuration); resolveDuration = Mathf.Max(0f, GameSettings.MatchResolveDuration); radarPingInterval = Mathf.Max(0f, GameSettings.MatchRadarPingInterval); radarPingLifetime = Mathf.Max(0f, GameSettings.MatchRadarPingLifetime); } private IEnumerator MatchSequence(HashSet hunterIds) { _statueRecords.Clear(); // fresh match inherits no stale statue captures (EndMatch also clears) // One outcome record per participant. Survived is the pending-hider default (flips on a hit / // disconnect); names are resolved later - the freshly respawned avatars' name NetworkVariables // may not have replicated to the server yet at this point. _matchRecords.Clear(); foreach (ulong id in _net.ConnectedClientsIds) _matchRecords[id] = new ResultRecord { outcome = hunterIds.Contains(id) ? PlayerOutcome.Hunter : PlayerOutcome.Survived }; // 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 (RunLoadingBarrier 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(); // Initial live scoreboard (everyone still Hunter/hiding). Sent AFTER the loading barrier so every // avatar's name NetworkVariable has replicated to the server. SetPhaseServer(MatchSubPhase.Hide, hideDuration); BroadcastLiveResults(); yield return WaitPhase(hideDuration, null); SetPhaseServer(MatchSubPhase.Seek, seekDuration); yield return RunSeekPhase(seekDuration); // Seek is over - freeze the outcome (winner + per-player rows), apply it locally (host screen) and // broadcast it to every client. The pause menu auto-opens on Resolution and presents the scoreboard // (which was already live during the match). FinalizeMatchResults(); // Materialize a frozen statue of every hit hider (converted / found) at its capture spot. The last // hider is often still resolving (its statue's painting hasn't replicated to every client yet), so // hold the Resolution countdown - frozen at 0:00, exactly like the pre-match loading barrier - until // every client has each statue spawned & painted. Then release the timer. No statue prefab / no // hits -> no hold, the countdown starts immediately (unchanged behaviour). bool holdForStatues = statuePrefab && _statueRecords.Count > 0; SetPhaseServer(MatchSubPhase.Resolution, holdForStatues ? 0f : resolveDuration); SpawnStatues(); if (holdForStatues) { yield return RunStatueBarrier(); SetPhaseServer(MatchSubPhase.Resolution, resolveDuration); // statues loaded -> start the countdown } 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; int pingsSent = 0; // sweeps fired so far; boundaries anchored to _seekStartTime (the HUD's ping anchor) while (t > 0f) { if (_pendingHiders.Count == 0) yield break; if (ConsumeSkip()) yield break; // DEBUG: jump straight to Resolution if (radarPingInterval > 0f) { // Fire on absolute interval boundaries from Seek start (n*interval), sharing the exact anchor the // HUD countdown uses (LocalNextPingIn's modulo) - so the on-screen ping timer hits 0:00 the same // moment the sweep fires. An accumulating countdown would discard each cycle's overshoot and drift // seconds late over a match (worse with frame spikes). A missed boundary (huge hitch) is skipped, // not caught up - one late sweep, never a burst. int due = Mathf.FloorToInt((Time.time - _seekStartTime) / radarPingInterval); if (due > pingsSent) { BroadcastRadarPings(); pingsSent = due; } } t -= Time.deltaTime; yield return null; } } 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("Next Sub-Phase (Debug)")] public void DebugSkipToNextSubPhase() { if (!_net) 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 || !_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 || !_net.IsServer) return; if (!IsMatchAuthority(senderClientId)) return; DebugSkipServer(); } // --- Readiness barriers ---------------------------------------------------------------------------- // Server-only. Pre-match: hold everyone in Loading (frozen, no countdown) until every client has spawned & // paint-applied the freshly respawned avatars, or matchLoadTimeout elapses. Loading was already asserted // before the scene swap; re-assert it here after the respawn, then run the generic barrier over the avatars. private IEnumerator RunLoadingBarrier() { _barrierTargets.Clear(); foreach (ulong id in _net.ConnectedClientsIds) if (_net.ConnectedClients.TryGetValue(id, out var c) && c.PlayerObject) _barrierTargets.Add(c.PlayerObject.NetworkObjectId); SetPhaseServer(MatchSubPhase.Loading, 0f); yield return RunReadinessBarrier(MatchSubPhase.Loading); } // Server-only (Conversion). Hold the (already-entered) Resolution phase until every client has each conversion // statue spawned & painted, so a just-caught hider's still-loading statue doesn't burn resolve time. The // statues carry the same PlayerPaintSync the avatar barrier keys off, so "painted" == the statue's body reveal. private IEnumerator RunStatueBarrier() { _barrierTargets.Clear(); for (int i = 0; i < _statues.Count; i++) if (_statues[i]) _barrierTargets.Add(_statues[i].NetworkObjectId); yield return RunReadinessBarrier(MatchSubPhase.Resolution); } // Server-only. Generic readiness barrier over the already-populated _barrierTargets: snapshot the expected // clients, bump the round generation, broadcast the targets + holding phase, then spin until every still- // connected expected client has reported ready - or matchLoadTimeout elapses. `activePhase` is the frozen // holding phase (Loading pre-match, Resolution for statues) that clients poll during. private IEnumerator RunReadinessBarrier(MatchSubPhase activePhase) { _skipPhase = false; // drop any debug-skip left over from the previous phase/match; only this wait may set it _expectedReady.Clear(); _readyClients.Clear(); foreach (ulong id in _net.ConnectedClientsIds) _expectedReady.Add(id); _loadGeneration++; BroadcastBeginLoad(activePhase); float t = matchLoadTimeout; while (!AllClientsReady()) { if (ConsumeSkip()) break; // DEBUG: proceed 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(_barrierTargets)) _readyClients.Add(_net.LocalClientId); if (matchLoadTimeout > 0f) { t -= Time.deltaTime; if (t <= 0f) { Debug.LogWarning("[MatchManager] Readiness barrier timed out - proceeding anyway.", this); break; } } yield return null; } } // 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 ? _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 ids + holding phase + generation for this barrier round. private void BroadcastBeginLoad(MatchSubPhase activePhase) { var cmm = _net ? _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(byte) + sizeof(int) + _barrierTargets.Count * sizeof(ulong); using (var writer = new FastBufferWriter(size, Allocator.Temp)) { writer.WriteValueSafe(_loadGeneration); writer.WriteValueSafe((byte)activePhase); writer.WriteValueSafe(_barrierTargets.Count); for (int i = 0; i < _barrierTargets.Count; i++) writer.WriteValueSafe(_barrierTargets[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 byte activePhase); reader.ReadValueSafe(out int count); _loadTargetIds.Clear(); for (int i = 0; i < count; i++) { reader.ReadValueSafe(out ulong id); _loadTargetIds.Add(id); } _loadGenerationClient = generation; _loadActivePhaseClient = (MatchSubPhase)activePhase; if (_clientLoadPoll != null) StopCoroutine(_clientLoadPoll); _clientLoadPoll = StartCoroutine(ClientLoadingPoll()); } // Client-only. Polls until this machine has spawned & paint-applied every target, then reports ready. Bails // without a reply if the phase leaves the holding phase first (match ended, or the server already moved on). private IEnumerator ClientLoadingPoll() { while (_subPhase == _loadActivePhaseClient) { 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 ? _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 || !_net.IsServer) return; if (generation != _loadGeneration) return; // stale ack from a previous barrier round _readyClients.Add(senderClientId); } private void SetPhaseServer(MatchSubPhase phase, float duration) { ApplyPhaseLocal(phase, duration, matchMode, radarPingInterval); BroadcastPhase(phase, duration, matchMode, 0, true); } // Shared by the server's SetPhaseServer and a client's OnPhaseMessage handler. private void ApplyPhaseLocal(MatchSubPhase phase, float duration, MatchMode mode, float pingInterval) { bool changed = _subPhase != phase; _subPhase = phase; _phaseEndTime = Time.time + duration; _localMode = mode; _localPingInterval = pingInterval; // 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(); } // Match fully over (back to lobby / teardown): nobody carries a stale scoreboard around. if (changed && phase == MatchSubPhase.None) ClearLocalResults(); } // 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 ? _net.CustomMessagingManager : null; if (cmm == null) return; using (var writer = new FastBufferWriter(sizeof(byte) + sizeof(float) + sizeof(byte) + sizeof(float), Allocator.Temp)) { writer.WriteValueSafe((byte)phase); writer.WriteValueSafe(duration); writer.WriteValueSafe((byte)mode); writer.WriteValueSafe(radarPingInterval); 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); reader.ReadValueSafe(out float pingInterval); ApplyPhaseLocal((MatchSubPhase)phase, duration, (MatchMode)mode, pingInterval); } // 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 || !_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); EmitRadarPings(); } // Server-only. Sends whatever hider NetworkObjectIds are staged in _radarPingIds to every HUNTER as radar // blips (a host-hunter applies locally, no loopback - mirroring SetPhaseServer). Shared by the interval // sweep and the manual single-hider ping so both drop the identical blip (same lifetime, same path). // No-op if nothing is staged or there is no hunter to receive it. private void EmitRadarPings() { 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); } } /// Client-side: the local hider manually requests a radar ping on itself (the E key), making it /// blip on every hunter's radar exactly like an interval sweep. Routes to the server, which gates the Seek /// phase and that this client is still a live (un-found) hider. On a host this IS the server, so it applies /// directly. No-op outside the Seek phase. public void RequestManualPing() { if (!_net || _subPhase != MatchSubPhase.Seek) return; if (_net.IsServer) { ServerManualPing(_net.LocalClientId); return; } SendEmptyNamedMessage(MsgManualPing, NetworkManager.ServerClientId); } // Server handler: a client asked for a manual self-ping. The sender id is the trusted routing; // ServerManualPing validates it is a live hider during Seek. private void OnManualPingMessage(ulong senderClientId, FastBufferReader reader) { if (!_net || !_net.IsServer) return; ServerManualPing(senderClientId); } // Server-only. Emits a single-hider radar ping for hiderClientId to every hunter - if it is a still-un-found // hider during Seek. Same blip (and lifetime) as an interval sweep, just one target and on demand. private void ServerManualPing(ulong hiderClientId) { if (_subPhase != MatchSubPhase.Seek) return; if (!_pendingHiders.Contains(hiderClientId)) return; // not a live (un-found) hider if (!_net.ConnectedClients.TryGetValue(hiderClientId, out var c) || !c.PlayerObject) return; _radarPingIds.Clear(); _radarPingIds.Add(c.PlayerObject.NetworkObjectId); EmitRadarPings(); } // 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 ? _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 ? _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 || _subPhase != MatchSubPhase.Seek) return; if (_net.IsServer) { if (_pendingHiders.Contains(targetClientId) && IsHunter(_net.LocalClientId)) MarkHiderFound(targetClientId, _net.LocalClientId); 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 || !_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, senderClientId); } // 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, ulong shooterClientId) { if (_revealingHiders.Contains(hiderClientId)) return; // already flashing / being resolved // Freeze the result row at the HIT moment: both avatars are guaranteed live here, so the names // resolve now (a Conversion despawns the hider; a later resolve could miss it). if (_matchRecords.TryGetValue(hiderClientId, out var rec)) { rec.outcome = matchMode == MatchMode.Conversion ? PlayerOutcome.Converted : PlayerOutcome.Found; rec.time = Mathf.Max(0f, Time.time - _seekStartTime); rec.name = ResolveDisplayName(hiderClientId); rec.foundBy = ResolveDisplayName(shooterClientId); BroadcastLiveResults(); // live scoreboard: the hit row updates on every client immediately } 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 || !_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 { // Classic: snapshot the spot/stance/painting too, so Resolution shows a statue of every // found hider (the found avatar itself stays hidden - its owner is free-cam spectating). CaptureStatue(c.PlayerObject.transform.position, c.PlayerObject.transform.rotation, c.PlayerObject); 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 SpawnStatues). CaptureStatue(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 hit hider's spot / stance / painting (Conversion convert AND Classic found) 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 CaptureStatue(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); _statueRecords.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 (no hider was hit). private void SpawnStatues() { if (!statuePrefab || _statueRecords.Count == 0) return; foreach (var rec in _statueRecords) { 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); } } _statueRecords.Clear(); } private void DespawnStatues() { for (int i = 0; i < _statues.Count; i++) { var s = _statues[i]; if (s && s.IsSpawned) s.Despawn(true); } _statues.Clear(); } 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; } // --- Match result ----------------------------------------------------------------------------------- // Server-only. Resolves a client's current display name; same "Player " fallback the nameplates // use when the avatar is already gone or the name is not resolvable. private string ResolveDisplayName(ulong clientId) { if (_net.ConnectedClients.TryGetValue(clientId, out var c) && c.PlayerObject) { var setup = c.PlayerObject.GetComponent(); if (setup) return setup.DisplayName; } return $"Player {clientId}"; } // Server-only, once when Seek ends. Resolves outstanding names, fills survivor times and computes the // winner: any un-hit hider left -> the hiders win. A hider still in its hit-reveal flash was hit IN time // (its record is already Found/Converted), so it counts for the hunters even though it is still in // _pendingHiders. Applies the result locally (host screen; no loopback) and broadcasts it. private void FinalizeMatchResults() { float elapsed = Mathf.Max(0f, Time.time - _seekStartTime); bool anySurvivor = false; foreach (var kv in _matchRecords) { ResultRecord rec = kv.Value; if (rec.name == null) rec.name = ResolveDisplayName(kv.Key); if (rec.outcome == PlayerOutcome.Survived) { rec.time = elapsed; anySurvivor = true; } } RebuildLocalResults(); LocalWinner = anySurvivor ? MatchWinner.Hiders : MatchWinner.Hunters; ResultVersion++; SendMatchResult(0, true); } // Server-only. Rebuilds the replicated row list (scoreboard order) from the outcome records. A record // whose name is not frozen yet resolves it transiently (NOT stored), so FinalizeMatchResults still // re-resolves the final names when Seek ends. private void RebuildLocalResults() { _localResults.Clear(); foreach (var kv in _matchRecords) { ResultRecord rec = kv.Value; _localResults.Add(new PlayerResult { Name = rec.name ?? ResolveDisplayName(kv.Key), Outcome = rec.outcome, SurvivalTime = rec.time, FoundBy = rec.foundBy ?? "" }); } // Scoreboard order: survivors, then found/converted (longest-lasting first), hunters, leavers. _localResults.Sort(CompareResults); } // Server-only. Replicates the CURRENT scoreboard while the match is still running (winner not yet // decided): the roster at Hide start, then every hit / mid-match leave. Applies locally too (host // screen; ResultVersion bump) - the client handler is the same one the final result uses, rows simply // re-bind. No-op once the result is frozen so nothing rewrites the final scoreboard. private void BroadcastLiveResults(ulong excludeClient = ulong.MaxValue) { if (Phase != MatchPhase.InMatch || LocalWinner != MatchWinner.None) return; RebuildLocalResults(); ResultVersion++; SendMatchResult(0, true, excludeClient); } private static int CompareResults(PlayerResult a, PlayerResult b) { int rank = OutcomeRank(a.Outcome).CompareTo(OutcomeRank(b.Outcome)); if (rank != 0) return rank; int time = b.SurvivalTime.CompareTo(a.SurvivalTime); if (time != 0) return time; return string.Compare(a.Name, b.Name, System.StringComparison.OrdinalIgnoreCase); } private static int OutcomeRank(PlayerOutcome outcome) { switch (outcome) { case PlayerOutcome.Survived: return 0; case PlayerOutcome.Found: case PlayerOutcome.Converted: return 1; case PlayerOutcome.Hunter: return 2; default: return 3; // Left } } // Sends the current result rows (live mid-match or frozen final) to one client (late joiner) or to all // clients except the server itself (which applied them directly - no loopback, mirroring BroadcastPhase). // toAll can exclude one extra client (a mid-teardown leaver). private void SendMatchResult(ulong targetClient, bool toAll, ulong excludeClient = ulong.MaxValue) { var cmm = _net ? _net.CustomMessagingManager : null; if (cmm == null || _localResults.Count == 0) return; // Rows carry variable-size strings: start modest, allow growth (names are capped at 24 chars). using (var writer = new FastBufferWriter(1024, Allocator.Temp, 64 * 1024)) { writer.WriteValueSafe((byte)LocalWinner); writer.WriteValueSafe(_localResults.Count); for (int i = 0; i < _localResults.Count; i++) { PlayerResult r = _localResults[i]; writer.WriteValueSafe(r.Name); writer.WriteValueSafe((byte)r.Outcome); writer.WriteValueSafe(r.SurvivalTime); writer.WriteValueSafe(r.FoundBy ?? ""); } if (toAll) { _phaseRecipients.Clear(); foreach (ulong id in _net.ConnectedClientsIds) if (id != _net.LocalClientId && id != excludeClient) _phaseRecipients.Add(id); if (_phaseRecipients.Count > 0) cmm.SendNamedMessage(MsgMatchResult, _phaseRecipients, writer); } else { cmm.SendNamedMessage(MsgMatchResult, targetClient, writer); } } } // Client handler: the server sent the finished match's result rows (kept in wire = scoreboard order). private void OnMatchResultMessage(ulong senderClientId, FastBufferReader reader) { reader.ReadValueSafe(out byte winner); reader.ReadValueSafe(out int count); _localResults.Clear(); for (int i = 0; i < count; i++) { reader.ReadValueSafe(out string name); reader.ReadValueSafe(out byte outcome); reader.ReadValueSafe(out float time); reader.ReadValueSafe(out string foundBy); _localResults.Add(new PlayerResult { Name = name, Outcome = (PlayerOutcome)outcome, SurvivalTime = time, FoundBy = foundBy }); } LocalWinner = (MatchWinner)winner; ResultVersion++; } // Every machine: drop the scoreboard when the match fully ends (sub-phase back to None). private void ClearLocalResults() { if (LocalWinner == MatchWinner.None && _localResults.Count == 0) return; LocalWinner = MatchWinner.None; _localResults.Clear(); ResultVersion++; } /// 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 || !_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(); DespawnStatues(); // drop any Resolution statues before the map unloads _statueRecords.Clear(); // and any captures from a match ended before Resolution _matchRecords.Clear(); // and the outcome records (the None broadcast clears every client's mirror) 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); } 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; } // 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) 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) 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 ? _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 || !_net.IsServer) return; if (!IsMatchAuthority(senderClientId)) return; EndMatch(); // guards Phase == InMatch itself } }