☰
从二维数组算法到2048游戏:别急着写UI,先理清逻辑内核
2026/9/30 5:08:40 网站建设 项目流程

先说说我自己的经历:第一次正经写 2048 小游戏,我干了件特别蠢的事——打开编辑器就开始画div网格,忙着配圆角、配颜色、配过渡动画,仿佛 UI 好看就万事大吉。结果呢?四个方向的事件绑定好了,却没有一行代码能告诉我们"按下方向键之后棋盘应该变成什么样"。憋到半夜才发现,真正卡住我的不是界面,而是那块棋盘背后的数组算法。标题这句话特别对:别急着写 UI。2048 这个看似简单的数字拼图,骨子里就是一组非常经典的二维数组操作,把这层逻辑想透了,UI 反而变成最轻松的一层。

这篇文章想讲的,就是把 2048 从"数组算法"到"前端界面"完整拆一遍,重点放在数组、矩阵操作和逻辑与渲染的分离上。适合刚学完数组和前端基础、想拿个小项目练手的人,也适合那些会用键盘滑 2048 但没搞懂内部原理的同学。你可以把它当一份从零实现的攻略,也可以当成一道二维数组的专项练习题来看。

1. 先写 UI 是最容易翻车的顺序:一个新手翻车现场还原

先还原一下那个经典翻车流程,很多人应该有同感。

1.1 打开编辑器先画格子,然后呢?

新手打开编辑器,第一件事基本都是:建一个4x4的容器,用 CSS Grid 或者 Flex 布局把十六个格子铺开,然后给每个格子标上数字2、4、8……这一步很爽,画面立刻就有模有样了。接着你开始处理键盘事件,按下方向键之后做什么?你下意识地想去操作 DOM:把某个div里的文字改掉,把某个数字挪到隔壁格子,甚至想去"计算"某个格子应该移动到哪个格子。

这就是问题的根源。你在把一个基于状态的游戏,硬生生做成了"手动操作界面元素"的活。2048 的界面只是游戏状态的投影,投影本身不应该是被操作的对象,可一旦你从 UI 入手,思维就会被 DOM 结构带跑偏:你要考虑某个数字块怎么平移、边界在哪里、要按什么顺序更新十几个格子。这还没算上合并动画,光是逻辑分支就能把你绕晕。

我自己当时就是这么卡住的。写了大概两百行 DOM 操作代码后,发现按下一次方向键,只是把几个格子里的文字改对了,但合并了几次、哪里有新块、什么时候游戏结束,全都混乱了。于是推倒重来。那次的教训很直接:棋盘在屏幕上是什么样,完全不应该是逻辑层的责任。

1.2 2048 真正的复杂度来自数组,不是界面

把游戏玩法抽象来看,2048 只有一个核心概念:一个4x4的棋盘,上面散布着一些数字,每次滑动时所有数字向滑动方向靠拢,相同的数字碰到一起就合并成它们的和,然后系统在空位随机生成一个新数字。

这个过程从头到尾都是"数据"的变化,跟界面没有半点关系。同一个逻辑,你可以跑在浏览器里,也可以跑在 Node 命令行里,甚至可以打印成最原始的字符串逐行显示。真正有挑战性、有意思的,是那 16 个格子怎么在代码里被正确地移动、合并、判断。如果能先把这部分写好,UI 只是套上去的一件衣服。

所以我的建议一直是:先把 2048 当成一道纯算法题来做,让它在控制台里跑通,再碰任何样式和 DOM。标题说"别急着写 UI",核心就是这个意思。下面我就按这个顺序,从数组建模开始一步步拆。

2. 棋盘建模:二维数组才是游戏的骨骼,CSS 只是皮肉

2048 棋盘在屏幕上是四行四列,在代码里更简单:一个四行四列的二维数组。每一个格子对应数组里的一个元素。

2.1 4x4 棋盘到 4x4 数组的映射关系

这是最直观的做法:

const board = [ [0, 0, 0, 0], [0, 2, 0, 4], [0, 0, 0, 0], [2, 0, 0, 0], ];

