From bfcd84afe4d4bb286e542a40072fa8d8f8af5e1a Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Mon, 6 Jul 2026 13:59:28 +0200 Subject: [PATCH] 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 --- Assets/Scripts/Components/Radar.cs | 12 ++ Assets/Scripts/Components/RadarTarget.cs | 50 ++++-- Assets/Scripts/GameMode/GameModeManager.cs | 25 +++ Assets/Scripts/Network/MatchManager.cs | 176 +++++++++++++++------ 4 files changed, 199 insertions(+), 64 deletions(-) diff --git a/Assets/Scripts/Components/Radar.cs b/Assets/Scripts/Components/Radar.cs index 8c3bfd3..424081f 100644 --- a/Assets/Scripts/Components/Radar.cs +++ b/Assets/Scripts/Components/Radar.cs @@ -117,6 +117,18 @@ public sealed class Radar : MonoBehaviour return null; } + // Already tracking this exact transform? Refresh its countdown instead of stacking a duplicate blip, + // so a fresh ping (interval or manual) just resets the existing target's lifetime. + for (int i = 0; i < _blips.Count; i++) + { + var existing = _blips[i]; + if (existing && existing.Target == target) + { + existing.Refresh(lifetime); + return existing; + } + } + Transform clone = Instantiate(targetTemplate, targetTemplate.parent); clone.name = "Target"; clone.localPosition = targetTemplate.localPosition; diff --git a/Assets/Scripts/Components/RadarTarget.cs b/Assets/Scripts/Components/RadarTarget.cs index 3a4c2a8..0e8585a 100644 --- a/Assets/Scripts/Components/RadarTarget.cs +++ b/Assets/Scripts/Components/RadarTarget.cs @@ -7,6 +7,11 @@ using UnityEngine; /// once its lifetime elapses - or earlier if its target is destroyed. The point's base colour and size encode /// the two things the flat radar disc can't show via position alone: tints it by /// how far the target sits ahead of / behind the camera, and its scale shrinks with camera -> target distance. +/// +/// On top of the radar-wide fade, each blip fades in when it spawns and fades out at the end of its life (or when +/// its world target vanishes) via its own factor, so blips appear/disappear smoothly instead +/// of popping. Fade-in and fade-out speeds are independent; the factor persists across so a +/// refreshed blip is pulled back to full opacity rather than flickering to zero first. /// [DisallowMultipleComponent] public sealed class RadarTarget : MonoBehaviour @@ -20,11 +25,18 @@ public sealed class RadarTarget : MonoBehaviour [Tooltip("camera -> target distance mapped onto pointDistanceScale: x = near distance, y = far distance.")] [SerializeField] Vector2 pointDistanceRange; + [Header("Fade")] + [Tooltip("Per-blip fade-in speed in alpha units per second when the blip appears. e.g. 6 = ~0.17s. 0 = appear instantly.")] + [SerializeField] float fadeInSpeed = 6f; + [Tooltip("Per-blip fade-out speed in alpha units per second at the end of life / when the target vanishes. e.g. 4 = ~0.25s. 0 = vanish instantly.")] + [SerializeField] float fadeOutSpeed = 4f; + private Transform _target; private Camera _camera; private float _lifetime; private float _age; private bool _initialised; + private float _fade; // per-blip 0..1 ramp, multiplied on top of the radar-wide fade; persists across Refresh private MaterialPropertyBlock _mpb; private Color _pointColor = Color.white; // last gradient-evaluated point colour; its alpha is scaled by the radar fade @@ -44,27 +56,39 @@ public sealed class RadarTarget : MonoBehaviour PushPointColor(); } + /// Restarts this blip's countdown to seconds. Used when a ping arrives + /// for a target this radar already tracks: the existing blip is refreshed instead of stacking a duplicate. + public void Refresh(float lifetime) + { + _lifetime = lifetime; + _age = 0f; + } + // LateUpdate so the aim uses the camera's final position/rotation for this frame. private void LateUpdate() { if (!_initialised) return; _age += Time.deltaTime; - if (_age >= _lifetime) + + // Fade out at the very end of life, or immediately if the world target vanished (e.g. a caught hider) + // instead of dangling. The fade-out window equals the time to ramp 1 -> 0 at fadeOutSpeed, so the point + // reaches full transparency right as the lifetime elapses; then the blip destroys itself. + float fadeOutWindow = fadeOutSpeed > 0f ? 1f / fadeOutSpeed : 0f; + bool fadingOut = !_target || RemainingLifetime <= fadeOutWindow; + + float goal = fadingOut ? 0f : 1f; + float speed = Mathf.Max(0f, fadingOut ? fadeOutSpeed : fadeInSpeed); + _fade = Mathf.MoveTowards(_fade, goal, speed * Time.deltaTime); + + if (fadingOut && (speed <= 0f || _fade <= 0.0001f)) { Destroy(gameObject); return; } - // The world target may despawn (e.g. a caught hider) before this blip expires - drop it, don't dangle. - if (!_target) - { - Destroy(gameObject); - return; - } - - Aim(); - PushPointColor(); // every frame so the blip's alpha tracks the radar fade even when Aim early-returns + Aim(); // keeps last orientation/colour if the target is gone (Aim early-returns) + PushPointColor(); // every frame so the alpha tracks both the radar fade and this blip's own fade } private void Aim() @@ -100,13 +124,13 @@ public sealed class RadarTarget : MonoBehaviour _pointColor = forwardGradient.Evaluate(t); } - // Writes the point's _BaseColor with its alpha scaled by the radar's shared fade factor, so blips fade in/out - // in step with the disc instead of the overlay snapping on/off. + // Writes the point's _BaseColor with its alpha scaled by both the radar's shared fade factor (so blips fade in + // step with the disc) and this blip's own fade (so it eases in on spawn and out at the end of its life). private void PushPointColor() { if (!pointRenderer) return; Color c = _pointColor; - c.a *= Radar.Instance ? Radar.Instance.CurrentFade : 1f; + c.a *= (Radar.Instance ? Radar.Instance.CurrentFade : 1f) * _fade; _mpb ??= new MaterialPropertyBlock(); pointRenderer.GetPropertyBlock(_mpb); _mpb.SetColor(BaseColorId, c); diff --git a/Assets/Scripts/GameMode/GameModeManager.cs b/Assets/Scripts/GameMode/GameModeManager.cs index 8ac99a0..bb364ce 100644 --- a/Assets/Scripts/GameMode/GameModeManager.cs +++ b/Assets/Scripts/GameMode/GameModeManager.cs @@ -42,6 +42,16 @@ public class GameModeManager : MonoBehaviour [Tooltip("Mode active at startup (0 = Player Control).")] public int startModeIndex = 0; + [Header("Hider radar ping")] + [Tooltip("Key a HIDER presses to manually ping itself on every hunter's radar during Seek - the same blip " + + "as the periodic sweep, on demand. Ignored for hunters, outside Seek, and in Free Cam (whose fly " + + "controls already use this key).")] +#if ENABLE_INPUT_SYSTEM + public Key pingKey = Key.E; +#else + public KeyCode pingKey = KeyCode.E; +#endif + [Header("Player collision")] [Tooltip("ON = players physically block each other (default). OFF = the local player passes through every " + "other player - their CharacterController stops colliding with other players' body colliders. " + @@ -76,6 +86,7 @@ public class GameModeManager : MonoBehaviour private bool _paused; private int _resumeIndex = -1; private bool _loadFrozen; // true while the pre-match Loading barrier is holding the local player still + private PlayerRole? _localRole; // bound local avatar's role (null = pre-spawn / spectator / unscoped lobby) // Player-collision reconciler state (see ReconcilePlayerCollision). Non-serialized: reset each play session. private bool _playerCollisionDirty = true; // force a re-apply (startup, toggle flip, inspector edit) @@ -180,6 +191,7 @@ public class GameModeManager : MonoBehaviour { player = null; painter = null; + _localRole = null; // spectator / released: no hider ping PainterChanged?.Invoke(null); } @@ -237,9 +249,15 @@ public class GameModeManager : MonoBehaviour // Lobby avatar -> full set; match variant -> its role's modes. Scope is baked per prefab // variant, so it follows the spawned avatar with no replicated-phase timing race. if (setup && setup.ScopedModes) + { + _localRole = setup.Role; BuildModes(setup.Role); + } else + { + _localRole = null; // unscoped lobby avatar: no hider ping BuildModes(null); + } // Index 0 is each set's intended start mode. SwitchTo(0); @@ -394,6 +412,13 @@ public class GameModeManager : MonoBehaviour SwitchTo(next); } + // A hider manually pings itself onto every hunter's radar (same blip as the periodic sweep), on demand. + // Excluded in Free Cam whose fly controls already use this key; the phase/role gate lives in MatchManager + // (RequestManualPing no-ops outside Seek / for a found hider), so a stray press is harmless. + if (_localRole == PlayerRole.Hider && !(ActiveMode is FreeCamMode) && + InputUtil.GameplayKeyPressed(pingKey) && MatchManager.Instance) + MatchManager.Instance.RequestManualPing(); + ActiveMode?.Tick(); } diff --git a/Assets/Scripts/Network/MatchManager.cs b/Assets/Scripts/Network/MatchManager.cs index cb64f07..906a7bf 100644 --- a/Assets/Scripts/Network/MatchManager.cs +++ b/Assets/Scripts/Network/MatchManager.cs @@ -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 _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(); + // 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 _barrierTargets = 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. + // 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 _loadTargetIds = new List(); + 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 } } + /// 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. + 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()