From 752b4370782da2a8847fb34455a7837d89355042 Mon Sep 17 00:00:00 2001 From: cabinw Date: Mon, 14 Sep 2026 14:00:33 +0800 Subject: [PATCH] docs: design review and bench reliability analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design-review.html is the gameplay and UI read on the shipped campaign, with boards rendered from the repo's own geometry rather than redrawn. bench-reliability.html asks which of the bench's numbers are worth acting on, and answers it by running the same 18 levels twice under identical conditions. The mechanical rotation counters reproduce (r = 0.82-0.92) and three different models agree on them level for level, so they measure the level rather than the player — a 12-second rule-bot sweep gets the same answer as hours of model time. Difficulty and stuck ratings also hold up (r = 0.93 / 0.85), but the ratings for rotation, clarity and fun do not (0.32 / 0.19 / 0.56): asking a model whether rotation matters is noise, while counting blind rotations is not. Every figure is computed from bench/runs by build-bench-report.mjs; nothing in the page is hand-written, so it can be regenerated as runs accumulate. Co-Authored-By: Claude Opus 5 --- bench/v05-rulebot-summary.md | 85 +++ docs/bench-reliability.html | 414 ++++++++++++ docs/design-review.html | 1121 ++++++++++++++++++++++++++++++++ scripts/build-bench-report.mjs | 436 +++++++++++++ 4 files changed, 2056 insertions(+) create mode 100644 bench/v05-rulebot-summary.md create mode 100644 docs/bench-reliability.html create mode 100644 docs/design-review.html create mode 100644 scripts/build-bench-report.mjs diff --git a/bench/v05-rulebot-summary.md b/bench/v05-rulebot-summary.md new file mode 100644 index 0000000..d9b4752 --- /dev/null +++ b/bench/v05-rulebot-summary.md @@ -0,0 +1,85 @@ +# v0.5 千关 · 规则机器人扫描 + +日期:2026-09-14 · 工具:scripts/bench-batch.mjs(BENCH_PROVIDER=mock)· 12 秒 · 1000/1000 清盘 · 0 空点 + +规则机器人只做协议里的四条规则:路上没人、不紧贴谁就点;一枚都走不了就右转。它不读求解器,也不会左转——所以它的「盲转」就是「一个只会朝一个方向转的玩家会撞到的空朝向」。 + +## 总体 + +| 指标 | 值 | +|---|---:| +| 清盘 | 1000 / 1000 | +| 零旋转清盘 | 568 | +| 用了比最少多的旋转的关 | 75(7.5%) | +| 其中要绕整整一圈(连续 4 次空转)| **21** | +| 转错方向 ≥1 的关 | 75(全部只错 1 次:错一次就得绕一圈或绕回)| +| 动作 ÷ 棋子 中位 | 1.00(照规则玩没有任何浪费)| + +## 转错方向的 75 关按体验类型 + +| 体验 | 关数 | +|---|---:| +| 分段释放 | 75 | + +绕整整一圈的 21 关按体验类型:{"分段释放":21} + +## 要绕一圈的 21 关(右转是错的,而且要连错 4 次才回到对的朝向) + +| 关号 | 体验 | 格 | 枚 | 旋转 用/最少 | 步 | +|---:|---|---:|---:|---:|---:| +| 72 | 分段释放 | 37 | 8 | 6/2 | 14 | +| 78 | 分段释放 | 37 | 11 | 6/2 | 17 | +| 94 | 分段释放 | 37 | 9 | 6/2 | 15 | +| 99 | 分段释放 | 37 | 8 | 6/2 | 14 | +| 110 | 分段释放 | 37 | 11 | 6/2 | 17 | +| 184 | 分段释放 | 37 | 11 | 6/2 | 17 | +| 214 | 分段释放 | 61 | 10 | 6/2 | 16 | +| 254 | 分段释放 | 61 | 11 | 6/2 | 17 | +| 300 | 分段释放 | 91 | 14 | 6/2 | 20 | +| 322 | 分段释放 | 37 | 11 | 6/2 | 17 | +| 409 | 分段释放 | 37 | 10 | 6/2 | 16 | +| 494 | 分段释放 | 91 | 12 | 6/2 | 18 | +| 509 | 分段释放 | 61 | 13 | 6/2 | 19 | +| 522 | 分段释放 | 91 | 13 | 6/2 | 19 | +| 693 | 分段释放 | 91 | 15 | 6/2 | 21 | +| 757 | 分段释放 | 91 | 16 | 6/2 | 22 | +| 784 | 分段释放 | 37 | 11 | 6/2 | 17 | +| 850 | 分段释放 | 91 | 14 | 6/2 | 20 | +| 853 | 分段释放 | 37 | 11 | 6/2 | 17 | +| 860 | 分段释放 | 91 | 16 | 6/2 | 22 | +| 900 | 分段释放 | 91 | 14 | 6/2 | 20 | + +这些关的结构都是 createStageSeed 的 A/B/C 两转锁:第一转 +1 放出 B,第二转必须 **转回 0** 放 A。一个转过去之后继续朝同一方向转的玩家,会连续看到 4 个空朝向——这正是 D5 原型第 02 关被判「无解」的那种体验,在 v0.5 里有 21 关。 + +## 逐章 + +| 章 | 需旋转关 | 转错方向≥1 | 绕一圈 | 平均多转 | +|---:|---:|---:|---:|---:| +| 1 | 40% | 2 | 0 | 0.10 | +| 2 | 44% | 5 | 4 | 0.34 | +| 3 | 40% | 4 | 1 | 0.18 | +| 4 | 42% | 2 | 1 | 0.10 | +| 5 | 46% | 3 | 1 | 0.12 | +| 6 | 40% | 2 | 2 | 0.16 | +| 7 | 44% | 3 | 1 | 0.12 | +| 8 | 42% | 4 | 0 | 0.14 | +| 9 | 46% | 5 | 1 | 0.18 | +| 10 | 46% | 5 | 1 | 0.20 | +| 11 | 48% | 6 | 2 | 0.30 | +| 12 | 42% | 6 | 0 | 0.16 | +| 13 | 40% | 3 | 0 | 0.10 | +| 14 | 46% | 5 | 1 | 0.24 | +| 15 | 42% | 3 | 0 | 0.08 | +| 16 | 42% | 3 | 2 | 0.18 | +| 17 | 42% | 4 | 1 | 0.16 | +| 18 | 40% | 5 | 3 | 0.32 | +| 19 | 46% | 3 | 0 | 0.10 | +| 20 | 46% | 2 | 0 | 0.04 | + +20 章之间没有坡度:转错方向的关每章 2–6 个,绕一圈的关每章 0–2 个;最早的两关在第 2 章(L72、L78),第 1 章已有 2 关转错方向。全部 75 关都是「分段释放」这一种体验——问题集中在 createStageSeed 的 +1 再 −1 结构上。 + +## 怎么用 + +- 这 21 关是「体感无解」的高风险名单,优先人工看或让 Claude Code 跑( 人设 turner)。 +- 75 关转错方向的关,如果 §07 的闲置方向提示上线,就不再是问题;没上线之前,它们是流失点。 +- 规则机器人 0 空点、1.00 动作/棋子,说明**照规则玩 v0.5 没有任何需要判断的地方**——与「猴子玩家 100% 通关」互为印证:难度旋钮和选择旋钮都是零。 diff --git a/docs/bench-reliability.html b/docs/bench-reliability.html new file mode 100644 index 0000000..e7cb3bc --- /dev/null +++ b/docs/bench-reliability.html @@ -0,0 +1,414 @@ + + + + + +关卡实验台:信度检验与 1000 关流程 + + + +
+ +
+
HEX TRAILS · 关卡实验台
+

Agent 评级里,哪些数字真能拿来做关卡

+

同一批关卡、同一个模型、同一套盘面,跑两遍。能重现的指标才配进流程——这份报告把每个指标逐个检验了一遍,结论和直觉相反。

+

生成于 2026-09-14 13:55 GMT+8 · 数据来自 bench/runs 的 139 局 · 关卡集 levels/prototypes/d5.json(18 关)

+
+ +

01参与检验的批次

+

每一批用 agent 字段标识,不靠文件名猜来源。「旧盘面」指擦碰只报第一个阻挡者的版本,「新盘面」指修复后全部列出的版本。

+
+ + + + + + + +
批次关数通关空点擦碰空点盲转转错多转单局均每决策
grok-4.6 · 旧盘面1818/18442182283 s6.8 s
grok-4.6 · 新盘面 · 第 1 次1818/18212182255 s3.9 s
grok-4.6 · 新盘面 · 第 2 次1818/180025102849 s3.9 s
codex-spark · 旧盘面66/60022215 s1.4 s
kimi-k3 · 新盘面1818/18002182248 s5.2 s
+

所有批次通关率都是 100%。这本身就是一条结论:通关率在这批内容上毫无区分度,任何流程都不该建立在通过/失败上。

+ + +

02信度检验:跑两遍还一样吗

+

同模型(grok-4.6 xhigh)、同 harness(pi)、同盘面(新)、同 18 关,只是又跑了一遍。两次之间唯一的变量就是运行本身,所以下面的相关系数就是这个指标的复测信度。

+

判定规则:关间标准差 < 0.35 说明这个指标在不同关卡上几乎一样,再稳也没用(无法排序);复测 r < 0.5 说明换一次运行结果就变,不能作为依据。两条都过才算可用。

+
+ + + + + + + + + + + + + + +
指标关间标准差复测相关 r逐关完全一致判定
多转
清盘旋转数 − 求解器最少旋转数
1.580.8717/18可用
盲转
转了一次,什么出口都没打开
1.570.9217/18可用 · 高
转错方向
另一边本来能打开
0.500.8217/18可用
步数2.240.7616/18可用
决策数
看了几次盘面
1.890.7710/18可用
空点
点了没动
0.46—17/18噪声
动作÷棋子0.150.6916/18无区分度
评分:无解感0.870.8514/18可用
评分:难度0.590.9317/18可用 · 高
评分:旋转意义0.370.3215/18噪声
评分:清晰度0.470.1912/18噪声
评分:好玩0.420.5615/18可用
+

「关间标准差」衡量这个指标能不能把不同关卡区分开——标准差接近 0 的指标就算完全可复现也没用,因为每关都一样。两个条件要同时满足:既能区分关卡,又能跑两遍不变。

+ +
+

多转

r = 0.87
+ + + + + 第 1 次 + 第 2 次 +
+

盲转

r = 0.92
+ + + + + 第 1 次 + 第 2 次 +
+

转错方向

r = 0.82
+ + + + + 第 1 次 + 第 2 次 +
+

步数

r = 0.76
+ + + + + 第 1 次 + 第 2 次 +
+

决策数

r = 0.77
+ + + + + 第 1 次 + 第 2 次 +
+

动作÷棋子

r = 0.69
+ + + + + 第 1 次 + 第 2 次 +
+

评分:无解感

r = 0.85
+ + + + + 第 1 次 + 第 2 次 +
+

评分:难度

r = 0.93
+ + + + + 第 1 次 + 第 2 次 +
+
+

虚线是「两次完全相同」。点贴着虚线 = 可复现;点散开 = 这个数字换一次运行就变。

+ + + +

对照:换了盘面格式的那一对

+

上一节控制了所有变量。作为对照,这里是旧盘面 vs 新盘面的同 18 关配对——它多了一个变量(盘面变了),所以相关系数是信度的下限而非真值。两张表一起看,能分清「指标本身不稳」和「盘面变化导致的差异」。

+
+ + + + + + + + + + + + + + +
指标控制变量后 r换盘面后 r差异说明
多转0.871.00两者接近,盘面变化影响很小
盲转0.921.00两者接近,盘面变化影响很小
转错方向0.821.00控制变量后反而更低——值得留意
步数0.760.95控制变量后反而更低——值得留意
决策数0.770.87两者接近,盘面变化影响很小
空点—-0.10—
动作÷棋子0.690.92控制变量后反而更低——值得留意
评分:无解感0.850.80两者接近,盘面变化影响很小
评分:难度0.930.63控制变量后明显更高——之前被盘面变化压低了
评分:旋转意义0.320.06控制变量后明显更高——之前被盘面变化压低了
评分:清晰度0.190.09两者接近,盘面变化影响很小
评分:好玩0.560.21控制变量后明显更高——之前被盘面变化压低了
+ + +

