diff --git a/Assets/Scripts/MatchManager.cs b/Assets/Scripts/MatchManager.cs index 4865028..12f41bd 100644 --- a/Assets/Scripts/MatchManager.cs +++ b/Assets/Scripts/MatchManager.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Collections.Generic; +using Unity.Collections; using Unity.Netcode; using UnityEngine; using UnityEngine.SceneManagement; @@ -36,9 +37,17 @@ using UnityEngine.InputSystem; /// (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 host presses Esc -> : 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. +/// - The match authority presses Esc -> : 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. @@ -104,6 +113,19 @@ public class MatchManager : MonoBehaviour // 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; } @@ -140,7 +162,13 @@ public class MatchManager : MonoBehaviour _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; } @@ -158,9 +186,19 @@ public class MatchManager : MonoBehaviour 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()); } @@ -168,7 +206,13 @@ public class MatchManager : MonoBehaviour 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 @@ -182,26 +226,50 @@ public class MatchManager : MonoBehaviour 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(); } - // 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. + // 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 || !_net.IsServer || Phase != MatchPhase.InMatch) return; + 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 - if (Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame) EndMatch(); + return Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame; #else - if (Input.GetKeyDown(KeyCode.Escape)) EndMatch(); + return Input.GetKeyDown(KeyCode.Escape); #endif } @@ -238,6 +306,18 @@ public class MatchManager : MonoBehaviour 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; @@ -248,6 +328,27 @@ public class MatchManager : MonoBehaviour 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) @@ -422,4 +523,47 @@ public class MatchManager : MonoBehaviour _carriedPaint = null; return d; } + + /// True if the given client currently holds match-control authority (may start and end the match). + 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 + } } diff --git a/Assets/Scripts/StartMatchTrigger.cs b/Assets/Scripts/StartMatchTrigger.cs index 25b7a38..22e91c3 100644 --- a/Assets/Scripts/StartMatchTrigger.cs +++ b/Assets/Scripts/StartMatchTrigger.cs @@ -2,25 +2,75 @@ using Unity.Netcode; using UnityEngine; /// -/// Sits on the Lobby's "Start Match" trigger volume (a BoxCollider with isTrigger on). When the HOST's -/// own player walks in, it asks the to start the match. Server-gated: pure -/// clients fire nothing (IsServer is false there), and only the host-owned player object counts, so a -/// remote avatar passing through never starts the match. The trigger lives in the additively-loaded -/// Lobby and is destroyed when the lobby unloads, so it cannot start a second match. +/// Sits on the Lobby's "Start Match" trigger volume (a BoxCollider with isTrigger on). When the match +/// AUTHORITY's avatar stands inside it, it asks the to start the match. +/// +/// Detection POLLS a server-side physics overlap every frame instead of relying on OnTriggerEnter. +/// Why: the authority can be a REMOTE client (a dedicated server with no host player hands authority to +/// the first client). On the server that client's avatar is a NetworkTransform-driven replica - its +/// CharacterController is disabled () and it carries no Rigidbody, so it +/// is moved by direct transform writes. Unity only sends OnTriggerEnter when one of the two colliders has +/// a Rigidbody (or is a CharacterController moving via Move()); a transform-moved non-Rigidbody collider +/// against a static trigger fires nothing. That is exactly why a host's own (Move()-driven) avatar tripped +/// the trigger but a remote authority never did. An explicit overlap query finds the avatar's (baked) +/// collider regardless of HOW it is moved, so start works for a host and a dedicated server alike. +/// +/// Server-gated: pure clients query nothing. Only the authority avatar starts the match; a passing +/// non-authority avatar is ignored. Runs only while in the lobby phase and stops once it has started a +/// match. The trigger lives in the additively-loaded Lobby and is destroyed when the lobby unloads, so a +/// re-match (back in the lobby) gets a fresh instance with its guard reset. /// [RequireComponent(typeof(Collider))] [DisallowMultipleComponent] public class StartMatchTrigger : MonoBehaviour { - private void OnTriggerEnter(Collider other) + private Collider _collider; + private readonly Collider[] _overlap = new Collider[16]; + private bool _started; + + private void Awake() => _collider = GetComponent(); + + private void Update() { + if (_started) return; + var nm = NetworkManager.Singleton; - if (!nm || !nm.IsServer) return; // only the server/host starts the match + if (!nm || !nm.IsServer) return; // detection + start are server-side + var match = MatchManager.Instance; + if (!match || match.Phase != MatchManager.MatchPhase.Lobby) return; - var netObj = other.GetComponentInParent(); - if (!netObj || !netObj.IsPlayerObject) return; // must be a player avatar - if (netObj.OwnerClientId != nm.LocalClientId) return; // ...and specifically the host's own + int count = OverlapTrigger(); + for (int i = 0; i < count; i++) + { + var netObj = _overlap[i].GetComponentInParent(); + if (!netObj || !netObj.IsPlayerObject) continue; // must be a player avatar + if (!match.IsMatchAuthority(netObj.OwnerClientId)) continue; // ...the authority's - if (MatchManager.Instance) MatchManager.Instance.StartMatch(); + _started = true; + match.StartMatch(); + return; + } + } + + // Overlaps the trigger's own volume. Uses the oriented box for a BoxCollider (the authored shape); + // falls back to the collider's world AABB for any other collider type. Triggers are ignored so the + // query skips this volume itself and reports only solid avatar colliders (e.g. the baked mesh). + private int OverlapTrigger() + { + if (_collider is BoxCollider box) + { + Vector3 center = transform.TransformPoint(box.center); + Vector3 scale = transform.lossyScale; + Vector3 half = new Vector3( + Mathf.Abs(box.size.x * scale.x), + Mathf.Abs(box.size.y * scale.y), + Mathf.Abs(box.size.z * scale.z)) * 0.5f; + return Physics.OverlapBoxNonAlloc( + center, half, _overlap, transform.rotation, ~0, QueryTriggerInteraction.Ignore); + } + + Bounds b = _collider.bounds; + return Physics.OverlapBoxNonAlloc( + b.center, b.extents, _overlap, Quaternion.identity, ~0, QueryTriggerInteraction.Ignore); } }