两个看不见的索引 bug:为什么你的 2048 玩着玩着方块就消失了

举报
yd_251844643 发表于 2026/10/24 11:56:16 2026/10/24
【摘要】 连按 20 次方向键,棋盘只剩 3 个方块——两个不报错、不崩溃的索引 bug:循环里用 indexOf 反推行号,以及 reverse 后忘记把索引翻回来。附复现方法和防回归检查。

背景

前段时间写了个单文件 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 实现的真实调试过程。示例代码为简化后的等价写法,完整实现见仓库。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。