03跨模型交叉验证:这些数字属于关卡,不属于玩家

+ +

下表是同一套盘面下所有批次的 多转 / 盲转 / 转错方向。不同厂商的模型、不同 harness、各自独立跑——如果这些数字真的由关卡决定,它们就该一样。

+
+
逐关完全一致
17 / 18
三项数字一个不差
+
参与批次
3
grok新① · grok新② · kimi
+
+
+ + + + + + + + + + + + + + + + + + + + +
关家族grok新①grok新②kimi一致
L1switchback0/0/00/0/00/0/0✔
L2switchback1/1/11/1/11/1/1✔
L3weave0/0/00/0/00/0/0✔
L4switchback1/1/11/1/11/1/1✔
L5weave1/1/11/1/11/1/1✔
L6weave0/0/00/0/00/0/0✔
L7petals0/0/00/0/00/0/0✔
L8petals0/0/00/0/00/0/0✔
L9weave2/1/02/1/02/1/0✔
L10petals0/0/00/0/00/0/0✔
L11petals0/0/00/0/00/0/0✔
L12switchback1/1/11/1/11/1/1✔
L13orbits4/4/110/8/34/4/1✘
L14crossing0/0/00/0/00/0/0✔
L15orbits4/4/14/4/14/4/1✔
L16orbits0/0/00/0/00/0/0✔
L17spiral4/4/14/4/14/4/1✔
L18spiral4/4/14/4/14/4/1✔
+
+

这是全份报告里最硬的一条结论。17/18 关上,独立运行的不同模型给出了完全相同的旋转三元组。这意味着规则机器人 12 秒扫出来的这三个数,等价于大模型跑几小时的结果——这个维度完全不需要 agent。

+
+ +

不一致的关:不是难,是不稳定

+

下面这些关在不同运行之间给出了不同的数字。排除模型差异后,剩下的解释是同一关的体验取决于起手方向——两个玩家玩同一关,一个觉得还行,一个觉得这关有毛病。这比"稳定地难"更值得处理。

+
L13 orbits:4/4/1 / 10/8/3 / 4/4/1
+ +

04家族结构:机械指标指向同一个地方

+

把「多转」按路线家族平均。这是关卡结构问题,不是玩家问题——两批 grok、以及 spark,在这一点上给出一致的结果。

+ +

grok-4.6 · 旧盘面 · 各家族平均多转

+
spiral2 关 + + 4.0
+
orbits3 关 + + 2.7
+
switchback4 关 + + 0.8
+
weave4 关 + + 0.8
+
petals4 关 + + 0.0
+
crossing1 关 + + 0.0
+ +

grok-4.6 · 新盘面 · 第 1 次 · 各家族平均多转

+
spiral2 关 + + 4.0
+
orbits3 关 + + 2.7
+
switchback4 关 + + 0.8
+
weave4 关 + + 0.8
+
petals4 关 + + 0.0
+
crossing1 关 + + 0.0
+ +

grok-4.6 · 新盘面 · 第 2 次 · 各家族平均多转

+
orbits3 关 + + 4.7
+
spiral2 关 + + 4.0
+
switchback4 关 + + 0.8
+
weave4 关 + + 0.8
+
petals4 关 + + 0.0
+
crossing1 关 + + 0.0
+ +

codex-spark · 旧盘面 · 各家族平均多转

+
switchback3 关 + + 0.3
+
weave3 关 + + 0.3
+ +

kimi-k3 · 新盘面 · 各家族平均多转

+
spiral2 关 + + 4.0
+
orbits3 关 + + 2.7
+
switchback4 关 + + 0.8
+
weave4 关 + + 0.8
+
petals4 关 + + 0.0
+
crossing1 关 + + 0.0
+
+

orbits 和 spiral 是问题家族。它们稳定地需要 7 次旋转(最少 3 次)外加 4 次盲转——等于把 6 个朝向挨个试一遍。而 petals 和 crossing 是干净的 0。锁住率解释不了这个差异(L16 也是 orbits、锁 78%,却是 3/3)。

+
+ +

05Agent 说了什么

+

评分是 1–5 的数字,worst 是它自己写的一句话。下面按反复出现的主题归类——价值在重复模式,不在单条内容。

+ +

擦碰看不出来 · 22 次提及

+
L1 switchback(grok旧):开局 t2/t11 路上看着没人其实会擦到,差点空点;只剩 t0 和 t3 对堵时短暂以为卡住了
+
L3 weave(grok旧):t5走后(第9步)t2和t3都像能出界,其实互相擦碰,只能再转
+
L3 weave(grok新①):朝向1时 t2 和 t3 互相擦碰、t0 被 t2 压着,短暂不确定是再转还是还有能点的
+
L3 weave(grok新②):第9步清完后t2和t3互相擦挡,谁先走都不行,只能转
+ +

不知道该转哪边 · 16 次提及

+
L2 switchback(grok旧):第 13 步右转后朝向 3 没新出口,t0 和 t3 仍互相挡,不确定该不该继续同向转
+
L2 switchback(grok新①):第15步右转后朝向3没有新出口,最后两枚还互压,一度担心转错了方向
+
L2 switchback(spark旧):第 8-9 步清掉 t5,t4,t2 后只剩 t0/t3 互压,朝向2不再有出口,开始不确定该是继续走还是转哪边
+
L3 weave(spark旧):第 5 步:第一轮转盘前所有可见线路都被压住/擦边,必须停下来判断该往哪个方向转
+ +

长路线读不动 · 20 次提及

+
L1 switchback(kimi):第 10 步后 t0/t2/t3/t10 四枚互相压成环,盘面上看不出解法,只能靠旋转试出新朝向
+
L6 weave(grok新①):开局 t0/t1 路线三十多格绕完全盘,只能靠路上有谁的摘要,看不出线实际怎么走
+
L7 petals(grok旧):开局 t0/t3 各 19 格 15 弯还叠着好几枚,文字路线太长,分不清该先解哪一层
+
L7 petals(grok新①):第 10 步再右转只开了 t7,t0 和 t3 仍 19 格对压,不确定是不是转错方向
+ +

061000 关该怎么跑

+

核心判断:机械计数器当主力,agent 做抽检。免费的规则机器人能扫全量并稳定给出盲转/多转/转错;agent 贵但能提供两样机器给不了的东西——难度的相对排序,和用自然语言点出的系统性缺陷。关键是只收集经过信度检验的那几项。

+
    +
  1. 生成期硬门槛 · 零成本

    用已有求解器,在生成时就卡掉:最少旋转 ≥ 2、开局锁住率 ≤ 90%、首转两个方向都能推进、无死局状态。不合格的根本不进候选池。

  2. +
  3. 规则机器人全量扫 · 1000 关 12 秒

    产出盲转 / 多转 / 转错方向。按 盲转 ≥ 2 或 多转 ≥ 2 打标。这条规则在 D5 上正好命中 orbits+spiral 那几关,和两个大模型跑两小时得到的结论一致。

  4. +
  5. Agent 只跑被标记的 + 5% 随机抽样

    不是 1000 关全跑。收集三样东西:difficulty(r=0.93,可用来给 1000 关排序)、stuck(r=0.85,体感无解的直接信号)、以及 worst 文字(重复模式能挖出系统性缺陷)。丢掉 rotation / clarity / fun——复测 r 只有 0.32 / 0.19 / 0.56,是模型噪声。按实测 51 s/关、4 路并行,150 关约半小时。

  6. +
  7. 真人标定锚点 · 5–10 关

    Agent 的难度排序可信(复测 r=0.93),但绝对值可能整体偏移——它觉得「刚好」的关,真人可能觉得太简单。所以真人只需要玩少数几关当锚点,把 agent 的相对排序映射到真实难度刻度上,不必逐关评。这比原本设想的省很多人力。

  8. +
+
+

最该做的一件事:把它变成回归测试,而不是评审流程。改完生成器,12 秒跑一遍规则机器人,比较盲转分布。从 22% 降到 5% 就是客观改善,升上去就是刚引入了退化。12 秒一轮可以进 CI;几小时一轮且分数不可复现的人工评审,不可能成为迭代循环。

+
+ +

07这份结论的边界

+

写清楚哪里不够硬,比多列几个数字重要。

+
    +
  • 跨模型一致性只验证了 6 关(spark 与 grok 的交集),其中 1 关不一致。机械指标"与玩家无关"这个说法,样本还太小。
  • +
  • 复测只做了 2 次。2 次能算相关系数,但对评分这种低方差的离散量,2 次的估计本身就有不小的不确定性。
  • +
  • Agent 读的是文字不是像素,测不出"看不清"。clarity 这一项从原理上就不该指望它。
  • +
  • 所有结论都基于 D5 原型这 18 关。这批关卡通关率 100%、难度评分方差极小,换一批更难的内容,结论可能不同。
  • +
+ +
+

数据:bench/runs · 生成脚本:scripts/build-bench-report.mjs · 重新生成:node scripts/build-bench-report.mjs

+

本页所有数字由脚本从存档直接计算,未经手工填写。相关系数为 Pearson r,标准差为总体标准差。

+
+ +
+ + \ No newline at end of file diff --git a/docs/design-review.html b/docs/design-review.html new file mode 100644 index 0000000..d222274 --- /dev/null +++ b/docs/design-review.html @@ -0,0 +1,1121 @@ + + + + + +转转六角 设计评审 + + + + +
+
+

Design Review · v3 · 2026-09-13 · main @ 10cf93a · 方案见 §07 · 难度曲线见 §08

+

转转六角:一台按顺序播放动画的机器 + HEX TRAILS — Gameplay & UI Review

+

这份评审的结论只有一句:这个项目的工程质量和文档纪律,远高于它的设计质量。大量产能被投入在可以被自动化验收的维度——可解性、对比度、回放证据、关卡数量——恰恰绕开了唯一不能被自动化验收的那个问题:玩家为什么要想。

+ +
+
+ +
+
+
+
+
100%
+
「猴子玩家」通关率
完全不看轨道,随机点击
+
1000 / 1000 关
+
+
+
1.67
+
动作 ÷ 棋子数
理论下限是 1.00
+
v0.3 为 2.60 · v0.4 为 3.42
+
+
+
562
+
1000 关里不重复的
轨道底盘数量
+
一块底盘最多被用 21 次
+
+
+
34px
+
91 格关卡的棋子可点直径
iOS 最小触控为 44px
+
541 / 1000 关低于 44px
+
+
+
+
+ + + +
+
+ + +
+
+
+ 01 + 动摇根基 + 改动成本 中 +
+

核心循环里没有决策,只有执行

+
+ +

这个游戏在数学上不存在「坏棋」,也不存在失败。因此它无法产生解谜游戏赖以为生的那件事:想通了。

+ +

我用仓库自己的 src/core.js 写了一个「猴子玩家」——完全不读轨道,随机点一枚在场棋子,连续 3 次点不动就随机转一下底盘。跑完全部 1000 关:

+ +
+ + + + + + + + +
猴子玩家(零智能策略)在三个关卡版本上的表现。动作 = 点击 + 旋转。
关卡版本关数通关率动作 ÷ 棋子(中位数)含义
v0.5 · 千段轨迹1000100%1.67每枚棋子只浪费 0.67 次点击
v0.4 · 长路与惊喜(手作)20100%3.42手作版反而更需要试探
v0.3 · 弯轨 48 关(手作)48100%2.60同上
+
+ +

注意最后一列的方向:程序生成的 v0.5 把游戏做得比手作版更空,不是更厚。1000 关的扩产过程中,「需要思考」这件事被稀释掉了。

+ +

开局就已经解完了

+ +
+ + + + + + + + + + + +
对 1000 关冻结布局逐关重算,使用 core.available() 与 core.solve()。
结构指标实测为什么重要
开局即可直接离场的棋子占比中位数 64%过半棋子不需要任何判断
开局 ≥50% 棋子可出的关卡847 关85% 的关卡开局就半解
开局 0 枚可出(必须先旋转)0 关一次都没有出现过
「一波清空」:所有棋子开局全可出,任意顺序285 关28.5% 的关卡完全没有谜题
最少旋转数 = 0568 关差异化机制在 57% 关卡用不到
最少旋转数的全局上限2 次1000 关里没有一关需要转 3 次
+
+ +

