用户登录

技术文章

外部链接将在新标签页打开。

视觉小说与漫画的分支叙事:把剧情写成可测试的状态机

一句话回答

把分支叙事拆成剧情图、持久状态与显式转移三层,把守卫条件和副作用从脚本里提出来,就能用图不变量检查、转移表测试、路径枚举和存档回放来验证。漫画的解锁式叙事更适合建模为偏序集合,而不是选择树;状态字段应尽量少,并带 schema 版本以便迁移。

核心要点 TL;DR
  • 分支难测的根因是状态隐式、条件散落在脚本里;把 guard 和 effect 提取为数据,才能静态枚举和断言。
  • 可测试性分四层:图不变量、转移表、路径枚举与覆盖率、存档往返与版本迁移。
  • 只把影响未来分支的数据放进持久状态;已读、解锁与剧情标记分开,能显著降低存档迁移成本。

把分支叙事写成可测试的状态机,核心只有一句话:让“当前在哪、持有什么标记、下一步有哪些合法选择”变成显式数据,而不是散落在脚本里的 if。做到这一点,分支就能被枚举、被回放、被断言;做不到,测试只能靠人手点一遍,而且点不完整。

本文讨论的是工程设计,不涉及具体作品评价。文中的代码是结构示意,未实际执行,不附带运行结果;也没有引用外部资料,因为本次提供的素材与叙事系统无关。涉及具体工具链能力的地方标注为待核实。

分支叙事为什么容易失控#

常见写法是把剧情写成一段脚本,条件判断直接读全局变量:

if (affection["lin"] > 3 && !flags["saw_letter"]) {
  goto("rooftop_secret");
}

这种写法在分支少时没问题,规模一上来会暴露四个问题。

  • 状态隐式:哪些变量影响分支,只有读完全部脚本才知道;新增一个 flag 可能悄悄改变旧路径的行为。
  • 组合爆炸:十个二值 flag 就有 1024 种组合,靠人工遍历不现实。
  • 内容与逻辑耦合:改一句台词可能连带改动跳转条件,评审时很难看出影响面。
  • 不可回放:随机数、系统时间、外部数据直接写在判断里,同一个存档在不同时刻可能走出不同结果,bug 无法复现。

三层模型:剧情图、持久状态、显式转移#

把叙事拆成三层,每层单独可测。

第一层:剧情图

节点是场景、页或对话单元,边是选择。节点只保存稳定 id 和内容引用,不保存逻辑。图是数据,可以序列化成 JSON,也就可以做静态检查。

第二层:持久状态

状态只放会影响未来分支的数据。建议至少分成三组,不要混在一起:

字段含义是否进存档
nodeId当前所在节点是
flags剧情标记,如“拿到信”是
counters数值,如好感度、选择次数是
seen已读节点集合,用于二周目与跳过是,但可与剧情状态分离
uiState文字速度、当前对话框位置否

把 seen 单独拿出来,是因为它只增不减,且通常不参与分支判断。混进剧情状态后,每次改阅读体验都要做存档迁移。

第三层:转移

转移是纯函数:给定状态和一个选择,返回新状态。守卫条件(guard)决定选择是否可选,效果(effect)声明状态如何变化。两者都应是数据或受限表达式,而不是任意代码——任意代码无法静态枚举,只能动态测。

把转移写成纯函数#

下面的 TypeScript 片段只示意结构,未执行,也没有配套测试输出。它演示三个约束:不修改入参、随机数从外部注入、状态带版本号。

interface StoryState {
  readonly nodeId: string;
  readonly flags: Readonly<Record<string, boolean>>;
  readonly counters: Readonly<Record<string, number>>;
  readonly seen: ReadonlySet<string>;
  readonly schemaVersion: number;
}

interface Choice {
  readonly id: string;
  readonly to: string;
  readonly guard?: (s: StoryState) => boolean;
  readonly effect?: (s: StoryState, rng: () => number) => StoryState;
}

function step(state: StoryState, choice: Choice, rng: () => number): StoryState {
  if (choice.guard && !choice.guard(state)) {
    throw new Error(`choice ${choice.id} unavailable at ${state.nodeId}`);
  }
  const applied = choice.effect ? choice.effect(state, rng) : state;
  return { ...applied, nodeId: choice.to, seen: new Set([...applied.seen, choice.to]) };
}

随机数作为参数传入,是为了让测试可以固定种子、让回放可复现。同理,时间、平台、玩家输入都应从边界注入,不要直接读全局。

可测试性的四个层次#

1. 图不变量检查

这类检查不需要跑剧情,只要图数据,适合放在提交前。至少覆盖:

  • 从起点出发,每个节点可达;反向遍历可找出孤儿节点。
  • 每个非结局节点至少有一个出口;每个出口的 to 都存在。
  • 每个结局都至少有一条从起点到达它的路径。
  • 没有只进不出的“死胡同”,除非它被标记为结局。

