Add match-end, spectator-hold join, and shutdown scene teardown

- Host presses Esc to end a running match: swap Test Map back to Lobby and
  respawn everyone as the neutral Player (server-gated, host-only).
- A client joining mid-match is spectator-held (no avatar) and spawns into
  the lobby when the match ends.
- On shutdown/disconnect, unload every gameplay scene so only Main is left
  loaded & active (NGO's Dispose does not unload additively-synced scenes).
- Generalize RespawnAll to a prefab selector + position so match-start
  (role variants) and match-end (neutral Player) share one path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-27 23:29:28 +02:00
1 parent bd70f3b8b9
commit be6db5b0a9
1 file changed
+116 -11
+116 -11
View File
@@ -3,6 +3,9 @@ using System.Collections.Generic;
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
@@ -33,6 +36,14 @@ using UnityEngine.SceneManagement;
/// (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.
///
/// End / join / teardown:
/// - The host presses Esc -> <see cref="EndMatch"/>: Test Map is swapped back to Lobby and everyone is
/// respawned as the neutral Player. Server-gated, so only the host can end the match.
/// - A client that joins MID-MATCH is spectator-held (no avatar) - NGO still streams the running map to
/// it, but it doesn't play; it spawns into the lobby 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
@@ -47,6 +58,9 @@ public class MatchManager : MonoBehaviour
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";
@@ -70,6 +84,7 @@ public class MatchManager : MonoBehaviour
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
private void Awake()
{
@@ -114,6 +129,7 @@ public class MatchManager : MonoBehaviour
private void OnServerStarted()
{
_cleanedUp = false;
HookSceneManager();
// Keep the connecting client's already-loaded scenes (its Main) and layer ours on top.
@@ -131,14 +147,44 @@ public class MatchManager : MonoBehaviour
StartCoroutine(LoadLobbyRoutine());
}
private void OnClientStarted() => HookSceneManager();
private void OnClientStarted()
{
_cleanedUp = false;
HookSceneManager();
}
// 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;
Phase = MatchPhase.Lobby;
if (_net) _net.OnClientConnectedCallback -= OnClientConnected;
if (_net)
{
_net.OnClientConnectedCallback -= OnClientConnected;
if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent;
}
ReturnToMainOnly();
}
// Host-only: Esc ends the running match and sends everyone back to the lobby. Read locally here
// (Input System reads the device directly, independent of focus/other readers); guarded to the server
// so pure clients pressing Esc do nothing.
private void Update()
{
if (_net == null || !_net.IsServer || Phase != MatchPhase.InMatch) return;
#if ENABLE_INPUT_SYSTEM
if (Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame) EndMatch();
#else
if (Input.GetKeyDown(KeyCode.Escape)) EndMatch();
#endif
}
private void HookSceneManager()
@@ -174,11 +220,15 @@ public class MatchManager : MonoBehaviour
private void OnClientConnected(ulong clientId)
{
if (_net == null || !_net.IsServer) return;
// During the Lobby->Test Map swap the active scene briefly falls back to Main (no floor); defer
// and let RespawnAll spawn this joiner once the map is loaded.
// 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;
Vector3 at = Phase == MatchPhase.InMatch ? matchSpawnCenter : lobbySpawnCenter;
SpawnPlayerFor(clientId, at);
// Spectator-hold: a client that joins MID-MATCH gets NO avatar - they wait. NGO still streams the
// running map to their client (unavoidable), but they don't play in it. When the host ends the
// match, EndMatchSequence's respawn loop covers every connected client, so they spawn into the
// lobby with everyone else then.
if (Phase == MatchPhase.InMatch) return;
SpawnPlayerFor(clientId, lobbySpawnCenter);
}
// Spawns the neutral Player avatar for a client into the currently active gameplay scene. No-op if
@@ -233,12 +283,44 @@ public class MatchManager : MonoBehaviour
SceneEventType.LoadEventCompleted, matchSceneName);
// 3. Respawn every player as its rolled role's prefab variant.
RespawnAll(hunterId);
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)
@@ -262,21 +344,44 @@ public class MatchManager : MonoBehaviour
_net.SceneManager.OnSceneEvent -= OnEvt;
}
private void RespawnAll(ulong hunterId)
// 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 = (id == hunterId) ? hunterPrefab : hiderPrefab;
GameObject prefab = prefabFor(id);
if (!prefab) continue;
// Despawn the lobby avatar first so the client has no stale player object when the variant
// 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, matchSpawnCenter, Quaternion.identity);
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()
{
Scene main = SceneManager.GetSceneByName(mainSceneName);
if (main.IsValid() && main.isLoaded) SceneManager.SetActiveScene(main);
UnloadLocalIfLoaded(lobbySceneName);
UnloadLocalIfLoaded(matchSceneName);
}
private void UnloadLocalIfLoaded(string sceneName)
{
Scene s = SceneManager.GetSceneByName(sceneName);
if (s.IsValid() && s.isLoaded && s != SceneManager.GetActiveScene())
SceneManager.UnloadSceneAsync(s);
}
}