为什么会这样:三条规则叠加的结果

+ +

① 消除是单调的。src/core.js:87 的 trace() 只按占位判定阻挡,移走一枚棋子只会减少障碍、永不增加。所以「能出的就出掉」永远是最优策略,点击顺序不影响任何结果——所有可出棋子塌缩成一个等价类,有效分支因子约等于 1。

+ +

② 失败在数学上不可达。全部 1000 关 moveLimit === tiles.length,而 moves 只在成功离场时减 1、同时棋子也少 1,两者永远相等;rotationEnabled 全为 true,canRotate() 全部返回真。Game.lost 的两个分支都永不成立,src/app.js:170 的失败分支是死代码。

+ +

③ 游戏替玩家说出了答案。src/app.js:218 在每次旋转后弹 toast「转一转,N 枚棋子的出口打开了!」——而这恰好是 432 个「需要旋转」关卡里唯一需要动脑的那一刻。同时提示按钮免费、无限次,直接把完整路线画出来。

+ +

再叠上一层:通关面板那三颗星是 src/app.js:167 里写死的三个 icon('star'),无论你用几步、用不用提示,永远三颗。mistakes 在 core.js:297 老老实实累加,全项目零处读取。系统知道玩家会点错,但点错不产生任何后果,所以没必要显示——这正是问题的自白。

+ +

怎么改 · 已修订

+ +

本节初版建议「旋转变有限资源」与「relay 停靠点上主线」已撤回。制作人的约束是面向超休闲玩家、难度尽量小且逐步释放——这两条都会引入「卡住 / 弄坏局面」的感受,对这个用户群有害。撤回的理由和替代方案见 §07 修订方案。这里保留的判断是:「难度低」和「没有选择」是两个旋钮——不动第一个旋钮,甚至再往下调,也能把第二个从 0 调到 1。

+ +

不要加扣步惩罚。docs/gameplay-direction.md §2 的「免费旋转 + 免费试错 + 无出口不判负」是全项目最正确的设计决策,必须保住。§07 的全部方案都建立在这条之上。

+
+ + +
+
+
+ 02 + 动摇根基 + 改动成本 大 +
+

1000 关是一个数量幻觉

+
+ +

1000 关只有 30 个关卡名、562 块不重复的底盘,和一条完全水平的难度曲线。它不是资产,是同一关的 1000 次随机重排。

+ +

同一块底盘,相隔 563 关

+ +

我从 levels/v0.5.json 用仓库自己的 path-math.js 把底盘还原成矢量图。下面两张是第 14 关和第 577 关,移除棋子后的轨道布局:

+ +
+
+ +
LEVEL 014 · 第 1 章「初见弯路」
37 格 · 7 枚棋子
+
+
+ +
LEVEL 577 · 第 12 章「曲径相逢」
37 格 · 9 枚棋子
+
+
+ +
两张图的 SVG 逐字节相同
+ +

这不是孤例。对全部 1000 关按「格坐标 + 轨道配对」做精确去重:

+ +
+ + + + + + + + + + +
底盘复用统计。官方审核报告的「精确对称去重后 1000 关唯一」成立,因为它把棋子算进了指纹;玩家一眼看到的却是底盘。
指标实测
不重复的轨道底盘562 / 1000
与别的关共用底盘的关卡591 关(59%)
单块底盘的最高复用次数21 次
不重复的关卡名30 个
单个关卡名的最高出现次数63 次(花瓣 · 清挡放行)
+
+ +

30 个名字 = 6 种形态(花瓣 / 环绕 / 螺旋 / 回折 / 编织 / 双叶跨区)× 5 种体验(长路巡游 / 清挡放行 / 旋转换路 / 分段释放 / 轻巧反差)。整个 1000 关战役,就是这张 6×5 的表格,每格填进去 33 个程序生成的实例。

+ +

20 章,没有一章有自己的身份

+ +

下表每一行是一章 50 关。注意形态种类和体验种类两列——每一章都均匀地混着全部 6 种形态和全部 5 种体验。章节名写得有情绪(初见弯路 → 花瓣初开 → 千回百转 → 一路盛放),但它们只存在于一个下拉框的标题里。

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
逐章重算。「开局可出比例」是衡量「这一关需要多少判断」最直接的指标——它在 20 章里完全没有移动。
章章节名平均格数平均棋子(↑ 唯一在增长的量)开局可出比例(→ 完全水平)必旋 %形态体验
1初见弯路347.271%40%6 / 65 / 5
2朝内的出口377.570%44%6 / 65 / 5
3绕行花园448.272%40%6 / 65 / 5
4轻轻让路489.069%42%6 / 65 / 5
5环里的风景488.966%46%6 / 65 / 5
6花瓣初开588.870%40%6 / 65 / 5
7交织之间589.870%44%6 / 65 / 5
8两岸来信609.870%42%6 / 65 / 5
9回旋的发现6510.169%46%6 / 65 / 5
10层层放行6810.971%46%6 / 65 / 5
11走进花心6310.269%48%6 / 65 / 5
12曲径相逢6510.770%42%6 / 65 / 5
13另一侧的出口6110.971%40%6 / 65 / 5
14编织新路6111.368%46%6 / 65 / 5
15旋开的花园6411.570%42%6 / 65 / 5
16从容穿行6311.870%42%6 / 65 / 5
17旧路新发现7011.969%42%6 / 65 / 5
18花与回声6911.871%40%6 / 65 / 5
19千回百转6612.170%46%6 / 65 / 5
20一路盛放5811.667%46%6 / 65 / 5
+
+ +

从第 1 关到第 1000 关,玩家面对的认知负担没有变化,只是要多点四下。第 20 章的平均格数甚至降回到 58,比第 10 章的 68 还小。

+ +

顺便说一句核心卖点的真实密度

+ +

「长路巡游:一次点击,享受一整段弯绕离场」是设计准则里的第一条爽点。我模拟了完整通关,用 exit-animation.js 的 exitTiming() 统计玩家实际看到的每一段动画:

+ +
+ + + + + + + + + + +
n = 10,208 次成功离场,来自 1000 次完整模拟通关。
观看内容实测判断
一关中最长那一段离场动画中位数 4.24 秒卖点成立
一关中全部离场动画总时长中位数 11.0 秒可接受
单次离场动画时长中位数 0.57 秒—
时长 < 0.5 秒(一闪而过)的点击占比43%密度不足
时长 ≥ 2 秒(真正的巡游)的点击占比15.2%—
+
+ +

结论要说公道:长路的高光每关确实会出现一次(95% 的关卡至少有一段 ≥2 秒),这一条设计目标是兑现了的。问题是其余 43% 的点击换来的是不到半秒的一闪而过——爽点存在,但被大量无内容的点击稀释了。

+ +

怎么改

+

停止关卡生产,把 1000 关砍到 200–300 关。排序键从「棋子数」换成三元组 (必须旋转次数, 解法波次, 首波可点比例),并在章内强制单调:第 1 章开局可出比例上限 0.80,第 6 章 0.55,第 12 章 0.35。「0 次旋转即可通关」的全局比例从 56.8% 压到每章 ≤10%——v0.1 的 24 关做到过 0%。同时把 levels/v0.5-analysis.json 移出发行包,src/levels-v05.js(单文件 9.6 MB,首屏静态 import)改成按章 dynamic import,levels/v0.5/chapter-01..20.json 已经切好了只是没接上。

+
+ + +
+
+
+ 03 + 显著影响体验 + 改动成本 小 · 约 15 行 +
+

核心动词「观察」,在 UI 上根本不存在

+
+ +

你不肯让玩家免费地「自己看」,却愿意免费无限地「直接告诉他答案」。这是整份评审里性价比最高的一处修改。

+ +

这个游戏的核心动词是看懂这枚棋子会走到哪。但正式模式里没有任何「看」的操作——没有长按、没有悬停、没有选中高亮。playTile 的第一行就是 path=null,然后直接执行。唯一能看到一条路线的方式,是把它执行掉。

+ +

而能力早就有了:

+ +
+ + + + + + + + +
三处代码,同一个能力,三种可用性。
位置做的事谁能用
app.js:201编辑器「查看路径」工具:path=trace(draft,tile)只有开发者
app.js:244提示按钮:path=trace(...) 画出完整路线,无次数限制、无消耗玩家,但只能看求解器替他选中的那一枚
app.js:179playTile 开头 path=null玩家想看的那一枚 —— 看不了
+
+ +

渲染通道 renderer.js:79 已经在那儿了,提示按钮上甚至标着「免费」。当规则前提是「旋转免费、失败免费」、唯一有成本的动作是成功离场时,不给免费预览是自相矛盾的:你把所有摩擦都去掉了,却在最需要思考的环节强迫玩家用执行代替思考。结果是玩家养成「乱点试试」的习惯,这又回过头来印证了第 01 条。

+ +

怎么改

+

canvas 的 pointerdown 挂一个 250 ms 计时器,触发时执行 path=trace(game.level,tile,game.tiles); draw(),pointerup 清空且不调用 game.tap()。可离场用 THEME.primary、被挡用 THEME.warning 画到接触点。同时把提示按钮的文案从「提示」改成「找一枚能走的」——让「我自己看」和「帮我找」在语义上彻底分开。另外把 TILE_RADIUS 从 .985 降到 .90,让棋子底下露出一圈轨道端口:规划「移开 A 之后 B 往哪走」所必需的那段轨道,现在恰好被 A 自己完全盖住。

+
+ + +
+
+
+ 04 + 动摇根基 + 改动成本 中 +
+

棋盘在目标设备上物理不可读

+
+ +

54% 的关卡,棋子可点直径低于 iOS 最小触控尺寸。而且瓶颈是宽度不是高度——「把画布加高」这条路是堵死的。

+ +

触控尺寸

+ +

用 geometry.js 的 layout() 在 390 px 宽手机上逐档实算。下图中每个六角都按真实相对尺寸绘制,虚线圆是 44 px 最小触控参考:

+ +
+ 19 格六角宽 82px72px可点直径37 格六角宽 59px52px可点直径61 格六角宽 47px41px可点直径91 格六角宽 38px34px可点直径 +
虚线圆 = 44 px 最小触控参考。实测值:layout() 取 min((width−48)/跨度X, (height−50)/跨度Y, cellLimit);对 91 格棋盘,宽度项 19.18 < 高度项 23.20,宽度胜出。我把画布高度从 492 px 调到 700 px 重算,宽度项仍然是瓶颈——布局回收救不了它。
+
+ +
+ + + + + + + + + +
可点直径 = 六角宽 × COLLIDER_RADIUS (0.88)。
底盘六角宽可点直径关卡数判断
19 格 (r2)82 px72 px7充裕
37 格 (r3)59 px52 px452合格
61 格 (r4)47 px41 px268低于 44 px
91 格 (r5)38 px34 px273远低于 44 px
+
+ +

这是第 500 关的真实棋盘

+ +

91 格、14 枚棋子、近 200 段等权重的深蓝曲线。请试着用眼睛从任意一枚棋子追到它的出口:

+ +
+ +
LEVEL 500 · 第 10 章「层层放行」· 91 格 · 14 枚棋子
在手机上,这整块图的宽度是 326 px。
+
+ +

棋盘越大不是「更有内容」,而是「更看不清」。而看不清 + 试错免费 = 乱点——这和第 01 条是同一个洞的两面。

+ +

renderer.js:69 其实内置了 trackEmphasis 降权机制,labs/flower 和 labs/campaign-review 都在用,唯独主游戏的 draw() 没传这个参数,所以每一根轨道都以 primary 全权重渲染。

+ +

我注意到 docs/v0.5-browser-review.md 记录了「主路线加粗」方案被撤回。撤回本身是对的——加粗 routeDesign.featured 会直接剧透答案。但正确的做法不是回到全部等权重,而是按「与任何棋子当前可达集不相交」来降权:只压掉纯装饰的线,不暗示解法。

+ +

竖屏空间分配

+ +
+ 27% · 224px顶栏 · LEVEL 500 · 待解棋子 · toast · 130px 纯空白43% · 363px棋盘(实际绘制 326 × 363)30% · 257px左转 / 右转 · 撤销 / 重来 / 提示390 × 844 +
390 × 844 实测(第 500 关)。棋盘实际绘制区域 326 × 363 px。顶部 130 px 是纯空白。
+
+ +

