Compare commits

...

20 Commits

Author SHA1 Message Date
YourWishes acf0ef6be5 Tabs 2026-06-14 10:57:36 -05:00
YourWishes 0cf1f92eaa UI improvements 2026-06-14 10:19:31 -05:00
YourWishes 8ffde98fdd input binds 2026-06-13 07:38:28 -05:00
YourWishes bfd455341d Fix main menu focus 2026-06-12 22:21:42 -05:00
YourWishes 27d056fafa Slowly fixing small ui bugs 2026-06-12 22:03:51 -05:00
YourWishes 3d01fcce86 Lots of little tweaks and fixes 2026-06-12 20:26:00 -05:00
YourWishes 7fc1a4645c Dialogue system really good 2026-06-12 14:15:31 -05:00
YourWishes aae68a60d5 Fixed flicker 2026-06-12 13:57:22 -05:00
YourWishes b2e8cd2c30 Improved manual speedup 2026-06-12 12:17:23 -05:00
YourWishes 45540c31b2 Improve scrolling 2026-06-12 12:13:18 -05:00
YourWishes 96141e48a2 Advance indicator 2026-06-12 11:59:15 -05:00
YourWishes 2f3a4eab66 Improved UI textbox 2026-06-12 11:56:30 -05:00
YourWishes f6a0bb156e Really expanded dialogue docs 2026-06-12 09:46:08 -05:00
YourWishes 5668250ea9 Fix some bugs with chat 2026-06-12 09:16:19 -05:00
YourWishes b364fae1c7 in-world ui textboxes 2026-06-12 09:08:13 -05:00
YourWishes a4d47d7f00 Add main menu to pause menu 2026-06-12 08:40:11 -05:00
YourWishes 7c9daf91a1 More camera tweaking 2026-06-12 07:44:32 -05:00
YourWishes c7b0d79d06 Entity movement improvements 2026-06-11 23:03:11 -05:00
YourWishes 5f52b20400 Update camera doc 2026-06-11 22:33:33 -05:00
YourWishes b5d29aab55 Bike Shed Camera 2026-06-11 22:32:24 -05:00
72 changed files with 1873 additions and 732 deletions
+8 -2
View File
@@ -17,7 +17,7 @@ Never instantiate these — access only via the global handle.
| `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.GAME_MENU` |
| `UI` | Root UI accessor — `UI.PAUSE_MENU`, `UI.QUIT_DIALOG`, `UI.MAIN_MENU_DIALOG`, `UI.BACKDROP`, `UI.DEBUG_MENU`, `UI.GAME_MENU` |
## Scene Graph
@@ -25,7 +25,13 @@ 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, PauseMenu, GameMenu
DebugMenu
├─ GameMenu
├─ ChatBoxContainer (world-space dialogue textboxes)
├─ ModalBackdrop (repositions dynamically — see ui.md)
├─ PauseMenu
├─ QuitConfirmDialog
└─ MainMenuConfirmDialog
```
`RootScene` listens to `SCENE.sceneChanged` and shows/hides the correct sub-tree.
+288 -23
View File
@@ -2,52 +2,263 @@
All authored text — NPC conversations, item pickup messages, battle narration — is written in `.dialogue` files and played back via `DialogueManager` (godot_dialogue_manager v3.10.4).
## Plugin
## Plugin — godot_dialogue_manager v3.10.4
- Repo: [nathanhoad/godot_dialogue_manager](https://github.com/nathanhoad/godot_dialogue_manager)
- Autoload: `DialogueManager` (registered in `project.godot`)
- Plugin must be enabled in Godot → Project Settings → Plugins → Dialogue Manager
- Plugin must be enabled: Godot → Project Settings → Plugins → Dialogue Manager
- `.dialogue` files are imported as `DialogueResource` once the plugin is active
- Dialogue files live in `dialogue/` organised by category (`npc/`, `item/`, `battle/`)
### Key API
#### Fetching lines manually (our approach)
```gdscript
# Must be awaited. Runs any mutations encountered along the way.
# Returns a DialogueLine, or null when dialogue ends.
var line:DialogueLine = await DialogueManager.get_next_dialogue_line(
resource, # DialogueResource
"start", # cue / section title to begin from
[extra_state_obj] # optional: objects whose properties are accessible in {{variables}}
)
```
This is the low-level path we use — it gives full control over how lines are displayed. We do **not** use `DialogueManager.show_dialogue_balloon()` since we render lines ourselves via the world-space textbox system.
#### Advancing to the next line
```gdscript
# Pass line.next_id to continue the conversation
var next_line = await DialogueManager.get_next_dialogue_line(resource, line.next_id)
# For a chosen response, pass the response's next_id instead
var next_line = await DialogueManager.get_next_dialogue_line(resource, chosen_response.next_id)
```
#### Signals
| Signal | When |
|---|---|
| `dialogue_started(resource)` | First line fetched from a resource |
| `dialogue_ended(resource)` | `get_next_dialogue_line` returns `null` |
| `got_dialogue(line)` | Each time a printable line is found |
| `mutated(mutation)` | A `do` / `set` mutation line is about to run |
| `passed_cue(cue)` | A `~ cue` marker is passed through |
`UISingleton.dialogueActive` should be driven by `dialogue_started` / `dialogue_ended` rather than set manually.
### DialogueLine properties
| Property | Type | Notes |
|---|---|---|
| `character` | `String` | Speaker name (empty string if no speaker) |
| `text` | `String` | The line body, with BBCode and `{{variables}}` resolved |
| `responses` | `Array[DialogueResponse]` | Non-empty when the line has choices |
| `next_id` | `String` | ID to pass to the next `get_next_dialogue_line` call |
| `tags` | `PackedStringArray` | `[#tag]` annotations on the line — use for expressions, camera cues, etc. |
### DialogueResponse properties
| Property | Type | Notes |
|---|---|---|
| `text` | `String` | Display text for the option |
| `next_id` | `String` | Pass to `get_next_dialogue_line` if this response is chosen |
| `is_allowed` | `bool` | False if the `[if condition]` check failed — hide or grey out disallowed responses |
| `character` | `String` | Speaker name for this response (usually the player) |
### BBCode tags (dialogue-manager extras)
Beyond standard Godot BBCode, the plugin adds:
| Tag | Effect |
|---|---|
| `[wait=N]` | Pause reveal for N seconds (or `[wait="action"]` to wait for input) |
| `[speed=N]` | Multiply reveal speed by N for the remainder of the line |
| `[next=N]` | Auto-advance to next line after N seconds (used with `TIMED` mode) |
| `[next=auto]` | Auto-advance after a length-estimated delay |
| `[[A\|B\|C]]` | Pick one option at random inline |
---
## Design Goals
These are the intended behaviours for the full system. Some are implemented; some are not yet (see status notes per section).
### World-space textboxes
Every textbox is anchored to a 3D point in the world and projected into UI space each frame. There is no bottom-bar HUD textbox — all speech appears spatially near the speaker.
- Textbox shows a **speaker name** (small static label) and **body text** (character-by-character reveal).
- Max width/height is capped; text wraps automatically. Overflow becomes additional **pages** advanced by player input.
- If the anchor point goes off-screen, the textbox **clamps to the nearest screen edge** — a textbox is always visible if it is active. If a dialogue sequence should genuinely allow its textbox to go off-screen (e.g. distant background NPC chatter the player isn't meant to read), use a non-blocking sequence and don't show the textbox at all, or use a different trigger type.
- Multiple textboxes can be on screen simultaneously (e.g. two NPCs talking to each other while the player has their own dialogue open).
### Speaker → Entity mapping
The speaker name in a `.dialogue` line (e.g. `Guard: Halt!`) is matched against entities present in the current scene by a **separate `dialogueName` export** on `Entity` (not `entityId`). The matched entity provides the 3D anchor point for the textbox and the display name shown in the speaker label.
- `dialogueName` is the key used for lookup — set it in the Inspector, keep it stable within a project.
- The **display name** shown to the player is a separate property and can differ (e.g. `dialogueName = "guard_captain"` but display name = `"???"` before the character has revealed their name). This lets you write dialogue that references a speaker before the player knows who they are.
- **Every dialogue line must have a speaker.** There is no narrator/no-speaker path — if a line has no natural speaker, use a named invisible anchor Entity in the scene.
> **Implemented:** Entity lookup is live — `OVERWORLD.getEntityByDialogueName(line.character)` resolves the speaker. 3D→UI projection runs every frame in `DialogueTextbox._updateWorldPosition()`.
### All conversation routes through DialogueManager
Whether a line of dialogue is triggered by:
- A player pressing Interact on an NPC,
- A player walking into a proximity trigger,
- A cutscene sequence,
- Two NPCs conversing automatically,
…it is always written in a `.dialogue` file and played back via `DialogueManager.get_next_dialogue_line()`. There is no separate "dumb string" path for simple one-liners.
> **Done:** `CHATBOX` interact type removed. All NPC text now routes through DialogueManager.
### Dialogue and movement control
Dialogue does **not** automatically block movement or camera. Each dialogue sequence declares whether it pauses them:
- **Blocking** (most NPC conversations triggered by player): sets `UI.activeConversation = true` for the duration; `EntityMovement._canMove()` returns false and `OverworldCamera._canOrbit()` returns false.
- **Non-blocking** (ambient chatter, background NPC conversations, timed popups): dialogue runs without setting `activeConversation`; the player can move and orbit the camera freely.
This is configured per `DialogueAction` call, not per line.
> **Implemented:** `DialogueMode.CONVERSATION` sets `UI.activeConversation = true` (blocks movement and camera orbit). `NARRATION` and `AMBIENT` are non-blocking. `UI.dialogueActive` is driven by `DialogueManager.dialogue_started/ended` signals (emitted by `DialogueAction`) and is true for any running dialogue regardless of mode.
### Text reveal (scrolling)
Body text is revealed character-by-character at a speed approximating natural human speech. The reveal is not purely uniform — punctuation introduces natural pauses:
| Character | Behaviour |
|---|---|
| `,` `;` | Short pause (~0.15s) |
| `.` `!` `?` | Longer pause (~0.4s) |
| `...` | Each dot revealed individually with a pause between (~0.3s each), simulating thinking |
Holding the **Interact** bind speeds up reveal to near-instant. Once all characters on the current page are revealed, pressing Interact advances to the next page or the next dialogue line.
The plugin returns `DialogueLine.text` as a plain BBCode string — it does **not** handle punctuation pauses or `...` detection. All reveal pacing is implemented in our textbox, not the plugin.
The `[wait=N]` and `[speed=N]` BBCode tags are extracted from the text by the plugin during line resolution (stored in `DialogueLine.speeds` and inline mutation data). Our textbox must read these to adjust reveal behaviour inline — e.g. a shocked line using `[speed=4]` to flash past quickly.
**ADA note:** fast reveal speeds (via `[speed=N]` or hold-to-skip) should not flash entire blocks of text in a way that could trigger photosensitive responses. Avoid `[speed=N]` values so high that multiple lines appear simultaneously. The hold-to-skip path is safe since it requires sustained player input.
> **Implemented:** `DialogueTextbox` has character-by-character reveal, punctuation pauses, `...` detection, hold-to-skip (requires releasing Interact first — guarded by `_hasLetGoOfInteract`), and a pulsing advance indicator (▼) shown when waiting for input. Punctuation pauses are suppressed for the last visible character on a page or at end of text so there is no delay before the player can advance. `[wait=N]` / `[speed=N]` BBCode tag support is not yet wired.
### Advancement modes
Each dialogue sequence uses one of two advancement modes:
| Mode | Behaviour |
|---|---|
| `PLAYER` (default) | Player presses Interact to advance to the next page or line once reveal is complete. Used by `CONVERSATION` and `NARRATION` modes. |
| `TIMED` | Each line auto-advances after a delay estimated from line length (reading speed). A per-line `[next=N]` tag overrides the estimated delay. Player input is ignored. Used by `AMBIENT` mode. |
Mixed sequences (some lines timed, some player-advanced) are not supported — advancement mode is determined by `DialogueMode`, not per line.
> **Implemented:** `TIMED` auto-advance is wired in `DialogueTextbox._processAutoAdvance`. Delay is estimated from line length (characters / `READING_CHARS_PER_SECOND`). Per-line `[next=N]` override is not yet wired.
### Response / choice UI
When a `DialogueLine` has a non-empty `responses` array, reveal pauses and a choice textbox appears **anchored to the player entity** — choices appear as if the player is speaking. The options are a vertical list; the player selects with D-pad/arrow keys and confirms with Interact. The chosen response's `next_id` is passed to `get_next_dialogue_line()`.
The choice textbox follows the same world-space anchor and screen-edge clamping rules as all other textboxes.
> **Implemented:** `DialogueChoiceBox` is shown when allowed responses exist, anchored to the player entity via `OVERWORLD.getPlayerEntity()`. Selected item shows a `▶ ` prefix and yellow highlight. Navigation uses Godot's native focus system — labels have `focus_mode = FOCUS_ALL` with explicit `focus_neighbor_top/bottom` to prevent focus escaping the list at the edges; `focus_entered` signals update `_selectedIndex`. `_input` handles only the game-specific `interact` confirm; `ui_up`/`ui_down`/`ui_accept` are handled natively by the engine.
### Trigger types
| Trigger | How it works |
|---|---|
| Player interact | Entity `interactType = CONVERSATION`; player presses Interact nearby |
| Proximity (enter area) | Trigger volume fires when player enters; starts non-blocking dialogue |
| Cutscene sequence | `DialogueAction` added to a `Cutscene` queue |
| Scripted NPC-to-NPC | Cutscene sequence with non-blocking mode, no player involvement |
> **Proximity trigger:** defined as `PROXIMITY_CHATBOX` in `Entity.InteractType` but not yet implemented.
---
## DialogueMode enum
Every dialogue sequence has a `DialogueMode` that controls movement blocking and advancement behaviour. Set it per `DialogueAction` call — not per line.
| Mode | Movement | Camera orbit | Advancement | Typical use |
|---|---|---|---|---|
| `CONVERSATION` | Blocked | Locked | Player (Interact) | NPC interactions, cutscene dialogue |
| `NARRATION` | Non-blocking | Free | Player (Interact) | Item pickups, announcements the player can dismiss when ready |
| `AMBIENT` | Non-blocking | Free | Timed (auto) | Background NPC-to-NPC chatter, timed popups |
`UI.dialogueActive` is driven by `DialogueManager.dialogue_started` / `dialogue_ended` signals (emitted by `DialogueAction`) and is true whenever any dialogue is running, regardless of mode. Movement and camera blocking are checked separately via `UI.activeConversation`: `EntityMovement._canMove()` and `OverworldCamera._canOrbit()` both return false only when an active `CONVERSATION` sequence is in progress.
More modes can be added to this enum as new use cases arise.
---
## DialogueAction — the Cutscene bridge
[cutscene/dialogue/DialogueAction.gd](../../cutscene/dialogue/DialogueAction.gd) is the glue between the `Cutscene` queue and `DialogueManager`. It runs a `.dialogue` file through `VNTextbox` line-by-line and returns `CUTSCENE_CONTINUE` when the last line is dismissed.
[cutscene/dialogue/DialogueAction.gd](../../cutscene/dialogue/DialogueAction.gd) is the glue between the `Cutscene` queue and `DialogueManager`. It fetches lines via `get_next_dialogue_line()`, displays them in the world-space textbox, and returns `CUTSCENE_CONTINUE` when the last line is dismissed.
```gdscript
# Add a dialogue step to any Cutscene
cutscene.addCallable(DialogueAction.getDialogueCallable(
load("res://dialogue/npc/test.dialogue"),
"start", # title to begin from
[entity] # extra_game_states: objects/dicts accessible in the .dialogue file
"res://dialogue/npc/guard", # base path — no locale suffix, no extension
"start", # section to begin from
[entity], # extra_game_states exposed to {{variables}} in the file
DialogueAction.DialogueMode.CONVERSATION
))
```
Movement is blocked automatically while dialogue runs because `VNTextbox` is open (`EntityMovement._canMove()` checks `UI.TEXTBOX.isClosed`).
`DialogueAction` resolves the base path to the correct locale file at runtime (e.g. `guard.en.dialogue`), falling back to `en` if the active locale file is missing.
---
## Writing .dialogue files
```
~ title_name # entry point / jump target
Speaker: Line of dialogue.
Another line with no speaker.
SpeakerName: Line of dialogue.
~ another_section
Speaker: Variables resolve inline: {{some_property}}.
SpeakerName: Variables resolve inline: {{some_property}}.
=> END # end this dialogue
```
**Key syntax:**
- `~ title` — section anchor; use as the `title` argument to `DialogueAction`
- `~ title` — section anchor; pass as the `title` argument to `DialogueAction`
- `=> title` — jump to another section; `=> END` ends the dialogue
- `{{variable}}` — resolves a property from any autoload or `extra_game_states` object
- `do AUTOLOAD.method()` — call a method on any autoload (e.g. `do PARTY.BACKPACK.addStack(stack)`)
- `set AUTOLOAD.property = value` — set a property
- `- Option text` — response branch (indented lines handle each branch)
- `set AUTOLOAD.property = value` — set a property on any autoload
- `- Option text` — response branch (player choice); indented lines run for that branch
- `[if condition]` — conditional line or response
- `[wait=N]` — pause character reveal for N seconds mid-line
- `[speed=N]` — multiply reveal speed by N for the rest of the line
**Mutations fire automatically** before the next dialogue line is returned — you do not handle them manually in GDScript.
### Speaker names
The name before the colon becomes `DialogueLine.character`. This string is matched case-insensitively against the `dialogueName` export on `Entity` nodes in the current scene, to find the 3D anchor point.
Keep `dialogueName` values stable across a project — they are the linking key between dialogue files and scene nodes. The player-visible name shown in the textbox speaker label is a separate property and can change at runtime (e.g. `"???"` before a character is introduced).
```
guard_captain: Halt! Who goes there?
```
```gdscript
# Entity Inspector exports
entityId = "guard_captain_01" # internal UUID, can be anything
dialogueName = "guard_captain" # must match the .dialogue speaker name
displayName = "???" # shown to the player in the textbox speaker label
```
---
## Passing runtime data to dialogue
Use `extra_game_states` to expose GDScript objects to `{{variable}}` tokens:
@@ -62,28 +273,82 @@ DialogueAction.getDialogueCallable(resource, 'start', [ItemDialogueState.new("Po
Then in the `.dialogue` file:
```
Obtained {{item_name}} x{{quantity}}.
shopkeeper: Here's your {{item_name}} x{{quantity}}.
```
---
## NPC entities
Set these two exports on an Entity node whose `interactType = CONVERSATION`:
Set these exports on an Entity node whose `interactType = CONVERSATION`:
| Export | Purpose |
|---|---|
| `dialogueResource:DialogueResource` | The `.dialogue` file to run |
| `dialogueTitle:String` | Title (section) to start from (default `"start"`) |
| `dialogueBasePath:String` | Base path to the dialogue file, without locale suffix or extension (e.g. `"res://dialogue/npc/guard"`) |
| `dialogueTitle:String` | Section to start from (default `"start"`) |
| `dialogueName:String` | Key matched against `.dialogue` speaker names to find this entity's textbox anchor |
| `displayName:String` | Player-visible name shown in the textbox speaker label (can differ from `dialogueName`, e.g. `"???"`) |
After first enabling the plugin and reimporting, assign `dialogueResource` directly in the Inspector. Until then, assign it in code (see [TestMap.gd](../../overworld/map/TestMap.gd) for the pattern).
`dialogueName` must match the speaker name used in the dialogue file. `entityId` is unrelated to dialogue lookup.
---
## Dialogue files
| File | Used by |
|---|---|
| `dialogue/npc/test.dialogue` | TestMap NPC (NotPlayer) |
| `dialogue/item/pickup.dialogue` | `ItemAction` — item pickup text with `{{item_name}}` and `{{quantity}}` |
| `dialogue/battle/narration.dialogue` | `BattleCutsceneAction` — move announcements, victory/defeat |
| `dialogue/npc/test.en.dialogue` | TestMap NPC (NotPlayer / "Stranger") |
| `dialogue/item/pickup.en.dialogue` | `ItemAction` — item pickup text with `{{item_name}}` and `{{quantity}}` |
| `dialogue/battle/narration.en.dialogue` | `BattleCutsceneAction` — move announcements, victory/defeat |
## Response branching (current limitation)
---
`DialogueAction` auto-selects the **first allowed response** when a line has multiple options. There is no in-game response UI yet. To add one, replace the auto-select block in `DialogueAction.dialogueCallable` with a call to your response menu and `await` its selection signal.
## Translation
Translation uses **per-locale dialogue files**. Each `.dialogue` file has a locale suffix; `DialogueAction` selects the correct file at runtime based on the active locale in `SETTINGS`.
```
dialogue/npc/test.en.dialogue
dialogue/npc/test.ja.dialogue
```
`DialogueAction.getDialogueCallable()` accepts a base path (no locale suffix, no extension — e.g. `"res://dialogue/npc/test"`) and resolves the active locale automatically via `TranslationServer.get_locale()` (e.g. → `test.en.dialogue`), falling back to `en` if the locale file is missing.
To switch language at runtime: `TranslationServer.set_locale("ja")` — all subsequent dialogue loads will use the new locale. The project default is configured in Godot's **Project Settings → Localization** tab.
Do not hardcode any visible text outside of `.dialogue` files. All player-facing strings — including item pickup messages, battle narration, and UI prompts — must live in a dialogue file so they are covered by the locale system.
> **Implemented:** `DialogueAction._loadLocaleResource(basePath)` resolves the active locale via `TranslationServer.get_locale().left(2)`, falling back to `en` if the locale file is missing.
---
## Technical implementation notes
### DialogueTextbox layout
`DialogueTextbox` (`ui/component/DialogueTextbox.gd`) does its entire layout synchronously in `setup()` — there is no deferred pre-pass frame:
1. `size.x = MAX_WIDTH` is set on the PanelContainer.
2. `_bodyLabelWidth()` reads the panel `StyleBox` margins to compute the BodyLabel's inner width.
3. `_bodyLabel.size.x` is set explicitly so that `get_character_line(i)` has correct metrics when called next.
4. `get_character_line()` internally calls `_validate_line_caches()` which forces synchronous text shaping — no render frame needed.
5. `_buildPreWrappedText()` iterates characters, detects wrap boundaries via `get_character_line()`, and inserts explicit `\n` characters.
6. The pre-wrapped text is set back on the label with `autowrap_mode = AUTOWRAP_OFF` so layout never changes during reveal.
7. Page detection during reveal uses `_parsedText.left(idx+1).count("\n")` — pure string math, no Godot layout calls.
8. `_revealNextChar()` is called at end of `setup()` and `_advance()` so the box first appears with one character already visible.
**Do not** re-introduce a pre-pass frame (setting `visible = true` with an off-screen position or `modulate.a = 0`) — this causes a one-frame flicker that the user can see.
### DialogueChoiceBox layout
`DialogueChoiceBox` (`ui/component/DialogueChoiceBox.gd`) computes its height synchronously in `setup()`:
1. For each label: `add_child(label)`, then `label.size.x = _labelWidth()`, then `label.get_minimum_size().y` (forces synchronous shaping).
2. Sum label heights + list separator gaps + panel top/bottom margins.
3. `size = Vector2(MAX_WIDTH, totalHeight)` is set before `visible = true`.
Navigation uses Godot's native focus system. Labels have `focus_mode = FOCUS_ALL`; `focus_neighbor_top/bottom` is set explicitly to keep focus within the list (edge labels point back to themselves). `focus_entered` signals update `_selectedIndex`. `_input` handles only the game-specific `interact` confirm; `ui_up`/`ui_down` are handled natively by the engine. `_confirm()` guards `if not visible` to prevent double-firing if `interact` and `ui_accept` share a physical key.
## Open questions
None currently. Update this section as new design questions arise.
+17 -5
View File
@@ -73,28 +73,40 @@ Camera-relative direction is derived from the active `Camera3D`'s basis — the
## Camera
`OverworldCamera` orbits around `targetNode` using yaw/pitch angles. It operates in two modes: `CameraMode.FREE` (stays where placed) and `CameraMode.CENTERED` (lerps to orbit directly behind the player).
`OverworldCamera` orbits around `targetNode` using yaw/pitch angles. It operates in two modes: `CameraMode.FREE` (stays where placed) and `CameraMode.CENTERED` (eases to orbit behind the player using a velocity-based approach).
| 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` | `7.0` | Orbit radius |
| `minDistance:float` | `2.0` | Minimum camera distance (collision won't push closer) |
| `minDistance:float` | `2.5` | Minimum camera distance (collision won't push closer) |
| `pitchMin/Max:float` | `-80° / 70°` | Vertical clamp |
| `orbitSensitivity:float` | `144.0` | Degrees/sec at full controller input |
| `orbitAcceleration:float` | `600.0` | Controller ramp speed (deg/s²) |
| `orbitFriction:float` | `10.0` | Controller/centering velocity decay rate |
| `mouseSensitivity:float` | `0.3` | Right-click drag sensitivity |
| `centeredDelay:float` | `2.0` | Seconds of walking + no camera input before switching to CENTERED |
| `centeredFollowRate:float` | `3.0` | Lerp rate in CENTERED mode (higher = faster; 3 ≈ 1s to fully center) |
| `centeredDelay:float` | `1.0` | Seconds of walking + no camera input before switching to CENTERED |
| `centeredFollowRate:float` | `0.75` | Base centering rate; scales up with angular distance from target |
| `centeredMaxFollowRate:float` | `3.0` | Cap on the dynamic follow rate |
| `centeredAcceleration:float` | `180.0` | How fast `_centerVelocity` ramps up toward target (deg/s²) |
| `centeredMaxYawDiff:float` | `120.0` | If yaw offset exceeds this, centering is suppressed (e.g. player walking toward camera) |
| `centeredPitch:float` | `30.0` | Target pitch in CENTERED mode |
| `collisionMask:int` | `1` | Physics layers the camera avoids; entities are on layer 2 and excluded by default |
**Input lock:** `_canOrbit()` returns false when `UI.activeConversation` is true. All manual orbit input (controller stick, right-click drag, `center_camera`) is suppressed. If right-click was held when a conversation starts, the mouse is released automatically. The camera stays at its current position and the positioning math still runs, so it remains correctly placed relative to the (non-moving) player.
**Mode transitions:**
- FREE → CENTERED: after `centeredDelay` seconds of player movement with no camera input, or immediately via `center_camera` (G / LB)
- CENTERED → FREE: any manual camera input (controller stick or right-click drag)
Collision resolution uses a sphere-plane circle intersection so the camera satisfies both `minDistance` and terrain clearance simultaneously (`COLLISION_MARGIN = 0.3`).
**CENTERED mode behaviour:**
- "Behind" direction is derived from the player's velocity vector (not facing direction), so centering tracks actual movement and eases out naturally as the player decelerates
- Yaw uses a velocity model: `_centerVelocity` (deg/s) accelerates toward the target rate via `centeredAcceleration` and friction-decays via `orbitFriction` when the player stops — no sudden snap
- Pitch recenters at 3× the yaw rate via direct lerp
- If `abs(yawDiff) > centeredMaxYawDiff`, centering is suppressed and `_centerVelocity` decays instead
Collision resolution uses a sphere-plane circle intersection so the camera satisfies both `minDistance` and terrain clearance simultaneously.
## Adding a new map
+1 -1
View File
@@ -12,4 +12,4 @@ These exist in the codebase but have no real implementation yet. Don't assume th
| `BattleItem.perform()` | Stub |
| `CookingScene.tscn` | Placeholder UI only |
| Response branching in `DialogueAction` | Auto-selects first allowed response; no response UI exists yet |
| `Pause.gd` | Logic commented out |
| `Pause.gd` | Wired — opens/closes `UI.PAUSE_MENU`; blocked on `INITIAL` scene |
+128
View File
@@ -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`
+128 -57
View File
@@ -5,57 +5,116 @@
```
RootUI (Control, fullscreen, always visible)
├── DebugMenu
├── PauseMenu
│ ├── PauseSettings
│ └── PauseMain
├── GameMenu
│ ├── GameMenuPartyTab
│ └── GameMenuItemsTab
── VNTextbox
── ChatBoxContainer
│ └── InteractIndicator
├── ModalBackdrop ← shared backdrop; z_index managed by FocusStack
├── PauseMenu
│ ├── PauseMain
│ └── PauseSettings
├── QuitConfirmDialog
└── MainMenuConfirmDialog
```
`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`.
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.
## UI singleton
`UI` (autoload) is the global access point.
| Accessor | Returns | Notes |
| Accessor | Type | 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 |
| `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 `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.
`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.
## VNTextbox
## ClosableMenu
Bottom-anchored `PanelContainer` that reveals text character-by-character and paginates when content overflows 4 lines.
**Showing text from code:**
Base class for all togglable panels. Extends `Control`; the `isOpen:bool` export drives `visible`.
```gdscript
# Fire-and-forget
UI.TEXTBOX.setText("Hello world.")
# Await player dismiss — use this in cutscene callables
await UI.TEXTBOX.setTextAndWait("Hello world.")
menu.open() # shows, pushes to FocusStack (if canClose), emits opened
menu.close() # pops from FocusStack (if canClose), hides, emits closed
menu.toggle()
```
**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
**Key exports / properties:**
**Input guard:** `EntityMovement._canMove()` returns `false` while `!UI.TEXTBOX.isClosed`. Don't set text without also expecting movement to be blocked.
| 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. |
**Signal:** `textboxClosing` — emitted once per `setTextAndWait` call when player dismisses.
**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`.
```gdscript
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:**
```gdscript
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 `VNTextbox`. Handles:
`RichTextLabel` subclass (`@tool`) used inside world-space dialogue textboxes. Handles:
- Smart word-wrap (`TextServer.AUTOWRAP_WORD_SMART`)
- Pagination via `maxLines` / `startLine` exports
@@ -64,20 +123,6 @@ await UI.TEXTBOX.setTextAndWait("Hello world.")
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()`.
@@ -93,31 +138,54 @@ 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.
**Key methods on `GameMenu`:**
**Key members on `GameMenu`:**
| Method | Effect |
| Member | Notes |
|---|---|
| `open()` | Shows menu, refreshes active tab, grabs sidebar focus |
| `close()` | Hides menu |
| `isOpen() -> bool` | Visibility state |
| `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. 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()`.
## Pause menu
`PauseMenu` wraps `PauseMain` (item list) and `PauseSettings` (settings tabs).
`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()` | Shows container, opens PauseMain |
| `PauseMenu.close()` | Hides everything |
| `PauseMenu.isOpen() -> bool` | Visibility state |
| `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` inside the pause menu: if PauseSettings is open, closes it and reopens PauseMain; otherwise closes the whole menu.
**`ui_cancel` behaviour inside PauseMenu:**
- If `PauseSettings` is open → closes settings, reopens PauseMain
- Otherwise → closes PauseMenu
> **Stub:** `Pause.gd` (the singleton) has its logic commented out. Pause menu is not yet wired to actual game-pause state.
(`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
@@ -134,7 +202,7 @@ To add a new tab: add a value to `GameMenu.Tab`, create a tab scene/script under
| Cooking | `SCENE.setScene(COOKING)` |
| Initial | `SCENE.setScene(INITIAL)` |
Access via `UI.DEBUG_MENU`. Starts hidden; `isClosed` getter/setter controls visibility.
Access via `UI.DEBUG_MENU`. Starts hidden.
## Theme & assets
@@ -145,6 +213,9 @@ Access via `UI.DEBUG_MENU`. Starts hidden; `isClosed` getter/setter controls vis
## 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)
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.
+2
View File
@@ -13,6 +13,7 @@ Detailed reference lives in [.claude/docs/](.claude/docs/):
- [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
- [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/architecture.md
@@ -21,3 +22,4 @@ Detailed reference lives in [.claude/docs/](.claude/docs/):
@.claude/docs/stubs.md
@.claude/docs/overworld.md
@.claude/docs/ui.md
@.claude/docs/ui-navigation.md
+4 -5
View File
@@ -1,7 +1,6 @@
class_name BattleCutsceneAction
const DialogueAction = preload("res://cutscene/dialogue/DialogueAction.gd")
static var NARRATION:DialogueResource = preload("res://dialogue/battle/narration.dialogue")
const _NARRATION_BASE:String = "res://dialogue/battle/narration"
# State object passed as extra_game_states so {{variable}} tokens resolve in the dialogue file.
class BattleNarrationState:
@@ -29,7 +28,7 @@ static func battleDecisionCallable(params:Dictionary) -> int:
var cutscene:Cutscene = params['cutscene']
cutscene.addCallable(
DialogueAction.getDialogueCallable(NARRATION, 'move_perform', [state]).merged(
DialogueAction.getDialogueCallable(_NARRATION_BASE, 'move_perform', [state], DialogueAction.DialogueMode.NARRATION).merged(
{'position': Cutscene.CUTSCENE_ADD_NEXT}, false
)
)
@@ -58,7 +57,7 @@ static func playerDecisionCallable(params:Dictionary) -> int:
if allPlayersDead:
params['cutscene'].addCallable(
DialogueAction.getDialogueCallable(NARRATION, 'battle_defeat').merged(
DialogueAction.getDialogueCallable(_NARRATION_BASE, 'battle_defeat', [], DialogueAction.DialogueMode.NARRATION).merged(
{'position': Cutscene.CUTSCENE_ADD_NEXT}, false
)
)
@@ -66,7 +65,7 @@ static func playerDecisionCallable(params:Dictionary) -> int:
if allEnemiesDead:
params['cutscene'].addCallable(
DialogueAction.getDialogueCallable(NARRATION, 'battle_victory').merged(
DialogueAction.getDialogueCallable(_NARRATION_BASE, 'battle_victory', [], DialogueAction.DialogueMode.NARRATION).merged(
{'position': Cutscene.CUTSCENE_ADD_NEXT}, false
)
)
+60 -29
View File
@@ -1,45 +1,76 @@
class_name DialogueAction
# Runs a .dialogue file through the VNTextbox and returns CUTSCENE_CONTINUE when
# the last line is dismissed. Mutations in the .dialogue file are executed
# automatically by DialogueManager before the line is returned.
#
# extra_game_states: additional objects/dicts whose properties and methods are
# accessible inside the .dialogue file (alongside all autoloads).
const _TextboxGd = preload("res://ui/component/DialogueTextbox.gd")
const _ChoiceBoxGd = preload("res://ui/component/DialogueChoiceBox.gd")
enum DialogueMode {
CONVERSATION, # blocks movement, player advances
NARRATION, # non-blocking, player advances
AMBIENT, # non-blocking, timed auto-advance
}
static func dialogueCallable(params:Dictionary) -> int:
assert(params.has('resource'))
var resource:DialogueResource = params['resource']
assert(params.has('basePath'))
var basePath:String = params['basePath']
var title:String = params.get('title', 'start')
var extraStates:Array = params.get('extraStates', [])
var mode:DialogueMode = params.get('mode', DialogueMode.CONVERSATION)
UI.dialogueActive = true
var resource:DialogueResource = _loadLocaleResource(basePath)
assert(resource != null, "DialogueAction: could not load resource for path: " + basePath)
if mode == DialogueMode.CONVERSATION:
UI.activeConversation = true
var advancementMode:int = (
_TextboxGd.AdvancementMode.TIMED
if mode == DialogueMode.AMBIENT
else _TextboxGd.AdvancementMode.PLAYER
)
DialogueManager.dialogue_started.emit(resource)
var line:DialogueLine = await DialogueManager.get_next_dialogue_line(resource, title, extraStates)
while line != null:
var text:String = line.text
if line.character:
text = line.character + ": " + text
var entity:Entity = OVERWORLD.getEntityByDialogueName(line.character)
if line.responses.size() > 0:
# Show text then auto-pick the first allowed response.
# Replace this block with a real response UI when branching dialogue is needed.
await UI.TEXTBOX.setTextAndWait(text)
var nextId:String = ""
for response:DialogueResponse in line.responses:
if response.is_allowed:
nextId = response.next_id
break
line = await DialogueManager.get_next_dialogue_line(resource, nextId, extraStates)
var textbox = _TextboxGd.SCENE.instantiate()
UI.chatBoxContainer.add_child(textbox)
textbox.setup(line, entity, advancementMode)
await textbox.dismissed
var allowedResponses:Array = line.responses.filter(func(r): return r.is_allowed)
if allowedResponses.size() > 0:
var playerEntity:Entity = OVERWORLD.getPlayerEntity()
var choiceBox = _ChoiceBoxGd.SCENE.instantiate()
UI.chatBoxContainer.add_child(choiceBox)
choiceBox.setup(line.responses, playerEntity)
var chosen:DialogueResponse = await choiceBox.chosen
line = await DialogueManager.get_next_dialogue_line(resource, chosen.next_id, extraStates)
else:
await UI.TEXTBOX.setTextAndWait(text)
line = await DialogueManager.get_next_dialogue_line(resource, line.next_id, extraStates)
UI.dialogueActive = false
DialogueManager.dialogue_ended.emit(resource)
if mode == DialogueMode.CONVERSATION:
UI.activeConversation = false
return Cutscene.CUTSCENE_CONTINUE
static func getDialogueCallable(resource:DialogueResource, title:String = 'start', extraStates:Array = []) -> Dictionary:
static func _loadLocaleResource(basePath:String) -> DialogueResource:
var lang:String = TranslationServer.get_locale().left(2)
var localePath:String = basePath + "." + lang + ".dialogue"
if ResourceLoader.exists(localePath):
return load(localePath)
var fallback:String = basePath + ".en.dialogue"
if ResourceLoader.exists(fallback):
return load(fallback)
return null
static func getDialogueCallable(basePath:String, title:String = "start", extraStates:Array = [], mode:DialogueMode = DialogueMode.CONVERSATION) -> Dictionary:
return {
'function': dialogueCallable,
'resource': resource,
'title': title,
'extraStates': extraStates,
"function": dialogueCallable,
"basePath": basePath,
"title": title,
"extraStates": extraStates,
"mode": mode,
}
+8 -7
View File
@@ -1,23 +1,24 @@
class_name ItemAction
const DialogueAction = preload("res://cutscene/dialogue/DialogueAction.gd")
# Passed as extra_game_states so {{item_name}} and {{quantity}} resolve in the .dialogue file.
class ItemDialogueState:
var item_name:String
var quantity:int
func _init(name:String, qty:int) -> void:
item_name = name
func _init(itemName:String, qty:int) -> void:
item_name = itemName
quantity = qty
static var PICKUP_DIALOGUE:DialogueResource = preload("res://dialogue/item/pickup.dialogue")
static func itemGetCallable(params:Dictionary) -> int:
assert(params.has('stack'))
var stack:ItemStack = params['stack']
PARTY.BACKPACK.addStack(stack)
var state = ItemDialogueState.new(Item.getItemName(stack.item), stack.quantity)
var dialogueParams:Dictionary = DialogueAction.getDialogueCallable(PICKUP_DIALOGUE, 'start', [state])
var dialogueParams:Dictionary = DialogueAction.getDialogueCallable(
"res://dialogue/item/pickup",
"start",
[state],
DialogueAction.DialogueMode.CONVERSATION
)
dialogueParams['position'] = Cutscene.CUTSCENE_ADD_NEXT
params['cutscene'].addCallable(dialogueParams)
return Cutscene.CUTSCENE_CONTINUE
-16
View File
@@ -1,16 +0,0 @@
[remap]
importer="dialogue_manager"
importer_version=15
type="Resource"
uid="uid://b0xspt5l72ta4"
path="res://.godot/imported/narration.dialogue-d3cec8f2ca7d5fcccf22c774ea16000c.tres"
[deps]
source_file="res://dialogue/battle/narration.dialogue"
dest_files=["res://.godot/imported/narration.dialogue-d3cec8f2ca7d5fcccf22c774ea16000c.tres"]
[params]
defaults=true
@@ -0,0 +1,16 @@
[remap]
importer="dialogue_manager"
importer_version=15
type="Resource"
uid="uid://c4ik5l43fllwe"
path="res://.godot/imported/narration.en.dialogue-306b824321c5ffb5e2681c2a3febbbf1.tres"
[deps]
source_file="res://dialogue/battle/narration.en.dialogue"
dest_files=["res://.godot/imported/narration.en.dialogue-306b824321c5ffb5e2681c2a3febbbf1.tres"]
[params]
defaults=true
-16
View File
@@ -1,16 +0,0 @@
[remap]
importer="dialogue_manager"
importer_version=15
type="Resource"
uid="uid://b1xscm8cjvdwa"
path="res://.godot/imported/pickup.dialogue-002022bf79323195869be8ebaf81f1dd.tres"
[deps]
source_file="res://dialogue/item/pickup.dialogue"
dest_files=["res://.godot/imported/pickup.dialogue-002022bf79323195869be8ebaf81f1dd.tres"]
[params]
defaults=true
+16
View File
@@ -0,0 +1,16 @@
[remap]
importer="dialogue_manager"
importer_version=15
type="Resource"
uid="uid://mldhv5ofaxmf"
path="res://.godot/imported/pickup.en.dialogue-9582a3392a037e5325ef2829f22ff4cb.tres"
[deps]
source_file="res://dialogue/item/pickup.en.dialogue"
dest_files=["res://.godot/imported/pickup.en.dialogue-9582a3392a037e5325ef2829f22ff4cb.tres"]
[params]
defaults=true
-16
View File
@@ -1,16 +0,0 @@
[remap]
importer="dialogue_manager"
importer_version=15
type="Resource"
uid="uid://b7hdnwp46h3hi"
path="res://.godot/imported/test.dialogue-3675c9be06c1457d57c9a2cca7088875.tres"
[deps]
source_file="res://dialogue/npc/test.dialogue"
dest_files=["res://.godot/imported/test.dialogue-3675c9be06c1457d57c9a2cca7088875.tres"]
[params]
defaults=true
+16
View File
@@ -0,0 +1,16 @@
[remap]
importer="dialogue_manager"
importer_version=15
type="Resource"
uid="uid://bh0d47hd3edu7"
path="res://.godot/imported/test.en.dialogue-5bcd16c222052c4220f719dbcbf91fa5.tres"
[deps]
source_file="res://dialogue/npc/test.en.dialogue"
dest_files=["res://.godot/imported/test.en.dialogue-5bcd16c222052c4220f719dbcbf91fa5.tres"]
[params]
defaults=true
+19
View File
@@ -7,6 +7,25 @@ var hasFadedOut:bool = false
var playerDestinationNodeName:String
var newMapLoaded:bool = false
var _dialogueEntities:Dictionary = {}
var _playerEntity:Entity = null
func registerDialogueEntity(entity:Entity) -> void:
_dialogueEntities[entity.dialogueName.to_lower()] = entity
if entity.movementType == Entity.MovementType.PLAYER:
_playerEntity = entity
func unregisterDialogueEntity(entity:Entity) -> void:
_dialogueEntities.erase(entity.dialogueName.to_lower())
if _playerEntity == entity:
_playerEntity = null
func getEntityByDialogueName(dialogueName:String) -> Entity:
return _dialogueEntities.get(dialogueName.to_lower(), null)
func getPlayerEntity() -> Entity:
return _playerEntity
func isMapChanging() -> bool:
return newMapPath != ""
+45 -16
View File
@@ -1,6 +1,6 @@
class_name OverworldCamera extends Camera3D
enum CameraMode { FREE, CENTERED }
enum CameraMode { FREE, CENTERED, MANUAL_CENTER }
# const COLLISION_MARGIN:float = 0.1
const COLLISION_MARGIN:float = 0
@@ -24,10 +24,11 @@ const COLLISION_MARGIN:float = 0
@export_category("Auto-center")
@export var centeredDelay:float = 1.0
@export var centeredFollowRate:float = 0.75
@export var centeredMaxFollowRate:float = 1.5
@export var centeredAcceleration:float = 360.0
@export var centeredMaxFollowRate:float = 3.0
@export var centeredAcceleration:float = 180.0
@export var centeredMaxYawDiff:float = 120.0
@export var centeredPitch:float = 30.0
@export var manualCenterMultiplier:float = 10.0
@export_category("Collision")
@export_flags_3d_physics var collisionMask:int = 1
@@ -41,7 +42,19 @@ var _freeTimer:float = 0.0
var _mouseDelta:Vector2 = Vector2.ZERO
var _rightMouseHeld:bool = false
func _canOrbit() -> bool:
if UI.activeConversation:
return false
if UI.FOCUS_STACK.top() != null:
return false
return true
func _input(event:InputEvent) -> void:
if not _canOrbit():
if _rightMouseHeld:
_rightMouseHeld = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
_rightMouseHeld = event.pressed
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _rightMouseHeld else Input.MOUSE_MODE_VISIBLE
@@ -55,13 +68,17 @@ func _process(delta:float) -> void:
var xMult:float = -1.0 if SETTINGS.invertCameraX else 1.0
var yMult:float = 1.0 if SETTINGS.invertCameraY else -1.0
var orbitInput:Vector2 = Input.get_vector(
"camera_orbit_left", "camera_orbit_right",
"camera_orbit_up", "camera_orbit_down"
)
var orbitInput:Vector2 = Vector2.ZERO
var mouseActive:bool = false
if _canOrbit():
orbitInput = Input.get_vector(
"camera_orbit_left", "camera_orbit_right",
"camera_orbit_up", "camera_orbit_down"
)
mouseActive = _mouseDelta.length_squared() > 0.0
var controllerActive:bool = orbitInput.length() > 0.01
var mouseActive:bool = _mouseDelta.length_squared() > 0.0
# Any manual camera input returns to FREE and resets the centering timer
if controllerActive or mouseActive:
@@ -82,11 +99,11 @@ func _process(delta:float) -> void:
if mouseActive:
_yaw += _mouseDelta.x * mouseSensitivity * SETTINGS.cameraSpeedMouse * xMult
_pitch += _mouseDelta.y * mouseSensitivity * SETTINGS.cameraSpeedMouse * yMult
_mouseDelta = Vector2.ZERO
_mouseDelta = Vector2.ZERO
# center_camera input → switch to CENTERED immediately
if Input.is_action_just_pressed("center_camera"):
_mode = CameraMode.CENTERED
# center_camera input → switch to MANUAL_CENTER immediately
if _canOrbit() and Input.is_action_just_pressed("center_camera"):
_mode = CameraMode.MANUAL_CENTER
# In FREE mode, accumulate time toward auto-centering while the player is moving
if _mode == CameraMode.FREE:
@@ -102,16 +119,28 @@ func _process(delta:float) -> void:
# In CENTERED mode, accelerate a yaw velocity toward behind the player (using
# actual movement velocity for direction) then friction-decay when idle —
# mirrors how controller input works so there's no sudden stop.
if _mode == CameraMode.CENTERED:
if _mode == CameraMode.CENTERED or _mode == CameraMode.MANUAL_CENTER:
var centerBody := targetNode as CharacterBody3D
var vel3d:Vector3 = Vector3.ZERO if centerBody == null else centerBody.velocity
var behindYaw:float = 0.0
var hasTarget:bool = false
if vel3d.length_squared() > 0.1:
var behindYaw:float = rad_to_deg(atan2(-vel3d.x, -vel3d.z))
behindYaw = rad_to_deg(atan2(-vel3d.x, -vel3d.z))
hasTarget = true
elif _mode == CameraMode.MANUAL_CENTER and targetNode != null:
behindYaw = rad_to_deg(targetNode.rotation.y)
hasTarget = true
if hasTarget:
var centerYawDiff:float = fposmod(behindYaw - _yaw + 180.0, 360.0) - 180.0
if _mode == CameraMode.MANUAL_CENTER and abs(centerYawDiff) < centeredMaxYawDiff * 0.05:
_mode = CameraMode.FREE
var totalAngle:float = abs(centerYawDiff) + abs(centeredPitch - _pitch)
var dynamicRate:float = minf(centeredFollowRate * (1.0 + totalAngle / 90.0), centeredMaxFollowRate)
var speedMult:float = manualCenterMultiplier if _mode == CameraMode.MANUAL_CENTER else 1.0
var dynamicRate:float = minf(centeredFollowRate * (1.0 + totalAngle / 90.0), centeredMaxFollowRate) * speedMult
if abs(centerYawDiff) <= centeredMaxYawDiff:
_centerVelocity = move_toward(_centerVelocity, centerYawDiff * dynamicRate, centeredAcceleration * delta)
_centerVelocity = move_toward(_centerVelocity, centerYawDiff * dynamicRate, centeredAcceleration * speedMult * delta)
var tPitch:float = minf(dynamicRate * 3.0 * delta, 1.0)
_pitch = lerpf(_pitch, centeredPitch, tPitch)
else:
+19 -5
View File
@@ -14,7 +14,8 @@ enum InteractType {
ONE_TIME_ITEM,
CUTSCENE,
BATTLE_TEST,
};
PROXIMITY_CHATBOX,
}
@export_category("Identification")
@export var entityId:String = UUID.uuidv4()
@@ -22,17 +23,30 @@ enum InteractType {
var button := func():
entityId = UUID.uuidv4()
# Movement settings
@export_category("Dialogue")
@export var dialogueName:String = ""
@export var displayName:String = ""
@export_category("Movement")
@export var movementType:MovementType = MovementType.NONE
# Interaction settings
@export_category("Interactions")
@export var interactType:InteractType = InteractType.NONE
@export var dialogueResource:DialogueResource = null
@export var dialogueBasePath:String = ""
@export var dialogueTitle:String = "start"
@export var oneTimeItem:ItemResource = null
@export var cutscene:CutsceneResource = null
# TEST BATTLE
@export_category("Test Battle")
func _enter_tree() -> void:
if Engine.is_editor_hint():
return
if dialogueName != "":
OVERWORLD.registerDialogueEntity(self)
func _exit_tree() -> void:
if Engine.is_editor_hint():
return
if dialogueName != "":
OVERWORLD.unregisterDialogueEntity(self)
+1
View File
@@ -27,6 +27,7 @@ size = Vector3(1.3, 1.3, 1.3)
[node name="Entity" type="CharacterBody3D"]
collision_layer = 2
collision_mask = 3
script = ExtResource("1_8e8ef")
entityId = "35ce980f-3df9-4e09-b365-1a228948cf78"
metadata/_custom_type_script = "uid://c8146flooxeue"
+20 -40
View File
@@ -1,6 +1,5 @@
class_name EntityInteractableArea extends Area3D
const ItemAction = preload("res://cutscene/item/ItemAction.gd")
const DialogueAction = preload("res://cutscene/dialogue/DialogueAction.gd")
@export var entity:Entity
@@ -8,40 +7,32 @@ func isInteractable() -> bool:
if !entity:
return false
if entity.interactType == Entity.InteractType.NONE:
return false
if entity.interactType == Entity.InteractType.CONVERSATION:
return entity.dialogueResource != null
if entity.interactType == Entity.InteractType.CUTSCENE:
if entity.cutscene == null:
match entity.interactType:
Entity.InteractType.NONE:
return false
if !entity.cutscene.canRun():
return false
return true
if entity.interactType == Entity.InteractType.ONE_TIME_ITEM:
if entity.oneTimeItem == null:
return false
if entity.oneTimeItem.quantity <= 0:
return false
if entity.oneTimeItem.item == Item.Id.NULL:
return false
return true
if entity.interactType == Entity.InteractType.BATTLE_TEST:
return true
Entity.InteractType.CONVERSATION:
return entity.dialogueBasePath != ""
Entity.InteractType.CUTSCENE:
return entity.cutscene != null and entity.cutscene.canRun()
Entity.InteractType.ONE_TIME_ITEM:
return (
entity.oneTimeItem != null
and entity.oneTimeItem.quantity > 0
and entity.oneTimeItem.item != Item.Id.NULL
)
Entity.InteractType.BATTLE_TEST:
return true
return false
func _onConversationInteract(_other:Entity) -> void:
assert(entity.dialogueResource != null)
assert(entity.dialogueBasePath != "")
var cutscene:Cutscene = Cutscene.new()
cutscene.addCallable(DialogueAction.getDialogueCallable(
entity.dialogueResource,
entity.dialogueBasePath,
entity.dialogueTitle,
[entity]
[entity],
DialogueAction.DialogueMode.CONVERSATION
))
cutscene.start()
@@ -53,24 +44,17 @@ func _onItemInteract(_other:Entity) -> void:
entity.queue_free()
func onInteract(other:Entity) -> void:
if entity.interactType == Entity.InteractType.NONE:
return
match entity.interactType:
Entity.InteractType.NONE:
return
Entity.InteractType.CONVERSATION:
_onConversationInteract(other)
return
Entity.InteractType.ONE_TIME_ITEM:
_onItemInteract(other)
return
Entity.InteractType.CUTSCENE:
var cutscene:Cutscene = Cutscene.new()
entity.cutscene.queue(cutscene)
cutscene.start()
return
Entity.InteractType.BATTLE_TEST:
var testEnemy = BattleFighter.new({
'controller': BattleFighter.FighterController.AI
@@ -81,7 +65,3 @@ func onInteract(other:Entity) -> void:
BATTLE.BattlePosition.LEFT_MIDDLE_FRONT: testEnemy
}))
cutscene.start()
return
_:
pass
+25
View File
@@ -22,11 +22,36 @@ func _exit_tree() -> void:
self.area_entered.disconnect(_onAreaEntered)
self.area_exited.disconnect(_onAreaExited)
func _process(_delta:float) -> void:
if entity.movementType != Entity.MovementType.PLAYER:
return
if UI.INTERACT_INDICATOR and UI.INTERACT_INDICATOR.visible:
UI.INTERACT_INDICATOR.updateWorldPosition()
func _getBestInteractable() -> Entity:
for area in interactableAreas:
if area.isInteractable():
return area.entity
return null
func _updateIndicator() -> void:
if entity.movementType != Entity.MovementType.PLAYER:
return
if UI.INTERACT_INDICATOR == null:
return
var best:Entity = _getBestInteractable()
if best:
UI.INTERACT_INDICATOR.setEntity(best)
else:
UI.INTERACT_INDICATOR.clear()
func _onAreaEntered(area:Area3D) -> void:
if area is EntityInteractableArea:
if area.entity == entity:
return
interactableAreas.append(area)
_updateIndicator()
func _onAreaExited(area:Area3D) -> void:
interactableAreas.erase(area)
_updateIndicator()
+11 -16
View File
@@ -2,17 +2,13 @@ class_name EntityMovement extends Node
const GRAVITY = Vector3.DOWN * 100
const FRICTION = 0.01
const WALK_SPEED_DEFAULT = 8
const RUN_SPEED_DEFAULT = 12
# var _inputDir:Vector2 = Vector2.ZERO
# var _running:bool = false
const SPEED_DEFAULT = 8
const ROTATION_SPEED_DEFAULT = 15.0
@export var entity:Entity
@export var interactingArea:EntityInteractingArea
# @export var rotate:Node3D
@export var walkSpeed:float = WALK_SPEED_DEFAULT
# @export var runSpeed:float = RUN_SPEED_DEFAULT
@export var speed:float = SPEED_DEFAULT
@export var rotationSpeed:float = ROTATION_SPEED_DEFAULT
#
# Private Methods
@@ -21,10 +17,11 @@ func _applyGravity() -> void:
if !entity.is_on_floor():
entity.velocity += GRAVITY * get_process_delta_time()
func _applyPlayerMovement(_delta:float):
func _applyPlayerMovement(delta:float):
# Interactions, may move
if Input.is_action_just_pressed("interact") && interactingArea && interactingArea.hasInteraction():
interactingArea.interact()
if !UI.dialogueActive:
interactingArea.interact()
return
# Directional input
@@ -48,9 +45,9 @@ func _applyPlayerMovement(_delta:float):
if directionAdjusted.length() <= 0.01:
return
entity.look_at(entity.global_position + directionAdjusted, Vector3.UP)
var targetYaw:float = atan2(-directionAdjusted.x, -directionAdjusted.z)
entity.rotation.y = lerp_angle(entity.rotation.y, targetYaw, rotationSpeed * delta)
var speed = walkSpeed
entity.velocity.x = directionAdjusted.x * speed
entity.velocity.z = directionAdjusted.z * speed
@@ -66,11 +63,9 @@ func _applyFriction(delta:float) -> void:
entity.velocity.z *= delta * FRICTION
func _canMove() -> bool:
if UI.dialogueActive:
if UI.activeConversation:
return false
if !UI.TEXTBOX.isClosed:
return false
if UI.GAME_MENU && UI.GAME_MENU.isOpen():
if UI.FOCUS_STACK.top() != null:
return false
return true
+13
View File
@@ -0,0 +1,13 @@
class_name EntityProximityArea extends Area3D
@export var entity:Entity
func _ready() -> void:
body_entered.connect(_onBodyEntered)
body_exited.connect(_onBodyExited)
func _onBodyEntered(_body:Node3D) -> void:
pass
func _onBodyExited(_body:Node3D) -> void:
pass
@@ -0,0 +1 @@
uid://bq2lsd8uyrtcf
+1 -4
View File
@@ -1,10 +1,7 @@
extends Node3D
func _ready() -> void:
# Assign dialogue resources after the plugin has imported the .dialogue files.
# Once the DialogueManager plugin is enabled in the editor, you can assign
# dialogueResource directly in the Inspector instead.
var npc:Entity = $NotPlayer
if npc:
npc.dialogueResource = load("res://dialogue/npc/test.dialogue")
npc.dialogueBasePath = "res://dialogue/npc/test"
npc.dialogueTitle = "start"
+28 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=9 format=3 uid="uid://d0ywgijpuqy0r"]
[gd_scene load_steps=11 format=3 uid="uid://d0ywgijpuqy0r"]
[ext_resource type="Script" uid="uid://xe6pcuq741xi" path="res://overworld/map/TestMap.gd" id="1_6ms5s"]
[ext_resource type="PackedScene" uid="uid://cluuhtfjeodwb" path="res://overworld/map/TestMapBase.tscn" id="1_ox0si"]
@@ -6,6 +6,7 @@
[ext_resource type="Script" uid="uid://38ya6vphm5bu" path="res://item/ItemResource.gd" id="4_xf0pb"]
[ext_resource type="Script" uid="uid://b5c8g5frishjs" path="res://cutscene/cutscene/TestCutscene.gd" id="5_125nt"]
[ext_resource type="Script" uid="uid://8tsov4ihmnxl" path="res://overworld/camera/OverworldCamera.gd" id="7_tr4a0"]
[ext_resource type="Script" uid="uid://bq2lsd8uyrtcf" path="res://overworld/entity/EntityProximityArea.gd" id="8_prox"]
[sub_resource type="Resource" id="Resource_125nt"]
script = ExtResource("4_xf0pb")
@@ -17,12 +18,17 @@ metadata/_custom_type_script = "uid://38ya6vphm5bu"
script = ExtResource("5_125nt")
metadata/_custom_type_script = "uid://b5c8g5frishjs"
[sub_resource type="SphereShape3D" id="SphereShape3D_prox"]
radius = 3.0
[node name="TestMap" type="Node3D"]
script = ExtResource("1_6ms5s")
[node name="NotPlayer" parent="." instance=ExtResource("2_jmygs")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00883961, 1.11219, 0.0142021)
entityId = "bcabec96-8d33-4c16-a997-3bb3b0562b33"
dialogueName = "stranger"
displayName = "Stranger"
interactType = 1
[node name="NotPlayer4" parent="." instance=ExtResource("2_jmygs")]
@@ -42,11 +48,32 @@ entityId = "ad5a1504-7fbf-45d6-b1bf-6e7af6314066"
interactType = 3
cutscene = SubResource("Resource_tr4a0")
[node name="ChatboxNPC" parent="." instance=ExtResource("2_jmygs")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -4, 1.11219, -3)
entityId = "c1a2b3c4-d5e6-7890-abcd-ef1234567891"
interactType = 5
[node name="ProximityNPC" parent="." instance=ExtResource("2_jmygs")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4, 1.11219, -4)
entityId = "c1a2b3c4-d5e6-7890-abcd-ef1234567892"
interactType = 6
[node name="EntityProximityArea" type="Area3D" parent="ProximityNPC" node_paths=PackedStringArray("entity")]
collision_layer = 0
collision_mask = 2
script = ExtResource("8_prox")
entity = NodePath("..")
[node name="CollisionShape3D" type="CollisionShape3D" parent="ProximityNPC/EntityProximityArea"]
shape = SubResource("SphereShape3D_prox")
[node name="TestMapBase" parent="." instance=ExtResource("1_ox0si")]
[node name="Player" parent="." instance=ExtResource("2_jmygs")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.1915, 1.05, 0.125589)
entityId = "player"
dialogueName = "john"
displayName = "John"
movementType = 2
[node name="Camera3D" type="Camera3D" parent="." node_paths=PackedStringArray("targetNode")]
+53 -44
View File
@@ -65,6 +65,52 @@ theme/custom_font="uid://ck4rsg0nvvxtq"
[input]
ui_accept={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194309,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
]
}
ui_cancel={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194305,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":false,"script":null)
]
}
ui_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194319,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":13,"pressure":0.0,"pressed":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":-1.0,"script":null)
]
}
ui_right={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194321,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":14,"pressure":0.0,"pressed":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":1.0,"script":null)
]
}
ui_up={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194320,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":11,"pressure":0.0,"pressed":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
]
}
ui_down={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194322,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":12,"pressure":0.0,"pressed":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
]
}
move_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
@@ -129,32 +175,36 @@ menu={
camera_orbit_left={
"deadzone": 0.15,
"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":-1.0,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null)
]
}
camera_orbit_right={
"deadzone": 0.15,
"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":1.0,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":76,"key_label":0,"unicode":108,"location":0,"echo":false,"script":null)
]
}
camera_orbit_up={
"deadzone": 0.15,
"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":-1.0,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":73,"key_label":0,"unicode":105,"location":0,"echo":false,"script":null)
]
}
camera_orbit_down={
"deadzone": 0.15,
"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":1.0,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null)
]
}
tab_prev={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":9,"pressure":0.0,"pressed":false,"script":null)
]
}
tab_next={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":51,"key_label":0,"unicode":51,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
]
}
@@ -164,52 +214,11 @@ center_camera={
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":9,"pressure":0.0,"pressed":false,"script":null)
]
}
ui_accept={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194309,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
]
}
ui_cancel={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194305,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":false,"script":null)
]
}
ui_up={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194320,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":11,"pressure":0.0,"pressed":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
]
}
ui_down={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194322,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":12,"pressure":0.0,"pressed":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
]
}
ui_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194319,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":13,"pressure":0.0,"pressed":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":-1.0,"script":null)
]
}
ui_right={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194321,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":14,"pressure":0.0,"pressed":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":1.0,"script":null)
]
}
[internationalization]
locale/translations=PackedStringArray("res://locale/en_AU.po")
locale/translations_pot_files=PackedStringArray("res://dialogue/battle/narration.dialogue", "res://dialogue/item/pickup.dialogue", "res://dialogue/npc/test.dialogue")
locale/translations_pot_files=PackedStringArray("res://dialogue/battle/narration.en.dialogue", "res://dialogue/item/pickup.en.dialogue", "res://dialogue/npc/test.en.dialogue")
locale/language_filter=["ja"]
locale/country_filter=["JP"]
+3 -1
View File
@@ -9,7 +9,9 @@ func _unhandled_input(event:InputEvent) -> void:
var menu:PauseMenu = UI.PAUSE_MENU
if menu == null:
return
if menu.isOpen():
if SCENE.currentScene == SceneSingleton.SceneType.INITIAL and !menu.isOpen:
return
if menu.isOpen:
menu.close()
else:
menu.open()
+1
View File
@@ -4,3 +4,4 @@ var invertCameraX:bool = false
var invertCameraY:bool = false
var cameraSpeedController:float = 1.0
var cameraSpeedMouse:float = 1.0
var textSpeed:float = 1.0
+4 -1
View File
@@ -1,9 +1,12 @@
class_name RootUI extends Control
@export var debugMenu:DebugMenu
@export var textBox:VNTextbox
@export var gameMenu:GameMenu
@export var pauseMenu:PauseMenu
@export var quitConfirmDialog:QuitConfirmDialog
@export var mainMenuConfirmDialog:ConfirmDialog
@export var modalBackdrop:ModalBackdrop
@export var chatBoxContainer:Control
func _enter_tree() -> void:
UI.rootUi = self
+40 -6
View File
@@ -1,12 +1,15 @@
[gd_scene load_steps=6 format=3 uid="uid://baos0arpiskbp"]
[gd_scene load_steps=9 format=3 uid="uid://baos0arpiskbp"]
[ext_resource type="PackedScene" uid="uid://bkx3l0kckf4a8" path="res://ui/component/VNTextbox.tscn" id="1_1mtk3"]
[ext_resource type="Script" uid="uid://dq3qyyayugt5l" path="res://ui/RootUI.gd" id="1_son71"]
[ext_resource type="PackedScene" uid="uid://c0i5e2dj11d8c" path="res://ui/pause/PauseMenu.tscn" id="2_atyu8"]
[ext_resource type="PackedScene" uid="uid://b38dr0wkix76t" path="res://ui/debugmenu/DebugMenu.tscn" id="4_u132g"]
[ext_resource type="PackedScene" uid="uid://bv5r2x9m4k7n1" path="res://ui/gamemenu/GameMenu.tscn" id="5_gmenu"]
[ext_resource type="PackedScene" path="res://ui/component/InteractIndicator.tscn" id="6_iind"]
[ext_resource type="Script" path="res://ui/component/ModalBackdrop.gd" id="7_mbdp"]
[ext_resource type="PackedScene" uid="uid://cqdf1x7m2canp" path="res://ui/component/QuitConfirmDialog.tscn" id="8_qcd"]
[ext_resource type="PackedScene" uid="uid://bmmc3x8n1d7qp" path="res://ui/component/MainMenuConfirmDialog.tscn" id="9_mmcd"]
[node name="RootUI" type="Control" node_paths=PackedStringArray("debugMenu", "textBox", "gameMenu", "pauseMenu")]
[node name="RootUI" type="Control" node_paths=PackedStringArray("debugMenu", "gameMenu", "pauseMenu", "quitConfirmDialog", "mainMenuConfirmDialog", "modalBackdrop", "chatBoxContainer")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
@@ -16,24 +19,55 @@ grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_son71")
debugMenu = NodePath("DebugMenu")
textBox = NodePath("VNTextbox")
gameMenu = NodePath("GameMenu")
pauseMenu = NodePath("PauseMenu")
quitConfirmDialog = NodePath("QuitConfirmDialog")
mainMenuConfirmDialog = NodePath("MainMenuConfirmDialog")
modalBackdrop = NodePath("ModalBackdrop")
chatBoxContainer = NodePath("ChatBoxContainer")
metadata/_custom_type_script = "uid://dq3qyyayugt5l"
[node name="DebugMenu" parent="." instance=ExtResource("4_u132g")]
visible = false
layout_mode = 1
[node name="GameMenu" parent="." instance=ExtResource("5_gmenu")]
visible = false
layout_mode = 1
[node name="ChatBoxContainer" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="InteractIndicator" parent="ChatBoxContainer" instance=ExtResource("6_iind")]
[node name="ModalBackdrop" type="ColorRect" parent="."]
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 1
process_mode = 3
color = Color(0, 0, 0, 0.5)
script = ExtResource("7_mbdp")
[node name="PauseMenu" parent="." instance=ExtResource("2_atyu8")]
visible = false
layout_mode = 1
process_mode = 3
[node name="GameMenu" parent="." instance=ExtResource("5_gmenu")]
[node name="QuitConfirmDialog" parent="." instance=ExtResource("8_qcd")]
visible = false
layout_mode = 1
[node name="VNTextbox" parent="." instance=ExtResource("1_1mtk3")]
[node name="MainMenuConfirmDialog" parent="." instance=ExtResource("9_mmcd")]
visible = false
layout_mode = 1
+31
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bnttsy278nvaw
+68 -15
View File
@@ -1,31 +1,84 @@
extends Node
var rootUi:RootUI = null
const _FocusStackScript = preload("res://ui/UIFocusStack.gd")
# True for the entire duration of a DialogueAction run, including the frames
# between lines where the textbox is momentarily closed.
var rootUi:RootUI = null
var interactIndicator:InteractIndicator = null
var FOCUS_STACK:RefCounted = null
# True whenever any dialogue resource is being processed by DialogueManager.
# Driven by DialogueManager.dialogue_started / dialogue_ended signals.
var dialogueActive:bool = false
var DEBUG_MENU:
# True only during a CONVERSATION-mode sequence. Blocks player movement.
var activeConversation:bool = false
func _ready() -> void:
FOCUS_STACK = _FocusStackScript.new()
DialogueManager.dialogue_started.connect(_onDialogueStarted)
DialogueManager.dialogue_ended.connect(_onDialogueEnded)
SCENE.sceneChanged.connect(_onSceneChanged)
func _onDialogueStarted(_resource:DialogueResource) -> void:
dialogueActive = true
func _onDialogueEnded(_resource:DialogueResource) -> void:
dialogueActive = false
func _onSceneChanged(_newScene:SceneSingleton.SceneType) -> void:
_cleanupDialogue()
func _cleanupDialogue() -> void:
if chatBoxContainer:
for child in chatBoxContainer.get_children():
if child is DialogueTextbox or child is DialogueChoiceBox:
child.queue_free()
activeConversation = false
dialogueActive = false
if INTERACT_INDICATOR:
INTERACT_INDICATOR.clear()
var INTERACT_INDICATOR:InteractIndicator:
get(): return interactIndicator
var chatBoxContainer:Control:
get():
if rootUi && rootUi.debugMenu:
if rootUi:
return rootUi.chatBoxContainer
return null
var DEBUG_MENU:DebugMenu:
get():
if rootUi:
return rootUi.debugMenu
return null
var TEXTBOX:
var GAME_MENU:GameMenu:
get():
if rootUi && rootUi.textBox:
return rootUi.textBox
return null
var GAME_MENU:
get():
if rootUi && rootUi.gameMenu:
if rootUi:
return rootUi.gameMenu
return null
var PAUSE_MENU:
var PAUSE_MENU:PauseMenu:
get():
if rootUi && rootUi.pauseMenu:
if rootUi:
return rootUi.pauseMenu
return null
var QUIT_DIALOG:QuitConfirmDialog:
get():
if rootUi:
return rootUi.quitConfirmDialog
return null
var MAIN_MENU_DIALOG:ConfirmDialog:
get():
if rootUi:
return rootUi.mainMenuConfirmDialog
return null
var BACKDROP:ModalBackdrop:
get():
if rootUi:
return rootUi.modalBackdrop
return null
+62 -15
View File
@@ -1,18 +1,19 @@
class_name ClosableMenu extends Control
@export var isOpen: bool:
set(newValue):
isOpen = newValue
visible = newValue
if newValue:
opened.emit()
else:
closed.emit()
signal opened
signal closed
signal focusGained
signal focusLost
@export var canClose:bool = true
@export var isOpen:bool = false:
set(v):
isOpen = v
visible = v
get():
return isOpen
signal closed
signal opened
var _savedFocusNode:Control = null
func _enter_tree() -> void:
visible = isOpen
@@ -22,13 +23,59 @@ func _exit_tree() -> void:
func _ready() -> void:
visible = isOpen
print("ClosableMenu is ready, isOpen: ", isOpen)
func close() -> void:
isOpen = false
if canClose:
set_process_unhandled_input(false)
func open() -> void:
visible = true
if canClose:
UI.FOCUS_STACK.push(self)
isOpen = true
opened.emit()
func close() -> void:
if canClose:
UI.FOCUS_STACK.pop()
isOpen = false
closed.emit()
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
+28
View File
@@ -0,0 +1,28 @@
class_name ConfirmDialog extends ClosableMenu
signal confirmed
@export var btnYes:Button
@export var btnNo:Button
func _ready() -> void:
super._ready()
close()
btnYes.pressed.connect(_onYes)
btnNo.pressed.connect(close)
btnYes.focus_neighbor_top = btnNo.get_path()
btnYes.focus_neighbor_bottom = btnNo.get_path()
btnNo.focus_neighbor_top = btnYes.get_path()
btnNo.focus_neighbor_bottom = btnYes.get_path()
func _onYes() -> void:
close()
confirmed.emit()
func _grabInitialFocus() -> void:
btnNo.grab_focus()
func _unhandled_input(event:InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
close()
get_viewport().set_input_as_handled()
+1
View File
@@ -0,0 +1 @@
uid://cee1d1wrf0ypc
+116
View File
@@ -0,0 +1,116 @@
class_name DialogueChoiceBox extends PanelContainer
const SCENE:PackedScene = preload("res://ui/component/DialogueChoiceBox.tscn")
const MAX_WIDTH:float = 120.0
signal chosen(response:DialogueResponse)
var _responses:Array[DialogueResponse] = []
var _entity:Entity = null
var _selectedIndex:int = 0
var _hasLetGoOfInteract:bool = true
@onready var _list:VBoxContainer = $VBoxContainer/List
func _ready() -> void:
size.x = MAX_WIDTH
visible = false
func setup(responses:Array[DialogueResponse], entity:Entity) -> void:
_entity = entity
_responses = responses.filter(func(r): return r.is_allowed)
_selectedIndex = 0
_hasLetGoOfInteract = !Input.is_action_pressed("interact")
for child in _list.get_children():
child.queue_free()
var innerWidth:float = _labelWidth()
var sep:int = _list.get_theme_constant("separation")
var totalHeight:float = 0.0
for i in range(_responses.size()):
var label:Label = Label.new()
label.text = _responses[i].text
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.focus_mode = Control.FOCUS_ALL
_list.add_child(label)
label.size.x = innerWidth
totalHeight += label.get_minimum_size().y
if i > 0:
totalHeight += sep
var idx:int = i
label.focus_entered.connect(func(): _onFocused(idx))
# Prevent focus escaping the list at the edges
var count:int = _list.get_child_count()
for i in range(count):
var label:Label = _list.get_child(i)
label.focus_neighbor_top = _list.get_child(max(0, i - 1)).get_path()
label.focus_neighbor_bottom = _list.get_child(min(count - 1, i + 1)).get_path()
var style:StyleBox = get_theme_stylebox("panel")
if style:
totalHeight += style.get_margin(SIDE_TOP) + style.get_margin(SIDE_BOTTOM)
size = Vector2(MAX_WIDTH, totalHeight)
_updateWorldPosition()
visible = true
_updateSelection()
_list.get_child(0).grab_focus()
func _labelWidth() -> float:
var style:StyleBox = get_theme_stylebox("panel")
if style == null:
return MAX_WIDTH
return MAX_WIDTH - style.get_margin(SIDE_LEFT) - style.get_margin(SIDE_RIGHT)
func _process(_delta:float) -> void:
if not visible:
return
_updateWorldPosition()
func _input(event:InputEvent) -> void:
if not visible:
return
if event.is_action_released("interact"):
_hasLetGoOfInteract = true
return
if event.is_action_pressed("interact") and _hasLetGoOfInteract:
_confirm()
get_viewport().set_input_as_handled()
func _onFocused(idx:int) -> void:
_selectedIndex = idx
_updateSelection()
func _confirm() -> void:
if not visible:
return
visible = false
chosen.emit(_responses[_selectedIndex])
queue_free()
func _updateSelection() -> void:
var children:Array = _list.get_children()
for i in children.size():
var label:Label = children[i]
if i == _selectedIndex:
label.text = "" + _responses[i].text
label.add_theme_color_override("font_color", Color.YELLOW)
else:
label.text = _responses[i].text
label.remove_theme_color_override("font_color")
func _updateWorldPosition() -> void:
if _entity == null:
return
var camera:Camera3D = get_viewport().get_camera_3d()
if camera == null:
return
var worldPos:Vector3 = _entity.global_position + Vector3(0, 2.5, 0)
var screenPos:Vector2 = camera.unproject_position(worldPos)
var viewportSize:Vector2 = get_viewport().get_visible_rect().size
position = screenPos - size * 0.5
position.x = clamp(position.x, 0.0, viewportSize.x - size.x)
position.y = clamp(position.y, 0.0, viewportSize.y - size.y)
+1
View File
@@ -0,0 +1 @@
uid://cbgsqs5t10l06
+18
View File
@@ -0,0 +1,18 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Theme" path="res://ui/UI Theme.tres" id="1"]
[ext_resource type="Script" path="res://ui/component/DialogueChoiceBox.gd" id="2"]
[node name="DialogueChoiceBox" type="PanelContainer"]
custom_minimum_size = Vector2(120, 0)
mouse_filter = 2
theme = ExtResource("1")
script = ExtResource("2")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 2
theme_override_constants/separation = 1
[node name="List" type="VBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 2
+219
View File
@@ -0,0 +1,219 @@
class_name DialogueTextbox extends PanelContainer
const SCENE:PackedScene = preload("res://ui/component/DialogueTextbox.tscn")
enum AdvancementMode { PLAYER, TIMED }
const LINES_PER_PAGE:int = 4
const CHARS_PER_SECOND:float = 24.0
const PAUSE_COMMA:float = 0.15
const PAUSE_SENTENCE:float = 0.4
const PAUSE_ELLIPSIS_DOT:float = 0.3
const READING_CHARS_PER_SECOND:float = 16.0
const MAX_WIDTH:float = 120.0
signal dismissed
var _entity:Entity = null
var _parsedText:String = ""
var _startLine:int = 0
var _linesPerPage:int = 0
var _revealTimer:float = 0.0
var _pauseTimer:float = 0.0
var _autoAdvanceTimer:float = 0.0
var _isRevealing:bool = false
var _isWaitingForInput:bool = false
var _advancementMode:AdvancementMode = AdvancementMode.PLAYER
@onready var _speakerLabel:Label = $VBoxContainer/SpeakerLabel
@onready var _bodyLabel:RichTextLabel = $VBoxContainer/BodyLabel
@onready var _advanceIndicator:Label = $VBoxContainer/AdvanceIndicator
func _ready() -> void:
size.x = MAX_WIDTH
visible = false
var scrollbar = _bodyLabel.get_v_scroll_bar()
if scrollbar:
scrollbar.modulate.a = 0.0
scrollbar.mouse_filter = Control.MOUSE_FILTER_IGNORE
func setup(line:DialogueLine, entity:Entity, mode:AdvancementMode = AdvancementMode.PLAYER) -> void:
_entity = entity
_advancementMode = mode
_speakerLabel.text = entity.displayName if entity else ""
_speakerLabel.visible = _speakerLabel.text != ""
# Set width explicitly before text so get_character_line() uses correct metrics.
# The Container layout pass hasn't run yet, so we derive inner width from the
# panel StyleBox margins rather than waiting for a deferred layout frame.
size.x = MAX_WIDTH
_bodyLabel.size.x = _bodyLabelWidth()
_bodyLabel.text = line.text
_bodyLabel.visible_characters = -1
_bodyLabel.scroll_to_line(0)
# get_character_line() forces synchronous text shaping via _validate_line_caches(),
# so we can compute pre-wrapped text right now without any pre-pass frame.
_parsedText = _buildPreWrappedText(_bodyLabel.get_parsed_text())
_bodyLabel.text = _parsedText
_bodyLabel.autowrap_mode = TextServer.AUTOWRAP_OFF
_bodyLabel.visible_characters = 0
_startLine = 0
_linesPerPage = LINES_PER_PAGE
_revealTimer = 0.0
_pauseTimer = 0.0
_autoAdvanceTimer = 0.0
_isRevealing = true
_isWaitingForInput = false
_updateWorldPosition()
visible = true
_revealNextChar()
func _bodyLabelWidth() -> float:
var style:StyleBox = get_theme_stylebox("panel")
if style == null:
return MAX_WIDTH
return MAX_WIDTH - style.get_margin(SIDE_LEFT) - style.get_margin(SIDE_RIGHT)
func _process(delta:float) -> void:
if not visible:
return
_updateWorldPosition()
if _isWaitingForInput:
_advanceIndicator.modulate.a = 0.5 + 0.5 * sin(Time.get_ticks_msec() / 300.0)
else:
_advanceIndicator.modulate.a = 0.0
if _isRevealing:
_processReveal(delta)
return
if _isWaitingForInput:
_processAdvanceInput()
return
if _advancementMode == AdvancementMode.TIMED:
_processAutoAdvance(delta)
func _buildPreWrappedText(parsed:String) -> String:
if parsed.is_empty():
return parsed
var result:String = ""
var prevLine:int = 0
for i in range(len(parsed)):
var ch:String = parsed[i]
if ch == "\n":
result += "\n"
prevLine += 1
continue
var charLine:int = _bodyLabel.get_character_line(i)
if charLine > prevLine:
result += "\n"
prevLine = charLine
result += ch
return result
func _processReveal(delta:float) -> void:
var scaledDelta:float = delta * SETTINGS.textSpeed
if _pauseTimer > 0.0:
_pauseTimer -= scaledDelta
return
_revealTimer += scaledDelta
while _revealTimer >= 1.0 / CHARS_PER_SECOND:
_revealTimer -= 1.0 / CHARS_PER_SECOND
if not _revealNextChar():
return
func _revealNextChar() -> bool:
var totalChars:int = len(_parsedText)
if _bodyLabel.visible_characters >= totalChars:
_onRevealComplete()
return false
_bodyLabel.visible_characters += 1
var idx:int = _bodyLabel.visible_characters - 1
var charLine:int = _parsedText.left(idx + 1).count("\n")
if charLine >= _startLine + _linesPerPage:
_bodyLabel.visible_characters -= 1
_onPageFull()
return false
if idx < len(_parsedText):
var isLastVisible:bool = idx >= len(_parsedText) - 1
if not isLastVisible:
var nextCharLine:int = _parsedText.left(idx + 2).count("\n")
isLastVisible = nextCharLine >= _startLine + _linesPerPage
if not isLastVisible:
_pauseTimer = _getPauseForChar(idx)
return true
func _getPauseForChar(idx:int) -> float:
var ch:String = _parsedText[idx]
if ch == "," or ch == ";":
return PAUSE_COMMA
if ch == "." or ch == "!" or ch == "?":
if ch == ".":
var prevDot:bool = idx > 0 and _parsedText[idx - 1] == "."
var nextDot:bool = idx < len(_parsedText) - 1 and _parsedText[idx + 1] == "."
if prevDot or nextDot:
return PAUSE_ELLIPSIS_DOT
return PAUSE_SENTENCE
return 0.0
func _onPageFull() -> void:
_isRevealing = false
_isWaitingForInput = true
func _onRevealComplete() -> void:
_isRevealing = false
if _advancementMode == AdvancementMode.TIMED:
_autoAdvanceTimer = len(_parsedText) / READING_CHARS_PER_SECOND
else:
_isWaitingForInput = true
func _processAdvanceInput() -> void:
if Input.is_action_just_pressed("interact"):
_advance()
func _processAutoAdvance(delta:float) -> void:
_autoAdvanceTimer -= delta
if _autoAdvanceTimer <= 0.0:
_advance()
func _advance() -> void:
_advanceIndicator.modulate.a = 0.0
var totalLines:int = _parsedText.count("\n") + 1
var hasMorePages:bool = _startLine + _linesPerPage < totalLines
if hasMorePages:
_startLine += _linesPerPage
_bodyLabel.scroll_to_line(_startLine)
_isWaitingForInput = false
_revealTimer = 0.0
_pauseTimer = 0.0
_isRevealing = true
_revealNextChar()
else:
dismissed.emit()
visible = false
queue_free()
func _updateWorldPosition() -> void:
if _entity == null:
return
var camera:Camera3D = get_viewport().get_camera_3d()
if camera == null:
return
var worldPos:Vector3 = _entity.global_position + Vector3(0, 2.5, 0)
var screenPos:Vector2 = camera.unproject_position(worldPos)
var viewportSize:Vector2 = get_viewport().get_visible_rect().size
position = screenPos - size * 0.5
position.x = clamp(position.x, 0.0, viewportSize.x - size.x)
position.y = clamp(position.y, 0.0, viewportSize.y - size.y)
+1
View File
@@ -0,0 +1 @@
uid://drivjdgk70cqq
+33
View File
@@ -0,0 +1,33 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Theme" path="res://ui/UI Theme.tres" id="1"]
[ext_resource type="Script" path="res://ui/component/DialogueTextbox.gd" id="2"]
[node name="DialogueTextbox" type="PanelContainer"]
custom_minimum_size = Vector2(120, 70)
mouse_filter = 2
theme = ExtResource("1")
script = ExtResource("2")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 2
theme_override_constants/separation = 1
[node name="SpeakerLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
text = ""
visible = false
[node name="BodyLabel" type="RichTextLabel" parent="VBoxContainer"]
layout_mode = 2
custom_minimum_size = Vector2(0, 36)
size_flags_vertical = 3
bbcode_enabled = true
scroll_active = true
autowrap_mode = 3
[node name="AdvanceIndicator" type="Label" parent="VBoxContainer"]
layout_mode = 2
modulate = Color(1, 1, 1, 0)
text = "▼"
horizontal_alignment = 2
+56
View File
@@ -0,0 +1,56 @@
class_name InteractIndicator extends PanelContainer
var _entity:Entity = null
func _enter_tree() -> void:
UI.interactIndicator = self
func _exit_tree() -> void:
if UI.interactIndicator == self:
UI.interactIndicator = null
func _ready() -> void:
visible = false
DialogueManager.dialogue_started.connect(_onDialogueStarted)
DialogueManager.dialogue_ended.connect(_onDialogueEnded)
func setEntity(entity:Entity) -> void:
if is_instance_valid(_entity):
_entity.tree_exiting.disconnect(_onEntityExiting)
_entity = entity
_entity.tree_exiting.connect(_onEntityExiting)
visible = _canShow()
if visible:
updateWorldPosition()
func clear() -> void:
if is_instance_valid(_entity):
_entity.tree_exiting.disconnect(_onEntityExiting)
_entity = null
visible = false
func _canShow() -> bool:
return _entity != null and not UI.dialogueActive
func _onEntityExiting() -> void:
_entity = null
visible = false
func _onDialogueStarted(_resource:DialogueResource) -> void:
visible = false
func _onDialogueEnded(_resource:DialogueResource) -> void:
visible = _canShow()
if visible:
updateWorldPosition()
func updateWorldPosition() -> void:
var camera:Camera3D = get_viewport().get_camera_3d()
if camera == null:
return
var worldPos:Vector3 = _entity.global_position + Vector3(0, 2.5, 0)
var screenPos:Vector2 = camera.unproject_position(worldPos)
var viewportSize:Vector2 = get_viewport().get_visible_rect().size
position = screenPos - size * 0.5
position.x = clamp(position.x, 0.0, viewportSize.x - size.x)
position.y = clamp(position.y, 0.0, viewportSize.y - size.y)
+1
View File
@@ -0,0 +1 @@
uid://xrcb2e7jwlm0
+13
View File
@@ -0,0 +1,13 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Theme" path="res://ui/UI Theme.tres" id="1"]
[ext_resource type="Script" path="res://ui/component/InteractIndicator.gd" id="2"]
[node name="InteractIndicator" type="PanelContainer"]
mouse_filter = 2
theme = ExtResource("1")
script = ExtResource("2")
[node name="Label" type="Label" parent="."]
layout_mode = 2
text = "INTERACT"
+38
View File
@@ -0,0 +1,38 @@
[gd_scene load_steps=2 format=3 uid="uid://bmmc3x8n1d7qp"]
[ext_resource type="Script" path="res://ui/component/ConfirmDialog.gd" id="1_mmcd"]
[node name="MainMenuConfirmDialog" type="Control" node_paths=PackedStringArray("btnYes", "btnNo")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
process_mode = 3
script = ExtResource("1_mmcd")
btnYes = NodePath("VBoxContainer/Yes")
btnNo = NodePath("VBoxContainer/No")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
grow_horizontal = 2
grow_vertical = 2
[node name="Label" type="Label" parent="VBoxContainer"]
layout_mode = 2
text = "Return to main menu?"
horizontal_alignment = 1
[node name="Yes" type="Button" parent="VBoxContainer"]
layout_mode = 2
text = "Yes"
[node name="No" type="Button" parent="VBoxContainer"]
layout_mode = 2
text = "No"
+16
View File
@@ -0,0 +1,16 @@
class_name ModalBackdrop extends ColorRect
func _ready() -> void:
visible = false
mouse_filter = MOUSE_FILTER_IGNORE
UI.FOCUS_STACK.activeLayerChanged.connect(_onActiveLayerChanged)
func _onActiveLayerChanged(layer:ClosableMenu) -> void:
if layer == null or layer.get_parent() != get_parent():
visible = false
mouse_filter = MOUSE_FILTER_IGNORE
z_index = 0
else:
visible = true
mouse_filter = MOUSE_FILTER_STOP
z_index = layer.z_index - 5
+1
View File
@@ -0,0 +1 @@
uid://bmcklt3xo3hk1
+5
View File
@@ -0,0 +1,5 @@
class_name QuitConfirmDialog extends ConfirmDialog
func _ready() -> void:
super._ready()
confirmed.connect(func(): get_tree().quit())
+1
View File
@@ -0,0 +1 @@
uid://deov3ob0lojyo
+38
View File
@@ -0,0 +1,38 @@
[gd_scene load_steps=2 format=3 uid="uid://cqdf1x7m2canp"]
[ext_resource type="Script" path="res://ui/component/QuitConfirmDialog.gd" id="1_qcd"]
[node name="QuitConfirmDialog" type="Control" node_paths=PackedStringArray("btnYes", "btnNo")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
process_mode = 3
script = ExtResource("1_qcd")
btnYes = NodePath("VBoxContainer/Yes")
btnNo = NodePath("VBoxContainer/No")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
grow_horizontal = 2
grow_vertical = 2
[node name="Label" type="Label" parent="VBoxContainer"]
layout_mode = 2
text = "Quit to desktop?"
horizontal_alignment = 1
[node name="Yes" type="Button" parent="VBoxContainer"]
layout_mode = 2
text = "Yes"
[node name="No" type="Button" parent="VBoxContainer"]
layout_mode = 2
text = "No"
+43
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dacm5qwmmkcsm
-96
View File
@@ -1,96 +0,0 @@
class_name VNTextbox extends PanelContainer
const VN_REVEAL_TIME = 0.01
var label:AdvancedRichText;
var parsedOutText = ""
var revealTimer:float = 0;
var lineStarts:Array[int] = [];
var newlineIndexes:Array[int] = [];
var currentViewScrolled = true;
var isSpeedupDown = false;
var hasLetGoOfInteract:bool = true;
var isClosed:bool = false:
get():
return !self.visible;
set(value):
self.visible = !value;
signal textboxClosing
func _ready() -> void:
label = $MarginContainer/Label
isClosed = true
func _process(delta: float) -> void:
if isClosed:
return;
if label.getFinalText() == "":
isClosed = true;
return;
if Input.is_action_just_released("interact") && !hasLetGoOfInteract:
hasLetGoOfInteract = true;
return
# Have we finished displaying the current page?
if label.visible_characters >= label.getCharactersDisplayedCount():
# Not finished displaying current page
if (label.maxLines + label.startLine) < label.getTotalLineCount():
if Input.is_action_just_pressed("interact") && hasLetGoOfInteract:
label.startLine += label.maxLines;
label.visible_characters = 0;
currentViewScrolled = false;
return
currentViewScrolled = true;
else:
# On last page
if Input.is_action_just_released("interact") && hasLetGoOfInteract:
textboxClosing.emit();
isClosed = true;
currentViewScrolled = true
return;
# This prevents the game trying to advance if the player is still holding
# down interact.
if Input.is_action_just_pressed("interact") && hasLetGoOfInteract:
isSpeedupDown = true;
elif Input.is_action_just_released("interact") && hasLetGoOfInteract:
isSpeedupDown = false;
elif !Input.is_action_pressed("interact") && hasLetGoOfInteract:
isSpeedupDown = false;
revealTimer += delta;
if isSpeedupDown:
revealTimer += delta;
if revealTimer > VN_REVEAL_TIME:
revealTimer = 0;
label.visible_characters += 1;
func setText(text:String) -> void:
# Prepare textbox for scrolling
# Resets scroll
revealTimer = 0;
currentViewScrolled = false;
label.startLine = 0;
# I had a frame wait here before.
label.text = text;
label.visible_characters = 0;
# Resets speedup and advancing
hasLetGoOfInteract = !Input.is_action_pressed("interact");
isSpeedupDown = false
isClosed = false;
func setTextAndWait(text:String) -> void:
self.setText(text);
await self.textboxClosing
await get_tree().process_frame
-1
View File
@@ -1 +0,0 @@
uid://h8lw23ypcfty
-63
View File
@@ -1,63 +0,0 @@
[gd_scene load_steps=4 format=3 uid="uid://bkx3l0kckf4a8"]
[ext_resource type="Theme" uid="uid://dm7ee4aqjr2dl" path="res://ui/UI Theme.tres" id="1_wx4lp"]
[ext_resource type="Script" uid="uid://h8lw23ypcfty" path="res://ui/component/VNTextbox.gd" id="2_uo1gm"]
[ext_resource type="Script" uid="uid://bjj6upgk1uvxd" path="res://ui/component/advancedrichtext/AdvancedRichText.gd" id="3_m60k3"]
[node name="VNTextbox" type="PanelContainer"]
clip_contents = true
anchors_preset = 12
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -58.0
grow_horizontal = 2
grow_vertical = 0
theme = ExtResource("1_wx4lp")
script = ExtResource("2_uo1gm")
[node name="MarginContainer" type="MarginContainer" parent="."]
clip_contents = true
layout_mode = 2
theme = ExtResource("1_wx4lp")
theme_override_constants/margin_left = 4
theme_override_constants/margin_top = 4
theme_override_constants/margin_right = 4
theme_override_constants/margin_bottom = 4
[node name="Label" type="RichTextLabel" parent="MarginContainer"]
layout_mode = 2
theme = ExtResource("1_wx4lp")
bbcode_enabled = true
text = "Hello, I'm an NPC!
This is the second line here, I am purposefully adding a tonne of words so that it is forced to go across multiple lines and you can see how the word wrapping works, not only using Godot's built in word wrapping but with my advanced visibile characters smart wrapping. Now I am doing a multiline thing
Line 1
Line 2
Line 3
Line 4
Line 5
Line 6
Line 7
Line 8
Line 9
Line 10"
script = ExtResource("3_m60k3")
userText = "Hello, I'm an NPC!
This is the second line here, I am purposefully adding a tonne of words so that it is forced to go across multiple lines and you can see how the word wrapping works, not only using Godot's built in word wrapping but with my advanced visibile characters smart wrapping. Now I am doing a multiline thing
Line 1
Line 2
Line 3
Line 4
Line 5
Line 6
Line 7
Line 8
Line 9
Line 10"
_finalText = "Hello, I'm an NPC!
This is the second line here, I am purposefully adding a tonne of words so that it is forced to go across multiple lines and you can see how the word wrapping works, not only using Godot's built in word wrapping but with my advanced visibile characters smart wrapping. Now I am doing a multiline thing
Line 1
Line 2"
_newLineIndexes = Array[int]([0])
_lines = PackedStringArray("Hello, I\'m an NPC!", "This is the second line here, I am purposefully adding a tonne of words so that it is forced to go across multiple lines and you can see how the word wrapping works, not only using Godot\'s built in word wrapping but with my advanced visibile characters smart wrapping. Now I am doing a multiline thing", "Line 1", "Line 2", "Line 3", "Line 4", "Line 5", "Line 6", "Line 7", "Line 8", "Line 9", "Line 10")
maxLines = 4
@@ -1,141 +0,0 @@
@tool
class_name AdvancedRichText extends RichTextLabel
@export_multiline var userText:String = "" # The text the user is asking for
@export_multiline var _finalText:String = "" # The final text after processing (translation, wrapping, etc.)
@export var _newLineIndexes:Array[int] = [] # The indexes of where each line starts in finalText
@export var _lines:PackedStringArray = [];
# Hides the original RichTextLabel text property
func _set(property: StringName, value) -> bool:
if property == "text":
userText = value
_recalcText()
return true
elif property == "richtextlabel_text":
text = value
return true
return false
func _get(property: StringName):
if property == "text":
return userText
elif property == "richtextlabel_text":
return text
return null
@export var translate:bool = true:
set(value):
translate = value
_recalcText()
get():
return translate
@export var smartWrap:bool = true:
set(value):
smartWrap = value
_recalcText()
get():
return smartWrap
@export var maxLines:int = -1:
set(value):
maxLines = value
_recalcText()
get():
return maxLines
@export var startLine:int = 0:
set(value):
startLine = value
_recalcText()
get():
return startLine
# Returns count of characters that can be displayed, assuming visible_chars = -1
func getCharactersDisplayedCount() -> int:
# Count characters
var count = 0
var lineCount = min(startLine + maxLines, _lines.size()) - startLine
for i in range(startLine, startLine + lineCount):
count += _lines[i].length()
if lineCount > 1:
count += lineCount - 1 # Add newlines
return count
func getFinalText() -> String:
return _finalText
func getTotalLineCount() -> int:
return _lines.size()
func _enter_tree() -> void:
self.threaded = false;
func _recalcText() -> void:
_lines.clear()
if userText.is_empty():
self.richtextlabel_text = ""
return
# Translate if needed
var textTranslated = userText
if self.translate:
textTranslated = tr(textTranslated)
# Replace input bb tags.
var regex = RegEx.new()
regex.compile(r"\[input action=(.*?)\](.*?)\[/input\]")
var inputIconText = textTranslated
for match in regex.search_all(textTranslated):
var action = match.get_string(1).to_lower()
var height:int = get_theme_font_size("normal_font_size")
var img_tag = "[img height=%d valign=center,center]res://ui/input/%s.tres[/img]" % [ height, action ]
inputIconText = inputIconText.replace(match.get_string(0), img_tag)
# Perform smart wrapping
var wrappedText = inputIconText
if smartWrap:
var unwrappedText = wrappedText.strip_edges()
self.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART;
self.richtextlabel_text = unwrappedText
self.visible_characters = -1;
self.fit_content = false;
_newLineIndexes = [];
# Determine where the wrapped newlines are
var line = 0;
var wasNewLine = false;
for i in range(0, self.richtextlabel_text.length()):
var tLine = self.get_character_line(i);
if tLine == line:
wasNewLine = false
if self.richtextlabel_text[i] == "\n":
wasNewLine = true
continue;
if !wasNewLine:
_newLineIndexes.append(i);
line = tLine;
# Create fake pre-wrapped text.
wrappedText = "";
for i in range(0, self.richtextlabel_text.length()):
if _newLineIndexes.find(i) != -1 and i != 0:
wrappedText += "\n";
wrappedText += self.richtextlabel_text[i];
# Handle max and start line(s)
var maxText = wrappedText
if maxLines > 0:
_lines = maxText.split("\n", true);
var selectedLines = [];
for i in range(startLine, min(startLine + maxLines, _lines.size())):
selectedLines.append(_lines[i]);
maxText = "\n".join(selectedLines);
_finalText = maxText
self.richtextlabel_text = maxText
# print("Updated text")
@@ -1 +0,0 @@
uid://bjj6upgk1uvxd
+12 -19
View File
@@ -1,4 +1,4 @@
class_name GameMenu extends Control
class_name GameMenu extends ClosableMenu
enum Tab { PARTY, ITEMS }
@@ -9,21 +9,14 @@ enum Tab { PARTY, ITEMS }
var _currentTab:Tab = Tab.PARTY
func _ready() -> void:
visible = false
super._ready()
SIDEBAR.item_selected.connect(_onTabSelected)
func open() -> void:
visible = true
func _grabInitialFocus() -> void:
_selectTab(_currentTab)
SIDEBAR.select(_currentTab)
SIDEBAR.grab_focus()
func close() -> void:
visible = false
func isOpen() -> bool:
return visible
func _onTabSelected(index:int) -> void:
_selectTab(index as Tab)
@@ -37,16 +30,16 @@ func _selectTab(tab:Tab) -> void:
Tab.ITEMS:
ITEMS_TAB.refresh()
func _input(event:InputEvent) -> void:
if not event.is_action_pressed("menu"):
return
if isOpen:
close()
elif UI.FOCUS_STACK.top() == null and not UI.dialogueActive and SCENE.currentScene == SceneSingleton.SceneType.OVERWORLD:
open()
get_viewport().set_input_as_handled()
func _unhandled_input(event:InputEvent) -> void:
if event.is_action_pressed("menu"):
if visible:
close()
elif !UI.dialogueActive && UI.TEXTBOX.isClosed:
open()
get_viewport().set_input_as_handled()
return
if !visible:
return
if event.is_action_pressed("ui_cancel"):
close()
get_viewport().set_input_as_handled()
+13 -7
View File
@@ -2,20 +2,19 @@ class_name MainMenu extends Control
@export var btnNewGame:Button
@export var btnSettings:Button
@export var btnQuit:Button
@export var settingsMenu:ClosableMenu
@export_file("*.tscn") var newGameScene:String
func _ready() -> void:
btnNewGame.pressed.connect(onNewGamePressed)
btnSettings.pressed.connect(onSettingsPressed)
settingsMenu.opened.connect(_onSettingsOpened)
btnQuit.pressed.connect(_onQuitPressed)
settingsMenu.closed.connect(_onSettingsClosed)
btnNewGame.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 _notification(what:int) -> void:
if what == NOTIFICATION_ENTER_TREE:
btnNewGame.call_deferred("grab_focus")
func _onSettingsClosed() -> void:
btnSettings.grab_focus()
@@ -26,9 +25,16 @@ func _unhandled_input(event:InputEvent) -> void:
settingsMenu.close()
get_viewport().set_input_as_handled()
func _onQuitPressed() -> void:
UI.QUIT_DIALOG.closed.connect(_onQuitDialogClosed, CONNECT_ONE_SHOT)
UI.QUIT_DIALOG.open()
func _onQuitDialogClosed() -> void:
btnQuit.grab_focus()
func onNewGamePressed() -> void:
SCENE.setScene(SceneSingleton.SceneType.OVERWORLD)
OVERWORLD.mapChange(newGameScene, "PlayerSpawnPoint")
func onSettingsPressed() -> void:
settingsMenu.isOpen = true
settingsMenu.open()
+6 -1
View File
@@ -4,7 +4,7 @@
[ext_resource type="Script" uid="uid://bcjfv6dw0ugvo" path="res://ui/component/ClosableMenu.gd" id="2_f3vro"]
[ext_resource type="PackedScene" uid="uid://d3f31lli1ahts" path="res://ui/settings/SettingsMenu.tscn" id="3_44i87"]
[node name="Main Menu" type="Control" node_paths=PackedStringArray("btnNewGame", "btnSettings", "settingsMenu")]
[node name="Main Menu" type="Control" node_paths=PackedStringArray("btnNewGame", "btnSettings", "btnQuit", "settingsMenu")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
@@ -14,6 +14,7 @@ grow_vertical = 2
script = ExtResource("1_vp3lc")
btnNewGame = NodePath("VBoxContainer/NewGame")
btnSettings = NodePath("VBoxContainer/Settings")
btnQuit = NodePath("VBoxContainer/Quit")
settingsMenu = NodePath("MainMenuSettings")
newGameScene = "uid://d0ywgijpuqy0r"
metadata/_custom_type_script = "uid://btfeuku41py2b"
@@ -35,6 +36,10 @@ text = "New Game"
layout_mode = 2
text = "Settings"
[node name="Quit" type="Button" parent="VBoxContainer"]
layout_mode = 2
text = "Quit Game"
[node name="MainMenuSettings" type="Control" parent="."]
visible = false
layout_mode = 1
+9 -2
View File
@@ -2,17 +2,24 @@ class_name PauseMain extends VBoxContainer
signal resumeRequested
signal settingsRequested
signal quitRequested
@export var btnResume:Button
@export var btnSettings:Button
@export var btnMainMenu:Button
@export var btnQuit:Button
func _ready() -> void:
visible = false
btnResume.pressed.connect(resumeRequested.emit)
btnSettings.pressed.connect(settingsRequested.emit)
btnQuit.pressed.connect(quitRequested.emit)
btnMainMenu.pressed.connect(_showMainMenuConfirm)
btnQuit.pressed.connect(_showQuitConfirm)
func _showQuitConfirm() -> void:
UI.QUIT_DIALOG.open()
func _showMainMenuConfirm() -> void:
UI.MAIN_MENU_DIALOG.open()
func open() -> void:
visible = true
+15 -7
View File
@@ -2,7 +2,7 @@
[ext_resource type="Script" uid="uid://c7kvg0jw6w340" path="res://ui/pause/PauseMain.gd" id="1_b5xfl"]
[node name="PauseMain" type="VBoxContainer" node_paths=PackedStringArray("btnResume", "btnSettings", "btnQuit")]
[node name="PauseMain" type="VBoxContainer" node_paths=PackedStringArray("btnResume", "btnSettings", "btnMainMenu", "btnQuit")]
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
@@ -12,23 +12,31 @@ grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_b5xfl")
metadata/_custom_type_script = "uid://c7kvg0jw6w340"
btnResume = NodePath("Resume")
btnSettings = NodePath("Settings")
btnQuit = NodePath("Quit")
btnResume = NodePath("MainButtons/Resume")
btnSettings = NodePath("MainButtons/Settings")
btnMainMenu = NodePath("MainButtons/MainMenu")
btnQuit = NodePath("MainButtons/Quit")
[node name="Title" type="Label" parent="."]
layout_mode = 2
text = "Paused"
horizontal_alignment = 1
[node name="Resume" type="Button" parent="."]
[node name="MainButtons" type="VBoxContainer" parent="."]
layout_mode = 2
[node name="Resume" type="Button" parent="MainButtons"]
layout_mode = 2
text = "Resume"
[node name="Settings" type="Button" parent="."]
[node name="Settings" type="Button" parent="MainButtons"]
layout_mode = 2
text = "Settings"
[node name="Quit" type="Button" parent="."]
[node name="MainMenu" type="Button" parent="MainButtons"]
layout_mode = 2
text = "Main Menu"
[node name="Quit" type="Button" parent="MainButtons"]
layout_mode = 2
text = "Quit Game"
+18 -17
View File
@@ -1,39 +1,40 @@
class_name PauseMenu extends Control
class_name PauseMenu extends ClosableMenu
@export var MAIN:PauseMain
@export var settingsPanel:PauseSettings
func _ready() -> void:
close()
super._ready()
MAIN.resumeRequested.connect(close)
MAIN.settingsRequested.connect(_openSettings)
MAIN.quitRequested.connect(func(): get_tree().quit())
func isOpen() -> bool:
return visible
UI.MAIN_MENU_DIALOG.confirmed.connect(_goToMainMenu)
func open() -> void:
visible = true
super.open()
get_tree().paused = true
settingsPanel.close()
MAIN.open()
func close() -> void:
get_tree().paused = false
visible = false
MAIN.close()
settingsPanel.close()
MAIN.close()
super.close()
func _openSettings() -> void:
MAIN.close()
settingsPanel.open()
func _goToMainMenu() -> void:
close()
SCENE.setScene(SceneSingleton.SceneType.INITIAL)
func _unhandled_input(event:InputEvent) -> void:
if !visible:
if not event.is_action_pressed("ui_cancel"):
return
if event.is_action_pressed("ui_cancel"):
if settingsPanel.isOpen():
settingsPanel.close()
MAIN.open()
else:
close()
get_viewport().set_input_as_handled()
if settingsPanel.isOpen():
settingsPanel.close()
MAIN.open()
else:
close()
get_viewport().set_input_as_handled()
+12 -31
View File
@@ -1,14 +1,15 @@
class_name SettingsMenu extends Control
class_name SettingsMenu extends TabMenu
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 checkInvertY:CheckBox
@export var sliderControllerSpeed:HSlider
@export var sliderMouseSpeed:HSlider
@export var optionTextSpeed:OptionButton
func _ready() -> void:
tabs.tab_changed.connect(onTabChanged)
super._ready()
checkInvertX.button_pressed = SETTINGS.invertCameraX
checkInvertY.button_pressed = SETTINGS.invertCameraY
checkInvertX.toggled.connect(func(v:bool): SETTINGS.invertCameraX = v)
@@ -17,31 +18,11 @@ func _ready() -> void:
sliderMouseSpeed.value = SETTINGS.cameraSpeedMouse
sliderControllerSpeed.value_changed.connect(func(v:float): SETTINGS.cameraSpeedController = v)
sliderMouseSpeed.value_changed.connect(func(v:float): SETTINGS.cameraSpeedMouse = v)
onTabChanged(tabs.current_tab)
optionTextSpeed.select(_textSpeedToIndex(SETTINGS.textSpeed))
optionTextSpeed.item_selected.connect(func(idx:int): SETTINGS.textSpeed = TEXT_SPEED_VALUES[idx])
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:
for child in container.get_children():
if child is Control and child.focus_mode != Control.FOCUS_NONE:
child.grab_focus()
return
func _textSpeedToIndex(speed:float) -> int:
match speed:
0.2: return 0
2.0: return 2
_: return 1
+19 -4
View File
@@ -2,7 +2,7 @@
[ext_resource type="Script" uid="uid://efmr0xkbw1py" path="res://ui/settings/SettingsMenu.gd" id="1_4lnig"]
[node name="SettingsMenu" type="Control" node_paths=PackedStringArray("tabs", "tabControls", "checkInvertX", "checkInvertY", "sliderControllerSpeed", "sliderMouseSpeed")]
[node name="SettingsMenu" type="Control" node_paths=PackedStringArray("tabs", "tabControls", "checkInvertX", "checkInvertY", "sliderControllerSpeed", "sliderMouseSpeed", "optionTextSpeed")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
@@ -11,11 +11,12 @@ grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_4lnig")
tabs = NodePath("VBoxContainer/TabBar")
tabControls = [NodePath("VBoxContainer/ScrollContainer/LabelGameplay"), NodePath("VBoxContainer/ScrollContainer/LabelSound"), NodePath("VBoxContainer/ScrollContainer/LabelGraphics"), NodePath("VBoxContainer/ScrollContainer/PanelControls")]
tabControls = [NodePath("VBoxContainer/ScrollContainer/PanelGameplay"), NodePath("VBoxContainer/ScrollContainer/LabelSound"), NodePath("VBoxContainer/ScrollContainer/LabelGraphics"), NodePath("VBoxContainer/ScrollContainer/PanelControls")]
checkInvertX = NodePath("VBoxContainer/ScrollContainer/PanelControls/CheckInvertX")
checkInvertY = NodePath("VBoxContainer/ScrollContainer/PanelControls/CheckInvertY")
sliderControllerSpeed = NodePath("VBoxContainer/ScrollContainer/PanelControls/SliderControllerSpeed")
sliderMouseSpeed = NodePath("VBoxContainer/ScrollContainer/PanelControls/SliderMouseSpeed")
optionTextSpeed = NodePath("VBoxContainer/ScrollContainer/PanelGameplay/OptionTextSpeed")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
@@ -48,10 +49,24 @@ visible = false
layout_mode = 2
text = "Sound"
[node name="LabelGameplay" type="Label" parent="VBoxContainer/ScrollContainer"]
[node name="PanelGameplay" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
visible = false
layout_mode = 2
text = "Gameplay"
[node name="LabelTextSpeed" type="Label" parent="VBoxContainer/ScrollContainer/PanelGameplay"]
layout_mode = 2
text = "Text Speed"
[node name="OptionTextSpeed" type="OptionButton" parent="VBoxContainer/ScrollContainer/PanelGameplay"]
layout_mode = 2
focus_mode = 2
item_count = 3
item_0/text = "Slow"
item_0/id = 0
item_1/text = "Normal"
item_1/id = 1
item_2/text = "Fast"
item_2/id = 2
[node name="PanelControls" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
visible = false