233 lines
7 KiB
Markdown
233 lines
7 KiB
Markdown
# Dialogue System Runtime Spec
|
|
|
|
This document is a code-aligned reference for how dialogue is parsed, evaluated, and executed in-game.
|
|
|
|
## Quick Visual
|
|
|
|

|
|
|
|
## 1) Data Model (Current Canonical Shape)
|
|
|
|
Dialogue nodes are authored primarily with arrays:
|
|
|
|
- `conditions[]`
|
|
- `text`
|
|
- `conditionType`
|
|
- `conditionValue` (for `item`, use `itemId:quantity`)
|
|
- `conditionStepId`
|
|
- `conditionNot`
|
|
- `nextId`
|
|
- `reactions[]`
|
|
- `reactionType`
|
|
- `reactionValue`
|
|
- `choices[]`
|
|
- `text`
|
|
- `nextId`
|
|
- `conditionType`
|
|
- `conditionValue`
|
|
- `conditionStepId`
|
|
- `conditionNot`
|
|
- `reactionType`
|
|
- `reactionValue`
|
|
|
|
Compatibility fields still exist in runtime structures (`text`, `conditionType`, `conditionValue`, `conditionStepId`, `conditionNot`, `nextId`, `reactionType`, `reactionValue`) and are used as fallback when needed.
|
|
|
|
## 2) Parsing Order (JSON -> Runtime Content)
|
|
|
|
Parsing entrypoint:
|
|
|
|
- `ContentManager::loadAll()`
|
|
- `readDialogueNodes()` for each NPC
|
|
|
|
Per dialogue node, parsing order is:
|
|
|
|
1. Read legacy/base fields first (`id`, `text`, legacy condition/reaction/next/order fields).
|
|
2. Read `conditions[]`.
|
|
- Each condition uses `condition.text`, defaulting to base `node.text` if missing.
|
|
3. If `conditions[]` is empty, synthesize exactly one condition from legacy/base fields.
|
|
4. Read `reactions[]`.
|
|
5. If `reactions[]` is empty, only synthesize a fallback reaction if legacy reaction fields were present in JSON.
|
|
- This allows intentionally empty reaction arrays.
|
|
6. Read `choices[]`.
|
|
- Supports migration path for very old choice payloads missing `conditionType`.
|
|
|
|
## 3) Runtime Graph Build Order
|
|
|
|
At interaction start (`E` near NPC):
|
|
|
|
1. `openNpcDialogue()` calls `updateQuestProgression()`.
|
|
2. NPC node defs are converted to runtime `game::DialogueNode` objects.
|
|
3. Conditions, reactions, and choices are copied into runtime node vectors.
|
|
4. Start node is selected with `selectDialogueStartNodeId()`.
|
|
5. `DialogueSession::openGraph()` is called.
|
|
6. If a start node exists, node reactions are applied immediately (`applyNodeReactions()`).
|
|
|
|
## 4) Start Node Selection Logic
|
|
|
|
`selectDialogueStartNodeId()` order:
|
|
|
|
1. If `defaultDialogueNodeId` exists and is valid, use it.
|
|
2. Else, sort nodes by `order`, then `id`.
|
|
3. Build inbound edge set from:
|
|
- node `nextId`
|
|
- choice `nextId`
|
|
4. Candidate roots are nodes with no inbound references.
|
|
- If none, all nodes become candidates.
|
|
5. Filter candidates by `dialogueNodeMatchesContext()`.
|
|
6. Pick highest `dialogueNodePriority()`.
|
|
- Priority: `quest_step_completed` > `quest_started/quest_completed` > `item/flag` > default.
|
|
7. Tie-break by smaller `order`.
|
|
8. If no contextual match, fall back to first sorted node.
|
|
|
|
## 5) Condition Evaluation Order
|
|
|
|
Core function: `doesConditionPass(type, value, stepId, conditionNot)`.
|
|
|
|
Evaluation sequence:
|
|
|
|
1. Evaluate base condition type.
|
|
2. Supported types include:
|
|
- `always`
|
|
- `flag`
|
|
- `item` (supports `itemId:quantity`; quantity defaults to `1` if omitted)
|
|
- `level`
|
|
- `currency` (supports `key:amount` format)
|
|
- `skill`
|
|
- `quest_started`, `quest_completed`
|
|
- `quest_step_completed`
|
|
3. Apply NOT inversion if `conditionNot == true`.
|
|
|
|
Node-level match order:
|
|
|
|
- `resolveMatchedNodeCondition(node)` scans `node.conditions` from index 0 upward.
|
|
- First passing condition wins.
|
|
|
|
That winning condition is then used for:
|
|
|
|
- Display text via `resolveNodeConditionalText()` (`condition.text`, else legacy `node.text`).
|
|
- Continue target via `resolveNodeConditionalNext()` (`condition.nextId`, else legacy `node.nextNodeId`).
|
|
|
|
## 6) Choice Visibility and Selection Order
|
|
|
|
Choice visibility:
|
|
|
|
- `buildVisibleChoiceIndices(node)` iterates choices in list order.
|
|
- Includes only choices where `doesChoiceMeetConditions(choice)` returns true.
|
|
|
|
When user presses numeric choice key:
|
|
|
|
1. Map key `1..9` to visible index.
|
|
2. Resolve real choice index from `visibleChoiceIndices`.
|
|
3. Apply choice reaction first (`applyDialogueReaction`).
|
|
4. Resolve target with `resolveChoiceTarget(choice)`:
|
|
- If condition passes -> `nextId`
|
|
- Else close
|
|
5. Advance to target with `advanceTo()`.
|
|
6. Apply entered-node reactions.
|
|
7. `updateQuestProgression()`.
|
|
|
|
## 7) Reactions Execution Order
|
|
|
|
`applyNodeReactions(node)`:
|
|
|
|
1. If `node.reactions` is non-empty, execute each in array order.
|
|
2. Else fallback to legacy single reaction fields.
|
|
|
|
`applyDialogueReaction(type, value)` supports:
|
|
|
|
- `grant_flag` / `grant_quest_flag`
|
|
- `grant_item` using `itemId:quantity` values such as `copper_ore:1`
|
|
- `start_quest`
|
|
- `complete_quest`
|
|
|
|
## 8) Input Execution Paths
|
|
|
|
### A) Dialogue open + no choices
|
|
|
|
- Continue key (`E` / Enter / Space):
|
|
1. Resolve condition-based next node
|
|
2. Advance or close
|
|
3. Apply entered-node reactions (if advanced)
|
|
|
|
### B) Dialogue open + choices exist
|
|
|
|
- If visible choices exist:
|
|
- Number keys choose branch
|
|
- If no visible choices:
|
|
- Continue key uses node condition-based next/close path
|
|
|
|
### C) Escape
|
|
|
|
- `Esc` closes dialogue immediately.
|
|
|
|
## 9) Rendering Order (Dialogue Box)
|
|
|
|
`renderDialogueBox()` draws:
|
|
|
|
1. Speaker bar
|
|
2. Body text from `resolveNodeConditionalText()`
|
|
3. Visible choices list (if any)
|
|
4. Footer hint:
|
|
- `[E] Continue` if condition-resolved next exists
|
|
- `[E] Close` otherwise
|
|
|
|
## 10) Flowchart
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
A[Player presses E near NPC] --> B[openNpcDialogue]
|
|
B --> C[updateQuestProgression]
|
|
C --> D[Build runtime graph from content nodes]
|
|
D --> E[selectDialogueStartNodeId]
|
|
E --> F[DialogueSession.openGraph]
|
|
F --> G{Start node exists?}
|
|
G -- No --> Z[Close]
|
|
G -- Yes --> H[applyNodeReactions on start node]
|
|
|
|
H --> I[Dialogue loop]
|
|
I --> J{Current node has choices?}
|
|
|
|
J -- No --> K{Continue key pressed?}
|
|
K -- No --> I
|
|
K -- Yes --> L[resolveNodeConditionalNext via first passing condition]
|
|
L --> M{next exists?}
|
|
M -- No --> Z
|
|
M -- Yes --> N[advanceTo next]
|
|
N --> O[applyNodeReactions on entered node]
|
|
O --> I
|
|
|
|
J -- Yes --> P[buildVisibleChoiceIndices]
|
|
P --> Q{Any visible choices?}
|
|
|
|
Q -- No --> R{Continue key pressed?}
|
|
R -- No --> I
|
|
R -- Yes --> L
|
|
|
|
Q -- Yes --> S{Number key 1..9?}
|
|
S -- No --> I
|
|
S -- Yes --> T[Map visible index -> actual choice]
|
|
T --> U[apply choice reaction]
|
|
U --> V[resolveChoiceTarget]
|
|
V --> W{target exists?}
|
|
W -- No --> Z
|
|
W -- Yes --> X[advanceTo target]
|
|
X --> Y[applyNodeReactions on entered node]
|
|
Y --> I
|
|
```
|
|
|
|
## 11) Practical Authoring Implications
|
|
|
|
- Condition order is behavior-critical. Put the most specific conditions first.
|
|
- Item conditions should be authored as `itemId:quantity` so runtime quantity checks are explicit.
|
|
- In the editor, `conditionType=item` surfaces item picker + quantity input and writes `conditionValue` in that format.
|
|
- Dialogue line text should be authored on condition entries (`conditions[].text`).
|
|
- Empty `reactions[]` is valid and now preserved.
|
|
- Choice order affects both visual order and numeric selection mapping.
|
|
|
|
## 12) Primary Source Files
|
|
|
|
- `src/content/ContentManager.cpp`
|
|
- `src/game/Game.cpp`
|
|
- `src/game/Dialogue.cpp`
|
|
- `src/content/ContentTypes.hpp`
|
|
- `src/game/Dialogue.hpp`
|