Skip to content

交互案例 · 状态树审计

永远不会打开的分支 ​

有条件通过·《玻璃花园》 · React + Zustand

TL;DR ​

《玻璃花园》看起来像一部常规的分支视觉小说:22 个场景、44 个选择,其中 9 个 带条件,7 个结局。静态图检查没有发现悬空跳转,也没有孤立场景。状态空间搜索 发现 7 个结局全部可达,但有一个条件选择不可达。唯一授予“黑金双色梳”的场景是 scene_2_05N;需要这把梳子的选择却位于 scene_2_03A,而图中没有从授予场景 回到那里的路径。故事仍然可以通关,这个分支只是永远不可见——这是最昂贵的一类 内容 bug,因为没有玩家能报告它。

背景 ​

源材料包含 scripts_src/ 下的 Ren'Py 风格脚本(label、jump、menu),以及 src/data/story.js 里的运行时故事图。运行时不是 Ren'Py 本身,而是 React + Zustand,但形状相同:场景表、带可选条件的选择,以及修改持久状态的 effects。

对图有影响的状态:

状态在图中的作用
san_value控制隐藏选项是否出现;clamp 在 [0, 100]
player_beliefunaware / amelia_lover / natalie_father,解锁身份线
identity_flag由信念 + 梳子派生,玩家不能直接选择
has_comb打开真结局通道;只在 scene_2_05N 被设置
inventory保存 天平宝剑、破损的钢斧、烂苹果金属罐、黑金双色梳
flags记录路线选择;用于诊断和未来条件

玩家看不到这些值的大部分。他们看到的是一段文字、一个看似不可选的选项,或者 干脆没有选项。这就是信息差:图知道某个分支为什么消失,玩家只知道它不在。

量化这张图 ​

我对故事数据跑了两类检查:

  1. 静态图检查——每个 next 目标都存在;忽略条件时,没有场景从 scene_1_01 不可达。
  2. 状态空间搜索——先应用选择 effects,再应用目标场景的 onEnter,在真实 条件下探索所有可达状态。这与 store 的 selectChoice 顺序一致。
指标结果
场景22
选择44
条件选择9
结局7
悬空 next 目标0
孤立场景0
探索到的唯一状态168
探索到的转移179
可达结局7/7
可达非重启选择36/37
不可达条件选择1

唯一不可达的是 scene_2_03A:use_comb_confrontation:

js
// story.js — 唯一授予 has_comb 的地方
scene_2_05N: {
  onEnter: {
    set: { san_value: 50, truth_key: true, has_comb: true },
    addItems: ['黑金双色梳'],
  },
  // ...
}

// story.js — 需要它的选择
scene_2_03A: {
  choices: [
    {
      id: 'use_comb_confrontation',
      condition: (state) => state.has_comb,
      next: 'scene_3_00_TE',
    },
  ],
}

scene_2_03A 从 scene_2_02A(宝剑线)进入。scene_2_05N 从祭坛、安全区出口 或金发画像支线进入;它的出边只通向 scene_3_00_TE 或 scene_hiddenB → scene_2_04A1。没有任何一条边回到 scene_2_03A。条件本身是 合法 JavaScript,目标也存在,但选择依然不可达。

边界条件 ​

真正有意思的条件不是显式的“有没有道具”,而是它的真假由更早的选择决定,往往 在玩家看到后果之前就已经写好。

门条件门前可达值状态
金发画像san_value < 1001_02A:只有 95潜伏——在唯一路径上恒为真
相信阿米莉亚san_value > 602_03A:93–95潜伏——在当前图中恒为真
隐藏结局san_value <= 40 && inventory.includes('天平宝剑')2_04A1:30–90生效——取决于之前的 SAN 损失
真结局梳子has_comb2_03A:永不为真;2_04A1:梳子路线为真在 2_03A 是死分支,在 2_04A1 生效

SAN 门很有启发性。san_value < 100 和 san_value > 60 看起来像动态难度检查, 但在它们所在的门前,可达值只有 95 和 93–95。它们是潜伏条件:代码正确,却在当前 图里从不改变可见路线。隐藏结局则是真正生效的:镜子扣 55,从 100 出发剩 45; 如果之前损失过 5 点,就剩 40,选项打开。梳子门正好相反:它在 2_04A1 为真, 在同样被求值的 2_03A 却永远为假。

这就是状态树测试的价值。单独测试每个条件只能证明谓词能工作,不能证明它会在一个 “能为真”的状态里被求值——也不能证明它能为假。死掉的梳子选择正是这个缺口: state.has_comb 可以为真,只是永远不在那个场景。

逻辑一致性测试 ​

  1. 引用完整性——每个选择目标都能解析到场景;正常游玩不会落到 MISSING_SCENE 兜底页。
  2. 可达性——从起点做 BFS,忽略条件,抓孤立场景和拼错的边。
  3. 状态可达性——在 (scene, san, inventory, has_comb, belief, flags) 上做 BFS,抓“结构合法但语义已死”的条件。
  4. 结局覆盖——断言每个结局场景至少被到达一次,且没有非结局场景是死路。
  5. 效果顺序——先应用选择 effects,再应用 onEnter,与 store 一致。顺序 反过来会改变目标场景里哪些门可见。
  6. Clamp 行为——SAN 保持在 [0, 100];背包去重;flags 合并而不是覆盖。
  7. 玩家/调试分离——开发模式元数据在故事书图标被三击之前不进入 DOM; 图审计不能依赖它。

证据 ​

检查项结果
非重启目标解析37/37
悬空目标0
孤立场景0
可达结局7/7
可达非重启选择36/37
不可达条件选择1(scene_2_03A:use_comb_confrontation)
无结局的死路0
探索到的唯一状态168
探索到的转移179

修复很小,但修法是一个设计决策:

  • 如果是遗留内容,删除这个死选择;或
  • 如果对质本来就该允许带梳子重演,增加一条从 scene_2_05N 回到 scene_2_03A 的路径;或
  • 把选择移到 scene_2_04A1,那里梳子确实可用。

结论 ​

有条件通过。 可玩的故事是连贯的:7 个结局全部可达,没有场景死路,没有断链。 只有一个条件选择永远看不到。这不是叙事的发布阻断项,但它是真实的内容缺陷,也是 对未来分支的警告:一张图可以结构合法,却包含任何状态都打不开的选项。

可复用的规则是:在状态空间里测试可达性,而不只是在场景图里。 一个能编译的 condition,不等于一个能触发的分支。

相关 ​

Crystaria (with Paw and Kyle)