两个看不见的索引 bug:为什么你的 2048 玩着玩着方块就消失了
背景
前段时间写了个单文件 2048,10KB、零依赖,键盘和触屏都能玩。自己随手点了几下觉得没问题,就搁在那儿了。
后来为了给它准备展示用的截图,我写了个脚本模拟按键,连按 20 下方向键——结果棋盘上只剩 3 个方块,而且直接弹出了「游戏结束」。
正常玩 20 步,棋盘应该逐渐被填满。只剩 3 个方块,意味着方块在移动过程中凭空消失了。
这类 bug 最麻烦的地方在于:它不影响编译,不报错,控制台干干净净,手动慢慢玩也未必能复现——只有连续快速移动时才会暴露。
先看现象,再猜原因
复现脚本很简单:
const seq = ['ArrowLeft','ArrowUp','ArrowRight','ArrowDown', /* ... 重复20次 */];
seq.forEach(k => document.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true })));
结果:
分数:0
棋盘上的方块:2, 2, 2
状态:游戏结束
分数 0 说明一次合并都没发生,但方块只剩 3 个。这两个线索结合起来,指向一个结论:方块不是被合并掉的,是在写回坐标时被覆盖掉了。
Bug 1:循环里用 indexOf 反推行号
原代码的移动逻辑分两步:先把每一行抽成 line,压缩合并,再按行号写回坐标:
for (const line of lines) {
const placed = [];
// ...压缩合并,结果放进 placed
for (let k = 0; k < placed.length; k++) {
const t = placed[k].t;
const r = (dir === 'left' || dir === 'right') ? lines.indexOf(line) : k;
const c = (dir === 'left' || dir === 'right') ? k : lines.indexOf(line);
t.r = r;
t.c = c;
}
}
lines.indexOf(line) 是在问「我当前处理的这行,在 lines 里排第几」。但这个问题在循环里本来就有答案——它就是当前迭代的位置,只是被 for...of 藏起来了。
用日志看它到底错在哪
我在写回前后打了坐标日志:
console.log('line', li, 'before', line.map(x => `${x.t.r},${x.t.c}=${x.t.v}`));
console.log('after', placed.map(p => `${p.t.r},${p.t.c}=${p.t.v}`));
输出是这样的:
line 0 before 0,0=2
line 1 before 1,0=2
line 2 before 2,0=2
after 0,0=2
after 0,0=2 ← 三个方块都写到了 (0,0)
after 0,0=2
三行不同的数据,全部写到了同一个坐标。indexOf 对三个不同的 line 返回了同一个 0。
indexOf 是按引用做严格相等比较的。理论上每轮 const line = [] 都是新数组,引用不同,它应该返回各自的下标。但实测就是全 0,而继续追语言层面的细节对解决问题没有帮助——真正该改的是这种写法本身。
直接消除对它的依赖
循环里已经知道「现在是第几行」,就不该再让程序回头去查:
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
// ...
const r = (dir === 'left' || dir === 'right') ? li : pos;
const c = (dir === 'left' || dir === 'right') ? pos : li;
}
改完重跑 20 步模拟:
分数:52
方块数:8
状态:进行中
方块不再消失,分数也正常增长。
这里值得记一笔:li 是循环自带的计数器,O(1) 且必然正确;indexOf 是一次 O(n) 的回头查找,结果依赖引用语义。用一次查找去重新推断一个已经知道的事实,是把确定性换成了不确定性,而且出错时不会报错,只是数据悄悄写到错误的位置。
Bug 2:reverse() 之后忘了把索引翻回来
修完第一个问题,我把方向全试了一遍,发现新问题:向右和向下移动时,方块位置是反的。
原因很直接:
if (dir === 'down' || dir === 'right') line.reverse();
为了让「向尾部合并」的逻辑统一,代码把行反转了,于是 line[0] 变成了最右边(或最下边)的方块。但写回时用的还是 pos = k:
const r = (dir === 'left' || dir === 'right') ? li : k; // k 从 0 开始
const c = (dir === 'left' || dir === 'right') ? k : li;
k = 0 被写到第 0 列,而它本该是第 3 列。整行就左右颠倒了。
修复:
const flip = (dir === 'down' || dir === 'right');
for (let k = 0; k < placed.length; k++) {
const pos = flip ? (N - 1 - k) : k;
const r = (dir === 'left' || dir === 'right') ? li : pos;
const c = (dir === 'left' || dir === 'right') ? pos : li;
}
凡是「为了复用逻辑而反转数据」的地方,写回时都必须再反转一次。 这类对称性错误在网格、矩阵、分页、栈相关的代码里特别常见,而且往往只在某个方向上出错,测试时容易漏。
怎么防止再犯
这两个 bug 都源于「靠肉眼验证」。修完之后我加了三条检查:
1. 每个方向单独走一步,断言坐标
test('向右移动后方块靠右', () => {
// 造一行 [2,0,0,0],向右移动
// 断言唯一方块在 c === 3
});
2. 连续移动后断言方块总数守恒
2048 的规则是:每次移动最多减少「合并次数」个方块,再新增 1 个。如果一次移动后方块数骤降,就是写回覆盖了。
const before = countTiles();
const merges = countMergesInMove();
move('left');
const after = countTiles();
// after 应该 >= before - merges
3. 随机走 1000 步不出异常
比手点靠谱得多,而且能覆盖到「连续快速移动」这种手动难以复现的路径。
小结
| 现象 | 表面原因 | 真实原因 | 修法 |
|---|---|---|---|
| 方块凭空消失 | 像是合并了 | 循环里用 lines.indexOf(line) 反推行号,写回坐标撞车 |
改用 for (let li ...) 的索引 |
| 向右/向下位置颠倒 | 像是对齐问题 | reverse() 后写回未翻回索引 |
pos = flip ? N-1-k : k |
两个 bug 有一个共同教训:循环体里已经知道的东西,不要交给查找函数重新推断。li 是 O(1) 的已知事实,indexOf 是 O(n) 的猜测;用猜测去决定写哪里,出错时还不报错,只是数据悄悄没了。
另外,如果一段代码需要你反复在脑内模拟语言行为才能确认它是对的,那它就该重写了——不是因为一定有 bug,而是因为它已经不可维护。
本文基于一个单文件 2048 实现的真实调试过程。示例代码为简化后的等价写法,完整实现见仓库。
- 点赞
- 收藏
- 关注作者
评论(0)