355 lines
20 KiB
Markdown
355 lines
20 KiB
Markdown
# Dialogue System
|
|
|
|
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 — godot_dialogue_manager v3.10.4
|
|
|
|
- Repo: [nathanhoad/godot_dialogue_manager](https://github.com/nathanhoad/godot_dialogue_manager)
|
|
- Autoload: `DialogueManager` (registered in `project.godot`)
|
|
- Plugin must be enabled: Godot → Project Settings → Plugins → Dialogue Manager
|
|
- `.dialogue` files are imported as `DialogueResource` once the plugin is active
|
|
- Dialogue files live in `dialogue/` organised by category (`npc/`, `item/`, `battle/`)
|
|
|
|
### Key API
|
|
|
|
#### Fetching lines manually (our approach)
|
|
|
|
```gdscript
|
|
# Must be awaited. Runs any mutations encountered along the way.
|
|
# Returns a DialogueLine, or null when dialogue ends.
|
|
var line:DialogueLine = await DialogueManager.get_next_dialogue_line(
|
|
resource, # DialogueResource
|
|
"start", # cue / section title to begin from
|
|
[extra_state_obj] # optional: objects whose properties are accessible in {{variables}}
|
|
)
|
|
```
|
|
|
|
This is the low-level path we use — it gives full control over how lines are displayed. We do **not** use `DialogueManager.show_dialogue_balloon()` since we render lines ourselves via the world-space textbox system.
|
|
|
|
#### Advancing to the next line
|
|
|
|
```gdscript
|
|
# Pass line.next_id to continue the conversation
|
|
var next_line = await DialogueManager.get_next_dialogue_line(resource, line.next_id)
|
|
|
|
# For a chosen response, pass the response's next_id instead
|
|
var next_line = await DialogueManager.get_next_dialogue_line(resource, chosen_response.next_id)
|
|
```
|
|
|
|
#### Signals
|
|
|
|
| Signal | When |
|
|
|---|---|
|
|
| `dialogue_started(resource)` | First line fetched from a resource |
|
|
| `dialogue_ended(resource)` | `get_next_dialogue_line` returns `null` |
|
|
| `got_dialogue(line)` | Each time a printable line is found |
|
|
| `mutated(mutation)` | A `do` / `set` mutation line is about to run |
|
|
| `passed_cue(cue)` | A `~ cue` marker is passed through |
|
|
|
|
`UISingleton.dialogueActive` should be driven by `dialogue_started` / `dialogue_ended` rather than set manually.
|
|
|
|
### DialogueLine properties
|
|
|
|
| Property | Type | Notes |
|
|
|---|---|---|
|
|
| `character` | `String` | Speaker name (empty string if no speaker) |
|
|
| `text` | `String` | The line body, with BBCode and `{{variables}}` resolved |
|
|
| `responses` | `Array[DialogueResponse]` | Non-empty when the line has choices |
|
|
| `next_id` | `String` | ID to pass to the next `get_next_dialogue_line` call |
|
|
| `tags` | `PackedStringArray` | `[#tag]` annotations on the line — use for expressions, camera cues, etc. |
|
|
|
|
### DialogueResponse properties
|
|
|
|
| Property | Type | Notes |
|
|
|---|---|---|
|
|
| `text` | `String` | Display text for the option |
|
|
| `next_id` | `String` | Pass to `get_next_dialogue_line` if this response is chosen |
|
|
| `is_allowed` | `bool` | False if the `[if condition]` check failed — hide or grey out disallowed responses |
|
|
| `character` | `String` | Speaker name for this response (usually the player) |
|
|
|
|
### BBCode tags (dialogue-manager extras)
|
|
|
|
Beyond standard Godot BBCode, the plugin adds:
|
|
|
|
| Tag | Effect |
|
|
|---|---|
|
|
| `[wait=N]` | Pause reveal for N seconds (or `[wait="action"]` to wait for input) |
|
|
| `[speed=N]` | Multiply reveal speed by N for the remainder of the line |
|
|
| `[next=N]` | Auto-advance to next line after N seconds (used with `TIMED` mode) |
|
|
| `[next=auto]` | Auto-advance after a length-estimated delay |
|
|
| `[[A\|B\|C]]` | Pick one option at random inline |
|
|
|
|
---
|
|
|
|
## Design Goals
|
|
|
|
These are the intended behaviours for the full system. Some are implemented; some are not yet (see status notes per section).
|
|
|
|
### World-space textboxes
|
|
|
|
Every textbox is anchored to a 3D point in the world and projected into UI space each frame. There is no bottom-bar HUD textbox — all speech appears spatially near the speaker.
|
|
|
|
- Textbox shows a **speaker name** (small static label) and **body text** (character-by-character reveal).
|
|
- Max width/height is capped; text wraps automatically. Overflow becomes additional **pages** advanced by player input.
|
|
- If the anchor point goes off-screen, the textbox **clamps to the nearest screen edge** — a textbox is always visible if it is active. If a dialogue sequence should genuinely allow its textbox to go off-screen (e.g. distant background NPC chatter the player isn't meant to read), use a non-blocking sequence and don't show the textbox at all, or use a different trigger type.
|
|
- Multiple textboxes can be on screen simultaneously (e.g. two NPCs talking to each other while the player has their own dialogue open).
|
|
|
|
### Speaker → Entity mapping
|
|
|
|
The speaker name in a `.dialogue` line (e.g. `Guard: Halt!`) is matched against entities present in the current scene by a **separate `dialogueName` export** on `Entity` (not `entityId`). The matched entity provides the 3D anchor point for the textbox and the display name shown in the speaker label.
|
|
|
|
- `dialogueName` is the key used for lookup — set it in the Inspector, keep it stable within a project.
|
|
- The **display name** shown to the player is a separate property and can differ (e.g. `dialogueName = "guard_captain"` but display name = `"???"` before the character has revealed their name). This lets you write dialogue that references a speaker before the player knows who they are.
|
|
- **Every dialogue line must have a speaker.** There is no narrator/no-speaker path — if a line has no natural speaker, use a named invisible anchor Entity in the scene.
|
|
|
|
> **Implemented:** Entity lookup is live — `OVERWORLD.getEntityByDialogueName(line.character)` resolves the speaker. 3D→UI projection runs every frame in `DialogueTextbox._updateWorldPosition()`.
|
|
|
|
### All conversation routes through DialogueManager
|
|
|
|
Whether a line of dialogue is triggered by:
|
|
- A player pressing Interact on an NPC,
|
|
- A player walking into a proximity trigger,
|
|
- A cutscene sequence,
|
|
- Two NPCs conversing automatically,
|
|
|
|
…it is always written in a `.dialogue` file and played back via `DialogueManager.get_next_dialogue_line()`. There is no separate "dumb string" path for simple one-liners.
|
|
|
|
> **Done:** `CHATBOX` interact type removed. All NPC text now routes through DialogueManager.
|
|
|
|
### Dialogue and movement control
|
|
|
|
Dialogue does **not** automatically block movement or camera. Each dialogue sequence declares whether it pauses them:
|
|
|
|
- **Blocking** (most NPC conversations triggered by player): sets `UI.activeConversation = true` for the duration; `EntityMovement._canMove()` returns false and `OverworldCamera._canOrbit()` returns false.
|
|
- **Non-blocking** (ambient chatter, background NPC conversations, timed popups): dialogue runs without setting `activeConversation`; the player can move and orbit the camera freely.
|
|
|
|
This is configured per `DialogueAction` call, not per line.
|
|
|
|
> **Implemented:** `DialogueMode.CONVERSATION` sets `UI.activeConversation = true` (blocks movement and camera orbit). `NARRATION` and `AMBIENT` are non-blocking. `UI.dialogueActive` is driven by `DialogueManager.dialogue_started/ended` signals (emitted by `DialogueAction`) and is true for any running dialogue regardless of mode.
|
|
|
|
### Text reveal (scrolling)
|
|
|
|
Body text is revealed character-by-character at a speed approximating natural human speech. The reveal is not purely uniform — punctuation introduces natural pauses:
|
|
|
|
| Character | Behaviour |
|
|
|---|---|
|
|
| `,` `;` | Short pause (~0.15s) |
|
|
| `.` `!` `?` | Longer pause (~0.4s) |
|
|
| `...` | Each dot revealed individually with a pause between (~0.3s each), simulating thinking |
|
|
|
|
Holding the **Interact** bind speeds up reveal to near-instant. Once all characters on the current page are revealed, pressing Interact advances to the next page or the next dialogue line.
|
|
|
|
The plugin returns `DialogueLine.text` as a plain BBCode string — it does **not** handle punctuation pauses or `...` detection. All reveal pacing is implemented in our textbox, not the plugin.
|
|
|
|
The `[wait=N]` and `[speed=N]` BBCode tags are extracted from the text by the plugin during line resolution (stored in `DialogueLine.speeds` and inline mutation data). Our textbox must read these to adjust reveal behaviour inline — e.g. a shocked line using `[speed=4]` to flash past quickly.
|
|
|
|
**ADA note:** fast reveal speeds (via `[speed=N]` or hold-to-skip) should not flash entire blocks of text in a way that could trigger photosensitive responses. Avoid `[speed=N]` values so high that multiple lines appear simultaneously. The hold-to-skip path is safe since it requires sustained player input.
|
|
|
|
> **Implemented:** `DialogueTextbox` has character-by-character reveal, punctuation pauses, `...` detection, hold-to-skip (requires releasing Interact first — guarded by `_hasLetGoOfInteract`), and a pulsing advance indicator (▼) shown when waiting for input. Punctuation pauses are suppressed for the last visible character on a page or at end of text so there is no delay before the player can advance. `[wait=N]` / `[speed=N]` BBCode tag support is not yet wired.
|
|
|
|
### Advancement modes
|
|
|
|
Each dialogue sequence uses one of two advancement modes:
|
|
|
|
| Mode | Behaviour |
|
|
|---|---|
|
|
| `PLAYER` (default) | Player presses Interact to advance to the next page or line once reveal is complete. Used by `CONVERSATION` and `NARRATION` modes. |
|
|
| `TIMED` | Each line auto-advances after a delay estimated from line length (reading speed). A per-line `[next=N]` tag overrides the estimated delay. Player input is ignored. Used by `AMBIENT` mode. |
|
|
|
|
Mixed sequences (some lines timed, some player-advanced) are not supported — advancement mode is determined by `DialogueMode`, not per line.
|
|
|
|
> **Implemented:** `TIMED` auto-advance is wired in `DialogueTextbox._processAutoAdvance`. Delay is estimated from line length (characters / `READING_CHARS_PER_SECOND`). Per-line `[next=N]` override is not yet wired.
|
|
|
|
### Response / choice UI
|
|
|
|
When a `DialogueLine` has a non-empty `responses` array, reveal pauses and a choice textbox appears **anchored to the player entity** — choices appear as if the player is speaking. The options are a vertical list; the player selects with D-pad/arrow keys and confirms with Interact. The chosen response's `next_id` is passed to `get_next_dialogue_line()`.
|
|
|
|
The choice textbox follows the same world-space anchor and screen-edge clamping rules as all other textboxes.
|
|
|
|
> **Implemented:** `DialogueChoiceBox` is shown when allowed responses exist, anchored to the player entity via `OVERWORLD.getPlayerEntity()`. Selected item shows a `▶ ` prefix and yellow highlight. Navigation uses Godot's native focus system — labels have `focus_mode = FOCUS_ALL` with explicit `focus_neighbor_top/bottom` to prevent focus escaping the list at the edges; `focus_entered` signals update `_selectedIndex`. `_input` handles only the game-specific `interact` confirm; `ui_up`/`ui_down`/`ui_accept` are handled natively by the engine.
|
|
|
|
### Trigger types
|
|
|
|
| Trigger | How it works |
|
|
|---|---|
|
|
| Player interact | Entity `interactType = CONVERSATION`; player presses Interact nearby |
|
|
| Proximity (enter area) | Trigger volume fires when player enters; starts non-blocking dialogue |
|
|
| Cutscene sequence | `DialogueAction` added to a `Cutscene` queue |
|
|
| Scripted NPC-to-NPC | Cutscene sequence with non-blocking mode, no player involvement |
|
|
|
|
> **Proximity trigger:** defined as `PROXIMITY_CHATBOX` in `Entity.InteractType` but not yet implemented.
|
|
|
|
---
|
|
|
|
## DialogueMode enum
|
|
|
|
Every dialogue sequence has a `DialogueMode` that controls movement blocking and advancement behaviour. Set it per `DialogueAction` call — not per line.
|
|
|
|
| Mode | Movement | Camera orbit | Advancement | Typical use |
|
|
|---|---|---|---|---|
|
|
| `CONVERSATION` | Blocked | Locked | Player (Interact) | NPC interactions, cutscene dialogue |
|
|
| `NARRATION` | Non-blocking | Free | Player (Interact) | Item pickups, announcements the player can dismiss when ready |
|
|
| `AMBIENT` | Non-blocking | Free | Timed (auto) | Background NPC-to-NPC chatter, timed popups |
|
|
|
|
`UI.dialogueActive` is driven by `DialogueManager.dialogue_started` / `dialogue_ended` signals (emitted by `DialogueAction`) and is true whenever any dialogue is running, regardless of mode. Movement and camera blocking are checked separately via `UI.activeConversation`: `EntityMovement._canMove()` and `OverworldCamera._canOrbit()` both return false only when an active `CONVERSATION` sequence is in progress.
|
|
|
|
More modes can be added to this enum as new use cases arise.
|
|
|
|
---
|
|
|
|
## DialogueAction — the Cutscene bridge
|
|
|
|
[cutscene/dialogue/DialogueAction.gd](../../cutscene/dialogue/DialogueAction.gd) is the glue between the `Cutscene` queue and `DialogueManager`. It fetches lines via `get_next_dialogue_line()`, displays them in the world-space textbox, and returns `CUTSCENE_CONTINUE` when the last line is dismissed.
|
|
|
|
```gdscript
|
|
# Add a dialogue step to any Cutscene
|
|
cutscene.addCallable(DialogueAction.getDialogueCallable(
|
|
"res://dialogue/npc/guard", # base path — no locale suffix, no extension
|
|
"start", # section to begin from
|
|
[entity], # extra_game_states exposed to {{variables}} in the file
|
|
DialogueAction.DialogueMode.CONVERSATION
|
|
))
|
|
```
|
|
|
|
`DialogueAction` resolves the base path to the correct locale file at runtime (e.g. `guard.en.dialogue`), falling back to `en` if the active locale file is missing.
|
|
|
|
---
|
|
|
|
## Writing .dialogue files
|
|
|
|
```
|
|
~ title_name # entry point / jump target
|
|
|
|
SpeakerName: Line of dialogue.
|
|
|
|
~ another_section
|
|
SpeakerName: Variables resolve inline: {{some_property}}.
|
|
=> END # end this dialogue
|
|
```
|
|
|
|
**Key syntax:**
|
|
- `~ title` — section anchor; pass as the `title` argument to `DialogueAction`
|
|
- `=> title` — jump to another section; `=> END` ends the dialogue
|
|
- `{{variable}}` — resolves a property from any autoload or `extra_game_states` object
|
|
- `do AUTOLOAD.method()` — call a method on any autoload (e.g. `do PARTY.BACKPACK.addStack(stack)`)
|
|
- `set AUTOLOAD.property = value` — set a property on any autoload
|
|
- `- Option text` — response branch (player choice); indented lines run for that branch
|
|
- `[if condition]` — conditional line or response
|
|
- `[wait=N]` — pause character reveal for N seconds mid-line
|
|
- `[speed=N]` — multiply reveal speed by N for the rest of the line
|
|
|
|
**Mutations fire automatically** before the next dialogue line is returned — you do not handle them manually in GDScript.
|
|
|
|
### Speaker names
|
|
|
|
The name before the colon becomes `DialogueLine.character`. This string is matched case-insensitively against the `dialogueName` export on `Entity` nodes in the current scene, to find the 3D anchor point.
|
|
|
|
Keep `dialogueName` values stable across a project — they are the linking key between dialogue files and scene nodes. The player-visible name shown in the textbox speaker label is a separate property and can change at runtime (e.g. `"???"` before a character is introduced).
|
|
|
|
```
|
|
guard_captain: Halt! Who goes there?
|
|
```
|
|
|
|
```gdscript
|
|
# Entity Inspector exports
|
|
entityId = "guard_captain_01" # internal UUID, can be anything
|
|
dialogueName = "guard_captain" # must match the .dialogue speaker name
|
|
displayName = "???" # shown to the player in the textbox speaker label
|
|
```
|
|
|
|
---
|
|
|
|
## Passing runtime data to dialogue
|
|
|
|
Use `extra_game_states` to expose GDScript objects to `{{variable}}` tokens:
|
|
|
|
```gdscript
|
|
class ItemDialogueState:
|
|
var item_name:String
|
|
var quantity:int
|
|
|
|
DialogueAction.getDialogueCallable(resource, 'start', [ItemDialogueState.new("Potato", 1)])
|
|
```
|
|
|
|
Then in the `.dialogue` file:
|
|
```
|
|
shopkeeper: Here's your {{item_name}} x{{quantity}}.
|
|
```
|
|
|
|
---
|
|
|
|
## NPC entities
|
|
|
|
Set these exports on an Entity node whose `interactType = CONVERSATION`:
|
|
|
|
| Export | Purpose |
|
|
|---|---|
|
|
| `dialogueBasePath:String` | Base path to the dialogue file, without locale suffix or extension (e.g. `"res://dialogue/npc/guard"`) |
|
|
| `dialogueTitle:String` | Section to start from (default `"start"`) |
|
|
| `dialogueName:String` | Key matched against `.dialogue` speaker names to find this entity's textbox anchor |
|
|
| `displayName:String` | Player-visible name shown in the textbox speaker label (can differ from `dialogueName`, e.g. `"???"`) |
|
|
|
|
`dialogueName` must match the speaker name used in the dialogue file. `entityId` is unrelated to dialogue lookup.
|
|
|
|
---
|
|
|
|
## Dialogue files
|
|
|
|
| File | Used by |
|
|
|---|---|
|
|
| `dialogue/npc/test.en.dialogue` | TestMap NPC (NotPlayer / "Stranger") |
|
|
| `dialogue/item/pickup.en.dialogue` | `ItemAction` — item pickup text with `{{item_name}}` and `{{quantity}}` |
|
|
| `dialogue/battle/narration.en.dialogue` | `BattleCutsceneAction` — move announcements, victory/defeat |
|
|
|
|
---
|
|
|
|
## 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.
|