2048 的合并为什么不能连锁:用纯函数和确定性测试守住规则
向左推动 [2,2,4,0],正确结果是 [4,4,0,0],本步加 4 分。前两个 2 合成的 4,不能立刻与后面的 4 再合成 8。若代码在原数组里反复查找相邻相同值,很容易把一次移动变成连续消除。
再看 [2,2,2,2]:向左应得到 [4,4,0,0],加 8 分。虽然与前例的最终排列一样,参与合并的原始方块不同,得分也不同。只断言棋盘,不检查分数,会漏掉一部分规则错误。
让新结果退出本轮扫描
单行处理可以拆成三步:去掉零,从移动目标一侧扫描,最后补零。关键是扫描输入序列,把结果写入新数组;成功合并就消耗两个输入。伪代码如下:
numbers = input 去掉所有零
output = []; gain = 0; i = 0
while i < numbers.length:
if 后面还有一个数 且 numbers[i] == numbers[i + 1]:
output 追加 numbers[i] * 2
gain += numbers[i] * 2
i += 2
else:
output 追加 numbers[i]
i += 1
output 补零到四格
return { line: output, score: gain }
这里没有“再检查刚生成数字”的入口,因此每个原始方块在一轮中最多参与一次合并。[2,0,2,4] 先压缩成 [2,2,4],随后得到 [4,4,0,0];[2,4,2,0] 则保持原样,不能跨过中间的 4 合并两端的 2。
实际实现中的 mergeLine 不访问页面,也不读取随机数。输入相同,返回的数字与得分就相同,调用者的数组保持原样。这使问题可以缩小到四个数字,而不必靠反复试玩碰到异常。
四个方向,共用同一条规则
方向还决定先配对哪一端。[2,2,2,0] 向左得到 [4,2,0,0],向右得到 [0,0,2,4]。验证时也要使用不对称输入:[2,2,4,4] 向左为 [4,8,0,0],向右为 [0,0,4,8]。后者并非前者的简单翻转,不能把向左计算后的数组直接反转来实现向右移动。
实现采用坐标序列统一处理:左、上沿索引递增读取,右、下沿索引递减读取;行或列转换成一维序列,交给 mergeLine,再按同一组坐标写回。需要反向的是读取顺序与写回位置。这能避免四份合并逻辑逐渐产生差异。现有测试用不对称棋盘分别核对四个方向的完整结果、得分,并确认输入棋盘未被修改。
随机生成必须服从回合边界
一次回合先执行确定性的 moveBoard,比较每个格子的值判断是否变化。没有变化,就直接返回原状态,不增加步数、不计分、不生成新数字,也不消耗随机数。例如左对齐的 [2,4,0,0] 再向左移动,仍是无效移动;“有空格”不代表“这个方向有效”。
有效移动后,才生成一个新方块。spawnTile(board, random) 接受随机函数,默认使用 Math.random,约定返回值在 [0,1):第一次取样选择按行枚举的空格,第二次以小于 0.9 生成 2,否则生成 4。测试传入固定序列,就能精确验证位置与数值;传入会抛错的函数,还能发现本应不取样的分支意外调用了随机源。
还应区分两个数值关系:移动与合并阶段保持棋盘总和不变;生成阶段使总和增加 2 或 4。得分增量则是所有合并产物之和。总和守恒能发现丢数、复制数字,却不能单独发现连锁合并,因为错误地把两个 4 合成 8 也守恒。它必须与具体的单次合并样例一起使用。
满盘与终局之间还差一次判断
满盘仍可能存在合法合并。canMove 检查空格,以及水平方向、垂直方向的相邻相同值;扫描时只看右邻和下邻,就能覆盖所有相邻对。没有空格且没有相同邻居,才是无法继续。
终局检查应在生成新方块之后执行,因为最后一个空格可能刚被填满。现有用例用固定随机序列构造这一边界,确认完整回合结束时 over 已更新。达到 2048 则是另一个状态:记录胜利并暂停,选择继续后可恢复移动;不能把达到目标直接当成无路可走。
这些断言直接运行从实际 HTML 中提取的 game-core,避免测试维护另一份规则副本。2026 年 10 月 24 日保存的 test-green-final.log 记录了 20 项通过、0 项失败,涵盖四方向、无效移动、随机边界、终局及胜利后继续等行为。本文依据实现、测试文件与当时记录展开分析,没有重新运行测试;守恒关系是补充的检查思路,不代表现有测试已覆盖所有输入。
把合并、方向映射和回合推进分清之后,界面只需把操作转换成方向、渲染新状态。真正支撑玩法可信度的,是每条规则都有明确的输入、输出和可复查的反例。小项目同样需要明确的规则契约。
- 点赞
- 收藏
- 关注作者
评论(0)