如果 guard 是受限表达式而非任意函数,还可以做可满足性近似检查:某个选择是否在任何一个可达状态下都不可能成立。真正的可满足性求解成本较高,工程上通常退化为“用可达状态样本代入求值”。

2. 转移表测试

为每个选择准备满足与不满足 guard 的两组状态,断言:可用性判断正确、effect 只改声明的字段、未声明的字段保持不变。这类测试数量与选择数成正比,写起来机械,但回归价值最高。

3. 路径枚举与覆盖率

对图做深度优先搜索,枚举到指定深度或到结局为止的路径,统计节点覆盖与边覆盖。下面的骨架同样未执行,且省略了环检测与深度上限,实际使用必须加上,否则带环的图不会终止。

function enumeratePaths(start: StoryState, graph: Map<string, Choice[]>, maxDepth: number) {
  const result: string[][] = [];
  const walk = (s: StoryState, acc: string[]) => {
    if (acc.length >= maxDepth) return;
    const choices = (graph.get(s.nodeId) ?? []).filter(c => !c.guard || c.guard(s));
    if (choices.length === 0) { result.push(acc); return; }
    for (const c of choices) walk(step(s, c, () => 0.5), [...acc, c.id]);
  };
  walk(start, []);
  return result;
}

路径数量会随分支指数增长,所以实际做法是设上限、按覆盖率采样,而不是穷举全部组合。属性测试更适合这类场景:随机生成选择序列,断言“同一序列重放两次得到相同状态”“存档往返后状态相等”。

4. 存档往返与版本迁移

存档必须带 schemaVersion,迁移写成链式函数:v1 到 v2、v2 到 v3,逐级升级,不跳级。测试用几份真实结构的旧存档样本(脱敏后)跑迁移,断言迁移后可继续执行且关键标记不丢。

另一个常被忽略的点是内容 id 的稳定性。节点 id 一旦发布就不要复用;需要下线时保留墓碑记录,让旧存档能识别“这里曾经有一个节点”,而不是跳到另一个同 id 的新场景。

漫画的分支:偏序,而不是选择树#

视觉小说的分支通常是“选择 → 不同场景”,可以用树或带汇合的有向图描述。漫画式叙事的常见形态不同:读者按页或按格推进,分支更多表现为解锁——读完 A 才能看 B,B 和 C 之间没有互斥关系。

这时进度不是一个线性位置,而是一个已读集合;“当前在哪”反而次要。建模时把解锁条件写成对集合的谓词,把章节门(chapter gate)作为主要控制点,而不是给每一页都挂 flag。否则页数一多,状态字段会膨胀到无法维护。

如果确实存在“先看 B 还是先看 C”的顺序差异,需要显式设计汇合点,否则两条顺序会各自派生后续内容,图会迅速膨胀。汇合点之后的文案不能假设读者一定先看过哪一条。

边界与取舍#

  • 不是所有作品都值得状态机。线性叙事、无跨场景状态、单人维护的项目,脚本内条件更直接。
  • guard 的表达力要有上限。只允许字段比较、布尔组合和简单计数,超过这个范围就退化成另一门编程语言,静态分析随之失效。
  • 性能通常不是瓶颈。状态机本身开销很小,真正的成本在内容加载与资源切换;路径枚举则需要显式上限,避免在大型图上跑爆内存。
  • 工具选择待核实。现成的叙事脚本语言各有能力边界,本文没有核验其导出格式、许可与运行时支持,无法给出推荐。可操作的判断标准只有两条:能否导出可枚举的图,状态能否序列化并带版本。

落地顺序#

  1. 先把图数据抽出来,写一个只做引用检查和可达性检查的脚本,接进提交流程。
  2. 再把 guard 与 effect 从脚本里提成数据,补转移表测试。
  3. 加上 schemaVersion 与迁移链,用旧存档样本做回归。
  4. 最后才做路径枚举与覆盖率统计,并设定深度与数量上限。

这个顺序的好处是每一步都能独立交付:第一步就能拦住悬空跳转,第二步能拦住条件写反,第三步能拦住改字段导致的读档崩溃。路径枚举放在最后,是因为它依赖前三步产出的结构化数据;没有前两步,枚举本身也没有可靠的输入。

常见问题(FAQ)

所有视觉小说都需要状态机吗?
不必。线性叙事或分支极少、又没有跨场景状态的作品,脚本内条件更省事;状态机的收益随分支数量、存档需求和多人协作规模上升。
应该用现成的叙事脚本语言还是自己写?
本文没有核验具体工具的能力与许可,无法给出推荐。可操作的判断标准是:它能否导出可枚举的图结构,以及状态能否序列化并带版本号。

作者:沐小悠

本文约 11 分钟读完 · 关键词:视觉小说、分支叙事、状态机、剧情图、可测试性

更多文章 GET 示例接口