"别急着写 UI"是我带新手做项目时常说的一句话,尤其是那种界面看起来花花绿绿的小游戏。今天拿 2048 当例子聊聊,因为这个游戏大家太熟了,规则简单到一句话能说完,但它的核心逻辑,说穿了就是一个经典到不能再经典的二维数组算法问题。数组、遍历、碰撞、交换、合并、边界判断——这些平时在算法题里背了又背的东西,在这个小游戏里全都能落地。把这套逻辑想清楚,别说 2048 了,很多类似的滑块类游戏你都能一眼看穿。这篇文章就写给想自己动手写一个 2048、或者想借个小项目把算法基础打扎实的人。
1. 为什么说"先别写 UI"
很多人拿到"做个 2048"这个需求,第一反应是打开编辑器,先把 4x4 的网格画出来,给每个格子设置背景色,再琢磨数字怎么居中显示。我见过不少新手把这个界面折腾了整整两天,结果一到写滑动逻辑就卡死——不知道格子怎么移动,不知道两个相邻的 2 能不能合并,不知道滑动一次之后新方块该出现在哪里。
这不是说 UI 不重要,而是说UI 是结果,算法是原因。界面是给算法背书的展示层,你把算法的每个状态想明白了,UI 就只是把状态"翻译"成视觉元素。反过来,你先把界面做完,再去想算法,就等于先把房子装修好再去砌承重墙,最后大概率要把瓷砖砸了重新来。
1.1 游戏本质是一个"状态机"
我们先把 2048 拆解成最朴素的形式:一个 4x4 的格子,里面填了一堆 2 的幂次方数字。玩家每次操作就做一件事——按一下方向键,让所有格子往那个方向"滑",滑的过程中相同的数字碰到一起就加成一个更大的数字。滑完之后,在空出来的格子里随机冒出一个小数字(2 或者 4)。如果所有格子都满了,而且相邻格子之间没有任何一个相等数字,游戏就结束了。
这个描述里没有任何 UI。你可以用文字、用命令行、用一个小网页来承载这套规则,甚至在一张草稿纸上模拟它。但游戏能不能成立,完全取决于这套规则能不能被精确地执行。所以,代码的骨架应该是"规则",不是"界面"。
1.2 算法先行能帮你省掉什么
我把算法先行的好处总结成几句话,你感受一下:
- 首先,你可以脱离视觉调试。写一个纯控制台的版本,每一步移动后直接把数组打印出来,哪个格子到了哪,哪个数字合并了,一目了然。用 UI 调试的时候,你还得盯着一堆坐标换算,很容易被界面表现带偏。
- 其次,数据结构决定了代码难度。2048 最自然的数据结构就是一个二维数组,
board[row][col]。只要你选对了这个结构,后面所有逻辑都顺了。很多人卡住,是因为一开始就用数组存"格子对象",每个格子带一堆样式属性,结果每个操作都得在 UI 属性和数字之间来回折腾。 - 最后,算法先行让你容易测试。你可以在代码里写一堆测试用例,给定初始数组和一次滑动,断言最终的数组长什么样。这些测试比任何界面点击都好使,改代码的时候心里有底。
如果你之前写小游戏的习惯是"边画界面边想逻辑",我强烈建议你试着反过来一次。不是 UI 不重要,而是你的思维得先安置在"状态"上,而不是"画面"上。状态对了,画面是水到渠成的事。
2. 2048 的规则拆解:从游戏文案到算法条件
在动手写代码之前,得先把一句"相同数字合并"翻译成精确的算法条件。这一步骤很多人跳过了,结果写出来的逻辑在大多数情况下看着对,一旦碰到连续合并、多个相同数字同时出现这种 corner case 就露馅。
2.1 移动的本质是"非零元素重排"
想象你按了一下"左"键。这一瞬间,每一行的数字都要往左边靠拢。比如这一行是[0, 2, 2, 4],按左之后理论上应该变成[4, 4, 0, 0]——两个 2 合并成 4,原来的 4 住在最左边。这就是"重排":先把所有非零数字提取出来,按原来的相对顺序排到左边,剩下的位置全补 0。这一步做对了,移动效果就完成了一大半。
但事情没这么简单。[2, 2, 2, 2]按左之后是[4, 4, 0, 0]还是[8, 0, 0, 0]?这取决于你定义"一次滑动只能合并一次"还是"允许连锁合并"。标准 2048 的规则是:同一个数字在一次滑动中只能参与一次合并。所以[2, 2, 2, 2]的结果是[4, 4, 0, 0],不是[8, 0, 0, 0]。这个细节特别容易踩坑,很多人第一版实现会写成一路合到底,结果玩两把就出现"4 个 2 直接变 8"这种不符合原版规则的现象。
2.2 "合并"的判断条件
再精确一点:合并发生在相邻的两个非零数字相等的时候。但"相邻"这两个字很微妙——[2, 0, 2, 0]按左,这两个 2 算是相邻吗?它们中间隔了 0,但最终要合并。所以真正的判断逻辑是:从左到右扫描,取第一个非零数字,再看它右边剩下的数字里找到下一个非零数字,如果两者相等,就合并;如果不等,就把当前数字定位到结果的下一个位置,继续找。
这个逻辑用文字说起来有点绕,我直接写代码给你看。后面讲的每一种方向(上、下、左、右)其实都建立在同一个核心函数上,我们只需要写好"往左滑动一行"的处理,其他方向都靠旋转这个技巧去复用。
2.3 为什么推荐先做"向左滑动"
方向有四八个,如果每个方向单独写一套循环,代码会非常臃肿,还容易在行和列之间换来换去的时候把下标搞错。聪明一点的做法是:先写一个通用的mergeLine(line)函数,它只处理一维数组的"左合并";然后处理"上、下、右"时,先把这个方向的格子排列转换成一维数组,处理完再还原。
具体怎么做,第三部分我详细讲。这里先给结论:你只需要把"向左"这一个方向彻底写对、写稳,整个游戏的移动逻辑等于完成了四分之三。剩下的四分之一,是"换方向"这件事的数学处理。
3. 核心算法:一维数组的合并与移动
现在开始写核心代码。我用了不少语言来演示,看起来五花八门,其实本质都是一样的。这里我用 JavaScript 写,因为它可以直接在浏览器里跑,后面接 UI 你也能对上。你完全可以换成 Python、C++、Java,只要保留下面的函数逻辑就行。
3.1 第一步:把一行非零元素"挤"到左边
先看最简单的动作:把一个数组里的 0 都去掉,剩下的元素按顺序排到前面,末尾补 0。这一步叫"压缩"。
function compress(line) { const result = []; for (let value of line) { if (value !== 0) { result.push(value); } } while (result.length < 4) { result.push(0); } return result; }测试一下:compress([0, 2, 2, 4])得到[2, 2, 4, 0]。很好,数字的相对顺序没变,只是把空位挤到了右边。这个函数你别小看,它就是滑动的基础。你想象一下,每往左滑一次,每一步之前都要做一次这样的压缩,把散落在数组中间的 0 清理掉,才能看到"谁和谁是紧挨着的"。
3.2 第二步:合并相同数字
压缩之后,数组里已经没有 0 夹在中间了。这时候可以安全的扫描合并。我见过很多人在这里用"遍历一遍然后和前一个比较"的方法,但这会踩到"重复合并"的坑。这里我推荐一个很好用的小技巧:用标志位 mark 标记某个位置是否已经被合并过。
function merge(line) { // 先压缩 line = compress(line); let result = new Array(4).fill(0); let index = 0; let i = 0; while (i < 4) { if (i < 3 && line[i] === line[i + 1] && line[i] !== 0) { result[index] = line[i] * 2; i += 2; // 跳过下一格,因为这一格已经被合并了 } else { result[index] = line[i]; i += 1; } index++; } return result; }这个实现的妙处在于line[i] === line[i + 1]这个判断只在压缩后的连续数组上做,所以不会出现"数字中间隔了一个 0 却合并了"这种逻辑问题。i += 2跳过被合并的那个位置,天然保证了"一次滑动只能合并一次"。测试merge([2, 2, 2, 2]),结果是[4, 4, 0, 0],符合标准规则。再测merge([2, 2, 4, 0]):先压缩成[2, 2, 4, 0],2 和 2 合并成 4,然后下一个是 4,结果就是[4, 4, 0, 0]——注意,这个 4 和原来那个 4 是同等级的数,但它们并没有合并!因为刚才的 4 已经作为合并结果占了一个位置,扫描指针已经到它后面了,所以它不会再去和原来的 4 合并。标准 2048 里就是这样的:一次滑动中,新产生的数字不会再参与本轮的合并。
3.3 第三步:四行统一处理
有了merge函数,处理整个棋盘"向左滑动"就非常简单了。把二维数组的每一行取出来,丢进merge,再放回去。
function moveLeft(board) { const newBoard = board.map(row => merge(row)); return newBoard; }是不是觉得太轻松了?对,就是因为我们已经把最难的逻辑封装在了merge里。现在你要做上、下、右,不需要重新写"按列扫描"的逻辑,只需要一个旋转技巧。
3.4 旋转技巧:一招解决四个方向
先给结论:矩阵顺时针旋转 90 度,然后再调已有的moveLeft,再旋转回去,就能实现任意方向的移动。
具体来说:
- 按"上" = 矩阵顺时针旋转 90 度 ->
moveLeft-> 矩阵逆时针旋转 90 度 - 按"下" = 矩阵顺时针旋转 90 度两次 ->
moveLeft-> 矩阵逆时针旋转 90 度两次 - 按"右" = 矩阵顺时针旋转 90 度三次 ->
moveLeft-> 矩阵逆时针旋转 90 度三次
为什么能这么干?因为旋转本质上就是交换了行列的对应关系。"上移"要求每一列从上到下压缩合并,你把这个矩阵顺时针转 90 度,原来的每一列就变成了一行,那么"从上到下合并"就等价于"对新矩阵从左到右合并",也就是moveLeft。合并完之后再转回去,数据位置就正确了。
旋转函数长这样:
function rotateClockwise(matrix) { const n = matrix.length; const result = Array.from({ length: n }, () => new Array(n).fill(0)); for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { result[j][n - 1 - i] = matrix[i][j]; } } return result; } function rotateCounterClockwise(matrix) { const n = matrix.length; const result = Array.from({ length: n }, () => new Array(n).fill(0)); for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { result[n - 1 - j][i] = matrix[i][j]; } } return result; }然后移动的逻辑统一写成:
function move(board, direction) { // direction: 'left' | 'right' | 'up' | 'down' let rotated = board; let times = 0; if (direction === 'right') times = 1; if (direction === 'up') times = 2; if (direction === 'down') times = 3; // times 次顺时针旋转 for (let i = 0; i < times; i++) { rotated = rotateClockwise(rotated); } let moved = moveLeft(rotated); for (let i = 0; i < times; i++) { moved = rotateCounterClockwise(moved); } return moved; }为什么 right 只要转 1 次?因为把矩阵顺时针转 90 度,原来的"从左到右"就变成了"从下到上",你再调用moveLeft,就相当于让原来的行从右往左滑。转回来之后,结果就是原方向下的正确状态。这一招不仅简洁,还能保证所有方向上的合并规则完全一致——因为你始终调用的同一个merge函数。
我在这里补充一个新手常见的误区:转完方向之后,一定不要忘了转回去。有些人总是在调试时发现"按了上键数字跑到右边去了",就是因为只转了没转回来。
3.5 判断"这次移动有没有产生变化"
游戏里有个很重要的交互逻辑:如果玩家按的方向没有引起任何格子变化,就不需要生成新方块,也不需要把这次操作算作一步。比如棋盘已经只有一行[2, 4, 8, 16],你按上,没有任何数字会动,那就什么都别发生。
所以每次移动之后,我们要比较新旧棋盘是否相同。
function boardsEqual(a, b) { for (let i = 0; i < 4; i++) { for (let j = 0; j < 4; j++) { if (a[i][j] !== b[i][j]) return false; } } return true; }这个函数本身很简单,但它代表了一个重要的思路:UI 只应该响应"真正发生了的移动",这样既能防止瞎生成数字,也能避免动画错乱。你以后做任何游戏,都可以保持这个习惯——逻辑层判断状态是否变化,界面层才决定要不要渲染。
4. 随机方块、胜利判定与胜负判定
移动合并搞定了,2048 就完成了一小半。接下来的问题是:每次移动后,怎么在空位生成一个新的 2 或 4?棋盘什么时候算赢?什么时候算死?这些都是算法问题,但会直接影响游戏体验。
4.1 随机生成新方块的细节
随机生成规则一般是:先找到所有值为 0 的格子,随机选一个,再以 90% 的概率填 2,10% 的概率填 4。这个比例是原版经典配置,太高的 4 概率会让游戏前期难度飙升,太低的话游戏又太无聊。
一个常见的坑是"随机分布不均匀"。如果你用一个Math.floor(Math.random() * total)从 0 到 total 之间取一个索引,再从头遍历格子,数到第 index 个空格子时填入,这样在数学上其实是均匀的。但如果你用"先随机行再随机列,如果那个位置不是空位就再随机一次"的方式,可能效率较低,极端情况下还会出现死循环——所以还是老老实实先收集空位列表。
function randomEmptyCell(board) { const empty = []; for (let i = 0; i < 4; i++) { for (let j = 0; j < 4; j++) { if (board[i][j] === 0) empty.push([i, j]); } } if (empty.length === 0) return null; const [x, y] = empty[Math.floor(Math.random() * empty.length)]; board[x][y] = Math.random() < 0.9 ? 2 : 4; return [x, y]; }这里我建议生成新方块的位置和值的信息要返回给 UI 层。为什么?因为如果你要做"新方块放大一下"这种动画,你得知道哪个格子是新生成的。这个信息就是返回值[x, y]和填进去的值。算法先行不代表 UI 不重要,而是算法要能轻松地给 UI 提供它需要的信息。
4.2 胜利判定:2048 里程碑
2048 这个游戏名字来源于目标数字:当任意一个格子达到 2048,你就赢了。但很多游戏并不在出现 2048 时立刻结束,而是给你两个选择:继续玩或者见好就收。作为开发者,你至少得在检测到 2048 的时候弹个提示。
实现很简单:遍历棋盘,只要board[i][j] === 2048,就是胜利。
function checkWin(board) { for (let row of board) { if (row.includes(2048)) return true; } return false; }有的变体规则会检查任意格子达到某个 target 数字,比如 4096、8192。你可以把 2048 定义成一个常量,游戏界面显示目标值也用它。这样以后改目标数字就改一行,不用全局搜索魔法数字。
4.3 失败判定:一个容易犯错的地方
失败条件有两个,必须同时满足:
- 所有格子都被占满,没有任何值为 0 的格子;
- 相邻格子之间没有相等的数字。
注意,不是只有行相邻才算,列相邻也要算。我第一次实现的时候只检查了行,结果明明某一列里有两个连续的 4 还能合并,游戏却提前判负了。
function canMove(board) { // 有空位就可以移动 for (let i = 0; i < 4; i++) { for (let j = 0; j < 4; j++) { if (board[i][j] === 0) return true; } } // 水平相邻相等 for (let i = 0; i < 4; i++) { for (let j = 0; j < 3; j++) { if (board[i][j] === board[i][j + 1]) return true; } } // 垂直相邻相等 for (let i = 0; i < 3; i++) { for (let j = 0; j < 4; j++) { if (board[i][j] === board[i + 1][j]) return true; } } return false; }这个canMove函数是游戏循环的核心判断。每次平移结束、新方块生成之后,你都调用它。如果返回false,游戏就结束了。你会发现这个函数也能用来判断"能不能往某个方向滑动"——只要某个方向移动前后棋盘变了,就说明这个方向能走。但更高效的做法是像上面那样直接判断。这个函数的时间复杂度固定是 O(16),就算调用频率再高也没压力。
4.4 游戏主循环:算法之间的协作
现在我们把上面的所有函数串起来,形成一个完整的游戏循环:
- 初始化棋盘,随机生成两个初始方块(经典配置是两个 2,也有一个 2 一个 4 的变体)。
- 等待玩家按键。
- 根据方向调用
move(board, direction)。 - 用
boardsEqual判断这一步是否有效。没变化则忽略按键,回到 2。 - 有效则更新棋盘;检查是否胜利,是则弹出胜利提示。
- 调用
randomEmptyCell生成新方块。 - 调用
canMove判断是否还有可移动的可能。没有则游戏结束。 - 回到 2,等待下一次操作。
注意顺序:先检测胜利,再生成新方块。因为如果你先生成新方块,某个本来已经凑成 2048 的格子可能被新方块盖住判断?不会盖住,但顺序错了会影响胜利检测的时机,玩家体验上也有差异。而且如果棋盘已满但可以移动,先生的方块可能会让棋盘真的满了,这时候失败判定就会触发——但这不是玩家的操作导致的,不合理。
还有一个体验细节:游戏的初始状态应该是"空棋盘 + 两个初始方块"还是"已经有数字的棋盘"?标准做法是后者,开局就随机给两个方块。如果你给一个全空棋盘让玩家手动开始,会多一个多余的操作。
5. 从数组算法到 UI 层:怎么把状态美美地"画"出来
算法写清楚之后,UI 就是纯粹的"状态到视觉"的映射。你会惊奇地发现,之前"边写 UI 边想逻辑"时候觉得非常难搞的动效问题,现在变得非常简单,因为你知道每一帧的数据结构长什么样了。
5.1 棋盘渲染的本质是"遍历数组"
如果用的是网页,你可以用 CSS Grid 画 4x4 网格,然后每个格子绑定到一个board[i][j]值。每次数据变化,重新渲染整个面板即可。2048 格子数量很少,全部重绘也不会有性能问题,不需要做啥虚拟 DOM 之类的优化。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>2048</title> <style> .grid { display: grid; grid-template-columns: repeat(4, 80px); grid-template-rows: repeat(4, 80px); gap: 8px; background: #bbada0; padding: 8px; border-radius: 8px; width: fit-content; } .cell { display: flex; align-items: center; justify-content: center; background: #cdc1b4; border-radius: 6px; font-size: 28px; font-weight: bold; color: #776e65; } </style> </head> <body> <div id="board" class="grid"></div> <button onclick="slide('left')">左</button> <button onclick="slide('right')">右</button> <button onclick="slide('up')">上</button> <button onclick="slide('down')">下</button> <script src="2048.js"></script> </body> </html>渲染函数:
function render(board) { const container = document.getElementById('board'); container.innerHTML = ''; for (let i = 0; i < 4; i++) { for (let j = 0; j < 4; j++) { const div = document.createElement('div'); div.className = 'cell'; div.textContent = board[i][j] !== 0 ? board[i][j] : ''; container.appendChild(div); } } }能看到吗?整个 UI 层就一个render函数。你不需要关心哪个格子动了、哪个格子没动,只需要把整个数组铺到界面上。只要render被反复调用,棋盘就永远和数据保持一致。这就是"算法驱动 UI"的意思:UI 只是数组的一个投影。
5.2 键盘监听与按键映射
桌面端 2048 一般用方向键操作,偶尔也有 WASD 方案。你要做的就是监听keydown事件,把按键映射成算法层能识别的字符串。
document.addEventListener('keydown', function(event) { const keyMap = { 'ArrowLeft': 'left', 'ArrowRight': 'right', 'ArrowUp': 'up', 'ArrowDown': 'down', 'a': 'left', 'd': 'right', 'w': 'up', 's': 'down' }; const direction = keyMap[event.key]; if (direction) { event.preventDefault(); slide(direction); } });按住方向键不放会连续触发移动,这其实是符合大多数玩家习惯的,不需要额外做防抖。但如果你希望手感更柔和,可以在每次滑动后加一个 100ms 的冷却时间,防止手滑长按导致格子飞速乱跑。移动端则要处理 touch 事件,通过比较touchstart和touchend的坐标差来判断滑动的方向。坐标差的绝对值就可以当成滑动方向:横向大于纵向算是左/右,反之是上/下。这个逻辑很常见,也不复杂。
5.3 动效的简单处理:CSS 过渡与"预告片"
很多人一想到动效就头疼,觉得滑块应该平滑移动过去。其实最简单的方案是给格子加 CSS transition,每次格子数值变化、位置变化,浏览器自动帮你补间。比如:
.cell { transition: all 0.1s ease; }如果你希望数字放大一下,可以在数据变化之后,给对应格子的 DOM 加一个类,用 CSS 动画缩放一下。这个类在动画结束后移除。
还有一点:如果你用了"整个棋盘重新渲染"的方式,你会发现 CSS transition 在某些浏览器里不生效,因为浏览器会把彻底替换 DOM 视为全新元素,而不是移动。要规避这个,你就得用"新旧对比"的方式去更新每个格子的内容,而不是全部重建。不过对于 2048 这个规模的游戏,我建议你先别做复杂的动效,一个 resize 动画都够炫了。核心是先把流程跑通。
5.4 移动终端适配:触摸滑动的细节
手机上的 UI 有一个特别容易出 bug 的地方:滑动的同时页面跟着滚动。你必须给触摸事件加preventDefault,防止浏览器默认的滚动行为。有时候还需要设置touch-action: none的 CSS 属性。如果漏了这一步,玩家在棋盘上左右滑动时,页面会跟着左右晃,甚至直接触发浏览器返回手势。
另外一个细节是滑动阈值的设置。有的玩家手指非常轻微地移动了一下,理论上这不算滑动。一般建议坐标差的绝对值超过 20px 才触发方向操作,太小的移动应该忽略。这个就看你自己的手感测试了,10~30 之间都算合理。
6. 写 2048 时会踩的坑:问题排查实录
这部分我把自己给别人调试时遇到的高频问题整理成一个速查表。每一行都是真实案例,也是我口中常说的"经验比代码值钱"的那部分。
6.1 高频问题速查表
| 现象 | 可能原因 | 解决建议 |
|---|---|---|
| 按右方向,数字往右跑但合并结果不对 | 合并顺序不对,导致靠右侧的数字先被合并了 | 确保每次合并都从"运动方向的最远端"开始扫描。如果是往右移动,合并时要从右往左遍历处理 |
| 按上方向,数字变成横向移动 | 没有在调用moveLeft之后把矩阵转回去 | 检查旋转对称性,移动完记得逆旋转 |
| 连续按两次方向,数字合并了两次 | merge函数没有用"跳过已合并"逻辑 | 检查i += 2的逻辑,确认同一轮滑动不会重复合并 |
| 随机方块生成在已有数字上 | 空位列表没有及时更新,或者 board 引用传递错误 | 生成新方块前重新收集空位;不要修改旧 board 的引用 |
| 游戏提前判负 | 只检查了行方向,或者忘了检查"存在相邻相等" | 按 4.3 节的canMove实现完整判断 |
| 滑动后界面没有刷新 | 忘记调用render,或者 render 收到的是旧 board 引用 | 移动后先更新 board 再调用 render,保证传入最新状态 |
| 生成的新方块一直在同一个位置 | 随机种子用得太差,或者随机逻辑写成了"随机行列直到空位"但循环条件写错 | 使用收集空位列表再随机取值的方式 |
| 2048 出现后没有提示 | 胜利判定代码没放进主循环 | 在移动后立刻检测,放在生成新方块之前 |
6.2 调试心得:让"看得见"成为你的优势
因为我推荐纯数组逻辑先行,所以调试 2048 有一个很爽的手段:直接在控制台里console.table(board),一行命令就能看到整个棋盘。
function debug(board) { console.table(board); }配合几个写死的测试用例,你会发现查 bug 的速度比用 UI 快十倍。我自己的习惯是维护一个testBoard数组,里面放各种刁钻的布局,比如:
const test1 = [ [2, 2, 0, 0], [2, 0, 2, 0], [0, 0, 0, 2], [2, 2, 2, 2] ];然后分别调move(test1, 'left'),看输出是否符合预期。如果不符合,就逐步打印中间状态。
遇到这种"数组一眼看不出来"的情况,我一般会在merge函数里加日志,打印压缩前、压缩后、合并后三个状态。这招比任何断点调试都直观。写好之后记得把日志删掉,或者加一个环境变量开关。
6.3 关于性能:2048 需要担心吗
这个问题的答案很明确:不需要。4x4 的棋盘,一次移动最多涉及 16 个元素,再怎么遍历也就是 O(16) 的复杂度。哪怕你用每秒 60 次的全量重绘,性能也完全没有压力。真正需要优化的是如果你做了"无限棋盘"或者"超大棋盘"变体时,才需要考虑更高效的数据结构——但那就不是 2048 而是另一个游戏了。
不过有一个地方值得你留心:如果 UI 层每次都重建所有 DOM 节点,大量创建和销毁元素会导致轻微的性能开销和动画抖动。对小游戏无所谓,但如果你以后做更复杂的东西,可以试试用 DOM 复用或 Canvas 渲染。这里我提供一个进阶思路:用 canvas 绘制棋盘,每次数据变化只重新绘制整个画面,连 DOM 都不用碰。
6.4 玩法上的微调:别让规则困住你
2048 没人规定只能 4x4。你可以轻松地把代码里的常量改成 5x5、6x6,甚至做成非正方形棋盘。不过要提醒你,rotateClockwise函数是为正方形设计的,如果你改成矩形棋盘,旋转逻辑就得换一套(比如转置加行反转)。很多人会在这地方卡住——"4x4 好好的,5x4 就崩了"。我跟你说,遇到这种情况别慌,先想清楚"旋转"对非正方形矩阵意味着什么,一般来说你是要"交换行列维度"而非"旋转"。
还有变体可以调整:目标数字改成 1024(快节奏)、新的方块随机生成 4 的概率更高(更难的生存模式)、移动后不生成新方块(纯粹解谜)。这些变体在你的代码里往往只需要改一个常量或者删一行调用,这就是算法层和 UI 层分离带来的红利——改规则跟改界面互不影响。
7. 从 2048 提炼通用能力:这下你会做"所有"滑块类游戏了
你可能会想,2048 就这么点东西,写完就完了?其实不是。2048 里这套"旋转 + 压缩 + 合并"的组合,是很多知名游戏/功能的原型。
7.1 通用的"滑动合并"模型
只要你碰到的游戏或者功能是"把一堆元素往某个方向推,相同元素发生反应",基本都能套用这套模式。比如:
- 消消乐(三消)的某些消除逻辑;
- 某些弹珠游戏的碰撞合并;
- 甚至《俄罗斯方块》里的行消除,也有"压缩"的思想——满行消除之后,上面的格子要往下落,本质上就是一个"把非空行往上压"的数组重排。
这套思想的抽象价值在于:当你把问题理解成"数组状态转换",很多看似不同的问题就统一成了一个你熟悉的操作。你以后碰到新的滑块类游戏,第一反应不再是"我要写一堆 if 判断",而是"我先定义好状态,再写一个转换函数"。
7.2 算法题的底层逻辑:状态转换
面试时候很多人刷数组题,从两数之和刷到旋转图像,题解看得懂,但面试一考就懵。为什么?因为没有把算法问题和实际场景挂钩。2048 就是一个最好的"场景化教材":
- 旋转矩阵,是不是
rotateClockwise? - 双指针压缩数组,是不是
compress? - 一个"合并相邻重复元素并原地去重"的变体,是不是
merge?
很多人不是不会写代码,而是不知道什么时候该用什么。2048 这个项目循序渐进地把这些点串起来,让你在做一个小游戏的过程中把"数组算法"这一块彻底打通。以后你做题遇到"把零移到末尾"(Move Zeroes)、"合并相邻相同项",脑子里会瞬间蹦出这段经验:这不就是 2048 的compress和merge吗?直接照葫芦画瓢。
7.3 重构意识:从"能跑"到"好改"
很多人的代码写完就完了,从没想过重构。但 2048 是极好的重构练习对象。比如:
- 一开始你用一个对象数组
[{value: 2, merged: false}, ...]存每格数据,后来发现直接存值更简单,这就是数据结构的重构; - 一开始四个方向各写了一套循环,后来用旋转复用
moveLeft,这就是逻辑的重构; - 一开始把 2048 魔法数字写得到处都是,后来抽成常量,这是可维护性重构。
我在自己写2048的过程中,最多的时候把代码从 200 行缩到 80 行,而且功能完全没变。这种"减法"的快感,是只写 UI 的人体会不到的。
7.4 一个建议:做完 2048 再做个"自动求解"
如果你和我一样,玩几把就腻了,那我推荐一个进阶玩法:给 2048 写一个 AI 自动求解器。不用太复杂,最简单的启发式规则就能玩到 512 甚至 1024。核心思路就是永远往一个方向(比如左下)堆大数,避免在最高行产生小数字。你不需要机器学习,只需要在每次移动前枚举四个方向,对每个结果打一个分,然后选择得分最高的方向。
打分可以用这几条:
- 行数越高,数字越大(鼓励大数沉底);
- 空位数多(鼓励产生新的空间);
- 相邻相等数字多(鼓励未来的合并)。
这个求解器写下来,你对游戏状态的理解又会深一层,因为你从"我自己怎么操作"跳到了"电脑如何评估局面"。很多游戏 AI 的雏形,说白了就是这么点事——枚举可能的走法,选一个对局面评分最高的。
收尾:一点点个人体会
写 2048 这个小项目,我最大的收获不是"会写一个小游戏",而是终于把"先想状态、再写界面"这个习惯刻进了肌肉里。后来做稍微大一点的前端项目,哪怕是后台管理系统,我也习惯于先画出数据结构和状态转换关系,再动手铺 UI——因为这个思路真的能少吞很多后悔药。如果你手头有想写的小游戏,或者正愁学数组算法没地方练手,我特别建议你把 2048 按上面的流程做一遍,先把逻辑层写到能"空跑"——也就是在命令行里就能玩——然后你再花半天时间给它套一个你最喜欢的皮肤。你会发现,那层皮,真的只是锦上添花而已。