在一个只有棋盘值得看的游戏里,棋盘只拿到 43%。还有一处纯浪费:design/components.css:64 用 display:none!important 把缩放工具条藏了——board-viewport.js 的双指缩放和拖动代码完整运行着,但屏幕上没有任何东西告诉玩家这个能力存在。

+ +

怎么改

+

① 主线棋盘上限降到 61 格,91 格那 273 关移出主线;② 非活跃轨道降到 35% 不透明度;③ 恢复缩放工具条的可见性,或至少在首次进入 61 格以上关卡时提示一次「双指可放大」;④ 回收顶部那 130 px 死区。

+
+ + +
+
+
+ 05 + 显著影响体验 + 改动成本 小 · 约 1 行 +
+

5 套设计 × 5 主题 = 1 套设计 × 25 个色板

+
+ +

没有一套设计触碰到真正的视觉问题。25 组色板全部是在同一个有缺陷的画面上调色。

+ +

我逐张看了 design/index.html 的五套方案。01 柔和原色 / 02 高彩游乐场 / 03 积木轨道屋 / 05 轻线纸境——棋盘造型、棋子形状、箭头画法、轨道渲染方式完全一致,变的只有色相。只有 04「动物弯弯镇」真的换了棋子形象(也是五套里唯一有角色感的),但代价是方向箭头被缩小、可读性下降。

+ +

真正的视觉问题——轨道不可读、棋盘没有可辨识结构、没有世界观——五套里一套都没碰。

+ +

主题的使用方式也白做了

+ +

themeScore 绑在单关难度 tier 上(campaign-browser.js:35,查表 [12,32,48,62,78]),而 tier 在相邻关之间跳来跳去:

+ +
+ + + + + + + + + +
全量重算 1000 关的主题落点。
指标实测后果
999 次关卡切换中背景变色次数696 次(69.7%)过一关屏幕闪一次,不承载任何信息
最长连续同主题6 关建立不起「第 7 章是紫色的」这种空间记忆
apricot / lagoon / lavender / rose527 / 319 / 145 / 9一个主题占 52.7%,一个只出现 9 次
midnight「每周挑战」0 关themeForScore 只返回索引 0–3,发行路径上永不可达
+
+ +

也就是说:投入了 5 套设计 × 5 主题的产能,玩家在发行路径上实质只会看到 2 种颜色,还是以随机顺序闪给他看。

+ +

还有一处被绿灯掩盖的问题

+ +

design/contrast-report.json 33 项全 pass,但它校验的是 token 语义配对,不是 token 在场景里的实际组合。实测白色旋转箭头压在场景背景上的对比度:

+ +
+ + + + + + + + + +
W3C 非文字对比度最低要求 3:1(项目自己在 Design.md §2 引用过)。
设计方案白色旋转箭头 vs 场景背景判断
paper · 轻线纸境1.15 – 1.20 : 1基本隐形
calm · 柔和原色1.35 – 1.60 : 1不合格
animals · 动物弯弯镇1.81 – 3.32 : 1多数不合格
toy · 积木轨道屋1.62 – 3.44 : 1多数不合格
+
+ +

而旋转恰恰是这个游戏最鼓励玩家做的动作——app.js:76 的 rotationHint 还会主动引导玩家去按它。这就是「文档写得很完备」和「设计做得好」之间的差距:33 项全绿的绿灯,掩盖的是屏幕上最看不见的东西恰好是最该被看见的操作。

+ +

怎么改

+

主题绑章节,一行改动:app.js:87 的 applyTheme 输入从 production.themeScore 换成 Math.floor(current/50),20 章映射到 5 个主题、每 4 章换一次。背景色切换从 696 次降到 4 次,每次都对应玩家能感知到的章节边界,midnight 也真正进入发行路径(第 17–20 章)。难度信息不要用背景色承载。

+

