diff --git a/Assets/Scripts/GameMode/GameModeManager.cs b/Assets/Scripts/GameMode/GameModeManager.cs index 5143ab3..e225ed3 100644 --- a/Assets/Scripts/GameMode/GameModeManager.cs +++ b/Assets/Scripts/GameMode/GameModeManager.cs @@ -61,6 +61,7 @@ public class GameModeManager : MonoBehaviour private int _activeIndex = -1; private bool _paused; private int _resumeIndex = -1; + private bool _loadFrozen; // true while the pre-match Loading barrier is holding the local player still /// The currently active mode (null before startup). public GameMode ActiveMode => @@ -322,6 +323,23 @@ public class GameModeManager : MonoBehaviour // switch key and Tick so Tab can't bootstrap a mode and no camera rig is driven until a bind. if (_activeIndex < 0) return; + // Pre-match loading barrier: hold the local player frozen (no movement, painting or mode switching) + // until every client has finished loading and the Hide phase begins. Re-asserted each frame so nothing + // sneaks input in; on release the active mode is re-entered to restore its intended control state + // without a camera jump (exactly like SetPaused's resume - the mode was never Exited). + if (MovementGated()) + { + if (player) player.ControlEnabled = false; + if (painter) painter.PaintingEnabled = false; + _loadFrozen = true; + return; + } + if (_loadFrozen) + { + _loadFrozen = false; + ActiveMode?.Enter(); + } + // Resolution (the reveal window) is watch-only: force out of Paint and keep the switch key from // re-entering. No restore needed - the end-of-match respawn rebuilds the cycle at index 0. bool suppressPaint = PaintSuppressed(); @@ -355,6 +373,15 @@ public class GameModeManager : MonoBehaviour match.LocalSubPhase == MatchManager.MatchSubPhase.Resolution; } + /// True while the local match is in its pre-Hide LOADING barrier, during which the local player is + /// frozen (no movement, painting or mode switching) until every client has finished loading. + private bool MovementGated() + { + var match = MatchManager.Instance; + return match && match.MatchActive && + match.LocalSubPhase == MatchManager.MatchSubPhase.Loading; + } + /// First non-Paint mode index at or after (wrapping once through the /// cycle); -1 if the set has no non-paint mode (a degenerate paint-only configuration). private int FirstNonPaintFrom(int start) diff --git a/Assets/Scripts/Network/MatchManager.cs b/Assets/Scripts/Network/MatchManager.cs index bfeef09..e7e3eb4 100644 --- a/Assets/Scripts/Network/MatchManager.cs +++ b/Assets/Scripts/Network/MatchManager.cs @@ -37,9 +37,11 @@ public class MatchManager : MonoBehaviour /// The in-match sub-phase, replicated to every client over a named message (not via NGO /// NetworkVariables - this is a MonoBehaviour by design, see class summary). None = no match running. - /// Hide -> Seek -> Resolution, then back to the lobby. Drives per-client visibility, hunter shooting - /// and the match HUD countdown. - public enum MatchSubPhase { None, Hide, Seek, Resolution } + /// Loading -> Hide -> Seek -> Resolution, then back to the lobby. Drives per-client visibility, hunter + /// shooting and the match HUD countdown. Loading is the pre-match barrier: everyone is frozen (no movement, + /// no countdown) until every client has spawned all match avatars AND applied their paintings, so nobody + /// moves or sees a half-loaded world before Hide begins. + public enum MatchSubPhase { None, Loading, Hide, Seek, Resolution } /// How the running match behaves. Chosen server-side () and replicated /// to every client alongside the sub-phase (so visibility is right from the first Hide frame and late @@ -106,6 +108,13 @@ public class MatchManager : MonoBehaviour [Min(0f)] [SerializeField] private float resolveDuration = 8f; + [Tooltip("Max seconds the pre-match LOADING barrier waits for EVERY client to finish loading (all match " + + "avatars spawned AND their paintings applied on every machine) before the Hide phase starts and " + + "players may move. Until then everyone is frozen and the countdown is held. A safety cap only: if " + + "a client hangs, the match starts anyway once this elapses. 0 = wait indefinitely (not recommended).")] + [Min(0f)] + [SerializeField] private float matchLoadTimeout = 20f; + [Header("Hunter shooting")] [Tooltip("Max distance (metres) of the hunter's camera-centre shoot raycast.")] [Min(0f)] @@ -228,6 +237,12 @@ public class MatchManager : MonoBehaviour // Server -> HUNTER clients only, on each Seek radar sweep: the still-un-found hiders' NetworkObjectIds plus // the blip lifetime. Each hunter resolves the ids locally and drops a Radar blip pointing at the hider. private const string MsgRadarPing = "Match_RadarPing"; + // Pre-match LOADING barrier. Server -> all clients, once the match avatars are respawned: the target avatar + // NetworkObjectIds + a generation tag; each client waits until it has spawned & paint-applied all of them. + // Client -> server: the "I finished loading" reply (echoes the generation so a stale ack from a previous + // barrier round is dropped). The server holds the Hide phase until every expected client has replied. + private const string MsgBeginLoad = "Match_BeginLoad"; + private const string MsgReady = "Match_Ready"; // Server-only sub-phase machine. _pendingHiders holds the hiders not yet found; seek ends the instant it // empties. _phaseRoutine is the running Hide->Seek->Resolution coroutine (stopped if the match is ended @@ -244,6 +259,21 @@ public class MatchManager : MonoBehaviour private readonly List _radarPingIds = new List(); // reused scratch: hider NetworkObjectIds for a sweep private readonly List _radarRecipients = new List(); // reused scratch: hunter client recipients for a sweep + // Server-only pre-match loading barrier. _matchAvatarIds = the freshly respawned avatars every client must + // have spawned & paint-applied; _expectedReady = the clients present when the barrier opened (a mid-load + // joiner isn't required, so it can't stall the start); _readyClients = those that have reported ready. + // _loadRecipients is reused scratch for the broadcast. _loadGeneration tags the round so a late ack from a + // previous barrier is ignored. + private readonly List _matchAvatarIds = new List(); + private readonly List _loadRecipients = new List(); + private readonly HashSet _expectedReady = new HashSet(); + private readonly HashSet _readyClients = new HashSet(); + private int _loadGeneration; + // Client-side barrier state: the target avatar ids to wait for, the generation to echo back, and the running poll. + private readonly List _loadTargetIds = new List(); + private int _loadGenerationClient; + private Coroutine _clientLoadPoll; + private void Awake() { if (Instance && Instance != this) { Destroy(this); return; } @@ -298,6 +328,8 @@ public class MatchManager : MonoBehaviour _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRadarPing); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgIdentity); + _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgBeginLoad); + _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReady); } } @@ -323,6 +355,7 @@ public class MatchManager : MonoBehaviour _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRequestEndMatch, OnRequestEndMatchMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReportHit, OnReportHitMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgIdentity, OnIdentityMessage); + _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReady, OnReadyMessage); } _subPhase = MatchSubPhase.None; @@ -350,6 +383,7 @@ public class MatchManager : MonoBehaviour _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgAuthorityGranted, OnAuthorityGrantedMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgPhase, OnPhaseMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRadarPing, OnRadarPingMessage); + _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgBeginLoad, OnBeginLoadMessage); } // Report our persistent id to the server the moment our own connection completes (host records its own @@ -376,6 +410,12 @@ public class MatchManager : MonoBehaviour ApplyPhaseLocal(MatchSubPhase.None, 0f, _localMode); _pendingHiders.Clear(); _persistentIds.Clear(); + // Drop any in-flight loading-barrier state (StopAllCoroutines above already killed the polls). + _expectedReady.Clear(); + _readyClients.Clear(); + _matchAvatarIds.Clear(); + _loadTargetIds.Clear(); + _clientLoadPoll = null; if (_net) UnhookSessionCallbacks(); ReturnToMainOnly(); @@ -447,6 +487,10 @@ public class MatchManager : MonoBehaviour // A hider that leaves mid-match can never be found - drop it so "all hiders found" can still trigger. _pendingHiders.Remove(clientId); _revealingHiders.Remove(clientId); // and out of the in-flight flash set (its resolve coroutine no-ops) + // Drop it from the loading barrier too: an expected client that leaves mid-load must stop gating the + // start (AllClientsReady only requires still-connected expected clients). + _expectedReady.Remove(clientId); + _readyClients.Remove(clientId); // Free the ephemeral clientId -> identity mapping (the clientId will be reused by a later joiner). _persistentIds.Remove(clientId); @@ -581,6 +625,12 @@ public class MatchManager : MonoBehaviour // Resolution are fixed-length; Seek bails the instant the last hider is found (or its timer runs out). private IEnumerator RunMatchPhases() { + // Pre-match readiness barrier: hold everyone in the Loading sub-phase (frozen, no countdown) until every + // client has spawned all match avatars AND applied their paintings, so nobody moves or sees a half-loaded + // world before Hide. Bounded by matchLoadTimeout so a hung client can't stall the match forever. Lives in + // _phaseRoutine (yielded inline), so an early Back-to-Lobby that stops that routine stops the barrier too. + yield return RunLoadingBarrier(); + SetPhaseServer(MatchSubPhase.Hide, hideDuration); yield return WaitPhase(hideDuration, null); @@ -632,6 +682,156 @@ public class MatchManager : MonoBehaviour } } + // --- Pre-match loading barrier --------------------------------------------------------------------- + // Server-only. Opens the barrier (snapshot the just-respawned avatars + the clients that must load them, + // switch everyone to Loading, tell each client which avatars to wait for), then spins until every still- + // connected expected client has reported ready - or matchLoadTimeout elapses. + private IEnumerator RunLoadingBarrier() + { + BeginLoadingBarrierServer(); + + float t = matchLoadTimeout; + while (!AllClientsReady()) + { + // The host is a client too and never messages itself: fold its own local readiness in directly. + if (LocalClientReady(_matchAvatarIds)) _readyClients.Add(_net.LocalClientId); + + if (matchLoadTimeout > 0f) + { + t -= Time.deltaTime; + if (t <= 0f) + { + Debug.LogWarning("[MatchManager] Load barrier timed out - starting the match anyway.", this); + break; + } + } + yield return null; + } + } + + // Server-only. Captures the barrier target (the avatars RespawnAll just created + the clients that must load + // them), bumps the generation, switches everyone to Loading (duration 0 = no countdown) and broadcasts the + // target avatar ids so each client can start waiting. + private void BeginLoadingBarrierServer() + { + _matchAvatarIds.Clear(); + _expectedReady.Clear(); + _readyClients.Clear(); + foreach (ulong id in _net.ConnectedClientsIds) + { + _expectedReady.Add(id); + if (_net.ConnectedClients.TryGetValue(id, out var c) && c.PlayerObject) + _matchAvatarIds.Add(c.PlayerObject.NetworkObjectId); + } + _loadGeneration++; + + SetPhaseServer(MatchSubPhase.Loading, 0f); + BroadcastBeginLoad(); + } + + // True once every expected client that is STILL connected has reported ready. A client that disconnected + // mid-load drops out of the requirement; a mid-load joiner was never in _expectedReady, so it can't stall it. + private bool AllClientsReady() + { + foreach (ulong id in _expectedReady) + if (_net.ConnectedClients.ContainsKey(id) && !_readyClients.Contains(id)) + return false; + return true; + } + + // Any machine: true once all target avatars are spawned locally AND each one's painting has been applied + // (PlayerPaintSync.PaintRevealReady). The local owner's own avatar is always ready; remotes gate on the + // applied paint cutoff - exactly the condition the per-client reveal uses to un-hide the body. + private bool LocalClientReady(List ids) + { + var sm = _net != null ? _net.SpawnManager : null; + if (sm == null) return false; + for (int i = 0; i < ids.Count; i++) + { + if (!sm.SpawnedObjects.TryGetValue(ids[i], out var no) || !no) return false; + var sync = no.GetComponentInChildren(true); + if (sync && !sync.PaintRevealReady) return false; + } + return true; + } + + // Server -> every other client: the target avatar ids + generation for this barrier round. + private void BroadcastBeginLoad() + { + var cmm = _net != null ? _net.CustomMessagingManager : null; + if (cmm == null) return; + + _loadRecipients.Clear(); + foreach (ulong id in _net.ConnectedClientsIds) + if (id != _net.LocalClientId) _loadRecipients.Add(id); + if (_loadRecipients.Count == 0) return; + + int size = sizeof(int) + sizeof(int) + _matchAvatarIds.Count * sizeof(ulong); + using (var writer = new FastBufferWriter(size, Allocator.Temp)) + { + writer.WriteValueSafe(_loadGeneration); + writer.WriteValueSafe(_matchAvatarIds.Count); + for (int i = 0; i < _matchAvatarIds.Count; i++) + writer.WriteValueSafe(_matchAvatarIds[i]); + cmm.SendNamedMessage(MsgBeginLoad, _loadRecipients, writer); + } + } + + // Client handler: the server opened the loading barrier. Store the target avatars + generation and start + // polling; when everything is spawned & painted, reply Ready. Re-started if a fresh barrier message arrives. + private void OnBeginLoadMessage(ulong senderClientId, FastBufferReader reader) + { + reader.ReadValueSafe(out int generation); + reader.ReadValueSafe(out int count); + _loadTargetIds.Clear(); + for (int i = 0; i < count; i++) + { + reader.ReadValueSafe(out ulong id); + _loadTargetIds.Add(id); + } + _loadGenerationClient = generation; + + if (_clientLoadPoll != null) StopCoroutine(_clientLoadPoll); + _clientLoadPoll = StartCoroutine(ClientLoadingPoll()); + } + + // Client-only. Polls until this machine has spawned & paint-applied every target avatar, then reports ready. + // Bails without a reply if the phase leaves Loading first (match ended, or the server already moved to Hide). + private IEnumerator ClientLoadingPoll() + { + while (_subPhase == MatchSubPhase.Loading) + { + if (LocalClientReady(_loadTargetIds)) + { + SendReadyToServer(_loadGenerationClient); + break; + } + yield return null; + } + _clientLoadPoll = null; + } + + // Client -> server: this machine finished loading (echoes the generation so a stale reply is ignored). + private void SendReadyToServer(int generation) + { + var cmm = _net != null ? _net.CustomMessagingManager : null; + if (cmm == null) return; + using (var writer = new FastBufferWriter(sizeof(int), Allocator.Temp)) + { + writer.WriteValueSafe(generation); + cmm.SendNamedMessage(MsgReady, NetworkManager.ServerClientId, writer); + } + } + + // Server handler: a client reported it finished loading. Counted only if it is for the current barrier round. + private void OnReadyMessage(ulong senderClientId, FastBufferReader reader) + { + reader.ReadValueSafe(out int generation); + if (_net == null || !_net.IsServer) return; + if (generation != _loadGeneration) return; // stale ack from a previous barrier round + _readyClients.Add(senderClientId); + } + // Server-only. Applies the sub-phase to the server's own mirror and broadcasts it to every other client. private void SetPhaseServer(MatchSubPhase phase, float duration) { diff --git a/Assets/Scripts/Network/NetworkPlayerSetup.cs b/Assets/Scripts/Network/NetworkPlayerSetup.cs index 8459b43..fa4d8db 100644 --- a/Assets/Scripts/Network/NetworkPlayerSetup.cs +++ b/Assets/Scripts/Network/NetworkPlayerSetup.cs @@ -326,6 +326,9 @@ public class NetworkPlayerSetup : NetworkBehaviour if (role != PlayerRole.Hider) return true; // hunters are always visible var phase = match.LocalSubPhase; + // The pre-match loading window conceals hiders exactly like Hide, so a fast-loading client can't reveal + // a hider (once its paint applies) before the match proper even begins. + if (phase == MatchManager.MatchSubPhase.Loading) phase = MatchManager.MatchSubPhase.Hide; if (phase == MatchManager.MatchSubPhase.Resolution) return true; // all revealed at the end if (match.LocalMatchMode == MatchManager.MatchMode.Conversion) diff --git a/Assets/Scripts/Network/UI/MatchHud.cs b/Assets/Scripts/Network/UI/MatchHud.cs index c039d71..9fd068f 100644 --- a/Assets/Scripts/Network/UI/MatchHud.cs +++ b/Assets/Scripts/Network/UI/MatchHud.cs @@ -104,6 +104,7 @@ public class MatchHud : MonoBehaviour { switch (phase) { + case MatchManager.MatchSubPhase.Loading: return "Loading"; case MatchManager.MatchSubPhase.Hide: return "Hide"; case MatchManager.MatchSubPhase.Seek: return "Seek"; case MatchManager.MatchSubPhase.Resolution: return "Resolution";