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:
1 parent
bd70f3b8b9
commit
be6db5b0a9
1 file changed
+116
-11
+116
-11
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user