Compare commits
16 Commits
7c9daf91a1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| acf0ef6be5 | |||
| 0cf1f92eaa | |||
| 8ffde98fdd | |||
| bfd455341d | |||
| 27d056fafa | |||
| 3d01fcce86 | |||
| 7fc1a4645c | |||
| aae68a60d5 | |||
| b2e8cd2c30 | |||
| 45540c31b2 | |||
| 96141e48a2 | |||
| 2f3a4eab66 | |||
| f6a0bb156e | |||
| 5668250ea9 | |||
| b364fae1c7 | |||
| a4d47d7f00 |
@@ -17,7 +17,7 @@ Never instantiate these — access only via the global handle.
|
|||||||
| `QUEST` | Quest management (stub) |
|
| `QUEST` | Quest management (stub) |
|
||||||
| `CUTSCENE` | Cutscene global (stub) |
|
| `CUTSCENE` | Cutscene global (stub) |
|
||||||
| `DialogueManager` | godot_dialogue_manager v3.10.4 — parses and steps through `.dialogue` files |
|
| `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
|
## Scene Graph
|
||||||
|
|
||||||
@@ -25,7 +25,13 @@ Never instantiate these — access only via the global handle.
|
|||||||
RootScene (Node3D)
|
RootScene (Node3D)
|
||||||
└─ overworld / battle / cooking / initial ← one shown at a time
|
└─ overworld / battle / cooking / initial ← one shown at a time
|
||||||
RootUI (Control, always visible)
|
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.
|
`RootScene` listens to `SCENE.sceneChanged` and shows/hides the correct sub-tree.
|
||||||
|
|||||||
+288
-23
@@ -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).
|
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`)
|
- 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 are imported as `DialogueResource` once the plugin is active
|
||||||
- Dialogue files live in `dialogue/` organised by category (`npc/`, `item/`, `battle/`)
|
- 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
|
## 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
|
```gdscript
|
||||||
# Add a dialogue step to any Cutscene
|
# Add a dialogue step to any Cutscene
|
||||||
cutscene.addCallable(DialogueAction.getDialogueCallable(
|
cutscene.addCallable(DialogueAction.getDialogueCallable(
|
||||||
load("res://dialogue/npc/test.dialogue"),
|
"res://dialogue/npc/guard", # base path — no locale suffix, no extension
|
||||||
"start", # title to begin from
|
"start", # section to begin from
|
||||||
[entity] # extra_game_states: objects/dicts accessible in the .dialogue file
|
[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
|
## Writing .dialogue files
|
||||||
|
|
||||||
```
|
```
|
||||||
~ title_name # entry point / jump target
|
~ title_name # entry point / jump target
|
||||||
|
|
||||||
Speaker: Line of dialogue.
|
SpeakerName: Line of dialogue.
|
||||||
Another line with no speaker.
|
|
||||||
|
|
||||||
~ another_section
|
~ another_section
|
||||||
Speaker: Variables resolve inline: {{some_property}}.
|
SpeakerName: Variables resolve inline: {{some_property}}.
|
||||||
=> END # end this dialogue
|
=> END # end this dialogue
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key syntax:**
|
**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
|
- `=> title` — jump to another section; `=> END` ends the dialogue
|
||||||
- `{{variable}}` — resolves a property from any autoload or `extra_game_states` object
|
- `{{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)`)
|
- `do AUTOLOAD.method()` — call a method on any autoload (e.g. `do PARTY.BACKPACK.addStack(stack)`)
|
||||||
- `set AUTOLOAD.property = value` — set a property
|
- `set AUTOLOAD.property = value` — set a property on any autoload
|
||||||
- `- Option text` — response branch (indented lines handle each branch)
|
- `- Option text` — response branch (player choice); indented lines run for that branch
|
||||||
- `[if condition]` — conditional line or response
|
- `[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.
|
**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
|
## Passing runtime data to dialogue
|
||||||
|
|
||||||
Use `extra_game_states` to expose GDScript objects to `{{variable}}` tokens:
|
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:
|
Then in the `.dialogue` file:
|
||||||
```
|
```
|
||||||
Obtained {{item_name}} x{{quantity}}.
|
shopkeeper: Here's your {{item_name}} x{{quantity}}.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## NPC entities
|
## 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 |
|
| Export | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `dialogueResource:DialogueResource` | The `.dialogue` file to run |
|
| `dialogueBasePath:String` | Base path to the dialogue file, without locale suffix or extension (e.g. `"res://dialogue/npc/guard"`) |
|
||||||
| `dialogueTitle:String` | Title (section) to start from (default `"start"`) |
|
| `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
|
## Dialogue files
|
||||||
|
|
||||||
| File | Used by |
|
| File | Used by |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `dialogue/npc/test.dialogue` | TestMap NPC (NotPlayer) |
|
| `dialogue/npc/test.en.dialogue` | TestMap NPC (NotPlayer / "Stranger") |
|
||||||
| `dialogue/item/pickup.dialogue` | `ItemAction` — item pickup text with `{{item_name}}` and `{{quantity}}` |
|
| `dialogue/item/pickup.en.dialogue` | `ItemAction` — item pickup text with `{{item_name}}` and `{{quantity}}` |
|
||||||
| `dialogue/battle/narration.dialogue` | `BattleCutsceneAction` — move announcements, victory/defeat |
|
| `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.
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ Camera-relative direction is derived from the active `Camera3D`'s basis — the
|
|||||||
| `centeredPitch:float` | `30.0` | Target pitch in CENTERED mode |
|
| `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 |
|
| `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:**
|
**Mode transitions:**
|
||||||
- FREE → CENTERED: after `centeredDelay` seconds of player movement with no camera input, or immediately via `center_camera` (G / LB)
|
- 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)
|
- CENTERED → FREE: any manual camera input (controller stick or right-click drag)
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ These exist in the codebase but have no real implementation yet. Don't assume th
|
|||||||
| `BattleItem.perform()` | Stub |
|
| `BattleItem.perform()` | Stub |
|
||||||
| `CookingScene.tscn` | Placeholder UI only |
|
| `CookingScene.tscn` | Placeholder UI only |
|
||||||
| Response branching in `DialogueAction` | Auto-selects first allowed response; no response UI exists yet |
|
| 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 |
|
||||||
|
|||||||
@@ -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
@@ -5,57 +5,116 @@
|
|||||||
```
|
```
|
||||||
RootUI (Control, fullscreen, always visible)
|
RootUI (Control, fullscreen, always visible)
|
||||||
├── DebugMenu
|
├── DebugMenu
|
||||||
├── PauseMenu
|
|
||||||
│ ├── PauseSettings
|
|
||||||
│ └── PauseMain
|
|
||||||
├── GameMenu
|
├── GameMenu
|
||||||
│ ├── GameMenuPartyTab
|
│ ├── GameMenuPartyTab
|
||||||
│ └── GameMenuItemsTab
|
│ └── 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 singleton
|
||||||
|
|
||||||
`UI` (autoload) is the global access point.
|
`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.DEBUG_MENU` | `DebugMenu` | Dev scene-jump overlay |
|
||||||
| `UI.GAME_MENU` | `GameMenu` | JRPG-style in-game menu |
|
| `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.
|
Base class for all togglable panels. Extends `Control`; the `isOpen:bool` export drives `visible`.
|
||||||
|
|
||||||
**Showing text from code:**
|
|
||||||
|
|
||||||
```gdscript
|
```gdscript
|
||||||
# Fire-and-forget
|
menu.open() # shows, pushes to FocusStack (if canClose), emits opened
|
||||||
UI.TEXTBOX.setText("Hello world.")
|
menu.close() # pops from FocusStack (if canClose), hides, emits closed
|
||||||
|
menu.toggle()
|
||||||
# Await player dismiss — use this in cutscene callables
|
|
||||||
await UI.TEXTBOX.setTextAndWait("Hello world.")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Flow:**
|
**Key exports / properties:**
|
||||||
1. `setText` resets reveal state and sets new text; textbox becomes visible automatically
|
|
||||||
2. Player holds `interact` to speed up reveal; press again after reveal completes to advance page or close
|
|
||||||
3. `textboxClosing` signal fires when the last page is dismissed
|
|
||||||
4. `setTextAndWait` awaits that signal before returning
|
|
||||||
|
|
||||||
**Input guard:** `EntityMovement._canMove()` returns `false` while `!UI.TEXTBOX.isClosed`. Don't set text without also expecting movement to be blocked.
|
| 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
|
## 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`)
|
- Smart word-wrap (`TextServer.AUTOWRAP_WORD_SMART`)
|
||||||
- Pagination via `maxLines` / `startLine` exports
|
- 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`.
|
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
|
## 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()`.
|
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.
|
Both tabs are populated dynamically on open; call `refresh()` on the active tab directly if data changes while the menu is already open.
|
||||||
|
|
||||||
**Key methods on `GameMenu`:**
|
**Key members on `GameMenu`:**
|
||||||
|
|
||||||
| Method | Effect |
|
| Member | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `open()` | Shows menu, refreshes active tab, grabs sidebar focus |
|
| `open()` | Shows menu, refreshes active tab, pushes to FocusStack, grabs sidebar focus |
|
||||||
| `close()` | Hides menu |
|
| `close()` | Pops from FocusStack, hides menu |
|
||||||
| `isOpen() -> bool` | Visibility state |
|
| `isOpen:bool` | Property — read directly, not a method |
|
||||||
|
|
||||||
`ui_cancel` or `menu` closes the menu. The `menu` input opens it only when `UI.dialogueActive` is false and the textbox is closed.
|
`ui_cancel` or `menu` closes the menu. `menu` is handled in `_input` (always fires) and opens the menu only when `UI.FOCUS_STACK.top() == null` and `UI.dialogueActive` is false.
|
||||||
|
|
||||||
To add a new tab: add a value to `GameMenu.Tab`, create a tab scene/script under `ui/gamemenu/`, instance it in `GameMenu.tscn` as a sibling of the other tabs, add an `@export` for it in `GameMenu.gd`, and add the `match` branch in `_selectTab()`.
|
To add a new tab: add a value to `GameMenu.Tab`, create a tab scene/script under `ui/gamemenu/`, instance it in `GameMenu.tscn` as a sibling of the other tabs, add an `@export` for it in `GameMenu.gd`, and add the `match` branch in `_selectTab()`.
|
||||||
|
|
||||||
## Pause menu
|
## 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.open()` | Pushes to FocusStack, pauses tree, opens PauseMain, emits `opened` |
|
||||||
| `PauseMenu.close()` | Hides everything |
|
| `PauseMenu.close()` | Unpauses tree, closes sub-panels, pops from FocusStack, emits `closed` |
|
||||||
| `PauseMenu.isOpen() -> bool` | Visibility state |
|
| `PauseMenu.isOpen:bool` | Property — read directly, not a method |
|
||||||
|
|
||||||
`ui_cancel` 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
|
## 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)` |
|
| Cooking | `SCENE.setScene(COOKING)` |
|
||||||
| Initial | `SCENE.setScene(INITIAL)` |
|
| 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
|
## Theme & assets
|
||||||
|
|
||||||
@@ -145,6 +213,9 @@ Access via `UI.DEBUG_MENU`. Starts hidden; `isClosed` getter/setter controls vis
|
|||||||
## Adding a new menu
|
## Adding a new menu
|
||||||
|
|
||||||
1. Create a scene whose root extends `ClosableMenu` (or `Control` if open/close isn't needed)
|
1. Create a scene whose root extends `ClosableMenu` (or `Control` if open/close isn't needed)
|
||||||
2. Add it as a child of `RootUI.tscn`
|
2. Override `_grabInitialFocus()` to place focus on the first interactive element on open
|
||||||
3. Export a typed reference on `RootUI.gd` and wire it in the Inspector
|
3. Add it as a child of `RootUI.tscn` — position after `PauseMenu` if it should render above it
|
||||||
4. Expose via a getter on `UISingleton.gd` if other systems need access (follow the `TEXTBOX` / `DEBUG_MENU` pattern)
|
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.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Detailed reference lives in [.claude/docs/](.claude/docs/):
|
|||||||
- [Stubs](.claude/docs/stubs.md) — incomplete / placeholder systems to avoid relying on
|
- [Stubs](.claude/docs/stubs.md) — incomplete / placeholder systems to avoid relying on
|
||||||
- [Overworld](.claude/docs/overworld.md) — map transitions, Entity exports, interaction types, camera, movement
|
- [Overworld](.claude/docs/overworld.md) — map transitions, Entity exports, interaction types, camera, movement
|
||||||
- [UI](.claude/docs/ui.md) — UI singleton, VNTextbox, ClosableMenu, pause/debug/settings menus, AdvancedRichText
|
- [UI](.claude/docs/ui.md) — UI singleton, VNTextbox, ClosableMenu, pause/debug/settings menus, AdvancedRichText
|
||||||
|
- [UI Navigation](.claude/docs/ui-navigation.md) — FocusStack, FocusLayer, SidebarMenu, modal layering, mouse blocking (design doc — not yet implemented)
|
||||||
|
|
||||||
@.claude/docs/code-style.md
|
@.claude/docs/code-style.md
|
||||||
@.claude/docs/architecture.md
|
@.claude/docs/architecture.md
|
||||||
@@ -21,3 +22,4 @@ Detailed reference lives in [.claude/docs/](.claude/docs/):
|
|||||||
@.claude/docs/stubs.md
|
@.claude/docs/stubs.md
|
||||||
@.claude/docs/overworld.md
|
@.claude/docs/overworld.md
|
||||||
@.claude/docs/ui.md
|
@.claude/docs/ui.md
|
||||||
|
@.claude/docs/ui-navigation.md
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
class_name BattleCutsceneAction
|
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.
|
# State object passed as extra_game_states so {{variable}} tokens resolve in the dialogue file.
|
||||||
class BattleNarrationState:
|
class BattleNarrationState:
|
||||||
@@ -29,7 +28,7 @@ static func battleDecisionCallable(params:Dictionary) -> int:
|
|||||||
|
|
||||||
var cutscene:Cutscene = params['cutscene']
|
var cutscene:Cutscene = params['cutscene']
|
||||||
cutscene.addCallable(
|
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
|
{'position': Cutscene.CUTSCENE_ADD_NEXT}, false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -58,7 +57,7 @@ static func playerDecisionCallable(params:Dictionary) -> int:
|
|||||||
|
|
||||||
if allPlayersDead:
|
if allPlayersDead:
|
||||||
params['cutscene'].addCallable(
|
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
|
{'position': Cutscene.CUTSCENE_ADD_NEXT}, false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -66,7 +65,7 @@ static func playerDecisionCallable(params:Dictionary) -> int:
|
|||||||
|
|
||||||
if allEnemiesDead:
|
if allEnemiesDead:
|
||||||
params['cutscene'].addCallable(
|
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
|
{'position': Cutscene.CUTSCENE_ADD_NEXT}, false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,45 +1,76 @@
|
|||||||
class_name DialogueAction
|
class_name DialogueAction
|
||||||
|
|
||||||
# Runs a .dialogue file through the VNTextbox and returns CUTSCENE_CONTINUE when
|
const _TextboxGd = preload("res://ui/component/DialogueTextbox.gd")
|
||||||
# the last line is dismissed. Mutations in the .dialogue file are executed
|
const _ChoiceBoxGd = preload("res://ui/component/DialogueChoiceBox.gd")
|
||||||
# automatically by DialogueManager before the line is returned.
|
|
||||||
#
|
enum DialogueMode {
|
||||||
# extra_game_states: additional objects/dicts whose properties and methods are
|
CONVERSATION, # blocks movement, player advances
|
||||||
# accessible inside the .dialogue file (alongside all autoloads).
|
NARRATION, # non-blocking, player advances
|
||||||
|
AMBIENT, # non-blocking, timed auto-advance
|
||||||
|
}
|
||||||
|
|
||||||
static func dialogueCallable(params:Dictionary) -> int:
|
static func dialogueCallable(params:Dictionary) -> int:
|
||||||
assert(params.has('resource'))
|
assert(params.has('basePath'))
|
||||||
var resource:DialogueResource = params['resource']
|
var basePath:String = params['basePath']
|
||||||
var title:String = params.get('title', 'start')
|
var title:String = params.get('title', 'start')
|
||||||
var extraStates:Array = params.get('extraStates', [])
|
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)
|
var line:DialogueLine = await DialogueManager.get_next_dialogue_line(resource, title, extraStates)
|
||||||
while line != null:
|
while line != null:
|
||||||
var text:String = line.text
|
var entity:Entity = OVERWORLD.getEntityByDialogueName(line.character)
|
||||||
if line.character:
|
|
||||||
text = line.character + ": " + text
|
|
||||||
|
|
||||||
if line.responses.size() > 0:
|
var textbox = _TextboxGd.SCENE.instantiate()
|
||||||
# Show text then auto-pick the first allowed response.
|
UI.chatBoxContainer.add_child(textbox)
|
||||||
# Replace this block with a real response UI when branching dialogue is needed.
|
textbox.setup(line, entity, advancementMode)
|
||||||
await UI.TEXTBOX.setTextAndWait(text)
|
await textbox.dismissed
|
||||||
var nextId:String = ""
|
|
||||||
for response:DialogueResponse in line.responses:
|
var allowedResponses:Array = line.responses.filter(func(r): return r.is_allowed)
|
||||||
if response.is_allowed:
|
if allowedResponses.size() > 0:
|
||||||
nextId = response.next_id
|
var playerEntity:Entity = OVERWORLD.getPlayerEntity()
|
||||||
break
|
var choiceBox = _ChoiceBoxGd.SCENE.instantiate()
|
||||||
line = await DialogueManager.get_next_dialogue_line(resource, nextId, extraStates)
|
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:
|
else:
|
||||||
await UI.TEXTBOX.setTextAndWait(text)
|
|
||||||
line = await DialogueManager.get_next_dialogue_line(resource, line.next_id, extraStates)
|
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
|
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 {
|
return {
|
||||||
'function': dialogueCallable,
|
"function": dialogueCallable,
|
||||||
'resource': resource,
|
"basePath": basePath,
|
||||||
'title': title,
|
"title": title,
|
||||||
'extraStates': extraStates,
|
"extraStates": extraStates,
|
||||||
|
"mode": mode,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
class_name ItemAction
|
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:
|
class ItemDialogueState:
|
||||||
var item_name:String
|
var item_name:String
|
||||||
var quantity:int
|
var quantity:int
|
||||||
func _init(name:String, qty:int) -> void:
|
func _init(itemName:String, qty:int) -> void:
|
||||||
item_name = name
|
item_name = itemName
|
||||||
quantity = qty
|
quantity = qty
|
||||||
|
|
||||||
static var PICKUP_DIALOGUE:DialogueResource = preload("res://dialogue/item/pickup.dialogue")
|
|
||||||
|
|
||||||
static func itemGetCallable(params:Dictionary) -> int:
|
static func itemGetCallable(params:Dictionary) -> int:
|
||||||
assert(params.has('stack'))
|
assert(params.has('stack'))
|
||||||
var stack:ItemStack = params['stack']
|
var stack:ItemStack = params['stack']
|
||||||
PARTY.BACKPACK.addStack(stack)
|
PARTY.BACKPACK.addStack(stack)
|
||||||
|
|
||||||
var state = ItemDialogueState.new(Item.getItemName(stack.item), stack.quantity)
|
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
|
dialogueParams['position'] = Cutscene.CUTSCENE_ADD_NEXT
|
||||||
params['cutscene'].addCallable(dialogueParams)
|
params['cutscene'].addCallable(dialogueParams)
|
||||||
return Cutscene.CUTSCENE_CONTINUE
|
return Cutscene.CUTSCENE_CONTINUE
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -7,6 +7,25 @@ var hasFadedOut:bool = false
|
|||||||
var playerDestinationNodeName:String
|
var playerDestinationNodeName:String
|
||||||
var newMapLoaded:bool = false
|
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:
|
func isMapChanging() -> bool:
|
||||||
return newMapPath != ""
|
return newMapPath != ""
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,19 @@ var _freeTimer:float = 0.0
|
|||||||
var _mouseDelta:Vector2 = Vector2.ZERO
|
var _mouseDelta:Vector2 = Vector2.ZERO
|
||||||
var _rightMouseHeld:bool = false
|
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:
|
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:
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
|
||||||
_rightMouseHeld = event.pressed
|
_rightMouseHeld = event.pressed
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _rightMouseHeld else Input.MOUSE_MODE_VISIBLE
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _rightMouseHeld else Input.MOUSE_MODE_VISIBLE
|
||||||
@@ -56,13 +68,17 @@ func _process(delta:float) -> void:
|
|||||||
var xMult:float = -1.0 if SETTINGS.invertCameraX else 1.0
|
var xMult:float = -1.0 if SETTINGS.invertCameraX else 1.0
|
||||||
var yMult:float = 1.0 if SETTINGS.invertCameraY else -1.0
|
var yMult:float = 1.0 if SETTINGS.invertCameraY else -1.0
|
||||||
|
|
||||||
var orbitInput:Vector2 = Input.get_vector(
|
var orbitInput:Vector2 = Vector2.ZERO
|
||||||
"camera_orbit_left", "camera_orbit_right",
|
var mouseActive:bool = false
|
||||||
"camera_orbit_up", "camera_orbit_down"
|
|
||||||
)
|
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 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
|
# Any manual camera input returns to FREE and resets the centering timer
|
||||||
if controllerActive or mouseActive:
|
if controllerActive or mouseActive:
|
||||||
@@ -83,10 +99,10 @@ func _process(delta:float) -> void:
|
|||||||
if mouseActive:
|
if mouseActive:
|
||||||
_yaw += _mouseDelta.x * mouseSensitivity * SETTINGS.cameraSpeedMouse * xMult
|
_yaw += _mouseDelta.x * mouseSensitivity * SETTINGS.cameraSpeedMouse * xMult
|
||||||
_pitch += _mouseDelta.y * mouseSensitivity * SETTINGS.cameraSpeedMouse * yMult
|
_pitch += _mouseDelta.y * mouseSensitivity * SETTINGS.cameraSpeedMouse * yMult
|
||||||
_mouseDelta = Vector2.ZERO
|
_mouseDelta = Vector2.ZERO
|
||||||
|
|
||||||
# center_camera input → switch to MANUAL_CENTER immediately
|
# center_camera input → switch to MANUAL_CENTER immediately
|
||||||
if Input.is_action_just_pressed("center_camera"):
|
if _canOrbit() and Input.is_action_just_pressed("center_camera"):
|
||||||
_mode = CameraMode.MANUAL_CENTER
|
_mode = CameraMode.MANUAL_CENTER
|
||||||
|
|
||||||
# In FREE mode, accumulate time toward auto-centering while the player is moving
|
# In FREE mode, accumulate time toward auto-centering while the player is moving
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ enum InteractType {
|
|||||||
ONE_TIME_ITEM,
|
ONE_TIME_ITEM,
|
||||||
CUTSCENE,
|
CUTSCENE,
|
||||||
BATTLE_TEST,
|
BATTLE_TEST,
|
||||||
};
|
PROXIMITY_CHATBOX,
|
||||||
|
}
|
||||||
|
|
||||||
@export_category("Identification")
|
@export_category("Identification")
|
||||||
@export var entityId:String = UUID.uuidv4()
|
@export var entityId:String = UUID.uuidv4()
|
||||||
@@ -22,17 +23,30 @@ enum InteractType {
|
|||||||
var button := func():
|
var button := func():
|
||||||
entityId = UUID.uuidv4()
|
entityId = UUID.uuidv4()
|
||||||
|
|
||||||
# Movement settings
|
@export_category("Dialogue")
|
||||||
|
@export var dialogueName:String = ""
|
||||||
|
@export var displayName:String = ""
|
||||||
|
|
||||||
@export_category("Movement")
|
@export_category("Movement")
|
||||||
@export var movementType:MovementType = MovementType.NONE
|
@export var movementType:MovementType = MovementType.NONE
|
||||||
|
|
||||||
# Interaction settings
|
|
||||||
@export_category("Interactions")
|
@export_category("Interactions")
|
||||||
@export var interactType:InteractType = InteractType.NONE
|
@export var interactType:InteractType = InteractType.NONE
|
||||||
@export var dialogueResource:DialogueResource = null
|
@export var dialogueBasePath:String = ""
|
||||||
@export var dialogueTitle:String = "start"
|
@export var dialogueTitle:String = "start"
|
||||||
@export var oneTimeItem:ItemResource = null
|
@export var oneTimeItem:ItemResource = null
|
||||||
@export var cutscene:CutsceneResource = null
|
@export var cutscene:CutsceneResource = null
|
||||||
|
|
||||||
# TEST BATTLE
|
|
||||||
@export_category("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,6 +1,5 @@
|
|||||||
class_name EntityInteractableArea extends Area3D
|
class_name EntityInteractableArea extends Area3D
|
||||||
const ItemAction = preload("res://cutscene/item/ItemAction.gd")
|
const ItemAction = preload("res://cutscene/item/ItemAction.gd")
|
||||||
const DialogueAction = preload("res://cutscene/dialogue/DialogueAction.gd")
|
|
||||||
|
|
||||||
@export var entity:Entity
|
@export var entity:Entity
|
||||||
|
|
||||||
@@ -8,40 +7,32 @@ func isInteractable() -> bool:
|
|||||||
if !entity:
|
if !entity:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
if entity.interactType == Entity.InteractType.NONE:
|
match entity.interactType:
|
||||||
return false
|
Entity.InteractType.NONE:
|
||||||
|
|
||||||
if entity.interactType == Entity.InteractType.CONVERSATION:
|
|
||||||
return entity.dialogueResource != null
|
|
||||||
|
|
||||||
if entity.interactType == Entity.InteractType.CUTSCENE:
|
|
||||||
if entity.cutscene == null:
|
|
||||||
return false
|
return false
|
||||||
if !entity.cutscene.canRun():
|
Entity.InteractType.CONVERSATION:
|
||||||
return false
|
return entity.dialogueBasePath != ""
|
||||||
return true
|
Entity.InteractType.CUTSCENE:
|
||||||
|
return entity.cutscene != null and entity.cutscene.canRun()
|
||||||
if entity.interactType == Entity.InteractType.ONE_TIME_ITEM:
|
Entity.InteractType.ONE_TIME_ITEM:
|
||||||
if entity.oneTimeItem == null:
|
return (
|
||||||
return false
|
entity.oneTimeItem != null
|
||||||
if entity.oneTimeItem.quantity <= 0:
|
and entity.oneTimeItem.quantity > 0
|
||||||
return false
|
and entity.oneTimeItem.item != Item.Id.NULL
|
||||||
if entity.oneTimeItem.item == Item.Id.NULL:
|
)
|
||||||
return false
|
Entity.InteractType.BATTLE_TEST:
|
||||||
return true
|
return true
|
||||||
|
|
||||||
if entity.interactType == Entity.InteractType.BATTLE_TEST:
|
|
||||||
return true
|
|
||||||
|
|
||||||
return false
|
return false
|
||||||
|
|
||||||
func _onConversationInteract(_other:Entity) -> void:
|
func _onConversationInteract(_other:Entity) -> void:
|
||||||
assert(entity.dialogueResource != null)
|
assert(entity.dialogueBasePath != "")
|
||||||
var cutscene:Cutscene = Cutscene.new()
|
var cutscene:Cutscene = Cutscene.new()
|
||||||
cutscene.addCallable(DialogueAction.getDialogueCallable(
|
cutscene.addCallable(DialogueAction.getDialogueCallable(
|
||||||
entity.dialogueResource,
|
entity.dialogueBasePath,
|
||||||
entity.dialogueTitle,
|
entity.dialogueTitle,
|
||||||
[entity]
|
[entity],
|
||||||
|
DialogueAction.DialogueMode.CONVERSATION
|
||||||
))
|
))
|
||||||
cutscene.start()
|
cutscene.start()
|
||||||
|
|
||||||
@@ -53,24 +44,17 @@ func _onItemInteract(_other:Entity) -> void:
|
|||||||
entity.queue_free()
|
entity.queue_free()
|
||||||
|
|
||||||
func onInteract(other:Entity) -> void:
|
func onInteract(other:Entity) -> void:
|
||||||
if entity.interactType == Entity.InteractType.NONE:
|
|
||||||
return
|
|
||||||
|
|
||||||
match entity.interactType:
|
match entity.interactType:
|
||||||
|
Entity.InteractType.NONE:
|
||||||
|
return
|
||||||
Entity.InteractType.CONVERSATION:
|
Entity.InteractType.CONVERSATION:
|
||||||
_onConversationInteract(other)
|
_onConversationInteract(other)
|
||||||
return
|
|
||||||
|
|
||||||
Entity.InteractType.ONE_TIME_ITEM:
|
Entity.InteractType.ONE_TIME_ITEM:
|
||||||
_onItemInteract(other)
|
_onItemInteract(other)
|
||||||
return
|
|
||||||
|
|
||||||
Entity.InteractType.CUTSCENE:
|
Entity.InteractType.CUTSCENE:
|
||||||
var cutscene:Cutscene = Cutscene.new()
|
var cutscene:Cutscene = Cutscene.new()
|
||||||
entity.cutscene.queue(cutscene)
|
entity.cutscene.queue(cutscene)
|
||||||
cutscene.start()
|
cutscene.start()
|
||||||
return
|
|
||||||
|
|
||||||
Entity.InteractType.BATTLE_TEST:
|
Entity.InteractType.BATTLE_TEST:
|
||||||
var testEnemy = BattleFighter.new({
|
var testEnemy = BattleFighter.new({
|
||||||
'controller': BattleFighter.FighterController.AI
|
'controller': BattleFighter.FighterController.AI
|
||||||
@@ -81,7 +65,3 @@ func onInteract(other:Entity) -> void:
|
|||||||
BATTLE.BattlePosition.LEFT_MIDDLE_FRONT: testEnemy
|
BATTLE.BattlePosition.LEFT_MIDDLE_FRONT: testEnemy
|
||||||
}))
|
}))
|
||||||
cutscene.start()
|
cutscene.start()
|
||||||
return
|
|
||||||
|
|
||||||
_:
|
|
||||||
pass
|
|
||||||
|
|||||||
@@ -22,11 +22,36 @@ func _exit_tree() -> void:
|
|||||||
self.area_entered.disconnect(_onAreaEntered)
|
self.area_entered.disconnect(_onAreaEntered)
|
||||||
self.area_exited.disconnect(_onAreaExited)
|
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:
|
func _onAreaEntered(area:Area3D) -> void:
|
||||||
if area is EntityInteractableArea:
|
if area is EntityInteractableArea:
|
||||||
if area.entity == entity:
|
if area.entity == entity:
|
||||||
return
|
return
|
||||||
interactableAreas.append(area)
|
interactableAreas.append(area)
|
||||||
|
_updateIndicator()
|
||||||
|
|
||||||
func _onAreaExited(area:Area3D) -> void:
|
func _onAreaExited(area:Area3D) -> void:
|
||||||
interactableAreas.erase(area)
|
interactableAreas.erase(area)
|
||||||
|
_updateIndicator()
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ func _applyGravity() -> void:
|
|||||||
func _applyPlayerMovement(delta:float):
|
func _applyPlayerMovement(delta:float):
|
||||||
# Interactions, may move
|
# Interactions, may move
|
||||||
if Input.is_action_just_pressed("interact") && interactingArea && interactingArea.hasInteraction():
|
if Input.is_action_just_pressed("interact") && interactingArea && interactingArea.hasInteraction():
|
||||||
interactingArea.interact()
|
if !UI.dialogueActive:
|
||||||
|
interactingArea.interact()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Directional input
|
# Directional input
|
||||||
@@ -62,11 +63,9 @@ func _applyFriction(delta:float) -> void:
|
|||||||
entity.velocity.z *= delta * FRICTION
|
entity.velocity.z *= delta * FRICTION
|
||||||
|
|
||||||
func _canMove() -> bool:
|
func _canMove() -> bool:
|
||||||
if UI.dialogueActive:
|
if UI.activeConversation:
|
||||||
return false
|
return false
|
||||||
if !UI.TEXTBOX.isClosed:
|
if UI.FOCUS_STACK.top() != null:
|
||||||
return false
|
|
||||||
if UI.GAME_MENU && UI.GAME_MENU.isOpen():
|
|
||||||
return false
|
return false
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +1,7 @@
|
|||||||
extends Node3D
|
extends Node3D
|
||||||
|
|
||||||
func _ready() -> void:
|
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
|
var npc:Entity = $NotPlayer
|
||||||
if npc:
|
if npc:
|
||||||
npc.dialogueResource = load("res://dialogue/npc/test.dialogue")
|
npc.dialogueBasePath = "res://dialogue/npc/test"
|
||||||
npc.dialogueTitle = "start"
|
npc.dialogueTitle = "start"
|
||||||
|
|||||||
@@ -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="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"]
|
[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://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://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://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"]
|
[sub_resource type="Resource" id="Resource_125nt"]
|
||||||
script = ExtResource("4_xf0pb")
|
script = ExtResource("4_xf0pb")
|
||||||
@@ -17,12 +18,17 @@ metadata/_custom_type_script = "uid://38ya6vphm5bu"
|
|||||||
script = ExtResource("5_125nt")
|
script = ExtResource("5_125nt")
|
||||||
metadata/_custom_type_script = "uid://b5c8g5frishjs"
|
metadata/_custom_type_script = "uid://b5c8g5frishjs"
|
||||||
|
|
||||||
|
[sub_resource type="SphereShape3D" id="SphereShape3D_prox"]
|
||||||
|
radius = 3.0
|
||||||
|
|
||||||
[node name="TestMap" type="Node3D"]
|
[node name="TestMap" type="Node3D"]
|
||||||
script = ExtResource("1_6ms5s")
|
script = ExtResource("1_6ms5s")
|
||||||
|
|
||||||
[node name="NotPlayer" parent="." instance=ExtResource("2_jmygs")]
|
[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)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00883961, 1.11219, 0.0142021)
|
||||||
entityId = "bcabec96-8d33-4c16-a997-3bb3b0562b33"
|
entityId = "bcabec96-8d33-4c16-a997-3bb3b0562b33"
|
||||||
|
dialogueName = "stranger"
|
||||||
|
displayName = "Stranger"
|
||||||
interactType = 1
|
interactType = 1
|
||||||
|
|
||||||
[node name="NotPlayer4" parent="." instance=ExtResource("2_jmygs")]
|
[node name="NotPlayer4" parent="." instance=ExtResource("2_jmygs")]
|
||||||
@@ -42,11 +48,32 @@ entityId = "ad5a1504-7fbf-45d6-b1bf-6e7af6314066"
|
|||||||
interactType = 3
|
interactType = 3
|
||||||
cutscene = SubResource("Resource_tr4a0")
|
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="TestMapBase" parent="." instance=ExtResource("1_ox0si")]
|
||||||
|
|
||||||
[node name="Player" parent="." instance=ExtResource("2_jmygs")]
|
[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)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.1915, 1.05, 0.125589)
|
||||||
entityId = "player"
|
entityId = "player"
|
||||||
|
dialogueName = "john"
|
||||||
|
displayName = "John"
|
||||||
movementType = 2
|
movementType = 2
|
||||||
|
|
||||||
[node name="Camera3D" type="Camera3D" parent="." node_paths=PackedStringArray("targetNode")]
|
[node name="Camera3D" type="Camera3D" parent="." node_paths=PackedStringArray("targetNode")]
|
||||||
|
|||||||
+12
-3
@@ -69,6 +69,7 @@ ui_accept={
|
|||||||
"deadzone": 0.5,
|
"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)
|
"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":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)
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -81,6 +82,7 @@ ui_cancel={
|
|||||||
ui_left={
|
ui_left={
|
||||||
"deadzone": 0.5,
|
"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)
|
"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(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)
|
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":-1.0,"script":null)
|
||||||
]
|
]
|
||||||
@@ -88,6 +90,7 @@ ui_left={
|
|||||||
ui_right={
|
ui_right={
|
||||||
"deadzone": 0.5,
|
"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)
|
"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(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)
|
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":1.0,"script":null)
|
||||||
]
|
]
|
||||||
@@ -95,6 +98,7 @@ ui_right={
|
|||||||
ui_up={
|
ui_up={
|
||||||
"deadzone": 0.5,
|
"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)
|
"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(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)
|
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
|
||||||
]
|
]
|
||||||
@@ -102,6 +106,7 @@ ui_up={
|
|||||||
ui_down={
|
ui_down={
|
||||||
"deadzone": 0.5,
|
"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)
|
"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(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)
|
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
|
||||||
]
|
]
|
||||||
@@ -170,32 +175,36 @@ menu={
|
|||||||
camera_orbit_left={
|
camera_orbit_left={
|
||||||
"deadzone": 0.15,
|
"deadzone": 0.15,
|
||||||
"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":-1.0,"script":null)
|
"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={
|
camera_orbit_right={
|
||||||
"deadzone": 0.15,
|
"deadzone": 0.15,
|
||||||
"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":1.0,"script":null)
|
"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={
|
camera_orbit_up={
|
||||||
"deadzone": 0.15,
|
"deadzone": 0.15,
|
||||||
"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":-1.0,"script":null)
|
"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={
|
camera_orbit_down={
|
||||||
"deadzone": 0.15,
|
"deadzone": 0.15,
|
||||||
"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":1.0,"script":null)
|
"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={
|
tab_prev={
|
||||||
"deadzone": 0.5,
|
"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)
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":9,"pressure":0.0,"pressed":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
tab_next={
|
tab_next={
|
||||||
"deadzone": 0.5,
|
"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)
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -209,7 +218,7 @@ center_camera={
|
|||||||
[internationalization]
|
[internationalization]
|
||||||
|
|
||||||
locale/translations=PackedStringArray("res://locale/en_AU.po")
|
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/language_filter=["ja"]
|
||||||
locale/country_filter=["JP"]
|
locale/country_filter=["JP"]
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,9 @@ func _unhandled_input(event:InputEvent) -> void:
|
|||||||
var menu:PauseMenu = UI.PAUSE_MENU
|
var menu:PauseMenu = UI.PAUSE_MENU
|
||||||
if menu == null:
|
if menu == null:
|
||||||
return
|
return
|
||||||
if menu.isOpen():
|
if SCENE.currentScene == SceneSingleton.SceneType.INITIAL and !menu.isOpen:
|
||||||
|
return
|
||||||
|
if menu.isOpen:
|
||||||
menu.close()
|
menu.close()
|
||||||
else:
|
else:
|
||||||
menu.open()
|
menu.open()
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ var invertCameraX:bool = false
|
|||||||
var invertCameraY:bool = false
|
var invertCameraY:bool = false
|
||||||
var cameraSpeedController:float = 1.0
|
var cameraSpeedController:float = 1.0
|
||||||
var cameraSpeedMouse:float = 1.0
|
var cameraSpeedMouse:float = 1.0
|
||||||
|
var textSpeed:float = 1.0
|
||||||
|
|||||||
+4
-1
@@ -1,9 +1,12 @@
|
|||||||
class_name RootUI extends Control
|
class_name RootUI extends Control
|
||||||
|
|
||||||
@export var debugMenu:DebugMenu
|
@export var debugMenu:DebugMenu
|
||||||
@export var textBox:VNTextbox
|
|
||||||
@export var gameMenu:GameMenu
|
@export var gameMenu:GameMenu
|
||||||
@export var pauseMenu:PauseMenu
|
@export var pauseMenu:PauseMenu
|
||||||
|
@export var quitConfirmDialog:QuitConfirmDialog
|
||||||
|
@export var mainMenuConfirmDialog:ConfirmDialog
|
||||||
|
@export var modalBackdrop:ModalBackdrop
|
||||||
|
@export var chatBoxContainer:Control
|
||||||
|
|
||||||
func _enter_tree() -> void:
|
func _enter_tree() -> void:
|
||||||
UI.rootUi = self
|
UI.rootUi = self
|
||||||
|
|||||||
+40
-6
@@ -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="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://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://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" 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
|
layout_mode = 3
|
||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
@@ -16,24 +19,55 @@ grow_vertical = 2
|
|||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
script = ExtResource("1_son71")
|
script = ExtResource("1_son71")
|
||||||
debugMenu = NodePath("DebugMenu")
|
debugMenu = NodePath("DebugMenu")
|
||||||
textBox = NodePath("VNTextbox")
|
|
||||||
gameMenu = NodePath("GameMenu")
|
gameMenu = NodePath("GameMenu")
|
||||||
pauseMenu = NodePath("PauseMenu")
|
pauseMenu = NodePath("PauseMenu")
|
||||||
|
quitConfirmDialog = NodePath("QuitConfirmDialog")
|
||||||
|
mainMenuConfirmDialog = NodePath("MainMenuConfirmDialog")
|
||||||
|
modalBackdrop = NodePath("ModalBackdrop")
|
||||||
|
chatBoxContainer = NodePath("ChatBoxContainer")
|
||||||
metadata/_custom_type_script = "uid://dq3qyyayugt5l"
|
metadata/_custom_type_script = "uid://dq3qyyayugt5l"
|
||||||
|
|
||||||
[node name="DebugMenu" parent="." instance=ExtResource("4_u132g")]
|
[node name="DebugMenu" parent="." instance=ExtResource("4_u132g")]
|
||||||
visible = false
|
visible = false
|
||||||
layout_mode = 1
|
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")]
|
[node name="PauseMenu" parent="." instance=ExtResource("2_atyu8")]
|
||||||
visible = false
|
visible = false
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
process_mode = 3
|
process_mode = 3
|
||||||
|
|
||||||
[node name="GameMenu" parent="." instance=ExtResource("5_gmenu")]
|
[node name="QuitConfirmDialog" parent="." instance=ExtResource("8_qcd")]
|
||||||
visible = false
|
visible = false
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
|
|
||||||
[node name="VNTextbox" parent="." instance=ExtResource("1_1mtk3")]
|
[node name="MainMenuConfirmDialog" parent="." instance=ExtResource("9_mmcd")]
|
||||||
visible = false
|
visible = false
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
class_name UIFocusStack extends RefCounted
|
||||||
|
|
||||||
|
signal activeLayerChanged(layer:ClosableMenu)
|
||||||
|
|
||||||
|
const Z_STEP:int = 10
|
||||||
|
|
||||||
|
var _stack:Array[ClosableMenu] = []
|
||||||
|
|
||||||
|
func push(layer:ClosableMenu) -> void:
|
||||||
|
if not _stack.is_empty():
|
||||||
|
_stack.back()._onFocusLost()
|
||||||
|
_stack.push_back(layer)
|
||||||
|
layer.z_index = _stack.size() * Z_STEP
|
||||||
|
layer._onFocusGained()
|
||||||
|
activeLayerChanged.emit(layer)
|
||||||
|
|
||||||
|
func pop() -> void:
|
||||||
|
if _stack.is_empty(): return
|
||||||
|
var removed:ClosableMenu = _stack.pop_back()
|
||||||
|
removed.z_index = 0
|
||||||
|
removed._onFocusLost()
|
||||||
|
var next:ClosableMenu = top()
|
||||||
|
if next != null:
|
||||||
|
next._onFocusGained()
|
||||||
|
activeLayerChanged.emit(next)
|
||||||
|
|
||||||
|
func top() -> ClosableMenu:
|
||||||
|
return _stack.back() if not _stack.is_empty() else null
|
||||||
|
|
||||||
|
func isTop(layer:ClosableMenu) -> bool:
|
||||||
|
return top() == layer
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bnttsy278nvaw
|
||||||
+68
-15
@@ -1,31 +1,84 @@
|
|||||||
extends Node
|
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
|
var rootUi:RootUI = null
|
||||||
# between lines where the textbox is momentarily closed.
|
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 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():
|
get():
|
||||||
if rootUi && rootUi.debugMenu:
|
if rootUi:
|
||||||
|
return rootUi.chatBoxContainer
|
||||||
|
return null
|
||||||
|
|
||||||
|
var DEBUG_MENU:DebugMenu:
|
||||||
|
get():
|
||||||
|
if rootUi:
|
||||||
return rootUi.debugMenu
|
return rootUi.debugMenu
|
||||||
return null
|
return null
|
||||||
|
|
||||||
var TEXTBOX:
|
var GAME_MENU:GameMenu:
|
||||||
get():
|
get():
|
||||||
if rootUi && rootUi.textBox:
|
if rootUi:
|
||||||
return rootUi.textBox
|
|
||||||
return null
|
|
||||||
|
|
||||||
var GAME_MENU:
|
|
||||||
get():
|
|
||||||
if rootUi && rootUi.gameMenu:
|
|
||||||
return rootUi.gameMenu
|
return rootUi.gameMenu
|
||||||
return null
|
return null
|
||||||
|
|
||||||
var PAUSE_MENU:
|
var PAUSE_MENU:PauseMenu:
|
||||||
get():
|
get():
|
||||||
if rootUi && rootUi.pauseMenu:
|
if rootUi:
|
||||||
return rootUi.pauseMenu
|
return rootUi.pauseMenu
|
||||||
return null
|
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
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
class_name ClosableMenu extends Control
|
class_name ClosableMenu extends Control
|
||||||
|
|
||||||
@export var isOpen: bool:
|
signal opened
|
||||||
set(newValue):
|
signal closed
|
||||||
isOpen = newValue
|
signal focusGained
|
||||||
visible = newValue
|
signal focusLost
|
||||||
if newValue:
|
|
||||||
opened.emit()
|
@export var canClose:bool = true
|
||||||
else:
|
@export var isOpen:bool = false:
|
||||||
closed.emit()
|
set(v):
|
||||||
|
isOpen = v
|
||||||
|
visible = v
|
||||||
get():
|
get():
|
||||||
return isOpen
|
return isOpen
|
||||||
|
|
||||||
signal closed
|
var _savedFocusNode:Control = null
|
||||||
signal opened
|
|
||||||
|
|
||||||
func _enter_tree() -> void:
|
func _enter_tree() -> void:
|
||||||
visible = isOpen
|
visible = isOpen
|
||||||
@@ -22,13 +23,59 @@ func _exit_tree() -> void:
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
visible = isOpen
|
visible = isOpen
|
||||||
print("ClosableMenu is ready, isOpen: ", isOpen)
|
if canClose:
|
||||||
|
set_process_unhandled_input(false)
|
||||||
func close() -> void:
|
|
||||||
isOpen = false
|
|
||||||
|
|
||||||
func open() -> void:
|
func open() -> void:
|
||||||
|
visible = true
|
||||||
|
if canClose:
|
||||||
|
UI.FOCUS_STACK.push(self)
|
||||||
isOpen = true
|
isOpen = true
|
||||||
|
opened.emit()
|
||||||
|
|
||||||
|
func close() -> void:
|
||||||
|
if canClose:
|
||||||
|
UI.FOCUS_STACK.pop()
|
||||||
|
isOpen = false
|
||||||
|
closed.emit()
|
||||||
|
|
||||||
func toggle() -> void:
|
func toggle() -> void:
|
||||||
isOpen = !isOpen
|
if isOpen:
|
||||||
|
close()
|
||||||
|
else:
|
||||||
|
open()
|
||||||
|
|
||||||
|
func _onFocusGained() -> void:
|
||||||
|
set_process_unhandled_input(true)
|
||||||
|
get_viewport().gui_focus_changed.connect(_onViewportFocusChanged)
|
||||||
|
if _savedFocusNode != null and is_instance_valid(_savedFocusNode):
|
||||||
|
_savedFocusNode.grab_focus()
|
||||||
|
else:
|
||||||
|
_grabInitialFocus()
|
||||||
|
var currentFocus:Control = get_viewport().gui_get_focus_owner()
|
||||||
|
if currentFocus != null and is_ancestor_of(currentFocus):
|
||||||
|
_savedFocusNode = currentFocus
|
||||||
|
focusGained.emit()
|
||||||
|
|
||||||
|
func _onFocusLost() -> void:
|
||||||
|
_savedFocusNode = get_viewport().gui_get_focus_owner()
|
||||||
|
if get_viewport().gui_focus_changed.is_connected(_onViewportFocusChanged):
|
||||||
|
get_viewport().gui_focus_changed.disconnect(_onViewportFocusChanged)
|
||||||
|
set_process_unhandled_input(false)
|
||||||
|
focusLost.emit()
|
||||||
|
|
||||||
|
func _onViewportFocusChanged(control:Control) -> void:
|
||||||
|
if control == null or is_ancestor_of(control):
|
||||||
|
return
|
||||||
|
var node:Node = control
|
||||||
|
while node != null:
|
||||||
|
if node is Popup:
|
||||||
|
return
|
||||||
|
node = node.get_parent()
|
||||||
|
if _savedFocusNode != null and is_instance_valid(_savedFocusNode):
|
||||||
|
_savedFocusNode.grab_focus()
|
||||||
|
else:
|
||||||
|
_grabInitialFocus()
|
||||||
|
|
||||||
|
func _grabInitialFocus() -> void:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cee1d1wrf0ypc
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cbgsqs5t10l06
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://drivjdgk70cqq
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://xrcb2e7jwlm0
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bmcklt3xo3hk1
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class_name QuitConfirmDialog extends ConfirmDialog
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
super._ready()
|
||||||
|
confirmed.connect(func(): get_tree().quit())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://deov3ob0lojyo
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
class_name TabMenu extends Control
|
||||||
|
|
||||||
|
@export var tabs:TabBar
|
||||||
|
@export var tabControls:Array[Control]
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
tabs.tab_changed.connect(_onTabChanged)
|
||||||
|
_onTabChanged(tabs.current_tab)
|
||||||
|
|
||||||
|
func _notification(what:int) -> void:
|
||||||
|
if what == NOTIFICATION_VISIBILITY_CHANGED and visible:
|
||||||
|
tabs.grab_focus()
|
||||||
|
|
||||||
|
func _input(event:InputEvent) -> void:
|
||||||
|
if !is_visible_in_tree():
|
||||||
|
return
|
||||||
|
if event.is_action_pressed("tab_next"):
|
||||||
|
tabs.current_tab = (tabs.current_tab + 1) % tabs.tab_count
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
elif event.is_action_pressed("tab_prev"):
|
||||||
|
tabs.current_tab = (tabs.current_tab - 1 + tabs.tab_count) % tabs.tab_count
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
elif tabs.has_focus() and event.is_action_pressed("ui_accept"):
|
||||||
|
var idx:int = tabs.current_tab
|
||||||
|
if idx >= 0 and idx < tabControls.size() and _focusFirstIn(tabControls[idx]):
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
func _onTabChanged(tabIndex:int) -> void:
|
||||||
|
for control in tabControls:
|
||||||
|
control.visible = false
|
||||||
|
if tabIndex >= 0 and tabIndex < tabControls.size():
|
||||||
|
tabControls[tabIndex].visible = true
|
||||||
|
|
||||||
|
func _focusFirstIn(container:Control) -> bool:
|
||||||
|
for child in container.get_children():
|
||||||
|
if not child is Control:
|
||||||
|
continue
|
||||||
|
if child.focus_mode != Control.FOCUS_NONE and child.is_visible_in_tree():
|
||||||
|
child.grab_focus()
|
||||||
|
return true
|
||||||
|
if _focusFirstIn(child):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dacm5qwmmkcsm
|
||||||
@@ -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 +0,0 @@
|
|||||||
uid://h8lw23ypcfty
|
|
||||||
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
class_name GameMenu extends Control
|
class_name GameMenu extends ClosableMenu
|
||||||
|
|
||||||
enum Tab { PARTY, ITEMS }
|
enum Tab { PARTY, ITEMS }
|
||||||
|
|
||||||
@@ -9,21 +9,14 @@ enum Tab { PARTY, ITEMS }
|
|||||||
var _currentTab:Tab = Tab.PARTY
|
var _currentTab:Tab = Tab.PARTY
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
visible = false
|
super._ready()
|
||||||
SIDEBAR.item_selected.connect(_onTabSelected)
|
SIDEBAR.item_selected.connect(_onTabSelected)
|
||||||
|
|
||||||
func open() -> void:
|
func _grabInitialFocus() -> void:
|
||||||
visible = true
|
|
||||||
_selectTab(_currentTab)
|
_selectTab(_currentTab)
|
||||||
SIDEBAR.select(_currentTab)
|
SIDEBAR.select(_currentTab)
|
||||||
SIDEBAR.grab_focus()
|
SIDEBAR.grab_focus()
|
||||||
|
|
||||||
func close() -> void:
|
|
||||||
visible = false
|
|
||||||
|
|
||||||
func isOpen() -> bool:
|
|
||||||
return visible
|
|
||||||
|
|
||||||
func _onTabSelected(index:int) -> void:
|
func _onTabSelected(index:int) -> void:
|
||||||
_selectTab(index as Tab)
|
_selectTab(index as Tab)
|
||||||
|
|
||||||
@@ -37,16 +30,16 @@ func _selectTab(tab:Tab) -> void:
|
|||||||
Tab.ITEMS:
|
Tab.ITEMS:
|
||||||
ITEMS_TAB.refresh()
|
ITEMS_TAB.refresh()
|
||||||
|
|
||||||
|
func _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:
|
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"):
|
if event.is_action_pressed("ui_cancel"):
|
||||||
close()
|
close()
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
|
|||||||
+13
-7
@@ -2,20 +2,19 @@ class_name MainMenu extends Control
|
|||||||
|
|
||||||
@export var btnNewGame:Button
|
@export var btnNewGame:Button
|
||||||
@export var btnSettings:Button
|
@export var btnSettings:Button
|
||||||
|
@export var btnQuit:Button
|
||||||
@export var settingsMenu:ClosableMenu
|
@export var settingsMenu:ClosableMenu
|
||||||
@export_file("*.tscn") var newGameScene:String
|
@export_file("*.tscn") var newGameScene:String
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
btnNewGame.pressed.connect(onNewGamePressed)
|
btnNewGame.pressed.connect(onNewGamePressed)
|
||||||
btnSettings.pressed.connect(onSettingsPressed)
|
btnSettings.pressed.connect(onSettingsPressed)
|
||||||
settingsMenu.opened.connect(_onSettingsOpened)
|
btnQuit.pressed.connect(_onQuitPressed)
|
||||||
settingsMenu.closed.connect(_onSettingsClosed)
|
settingsMenu.closed.connect(_onSettingsClosed)
|
||||||
btnNewGame.grab_focus()
|
|
||||||
|
|
||||||
func _onSettingsOpened() -> void:
|
func _notification(what:int) -> void:
|
||||||
# Move focus into the settings panel so the controller can navigate it.
|
if what == NOTIFICATION_ENTER_TREE:
|
||||||
# The SettingsMenu grabs its own internal focus via _notification.
|
btnNewGame.call_deferred("grab_focus")
|
||||||
pass
|
|
||||||
|
|
||||||
func _onSettingsClosed() -> void:
|
func _onSettingsClosed() -> void:
|
||||||
btnSettings.grab_focus()
|
btnSettings.grab_focus()
|
||||||
@@ -26,9 +25,16 @@ func _unhandled_input(event:InputEvent) -> void:
|
|||||||
settingsMenu.close()
|
settingsMenu.close()
|
||||||
get_viewport().set_input_as_handled()
|
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:
|
func onNewGamePressed() -> void:
|
||||||
SCENE.setScene(SceneSingleton.SceneType.OVERWORLD)
|
SCENE.setScene(SceneSingleton.SceneType.OVERWORLD)
|
||||||
OVERWORLD.mapChange(newGameScene, "PlayerSpawnPoint")
|
OVERWORLD.mapChange(newGameScene, "PlayerSpawnPoint")
|
||||||
|
|
||||||
func onSettingsPressed() -> void:
|
func onSettingsPressed() -> void:
|
||||||
settingsMenu.isOpen = true
|
settingsMenu.open()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
[ext_resource type="Script" uid="uid://bcjfv6dw0ugvo" path="res://ui/component/ClosableMenu.gd" id="2_f3vro"]
|
[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"]
|
[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
|
layout_mode = 3
|
||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
@@ -14,6 +14,7 @@ grow_vertical = 2
|
|||||||
script = ExtResource("1_vp3lc")
|
script = ExtResource("1_vp3lc")
|
||||||
btnNewGame = NodePath("VBoxContainer/NewGame")
|
btnNewGame = NodePath("VBoxContainer/NewGame")
|
||||||
btnSettings = NodePath("VBoxContainer/Settings")
|
btnSettings = NodePath("VBoxContainer/Settings")
|
||||||
|
btnQuit = NodePath("VBoxContainer/Quit")
|
||||||
settingsMenu = NodePath("MainMenuSettings")
|
settingsMenu = NodePath("MainMenuSettings")
|
||||||
newGameScene = "uid://d0ywgijpuqy0r"
|
newGameScene = "uid://d0ywgijpuqy0r"
|
||||||
metadata/_custom_type_script = "uid://btfeuku41py2b"
|
metadata/_custom_type_script = "uid://btfeuku41py2b"
|
||||||
@@ -35,6 +36,10 @@ text = "New Game"
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "Settings"
|
text = "Settings"
|
||||||
|
|
||||||
|
[node name="Quit" type="Button" parent="VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Quit Game"
|
||||||
|
|
||||||
[node name="MainMenuSettings" type="Control" parent="."]
|
[node name="MainMenuSettings" type="Control" parent="."]
|
||||||
visible = false
|
visible = false
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
|
|||||||
+7
-21
@@ -2,41 +2,27 @@ class_name PauseMain extends VBoxContainer
|
|||||||
|
|
||||||
signal resumeRequested
|
signal resumeRequested
|
||||||
signal settingsRequested
|
signal settingsRequested
|
||||||
signal quitRequested
|
|
||||||
|
|
||||||
@export var btnResume:Button
|
@export var btnResume:Button
|
||||||
@export var btnSettings:Button
|
@export var btnSettings:Button
|
||||||
|
@export var btnMainMenu:Button
|
||||||
@export var btnQuit:Button
|
@export var btnQuit:Button
|
||||||
@export var mainButtons:VBoxContainer
|
|
||||||
@export var confirmQuit:VBoxContainer
|
|
||||||
@export var btnQuitConfirm:Button
|
|
||||||
@export var btnQuitCancel:Button
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
visible = false
|
visible = false
|
||||||
btnResume.pressed.connect(resumeRequested.emit)
|
btnResume.pressed.connect(resumeRequested.emit)
|
||||||
btnSettings.pressed.connect(settingsRequested.emit)
|
btnSettings.pressed.connect(settingsRequested.emit)
|
||||||
btnQuit.pressed.connect(_showConfirm)
|
btnMainMenu.pressed.connect(_showMainMenuConfirm)
|
||||||
btnQuitConfirm.pressed.connect(quitRequested.emit)
|
btnQuit.pressed.connect(_showQuitConfirm)
|
||||||
btnQuitCancel.pressed.connect(cancelConfirm)
|
|
||||||
|
|
||||||
func _showConfirm() -> void:
|
func _showQuitConfirm() -> void:
|
||||||
mainButtons.visible = false
|
UI.QUIT_DIALOG.open()
|
||||||
confirmQuit.visible = true
|
|
||||||
btnQuitCancel.grab_focus()
|
|
||||||
|
|
||||||
func cancelConfirm() -> void:
|
func _showMainMenuConfirm() -> void:
|
||||||
mainButtons.visible = true
|
UI.MAIN_MENU_DIALOG.open()
|
||||||
confirmQuit.visible = false
|
|
||||||
btnQuit.grab_focus()
|
|
||||||
|
|
||||||
func isConfirming() -> bool:
|
|
||||||
return confirmQuit.visible
|
|
||||||
|
|
||||||
func open() -> void:
|
func open() -> void:
|
||||||
visible = true
|
visible = true
|
||||||
if isConfirming():
|
|
||||||
cancelConfirm()
|
|
||||||
btnResume.grab_focus()
|
btnResume.grab_focus()
|
||||||
|
|
||||||
func close() -> void:
|
func close() -> void:
|
||||||
|
|||||||
+6
-22
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[ext_resource type="Script" uid="uid://c7kvg0jw6w340" path="res://ui/pause/PauseMain.gd" id="1_b5xfl"]
|
[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", "mainButtons", "confirmQuit", "btnQuitConfirm", "btnQuitCancel")]
|
[node name="PauseMain" type="VBoxContainer" node_paths=PackedStringArray("btnResume", "btnSettings", "btnMainMenu", "btnQuit")]
|
||||||
anchors_preset = 8
|
anchors_preset = 8
|
||||||
anchor_left = 0.5
|
anchor_left = 0.5
|
||||||
anchor_top = 0.5
|
anchor_top = 0.5
|
||||||
@@ -14,11 +14,8 @@ script = ExtResource("1_b5xfl")
|
|||||||
metadata/_custom_type_script = "uid://c7kvg0jw6w340"
|
metadata/_custom_type_script = "uid://c7kvg0jw6w340"
|
||||||
btnResume = NodePath("MainButtons/Resume")
|
btnResume = NodePath("MainButtons/Resume")
|
||||||
btnSettings = NodePath("MainButtons/Settings")
|
btnSettings = NodePath("MainButtons/Settings")
|
||||||
|
btnMainMenu = NodePath("MainButtons/MainMenu")
|
||||||
btnQuit = NodePath("MainButtons/Quit")
|
btnQuit = NodePath("MainButtons/Quit")
|
||||||
mainButtons = NodePath("MainButtons")
|
|
||||||
confirmQuit = NodePath("ConfirmQuit")
|
|
||||||
btnQuitConfirm = NodePath("ConfirmQuit/Yes")
|
|
||||||
btnQuitCancel = NodePath("ConfirmQuit/No")
|
|
||||||
|
|
||||||
[node name="Title" type="Label" parent="."]
|
[node name="Title" type="Label" parent="."]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
@@ -36,23 +33,10 @@ text = "Resume"
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "Settings"
|
text = "Settings"
|
||||||
|
|
||||||
|
[node name="MainMenu" type="Button" parent="MainButtons"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Main Menu"
|
||||||
|
|
||||||
[node name="Quit" type="Button" parent="MainButtons"]
|
[node name="Quit" type="Button" parent="MainButtons"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "Quit Game"
|
text = "Quit Game"
|
||||||
|
|
||||||
[node name="ConfirmQuit" type="VBoxContainer" parent="."]
|
|
||||||
layout_mode = 2
|
|
||||||
visible = false
|
|
||||||
|
|
||||||
[node name="Label" type="Label" parent="ConfirmQuit"]
|
|
||||||
layout_mode = 2
|
|
||||||
text = "Quit to desktop?"
|
|
||||||
horizontal_alignment = 1
|
|
||||||
|
|
||||||
[node name="Yes" type="Button" parent="ConfirmQuit"]
|
|
||||||
layout_mode = 2
|
|
||||||
text = "Yes"
|
|
||||||
|
|
||||||
[node name="No" type="Button" parent="ConfirmQuit"]
|
|
||||||
layout_mode = 2
|
|
||||||
text = "No"
|
|
||||||
|
|||||||
+18
-19
@@ -1,41 +1,40 @@
|
|||||||
class_name PauseMenu extends Control
|
class_name PauseMenu extends ClosableMenu
|
||||||
|
|
||||||
@export var MAIN:PauseMain
|
@export var MAIN:PauseMain
|
||||||
@export var settingsPanel:PauseSettings
|
@export var settingsPanel:PauseSettings
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
close()
|
super._ready()
|
||||||
MAIN.resumeRequested.connect(close)
|
MAIN.resumeRequested.connect(close)
|
||||||
MAIN.settingsRequested.connect(_openSettings)
|
MAIN.settingsRequested.connect(_openSettings)
|
||||||
MAIN.quitRequested.connect(func(): get_tree().quit())
|
UI.MAIN_MENU_DIALOG.confirmed.connect(_goToMainMenu)
|
||||||
|
|
||||||
func isOpen() -> bool:
|
|
||||||
return visible
|
|
||||||
|
|
||||||
func open() -> void:
|
func open() -> void:
|
||||||
visible = true
|
super.open()
|
||||||
get_tree().paused = true
|
get_tree().paused = true
|
||||||
|
settingsPanel.close()
|
||||||
MAIN.open()
|
MAIN.open()
|
||||||
|
|
||||||
func close() -> void:
|
func close() -> void:
|
||||||
get_tree().paused = false
|
get_tree().paused = false
|
||||||
visible = false
|
|
||||||
MAIN.close()
|
|
||||||
settingsPanel.close()
|
settingsPanel.close()
|
||||||
|
MAIN.close()
|
||||||
|
super.close()
|
||||||
|
|
||||||
func _openSettings() -> void:
|
func _openSettings() -> void:
|
||||||
MAIN.close()
|
MAIN.close()
|
||||||
settingsPanel.open()
|
settingsPanel.open()
|
||||||
|
|
||||||
|
func _goToMainMenu() -> void:
|
||||||
|
close()
|
||||||
|
SCENE.setScene(SceneSingleton.SceneType.INITIAL)
|
||||||
|
|
||||||
func _unhandled_input(event:InputEvent) -> void:
|
func _unhandled_input(event:InputEvent) -> void:
|
||||||
if !visible:
|
if not event.is_action_pressed("ui_cancel"):
|
||||||
return
|
return
|
||||||
if event.is_action_pressed("ui_cancel"):
|
if settingsPanel.isOpen():
|
||||||
if MAIN.isConfirming():
|
settingsPanel.close()
|
||||||
MAIN.cancelConfirm()
|
MAIN.open()
|
||||||
elif settingsPanel.isOpen():
|
else:
|
||||||
settingsPanel.close()
|
close()
|
||||||
MAIN.open()
|
get_viewport().set_input_as_handled()
|
||||||
else:
|
|
||||||
close()
|
|
||||||
get_viewport().set_input_as_handled()
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ class_name PauseSettings extends Control
|
|||||||
|
|
||||||
func open() -> void:
|
func open() -> void:
|
||||||
visible = true
|
visible = true
|
||||||
|
|
||||||
func close() -> void:
|
func close() -> void:
|
||||||
visible = false
|
visible = false
|
||||||
|
|
||||||
|
|||||||
+12
-31
@@ -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 checkInvertX:CheckBox
|
||||||
@export var checkInvertY:CheckBox
|
@export var checkInvertY:CheckBox
|
||||||
@export var sliderControllerSpeed:HSlider
|
@export var sliderControllerSpeed:HSlider
|
||||||
@export var sliderMouseSpeed:HSlider
|
@export var sliderMouseSpeed:HSlider
|
||||||
|
@export var optionTextSpeed:OptionButton
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
tabs.tab_changed.connect(onTabChanged)
|
super._ready()
|
||||||
checkInvertX.button_pressed = SETTINGS.invertCameraX
|
checkInvertX.button_pressed = SETTINGS.invertCameraX
|
||||||
checkInvertY.button_pressed = SETTINGS.invertCameraY
|
checkInvertY.button_pressed = SETTINGS.invertCameraY
|
||||||
checkInvertX.toggled.connect(func(v:bool): SETTINGS.invertCameraX = v)
|
checkInvertX.toggled.connect(func(v:bool): SETTINGS.invertCameraX = v)
|
||||||
@@ -17,31 +18,11 @@ func _ready() -> void:
|
|||||||
sliderMouseSpeed.value = SETTINGS.cameraSpeedMouse
|
sliderMouseSpeed.value = SETTINGS.cameraSpeedMouse
|
||||||
sliderControllerSpeed.value_changed.connect(func(v:float): SETTINGS.cameraSpeedController = v)
|
sliderControllerSpeed.value_changed.connect(func(v:float): SETTINGS.cameraSpeedController = v)
|
||||||
sliderMouseSpeed.value_changed.connect(func(v:float): SETTINGS.cameraSpeedMouse = 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:
|
func _textSpeedToIndex(speed:float) -> int:
|
||||||
if what == NOTIFICATION_VISIBILITY_CHANGED and visible:
|
match speed:
|
||||||
tabs.grab_focus()
|
0.2: return 0
|
||||||
|
2.0: return 2
|
||||||
func _input(event:InputEvent) -> void:
|
_: return 1
|
||||||
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
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[ext_resource type="Script" uid="uid://efmr0xkbw1py" path="res://ui/settings/SettingsMenu.gd" id="1_4lnig"]
|
[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
|
layout_mode = 3
|
||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
@@ -11,11 +11,12 @@ grow_horizontal = 2
|
|||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
script = ExtResource("1_4lnig")
|
script = ExtResource("1_4lnig")
|
||||||
tabs = NodePath("VBoxContainer/TabBar")
|
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")
|
checkInvertX = NodePath("VBoxContainer/ScrollContainer/PanelControls/CheckInvertX")
|
||||||
checkInvertY = NodePath("VBoxContainer/ScrollContainer/PanelControls/CheckInvertY")
|
checkInvertY = NodePath("VBoxContainer/ScrollContainer/PanelControls/CheckInvertY")
|
||||||
sliderControllerSpeed = NodePath("VBoxContainer/ScrollContainer/PanelControls/SliderControllerSpeed")
|
sliderControllerSpeed = NodePath("VBoxContainer/ScrollContainer/PanelControls/SliderControllerSpeed")
|
||||||
sliderMouseSpeed = NodePath("VBoxContainer/ScrollContainer/PanelControls/SliderMouseSpeed")
|
sliderMouseSpeed = NodePath("VBoxContainer/ScrollContainer/PanelControls/SliderMouseSpeed")
|
||||||
|
optionTextSpeed = NodePath("VBoxContainer/ScrollContainer/PanelGameplay/OptionTextSpeed")
|
||||||
|
|
||||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
@@ -48,10 +49,24 @@ visible = false
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "Sound"
|
text = "Sound"
|
||||||
|
|
||||||
[node name="LabelGameplay" type="Label" parent="VBoxContainer/ScrollContainer"]
|
[node name="PanelGameplay" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
|
||||||
visible = false
|
visible = false
|
||||||
layout_mode = 2
|
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"]
|
[node name="PanelControls" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
|
||||||
visible = false
|
visible = false
|
||||||
|
|||||||
Reference in New Issue
Block a user