做一个2048小游戏,最让人卡住的往往不是UI怎么写,而是那套藏在格子背后的数组算法。我自己带过几批做项目的新人,几乎每次有人兴冲冲地开始写2048,第一步都是去调CSS Grid布局、给方块设计圆角和渐变色,结果到了“按方向键之后数字怎么移动合并”这一步,直接卡住不动了。这篇文章我想聊聊怎么先把数据层想清楚,用数组操作把2048的核心逻辑跑通,然后再去碰界面。适合刚接触前端或想做小游戏练手的朋友,照着这个顺序做,你会发现UI反而是整个项目里最轻松的部分。
1. 先把棋盘想清楚:2048 的核心不是界面,是数据
很多人玩2048的时候觉得这是个反应类游戏,其实从开发者的角度看,它就是一个纯粹的“矩阵变换”题目。棋盘固定4x4,每个格子要么是空,要么是一个2的次幂数字,你把上下左右四个方向拆开看,本质上是同一套规则反复作用在二维数组上:先把所有数字往某个方向推,碰到相同的数字就合并,合并之后再填补空位。想清楚这一点,整个游戏的复杂度就降了一大截。
1.1 为什么说这是个数组游戏
假设有一排四格,从左到右分别是 [0, 2, 2, 0],你按下左移键,期望得到的结果是 [4, 0, 0, 0]。再看 [2, 2, 2, 2],按左移,期望结果是 [4, 4, 0, 0],而不是 [8, 0, 0, 0]。这里面藏着两个重要规则:一是有空位就要补齐,二是相同数字相邻时只合并一次,合并后的结果不参与本轮再次合并。
这两个规则用生活化的方式理解,就像是整理一条货架:先把所有货物往一头推紧,让空位全部跑到另一头,然后把相邻的相同货物两两打包成一个大箱,打包完再推紧一次。整个过程只扫一遍,不会出现“打包之后的大箱又立刻和另一个大箱打包”的情况,这就是2048和随便写个消消乐最大的区别。这个整理动作,翻译成程序语言就是数组的过滤、相邻比较和插入空位。
1.2 数据结构选型:为什么是二维数组
做2048,数据结构几乎没有任何悬念,直接用一个4x4的二维数组。每一行是四个数字,数组下标对应棋盘坐标,比如grid[1][2]就是第二行第三列那个格子的值,0表示空。我见过有人用对象数组或者Map来存棋子的,比如[{x:1, y:2, value:4}]这种,实际做下来非常别扭。
原因是2048的每一次操作都要求“按行”或“按列”整体处理,二维数组天然支持这种按维度遍历。而且后面判断是否胜利、是否游戏结束,也都是在数组上做扫描,比如检查是否存在相邻相同数字,这直接就是两层for循环的事。如果换成散列存储,反而要先把坐标排序整理成行列结构,绕一大圈。此外二维数组还有一个隐藏好处:存档非常方便,直接JSON.stringify(grid)就能把整盘棋序列化成一串文本,下次JSON.parse回来照样玩。
1.3 从规则反推需求清单
动手写代码之前,先别急着开IDE,拿张纸把2048的规则拆成几个函数。我习惯这样列:
- 核心移动逻辑:输入一行数组,输出被“推紧并合并”后的一行数组
- 方向处理:把上下左右四个方向的移动,统一转换成“对行做同样的处理”
- 随机生成数字:每次有效移动后,在空白格里随机生成一个2或4
- 状态判断:生成后检查游戏是否结束,是否达到2048
这样拆完之后你会发现,真正复杂的只有第一个函数,其他都是在这个函数基础上套壳。这也是这篇文章标题想表达的意思:别急着写UI,先把这一行数组的处理逻辑写好写对,后面全是水到渠成。
2. 核心算法:一行一行“压平”数组
2048最核心的算法可以浓缩成一个函数:输入一行长度为4的数组,输出移动并合并后的数组。比如输入 [0, 2, 0, 2],输出 [4, 0, 0, 0]。很多新手一上来就想在一个函数里同时处理移动、合并、补位,结果各种边界条件把自己绕晕。我的做法是先把它拆成三个子步骤,每一步单独实现,最后再拼起来。
2.1 一行移动的三步走
第一步,把数组里的所有非零数字挑出来,保持相对顺序不变。这一步可以叫“压缩”。比如 [0, 2, 0, 2] 经过压缩变成 [2, 2]。代码上就是一行filter:
const compact = row.filter(v => v !== 0);第二步,对压缩后的数组进行合并。从左往右扫描,如果相邻两个数字相同,就把左边那个翻倍,右边那个删除。这里有个隐藏细节:合并完一轮后,数组长度会变短,所以要控制好索引步进,不能简单地在for循环里i++。
第三步,把合并后的数组末尾补0,补回4个元素。这样 [2, 2] 合并成 [4],再补0得到 [4, 0, 0, 0]。
三步合起来就是我说的slideRow函数。注意第二步的合并必须做成“一次匹配”,即每轮合并只检查一轮,不能用while循环反复合并,否则 [2, 2, 2, 2] 会直接变成 [8, 0, 0, 0],这和规则相违背。
2.2 合并规则里最容易被忽略的细节
“每轮每个数字只能参与一次合并”这条规则,是2048和老一代同类游戏的核心差异。我在不少实现里看到过错误写法:用递归或者多次循环去合并,结果棋盘上出现8甚至16的“跳级合并”,玩起来手感极差。
正确的写法是:固定一个方向扫描,从靠近移动方向的那一端开始处理。比如行向左移动,就从左往右扫描,维护一个“结果数组”,遍历原数组的每个非零元素。这里有个很实用的经验:用双指针或者直接push到新数组,比在原数组上splice更安全。每次合并后跳过已经参与合并的那个元素,也就是索引额外加一,这样自然就实现了“只合并一次”。
以左移为例,我给出一个可直接复用的实现:
function slideRow(row) { const arr = row.filter(v => v !== 0); const res = []; for (let i = 0; i < arr.length; i++) { if (i + 1 < arr.length && arr[i] === arr[i + 1]) { res.push(arr[i] * 2); i++; // 跳过下一个元素,保证只合并一次 } else { res.push(arr[i]); } } while (res.length < 4) { res.push(0); } return res; }这个函数跑一遍上面说的测试用例,[0, 2, 0, 2] 返回 [4, 0, 0, 0],[2, 2, 2, 2] 返回 [4, 4, 0, 0],[2, 0, 2, 2] 返回 [4, 2, 0, 0]。这几个用例在开发时一定要当成单元测试来反复跑,我后面会专门解释为什么。
2.3 四个方向不用写四份逻辑
行合并会写了,但游戏有上下左右四个方向。难道要写四个不同的slideRow吗?当然不用。这里有一个非常经典的矩阵操作技巧:转置。
所谓转置,就是把矩阵的行和列互换,第一行变第一列,第二行变第二列。我用一个具体的例子说明:假设棋盘第一列是 [2, 0, 2, 0],按下“上移”,期望结果是这一列变成 [4, 0, 0, 0]。把整个棋盘转置之后,原来的第一列变成了第一行,对转置后的棋盘执行一次“左移”,也就是对每一行调用slideRow,得到的结果再转置回去,就相当于对原棋盘做了“上移”。
右移和下移同样可以用翻转加左移来组合:右移等于把每行反过来,左移,再反过来;下移等于转置之后做右移,再转置回来。这样我们就只需要维护一个核心的左移函数,其他方向全部复用。我在项目里习惯封装一个move(grid, direction)函数,内部用switch去选择要不要先转置、要不要先翻转。这样做的最大好处是,之后如果要加新的方向键、或者支持对角线移动做变种玩法,只需要在组合方式上做文章,合并逻辑一概不动。
2.4 移动之后要做什么
一次有效移动之后,盘面一定会发生变化,这时候才轮到“随机生成数字”上场。生成新数字的位置必须是在空白格里,这里有一个常见的笨办法:反复随机生成坐标,然后判断是否为空,不为空就重新生成。这种做法在盘面快满的时候会非常低效,极端情况下可能死循环。
更稳妥的做法是先遍历整个二维数组,把所有空格的坐标收集到一个数组里,然后从这个数组里随机取一个。选2还是选4,概率我一般设为90%出2、10%出4,和原版保持一致。再往下就是判断游戏是否结束:如果棋盘没有任何空格,并且任意相邻两个格子都没有相同数字,那就game over。注意判断相邻相同数字时,要做上边界和左边界判断,避免数组下标越界,我自己的写法是检查右边和下边,只查两个方向就够覆盖所有相邻关系了。
3. 从算法到能玩:UI 只需要做三件事
等到核心逻辑全部在控制台里跑通之后,再开始写UI,你的心态会完全不一样。因为这时候UI只剩下三件非常简单的事情:渲染棋盘、监听按键、更新视图。很多人项目做到一半崩溃,不是UI太难写,而是算法还没通就开始写UI,每个按键按下去都得到错误结果,根本分不清是数据问题还是渲染问题。
3.1 渲染层的最小实现
如果你用的是原生JavaScript,最简单的渲染方式就是准备16个DOM元素,比如一个4x4的网格容器,每个格子对应二维数组的一个元素。每次移动结束后,遍历二维数组,把每个数字填进对应的DOM节点里。数字为0的格子显示为空,有数字的格子显示数字本身。
很多教程会教你用innerHTML每次都重新生成全部格子,这在小游戏里其实够用,但体验一般。原因很简单:每次操作都销毁重建DOM节点,哪怕数据没变,页面也会产生闪烁感,方向键连按的时候尤其明显,热词里提到的“ui界面卡顿”多半就是这个原因。我的建议是第一版直接建立16个格子节点,操作后只更新文本和样式类,不做任何增删节点的操作。这样代码量并没有多多少,但稳定性提升很明显。
3.2 把键盘事件映射到数组操作
键盘监听本身不复杂,window.addEventListener('keydown', handler)然后根据event.key判断方向即可。真正要留意的是,不是每次按键都要触发移动逻辑,只有当移动后数组发生了变化,才允许生成新数字。否则的话,玩家按了一个无法移动的方向,棋盘上却还是刷了一个新数字,这个bug非常隐蔽,还容易导致游戏进度被破坏。
我习惯把move函数设计成返回一个对象,比如{ grid: newGrid, moved: true },moved字段表示这次操作是否让盘面产生了变化。在按键处理函数里判断一下moved,只有为真时才调用addRandomTile和render。这样数据层和交互层的边界就非常清楚了。
3.3 动画是最后一步,不是第一步
很多人做2048非要先做滑动动画,觉得数字没有动画就不像游戏。这个方向本身没毛病,但前提是逻辑已经稳了。我的建议是先用最简单的方式把游戏跑起来:按一下方向键,格子直接变化,不做过渡。等你玩得顺手了,确认合并规则、胜负判断都没问题,再给格子加CSStransition过渡效果。加过渡的时候也要注意,动画只是视觉反馈,底层数组依然是瞬间变化,不要把动画和数据的生命周期绑在一起,否则会出现“动画还没播完,数据已经变了”导致的样式错乱。
我在实际项目里的顺序是:纯函数验证 -> 控制台手动测试 -> 最简渲染跑通 -> 加分值和游戏结束逻辑 -> 最后做动画和样式。每一步都走得踏实,后面返工的概率就小很多。
4. 实战中的坑:常见问题与排查方法
我自己写2048以及帮别人看代码的时候,遇到过不少反复出现的坑。这些问题高度集中,我把它们整理成一份速查表,对应解法也一并附上,开发时可以直接对照排查。
| 症状 | 根因 | 解决办法 |
|---|---|---|
| 一行相同数字合并出超大值(如2,2,2,2变成8) | 合并后被合并过的数字再次参与合并 | 合并时索引额外跳过,保证每个数字本轮只合并一次 |
| 按无法移动的方向,棋盘上却多了新数字 | 没有判断移动前后数组是否变化 | 只有moved为真时才执行随机生成数字和重新渲染 |
| 上移和下移结果完全错误,甚至数组越界 | 转置/翻转组合逻辑搞混 | 先单独测试转置函数,用已知矩阵验证 |
| 移动后数字丢失,原本的数字消失了 | 修改数组时没有深拷贝,原数组被污染 | 进move之前用JSON.parse(JSON.stringify(grid))做深拷贝 |
| 盘面快满时偶尔卡顿 | 随机生成数字用了遍历重试法 | 先收集空白格坐标数组,再从中随机取一个 |
4.1 深拷贝这个最容易翻车的点
我见过最头疼的bug是:移动一次之后,棋盘上凭空少了一个数字,而且每次少的数字还不一样。查了很久才发现,原来是在做方向转换时,直接对原数组的行执行了reverse(),导致原数据被就地修改了。JavaScript里数组是引用类型,grid[i].reverse()会直接改变原数组,后续操作全都建立在错误数据上。
所以进move函数的第一件事就是深拷贝。最省事的做法是JSON.parse(JSON.stringify(grid)),4x4的小棋盘开销完全可以接受。如果你喜欢函数式风格,也可以手动grid.map(row => [...row])做浅拷贝,因为每行是一层数组,浅拷贝行数组已经足够。但要注意:这两种方式都不能混用,对行内元素做修改没问题,对行本身做reverse或splice就必须先拷贝。
4.2 用“固定棋盘”测试方向逻辑
移动、合并逻辑这种东西,千万不要靠人肉肉眼去盯随机生成的棋盘,很难看出规律。我的习惯是固定一个棋盘,然后把四个方向的期望输出全写到注释里,一条一条跑。
比如我常用来做测试的棋盘是:
2 0 2 4 4 4 0 2 0 0 0 2 2 2 2 2向左移动之后,手动用笔算一下期望结果:第一行 [4, 4, 0, 0],第二行 [8, 2, 0, 0],第三行 [2, 0, 0, 0],第四行 [4, 4, 0, 0]。然后在控制台直接调用move函数,逐行比对。只要这一组数据四个方向都符合预期,你就可以放心地把它接到键盘事件上。这个过程五分钟都用不了,但能帮你省下一整个晚上的排查时间。
4.3 分数累计和重新开始的处理
分数这东西看起来简单,但藏着一个细节:分数应该累加的是“本次合并产生的数值”,而不是“移动后棋盘的总和”。比如 [2, 2, 0, 0] 左移,应该加4分,因为你把两个2合成一个4。如果你直接计算移动前后的数组总和差异,第一次可能凑巧对,但后续因为有新随机数字生成,总和会一直变,分数就会错得一塌糊涂。
正确的做法是在slideRow里,每次发生合并时,就把合并结果累加到一个变量里,然后由move函数汇总到总分。重新开始则更简单,把grid重置成全0数组,分数归零,再初始化两个随机数字就行。
5. 进阶:这套数组算法的复用价值
2048写完之后,你手里的宝贝其实不只是一个小游戏,而是一套可以迁移到很多场景的数组处理思路。这几天我看到许多搜索热词都和数组处理相关,比如各种C++小游戏、Python小游戏、前端UI框架的使用,背后其实都绕不开数据结构的组织。我想展开聊聊这套算法的迁移价值。
5.1 消消乐和俄罗斯方块
如果你玩过消消乐,会发现核心逻辑和2048非常像:都是在一个二维网格里找出相邻相同元素,然后消除、下落、再补新元素。区别只在“找出相邻相同元素”这一步用的是连通域搜索,也就是你想找一片连在一起的同色块,通常用深度优先搜索或广度优先搜索。但2048锻炼的“按行按列扫描、合并、压缩”能力,刚好是消消乐下落逻辑的基础。
俄罗斯方块更是如此:每一行满了之后要消除,本质就是“把非满行保留、把满行删掉、在顶部补零行”,这其实就是数组filter和unshift操作。会写2048的slideRow,再去看俄罗斯方块的消除逻辑,会发现思路几乎是被手把手喂到嘴边的。
5.2 存档系统和“悔一步”功能
很多2048版本支持存档和悔棋,这两个功能都是建立在数组可序列化的基础上。存档就是保存当前二维数组和分数,读档就是还原。悔棋则需要维护一个历史记录栈,每次移动前把当前状态压入栈里,想悔棋就弹栈恢复。我自己的实现里,历史记录栈存的是grid的深拷贝快照,而不是操作指令,虽然会多占一点内存,但恢复起来绝对不出错。
这个模式在很多游戏里都很常用,理解之后你也可以把它用在别的项目里,做一个撤销功能其实是数据栈的经典应用。
5.3 从2048到2048 AI
如果你感兴趣,2048还有一个很有名的玩法:写一个AI来自动玩。AI的核心逻辑建立在你已经写好的move函数上:让AI枚举下一步所有可能的方向,预测移动后棋盘的状态,再用启发式评分函数(比如空格数、相邻数字差值、最大数字所在位置)去给每种状态打分,然后选择分数最高的方向走。
这件事听起来高大上,但你已经写好最难的底层操作函数之后,AI部分其实只是循环调用你的move和addRandomTile,再做一个简单的评分比较。我第一次完成这个AI的时候,最大的感触就是:所有复杂系统的根基,往往就是几个不起眼的纯函数。
5.4 数据驱动思维对前端开发的帮助
最后聊一点和代码本身无关、但我觉得比代码更有价值的东西:数据驱动视图的思维。在2048项目里,数据是二维数组,视图是16个格子,数据怎么变,视图就怎么跟着渲染。你做完这个项目以后再去看那些UI框架,比如热词里提到的element ui、Vue数据绑定之类的概念,会发现底层逻辑完全相通:状态管理好了,界面自然会正确展示。
我见过太多人写前端时,遇到界面状态变化,第一反应是直接操作DOM去改样式,改到最后自己都不知道每个元素处于什么状态。2048这个项目真正教会你的,是先定义清楚状态、写清楚状态到底怎么变化,然后让UI成为状态的投影。这个思维一旦建立起来,写任何前端应用都会更有条理,这是单靠背UI组件API学不来的。
我在实际做这个小项目时还有一个切身的体会:UI和逻辑分离不是一句空话,而是能实打实提高效率的开发习惯。先写一个没有界面的纯逻辑版本,用控制台跑通所有规则,再花一两个小时套上最简UI,整个过程不会超过半天。而如果一开始就铺开做样式写动画,很可能两三天过去了还在调方向键合并的bug里打转。
最后再分享一个实用的小技巧:写slideRow的时候,不要只是写完就丢,留几个测试用例放在代码旁边,比如我上面列的那些固定棋盘。之后你无论怎么重构代码、改结构,都能在五分钟内确认合并逻辑没有坏掉。这比任何调试技巧都管用。等你把这个流程走顺了,会发现2048只是起点,后面再碰到任何二维网格类的项目,你都会比之前从容得多。