Add match-start readiness barrier (Loading phase)
Gate the Lobby-to-world transition on a new MatchSubPhase.Loading before Hide: movement stays frozen and the Hide countdown does not start until every connected client has spawned all match avatars and applied their paintings. - MatchManager: Loading phase; matchLoadTimeout tunable; Match_BeginLoad broadcasts the respawned avatar NetworkObjectIds (+ generation tag), Match_Ready collects per-client ACKs (host self-checks locally); barrier coroutine in RunMatchPhases before Hide; disconnect/teardown cleanup. - GameModeManager: freeze local player (movement/paint/tab) during Loading, restored via ActiveMode.Enter() on transition to Hide (no camera jump). - NetworkPlayerSetup: treat Loading like Hide in ComputeVisible so a fast-loading client cannot reveal a hider early. - MatchHud: "Loading" phase label. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec395ab766
commit
fba4780c0e
4 files changed
+234
-3
No files matched your search
@@ -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
|
||||
|
||||
/// <summary>The currently active mode (null before startup).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private bool MovementGated()
|
||||
{
|
||||
var match = MatchManager.Instance;
|
||||
return match && match.MatchActive &&
|
||||
match.LocalSubPhase == MatchManager.MatchSubPhase.Loading;
|
||||
}
|
||||
|
||||
/// <summary>First non-Paint mode index at or after <paramref name="start"/> (wrapping once through the
|
||||
/// cycle); -1 if the set has no non-paint mode (a degenerate paint-only configuration).</summary>
|
||||
private int FirstNonPaintFrom(int start)
|
||||
|
||||
@@ -37,9 +37,11 @@ public class MatchManager : MonoBehaviour
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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.</summary>
|
||||
public enum MatchSubPhase { None, Loading, Hide, Seek, Resolution }
|
||||
|
||||
/// <summary>How the running match behaves. Chosen server-side (<see cref="matchMode"/>) 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<ulong> _radarPingIds = new List<ulong>(); // reused scratch: hider NetworkObjectIds for a sweep
|
||||
private readonly List<ulong> _radarRecipients = new List<ulong>(); // 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<ulong> _matchAvatarIds = new List<ulong>();
|
||||
private readonly List<ulong> _loadRecipients = new List<ulong>();
|
||||
private readonly HashSet<ulong> _expectedReady = new HashSet<ulong>();
|
||||
private readonly HashSet<ulong> _readyClients = new HashSet<ulong>();
|
||||
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<ulong> _loadTargetIds = new List<ulong>();
|
||||
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<ulong> 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<PlayerPaintSync>(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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user