Files
chameleon/Assets/Scripts/Network/MatchManager.cs
T
adminandClaude Opus 4.8 1fbd66095c Radar ping: anchor sweeps to seek start so they stay in sync with the HUD
RunSeekPhase fired the interval ping from an accumulating countdown that discarded each cycle's frame overshoot and drifted seconds late over a match, while the HUD ping countdown is anchored absolutely to _seekStartTime. Fire on absolute interval boundaries (n*interval from _seekStartTime) instead, sharing the HUD's anchor so the on-screen timer hits 0:00 the same moment the sweep fires. A missed boundary is skipped, never bursted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:36:59 +02:00

1514 lines
76 KiB
C#

using System.Collections;
using System.Collections.Generic;
using Unity.Collections;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.SceneManagement;
/// <summary>
/// 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 (<see cref="NetworkSceneManager.SetClientSynchronizationMode"/>). 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).
/// <see cref="NetworkConfig.PlayerPrefab"/> 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 (<see cref="NetworkPlayerSetup.ScopedModes"/>), not from a replicated phase, so there
/// is no spawn-vs-phase timing race.
/// </summary>
[DisallowMultipleComponent]
public class MatchManager : MonoBehaviour
{
public enum MatchPhase { Lobby, Transitioning, InMatch }
/// <summary>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.</summary>
public enum MatchSubPhase { None, Loading, Hide, Seek, Resolution }
/// <summary>How the running match behaves. Chosen server-side (<see cref="matchMode"/>) 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:
/// <list type="bullet">
/// <item><b>Classic</b> - hiders hidden from hunters during Hide only; a hit marks the hider FOUND
/// (body hidden for all, owner drops to free-cam spectating).</item>
/// <item><b>Conversion</b> - 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.</item>
/// </list></summary>
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;
/// <summary>Range of the hunter's shoot raycast (read by <see cref="FirstPersonMode"/>).</summary>
public float ShootRange => shootRange;
/// <summary>Hunter shoot cooldown in seconds (read by <see cref="FirstPersonMode"/>).</summary>
public float ShootCooldown => shootCooldown;
/// <summary>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 <see cref="NetworkPlayerSetup"/> against its own
/// concealment meter; baked identically on every client, so it needs no replication.</summary>
public float EmbeddedRevealPercent => embeddedRevealPercent;
/// <summary>Server-local phase guard. Not replicated - see class summary.</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
/// <summary>True while a match sub-phase (Hide/Seek/Resolution) is running on this machine.</summary>
public bool MatchActive => _subPhase != MatchSubPhase.None;
/// <summary>This machine's current sub-phase (mirror of the server's broadcast).</summary>
public MatchSubPhase LocalSubPhase => _subPhase;
/// <summary>The running match's mode (mirror of the server's choice). Drives per-client hider visibility.</summary>
public MatchMode LocalMatchMode => _localMode;
/// <summary>Seconds left in the current sub-phase for the HUD (clamped to 0; cosmetic).</summary>
public float LocalPhaseRemaining => Mathf.Max(0f, _phaseEndTime - Time.time);
/// <summary>Seconds between radar ping sweeps (0 = pings disabled). Baked identically on every client
/// (persistent MatchManager, no replication - like <see cref="EmbeddedRevealPercent"/>), so the HUD can
/// drive the ping countdown locally.</summary>
public float RadarPingInterval => radarPingInterval;
/// <summary>Seconds until the next radar ping sweep for the HUD (counts down from <see cref="RadarPingInterval"/>
/// and repeats). 0 outside Seek or when pings are disabled. Computed locally from the local Seek-start +
/// fixed interval - cosmetic, like <see cref="LocalPhaseRemaining"/> (a mid-match joiner's is offset by the
/// time it missed; the server's sweep timer is START-aligned the same way).</summary>
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";
// 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-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<ulong> _pendingHiders = new HashSet<ulong>();
// 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<ulong> _revealingHiders = new HashSet<ulong>();
// 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<ulong, string> _persistentIds = new Dictionary<ulong, string>();
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<ulong> _phaseRecipients = new List<ulong>(); // reused scratch for the phase broadcast
private readonly List<ulong> _radarPingIds = new List<ulong>(); // reused scratch: hider NetworkObjectIds for a sweep
private readonly List<ulong> _radarRecipients = new List<ulong>(); // 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<ulong> _barrierTargets = new List<ulong>();
private readonly List<ulong> _loadRecipients = new List<ulong>();
private readonly HashSet<ulong> _expectedReady = new HashSet<ulong>();
private readonly HashSet<ulong> _readyClients = new HashSet<ulong>();
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<ulong> _loadTargetIds = new List<ulong>();
private MatchSubPhase _loadActivePhaseClient;
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<StatueRecord> _conversionRecords = new List<StatueRecord>();
private readonly List<NetworkObject> _statues = new List<NetworkObject>();
private void Awake()
{
if (Instance && Instance != this) { Destroy(this); return; }
Instance = this;
_net = GetComponent<NetworkManager>();
if (!_net) _net = FindFirstObjectByType<NetworkManager>();
// 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<NetworkManager>();
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(MsgManualPing);
_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(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);
}
// 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();
_barrierTargets.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<ulong>(_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);
}
/// <summary>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.</summary>
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<NetworkObject>().SpawnAsPlayerObject(clientId);
}
/// <summary>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).</summary>
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<ulong>(_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<ulong>();
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<ulong> 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 (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();
SetPhaseServer(MatchSubPhase.Hide, hideDuration);
yield return WaitPhase(hideDuration, null);
SetPhaseServer(MatchSubPhase.Seek, seekDuration);
yield return RunSeekPhase(seekDuration);
// Conversion mode: materialize a frozen statue of every converted hider at its capture spot. The last
// hider is often still converting (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
// conversions / Classic -> no hold, the countdown starts immediately (unchanged behaviour).
bool holdForStatues = statuePrefab && _conversionRecords.Count > 0;
SetPhaseServer(MatchSubPhase.Resolution, holdForStatues ? 0f : resolveDuration);
SpawnConversionStatues();
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;
}
}
// Waits up to 'seconds' of server real time, returning early the moment 'until' (if supplied) is true.
private IEnumerator WaitPhase(float seconds, System.Func<bool> 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;
}
/// <summary>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.</summary>
[ContextMenu("Next Sub-Phase (Debug)")]
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();
}
// --- 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<ulong> 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<PlayerPaintSync>(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 != 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(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 != 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);
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);
}
}
/// <summary>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.</summary>
public void RequestManualPing()
{
if (_net == null || _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 == null || !_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 != 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);
}
}
/// <summary>Client-side: the local hunter's shoot raycast hit the hider owned by <paramref name="targetClientId"/>.
/// 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.</summary>
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<NetworkPlayerSetup>();
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<NetworkPlayerSetup>();
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<NetworkPlayerSetup>();
if (setup) setup.MarkConversionSpawn(pos);
go.GetComponent<NetworkObject>().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<PlayerPoseController>(true);
if (pose) poseIndex = pose.CurrentIndex;
byte[] paint = null;
var painter = hider.GetComponentInChildren<MeshPainter>(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<MatchStatue>();
if (statue) statue.ServerInit(rec.poseIndex, rec.paint);
var netObj = go.GetComponent<NetworkObject>();
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<NetworkPlayerSetup>();
return setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter;
}
/// <summary>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.</summary>
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<SceneEventProgressStatus> 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<ulong, GameObject> prefabFor, Vector3 at)
{
var ids = new List<ulong>(_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<NetworkObject>().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<GameModeManager>(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);
}
/// <summary>Owner-side: park the local painting so the avatar a scene switch spawns next can restore it.</summary>
public void StoreCarriedPaint(byte[] data) => _carriedPaint = data;
/// <summary>Owner-side: take (and clear) the parked painting for the freshly spawned avatar, or null if none.</summary>
public byte[] TakeCarriedPaint()
{
byte[] d = _carriedPaint;
_carriedPaint = null;
return d;
}
/// <summary>True if the given client currently holds match-control authority (may start and end the match).</summary>
public bool IsMatchAuthority(ulong clientId) => _hasAuthorityClient && clientId == _authorityClientId;
/// <summary>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.</summary>
public bool IsLocalMatchAuthority
{
get
{
if (_net == null) return false;
if (_net.IsServer) return IsMatchAuthority(_net.LocalClientId);
return _isLocalAuthority;
}
}
/// <summary>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.</summary>
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
}
}