DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 19
STEP 01 / CODE / LOGIC

先说结论

对话节点保存当前内容,选项表示可以走的边,Condition 只判断能否选择,Effect 在选择成立后修改状态,Jump 用稳定 ID 进入下一个节点。判断和修改必须分开,否则仅仅显示选项就可能消耗物品。

当前节点:town_01
玩家金币:30
选项 A:支付 20 金币,跳到 secret_02
选项 B:离开,跳到 exit_01

分类:工程设计

先说结论

对话节点保存当前内容,选项表示可以走的边,Condition 只判断能否选择,Effect 在选择成立后修改状态,Jump 用稳定 ID 进入下一个节点。判断和修改必须分开,否则仅仅显示选项就可能消耗物品。

购买情报的一次跳转

当前节点:town_01
玩家金币:30
选项 A:支付 20 金币,跳到 secret_02
选项 B:离开,跳到 exit_01

显示选项 A 时只检查 30 >= 20,金币仍是 30。玩家真正点击后才执行 金币 -= 20,结果为 10,再跳到 secret_02。若目标节点不存在,加载阶段就应报告错误,而不是运行到一半停在空白页面。

生活化模型:一本可以选择路线的导览册

想象一本互动导览册:

当前页:正在展示的内容。
页面选项:读者接下来可以选什么。
条件:某条路线是否对当前读者开放。
效果:选择后盖章、领取票券或改变记录。
跳转:翻到哪一页。

对应到对话系统:

节点 Node:一段对话状态。
选项 Option:从当前节点发出的候选动作。
条件 Condition:选项或节点是否可进入。
效果 Effect:确认选择后产生的状态变化。
跳转 Target:下一节点标识。
上下文 Context:条件读取和效果修改的数据。

对话系统不是只有一串文字。它是一台根据上下文在图上移动、执行受控状态变化的解释器。

通用概念与具体框架边界

下面是通用设计概念:

内容以节点和边组织。
条件决定候选是否可用。
效果在明确提交点改变上下文。
跳转决定下一个节点。
加载前验证引用和规则。
运行时保留失败原因。

具体框架可以选择:

树、一般有向图或脚本语言。
条件使用代码、表达式还是数据表。
效果立即执行、排队执行或事务提交。
节点 ID 使用字符串、整数还是资源标识。
是否允许循环。
文本与本地化怎样存储。

这些选择没有唯一标准。本文使用有向图作为教学模型。

职责图

                   ┌──────────────────┐
                   │ Dialogue Graph   │
                   │ Nodes by ID      │
                   └────────┬─────────┘
                            │ 查找
                            ▼
                   ┌──────────────────┐
                   │   Current Node   │
                   │ Text + Options   │
                   └────────┬─────────┘
                            │ 枚举
             ┌──────────────┼──────────────┐
             ▼              ▼              ▼
          Option A       Option B       Option C
             │              │              │
          Condition      Condition      Condition
             │ 通过          │ 失败          │ 错误
             ▼              ▼              ▼
        可选择并展示      隐藏或禁用      暴露验证失败
             │
             ▼
           Effect ──成功──> Target Node
             │
             └──失败──> 保持失败可见,不伪装成跳转成功

“条件不满足”和“条件求值失败”必须区分。前者是正常业务结果,后者可能是数据缺失或表达式错误。

Node:对话状态

一个节点至少需要稳定标识,并可包含:

要展示的文本引用。
说话者或表现元数据。
可选的进入条件。
可选的进入效果。
一组 Options。
结束标记或明确的终止类型。

不是每个节点都必须有文字。例如分支节点可以只负责条件路由;等待节点可以由外部事件继续。但这些节点类型必须在数据模型中明确,而不是用空字符串猜测含义。

Option:从当前节点发出的边

Option 通常包含:

选项自身标识。
显示文本。
可用条件。
选择效果。
目标节点 ID。

显示顺序、隐藏或禁用策略、是否允许自动选择,都是表现或交互规则。

当没有可用选项时,系统不能擅自跳到第一条边。它应根据节点的明确终止规则结束,或报告“无可用出口”。

Condition:只读判断

Condition 应尽量只读取 Context,并返回结构化结果:

