Sweep over 48 files (Editor/ excluded): remove comments that restate the code, condense class summaries, fix three stale doc statements (HsvColorPicker alpha bar, PaintHud layoutPrefab, MasterVolume FallbackDefault). Inline two single-use wrappers (IsPointerOverUi, BuildModes), drop an unused using, replace a magic literal with the existing ParallaxBase constant, and convert ~10 UnityEngine.Object null checks to the implicit-bool convention. No serialized fields, signatures, RPC/NetworkVariable names, defaults, or #if blocks touched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1505 lines
75 KiB
C#
1505 lines
75 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)
|
|
{
|
|
_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;
|
|
}
|
|
|
|
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 || _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 || !_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 || !_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 || clientId != _net.LocalClientId) return;
|
|
|
|
if (_net.IsServer) RegisterIdentity(_net.LocalClientId, PersistentPlayerId.Value);
|
|
else SendIdentityToServer(PersistentPlayerId.Value);
|
|
}
|
|
|
|
// Client -> server: report this install's persistent id so the server can map it to our ephemeral clientId.
|
|
private void SendIdentityToServer(string persistentId)
|
|
{
|
|
var cmm = _net ? _net.CustomMessagingManager : null;
|
|
if (cmm == null || string.IsNullOrEmpty(persistentId)) return;
|
|
// Grows up to the cap if a future id source is longer than the initial buffer (persistent ids are short).
|
|
using (var writer = new FastBufferWriter(256, Allocator.Temp, 1024))
|
|
{
|
|
writer.WriteValueSafe(persistentId);
|
|
cmm.SendNamedMessage(MsgIdentity, NetworkManager.ServerClientId, writer);
|
|
}
|
|
}
|
|
|
|
// Server handler: a client reported its persistent per-install id right after connecting.
|
|
private void OnIdentityMessage(ulong senderClientId, FastBufferReader reader)
|
|
{
|
|
reader.ReadValueSafe(out string persistentId);
|
|
if (!_net || !_net.IsServer) return;
|
|
RegisterIdentity(senderClientId, persistentId);
|
|
}
|
|
|
|
// Server-only. Records the ephemeral clientId -> persistent id mapping (idempotent; ignores empty ids).
|
|
private void RegisterIdentity(ulong clientId, string persistentId)
|
|
{
|
|
if (string.IsNullOrEmpty(persistentId)) return;
|
|
_persistentIds[clientId] = persistentId;
|
|
Debug.Log($"[MatchManager] Client {clientId} persistent id: {persistentId}", this);
|
|
}
|
|
|
|
/// <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 || !_net.IsServer) return;
|
|
if (Phase != MatchPhase.Lobby) return;
|
|
if (!hunterPrefab || !hiderPrefab)
|
|
{
|
|
Debug.LogError("[MatchManager] Role prefabs not assigned - cannot start match.", this);
|
|
return;
|
|
}
|
|
|
|
var ids = new List<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;
|
|
}
|
|
}
|
|
|
|
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) return;
|
|
if (_net.IsServer) { DebugSkipServer(); return; }
|
|
if (_isLocalAuthority) SendEmptyNamedMessage(MsgDebugSkip, NetworkManager.ServerClientId);
|
|
}
|
|
|
|
// Server-only. Flags the running phase-wait loop to break early so RunMatchPhases moves to the next sub-phase.
|
|
private void DebugSkipServer()
|
|
{
|
|
if (!_net || !_net.IsServer) return;
|
|
if (_subPhase == MatchSubPhase.None) return; // nothing running to skip
|
|
_skipPhase = true;
|
|
Debug.Log($"[MatchManager] DEBUG: skipping the rest of {_subPhase}.", this);
|
|
}
|
|
|
|
// Server handler: an authority client asked to skip to the next sub-phase.
|
|
private void OnDebugSkipMessage(ulong senderClientId, FastBufferReader reader)
|
|
{
|
|
if (!_net || !_net.IsServer) return;
|
|
if (!IsMatchAuthority(senderClientId)) return;
|
|
DebugSkipServer();
|
|
}
|
|
|
|
// --- Readiness barriers ----------------------------------------------------------------------------
|
|
// Server-only. Pre-match: hold everyone in Loading (frozen, no countdown) until every client has spawned &
|
|
// paint-applied the freshly respawned avatars, or matchLoadTimeout elapses. Loading was already asserted
|
|
// before the scene swap; re-assert it here after the respawn, then run the generic barrier over the avatars.
|
|
private IEnumerator RunLoadingBarrier()
|
|
{
|
|
_barrierTargets.Clear();
|
|
foreach (ulong id in _net.ConnectedClientsIds)
|
|
if (_net.ConnectedClients.TryGetValue(id, out var c) && c.PlayerObject)
|
|
_barrierTargets.Add(c.PlayerObject.NetworkObjectId);
|
|
|
|
SetPhaseServer(MatchSubPhase.Loading, 0f);
|
|
yield return RunReadinessBarrier(MatchSubPhase.Loading);
|
|
}
|
|
|
|
// Server-only (Conversion). Hold the (already-entered) Resolution phase until every client has each conversion
|
|
// statue spawned & painted, so a just-caught hider's still-loading statue doesn't burn resolve time. The
|
|
// statues carry the same PlayerPaintSync the avatar barrier keys off, so "painted" == the statue's body reveal.
|
|
private IEnumerator RunStatueBarrier()
|
|
{
|
|
_barrierTargets.Clear();
|
|
for (int i = 0; i < _statues.Count; i++)
|
|
if (_statues[i]) _barrierTargets.Add(_statues[i].NetworkObjectId);
|
|
|
|
yield return RunReadinessBarrier(MatchSubPhase.Resolution);
|
|
}
|
|
|
|
// Server-only. Generic readiness barrier over the already-populated _barrierTargets: snapshot the expected
|
|
// clients, bump the round generation, broadcast the targets + holding phase, then spin until every still-
|
|
// connected expected client has reported ready - or matchLoadTimeout elapses. `activePhase` is the frozen
|
|
// holding phase (Loading pre-match, Resolution for statues) that clients poll during.
|
|
private IEnumerator RunReadinessBarrier(MatchSubPhase activePhase)
|
|
{
|
|
_skipPhase = false; // drop any debug-skip left over from the previous phase/match; only this wait may set it
|
|
_expectedReady.Clear();
|
|
_readyClients.Clear();
|
|
foreach (ulong id in _net.ConnectedClientsIds)
|
|
_expectedReady.Add(id);
|
|
_loadGeneration++;
|
|
BroadcastBeginLoad(activePhase);
|
|
|
|
float t = matchLoadTimeout;
|
|
while (!AllClientsReady())
|
|
{
|
|
if (ConsumeSkip()) break; // DEBUG: proceed now, without waiting for every client
|
|
|
|
// The host is a client too and never messages itself: fold its own local readiness in directly.
|
|
if (LocalClientReady(_barrierTargets)) _readyClients.Add(_net.LocalClientId);
|
|
|
|
if (matchLoadTimeout > 0f)
|
|
{
|
|
t -= Time.deltaTime;
|
|
if (t <= 0f)
|
|
{
|
|
Debug.LogWarning("[MatchManager] Readiness barrier timed out - proceeding anyway.", this);
|
|
break;
|
|
}
|
|
}
|
|
yield return null;
|
|
}
|
|
}
|
|
|
|
// True once every expected client that is STILL connected has reported ready. A client that disconnected
|
|
// mid-load drops out of the requirement; a mid-load joiner was never in _expectedReady, so it can't stall it.
|
|
private bool AllClientsReady()
|
|
{
|
|
foreach (ulong id in _expectedReady)
|
|
if (_net.ConnectedClients.ContainsKey(id) && !_readyClients.Contains(id))
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
// Any machine: true once all target avatars are spawned locally AND each one's painting has been applied
|
|
// (PlayerPaintSync.PaintRevealReady). The local owner's own avatar is always ready; remotes gate on the
|
|
// applied paint cutoff - exactly the condition the per-client reveal uses to un-hide the body.
|
|
private bool LocalClientReady(List<ulong> ids)
|
|
{
|
|
var sm = _net ? _net.SpawnManager : null;
|
|
if (sm == null) return false;
|
|
for (int i = 0; i < ids.Count; i++)
|
|
{
|
|
if (!sm.SpawnedObjects.TryGetValue(ids[i], out var no) || !no) return false;
|
|
var sync = no.GetComponentInChildren<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 ? _net.CustomMessagingManager : null;
|
|
if (cmm == null) return;
|
|
|
|
_loadRecipients.Clear();
|
|
foreach (ulong id in _net.ConnectedClientsIds)
|
|
if (id != _net.LocalClientId) _loadRecipients.Add(id);
|
|
if (_loadRecipients.Count == 0) return;
|
|
|
|
int size = sizeof(int) + sizeof(byte) + sizeof(int) + _barrierTargets.Count * sizeof(ulong);
|
|
using (var writer = new FastBufferWriter(size, Allocator.Temp))
|
|
{
|
|
writer.WriteValueSafe(_loadGeneration);
|
|
writer.WriteValueSafe((byte)activePhase);
|
|
writer.WriteValueSafe(_barrierTargets.Count);
|
|
for (int i = 0; i < _barrierTargets.Count; i++)
|
|
writer.WriteValueSafe(_barrierTargets[i]);
|
|
cmm.SendNamedMessage(MsgBeginLoad, _loadRecipients, writer);
|
|
}
|
|
}
|
|
|
|
// Client handler: the server opened the loading barrier. Store the target avatars + generation and start
|
|
// polling; when everything is spawned & painted, reply Ready. Re-started if a fresh barrier message arrives.
|
|
private void OnBeginLoadMessage(ulong senderClientId, FastBufferReader reader)
|
|
{
|
|
reader.ReadValueSafe(out int generation);
|
|
reader.ReadValueSafe(out byte activePhase);
|
|
reader.ReadValueSafe(out int count);
|
|
_loadTargetIds.Clear();
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
reader.ReadValueSafe(out ulong id);
|
|
_loadTargetIds.Add(id);
|
|
}
|
|
_loadGenerationClient = generation;
|
|
_loadActivePhaseClient = (MatchSubPhase)activePhase;
|
|
|
|
if (_clientLoadPoll != null) StopCoroutine(_clientLoadPoll);
|
|
_clientLoadPoll = StartCoroutine(ClientLoadingPoll());
|
|
}
|
|
|
|
// Client-only. Polls until this machine has spawned & paint-applied every target, then reports ready. Bails
|
|
// without a reply if the phase leaves the holding phase first (match ended, or the server already moved on).
|
|
private IEnumerator ClientLoadingPoll()
|
|
{
|
|
while (_subPhase == _loadActivePhaseClient)
|
|
{
|
|
if (LocalClientReady(_loadTargetIds))
|
|
{
|
|
SendReadyToServer(_loadGenerationClient);
|
|
break;
|
|
}
|
|
yield return null;
|
|
}
|
|
_clientLoadPoll = null;
|
|
}
|
|
|
|
// Client -> server: this machine finished loading (echoes the generation so a stale reply is ignored).
|
|
private void SendReadyToServer(int generation)
|
|
{
|
|
var cmm = _net ? _net.CustomMessagingManager : null;
|
|
if (cmm == null) return;
|
|
using (var writer = new FastBufferWriter(sizeof(int), Allocator.Temp))
|
|
{
|
|
writer.WriteValueSafe(generation);
|
|
cmm.SendNamedMessage(MsgReady, NetworkManager.ServerClientId, writer);
|
|
}
|
|
}
|
|
|
|
// Server handler: a client reported it finished loading. Counted only if it is for the current barrier round.
|
|
private void OnReadyMessage(ulong senderClientId, FastBufferReader reader)
|
|
{
|
|
reader.ReadValueSafe(out int generation);
|
|
if (!_net || !_net.IsServer) return;
|
|
if (generation != _loadGeneration) return; // stale ack from a previous barrier round
|
|
_readyClients.Add(senderClientId);
|
|
}
|
|
|
|
private void SetPhaseServer(MatchSubPhase phase, float duration)
|
|
{
|
|
ApplyPhaseLocal(phase, duration, matchMode);
|
|
BroadcastPhase(phase, duration, matchMode, 0, true);
|
|
}
|
|
|
|
// Shared by the server's SetPhaseServer and a client's OnPhaseMessage handler.
|
|
private void ApplyPhaseLocal(MatchSubPhase phase, float duration, MatchMode mode)
|
|
{
|
|
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 ? _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 || !_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 || _subPhase != MatchSubPhase.Seek) return;
|
|
|
|
if (_net.IsServer) { ServerManualPing(_net.LocalClientId); return; }
|
|
SendEmptyNamedMessage(MsgManualPing, NetworkManager.ServerClientId);
|
|
}
|
|
|
|
// Server handler: a client asked for a manual self-ping. The sender id is the trusted routing;
|
|
// ServerManualPing validates it is a live hider during Seek.
|
|
private void OnManualPingMessage(ulong senderClientId, FastBufferReader reader)
|
|
{
|
|
if (!_net || !_net.IsServer) return;
|
|
ServerManualPing(senderClientId);
|
|
}
|
|
|
|
// Server-only. Emits a single-hider radar ping for hiderClientId to every hunter - if it is a still-un-found
|
|
// hider during Seek. Same blip (and lifetime) as an interval sweep, just one target and on demand.
|
|
private void ServerManualPing(ulong hiderClientId)
|
|
{
|
|
if (_subPhase != MatchSubPhase.Seek) return;
|
|
if (!_pendingHiders.Contains(hiderClientId)) return; // not a live (un-found) hider
|
|
if (!_net.ConnectedClients.TryGetValue(hiderClientId, out var c) || !c.PlayerObject) return;
|
|
|
|
_radarPingIds.Clear();
|
|
_radarPingIds.Add(c.PlayerObject.NetworkObjectId);
|
|
EmitRadarPings();
|
|
}
|
|
|
|
// Host/server local path: resolve the swept hider NetworkObjectIds (in _radarPingIds) to their live
|
|
// transforms and drop a blip on the local radar. Mirrors OnRadarPingMessage for the host's own hunter view.
|
|
private void SpawnRadarBlipsLocal()
|
|
{
|
|
var radar = Radar.Instance;
|
|
var spawnMgr = _net ? _net.SpawnManager : null;
|
|
if (!radar || spawnMgr == null) return;
|
|
for (int i = 0; i < _radarPingIds.Count; i++)
|
|
if (spawnMgr.SpawnedObjects.TryGetValue(_radarPingIds[i], out var no) && no)
|
|
radar.Spawn(no.transform, radarPingLifetime);
|
|
}
|
|
|
|
// Hunter-client handler: a radar sweep arrived. Resolve each hider NetworkObjectId to its live transform and
|
|
// drop a blip on the local radar. Only hunter clients are sent this message (filtered server-side).
|
|
private void OnRadarPingMessage(ulong senderClientId, FastBufferReader reader)
|
|
{
|
|
reader.ReadValueSafe(out float lifetime);
|
|
reader.ReadValueSafe(out int count);
|
|
var radar = Radar.Instance;
|
|
var spawnMgr = _net ? _net.SpawnManager : null;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
reader.ReadValueSafe(out ulong netObjId);
|
|
if (radar && spawnMgr != null &&
|
|
spawnMgr.SpawnedObjects.TryGetValue(netObjId, out var no) && no)
|
|
radar.Spawn(no.transform, lifetime);
|
|
}
|
|
}
|
|
|
|
/// <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 || _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 || !_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 || !_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();
|
|
}
|
|
|
|
private void DespawnConversionStatues()
|
|
{
|
|
for (int i = 0; i < _statues.Count; i++)
|
|
{
|
|
var s = _statues[i];
|
|
if (s && s.IsSpawned) s.Despawn(true);
|
|
}
|
|
_statues.Clear();
|
|
}
|
|
|
|
private bool IsHunter(ulong clientId)
|
|
{
|
|
if (!_net.ConnectedClients.TryGetValue(clientId, out var c) || !c.PlayerObject) return false;
|
|
var setup = c.PlayerObject.GetComponent<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 || !_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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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) 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) return;
|
|
if (_net.IsServer) { EndMatch(); return; } // EndMatch itself guards Phase == InMatch
|
|
if (_isLocalAuthority) RequestEndMatchFromServer();
|
|
}
|
|
|
|
// Local active-scene kind. Every peer sets its active scene to Lobby/Map as those load (see OnSceneEvent)
|
|
// and back to Main on teardown, so a local UI can tell where it is without the server-local Phase.
|
|
// Scene.name allocates a fresh string per call and these are polled per frame -> cached per scene handle.
|
|
public bool IsLocalSceneLobby => ActiveSceneName == lobbySceneName;
|
|
public bool IsLocalSceneMap => ActiveSceneName == matchSceneName;
|
|
|
|
private int _activeSceneHandle;
|
|
private string _activeSceneName;
|
|
|
|
private string ActiveSceneName
|
|
{
|
|
get
|
|
{
|
|
Scene s = SceneManager.GetActiveScene();
|
|
if (_activeSceneName == null || s.handle != _activeSceneHandle)
|
|
{
|
|
_activeSceneHandle = s.handle;
|
|
_activeSceneName = s.name;
|
|
}
|
|
return _activeSceneName;
|
|
}
|
|
}
|
|
|
|
// Sends a named message whose payload carries no information (a named message still needs a buffer,
|
|
// so a single dummy byte is written).
|
|
private void SendEmptyNamedMessage(string messageName, ulong targetClientId)
|
|
{
|
|
var cmm = _net ? _net.CustomMessagingManager : null;
|
|
if (cmm == null) return;
|
|
using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp))
|
|
{
|
|
writer.WriteByteSafe(1);
|
|
cmm.SendNamedMessage(messageName, targetClientId, writer);
|
|
}
|
|
}
|
|
|
|
// Server -> client: tell the designated client it is the match authority (so it reads Esc locally).
|
|
private void SendAuthorityGrant(ulong clientId) => SendEmptyNamedMessage(MsgAuthorityGranted, clientId);
|
|
|
|
// Client handler: this machine has been granted match authority.
|
|
private void OnAuthorityGrantedMessage(ulong senderClientId, FastBufferReader reader)
|
|
{
|
|
_isLocalAuthority = true;
|
|
Debug.Log("[MatchManager] This client is the match authority: walk into the Start Match trigger to begin, Esc to end.", this);
|
|
}
|
|
|
|
// Client -> server: ask the server to end the match. Only honored from the authority client (and only
|
|
// while a match is running - EndMatch guards the phase).
|
|
private void RequestEndMatchFromServer() => SendEmptyNamedMessage(MsgRequestEndMatch, NetworkManager.ServerClientId);
|
|
|
|
// Server handler: an authority client asked to end the match.
|
|
private void OnRequestEndMatchMessage(ulong senderClientId, FastBufferReader reader)
|
|
{
|
|
if (!_net || !_net.IsServer) return;
|
|
if (!IsMatchAuthority(senderClientId)) return;
|
|
EndMatch(); // guards Phase == InMatch itself
|
|
}
|
|
}
|