StartMatch rolled exactly one random Hunter. Add a serialized hunterCount field (default 1, [Min(0)], inspector-tunable) and roll N distinct random Hunters via a partial Fisher-Yates on the client-id list, clamped to the player count: 0 = all Hiders, >= player count = all Hunters. The match sequence now carries a HashSet of hunter ids instead of a single id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
589 lines
29 KiB
C#
589 lines
29 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 }
|
|
|
|
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);
|
|
|
|
/// <summary>Server-local phase guard. Not replicated - see class summary.</summary>
|
|
public MatchPhase Phase { get; private set; } = MatchPhase.Lobby;
|
|
|
|
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";
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
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);
|
|
|
|
// 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). Registered
|
|
// here (client start) so the handler exists before the server sends the grant on connect.
|
|
if (_net.CustomMessagingManager != null)
|
|
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgAuthorityGranted, OnAuthorityGrantedMessage);
|
|
}
|
|
|
|
// 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();
|
|
_hooked = false;
|
|
_sceneReady = false;
|
|
_hasAuthorityClient = false;
|
|
_isLocalAuthority = false;
|
|
Phase = MatchPhase.Lobby;
|
|
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);
|
|
}
|
|
}
|
|
|
|
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); 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;
|
|
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);
|
|
|
|
Phase = MatchPhase.InMatch;
|
|
Debug.Log("[MatchManager] Match started.", this);
|
|
}
|
|
|
|
/// <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
|
|
|
|
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
|
|
}
|
|
}
|