From cdfec364b25f315998dd2dd378bf108707a8f87a Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sun, 28 Jun 2026 11:34:13 +0200 Subject: [PATCH] Toggle session panel with Escape as in-game pause overlay - Panel shows in Main while disconnected, auto-hides once a connection is established, and toggles open/closed with Escape in the Lobby or on a Map. - Escape now pauses gameplay (active mode exits, cursor freed, paint and movement disabled) instead of ending the match directly; closing restores the prior mode via SwitchTo. - Disconnect button is context-aware: leaves the session in the Lobby, acts as "Back to Lobby" (EndMatch) for the match authority on a Map, and stays a plain disconnect for non-authority clients on a Map. - Removed the Escape->EndMatch handler from MatchManager; match end now routes through RequestEndMatch with the same authority routing. Co-Authored-By: Claude Opus 4.8 --- Assets/Scenes/Main.unity | 40 ++++++-- Assets/Scripts/GameModeManager.cs | 61 +++++++++++ Assets/Scripts/MatchManager.cs | 75 +++++++------- Assets/Scripts/OnlineSessionBrowserUI.cs | 123 ++++++++++++++++++++++- 4 files changed, 248 insertions(+), 51 deletions(-) diff --git a/Assets/Scenes/Main.unity b/Assets/Scenes/Main.unity index 6000858..8cf54c3 100644 --- a/Assets/Scenes/Main.unity +++ b/Assets/Scenes/Main.unity @@ -678,6 +678,11 @@ MonoBehaviour: m_PostInfinity: 2 m_RotationOrder: 4 CustomBlends: {fileID: 11400000, guid: 47b57a7fe51fd154e975311ad293878a, type: 2} +--- !u!1 &499812162 stripped +GameObject: + m_CorrespondingSourceObject: {fileID: 534773943941885308, guid: 940219f29a5971947be1767af2ec5a71, type: 3} + m_PrefabInstance: {fileID: 8599056809828915506} + m_PrefabAsset: {fileID: 0} --- !u!224 &499812163 stripped RectTransform: m_CorrespondingSourceObject: {fileID: 4566662197198005404, guid: 940219f29a5971947be1767af2ec5a71, type: 3} @@ -819,6 +824,7 @@ MonoBehaviour: listContent: {fileID: 764069389} entryTemplate: {fileID: 1675823052} statusLabel: {fileID: 2102676159} + panelRoot: {fileID: 499812162} selectedTint: {r: 0.26, g: 0.52, b: 0.96, a: 1} normalTint: {r: 1, g: 1, b: 1, a: 0.05} --- !u!114 &701418504 stripped @@ -1373,7 +1379,7 @@ MonoBehaviour: m_OnCullStateChanged: m_PersistentCalls: m_Calls: [] - m_text: Idle + m_text: m_isRightToLeft: 0 m_fontAsset: {fileID: 11400000, guid: 964ae5cd392ba08499b88de012ff8213, type: 2} m_sharedMaterial: {fileID: 1752619484} @@ -1409,8 +1415,8 @@ MonoBehaviour: m_fontSizeMin: 18 m_fontSizeMax: 72 m_fontStyle: 0 - m_HorizontalAlignment: 2 - m_VerticalAlignment: 8192 + m_HorizontalAlignment: 1 + m_VerticalAlignment: 2048 m_textAlignment: 65535 m_characterSpacing: 0 m_characterHorizontalScale: 1 @@ -1421,7 +1427,7 @@ MonoBehaviour: m_charWidthMaxAdj: 0 m_TextWrappingMode: 1 m_wordWrappingRatios: 0.4 - m_overflowMode: 0 + m_overflowMode: 1 m_linkedTextComponent: {fileID: 0} parentLinkedComponent: {fileID: 0} m_enableKerning: 0 @@ -1441,7 +1447,7 @@ MonoBehaviour: m_VertexBufferAutoSizeReduction: 0 m_useMaxVisibleDescender: 1 m_pageToDisplay: 1 - m_margin: {x: 0, y: 0, z: 0, w: 0} + m_margin: {x: 0, y: 0, z: 0, w: -27.158808} m_isUsingLegacyAnimationComponent: 0 m_isVolumetricText: 0 m_hasFontAssetChanged: 0 @@ -1471,7 +1477,7 @@ RectTransform: m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 0} - m_AnchoredPosition: {x: 0, y: -4} + m_AnchoredPosition: {x: 0, y: 96} m_SizeDelta: {x: -16, y: 24} m_Pivot: {x: 0.5, y: 1} --- !u!1001 &8599056809828915506 @@ -1492,7 +1498,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 458161408064890808, guid: 940219f29a5971947be1767af2ec5a71, type: 3} propertyPath: m_SizeDelta.x - value: 0 + value: 58.7 objectReference: {fileID: 0} - target: {fileID: 458161408064890808, guid: 940219f29a5971947be1767af2ec5a71, type: 3} propertyPath: m_SizeDelta.y @@ -1510,6 +1516,14 @@ PrefabInstance: propertyPath: m_Name value: Online Session Manager objectReference: {fileID: 0} + - target: {fileID: 1117235872924133776, guid: 940219f29a5971947be1767af2ec5a71, type: 3} + propertyPath: m_SizeDelta.y + value: -134 + objectReference: {fileID: 0} + - target: {fileID: 1117235872924133776, guid: 940219f29a5971947be1767af2ec5a71, type: 3} + propertyPath: m_AnchoredPosition.y + value: 35 + objectReference: {fileID: 0} - target: {fileID: 1124549748001373863, guid: 940219f29a5971947be1767af2ec5a71, type: 3} propertyPath: m_AnchorMax.y value: 0 @@ -1520,7 +1534,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 1124549748001373863, guid: 940219f29a5971947be1767af2ec5a71, type: 3} propertyPath: m_SizeDelta.x - value: 0 + value: 154.8 objectReference: {fileID: 0} - target: {fileID: 1124549748001373863, guid: 940219f29a5971947be1767af2ec5a71, type: 3} propertyPath: m_SizeDelta.y @@ -1544,7 +1558,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 2926165666280585152, guid: 940219f29a5971947be1767af2ec5a71, type: 3} propertyPath: m_SizeDelta.x - value: 0 + value: 65.4 objectReference: {fileID: 0} - target: {fileID: 2926165666280585152, guid: 940219f29a5971947be1767af2ec5a71, type: 3} propertyPath: m_SizeDelta.y @@ -1666,6 +1680,10 @@ PrefabInstance: propertyPath: m_SizeDelta.y value: 0 objectReference: {fileID: 0} + - target: {fileID: 8080141125004544176, guid: 940219f29a5971947be1767af2ec5a71, type: 3} + propertyPath: m_ChildControlWidth + value: 0 + objectReference: {fileID: 0} - target: {fileID: 8110497960478258844, guid: 940219f29a5971947be1767af2ec5a71, type: 3} propertyPath: m_fontMaterial value: @@ -1700,7 +1718,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 8555406533395562537, guid: 940219f29a5971947be1767af2ec5a71, type: 3} propertyPath: m_SizeDelta.x - value: 0 + value: 87.1 objectReference: {fileID: 0} - target: {fileID: 8555406533395562537, guid: 940219f29a5971947be1767af2ec5a71, type: 3} propertyPath: m_SizeDelta.y @@ -1718,7 +1736,7 @@ PrefabInstance: m_RemovedGameObjects: [] m_AddedGameObjects: - targetCorrespondingSourceObject: {fileID: 4566662197198005404, guid: 940219f29a5971947be1767af2ec5a71, type: 3} - insertIndex: -1 + insertIndex: 1 addedObject: {fileID: 2102676161} m_AddedComponents: - targetCorrespondingSourceObject: {fileID: 1040229641786897100, guid: 940219f29a5971947be1767af2ec5a71, type: 3} diff --git a/Assets/Scripts/GameModeManager.cs b/Assets/Scripts/GameModeManager.cs index 9fea14b..bdfe957 100644 --- a/Assets/Scripts/GameModeManager.cs +++ b/Assets/Scripts/GameModeManager.cs @@ -63,6 +63,11 @@ public class GameModeManager : MonoBehaviour private readonly List _modes = new List(); private int _activeIndex = -1; + private bool _paused; + private int _resumeIndex = -1; + + /// True while local gameplay is suspended for a modal overlay (see ). + public bool IsPaused => _paused; /// The currently active mode (null before startup). public GameMode ActiveMode => @@ -145,6 +150,9 @@ public class GameModeManager : MonoBehaviour /// public void BindSpectator() { + _paused = false; + _resumeIndex = -1; + player = null; painter = null; PainterChanged?.Invoke(null); @@ -165,6 +173,10 @@ public class GameModeManager : MonoBehaviour { if (!localPlayer) return; + // A fresh bind is a definitive state reset - drop any modal pause so the new avatar starts live. + _paused = false; + _resumeIndex = -1; + var setup = localPlayer.GetComponent(); player = localPlayer; @@ -216,6 +228,9 @@ public class GameModeManager : MonoBehaviour /// public void ReleaseToNoPlayer() { + _paused = false; + _resumeIndex = -1; + ActiveMode?.Exit(); _activeIndex = -1; _modes.Clear(); @@ -236,8 +251,54 @@ public class GameModeManager : MonoBehaviour Cursor.visible = true; } + /// + /// Pauses or resumes local gameplay for a full-screen modal overlay (the online session panel, opened + /// with Esc in the lobby/map). Pausing frees the OS cursor and suppresses ALL local input - movement, + /// painting, camera look and the mode-switch key - WITHOUT tearing down the mode cycle, so resuming + /// re-enters the exact mode that was active and restores its camera/cursor state. It mirrors the + /// cursor handling (Exit the active mode, then park the orbit rig in + /// Disabled so it stops re-grabbing the cursor every frame) but is reversible. No-op if unchanged. + /// + public void SetPaused(bool paused) + { + if (paused == _paused) return; + _paused = paused; + + if (paused) + { + _resumeIndex = _activeIndex; + ActiveMode?.Exit(); // undo any FreeCam/First-Person takeover (also releases the cursor) + _activeIndex = -1; + + // Painting and movement run off their own flags/Update loops and the orbit rig re-asserts the + // cursor each frame from its LookMode, so stop them all explicitly, then free the cursor. A + // Disabled look-mode keeps the cursor free every frame (the same trick ReleaseToNoPlayer uses). + if (player) player.ControlEnabled = false; + if (painter) painter.PaintingEnabled = false; + if (cameraInput) + { + cameraInput.enabled = true; + cameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.Disabled; + } + Cursor.lockState = CursorLockMode.None; + Cursor.visible = true; + + ModeChanged?.Invoke(null); // hide mode-following UI (the paint HUD) beneath the overlay + } + else + { + int resume = _resumeIndex; + _resumeIndex = -1; + if (resume >= 0 && resume < _modes.Count) + SwitchTo(resume); // re-Enter restores player/painter/look + cursor (+ re-shows the HUD) + else + ModeChanged?.Invoke(ActiveMode); + } + } + private void Update() { + if (_paused) return; if (_modes.Count == 0) return; if (WasSwitchPressed()) diff --git a/Assets/Scripts/MatchManager.cs b/Assets/Scripts/MatchManager.cs index 12f41bd..7bc4e83 100644 --- a/Assets/Scripts/MatchManager.cs +++ b/Assets/Scripts/MatchManager.cs @@ -4,9 +4,6 @@ using Unity.Collections; using Unity.Netcode; using UnityEngine; using UnityEngine.SceneManagement; -#if ENABLE_INPUT_SYSTEM -using UnityEngine.InputSystem; -#endif /// /// Server-authoritative match flow. Lives on the persistent NetworkManager object in the boot scene @@ -37,17 +34,19 @@ using UnityEngine.InputSystem; /// (strictly serialized - an NGO constraint, only one scene op in flight); then every player object is /// despawned and respawned as its role's prefab variant. /// -/// Match-control AUTHORITY: the client allowed to start (walk into the trigger) and end (Esc) the match. +/// Match-control AUTHORITY: the client allowed to start (walk into the trigger) and end the match. /// A host has its own player, so the host is the authority. A DEDICATED server (StartServer, no local /// player) hands authority to the FIRST client that connects, so a server nobody plays on is still /// controllable. The id is server-local; the designated remote client is told via a named message so it -/// reads Esc locally and asks the server (which validates the sender) to end. If the authority leaves, -/// it is reassigned to another connected client. +/// can ask the server (which validates the sender) to end. If the authority leaves, it is reassigned to +/// another connected client. /// /// End / join / teardown: -/// - The match authority presses Esc -> : Test Map is swapped back to Lobby and -/// everyone is respawned as the neutral Player. The host/server ends directly; a remote authority -/// client requests it over a named message that only the authority's id is honored for. +/// - The match authority ends the match via -> +/// (driven by the online session panel's "Back to Lobby" button - Esc opens that panel rather than +/// ending directly). Test Map is swapped back to Lobby and everyone is respawned as the neutral Player. +/// The host/server ends directly; a remote authority client requests it over a named message that only +/// the authority's id is honored for. A non-authority client's panel button is a plain disconnect. /// - A client that joins MID-MATCH spawns as a SPECTATOR: a dedicated EMPTY prefab (no body/collider) /// bound to free-cam-only, so it flies around the running map and watches but does not play. It /// respawns as a neutral lobby Player when the match ends. @@ -244,35 +243,6 @@ public class MatchManager : MonoBehaviour ReturnToMainOnly(); } - // Esc ends the running match (sends everyone back to the lobby) - but only for the match AUTHORITY. - // The host/server reads it locally and ends directly. A remote authority client (dedicated server) - // can't end the match itself (the flow is server-authoritative), so it asks the server over a named - // message; the server validates the sender is the authority and that a match is running. - private void Update() - { - if (_net == null) return; - - if (_net.IsServer) - { - if (Phase == MatchPhase.InMatch && IsMatchAuthority(_net.LocalClientId) && EscapePressed()) - EndMatch(); - return; - } - - // Pure client: Phase is server-local, so we don't gate on it here - the server checks it on receipt. - if (_isLocalAuthority && EscapePressed()) - RequestEndMatchFromServer(); - } - - private static bool EscapePressed() - { -#if ENABLE_INPUT_SYSTEM - return Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame; -#else - return Input.GetKeyDown(KeyCode.Escape); -#endif - } - private void HookSceneManager() { if (_hooked || _net == null || _net.SceneManager == null) return; @@ -527,6 +497,35 @@ public class MatchManager : MonoBehaviour /// True if the given client currently holds match-control authority (may start and end the match). public bool IsMatchAuthority(ulong clientId) => _hasAuthorityClient && clientId == _authorityClientId; + /// True if THIS machine holds match authority - the host (whose own client id is the authority) + /// or the granted authority client on a dedicated server. Drives the online panel's Disconnect button: + /// on the map the authority's button means "Back to Lobby" (end the match); everyone else disconnects. + public bool IsLocalMatchAuthority + { + get + { + if (_net == null) return false; + if (_net.IsServer) return IsMatchAuthority(_net.LocalClientId); + return _isLocalAuthority; + } + } + + /// Ends the running match for everyone (back to the Lobby), routed through authority: the + /// host/server ends directly; a remote authority client asks the server. No-op if this machine is not + /// the authority or no match is running. Replaces the old Esc handler - now driven by the panel button. + public void RequestEndMatch() + { + if (_net == null) return; + if (_net.IsServer) { EndMatch(); return; } // EndMatch itself guards Phase == InMatch + if (_isLocalAuthority) RequestEndMatchFromServer(); + } + + // Local active-scene kind. Every peer sets its active scene to Lobby/Map as those load (see OnSceneEvent) + // and back to Main on teardown, so a local UI can tell where it is without the server-local Phase. + public bool IsLocalSceneMain => SceneManager.GetActiveScene().name == mainSceneName; + public bool IsLocalSceneLobby => SceneManager.GetActiveScene().name == lobbySceneName; + public bool IsLocalSceneMap => SceneManager.GetActiveScene().name == matchSceneName; + // Server -> client: tell the designated client it is the match authority (so it reads Esc locally). private void SendAuthorityGrant(ulong clientId) { diff --git a/Assets/Scripts/OnlineSessionBrowserUI.cs b/Assets/Scripts/OnlineSessionBrowserUI.cs index 19b50c1..e0cafea 100644 --- a/Assets/Scripts/OnlineSessionBrowserUI.cs +++ b/Assets/Scripts/OnlineSessionBrowserUI.cs @@ -1,7 +1,11 @@ using System.Collections.Generic; using TMPro; using UnityEngine; +using UnityEngine.SceneManagement; using UnityEngine.UI; +#if ENABLE_INPUT_SYSTEM +using UnityEngine.InputSystem; +#endif /// /// uGUI binding for . Drives the server-browser canvas - Host / Refresh / @@ -13,6 +17,13 @@ using UnityEngine.UI; /// Selection model: Refresh fills the list with clones of (each a clickable row /// showing name + player count). Clicking a row selects it; Join connects to the selected session. While /// connected, only Disconnect is interactable. +/// +/// Overlay behaviour: the panel is always shown in Main (not connected). Once a connection is up it hides; +/// in the lobby/map Esc toggles it back as a modal pause overlay (cursor freed, local gameplay suspended via +/// ). On the map the Disconnect button becomes "Back to Lobby" for the +/// match authority (ends the match), while everyone else (in the lobby, or a non-authority client on the map) +/// 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. /// public class OnlineSessionBrowserUI : MonoBehaviour { @@ -33,6 +44,11 @@ public class OnlineSessionBrowserUI : MonoBehaviour [Header("Status")] [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.")] + [SerializeField] private GameObject panelRoot; + [Tooltip("Background tint of the selected row.")] [SerializeField] private Color selectedTint = new Color(0.26f, 0.52f, 0.96f, 1f); [Tooltip("Background tint of an unselected row.")] @@ -42,10 +58,22 @@ public class OnlineSessionBrowserUI : MonoBehaviour private readonly List _rows = new List(); private string _selectedId; + private MatchManager _match; // local match state (scene kind + authority); found at runtime + 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 _connected; // last-seen connection state (to detect transitions) + private string _lastScene; // last-seen active scene name (to auto-close on a scene change) + private string _disconnectText; // last applied caption (avoids per-frame TMP churn) + private void Awake() { // The template is the prototype, never shown itself - layout ignores it while disabled. if (entryTemplate) entryTemplate.SetActive(false); + + _match = FindFirstObjectByType(); + _gameMode = FindFirstObjectByType(); } private void OnEnable() @@ -53,7 +81,11 @@ public class OnlineSessionBrowserUI : MonoBehaviour if (hostButton) hostButton.onClick.AddListener(OnHost); if (refreshButton) refreshButton.onClick.AddListener(OnRefresh); if (joinButton) joinButton.onClick.AddListener(OnJoin); - if (disconnectButton) disconnectButton.onClick.AddListener(OnDisconnect); + if (disconnectButton) + { + disconnectButton.onClick.AddListener(OnDisconnect); + _disconnectLabel = disconnectButton.GetComponentInChildren(true); + } if (manager) { @@ -61,8 +93,10 @@ public class OnlineSessionBrowserUI : MonoBehaviour manager.SessionsChanged += RebuildList; } + _connected = manager && manager.IsConnected; RebuildList(); OnManagerChanged(); + ApplyVisibility(); } private void OnDisable() @@ -79,16 +113,92 @@ public class OnlineSessionBrowserUI : MonoBehaviour } } + private void Update() + { + if (!manager) return; + bool connected = manager.IsConnected; + + // Auto-close the overlay if the scene changed underneath it (a remote ended/started the match, or + // we walked into Start Match) so it never lingers as a stale pause across a transition. + string scene = SceneManager.GetActiveScene().name; + if (connected && _open && _lastScene != null && scene != _lastScene) + SetOpen(false); + _lastScene = scene; + + if (!connected) return; // Main: panel always shown, Esc ignored + + bool inLobbyOrMap = _match && (_match.IsLocalSceneLobby || _match.IsLocalSceneMap); + if (inLobbyOrMap && EscapePressed()) + SetOpen(!_open); + + RefreshDisconnectLabel(); + } + 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); } - private void OnDisconnect() { if (manager) manager.Disconnect(); } + + private void OnDisconnect() + { + if (!manager) return; + + // On the map, the match AUTHORITY's button means "Back to Lobby" (end the match for everyone) + // rather than leaving the session. Everyone else - in the lobby, or a non-authority client on the + // map - does a plain disconnect. + if (_match && _match.IsLocalSceneMap && _match.IsLocalMatchAuthority) + { + _match.RequestEndMatch(); + SetOpen(false); // heading back to the lobby; close the overlay + resume input + return; + } + + manager.Disconnect(); + } private void OnManagerChanged() { if (!manager) return; + + bool connected = manager.IsConnected; + if (connected != _connected) + { + _connected = connected; + // Connection up (hosted/joined) -> hide the panel; Esc reopens it in the lobby/map. + // Back to Main (disconnect/shutdown) -> show it again and drop any pause. + _open = false; + if (!connected && _gameMode) _gameMode.SetPaused(false); + ApplyVisibility(); + } + if (statusLabel) statusLabel.text = manager.Status; UpdateButtons(); + RefreshDisconnectLabel(); + } + + private void SetOpen(bool open) + { + _open = open; + ApplyVisibility(); + if (_gameMode) _gameMode.SetPaused(open); + } + + private void ApplyVisibility() + { + bool show = !(manager && manager.IsConnected) || _open; + if (panelRoot && panelRoot.activeSelf != show) + panelRoot.SetActive(show); + } + + private void RefreshDisconnectLabel() + { + if (!_disconnectLabel) return; + bool backToLobby = _match && _match.IsLocalSceneMap && _match.IsLocalMatchAuthority; + string text = backToLobby ? "Back to Lobby" : "Disconnect"; + if (text != _disconnectText) + { + _disconnectText = text; + _disconnectLabel.text = text; + } } private void UpdateButtons() @@ -165,4 +275,13 @@ public class OnlineSessionBrowserUI : MonoBehaviour var t = root.Find(childName); return t ? t.GetComponent() : null; } + + private static bool EscapePressed() + { +#if ENABLE_INPUT_SYSTEM + return Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame; +#else + return Input.GetKeyDown(KeyCode.Escape); +#endif + } }