Block 类型
block 是对话场景的构建单元 — 编辑器图中的每个节点都是一个 block。engine 将流程从一个 block 路由到下一个,并为每种类型调用对应的 handler。
共有 6 种类型:Dialog、Choice、Condition、Router、Action 和 Note。Dialog、Choice、Condition 和 Action 是内容 block,各有专用的 handler(onDialog、onChoice、onCondition、onAction)— 四个都是必需的,在调用 start() 时验证。Router 没有 handler:engine 自己分发它。Note block 会被自动跳过。
handler 分为两个层级:global handler(注册在 engine 上)覆盖所有 scene,对大多数游戏来说足够。scene handler(注册在 SceneHandle 上)可以为特定 scene 补充或覆盖 global handler。详见 Handlers。
DIALOG
dialog block 代表一句台词 — 角色对话、旁白、屏幕文字。engine 通过 onResolveCharacter callback 解析说话的角色,并以 context.character 暴露给 handler。典型的 dialog handler 在游戏中创建一个文本实例(文本框、气泡、字幕…),等待玩家或动画完成,然后调用 next() 推进 engine。可选的 cleanup 函数可以在 engine 进入下一个 block 时清理副作用。
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);
};
});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);
};
});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);
};
});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)
)当叙事设计师为每个角色分配了专用输出(portPerCharacter)时,handler 必须调用 resolveCharacterPort() 来告诉 engine 在 next() 时走哪条路径。
CHOICE
choice block 是玩家做出选择的分支点 — 对话菜单、选项列表。context.options 包含所有可用选项。当配置了 onResolveCondition() 时,每个选项被标记为 visible: true | false — handler 过滤并显示想要的选项。玩家交互后,selectChoice(optionId) 告诉 engine 走哪条路径 — option 的 id 就是 flow 离开时所用的 port(C1、C2…)— 然后 next() 推进 flow。
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();
});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();
});engine.onChoice([&game](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
std::vector<const lsde::RuntimeChoiceItem*> 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(); };
});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()
)visible is a tag, not a filter
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.
参见 Choice 可见性 了解完整的可选标记系统。
CONDITION
condition block 是一个不可见的道岔 — 它查阅游戏状态,在玩家看不见的情况下把 flow 送出去。
engine 自己不比较任何东西。 它不读 dictionary,不知道 credits 里装着什么,也没有实现 greaterOrEqual。它只是把每个测试交给 onResolveCondition() 并把答案 组装起来。无论哪种模式,每个测试都只被问一次。
只要注册了 resolver,engine 在调用 handler 之前就已经知道出口 port:onCondition 变为可选, 沦为记录日志或强制覆盖的钩子。handler 收到 context.cases,每个 case 都带着自己的 port 和已经 求好的 result。要覆盖路由,context.resolve(port) 接受一个端口名 — "out"、"default", 或某个 case 的端口("K1")。
模式只有两种,仅此两种:
- 没有
portPerCase— 所有 case 都必须成立。成立则 flow 从out出,否则从default出。 portPerCase: true— 第一个成立的 case 从它自己的 port(K1、K2…)出。 若无一成立,则default。
没有 when 的 case 永远为真,在 portPerCase 模式下会让它下面的 case 无法到达。这是 writer 画出的 图,不是需要报告的错误。一个 case 都没有的 block 从 out 出:什么都没被问,所以什么都没失败。
default 意思是「没有任何 case 成立」— 不是「选中的出口没有接线」。没有接线的 port 会结束 flow,而那是一种正当的结束。
dictionary 为保留字 choice 的测试查询玩家已经给出的答案: { dict: "choice", entry: "CHOICE-001", value: "C1" }。engine 会从 scene 的历史中自行回答, 这个问题永远不会到达游戏。另见 scene.getChoice(blockId) 和 scene.evaluateCondition(test)。
// 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();
});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;
});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 {};
});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()
)Two modes, and only two
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
router block 携带与 condition 相同的 cases,但反过来读。condition 问的是哪一个成立,然后从单一 port 出去;router 问的是哪些成立:它对每一个 case 求值,为每个为真的 case 启动其 port,然后总是继续 —— 全部成立走 then,任何一个不成立走 catch。一个 case 都没有的 router 从 then 出去,就像 Promise.all([]) 会 resolve 一样。
它的 K* 路由和任何其他 port 一样被走过:isAsync 的目标开启自己的轨道,其余的依次走过,而后续放在最后。catch 不取消任何东西 —— 为真的 case 的轨道已经在跑了。
没有 onRouter handler,start() 也不要求它:engine 自己分发 router。要观察一个,用 handle.on_block(id) / handle.onBlock(id)。
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();
});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;
});handle->onBlock("ROUTER-001", [](auto*, auto* block, IBaseBlockContext* ctx, auto next) -> CleanupFn {
auto* router = dynamic_cast<IRouterContext*>(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 {};
});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()
)A tally, not a choice
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.
完整契约与图示见 Router block;router 的路由通常命名的入口端口 inPortPerCharacter 见 分配角色。
ACTION
action block 在游戏中触发副作用 — 给予物品、播放音效、设置标志。context.calls 携带这些调用: 每一个都引用一个已声明 function 的 fn,其 args 按名称传入, 从不按位置。handler 执行它们,然后调用 context.resolve() 走 then port,或 context.reject() 走 catch port — 而如果 designer 没有接任何 catch,flow 会从 then 继续,而不是把玩家丢在原地。
engine.onAction(({ block, context, next }) => {
const { actions } = block;
game
.executeActionsList(actions)
.then(() => context.resolve())
.catch((err) => context.reject(err))
.finally(() => next());
});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;
});engine.onAction([&game](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn {
auto* ab = dynamic_cast<const ActionBlock*>(block);
game.executeActionsList(ab->actions, [ctx, next]() {
ctx->resolve();
next();
}, [ctx, next](const auto& err) {
ctx->reject(err);
next();
});
return {};
});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
note block 是叙事设计师的便签 — 注释、提醒、上下文。在遍历过程中自动跳过。虽然技术上可以通过 onBeforeBlock 拦截 note block,但不推荐这样做 — action block 应该能覆盖所有副作用需求。
通用属性
所有 block 共享以下基础字段(BlueprintBlockBase):
| 字段 | 类型 | 描述 |
|---|---|---|
id | string | 相对于所在 scene 的标识 — DIALOG-002。id 会在不同 scene 之间重复。 |
key | string | 本地化文件所持有的完整 i18n key |
type | BlockType | dialog、choice、condition、router、action 或 note |
label | string? | writer 填写时的可读名称 |
parentLabels | string[]? | 编辑器中的父文件夹层级 |
note | string? | writer 的备注 |
actors | string[]? | block 引用的 card id,按文件顺序 |
emotion | string? | 情绪的 card id — 它属于 block,而不是每个 actor |
intensity | number? | 该情绪的强度 |
text | TextByLocale? | inline 导出模式下按 locale 的文本 |
props | PropertyBag? | 同一个袋子:native 与 writer 自己的属性,以裸 id 共存 |
options | Option[]? | 仅 CHOICE |
cases | ConditionCase[]? | CONDITION 与 ROUTER — 同样的数据,反向读取 |
calls | ActionCall[]? | 仅 ACTION |
next | Link[]? | block 的出线。 v2 中没有 connection 表 |
入口 block 并不标记在 block 上:指名它的是 scene,写在 scene.start 里。因此一个 scene 不可能 声明两个入口。
NativeProperties
engine 从 props 中读取的十个属性。id 不会冲突 — LSDE 会拒绝与 native 同名的项目属性 — 所以 区分它们只是一次查找。
| 字段 | 类型 | 描述 |
|---|---|---|
isAsync | boolean? | 在这个 block 上开启一条并行轨道,而不是继续当前轨道 |
waitForBlocks | string[]? | 本 scene 的 block id。在它们全部完成之前,block 会在被分发之前被扣住 — 不会调用任何 handler |
delay | number? | block 播放前的毫秒数。由 onBeforeBlock 应用,engine 从不应用 |
timeout | number? | 台词说完之后 block 留在屏幕上的毫秒数,随后自己离开 — block 的自动推进。优先于 waitInput。原样传递,engine 不做任何强制 |
waitInput | boolean? | 等待玩家输入。原样传递,从不解释 — timeout 优先于它 |
debug | boolean? | 编辑器调试标志。原样传递 |
portPerCharacter | boolean? | block 从以 actor 的 card id 命名的 port 出去,而不是 out |
inPortPerCharacter | boolean? | 接线指名 actor:link 的 toPort 是一个 card id,只有这个 actor 会被提供给 onResolveCharacter。见 分配角色 |
skipIfMissingActor | boolean? | 原样传递 — 由游戏决定 |
portPerCase | boolean? | CONDITION:每个 case 从自己的 port(K1…)出去,而不是共用 out |
在 v2 中 delay 和 timeout 的单位是毫秒
它们在 v1 中是秒,而运行时没有任何东西会提示这个变化:迁移过来的项目会把 3 秒的停顿变成 3 毫秒。
timeout 是 block 的自动推进 —— 从台词说完那一刻开始计时
倒计时从台词已经说完时开始,而不是 block 到达时。作者设定的是它在最后一个字打完(或最后一个音节念完)之后留在屏幕上的时间,然后 block 自己离开。
从到达开始计时是那种读起来自然、跑起来错误的做法:一句 120 字的台词配 2500 ms,会在句子中间被截断。
它优先于 waitInput,也优先于立即离开。三者都在说 block 何时被离开,而作者写在卡片上的那个是最具体的答案。所以点击只能加快显示,永远不能把 block 打发走 —— 去催一句正在演自己时间的台词没有意义,加快它才有;而这次加快正是启动倒计时的动作。
又因为离开一个 block 正是把它标记为已完成,所以 timeout 也是释放指名它的 waitForBlocks 的那一步。
这十个之中,只有三个会改变遍历:isAsync、waitForBlocks 和 inPortPerCharacter。其余七个原样交给游戏,由游戏决定 拿它们做什么。
