
如果你让我给“2048 小游戏”的开发展开方式排一个优先级我一定会把这个排序放在最前面别急着写 UI先把棋盘背后那套数组算法弄明白。2048 的界面看起来再花哨核心也就是一个 4×4 二维数组上的几次操作。很多人在一开始就去抠格子样式、动画曲线、按钮布局结果算法一改UI 层跟着一团乱。其实正确的顺序应该是“数据层先行UI 只是最后一公里”。这篇就围绕如何先把 2048 的数组算法拆开、实现、跑通最后再谈 UI 的接入。1. 先把界面放到一边为什么 2048 的核心是数据层1.1 4×4 棋盘本质是一个二维数组2048 的棋盘是一个固定大小的 4×4 矩阵。每一个格子要么是空要么是一个 2 的幂数值。按上下左右键本质上就是在改这个矩阵里的数据。UI 上看到的方块移动、合并、飞入动画全部是数据变化之后视觉化的结果。所以在你写任何界面代码之前最好先建立一个“数据模型”的观念const grid [ [2, 0, 0, 2], [4, 4, 4, 4], [0, 0, 0, 0], [0, 0, 0, 0] ];这只是一个二维数组。你要做的就是在不同方向按键时对这个二维数组进行计算。UI 再复杂也只是把这个二维数组渲染到页面上。你可以用 CSS、Canvas、DOM 节点甚至打印到控制台里都不影响游戏逻辑本身。这是我做小游戏比较坚持的一个原则把逻辑与表现分开。数据层负责“发生了什么”UI 层只负责“看起来怎么样”。一旦确定move(grid, left)这样一个纯函数就意味着一份逻辑可以套到任意前端框架。今天用原生 JavaScript明天换 Vue、React后端跑自动化脚本甚至移植成微信小游戏逻辑层不用动。1.2 先写 UI 会带来的问题我看到很多初学者做 2048 时第一件事是画棋盘、定义格子、加键盘监听。等 UI 全部画好再开始写数组运算。结果往往会遇到一个很尴尬的局面每写一个移动函数都要回头改 UI 的渲染逻辑每次处理合并 bug都要在控制台和页面之间来回切换当动画效果和数组状态不一致时甚至很难定位问题到底出在“数据算错”还是“界面画错”。如果你反过来先写一个没有界面的“核心引擎”测试完再接入 UI开发体验会完全不一样。你可以只面对数组用console.table()打印每一步结果直接在命令行里验证逻辑。等所有数组操作都稳定了再花很短时间套一层界面基本不会有大改。这也解释了为什么我经常建议别人做 2048 时先做“数组算法”而不是先做“视觉”。这已经不止是 2048 的问题很多游戏、应用、工具类项目都是同一个道理数据模型稳定的项目UI 怎么折腾都稳数据模型不稳定的项目UI 越漂亮越难维护。2. 经典的数组算法压缩、合并、补零2.1 移动一行发生了什么2048 的移动规则可以分成三个步骤把一整行数据拿来看就非常清楚假设有一行[0, 2, 2, 0]向左移动后结果应该是[4, 0, 0, 0]。这个过程中发生了什么第一步压缩。把所有非 0 数字往左靠忽略空格[2, 2]第二步合并。从左到右依次比较相邻元素如果相同就合并成一个。2和2合并成4[4]第三步补零。把行补满到原来的长度 4[4, 0, 0, 0]是不是很像数组里常见的“去零压缩 相邻合并”操作这个套路在很多算法题里都能见到核心就是对数组的遍历、剪裁和填充。你也可以把它理解成一个“滚动窗口”每次都只处理相邻两个有效数字。很多人可能觉得“这不就是 filter 然后循环嘛有什么难的”。难点不在这一步而在“合并”的细节里。你需要注意一行里的同一个方块在一个回合中只能合并一次。比如[2, 2, 2, 2]向左移动得到的结果是[4, 4, 0, 0]而不是[8, 0, 0, 0]。如果第一次合并后合并结果还留在原数组里继续跟下一个 2 比较就会发生连锁合并这就是最常见的数组 bug。2.2 所有方向都能映射成“左移”一开始你可能想为四个方向分别写四个处理函数left、right、up、down。其实完全不需要。你只需要把“左移”这个基础操作写对其他方向都通过矩阵变换转换成左移。左移(left)直接对每一行执行行滑动。右移(right)先把每一行反转执行行滑动再反转回来。上移(up)先对矩阵做转置转置后每一列变成行执行行滑动再转置回来。下移(down)先转置再反转行执行行滑动再反转回来再转置回去。这个思路很经典。核心原因是“一行滑动”这个算法写一次就够通过方向变换复用避免四份重复代码。矩阵转置听起来高大上其实就是行列互换function transpose(grid) { return grid[0].map((_, colIndex) grid.map(row row[colIndex])); }对于 4×4 矩阵来说这个操作非常轻。有了这个函数移动所有方向就统一了。2.3 合并规则与无效移动合并规则里的关键点也是面试常考的点就是“每个方块在一个回合里最多参与一次合并”。从右往左、从左往右都一样一旦某两个值合并了合并后的新方块就不再参与这一轮后续合并。比如[4, 4, 4, 0]左移后的结果是[8, 4, 0, 0]。前两个 4 合并成 8第三个 4 保持原样不会继续和 8 再一次合并。如果写错了很容易变成[8, 4, 0, 0]还是[8, 4, 0, 0]其实正确结果就是[8, 4, 0, 0]但[4, 4, 4, 4]这种“四个相同连续值”更能暴露问题。凡是写成[8, 0, 0, 0]的基本都是合并链没控制住。另外还需要注意无效移动的判断。当按下某个方向但棋盘没有任何变化时比如所有数字已经贴边或者没有可合并的相邻数这时不应该生成新方块。判断方法很简单移动前后的二维数组逐元素比较如果完全相同说明这个方向无效。3. 核心实现先从单行算法写起再组装成完整移动3.1 单行滑动实现我建议把行滑动写成独立函数这样测试单个输入输出会非常方便。下面这个版本是我比较常用的function slideRow(row) { const compact row.filter((v) v ! 0); let gained 0; const size row.length; for (let i 0; i compact.length - 1; i) { if (compact[i] compact[i 1]) { compact[i] * 2; gained compact[i]; compact.splice(i 1, 1); } } while (compact.length size) { compact.push(0); } return { row: compact, gained }; }这个写法有几个点值得解释。row.filter(v v ! 0)把非零数字提取出来同时压缩了数组。为什么用 filter因为零在 2048 里只是“空位”不动、不合、不影响结果全部干掉再处理是最直观的思路。splice(i 1, 1)是关键。当两个相邻数字相等时把前一个翻倍然后把后一个从数组里移除。因为后面一个数字被移除了循环变量i继续增加时前一个数字已经不会再跟它原本的下一个数字比较天然实现了“每个方块只合并一次”。gained用来累加本次行合并产生的分数。2048 的规则是合并生成的方块值会加到总得分里。比如两个 2 合成一个 4得分增加 4。如果不单独记录后面计分会很麻烦。最后把结果补零到size长度。因为filter后数组长度可能小于 4而棋盘是固定 4×4必须补满。3.2 方向映射与棋盘移动有了行滑动接下来组装矩阵移动。我会写一个move函数通过方向和转置、反转的组合复用同一个slideRowfunction transpose(grid) { return grid[0].map((_, colIndex) grid.map(row row[colIndex])); } function move(grid, direction) { let work grid; let gained 0; // 上下方向先转置把列变成行 if (direction up || direction down) { work transpose(grid); } // 下、右方向需要先反转行再滑动再反转回来 work work.map((row) { const shouldReverse direction right || direction down; const source shouldReverse ? [...row].reverse() : row; const { row: movedRow, gained: rowGain } slideRow(source); gained rowGain; return shouldReverse ? movedRow.reverse() : movedRow; }); if (direction up || direction down) { work transpose(work); } return { grid: work, gained }; }为什么要用[...row].reverse()因为reverse()会原地修改数组如果直接对原矩阵的行做反转会导致原数据被破坏。先展开成新数组再反转可以保持输入矩阵不被修改。这个习惯很值得养成尤其是后面要做“移动前对比移动后是否变化”的时候如果原数据被改了对比就失去意义。上移和下移的转置逻辑也可以这样理解上移时每一列独立向上滑动。转置后每一列变成了行向上滑动就变成了向左滑动。滑动完再转置回去矩阵恢复原来的行列方向但数据已经被更新。3.3 完整游戏循环最后补上创建棋盘、随机生成数字、判断游戏是否结束的辅助函数完整循环就出来了function createGrid() { return Array.from({ length: 4 }, () Array(4).fill(0)); } function addRandomTile(grid) { const emptyCells []; grid.forEach((row, r) { row.forEach((value, c) { if (value 0) emptyCells.push({ r, c }); }); }); if (emptyCells.length 0) return; const { r, c } emptyCells[Math.floor(Math.random() * emptyCells.length)]; grid[r][c] Math.random() 0.9 ? 2 : 4; } function gridsEqual(a, b) { return a.every((row, r) row.every((value, c) value b[r][c])); } function canMove(grid) { for (let r 0; r 4; r) { for (let c 0; c 4; c) { if (grid[r][c] 0) return true; if (c 3 grid[r][c] grid[r][c 1]) return true; if (r 3 grid[r][c] grid[r 1][c]) return true; } } return false; }addRandomTile里我选择 90% 概率生成 210% 概率生成 4这是 2048 比较通用的概率。你也可以改成 85%/15%对玩法影响不大但 90/10 更接近原版手感。canMove的判断很有意思只要存在至少一个空格游戏就没结束如果没有任何空格再检查是否还存在两个相邻相同值。为什么检查相邻相同值只要存在相邻相同值按这个方向就能合并出空位游戏就不算结束。这个方法不需要真的把四个方向都跑一遍效率更高。一个完整回合应该是这样function playRound(grid, direction, score) { const { grid: nextGrid, gained } move(grid, direction); if (gridsEqual(grid, nextGrid)) { return { grid, score, changed: false, ended: false }; } const newGrid nextGrid.map(row [...row]); addRandomTile(newGrid); return { grid: newGrid, score: score gained, changed: true, ended: !canMove(newGrid) }; }我额外做了一个newGrid nextGrid.map(row [...row])这是为了防止addRandomTile直接修改move返回的对象保持“参数不被外部函数意外修改”的习惯。对于小游戏来说这个严谨度已经足够了。4. 没有 UI 也能把游戏跑通控制台与最小测试4.1 控制台模拟完整对局我经常在命令行里直接测 2048 逻辑。最省事的方式是创建一个 Node.js 脚本用console.table打印棋盘然后用readline监听键盘方向键。这样你在不写任何 UI 的情况下也能完整体验一整局游戏并能观察每一步数组变化。核心思路非常简单let grid createGrid(); let score 0; addRandomTile(grid); addRandomTile(grid); console.table(grid);接着写一个函数处理按键。按左键执行playRound(grid, left, score)按右键执行playRound(grid, right, score)然后打印新的grid和score即可。这里的优势很明显当你发现某个方向的合并结果不符合预期时可以直接复现“初始棋盘 特定按键”把棋盘拷贝到测试文件里跑一次就能看到结果完全不需要点击界面、观察动画、对比 DOM。我把这个过程称为“用脚本驱动游戏”其实很多 2048 自动玩脚本也是这么做的只是多了个决策函数。4.2 几个最该写的最小断言自己写小游戏时我会顺手加几个断言防止后期改逻辑改出回归问题。这些断言不需要引入重型测试框架Node.js 自带的assert就够用。const assert require(node:assert); const slide (row) slideRow(row).row; assert.deepStrictEqual(slide([2, 2, 2, 2]), [4, 4, 0, 0]); assert.deepStrictEqual(slide([4, 4, 4, 0]), [8, 4, 0, 0]); assert.deepStrictEqual(slide([2, 0, 2, 4]), [4, 4, 0, 0]); assert.deepStrictEqual(slide([0, 0, 0, 0]), [0, 0, 0, 0]);然后对完整棋盘做一次移动断言const sample [ [2, 0, 0, 2], [4, 4, 4, 4], [0, 0, 0, 0], [0, 0, 0, 0] ]; assert.deepStrictEqual(move(sample, left).grid, [ [4, 0, 0, 0], [8, 8, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0] ]);表驱动测试更好把一堆用例放进数组循环跑const rowCases [ { input: [2, 2, 4, 0], expect: [4, 4, 0, 0] }, { input: [0, 2, 2, 2], expect: [4, 2, 0, 0] }, { input: [2, 2, 2, 2], expect: [4, 4, 0, 0] }, { input: [4, 4, 8, 0], expect: [8, 8, 0, 0] } ]; rowCases.forEach(({ input, expect }) { assert.deepStrictEqual(slide(input), expect); });这种测试不仅是在保护你现在的代码也是在保护未来的你。加了动画、重构了代码、换了框架之后只需要跑一遍测试就能知道数组算法有没有被改坏。5. 实战中的常见坑与排查思路5.1 合并链问题很多人第一次写合并逻辑时会这样写for (let i 0; i arr.length; i) { if (arr[i] arr[i 1]) { arr[i] * 2; } }这样写的问题在哪[2, 2, 2, 2]经过第一轮变成[4, 2, 2, 0]然后循环继续把2和后面的2合并成4最后变成[4, 4, 0, 0]。这个例子还勉强算对。但如果数组是[4, 2, 2, 2]你想的是“前两个合并一次后两个合并一次”结果却可能是[4, 4, 2, 0]也能接受。真正的问题是当规则不允许连续合并时例如[2, 2, 2, 2]被处理成[8, 0, 0, 0]。解决办法就是我在前面用splice(i 1, 1)做的那个细节一旦合并就移除被合并的项等于跳过了合并后的元素。这个操作让数组长度变短循环索引自然向前跳过了一个位置所以不会再次参与合并。5.2 无效移动还会生成新方块游戏里最“隐蔽”的 bug 是按了不能移动的方向棋盘没变化但addRandomTile还是执行了。刚开始玩好像没太大问题但积累几轮之后你会发现棋盘上莫名其妙多了很多方块游戏难度失衡甚至还没怎么操作就满了。原因就是没有在生成新方块之前判断“移动前后棋盘是否完全一致”。对应的解决思路也很简单对比两层数组任何元素发生变化才允许生成新方块。我在playRound里就是先gridsEqual对比不一致才addRandomTile。5.3 转置和反转的方向搞反这是我实际踩过比较多的坑。上移和下移如果只用转置不同方向滑动很容易搞混。有人会把上移写成transpose→ 右滑 →transpose恢复。看起来好像也对但实际会把列上下的数字顺序颠倒合并规则就错了。解决办法是永远记住方向映射的关系实际方向预处理滑动方向后处理左无左无右行反转左行反转恢复上矩阵转置左矩阵转置恢复下矩阵转置、行反转左行反转恢复、矩阵转置恢复把所有方向都先变成“左”调试的时候可以打点验证。比如把up的临时结果打印出来看到转置后的矩阵再手动模拟一下基本就能发现问题。5.4 数组引用互相污染JavaScript 里二维数组是引用类型。你写let b grid然后修改b[0][0]grid也会跟着变。很多人没意识到这一点导致移动后的棋盘和原始棋盘互相污染对比永远相等或者随机方块写到原来的棋盘上去了。我的建议是纯计算函数不要修改入参move和slideRow都返回新数组。需要修改棋盘的地方比如addRandomTile单独处理并明确注释说明这里是有意原地修改。在移动和随机生成之间如果不想让原棋盘被修改就提前复制一层。对于 4×4 这种小规模数据复制一层数组的性能损耗完全可以忽略换来的可读性和可维护性非常划算。6. 数据层稳定之后再接 UI我的一些后续建议6.1 UI 只是数组的一次快照当你把数组算法写得足够干净接 UI 就变成了一件很轻松的事。你不需要在每个按键监听里“动画数据同步”只需要做两步监听键盘事件调用move(grid, direction)用返回的新数组重新渲染界面。你可以用 Vue、React、Canvas或者最简单的原生 DOM。因为你已经有一个稳定的“状态输入 → 新状态输出”的函数UI 只是对应状态的一次快照。比如 React 风格的状态管理可以写成function onKey(dir) { const { grid: nextGrid, gained } move(grid, dir); if (gridsEqual(grid, nextGrid)) return; setGrid(nextGrid); setScore(score gained); }注意这里我省略了随机生成和结束判断实际项目中你只需要把playRound接到 UI 层逻辑完全不用改。6.2 动画效果可以后续再补如果你先写 UI 再改算法动画往往是最大的阻力。但如果你反过来算法已经稳定再补动画就轻松很多。你可以在数据层记录每次移动的“差值”比如哪些格子合并了、哪些格子移动到了哪里UI 根据这些信息做过渡动画。即使没有动画一个console.table级别的渲染也能完整玩一局。我个人的建议是第一步只做最朴素的“刷新整个 4×4 网格”不搞任何特效。等核心玩法稳定想提升观感时再加“移动轨迹”“合并闪烁”“分数弹跳”这些效果。这样每一步都有明确的反馈不会让动画和逻辑互相影响。6.3 扩展方向还有很多数组算法稳定之后你可以把这个核心逻辑往很多方向扩展加入撤回功能只需要保存每一步的历史状态写一个人工智能自动玩脚本只需要在playRound之前加一个决策函数算出哪一步得分高或剩余空格多甚至改成 5×5、6×6 的棋盘因为算法里没有硬编码 4 这个数字改成GRID_SIZE常量即可。如果你要做成多端小游戏比如网页、小程序、Unity同样的数据层可以直接复用。我甚至见过有人拿这套数组逻辑做命令行版 2048、自动求解器和算法教学示例因为核心逻辑完全可以脱离 UI 独立存在。回到一开始说的“别急着写 UI”我的实际体验是数组算法才是这个项目最花时间的部分。把它做好了UI 只是一个表面功夫把它做差UI 再炫也救不了底层的手感。先把 2048 的数组算法写完、测透你会发现后续所有界面工作都变得非常顺。