生命周期与验证
每个 Block 的执行顺序
- 上一个 block 的清理 — 上一个 block 的 handler 返回的清理函数在转换时执行(
next()被调用时) onValidateNextBlock— 执行前的验证onBeforeBlock— 预处理(必须调用resolve()才能继续)- 类型 handler(先第 2 层,再第 1 层)
Scene 事件
engine.onSceneEnter(({ scene, context }) => {
// Called when handle.start() is executed
});
engine.onSceneExit(({ scene, context }) => {
// Called when the scene ends. context.reason says why:
// 'completed' | 'cancelled' | 'invalidated' | 'faulted' | 'deadlocked'
// scene.getSceneId() says WHICH scene — a global handler needs it when several play.
if (context.reason === 'faulted') {
console.error(`Scene ${scene.getSceneId()} failed`, context.error);
}
if (context.reason === 'deadlocked') {
console.warn('Scene stuck waiting for', context.waitingFor);
}
});engine.OnSceneEnter(args => {
// Called when handle.Start() is executed
});
engine.OnSceneExit(args => {
// Called when the scene ends. args.Context.Reason says why (SceneEndReason.*)
// args.Scene.GetSceneId() says WHICH scene — a global handler needs it when several play.
if (args.Context.Reason == SceneEndReason.Faulted)
Debug.LogException(args.Context.Error);
if (args.Context.Reason == SceneEndReason.Deadlocked)
Debug.LogWarning($"{args.Scene.GetSceneId()} stuck waiting for {string.Join(", ", args.Context.WaitingFor)}");
});engine.onSceneEnter([](const lsde::SceneLifecycleArgs& args) {
// Called when handle->start() is executed
});
engine.onSceneExit([](const lsde::SceneLifecycleArgs& args) {
// Called when the scene ends. args.context.reason says why (lsde::SceneEndReason::*)
// args.scene->getSceneId() says WHICH scene — a global handler needs it when several play.
if (args.context.reason == lsde::SceneEndReason::Faulted) {
// args.context.error holds the exception: std::rethrow_exception it to read it
}
if (args.context.reason == lsde::SceneEndReason::Deadlocked) {
// args.context.waitingFor names the blocks the scene was stuck on
}
});engine.on_scene_enter(func(args):
pass # Called when handle.start() is executed
)
engine.on_scene_exit(func(args):
# Called when the scene ends. args["context"]["reason"] says why (LsdeTypes.SCENE_END_*)
# args["scene"].get_scene_id() says WHICH scene — a global handler needs it when several play.
if args["context"]["reason"] == LsdeTypes.SCENE_END_DEADLOCKED:
push_warning("%s stuck waiting for %s" % [args["scene"].get_scene_id(), str(args["context"]["waitingFor"])])
)onValidateNextBlock
拦截每次 block 转换进行验证。handler 接收下一个 block(nextContext)和上一个 block(fromContext)的已解析角色:
engine.onValidateNextBlock(({ nextBlock, fromBlock, nextContext, fromContext }) => {
return { valid: true };
});
engine.onInvalidateBlock(({ scene, reason }) => {
console.error('Invalid block:', reason);
scene.cancel();
});engine.OnValidateNextBlock(args => {
// args.NextContext.Character, args.FromContext?.Character
return new ValidationResult { Valid = true };
});
engine.OnInvalidateBlock(args => {
Console.Error.WriteLine($"Invalid block: {args.Reason}");
args.Scene.Cancel();
});engine.onValidateNextBlock([](const auto& args) {
// args.nextContext.character, args.fromContext.character (check args.hasFromContext)
return ValidationResult{true};
});
engine.onInvalidateBlock([](auto* scene, const auto& reason) {
std::cerr << "Invalid block: " << reason << "\n";
scene->cancel();
});engine.on_validate_next_block(func(args):
# args["nextContext"]["character"], args["fromContext"]["character"]
return {"valid": true}
)
engine.on_invalidate_block(func(args):
printerr("Invalid block: %s" % args["reason"])
args["scene"].cancel()
)Character Gating
使用 nextContext.character 根据游戏状态控制哪些 block 可以执行:
// Block if the character is stunned
engine.onValidateNextBlock(({ nextContext }) => {
const { character } = nextContext;
if (!character) return { valid: false, reason: 'no_character' };
if (game.characterHasStatus(character, 'stunned'))
return { valid: false, reason: 'character_stunned' };
return { valid: true };
});engine.OnValidateNextBlock(args => {
var character = args.NextContext.Character;
if (character == null)
return ValidationResult.Fail("no_character");
if (game.CharacterHasStatus(character, "stunned"))
return ValidationResult.Fail("character_stunned");
return ValidationResult.Ok();
});engine.onValidateNextBlock([&game](const auto& args) {
auto* character = args.nextContext.character;
if (!character) return ValidationResult{false, "no_character"};
if (game.characterHasStatus(character, "stunned"))
return ValidationResult{false, "character_stunned"};
return ValidationResult{true};
});engine.on_validate_next_block(func(args):
var character = args["nextContext"]["character"]
if character == null:
return {"valid": false, "reason": "no_character"}
if game.character_has_status(character, "stunned"):
return {"valid": false, "reason": "character_stunned"}
return {"valid": true}
)使用 fromContext.character 验证角色之间的转换(例如:关系检查、冷却时间)。fromContext 在场景的第一个 block 中为 null。
onBeforeBlock
在每个 block 之前调用。必须调用 resolve() 才能继续:
engine.onBeforeBlock(({ block, resolve }) => {
const delay = LsdeUtils.getNativeProperties(block)?.delay;
if (delay) {
setTimeout(resolve, delay * 1000);
} else {
resolve();
}
});engine.OnBeforeBlock(args => {
var delay = args.Block.NativeProperties?.Delay;
if (delay.HasValue)
{
// use your engine's delay system (coroutine, DOTween, Invoke, etc.)
DelayThenCall((float)delay.Value, args.Resolve);
}
else
{
args.Resolve();
}
});engine.onBeforeBlock([](const auto& args) {
auto delay = args.block->props
? args.block->props->delay : std::nullopt;
if (delay.has_value()) {
// use your engine's timer system (FTimerManager, SDL_AddTimer, etc.)
scheduleDelay(delay.value(), [&args]() { args.resolve(); });
} else {
args.resolve();
}
});engine.on_before_block(func(args):
var delay = args["block"].get("props", {}).get("delay", 0)
if delay > 0:
await get_tree().create_timer(delay).timeout
args["resolve"].call()
)在 onBeforeBlock 仍在执行时调用的 resolve(),会在它返回时才生效 — 与在 handler 内调用 next() 完全一样。类型 handler 在你的回调结束之后才被分发,因此写在 resolve() 之后的代码会在 block 之前运行。
清理函数
handler 可以返回一个清理函数,在离开 block 时调用:
engine.onDialog(({ block, next }) => {
const element = showDialogUI(block);
// next() is called later — by player input, timer, etc.
return () => {
element.remove(); // called when the engine moves to the next block
};
});engine.OnDialog(args => {
var element = ShowDialogUI(args.Block);
// next() is called later — by player input, timer, etc.
return () => element.SetActive(false);
});engine.onDialog([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
auto* element = showDialogUI(block);
// next() is called later — by player input, timer, etc.
return [element]() { element->remove(); };
});engine.on_dialog(func(args):
var element = show_dialog_ui(args["block"])
# next is called later — by player input, timer, etc.
return func(): element.queue_free()
)错误边界
没有任何异常被吞掉。 当 engine 遍历图时,如果你的任何代码抛出异常 — 类型 handler、清理函数、 onValidateNextBlock、onInvalidateBlock、onBeforeBlock、onResolveCondition、onResolveCharacter 或 onSceneEnter — engine 会先关闭整个 scene,然后把错误重新抛给调用 start()、next() 或 resolve() 的一方。
这在每一条轨道上都成立。在 isAsync 分支上抛出异常的 handler 会关闭 scene,而不仅仅是它的分支。
正是这个顺序让它可用。当错误到达你的代码时:
- 清理函数已经执行
- async 轨道已经取消
onSceneExit已经以reason: 'faulted'触发,错误本身在context.error中
对话干净地停止了,接下来做什么由你决定 — 不带它继续、显示一个画面,或者让它崩溃。请在 start()、next() 或 resolve() 外面放上你自己的 try/catch。
错误有意地出现在两个地方。它被抛给调用 next() 的一方 —— 在游戏中通常是一次点击或一个计时器 —— 同时通过 context.error 交给 onSceneExit,而等待对话结束的代码正是在那里监听。只在其中一处记录日志。
由 onSceneExit 本身抛出的异常也以同样的方式到达你这里,而 scene 依然会被释放:engine.isRunning() 不再计入它。除非 scene 本来就正因一个错误而关闭:那时你收到的是那个错误,正是它解释了其余的一切。
GDScript
GDScript 没有异常。handler 中的脚本错误会输出到 Godot 日志,调用返回 null:block 只会一直等待一个 永远不会到来的 next()。没有任何东西会替你关闭 scene。
为什么改变
v1 会静默吞掉 handler 的异常 — 连日志都没有 — 而同一个 handler 返回的清理函数抛出的异常却会到达 调用方。同一种故障,两种相反的行为,而安静的那一种在项目运行的整个期间掩盖了真正的 bug。
2.0.0 只为类型 handler 修复了这一点。抛出异常的验证、onBeforeBlock 或解析器 — 或者并行轨道上的 handler — 仍会让 scene 保持打开,却没有任何东西能让它前进,等待对话结束的游戏会永远等下去。
Scene 结束的原因
onSceneExit 通过 context.reason 得知原因:
reason | 何时 |
|---|---|
completed | 流程走到了图的尽头 |
cancelled | scene.cancel() 或 engine.stop() |
invalidated | onValidateNextBlock 拒绝了最后一条运行中的轨道正要进入的 block |
faulted | 遍历过程中你的代码抛出了异常。context.error 保存了被抛出的内容 — 参见错误边界 |
deadlocked | 剩下的所有轨道都停在一个没有任何东西能完成的 waitForBlocks 上。context.waitingFor 列出这些 block |
onSceneEnter 不会收到原因。
scene.getSceneId() 说明哪一个 scene 结束了,scene.getScenePath() 给出它的路径。只要有两个 scene 同时播放,全局的 onSceneExit 就需要它。请保存 id 而不是路径:有人重命名 scene 的那一天,路径就会改变。
死锁会关闭 scene。 一个等待永远不会有轨道完成的 block 的 block — 例如流程没有走的分支上的 block — 过去会让 scene 永久保持打开,也没有 onSceneExit。现在,只要最后一条能前进的轨道停下,scene 就会以 deadlocked 关闭,waitingFor 会告诉你哪一个汇合接错了。
单一线程
engine 不是线程安全的。请从启动 scene 的线程调用 next()、resolve() 和 cancel() — 在 Unity 中是 主线程,在 Unreal 中是游戏线程。
在 C# 和 C++ 中,来自其他线程的调用会被一个指明两个线程的异常拒绝,并且不会改变任何东西:从正确的 线程调用时,同一个 next() 依然有效。在 Unity 中,调用 engine 之前请先切回主线程 (await UniTask.SwitchToMainThread(),或把调用排入主线程队列)。
cancel()
调用 scene.cancel() 会触发以下序列:
- 所有异步轨道被取消
- 当前 block 的清理函数被执行
onSceneExithandler 以reason: 'cancelled'被调用- scene 被标记为已完成
当 scene 已经在关闭时,清理函数调用 scene.cancel() 或 engine.stop() 会被忽略:onSceneExit 只触发一次。
engine.onInvalidateBlock(({ scene, reason }) => {
console.error('Validation failed:', reason);
scene.cancel();
});engine.OnInvalidateBlock(args => {
Console.Error.WriteLine($"Validation failed: {args.Reason}");
args.Scene.Cancel();
});engine.onInvalidateBlock([](auto* scene, const auto& reason) {
std::cerr << "Validation failed: " << reason << "\n";
scene->cancel();
});engine.on_invalidate_block(func(args):
printerr("Validation failed: %s" % args["reason"])
args["scene"].cancel()
)NativeProperties
控制 engine 如何调度 block 的执行属性:
| 字段 | 类型 | 描述 |
|---|---|---|
isAsync | boolean? | 在并行异步轨道上执行 |
delay | number? | block 播放前的毫秒数。由 onBeforeBlock 应用,engine 从不应用 |
timeout | number? | 台词说完之后 block 留在屏幕上的毫秒数,随后自己离开 — block 的自动推进。优先于 waitInput。原样传递,engine 不做任何强制 |
portPerCharacter | boolean? | metadata 中每个角色一个输出端口 |
skipIfMissingActor | boolean? | 如果引用的角色不存在则跳过 block |
debug | boolean? | 编辑器调试标志 |
waitForBlocks | string[]? | 本 scene 的 block id。在它们全部完成之前,block 会在被分发之前被扣住。如果没有任何东西能完成它们,scene 会以 deadlocked 关闭 |
waitInput | boolean? | 用于显式玩家输入控制的被动标志 — timeout 优先于它 |
