Skip to content

Block 类型

block 是对话场景的构建单元 — 编辑器图中的每个节点都是一个 block。engine 将流程从一个 block 路由到下一个,并为每种类型调用对应的 handler。

共有 5 种类型:DialogChoiceConditionActionNote。前四种是内容 block,各有专用的 handler(onDialogonChoiceonConditiononAction)— 四个都是必需的,在调用 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 时清理副作用。

ts
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);
  };
});
csharp
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);
    };
});
cpp
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);
    };
});
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("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。

ts
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();
});
csharp
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();
});
cpp
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(); };
});
gdscript
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/default port。
  • dispatcher 模式enableDispatcher = true):所有匹配的组同时作为 async track 触发。false/default port 变为主继续 track("Continue"),始终执行。连接到条件 port 的 block 必须是 async 的。
ts
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();
});
csharp
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;
});
cpp
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 {};
});
gdscript
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")。

ts
engine.onAction(({ block, context, next }) => {
  const { actions } = block;
  game
    .executeActionsList(actions)
    .then(() => context.resolve())
    .catch((err) => context.reject(err))
    .finally(() => next());
});
csharp
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
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 {};
});
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

note block 是叙事设计师的便签 — 注释、提醒、上下文。在遍历过程中自动跳过。虽然技术上可以通过 onBeforeBlock 拦截 note block,但不推荐这样做 — action block 应该能覆盖所有副作用需求。

通用属性

所有 block 共享以下基础字段(BlueprintBlockBase):

字段类型描述
uuidstring唯一标识符
typeBlockType判别类型
labelstring?人类可读的名称
parentLabelsstring[]?编辑器中的父文件夹层级
propertiesBlockProperty[]键值属性
userPropertiesRecord?自由格式的用户属性
nativePropertiesNativeProperties?执行属性
metadataBlockMetadata?显示元数据(角色、标签、颜色)
isStartBlockboolean?标记入口 block

NativeProperties

字段类型描述
isAsyncboolean?在并行异步轨道上执行
delaynumber?执行前的延迟(由 onBeforeBlock 消费)
timeoutnumber?执行超时时间
portPerCharacterboolean?metadata 中每个角色对应一个输出 port
skipIfMissingActorboolean?如果引用的 actor 不存在则跳过 block
debugboolean?编辑器调试标志
waitForBlocksstring[]?此 block 可以继续之前必须已访问的 block UUID
waitInputboolean?用于显式玩家输入控制的被动标志
enableDispatcherboolean?dispatcher 模式:所有匹配的条件作为 async track 触发,false/default port 变为继续 track