board[row][column]表示第row行第column列的值。0表示空格,其余数字就是格子里显示的数字。比如上面的数组,对应的棋盘就是:第二行第二列有个2,第二行第四列有个4,第四行第一列有个2。

这个映射关系是你以后所有操作的地基,必须先走通。我见过有人用一维数组加索引运算的方式做,比如board[r * 4 + c],性能上其实也没问题,但二维数组的可读性好得多,尤其写"按行处理"的逻辑时,直接board.forEach(row => ...)非常自然。对新手来说,二维数组就是 2048 的最佳建模方式。

2.2 为什么用二维数组?以及一个"看着高级"的扁平化方案

二维数组之所以好用,是因为 2048 的规则天然围绕"行"和"列"展开。向左滑动就是处理每一行;向上滑动就是处理每一列。如果数据放在一维数组里,每次都要手动算行列索引,代码会变得很绕,而且一旦棋盘尺寸变成5x5或者6x6,所有索引操作都要跟着改。

我并不是说一维数组方案不行。在一些追求极致性能的场景里,一维数组因为内存连续、缓存友好,确实更快。2048 这种只有 16 格的游戏,性能需求低得可以忽略,所以优先选可维护性更好的二维数组。就算以后想改用扁平数组,核心的移动算法也得先在二维数组的思维下理清楚。

用二维数组时,有一个我自己习惯的小约束:始终把"不修改原数组"作为默认原则,移动函数应该返回一个新数组,而不是在原数组上改。这样后面判断"这次按键到底有没有产生变化"会特别方便,而且不容易被引用类型的问题坑到。这一步想清楚,接下来每一段代码都顺。

3. 左移算法拆成三刀:压缩、合并、再压缩

2048 里四个方向的移动,本质上全是"左移"的变体。所以先把"一行数据向左移动"这个原语锤炼好,游戏的核心就完成了 80%。这个说法不夸张,我下面会演示。

3.1 第一刀:把零全部赶到行尾

假设某一行现在是:

[2, 0, 2, 4]

向左移动的第一步,是让所有非零数字靠到左边,也就是把0全部清除以后,在行尾补上对应数量的0。这一步我习惯叫压缩:

function compact(row) { const nums = row.filter((v) => v !== 0); return nums.concat(Array(row.length - nums.length).fill(0)); }

上面那行的压缩结果是:

[2, 2, 4, 0]

这一步虽然简单,但是很重要,因为它把"数字间可能有空格"这个复杂问题直接消除了。压缩之后,整行的数字都是紧挨着的,后面做合并就只需要看相邻两个元素。我经常把这一步类比成排队的时候人挤一挤,空位全甩到队尾,后面再来处理两个人的组合。

3.2 第二刀:相邻相同合并,注意"一次滑动只合并一次"的规则

压缩完,进入整道题最容易被绕进去的地方:合并。2048 合并规则里有条隐藏细节——一次滑动中,一个格子只能参与一次合并。比如这一行:

[4, 4, 8, 0]

向左移动,正确的合并结果应该是:

[8, 8, 0, 0]

最左边的4和4合并成8,原来的8保持不动。你可能会问,合并出来的8和原来的8又挨着了,为什么不再合并成16?因为那个新生成的8已经是合并产物,不能再参与本轮第二次合并。

这个规则的另一个典型例子是[2, 2, 2, 0],结果是[4, 2, 0, 0]而不是[6, 0, 0, 0],更不是[8, 0, 0, 0]。

我推荐的合并写法是:在一个循环里,从左往右扫描,发现相邻两个相等,就合并并把索引向后跳一位,跳过那个已经合并过一次的块:

