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
- }
}