Block 类型
block 是对话场景的构建单元 — 编辑器图中的每个节点都是一个 block。engine 将流程从一个 block 路由到下一个,并为每种类型调用对应的 handler。
共有 5 种类型:Dialog、Choice、Condition、Action 和 Note。前四种是内容 block,各有专用的 handler(onDialog、onChoice、onCondition、onAction)— 四个都是必需的,在调用 start() 时验证。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 { dialogueText, nativeProperties } = block;
const { character, resolveCharacterPort } = context;
const text = game.getLocalizedText(dialogueText);
const emotion = game.getCharacterEmotion(character);
character && resolveCharacterPort(character.uuid);
game.moveCameraToCharacter(character);
game.animateCharacter(character, emotion);
const dialog = game.createDialog(text, character, emotion);
const shouldWaitInput = game.shouldWaitPlayerInputForDialog(nativeProperties);
// next() tells the engine this block is done — call it when the player
// dismisses the dialog or when the text animation finishes on its own
if (shouldWaitInput) {
dialog.onInput(() => next(), { once: true });
} else {
dialog.then(() =>
game.wait(nativeProperties?.timeout ?? 0).then(() => 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.DialogueText),
context.Character,
Game.GetCharacterEmotion(context.Character)
);
if (ch != null) context.ResolveCharacterPort(ch.Uuid);
Game.MoveCameraToCharacter(ch);
Game.AnimateCharacter(ch, emotion);
var dialog = Game.CreateDialog(text, ch, emotion);
var shouldWaitInput = Game.ShouldWaitPlayerInputForDialog(block.NativeProperties);
// next() tells the engine this block is done — call it when the player
// dismisses the dialog or when the text animation finishes on its own
if (shouldWaitInput)
dialog.OnInput(() => next(), once: true);
else
dialog.Then(() =>
Game.Wait(block.NativeProperties?.Timeout ?? 0).Then(() => 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->dialogueText);
auto emotion = game.getCharacterEmotion(ch);
if (ch) ctx->resolveCharacterPort(ch->uuid);
game.moveCameraToCharacter(ch);
game.animateCharacter(ch, emotion);
auto* dialog = game.createDialog(text, ch, emotion);
auto shouldWaitInput = game.shouldWaitPlayerInputForDialog(block->nativeProperties);
// next() tells the engine this block is done — call it when the player
// dismisses the dialog or when the text animation finishes on its own
if (shouldWaitInput) {
dialog->onInput([next]() { next(); });
} else {
dialog->then([&game, next, block]() {
game.wait(block->nativeProperties ? block->nativeProperties->timeout.value_or(0) : 0)
.then([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("dialogueText"))
var emotion = game.get_character_emotion(ch)
if ch:
ctx.resolve_character_port(ch.get("uuid", ""))
game.move_camera_to_character(ch)
game.animate_character(ch, emotion)
var dialog = game.create_dialog(text, ch, emotion)
var should_wait = game.should_wait_player_input(block.get("nativeProperties"))
# next_fn.call() tells the engine this block is done — call it when the player
# dismisses the dialog or when the text animation finishes on its own
if should_wait:
dialog.on_input(func(): next_fn.call(), true)
else:
await dialog.wait()
await game.wait(block.get("nativeProperties", {}).get("timeout", 0))
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.choices 包含所有可用选项。当配置了 onResolveCondition() 时,每个选项被标记为 visible: true | false — handler 过滤并显示想要的选项。玩家交互后,selectChoice(uuid) 告诉 engine 走哪条路径,然后 next() 推进 flow。
engine.onChoice(({ block, context, next }) => {
const { nativeProperties } = block;
const { choices, selectChoice } = context;
const visible = choices.filter(c => c.visible !== false);
const dialog = game.createChoice(visible);
// when the player picks a choice, select it and advance
dialog
.then((selected) => selectChoice(selected))
.finally(() => next());
// optional: if the narrative designer set a timeout on this block
if (nativeProperties?.timeout) {
const timeout = game.wait(nativeProperties.timeout).then(() => next());
dialog.finally(() => timeout.cancel());
}
return () => dialog.destroy();
});engine.OnChoice(args => {
var (scene, block, context, next) = args;
var visible = context.Choices
.Where(c => c.Visible != false).ToList();
var dialog = Game.CreateChoice(visible);
// when the player picks a choice, select it and advance
dialog.OnSelect(selected => {
context.SelectChoice(selected);
next();
});
// optional: if the narrative designer set a timeout on this block
if (block.NativeProperties?.Timeout is { } timeout)
Game.Wait(timeout).Then(() => next());
return () => dialog.Destroy();
});engine.onChoice([&game](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn {
std::vector<const RuntimeChoiceItem*> visible;
for (const auto& c : ctx->choices())
if (!c.visible.has_value() || c.visible.value())
visible.push_back(&c);
auto* dialog = game.createChoice(visible);
// when the player picks a choice, select it and advance
dialog->onSelect([ctx, next](const auto& selected) {
ctx->selectChoice(selected);
next();
});
// optional: if the narrative designer set a timeout on this block
if (block->nativeProperties && block->nativeProperties->timeout) {
game.wait(*block->nativeProperties->timeout).then([next]() { next(); });
}
return [dialog]() { dialog->destroy(); };
});engine.on_choice(func(args):
var block = args["block"]
var ctx = args["context"]
var next_fn = args["next"]
var visible = []
for c in ctx.choices:
if c.get("visible") != false:
visible.append(c)
var dialog = game.create_choice(visible)
# when the player picks a choice, select it and advance
var selected = await dialog.choice_selected
ctx.select_choice(selected)
next_fn.call()
# optional: if the narrative designer set a timeout on this block
# use a Timer node or game.wait() to handle timeouts natively
return func(): dialog.destroy()
)参见 Choice 可见性 了解完整的可选标记系统。
CONDITION
condition block 是一个不可见的开关 — 它评估游戏状态,在玩家看不到的情况下将 flow 送入两条路径之一。handler 评估 block 中的条件(变量、标志、背包…)然后调用 context.resolve(result) — true 走 port 0,false 走 port 1。以 choice: 开头的 key 的条件引用了玩家之前的选择 — scene.evaluateCondition(cond) 通过内部历史自动解析。
condition block 支持两种评估模式:
- switch 模式(默认):按顺序评估条件组。第一个匹配的组路由到对应的 port(
true/case_N)。如果没有匹配,则走false/defaultport。 - dispatcher 模式(
enableDispatcher= true):所有匹配的组同时作为 async track 触发。false/defaultport 变为主继续 track("Continue"),始终执行。连接到条件 port 的 block 必须是 async 的。
engine.onCondition(({ block, context, next }) => {
const { conditionGroups } = context;
const isDispatcher = !!block.nativeProperties?.enableDispatcher;
// conditionGroups are pre-evaluated when onResolveCondition is installed.
// Each group has: conditions, portIndex, result (true/false/undefined).
const matched = conditionGroups
.filter((g) => g.result)
.map((g) => g.portIndex);
// switch mode: first match index or -1 (default port)
// dispatcher mode: all matched indices (each fires an async track)
const result = isDispatcher ? matched : (matched[0] ?? -1);
context.resolve(result);
next();
});engine.OnCondition(args => {
var groups = args.Context.ConditionGroups!;
var isDispatcher = args.Block.NativeProperties?.EnableDispatcher == true;
var matched = groups.Where(g => g.Result == true).Select(g => g.PortIndex).ToList();
object result = isDispatcher ? (object)matched : (object)(matched.Count > 0 ? matched[0] : -1);
args.Context.Resolve(result);
args.Next();
return null;
});engine.onCondition([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
// Result is auto-resolved by the engine when onResolveCondition is installed.
// Override with ctx->resolve(result) if needed.
next();
return {};
});engine.on_condition(func(args):
var ctx = args["context"]
var groups = ctx.condition_groups
var np = args["block"].get("nativeProperties")
var is_dispatcher = np is Dictionary and np.get("enableDispatcher", false)
var matched = []
for g in groups:
if g.get("result", false):
matched.append(g.get("port_index", 0))
var result = matched if is_dispatcher else (matched[0] if matched.size() > 0 else -1)
ctx.resolve(result)
args["next"].call()
return Callable()
)ACTION
action block 在游戏中触发副作用 — 给予物品、播放音效、设置标志。每个 action 引用一个 actionId,由开发者映射到自己的系统。handler 执行 action 列表后调用 context.resolve() 走 "then" port,或调用 context.reject(error) 走 "catch" port(如果没有 "catch" 连接则回退到 "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):
| 字段 | 类型 | 描述 |
|---|---|---|
uuid | string | 唯一标识符 |
type | BlockType | 判别类型 |
label | string? | 人类可读的名称 |
parentLabels | string[]? | 编辑器中的父文件夹层级 |
properties | BlockProperty[] | 键值属性 |
userProperties | Record? | 自由格式的用户属性 |
nativeProperties | NativeProperties? | 执行属性 |
metadata | BlockMetadata? | 显示元数据(角色、标签、颜色) |
isStartBlock | boolean? | 标记入口 block |
NativeProperties
| 字段 | 类型 | 描述 |
|---|---|---|
isAsync | boolean? | 在并行异步轨道上执行 |
delay | number? | 执行前的延迟(由 onBeforeBlock 消费) |
timeout | number? | 执行超时时间 |
portPerCharacter | boolean? | metadata 中每个角色对应一个输出 port |
skipIfMissingActor | boolean? | 如果引用的 actor 不存在则跳过 block |
debug | boolean? | 编辑器调试标志 |
waitForBlocks | string[]? | 此 block 可以继续之前必须已访问的 block UUID |
waitInput | boolean? | 用于显式玩家输入控制的被动标志 |
enableDispatcher | boolean? | dispatcher 模式:所有匹配的条件作为 async track 触发,false/default port 变为继续 track |