function mergeRow(row) { let arr = compact(row); for (let i = 0; i < arr.length - 1; i++) { if (arr[i] !== 0 && arr[i] === arr[i + 1]) { arr[i] *= 2; arr[i + 1] = 0; i++; // 合并过一次,跳过被合并的下一个 } } return compact(arr); }

这里的i++是整个合并过程的灵魂。因为arr[i + 1]被置成0了,如果不跳过,下一轮循环会把arr[i + 1](也就是 0)抱到下一轮比较里,虽然有时候碰巧结果对,但碰到[2, 2, 2]这种情况就会出错。而跳过之后,才能保证"一个块本轮只合并一次"。

3.3 第三刀:合并后留下的空位再做一次压缩

合并完成后,数组里又出现了0。比如[4, 4, 8, 0]合并后变成[8, 0, 8, 0],这显然不是最终形态,需要再做一次压缩:

[4, 4, 8, 0] -> 压缩 -> [4, 4, 8, 0] -> 合并 -> [8, 0, 8, 0] -> 压缩 -> [8, 8, 0, 0]

所以"左移一行"的完整过程就是:压缩、合并、再压缩。这就是我说"三刀"的意思。其实有些更精简的实现把压缩和合并在一个 while 循环里直接做掉,逻辑更快。比如我生产环境里更常用这种写法:

function mergeRow(row) { const nums = row.filter((v) => v !== 0); const result = []; let gained = 0; for (let i = 0; i < nums.length; i++) { if (i + 1 < nums.length && nums[i] === nums[i + 1]) { const merged = nums[i] * 2; result.push(merged); gained += merged; i++; } else { result.push(nums[i]); } } while (result.length < row.length) result.push(0); return { row: result, gained }; }

这种写法直接基于"过滤掉零后的数字序列"进行合并,天然满足"相邻合并后跳过一格"的规则。我把教学版和这个生产版都放出来,是因为实际项目里你会想要计分数据gained,这个返回值后面做总分统计时直接用得上。

4. 四个方向收敛成一个原语:倒序与转置的小技巧

左移搞定后,其余三个方向最笨的做法是把上面的逻辑各自重写一遍。但经典的 2048 算法题考的就是你能不能把所有方向都"翻译"回左移。翻译工具有两个:数组倒序和矩阵转置。

4.1 右移 = 倒序 + 左移 + 倒序

先看右移。右移的规则和左移完全对称,只是方向相反。如果你先把这行倒序,那么"从右往左合并"就变成了"从左往右合并",处理完再倒序回来:

function reverseRow(row) { return row.slice().reverse(); } // 对每一行做右移 board = board.map((row) => reverseRow(row)); board = board.map((row) => mergeRow(row).row); board = board.map((row) => reverseRow(row));

举例:[2, 2, 4, 0]右移,倒序为[0, 4, 2, 2],左移合并为[4, 4, 0, 0],再倒序为[0, 0, 4, 4],完全符合预期。注意reverseRow里我用了slice()拷贝,不然会原地翻转原数组,破坏不可变原则。

4.2 上移/下移:先转置,再当左移处理

上移和下移麻烦在它们是"按列"操作,不是"按行"。但如果你把整个矩阵转置,也就是把行变成列、列变成行,那么"按列向左移动"就等价于"按行向左移动",处理完再转置回来。

这个思路特别经典,我当初第一次看到的时候拍了一下大腿。矩阵转置这个数组操作,在很多二维数据处理的场景里都会用到,不只是 2048。它的数学定义很简单:原矩阵第i行第j列的元素,放到新矩阵第j行第i列。

function transpose(matrix) { return matrix[0].map((_, colIndex) => matrix.map((row) => row[colIndex])); }

这段代码建议直接背下来,它非常精炼。外层map遍历列索引,内层map把每一行的第colIndex个元素抽出来组成新的一行,效果就是行列互换。

于是四个方向可以统一成这样:

  • 左移:直接对每一行做mergeRow
  • 右移:每行倒序 -> 左移 -> 倒序
  • 上移:转置 -> 左移 -> 转置
  • 下移:转置 -> 右移 -> 转置

4.3 转置函数怎么写不容易出错:一个可以背下来的模板

转置函数最大的坑是下标写反。新手很容易写出matrix[i][j]到result[i][j]的复制代码,那只是复制了一份矩阵,根本没有转置。我这里提供两个"从原理出发"的检查方法。

第一个:检查行列数的变化。一个4x4方阵转置完还是4x4,看不出问题,但如果是2x3的矩阵,转置后应该是3x2。用非方阵去测试transpose函数,如果行列数没互换,基本就是写错了。第二个:检查对角线。方阵转置之后,主对角线上的元素保持不变,比如board[1][2]应当等于转置后board[2][1],拿这个值手动验一次。

下面是我推荐背下来的模板,稳、短、可读:

function transpose(matrix) { return matrix[0].map((_, colIndex) => matrix.map((row) => row[colIndex]) ); }

有了倒序和转置这两个工具,移动方向的代码可以从之前那种四段复制粘贴,收敛成一个非常干净的move函数:

function move(board, direction) { switch (direction) { case 'left': return board.map((row) => mergeRow(row).row); case 'right': return board.map((row) => reverseRow(row)) .map((row) => mergeRow(row).row) .map((row) => reverseRow(row)); case 'up': return transpose( transpose(board).map((row) => mergeRow(row).row) ); case 'down': return transpose( transpose(board).map((row) => reverseRow(row)) .map((row) => mergeRow(row).row) .map((row) => reverseRow(row)) ); } }

transpose(board)得到的矩阵拿去做操作,之后再transpose回来,就是你想要的上移或下移结果。这套思路极好地体现了"为什么二维数组算法是 2048 的灵魂":如果你的代码里四个方向各写一套逻辑,迟早会在某个边界条件上翻车;而收敛成"一个原语加两个矩阵变换",几乎不需要单独测每个方向对不对,只要测试左移和矩阵变换即可。

5. 围绕数组的三个辅助逻辑:随机块、有效移动、终局判定

2048 除开移动合并,还有三件事必须天天跟数组打交道:随机生成新块、判断按键是否有效、判断游戏是否结束。它们单独拿出来都不难,但是组合在一起,会让游戏循环变得完整。

5.1 随机生成新块:统计空格子,掷一次骰子

每次有效移动之后,棋盘上的某个空位要随机出现一个新数字。这个逻辑可以拆成两步:

  1. 遍历整个二维数组,把所有值为0的位置收集起来。
  2. 从这些空位里随机选一个,填上2或4。经典规则是90%概率出2,10%概率出4。
function addRandomTile(board) { const empty = []; for (let r = 0; r < board.length; r++) { for (let c = 0; c < board[r].length; c++) { if (board[r][c] === 0) empty.push([r, c]); } } if (empty.length === 0) return false; const [r, c] = empty[Math.floor(Math.random() * empty.length)]; board[r][c] = Math.random() < 0.9 ? 2 : 4; return true; }

这个函数有个容易被忽略的前提:它直接修改了传入的board。在我自己设计的代码里,我倾向于只允许这个函数修改原数组,其他移动、合并函数保持纯函数特性。这样职责分明,不至于所有地方都"偷偷改数组"。

5.2 判断这一次按键是否有效:整个棋盘做一次快照对比

没有产生任何变化的移动(比如整行已经是[2, 0, 0, 0]还往左滑)不应该生成新块。判断方法有很多种,最高效的方法是"移动之前先判断有没有空隙或者可合并的相邻对"。但我个人强烈建议新手用最朴素的办法:移动前后各留一份棋盘,逐格对比。

function boardsEqual(a, b) { for (let r = 0; r < a.length; r++) { for (let c = 0; c < a[r].length; c++) { if (a[r][c] !== b[r][c]) return false; } } return true; }

你可能会觉得这样"太笨了",但注意 2048 只有 16 个格子,逐格对比的开销完全可以忽略。用这种"笨"办法,换回来的是正确性和可读性,这恰恰是工程里更重要的东西。后面我还会讲,直接比较数组引用是一个巨大的坑,所以这里宁可写一个boardsEqual也不要偷懒。

5.3 终局判定:没有空格,且没有相邻相等

游戏结束的条件有两个,同时满足才算结束:

  • 棋盘上没有任何一个空格;
  • 任意相邻的两个格子(横向相邻或纵向相邻)都不相等。

第二个条件的原理可以这么理解:如果存在两个相等的相邻格子,那么朝它们所在的方向滑动一次,这两个格子就能合并,游戏还远没到结束。所以不需要真的对四个方向各跑一遍移动,只要遍历一遍棋盘查相邻相等即可:

function isGameOver(board) { const size = board.length; for (let r = 0; r < size; r++) { for (let c = 0; c < size; c++) { if (board[r][c] === 0) return false; if (r + 1 < size && board[r][c] === board[r + 1][c]) return false; if (c + 1 < size && board[r][c] === board[r][c + 1]) return false; } } return true; }

我之前见过有人真的把四个方向的移动各跑一遍,然后看有没有变化来判断游戏是否结束。逻辑上没错,但代码冗余得多,而且还要小心移动函数是否会修改原数组。用"是否存在相邻相等"这个思路,一次遍历就搞定了,效率高,理解起来也顺。

6. 把算法和 UI 拆开:让你的游戏在控制台里跑通,再画界面

到了这一步,你手上已经有了一整套纯逻辑函数。现在可以正式回答标题里的问题了:为什么别急着写 UI?因为如果你一开始就把 UI 晾在一边,先用这个纯函数内核在控制台里把游戏跑通,后面做的事其实就只是"把数组画出来"。

6.1 逻辑层只需要暴露 move、addRandomTile、isGameOver 三个能力

我通常会用一个Game类把这些函数组装起来,让外部只和它打交道:

class Game { constructor(size = 4) { this.size = size; this.score = 0; this.board = Array.from({ length: size }, () => Array(size).fill(0)); addRandomTile(this.board); addRandomTile(this.board); } move(direction) { const newBoard = move(this.board, direction); if (boardsEqual(this.board, newBoard)) { return { moved: false }; } this.board = newBoard; this.score += this.getLastMoveGained(direction); addRandomTile(this.board); return { moved: true }; } getLastMoveGained(direction) { // 可以在 move 函数里同时返回 gained,这里简化为重新计算 // 更优雅的做法是让 move 返回 { board, gained } } isGameOver() { return isGameOver(this.board); } }

这里的关键点是:UI 层完全不 care 这些方法是基于什么数组操作实现的。UI 只需要知道三件事:初始有棋盘、按键调用move、结束后提示Game Over。你在浏览器里按方向键,本质上只是调用一个纯逻辑方法,然后拿到一个新的二维数组。

6.2 render 函数是唯一需要碰 DOM 的地方

把界面想象成一句"投影"逻辑:每次棋盘变化,调用一个render,把二维数组映射成格子。

function render(game) { const container = document.getElementById('board'); container.innerHTML = ''; const board = game.board; for (let r = 0; r < board.length; r++) { for (let c = 0; c < board[r].length; c++) { const cell = document.createElement('div'); cell.className = 'cell'; cell.textContent = board[r][c] === 0 ? '' : board[r][c]; container.appendChild(cell); } } }

这个render是全项目里唯一允许碰 DOM 的代码。这样带来的好处是,你可以在任何环境里测试、调试、甚至写自动化测试脚本去验证逻辑:只要生成一个Game实例,连续执行几个move,然后打印game.board,控制台里就能看到每一步的正确结果。我以前就是先写一个console.log版本的纯逻辑,跑通所有边界用例之后,才开始写 HTML 和 CSS,整个开发过程极其安心。

6.3 用同样的内核支持撤销和 5x5 变体

因为逻辑和 UI 解耦了,两个很酷的功能只需要几行代码就能加上。第一个是撤销。你只需要在每次有效移动前,把当前棋盘JSON.parse(JSON.stringify(this.board))存进一个历史栈,撤销时弹出一格就行:

this.history.push(this.board.map((row) => row.slice()));

第二个是变体尺寸。你会发现之前所有函数里,几乎没有写死4这个数字——board.length就是尺寸。如果你想做一个5x5的 2048,只需要new Game(5),UI 那边把网格布局改成五行五列,算法一行都不用改。这就是"数据结构先行,UI 只是投影"的实惠。

7. 我在实战里反复踩的三个坑,以及送给新手的建议

再补充几个我实际开发中踩过、也经常看同行踩的坑。写这篇文的时候我特意回想了一下,很多坑不是因为算法难,而是因为 JavaScript 数组的某些特性太容易迷惑人了。

7.1 坑一:数组是引用类型,直接比较棋盘永远"无变化"

这个太经典了。新手在判断"这次移动有没有效"时,很容易写出这样的代码:

if (this.board === newBoard) { // 没有移动? }

问题是,两个不同的数组即使内容一模一样,引用也不相等;而如果你把一个数组赋给另一个变量,它们又指向同一块内存,怎么比都相等。在这个场景里,直接比较引用几乎永远得不到你想要的结果。

我自己的解决办法就是第 5 节里的boardsEqual函数,老老实实逐格比较。你可能会觉得这个函数很土,但它在 4x4 棋盘上性能完全够用,而且可读性极强。但凡跟他人协作,后来者看到boardsEqual(this.board, newBoard)也能秒懂。用土办法解决小规模问题,本身就是一种优化。

7.2 坑二:合并方向写反,4 4 8 一碗菜全煮糊

合并逻辑里,方向感和"只合并一次"这两件事特别容易一起出错。比如[4, 4, 8, 0]向左,错误版本可能输出[16, 0, 0, 0],因为先合并了前两个 4 得到 8,又继续把新 8 和原 8 合并成 16。这是很多教程代码都容易踩的隐藏 bug。

要避免这个坑,最好在写mergeRow的时候就补上"跳过"逻辑,参考第 3 节的代码。我当时给自己定了一条死规矩:合并完成后立即让下标加一,永远不要用一个已经参与合并过的元素去参加本轮的后半段合并。另一个建议是写几个固定的测试用例跑一遍,比如[2,2,2,0] -> [4,2,0,0]、[4,4,8,0] -> [8,8,0,0]、[2,0,0,2] -> [4,0,0,0]。把这些用例先在控制台里跑通,比在界面上肉眼观察稳得多。

7.3 三条建议:纯函数、先跑控制台、再做动画

最后是我压箱底的几条经验,不算什么高深理论,但确实能帮你少走弯路。

第一,尽量让移动和合并函数保持纯函数特性,不修改传入的数组,而是返回新数组。这样做的直接好处是"比较变化"变得特别简单,间接好处是以后你想做撤销、回放、测试,成本都非常低。纯函数是数组操作里最值得习惯的一种思维方式。

第二,UI 是最后一步,但也不能太晚。我个人的节奏是:先在 Node 或浏览器控制台里把Game类跑通,再写一个最丑的render函数(哪怕只是把数字拼成字符串),然后再开始美化。这样每一步都建立在"逻辑正确"的地基上,而不是在一个界面半成品上反复猜 bug。如果你一上来就接各种动画库、花整套 CSS,最后发现核心移动算法写错了,返工成本会让你崩溃。

第三,动画一定放最后。2048 的滑动动画看起来很高级,但它纯粹是视觉层的东西。用 CSS 过渡也好、用 requestAnimationFrame 也好,都不该影响游戏逻辑的正确性。很多人做 2048 的时候把 80% 时间花在动画和样式上,结果核心算法漏洞百出。我认为真正舒服的开发顺序是:数组算法切入,纯函数收尾,渲染层贴上去,动画随手点缀,这才是一个练手项目该有的完成姿态。

如果这篇能帮你把 2048 背后的数组操作理顺,那我强烈建议你顺手再做一件事:把同样的内核写一遍,但棋盘改成 5x5。你会发现之前所有函数几乎不用动,这其实就是"数据与展示分离"和"通用数组算法"给你带来的回报。2048 确实只是个小游戏,但这个小游戏里藏着的二维数组技巧,足够让你在日后面试、做小工具、处理表格数据时,多一分从容。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询