
1. 别急着写 UI先想想这个游戏到底在“算”什么做前端或者独立开发的朋友应该都写过或者想过写一个小游戏练手。2048 往往是最常被翻牌子的那个规则简单、交互直观、界面也不复杂。但我见过太多人一上来就拉个 4x4 的方格、写样式、绑定键盘事件折腾两三天把界面做得漂漂亮亮最后卡在“方块怎么合并”这一步——因为从一开始就把重心放错了地方。这个标题其实是我自己踩坑后的总结。我之前带过一个新手他先用 Element UI 搭了个棋盘每个格子一个 div然后绑定上下左右按键去操作 DOM一个晚上就写出了“能看但动不了”的界面。问题出在哪他把棋盘当成了界面问题实际上它是一个典型的二维数组状态模型。2048 的核心难度不在 UI 层而在你如何用数组高效地表达“移动、合并、消除”这三个动作。所以这篇文章想聊的不是教你做一个漂亮的 2048而是想借这个游戏把背后的数组算法拆开讲透。无论你是写 C、Java、Python 还是 JavaScript理解了这套二维数组的处理思路以后遇到矩阵旋转、行列压缩、消消乐、俄罗斯方块这类问题都会轻松很多。我也用 JavaScript 手工搭了一版最精简的实现文章里会直接放出核心代码和完整流程适合想练手的同学照着写一遍。2. 棋盘的数据结构为什么用二维数组而不是直接操作 DOM2.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 合并成 8arr变成[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. 从算法到游戏完整实现一个可玩的 20484.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% 概率为 210% 概率为 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 idboard !-- 动态生成 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 也能玩但体验会比较生硬。我在做了基础版本之后又花了点时间加滑动过渡这里有一个关键点想提醒大家动画必须基于“移动前的位置”计算而不是移动后的位置反过来推测。举个例子你按了“左”原来在row0, col3的一个 4 移动到了row0, col0。如果你直接渲染的时候把元素放在col0再让它从col0滑到col0等于没有动画。实现动画的正确思路是移动前记录每个非零方块的位置和值。移动后用新旧位置对比给方块设置一个位移比如从col3到col0就是水平位移三格。动画结束后再更新为最终位置。我在第一版动画里偷懒直接操作 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 的小棋盘恰好提供了一个低门槛的练习场让你用最小的成本去感受“先想清楚数据结构再动手写界面”到底意味着什么。把这个项目彻底吃透后面你再看到类似的棋盘类游戏、二维数组题目心里就亮堂多了。