diff --git a/.claude/docs/dialogue.md b/.claude/docs/dialogue.md index dbf0303..9627150 100644 --- a/.claude/docs/dialogue.md +++ b/.claude/docs/dialogue.md @@ -2,52 +2,245 @@ All authored text — NPC conversations, item pickup messages, battle narration — is written in `.dialogue` files and played back via `DialogueManager` (godot_dialogue_manager v3.10.4). -## Plugin +## Plugin — godot_dialogue_manager v3.10.4 +- Repo: [nathanhoad/godot_dialogue_manager](https://github.com/nathanhoad/godot_dialogue_manager) - Autoload: `DialogueManager` (registered in `project.godot`) -- Plugin must be enabled in Godot → Project Settings → Plugins → Dialogue Manager +- Plugin must be enabled: Godot → Project Settings → Plugins → Dialogue Manager - `.dialogue` files are imported as `DialogueResource` once the plugin is active - Dialogue files live in `dialogue/` organised by category (`npc/`, `item/`, `battle/`) -## DialogueAction — the Cutscene bridge +### Key API -[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. +#### Fetching lines manually (our approach) ```gdscript -# Add a dialogue step to any Cutscene +# 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. + +> **Not yet implemented:** the entity-lookup and 3D→UI projection logic. Currently `DialogueAction` passes a target node manually. + +### 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. + +> **Migration needed:** the current `CHATBOX` interact type (WorldChatBox, plain string) should be replaced with a one-line `.dialogue` file going through the same pipeline. + +### Dialogue and movement control + +Dialogue does **not** automatically block movement. Each dialogue sequence declares whether it pauses movement: + +- **Blocking** (most NPC conversations triggered by player): sets `UI.dialogueActive = true` for the duration; `EntityMovement._canMove()` returns false. +- **Non-blocking** (ambient chatter, background NPC conversations, timed popups): dialogue runs without setting `dialogueActive`; the player can move freely. + +This is configured per `DialogueAction` call, not per line. + +> **Partially implemented:** `DialogueAction` always sets `UI.dialogueActive`. The non-blocking path does not exist yet. + +### 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 `[wait=N]` and `[speed=N]` BBCode tags from DialogueManager can override reveal behaviour inline for specific moments (e.g. a shocked line flashing by at `[speed=4]`). + +**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 using `[speed=N]` values so high that multiple lines appear simultaneously. The hold-to-skip path is safe since it requires sustained player input. + +> **Partially implemented:** `VNTextbox` has character-by-character reveal and hold-to-skip. Punctuation pause logic is not yet implemented. + +### 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. | +| `TIMED` | Each line auto-advances after a delay estimated from line length (reading speed). A per-line `[next=N]` override is supported for lines that need a specific timing. Player input is ignored. | + +Mixed sequences (some lines timed, some player-advanced) are not supported — the mode is set per conversation, not per line. + +> **Not yet implemented:** `TIMED` mode. Currently all dialogue requires player input. + +### 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. + +> **Not yet implemented:** `DialogueAction` currently auto-selects the first allowed response. Replace the auto-select block with a call to the response UI and await its selection signal. + +### 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. + +--- + +## 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 blocking dialogue step to any Cutscene cutscene.addCallable(DialogueAction.getDialogueCallable( load("res://dialogue/npc/test.dialogue"), - "start", # title to begin from - [entity] # extra_game_states: objects/dicts accessible in the .dialogue file + "start", # section to begin from + [entity], # extra_game_states exposed to {{variables}} in the file + true # blocking — pauses player movement )) ``` -Movement is blocked automatically while dialogue runs because `VNTextbox` is open (`EntityMovement._canMove()` checks `UI.TEXTBOX.isClosed`). +The `blockMovement` argument (fourth param, default `true`) controls whether `UI.dialogueActive` is set for the duration. + +--- ## Writing .dialogue files ``` ~ title_name # entry point / jump target -Speaker: Line of dialogue. -Another line with no speaker. +SpeakerName: Line of dialogue. ~ another_section -Speaker: Variables resolve inline: {{some_property}}. +SpeakerName: Variables resolve inline: {{some_property}}. => END # end this dialogue ``` **Key syntax:** -- `~ title` — section anchor; use as the `title` argument to `DialogueAction` +- `~ title` — section anchor; pass as the `title` argument to `DialogueAction` - `=> title` — jump to another section; `=> END` ends the dialogue - `{{variable}}` — resolves a property from any autoload or `extra_game_states` object - `do AUTOLOAD.method()` — call a method on any autoload (e.g. `do PARTY.BACKPACK.addStack(stack)`) -- `set AUTOLOAD.property = value` — set a property -- `- Option text` — response branch (indented lines handle each branch) +- `set AUTOLOAD.property = value` — set a property on any autoload +- `- Option text` — response branch (player choice); indented lines run for that branch - `[if condition]` — conditional line or response +- `[wait=N]` — pause character reveal for N seconds mid-line +- `[speed=N]` — multiply reveal speed by N for the rest of the line **Mutations fire automatically** before the next dialogue line is returned — you do not handle them manually in GDScript. +### Speaker names + +The name before the colon becomes `DialogueLine.character`. This string is matched case-insensitively against the `dialogueName` export on `Entity` nodes in the current scene, to find the 3D anchor point. + +Keep `dialogueName` values stable across a project — they are the linking key between dialogue files and scene nodes. The player-visible name shown in the textbox speaker label is a separate property and can change at runtime (e.g. `"???"` before a character is introduced). + +``` +guard_captain: Halt! Who goes there? +``` + +```gdscript +# Entity Inspector exports +entityId = "guard_captain_01" # internal UUID, can be anything +dialogueName = "guard_captain" # must match the .dialogue speaker name +displayName = "???" # shown to the player in the textbox speaker label +``` + +--- + ## Passing runtime data to dialogue Use `extra_game_states` to expose GDScript objects to `{{variable}}` tokens: @@ -62,19 +255,23 @@ DialogueAction.getDialogueCallable(resource, 'start', [ItemDialogueState.new("Po Then in the `.dialogue` file: ``` -Obtained {{item_name}} x{{quantity}}. +shopkeeper: Here's your {{item_name}} x{{quantity}}. ``` +--- + ## NPC entities -Set these two exports on an Entity node whose `interactType = CONVERSATION`: +Set these exports on an Entity node whose `interactType = CONVERSATION`: | Export | Purpose | |---|---| | `dialogueResource:DialogueResource` | The `.dialogue` file to run | -| `dialogueTitle:String` | Title (section) to start from (default `"start"`) | +| `dialogueTitle:String` | Section to start from (default `"start"`) | -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). +The entity's `entityId` must match the speaker name used in the dialogue file so the textbox anchor resolves correctly. + +--- ## Dialogue files @@ -84,6 +281,25 @@ After first enabling the plugin and reimporting, assign `dialogueResource` direc | `dialogue/item/pickup.dialogue` | `ItemAction` — item pickup text with `{{item_name}}` and `{{quantity}}` | | `dialogue/battle/narration.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 (without locale suffix) and resolves the active locale automatically, falling back to `en` if the locale file is missing. + +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. + +> **Not yet implemented:** locale resolution in `DialogueAction`. Currently loads the resource directly. + +--- + +## Open questions + +None currently. Update this section as new design questions arise.