Files
chameleon/Assets/Scripts/MatchManager.cs
T
adminandClaude Opus 4.8 bd70f3b8b9 Add lobby/match-start matchmaking flow with additive scene management
Replace the connection-approval role selection with a server-authoritative
match flow built on NGO additive scene management:

- Scenes: Main is the persistent foundation (NetworkManager, GameModeManager,
  cameras, HUD); Lobby and Test Map load/unload additively. Add Lobby + Test Map
  to the build settings; remove the obsolete SampleScene.
- MatchManager (plain MonoBehaviour singleton): loads Lobby on server start,
  StartMatchTrigger lets the host start the match, rolls one random Hunter (rest
  Hider), swaps Lobby -> Test Map (strictly serialized per NGO), respawns each
  player as its role variant. Forces additive client sync mode so clients keep
  Main instead of single-loading it (fixes "Destination scene is not valid").
- Server-driven spawning: null out NetworkConfig.PlayerPrefab at runtime to
  disable auto-spawn, then spawn players after the lobby floor exists (fixes
  falling through the floor) with double-spawn and transition guards.
- StartMatchTrigger: server-gated OnTriggerEnter, only the host's own player
  starts the match.
- NetworkBootstrap: drop role selection / connection approval; everyone spawns
  as the neutral Player prefab. Host/Client/Server/Shutdown buttons stay.
- NetworkPlayerSetup.ScopedModes flag (baked on Hunter/Hider prefabs): lobby
  avatar gets the full mode set, match variants get the role-scoped set.
- GameModeManager.BindLocalPlayer reads ScopedModes and rebinds the painter
  unconditionally (fixes stale painter binding on respawn -> brush painting in
  every mode).
- MeshPainter.PaintingEnabled defaults to false (unbound/pre-spawn/remote never
  paints); PaintHud visibility now also gates on a bound painter (fixes the HUD
  showing at startup before any avatar exists).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:44:18 +02:00

283 lines
13 KiB
C#

using System.Collections;
using System.Collections.Generic;
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"/>: 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.
///
/// 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)")]
[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;
[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 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;
if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent;
}
if (Instance == this) Instance = null;
}
private void OnServerStarted()
{
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;
// Spawn late joiners on connect (server-side callback). Re-hook defensively in case of re-host.
_net.OnClientConnectedCallback -= OnClientConnected;
_net.OnClientConnectedCallback += OnClientConnected;
StartCoroutine(LoadLobbyRoutine());
}
private void OnClientStarted() => HookSceneManager();
private void OnNetStopped(bool _)
{
_hooked = false;
_sceneReady = false;
Phase = MatchPhase.Lobby;
if (_net) _net.OnClientConnectedCallback -= OnClientConnected;
}
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)) SpawnPlayerFor(id, 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 SpawnPlayerFor prevents a double spawn).
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.
if (!_sceneReady || Phase == MatchPhase.Transitioning) return;
Vector3 at = Phase == MatchPhase.InMatch ? matchSpawnCenter : lobbySpawnCenter;
SpawnPlayerFor(clientId, at);
}
// Spawns the neutral Player avatar for a client 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 SpawnPlayerFor(ulong clientId, Vector3 at)
{
if (!_playerPrefab)
{
Debug.LogError("[MatchManager] Player prefab missing (was NetworkConfig.PlayerPrefab assigned?) - cannot spawn.", this);
return;
}
if (!_net.ConnectedClients.TryGetValue(clientId, out var client)) return;
if (client.PlayerObject) return;
var go = Instantiate(_playerPrefab, 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[Random.Range(0, 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(hunterId);
Phase = MatchPhase.InMatch;
Debug.Log("[MatchManager] Match started.", 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;
}
private void RespawnAll(ulong hunterId)
{
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;
// Despawn the lobby avatar first so the client has no stale player object when the variant
// 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);
go.GetComponent<NetworkObject>().SpawnAsPlayerObject(id);
}
}
}