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.
/// Hide -> Seek -> Resolution, then back to the lobby. Drives per-client visibility, hunter shooting
/// and the match HUD countdown.
public enum MatchSubPhase { None, Hide, Seek, Resolution }
/// How the running match behaves. Chosen server-side () and replicated
/// to every client alongside the sub-phase (so visibility is right from the first Hide frame and late
/// joiners sync it). Differs only in hider visibility and what a hit does:
///
/// - Classic - hiders hidden from hunters during Hide only; a hit marks the hider FOUND
/// (body hidden for all, owner drops to free-cam spectating).
/// - Conversion - hiders are hidden from EACH OTHER too (Hide and Seek); during Seek only
/// hunters see hiders; a hit CONVERTS the hider into a Hunter (respawned as the Hunter variant in place),
/// so it keeps playing on the hunters' side. Resolution reveals everyone in both modes.
///
public enum MatchMode { Classic, Conversion }
public static MatchManager Instance { get; private set; }
[Header("Scenes (must be in Build Settings)")]
[Tooltip("Persistent boot scene that always stays loaded (NetworkManager, cameras, HUD). On a " +
"shutdown/disconnect every other scene is unloaded so this is the only one left & active.")]
[SerializeField] private string mainSceneName = "Main";
[SerializeField] private string lobbySceneName = "Lobby";
[SerializeField] private string matchSceneName = "Test Map";
[Header("Role prefab variants (must be registered network prefabs)")]
[SerializeField] private GameObject hunterPrefab;
[SerializeField] private GameObject hiderPrefab;
[Tooltip("Empty free-cam-only avatar spawned for a client that joins MID-MATCH. No body/collider - the " +
"free-cam rig is a separate scene object. Must be a registered network prefab.")]
[SerializeField] private GameObject spectatorPrefab;
[Header("Roles")]
[Tooltip("How many randomly chosen players become Hunters at match start; everyone else is a Hider. " +
"0 = all Hiders; a value at or above the player count makes everyone a Hunter. Default 1.")]
[Min(0)]
[SerializeField] private int hunterCount = 1;
[Header("Spawn")]
[Tooltip("World position lobby avatars are spawned at (raised a touch above the floor so gravity " +
"settles them). Per-client ring fan-out is applied on top (NetworkPlayerSetup.PlaceOnSpawnRing).")]
[SerializeField] private Vector3 lobbySpawnCenter = new Vector3(0f, 1f, 0f);
[Tooltip("World position the respawned match avatars are placed at on the match map; per-client ring " +
"fan-out is applied on top (NetworkPlayerSetup.PlaceOnSpawnRing).")]
[SerializeField] private Vector3 matchSpawnCenter = new Vector3(0f, 1f, 0f);
[Header("Match mode")]
[Tooltip("Classic = hiders are visible to each other, a hit marks them FOUND (free-cam spectating). " +
"Conversion = hiders are hidden from each other too (only hunters see them during Seek), and a " +
"hit CONVERTS the hider into a Hunter that keeps playing. Replicated to clients on match start.")]
[SerializeField] private MatchMode matchMode = MatchMode.Classic;
[Header("Match phases (seconds)")]
[Tooltip("HIDE phase length. Hunters move freely but cannot see the hiders (hider meshes are hidden on " +
"hunter clients); hiders scatter and hide.")]
[Min(0f)]
[SerializeField] private float hideDuration = 30f;
[Tooltip("SEEK phase length. Hunters can now see the hiders and shoot them. Ends early the moment every " +
"hider has been found.")]
[Min(0f)]
[SerializeField] private float seekDuration = 120f;
[Tooltip("RESOLUTION phase length. Nothing happens here yet; after it elapses everyone returns to the lobby.")]
[Min(0f)]
[SerializeField] private float resolveDuration = 8f;
[Header("Hunter shooting")]
[Tooltip("Max distance (metres) of the hunter's camera-centre shoot raycast.")]
[Min(0f)]
[SerializeField] private float shootRange = 100f;
[Tooltip("Minimum seconds between hunter shots (left-click cooldown).")]
[Min(0f)]
[SerializeField] private float shootCooldown = 0.4f;
[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;
/// 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 MatchMode _localMode = MatchMode.Classic; // mirror of the server's matchMode, carried in the phase message
private const string PreMatchTag = "PreMatch"; // scene props destroyed when seek starts
/// True while a match sub-phase (Hide/Seek/Resolution) is running on this machine.
public bool MatchActive => _subPhase != MatchSubPhase.None;
/// This machine's current sub-phase (mirror of the server's broadcast).
public MatchSubPhase LocalSubPhase => _subPhase;
/// The running match's mode (mirror of the server's choice). Drives per-client hider visibility.
public MatchMode LocalMatchMode => _localMode;
/// Seconds left in the current sub-phase for the HUD (clamped to 0; cosmetic).
public float LocalPhaseRemaining => Mathf.Max(0f, _phaseEndTime - Time.time);
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-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;
private readonly List _phaseRecipients = new List(); // reused scratch for the phase broadcast
private void Awake()
{
if (Instance && Instance != this) { Destroy(this); return; }
Instance = this;
_net = GetComponent();
if (!_net) _net = FindFirstObjectByType();
// Disable NGO auto-spawn (decided purely by PlayerPrefab != null); keep the reference and spawn
// manually once a floored scene exists. Runtime-only mutation - no asset diff.
if (_net != null)
{
_playerPrefab = _net.NetworkConfig.PlayerPrefab;
_net.NetworkConfig.PlayerPrefab = null;
}
}
private void OnEnable()
{
if (!_net) _net = FindFirstObjectByType();
if (!_net) return;
_net.OnServerStarted += OnServerStarted;
_net.OnClientStarted += OnClientStarted;
_net.OnServerStopped += OnNetStopped;
_net.OnClientStopped += OnNetStopped;
}
private void OnDisable()
{
if (_net)
{
_net.OnServerStarted -= OnServerStarted;
_net.OnClientStarted -= OnClientStarted;
_net.OnServerStopped -= OnNetStopped;
_net.OnClientStopped -= OnNetStopped;
UnhookSessionCallbacks();
}
if (Instance == this) Instance = null;
}
// Unhooks the per-session callbacks and named-message handlers (shared by OnDisable and OnNetStopped).
private void UnhookSessionCallbacks()
{
_net.OnClientConnectedCallback -= OnClientConnected;
_net.OnClientConnectedCallback -= OnLocalClientConnected;
_net.OnClientDisconnectCallback -= OnClientDisconnected;
if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent;
if (_net.CustomMessagingManager != null)
{
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgIdentity);
}
}
private void OnServerStarted()
{
_cleanedUp = false;
HookSceneManager();
// Keep the connecting client's already-loaded scenes (its Main) and layer ours on top.
// Must run before any client synchronizes; auto-propagates to clients. See class summary.
if (_net.SceneManager != null)
_net.SceneManager.SetClientSynchronizationMode(LoadSceneMode.Additive);
Phase = MatchPhase.Lobby;
_sceneReady = false;
// Host -> authority right away; dedicated server -> the first client to connect claims it.
_hasAuthorityClient = false;
if (_net.IsHost) { _authorityClientId = _net.LocalClientId; _hasAuthorityClient = true; }
if (_net.CustomMessagingManager != null)
{
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRequestEndMatch, OnRequestEndMatchMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReportHit, OnReportHitMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgIdentity, OnIdentityMessage);
}
_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);
}
// 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;
_hooked = false;
_sceneReady = false;
_hasAuthorityClient = false;
_isLocalAuthority = false;
Phase = MatchPhase.Lobby;
ApplyPhaseLocal(MatchSubPhase.None, 0f, _localMode);
_pendingHiders.Clear();
_persistentIds.Clear();
if (_net) UnhookSessionCallbacks();
ReturnToMainOnly();
}
private void HookSceneManager()
{
if (_hooked || _net == null || _net.SceneManager == null) return;
_net.SceneManager.OnSceneEvent += OnSceneEvent;
_hooked = true;
}
// Keep the freshly loaded gameplay scene the ACTIVE scene on every machine (lighting / skybox /
// instantiation context) - NGO additive loads don't promote the active scene on their own.
private void OnSceneEvent(SceneEvent e)
{
if (e.SceneEventType != SceneEventType.LoadComplete) return;
if (e.SceneName != lobbySceneName && e.SceneName != matchSceneName) return;
if (e.Scene.IsValid() && e.Scene.isLoaded) SceneManager.SetActiveScene(e.Scene);
}
private IEnumerator LoadLobbyRoutine()
{
yield return SceneOp(() => _net.SceneManager.LoadScene(lobbySceneName, LoadSceneMode.Additive),
SceneEventType.LoadEventCompleted, lobbySceneName);
// Scene is up - safe to spawn the already-connected peers (host + anyone who joined mid-load).
_sceneReady = true;
foreach (ulong id in new List(_net.ConnectedClientsIds)) SpawnFor(id, _playerPrefab, lobbySpawnCenter);
}
// Server-side: a peer finished connecting. If a gameplay scene is already up, spawn it now; otherwise
// the load/respawn routine covers it (the PlayerObject guard in SpawnFor prevents a double spawn).
private void OnClientConnected(ulong clientId)
{
if (_net == null || !_net.IsServer) return;
// Dedicated-server case: the first client to connect becomes the match authority (a host already
// claimed it at server start). Told via named message so it reads Esc locally.
if (!_hasAuthorityClient)
{
_authorityClientId = clientId;
_hasAuthorityClient = true;
SendAuthorityGrant(clientId);
Debug.Log($"[MatchManager] Client {clientId} is now the match authority.", this);
}
// During a Lobby<->Test Map swap the active scene briefly falls back to Main (no floor); defer
// and let the respawn loop spawn this joiner once the target scene is loaded.
if (!_sceneReady || Phase == MatchPhase.Transitioning) return;
// Mid-match joiner: empty SPECTATOR avatar (free-cam only); the end-of-match respawn loop turns
// it into a neutral lobby Player with everyone else.
if (Phase == MatchPhase.InMatch)
{
SpawnFor(clientId, spectatorPrefab, matchSpawnCenter);
// Sync the joiner's HUD/visibility to the running sub-phase + mode (remaining time as the duration).
BroadcastPhase(_subPhase, LocalPhaseRemaining, matchMode, clientId, false);
return;
}
SpawnFor(clientId, _playerPrefab, lobbySpawnCenter);
}
// Server-side: if the match authority leaves, hand authority to another connected client so a
// dedicated-server match stays controllable.
private void OnClientDisconnected(ulong clientId)
{
if (_net == null || !_net.IsServer) return;
// A hider that leaves mid-match can never be found - drop it so "all hiders found" can still trigger.
_pendingHiders.Remove(clientId);
_revealingHiders.Remove(clientId); // and out of the in-flight flash set (its resolve coroutine no-ops)
// Free the ephemeral clientId -> identity mapping (the clientId will be reused by a later joiner).
_persistentIds.Remove(clientId);
if (!_hasAuthorityClient || clientId != _authorityClientId) return;
if (_net.IsHost && clientId == _net.LocalClientId) return; // host shutting down; teardown handles it
_hasAuthorityClient = false;
foreach (ulong id in _net.ConnectedClientsIds)
{
if (id == clientId) continue; // the leaving client may still be listed momentarily
_authorityClientId = id;
_hasAuthorityClient = true;
SendAuthorityGrant(id);
Debug.Log($"[MatchManager] Match authority reassigned to client {id}.", this);
break;
}
}
// Fired locally when OUR own client connection completes (host and pure client alike). The host is already
// the server, so it records its own identity directly; a pure client reports it to the server by message.
private void OnLocalClientConnected(ulong clientId)
{
if (_net == null || clientId != _net.LocalClientId) return;
if (_net.IsServer) RegisterIdentity(_net.LocalClientId, PersistentPlayerId.Value);
else SendIdentityToServer(PersistentPlayerId.Value);
}
// Client -> server: report this install's persistent id so the server can map it to our ephemeral clientId.
private void SendIdentityToServer(string persistentId)
{
var cmm = _net != null ? _net.CustomMessagingManager : null;
if (cmm == null || string.IsNullOrEmpty(persistentId)) return;
// Grows up to the cap if a future id source is longer than the initial buffer (persistent ids are short).
using (var writer = new FastBufferWriter(256, Allocator.Temp, 1024))
{
writer.WriteValueSafe(persistentId);
cmm.SendNamedMessage(MsgIdentity, NetworkManager.ServerClientId, writer);
}
}
// Server handler: a client reported its persistent per-install id right after connecting.
private void OnIdentityMessage(ulong senderClientId, FastBufferReader reader)
{
reader.ReadValueSafe(out string persistentId);
if (_net == null || !_net.IsServer) return;
RegisterIdentity(senderClientId, persistentId);
}
// Server-only. Records the ephemeral clientId -> persistent id mapping (idempotent; ignores empty ids).
private void RegisterIdentity(ulong clientId, string persistentId)
{
if (string.IsNullOrEmpty(persistentId)) return;
_persistentIds[clientId] = persistentId;
Debug.Log($"[MatchManager] Client {clientId} persistent id: {persistentId}", this);
}
/// Server-only. The stable per-install id a client reported on connect (survives that client's
/// app restarts), or null if unknown / not yet received. Foundation for reconnect recognition.
public string PersistentIdOf(ulong clientId) =>
_persistentIds.TryGetValue(clientId, out var id) ? id : null;
// Spawns the given prefab as a client's player object into the currently active gameplay scene. No-op
// if the client already owns a player object (double-spawn guard) or has since disconnected.
private void SpawnFor(ulong clientId, GameObject prefab, Vector3 at)
{
if (!prefab)
{
Debug.LogError("[MatchManager] Spawn prefab missing - cannot spawn.", this);
return;
}
if (!_net.ConnectedClients.TryGetValue(clientId, out var client)) return;
if (client.PlayerObject) return;
var go = Instantiate(prefab, at, Quaternion.identity);
go.GetComponent().SpawnAsPlayerObject(clientId);
}
/// Server-only. Starts the match: roll roles, swap Lobby -> Test Map, respawn as variants.
/// Safe to call repeatedly (only acts once, while still in the lobby phase).
public void StartMatch()
{
if (_net == null || !_net.IsServer) return;
if (Phase != MatchPhase.Lobby) return; // double-start / not-in-lobby guard
if (!hunterPrefab || !hiderPrefab)
{
Debug.LogError("[MatchManager] Role prefabs not assigned - cannot start match.", this);
return;
}
var ids = new List(_net.ConnectedClientsIds);
if (ids.Count == 0) return;
Phase = MatchPhase.Transitioning;
// Roll N distinct random Hunters via partial Fisher-Yates (swap a random not-yet-picked id into
// each leading slot); hunterCount 0 -> all Hiders, >= player count -> all Hunters.
int wanted = Mathf.Clamp(hunterCount, 0, ids.Count);
var hunterIds = new HashSet();
for (int i = 0; i < wanted; i++)
{
int j = i + _roleRng.Next(ids.Count - i);
(ids[i], ids[j]) = (ids[j], ids[i]);
hunterIds.Add(ids[i]);
}
Debug.Log($"[MatchManager] Starting match. {hunterIds.Count} hunter(s), " +
$"{ids.Count - hunterIds.Count} hider(s), {ids.Count} player(s).", this);
StartCoroutine(MatchSequence(hunterIds));
}
private IEnumerator MatchSequence(HashSet hunterIds)
{
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()
{
SetPhaseServer(MatchSubPhase.Hide, hideDuration);
yield return WaitPhase(hideDuration, null);
SetPhaseServer(MatchSubPhase.Seek, seekDuration);
yield return WaitPhase(seekDuration, () => _pendingHiders.Count == 0);
SetPhaseServer(MatchSubPhase.Resolution, resolveDuration);
yield return WaitPhase(resolveDuration, null);
_phaseRoutine = null;
EndMatch();
}
// Waits up to 'seconds' of server real time, returning early the moment 'until' (if supplied) is true.
private IEnumerator WaitPhase(float seconds, System.Func until)
{
float t = seconds;
while (t > 0f)
{
if (until != null && until()) yield break;
t -= Time.deltaTime;
yield return null;
}
}
// Server-only. Applies the sub-phase to the server's own mirror and broadcasts it to every other client.
private void SetPhaseServer(MatchSubPhase phase, float duration)
{
ApplyPhaseLocal(phase, duration, matchMode);
BroadcastPhase(phase, duration, matchMode, 0, true);
}
// Sets this machine's sub-phase mirror + countdown + mode (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.
if (changed && phase == MatchSubPhase.Seek) 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);
}
/// Client-side: the local hunter's shoot raycast hit the hider owned by .
/// Routes to the server, which validates phase/role/found-state. On a host this IS the server, so it
/// applies directly. No-op outside the seek phase.
public void ReportHit(ulong targetClientId)
{
if (_net == null || _subPhase != MatchSubPhase.Seek) return;
if (_net.IsServer)
{
if (_pendingHiders.Contains(targetClientId) && IsHunter(_net.LocalClientId))
MarkHiderFound(targetClientId);
return;
}
var cmm = _net.CustomMessagingManager;
if (cmm == null) return;
using (var writer = new FastBufferWriter(sizeof(ulong), Allocator.Temp))
{
writer.WriteValueSafe(targetClientId);
cmm.SendNamedMessage(MsgReportHit, NetworkManager.ServerClientId, writer);
}
}
// Server handler: a hunter client reports its shoot raycast hit a hider. Trusts the client's raycast for
// the hit itself (a co-op hide & seek, not a competitive shooter); the server gates phase, shooter role
// and the target still being an un-found hider.
private void OnReportHitMessage(ulong senderClientId, FastBufferReader reader)
{
reader.ReadValueSafe(out ulong targetClientId);
if (_net == null || !_net.IsServer) return;
if (_subPhase != MatchSubPhase.Seek) return;
if (!_pendingHiders.Contains(targetClientId)) return; // not a live (un-found) hider
if (!IsHunter(senderClientId)) return; // only a hunter may shoot
MarkHiderFound(targetClientId);
}
// Server-only. Resolves a hit on a hider: flashes its X-ray glow on every client, then (after hitRevealDelay
// so the flash is actually seen) drops it from the pending set and applies the mode outcome - Classic flags
// the avatar found (replicated by its NetworkPlayerSetup -> free-cam); Conversion respawns it as a Hunter.
// The hider stays in _pendingHiders during the flash so seek doesn't end early on the last hit; the seek
// loop ends the phase once _pendingHiders empties (no hiders left to find/convert).
private void MarkHiderFound(ulong hiderClientId)
{
if (_revealingHiders.Contains(hiderClientId)) return; // already flashing / being resolved
if (_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject)
{
var setup = c.PlayerObject.GetComponent();
if (setup) setup.FlashHitReveal(hitRevealDelay); // light it up on every client, now
}
_revealingHiders.Add(hiderClientId);
StartCoroutine(ResolveHitAfterReveal(hiderClientId));
}
// Server-only. Waits out the reveal flash, then removes the hider and applies the mode outcome. Split from
// MarkHiderFound so the glow is visible before the avatar leaves play (Classic hide / Conversion despawn).
private IEnumerator ResolveHitAfterReveal(ulong hiderClientId)
{
if (hitRevealDelay > 0f) yield return new WaitForSeconds(hitRevealDelay);
_revealingHiders.Remove(hiderClientId);
if (_net == null || !_net.IsServer) yield break;
_pendingHiders.Remove(hiderClientId);
// A Back-to-Lobby (EndMatch) during the flash tears the match down and respawns everyone; don't then
// convert/found a stale avatar.
if (Phase == MatchPhase.InMatch &&
_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject)
{
if (matchMode == MatchMode.Conversion)
ConvertToHunter(hiderClientId, c.PlayerObject);
else
{
var setup = c.PlayerObject.GetComponent();
if (setup) setup.ServerMarkFound();
}
}
string verb = matchMode == MatchMode.Conversion ? "converted to hunter" : "found";
Debug.Log($"[MatchManager] Hider {hiderClientId} {verb} - {_pendingHiders.Count} hider(s) left.", this);
}
// Server-only (Conversion mode). Despawns the tagged hider and respawns it as the Hunter variant for
// the same client AT THE SAME position/rotation - no teleport. The owner skips its spawn placement
// mid-Seek (see NetworkPlayerSetup) so the server-set position sticks; painting carries across via
// the paint carry. Afterwards the client is a full hunter with no special-case runtime state.
private void ConvertToHunter(ulong clientId, NetworkObject current)
{
if (!hunterPrefab)
{
Debug.LogError("[MatchManager] Hunter prefab missing - cannot convert hider.", this);
return;
}
Vector3 pos = current.transform.position;
Quaternion rot = current.transform.rotation;
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);
}
// True if the client's current avatar is a Hunter role variant (used to gate shooting server-side).
private bool IsHunter(ulong clientId)
{
if (!_net.ConnectedClients.TryGetValue(clientId, out var c) || !c.PlayerObject) return false;
var setup = c.PlayerObject.GetComponent();
return setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter;
}
/// Server-only. Ends the running match: swap Test Map -> Lobby and respawn everyone as the
/// neutral lobby Player (including any spectator-held mid-match joiners). Only acts while in a match.
public void EndMatch()
{
if (_net == null || !_net.IsServer) return;
if (Phase != MatchPhase.InMatch) return; // only endable mid-match
// Stop the sub-phase machine if the match is being ended early (authority's Back-to-Lobby) and clear
// the replicated sub-phase so every client drops its match HUD/visibility before the scene swaps.
if (_phaseRoutine != null) { StopCoroutine(_phaseRoutine); _phaseRoutine = null; }
_pendingHiders.Clear();
SetPhaseServer(MatchSubPhase.None, 0f);
Phase = MatchPhase.Transitioning;
Debug.Log("[MatchManager] Ending match -> returning to lobby.", this);
StartCoroutine(EndMatchSequence());
}
private IEnumerator EndMatchSequence()
{
yield return SwapScenes(matchSceneName, lobbySceneName);
RespawnAll(_ => _playerPrefab, lobbySpawnCenter);
Phase = MatchPhase.Lobby;
Debug.Log("[MatchManager] Back in lobby.", this);
}
// Unloads one gameplay scene (if loaded), then additively loads the next (shared by the match
// start/end sequences).
private IEnumerator SwapScenes(string unloadSceneName, string loadSceneName)
{
var sm = _net.SceneManager;
Scene old = SceneManager.GetSceneByName(unloadSceneName);
if (old.IsValid() && old.isLoaded)
yield return SceneOp(() => sm.UnloadScene(old), SceneEventType.UnloadEventCompleted, unloadSceneName);
yield return SceneOp(() => sm.LoadScene(loadSceneName, LoadSceneMode.Additive),
SceneEventType.LoadEventCompleted, loadSceneName);
}
// Runs an NGO scene op and waits for its server-side completion event. NGO rejects a new op while one
// is in flight, so callers must await this before issuing the next.
private IEnumerator SceneOp(System.Func op, SceneEventType completed, string sceneName)
{
bool done = false;
void OnEvt(SceneEvent e)
{
if (e.SceneEventType == completed && e.SceneName == sceneName) done = true;
}
_net.SceneManager.OnSceneEvent += OnEvt;
SceneEventProgressStatus status = op();
if (status != SceneEventProgressStatus.Started)
{
Debug.LogWarning($"[MatchManager] Scene op on '{sceneName}' returned {status}.", this);
_net.SceneManager.OnSceneEvent -= OnEvt;
yield break;
}
while (!done) yield return null;
_net.SceneManager.OnSceneEvent -= OnEvt;
}
// Despawns each connected client's current avatar (if any) and spawns the prefab chosen by
// prefabFor at the given position. Used for both lobby->match (role variants) and match->lobby
// (neutral Player); the latter also gives spectator-held joiners their first avatar.
private void RespawnAll(System.Func prefabFor, Vector3 at)
{
var ids = new List(_net.ConnectedClientsIds);
foreach (ulong id in ids)
{
if (!_net.ConnectedClients.TryGetValue(id, out var client)) continue;
GameObject prefab = prefabFor(id);
if (!prefab) continue;
// Despawn the old avatar first so the client has no stale player object when the new one
// claims the slot (avoids NGO's "already has a player object" reassign warning).
NetworkObject old = client.PlayerObject;
if (old) old.Despawn(true);
var go = Instantiate(prefab, at, Quaternion.identity);
go.GetComponent().SpawnAsPlayerObject(id);
}
}
// Unloads every gameplay scene so only Main is left loaded & active. NGO is already gone here
// (shutdown), so this uses Unity's SceneManager, not NGO's. Main must be made active BEFORE the
// active gameplay scene is unloaded, otherwise Unity has no valid active scene to fall back to.
private void ReturnToMainOnly()
{
// A real disconnect/shutdown is not a respawn: drop any parked painting so a fresh session
// (re-host / reconnect) never restores a previous match's painting onto its first avatar.
_carriedPaint = null;
Scene main = SceneManager.GetSceneByName(mainSceneName);
if (main.IsValid() && main.isLoaded) SceneManager.SetActiveScene(main);
UnloadLocalIfLoaded(lobbySceneName);
UnloadLocalIfLoaded(matchSceneName);
// Drop the mode cycle back to a player-less state so the persistent orbit input stops re-locking
// the cursor (and any FreeCam/First-Person takeover is undone).
var modes = FindFirstObjectByType(FindObjectsInactive.Include);
if (modes) modes.ReleaseToNoPlayer();
}
private void UnloadLocalIfLoaded(string sceneName)
{
Scene s = SceneManager.GetSceneByName(sceneName);
if (s.IsValid() && s.isLoaded && s != SceneManager.GetActiveScene())
SceneManager.UnloadSceneAsync(s);
}
/// Owner-side: park the local painting so the avatar a scene switch spawns next can restore it.
public void StoreCarriedPaint(byte[] data) => _carriedPaint = data;
/// Owner-side: take (and clear) the parked painting for the freshly spawned avatar, or null if none.
public byte[] TakeCarriedPaint()
{
byte[] d = _carriedPaint;
_carriedPaint = null;
return d;
}
/// True if the given client currently holds match-control authority (may start and end the match).
public bool IsMatchAuthority(ulong clientId) => _hasAuthorityClient && clientId == _authorityClientId;
/// True if THIS machine holds match authority - the host (whose own client id is the authority)
/// or the granted authority client on a dedicated server. Drives the online panel's Disconnect button:
/// on the map the authority's button means "Back to Lobby" (end the match); everyone else disconnects.
public bool IsLocalMatchAuthority
{
get
{
if (_net == null) return false;
if (_net.IsServer) return IsMatchAuthority(_net.LocalClientId);
return _isLocalAuthority;
}
}
/// Ends the running match for everyone (back to the Lobby), routed through authority: the
/// host/server ends directly; a remote authority client asks the server. No-op if this machine is not
/// the authority or no match is running.
public void RequestEndMatch()
{
if (_net == null) return;
if (_net.IsServer) { EndMatch(); return; } // EndMatch itself guards Phase == InMatch
if (_isLocalAuthority) RequestEndMatchFromServer();
}
// Local active-scene kind. Every peer sets its active scene to Lobby/Map as those load (see OnSceneEvent)
// and back to Main on teardown, so a local UI can tell where it is without the server-local Phase.
// Scene.name allocates a fresh string per call and these are polled per frame -> cached per scene handle.
public bool IsLocalSceneLobby => ActiveSceneName == lobbySceneName;
public bool IsLocalSceneMap => ActiveSceneName == matchSceneName;
private int _activeSceneHandle;
private string _activeSceneName;
private string ActiveSceneName
{
get
{
Scene s = SceneManager.GetActiveScene();
if (_activeSceneName == null || s.handle != _activeSceneHandle)
{
_activeSceneHandle = s.handle;
_activeSceneName = s.name;
}
return _activeSceneName;
}
}
// Sends a named message whose payload carries no information (a named message still needs a buffer,
// so a single dummy byte is written).
private void SendEmptyNamedMessage(string messageName, ulong targetClientId)
{
var cmm = _net != null ? _net.CustomMessagingManager : null;
if (cmm == null) return;
using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp))
{
writer.WriteByteSafe(1);
cmm.SendNamedMessage(messageName, targetClientId, writer);
}
}
// Server -> client: tell the designated client it is the match authority (so it reads Esc locally).
private void SendAuthorityGrant(ulong clientId) => SendEmptyNamedMessage(MsgAuthorityGranted, clientId);
// Client handler: this machine has been granted match authority.
private void OnAuthorityGrantedMessage(ulong senderClientId, FastBufferReader reader)
{
_isLocalAuthority = true;
Debug.Log("[MatchManager] This client is the match authority: walk into the Start Match trigger to begin, Esc to end.", this);
}
// Client -> server: ask the server to end the match. Only honored from the authority client (and only
// while a match is running - EndMatch guards the phase).
private void RequestEndMatchFromServer() => SendEmptyNamedMessage(MsgRequestEndMatch, NetworkManager.ServerClientId);
// Server handler: an authority client asked to end the match.
private void OnRequestEndMatchMessage(ulong senderClientId, FastBufferReader reader)
{
if (_net == null || !_net.IsServer) return;
if (!IsMatchAuthority(senderClientId)) return;
EndMatch(); // guards Phase == InMatch itself
}
}