Passed:条件成立。
Rejected:条件正常计算,但不成立。
Faulted:条件无法正确计算,并携带错误。

条件求值不应偷偷修改上下文,否则:

仅仅预览选项就可能改变状态。
多次刷新 UI 会重复产生副作用。
验证与真正选择难以分离。

如果确实需要有副作用的检查,它应被建模为明确命令,而不是伪装成 Condition。

Effect:受控状态变化

Effect 在确认选择或进入节点的明确阶段执行。

常见效果概念包括:

设置标记。
修改数值。
发出命令。
记录已访问节点。
启动外部流程。

效果的权威性与持久化方式取决于宿主系统,不能假定所有效果都只改本地字典。

多个效果的提交语义

一个 Option 有多个效果时必须明确:

按什么顺序执行?
中途失败时,前面的效果是否保留?
是否支持事务或补偿?
跳转发生在效果之前还是之后?

本文的最小示例采用:

效果按数据顺序执行。
任一效果失败则停止,不执行跳转。
已经执行的效果不会被假定自动回滚。

这只是明确的教学语义。若需要原子提交,必须由 Context 或命令层提供事务能力。

Jump:用稳定 ID 跳转

跳转通常保存目标节点 ID,而不是直接依赖编辑器中的临时列表位置。

执行跳转前要验证:

目标 ID 存在。
目标节点类型可进入。
目标进入条件成功求值并通过。
当前运行上下文仍有效。

目标缺失时不能默认跳到开始节点,因为这会掩盖数据错误,也可能形成难以发现的循环。

内容加载时验证

能在运行前发现的问题,不应等用户选到那一支才暴露。

静态验证可以检查:

节点 ID 是否唯一。
入口节点是否存在。
每个 Target 是否存在。
Option ID 在所属节点内是否唯一。
必填文本或资源引用是否存在。
节点类型所需字段是否齐全。
条件和效果类型是否已注册。
不允许循环的图是否存在环。
必须可达的节点是否从入口可达。
非终止节点是否至少有一条结构出口。

“不可达节点”和“环”不一定永远是错误:

隐藏入口可能由外部事件进入。
循环可能用于重复询问。

验证器应根据明确内容规则分类为错误、警告或允许,而不是凭图形特征一刀切。

逐步状态:选择一个选项

以下 ID 都是教学示例

当前节点:N_Ask
候选选项:O_Accept、O_Leave

执行链:

1. 验证当前节点仍是 N_Ask。
2. 按 Option ID 找到 O_Accept。
3. 求值 O_Accept 的 Conditions。
4. 条件全部 Passed,选项获得提交资格。
5. 执行 Effects。
6. 每个 Effect 都成功。
7. 查找 Target Node。
8. 求值目标进入条件。
9. 更新 Current Node。
10. 返回新节点快照。

任一步失败,都应返回阶段、节点 ID、选项 ID 和原始错误。

可交互面板

可以同时显示:

图中的当前节点。
所有候选选项。
每个条件的 Passed / Rejected / Faulted。
待执行效果列表。
已经执行到哪个效果。
目标节点解析结果。
本次跳转结果。
访问历史。

拖动条件结果或删除目标节点,可以观察系统停在哪一阶段,而不是只看到“对话没反应”。

最小 C# 数据模型

public sealed record DialogueNode(
    string Id,
    string TextKey,
    IReadOnlyList<DialogueOption> Options,
    bool IsTerminal);

public sealed record DialogueOption(
    string Id,
    string TextKey,
    IReadOnlyList<ICondition> Conditions,
    IReadOnlyList<IEffect> Effects,
    string TargetNodeId);

public sealed record Evaluation(
    bool Passed,
    Exception? Error = null);

public interface ICondition
{
    Evaluation Evaluate(DialogueContext context);
}

public interface IEffect
{
    Exception? Apply(DialogueContext context);
}

public sealed record TransitionResult(
    bool Succeeded,
    DialogueNode? Node,
    string? Stage,
    Exception? Error);

这里用 Exception? 是为了展示原始失败可见性。实际系统也可以使用不依赖异常的结构化错误类型。

最小 C# 跳转流程

