LSDE Dialog Engine — Full Guide [English] (plain text, auto-generated) ============================================================ Concatenates all guide sections for LLM consumption. Source: lsde-ts/docs/guide/*.md ============================================================ # What is LSDEDE? **LSDE** (LS Dialog Editor) is a free tool for game and software developers that combines visual dialogue graph editing, AI-powered translation, voice generation, i18n code integration, and project diagnostics. It exports dialogue graphs as blueprints (JSON, XML, YAML, or CSV) containing scenes, blocks, dictionaries, functions and cards. More info: [lepasoft.com/en/software/ls-dialog-editor](https://lepasoft.com/en/software/ls-dialog-editor). **LSDEDE** (LSDE Dialog Engine) is the multi-runtime engine that loads and executes these blueprints. It is available in multiple languages for native integration into any game engine or framework. ## Available Runtimes | Runtime | Language | Target | Source | |---------|----------|--------|--------| | **TypeScript** | TypeScript / JavaScript | Reference implementation | [lsde-ts](https://github.com/jonlepage/LS-Dialog-Editor-Engine/tree/master/lsde-ts) | | **C#** | C# (.NET Standard 2.1) | Unity, Godot Mono, .NET | [lsde-csharp](https://github.com/jonlepage/LS-Dialog-Editor-Engine/tree/master/lsde-csharp) | | **C++** | C++17 | Unreal Engine, custom engines | [lsde-cpp](https://github.com/jonlepage/LS-Dialog-Editor-Engine/tree/master/lsde-cpp) | | **GDScript** | GDScript | Godot 4 | [lsde-gdscript](https://github.com/jonlepage/LS-Dialog-Editor-Engine/tree/master/lsde-gdscript) | All runtimes share the same blueprint format and pass a common cross-language test suite (52 test cases). ## Architecture Every runtime follows the same **callback-driven graph dispatcher** pattern: 1. **Blueprint** — An export from LSDE (JSON, XML, YAML or CSV). Every block carries its own outgoing wires in `next`: there is no connection table. 2. **Engine** — Validates the blueprint, builds the internal graph and dispatches blocks to registered handlers. 3. **Handlers** — Functions that react to each block type (dialog, choice, condition, action). 4. **Host Application** — Conditions, actions, and character resolution are implemented by handler callbacks. ``` Blueprint │ ▼ Engine ◄── next() ──┐ │ │ dispatch │ │ │ ▼ │ Handlers ────────────┘ ``` ## Design Principles - **Zero-dependency** — No runtime dependencies in any language. - **Framework-agnostic** — Works with any game engine or UI framework. - **Callback-driven** — No internal render loop. The host application calls `next()` when ready. - **Two-tier handlers** — Global (engine-level) and scene-level handlers with `preventGlobalHandler()`. - **Cross-language conformance** — All runtimes produce identical output for the same blueprint. ================================================================================ # Getting Started ## Installation ```bash [TypeScript] npm install @lsde/dialog-engine ``` ```bash [C#] dotnet add package LsdeDialogEngine # JSON loader — choose ONE based on your platform: dotnet add package LsdeDialogEngine.Newtonsoft # Unity dotnet add package LsdeDialogEngine.SystemTextJson # .NET 5+ / Godot .NET ``` ```bash [C++ (CMake)] # Add as a git submodule git submodule add https://github.com/jonlepage/LS-Dialog-Editor-Engine.git deps/lsde # CMakeLists.txt add_subdirectory(deps/lsde/lsde-cpp) target_link_libraries(your_target PRIVATE lsde) ``` ```bash [Unreal Engine] # Copy the headers and source into your project # e.g. Source/YourModule/ThirdParty/lsde/ cp -r lsde-cpp/include/lsde YourProject/Source/YourModule/ThirdParty/lsde/include cp -r lsde-cpp/src/* YourProject/Source/YourModule/ThirdParty/lsde/src # In your module's .Build.cs, add: # PublicIncludePaths.Add(Path.Combine(ModuleDirectory, "ThirdParty/lsde/include")); # Add the .cpp files from ThirdParty/lsde/src/ to your module ``` ```bash [GDScript] # Clone the repository git clone https://github.com/jonlepage/LS-Dialog-Editor-Engine.git # Copy the addon into the Godot project mkdir -p addons cp -r LS-Dialog-Editor-Engine/lsde-gdscript/addons/lsde addons/lsde rm -rf LS-Dialog-Editor-Engine ``` ## Minimal Usage The engine is a graph traversal machine — it dispatches blocks to registered handlers, which give them meaning. Without handlers, the engine has no output. > TIP: The engine consumes a `BlueprintExport` object, not a file. You can load your blueprint from JSON, XML, or YAML using any parser suited to your platform. See [Parsing & Import](./parsing) for recommendations. ```ts [TypeScript] import { DialogueEngine } from '@lsde/dialog-engine'; import blueprintJson from './blueprints.json'; const engine = new DialogueEngine(); engine.init({ data: blueprintJson }); // Unified condition resolver — evaluates game-state conditions for both // choice visibility and condition block pre-evaluation. engine.onResolveCondition((cond) => game.evaluateCondition(cond)); // 3 required handlers — bridge between the engine and your game // (onCondition is optional when onResolveCondition is installed) engine.onDialog(({ scene, block, context, next }) => { game .createDialogAuto(block, context) .catch(() => scene.cancel()) .finally(() => next()); }); engine.onChoice(({ scene, block, context, next }) => { game .createChoiceAuto(block, context) .catch(() => scene.cancel()) .finally(() => next()); }); engine.onAction(({ block, context, next }) => { game .executeActions(block.actions) .catch((err) => context.reject(err)) .finally(() => next()); }); // Start a scene anywhere in your game code function myGameScript(sceneId: string) { const scene = engine.scene(sceneId); scene.start(); } ``` ```csharp [C#] using LsdeDialogEngine; using LsdeDialogEngine.Json; // LsdeDialogEngine.SystemTextJson package // Unity: use LsdeDialogEngine.Newtonsoft instead var blueprint = LsdeJson.Parse(File.ReadAllText("blueprints.json")); var engine = new DialogueEngine(); engine.Init(new InitOptions { Data = blueprint }); // Unified condition resolver — evaluates game-state conditions for both // choice visibility and condition block pre-evaluation. engine.OnResolveCondition(cond => Game.EvaluateCondition(cond)); // 3 required handlers — bridge between the engine and your game engine.OnDialog(args => { var (scene, block, context, next) = args; Game.ShowDialog(block, context, onComplete: next); }); engine.OnChoice(args => { var (scene, block, context, next) = args; Game.ShowChoices(block, context, onSelected: next); }); engine.OnAction(args => { var (scene, block, context, next) = args; Game.ExecuteActions(block.Actions); context.Resolve(); next(); }); // Start a scene anywhere in your game code void MyGameScript(string sceneId) { var scene = engine.Scene(sceneId); scene.Start(); } ``` ```cpp [C++] #include using namespace lsde; DialogueEngine engine; engine.init({blueprint}); // Unified condition resolver — evaluates game-state conditions for both // choice visibility and condition block pre-evaluation. engine.onResolveCondition([](const ConditionTest& cond) { return game->evaluateCondition(cond); }); // 3 required handlers — bridge between the engine and your game engine.onDialog([](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn { game->showDialog(block, ctx, [next]() { next(); }); return {}; }); engine.onChoice([](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn { game->showOptions(block, ctx, [next]() { next(); }); return {}; }); engine.onAction([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn { game->executeActions(block->actions); ctx->resolve(); next(); return {}; }); // Start a scene anywhere in your game code auto scene = engine.scene(sceneId); scene->start(); ``` ```gdscript [GDScript] var engine = LsdeDialogueEngine.new() engine.init({"data": blueprint}) # Unified condition resolver — evaluates game-state conditions for both # choice visibility and condition block pre-evaluation. engine.on_resolve_condition(func(cond): return game.evaluate_condition(cond) ) # 3 required handlers — bridge between the engine and your game engine.on_dialog(func(args): await game.show_dialog(args["block"], args["context"]) args["next"].call() ) engine.on_choice(func(args): await game.show_options(args["block"], args["context"]) args["next"].call() ) engine.on_action(func(args): game.execute_actions(args["block"].actions) args["context"].resolve() args["next"].call() ) # Start a scene anywhere in your game code func my_game_script(scene_id: String): var scene = engine.scene(scene_id) scene.start() ``` ## Blueprint Validation `engine.init()` returns a [diagnostic report](/api-ref/interfaces/DiagnosticReport) with errors, warnings, and stats. The `check` option cross-validates against the host application's capabilities: ```ts [TypeScript] engine.init({ data, check: { functions: ['set_flag', 'play_sound'], // function ids your game implements dictionaries: { items: ['sword', 'shield'] }, // dictionary ids and their entry keys cards: ['Alice', 'Bob'], // card NAMES, never the editor id }, }); ``` ```csharp [C#] engine.Init(new InitOptions { Data = blueprint, Check = new CheckOptions { Functions = new() { "set_flag", "play_sound" }, Dictionaries = new() { ["items"] = new() { "sword", "shield" } }, Cards = new() { "Alice", "Bob" }, }, }); ``` ```cpp [C++] lsde::CheckOptions check; check.functions = {"set_flag", "play_sound"}; check.dictionaries = {{"items", {"sword", "shield"}}}; check.cards = {"Alice", "Bob"}; engine.init({blueprint, check}); ``` ```gdscript [GDScript] engine.init({ "data": blueprint, "check": { "functions": ["set_flag", "play_sound"], "dictionaries": {"items": ["sword", "shield"]}, "cards": ["Alice", "Bob"], }, }) ``` ### The twenty-five diagnostics **Errors — the payload is refused and nothing plays.** `errors` is non-empty and `engine.scene()` has nothing to hand you. | Code | What happened | |---|---| | `MISSING_DATA` | No `data` was passed to `init()` | | `MISMATCHED_EXPORTS` | Several files merged that come from different exports — `project` or `exportedAt` disagree. Pass the files of ONE export | | `WRONG_NAMING_CONVENTION` | Exported in `snake_case` or `PascalCase`; the engine reads camelCase. Project settings › Exporters › Naming convention | | `INVALID_FORMAT` | `format` is not `lsde-blueprints`. It is also what C# and C++ report for the case above: they validate a typed object, so the original key names are already gone | | `UNSUPPORTED_FORMAT_VERSION` | `version` is not `1`. A project still on LSDE 1.6 belongs on engine 0.3.x — there is no dual reader | | `NO_SCENES` | The payload carries no scene | | `DUPLICATE_SCENE` | Two scenes share a path or a stable id | | `MISSING_SCENE_PATH` | A scene has no path | | `DUPLICATE_BLOCK_ID` | Two blocks of the SAME scene share an id. Across scenes it is legal and expected — a block is (scene, id) | | `INVALID_START_BLOCK` | The scene names a start block that is not one of its blocks | | `BROKEN_LINK` | A wire points at a block that is not in the scene. The traversal would simply have nowhere to go | **Warnings — it plays, and something will quietly not work.** Read them; none of them is noise. | Code | What it costs you | |---|---| | `NO_START_BLOCK` | The scene has no start block, so `start()` has nowhere to begin | | `UNKNOWN_WAIT_BLOCK` | A `waitForBlocks` id is not a block of the scene, so that track parks **for good**. The check cannot go further: an id that does exist may still never be played | | `EMPTY_FUNCTION` | An action has a call with no function picked. The game is handed a call it cannot run | | `UNDECLARED_FUNCTION` | An action calls a function the export does not declare — a v1 id left behind in the project, say. It used to load silently and fail in game | | `UNDECLARED_ARGUMENT` | A call passes an argument the function does not declare | | `UNDECLARED_DICTIONARY_KEY` | A `dictionaryKey` argument is not an entry of the dictionary its parameter names | | `UNDECLARED_DICTIONARY` | A condition tests a dictionary the export does not declare | | `UNDECLARED_ENTRY` | A condition tests an entry its dictionary does not declare | | `UNKNOWN_CHOICE_BLOCK` | A test on the reserved `choice` dictionary names a block that is not a CHOICE of this scene | | `UNKNOWN_CHOICE_OPTION` | A test on the reserved `choice` dictionary names an option that CHOICE does not have | | `UNKNOWN_FUNCTION` | An action calls a function id your `check.functions` does not list | | `UNKNOWN_DICTIONARY` | A condition tests a dictionary id your `check.dictionaries` does not list | | `UNKNOWN_DICTIONARY_ENTRY` | The dictionary is known, the entry key is not | | `UNKNOWN_CARD` | A block cites an actor card NAME your `check.cards` does not list | The last four only appear when you pass `check` — without it the engine has nothing to compare against. The eight above them are always on: they compare what the blocks USE with what the export itself DECLARES, so no `check` is needed. A diagnostic found in a scene carries `sceneId` — the stable id (`sc_…`), what `handle.getSceneId()` answers — and `scenePath`, plus `blockId` when it points at a block. ================================================================================ # Blueprints & Scenes ## Blueprint Structure A `BlueprintExport` is the JSON file exported from the [LSDE](https://lepasoft.com/en/software/ls-dialog-editor "Lepasoft Dialog Editor") editor. It contains all the data the engine needs. ```ts [TypeScript] interface BlueprintExport { format: "lsde-blueprints"; // anything else is refused outright version: 1; // the FORMAT version, not the editor's generator: Generator; // which software wrote the file exportedAt: string; // ISO 8601 project: string; locales: string[]; referenceLocale: string; // the locale written first dictionaries: DictionaryDefinition[]; functions: FunctionDefinition[]; cards: Card[]; // what a block's `actors` and `emotion` cite scenes: BlueprintScene[]; } ``` ```csharp [C#] public class BlueprintExport { public string Format { get; set; } // "lsde-blueprints" public int Version { get; set; } // 1 public Generator? Generator { get; set; } public string ExportedAt { get; set; } public string Project { get; set; } public List Locales { get; set; } public string ReferenceLocale { get; set; } public List Dictionaries { get; set; } public List Functions { get; set; } public List Cards { get; set; } public List Scenes { get; set; } } ``` ```cpp [C++] struct BlueprintExport { std::string format; // "lsde-blueprints" int version = 0; // 1 Generator generator; std::string exportedAt; std::string project; std::vector locales; std::string referenceLocale; std::vector dictionaries; std::vector functions; std::vector cards; std::vector scenes; }; ``` ```gdscript [GDScript] # Dictionary with keys: # "format": String — always "lsde-blueprints" # "version": int — the FORMAT version, 1 # "generator": Dictionary # "exportedAt": String — ISO 8601 # "project": String # "locales": Array[String] # "referenceLocale": String # "dictionaries": Array[Dictionary] # "functions": Array[Dictionary] # "cards": Array[Dictionary] # "scenes": Array[Dictionary] ``` ## Scenes A scene is a self-contained dialogue sequence — a conversation, a cutscene, a tutorial prompt, a shop interaction. In a game, scenes are typically triggered by script events: the player talks to an NPC, enters a zone, or picks up an item. Each scene has its own entry block, its own flow, and its own state. Multiple scenes can run in parallel (e.g. a main dialogue and a tutorial overlay). Scenes are defined by the [`BlueprintScene`](/api-ref/type-aliases/BlueprintScene) interface: ```ts [TypeScript] interface Scene { scene: string; // the path — reactor_breach. Builds the i18n keys; changes on a rename. id: string; // the stable id — sc_u0vqg2g8. Store THIS outside the payload. label?: string; start?: string; // the id of the entry block. The scene names it; blocks carry no flag. blocks: Block[]; // wires live on each block, in block.next. There is no connection table. } ``` ```csharp [C#] public class BlueprintScene { public string Scene { get; set; } // the path — reactor_breach public string Id { get; set; } // the stable id — sc_u0vqg2g8 public string? Label { get; set; } public string? Start { get; set; } // the id of the entry block public List Blocks { get; set; } } ``` ```cpp [C++] struct BlueprintScene { std::string scene; // the path — reactor_breach std::string id; // the stable id — sc_u0vqg2g8 std::optional label; std::optional start; // the id of the entry block std::vector blocks; }; ``` ```gdscript [GDScript] # Dictionary with keys: # "scene": String — the path, reactor_breach # "id": String — the stable id, sc_u0vqg2g8 # "label": String (optional) # "start": String (optional) — the id of the entry block # "blocks": Array[Dictionary] — wires live on each block, in block["next"] ``` ## Connections Connections are the wires between blocks — they define which block leads to which. **There is no connection table in the export**: every block carries its own outgoing wires in `block.next`, and a wire only says which port it leaves by and where it goes. A wire has **never** crossed a scene, in any version of the format: `to` always names a block of the same scene. [`BlueprintConnection`](/api-ref/type-aliases/BlueprintConnection) is the **flattened** view of those wires, the one `engine.getSceneConnections(sceneRef)` returns — a wire with the block it leaves put back on it: ```ts [TypeScript] // A Link, plus the block it leaves. `Link` is what the payload carries, on block.next. type BlueprintConnection = Link & { from: string }; interface Link { port: string; // the exit port NAME: out, then, catch, default, C1, K1, or a card id to: string; // the id of the target block, IN THE SAME SCENE toPort: string; // always "in" } ``` ```csharp [C#] public class BlueprintConnection { public string From { get; set; } // the id of the block this wire leaves public string Port { get; set; } // the exit port NAME public string To { get; set; } // the id of the target block, in the same scene public string ToPort { get; set; } // always "in" } ``` ```cpp [C++] struct BlueprintConnection { std::string from; // the id of the block this wire leaves std::string port; // the exit port NAME std::string to; // the id of the target block, in the same scene std::string toPort = "in"; }; ``` ```gdscript [GDScript] # Dictionary with keys: # "from": String — the id of the block this wire leaves # "port": String — the exit port NAME: out, then, catch, default, C1, K1, or a card id # "to": String — the id of the target block, in the same scene # "toPort": String — always "in" ``` You won't typically need to inspect them — the engine handles routing internally. `engine.getSceneConnections(sceneRef)` exposes them for **graph inspection**: a debug view that shows the wiring without playing the scene. ## Dictionaries Dictionaries describe the registers of your game — switches, variables, inventory. The developer declares them in the [LSDE](https://lepasoft.com/en/software/ls-dialog-editor "Lepasoft Dialog Editor") editor to expose available game variables to the narrative designer. At runtime, the developer maps each dictionary to the corresponding system in their game. [`Conditions`](/api-ref/interfaces/ConditionTest) and [`onResolveCondition`](/api-ref/classes/DialogueEngine#onresolvecondition) use these keys to evaluate game state. Defined by the [`DictionaryDefinition`](/api-ref/interfaces/DictionaryDefinition) interface: ```ts [TypeScript] interface DictionaryDefinition { id: string; // what a ConditionTest.dict cites valueType: 'boolean' | 'number' | 'string'; // what its entries hold entries: string[]; // the entry names, flat } ``` ```csharp [C#] public class DictionaryDefinition { public string Id { get; set; } public string ValueType { get; set; } // "boolean" | "number" | "string" public List Entries { get; set; } } ``` ```cpp [C++] struct DictionaryDefinition { std::string id; std::string valueType; // "boolean" | "number" | "string" std::vector entries; }; ``` ```gdscript [GDScript] # Dictionary with keys: # "id": String # "valueType": "boolean" | "number" | "string" # "entries": Array[String] ``` ## Functions Functions describe what your game knows how to do — `set_flag`, `play_sound`, `give_item`. The developer declares them in the [LSDE](https://lepasoft.com/en/software/ls-dialog-editor "Lepasoft Dialog Editor") editor so that narrative designers can compose sequences with typed parameters. At runtime, the function `id` is what the developer maps to their own systems: an ACTION block cites it in `call.fn`, and its arguments arrive **by name** in `call.args`. Defined by the [`FunctionDefinition`](/api-ref/interfaces/FunctionDefinition) interface: ```ts [TypeScript] interface FunctionDefinition { id: string; // what an ActionCall.fn names params: FunctionParameter[]; } interface FunctionParameter { name: string; // ActionCall.args is keyed BY NAME, never by position type: ValueType; // 'boolean' | 'number' | 'string' | 'dictionaryKey' dictionary?: string; // for type 'dictionaryKey': the DictionaryDefinition.id it draws from } ``` ```csharp [C#] public class FunctionDefinition { public string Id { get; set; } public List Params { get; set; } } public class FunctionParameter { public string Name { get; set; } public string Type { get; set; } public string? Dictionary { get; set; } } ``` ```cpp [C++] struct FunctionDefinition { std::string id; std::vector params; }; struct FunctionParameter { std::string name; std::string type; std::optional dictionary; }; ``` ```gdscript [GDScript] # FunctionDefinition Dictionary: # "id": String # "params": Array[FunctionParameter] # # FunctionParameter Dictionary: # "name": String — args are keyed BY NAME, never by position # "type": "boolean" | "number" | "string" | "dictionaryKey" # "dictionary": String (optional) — for type "dictionaryKey", the DictionaryDefinition id ``` ================================================================================ # Block Types Blocks are the building blocks of a dialogue scene — each node in the editor graph is a block. The engine routes the flow from block to block and calls the matching handler for each type. There are 6 types: **Dialog**, **Choice**, **Condition**, **Router**, **Action**, and **Note**. Dialog, Choice, Condition and Action are content blocks with a dedicated handler (`onDialog`, `onChoice`, `onCondition`, `onAction`) — all four are **required** and validated when `start()` is called. A Router has no handler: the engine dispatches it on its own. Note blocks are skipped automatically. Handlers come in two tiers: **global handlers** (registered on the engine) cover all scenes and are sufficient for most games. **Scene handlers** (registered on a [`SceneHandle`](/api-ref/interfaces/SceneHandle)) can supplement or override globals for a specific scene. See [Handlers](/guide/handlers) for details. ## DIALOG A dialog block represents a line of speech — a character talking, a narrator, on-screen text. The engine resolves the speaking character via the `onResolveCharacter` callback and exposes it as `context.character`. A typical dialog handler creates a text instance in the game (textbox, bubble, subtitle…), waits for the player or an animation to finish, then calls `next()` to advance the engine. The optional cleanup function lets you clean up side effects when the engine moves to the next block. ```ts [TypeScript] engine.onDialog(({ block, context, next }) => { const { text, props } = block; const { character, resolveCharacterPort } = context; const line = game.getLocalizedText(text); const emotion = game.getCharacterEmotion(character); character && resolveCharacterPort(character.id); game.moveCameraToCharacter(character); game.animateCharacter(character, emotion); const dialog = game.createDialog(line, character, emotion); // next() tells the engine this block is done. WHEN to call it is answered by three // natives, and `timeout` comes FIRST because it outranks the other two: all three // say when the block is left, and the card is the most specific answer. if (props?.timeout) { // MILLISECONDS, and the countdown starts when the line has been SAID — `timeout` // is how long it STAYS on screen afterwards. Arming it on arrival truncates any // line slower to reveal than the timeout allows. dialog.onRevealComplete(() => game.wait(props.timeout).then(() => next())); // A click may HURRY that reveal; it may not dismiss a line that plays its own time. dialog.onInput(() => dialog.skipReveal(), { once: true }); } else if (game.shouldWaitPlayerInputForDialog(props)) { dialog.onInput(() => next(), { once: true }); } else { dialog.onRevealComplete(() => next()); } // cleanup: runs when the engine moves to the next block return () => { dialog.destroy(); game.animateCharacter(character, false); }; }); ``` ```csharp [C#] engine.OnDialog(args => { var (scene, block, context, next) = args; var (text, ch, emotion) = ( Game.GetLocalizedText(block.Text), context.Character, Game.GetCharacterEmotion(context.Character) ); if (ch != null) context.ResolveCharacterPort(ch.Id); Game.MoveCameraToCharacter(ch); Game.AnimateCharacter(ch, emotion); var dialog = Game.CreateDialog(text, ch, emotion); var natives = block.NativeProperties; // next() tells the engine this block is done. Timeout comes FIRST: it outranks // WaitInput and it outranks leaving at once. All three say when the block is left, // and the one the writer put on the card is the most specific answer. if (natives?.Timeout > 0) { // MILLISECONDS, and the countdown starts when the line has been SAID — Timeout // is how long it STAYS on screen afterwards. Arming it on arrival truncates any // line slower to reveal than the timeout allows. dialog.OnRevealComplete(() => Game.Wait(natives.Timeout.Value).Then(() => next())); // A click may HURRY that reveal; it may not dismiss a line playing its own time. dialog.OnInput(() => dialog.SkipReveal(), once: true); } else if (Game.ShouldWaitPlayerInputForDialog(natives)) { dialog.OnInput(() => next(), once: true); } else { dialog.OnRevealComplete(() => next()); } // cleanup: runs when the engine moves to the next block return () => { dialog.Destroy(); Game.AnimateCharacter(ch, false); }; }); ``` ```cpp [C++] engine.onDialog([&game](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn { auto* ch = ctx->character(); auto text = game.getLocalizedText(block->text); auto emotion = game.getCharacterEmotion(ch); if (ch) ctx->resolveCharacterPort(ch->id); game.moveCameraToCharacter(ch); game.animateCharacter(ch, emotion); auto* dialog = game.createDialog(text, ch, emotion); auto natives = lsde::getNativeProperties(*block); // next() tells the engine this block is done. timeout comes FIRST: it outranks // waitInput and it outranks leaving at once. All three say when the block is left, // and the one the writer put on the card is the most specific answer. if (natives.timeout.value_or(0) > 0) { // MILLISECONDS, and the countdown starts when the line has been SAID - timeout // is how long it STAYS on screen afterwards. Arming it on arrival truncates any // line slower to reveal than the timeout allows. const auto stayMs = *natives.timeout; dialog->onRevealComplete([&game, next, stayMs]() { game.wait(stayMs).then([next]() { next(); }); }); // A click may HURRY that reveal; it may not dismiss a line playing its own time. dialog->onInput([dialog]() { dialog->skipReveal(); }); } else if (game.shouldWaitPlayerInputForDialog(block->props)) { dialog->onInput([next]() { next(); }); } else { dialog->onRevealComplete([next]() { next(); }); } // cleanup: runs when the engine moves to the next block return [dialog, &game, ch]() { dialog->destroy(); game.animateCharacter(ch, false); }; }); ``` ```gdscript [GDScript] engine.on_dialog(func(args): var block = args["block"] var ctx = args["context"] var next_fn = args["next"] var ch = ctx.character var text = game.get_localized_text(block.get("text")) var emotion = game.get_character_emotion(ch) if ch: ctx.resolve_character_port(ch.get("id", "")) game.move_camera_to_character(ch) game.animate_character(ch, emotion) var dialog = game.create_dialog(text, ch, emotion) var natives = block.get("props", {}) var stay_ms = natives.get("timeout", 0) # next_fn.call() tells the engine this block is done. timeout comes FIRST: it # outranks waitInput and it outranks leaving at once. All three say when the block # is left, and the one written on the card is the most specific answer. if stay_ms > 0: # A click may HURRY the reveal; it may not dismiss a line playing its own time. dialog.on_input(func(): dialog.skip_reveal(), true) # MILLISECONDS, counted from the moment the line has been SAID — stay_ms is how # long it REMAINS on screen afterwards, never how long it has to be said in. await dialog.reveal_finished await game.wait(stay_ms) next_fn.call() elif game.should_wait_player_input(natives): dialog.on_input(func(): next_fn.call(), true) else: await dialog.reveal_finished next_fn.call() # cleanup: runs when the engine moves to the next block return func(): dialog.destroy() game.animate_character(ch, false) ) ``` When the narrative designer assigns a dedicated output per character ([`portPerCharacter`](/api-ref/interfaces/NativeProperties#portpercharacter)), the handler must call `resolveCharacterPort()` to tell the engine which path to follow on `next()`. ## CHOICE A choice block represents a branching point where the player picks a response — a dialogue menu, a list of options. `context.options` contains all available options. When [`onResolveCondition()`](/guide/choice-visibility) is configured, each option is tagged `visible: true | false` — the handler filters and displays whichever it wants. After the player interacts, `selectChoice(optionId)` tells the engine which path to follow — **the option id IS the port** the flow leaves by (`C1`, `C2`…) — then `next()` advances the flow. ```ts [TypeScript] engine.onChoice(({ block, context, next }) => { const natives = LsdeUtils.getNativeProperties(block); const { options, selectChoice } = context; // EVERY option is handed over, tagged. Filtering is your call — keeping the hidden ones lets // you grey them out or show "[locked]" instead of making them vanish. const offered = options.filter((o) => o.visible !== false); const dialog = game.createChoice(offered); // The option id IS its exit port (C1, C2…), so hand it straight back. dialog .then((selected) => selectChoice(selected)) .finally(() => next()); // Optional: if the writer set a timeout on this block. MILLISECONDS in v2, and a // CHOICE has no line to reveal, so the countdown starts once the options are readable. // // It must still SELECT one. An option id IS the exit port, so a choice left without // `selectChoice()` resolves to NO link at all and the branch dies silently — the // engine has nothing to follow. Picking the first offered option is the usual answer; // a `when` on it lets the writer decide which one that is. if (natives.timeout) { const timeout = game.wait(natives.timeout).then(() => { if (offered.length > 0) selectChoice(offered[0].id); next(); }); dialog.finally(() => timeout.cancel()); } return () => dialog.destroy(); }); ``` ```csharp [C#] engine.OnChoice(args => { var natives = LsdeUtils.GetNativeProperties(args.Block); var offered = args.Context.Options .Where(o => o.Visible != false).ToList(); var dialog = Game.CreateChoice(offered); // The option id IS its exit port (C1, C2…), so hand it straight back. dialog.OnSelect(selected => { args.Context.SelectChoice(selected); args.Next(); }); // Optional: MILLISECONDS in v2, counted from the moment the options are readable. // It must still SELECT one: an option id IS the exit port, so a choice left without // SelectChoice() resolves to NO link and the branch dies silently. if (natives.Timeout is { } timeout) Game.Wait(timeout).Then(() => { if (offered.Count > 0) args.Context.SelectChoice(offered[0].Id); args.Next(); }); return () => dialog.Destroy(); }); ``` ```cpp [C++] engine.onChoice([&game](auto*, auto* block, auto* ctx, auto next) -> CleanupFn { std::vector offered; for (const auto& option : ctx->options()) if (option.visible.value_or(true)) offered.push_back(&option); auto* dialog = game.createChoice(offered); // The option id IS its exit port (C1, C2…), so hand it straight back. dialog->onSelect([ctx, next](const std::string& selected) { ctx->selectChoice(selected); next(); }); // Optional: MILLISECONDS in v2, counted from the moment the options are readable. // It must still SELECT one: an option id IS the exit port, so a choice left without // selectChoice() resolves to NO link and the branch dies silently. auto natives = lsde::getNativeProperties(*block); if (natives.timeout) { game.wait(*natives.timeout).then([ctx, next, offered]() { if (!offered.empty()) ctx->selectChoice(offered.front()->id); next(); }); } return [dialog]() { dialog->destroy(); }; }); ``` ```gdscript [GDScript] engine.on_choice(func(args): var ctx = args["context"] var natives = LsdeUtils.get_native_properties(args["block"]) var offered = [] for option in ctx.options: if option.get("visible") != false: offered.append(option) var dialog = game.create_choice(offered) # The option id IS its exit port (C1, C2…), so hand it straight back. var selected = await dialog.choice_selected ctx.select_choice(selected) args["next"].call() # Optional: natives.get("timeout") is in MILLISECONDS. Use a Timer node, started # once the options are readable. It must still SELECT one: an option id IS the exit # port, so a choice left without select_choice() resolves to NO link and the branch # dies silently. return func(): dialog.destroy() ) ``` > TIP: With `engine.onResolveCondition()` installed, each option carries `visible: true | false`. Without one it is **undefined** — *unknown*, not *hidden*. That distinction matters: saying "hidden" about a question nobody could answer would take an answer away from the player. Filter on `visible !== false`, never on `visible === true`. See [Choice Visibility](/guide/choice-visibility) for the full opt-in tagging system. ## CONDITION A condition block is an invisible switch — it reads game state and sends the flow down a path without the player seeing it. **The engine never compares anything itself.** It reads no dictionary, does not know what `credits` holds, does not implement `greaterOrEqual`. It hands every test to [`onResolveCondition()`](/guide/choice-visibility) and assembles the answers. Each test reaches the resolver **exactly once**, whatever the mode. With a resolver installed the engine already knows the exit port before it calls the handler, which is what makes `onCondition` optional: it becomes a place to log or to override. The handler is handed `context.cases`, each case carrying its `port` and its already-computed `result`. To override, `context.resolve(port)` takes a **port NAME** — `"out"`, `"default"`, or a case port (`"K1"`). There are **two modes, and only two**: - **`portPerCase` absent** — every case must hold. If they all do the flow leaves by `out`; otherwise by `default`. - **`portPerCase: true`** — the **first** case that holds leaves by **its own port** (`K1`, `K2`…). If none holds, `default`. A case with no `when` is always true, and makes every case below it unreachable in `portPerCase` mode. That is the writer's drawing, not an error to report. A block with no cases at all leaves by `out`: nothing was asked, so nothing failed. `default` means "no case held" — **not** "the chosen exit has no wire". A port with no wire ends the flow, which is a legitimate ending. A test whose dictionary is the reserved word **`choice`** reads an answer the player already gave: `{ dict: "choice", entry: "CHOICE-001", value: "C1" }`. The engine answers it **itself**, from the scene's history — the question never reaches the game. See also `scene.getChoice(blockId)` and `scene.evaluateCondition(test)`. ```ts [TypeScript] // Optional once onResolveCondition is installed: the engine has already evaluated every case and // already knows which port the flow leaves by. This handler is where you log it, or override it. engine.onCondition(({ block, context, next }) => { // Each case carries its own exit port and its pre-evaluated result. const matched = context.cases.filter((c) => c.result).map((c) => c.port); console.log(`${block.id} matched:`, matched.join(', ') || 'none → default'); // Override the routing with a PORT NAME: 'out', 'default', or a case port like 'K2'. // context.resolve('default'); next(); }); ``` ```csharp [C#] engine.OnCondition(args => { var matched = args.Context.Cases .Where(c => c.Result == true) .Select(c => c.Port) .ToList(); Console.WriteLine($"{args.Block.Id} matched: {string.Join(", ", matched)}"); // args.Context.Resolve(Ports.Default); // override with a PORT NAME args.Next(); return null; }); ``` ```cpp [C++] engine.onCondition([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn { for (const auto& c : ctx->cases()) { std::cout << c.port << "=" << c.result.value_or(false) << " "; } // ctx->resolve(Ports::Default); // override with a PORT NAME next(); return {}; }); ``` ```gdscript [GDScript] engine.on_condition(func(args): var ctx = args["context"] var matched = [] for c in ctx.cases: if c["result"]: matched.append(c["port"]) print("%s matched: %s" % [args["block"]["id"], ", ".join(matched)]) # ctx.resolve(LsdeTypes.PORT_DEFAULT) # override with a PORT NAME args["next"].call() return Callable() ) ``` > TIP: `portPerCase` absent — every case must hold: the flow leaves by `out` if they all do, `default` otherwise. `portPerCase: true` — the **first** case that holds takes its own port (`K1`, `K2`…), `default` when none does. A case with no `when` is always true, and makes every case below it unreachable. The **dispatcher mode is gone**. It fired every matching case at once, in parallel, which meant a writer read three wires leaving a condition as a choice when it was three simultaneous launches. Use `isAsync` on the target blocks instead — where a reader of the graph can see it. ## ROUTER A router carries the **same `cases`** as a condition and reads them the opposite way. A condition asks *which one* holds and leaves by a single port; a router asks *which ones*: it evaluates **every** case, launches the port of each true one, and then always continues — by `then` when all of them held, by `catch` when any did not. A router with no case at all leaves by `then`, the way `Promise.all([])` resolves. Its `K*` routes are walked like any other port: an `isAsync` target opens its own track, the others are walked in turn, and the continuation comes **last**. `catch` cancels nothing — the tracks of the true cases are already running. There is **no `onRouter` handler** and `start()` requires none: the engine dispatches a router on its own. To observe one, use `handle.onBlock(id)`. ```ts [TypeScript] import type { RouterContext } from '@lsde/dialog-engine'; // A ROUTER has no handler of its own: the engine evaluates EVERY case, launches the port of each // true one and picks `then` or `catch` before a handler could speak. To WATCH one, target it by id. handle.onBlock('ROUTER-001', ({ block, context, next }) => { const { cases } = context as RouterContext; const launched = cases.filter((c) => c.result).map((c) => c.port); console.log(`${block.id} launched:`, launched.join(', ') || 'nothing'); // Nothing to resolve: the exits are a tally, not a choice. next(); }); ``` ```csharp [C#] handle.OnBlock("ROUTER-001", args => { var cases = ((IRouterContext)args.Context).Cases; var launched = cases.Where(c => c.Result == true).Select(c => c.Port).ToList(); Console.WriteLine($"{args.Block.Id} launched: {string.Join(", ", launched)}"); // Nothing to resolve: the exits are a tally, not a choice. args.Next(); return null; }); ``` ```cpp [C++] handle->onBlock("ROUTER-001", [](auto*, auto* block, IBaseBlockContext* ctx, auto next) -> CleanupFn { auto* router = dynamic_cast(ctx); for (const auto& c : router->cases()) { if (c.result.value_or(false)) std::cout << block->id << " launched " << c.port << "\n"; } // Nothing to resolve: the exits are a tally, not a choice. next(); return {}; }); ``` ```gdscript [GDScript] handle.on_block("ROUTER-001", func(args): var ctx = args["context"] # an LsdeBlockContext.RouterContext var launched = [] for c in ctx.cases: if c["result"]: launched.append(c["port"]) print("%s launched: %s" % [args["block"]["id"], ", ".join(launched)]) # Nothing to resolve: the exits are a tally, not a choice. args["next"].call() return Callable() ) ``` > TIP: A condition asks *which one* and leaves by a single port. A router asks *which ones*: every case is evaluated — a false one in the middle hides nothing after it — each true case launches its port, and the flow then continues by `then` when they **all** held, by `catch` when **any** did not. No case at all is `then`, the way `Promise.all([])` resolves. The `K*` routes are walked like any other port: an `isAsync` target opens its own track, the others are this track's, in turn, and the continuation comes **last** — which is what keeps `then`/`catch` as the main flow when the routes are async. `catch` cancels nothing: the tracks of the true cases are already running. See [The Router Block](/guide/router) for the full contract and diagrams, and [Distributing characters](/guide/character-distribution) for `inPortPerCharacter` — the entry port a router's routes typically name. ## ACTION An action block fires side effects in the game — give an item, play a sound, set a flag. `context.calls` carries the calls: each cites the `fn` of a declared [function](/guide/blueprints#functions), and its `args` arrive **by name**, never by position. The handler executes them then calls `context.resolve()` to follow the `then` port, or `context.reject()` to follow the `catch` port — and when the designer wired no `catch`, the flow carries on through `then` rather than stranding the player. ```ts [TypeScript] engine.onAction(({ block, context, next }) => { const { actions } = block; game .executeActionsList(actions) .then(() => context.resolve()) .catch((err) => context.reject(err)) .finally(() => next()); }); ``` ```csharp [C#] engine.OnAction(args => { var (scene, block, context, next) = args; Game.ExecuteActionsList(block.Actions, onComplete: () => { context.Resolve(); next(); }, onError: err => { context.Reject(err); next(); }); return null; }); ``` ```cpp [C++] engine.onAction([&game](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn { auto* ab = dynamic_cast(block); game.executeActionsList(ab->actions, [ctx, next]() { ctx->resolve(); next(); }, [ctx, next](const auto& err) { ctx->reject(err); next(); }); return {}; }); ``` ```gdscript [GDScript] engine.on_action(func(args): var block = args["block"] var ctx = args["context"] var next_fn = args["next"] await game.execute_actions_list(block.get("actions", [])) ctx.resolve() next_fn.call() ) ``` ## NOTE A note block is a sticky note for the narrative designer — comments, reminders, context. It is automatically skipped during traversal. While it is technically possible to intercept a note block via [`onBeforeBlock`](/guide/lifecycle), this is not recommended — the action block should cover all your side-effect needs. ## Common Properties All blocks share these base fields ([`BlueprintBlockBase`](/api-ref/type-aliases/BlueprintBlock)): | Field | Type | Description | |-------|------|-------------| | `id` | `string` | Identity **relative to its scene** — `DIALOG-002`. Ids repeat across scenes. | | `key` | `string` | The full i18n key, as the localization files carry it | | `type` | `BlockType` | `dialog`, `choice`, `condition`, `router`, `action` or `note` | | `label` | `string?` | Readable name, when the writer set one | | `parentLabels` | `string[]?` | Parent folder hierarchy from the editor | | `note` | `string?` | The writer's own note | | `actors` | `string[]?` | The **card ids** the block cites, in file order | | `emotion` | `string?` | The emotion's card id — it belongs to the **block**, not to each actor | | `intensity` | `number?` | How strongly, for that emotion | | `text` | `TextByLocale?` | The text per locale, when the export is inline | | `props` | `PropertyBag?` | **One bag**: the natives and the writer's own properties, by bare id | | `options` | `Option[]?` | CHOICE only | | `cases` | `ConditionCase[]?` | CONDITION and ROUTER — the same data, read in opposite ways | | `calls` | `ActionCall[]?` | ACTION only | | `next` | `Link[]?` | **The block's outgoing wires.** There is no connection table in v2 | The entry block is not flagged on the block: the **scene** names it, in `scene.start`. A scene therefore cannot declare two of them. ### NativeProperties The ten properties the **engine** reads, taken out of `props`. Ids cannot collide — LSDE refuses a project property that takes a native name — so telling them apart is a plain lookup. | Field | Type | Description | |-------|------|-------------| | `isAsync` | `boolean?` | **Opens a parallel track** on this block instead of continuing the current one | | `waitForBlocks` | `string[]?` | Block ids **of this scene**. The block is **held before it is dispatched** until every one of them has **finished** — no handler is called | | `delay` | `number?` | **MILLISECONDS** before the block plays. Applied by `onBeforeBlock`, never by the engine | | `timeout` | `number?` | **MILLISECONDS** the block STAYS after its line has been said, then it leaves on its own — an auto-advance for blocks. **Outranks `waitInput`**. Passed through; the engine enforces nothing | | `waitInput` | `boolean?` | Wait for player input. Passed through, never interpreted — **outranked by `timeout`** | | `debug` | `boolean?` | Debug flag for the editor. Passed through | | `portPerCharacter` | `boolean?` | The block leaves by a port **named by the actor's card id**, instead of `out` | | `inPortPerCharacter` | `boolean?` | The wire **names the actor**: a link's `toPort` is a card id, and only that actor is offered to `onResolveCharacter`. See [Distributing characters](/guide/character-distribution) | | `skipIfMissingActor` | `boolean?` | Passed through — the game decides | | `portPerCase` | `boolean?` | CONDITION: each case leaves by **its own port** (`K1`…) instead of sharing `out` | > WARNING: They were seconds in v1, and **nothing reports the change at runtime**: a migrated project turns a 3-second pause into 3 ms. > TIP: The countdown starts when the line has been **said**, not when the block arrived. What the writer sets is how long it STAYS on screen after its last character is typed (or its last syllable spoken); then the block leaves on its own. Counting from arrival is the mistake that reads naturally and plays wrong: 2500 ms on a 120-character line truncates it mid-sentence. It **outranks `waitInput`**, and it outranks leaving immediately. All three say WHEN the block is left, and the one the writer put on the card is the most specific answer. So a click may only **hurry the reveal**, never dismiss the block — pressing a line that plays its own time makes no sense, speeding it up does, and the hurried reveal is what arms the countdown. And since leaving a block is what marks it **finished**, a `timeout` is also what releases a [`waitForBlocks`](/guide/async-tracks) that names it. Only **three** of these change anything about the traversal: `isAsync`, `waitForBlocks` and `inPortPerCharacter`. The other seven are handed to the game untouched. ================================================================================ # The Router Block A Router **launches every route whose condition is met**, waits for the ones that block, then says whether all of them were. It is the **precondition dispatcher** block: "launch everything that is in place, and tell me whether everything was." ## Do not confuse it with a condition Both read conditions, but they are not in the same line of work. | | CONDITION | ROUTER | |---|---|---| | The question | "**which one** is true?" | "**which ones** are true?" | | Cases evaluated | stops as soon as it has its answer | **all** of them, to the last | | Exits taken | **one only** | **one per true case**, plus a continuation | A condition **switches**. A Router **dispatches**. ## The ports | Port | How many | Taken when | |---|---|---| | `K1`, `K2`, … | one per declared case | that case is true | | `then` | always 1 | **all** the cases were true | | `catch` | always 1 | **at least one** case was false | - **`then` and `catch` are exclusive.** One of the two is taken, never both, never neither. - **Both exist from the moment the block is created**, before the first case. - **`catch` does not mean "error"**, it means "a condition was not met". ## Example ``` ┌─────────────────────────────┐ ── the flow ────►│ ROUTER "the door" │ ├─────────────────────────────┤ │ K1 items.gold_key > 0 ├──► ACTION a chime (isAsync) │ K2 quest.guard == 2 ├──► DIALOG the guard nods (isAsync) │ K3 purse.gold >= 50 ├──► ACTION take 50 gold (isAsync) ├─────────────────────────────┤ │ then ├──► DIALOG "the door opens" │ catch ├──► DIALOG "something is missing" └─────────────────────────────┘ ``` The key and the gold, but not the guard → K1 and K3 leave, 2 cases out of 3 → exit through **`catch`**. All three → three routes leave, exit through `then`. > WARNING: The chime rings, the gold is taken, **and** the player is told something is missing. ## Execution ``` [[every isAsync route], [route 1], [route 2], …] then then | catch ``` 1. Evaluate **every** case, in order, never stopping early. 2. Launch **all** the `isAsync` routes at once. 3. Run the others **one at a time**, each to its end. 4. Continue through `then` if every case was true, otherwise `catch`. Recommended: `isAsync` on every target of your `K*` ports. The engine does not require it. ### Example with non-async ports Every party member present reacts. They should speak **one after another**, not three bubbles at once — so their routes carry no `isAsync`. ``` ┌─────────────────────────────┐ ── the flow ────►│ ROUTER "the reactions" │ ├─────────────────────────────┤ │ K1 quest.door == 3 ├──► ACTION shut the door (isAsync) │ K2 party.aria == true ├──► DIALOG Aria: "at last!" │ K3 party.bram == true ├──► DIALOG Bram: "no way" ├─────────────────────────────┤ │ then ├──► DIALOG "the party is ready" └─────────────────────────────┘ ``` All three cases are true: ``` time ───────────────────────────────────────────────► K1 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ detached — lives its own life K2 ▓▓▓▓▓▓▓ Aria speaks K3 ▓▓▓▓▓▓▓ then Bram then ▓▓▓▓▓▓ ``` ``` [[K1],[K2],[K3]].then ``` - **K2 is played to its end** — not just its first block, **the whole chain wired behind it**. K3 only starts afterwards. - **`then`** waits for K2 and K3. K1 may still be running. With `isAsync` on K2 and K3: Aria and Bram speak at the same time, and `then` leaves immediately. ## The cases Same shape as the CONDITION block. - A flat list: `dictionary.entry` · operator · value. - Named operators — `equals`, `notEquals`, `lessThan`, `lessOrEqual`, `greaterThan`, `greaterOrEqual`. The diagrams above use `>` and `==` for readability. - Every line after the first carries its own link: `and` or `or`. - No evaluation precedence is exported. The reference engine evaluates left to right. - A case **with no comparison is always true**. ## On the game side ```ts engine.onResolveCondition((test) => { // test = { dict: "items", entry: "gold_key", op: "greaterThan", value: 0 } return myGameState.answer(test); }); ``` The engine decides which ports leave and picks `then` or `catch`. Nothing to route by hand. There is **no `onRouter` handler**, and none is needed: by the time a handler could speak, every true case has launched its port and the continuation is picked — there is nothing left to answer. `start()` does not require one. To watch a router — log what matched, drive a debug view — use `handle.onBlock(id)`: its context carries the pre-evaluated `cases`, ALL of them, and no `resolve`. ```ts [TypeScript] import type { RouterContext } from '@lsde/dialog-engine'; // A ROUTER has no handler of its own: the engine evaluates EVERY case, launches the port of each // true one and picks `then` or `catch` before a handler could speak. To WATCH one, target it by id. handle.onBlock('ROUTER-001', ({ block, context, next }) => { const { cases } = context as RouterContext; const launched = cases.filter((c) => c.result).map((c) => c.port); console.log(`${block.id} launched:`, launched.join(', ') || 'nothing'); // Nothing to resolve: the exits are a tally, not a choice. next(); }); ``` ```csharp [C#] handle.OnBlock("ROUTER-001", args => { var cases = ((IRouterContext)args.Context).Cases; var launched = cases.Where(c => c.Result == true).Select(c => c.Port).ToList(); Console.WriteLine($"{args.Block.Id} launched: {string.Join(", ", launched)}"); // Nothing to resolve: the exits are a tally, not a choice. args.Next(); return null; }); ``` ```cpp [C++] handle->onBlock("ROUTER-001", [](auto*, auto* block, IBaseBlockContext* ctx, auto next) -> CleanupFn { auto* router = dynamic_cast(ctx); for (const auto& c : router->cases()) { if (c.result.value_or(false)) std::cout << block->id << " launched " << c.port << "\n"; } // Nothing to resolve: the exits are a tally, not a choice. next(); return {}; }); ``` ```gdscript [GDScript] handle.on_block("ROUTER-001", func(args): var ctx = args["context"] # an LsdeBlockContext.RouterContext var launched = [] for c in ctx.cases: if c["result"]: launched.append(c["port"]) print("%s launched: %s" % [args["block"]["id"], ", ".join(launched)]) # Nothing to resolve: the exits are a tally, not a choice. args["next"].call() return Callable() ) ``` > TIP: A condition asks *which one* and leaves by a single port. A router asks *which ones*: every case is evaluated — a false one in the middle hides nothing after it — each true case launches its port, and the flow then continues by `then` when they **all** held, by `catch` when **any** did not. No case at all is `then`, the way `Promise.all([])` resolves. The `K*` routes are walked like any other port: an `isAsync` target opens its own track, the others are this track's, in turn, and the continuation comes **last** — which is what keeps `then`/`catch` as the main flow when the routes are async. `catch` cancels nothing: the tracks of the true cases are already running. ## Pitfalls - **The chosen port may be wired to nothing** — the track ends there, like any terminal block. Not an error. - **Zero cases → `then`.** - **`catch` on an ACTION block means "the call failed"** — a different thing. - **"First one that holds wins" is not a Router**, it is a CONDITION with `portPerCase`. ## What it does not do - **Wait for its `isAsync` routes** — the philosophy of TypeScript's `Promise.all`. Deferred. - **The `finally` concept.** Ruled out. ## See also [Block Types](./block-types) · [Async Tracks](./async-tracks) · [Handlers](./handlers) ================================================================================ # Distributing characters A block carries a **list** of actors — `actors`. The engine **elects none of them**. That is deliberate: LSDE refuses to say whether the order of the list means "who speaks" or "who is present". The game decides, and it does so through a callback. ## The principle ```ts context.actors // the cast posted on the block — always the whole list context.character // the one who speaks, or undefined ``` `context.character` is `undefined` **until the game installs `onResolveCharacter`**. The engine does not guess. ## The three tools | what the designer means | the tool | where it happens | |---|---|---| | "several present, **one speaks**" | `onResolveCharacter` | in the game's code | | "what follows **differs by speaker**" | `portPerCharacter` | one **exit** port per actor | | "**this entry** names its actor" | `inPortPerCharacter` | one **entry** port per actor | `portPerCharacter` and `inPortPerCharacter` are symmetric: one names the exits, the other the entries. Both name their ports by the **card id**, never by an index. ## `inPortPerCharacter` — the wire names the actor When a block carries several actors and can be reached by several paths, the block alone cannot tell which actor the path it was reached by stands for. The entry port says it. ``` ┌──────────────────────────────┐ ┌───────────────────────────┐ │ ROUTER CONDITION-004 │ │ DIALOG-009 "Me too" │ ├──────────────────────────────┤ │ inPortPerCharacter: true │ │ K1 party.l1 ≠ true ├───────►│ ◂ l1 │ │ K2 party.l2 ≠ true ├───────►│ ◂ l2 │ │ K3 party.l3 ≠ true ├───────►│ ◂ l3 │ ├──────────────────────────────┤ │ ◂ in (nobody named) │ │ then ├──► └───────────────────────────┘ └──────────────────────────────┘ DIALOG-010 ``` `DIALOG-009` carries all three rabbits and one single line. Each true case of `CONDITION-004` launches the block **through its own rabbit's door**. ### Step by step ``` 1. CONDITION-004: case K1 is true → the wire leaves 2. it arrives on entry port l1 of DIALOG-009 3. the engine asks the game "give me l1" → onResolveCharacter([ l1 ]) 4a. the game returns card l1 → DIALOG-009 is assigned to l1 4b. the game returns undefined → nothing happens: that character does not exist ``` **The engine never decides on its own.** It asks, the game answers — the same callback as everywhere else. The one difference: it asks for **one** actor instead of handing over the whole list, so the game cannot pick the wrong rabbit. ### What the context holds On the pass entered through `l1`: ```ts context.actors → [ l1, l2, l3 ] the block's cast, unchanged context.character → l1 or undefined if the game does not have it ``` `actors` does not change with the door: it is the list posted on the block. Only `character` does. ### Entering through `in` The `in` port stays available on a block that has actor ports. Entering through it means **no actor named**: `onResolveCharacter` then receives the whole list, as everywhere else. A wire that names no actor port carries `toPort: "in"` — which is every wire of a project that does not use this property, so nothing already written changes. ## The recommended convention **Several wires into one block launch that block several times, identically.** If each wire must say something **different**: | what differs | what to do | |---|---| | only the character | `inPortPerCharacter` — one entry port per actor | | the text as well | **one block per wire** | What to avoid: several wires into a multi-actor block **without** entry ports. The block is played several times with no way to tell which wire brought it, and nothing reports it. ## See also [Handlers](./handlers) · [Block Types](./block-types) · [The Router Block](./router) ================================================================================ # Handlers ## Handlers Handlers are the bridge between the engine and your game. They work like observers — you subscribe a function, and the engine calls it when the matching event occurs. This is how you trigger the right behaviors in your game engine: display text, play an animation, evaluate state, etc. The engine exposes the following handlers: | Handler | Level | Description | |---------|-------|-------------| | [`onDialog`](/api-ref/classes/DialogueEngine#ondialog) | global / scene | Dialog block — display text | | [`onChoice`](/api-ref/classes/DialogueEngine#onchoice) | global / scene | Choice block — present choices | | [`onCondition`](/api-ref/classes/DialogueEngine#oncondition) | global / scene | Condition block — evaluate and branch | | [`onAction`](/api-ref/classes/DialogueEngine#onaction) | global / scene | Action block — trigger side effects | | [`onResolveCharacter`](/api-ref/classes/DialogueEngine#onresolvecharacter) | global / scene | Resolve which character is speaking | | [`onBeforeBlock`](/api-ref/classes/DialogueEngine#onbeforeblock) | global | Before every block (delay, entry animations…) | | [`onValidateNextBlock`](/api-ref/classes/DialogueEngine#onvalidatenextblock) | global | Validate before progressing to a block | | [`onInvalidateBlock`](/api-ref/classes/DialogueEngine#oninvalidateblock) | global | React when validation fails | | [`onSceneEnter`](/api-ref/classes/DialogueEngine#onsceneenter) | global / scene | A scene starts | | [`onSceneExit`](/api-ref/classes/DialogueEngine#onsceneexit) | global / scene | A scene ends | | [`onBlock`](/api-ref/interfaces/SceneHandle#onblock) | scene | Override one block by its id (`DIALOG-001`) | | [`onDialogId`](/api-ref/interfaces/SceneHandle#ondialogid) | scene | Override one DIALOG block by its id (type-safe) | | [`onChoiceId`](/api-ref/interfaces/SceneHandle#onchoiceid) | scene | Override one CHOICE block by its id (type-safe) | | [`onConditionId`](/api-ref/interfaces/SceneHandle#onconditionid) | scene | Override one CONDITION block by its id (type-safe) | | [`onActionId`](/api-ref/interfaces/SceneHandle#onactionid) | scene | Override one ACTION block by its id (type-safe) | | [`onResolveCondition`](/api-ref/classes/DialogueEngine#onresolvecondition) | global | Unified condition resolver (choice visibility + condition pre-evaluation) | `onDialog`, `onChoice`, and `onAction` are **required** — the engine validates their presence when `start()` is called and throws a descriptive error if any are missing. `onCondition` is **optional** when `onResolveCondition` is installed — the engine auto-routes from pre-evaluated condition groups. A ROUTER block has **no handler** and needs none: the engine evaluates every case, launches the port of each true one and continues by `then` (all held) or `catch` (one did not) on its own. To observe one, use `handle.onBlock(id)` — see [The Router Block](/guide/router). ```ts [TypeScript] // required — the engine won't start without these engine.onDialog(dialogHandler); engine.onChoice(choiceHandler); engine.onCondition(conditionHandler); engine.onAction(actionHandler); // optional — lifecycle, validation, pre-execution engine.onBeforeBlock(beforeBlockHandler); engine.onResolveCharacter(resolveCharacterHandler); engine.onValidateNextBlock(validateHandler); engine.onSceneEnter(sceneEnterHandler); engine.onSceneExit(sceneExitHandler); const handle = engine.scene(sceneId); handle.start(); ``` ```csharp [C#] // required — the engine won't start without these engine.OnDialog(dialogHandler); engine.OnChoice(choiceHandler); engine.OnCondition(conditionHandler); engine.OnAction(actionHandler); // optional — lifecycle, validation, pre-execution engine.OnBeforeBlock(beforeBlockHandler); engine.OnResolveCharacter(resolveCharacterHandler); engine.OnValidateNextBlock(validateHandler); engine.OnSceneEnter(sceneEnterHandler); engine.OnSceneExit(sceneExitHandler); var handle = engine.Scene(sceneId); handle.Start(); ``` ```cpp [C++] // required — the engine won't start without these engine.onDialog(dialogHandler); engine.onChoice(choiceHandler); engine.onCondition(conditionHandler); engine.onAction(actionHandler); // optional — lifecycle, validation, pre-execution engine.onBeforeBlock(beforeBlockHandler); engine.onResolveCharacter(resolveCharacterHandler); engine.onValidateNextBlock(validateHandler); engine.onSceneEnter(sceneEnterHandler); engine.onSceneExit(sceneExitHandler); auto handle = engine.scene(sceneId); handle->start(); ``` ```gdscript [GDScript] # required — the engine won't start without these engine.on_dialog(dialog_handler) engine.on_choice(choice_handler) engine.on_condition(condition_handler) engine.on_action(action_handler) # optional — lifecycle, validation, pre-execution engine.on_before_block(before_block_handler) engine.on_resolve_character(resolve_character_handler) engine.on_validate_next_block(validate_handler) engine.on_scene_enter(scene_enter_handler) engine.on_scene_exit(scene_exit_handler) var handle = engine.scene(scene_id) handle.start() ``` ## Two-Tier Handler System The engine resolves handlers in two tiers: - **Global handlers** — registered on the engine, they define the default behavior for every scene. They are typically all you need. - **Scene handlers** — registered on a specific [`SceneHandle`](/api-ref/interfaces/SceneHandle), they let you override or extend the default behavior when a scene requires a different rendering or control flow. This is rare, but available. When a block is dispatched, the engine resolves the handler in this order: 1. `handle.onBlock(blockId)` or `handle.onDialogId(blockId)` / `handle.onActionId(blockId)` / ... — block-specific override 2. `handle.onDialog()` / `handle.onChoice()` / ... — scene-level type handler 3. `engine.onDialog()` / `engine.onChoice()` / ... — global handler When both tiers are present, both run in sequence — scene first, then global — unless the scene handler calls `context.preventGlobalHandler()` to suppress the global pass. ```ts [TypeScript] // Tier 1 — global engine.onDialog(({ block, context, next }) => { console.log('Global dialog handler'); next(); }); // Tier 2 — scene-specific const handle = engine.scene(sceneId); handle.onDialog(({ block, context, next }) => { console.log('Scene-specific dialog handler'); context.preventGlobalHandler(); next(); }); handle.start(); ``` ```csharp [C#] // Tier 1 — global engine.OnDialog(args => { Console.WriteLine("Global dialog handler"); args.Next(); return null; }); // Tier 2 — scene-specific var handle = engine.Scene(sceneId); handle.OnDialog(args => { Console.WriteLine("Scene-specific dialog handler"); args.Context.PreventGlobalHandler(); args.Next(); return null; }); handle.Start(); ``` ```cpp [C++] // Tier 1 — global engine.onDialog([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn { std::cout << "Global dialog handler\n"; next(); return {}; }); // Tier 2 — scene-specific auto handle = engine.scene(sceneId); handle->onDialog([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn { std::cout << "Scene-specific dialog handler\n"; ctx->preventGlobalHandler(); next(); return {}; }); handle->start(); ``` ```gdscript [GDScript] # Tier 1 — global engine.on_dialog(func(args): print("Global dialog handler") args["next"].call() return Callable() ) # Tier 2 — scene-specific var handle = engine.scene(scene_id) handle.on_dialog(func(args): print("Scene-specific dialog handler") args["context"].prevent_global_handler() args["next"].call() return Callable() ) handle.start() ``` ## Character Resolution Character resolution is optional. By registering an `onResolveCharacter` callback, the engine invokes it before every block that has characters in its `actors`. The callback receives the list of characters assigned to the block and returns the one that should be active — or `undefined` if none is available. The resolved character is then accessible via `context.character` in all handlers. This is the ideal integration point to query your game state: check if a character is present in the scene, alive, in camera range, etc. Returning `undefined` opens the door to several strategies: skip the block via [`skipIfMissingActor`](/api-ref/interfaces/NativeProperties#skipifmissingactor), cancel the scene via `handle.cancel()`, or handle the case directly in the handler. ```ts [TypeScript] // Engine-level — applies to all scenes engine.onResolveCharacter((characters) => { return party.getActiveLeader(characters); }); // Scene-level override const handle = engine.scene(sceneId); handle.onResolveCharacter((characters) => { return battle.getActiveUnit(characters); }); ``` ```csharp [C#] engine.OnResolveCharacter(chars => party.GetActiveLeader(chars)); var handle = engine.Scene(sceneId); handle.OnResolveCharacter(chars => battle.GetActiveUnit(chars)); ``` ```cpp [C++] engine.onResolveCharacter([](const auto& chars) { return party.getActiveLeader(chars); }); auto handle = engine.scene(sceneId); handle->onResolveCharacter([](const auto& chars) { return battle.getActiveUnit(chars); }); ``` ```gdscript [GDScript] engine.on_resolve_character(func(chars): return party.get_active_leader(chars) ) var handle = engine.scene(scene_id) handle.on_resolve_character(func(chars): return battle.get_active_unit(chars) ) ``` ## Scene Lifecycle The `onSceneEnter` and `onSceneExit` callbacks let you react to a scene starting and ending — enable cinema mode, freeze NPCs, prepare the UI, clean up resources, etc. They are available at global level (on the engine) and at scene level (via `handle.onEnter()` / `handle.onExit()`). The scene handler replaces the global one if defined. ```ts [TypeScript] engine.onSceneEnter(({ scene }) => { game.cinemaMode(true); game.stopNpcMovements(); }); engine.onSceneExit(() => { game.cinemaMode(false); game.resumeNpcMovements(); }); // scene-level override const handle = engine.scene(sceneId); handle.onEnter(({ scene }) => { game.playIntroSequence(scene); }); ``` ```csharp [C#] engine.OnSceneEnter(args => { Game.CinemaMode(true); Game.StopNpcMovements(); }); engine.OnSceneExit(args => { Game.CinemaMode(false); Game.ResumeNpcMovements(); }); // scene-level override var handle = engine.Scene(sceneId); handle.OnEnter(args => { Game.PlayIntroSequence(args.Scene); }); ``` ```cpp [C++] engine.onSceneEnter([&game](const lsde::SceneLifecycleArgs&) { game.cinemaMode(true); game.stopNpcMovements(); }); engine.onSceneExit([&game](const lsde::SceneLifecycleArgs&) { game.cinemaMode(false); game.resumeNpcMovements(); }); // scene-level override auto handle = engine.scene(sceneId); handle->onEnter([&game](const lsde::SceneLifecycleArgs& args) { game.playIntroSequence(args.scene); }); ``` ```gdscript [GDScript] engine.on_scene_enter(func(args): game.cinema_mode(true) game.stop_npc_movements() ) engine.on_scene_exit(func(args): game.cinema_mode(false) game.resume_npc_movements() ) # scene-level override var handle = engine.scene(scene_id) handle.on_enter(func(args): game.play_intro_sequence(args["scene"]) ) ``` ## Block Override `onBlock(blockId)` lets you target a specific block by its identifier and assign it a dedicated handler. This is a rare use case — generic handlers cover the vast majority of needs — but for very specific scenarios where an individual block requires distinct behavior, it is available. ```ts [TypeScript] const handle = engine.scene(sceneId); handle.onBlock('DIALOG-001', ({ block, context, next }) => { next(); }); ``` ```csharp [C#] var handle = engine.Scene(sceneId); handle.OnBlock("DIALOG-001", args => { args.Next(); return null; }); ``` ```cpp [C++] auto handle = engine.scene(sceneId); handle->onBlock("DIALOG-001", [](auto*, auto*, auto*, auto next) -> CleanupFn { next(); return {}; }); ``` ```gdscript [GDScript] var handle = engine.scene(scene_id) handle.on_block("DIALOG-001", func(args): args["next"].call() return Callable() ) ``` ## Type-Safe Block Override `onDialogId(blockId)`, `onChoiceId(blockId)`, `onConditionId(blockId)`, and `onActionId(blockId)` are type-safe alternatives to `onBlock(blockId)`. They work exactly the same way — same priority, same `preventGlobalHandler` support — but the handler receives the specialized block type and context instead of the generic union. Use these when you know the block type at registration time and want full autocompletion on `block` and `context`. ```ts [TypeScript] const handle = engine.scene(sceneId); handle.onActionId('ACTION-001', ({ block, context, next }) => { // block is ActionBlock — actions is directly accessible for (const action of block.actions ?? []) { executeAction(action); } // context is ActionContext — resolve/reject are available context.resolve(); next(); }); ``` ```csharp [C#] var handle = engine.Scene(sceneId); handle.OnActionId("ACTION-001", args => { // args.Block is ActionBlock — Actions is directly accessible foreach (var action in args.Block.Actions ?? []) ExecuteAction(action); // args.Context is IActionContext — Resolve/Reject are available args.Context.Resolve(); args.Next(); }); ``` ```cpp [C++] auto handle = engine.scene(sceneId); handle->onActionId("ACTION-001", [](auto* scene, const ActionBlock* block, IActionContext* ctx, auto next) -> CleanupFn { // block is const ActionBlock* — actions is directly accessible for (const auto& action : block->actions) executeAction(action); // ctx is IActionContext* — resolve/reject are available ctx->resolve(); next(); return {}; }); ``` ```gdscript [GDScript] var handle = engine.scene(scene_id) handle.on_action_id("ACTION-001", func(args): # args["block"] contains actions directly for action in args["block"].get("actions", []): execute_action(action) # args["context"] has resolve/reject args["context"]["resolve"].call() args["next"].call() return Callable() ) ``` ## Visual Reference ### Two-Tier Handler Dispatch ```mermaid flowchart TD A[block dispatched] --> B{resolve scene handler} B --> B1{"onBlock(blockId) /\nonDialogId(blockId) etc.?"} B1 -- found --> S B1 -- not found --> B2{"handle.onDialog() etc.?"} B2 -- found --> S B2 -- not found --> G S[execute scene handler] --> D{preventGlobalHandler?} D -- yes --> Z[done] D -- no --> G["execute global handler\nengine.onDialog() etc."] G --> Z ``` ================================================================================ # Game Engine Integration LSDE is engine-agnostic — no dependency on any game engine, UI framework, or audio system. It walks a graph and calls your handlers. This page shows how to wire it into the most common game engines. For detailed handler implementation, see [Block Types](./block-types) and [Handlers](./handlers). ## Full Integration The following example shows one way to integrate LSDE into each engine. It covers the 4 required handlers — dialog, choice, condition, action — in a single class, as a starting point. Every game has its own needs. Adapt the structure, the layout, and the UI to your project. ```ts [TypeScript — Phaser] // Phaser 3.80+ — overlay scene that handles all LSDE block types import Phaser from 'phaser'; import { type DialogueEngine, LsdeUtils } from '@lsde/dialog-engine'; export class DialogueUI extends Phaser.Scene { private pendingNext: (() => void) | null = null; constructor() { super({ key: 'DialogueUI' }); } create(params: { engine: DialogueEngine }) { const dialog = this.createDialogPanel(); const choices = this.createChoicePanel(); // player advances dialogue via keyboard or touch this.input.keyboard?.addKey('SPACE').on('down', () => this.advance()); this.input.on('pointerdown', () => this.advance()); // ── DIALOG ──────────────────────────────────────────────── params.engine.onDialog(({ block, context, next }) => { const { text, props } = block; const { character, resolveCharacterPort } = context; const line = LsdeUtils.getLocalizedText(text); character && resolveCharacterPort(character.id); dialog.show(line, character?.name); this.pendingNext = next; // `timeout` outranks waitInput: a line carrying one plays its own time and the // click may only hurry its reveal, never dismiss it. this.dismissableByInput = !props?.timeout; // auto-advance for blocks. MILLISECONDS in v2 — no × 1000 — and counted from the // moment the line has been SAID, not from the moment the block arrived. let timer: Phaser.Time.TimerEvent | null = null; if (props?.timeout) { dialog.onRevealComplete(() => { timer = this.time.delayedCall(props.timeout, () => this.leaveBlock()); }); } return () => { dialog.hide(); this.pendingNext = null; timer?.destroy(); }; }); // ── CHOICE ──────────────────────────────────────────────── params.engine.onChoice(({ block, context, next }) => { const { props } = block; const { choices: items, selectChoice } = context; const offered = items.filter(c => c.visible !== false); const buttons = choices.show(offered, (optionId) => { selectChoice(optionId); next(); }); // On a CHOICE there is no line to reveal, so the countdown starts once the options // are on screen and readable. MILLISECONDS in v2 — no × 1000. And it must still // SELECT one: an option id IS the exit port, so a choice left without selectChoice() // resolves to NO link and the branch dies silently. let timer: Phaser.Time.TimerEvent | null = null; if (props?.timeout) { timer = this.time.delayedCall(props.timeout, () => { if (offered.length > 0) selectChoice(offered[0].id); next(); }); } return () => { choices.hide(buttons); timer?.destroy(); }; }); // ── CONDITION ───────────────────────────────────────────── params.engine.onCondition(({ block, context, next }) => { // evaluate your game state — return true or false const result = true; // ← replace with your game logic context.resolve(result); next(); }); // ── ACTION ──────────────────────────────────────────────── params.engine.onAction(({ block, context, next }) => { // execute your game effects — set flags, play sounds, give items // context.resolve() on success, context.reject(err) on failure context.resolve(); next(); }); } /** What the pointer calls. A line still revealing takes the click as "show me the * rest"; a line on a `timeout` plays its own time and never leaves this way. */ private advance() { if (!this.dialog.revealComplete) { this.dialog.skipReveal(); return; } if (!this.dismissableByInput) return; this.leaveBlock(); } private leaveBlock() { if (this.pendingNext) { this.pendingNext(); this.pendingNext = null; } } // ── UI factories ────────────────────────────────────────────── private createDialogPanel() { const bg = this.add .graphics() .fillStyle(0x000000, 0.8) .fillRoundedRect(0, 0, 700, 150, 12); const nameText = this.add.text(16, 12, '', { fontSize: '16px', color: '#ffcc00', fontStyle: 'bold', }); const bodyText = this.add.text(16, 38, '', { fontSize: '14px', color: '#ffffff', wordWrap: { width: 668 }, }); const container = this.add .container(50, 430, [bg, nameText, bodyText]) .setScrollFactor(0) .setDepth(1000) .setVisible(false); return { show: (text?: string, speaker?: string) => { nameText.setText(speaker ?? ''); bodyText.setText(text ?? ''); container.setVisible(true); }, hide: () => container.setVisible(false), }; } private createChoicePanel() { return { show: ( visible: { id: string; text?: Record }[], onSelect: (optionId: string) => void, ) => { return visible.map((choice, i) => { const text = LsdeUtils.getLocalizedText(choice.text) ?? choice.label ?? ''; return this.add .text(80, 440 + i * 40, text, { fontSize: '15px', color: '#ffffff' }) .setScrollFactor(0) .setDepth(1001) .setInteractive({ useHandCursor: true }) .on('pointerdown', () => onSelect(choice.id)); }); }, hide: (buttons: Phaser.GameObjects.Text[]) => buttons.forEach(b => b.destroy()), }; } } // start a dialogue scene from your game: // this.scene.launch('DialogueUI', { engine }); // engine.scene(sceneId).start(); ``` ```csharp [C# — Unity] // Unity 2021+ — MonoBehaviour that handles all LSDE block types using System.Linq; using UnityEngine; using UnityEngine.UI; using TMPro; using LsdeDialogEngine; public class DialogueUI : MonoBehaviour { [Header("Dialog UI — assign in Inspector")] [SerializeField] private GameObject dialogPanel; [SerializeField] private TMP_Text speakerName; [SerializeField] private TMP_Text dialogText; [Header("Choice UI")] [SerializeField] private Transform choiceContainer; [SerializeField] private GameObject choiceButtonPrefab; private DialogueEngine _engine; private System.Action _pendingNext; /// /// Call once to wire all LSDE handlers to this UI. /// The engine must be initialized before calling this method. /// public void Register(DialogueEngine engine) { _engine = engine; // ── DIALOG ──────────────────────────────────────────── engine.OnDialog(args => { var (_, block, context, next) = args; var text = LsdeUtils.GetLocalizedText(block.Text); var ch = context.Character; if (ch != null) context.ResolveCharacterPort(ch.Id); speakerName.text = ch?.Name ?? ""; dialogText.text = text ?? ""; dialogPanel.SetActive(true); _pendingNext = next; return () => { dialogPanel.SetActive(false); _pendingNext = null; }; }); // ── CHOICE ──────────────────────────────────────────── engine.OnChoice(args => { var (_, block, context, next) = args; var visible = context.Options .Where(c => c.Visible != false).ToList(); foreach (var choice in visible) { var btn = Instantiate(choiceButtonPrefab, choiceContainer); btn.GetComponentInChildren().text = LsdeUtils.GetLocalizedText(choice.Text) ?? choice.Label ?? ""; var optionId = choice.Id; btn.GetComponent