下载 App
《忆如泉涌》开发日志 #05:第五个玩法,让干扰"动"起来
10/203 浏览


四个玩法做下来,涌现已经走过四条路:难度的随机分布、语义冲突、维度绑定、信息衰减。
这一篇是第五种,也是唯一一种会动的:把干扰本身做成动态的。
同时这篇还有两件正经事:修掉挂了三篇的 T10 布局 bug,以及又一次被测试抓出的 bug——这次是测试脚本自己写错了。


一、涌现的第五种形态:干扰在运动
看这张截图。屏幕中央两个字:「变小」。
但请看清楚:这两个字现在非常大。
因为刚才那 1 秒里,它从小变大了。
所以正确答案是**「变大」**,不是「变小」。
这就是第五个玩法动态识别。规则一句话:忽略字义,判断它实际怎么动。
和前四个玩法的根本区别
前四个玩法的干扰都是静止的——字词写在那里、数字排在那里、字号定在那里,你看一眼就能分析。
动态识别的干扰在时间轴上展开:
| 玩法 | 干扰的性质 | 你什么时候能看清 |
|---|---|---|
| 色彩辨识 | 静态(字义 vs 字色) | 随时 |
| 意形分离 | 静态(数值 vs 字号) | 随时 |
| 排序记忆 | 信息消失 | 消失前 |
| 动态识别 | 运动(字义 vs 动效) | 只有那 1 秒 |
四种动效:旋转、静止、变大、变小。1 秒内只执行一种,展示完就停。
你没法"回头再看"—— 动效已经结束了。 字还留在屏幕上(停在终态),但它已经不动了。你只能靠刚才那 1 秒的记忆和判断。
为什么保留字词、不复位
这里有个反直觉的设计:展示结束后,字词保留,停在动效结束时的状态,不复位、不循环。
也就是说,如果刚才"变小"实际在变大,那字就会一直很大地留在那儿,直到你作答。
为什么不把它复位回原始大小?因为复位本身就是一个动作——玩家可能把"复位"误读成"变小"。一旦复位,题目就有歧义了。
停在终态,题面才是诚实的:它显示的,就是它最后的样子。
16 种组合,不筛不挑
字词 4 种 × 动效 4 种 = 16 种组合,独立均匀抽取。
其中 4 种字义与动效相同(写"旋转"就真在旋转),12 种不同。
我们不排除相同的组合。因为如果只出"冲突题",玩家会学会一个捷径:“反正它写的和做的一定不一样,那我反着选就行”——那就不是在观察了,是在套公式。
保留 4 种"诚实题",才能保证玩家必须真的去看。
顺带一个实现细节:16 种组合均匀抽取,但相邻两题不允许完全相同。不是为了难度,是为了可感知——如果连着两题都是"变小/变大",玩家会以为这题没换,产生"卡住了"的错觉。


二、动效必须"诚实"地反映时间
做动态玩法有个隐蔽的坑:动效不能按帧数推进。
如果代码写成"每帧加固定角度",那在 60fps 的设备上,1 秒转 270°;在 30fps 的设备上,1 秒只转 135°——同样的题,不同设备看到的东西不一样。
所以动效一律按真实经过时间插值:
进度 t = (当前时间 - 展示开始时间) / 1 秒
旋转角度 = 270° × t
缩放倍率 = 0.75 + (3.75 - 0.75) × t
t 会被裁剪到 [0, 1],所以无论卡帧还是快帧,动效都严格在 1 秒内完成,终点也严格一致。
这条写成了纯函数(Motion.At(effect, t)),好处是可以确定性测试:
变大起点 0.75 / 中点 2.25 / 终点 3.75
变小起点 3.75 / 中点 2.25 / 终点 0.75
旋转起点 0° / 终点 270°
静止全程不动
这些断言全部通过——动效的"诚实",是可以用数字验证的。


三、修掉挂了三篇的 T10
先还债。
舒尔特方格那个布局溢出 bug,从第 2 篇挂到第 4 篇。第 3 篇我定位了根因,但当时忙第四玩法没动手。这次修了。
根因回顾:求解单格尺寸的循环,从"整宽单格"起每次减 1px,上限 300 次。
- 一段横屏:起点 2040 / 3 = 680
- 需要降到约 271,要 409 次递减
- 预算只有 300 次 → 提前退出,带着没收够的尺寸收工 → 溢出
修法:把"每次减 1"换成二分查找。
因为约束对 cell 是单调的——cell 越小,棋盘越小、越容易放得下。单调函数求边界,二分是标准解法,迭代次数从几百降到约 10 次。
关键:这不是"把预算调大蒙过去"。 二分求出的解和原来"逐减到底"求出的解完全一样(都是满足约束的最大 cell),只是算得快。我没有改动任何布局规则,只是换了个等价的求解方式。
验证:
| 场景 | 修复前 | 修复后 |
|---|---|---|
| 无头断言 | 5831/5832 ❌ | 5832/5832 ✅ |
| 真实渲染 1280×720 | T10 失败 | PASS ✅ |
| 真实渲染 390×844 | — | 5862/5862 ✅ |
横屏截图我也读图确认了:5×5 棋盘完整显示、右侧留有边距、无溢出。


