Add some UI

This commit is contained in:
2026-06-11 20:42:08 -05:00
parent 456ea1e07e
commit f46f08c083
29 changed files with 678 additions and 88 deletions
+3 -2
View File
@@ -13,10 +13,11 @@ Never instantiate these — access only via the global handle.
| `OVERWORLD` | Map switching with threaded loading |
| `COOKING` | Cooking mini-game lifecycle |
| `SAVE` | Persistence (stub) |
| `SETTINGS` | Runtime settings — `invertCameraX:bool`, `invertCameraY:bool` |
| `QUEST` | Quest management (stub) |
| `CUTSCENE` | Cutscene global (stub) |
| `DialogueManager` | godot_dialogue_manager v3.10.4 — parses and steps through `.dialogue` files |
| `UI` | Root UI accessor — `UI.TEXTBOX`, `UI.DEBUG_MENU` |
| `UI` | Root UI accessor — `UI.TEXTBOX`, `UI.DEBUG_MENU`, `UI.GAME_MENU` |
## Scene Graph
@@ -24,7 +25,7 @@ Never instantiate these — access only via the global handle.
RootScene (Node3D)
└─ overworld / battle / cooking / initial ← one shown at a time
RootUI (Control, always visible)
└─ VNTextbox, DebugMenu
└─ VNTextbox, DebugMenu, PauseMenu, GameMenu
```
`RootScene` listens to `SCENE.sceneChanged` and shows/hides the correct sub-tree.
+96
View File
@@ -0,0 +1,96 @@
# Overworld System
## Scene structure
```
OverworldScene (Node3D) ← always resident, managed by SCENE singleton
└─ Map (Node3D) ← emptied and repopulated on each map change
TestMap (Node3D) ← typical map root, extends Node3D
├─ Player (Entity) ← movementType = PLAYER, entityId = "player"
├─ NPC/object (Entity) ... ← interactType drives what happens on interact
├─ TestMapBase (StaticBody3D) ← reusable terrain plane (200×200, collision layer 1)
└─ Camera3D (OverworldCamera) ← targetNode → Player
```
## Map transitions
Use `OVERWORLD.mapChange(path, destinationNodeName)` to switch maps.
```gdscript
OVERWORLD.mapChange("res://overworld/map/SomeMap.tscn", "SpawnPoint")
```
Flow: fade-out begins → map loads on a background thread → when both complete, `OVERWORLD.mapChanged` fires → `OverworldScene` clears `Map` children and instances the new map → fade-in begins. The `destinationNodeName` is passed with the signal for the new map to use as a spawn point (not yet wired to player placement — see [stubs](stubs.md)).
## Entity
All overworld objects (player, NPCs, items, triggers) are instances of [entity/Entity.tscn](../../overworld/entity/Entity.tscn) with different export values.
| Export | Purpose |
|---|---|
| `entityId:String` | UUID; use the Inspector button to regenerate |
| `movementType:MovementType` | `NONE` (static), `DISABLED`, or `PLAYER` (input-driven) |
| `interactType:InteractType` | What happens when the player presses Interact nearby |
| `dialogueResource:DialogueResource` | `.dialogue` file — required for `CONVERSATION` |
| `dialogueTitle:String` | Dialogue section to start from (default `"start"`) |
| `oneTimeItem:ItemResource` | Item granted on interact — required for `ONE_TIME_ITEM` |
| `cutscene:CutsceneResource` | Cutscene to run — required for `CUTSCENE` |
### Interaction types
| `InteractType` | Behaviour |
|---|---|
| `NONE` | Not interactable |
| `CONVERSATION` | Runs `dialogueResource` from `dialogueTitle` via `DialogueAction` |
| `ONE_TIME_ITEM` | Grants `oneTimeItem`, then frees the entity |
| `CUTSCENE` | Queues and starts `cutscene` |
| `BATTLE_TEST` | Starts a test battle (hardcoded enemy, for dev use) |
To add a new interaction type: add a value to `Entity.InteractType`, then add the matching `match` branch in `EntityInteractableArea.onInteract()` ([entity/EntityInteractableArea.gd](../../overworld/entity/EntityInteractableArea.gd)).
### Collision layers
| Area | Layer | Mask | Purpose |
|---|---|---|---|
| `EntityInteractingArea` | 0 | 2 | Player's reach — detects nearby interactables |
| `EntityInteractableArea` | 2 | 0 | Entity's surface — detected by other reaches |
The asymmetric setup means entities never trigger themselves.
## Movement
`EntityMovement` (a child `Node` under `Components`) handles all physics each frame:
1. Apply gravity if airborne
2. Apply friction (`velocity.x/z *= delta * FRICTION`)
3. If `_canMove()` and `movementType == PLAYER`: read input, compute camera-relative direction, set velocity
4. `move_and_slide()`
Movement is blocked (`_canMove() → false`) when `UI.dialogueActive`, `UI.TEXTBOX` is open, or `UI.GAME_MENU.isOpen()`.
Camera-relative direction is derived from the active `Camera3D`'s basis — the camera's Y-zeroed and renormalized X/Z axes map input axes to world axes. The entity faces (`look_at`) the movement direction each frame.
## Camera
`OverworldCamera` orbits around `targetNode` using yaw/pitch angles driven by `camera_orbit_*` inputs.
| Export | Default | Purpose |
|---|---|---|
| `targetNode:Node3D` | — | Node to orbit (assign Player in scene) |
| `pivotOffset:Vector3` | `(0, 1.2, 0)` | Orbit point above entity origin |
| `distance:float` | `10.0` | Orbit radius |
| `pitchMin/Max:float` | `-10° / 70°` | Vertical clamp |
| `orbitSensitivity:float` | `120.0` | Degrees/sec at full input |
| `collisionMask:int` | — | Layers the camera avoids (terrain = layer 1) |
If the ray from pivot to desired camera position hits `collisionMask`, the camera is pulled in to just in front of the hit point (with `COLLISION_MARGIN = 0.3`), clamped to `minDistance`.
## Adding a new map
1. Create a new scene (`Node3D` root) in `overworld/map/`
2. Add `Entity` instances, set `interactType` and relevant exports in the Inspector
3. Instance `TestMapBase` (or your own terrain) as a child
4. Add a `Camera3D` with `OverworldCamera` script; set `targetNode` to the Player entity
5. Add a `Player` entity with `movementType = PLAYER` and `entityId = "player"`
6. Switch to it with `OVERWORLD.mapChange("res://overworld/map/YourMap.tscn", "")`
+4
View File
@@ -9,6 +9,8 @@
## Entities (Overworld)
See [Overworld](overworld.md) for the full reference (scene structure, interaction types, camera, map transitions).
- All interactable world objects extend `Entity` (CharacterBody3D)
- Interaction type set via `@export var interactType:InteractType`
- Interaction routing lives in `EntityInteractableArea.onInteract()` — add new `InteractType` values there
@@ -21,6 +23,8 @@
## UI
See [UI](ui.md) for the full reference (VNTextbox, ClosableMenu, pause/debug/settings menus, AdvancedRichText, adding new menus).
- `UI.TEXTBOX.setTextAndWait(text)` — show dialogue and await player dismiss (use `await`)
- Movement is blocked automatically when `UI.TEXTBOX` is visible (`EntityMovement._canMove()` checks this)
- Menus extend `ClosableMenu` for open/close/toggle + `closed`/`opened` signals
+150
View File
@@ -0,0 +1,150 @@
# UI System
## Scene structure
```
RootUI (Control, fullscreen, always visible)
├── DebugMenu
├── PauseMenu
│ ├── PauseSettings
│ └── PauseMain
├── GameMenu
│ ├── GameMenuPartyTab
│ └── GameMenuItemsTab
└── VNTextbox
```
`RootUI` is a permanent child of `RootScene` and registers itself with the `UI` singleton on `_enter_tree`. Access its children via `UI.TEXTBOX`, `UI.DEBUG_MENU`, and `UI.GAME_MENU`.
## UI singleton
`UI` (autoload) is the global access point.
| Accessor | Returns | Notes |
|---|---|---|
| `UI.TEXTBOX` | `VNTextbox` | Bottom-screen dialogue box |
| `UI.DEBUG_MENU` | `DebugMenu` | Dev scene-jump overlay |
| `UI.GAME_MENU` | `GameMenu` | JRPG-style in-game menu |
| `UI.dialogueActive` | `bool` | `true` for the entire duration of a `DialogueAction`, including line transitions |
`dialogueActive` is set by `DialogueAction` — it is broader than just "textbox visible." Movement blocks on both flags: `UI.dialogueActive` prevents movement even while the textbox is briefly hidden between lines.
## VNTextbox
Bottom-anchored `PanelContainer` that reveals text character-by-character and paginates when content overflows 4 lines.
**Showing text from code:**
```gdscript
# Fire-and-forget
UI.TEXTBOX.setText("Hello world.")
# Await player dismiss — use this in cutscene callables
await UI.TEXTBOX.setTextAndWait("Hello world.")
```
**Flow:**
1. `setText` resets reveal state and sets new text; textbox becomes visible automatically
2. Player holds `interact` to speed up reveal; press again after reveal completes to advance page or close
3. `textboxClosing` signal fires when the last page is dismissed
4. `setTextAndWait` awaits that signal before returning
**Input guard:** `EntityMovement._canMove()` returns `false` while `!UI.TEXTBOX.isClosed`. Don't set text without also expecting movement to be blocked.
**Signal:** `textboxClosing` — emitted once per `setTextAndWait` call when player dismisses.
## AdvancedRichText
`RichTextLabel` subclass (`@tool`) used inside `VNTextbox`. 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`.
## ClosableMenu
Base class for any togglable panel. Extends `Control`; `isOpen` drives `visible`.
```gdscript
menu.open() # shows, emits opened
menu.close() # hides, emits closed
menu.toggle()
```
Signals: `opened`, `closed`.
All new menus that need standard show/hide behaviour should extend `ClosableMenu`.
## 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 methods on `GameMenu`:**
| Method | Effect |
|---|---|
| `open()` | Shows menu, refreshes active tab, grabs sidebar focus |
| `close()` | Hides menu |
| `isOpen() -> bool` | Visibility state |
`ui_cancel` or `menu` closes the menu. The `menu` input opens it only when `UI.dialogueActive` is false and the textbox is closed.
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` (item list) and `PauseSettings` (settings tabs).
| Method | Effect |
|---|---|
| `PauseMenu.open()` | Shows container, opens PauseMain |
| `PauseMenu.close()` | Hides everything |
| `PauseMenu.isOpen() -> bool` | Visibility state |
`ui_cancel` inside the pause menu: if PauseSettings is open, closes it and reopens PauseMain; otherwise closes the whole menu.
> **Stub:** `Pause.gd` (the singleton) has its logic commented out. Pause menu is not yet wired to actual game-pause state.
## 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; `isClosed` getter/setter controls visibility.
## 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. Add it as a child of `RootUI.tscn`
3. Export a typed reference on `RootUI.gd` and wire it in the Inspector
4. Expose via a getter on `UISingleton.gd` if other systems need access (follow the `TEXTBOX` / `DEBUG_MENU` pattern)