Drive the match through three server-authoritative phases (hide, seek, resolution) with per-phase durations, broadcasting the active phase and countdown to clients over the existing named-message infrastructure so MatchManager stays a plain MonoBehaviour and Main keeps zero in-scene network objects. During hide, hider meshes are culled on hunter clients only (each client computes visibility locally from role, phase and found state). During seek, hunters shoot a single raycast from the first-person camera centre (left click, cooldown-gated); a validated hit marks the hider found, which hides its mesh for everyone and binds the owner to spectator freecam. Seek ends early once every hider is found, then resolution runs before returning to the lobby. Force AlwaysAnimate on remote replicas so the animator keeps writing bone transforms while the body is hidden or off-screen. The Paintman animator is authored CullUpdateTransforms, so a culled renderer froze the pose on the rest pose and the skinned collider baked that frozen pose, landing replicated paint stamps on the wrong body location and leaving the painting desynced after hiders were shown again. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
838 lines
42 KiB
C#
838 lines
42 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), which stays loaded the whole session (NetworkManager, cameras, HUD). The gameplay scenes
|
|
/// (Lobby, Test Map) are loaded ADDITIVELY through NGO's scene manager so Main is never torn down.
|
|
///
|
|
/// Two design pillars make the persistent-Main + additive-gameplay model actually work:
|
|
///
|
|
/// 1. Clients sync ADDITIVELY (<see cref="NetworkSceneManager.SetClientSynchronizationMode"/> =
|
|
/// <see cref="LoadSceneMode.Additive"/>). The NGO default (Single) would unload every scene the
|
|
/// connecting client already has - including its own Main (cameras/HUD/managers) - and reload the
|
|
/// server's active scene in single mode, which also crashes the player-object scene migration
|
|
/// (MoveGameObjectToScene "Destination scene is not valid"). Additive keeps the client's Main and
|
|
/// layers the server's scenes on top. Must be set on the server before any client connects; it is
|
|
/// auto-propagated to clients via the synchronize event.
|
|
///
|
|
/// 2. Players are spawned by THIS manager, not by NGO auto-spawn. NGO would auto-spawn the player the
|
|
/// instant a peer connects, into whatever scene is active right then - for the host that is Main
|
|
/// (the additive Lobby load is still in flight), which has no floor, so the host falls into the
|
|
/// void. We disable auto-spawn (null out <see cref="NetworkConfig.PlayerPrefab"/>) and instead
|
|
/// spawn the neutral Player prefab into the gameplay scene only once it is loaded, so avatars always
|
|
/// land on a floor. Main therefore holds zero network objects.
|
|
///
|
|
/// Flow: server start -> additive sync mode set -> Lobby loaded additively -> Player spawned for every
|
|
/// connected peer (full mode set, including First Person). Late joiners spawn on connect into the
|
|
/// already-loaded scene. The host walks into the Start Match trigger -> <see cref="StartMatch"/>: a
|
|
/// configurable number of random connected clients are rolled Hunter (<see cref="hunterCount"/>,
|
|
/// default 1), the rest Hider; the Lobby is unloaded and Test Map loaded
|
|
/// (strictly serialized - an NGO constraint, only one scene op in flight); then every player object is
|
|
/// despawned and respawned as its role's prefab variant.
|
|
///
|
|
/// Match-control AUTHORITY: the client allowed to start (walk into the trigger) and end the match.
|
|
/// A host has its own player, so the host is the authority. A DEDICATED server (StartServer, no local
|
|
/// player) hands authority to the FIRST client that connects, so a server nobody plays on is still
|
|
/// controllable. The id is server-local; the designated remote client is told via a named message so it
|
|
/// can ask the server (which validates the sender) to end. If the authority leaves, it is reassigned to
|
|
/// another connected client.
|
|
///
|
|
/// End / join / teardown:
|
|
/// - The match authority ends the match via <see cref="RequestEndMatch"/> -> <see cref="EndMatch"/>
|
|
/// (driven by the online session panel's "Back to Lobby" button - Esc opens that panel rather than
|
|
/// ending directly). Test Map is swapped back to Lobby and everyone is respawned as the neutral Player.
|
|
/// The host/server ends directly; a remote authority client requests it over a named message that only
|
|
/// the authority's id is honored for. A non-authority client's panel button is a plain disconnect.
|
|
/// - A client that joins MID-MATCH spawns as a SPECTATOR: a dedicated EMPTY prefab (no body/collider)
|
|
/// bound to free-cam-only, so it flies around the running map and watches but does not play. It
|
|
/// respawns as a neutral lobby Player when the match ends.
|
|
/// - On shutdown / disconnect (<see cref="ReturnToMainOnly"/>) every gameplay scene is unloaded so only
|
|
/// Main is left loaded & active. NGO's Dispose does NOT unload additively-synced scenes, so we do it.
|
|
///
|
|
/// 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.
|
|
/// Hide -> Seek -> Resolution, then back to the lobby. Drives per-client visibility, hunter shooting
|
|
/// and the match HUD countdown.</summary>
|
|
public enum MatchSubPhase { None, Hide, Seek, Resolution }
|
|
|
|
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 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;
|
|
|
|
/// <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>Server-local phase guard. Not replicated - see class summary.</summary>
|
|
public MatchPhase Phase { get; private set; } = MatchPhase.Lobby;
|
|
|
|
// --- Replicated sub-phase mirror (every machine, set by the server's broadcast; the server sets its
|
|
// own copy directly in SetPhaseServer). Cosmetic countdown runs off the local clock from the broadcast
|
|
// duration; the authoritative transition is always server-driven, so any drift self-corrects on the
|
|
// next phase message. ---
|
|
private MatchSubPhase _subPhase = MatchSubPhase.None;
|
|
private float _phaseEndTime; // Time.time when the current sub-phase ends (for the HUD countdown)
|
|
private float _phaseDuration;
|
|
|
|
/// <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>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>Total length of the current sub-phase (for a HUD progress bar, if wanted).</summary>
|
|
public float LocalPhaseDuration => _phaseDuration;
|
|
/// <summary>Raised after the local sub-phase changes (argument = the new sub-phase). HUD/visibility follow it.</summary>
|
|
public event System.Action<MatchSubPhase> SubPhaseChanged;
|
|
|
|
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
|
|
|
|
// Role pick uses its OWN generator, seeded once from real entropy. UnityEngine.Random is a single
|
|
// engine-wide generator whose state at match-start can sit at the same point every fresh play
|
|
// session (nothing random consumed before it), which made the "random" hunter land on the same
|
|
// client every time. An independent System.Random decouples the pick from that shared global state.
|
|
private readonly System.Random _roleRng = new System.Random(System.Guid.NewGuid().GetHashCode());
|
|
|
|
// Painting carry across an avatar respawn (scene switch). Per-machine: the LOCAL owner's painting,
|
|
// parked here by its despawning PlayerPaintSync (that component is gone before the replacement spawns)
|
|
// and consumed once by the next owner avatar. This manager is persistent in Main, so it bridges the
|
|
// despawn->spawn gap. Cleared on teardown (ReturnToMainOnly) so a fresh session never inherits a
|
|
// previous one's painting; a normal respawn keeps Main loaded, so the parked copy survives it.
|
|
private byte[] _carriedPaint;
|
|
|
|
// Match-control authority. Server-local: which client may start/end the match. Host -> the host
|
|
// itself (set at server start); dedicated server -> the first client to connect (set on connect).
|
|
private bool _hasAuthorityClient;
|
|
private ulong _authorityClientId;
|
|
// Client-side mirror, set by the grant message below: this machine's local client is the authority,
|
|
// so it reads Esc and asks 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";
|
|
|
|
// 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>();
|
|
private Coroutine _phaseRoutine;
|
|
private readonly List<ulong> _phaseRecipients = new List<ulong>(); // reused scratch for the phase broadcast
|
|
|
|
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 on both host and remote paths).
|
|
// We keep the reference and spawn manually once a floored scene exists. Runtime-only mutation -
|
|
// the scene asset still carries PlayerPrefab, so there is no asset diff.
|
|
if (_net != null)
|
|
{
|
|
_playerPrefab = _net.NetworkConfig.PlayerPrefab;
|
|
_net.NetworkConfig.PlayerPrefab = null;
|
|
}
|
|
|
|
// Placeholder on-screen match HUD (phase + countdown + hunter crosshair). Added at runtime so it
|
|
// needs no scene/prefab authoring; trivially swapped for a proper Canvas later. Persistent like this.
|
|
if (!GetComponent<MatchHud>()) gameObject.AddComponent<MatchHud>();
|
|
}
|
|
|
|
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;
|
|
_net.OnClientConnectedCallback -= OnClientConnected;
|
|
_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);
|
|
}
|
|
}
|
|
if (Instance == this) Instance = null;
|
|
}
|
|
|
|
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;
|
|
|
|
// Match-control authority. A host has its own player, so it is the authority right away. A
|
|
// dedicated server has no local player; the first client to connect claims it (OnClientConnected).
|
|
_hasAuthorityClient = false;
|
|
if (_net.IsHost) { _authorityClientId = _net.LocalClientId; _hasAuthorityClient = true; }
|
|
|
|
if (_net.CustomMessagingManager != null)
|
|
{
|
|
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRequestEndMatch, OnRequestEndMatchMessage);
|
|
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReportHit, OnReportHitMessage);
|
|
}
|
|
|
|
_subPhase = MatchSubPhase.None;
|
|
_pendingHiders.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();
|
|
|
|
// Listen for the server granting this machine match authority (dedicated-server case) and for the
|
|
// sub-phase broadcasts. Registered here (client start) so the handlers exist before the server sends.
|
|
if (_net.CustomMessagingManager != null)
|
|
{
|
|
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgAuthorityGranted, OnAuthorityGrantedMessage);
|
|
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgPhase, OnPhaseMessage);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
_pendingHiders.Clear();
|
|
if (_net)
|
|
{
|
|
_net.OnClientConnectedCallback -= OnClientConnected;
|
|
_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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
// Lobby is loaded and active on the server: now it is safe to spawn the already-connected
|
|
// peers (the host, plus anyone who connected while the load was in flight).
|
|
_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 LoadLobbyRoutine (lobby) or RespawnAll (mid-transition) 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, so this only fires for a server with no local player). Tell the
|
|
// client over a named message so it reads Esc locally; the trigger picks it up server-side.
|
|
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: spawn an empty SPECTATOR avatar (free-cam only) into the running map. They can
|
|
// fly around and watch but don't play. When the host ends the match, EndMatchSequence's respawn
|
|
// loop covers every connected client, so they turn 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 (remaining time as the duration).
|
|
BroadcastPhase(_subPhase, LocalPhaseRemaining, clientId, false);
|
|
return;
|
|
}
|
|
SpawnFor(clientId, _playerPrefab, lobbySpawnCenter);
|
|
}
|
|
|
|
// Server-side: if the match authority leaves, hand authority to another connected client (lowest
|
|
// remaining id) so a dedicated-server match stays controllable. No-op in the host case (the host
|
|
// holds authority and only "leaves" on shutdown, which teardown handles).
|
|
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);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Spawns the given prefab as a client's player object into the currently active gameplay scene. No-op
|
|
// if the client already owns a player object (double-spawn guard) or has since disconnected.
|
|
private void SpawnFor(ulong clientId, GameObject prefab, Vector3 at)
|
|
{
|
|
if (!prefab)
|
|
{
|
|
Debug.LogError("[MatchManager] Spawn prefab missing - cannot spawn.", this);
|
|
return;
|
|
}
|
|
if (!_net.ConnectedClients.TryGetValue(clientId, out var client)) return;
|
|
if (client.PlayerObject) return;
|
|
|
|
var go = Instantiate(prefab, at, Quaternion.identity);
|
|
go.GetComponent<NetworkObject>().SpawnAsPlayerObject(clientId);
|
|
}
|
|
|
|
/// <summary>Server-only. Starts the match: roll roles, swap Lobby -> Test Map, respawn as variants.
|
|
/// Safe to call repeatedly (only acts once, while still in the lobby phase).</summary>
|
|
public void StartMatch()
|
|
{
|
|
if (_net == null || !_net.IsServer) return;
|
|
if (Phase != MatchPhase.Lobby) return; // double-start / not-in-lobby guard
|
|
if (!hunterPrefab || !hiderPrefab)
|
|
{
|
|
Debug.LogError("[MatchManager] Role prefabs not assigned - cannot start match.", this);
|
|
return;
|
|
}
|
|
|
|
var ids = new List<ulong>(_net.ConnectedClientsIds);
|
|
if (ids.Count == 0) return;
|
|
|
|
Phase = MatchPhase.Transitioning;
|
|
|
|
// Roll N distinct random Hunters (clamped to the player count); everyone else is a Hider.
|
|
// hunterCount 0 -> all Hiders, >= player count -> all Hunters. Partial Fisher-Yates: swap a
|
|
// random not-yet-picked id into each leading slot, then take that slot as a Hunter.
|
|
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)
|
|
{
|
|
var sm = _net.SceneManager;
|
|
|
|
// 1. Unload the lobby (strictly serialized: one scene op in flight at a time).
|
|
Scene lobby = SceneManager.GetSceneByName(lobbySceneName);
|
|
if (lobby.IsValid() && lobby.isLoaded)
|
|
yield return SceneOp(() => sm.UnloadScene(lobby), SceneEventType.UnloadEventCompleted, lobbySceneName);
|
|
|
|
// 2. Load the match map.
|
|
yield return SceneOp(() => sm.LoadScene(matchSceneName, LoadSceneMode.Additive),
|
|
SceneEventType.LoadEventCompleted, matchSceneName);
|
|
|
|
// 3. Respawn every player as its rolled role's prefab variant.
|
|
RespawnAll(id => hunterIds.Contains(id) ? hunterPrefab : hiderPrefab, matchSpawnCenter);
|
|
|
|
// The hiders the seek phase has to find: every connected client that wasn't rolled a hunter.
|
|
_pendingHiders.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(); // swaps the map back to the lobby and respawns everyone as the neutral Player
|
|
}
|
|
|
|
// Waits up to 'seconds' of server real time, returning early the moment 'until' (if supplied) is true.
|
|
private IEnumerator WaitPhase(float seconds, System.Func<bool> until)
|
|
{
|
|
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);
|
|
BroadcastPhase(phase, duration, 0, true);
|
|
}
|
|
|
|
// Sets this machine's sub-phase mirror + countdown and notifies followers (HUD / visibility). Shared by
|
|
// the server's SetPhaseServer and a client's OnPhaseMessage handler.
|
|
private void ApplyPhaseLocal(MatchSubPhase phase, float duration)
|
|
{
|
|
bool changed = _subPhase != phase;
|
|
_subPhase = phase;
|
|
_phaseDuration = duration;
|
|
_phaseEndTime = Time.time + duration;
|
|
if (changed) SubPhaseChanged?.Invoke(phase);
|
|
}
|
|
|
|
// Sends a sub-phase (byte) + its remaining duration (float) 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, ulong targetClient, bool toAll)
|
|
{
|
|
var cmm = _net != null ? _net.CustomMessagingManager : null;
|
|
if (cmm == null) return;
|
|
using (var writer = new FastBufferWriter(sizeof(byte) + sizeof(float), Allocator.Temp))
|
|
{
|
|
writer.WriteValueSafe((byte)phase);
|
|
writer.WriteValueSafe(duration);
|
|
if (toAll)
|
|
{
|
|
// Every client except the server itself (the host already applied it directly in
|
|
// SetPhaseServer, so it must not double-apply via loopback). One send, no writer reuse.
|
|
_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);
|
|
ApplyPhaseLocal((MatchSubPhase)phase, duration);
|
|
}
|
|
|
|
/// <summary>Client-side: the local hunter's shoot raycast hit the hider owned by <paramref name="targetClientId"/>.
|
|
/// Routes to the server, which validates phase/role/found-state. On a host this IS the server, so it
|
|
/// applies directly. No-op outside the seek phase.</summary>
|
|
public void ReportHit(ulong targetClientId)
|
|
{
|
|
if (_net == null || _subPhase != MatchSubPhase.Seek) return;
|
|
|
|
if (_net.IsServer)
|
|
{
|
|
if (_pendingHiders.Contains(targetClientId) && IsHunter(_net.LocalClientId))
|
|
MarkHiderFound(targetClientId);
|
|
return;
|
|
}
|
|
|
|
var cmm = _net.CustomMessagingManager;
|
|
if (cmm == null) return;
|
|
using (var writer = new FastBufferWriter(sizeof(ulong), Allocator.Temp))
|
|
{
|
|
writer.WriteValueSafe(targetClientId);
|
|
cmm.SendNamedMessage(MsgReportHit, NetworkManager.ServerClientId, writer);
|
|
}
|
|
}
|
|
|
|
// Server handler: a hunter client reports its shoot raycast hit a hider. Trusts the client's raycast for
|
|
// the hit itself (a co-op hide & seek, not a competitive shooter); the server gates phase, shooter role
|
|
// and the target still being an un-found hider.
|
|
private void OnReportHitMessage(ulong senderClientId, FastBufferReader reader)
|
|
{
|
|
reader.ReadValueSafe(out ulong targetClientId);
|
|
if (_net == null || !_net.IsServer) return;
|
|
if (_subPhase != MatchSubPhase.Seek) return;
|
|
if (!_pendingHiders.Contains(targetClientId)) return; // not a live (un-found) hider
|
|
if (!IsHunter(senderClientId)) return; // only a hunter may shoot
|
|
|
|
MarkHiderFound(targetClientId);
|
|
}
|
|
|
|
// Server-only. Flags the hider's avatar found (replicated by its NetworkPlayerSetup) and drops it from
|
|
// the pending set; the seek loop ends the phase early once that empties.
|
|
private void MarkHiderFound(ulong hiderClientId)
|
|
{
|
|
_pendingHiders.Remove(hiderClientId);
|
|
if (_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject)
|
|
{
|
|
var setup = c.PlayerObject.GetComponent<NetworkPlayerSetup>();
|
|
if (setup) setup.ServerMarkFound();
|
|
}
|
|
Debug.Log($"[MatchManager] Hider {hiderClientId} found - {_pendingHiders.Count} hider(s) left.", this);
|
|
}
|
|
|
|
// True if the client's current avatar is a Hunter role variant (used to gate shooting server-side).
|
|
private bool IsHunter(ulong clientId)
|
|
{
|
|
if (!_net.ConnectedClients.TryGetValue(clientId, out var c) || !c.PlayerObject) return false;
|
|
var setup = c.PlayerObject.GetComponent<NetworkPlayerSetup>();
|
|
return setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter;
|
|
}
|
|
|
|
/// <summary>Server-only. Ends the running match: swap Test Map -> Lobby and respawn everyone as the
|
|
/// neutral lobby Player (including any spectator-held mid-match joiners). Only acts while in a match.</summary>
|
|
public void EndMatch()
|
|
{
|
|
if (_net == null || !_net.IsServer) return;
|
|
if (Phase != MatchPhase.InMatch) return; // only endable mid-match
|
|
|
|
// Stop the sub-phase machine if the match is being ended early (authority's Back-to-Lobby) and clear
|
|
// the replicated sub-phase so every client drops its match HUD/visibility before the scene swaps.
|
|
if (_phaseRoutine != null) { StopCoroutine(_phaseRoutine); _phaseRoutine = null; }
|
|
_pendingHiders.Clear();
|
|
SetPhaseServer(MatchSubPhase.None, 0f);
|
|
|
|
Phase = MatchPhase.Transitioning;
|
|
Debug.Log("[MatchManager] Ending match -> returning to lobby.", this);
|
|
StartCoroutine(EndMatchSequence());
|
|
}
|
|
|
|
private IEnumerator EndMatchSequence()
|
|
{
|
|
var sm = _net.SceneManager;
|
|
|
|
// 1. Unload the match map (strictly serialized: one scene op in flight at a time).
|
|
Scene map = SceneManager.GetSceneByName(matchSceneName);
|
|
if (map.IsValid() && map.isLoaded)
|
|
yield return SceneOp(() => sm.UnloadScene(map), SceneEventType.UnloadEventCompleted, matchSceneName);
|
|
|
|
// 2. Reload the lobby.
|
|
yield return SceneOp(() => sm.LoadScene(lobbySceneName, LoadSceneMode.Additive),
|
|
SceneEventType.LoadEventCompleted, lobbySceneName);
|
|
|
|
// 3. Respawn everyone as the neutral lobby Player (covers held mid-match joiners too).
|
|
RespawnAll(_ => _playerPrefab, lobbySpawnCenter);
|
|
|
|
Phase = MatchPhase.Lobby;
|
|
Debug.Log("[MatchManager] Back in lobby.", this);
|
|
}
|
|
|
|
// Runs an NGO scene op and waits for its server-side completion event. NGO rejects a new op while one
|
|
// is in flight, so callers must await this before issuing the next.
|
|
private IEnumerator SceneOp(System.Func<SceneEventProgressStatus> op, SceneEventType completed, string sceneName)
|
|
{
|
|
bool done = false;
|
|
void OnEvt(SceneEvent e)
|
|
{
|
|
if (e.SceneEventType == completed && e.SceneName == sceneName) done = true;
|
|
}
|
|
|
|
_net.SceneManager.OnSceneEvent += OnEvt;
|
|
SceneEventProgressStatus status = op();
|
|
if (status != SceneEventProgressStatus.Started)
|
|
{
|
|
Debug.LogWarning($"[MatchManager] Scene op on '{sceneName}' returned {status}.", this);
|
|
_net.SceneManager.OnSceneEvent -= OnEvt;
|
|
yield break;
|
|
}
|
|
|
|
while (!done) yield return null;
|
|
_net.SceneManager.OnSceneEvent -= OnEvt;
|
|
}
|
|
|
|
// Despawns each connected client's current avatar (if any) and spawns the prefab chosen by
|
|
// prefabFor at the given position. Used for both lobby->match (role variants) and match->lobby
|
|
// (neutral Player); the latter also gives spectator-held joiners their first avatar.
|
|
private void RespawnAll(System.Func<ulong, GameObject> prefabFor, Vector3 at)
|
|
{
|
|
var ids = new List<ulong>(_net.ConnectedClientsIds);
|
|
foreach (ulong id in ids)
|
|
{
|
|
if (!_net.ConnectedClients.TryGetValue(id, out var client)) continue;
|
|
GameObject prefab = prefabFor(id);
|
|
if (!prefab) continue;
|
|
|
|
// Despawn the old avatar first so the client has no stale player object when the new one
|
|
// claims the slot (avoids NGO's "already has a player object" reassign warning).
|
|
NetworkObject old = client.PlayerObject;
|
|
if (old) old.Despawn(true);
|
|
|
|
var go = Instantiate(prefab, at, Quaternion.identity);
|
|
go.GetComponent<NetworkObject>().SpawnAsPlayerObject(id);
|
|
}
|
|
}
|
|
|
|
// Unloads every gameplay scene so only Main is left loaded & active. NGO is already gone here
|
|
// (shutdown), so this uses Unity's SceneManager, not NGO's. Main must be made active BEFORE the
|
|
// active gameplay scene is unloaded, otherwise Unity has no valid active scene to fall back to.
|
|
private void ReturnToMainOnly()
|
|
{
|
|
// A real disconnect/shutdown is not a respawn: drop any parked painting so a fresh session
|
|
// (re-host / reconnect) never restores a previous match's painting onto its first avatar.
|
|
_carriedPaint = null;
|
|
|
|
Scene main = SceneManager.GetSceneByName(mainSceneName);
|
|
if (main.IsValid() && main.isLoaded) SceneManager.SetActiveScene(main);
|
|
|
|
UnloadLocalIfLoaded(lobbySceneName);
|
|
UnloadLocalIfLoaded(matchSceneName);
|
|
|
|
// The local avatar is gone; 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). Without
|
|
// this the OS cursor stays captured back in the empty Main scene.
|
|
var modes = FindFirstObjectByType<GameModeManager>(FindObjectsInactive.Include);
|
|
if (modes) modes.ReleaseToNoPlayer();
|
|
}
|
|
|
|
private void UnloadLocalIfLoaded(string sceneName)
|
|
{
|
|
Scene s = SceneManager.GetSceneByName(sceneName);
|
|
if (s.IsValid() && s.isLoaded && s != SceneManager.GetActiveScene())
|
|
SceneManager.UnloadSceneAsync(s);
|
|
}
|
|
|
|
/// <summary>Owner-side: park the local painting so the avatar a scene switch spawns next can restore it.</summary>
|
|
public void StoreCarriedPaint(byte[] data) => _carriedPaint = data;
|
|
|
|
/// <summary>Owner-side: take (and clear) the parked painting for the freshly spawned avatar, or null if none.</summary>
|
|
public byte[] TakeCarriedPaint()
|
|
{
|
|
byte[] d = _carriedPaint;
|
|
_carriedPaint = null;
|
|
return d;
|
|
}
|
|
|
|
/// <summary>True if the given client currently holds match-control authority (may start and end the match).</summary>
|
|
public bool IsMatchAuthority(ulong clientId) => _hasAuthorityClient && clientId == _authorityClientId;
|
|
|
|
/// <summary>True if THIS machine holds match authority - the host (whose own client id is the authority)
|
|
/// or the granted authority client on a dedicated server. Drives the online panel's Disconnect button:
|
|
/// on the map the authority's button means "Back to Lobby" (end the match); everyone else disconnects.</summary>
|
|
public bool IsLocalMatchAuthority
|
|
{
|
|
get
|
|
{
|
|
if (_net == null) return false;
|
|
if (_net.IsServer) return IsMatchAuthority(_net.LocalClientId);
|
|
return _isLocalAuthority;
|
|
}
|
|
}
|
|
|
|
/// <summary>Ends the running match for everyone (back to the Lobby), routed through authority: the
|
|
/// host/server ends directly; a remote authority client asks the server. No-op if this machine is not
|
|
/// the authority or no match is running. Replaces the old Esc handler - now driven by the panel button.</summary>
|
|
public void RequestEndMatch()
|
|
{
|
|
if (_net == null) return;
|
|
if (_net.IsServer) { EndMatch(); return; } // EndMatch itself guards Phase == InMatch
|
|
if (_isLocalAuthority) RequestEndMatchFromServer();
|
|
}
|
|
|
|
// Local active-scene kind. Every peer sets its active scene to Lobby/Map as those load (see OnSceneEvent)
|
|
// and back to Main on teardown, so a local UI can tell where it is without the server-local Phase.
|
|
public bool IsLocalSceneMain => SceneManager.GetActiveScene().name == mainSceneName;
|
|
public bool IsLocalSceneLobby => SceneManager.GetActiveScene().name == lobbySceneName;
|
|
public bool IsLocalSceneMap => SceneManager.GetActiveScene().name == matchSceneName;
|
|
|
|
// Server -> client: tell the designated client it is the match authority (so it reads Esc locally).
|
|
private void SendAuthorityGrant(ulong clientId)
|
|
{
|
|
var cmm = _net != null ? _net.CustomMessagingManager : null;
|
|
if (cmm == null) return;
|
|
using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp))
|
|
{
|
|
writer.WriteByteSafe(1); // payload unused; a named message still needs a buffer
|
|
cmm.SendNamedMessage(MsgAuthorityGranted, clientId, writer);
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
{
|
|
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(MsgRequestEndMatch, NetworkManager.ServerClientId, writer);
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|