四、这次被抓的,是测试脚本自己
修完 T10 跑全量回归,动态识别报了 3 项失败:
Dynamic: FAILED 164/167
issues: ['[动态识别] 7 对 3 错', '[动态识别] 正确率 70.00%', '[动态识别] 同答对数按正确率排序']
一开始我怀疑是玩法逻辑有问题。排查后发现三个都是测试脚本自己写错了。
第一个:测试推进节奏错了
测试想构造"10 题里 7 对 3 错"。写法是每轮把时间 +1 秒,然后作答:
lua复制
luanow = now + 1 rate:Tick(now) rate:Answer(...)
看起来没错——每题 1 秒展示,加 1 秒正好进入下一题的作答阶段。
但实际跑出来是交替的 correct / ignored:
q1 effect=rotate pick=rotate -> correct
q2 effect=rotate pick=rotate -> ignored ← 被忽略了
q3 effect=rotate pick=rotate -> correct
q4 effect=rotate pick=rotate -> ignored
...
最终 correct=4 wrong=1 answered=5
为什么?
因为 Answer() 内部会立即换到下一题,而下一题又要重新展示 1 秒。所以答完第 1 题后,第 2 题才刚开始展示;此时 now 只加了 1 秒,正好卡在展示阶段,作答被忽略。
每两轮才能答一次题。 我实测确认了这个节奏(打了 10 轮,只答进 5 题)。
修法:不再用固定步长,而是按每题的展示截止时刻推进——这才是真实玩家的节奏。
第二个:答错选项写死了
测试想故意答错,写的是"如果前 7 题答对,后 3 题选 still(静止)"。
但如果实际动效恰好就是 still,那"故意答错"就变成答对了。
修法:答错时动态挑一个必定不同的选项(effect == "rotate" and "still" or "rotate"),而不是写死一个值。
有意思的是,同一个项目里的另一个测试脚本(RunDynamicChecks.lua)用的是正确写法。 两份测试写同一件事,一份对一份错——这也说明了为什么"同一逻辑要有独立验证"。
第三个:mock 数据排反了
榜单测试构造了三行假数据,断言"同答对数时,正确率高的在前"。
但 mock 数据本身把 60% 放在了 80% 前面:
Encode(12, 20) → 答对 12、答 20 题 → 60%
Encode(12, 15) → 答对 12、答 15 题 → 80%
而 Fetch 不重排(服务端已按降序返回,客户端只解码)。数据顺序反了,断言自然失败。
修法:把 mock 数据按真实降序摆正。
这三个 bug 说明什么
测试代码也是代码,一样会错。 而且测试写错了比产品代码写错更隐蔽——因为你会先怀疑产品代码。
这三个失败的共同点:它们都不是玩法逻辑的问题,而是"测试怎么模拟玩家"的问题。 玩家不会每 1 秒精确点一次,不会故意选一个可能碰巧正确的选项。
测试要模拟的是真实行为,不是理想行为。
修完之后:动态识别 177/177 全部通过。


五、五个玩法的当前状态
跑完五套断言:
| 玩法 | 断言总数 | 通过 | 失败 |
|---|---|---|---|
| 舒尔特方格 | 5832 | 5832 | 0 ✅(T10 已修) |
| 色彩辨识 | 286 | 286 | 0 |
| 意形分离 | 3509 | 3509 | 0 |
| 排序记忆 | 967 | 967 | 0 |
| 动态识别 | 177 | 177 | 0 |
另外动态识别还有一套独立的纯逻辑验收(RunDynamicChecks.lua),144 项全通过,覆盖题目生成、动效插值、分数编码、存档保优、榜单解码。
五个玩法,现在全部零失败。
关于排行榜还有个小设计:动态识别要按"答对数优先、正确率次之"排序,但云端只能存单个整数。所以编码成:
score = 答对数 × 10001 + 正确率万分比
10001 这个基数是关键——正确率的取值范围是 [0, 10000],所以步长取 10001 能保证次要不进位到主序,整数大小顺序与二级排序完全同序。


六、当前状态与下一步
| 项 | 状态 |
|---|---|
| 五个玩法 | ✅ 全部实装,断言零失败 |
| 主菜单五玩法入口 | ✅ |
| 五玩法独立排行榜 | ✅ |
| T10 布局 bug | ✅ 已修复并验证 |
| 发布物料 | ✅ 本地已备齐 |
| 真实设备验收 | ❌ 仍未进行 |
| 意形分离容量极限 | ❌ 发布前阻断项,仍未测 |
下一步:
- 真实设备验收 —— 现在所有验证都是离屏渲染 + 断言。真机的后台行为、联网提交、触摸手感,一个都还没验过。这是目前最大的空白。
- 意形分离容量极限 —— 数字会一直涨,迟早排不下。这是发布前必须解决的阻断项。
- 平台截图上传 —— 上篇提到的上传失败还没打通。


七、五个玩法,五种涌现
把五个玩法并排看,涌现的路径各不相同:
| 玩法 | 干扰维度 | 涌现来源 |
|---|---|---|
| 舒尔特方格 | — | 难度的随机分布 |
| 色彩辨识 | 字义 | 语义与视觉的冲突 |
| 意形分离 | 字号 | 数值与字号的绑定 |
| 排序记忆 | 时间 | 信息随时间的衰减 |
| 动态识别 | 运动 | 干扰本身在运动 |
注意最后一行:前四个玩法的干扰都是某个静态属性,动态识别的干扰是属性随时间的变化。
这是目前最难的一种干扰——因为它只存在 1 秒,错过了就没了。
而且它也是最诚实的一种:你不能回头再看,只能凭当下那一眼。


本文为《忆如泉涌》开发日志第 5 篇,记录 2026-10-21 的第五玩法实装、T10 修复与测试脚本纠错。












