1. 别急着写 UI:先想想这个游戏到底在“算”什么
做前端或者独立开发的朋友,应该都写过或者想过写一个小游戏练手。2048 往往是最常被翻牌子的那个,规则简单、交互直观、界面也不复杂。但我见过太多人一上来就拉个 4x4 的方格、写样式、绑定键盘事件,折腾两三天把界面做得漂漂亮亮,最后卡在“方块怎么合并”这一步——因为从一开始就把重心放错了地方。
这个标题其实是我自己踩坑后的总结。我之前带过一个新手,他先用 Element UI 搭了个棋盘,每个格子一个 div,然后绑定上下左右按键去操作 DOM,一个晚上就写出了“能看但动不了”的界面。问题出在哪?他把棋盘当成了界面问题,实际上它是一个典型的二维数组状态模型。2048 的核心难度,不在 UI 层,而在你如何用数组高效地表达“移动、合并、消除”这三个动作。
所以这篇文章想聊的,不是教你做一个漂亮的 2048,而是想借这个游戏把背后的数组算法拆开讲透。无论你是写 C++、Java、Python 还是 JavaScript,理解了这套二维数组的处理思路,以后遇到矩阵旋转、行列压缩、消消乐、俄罗斯方块这类问题,都会轻松很多。我也用 JavaScript 手工搭了一版最精简的实现,文章里会直接放出核心代码和完整流程,适合想练手的同学照着写一遍。
2. 棋盘的数据结构:为什么用二维数组,而不是直接操作 DOM
2.1 界面上看到的是格子,程序里处理的是状态
先回到最基础的问题:2048 的棋盘,在程序里到底应该怎么表示?
你当然可以直接用 HTML 搞一个 4x4 的表格,每个单元格放一个数字。但问题来了,当用户按下“左键”时,你要怎么判断哪一行能合并、哪一行不能动?如果直接去读 DOM 里的文字,你得到的是字符串“2”“4”“8”,还得转成数字、计算合并、再写回 DOM。这个流程不是不能跑,但它把“逻辑计算”和“界面渲染”搅在一起了。一旦你想加个“撤销”功能、或者做个 AI 自动玩,这种写法会让你抓狂,因为你根本没有一份独立的游戏数据可以回溯。
正确的做法是用一个二维数组来模拟棋盘。比如 JavaScript 里可以这样定义:
let board = [ [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0] ];0表示空格,非 0 的数字就是该位置上的方块值。整局游戏的所有逻辑,包括移动、合并、判断输赢、计算分数,全都基于这个二维数组展开。UI 层只负责读取board的数据,然后把结果画到屏幕上。
这个思路其实就是经典的“数据驱动渲染”。你先把状态搞对了,界面自然就对了。
2.2 边界处理:二维数组的下标越界陷阱
只要跟二维数组打交道,就绕不开下标越界的问题。2048 的棋盘虽然固定是 4x4,看起来简单,但你在写移动逻辑的时候,如果每个方向都单独用下标去遍历,很容易写出又臭又长还容易出错的代码。
举个例子,要实现“左移”,你可能会这样写:
for (let row = 0; row < 4; row++) { for (let col = 1; col < 4; col++) { // 处理 board[row][col] 想左边移动的逻辑 } }注意col从 1 开始,因为第 0 列已经是最左边了,不需要再移动。但如果要写“右移”,你就要反过来从col = 2往col = 0遍历。上移、下移还得再换行列,四个方向的循环嵌套写法完全不同,代码量直接翻倍,还容易在边界条件上踩坑。
我自己早期写过一版,四个方向各写了一套逻辑,最后 bug 就出在“右移时最后一列不应该动,但中间某一列合并后又触发了一次移动”这种边界情况上。改来改去,代码越来越乱,最后干脆重构,用下面这套统一的行处理函数来消灭四份重复代码。这个经验我在后面的实操部分会详细展开。
3. 核心算法拆解:一个数组函数如何复用四个方向
3.1 把问题降维:先处理一行,再处理棋盘
2048 的移动规则,本质上可以拆成这样:无论你往哪个方向滑,棋盘上的每一行(或者每一列)都会按照那个方向进行一次“压缩合并”。
我最开始犯的错就是一心想着“我怎么同时处理整个二维数组”,结果把自己绕晕了。后来想通了:二维数组的数据结构虽然有两个维度,但具体到移动逻辑时,你完全可以把它拆成一维的问题来解。
比如你要处理“左移”,你只需要考虑每行数组[2, 2, 0, 4]经过一次逻辑处理后变成[4, 4, 0, 0]。一行的问题想清楚了,二维的问题就只是“对每一行都执行同一个处理函数”而已。右移就是先把数组反转,处理完再反转回来。上移就是先把矩阵转置,处理完再转置回去。
这套思路有个好处:核心算法只写一遍,剩下的全是数据预处理。下面我直接把关键代码写出来。
3.2 slideRow 函数:一次遍历完成移动与合并
一次“左移”操作,对单行数组来说,需要完成三件事:去掉 0(空格)、合并相邻相同数字、再把 0 补回末尾。
我用一个最简单直接的思路实现:
// 处理一行,返回新的行数组 function slideRow(row) { // step 1: 去掉所有 0,只保留非零值 let arr = row.filter(n => n !== 0); // step 2: 从左往右合并相邻的相同数字 for (let i = 0; i < arr.length - 1; i++) { if (arr[i] === arr[i + 1]) { arr[i] *= 2; arr.splice(i + 1, 1); // 删除被合并的那一项 score += arr[i]; // 记录分数(如果全局有 score 变量) } } // step 3: 补齐 0 while (arr.length < 4) { arr.push(0); } return arr; }这里最重要的细节是for循环里的i++和splice。第一次合并后,arr的长度变短了,但i继续递增,所以不会出现同一个数字连续被合并两次的情况。举个例子,[4, 4, 4]经过处理后,第一次循环把前两个 4 合并成 8,arr变成[8, 4],然后i变成 1,循环结束,结果是[8, 4, 0, 0]。这正好符合 2048 的规则:每轮每个格子最多参与一次合并。
但这里有个性能问题:splice会移动数组元素,频繁调用在小棋盘上影响不大,但如果你想把这个算法扩展到更大的矩阵上,可以换一种写法——先用两个指针扫描,把合并结果写到新数组里。下面这种写法更“算法正确”,也更容易让你在面试中讲清楚:
function slideRow(row) { let arr = row.filter(n => n !== 0); let result = []; for (let i = 0; i < arr.length; i++) { if (i + 1 < arr.length && arr[i] === arr[i + 1]) { result.push(arr[i] * 2); score += arr[i] * 2; i++; // 跳过下一个,保证不重复合并 } else { result.push(arr[i]); } } while (result.length < 4) { result.push(0); } return result; }两种写法都可以,你在实战里选自己顺手、不容易出错的就行。我个人更推荐第二种,因为它没有修改原数组,纯函数式的风格在 debug 时更清晰。
3.3 转置与反转:一个函数搞定上下左右
现在有了slideRow,剩下的就是怎么把四个方向都映射到它上面。
核心技巧是两个数组操作:反转(reverse)和转置(transpose)。
- 右移:把每一行
reverse(),执行slideRow,再reverse()回来。 - 上移:把整个矩阵转置(行变列、列变行),然后对每一行执行
slideRow(此时相当于对原来的每一列做“左移”),再转置回来。 - 下移:先转置,然后对每一行反向处理(相当于原来的每一列做“右移”),再转置回来。
转置的代码也很简单:
function transpose(matrix) { let result = []; for (let col = 0; col < 4; col++) { result[col] = []; for (let row = 0; row < 4; row++) { result[col][row] = matrix[row][col]; } } return result; }于是主移动逻辑就变成:
function move(direction) { // direction: 'left' | 'right' | 'up' | 'down' if (direction === 'left' || direction === 'right') { for (let i = 0; i < 4; i++) { let row = board[i]; if (direction === 'right') row = row.reverse(); board[i] = slideRow(row); if (direction === 'right') board[i] = board[i].reverse(); } } else { board = transpose(board); for (let i = 0; i < 4; i++) { let row = board[i]; if (direction === 'down') row = row.reverse(); board[i] = slideRow(row); if (direction === 'down') board[i] = board[i].reverse(); } board = transpose(board); } }这一套写下来,四个方向一共只用了一处合并逻辑,代码量大幅减少,出错的概率也低得多。我第一次把这套重构跑通的时候,最大的感受就是:你不需要为每种方向单独写算法,而是想办法把数据统一成同一种形态,然后复用那一个函数。这个思路在后续遇到其他矩阵类题目时真的非常有用。
4. 从算法到游戏:完整实现一个可玩的 2048
4.1 游戏循环的四件套:生成、移动、判断、渲染
一个 2048 能从“静悄悄的数据”变成“能玩的小游戏”,本质是一个循环:
- 生成一个新方块(随机位置,2 或 4)。
- 玩家按方向键,棋盘数据更新。
- 更新分数。
- 判断游戏是否结束(棋盘满了且无法合并)或者玩家是否胜利(出现 2048)。
核心数据结构和移动逻辑上面已经给出来了,剩下的就是把这四件事串起来。我用一个很简短的流程来说明整个游戏的骨架:
// 初始化棋盘 function init() { board = [ [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0] ]; score = 0; addRandomTile(); addRandomTile(); render(); } // 随机生成一个数字(90% 概率为 2,10% 概率为 4) function addRandomTile() { let emptyCells = []; for (let r = 0; r < 4; r++) { for (let c = 0; c < 4; c++) { if (board[r][c] === 0) emptyCells.push([r, c]); } } if (emptyCells.length === 0) return; let [r, c] = emptyCells[Math.floor(Math.random() * emptyCells.length)]; board[r][c] = Math.random() < 0.9 ? 2 : 4; } // 判断是否还能移动(没有空格 + 相邻没有相同数字 = 游戏结束) function canMove() { for (let r = 0; r < 4; r++) { for (let c = 0; c < 4; c++) { if (board[r][c] === 0) return true; if (c + 1 < 4 && board[r][c] === board[r][c + 1]) return true; if (r + 1 < 4 && board[r][c] === board[r + 1][c]) return true; } } return false; }判断游戏结束的逻辑,是很多新手容易漏掉的点。很多人只判断“棋盘满了”,但实际上 2048 游戏结束的条件是“棋盘满了且没有任何相邻格子可以合并”。只要还有一列或者一行里有相邻相同数字,游戏就还能继续。上面这个canMove()把两种情况都考虑了。
4.2 渲染层:数据怎么变成你在屏幕上看到的样子
算法层跑通以后,UI 层反而成了最轻松的部分。我直接用 HTML + CSS 画棋盘,然后用一个render()函数把二维数组映射到界面上。
最简单的实现方式是维护一个 4x4 的 DOM 网格,每次渲染时更新每个格子的内容:
<div id="board"> <!-- 动态生成 16 个格子,用>function render() { for (let r = 0; r < 4; r++) { for (let c = 0; c < 4; c++) { let cell = document.querySelector(`[data-row="${r}"][data-col="${c}"]`); let value = board[r][c]; cell.textContent = value === 0 ? '' : value; cell.className = 'cell'; if (value !== 0) { cell.classList.add('cell-' + value); // 用于不同数字的背景色 } } } }这里的cell-2、cell-4、cell-8等 CSS 类负责控制不同数字的背景色和文字颜色。如果你想用 Element UI 之类现成的组件库也不是不行,但对于游戏场景,纯 CSS 控制样式其实更直接。因为 2048 的数字种类是有限的(2、4、8、16……2048),你完全可以用一组写死的样式类来覆盖所有情况。
还有一点值得注意:UI 层不要自己“决定”数字怎么移动和合并。我在实际操作中养成了一个习惯:无论动效多复杂,先保证board数组的值是正确的。动画效果只是锦上添花,核心逻辑和 UI 之间要有一个清晰的接口——UI 只负责读board,不负责改board。
4.3 动效优化:一个滑动动画的注意点
完全不带动画的 2048 也能玩,但体验会比较生硬。我在做了基础版本之后,又花了点时间加滑动过渡,这里有一个关键点想提醒大家:动画必须基于“移动前的位置”计算,而不是移动后的位置反过来推测。
举个例子,你按了“左”,原来在row=0, col=3的一个 4 移动到了row=0, col=0。如果你直接渲染的时候把元素放在col=0,再让它从col=0滑到col=0,等于没有动画。实现动画的正确思路是:
- 移动前,记录每个非零方块的位置和值。
- 移动后,用新旧位置对比,给方块设置一个位移(比如从
col=3到col=0,就是水平位移三格)。 - 动画结束后,再更新为最终位置。
我在第一版动画里偷懒,直接操作 DOM 位置,结果每次按键后方块都是“瞬移”的,后来改成上述方案才流畅。如果你用的是 Canvas 或者 SVG,思路也是同理——动画是基于差值计算的,不是基于结果反推。
5. 常见问题与排查技巧实录
5.1 为什么我的方块会重复合并?
这是 2048 初学者最容易遇到的 bug。举个例子,一行原始数据是[2, 2, 2, 2],正确的左移结果应该是[4, 4, 0, 0],但很多人的第一次实现会得到[8, 0, 0, 0]或者[2, 4, 2, 0](取决于遍历方向)。
根本原因在于:合并时没有记录“数组已经发生了变化”,导致同一个元素在同一轮里参与多次合并。比如用for (let i = 0; i < row.length; i++)循环,当row[0]和row[1]合并后,你把row[1]删掉了,原来的row[2]变成了新的row[1],但i已经递增,你可能会跳过它或者重复处理。
解决办法就是我在 3.2 节里写到的“双指针 + result 数组”方案:无论原数组如何变化,你只从头到尾遍历一次非零序列,遇到相邻相等就合并,合并后主动跳过一个元素。这样保证每个数字最多参与一次合并。
排查这个 bug 的另一个实用技巧是:写一个辅助函数,把某个方向的移动结果打印出来,手动模拟几组输入输出,比如[2,2,2]、[2,2,2,2]、[4,4,8]、[2,0,2,0]。只要这个行处理函数能扛住这些边界测试,方向扩展的代码基本就不会出大问题。
5.2 玩家按了方向键,但棋盘没变化,是不是逻辑错?
很多新手会写一个keydown监听器,然后每次都执行move(),结果发现“连按两次方向键数字会随机叠加”。这是正常的吗?其实要分情况:如果这次移动没有产生任何有效变化(比如所有方块都贴在最左边,你再按左),那么不应该生成新方块。如果生成了,就会导致你按一次“无效”方向键也“白白消耗”一个随机数,还会让棋盘越来越容易满。
所以正确的流程应该是:调用move()时,拿移动前后的棋盘做对比,如果数据完全一样,就不调用addRandomTile()。实现方法是在move()之前深拷贝一份board,移动后对比:
function isSameBoard(a, b) { for (let r = 0; r < 4; r++) { for (let c = 0; c < 4; c++) { if (a[r][c] !== b[r][c]) return false; } } return true; }这是一个小细节,但直接影响游戏手感和难度曲线。没有这一步,你的 2048 会比原版更难玩,因为无效操作也会消耗随机数。
5.3 键盘事件在页面上不生效怎么办?
如果你把 2048 嵌在一个比较大的页面里,可能会遇到键盘事件被其他元素(比如输入框、按钮)抢走焦点的问题。常规写法是:
document.addEventListener('keydown', handleKeydown);但如果你页面上有input这类可聚焦元素,按下方向键可能会先滚动页面或者移动光标,而不是触发你的游戏。更好的做法是监听window,并且判断事件对象的target是不是可输入元素,如果是就直接跳过。还可以用e.preventDefault()阻止方向键默认滚动行为,但要注意只在你处理游戏操作时才调用,否则会影响页面其他部分的滚动。
我在移动端调试时还遇到一个问题:手机浏览器没有方向键。最简单的方式是加四个按钮或者监听touchstart/touchend的滑动方向。滑动方向判断不难——记录起点坐标和终点坐标,计算水平/垂直位移哪个更大,就能确定滑动的方向。做得更完善一点,可以设置一个最小滑动阈值(比如 30 像素),避免轻触也被误判成滑动。
5.4 棋盘满了,但没有 2048,怎么判断游戏是否真的结束?
这个问题我在 4.1 节已经提到过,判断逻辑是“无法生成新方块 + 没有任何相邻相同数字”。但还有一个性能相关的点:你不需要每次移动后都遍历整个棋盘做一次完整判断。虽然 4x4 的棋盘非常小,遍历一遍根本没有压力,但如果你以后把这个逻辑迁移到更大的地图(比如 8x8 甚至 16x16),遍历成本会上升。可以在每次移动后先检查是否还有空格,如果没有空格了,再检查相邻合并的可能性。这个优化虽然简单,但体现了一种“先快筛、再精判”的思维方式。
还有一种情况:游戏里出现了 2048,但玩家想继续玩。原版 2048 在出现 2048 后会弹出一个“获胜”提示,但玩家可以选择继续。相应地,你的游戏状态里应该区分“win”和“over”两种状态:出现 2048 是胜利但不是结束;没有可移动方块才是真正的结束。
6. 扩展与进阶:同一种算法能玩出多少花样
写完了基础版 2048,这个项目能延伸出的方向其实很多。我简单列几个我亲自试过的玩法,每个都能帮你加深对数组算法的理解:
一是“撤销”功能。你只需要维护一个历史栈,每次移动前把board的深拷贝push进去,撤销时pop()出来。这个功能在核心逻辑阶段做起来非常轻松,如果你一开始就把数据结构和 UI 混在一起写,想做到撤销就得连 DOM 也要跟着存,麻烦得多。
二是“AI 自动玩”。网上有很多 2048 AI 的算法解析,最常见的是“期望值最大化”或者简单的启发式权重评估。它们本质上都是不断模拟下一步落子,评估棋盘状态的好坏(比如空格数量、最大数字的位置、单调性)。这需要你对棋盘状态做大量的复制和评估,你前面如果已经写了合理的deepCopy和transpose工具函数,AI 部分会省很多事。
三是“变体规则”。比如改成 5x5 棋盘、加入“随机 3”或者“禁止合并一次以上”的模式。这些改动往往只影响很少的代码——换棋盘尺寸(把 4 改成 N)、改随机生成逻辑、调整合并逻辑。如果你一开始就把数字 4 硬编码进了所有循环,改成可变大小就要动很多地方。
四是“超小 UI 化嵌套”。如果你想把 2048 做成微信小游戏、Unity 里的一个模块,或者嵌入到任何现有的管理系统里作为一个小应用,核心算法代码几乎可以原封不动地搬过去。变动的只有 UI 渲染层和交互层。
我在做 Unity 微信小游戏打包时,也遇到过类似的情况:C# 的逻辑部分完全不用动,只是把渲染从 DOM 换成了 Sprite 或者 UGUI。这就是“数据与表现分离”带来的最大红利。
7. 工具与语言适配:同样的逻辑,换语言怎么写
2048 的核心算法不绑定任何语言,但我发现很多人学算法只看不写,或者只用一个语言写。其实换语言换一遍,你对这个算法的理解会完全不同。
用 JavaScript 写,优点就是浏览器直接跑,无需环境配置,方便验证。用 Python 写,代码更短,适合做 AI 训练,因为 Python 对数组切片特别友好:
def slide_row(row): arr = [x for x in row if x != 0] result = [] i = 0 while i < len(arr): if i + 1 < len(arr) and arr[i] == arr[i + 1]: result.append(arr[i] * 2) i += 2 else: result.append(arr[i]) i += 1 result.extend([0] * (4 - len(result))) return result用 C++ 或者 Java 写,你能更深刻地理解“引用传递”和“数组拷贝”的区别——因为如果不小心在函数里直接修改了原数组,很容易出现多个方向互相污染的问题。
顺序上我的建议是:先把你最熟悉的语言写通一版,然后立刻换一门语言重写一遍,尤其是从 Python 换到 C++ 这种跨维度的切换,效果拔群。你不需要实现 UI,只需要把 move、合并、判断输赢这几个函数跑起来,配合命令行打印棋盘就行。
这里顺便说一个困惑很多人的概念:“UI 层”。这个词现在经常出现在前端招聘、系统设计里,但它本质上就是“表现层”。2048 项目最大的价值,就是让你在非常小的体量里体会到 UI 层和逻辑层分割的边界在哪。你不写 UI,程序照样可以用命令行玩;你不写算法,UI 就只是一堆不会动的格子。
8. 踩过坑之后,我建议你这样练
最后给你们一个操作顺序建议,是我同时带过好几个新手之后总结出来的:
第一步,先不要写界面。用命令行或者最简单的日志输出,把二维数组每一行打印出来。实现init、addRandomTile、move、canMove,用方向键模拟调用。
第二步,给棋盘加一个“手动输入方向”的测试入口。比如在命令行里输入a代表左、d代表右、w代表上、s代表下,每次输入后打印棋盘。这样你能非常快速地验证核心逻辑是不是正确。
第三步,确认逻辑没问题后,再写 UI。这时的 UI 只是一个“显示器”,你不需要分心去处理合并错乱的问题,只需要把数组展示出来就行。
第四步,再加动画、音效、特效。这些是锦上添花,不会影响你游戏的核心可玩性。
这个顺序最大的好处是:你随时知道自己哪里写错了。棋盘数据不对,一定是算法问题;棋盘数据对但显示不对,一定是渲染问题。问题边界清晰,排查效率高很多。
我个人在实际操作中的体会是,很多卡在“写 UI”这一步、摩拳擦掌想画一堆精美格子的人,缺的往往不是 UI 技巧,而是数据模型思维。2048 这个 4x4 的小棋盘,恰好提供了一个低门槛的练习场,让你用最小的成本去感受“先想清楚数据结构,再动手写界面”到底意味着什么。把这个项目彻底吃透,后面你再看到类似的棋盘类游戏、二维数组题目,心里就亮堂多了。