Files
2026-06-14 10:57:36 -05:00

10 KiB

UI System

Scene structure

RootUI (Control, fullscreen, always visible)
├── DebugMenu
├── GameMenu
│   ├── GameMenuPartyTab
│   └── GameMenuItemsTab
├── ChatBoxContainer
│   └── InteractIndicator
├── ModalBackdrop          ← shared backdrop; z_index managed by FocusStack
├── PauseMenu
│   ├── PauseMain
│   └── PauseSettings
├── QuitConfirmDialog
└── MainMenuConfirmDialog

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).

RootUI is a permanent child of RootScene and registers itself with the UI singleton on _enter_tree. Access its children through the UI singleton.

UI singleton

UI (autoload) is the global access point.

Accessor Type Notes
UI.DEBUG_MENU DebugMenu Dev scene-jump overlay
UI.GAME_MENU GameMenu JRPG-style in-game menu
UI.PAUSE_MENU PauseMenu Pause overlay; also pauses the scene tree
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.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.activeConversation bool true only during a CONVERSATION-mode dialogue
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 and camera orbit block when UI.FOCUS_STACK.top() != null or UI.activeConversation is true.

ClosableMenu

Base class for all togglable panels. Extends Control; the isOpen:bool export drives visible.

menu.open()    # shows, pushes to FocusStack (if canClose), emits opened
menu.close()   # pops from FocusStack (if canClose), hides, emits closed
menu.toggle()

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.

ConfirmDialog

Reusable "Yes / No" confirmation overlay at res://ui/component/ConfirmDialog.gd. Extends ClosableMenu.

dialog.open()                        # shows, focuses No (safe default), emits opened
dialog.confirmed                     # signal — fires after Yes is pressed, before close

Focus locking: focus_neighbor_top/bottom on both buttons is wired so controller navigation cannot escape the dialog to elements behind it.

ui_cancel closes the dialog the same as pressing No.

Subclassing:

class_name MyConfirmDialog extends ConfirmDialog

func _ready() -> void:
  super._ready()
  confirmed.connect(func(): do_the_thing())

QuitConfirmDialog uses this pattern — it extends ConfirmDialog and connects confirmed → get_tree().quit() in its own _ready().

Adding a new confirm dialog:

  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
  3. Add the instance to RootUI.tscn after PauseMenu (so it renders on top)
  4. Expose via RootUI.gd export + UISingleton.gd accessor if other systems need it
  5. 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

res://ui/component/ModalBackdrop.gd — a single fullscreen semi-transparent ColorRect shared across all modal overlays.

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, it always renders between the game world and the frontmost RootUI-level overlay, and it blocks mouse events from reaching anything behind it.

No registration is required — ModalBackdrop is self-contained. Do not call register() on it.

AdvancedRichText

RichTextLabel subclass (@tool) used inside world-space dialogue textboxes. Handles:

  • Smart word-wrap (TextServer.AUTOWRAP_WORD_SMART)
  • Pagination via maxLines / startLine exports
  • Inline input icons: [input action=interact]text[/input] → replaced with the icon image from res://ui/input/{action}.tres
  • Optional auto-translation via Godot's tr()

Supported icon actions: interact, pause, debug, up, down, left, right.

Game menu

JRPG-style in-game menu at res://ui/gamemenu/. Open with the menu input (Tab on keyboard, Y on controller). Blocks player movement while open via EntityMovement._canMove().

Structure:

  • Left sidebar: ItemList tab selector (Party, Items)
  • Right panel: active tab content
Tab Content
Party One card per PartyMember — name, status, HP/MP, ATK/DEF/SPD/MAG/LCK
Items One row per ItemStack in PARTY.BACKPACK — item name + quantity

Both tabs are populated dynamically on open; call refresh() on the active tab directly if data changes while the menu is already open.

Key members on GameMenu:

Member Notes
open() Shows menu, refreshes active tab, pushes to FocusStack, grabs sidebar focus
close() Pops from FocusStack, hides menu
isOpen:bool Property — read directly, not a method

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().

Pause menu

PauseMenu wraps PauseMain (button list) and PauseSettings (settings tabs). Opening it calls get_tree().paused = true; closing restores it.

Member Notes
PauseMenu.open() Pushes to FocusStack, pauses tree, opens PauseMain, emits opened
PauseMenu.close() Unpauses tree, closes sub-panels, pops from FocusStack, emits closed
PauseMenu.isOpen:bool Property — read directly, not a method

ui_cancel behaviour inside PauseMenu:

  • If PauseSettings is open → closes settings, reopens PauseMain
  • 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:

Button Behaviour
Resume Closes PauseMenu
Settings Opens PauseSettings
Main Menu Opens MainMenuConfirmDialog; on confirm → SCENE.setScene(INITIAL)
Quit Game Opens QuitConfirmDialog; on confirm → get_tree().quit()

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.

Main menu

res://ui/mainmenu/MainMenu.tscn. Buttons: New Game, Settings, Quit Game.

  • New Game → SCENE.setScene(OVERWORLD) + OVERWORLD.mapChange(...)
  • Settings → opens the MainMenuSettings overlay
  • Quit Game → opens UI.QUIT_DIALOG; on cancel, focus returns to the Quit button

Settings menu

SettingsMenu is a shared tabbed component instanced in both MainMenuSettings and PauseSettings. Three tabs: Gameplay, Sound, Graphics — currently contain placeholder labels only.

Debug menu

DebugMenu (toggle via debug input — F1) provides four scene-jump buttons:

Button Action
Overworld SCENE.setScene(OVERWORLD)
Battle SCENE.setScene(BATTLE)
Cooking SCENE.setScene(COOKING)
Initial SCENE.setScene(INITIAL)

Access via UI.DEBUG_MENU. Starts hidden.

Theme & assets

  • Global theme: res://ui/UI Theme.tres — applied to all UI nodes
  • Input icons: res://ui/input/{action}.tres — one file per action name
  • Font: configured globally in project settings

Adding a new menu

  1. Create a scene whose root extends ClosableMenu (or Control if open/close isn't needed)
  2. Override _grabInitialFocus() to place focus on the first interactive element on open
  3. Add it as a child of RootUI.tscn — position after PauseMenu if it should render above it
  4. Export a typed reference on RootUI.gd and wire it in the Inspector
  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.