Radar-Sichtbarkeitsgates, Hider-Pings im Seek + Ping-Countdown

Radar: nur sichtbar bei >=1 Target, in First-Person und in der Seek-Phase
(Overlay-Kamera getoggelt, Blip-Skripte laufen weiter). Singleton fuer
Server-Zugriff. GameModeManager: Kamera + Tab-Moduswechsel erst ab
gebundenem Lobby-Player (Inert-Zustand ohne Player, Orbit-Input geparkt).

MatchManager: Server tickt im Seek alle radarPingInterval (30s) und
broadcastet noch nicht gefundene Hider (NetworkObjectId + radarPingLifetime
3s) nur an Hunter-Clients; jeder Hunter loest lokal auf und spawnt Blips.
Ping-Loop in die Seek-Coroutine gefaltet (EndMatch stoppt ihn mit).

MatchHud: Ping-Countdown lokal aus Seek-Start + Kadenz berechnet, Format
m:ss (0:30..0:01), ausserhalb Seek / bei deaktivierten Pings 0:00.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-05 21:45:10 +02:00
1 parent 23b51d07a4
commit de82ce25de
4 files changed
+240 -12

No files matched your search

+136 -3
View File
@@ -128,6 +128,18 @@ public class MatchManager : MonoBehaviour
[Range(0f, 100f)]
[SerializeField] private float embeddedRevealPercent = 80f;
[Header("Radar pings")]
[Tooltip("Seconds between radar sweeps during Seek. Every interval each still-un-found hider is registered " +
"as a blip on every HUNTER's radar (hiders and spectators never receive them). Fires only in the " +
"Seek phase. 0 disables radar pings.")]
[Min(0f)]
[SerializeField] private float radarPingInterval = 30f;
[Tooltip("How long (seconds) each hider radar blip stays alive before it fades. Kept short relative to " +
"radarPingInterval so hunters get a periodic sweep, not a constant track.")]
[Min(0f)]
[SerializeField] private float radarPingLifetime = 3f;
/// <summary>Range of the hunter's shoot raycast (read by <see cref="FirstPersonMode"/>).</summary>
public float ShootRange => shootRange;
/// <summary>Hunter shoot cooldown in seconds (read by <see cref="FirstPersonMode"/>).</summary>
@@ -145,6 +157,7 @@ public class MatchManager : MonoBehaviour
// The countdown is cosmetic/local; the authoritative transition is server-driven, so drift self-corrects.
private MatchSubPhase _subPhase = MatchSubPhase.None;
private float _phaseEndTime; // Time.time when the current sub-phase ends (for the HUD countdown)
private float _seekStartTime; // Time.time Seek began locally (anchors the ping-countdown cadence, START-aligned)
private MatchMode _localMode = MatchMode.Classic; // mirror of the server's matchMode, carried in the phase message
private const string PreMatchTag = "PreMatch"; // scene props destroyed when seek starts
@@ -158,6 +171,25 @@ public class MatchManager : MonoBehaviour
/// <summary>Seconds left in the current sub-phase for the HUD (clamped to 0; cosmetic).</summary>
public float LocalPhaseRemaining => Mathf.Max(0f, _phaseEndTime - Time.time);
/// <summary>Seconds between radar ping sweeps (0 = pings disabled). Baked identically on every client
/// (persistent MatchManager, no replication - like <see cref="EmbeddedRevealPercent"/>), so the HUD can
/// drive the ping countdown locally.</summary>
public float RadarPingInterval => radarPingInterval;
/// <summary>Seconds until the next radar ping sweep for the HUD (counts down from <see cref="RadarPingInterval"/>
/// and repeats). 0 outside Seek or when pings are disabled. Computed locally from the local Seek-start +
/// fixed interval - cosmetic, like <see cref="LocalPhaseRemaining"/> (a mid-match joiner's is offset by the
/// time it missed; the server's sweep timer is START-aligned the same way).</summary>
public float LocalNextPingIn
{
get
{
if (_subPhase != MatchSubPhase.Seek || radarPingInterval <= 0f) return 0f;
float intoInterval = (Time.time - _seekStartTime) % radarPingInterval;
return radarPingInterval - intoInterval;
}
}
private NetworkManager _net;
private GameObject _playerPrefab; // captured from NetworkConfig.PlayerPrefab; auto-spawn is then disabled so we spawn manually into a loaded scene
private bool _hooked;
@@ -193,6 +225,9 @@ public class MatchManager : MonoBehaviour
// app restarts). Lets the server map the ephemeral NGO clientId to a stable identity - the reconnect
// foundation. See PersistentPlayerId.
private const string MsgIdentity = "Match_Identity";
// 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";
// 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
@@ -206,6 +241,8 @@ public class MatchManager : MonoBehaviour
private readonly Dictionary<ulong, string> _persistentIds = new Dictionary<ulong, string>();
private Coroutine _phaseRoutine;
private readonly List<ulong> _phaseRecipients = new List<ulong>(); // reused scratch for the phase broadcast
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
private void Awake()
{
@@ -258,6 +295,7 @@ public class MatchManager : MonoBehaviour
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRadarPing);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgIdentity);
}
@@ -311,6 +349,7 @@ public class MatchManager : MonoBehaviour
{
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgAuthorityGranted, OnAuthorityGrantedMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgPhase, OnPhaseMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRadarPing, OnRadarPingMessage);
}
// Report our persistent id to the server the moment our own connection completes (host records its own
@@ -546,7 +585,7 @@ public class MatchManager : MonoBehaviour
yield return WaitPhase(hideDuration, null);
SetPhaseServer(MatchSubPhase.Seek, seekDuration);
yield return WaitPhase(seekDuration, () => _pendingHiders.Count == 0);
yield return RunSeekPhase(seekDuration);
SetPhaseServer(MatchSubPhase.Resolution, resolveDuration);
yield return WaitPhase(resolveDuration, null);
@@ -555,6 +594,32 @@ public class MatchManager : MonoBehaviour
EndMatch();
}
// Server-only. The seek wait with the radar-ping sweep folded in, so pinging lives inside _phaseRoutine
// (an early Back-to-Lobby stops that routine and so stops pinging too). Ends the instant the last hider is
// found - identical early-out to the plain WaitPhase(_pendingHiders.Count == 0) it replaces for Seek.
private IEnumerator RunSeekPhase(float seconds)
{
float t = seconds;
float pingTimer = radarPingInterval; // first sweep one interval into Seek
while (t > 0f)
{
if (_pendingHiders.Count == 0) yield break;
if (radarPingInterval > 0f)
{
pingTimer -= Time.deltaTime;
if (pingTimer <= 0f)
{
BroadcastRadarPings();
pingTimer = radarPingInterval;
}
}
t -= Time.deltaTime;
yield return null;
}
}
// Waits up to 'seconds' of server real time, returning early the moment 'until' (if supplied) is true.
private IEnumerator WaitPhase(float seconds, System.Func<bool> until)
{
@@ -582,8 +647,13 @@ public class MatchManager : MonoBehaviour
_subPhase = phase;
_phaseEndTime = Time.time + duration;
_localMode = mode;
// Props staged only for the hide phase are gone the instant seek begins.
if (changed && phase == MatchSubPhase.Seek) DestroyPreMatchObjects();
// Props staged only for the hide phase are gone the instant seek begins; anchor the local ping-countdown
// cadence to the same moment (START-aligned, matching the server's sweep timer).
if (changed && phase == MatchSubPhase.Seek)
{
_seekStartTime = Time.time;
DestroyPreMatchObjects();
}
}
// Runs on every machine (server directly, clients via the phase message); the tagged props are plain
@@ -630,6 +700,69 @@ public class MatchManager : MonoBehaviour
ApplyPhaseLocal((MatchSubPhase)phase, duration, (MatchMode)mode);
}
// Server-only. One radar sweep during Seek: registers every still-un-found hider as a blip on each HUNTER's
// radar. Sends the hiders' NetworkObjectIds + blip lifetime to the hunter clients; a host-hunter applies
// locally (no loopback message, mirroring SetPhaseServer). Hiders and spectators never receive a ping.
private void BroadcastRadarPings()
{
if (_net == null || !_net.IsServer || _pendingHiders.Count == 0) return;
_radarPingIds.Clear();
foreach (ulong hiderClientId in _pendingHiders)
if (_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject)
_radarPingIds.Add(c.PlayerObject.NetworkObjectId);
if (_radarPingIds.Count == 0) return;
// Host-as-hunter: spawn on its own radar directly (it is excluded from the recipient list below).
if (IsHunter(_net.LocalClientId)) SpawnRadarBlipsLocal();
_radarRecipients.Clear();
foreach (ulong clientId in _net.ConnectedClientsIds)
if (clientId != _net.LocalClientId && IsHunter(clientId)) _radarRecipients.Add(clientId);
if (_radarRecipients.Count == 0) return;
var cmm = _net.CustomMessagingManager;
if (cmm == null) return;
int size = sizeof(float) + sizeof(int) + _radarPingIds.Count * sizeof(ulong);
using (var writer = new FastBufferWriter(size, Allocator.Temp))
{
writer.WriteValueSafe(radarPingLifetime);
writer.WriteValueSafe(_radarPingIds.Count);
for (int i = 0; i < _radarPingIds.Count; i++)
writer.WriteValueSafe(_radarPingIds[i]);
cmm.SendNamedMessage(MsgRadarPing, _radarRecipients, writer);
}
}
// 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()
{
var radar = Radar.Instance;
var spawnMgr = _net != null ? _net.SpawnManager : null;
if (!radar || spawnMgr == null) return;
for (int i = 0; i < _radarPingIds.Count; i++)
if (spawnMgr.SpawnedObjects.TryGetValue(_radarPingIds[i], out var no) && no)
radar.Spawn(no.transform, radarPingLifetime);
}
// Hunter-client handler: a radar sweep arrived. Resolve each hider NetworkObjectId to its live transform and
// drop a blip on the local radar. Only hunter clients are sent this message (filtered server-side).
private void OnRadarPingMessage(ulong senderClientId, FastBufferReader reader)
{
reader.ReadValueSafe(out float lifetime);
reader.ReadValueSafe(out int count);
var radar = Radar.Instance;
var spawnMgr = _net != null ? _net.SpawnManager : null;
for (int i = 0; i < count; i++)
{
reader.ReadValueSafe(out ulong netObjId);
if (radar && spawnMgr != null &&
spawnMgr.SpawnedObjects.TryGetValue(netObjId, out var no) && no)
radar.Spawn(no.transform, lifetime);
}
}
/// <summary>Client-side: the local hunter's shoot raycast hit the hider owned by <paramref name="targetClientId"/>.
/// Routes to the server, which validates phase/role/found-state. On a host this IS the server, so it
/// applies directly. No-op outside the seek phase.</summary>