旋转按钮加一个实底托盘:用 --color-utility (#52697E) 做 48 px 圆角方形背景,白图标放上面——onUtility/utility 这一对在 CONTRAST_PAIRS 里已经是过审的 3:1 配对,直接复用不引入新色。同时在 CONTRAST_PAIRS 里补一条「控件 / 场景背景」让它进构建门禁。

+
+ + +
+
+
+ 06 + 显著影响体验 + 改动成本 中 +
+

选关界面是开发者调试面板

+
+ +

「解锁」是休闲解谜唯一免费的货币,现在被一个 1–1000 的输入框白送掉了。

+ +

点开那个入口(按钮的 aria-label 写的是「选择关卡」——命名本身就承认它不是地图),看到的依次是:

+ +
+ + + + + + + + + + + +
src/app.js:304 showMapDialog 的完整内容。
元素服务于谁
版本 <select>,选项文字如「v0.2 · 直线较多版 · 48 关」开发者 — 这是 changelog
章节 <select>(20 项)开发者
1–1000 数字输入框 + 「前往」开发者 — 「我要验证第 847 关」
上/下章翻页器 + 50 个方块按钮玩家
两段「浏览器是否允许保存」的免责说明法务
指向 labs/campaign-review/ 内部制作审核页的链接开发者 — 不该发给玩家
+
+ +

三个具体伤害

+

①「直线较多版」是在让玩家评估你的开发决策,同时告诉他「我们改过 5 次方向」。② 五套独立存档把玩家投入切成五份——打完 v0.4 的 20 关,对总进度零贡献。③ v0.1–v0.4 那 140 关是有设计文档背书的手工内容(v0.4 的 curvedRatio 0.784 对 v0.5 的 0.394),质量密度远高于 1000 关,却被埋在下拉框第二层——新玩家默认 v0.5,永远看不到。

+ +

另外 .done(已通关)这个 class 在 style.css 和 design/*.css 里一条规则都没有——通关与未通关的按钮在关卡网格里几乎没有视觉差别。

+ +

怎么改

+

版本 <select>、1–1000 输入框、审核页链接、存储免责说明四项全部从玩家弹窗移除;版本切换保留 ?campaign= URL 参数即可(campaign-browser.js:2-8 已支持),审核链接移到 labs/ 自己的入口。把 v0.1–v0.4 的 140 关择优混编进主线前 240 关、统一存档——一条 1140 关的线,远胜五条互不相干的线。给 .level-btn.done 写实际样式(填充色 + 星标),并按章加锁。

+
+ + +
+
+
+ 07 + 修订方案 · 零难度增加 + 经五路对抗核查 +
+

不动难度旋钮,把「选择」从 0 调到 1

+
+ +

制作人的约束:超休闲、难度尽量小、逐步释放。修订后的方案没有一条引入失败态、卡住感或资源限制;其中四条比现状更容易。它们改的是「有没有的选」,不是「要想多久」。

+ +

修订过程

+

初版六条方案交给五路独立视角攻击——超休闲留存制作人、仓库维护者、关卡数据分析、UX 一致性、以及最初写这份尖锐评审的人。结果:六条没有一条被判「保留原样」,每条都被指出具体漏洞;五路一致同意撤回旋转限额与 relay。下面是核对到代码和数据之后的最终版,凡是批评者的数字,我都用 core.js 自己重算过。

+ +

先修我自己漏掉的:自动提示不是两处,是三处

+

初版只删了 app.js:185(离场后高亮下一枚)和 app.js:218(旋转后高亮)。五路全部指出我漏了 app.js:103–110:只要场上没有任何棋子能离场,旋转按钮立刻被点亮、指令条直接写出该转哪边。我重算了这一处的影响:

+ +
+ + + + + + + + +
432 个需旋转关,按「不旋转、贪心清到卡住」时剩余棋子数分组。这就是玩家第一次旋转时面对的状态。
首次旋转时剩余棋子关卡数占比
2 枚22352%
3 枚17340%
≥4 枚368%
+
+ +

92% 的旋转发生在「剩 2–3 枚、无路可走」的状态——而 103–110 恰好在那一刻把方向点亮。也就是说,「转哪边」这个全游戏唯一的真决策点,在 432 关里全部由机器代答。任何关卡曲线里的「需旋转 %」在这条提示下都是装饰。

+ +

最终方案

+ +
+
+
1
+
+

三处自动提示全部改为闲置门控,三套「看路」机制并成一套

+

删 app.js:185 / 218 的 hint=opened[0].id;103–110 的旋转方向高亮同样进闲置计时。阈值不是初版的 3 秒——从最后一个动画结束起算(不是从点击),T = clamp(4 s + 0.6 s × 剩余棋子, 5 s, 10 s),无出口需旋转时用 T/2;先出文字、再过 T 才画环。toast 保留但去掉数量(「有出口打开了」)——N = 1 时等于点名。闲置提示不置 helpUsed。提示按钮改为「立即触发闲置提示」,删掉 244 行直接画完整路线——长按预览、闲置提示、免费按钮三套重叠机制减为一套,代码反而少。教学关保留即时高亮。

+
+
约 30 行
当天
+
+
+
2
+
+

按下看路,松手发车——不是 250 ms 长按

+

被挡点击只花 800 ms 而且免费,「直接点、看它弹回来」比 250 ms 长按更快,所以长按永远不会被用(这一档没有长按交互)。改成 Parking Jam 手感:pointerdown 立即画该棋子路线(可出 primary / 被挡 warning 画到接触点,core.js:84 已给 collisionPoint),pointerup 在同格 = 发车、滑出 = 取消。手势仲裁:单指才启动,移动 > 8 px 取消,预览中松手不 tap 不 drag,拦 contextmenu(Android)。预览路线要画在棋子之上(renderer.js:77 现在在棋子之下)。
初版的「非可达轨道降权」作废:动态可达集 = 每次离场后棋盘自动亮出「谁能走远了」,是比 185 行更强的剧透;静态六朝向并集我实算覆盖 93–99% 的轨道段,降不了什么。替代:全盘轨道统一软化到 55–60%(用 renderer.js:69/74 已有的 secondary 通道,不用 alpha,保住 3:1 校验),只有按下的路线满色。
再加两条 always-on 的观察教学:离场棋子拖一条 200–300 ms 淡出尾迹;被挡时用 warning 色把路线画到接触点停 600 ms。每一次点击都在免费示范「它是这样走的 / 这样断的」。

+
+
约 60 行
1–2 天
+
+
+
3
+
+

连击:档位由「这一下打开了几枚」驱动

+

初版说连击「让看一眼再点有理由」——不成立:开局 64% 可出,285 关永不断,配上免费预览更不会断。连击是反馈,不是选择。但有一处能让顺序第一次有可感知的后果:app.js:184 已经算出每次点击解锁了几枚(opened.length),这是整个引擎里唯一随顺序变化的量。档位由它驱动:一下打开 N 枚就跳 N 档,opened = 0 只维持不升。「先移走闸门棋子」在 715 关里第一次被感知,零惩罚。
实现要点:计数在 game.tap 返回处(route.status==='exit'),不等动画;归零延到撞击音同帧(app.js:122 progress ≥ 0.42),否则玩家先看到连击消失再看到撞击,因果倒置;断裂静默,无红无音;旋转、预览、闲置提示都不归零;undo 用平行栈恢复;教学关不计;音高封顶 +5 半音;标签画在离场点,不占 board-toast;时间窗 2.5 s,短于闲置阈值,防「等提示续连」。

+
+
约 35 行
1 天
+
+
+
4
+
+

两枚只做加法的印章,不进关卡格

+

这一档没有按关评级——Hexa Sort、Screw Jam、Parking Jam 的通关面板都是无条件庆祝。所以印章只做加法:「完美」= 本局 mistakes === 0,达成时多放一次印章动效 + 彩带;没拿到时面板和现在完全一样(三颗硬编码的星改成无条件彩带,别再叫「星」)。不进关卡格、不进任何持久列表——地图上「3 金 40 灰」就是超休闲眼里的失败。
「一转到位」= game.rotations === par,par 取 app.js:67 已缓存的参考解旋转数;只在达成且 par > 0 时出现,缺席时什么都不显示,不显示数字。这是旋转限额的「只影响印章、不影响通关」软版本——432 个旋转关第一次有了「这一转是不是多余」的判断,零新状态。
三条明确规则:撤销洗白(core.js:278 snapshot 含 mistakes)——接受并写明;helpUsed 不影响印章;285 个全可出关不发印章、显示「畅通」、不计聚合。存档 normalizeProgress 白名单加 perfect,改 6 处测试断言。上线顺序硬依赖:第 2 条先于本条——没有零成本看路手段之前,不能让撞墙有任何记账。

+
+
约 40 行 + 存档
2 天
+
+
+
5
+
+

重排发布为 v0.6,用分带配额,不用全局排序

+

已被 §08 取代。制作人随后定下「旋转是主解法,零旋转关 ≤10%」——池子核对后,「首转时剩余 ≥50% 且一转释放 ≥2 枚」的关现有只有 12 关,重排做不出旋转为主的战役,必须重新生成约一半。下文保留作为数据层的约束记录(存档索引、验证脚本按 index 断言等仍然成立)。

+

「不重做任何关」在数据层不成立。v0.5.json 顺序 = chapter-XX.json = levels-v05.js,验证脚本和测试按 index 断言,每关内嵌章名,存档 completed 是索引数组——原地重排会把已通关标记静默映射到别的关。做法:campaign-versions.js 加 v0.6 并设为默认,v0.5 与其存档原样保留。
初版需求分公式错了:(1−可出比例) 与波次共线(r = 0.84),对猴子动作数做回归后三项权重是 0.08 / 0.48 / 0.43——我给了最弱项 0.6。改用分带配额:R 全可出 · G1 零旋转 ≤2 波 · G2 零旋转 ≥3 波 · T1 一转且首转剩 ≤3 枚 · T2 两转,每章配额 R 递减、T 递增。
「温柔旋转关只有 12」是我判据错——旋转的温柔度是「首转时剩几枚」不是「开局可出比例」:T1 且剩 ≤2 枚有 223 关(≤37 格 117 关),第 1 章第 2 关就能放,接住教学第 3 课。
初版前 4 章 200 关零决策 = 超休闲 D1–D3 的全部时长,流失原因会是无聊而不是难;这一档对零决策的容忍度是 5–10 关。第 1 章缩到 20 关;全可出关从第 3 章起每章 ≤15%,当喘气关。同底盘间隔 ≥100 关(562 块底盘、最大一组 21 关)。6 形态 × 5 体验改为按章解锁式亮相——这才是这一档的「坡度」。

+
+
约 80 行脚本 + 测试
1 周
+
+
+
6
+
+

91 格:第 2 条落地后真机重测再定,且不是一刀切

+

初版用 44 px 门槛砍 91 格自相矛盾——61 格 41 px 同样不过线而第 3 章就在用。改用「底盘半径 ≤ 4」。不整体删:91 格里 98 关全可出 / 一挡放行是全库最强的观赏内容(参考解最大弯数中位 16,37 格只有 10),保留作第 7 章起的「长局回报」槽,每章 ≤6 关、不连续;175 关需要决策的 91 格移到主线外的可选「大棋盘」章,不进进度分母。

+
+
数据层
随 v0.6
+
+
+ +

分带配额后的曲线

+

对抗核查中由关卡数据一路实算(移出需决策的 91 格、14 章 × 52 关;其底层「首转剩余棋子」分布与我的重算逐位一致)。对比初版全局排序:前 200 关需旋转 0%、全可出 100%。

+ +
+ + + + + + + + + + + + +
分带配额曲线。「猴子 APT」= 随机玩家每枚棋子的动作数,现状全库中位 1.67。
章开局可出比例需旋转 %猴子 APT第 1 章体验分布
191%12%1.20巡游 29 · 清挡 17 · 反差 3 · 换路 3
386%21%1.32
573%38%1.81分段释放从本章起
767%44%2.1391 格长局回报从本章起
1067%48%2.21
1261%52%2.53
1449%100%2.44
+
+ +

第 14 章的 0.49 仍然比 v0.3 / v0.4 手作版宽松(它们的猴子 APT 是 2.60 / 3.42)。整条曲线的最高点,都没有超过项目自己两年前手作关的难度。

+ +

批评者补上的、我漏掉的(全部零难度)

+ +
+
+
+
+
+

教学改覆盖层:三个弹窗才进第 1 关,这一档是 5 秒进真关

+

三课教学每课通关后 app.js:164 都开一个结果弹窗——玩家要关 3 个弹窗才见到第 1 关。Screw Jam / Hexa Sort 的做法是手指覆盖层引导、无弹窗。把三课并进正式第 1–3 关做覆盖层。超休闲留存一路认为这可能是 D1 最大的单一杠杆。

+
+
表现层
2 天
+
+
+
+
+
+

用朝向,不用顺序:让旋转变成关于「奖励」的选择

+

实算:3,294 枚棋子(928 关)换一个朝向能走更长的路;285 个全可出关里有 263 关旋转后有更长的巡游。可选目标「本关里程 N / M」或「让 X 号跑完最长路」(读 trace().steps.length,core.js:87 已给),让 568 个零旋转关的旋转按钮第一次有用途,且永远不影响通关。这是核查中找到的最强的「零难度的选择」——旋转不再是关于「能不能出」,而是关于「看多长」。

+
+
原型 2 天
验证后定
+
+
+
+
+
+

主题绑章节 · 关名按章生成 · 手作关作章末加餐

+

主题绑章节(app.js:87,2 行)在初版评审里有、修订时丢了,补回。30 个关名配 1000 关,改为家族 × 体验 × 章名生成(production-plan.js 已有表,10 行)。v0.3 / v0.4 那 68 关手作关(猴子 APT 2.60 / 3.42)作各章末尾的加餐,零风险现成内容。

+
+
约 15 行
半天
+
+
+
-
+
+

「旋转作为可选加速器」的内容现在几乎不存在——下一版生成器的事

+

568 个零旋转关里,开局转 ±60° 能多开 ≥1 枚的只有 38 关,≥2 枚的 3 关。「转一下更快」这种不必需但有回报的旋转,靠重排造不出来,要在下一批生成加 rotGain ≥ 2 的候选带。记在这里,不进当期。

+
+
生成器
下一版
+
+
+ +

撤回的两条,以及它们的软版本

+

旋转限额:撤回维持。五路一致。这一档所有「有限资源」都靠混合休闲经济(金币买道具)兜底,本项目没有经济系统,限额只剩惩罚;而且 3 次额度恰好只会咬在 140 关两转关上——那已经是全库最难的关。软版本就是第 4 条的「一转到位」印章。
relay 停靠点:不上主线。它是阻塞态(labs/variants/README.md「P 被占用时不能进入」),与定位正面冲突。但作为第 8–10 章之后的章节专属滴灌机制(每章只出 1 次、19 格 3–5 枚样本)是这一档 D7 的常规手段,可以留作后续。

+ +

诚实的结算

+

最初写那份尖锐评审的视角给修订方案打了分:「没有选择」这个根本问题,六条加起来解决了 3 / 10。因为没有一条改动状态空间——移走棋子仍然只减障碍,285 关仍然任意顺序清空。剩下的分数要靠三条真正触及「后果」的东西:朝向里程目标(把旋转变成关于奖励的选择)、opened.length 驱动的连击档位(顺序第一次有可感知后果)、「一转到位」印章(旋转次数第一次有判断)。这三条合起来,是「不动难度旋钮」这个约束下的天花板。再往上就要动规则,那是验证过这一轮之后的事。

+
+ + +
+
+
+ 08 + 难度曲线 · v0.6 + 依据项目调研 + 原文核读 +
+

曲线必须上升,而且只有两个旋钮能让它上升

+
+ +

制作人定下三条:零旋转关 ≤10% 只作补偿;难度是双刃剑;曲线一定增长、增长要合理——因为老玩家熟练后同一关越玩越简单。这三条在项目自己的调研里都有出处,而且项目自己的 1000 关计划表本来就是一条上升曲线。v0.5 没有执行它。

+ +

原则对应的原文

+
+ + + + + + + + + +
原文由三路核读:《一箭又一箭》三篇转录交叉核对(同一场演讲,算一个来源)、Refraction 论文 PDF 本地抽文、Lily's Garden arXiv HTML。exa 台账里没有专门讨论 flow / DDA / 技能习得的来源。
制作人原则原文依据原文给的数字不能迁移的部分
老玩家越玩越简单 → 曲线要涨《一箭又一箭》:「其实我们的难度并没有变,只是他们更熟练了」「到了一定的关卡,就需要增加困难关卡的频次」;3 档扩到 5 档,多次 A/B无阈值、无频率该游戏有失败态与时限,「困难」口径不同
增长要合理 = 边际新意恒定Refraction CHI 2015:已掌握的概念代价趋近 0,编排目标是让下一关的新负担保持在目标值附近;概念耗尽后系统会连续选相近关2,377 名玩家实验;无频率数字其生成器保证所有解共享解法图,本项目有多种释放顺序
太低无聊、太高流失Lily's Garden arXiv 2401.17436 §1、§3:前 ~100 关教学与容易关,之后存活老玩家的体感稳定在均值 3.2 次尝试、难关 ≥7 次3.2 / 7 / 1 次次数全部不可用(本项目无失败态);前 100 关的长度(本项目规则少得多)
新机关低频《一箭又一箭》:新机制「平均一天只遇到 1–2 次」;日均 30–40 关,核心用户 200–300 关1–2 次/日;30–40 关/日旋转不是新机关,是主解法——它的新组合频率应远高于此
+
+ +

项目自己的计划表 vs. 实际产出

+

docs/1000-level-production-plan.md §3 的预算表是上升的:挑战关(D4+D5)从 1–50 关的 0% 升到 801–1000 关的 22.5%,D1 从 50% 降到 12.5%,D5 从第 501 关起。研究报告 §7 已把同文件里「后半程保持难度平台」修正为「体感校准目标,不能固定结构难度而忽略熟练度」——这与制作人的原则③一致。实际 v0.5:

+ +
+ + + + + + + + + +
v0.5 按项目自己的 D1–D5 档位统计。「预算」按 §3 表格分段折算到 50 关。
范围预算 D4+D5实际 D4+D5预算 D1实际 D1判断
1–500%6%50%42%第 32 关已出现 D4
51–1007%20%20%38%第 2 章 = 第 20 章
201–50013%16%12%30%D1 是预算的 2.5 倍
801–100022.5%15%12.5%30%末段比预算更容易
+
+ +

而且 D 档本身退化成了旋转次数的换名:campaign-evidence.js:224 每次必需旋转记 15 分,结果零旋转关从不进 D4、两转关几乎都是 D4+。档位数不是问题,档位的定义才是。

+ +

规则层的一个事实:只有两个旋钮能拉曲线

+

消除单调(移走棋子只减障碍)+ 旋转免费可逆,意味着「先清完能清的、再转」永远不吃亏——「什么时候转」在这套规则下永远构不成难度。能拉的只剩两个量,而 v0.5 把它们全程按平了:

+ +
+ + + + + + + + + + +
对 432 个需旋转关,在「贪心清空后卡住」的状态下重算。「有效朝向」= 5 个非零朝向中能开出棋子的个数。
范围卡住时有效朝向(均值 / 5)最近有效朝向距离 = 1被旋转锁住的棋子(均值)只有一侧能开路含擦碰阻挡关91 格关
1–503.65100%2.3045%28%0%
51–2003.90100%2.6725%35%1%
201–5003.89100%2.5822%39%14%
501–8003.84100%2.7429%39%15%
801–10003.75100%2.6328%41%17%
+
+ +

三件事一起看:① 卡住时平均 3.8 个朝向都能开路、最近的永远只差一格、锁住的永远只是最后 2–3 枚——搜索广度和锁住占比全程为零坡度;② 真正在涨的是擦碰阻挡(28% → 41%)和 91 格大盘(0% → 17%)——这是阅读负担,不是推理,对超休闲玩家是最危险的轴;③ 一个反直觉的发现:第 1 章反而是全程最挑方向的一段(45% 单向关,之后 22–29%)——新手最先碰到的旋转最挑剔,老手碰到的反而随便转,和技能习得顺序正好相反。

+ +

还有一处结构性陷阱:production-plan.js:41 把第 3 章起每个章末(第 150、200、…、1000 关)强制成「分段释放 · 91 格 · 14 枚 · 转 2 次」。我逐关核对:18 个章末全部如此。结果是可预测的「第 50 关一定难」加上 16 对相邻双峰(149–150、249–250……),直接违反计划自己写的「不能让玩家猜到每第七关一定难」和「第 1000 关可以是庆祝关」。按日均 30–40 关,这个峰大约每 1.5 天落一次,多半落在当日会话末尾——最坏的流失位置。

+ +

v0.6 曲线:分段目标

+

以计划表的形状为最低坡度,按「旋转是主解法」重定义各档,把「后半程平台」改为持续缓升。数字全部是待 A/B 的起点,不是验证过的最优——原文没有给任何频率或阈值。

+ +
+ + + + + + + + + + +
三个可算的轴:锁住占比 = 首次旋转时剩余棋子比例;有效朝向 = 卡住时能开路的朝向数;卡住点数 ≈ 必需旋转次数。可读性轴单独封顶,不进难度。
范围零旋转关平均旋转锁住占比卡住时有效朝向单向关D4+D5本段新引入的旋转关系
1–5010%0.925–30%≥2,两侧都行<15%0%第 2 关就转;一转收尾;一转放一组
51–20010%1.030–35%≥215–25%≈7%开局先转;往返双转;同向双转
201–50010%1.1–1.235–40%1–225–40%≈13%三波释放;单向双转;一转放一组升级
501–8008%1.340%1;出现需转 2 格40–50%≈18%首个三转(D5);早转且单向
801–10008%1.4–1.540–45%1≈50%22–25%不再引入新关系,只做跨章重组
+
+ +

「增长要合理」的量化(推断,依据标注):困难关间隔从前 100 关的每 12–15 关一个,到 601–1000 关的每 5 关一个;D5 之后一律立刻接喘气关,D4 之后在 300 关前立刻、之后允许隔一关,间隔无规律。新旋转关系前 10 章每章引入 1 个、首次出现放在 D3 教学位、10 关内复习 2–3 次;11–20 章不再引入新关系而做跨章重组——Refraction 的教训是概念耗尽后系统必然退化成相近关反复,v0.5 后段 489 关旋转完全无用正是这个样子。可读性两项从难度里剔除并封顶:擦碰阻挡关 ≤40%,91 格按 §07 处理。

+ +

10% 喘气关怎么放

+

先解决一道算术:现行 scorer 一次必需旋转 = 15 分 → 最低 D2,所以「零旋转 ≤10%」意味着 D1 ≤100 关,低于计划表的 150。D1 必须按「决策数 ≤1」定义,不按旋转次数。喘气有两种合法形态:(a) 零旋转开放巡游——占 10% 配额;(b) 一转即全清——卡住后任一方向转一次、转完全部可出、主角走长路。这是招牌爽点,旋转是兑现不是负担,应计入喘气但不占配额。而 v0.5 里「旋转是开局第一步、转完全盘打开」的关一个都没有。

+

100 个零旋转关的绑定顺序:每个 D5 之后 +1;每个「新关系首次出现」的 D4 之后 +1;第 1 章教学位约 20 关;章末庆祝位(替换掉强制的分段释放峰)。第 100 关以后不允许两个零旋转关相邻——v0.5 有 27 串四连、20 串三连。

+ +

池子现实:不是重排,是重做一半

+
+ + + + + + + + + + + +
按可算判据对 1000 关重算。D5 定义:≥3 次旋转,或 2 次旋转且锁住 >50% 且单向。
档可算定义现有池v0.6 需要缺口
D0 喘气零旋转,开局 ≥75% 可出,≤1 波325≈90富余
D1 一转收尾1 转,锁住 ≤2 枚,两侧都行185≈280≈100(且 223 关同一互锁套路,去重后更大)
D2 一转换路1 转,锁住 ≤50%,放 ≥3 枚或单向102≈280≈180
D3 早转 / 双转收尾1 转且锁住 >50%,或 2 转收尾64≈190≈125
D4 双转组合2 转且(锁住 >35% 或 单向 或 ≥3 波)81≈135≈55
D5 深旋转≥3 转0≈2525 — 构造原型已完成:src/deep-lock.js 四子链式锁,6 形态 × r3/r4 × 8 种子产出 55 关(锁住占比中位 78%,每转释放 ≥2 枚 38/55),可在 labs/d5/ 试玩
+
+ +

最关键的一个数:「首次旋转时剩余 ≥50% 且一转释放 ≥2 枚」的关,现有 1000 关里只有 12 关。旋转为主的战役靠重排做不出来。最少新生成约 480 关;D1 池去重后约 510 关。现有 140 个两转关清一色是「首转放 1 枚、二转放 2 枚」的同一节奏,v0.1 的 24 关里有 4 关需要 3 转——规则允许,生成器没做。

+ +

生成器要改的地方

+
+
1

编排:beats 按档位重写为 8 种体验,5–8 拍锯齿乐句

breath / finish / group / choice / early / return / stages / deep。每句喘气 ≤1 拍且紧跟该句最高档之后;前 4 章不用 deep;11 章起启用;峰值位置按章打乱。introduction 前 10 关改为第 2 关就转、零旋转 2 关。删掉 :35 的 stages→turn 改写和 :41 的章末强制 stages。

production-plan.js
1 天
+
2

生成:三个新构造参数

turn/contrast 加 lockedShare(D1 0.2 / D2 0.35 / D3 0.6)——前 N 枚按朝向 1 摆放并要求在朝向 0 被挡;stage-seed 加 stagesCount=3 与「首转释放 ≥2」;contrast 允许 home=2(两转才接上长路)。这是 D5 池从 0 到 25 的唯一路径。

production-generator.js / stage-seed.js
3–4 天
+
3

筛选:按档位拒绝,用 core.js 可算的量

inspectCandidate 新增 band-rotations / band-dependency / band-direction / breath-too-deep / stage-rhythm-repeat 五个拒绝码;质量分加 −3·|锁住占比 − 目标|;旋转次数只作诊断不再进分;maxDryRotations 对 D5 放宽到 3。同底盘间隔 ≥100 关。

production-generator.js / build 脚本
1–2 天
+
4

档位:从「旋转次数 × 15 分」改为三元组

(锁住占比, 卡住时有效朝向数, 卡住点数)。擦碰阻挡与 91 格记为可读性负担,单独封顶,不进难度分。计划 §2 本来就要求四维分开记录。

campaign-evidence.js
半天
+
+ +

上线后怎么判断坡度对不对

+

本游戏无失败态,重试次数这个仪表不存在。替代指标(先验区间,须用前 100 关真人数据标定):连续空点(点了没动的最长连续次数:1–100 关 p90 ≤2,某关 p50 ≥3 = 可读性缺陷不是难);首次离场耗时(跨章 p50 上升 = 曲线在涨,连续 300 关不变 = 平);卡住停留(无可出 → 下一次旋转的秒数,50 关后 p50 <3 s;→0 且无效旋转也 →0 = 旋转已反射化);旋转效率(有效/总,55–80% 健康,≥90% 连续 200 关 = 无决策,<40% = 瞎转);逐关会话终止率(对滚动基线,>2× 的关标出——唯一能直接看到章末双峰伤害的指标);玩家用时 / 结构估算按档分组(D4 与 D2 比值相同 = 档位没在玩家身上兑现)。

+ +

原文没回答、要试玩才能定的

+

熟练度在本项目里多快累积(同一批玩家第 2 章和第 15 章各玩一次同结构关,测首次有效动作前时长的下降);「太难」在无失败态下的表现(退出位置而非重试);零旋转关放在困难关之后是否被感知为奖励;「中途旋转」比「收尾旋转」难多少;11–20 章只做重组会不会陷入 Refraction 的概念耗尽。这五条决定坡度涨多快,原文只说「到一定关数」。

+
+ + +
+
+
+ ◆ + 保住不要动 +
+

做对了的(少而实)

+
+ +

这个项目的技术底子是好的。上面每一条建议的代价都在十行到百行量级,而不是重构——这本身就是这套架构的功劳。

+ +
+
+

计数语义

+

旋转免费、失败试路免费、无出口不判负、撤销全量恢复棋盘与朝向。这消除了休闲解谜里最大的焦虑源。core.js:281–298

+
+
+

扫掠碰撞与绘制同源

+

画出来的线和判定的线是同一份数据,用 Minkowski 六边形做精确扫掠裁剪,出界射线还额外延长 24 单位防穿模。同规模项目几乎都在这里翻车。path-math.js

+
+
+

离场动画按真实弧长计时

+

exitTiming() 用 routePoints 累加实际路径长度算时长,不是固定补间。靠近出口的棋子不会被强行拖成同样长的等待。exit-animation.js:5–12

+
+
+

连点队列存 id 不存坐标

+

全文 7 行。队列只存 id,取出时对最新棋盘重新查找——旋转 240 ms 期间连点会按新朝向重判。这个细节多数项目做错。tap-queue.js

+
+
+

教学三课共用同一块棋盘

+

19 格盘不变,只把棋子从 1 → 2 → 2 加上去,第 3 课才打开旋转。玩家从教学到正式第 1 关没有任何视觉断层。方法论完全正确。tutorial-levels.js

+
+
+

旋转底盘这个机制本身

+

底盘转 60° 而棋子位置与箭头不动,把「改变路线」和「不改变起点」干净地分离开。Railbound、Cosmic Express 都是局部操作,没有直接对应物。这是全项目唯一值得押注的原创资产——而它现在有 568 关根本用不到。

+
+
+
+ + +
+
+
▸按优先级
+

如果是我,会这样排

+
+ +

本清单已按 §07 修订。排序原则:先把机器代答的那三处收回来,再给玩家看路的手段,然后才谈奖励与内容——顺序反了,印章会惩罚一个还没有零成本看路手段的玩家。

+ +
+
+
1
+
+

三处自动提示改闲置门控,三套「看路」机制并成一套

+

app.js:185 / 218 / 103–110。阈值从最后一个动画结束起算、随剩余棋子伸缩;toast 去掉数量;提示按钮改为触发闲置提示。当天可做,是全部方案的前提。

+
+
约 30 行
当天
+
+
+
2
+
+

按下看路、松手发车 + 尾迹 + 碰撞路线 + 全盘软化

+

0 ms 预览,不是 250 ms 长按;预览画在棋子之上;离场留尾迹、被挡画到接触点。「非可达轨道降权」作废,改为全盘统一软化、只有按下的路线满色。

+
+
约 60 行
1–2 天
+
+
+
3
+
+

连击(opened.length 驱动档位)+ 两枚只做加法的印章

+

连击是反馈不是选择,但档位由「这一下打开了几枚」驱动,顺序第一次有可感知后果。印章不进关卡格;「一转到位」是旋转限额的软版本。依赖第 2 条先上线。

+
+
约 75 行 + 存档
3 天
+
+
+
4
+
+

主题绑章节 · 关名按章生成 · 教学改覆盖层 · 清理选关弹窗

+

前两项约 15 行。教学覆盖层是 D1 的最大单一杠杆。选关弹窗删版本下拉、1–1000 输入框、审核页链接、免责说明;给已通关按钮实际样式。

+
+
表现层
3 天
+
+
+
5
+
+

v0.6:分带配额重排、同底盘间隔、机制按章亮相、手作关加餐

+

不原地重排(存档是索引数组)。第 1 章 20 关、第 2 关就有旋转;全可出关当喘气关每章 ≤15%;91 格只留 98 关观赏关作长局回报槽。曲线见 §07。

+
+
约 80 行脚本
1 周
+
+
+
6
+
+

里程目标原型:让旋转成为关于「看多长」的选择

+

3,294 枚棋子换朝向能走更长;263 个全可出关旋转后有更长巡游。做 20 关原型验证「玩家会不会为了看更长的路而主动转」,验证过再进主线。

+
+
原型 2 天
验证后定
+
+
+
7
+
+

暂缓:25 套主题、Cocos / 微信移植、关卡量产、relay

+

直到第 1–3 条上线并看到真人数据。relay 可留作第 8 章后的章节专属机制。下一批生成器要加 rotGain ≥ 2 候选带。

+
+
—
+
+
+
+ +
+
+ + +
+
+

方法与口径

+ +

做了什么

+
    +
  • 读完 README.md、AGENTS.md、Design.md、design/ 五套 design.md,以及 docs/ 下 27 份设计与验收文档。
  • +
  • 在 390 × 844 与 375 × 812 的浏览器视口实机试玩:教学 1–2 课、v0.5 第 1 / 14 / 500 / 577 / 1000 关、选关弹窗、设置面板、?design=animals 皮肤、design/index.html 五套方案总览。
  • +
  • 用仓库自己的 src/core.js / src/geometry.js / src/exit-animation.js 对 levels/v0.5.json 全量 1000 关做独立重算,不读任何已有分析文件的结论。
  • +
  • 「猴子玩家」模拟:确定性 PRNG,随机点一枚在场棋子,连续 K 次失败后随机旋转;K ∈ {1, 3, 6} 三档,结论一致。
  • +
  • 本页所有棋盘图由 levels/v0.5.json 经 src/path-math.js 的 trackPoints() 现场渲染成 SVG——不是截图,是原始数据。
  • +
  • 7 路并行深读 + 对抗性交叉核查:47 条初始见解经逐条回仓库复核,31 条因事实偏差被驳回,本页只保留通过核查的部分。
  • +
+ +

这份评审不能证明什么

+

这里没有任何真人玩家数据。「猴子玩家」证明的是「不看轨道也能通关」这个结构性事实,不是「玩家一定会这么玩」——真人会先认真看,看不懂才退化成乱点,而退化发生在第几关,只有试玩能回答。同理,「一关观看 11 秒」是动画时长的加总,不是玩家的注意力时长。levels/v0.5-reviews.json 里 1000/1000 关标注 playerCalibration: "not-tested",这个标注是诚实的,本页也继承它。

+

关于底盘复用:官方审核报告称「精确整体对称去重后 1000 关唯一」,这是成立的——它的指纹把棋子算了进去。我统计的 562 是移除棋子后的轨道底盘数。两个数字都对,衡量的是不同的东西;我认为后者更接近玩家一眼看到的画面。

+

关于「长路巡游」:初次统计开局态时我得到「路径中位数 1 格、动画 0.32 秒」,据此差点得出「卖点完全没兑现」的结论。完整通关模拟推翻了它——路径会随棋盘清空而变长,每关最长一段的中位数是 4.24 秒。本页采用后者。

+
+
+ +
+
+ 转转六角 · 玩法与 UI 设计评审 · 2026-09-13 + 本页无外部字体与 CDN 依赖,与项目自身规则一致 +
+
+ + + diff --git a/scripts/build-bench-report.mjs b/scripts/build-bench-report.mjs new file mode 100644 index 0000000..68045ef --- /dev/null +++ b/scripts/build-bench-report.mjs @@ -0,0 +1,436 @@ +#!/usr/bin/env node +// Build docs/bench-reliability.html — what the agent bench can and cannot tell +// us about level quality, computed from bench/runs. +// +// node scripts/build-bench-report.mjs [--out=docs/bench-reliability.html] +// +// Every number in the page comes from the run files; nothing is hand-written. +// Batches are identified by the `agent` field, so a run's provenance (which +// model, which board format) is never guessed. +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { metrics } from '../src/bench.js'; + +const flags = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; })); +const out = resolve(flags.out || 'docs/bench-reliability.html'); +const RUNS = resolve('bench/runs'); + +// ---------- data ---------- +const set = JSON.parse(readFileSync(resolve('levels/prototypes/d5.json'), 'utf8')); +const levels = Array.isArray(set) ? set : set.levels; +const meta = Object.fromEntries(levels.map((l, i) => [l.id, { + n: i + 1, + family: l.id.replace('proto-d5-', '').replace(/-r\d+-s\d+$/, ''), + lock: l.prototype ? Math.round(l.prototype.stats.lockedShare * 100) : null, + minRot: l.prototype ? l.prototype.stats.rotations : null, + cells: l.cells.length, tiles: l.tiles.length, +}])); + +const BATCHES = [ + { tag: 'Grok Build CLI · xhigh', label: 'grok-4.6 · 旧盘面', short: 'grok旧', format: 'old' }, + { tag: 'pi · Grok 4.6 xhigh', label: 'grok-4.6 · 新盘面 · 第 1 次', short: 'grok新①', format: 'new' }, + { tag: 'pi · Grok 4.6 xhigh · 复测', label: 'grok-4.6 · 新盘面 · 第 2 次', short: 'grok新②', format: 'new' }, + { tag: 'Codex CLI · xhigh', label: 'codex-spark · 旧盘面', short: 'spark旧', format: 'old' }, + { tag: 'Codex CLI · xhigh · 新盘面', label: 'codex-spark · 新盘面', short: 'spark新', format: 'new' }, + { tag: 'Kimi CLI · k3 high', label: 'kimi-k3 · 新盘面', short: 'kimi', format: 'new' }, +]; + +// Batches run under the fixed board format, i.e. everything that can be +// compared across models without the format change confounding it. +const NEW_TAGS = ['pi · Grok 4.6 xhigh', 'pi · Grok 4.6 xhigh · 复测', 'Kimi CLI · k3 high', 'Codex CLI · xhigh · 新盘面']; + +const runs = readdirSync(RUNS).filter(f => f.endsWith('.json') && f !== 'index.json') + .map(f => { try { return JSON.parse(readFileSync(resolve(RUNS, f), 'utf8')); } catch { return null; } }) + .filter(Boolean).filter(d => meta[d.level?.id]); + +const byTag = {}; +for (const b of BATCHES) { + const rows = {}; + for (const d of runs) if (d.agent === b.tag) rows[meta[d.level.id].n] = { m: metrics(d), lv: meta[d.level.id], run: d.run }; + byTag[b.tag] = rows; +} +const present = BATCHES.filter(b => Object.keys(byTag[b.tag]).length > 0); + +// ---------- stats ---------- +const mean = a => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null; +const sd = a => { const m = mean(a); return a.length ? Math.sqrt(mean(a.map(x => (x - m) ** 2))) : null; }; +const pearson = (u, v) => { + const n = u.length; if (n < 3) return null; + const mu = mean(u), mv = mean(v); + let su = 0, sv = 0, suv = 0; + for (let i = 0; i < n; i++) { const a = u[i] - mu, c = v[i] - mv; su += a * a; sv += c * c; suv += a * c; } + return (su && sv) ? suv / Math.sqrt(su * sv) : null; +}; + +const METRICS = [ + { key: 'extraRot', label: '多转', kind: 'mech', get: m => m.rotations - m.minimumRotations, hint: '清盘旋转数 − 求解器最少旋转数' }, + { key: 'blindRotations', label: '盲转', kind: 'mech', get: m => m.blindRotations, hint: '转了一次,什么出口都没打开' }, + { key: 'wrongWayRotations', label: '转错方向', kind: 'mech', get: m => m.wrongWayRotations, hint: '另一边本来能打开' }, + { key: 'steps', label: '步数', kind: 'mech', get: m => m.steps }, + { key: 'decisions', label: '决策数', kind: 'mech', get: m => m.decisions, hint: '看了几次盘面' }, + { key: 'deadTaps', label: '空点', kind: 'mech', get: m => m.deadTaps, hint: '点了没动' }, + { key: 'actionsPerPiece', label: '动作÷棋子', kind: 'mech', get: m => m.actionsPerPiece }, + { key: 'r.stuck', label: '评分:无解感', kind: 'rating', get: m => Number(m.rating?.stuck) }, + { key: 'r.difficulty', label: '评分:难度', kind: 'rating', get: m => Number(m.rating?.difficulty) }, + { key: 'r.rotation', label: '评分:旋转意义', kind: 'rating', get: m => Number(m.rating?.rotation) }, + { key: 'r.clarity', label: '评分:清晰度', kind: 'rating', get: m => Number(m.rating?.clarity) }, + { key: 'r.fun', label: '评分:好玩', kind: 'rating', get: m => Number(m.rating?.fun) }, +]; + +function pair(tagA, tagB) { + const A = byTag[tagA], B = byTag[tagB]; + const shared = Object.keys(A).filter(k => B[k]).map(Number).sort((a, b) => a - b); + return { shared, A, B }; +} + +function reliability(tagA, tagB) { + const { shared, A, B } = pair(tagA, tagB); + if (shared.length < 3) return null; + return METRICS.map(def => { + // Drop only the levels a metric is missing on, not the whole metric: a run + // still in progress has no rating yet, and losing every rating column to + // one unfinished level would hide exactly what this table is testing. + const ok = shared.filter(L => { + const a = def.get(A[L].m), b = def.get(B[L].m); + return a != null && b != null && !Number.isNaN(a) && !Number.isNaN(b); + }); + if (ok.length < 3) return null; + const u = ok.map(L => def.get(A[L].m)), v = ok.map(L => def.get(B[L].m)); + const r = pearson(u, v); + const spread = sd(u); + const same = u.filter((x, i) => x === v[i]).length; + const n = ok.length; + // A metric is usable for ranking levels only if it both separates levels + // and survives a repeat run. + const verdict = spread < 0.35 ? 'flat' : (r == null || r < 0.5) ? 'noise' : r >= 0.9 ? 'solid' : 'ok'; + return { ...def, r, spread, same, n, verdict, u, v }; + }).filter(Boolean); +} + +// ---------- html helpers ---------- +const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +const f2 = x => x == null ? '—' : (Math.round(x * 100) / 100).toFixed(2); +const f1 = x => x == null ? '—' : (Math.round(x * 10) / 10).toFixed(1); +const secs = ms => ms == null ? '—' : ms >= 10000 ? `${Math.round(ms / 1000)} s` : `${(ms / 1000).toFixed(1)} s`; + +const VERDICT = { + solid: ['可用 · 高', 'good'], ok: ['可用', 'good'], + noise: ['噪声', 'signal'], flat: ['无区分度', 'warn'], +}; + +// Scatter of one metric across two runs of the same conditions. +function scatter(m, size = 168) { + const pad = 26, span = size - pad - 10; + const all = [...m.u, ...m.v]; + const lo = Math.min(...all), hi = Math.max(...all); + const range = (hi - lo) || 1; + const X = x => pad + ((x - lo) / range) * span; + const Y = y => size - pad - ((y - lo) / range) * span; + const dots = m.u.map((x, i) => ``).join(''); + return ` + + + + ${dots} + 第 1 次 + 第 2 次 +`; +} + +// Per-family bar of a mechanical metric. +function familyBars(tag, key) { + const rows = Object.values(byTag[tag]); + if (!rows.length) return ''; + const fams = {}; + for (const r of rows) (fams[r.lv.family] ||= []).push(key === 'extraRot' ? r.m.rotations - r.m.minimumRotations : r.m[key]); + const entries = Object.entries(fams).map(([k, v]) => ({ k, avg: mean(v), n: v.length })).sort((a, b) => b.avg - a.avg); + const max = Math.max(...entries.map(e => e.avg), 1); + return `
${entries.map(e => ` +
${esc(e.k)}${e.n} 关 + + ${f1(e.avg)}
`).join('')}
`; +} + +function batchSummary(b) { + const rows = Object.values(byTag[b.tag]); + if (!rows.length) return null; + const S = f => rows.reduce((a, r) => a + f(r.m), 0); + const A = f => mean(rows.map(r => f(r.m))); + return { + ...b, n: rows.length, cleared: rows.filter(r => r.m.cleared).length, + dead: S(m => m.deadTaps), graze: S(m => m.grazeDeadTaps), + blind: S(m => m.blindRotations), wrong: S(m => m.wrongWayRotations), + extra: S(m => m.rotations - m.minimumRotations), + app: A(m => m.actionsPerPiece), apd: A(m => m.actionsPerDecision), + total: A(m => m.timing.totalMs), dec: A(m => m.timing.medianDecisionMs), + ratings: Object.fromEntries(['clarity', 'rotation', 'stuck', 'fun', 'difficulty'] + .map(k => [k, mean(rows.map(r => Number(r.m.rating?.[k])).filter(x => !Number.isNaN(x)))])), + }; +} + +// ---------- assemble ---------- +const summaries = present.map(batchSummary).filter(Boolean); +const clean = reliability('pi · Grok 4.6 xhigh', 'pi · Grok 4.6 xhigh · 复测'); +const confounded = reliability('Grok Build CLI · xhigh', 'pi · Grok 4.6 xhigh'); +const sparkPair = reliability('Codex CLI · xhigh · 新盘面', 'pi · Grok 4.6 xhigh'); + +// worst-step notes, grouped by the theme they name +const THEMES = [ + { key: 'graze', label: '擦碰看不出来', re: /擦|紧贴/ }, + { key: 'turn', label: '不知道该转哪边', re: /转哪|该不该|哪个方向|左转还是右转|转错|继续转/ }, + { key: 'long', label: '长路线读不动', re: /太长|\d{2}\s*格|看不出|分不清/ }, +]; +function notes() { + const outp = []; + for (const b of present) for (const [L, r] of Object.entries(byTag[b.tag])) { + const w = r.m.rating?.worst; if (!w) continue; + outp.push({ batch: b.short, L: Number(L), fam: r.lv.family, text: String(w) }); + } + return outp.sort((a, b) => a.L - b.L); +} +const allNotes = notes(); +const themed = THEMES.map(t => ({ ...t, hits: allNotes.filter(n => t.re.test(n.text)) })); + +const allCleared = summaries.every(s => s.cleared === s.n); +const anyRunning = summaries.some(s => s.cleared < s.n); +const clearRateNote = allCleared + ? '

所有批次通关率都是 100%。这本身就是一条结论:通关率在这批内容上毫无区分度,任何流程都不该建立在通过/失败上。

' + : `

已完成的对局通关率全部是 100%${anyRunning ? '(个别批次仍有对局在进行中,未计入)' : ''}。通关率在这批内容上毫无区分度,任何流程都不该建立在通过/失败上。

`; + +// Agreement on the rotation triple across every batch that saw the fixed board. +const liveTags = NEW_TAGS.filter(t => Object.keys(byTag[t] || {}).length); +const triple = m => [m.rotations - m.minimumRotations, m.blindRotations, m.wrongWayRotations].join('/'); +const crossRows = []; +for (let L = 1; L <= levels.length; L++) { + const cells = liveTags.map(t => byTag[t][L] ? triple(byTag[t][L].m) : null); + if (cells.some(c => c == null)) continue; + const lv = Object.values(meta).find(x => x.n === L); + crossRows.push({ L, family: lv.family, cells, agree: new Set(cells).size === 1 }); +} +const agreeN = crossRows.filter(r => r.agree).length; +const crossModel = crossRows.length < 3 ? '

同盘面批次不足,跨模型比较尚未成立。

' : ` +

下表是同一套盘面下所有批次的 多转 / 盲转 / 转错方向。不同厂商的模型、不同 harness、各自独立跑——如果这些数字真的由关卡决定,它们就该一样。

+
+
逐关完全一致
${agreeN} / ${crossRows.length}
三项数字一个不差
+
参与批次
${liveTags.length}
${liveTags.map(t => BATCHES.find(b => b.tag === t).short).join(' · ')}
+
+
+${liveTags.map(t => ``).join('')} + +${crossRows.map(r => `${r.cells.map(c => ``).join('')}`).join('\n')} +
关家族${esc(BATCHES.find(b => b.tag === t).short)}一致
L${r.L}${esc(r.family)}${c}${r.agree ? '✔' : '✘'}
+
+

这是全份报告里最硬的一条结论。${agreeN}/${crossRows.length} 关上,独立运行的不同模型给出了完全相同的旋转三元组。这意味着规则机器人 12 秒扫出来的这三个数,等价于大模型跑几小时的结果——这个维度完全不需要 agent。

+
+${crossRows.filter(r => !r.agree).length ? ` +

不一致的关:不是难,是不稳定

+

下面这些关在不同运行之间给出了不同的数字。排除模型差异后,剩下的解释是同一关的体验取决于起手方向——两个玩家玩同一关,一个觉得还行,一个觉得这关有毛病。这比"稳定地难"更值得处理。

+${crossRows.filter(r => !r.agree).map(r => `
L${r.L} ${esc(r.family)}:${r.cells.join(' / ')}
`).join('')}` : ''}`; + +const generated = new Date().toLocaleString('sv-SE', { timeZoneName: 'short' }).slice(0, 16) + + ' ' + new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop(); + +const html = ` + + + + +关卡实验台:信度检验与 1000 关流程 + + + +
+ +
+
HEX TRAILS · 关卡实验台
+

Agent 评级里,哪些数字真能拿来做关卡

+

同一批关卡、同一个模型、同一套盘面,跑两遍。能重现的指标才配进流程——这份报告把每个指标逐个检验了一遍,结论和直觉相反。

+

生成于 ${generated} · 数据来自 bench/runs 的 ${runs.length} 局 · 关卡集 levels/prototypes/d5.json(18 关)

+
+ +

01参与检验的批次

+

每一批用 agent 字段标识,不靠文件名猜来源。「旧盘面」指擦碰只报第一个阻挡者的版本,「新盘面」指修复后全部列出的版本。

+
+ + +${summaries.map(s => ``).join('\n')} +
批次关数通关空点擦碰空点盲转转错多转单局均每决策
${esc(s.label)}${s.n}${s.cleared}/${s.n}${s.dead}${s.graze}${s.blind}${s.wrong}${s.extra}${secs(s.total)}${secs(s.dec)}
+${clearRateNote} + +${clean ? ` +

02信度检验:跑两遍还一样吗

+

同模型(grok-4.6 xhigh)、同 harness(pi)、同盘面(新)、同 ${clean[0].n} 关,只是又跑了一遍。两次之间唯一的变量就是运行本身,所以下面的相关系数就是这个指标的复测信度。

+

判定规则:关间标准差 < 0.35 说明这个指标在不同关卡上几乎一样,再稳也没用(无法排序);复测 r < 0.5 说明换一次运行结果就变,不能作为依据。两条都过才算可用。

+
+ + +${clean.map(m => ``).join('\n')} +
指标关间标准差复测相关 r逐关完全一致判定
${esc(m.label)}${m.hint ? `
${esc(m.hint)}` : ''}
${f2(m.spread)}${f2(m.r)}${m.same}/${m.n}${VERDICT[m.verdict][0]}
+

「关间标准差」衡量这个指标能不能把不同关卡区分开——标准差接近 0 的指标就算完全可复现也没用,因为每关都一样。两个条件要同时满足:既能区分关卡,又能跑两遍不变。

+ +
+${clean.filter(m => m.r != null).slice(0, 8).map(m => `

${esc(m.label)}

r = ${f2(m.r)}
${scatter(m)}
`).join('\n')} +
+

虚线是「两次完全相同」。点贴着虚线 = 可复现;点散开 = 这个数字换一次运行就变。

+` : '

02信度检验

复测批次尚未完成。

'} + +${confounded ? ` +

对照:换了盘面格式的那一对

+

上一节控制了所有变量。作为对照,这里是旧盘面 vs 新盘面的同 18 关配对——它多了一个变量(盘面变了),所以相关系数是信度的下限而非真值。两张表一起看,能分清「指标本身不稳」和「盘面变化导致的差异」。

+
+ + +${(clean || []).map(m => { + const c = confounded.find(x => x.key === m.key); + if (!c) return ''; + const delta = (m.r != null && c.r != null) ? m.r - c.r : null; + const note = delta == null ? '—' : Math.abs(delta) < 0.15 ? '两者接近,盘面变化影响很小' + : delta > 0 ? '控制变量后明显更高——之前被盘面变化压低了' : '控制变量后反而更低——值得留意'; + return ``; +}).join('\n')} +
指标控制变量后 r换盘面后 r差异说明
${esc(m.label)}${f2(m.r)}${f2(c.r)}${note}
+` : ''} + +

03跨模型交叉验证:这些数字属于关卡,不属于玩家

+${crossModel} + +

04家族结构:机械指标指向同一个地方

+

把「多转」按路线家族平均。这是关卡结构问题,不是玩家问题——两批 grok、以及 spark,在这一点上给出一致的结果。

+${present.filter(b => Object.keys(byTag[b.tag]).length >= 6).map(b => ` +

${esc(b.label)} · 各家族平均多转

${familyBars(b.tag, 'extraRot')}
`).join('\n')} +
+

orbits 和 spiral 是问题家族。它们稳定地需要 7 次旋转(最少 3 次)外加 4 次盲转——等于把 6 个朝向挨个试一遍。而 petals 和 crossing 是干净的 0。锁住率解释不了这个差异(L16 也是 orbits、锁 78%,却是 3/3)。

+
+ +

05Agent 说了什么

+

评分是 1–5 的数字,worst 是它自己写的一句话。下面按反复出现的主题归类——价值在重复模式,不在单条内容。

+${themed.filter(t => t.hits.length).map(t => ` +

${esc(t.label)} · ${t.hits.length} 次提及

+${t.hits.slice(0, 4).map(n => `
L${n.L} ${esc(n.fam)}(${esc(n.batch)}):${esc(n.text.slice(0, 110))}
`).join('\n')}`).join('\n')} + +

061000 关该怎么跑

+

核心判断:机械计数器当主力,agent 做抽检。免费的规则机器人能扫全量并稳定给出盲转/多转/转错;agent 贵但能提供两样机器给不了的东西——难度的相对排序,和用自然语言点出的系统性缺陷。关键是只收集经过信度检验的那几项。

+
    +
  1. 生成期硬门槛 · 零成本

    用已有求解器,在生成时就卡掉:最少旋转 ≥ 2、开局锁住率 ≤ 90%、首转两个方向都能推进、无死局状态。不合格的根本不进候选池。

  2. +
  3. 规则机器人全量扫 · 1000 关 12 秒

    产出盲转 / 多转 / 转错方向。按 盲转 ≥ 2 或 多转 ≥ 2 打标。这条规则在 D5 上正好命中 orbits+spiral 那几关,和两个大模型跑两小时得到的结论一致。

  4. +
  5. Agent 只跑被标记的 + 5% 随机抽样

    不是 1000 关全跑。收集三样东西:difficulty(r=0.93,可用来给 1000 关排序)、stuck(r=0.85,体感无解的直接信号)、以及 worst 文字(重复模式能挖出系统性缺陷)。丢掉 rotation / clarity / fun——复测 r 只有 0.32 / 0.19 / 0.56,是模型噪声。按实测 ${summaries.length ? secs(mean(summaries.filter(s => s.format === 'new').map(s => s.total))) : '—'}/关、4 路并行,150 关约半小时。

  6. +
  7. 真人标定锚点 · 5–10 关

    Agent 的难度排序可信(复测 r=0.93),但绝对值可能整体偏移——它觉得「刚好」的关,真人可能觉得太简单。所以真人只需要玩少数几关当锚点,把 agent 的相对排序映射到真实难度刻度上,不必逐关评。这比原本设想的省很多人力。

  8. +
+
+

最该做的一件事:把它变成回归测试,而不是评审流程。改完生成器,12 秒跑一遍规则机器人,比较盲转分布。从 22% 降到 5% 就是客观改善,升上去就是刚引入了退化。12 秒一轮可以进 CI;几小时一轮且分数不可复现的人工评审,不可能成为迭代循环。

+
+ +

07这份结论的边界

+

写清楚哪里不够硬,比多列几个数字重要。

+
    +
  • 跨模型一致性只验证了 6 关(spark 与 grok 的交集),其中 1 关不一致。机械指标"与玩家无关"这个说法,样本还太小。
  • +
  • 复测只做了 2 次。2 次能算相关系数,但对评分这种低方差的离散量,2 次的估计本身就有不小的不确定性。
  • +
  • Agent 读的是文字不是像素,测不出"看不清"。clarity 这一项从原理上就不该指望它。
  • +
  • 所有结论都基于 D5 原型这 18 关。这批关卡通关率 100%、难度评分方差极小,换一批更难的内容,结论可能不同。
  • +
+ + + +
+ +`; + +writeFileSync(out, html); +const kb = (Buffer.byteLength(html) / 1024).toFixed(0); +console.log(`报告已生成:${out}(${kb} KB)`); +console.log(` 批次 ${summaries.length} 个 · 对局 ${runs.length} 局`); +if (clean) console.log(` 干净信度检验:${clean[0].n} 关配对 · ${clean.filter(m => m.verdict === 'solid' || m.verdict === 'ok').length}/${clean.length} 个指标判定可用`); +else console.log(' ⚠ 复测批次数据不足,信度一节留空');