Add OverviewMenu escape menu, remove Tab mode cycling

- New OverviewMenu component: Esc toggles the pause overlay, buttons
  switch modes via GameModeManager.TrySwitchTo<T>() and close the panel;
  visibility follows the role-scoped mode set, active mode disabled.
- Esc in paint mode exits to camera mode instead (PlayerControlMode,
  hunter falls back to FirstPersonMode) rather than opening the menu.
- Centralized all Esc handling in OverviewMenu (browser close included)
  to avoid frame races; OnlineSessionBrowserUI gains IsOpen/Open/Close.
- GameModeManager: switchKey Tab-cycle removed, TrySwitchTo<T> added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-12 12:34:12 +02:00
1 parent 367bc9cee0
commit 7ac6038121
4 files changed
+239 -53

No files matched your search

@@ -0,0 +1,180 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// 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 <see cref="GameModeManager.SetPaused"/>);
/// 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 <see cref="GameModeManager.TrySwitchTo{T}"/>) 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
/// (<see cref="GameModeManager.Modes"/>); the active mode's button is disabled. Attach to the
/// always-active canvas root; <see cref="panelRoot"/> is the toggled visual child.
/// </summary>
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<GameModeManager>();
if (!sessionBrowser) sessionBrowser = FindFirstObjectByType<OnlineSessionBrowserUI>();
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<PlayerControlMode>() && !gameMode.TrySwitchTo<FirstPersonMode>())
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);
}
/// <summary>Hides the panel; <paramref name="resume"/> = false hands the pause over (session
/// browser opening) instead of resuming gameplay.</summary>
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<PlayerControlMode>();
private void OnPaint() => SwitchMode<PaintMode>();
private void OnFirstPerson() => SwitchMode<FirstPersonMode>();
private void OnFreeCam() => SwitchMode<FreeCamMode>();
private void SwitchMode<T>() 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<T>();
}
/// <summary>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.</summary>
private void RefreshButtons()
{
if (!gameMode) return;
IReadOnlyList<GameMode> modes = gameMode.Modes;
GameMode active = gameMode.EffectiveMode;
SetModeButton<PlayerControlMode>(playerControlButton, modes, active);
SetModeButton<PaintMode>(paintButton, modes, active);
SetModeButton<FirstPersonMode>(firstPersonButton, modes, active);
SetModeButton<FreeCamMode>(freeCamButton, modes, active);
}
private static void SetModeButton<T>(Button button, IReadOnlyList<GameMode> 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
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6fff96a0af7faf3458af1fef51f6f4a0
+31 -29
View File
@@ -7,10 +7,11 @@ using UnityEngine.InputSystem;
#endif
/// <summary>
/// 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 <see cref="PlayerRole"/> 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 <see cref="OverviewMenu"/>, which calls <see cref="TrySwitchTo{T}"/>). The mode set is
/// scoped to the local avatar's <see cref="PlayerRole"/> 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.
/// </summary>
[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;
/// <summary>The registered role-scoped mode set (read-only; the overview menu builds its buttons from this).</summary>
public IReadOnlyList<GameMode> Modes => _modes;
/// <summary>True while gameplay is suspended for a modal overlay (see <see cref="SetPaused"/>).</summary>
public bool IsPaused => _paused;
/// <summary>The active mode, or - while paused - the mode that will resume. Null in the no-player state.</summary>
public GameMode EffectiveMode =>
ActiveMode ?? ((_resumeIndex >= 0 && _resumeIndex < _modes.Count) ? _modes[_resumeIndex] : null);
/// <summary>Switches to the registered mode of type <typeparamref name="T"/>; false if the
/// current role-scoped set has none.</summary>
public bool TrySwitchTo<T>() 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<PlayerCharacterController>();
@@ -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 <see cref="ReleaseToNoPlayer"/>).
/// <see cref="BindLocalPlayer"/> / <see cref="BindSpectator"/> promote out of it. With no active mode
/// (_activeIndex &lt; 0) Update ignores the switch key, so Tab cannot bootstrap a mode from here.
/// (_activeIndex &lt; 0) Update is inert and the overview menu refuses to open, so nothing can
/// bootstrap a mode from here.
/// </summary>
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.
@@ -3,9 +3,6 @@ using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// uGUI binding for <see cref="OnlineSessionManager"/>: Host / Refresh / row select / Join / Disconnect
@@ -13,13 +10,15 @@ using UnityEngine.InputSystem;
/// <see cref="OnlineSessionManager.SessionsChanged"/> 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
/// <see cref="GameModeManager.SetPaused"/>). It also auto-opens when the Resolution sub-phase begins so
/// lobby/map the overview (Esc) menu reopens it via <see cref="Open"/> as a modal pause overlay
/// (cursor freed, gameplay suspended via <see cref="GameModeManager.SetPaused"/>) - all Esc key
/// handling lives in <see cref="OverviewMenu"/>, which closes this overlay through <see cref="Close"/>.
/// 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 <see cref="panelRoot"/> 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.
/// </summary>
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();
}
/// <summary>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.</summary>
public bool IsOpen => _open;
/// <summary>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.</summary>
public bool Open()
{
if (!(manager && manager.IsConnected)) return false;
SetOpen(true);
return true;
}
/// <summary>Closes the modal overlay and resumes gameplay (Esc handling in <see cref="OverviewMenu"/>).</summary>
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<TMP_Text>() : 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
}
}