From de82ce25de42780497ddfeef85d1d5bb19beb99d Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sun, 5 Jul 2026 21:44:49 +0200 Subject: [PATCH] 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 --- Assets/Scripts/Components/Radar.cs | 59 +++++++++ Assets/Scripts/GameMode/GameModeManager.cs | 36 ++++-- Assets/Scripts/Network/MatchManager.cs | 139 ++++++++++++++++++++- Assets/Scripts/Network/UI/MatchHud.cs | 18 ++- 4 files changed, 240 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/Components/Radar.cs b/Assets/Scripts/Components/Radar.cs index aae5037..4a70141 100644 --- a/Assets/Scripts/Components/Radar.cs +++ b/Assets/Scripts/Components/Radar.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using UnityEngine; /// @@ -5,24 +6,53 @@ using UnityEngine; /// Spawns "Target" blips - clones of the child "Target Template" - each of which continuously points from /// the reference camera toward a world-space transform (visualising the bearing camera -> target) and then /// destroys itself after a given lifetime. See for the per-blip behaviour. +/// +/// The whole radar overlay is only shown while it is useful: at least one live target, first-person is active, +/// AND the match is in its Seek phase. It is hidden by switching the overlay camera's rendering off (not by +/// deactivating the GameObjects), so hidden blips keep counting down their lifetime and self-destruct on schedule. /// [DisallowMultipleComponent] public sealed class Radar : MonoBehaviour { + /// The scene's single radar overlay (lives in the persistent Main scene). Hunter clients spawn + /// hider blips onto it from MatchManager's radar-ping sweep. + public static Radar Instance { get; private set; } + [Tooltip("Template each spawned Target is cloned from. Auto-resolves to the child \"Target Template\".")] [SerializeField] private Transform targetTemplate; [Tooltip("Camera the direction is measured FROM (camera -> target). Auto-resolves to the Camera this radar sits under.")] [SerializeField] private Camera referenceCamera; + [Tooltip("Overlay camera that renders the radar. Its rendering is toggled off unless the radar should show " + + "(at least one live target AND first-person active). Auto-resolves to the Camera this radar sits under.")] + [SerializeField] private Camera radarCamera; + + [Tooltip("First-person rig. The radar is only shown while first-person is active. Empty = found automatically.")] + [SerializeField] private FirstPersonController firstPerson; + + // Live blips, pruned as they self-destruct; the radar shows only while at least one is alive. + private readonly List _blips = new List(); + private void Awake() { + Instance = this; + if (!targetTemplate) { var t = transform.Find("Target Template"); if (t) targetTemplate = t; } if (!referenceCamera) referenceCamera = GetComponentInParent(); + if (!radarCamera) radarCamera = GetComponentInParent(); + if (!firstPerson) firstPerson = FindFirstObjectByType(FindObjectsInactive.Include); + + ApplyVisibility(false); // hidden until a target exists, first-person is live AND the match is seeking + } + + private void OnDestroy() + { + if (Instance == this) Instance = null; } /// @@ -51,6 +81,35 @@ public sealed class Radar : MonoBehaviour var blip = clone.GetComponent(); blip.Init(target, referenceCamera, lifetime); + _blips.Add(blip); return blip; } + + // LateUpdate so the count reflects any blip that self-destructed this frame, in step with RadarTarget's aim. + private void LateUpdate() + { + // Drop self-destructed blips (Destroy leaves a fake-null entry behind). + for (int i = _blips.Count - 1; i >= 0; i--) + if (!_blips[i]) _blips.RemoveAt(i); + + bool visible = _blips.Count > 0 && firstPerson && firstPerson.enabled && InSeekPhase(); + ApplyVisibility(visible); + } + + // The radar only makes sense during the seek phase (hunters chasing hiders). Blips only spawn then anyway, + // but a blip's lifetime can outlast the phase edge, so gating here hides the overlay the instant Seek ends. + private static bool InSeekPhase() + { + var m = MatchManager.Instance; + return m && m.MatchActive && m.LocalSubPhase == MatchManager.MatchSubPhase.Seek; + } + + // Shows/hides the whole radar overlay in one toggle (disc + blips) by switching the overlay camera's + // rendering. The GameObjects stay active, so blips keep ticking down their lifetime while hidden. The + // overlay renders only the Radar layer, so nothing else is affected. + private void ApplyVisibility(bool visible) + { + if (radarCamera && radarCamera.enabled != visible) + radarCamera.enabled = visible; + } } diff --git a/Assets/Scripts/GameMode/GameModeManager.cs b/Assets/Scripts/GameMode/GameModeManager.cs index fe200eb..5143ab3 100644 --- a/Assets/Scripts/GameMode/GameModeManager.cs +++ b/Assets/Scripts/GameMode/GameModeManager.cs @@ -80,8 +80,14 @@ public class GameModeManager : MonoBehaviour private void Start() { - if (_modes.Count > 0) + // Go live only when this scene already holds a player to control (a scene-placed avatar). In the + // networked Main scene the avatar spawns on connect, so the manager stays inert - no camera control, + // no mode switching - until BindLocalPlayer promotes it once the lobby avatar exists. Without this, + // the pre-spawn menu already lets you orbit the camera and Tab-cycle modes with no player. + if (player && _modes.Count > 0) SwitchTo(Mathf.Clamp(startModeIndex, 0, _modes.Count - 1)); + else + EnterNoPlayerState(); } /// Registers the modes for the current context. Without a role (no networked avatar yet, @@ -228,22 +234,35 @@ public class GameModeManager : MonoBehaviour /// public void ReleaseToNoPlayer() { - _paused = false; - _resumeIndex = -1; - TearDownActiveMode(); _modes.Clear(); ClearPlayerBindings(); + EnterNoPlayerState(); + } - // The cycle is empty (no SwitchTo runs), so notify mode-following UI explicitly - otherwise the - // paint HUD (hides only via ModeChanged) would stay open after a shutdown during PaintMode. - ModeChanged?.Invoke(null); + /// + /// Puts the manager in the inert "no local player" state: no active mode, the persistent orbit input + /// parked in Disabled (it otherwise re-locks the cursor every frame while AlwaysActive, so a one-off + /// unlock would be reverted next frame) and the cursor freed. Entered at startup in a networked scene + /// before the lobby avatar spawns, and again after it is gone (see ). + /// / promote out of it. With no active mode + /// (_activeIndex < 0) Update ignores the switch key, so Tab cannot bootstrap a mode from here. + /// + private void EnterNoPlayerState() + { + _paused = false; + _resumeIndex = -1; + _activeIndex = -1; if (cameraInput) cameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.Disabled; // Belt-and-braces explicit release (covers the orbit input being absent/disabled). InputUtil.SetCursorLocked(false); + + // The cycle is inert (no SwitchTo runs), so notify mode-following UI explicitly - otherwise the + // paint HUD (hides only via ModeChanged) would stay open after a shutdown during PaintMode. + ModeChanged?.Invoke(null); } /// @@ -299,6 +318,9 @@ public class GameModeManager : MonoBehaviour { if (_paused) return; if (_modes.Count == 0) return; + // No active mode = the inert no-player state (pre-lobby, or after ReleaseToNoPlayer). Ignore the + // switch key and Tick so Tab can't bootstrap a mode and no camera rig is driven until a bind. + if (_activeIndex < 0) return; // 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. diff --git a/Assets/Scripts/Network/MatchManager.cs b/Assets/Scripts/Network/MatchManager.cs index 5a961f0..bfeef09 100644 --- a/Assets/Scripts/Network/MatchManager.cs +++ b/Assets/Scripts/Network/MatchManager.cs @@ -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; + /// Range of the hunter's shoot raycast (read by ). public float ShootRange => shootRange; /// Hunter shoot cooldown in seconds (read by ). @@ -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 /// Seconds left in the current sub-phase for the HUD (clamped to 0; cosmetic). public float LocalPhaseRemaining => Mathf.Max(0f, _phaseEndTime - Time.time); + /// Seconds between radar ping sweeps (0 = pings disabled). Baked identically on every client + /// (persistent MatchManager, no replication - like ), so the HUD can + /// drive the ping countdown locally. + public float RadarPingInterval => radarPingInterval; + + /// Seconds until the next radar ping sweep for the HUD (counts down from + /// and repeats). 0 outside Seek or when pings are disabled. Computed locally from the local Seek-start + + /// fixed interval - cosmetic, like (a mid-match joiner's is offset by the + /// time it missed; the server's sweep timer is START-aligned the same way). + 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 _persistentIds = new Dictionary(); private Coroutine _phaseRoutine; private readonly List _phaseRecipients = new List(); // reused scratch for the phase broadcast + 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 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 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); + } + } + /// Client-side: the local hunter's shoot raycast hit the hider owned by . /// 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. diff --git a/Assets/Scripts/Network/UI/MatchHud.cs b/Assets/Scripts/Network/UI/MatchHud.cs index a9d7142..c039d71 100644 --- a/Assets/Scripts/Network/UI/MatchHud.cs +++ b/Assets/Scripts/Network/UI/MatchHud.cs @@ -24,6 +24,10 @@ public class MatchHud : MonoBehaviour [Tooltip("Countdown label in the top bar.")] [SerializeField] private TMP_Text _timeText; + [Tooltip("Seconds until the next radar ping sweep; counts down from the interval and repeats. Shown only " + + "during the seek phase (blank otherwise).")] + [SerializeField] private TMP_Text _pingTimeText; + [SerializeField] private TMP_Text _hunterCount; [SerializeField] private TMP_Text _hiderCount; @@ -31,6 +35,7 @@ public class MatchHud : MonoBehaviour [SerializeField] private GameObject _crosshair; private int _lastSecond = -1; + private int _lastPingSecond = -1; private int _lastHunters = -1; private int _lastHiders = -1; private MatchManager.MatchSubPhase _lastPhase = (MatchManager.MatchSubPhase)(-1); @@ -38,9 +43,9 @@ public class MatchHud : MonoBehaviour private void Awake() { if (!_canvas) _canvas = GetComponent(); - if (!_canvas || !_phaseText || !_timeText || !_crosshair || !_hunterCount || !_hiderCount) + if (!_canvas || !_phaseText || !_timeText || !_pingTimeText || !_crosshair || !_hunterCount || !_hiderCount) { - Debug.LogError("[MatchHud] Unwired reference(s) - assign Canvas/Phase/Time/Crosshair/HunterCount/HiderCount in the Inspector.", this); + Debug.LogError("[MatchHud] Unwired reference(s) - assign Canvas/Phase/Time/PingTime/Crosshair/HunterCount/HiderCount in the Inspector.", this); enabled = false; return; } @@ -70,6 +75,15 @@ public class MatchHud : MonoBehaviour _timeText.text = $"{s / 60:0}:{s % 60:00}"; } + // Next-radar-sweep countdown: shown for everyone during Seek (pings only fire then), 0:00 otherwise. + bool showPing = phase == MatchManager.MatchSubPhase.Seek && match.RadarPingInterval > 0f; + int p = showPing ? Mathf.CeilToInt(Mathf.Max(0f, match.LocalNextPingIn)) : 0; + if (p != _lastPingSecond) + { + _lastPingSecond = p; + _pingTimeText.text = $"{p / 60:0}:{p % 60:00}"; + } + NetworkPlayerSetup.CountRoles(out int hunters, out int hiders); if (hunters != _lastHunters) {