public static TransitionResult Select(
    IReadOnlyDictionary<string, DialogueNode> nodes,
    DialogueContext context,
    DialogueNode current,
    string optionId)
{
    DialogueOption? option =
        current.Options.FirstOrDefault(item => item.Id == optionId);

    if (option is null)
    {
        return new TransitionResult(
            false, null, "FindOption",
            new InvalidOperationException($"Unknown option: {optionId}"));
    }

    foreach (ICondition condition in option.Conditions)
    {
        Evaluation evaluation = condition.Evaluate(context);

        if (evaluation.Error is not null)
            return new TransitionResult(false, null, "Condition", evaluation.Error);

        if (!evaluation.Passed)
        {
            return new TransitionResult(
                false, null, "ConditionRejected", null);
        }
    }

    foreach (IEffect effect in option.Effects)
    {
        Exception? error = effect.Apply(context);

        if (error is not null)
            return new TransitionResult(false, null, "Effect", error);
    }

    if (!nodes.TryGetValue(option.TargetNodeId, out DialogueNode? target))
    {
        return new TransitionResult(
            false, null, "ResolveTarget",
            new InvalidOperationException(
                $"Unknown target node: {option.TargetNodeId}"));
    }

    return new TransitionResult(true, target, null, null);
}

这个最小示例没有目标节点进入条件、事务和异步效果;这些需要在扩展模型时明确加入。

它也没有以下 fallback:

找不到 Option 就选择第一项。
条件报错就当作 false。
效果失败仍继续跳转。
目标缺失就回到入口。

进阶:异步条件与效果

如果 Condition 或 Effect 依赖异步操作,还要增加:

对话会话 generation。
CancellationToken。
选择提交期间的重复点击保护。
晚到回调的归属验证。
失败和取消的独立结果。

异步完成时必须确认:

当前仍是同一对话会话。
当前节点与选择请求仍匹配。
该选择尚未被取消或替代。

否则旧效果可能在用户已经离开节点后写入新上下文。

正常路径

入口节点存在。
当前节点成功展示。
条件正常求值。
用户选择可用 Option。
效果全部成功。
Target 存在且允许进入。
Current Node 更新。
终止节点按明确规则结束会话。

边界路径

节点是合法终止节点,没有 Options。
多个 Options 文本相同,但 ID 不同。
条件正常返回 Rejected。
图中存在明确允许的循环。
节点可由外部事件进入,因此从主入口不可达。
效果列表为空,选择只负责跳转。
选项显示后、点击前 Context 已变化,需要重新验证。

最后一种情况说明条件不能只在 UI 展示时检查一次,提交前还要验证仍然成立。

失败路径

入口节点缺失。
节点或 Option ID 重复。
Target ID 不存在。
条件求值抛错或引用缺失数据。
效果执行到一半失败。
目标进入条件失败。
异步回调属于旧会话。
不允许循环的数据中检测到环。
非终止节点没有任何可用出口。
本地化或表现资源缺失。

失败应指出:

Graph / Node / Option。
Condition 或 Effect 类型。
执行阶段。
原始错误。
是否已经产生部分效果。

代价与复杂度

若节点按 ID 使用哈希表索引,平均查找目标通常可接近常数时间。展示一个含 O 个选项、每项平均 C 个条件的节点,条件枚举成本可粗略写为:

O(O × C)

实际成本主要取决于条件本身。如果条件触发文件、网络或复杂查询,图查找成本反而不是重点。

静态验证常包含:

遍历所有节点和边:O(V + E)。
唯一性与引用检查:取决于索引结构。
环与可达性检查:通常也可在线性图遍历内完成。

其中 V 是节点数量,E 是跳转边数量。

更多数据驱动能力会增加:

编辑器与验证器复杂度。
版本迁移成本。
错误定位链长度。
异步和事务语义。
本地化资源管理。

最重要的收获

Node 表达状态,Option 表达候选边。
Condition 应是无副作用判断,并区分 Rejected 与 Faulted。
Effect 是明确提交的状态变化。
Jump 必须解析稳定 Target ID,缺失时暴露错误。
静态验证负责提前发现重复 ID、悬空引用和违反规则的图结构。
效果失败不能伪装成跳转成功,部分提交也必须可见。
异步对话同样需要 generation、取消和晚到回调验证。