diff --git a/Assets/Scripts/Components/UI/OverviewMenu.cs b/Assets/Scripts/Components/UI/OverviewMenu.cs new file mode 100644 index 0000000..fa76674 --- /dev/null +++ b/Assets/Scripts/Components/UI/OverviewMenu.cs @@ -0,0 +1,180 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; +#if ENABLE_INPUT_SYSTEM +using UnityEngine.InputSystem; +#endif + +/// +/// The Esc overview menu (the "Overview UI" canvas in Main). Owns ALL Escape handling: Esc toggles +/// this panel as a modal pause overlay (gameplay suspended via ); +/// while the session browser overlay is open instead, Esc closes that, and while paint mode is active +/// Esc exits to the camera mode (Player Control, or First Person for the hunter). Buttons switch game modes +/// (via ) or open the session browser, and every click +/// closes the panel. Mode buttons are only shown for modes present in the current role-scoped set +/// (); the active mode's button is disabled. Attach to the +/// always-active canvas root; is the toggled visual child. +/// +public class OverviewMenu : MonoBehaviour +{ + [Tooltip("Mode manager driving pause + mode switches. Empty = found automatically.")] + [SerializeField] private GameModeManager gameMode; + + [Tooltip("Session browser binder. While its overlay is open it owns Esc (close-only) and this menu " + + "stays closed. Empty = found automatically.")] + [SerializeField] private OnlineSessionBrowserUI sessionBrowser; + + [Header("Panel")] + [Tooltip("Visual container toggled by Esc. Must be a CHILD of this binder's object so the binder " + + "keeps reading Esc while the panel is hidden.")] + [SerializeField] private GameObject panelRoot; + + [Header("Buttons")] + [SerializeField] private Button sessionButton; + [Tooltip("Enters Player Control (third-person orbit) mode.")] + [SerializeField] private Button playerControlButton; + [SerializeField] private Button paintButton; + [SerializeField] private Button firstPersonButton; + [SerializeField] private Button freeCamButton; + + private bool _open; + + private void Awake() + { + if (!gameMode) gameMode = FindFirstObjectByType(); + if (!sessionBrowser) sessionBrowser = FindFirstObjectByType(); + if (panelRoot) panelRoot.SetActive(false); + } + + private void OnEnable() + { + if (sessionButton) sessionButton.onClick.AddListener(OnSession); + if (playerControlButton) playerControlButton.onClick.AddListener(OnPlayerControl); + if (paintButton) paintButton.onClick.AddListener(OnPaint); + if (firstPersonButton) firstPersonButton.onClick.AddListener(OnFirstPerson); + if (freeCamButton) freeCamButton.onClick.AddListener(OnFreeCam); + } + + private void OnDisable() + { + if (sessionButton) sessionButton.onClick.RemoveListener(OnSession); + if (playerControlButton) playerControlButton.onClick.RemoveListener(OnPlayerControl); + if (paintButton) paintButton.onClick.RemoveListener(OnPaint); + if (firstPersonButton) firstPersonButton.onClick.RemoveListener(OnFirstPerson); + if (freeCamButton) freeCamButton.onClick.RemoveListener(OnFreeCam); + } + + private void Update() + { + bool browserOpen = sessionBrowser && sessionBrowser.IsOpen; + + if (_open) + { + // The browser overlay took over (its Resolution auto-open) -> hide this panel; the pause + // is the browser's now, so don't release it. + if (browserOpen) + HidePanel(); + // An external state reset (respawn/bind, disconnect) dropped the pause underneath the + // open panel -> don't linger as a stale overlay over live gameplay. + else if (gameMode && !gameMode.IsPaused) + HidePanel(); + } + + if (!EscapePressed()) return; + + if (browserOpen) + sessionBrowser.Close(); + else if (_open) + Close(resume: true); + // Paint mode: Esc exits back to the camera mode instead of opening the menu. Player Control + // first; the hunter's role-scoped set has none, so it falls through to First Person. + else if (gameMode && gameMode.EffectiveMode is PaintMode) + { + if (!gameMode.TrySwitchTo() && !gameMode.TrySwitchTo()) + Open(); + } + // No active/paused mode = the inert no-player state (pre-lobby Main, where the session panel + // is always shown) - nothing to pause, don't open. + else if (gameMode && gameMode.EffectiveMode != null) + Open(); + } + + private void Open() + { + _open = true; + if (panelRoot) panelRoot.SetActive(true); + RefreshButtons(); + if (gameMode) gameMode.SetPaused(true); + } + + /// Hides the panel; = false hands the pause over (session + /// browser opening) instead of resuming gameplay. + private void Close(bool resume) + { + HidePanel(); + if (resume && gameMode) gameMode.SetPaused(false); + } + + private void HidePanel() + { + _open = false; + if (panelRoot) panelRoot.SetActive(false); + } + + private void OnSession() + { + Close(resume: false); + // Not connected (defensive - the menu should not be openable then): don't strand the pause. + if (!(sessionBrowser && sessionBrowser.Open()) && gameMode) + gameMode.SetPaused(false); + } + + private void OnPlayerControl() => SwitchMode(); + private void OnPaint() => SwitchMode(); + private void OnFirstPerson() => SwitchMode(); + private void OnFreeCam() => SwitchMode(); + + private void SwitchMode() where T : GameMode + { + // Resume first so the switch runs on a live cycle (SwitchTo on a paused manager would fight + // the pause bookkeeping); picking the already-active mode is a no-op inside SwitchTo. + Close(resume: true); + if (gameMode) gameMode.TrySwitchTo(); + } + + /// Shows only the buttons whose mode exists in the current role-scoped set and disables + /// the active mode's button. Ran on every open - the set only changes with a (re)bind, which + /// force-closes the panel anyway. + private void RefreshButtons() + { + if (!gameMode) return; + IReadOnlyList modes = gameMode.Modes; + GameMode active = gameMode.EffectiveMode; + + SetModeButton(playerControlButton, modes, active); + SetModeButton(paintButton, modes, active); + SetModeButton(firstPersonButton, modes, active); + SetModeButton(freeCamButton, modes, active); + } + + private static void SetModeButton(Button button, IReadOnlyList modes, GameMode active) + where T : GameMode + { + if (!button) return; + bool present = false; + for (int i = 0; i < modes.Count; i++) + if (modes[i] is T) { present = true; break; } + button.gameObject.SetActive(present); + button.interactable = !(active is T); + } + + // NOT TypingGuard-gated (GameplayKeyPressed): Esc must work even while a text field has focus. + private static bool EscapePressed() + { +#if ENABLE_INPUT_SYSTEM + return InputUtil.KeyPressedThisFrame(Key.Escape); +#else + return InputUtil.KeyPressedThisFrame(KeyCode.Escape); +#endif + } +} diff --git a/Assets/Scripts/Components/UI/OverviewMenu.cs.meta b/Assets/Scripts/Components/UI/OverviewMenu.cs.meta new file mode 100644 index 0000000..a2cc0cc --- /dev/null +++ b/Assets/Scripts/Components/UI/OverviewMenu.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6fff96a0af7faf3458af1fef51f6f4a0 \ No newline at end of file diff --git a/Assets/Scripts/GameMode/GameModeManager.cs b/Assets/Scripts/GameMode/GameModeManager.cs index 21ba78f..56bca5b 100644 --- a/Assets/Scripts/GameMode/GameModeManager.cs +++ b/Assets/Scripts/GameMode/GameModeManager.cs @@ -7,10 +7,11 @@ using UnityEngine.InputSystem; #endif /// -/// Switches the game between high-level modes. The switch key (Tab) cycles to the next registered -/// mode. The mode set is scoped to the local avatar's when it spawns -/// (Hunter: First Person + Paint; Hider: Player Control + Paint + Free Cam); a non-networked scene -/// uses the full set. Attach to a single, persistent scene GameObject. +/// Switches the game between high-level modes. Modes are selected via the overview (Esc) menu +/// (see , which calls ). The mode set is +/// scoped to the local avatar's when it spawns (Hunter: First Person + +/// Paint; Hider: Player Control + Paint + Free Cam); a non-networked scene uses the full set. +/// Attach to a single, persistent scene GameObject. /// [DisallowMultipleComponent] public class GameModeManager : MonoBehaviour @@ -32,13 +33,6 @@ public class GameModeManager : MonoBehaviour public FirstPersonController firstPerson; [Header("Switching")] - [Tooltip("Key that cycles to the next mode.")] -#if ENABLE_INPUT_SYSTEM - public Key switchKey = Key.Tab; -#else - public KeyCode switchKey = KeyCode.Tab; -#endif - [Tooltip("Mode active at startup (0 = Player Control).")] public int startModeIndex = 0; @@ -98,6 +92,25 @@ public class GameModeManager : MonoBehaviour public GameMode ActiveMode => (_activeIndex >= 0 && _activeIndex < _modes.Count) ? _modes[_activeIndex] : null; + /// The registered role-scoped mode set (read-only; the overview menu builds its buttons from this). + public IReadOnlyList Modes => _modes; + + /// True while gameplay is suspended for a modal overlay (see ). + public bool IsPaused => _paused; + + /// The active mode, or - while paused - the mode that will resume. Null in the no-player state. + public GameMode EffectiveMode => + ActiveMode ?? ((_resumeIndex >= 0 && _resumeIndex < _modes.Count) ? _modes[_resumeIndex] : null); + + /// Switches to the registered mode of type ; false if the + /// current role-scoped set has none. + public bool TrySwitchTo() where T : GameMode + { + for (int i = 0; i < _modes.Count; i++) + if (_modes[i] is T) { SwitchTo(i); return true; } + return false; + } + private void Awake() { if (!player) player = FindFirstObjectByType(); @@ -115,7 +128,7 @@ public class GameModeManager : MonoBehaviour // 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. + // the pre-spawn menu already lets you orbit the camera and switch modes with no player. if (player && _modes.Count > 0) SwitchTo(Mathf.Clamp(startModeIndex, 0, _modes.Count - 1)); else @@ -283,7 +296,8 @@ public class GameModeManager : MonoBehaviour /// 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. + /// (_activeIndex < 0) Update is inert and the overview menu refuses to open, so nothing can + /// bootstrap a mode from here. /// private void EnterNoPlayerState() { @@ -358,8 +372,8 @@ 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. + // No active mode = the inert no-player state (pre-lobby, or after ReleaseToNoPlayer). Skip the + // Tick so 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) @@ -389,27 +403,15 @@ public class GameModeManager : MonoBehaviour ActiveMode?.Enter(); } - // Resolution (the reveal window) is watch-only: force out of Paint and keep the switch key from + // Resolution (the reveal window) is watch-only: force out of Paint and keep mode switching from // re-entering. No restore needed - the end-of-match respawn rebuilds the cycle at index 0. - bool suppressPaint = PaintSuppressed(); - if (suppressPaint && ActiveMode is PaintMode) + if (PaintSuppressed() && ActiveMode is PaintMode) { int fallback = FirstNonPaintFrom(0); if (fallback >= 0) SwitchTo(fallback); else if (painter) painter.PaintingEnabled = false; // degenerate paint-only set: at least stop painting } - if (InputUtil.GameplayKeyPressed(switchKey)) - { - int next = (_activeIndex + 1) % _modes.Count; - if (suppressPaint && _modes[next] is PaintMode) - { - int skip = FirstNonPaintFrom(next); - if (skip >= 0) next = skip; - } - 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. diff --git a/Assets/Scripts/Network/UI/OnlineSessionBrowserUI.cs b/Assets/Scripts/Network/UI/OnlineSessionBrowserUI.cs index 54f49cb..160d272 100644 --- a/Assets/Scripts/Network/UI/OnlineSessionBrowserUI.cs +++ b/Assets/Scripts/Network/UI/OnlineSessionBrowserUI.cs @@ -3,9 +3,6 @@ using TMPro; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.UI; -#if ENABLE_INPUT_SYSTEM -using UnityEngine.InputSystem; -#endif /// /// uGUI binding for : Host / Refresh / row select / Join / Disconnect @@ -13,13 +10,15 @@ using UnityEngine.InputSystem; /// events; the session logic stays on the manager. /// /// The panel is always shown in Main (not connected); once a connection is up it hides, and in the -/// lobby/map Esc toggles it back as a modal pause overlay (cursor freed, gameplay suspended via -/// ). It also auto-opens when the Resolution sub-phase begins so +/// lobby/map the overview (Esc) menu reopens it via as a modal pause overlay +/// (cursor freed, gameplay suspended via ) - all Esc key +/// handling lives in , which closes this overlay through . +/// It also auto-opens when the Resolution sub-phase begins so /// the final match scoreboard (a section of this panel) is presented. The Disconnect button is /// context-sensitive: in Main it is an /// application "Exit"; on the map it becomes "Back to Lobby" for the match authority (ends the match); /// everyone else gets a plain disconnect. Only the visual is toggled - this binder -/// stays alive on its (always-active) canvas so it keeps reading Esc while the panel is hidden. +/// stays alive on its (always-active) canvas so it keeps tracking state while the panel is hidden. /// public class OnlineSessionBrowserUI : MonoBehaviour { @@ -41,8 +40,8 @@ public class OnlineSessionBrowserUI : MonoBehaviour [SerializeField] private TMP_Text statusLabel; [Header("Overlay")] - [Tooltip("Visual container toggled by Esc while connected (the panel root). Must be a CHILD of this " + - "binder's object so the binder keeps running - and reading Esc - while the panel is hidden.")] + [Tooltip("Visual container toggled while connected (the panel root). Must be a CHILD of this " + + "binder's object so the binder keeps running while the panel is hidden.")] [SerializeField] private GameObject panelRoot; [Tooltip("Background tint of the selected row.")] @@ -58,7 +57,7 @@ public class OnlineSessionBrowserUI : MonoBehaviour private GameModeManager _gameMode; // pause hook for the modal overlay; found at runtime private TMP_Text _disconnectLabel; // disconnect button caption (relabelled to "Back to Lobby") - private bool _open; // Esc-toggled visibility while connected + private bool _open; // overlay visibility while connected (toggled via Open/Close) private bool _connected; // last-seen connection state (to detect transitions) private Scene _lastScene; // last-seen active scene (to auto-close on a scene change) private string _disconnectText; // last applied caption (avoids per-frame TMP churn) @@ -123,10 +122,10 @@ public class OnlineSessionBrowserUI : MonoBehaviour SetOpen(false); _lastScene = scene; - if (!connected) return; // Main: panel always shown, Esc ignored + if (!connected) return; // Main: panel always shown // Resolution just began -> auto-open the overlay so the final scoreboard is presented. Esc can - // still close it; the lobby scene swap afterwards auto-closes it (see above). + // still close it (via the overview menu); the lobby scene swap afterwards auto-closes it (see above). MatchManager.MatchSubPhase subPhase = _match ? _match.LocalSubPhase : MatchManager.MatchSubPhase.None; if (subPhase != _lastSubPhase) { @@ -135,13 +134,25 @@ public class OnlineSessionBrowserUI : MonoBehaviour SetOpen(true); } - bool inLobbyOrMap = _match && (_match.IsLocalSceneLobby || _match.IsLocalSceneMap); - if (inLobbyOrMap && EscapePressed()) - SetOpen(!_open); - RefreshDisconnectLabel(); } + /// True while the modal overlay is open on a live connection (the always-shown Main panel + /// does not count). The overview menu yields Esc to this overlay while it is open. + public bool IsOpen => _open; + + /// Opens the modal overlay (used by the overview menu's Session button); false when + /// not connected - in Main the panel is always visible and never a pause overlay. + public bool Open() + { + if (!(manager && manager.IsConnected)) return false; + SetOpen(true); + return true; + } + + /// Closes the modal overlay and resumes gameplay (Esc handling in ). + public void Close() => SetOpen(false); + private void OnHost() { if (manager) manager.Host(); } private void OnRefresh() { if (manager) manager.Refresh(); } private void OnJoin() { if (manager && !string.IsNullOrEmpty(_selectedId)) manager.Join(_selectedId); } @@ -308,13 +319,4 @@ public class OnlineSessionBrowserUI : MonoBehaviour return t ? t.GetComponent() : null; } - // NOT TypingGuard-gated (GameplayKeyPressed): Esc must close the panel even while a text field has focus. - private static bool EscapePressed() - { -#if ENABLE_INPUT_SYSTEM - return InputUtil.KeyPressedThisFrame(Key.Escape); -#else - return InputUtil.KeyPressedThisFrame(KeyCode.Escape); -#endif - } }