Compare commits
2 Commits
8ffde98fdd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| acf0ef6be5 | |||
| 0cf1f92eaa |
@@ -0,0 +1,128 @@
|
|||||||
|
# UI Navigation Design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The four scenarios
|
||||||
|
|
||||||
|
1. **Two-panel navigation** — left sidebar selects a tab, right panel shows content; left/right crosses panels
|
||||||
|
2. **Modal focus** — when a modal opens the parent stops receiving input; closing restores it
|
||||||
|
3. **Nested modals** — multiple stacked modals; each blocks the one below; back unwinds the stack
|
||||||
|
4. **Mouse handling** — only the topmost active layer accepts clicks; background layers are blocked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core concept: Focus Stack
|
||||||
|
|
||||||
|
`UIFocusStack` (`ui/UIFocusStack.gd`) is a `RefCounted` that tracks an ordered stack of open `ClosableMenu` layers. Only the topmost layer processes input. When a layer is pushed, the one below is paused via `set_process_unhandled_input(false)`; when popped, it resumes. The topmost layer always renders on top — `z_index` is set automatically on push/pop so visual order always matches input priority.
|
||||||
|
|
||||||
|
Lives at `UI.FOCUS_STACK`. Key methods: `push(layer)`, `pop()`, `top() -> ClosableMenu`, `isTop(layer) -> bool`. Emits `activeLayerChanged(layer)` whenever the top changes; `null` means the stack is empty (world has focus).
|
||||||
|
|
||||||
|
```
|
||||||
|
Stack (bottom → top):
|
||||||
|
[GameMenu] z_index 10 ← paused while QuitDialog is open
|
||||||
|
[QuitConfirmDialog] z_index 20 ← top, owns input, renders on top
|
||||||
|
[ModalBackdrop] z_index 15 ← sits between them visually
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ClosableMenu
|
||||||
|
|
||||||
|
`ClosableMenu` (`ui/component/ClosableMenu.gd`) is the base for all interactive menus. Key additions:
|
||||||
|
|
||||||
|
- **`canClose:bool = true`** — when true, `open()`/`close()` push/pop the FocusStack. When false, the menu is purely passive (shown/hidden by code, no input ownership). Set in the Inspector or overridden in `_ready()`.
|
||||||
|
- **`focusGained` / `focusLost` signals** — emitted when the stack pushes/pops this layer.
|
||||||
|
- **`_savedFocusNode`** — the focused Control captured in `_onFocusLost()`. Restored in `_onFocusGained()` so that pressing back from a dialog returns focus to the button that triggered it.
|
||||||
|
- **`_grabInitialFocus()`** — override in subclasses to place focus on the right element when first opened (when no saved node exists).
|
||||||
|
- `open()` sets `visible = true`, pushes to stack (if `canClose`), then emits `opened`.
|
||||||
|
- `close()` pops from stack (if `canClose`), hides, then emits `closed`.
|
||||||
|
- `_ready()` calls `set_process_unhandled_input(false)` for `canClose` menus — they start silenced and only enable input when on top of the stack.
|
||||||
|
|
||||||
|
| Menu | canClose | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| `GameMenu` | `true` | Player opens and closes it |
|
||||||
|
| `ConfirmDialog` | `true` | Player dismisses it |
|
||||||
|
| `PauseMenu` | `true` | Player opens/closes via pause bind |
|
||||||
|
| `DialogueTextbox` | `false` | Dialogue system controls its lifetime |
|
||||||
|
| `PauseSettings` | n/a | Does not extend ClosableMenu — internal sub-panel |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Z-indexing
|
||||||
|
|
||||||
|
`UIFocusStack` is the sole owner of `z_index` for all `ClosableMenu` layers. On push, `z_index = stack_depth * 10`. On pop, `z_index` resets to 0. `ModalBackdrop` always sits at `(top z_index) - 5`.
|
||||||
|
|
||||||
|
Never set `z_index` manually on a ClosableMenu — the stack manages it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ModalBackdrop
|
||||||
|
|
||||||
|
`ModalBackdrop` (`ui/component/ModalBackdrop.gd`) connects to `UI.FOCUS_STACK.activeLayerChanged` in `_ready()`. When a layer is active it becomes visible, sets `mouse_filter = MOUSE_FILTER_STOP` (blocking all clicks on anything behind it), and sets its own `z_index` to `(top layer z_index) - 5`. When the stack empties it hides and resets to `MOUSE_FILTER_IGNORE`.
|
||||||
|
|
||||||
|
The `register()` method and `_openOverlays` tracking are removed — the FocusStack signal replaces that entirely. `RootUI._ready()` no longer calls `register()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 1: Two-Panel Navigation (SidebarMenu)
|
||||||
|
|
||||||
|
`SidebarMenu` (to be created at `ui/component/SidebarMenu.gd`) extends `ClosableMenu` and manages two internal panels: a left sidebar (tab selector) and a right content panel. An `_activePanel` enum (`SIDEBAR` / `CONTENT`) tracks which panel currently owns controller/keyboard navigation. Both panels live in the same focus layer — no stack push/pop when crossing between them.
|
||||||
|
|
||||||
|
**Controller / keyboard** — routed through `_unhandled_input`, which checks `_activePanel`:
|
||||||
|
- While `SIDEBAR`: UP/DOWN navigate sidebar items and update the content preview. RIGHT or ACCEPT calls `_enterContent()`. BACK closes the menu.
|
||||||
|
- While `CONTENT`: UP/DOWN navigate content items (wraps). LEFT or BACK calls `_exitContent()`. ACCEPT activates the item.
|
||||||
|
|
||||||
|
**Mouse** — ignores `_activePanel` entirely. `pressed` signals on items fire regardless of which panel the controller is in. Each item's press handler calls `_enterContent()` or `_exitContent()` as appropriate before processing the selection — these are no-ops if the panel is already active.
|
||||||
|
|
||||||
|
**ContentPanel protocol** — each right-panel tab must implement `grabFirstFocus()`, `releaseFocus()`, and `getSelectedIndex() -> int`. No enforced base class; convention only.
|
||||||
|
|
||||||
|
`GameMenu` will extend `SidebarMenu` once SidebarMenu is built.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 2 & 3: Modal Focus and Nested Modals
|
||||||
|
|
||||||
|
Both are the same mechanism — one push vs. multiple. Opening a sub-layer calls `layer.open()` which pushes it; the layer below automatically loses input. Closing calls `close()` which pops; the layer below automatically resumes and `_savedFocusNode` is restored to wherever focus was when the sub-layer opened.
|
||||||
|
|
||||||
|
For 3-deep nesting (`MainMenu → LoadGameModal → ConfirmDialog`), each BACK unwinds one level. No special logic — the stack handles it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 4: Mouse Handling
|
||||||
|
|
||||||
|
`ModalBackdrop` with `MOUSE_FILTER_STOP` eats all mouse events aimed at anything behind it. The topmost layer (higher `z_index`) renders above the backdrop and receives clicks normally. Works at any nesting depth.
|
||||||
|
|
||||||
|
Within a `SidebarMenu`, both panels are in the same layer so no backdrop is between them — mouse clicks always work on either panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## World input and movement blocking
|
||||||
|
|
||||||
|
`EntityMovement._canMove()` and `OverworldCamera._canOrbit()` now check `UI.FOCUS_STACK.top() != null` — if any layer is active, movement and camera orbit are blocked. This replaces the previous ad-hoc `UI.GAME_MENU.isOpen()` check.
|
||||||
|
|
||||||
|
`UI.activeConversation` is kept separately for dialogue-mode blocking (dialogue does not use the FocusStack).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What was changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `ui/UIFocusStack.gd` | New — FocusStack manager |
|
||||||
|
| `ui/component/ClosableMenu.gd` | Added `canClose`, focus signals, `_onFocusGained/Lost`, `_savedFocusNode`, `_grabInitialFocus` |
|
||||||
|
| `ui/UISingleton.gd` | Added `FOCUS_STACK` (initialized via preload in `_ready`) |
|
||||||
|
| `ui/component/ModalBackdrop.gd` | Rewritten — connects to FocusStack signal, sets `MOUSE_FILTER_STOP` and `z_index` |
|
||||||
|
| `ui/RootUI.gd` | Removed `modalBackdrop.register()` calls |
|
||||||
|
| `ui/component/ConfirmDialog.gd` | Extends ClosableMenu; `_grabInitialFocus` focuses No button; removed `!isOpen` guard |
|
||||||
|
| `ui/gamemenu/GameMenu.gd` | Extends ClosableMenu; uses `_grabInitialFocus`; "menu" toggle via `_input` |
|
||||||
|
| `ui/pause/PauseMenu.gd` | Extends ClosableMenu; removed visibility/dialog guards from `_unhandled_input` |
|
||||||
|
| `ui/pause/PauseSettings.gd` | Unchanged — stays as Control (internal sub-panel, not in stack) |
|
||||||
|
| `ui/mainmenu/MainMenu.gd` | `settingsMenu.open()` replaces direct `isOpen` set; removed `_onSettingsOpened` stub |
|
||||||
|
| `scene/Pause.gd` | `menu.isOpen()` → `menu.isOpen` (property) |
|
||||||
|
| `overworld/entity/EntityMovement.gd` | `_canMove` checks `FOCUS_STACK.top() != null` |
|
||||||
|
| `overworld/camera/OverworldCamera.gd` | `_canOrbit` checks `FOCUS_STACK.top() != null` |
|
||||||
|
|
||||||
|
## Still to implement
|
||||||
|
|
||||||
|
- `SidebarMenu` base class
|
||||||
|
- `GameMenu` refactored to extend `SidebarMenu`
|
||||||
+49
-38
@@ -10,7 +10,7 @@ RootUI (Control, fullscreen, always visible)
|
|||||||
│ └── GameMenuItemsTab
|
│ └── GameMenuItemsTab
|
||||||
├── ChatBoxContainer
|
├── ChatBoxContainer
|
||||||
│ └── InteractIndicator
|
│ └── InteractIndicator
|
||||||
├── ModalBackdrop ← shared backdrop; repositions dynamically
|
├── ModalBackdrop ← shared backdrop; z_index managed by FocusStack
|
||||||
├── PauseMenu
|
├── PauseMenu
|
||||||
│ ├── PauseMain
|
│ ├── PauseMain
|
||||||
│ └── PauseSettings
|
│ └── PauseSettings
|
||||||
@@ -18,7 +18,7 @@ RootUI (Control, fullscreen, always visible)
|
|||||||
└── MainMenuConfirmDialog
|
└── MainMenuConfirmDialog
|
||||||
```
|
```
|
||||||
|
|
||||||
Child order matters — later siblings render on top. `ModalBackdrop` shifts its own position in the tree at runtime to sit just below whichever modal overlay is currently topmost (see [ModalBackdrop](#modalbackdrop)).
|
Child order matters — later siblings render on top. `ModalBackdrop` stays at a fixed tree position; its `z_index` is driven automatically by the FocusStack (see [ModalBackdrop](#modalbackdrop)).
|
||||||
|
|
||||||
`RootUI` is a permanent child of `RootScene` and registers itself with the `UI` singleton on `_enter_tree`. Access its children through the `UI` singleton.
|
`RootUI` is a permanent child of `RootScene` and registers itself with the `UI` singleton on `_enter_tree`. Access its children through the `UI` singleton.
|
||||||
|
|
||||||
@@ -33,24 +33,38 @@ Child order matters — later siblings render on top. `ModalBackdrop` shifts its
|
|||||||
| `UI.PAUSE_MENU` | `PauseMenu` | Pause overlay; also pauses the scene tree |
|
| `UI.PAUSE_MENU` | `PauseMenu` | Pause overlay; also pauses the scene tree |
|
||||||
| `UI.QUIT_DIALOG` | `QuitConfirmDialog` | "Quit to desktop?" confirm; call `.open()` to show |
|
| `UI.QUIT_DIALOG` | `QuitConfirmDialog` | "Quit to desktop?" confirm; call `.open()` to show |
|
||||||
| `UI.MAIN_MENU_DIALOG` | `ConfirmDialog` | "Return to main menu?" confirm; call `.open()` to show |
|
| `UI.MAIN_MENU_DIALOG` | `ConfirmDialog` | "Return to main menu?" confirm; call `.open()` to show |
|
||||||
| `UI.BACKDROP` | `ModalBackdrop` | Shared semi-transparent backdrop; managed automatically |
|
| `UI.FOCUS_STACK` | `UIFocusStack` | Ordered stack of open `ClosableMenu` layers; only the top layer processes input |
|
||||||
|
| `UI.BACKDROP` | `ModalBackdrop` | Shared semi-transparent backdrop; driven by `FOCUS_STACK.activeLayerChanged` |
|
||||||
| `UI.dialogueActive` | `bool` | `true` for the entire duration of a `DialogueAction` |
|
| `UI.dialogueActive` | `bool` | `true` for the entire duration of a `DialogueAction` |
|
||||||
| `UI.activeConversation` | `bool` | `true` only during a `CONVERSATION`-mode dialogue |
|
| `UI.activeConversation` | `bool` | `true` only during a `CONVERSATION`-mode dialogue |
|
||||||
| `UI.chatBoxContainer` | `Control` | Parent node for world-space dialogue textboxes |
|
| `UI.chatBoxContainer` | `Control` | Parent node for world-space dialogue textboxes |
|
||||||
|
|
||||||
`dialogueActive` is set by `DialogueManager` signals — it is broader than any single textbox being visible. Movement blocks on `dialogueActive` and on `UI.GAME_MENU.isOpen()`.
|
`dialogueActive` is set by `DialogueManager` signals — it is broader than any single textbox being visible. Movement and camera orbit block when `UI.FOCUS_STACK.top() != null` or `UI.activeConversation` is true.
|
||||||
|
|
||||||
## ClosableMenu
|
## ClosableMenu
|
||||||
|
|
||||||
Base class for any togglable panel. Extends `Control`; the `isOpen:bool` export drives `visible`.
|
Base class for all togglable panels. Extends `Control`; the `isOpen:bool` export drives `visible`.
|
||||||
|
|
||||||
```gdscript
|
```gdscript
|
||||||
menu.open() # shows, emits opened
|
menu.open() # shows, pushes to FocusStack (if canClose), emits opened
|
||||||
menu.close() # hides, emits closed
|
menu.close() # pops from FocusStack (if canClose), hides, emits closed
|
||||||
menu.toggle()
|
menu.toggle()
|
||||||
```
|
```
|
||||||
|
|
||||||
Signals: `opened`, `closed`.
|
**Key exports / properties:**
|
||||||
|
|
||||||
|
| Name | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `isOpen:bool` | `false` | Property — read directly (`menu.isOpen`), not a method |
|
||||||
|
| `canClose:bool` | `true` | When true, open/close interact with `UI.FOCUS_STACK` and input is only processed while on top. When false, the menu is passive — shown/hidden externally, never enters the stack. |
|
||||||
|
|
||||||
|
**Signals:** `opened`, `closed`, `focusGained`, `focusLost`.
|
||||||
|
|
||||||
|
**Focus management** (only relevant when `canClose = true`):
|
||||||
|
- `_grabInitialFocus()` — virtual; override to place focus on the correct element on first open.
|
||||||
|
- `_savedFocusNode` — focus owner is captured on `_onFocusLost()` and restored on `_onFocusGained()`, so pressing back from a sub-dialog returns focus to the button that opened it.
|
||||||
|
- A `gui_focus_changed` focus trap runs while the layer is on top — if focus escapes to a node outside this layer, it is snapped back.
|
||||||
|
- `set_process_unhandled_input(false)` is set in `_ready()` for canClose menus; input is only re-enabled via `_onFocusGained()` while the layer is on top of the stack.
|
||||||
|
|
||||||
All new menus that need standard show/hide behaviour should extend `ClosableMenu`.
|
All new menus that need standard show/hide behaviour should extend `ClosableMenu`.
|
||||||
|
|
||||||
@@ -83,26 +97,20 @@ func _ready() -> void:
|
|||||||
1. Create a `.tscn` with `ConfirmDialog.gd` as script; set label text in the scene
|
1. Create a `.tscn` with `ConfirmDialog.gd` as script; set label text in the scene
|
||||||
2. Add `btnYes`/`btnNo` node path exports pointing to your two buttons
|
2. Add `btnYes`/`btnNo` node path exports pointing to your two buttons
|
||||||
3. Add the instance to `RootUI.tscn` after `PauseMenu` (so it renders on top)
|
3. Add the instance to `RootUI.tscn` after `PauseMenu` (so it renders on top)
|
||||||
4. Register it with the backdrop in `RootUI._ready()`: `modalBackdrop.register(myDialog)`
|
4. Expose via `RootUI.gd` export + `UISingleton.gd` accessor if other systems need it
|
||||||
5. Expose via `RootUI.gd` export + `UISingleton.gd` accessor if other systems need it
|
5. Connect `myDialog.confirmed` wherever the action should fire
|
||||||
6. Connect `myDialog.confirmed` wherever the action should fire
|
|
||||||
|
No manual backdrop registration needed — `ModalBackdrop` activates automatically when any `ClosableMenu` in RootUI enters the FocusStack.
|
||||||
|
|
||||||
## ModalBackdrop
|
## ModalBackdrop
|
||||||
|
|
||||||
`res://ui/component/ModalBackdrop.gd` — a single fullscreen semi-transparent `ColorRect` shared across all modal overlays.
|
`res://ui/component/ModalBackdrop.gd` — a single fullscreen semi-transparent `ColorRect` shared across all modal overlays.
|
||||||
|
|
||||||
**How it works:** each registered overlay's `opened`/`closed` signals are connected. When any overlay opens, the backdrop becomes visible and `move_child()`s itself in the scene tree to sit immediately before the highest-index open overlay. When all overlays close, it hides.
|
**How it works:** `ModalBackdrop` connects to `UI.FOCUS_STACK.activeLayerChanged` in `_ready()`. When a layer becomes active it checks whether that layer is a direct sibling (i.e. a child of RootUI). If yes: backdrop becomes visible, sets `mouse_filter = MOUSE_FILTER_STOP` (blocking all clicks on anything behind it), and sets `z_index = layer.z_index - 5`. If the top layer is from a different parent (e.g. settings inside the main menu scene), or the stack empties, the backdrop hides.
|
||||||
|
|
||||||
**Result:** only one backdrop is ever visible, and it always sits between the game world (or lower overlays) and the frontmost open overlay — even when overlays are stacked (e.g. `QuitConfirmDialog` over `PauseMenu`).
|
**Result:** only one backdrop is ever visible, it always renders between the game world and the frontmost RootUI-level overlay, and it blocks mouse events from reaching anything behind it.
|
||||||
|
|
||||||
**Registering a new overlay:**
|
No registration is required — `ModalBackdrop` is self-contained. Do not call `register()` on it.
|
||||||
|
|
||||||
```gdscript
|
|
||||||
# In RootUI._ready() — overlay must be a direct child of RootUI and have opened/closed signals
|
|
||||||
modalBackdrop.register(myOverlay)
|
|
||||||
```
|
|
||||||
|
|
||||||
`PauseMenu`, `QuitConfirmDialog`, and `MainMenuConfirmDialog` are all registered.
|
|
||||||
|
|
||||||
## AdvancedRichText
|
## AdvancedRichText
|
||||||
|
|
||||||
@@ -130,15 +138,15 @@ JRPG-style in-game menu at `res://ui/gamemenu/`. Open with the `menu` input (**T
|
|||||||
|
|
||||||
Both tabs are populated dynamically on open; call `refresh()` on the active tab directly if data changes while the menu is already open.
|
Both tabs are populated dynamically on open; call `refresh()` on the active tab directly if data changes while the menu is already open.
|
||||||
|
|
||||||
**Key methods on `GameMenu`:**
|
**Key members on `GameMenu`:**
|
||||||
|
|
||||||
| Method | Effect |
|
| Member | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `open()` | Shows menu, refreshes active tab, grabs sidebar focus |
|
| `open()` | Shows menu, refreshes active tab, pushes to FocusStack, grabs sidebar focus |
|
||||||
| `close()` | Hides menu |
|
| `close()` | Pops from FocusStack, hides menu |
|
||||||
| `isOpen() -> bool` | Visibility state |
|
| `isOpen:bool` | Property — read directly, not a method |
|
||||||
|
|
||||||
`ui_cancel` or `menu` closes the menu. The `menu` input opens it only when `UI.dialogueActive` is false and the textbox is closed.
|
`ui_cancel` or `menu` closes the menu. `menu` is handled in `_input` (always fires) and opens the menu only when `UI.FOCUS_STACK.top() == null` and `UI.dialogueActive` is false.
|
||||||
|
|
||||||
To add a new tab: add a value to `GameMenu.Tab`, create a tab scene/script under `ui/gamemenu/`, instance it in `GameMenu.tscn` as a sibling of the other tabs, add an `@export` for it in `GameMenu.gd`, and add the `match` branch in `_selectTab()`.
|
To add a new tab: add a value to `GameMenu.Tab`, create a tab scene/script under `ui/gamemenu/`, instance it in `GameMenu.tscn` as a sibling of the other tabs, add an `@export` for it in `GameMenu.gd`, and add the `match` branch in `_selectTab()`.
|
||||||
|
|
||||||
@@ -146,17 +154,18 @@ To add a new tab: add a value to `GameMenu.Tab`, create a tab scene/script under
|
|||||||
|
|
||||||
`PauseMenu` wraps `PauseMain` (button list) and `PauseSettings` (settings tabs). Opening it calls `get_tree().paused = true`; closing restores it.
|
`PauseMenu` wraps `PauseMain` (button list) and `PauseSettings` (settings tabs). Opening it calls `get_tree().paused = true`; closing restores it.
|
||||||
|
|
||||||
| Method | Effect |
|
| Member | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `PauseMenu.open()` | Pauses tree, shows container, opens PauseMain, emits `opened` |
|
| `PauseMenu.open()` | Pushes to FocusStack, pauses tree, opens PauseMain, emits `opened` |
|
||||||
| `PauseMenu.close()` | Unpauses tree, hides everything, emits `closed` |
|
| `PauseMenu.close()` | Unpauses tree, closes sub-panels, pops from FocusStack, emits `closed` |
|
||||||
| `PauseMenu.isOpen() -> bool` | Visibility state |
|
| `PauseMenu.isOpen:bool` | Property — read directly, not a method |
|
||||||
|
|
||||||
**`ui_cancel` behaviour inside PauseMenu:**
|
**`ui_cancel` behaviour inside PauseMenu:**
|
||||||
- If `QuitConfirmDialog` or `MainMenuConfirmDialog` is open → ignored (the dialog handles it)
|
|
||||||
- If `PauseSettings` is open → closes settings, reopens PauseMain
|
- If `PauseSettings` is open → closes settings, reopens PauseMain
|
||||||
- Otherwise → closes PauseMenu
|
- Otherwise → closes PauseMenu
|
||||||
|
|
||||||
|
(`QuitConfirmDialog` and `MainMenuConfirmDialog` sit above PauseMenu on the FocusStack and consume `ui_cancel` themselves — PauseMenu's `_unhandled_input` does not fire while they are open.)
|
||||||
|
|
||||||
**PauseMain buttons:**
|
**PauseMain buttons:**
|
||||||
|
|
||||||
| Button | Behaviour |
|
| Button | Behaviour |
|
||||||
@@ -166,9 +175,9 @@ To add a new tab: add a value to `GameMenu.Tab`, create a tab scene/script under
|
|||||||
| Main Menu | Opens `MainMenuConfirmDialog`; on confirm → `SCENE.setScene(INITIAL)` |
|
| Main Menu | Opens `MainMenuConfirmDialog`; on confirm → `SCENE.setScene(INITIAL)` |
|
||||||
| Quit Game | Opens `QuitConfirmDialog`; on confirm → `get_tree().quit()` |
|
| Quit Game | Opens `QuitConfirmDialog`; on confirm → `get_tree().quit()` |
|
||||||
|
|
||||||
When either confirm dialog cancels, focus returns to the button that opened it.
|
When either confirm dialog closes, focus automatically returns to the button that opened it via `_savedFocusNode` in the FocusStack.
|
||||||
|
|
||||||
**Cannot open on main menu:** `Pause.gd` checks `SCENE.currentScene == INITIAL` and skips opening when already closed.
|
**Cannot open on main menu:** `Pause.gd` checks `SCENE.currentScene == INITIAL` and skips opening.
|
||||||
|
|
||||||
## Main menu
|
## Main menu
|
||||||
|
|
||||||
@@ -204,7 +213,9 @@ Access via `UI.DEBUG_MENU`. Starts hidden.
|
|||||||
## Adding a new menu
|
## Adding a new menu
|
||||||
|
|
||||||
1. Create a scene whose root extends `ClosableMenu` (or `Control` if open/close isn't needed)
|
1. Create a scene whose root extends `ClosableMenu` (or `Control` if open/close isn't needed)
|
||||||
2. Add it as a child of `RootUI.tscn` — position after `PauseMenu` if it should render above it
|
2. Override `_grabInitialFocus()` to place focus on the first interactive element on open
|
||||||
3. Export a typed reference on `RootUI.gd` and wire it in the Inspector
|
3. Add it as a child of `RootUI.tscn` — position after `PauseMenu` if it should render above it
|
||||||
4. Expose via a getter on `UISingleton.gd` if other systems need access (follow the `PAUSE_MENU` / `GAME_MENU` pattern)
|
4. Export a typed reference on `RootUI.gd` and wire it in the Inspector
|
||||||
5. If it needs a backdrop, register it: `modalBackdrop.register(myMenu)` in `RootUI._ready()`
|
5. Expose via a getter on `UISingleton.gd` if other systems need access (follow the `PAUSE_MENU` / `GAME_MENU` pattern)
|
||||||
|
|
||||||
|
`ModalBackdrop` activates automatically for any `ClosableMenu` that is a direct child of `RootUI`. No registration step needed. Internal sub-panels (like `PauseSettings`) should extend `Control` directly and not enter the FocusStack.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Detailed reference lives in [.claude/docs/](.claude/docs/):
|
|||||||
- [Stubs](.claude/docs/stubs.md) — incomplete / placeholder systems to avoid relying on
|
- [Stubs](.claude/docs/stubs.md) — incomplete / placeholder systems to avoid relying on
|
||||||
- [Overworld](.claude/docs/overworld.md) — map transitions, Entity exports, interaction types, camera, movement
|
- [Overworld](.claude/docs/overworld.md) — map transitions, Entity exports, interaction types, camera, movement
|
||||||
- [UI](.claude/docs/ui.md) — UI singleton, VNTextbox, ClosableMenu, pause/debug/settings menus, AdvancedRichText
|
- [UI](.claude/docs/ui.md) — UI singleton, VNTextbox, ClosableMenu, pause/debug/settings menus, AdvancedRichText
|
||||||
|
- [UI Navigation](.claude/docs/ui-navigation.md) — FocusStack, FocusLayer, SidebarMenu, modal layering, mouse blocking (design doc — not yet implemented)
|
||||||
|
|
||||||
@.claude/docs/code-style.md
|
@.claude/docs/code-style.md
|
||||||
@.claude/docs/architecture.md
|
@.claude/docs/architecture.md
|
||||||
@@ -21,3 +22,4 @@ Detailed reference lives in [.claude/docs/](.claude/docs/):
|
|||||||
@.claude/docs/stubs.md
|
@.claude/docs/stubs.md
|
||||||
@.claude/docs/overworld.md
|
@.claude/docs/overworld.md
|
||||||
@.claude/docs/ui.md
|
@.claude/docs/ui.md
|
||||||
|
@.claude/docs/ui-navigation.md
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ var _mouseDelta:Vector2 = Vector2.ZERO
|
|||||||
var _rightMouseHeld:bool = false
|
var _rightMouseHeld:bool = false
|
||||||
|
|
||||||
func _canOrbit() -> bool:
|
func _canOrbit() -> bool:
|
||||||
return not UI.activeConversation
|
if UI.activeConversation:
|
||||||
|
return false
|
||||||
|
if UI.FOCUS_STACK.top() != null:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
func _input(event:InputEvent) -> void:
|
func _input(event:InputEvent) -> void:
|
||||||
if not _canOrbit():
|
if not _canOrbit():
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func _applyFriction(delta:float) -> void:
|
|||||||
func _canMove() -> bool:
|
func _canMove() -> bool:
|
||||||
if UI.activeConversation:
|
if UI.activeConversation:
|
||||||
return false
|
return false
|
||||||
if UI.GAME_MENU && UI.GAME_MENU.isOpen():
|
if UI.FOCUS_STACK.top() != null:
|
||||||
return false
|
return false
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -9,9 +9,9 @@ func _unhandled_input(event:InputEvent) -> void:
|
|||||||
var menu:PauseMenu = UI.PAUSE_MENU
|
var menu:PauseMenu = UI.PAUSE_MENU
|
||||||
if menu == null:
|
if menu == null:
|
||||||
return
|
return
|
||||||
if SCENE.currentScene == SceneSingleton.SceneType.INITIAL and !menu.isOpen():
|
if SCENE.currentScene == SceneSingleton.SceneType.INITIAL and !menu.isOpen:
|
||||||
return
|
return
|
||||||
if menu.isOpen():
|
if menu.isOpen:
|
||||||
menu.close()
|
menu.close()
|
||||||
else:
|
else:
|
||||||
menu.open()
|
menu.open()
|
||||||
|
|||||||
@@ -14,8 +14,3 @@ func _enter_tree() -> void:
|
|||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
if UI.rootUi == self:
|
if UI.rootUi == self:
|
||||||
UI.rootUi = null
|
UI.rootUi = null
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
modalBackdrop.register(pauseMenu)
|
|
||||||
modalBackdrop.register(quitConfirmDialog)
|
|
||||||
modalBackdrop.register(mainMenuConfirmDialog)
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
class_name UIFocusStack extends RefCounted
|
||||||
|
|
||||||
|
signal activeLayerChanged(layer:ClosableMenu)
|
||||||
|
|
||||||
|
const Z_STEP:int = 10
|
||||||
|
|
||||||
|
var _stack:Array[ClosableMenu] = []
|
||||||
|
|
||||||
|
func push(layer:ClosableMenu) -> void:
|
||||||
|
if not _stack.is_empty():
|
||||||
|
_stack.back()._onFocusLost()
|
||||||
|
_stack.push_back(layer)
|
||||||
|
layer.z_index = _stack.size() * Z_STEP
|
||||||
|
layer._onFocusGained()
|
||||||
|
activeLayerChanged.emit(layer)
|
||||||
|
|
||||||
|
func pop() -> void:
|
||||||
|
if _stack.is_empty(): return
|
||||||
|
var removed:ClosableMenu = _stack.pop_back()
|
||||||
|
removed.z_index = 0
|
||||||
|
removed._onFocusLost()
|
||||||
|
var next:ClosableMenu = top()
|
||||||
|
if next != null:
|
||||||
|
next._onFocusGained()
|
||||||
|
activeLayerChanged.emit(next)
|
||||||
|
|
||||||
|
func top() -> ClosableMenu:
|
||||||
|
return _stack.back() if not _stack.is_empty() else null
|
||||||
|
|
||||||
|
func isTop(layer:ClosableMenu) -> bool:
|
||||||
|
return top() == layer
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bnttsy278nvaw
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
|
const _FocusStackScript = preload("res://ui/UIFocusStack.gd")
|
||||||
|
|
||||||
var rootUi:RootUI = null
|
var rootUi:RootUI = null
|
||||||
var interactIndicator:InteractIndicator = null
|
var interactIndicator:InteractIndicator = null
|
||||||
|
var FOCUS_STACK:RefCounted = null
|
||||||
|
|
||||||
# True whenever any dialogue resource is being processed by DialogueManager.
|
# True whenever any dialogue resource is being processed by DialogueManager.
|
||||||
# Driven by DialogueManager.dialogue_started / dialogue_ended signals.
|
# Driven by DialogueManager.dialogue_started / dialogue_ended signals.
|
||||||
@@ -11,6 +14,7 @@ var dialogueActive:bool = false
|
|||||||
var activeConversation:bool = false
|
var activeConversation:bool = false
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
FOCUS_STACK = _FocusStackScript.new()
|
||||||
DialogueManager.dialogue_started.connect(_onDialogueStarted)
|
DialogueManager.dialogue_started.connect(_onDialogueStarted)
|
||||||
DialogueManager.dialogue_ended.connect(_onDialogueEnded)
|
DialogueManager.dialogue_ended.connect(_onDialogueEnded)
|
||||||
SCENE.sceneChanged.connect(_onSceneChanged)
|
SCENE.sceneChanged.connect(_onSceneChanged)
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
class_name ClosableMenu extends Control
|
class_name ClosableMenu extends Control
|
||||||
|
|
||||||
@export var isOpen: bool:
|
signal opened
|
||||||
set(newValue):
|
signal closed
|
||||||
isOpen = newValue
|
signal focusGained
|
||||||
visible = newValue
|
signal focusLost
|
||||||
if newValue:
|
|
||||||
opened.emit()
|
@export var canClose:bool = true
|
||||||
else:
|
@export var isOpen:bool = false:
|
||||||
closed.emit()
|
set(v):
|
||||||
|
isOpen = v
|
||||||
|
visible = v
|
||||||
get():
|
get():
|
||||||
return isOpen
|
return isOpen
|
||||||
|
|
||||||
signal closed
|
var _savedFocusNode:Control = null
|
||||||
signal opened
|
|
||||||
|
|
||||||
func _enter_tree() -> void:
|
func _enter_tree() -> void:
|
||||||
visible = isOpen
|
visible = isOpen
|
||||||
@@ -22,13 +23,59 @@ func _exit_tree() -> void:
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
visible = isOpen
|
visible = isOpen
|
||||||
print("ClosableMenu is ready, isOpen: ", isOpen)
|
if canClose:
|
||||||
|
set_process_unhandled_input(false)
|
||||||
func close() -> void:
|
|
||||||
isOpen = false
|
|
||||||
|
|
||||||
func open() -> void:
|
func open() -> void:
|
||||||
|
visible = true
|
||||||
|
if canClose:
|
||||||
|
UI.FOCUS_STACK.push(self)
|
||||||
isOpen = true
|
isOpen = true
|
||||||
|
opened.emit()
|
||||||
|
|
||||||
|
func close() -> void:
|
||||||
|
if canClose:
|
||||||
|
UI.FOCUS_STACK.pop()
|
||||||
|
isOpen = false
|
||||||
|
closed.emit()
|
||||||
|
|
||||||
func toggle() -> void:
|
func toggle() -> void:
|
||||||
isOpen = !isOpen
|
if isOpen:
|
||||||
|
close()
|
||||||
|
else:
|
||||||
|
open()
|
||||||
|
|
||||||
|
func _onFocusGained() -> void:
|
||||||
|
set_process_unhandled_input(true)
|
||||||
|
get_viewport().gui_focus_changed.connect(_onViewportFocusChanged)
|
||||||
|
if _savedFocusNode != null and is_instance_valid(_savedFocusNode):
|
||||||
|
_savedFocusNode.grab_focus()
|
||||||
|
else:
|
||||||
|
_grabInitialFocus()
|
||||||
|
var currentFocus:Control = get_viewport().gui_get_focus_owner()
|
||||||
|
if currentFocus != null and is_ancestor_of(currentFocus):
|
||||||
|
_savedFocusNode = currentFocus
|
||||||
|
focusGained.emit()
|
||||||
|
|
||||||
|
func _onFocusLost() -> void:
|
||||||
|
_savedFocusNode = get_viewport().gui_get_focus_owner()
|
||||||
|
if get_viewport().gui_focus_changed.is_connected(_onViewportFocusChanged):
|
||||||
|
get_viewport().gui_focus_changed.disconnect(_onViewportFocusChanged)
|
||||||
|
set_process_unhandled_input(false)
|
||||||
|
focusLost.emit()
|
||||||
|
|
||||||
|
func _onViewportFocusChanged(control:Control) -> void:
|
||||||
|
if control == null or is_ancestor_of(control):
|
||||||
|
return
|
||||||
|
var node:Node = control
|
||||||
|
while node != null:
|
||||||
|
if node is Popup:
|
||||||
|
return
|
||||||
|
node = node.get_parent()
|
||||||
|
if _savedFocusNode != null and is_instance_valid(_savedFocusNode):
|
||||||
|
_savedFocusNode.grab_focus()
|
||||||
|
else:
|
||||||
|
_grabInitialFocus()
|
||||||
|
|
||||||
|
func _grabInitialFocus() -> void:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ signal confirmed
|
|||||||
@export var btnNo:Button
|
@export var btnNo:Button
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
super._ready()
|
||||||
close()
|
close()
|
||||||
btnYes.pressed.connect(_onYes)
|
btnYes.pressed.connect(_onYes)
|
||||||
btnNo.pressed.connect(close)
|
btnNo.pressed.connect(close)
|
||||||
@@ -18,13 +19,10 @@ func _onYes() -> void:
|
|||||||
close()
|
close()
|
||||||
confirmed.emit()
|
confirmed.emit()
|
||||||
|
|
||||||
func open() -> void:
|
func _grabInitialFocus() -> void:
|
||||||
super.open()
|
|
||||||
btnNo.grab_focus()
|
btnNo.grab_focus()
|
||||||
|
|
||||||
func _unhandled_input(event:InputEvent) -> void:
|
func _unhandled_input(event:InputEvent) -> void:
|
||||||
if !isOpen:
|
|
||||||
return
|
|
||||||
if event.is_action_pressed("ui_cancel"):
|
if event.is_action_pressed("ui_cancel"):
|
||||||
close()
|
close()
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
|
|||||||
@@ -1,48 +1,16 @@
|
|||||||
class_name ModalBackdrop extends ColorRect
|
class_name ModalBackdrop extends ColorRect
|
||||||
|
|
||||||
# Tracks which overlays are currently open. Each entry must be a direct sibling
|
|
||||||
# (child of the same parent). The backdrop repositions itself in the scene tree
|
|
||||||
# to sit immediately below whichever open overlay has the highest tree index,
|
|
||||||
# so only one backdrop is ever visible regardless of how many overlays are open.
|
|
||||||
var _openOverlays:Array[Control] = []
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
visible = false
|
visible = false
|
||||||
|
mouse_filter = MOUSE_FILTER_IGNORE
|
||||||
|
UI.FOCUS_STACK.activeLayerChanged.connect(_onActiveLayerChanged)
|
||||||
|
|
||||||
func register(overlay:Control) -> void:
|
func _onActiveLayerChanged(layer:ClosableMenu) -> void:
|
||||||
assert(overlay.get_parent() == get_parent(), "ModalBackdrop: overlay must be a sibling")
|
if layer == null or layer.get_parent() != get_parent():
|
||||||
assert(overlay.has_signal("opened") and overlay.has_signal("closed"),
|
|
||||||
"ModalBackdrop: overlay must have opened/closed signals")
|
|
||||||
overlay.connect("opened", func(): _onOpened(overlay))
|
|
||||||
overlay.connect("closed", func(): _onClosed(overlay))
|
|
||||||
|
|
||||||
func _onOpened(overlay:Control) -> void:
|
|
||||||
if overlay not in _openOverlays:
|
|
||||||
_openOverlays.append(overlay)
|
|
||||||
_reposition()
|
|
||||||
|
|
||||||
func _onClosed(overlay:Control) -> void:
|
|
||||||
_openOverlays.erase(overlay)
|
|
||||||
_reposition()
|
|
||||||
|
|
||||||
func _reposition() -> void:
|
|
||||||
if _openOverlays.is_empty():
|
|
||||||
visible = false
|
visible = false
|
||||||
return
|
mouse_filter = MOUSE_FILTER_IGNORE
|
||||||
visible = true
|
z_index = 0
|
||||||
var top := _topOverlay()
|
|
||||||
var topIdx := top.get_index()
|
|
||||||
var myIdx := get_index()
|
|
||||||
if myIdx == topIdx - 1:
|
|
||||||
return
|
|
||||||
if myIdx < topIdx:
|
|
||||||
get_parent().move_child(self, topIdx - 1)
|
|
||||||
else:
|
else:
|
||||||
get_parent().move_child(self, topIdx)
|
visible = true
|
||||||
|
mouse_filter = MOUSE_FILTER_STOP
|
||||||
func _topOverlay() -> Control:
|
z_index = layer.z_index - 5
|
||||||
var top:Control = _openOverlays[0]
|
|
||||||
for overlay in _openOverlays:
|
|
||||||
if overlay.get_index() > top.get_index():
|
|
||||||
top = overlay
|
|
||||||
return top
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
class_name TabMenu extends Control
|
||||||
|
|
||||||
|
@export var tabs:TabBar
|
||||||
|
@export var tabControls:Array[Control]
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
tabs.tab_changed.connect(_onTabChanged)
|
||||||
|
_onTabChanged(tabs.current_tab)
|
||||||
|
|
||||||
|
func _notification(what:int) -> void:
|
||||||
|
if what == NOTIFICATION_VISIBILITY_CHANGED and visible:
|
||||||
|
tabs.grab_focus()
|
||||||
|
|
||||||
|
func _input(event:InputEvent) -> void:
|
||||||
|
if !is_visible_in_tree():
|
||||||
|
return
|
||||||
|
if event.is_action_pressed("tab_next"):
|
||||||
|
tabs.current_tab = (tabs.current_tab + 1) % tabs.tab_count
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
elif event.is_action_pressed("tab_prev"):
|
||||||
|
tabs.current_tab = (tabs.current_tab - 1 + tabs.tab_count) % tabs.tab_count
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
elif tabs.has_focus() and event.is_action_pressed("ui_accept"):
|
||||||
|
var idx:int = tabs.current_tab
|
||||||
|
if idx >= 0 and idx < tabControls.size() and _focusFirstIn(tabControls[idx]):
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
func _onTabChanged(tabIndex:int) -> void:
|
||||||
|
for control in tabControls:
|
||||||
|
control.visible = false
|
||||||
|
if tabIndex >= 0 and tabIndex < tabControls.size():
|
||||||
|
tabControls[tabIndex].visible = true
|
||||||
|
|
||||||
|
func _focusFirstIn(container:Control) -> bool:
|
||||||
|
for child in container.get_children():
|
||||||
|
if not child is Control:
|
||||||
|
continue
|
||||||
|
if child.focus_mode != Control.FOCUS_NONE and child.is_visible_in_tree():
|
||||||
|
child.grab_focus()
|
||||||
|
return true
|
||||||
|
if _focusFirstIn(child):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dacm5qwmmkcsm
|
||||||
+10
-17
@@ -1,4 +1,4 @@
|
|||||||
class_name GameMenu extends Control
|
class_name GameMenu extends ClosableMenu
|
||||||
|
|
||||||
enum Tab { PARTY, ITEMS }
|
enum Tab { PARTY, ITEMS }
|
||||||
|
|
||||||
@@ -9,21 +9,14 @@ enum Tab { PARTY, ITEMS }
|
|||||||
var _currentTab:Tab = Tab.PARTY
|
var _currentTab:Tab = Tab.PARTY
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
visible = false
|
super._ready()
|
||||||
SIDEBAR.item_selected.connect(_onTabSelected)
|
SIDEBAR.item_selected.connect(_onTabSelected)
|
||||||
|
|
||||||
func open() -> void:
|
func _grabInitialFocus() -> void:
|
||||||
visible = true
|
|
||||||
_selectTab(_currentTab)
|
_selectTab(_currentTab)
|
||||||
SIDEBAR.select(_currentTab)
|
SIDEBAR.select(_currentTab)
|
||||||
SIDEBAR.grab_focus()
|
SIDEBAR.grab_focus()
|
||||||
|
|
||||||
func close() -> void:
|
|
||||||
visible = false
|
|
||||||
|
|
||||||
func isOpen() -> bool:
|
|
||||||
return visible
|
|
||||||
|
|
||||||
func _onTabSelected(index:int) -> void:
|
func _onTabSelected(index:int) -> void:
|
||||||
_selectTab(index as Tab)
|
_selectTab(index as Tab)
|
||||||
|
|
||||||
@@ -37,16 +30,16 @@ func _selectTab(tab:Tab) -> void:
|
|||||||
Tab.ITEMS:
|
Tab.ITEMS:
|
||||||
ITEMS_TAB.refresh()
|
ITEMS_TAB.refresh()
|
||||||
|
|
||||||
func _unhandled_input(event:InputEvent) -> void:
|
func _input(event:InputEvent) -> void:
|
||||||
if event.is_action_pressed("menu"):
|
if not event.is_action_pressed("menu"):
|
||||||
if visible:
|
return
|
||||||
|
if isOpen:
|
||||||
close()
|
close()
|
||||||
elif !UI.dialogueActive and SCENE.currentScene == SceneSingleton.SceneType.OVERWORLD:
|
elif UI.FOCUS_STACK.top() == null and not UI.dialogueActive and SCENE.currentScene == SceneSingleton.SceneType.OVERWORLD:
|
||||||
open()
|
open()
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
return
|
|
||||||
if !visible:
|
func _unhandled_input(event:InputEvent) -> void:
|
||||||
return
|
|
||||||
if event.is_action_pressed("ui_cancel"):
|
if event.is_action_pressed("ui_cancel"):
|
||||||
close()
|
close()
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
|
|||||||
@@ -10,18 +10,12 @@ func _ready() -> void:
|
|||||||
btnNewGame.pressed.connect(onNewGamePressed)
|
btnNewGame.pressed.connect(onNewGamePressed)
|
||||||
btnSettings.pressed.connect(onSettingsPressed)
|
btnSettings.pressed.connect(onSettingsPressed)
|
||||||
btnQuit.pressed.connect(_onQuitPressed)
|
btnQuit.pressed.connect(_onQuitPressed)
|
||||||
settingsMenu.opened.connect(_onSettingsOpened)
|
|
||||||
settingsMenu.closed.connect(_onSettingsClosed)
|
settingsMenu.closed.connect(_onSettingsClosed)
|
||||||
|
|
||||||
func _notification(what:int) -> void:
|
func _notification(what:int) -> void:
|
||||||
if what == NOTIFICATION_ENTER_TREE:
|
if what == NOTIFICATION_ENTER_TREE:
|
||||||
btnNewGame.call_deferred("grab_focus")
|
btnNewGame.call_deferred("grab_focus")
|
||||||
|
|
||||||
func _onSettingsOpened() -> void:
|
|
||||||
# Move focus into the settings panel so the controller can navigate it.
|
|
||||||
# The SettingsMenu grabs its own internal focus via _notification.
|
|
||||||
pass
|
|
||||||
|
|
||||||
func _onSettingsClosed() -> void:
|
func _onSettingsClosed() -> void:
|
||||||
btnSettings.grab_focus()
|
btnSettings.grab_focus()
|
||||||
|
|
||||||
@@ -43,4 +37,4 @@ func onNewGamePressed() -> void:
|
|||||||
OVERWORLD.mapChange(newGameScene, "PlayerSpawnPoint")
|
OVERWORLD.mapChange(newGameScene, "PlayerSpawnPoint")
|
||||||
|
|
||||||
func onSettingsPressed() -> void:
|
func onSettingsPressed() -> void:
|
||||||
settingsMenu.isOpen = true
|
settingsMenu.open()
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ func _ready() -> void:
|
|||||||
btnSettings.pressed.connect(settingsRequested.emit)
|
btnSettings.pressed.connect(settingsRequested.emit)
|
||||||
btnMainMenu.pressed.connect(_showMainMenuConfirm)
|
btnMainMenu.pressed.connect(_showMainMenuConfirm)
|
||||||
btnQuit.pressed.connect(_showQuitConfirm)
|
btnQuit.pressed.connect(_showQuitConfirm)
|
||||||
UI.QUIT_DIALOG.closed.connect(_onQuitDialogClosed)
|
|
||||||
UI.MAIN_MENU_DIALOG.closed.connect(_onMainMenuDialogClosed)
|
|
||||||
|
|
||||||
func _showQuitConfirm() -> void:
|
func _showQuitConfirm() -> void:
|
||||||
UI.QUIT_DIALOG.open()
|
UI.QUIT_DIALOG.open()
|
||||||
@@ -23,14 +21,6 @@ func _showQuitConfirm() -> void:
|
|||||||
func _showMainMenuConfirm() -> void:
|
func _showMainMenuConfirm() -> void:
|
||||||
UI.MAIN_MENU_DIALOG.open()
|
UI.MAIN_MENU_DIALOG.open()
|
||||||
|
|
||||||
func _onQuitDialogClosed() -> void:
|
|
||||||
if isOpen():
|
|
||||||
btnQuit.grab_focus()
|
|
||||||
|
|
||||||
func _onMainMenuDialogClosed() -> void:
|
|
||||||
if isOpen():
|
|
||||||
btnMainMenu.grab_focus()
|
|
||||||
|
|
||||||
func open() -> void:
|
func open() -> void:
|
||||||
visible = true
|
visible = true
|
||||||
btnResume.grab_focus()
|
btnResume.grab_focus()
|
||||||
|
|||||||
+7
-18
@@ -1,32 +1,25 @@
|
|||||||
class_name PauseMenu extends Control
|
class_name PauseMenu extends ClosableMenu
|
||||||
|
|
||||||
signal opened
|
|
||||||
signal closed
|
|
||||||
|
|
||||||
@export var MAIN:PauseMain
|
@export var MAIN:PauseMain
|
||||||
@export var settingsPanel:PauseSettings
|
@export var settingsPanel:PauseSettings
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
close()
|
super._ready()
|
||||||
MAIN.resumeRequested.connect(close)
|
MAIN.resumeRequested.connect(close)
|
||||||
MAIN.settingsRequested.connect(_openSettings)
|
MAIN.settingsRequested.connect(_openSettings)
|
||||||
UI.MAIN_MENU_DIALOG.confirmed.connect(_goToMainMenu)
|
UI.MAIN_MENU_DIALOG.confirmed.connect(_goToMainMenu)
|
||||||
|
|
||||||
func isOpen() -> bool:
|
|
||||||
return visible
|
|
||||||
|
|
||||||
func open() -> void:
|
func open() -> void:
|
||||||
visible = true
|
super.open()
|
||||||
get_tree().paused = true
|
get_tree().paused = true
|
||||||
|
settingsPanel.close()
|
||||||
MAIN.open()
|
MAIN.open()
|
||||||
opened.emit()
|
|
||||||
|
|
||||||
func close() -> void:
|
func close() -> void:
|
||||||
get_tree().paused = false
|
get_tree().paused = false
|
||||||
visible = false
|
|
||||||
MAIN.close()
|
|
||||||
settingsPanel.close()
|
settingsPanel.close()
|
||||||
closed.emit()
|
MAIN.close()
|
||||||
|
super.close()
|
||||||
|
|
||||||
func _openSettings() -> void:
|
func _openSettings() -> void:
|
||||||
MAIN.close()
|
MAIN.close()
|
||||||
@@ -37,11 +30,7 @@ func _goToMainMenu() -> void:
|
|||||||
SCENE.setScene(SceneSingleton.SceneType.INITIAL)
|
SCENE.setScene(SceneSingleton.SceneType.INITIAL)
|
||||||
|
|
||||||
func _unhandled_input(event:InputEvent) -> void:
|
func _unhandled_input(event:InputEvent) -> void:
|
||||||
if !visible:
|
if not event.is_action_pressed("ui_cancel"):
|
||||||
return
|
|
||||||
if !event.is_action_pressed("ui_cancel"):
|
|
||||||
return
|
|
||||||
if (UI.QUIT_DIALOG != null and UI.QUIT_DIALOG.isOpen) or (UI.MAIN_MENU_DIALOG != null and UI.MAIN_MENU_DIALOG.isOpen):
|
|
||||||
return
|
return
|
||||||
if settingsPanel.isOpen():
|
if settingsPanel.isOpen():
|
||||||
settingsPanel.close()
|
settingsPanel.close()
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
class_name SettingsMenu extends Control
|
class_name SettingsMenu extends TabMenu
|
||||||
|
|
||||||
const TEXT_SPEED_VALUES:Array[float] = [0.2, 1.0, 2.0]
|
const TEXT_SPEED_VALUES:Array[float] = [0.2, 1.0, 2.0]
|
||||||
|
|
||||||
@export var tabs:TabBar
|
|
||||||
@export var tabControls:Array[Control]
|
|
||||||
@export var checkInvertX:CheckBox
|
@export var checkInvertX:CheckBox
|
||||||
@export var checkInvertY:CheckBox
|
@export var checkInvertY:CheckBox
|
||||||
@export var sliderControllerSpeed:HSlider
|
@export var sliderControllerSpeed:HSlider
|
||||||
@@ -11,7 +9,7 @@ const TEXT_SPEED_VALUES:Array[float] = [0.2, 1.0, 2.0]
|
|||||||
@export var optionTextSpeed:OptionButton
|
@export var optionTextSpeed:OptionButton
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
tabs.tab_changed.connect(onTabChanged)
|
super._ready()
|
||||||
checkInvertX.button_pressed = SETTINGS.invertCameraX
|
checkInvertX.button_pressed = SETTINGS.invertCameraX
|
||||||
checkInvertY.button_pressed = SETTINGS.invertCameraY
|
checkInvertY.button_pressed = SETTINGS.invertCameraY
|
||||||
checkInvertX.toggled.connect(func(v:bool): SETTINGS.invertCameraX = v)
|
checkInvertX.toggled.connect(func(v:bool): SETTINGS.invertCameraX = v)
|
||||||
@@ -22,39 +20,9 @@ func _ready() -> void:
|
|||||||
sliderMouseSpeed.value_changed.connect(func(v:float): SETTINGS.cameraSpeedMouse = v)
|
sliderMouseSpeed.value_changed.connect(func(v:float): SETTINGS.cameraSpeedMouse = v)
|
||||||
optionTextSpeed.select(_textSpeedToIndex(SETTINGS.textSpeed))
|
optionTextSpeed.select(_textSpeedToIndex(SETTINGS.textSpeed))
|
||||||
optionTextSpeed.item_selected.connect(func(idx:int): SETTINGS.textSpeed = TEXT_SPEED_VALUES[idx])
|
optionTextSpeed.item_selected.connect(func(idx:int): SETTINGS.textSpeed = TEXT_SPEED_VALUES[idx])
|
||||||
onTabChanged(tabs.current_tab)
|
|
||||||
|
|
||||||
func _textSpeedToIndex(speed:float) -> int:
|
func _textSpeedToIndex(speed:float) -> int:
|
||||||
match speed:
|
match speed:
|
||||||
0.2: return 0
|
0.2: return 0
|
||||||
2.0: return 2
|
2.0: return 2
|
||||||
_: return 1
|
_: return 1
|
||||||
|
|
||||||
func _notification(what:int) -> void:
|
|
||||||
if what == NOTIFICATION_VISIBILITY_CHANGED and visible:
|
|
||||||
tabs.grab_focus()
|
|
||||||
|
|
||||||
func _input(event:InputEvent) -> void:
|
|
||||||
if !is_visible_in_tree():
|
|
||||||
return
|
|
||||||
if event.is_action_pressed("tab_next"):
|
|
||||||
tabs.current_tab = (tabs.current_tab + 1) % tabs.tab_count
|
|
||||||
get_viewport().set_input_as_handled()
|
|
||||||
elif event.is_action_pressed("tab_prev"):
|
|
||||||
tabs.current_tab = (tabs.current_tab - 1 + tabs.tab_count) % tabs.tab_count
|
|
||||||
get_viewport().set_input_as_handled()
|
|
||||||
|
|
||||||
func onTabChanged(tabIndex:int) -> void:
|
|
||||||
for control in tabControls:
|
|
||||||
control.visible = false
|
|
||||||
if tabIndex >= 0 and tabIndex < tabControls.size():
|
|
||||||
tabControls[tabIndex].visible = true
|
|
||||||
_focusFirstIn(tabControls[tabIndex])
|
|
||||||
|
|
||||||
func _focusFirstIn(container:Control) -> void:
|
|
||||||
if !is_visible_in_tree():
|
|
||||||
return
|
|
||||||
for child in container.get_children():
|
|
||||||
if child is Control and child.focus_mode != Control.FOCUS_NONE:
|
|
||||||
child.grab_focus()
|
|
||||||
return
|
|
||||||
|
|||||||
Reference in New Issue
Block a user