Add manual radar ping, blip refresh/fade, and resolution statue barrier

- Manual hider ping on "E": server-authoritative RequestManualPing gated to
  Seek + living hider; shared EmitRadarPings path so interval and manual pings
  produce identical blips. Ping key read client-side in GameModeManager, role-
  gated and excluded in FreeCam (E is fly-up there).
- Radar refreshes an existing blip for the same transform target instead of
  duplicating it (lifetime resets to the configured value) via RadarTarget.Refresh.
- Blips fade in/out independently of the disc fade: per-blip MoveTowards factor
  multiplied on top of the radar fade, refresh-safe, with separate in/out speeds.
- Resolution countdown waits for conversion statues to load: generalized the
  match-start loading barrier (RunReadinessBarrier over _barrierTargets, hold
  phase carried in MsgBeginLoad) and run it a second time over statue IDs before
  starting the real resolveDuration. Only holds for Conversion with real statues.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-06 13:59:28 +02:00
1 parent 8f31c07bef
commit bfcd84afe4
4 files changed
+199 -64

No files matched your search

+125 -51
View File
@@ -251,6 +251,10 @@ public class MatchManager : MonoBehaviour
private const string MsgReady = "Match_Ready";
// Client -> server (authority only): DEBUG request to skip the rest of the current sub-phase.
private const string MsgDebugSkip = "Match_DebugSkip";
// Client -> server: a still-un-found hider manually requests a radar ping on itself (E key), so it blips
// on every hunter's radar exactly like an interval sweep. The server gates the Seek phase and that the
// sender is a live hider before emitting; the sender id is the trusted routing (no payload).
private const string MsgManualPing = "Match_ManualPing";
// 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
@@ -270,18 +274,20 @@ 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>();
// Server-only readiness barrier. _barrierTargets = the objects every client must have spawned & paint-applied
// this round (the freshly respawned avatars pre-match, or the conversion statues at Resolution); _expectedReady
// = the clients present when the barrier opened (a mid-load joiner isn't required, so it can't stall it);
// _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> _barrierTargets = 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.
// Client-side barrier state: the target ids to wait for, the holding phase to poll during, the generation to
// echo back, and the running poll.
private readonly List<ulong> _loadTargetIds = new List<ulong>();
private MatchSubPhase _loadActivePhaseClient;
private int _loadGenerationClient;
private Coroutine _clientLoadPoll;
@@ -345,6 +351,7 @@ public class MatchManager : MonoBehaviour
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRadarPing);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgManualPing);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgIdentity);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgBeginLoad);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReady);
@@ -373,6 +380,7 @@ public class MatchManager : MonoBehaviour
{
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRequestEndMatch, OnRequestEndMatchMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReportHit, OnReportHitMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgManualPing, OnManualPingMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgIdentity, OnIdentityMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReady, OnReadyMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgDebugSkip, OnDebugSkipMessage);
@@ -434,7 +442,7 @@ public class MatchManager : MonoBehaviour
// Drop any in-flight loading-barrier state (StopAllCoroutines above already killed the polls).
_expectedReady.Clear();
_readyClients.Clear();
_matchAvatarIds.Clear();
_barrierTargets.Clear();
_loadTargetIds.Clear();
_clientLoadPoll = null;
// Teardown unloads the gameplay scenes (destroys the statue objects); just drop our references.
@@ -634,8 +642,8 @@ public class MatchManager : MonoBehaviour
// Freeze everyone in Loading BEFORE unloading the lobby: the lobby floor vanishes a moment before the
// map's is loaded, so the still-bound lobby avatars would otherwise drop into the void during the swap.
// Loading gates movement, gravity AND the local own-body mesh on every client (see GameModeManager); the
// readiness barrier below (BeginLoadingBarrierServer re-asserts Loading) holds that hold until every
// client has the map + all match avatars ready, then Hide releases it.
// readiness barrier below (RunLoadingBarrier re-asserts Loading) holds that hold until every client has
// the map + all match avatars ready, then Hide releases it.
SetPhaseServer(MatchSubPhase.Loading, 0f);
yield return SwapScenes(lobbySceneName, matchSceneName);
@@ -670,10 +678,19 @@ public class MatchManager : MonoBehaviour
SetPhaseServer(MatchSubPhase.Seek, seekDuration);
yield return RunSeekPhase(seekDuration);
SetPhaseServer(MatchSubPhase.Resolution, resolveDuration);
// Conversion mode: materialize a frozen statue of every converted hider at its capture spot, now that
// Resolution (which reveals everyone) has begun. No-op with no records / no statue prefab / in Classic.
// Conversion mode: materialize a frozen statue of every converted hider at its capture spot. The last
// hider is often still converting (its statue's painting hasn't replicated to every client yet), so
// hold the Resolution countdown - frozen at 0:00, exactly like the pre-match loading barrier - until
// every client has each statue spawned & painted. Then release the timer. No statue prefab / no
// conversions / Classic -> no hold, the countdown starts immediately (unchanged behaviour).
bool holdForStatues = statuePrefab && _conversionRecords.Count > 0;
SetPhaseServer(MatchSubPhase.Resolution, holdForStatues ? 0f : resolveDuration);
SpawnConversionStatues();
if (holdForStatues)
{
yield return RunStatueBarrier();
SetPhaseServer(MatchSubPhase.Resolution, resolveDuration); // statues loaded -> start the countdown
}
yield return WaitPhase(resolveDuration, null);
_phaseRoutine = null;
@@ -760,29 +777,61 @@ public class MatchManager : MonoBehaviour
DebugSkipServer();
}
// --- 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.
// --- Readiness barriers ----------------------------------------------------------------------------
// Server-only. Pre-match: hold everyone in Loading (frozen, no countdown) until every client has spawned &
// paint-applied the freshly respawned avatars, or matchLoadTimeout elapses. Loading was already asserted
// before the scene swap; re-assert it here after the respawn, then run the generic barrier over the avatars.
private IEnumerator RunLoadingBarrier()
{
_skipPhase = false; // drop any debug-skip left over from the previous phase/match; only this phase may set it
BeginLoadingBarrierServer();
_barrierTargets.Clear();
foreach (ulong id in _net.ConnectedClientsIds)
if (_net.ConnectedClients.TryGetValue(id, out var c) && c.PlayerObject)
_barrierTargets.Add(c.PlayerObject.NetworkObjectId);
SetPhaseServer(MatchSubPhase.Loading, 0f);
yield return RunReadinessBarrier(MatchSubPhase.Loading);
}
// Server-only (Conversion). Hold the (already-entered) Resolution phase until every client has each conversion
// statue spawned & painted, so a just-caught hider's still-loading statue doesn't burn resolve time. The
// statues carry the same PlayerPaintSync the avatar barrier keys off, so "painted" == the statue's body reveal.
private IEnumerator RunStatueBarrier()
{
_barrierTargets.Clear();
for (int i = 0; i < _statues.Count; i++)
if (_statues[i]) _barrierTargets.Add(_statues[i].NetworkObjectId);
yield return RunReadinessBarrier(MatchSubPhase.Resolution);
}
// Server-only. Generic readiness barrier over the already-populated _barrierTargets: snapshot the expected
// clients, bump the round generation, broadcast the targets + holding phase, then spin until every still-
// connected expected client has reported ready - or matchLoadTimeout elapses. `activePhase` is the frozen
// holding phase (Loading pre-match, Resolution for statues) that clients poll during.
private IEnumerator RunReadinessBarrier(MatchSubPhase activePhase)
{
_skipPhase = false; // drop any debug-skip left over from the previous phase/match; only this wait may set it
_expectedReady.Clear();
_readyClients.Clear();
foreach (ulong id in _net.ConnectedClientsIds)
_expectedReady.Add(id);
_loadGeneration++;
BroadcastBeginLoad(activePhase);
float t = matchLoadTimeout;
while (!AllClientsReady())
{
if (ConsumeSkip()) break; // DEBUG: force the match to start now, without waiting for every client
if (ConsumeSkip()) break; // DEBUG: proceed now, without waiting for every client
// 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 (LocalClientReady(_barrierTargets)) _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);
Debug.LogWarning("[MatchManager] Readiness barrier timed out - proceeding anyway.", this);
break;
}
}
@@ -790,26 +839,6 @@ public class MatchManager : MonoBehaviour
}
}
// 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()
@@ -836,8 +865,8 @@ public class MatchManager : MonoBehaviour
return true;
}
// Server -> every other client: the target avatar ids + generation for this barrier round.
private void BroadcastBeginLoad()
// Server -> every other client: the target ids + holding phase + generation for this barrier round.
private void BroadcastBeginLoad(MatchSubPhase activePhase)
{
var cmm = _net != null ? _net.CustomMessagingManager : null;
if (cmm == null) return;
@@ -847,13 +876,14 @@ public class MatchManager : MonoBehaviour
if (id != _net.LocalClientId) _loadRecipients.Add(id);
if (_loadRecipients.Count == 0) return;
int size = sizeof(int) + sizeof(int) + _matchAvatarIds.Count * sizeof(ulong);
int size = sizeof(int) + sizeof(byte) + sizeof(int) + _barrierTargets.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]);
writer.WriteValueSafe((byte)activePhase);
writer.WriteValueSafe(_barrierTargets.Count);
for (int i = 0; i < _barrierTargets.Count; i++)
writer.WriteValueSafe(_barrierTargets[i]);
cmm.SendNamedMessage(MsgBeginLoad, _loadRecipients, writer);
}
}
@@ -863,6 +893,7 @@ public class MatchManager : MonoBehaviour
private void OnBeginLoadMessage(ulong senderClientId, FastBufferReader reader)
{
reader.ReadValueSafe(out int generation);
reader.ReadValueSafe(out byte activePhase);
reader.ReadValueSafe(out int count);
_loadTargetIds.Clear();
for (int i = 0; i < count; i++)
@@ -871,16 +902,17 @@ public class MatchManager : MonoBehaviour
_loadTargetIds.Add(id);
}
_loadGenerationClient = generation;
_loadActivePhaseClient = (MatchSubPhase)activePhase;
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).
// Client-only. Polls until this machine has spawned & paint-applied every target, then reports ready. Bails
// without a reply if the phase leaves the holding phase first (match ended, or the server already moved on).
private IEnumerator ClientLoadingPoll()
{
while (_subPhase == MatchSubPhase.Loading)
while (_subPhase == _loadActivePhaseClient)
{
if (LocalClientReady(_loadTargetIds))
{
@@ -992,6 +1024,15 @@ public class MatchManager : MonoBehaviour
foreach (ulong hiderClientId in _pendingHiders)
if (_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject)
_radarPingIds.Add(c.PlayerObject.NetworkObjectId);
EmitRadarPings();
}
// Server-only. Sends whatever hider NetworkObjectIds are staged in _radarPingIds to every HUNTER as radar
// blips (a host-hunter applies locally, no loopback - mirroring SetPhaseServer). Shared by the interval
// sweep and the manual single-hider ping so both drop the identical blip (same lifetime, same path).
// No-op if nothing is staged or there is no hunter to receive it.
private void EmitRadarPings()
{
if (_radarPingIds.Count == 0) return;
// Host-as-hunter: spawn on its own radar directly (it is excluded from the recipient list below).
@@ -1015,6 +1056,39 @@ public class MatchManager : MonoBehaviour
}
}
/// <summary>Client-side: the local hider manually requests a radar ping on itself (the E key), making it
/// blip on every hunter's radar exactly like an interval sweep. Routes to the server, which gates the Seek
/// phase and that this client is still a live (un-found) hider. On a host this IS the server, so it applies
/// directly. No-op outside the Seek phase.</summary>
public void RequestManualPing()
{
if (_net == null || _subPhase != MatchSubPhase.Seek) return;
if (_net.IsServer) { ServerManualPing(_net.LocalClientId); return; }
SendEmptyNamedMessage(MsgManualPing, NetworkManager.ServerClientId);
}
// Server handler: a client asked for a manual self-ping. The sender id is the trusted routing;
// ServerManualPing validates it is a live hider during Seek.
private void OnManualPingMessage(ulong senderClientId, FastBufferReader reader)
{
if (_net == null || !_net.IsServer) return;
ServerManualPing(senderClientId);
}
// Server-only. Emits a single-hider radar ping for hiderClientId to every hunter - if it is a still-un-found
// hider during Seek. Same blip (and lifetime) as an interval sweep, just one target and on demand.
private void ServerManualPing(ulong hiderClientId)
{
if (_subPhase != MatchSubPhase.Seek) return;
if (!_pendingHiders.Contains(hiderClientId)) return; // not a live (un-found) hider
if (!_net.ConnectedClients.TryGetValue(hiderClientId, out var c) || !c.PlayerObject) return;
_radarPingIds.Clear();
_radarPingIds.Add(c.PlayerObject.NetworkObjectId);
EmitRadarPings();
}
// Host/server local path: resolve the swept hider NetworkObjectIds (in _radarPingIds) to their live
// transforms and drop a blip on the local radar. Mirrors OnRadarPingMessage for the host's own hunter view.
private void SpawnRadarBlipsLocal()