Host keeps authority as before; a dedicated server (no host player) hands start/end authority to the first connecting client and reassigns it if that client leaves. The Start Match trigger now polls a server-side physics overlap instead of relying on OnTriggerEnter, so it fires for the authority client's remote avatar (moved by NetworkTransform, no CharacterController.Move on the server) as well as the host's owned avatar. Esc-to-end routes a named message from the authority client to the server when that client is not the host. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
570 lines
28 KiB
C#
570 lines
28 KiB
C#
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using Unity.Collections;
|
|
using Unity.Netcode;
|
|
using UnityEngine;
|
|
using UnityEngine.SceneManagement;
|
|
#if ENABLE_INPUT_SYSTEM
|
|
using UnityEngine.InputSystem;
|
|
#endif
|
|
|
|
/// <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"/>: one
|
|
/// random connected client is rolled Hunter, 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 (Esc) 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
|
|
/// reads Esc locally and asks 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 presses Esc -> <see cref="EndMatch"/>: 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 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("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();
|
|
}
|
|
|
|
// Esc ends the running match (sends everyone back to the lobby) - but only for the match AUTHORITY.
|
|
// The host/server reads it locally and ends directly. A remote authority client (dedicated server)
|
|
// can't end the match itself (the flow is server-authoritative), so it asks the server over a named
|
|
// message; the server validates the sender is the authority and that a match is running.
|
|
private void Update()
|
|
{
|
|
if (_net == null) return;
|
|
|
|
if (_net.IsServer)
|
|
{
|
|
if (Phase == MatchPhase.InMatch && IsMatchAuthority(_net.LocalClientId) && EscapePressed())
|
|
EndMatch();
|
|
return;
|
|
}
|
|
|
|
// Pure client: Phase is server-local, so we don't gate on it here - the server checks it on receipt.
|
|
if (_isLocalAuthority && EscapePressed())
|
|
RequestEndMatchFromServer();
|
|
}
|
|
|
|
private static bool EscapePressed()
|
|
{
|
|
#if ENABLE_INPUT_SYSTEM
|
|
return Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame;
|
|
#else
|
|
return Input.GetKeyDown(KeyCode.Escape);
|
|
#endif
|
|
}
|
|
|
|
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;
|
|
ulong hunterId = ids[_roleRng.Next(ids.Count)];
|
|
Debug.Log($"[MatchManager] Starting match. Hunter = client {hunterId}, {ids.Count} player(s).", this);
|
|
|
|
StartCoroutine(MatchSequence(hunterId));
|
|
}
|
|
|
|
private IEnumerator MatchSequence(ulong hunterId)
|
|
{
|
|
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 => (id == hunterId) ? 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;
|
|
|
|
// 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
|
|
}
|
|
}
|