纯JS实现2048小游戏:从核心算法到二次开发实战 简介这是一份面向初级Web工程师的2048网页小游戏完整源码包以纯JavaScript实现不依赖任何外部框架适合作为练手项目理解前端核心机制。代码覆盖HTML结构搭建、CSS布局与动画、JavaScript基础语法、DOM操作、二维数组与矩阵运算、移动合并算法、事件驱动编程及游戏状态管理等知识点能帮助初学者把零散语法串成可运行的小项目。资源共3个文件包含1个js脚本、1个jpg图片和1个docx说明文档压缩包约830KB体积轻量便于本地运行与逐行阅读。目前已有417人学习下载可作为课程作业、自学练习或面试前的前端基础复盘素材读者能从中获得可直接运行的完整代码、界面素材与配套说明并借此梳理游戏逻辑与调试思路。1. 从一次“手痒”说起为什么我建议你亲手跑一遍这个 2048前阵子帮一个刚转前端的朋友看简历项目栏里清一色写着“仿某某商城”“后台管理系统”问起来却连一个完整的状态管理都讲不清。我随手丢给他一个网页版 2048 的源码包让他先跑起来再说。结果他折腾半小时后跑来问为什么键盘按下去方块不动我一看焦点没落在游戏容器上。就这么个小坑恰恰暴露了很多初级 web 工程师的通病——能写页面但没真正理解“事件、状态、渲染”这条闭环。这个纯 JS 开发的 2048 小游戏正好是补这一课的好材料。它没有框架、没有构建工具、没有 npm 依赖打开 HTML 就能玩代码量控制在一个人一下午能通读的范围内。但它麻雀虽小五脏俱全二维数组表示棋盘、方向键监听、合并算法、随机生成新块、分数统计、游戏结束判定一个都不少。对初级 web 工程师来说它比任何教程都实在——你能看到每一行代码是怎么把“按一下方向键”变成“屏幕上数字跳动”的。这份源码还带了完整图片资源意味着你连样式和素材都不用自己找clone 下来就能改。接下来我按“先跑通、再拆解、最后动手改”的顺序把这个项目从头到尾讲一遍。2. 项目结构与技术选型一个不依赖框架的纯前端闭环2.1 目录里都有什么拿到源码包先别急着打开浏览器。我习惯先tree一下看结构这个项目的组织方式非常“教科书式”的轻量2048-game/ ├── index.html # 唯一入口页面 ├── css/ │ └── style.css # 棋盘、方块、动画样式 ├── js/ │ ├── main.js # 入口初始化、事件绑定 │ ├── game.js # 核心逻辑移动、合并、判定 │ └── render.js # 渲染层把数据映射到 DOM └── images/ ├── bg.png # 背景图 └── tile-*.png # 各数字方块贴图可选这个分层不是摆设。game.js只管数据render.js只管画main.js负责把两者接起来。你改逻辑不用碰样式换皮肤不用动算法。很多新手写小游戏喜欢把所有代码塞进一个script里跑是能跑但改起来就是灾难。这个项目的分层意识本身就是值得学的工程习惯。2.2 为什么不用框架有人会问都 2024 年了为什么不用 Vue 或 React我的看法是2048 这种规模的项目用框架反而是负担。它的状态极其简单——就是一个 4×4 的二维数组加一个分数没有路由、没有异步请求、没有复杂组件通信。用原生 JS你能清楚看到“数据变了 → 手动调 render”这条链路而不是被框架的响应式系统遮住。对初级工程师来说先理解手动渲染再去用框架的自动渲染认知顺序才是对的。这也是我推荐拿它练手的原因它逼你直面 DOM 操作和事件模型这两样东西是框架底下永远绕不开的地基。2.3 运行环境要求这个项目对环境的唯一要求就是——一个现代浏览器。不需要 Node不需要本地服务器当然起一个更规范。如果你装了 Python在项目根目录执行python -m http.server 8000然后访问localhost:8000就行。直接双击index.html也能跑但有些浏览器对file://协议下的图片加载和本地存储有限制起个静态服务器更稳妥。这一点后面避坑章节还会细说。3. 核心逻辑拆解一个方向键按下后发生了什么3.1 棋盘的数据表示整个游戏的“真相”只有一个变量一个 4×4 的二维数组。0 表示空格其余数字就是方块值。所有视觉呈现都是这个数组的投影。// game.js const SIZE 4; // 初始化一个全 0 的棋盘 function createBoard() { return Array.from({ length: SIZE }, () Array(SIZE).fill(0)); } // 在随机空格生成一个 2 或 490% 概率是 2 function addRandomTile(board) { const empty []; for (let r 0; r SIZE; r) { for (let c 0; c SIZE; c) { if (board[r][c] 0) empty.push({ r, c }); } } if (empty.length 0) return board; const { r, c } empty[Math.floor(Math.random() * empty.length)]; board[r][c] Math.random() 0.9 ? 2 : 4; return board; }createBoard用Array.from而不是new Array(4).fill([])是因为后者会让四行共享同一个引用改一行全变——这是新手极容易翻车的地方。addRandomTile先收集所有空格坐标再随机挑一个保证不会覆盖已有方块。2 和 4 的 9:1 概率是 2048 的经典设定你可以按需调整这个比例来改变难度。3.2 单行合并算法整个游戏的心脏向左移动时每一行独立处理先把非零数字挤到左边再从左往右扫描相邻且相等就合并、分数累加合并后的位置标记为已处理防止一次移动里连续合并比如2 2 2 2应该变成4 4而不是8。// 处理单行向左合并返回新行和本次得分 function slideLeft(row) { // 1. 去掉 0压缩到左侧 let tiles row.filter(v v ! 0); let score 0; // 2. 从左往右扫描合并 for (let i 0; i tiles.length - 1; i) { if (tiles[i] tiles[i 1]) { tiles[i] * 2; score tiles[i]; tiles[i 1] 0; i; // 跳过下一个防止连锁合并 } } // 3. 再次压缩补 0 到长度 4 tiles tiles.filter(v v ! 0); while (tiles.length SIZE) tiles.push(0); return { row: tiles, score }; }这段代码有三个关键点。第一filter(v v ! 0)先压缩把[0,2,0,2]变成[2,2]这样合并判断才准确。第二合并后i再配合循环的i等于跳两格避免[2,2,4]里刚合并出的 4 又和后面的 4 合并。第三最后再压缩一次并补零保证返回的行长度恒为 4。这个函数是纯函数输入一行输出一行不碰全局状态测试起来非常方便——你可以单独在控制台调它验证各种边界情况。3.3 四个方向的统一处理向右、向上、向下不需要写四套逻辑。常见做法是向右就把行反转用slideLeft处理完再反转回来向上向下则先做矩阵转置把列变成行处理完再转置回去。// 矩阵转置行列互换 function transpose(board) { return board[0].map((_, c) board.map(row row[c])); } // 统一入口direction 为 left | right | up | down function move(board, direction) { let working board.map(row [...row]); // 深拷贝避免污染原棋盘 let totalScore 0; let moved false; if (direction up || direction down) { working transpose(working); } for (let i 0; i SIZE; i) { let row working[i]; if (direction right || direction down) row row.reverse(); const result slideLeft(row); if (direction right || direction down) result.row.reverse(); // 判断是否真的发生了变化 if (result.row.some((v, idx) v ! working[i][idx])) moved true; working[i] result.row; totalScore result.score; } if (direction up || direction down) { working transpose(working); } return { board: working, score: totalScore, moved }; }moved这个标志很重要只有棋盘真的变了才应该生成新方块、才需要重绘。如果玩家按了一个无效方向比如所有方块都贴左边了还按左棋盘不变就不该冒出新数字。working用map(row [...row])做深拷贝是因为后面会原地修改行数据不拷贝会直接改掉传入的棋盘导致“撤销”之类的功能没法做。转置加反转这套组合拳把四个方向收敛成一个slideLeft代码量直接砍掉四分之三这是我觉得这个项目最值得学的设计。3.4 游戏结束判定结束条件有两个棋盘满了且四个方向都动不了。只判断“满了”是不够的因为满盘但还有相邻相等的情况仍可继续。function isGameOver(board) { // 还有空格就没结束 for (let r 0; r SIZE; r) for (let c 0; c SIZE; c) if (board[r][c] 0) return false; // 检查水平和垂直方向是否有相邻相等 for (let r 0; r SIZE; r) { for (let c 0; c SIZE; c) { const v board[r][c]; if (c SIZE - 1 v board[r][c 1]) return false; if (r SIZE - 1 v board[r 1][c]) return false; } } return true; }先扫空格有空格直接返回 false省掉后续判断。然后只检查右邻和下邻因为左邻和上邻在遍历过程中已经被前一个格子的右/下判断覆盖了不用重复。这个细节能看出作者是认真想过遍历顺序的。4. 渲染与交互把数组变成会动的方块4.1 DOM 结构与渲染策略渲染层有两种常见做法一是每次全量重建棋盘 DOM二是复用格子只更新数字和样式。这个项目用的是前者——简单直接4×4 一共 16 个格子重建开销可以忽略。// render.js function render(board) { const container document.getElementById(board); container.innerHTML ; // 清空重绘 board.forEach((row, r) { row.forEach((value, c) { const cell document.createElement(div); cell.className cell; if (value 0) { cell.classList.add(tile-${value 2048 ? super : value}); cell.textContent value; } container.appendChild(cell); }); }); }tile-${value}这个类名约定让 CSS 可以针对每个数字单独配色比如tile-2是浅灰、tile-2048是金色。超过 2048 的值统一走tile-super避免类名爆炸。全量重绘的代价是丢失 CSS 过渡动画——因为 DOM 是新建的浏览器不会对“新元素”做 transition。如果你想要方块滑动的动画就得改成复用格子、只改textContent和class的方案这是二次开发时第一个要动的地方后面会细说。4.2 键盘事件绑定交互入口在main.js核心是监听keydown把按键映射成方向调move再决定是否生成新块和重绘。// main.js const KEY_MAP { ArrowLeft: left, ArrowRight: right, ArrowUp: up, ArrowDown: down, a: left, d: right, w: up, s: down // 兼容 WASD }; let board createBoard(); let score 0; addRandomTile(board); addRandomTile(board); render(board); document.addEventListener(keydown, (e) { const dir KEY_MAP[e.key]; if (!dir) return; e.preventDefault(); // 阻止方向键滚动页面 const result move(board, dir); if (!result.moved) return; // 无效移动什么都不做 board result.board; score result.score; addRandomTile(board); render(board); updateScore(score); if (isGameOver(board)) { showGameOver(); } });e.preventDefault()这行不能省否则按方向键时整个页面会跟着滚动体验很差。KEY_MAP同时支持方向键和 WASD照顾不同习惯的玩家。注意if (!result.moved) return的位置——在生成新块之前就返回保证无效操作不会凭空多出一个方块。这个顺序如果写反就会出现“按了没反应但棋盘上多了个 2”的诡异现象是新手常见的逻辑漏洞。4.3 分数与最高分持久化分数显示很简单一个updateScore更新 DOM 文本即可。最高分用localStorage存刷新页面不丢。function updateScore(score) { document.getElementById(score).textContent score; const best Number(localStorage.getItem(best2048) || 0); if (score best) { localStorage.setItem(best2048, score); document.getElementById(best).textContent score; } }localStorage存的是字符串读出来要Number()转一下否则比较时会出现100 99这种字符串和数字混比的坑虽然 JS 会隐式转换但显式转更稳。键名加个best2048前缀避免和同域下其他页面的存储冲突。这套持久化思路可以原样搬到任何小游戏里是很好的练手模板。5. 避坑与排查我踩过的五个坑5.1 双击打开后图片不显示现象直接双击index.html背景图和方块贴图全是裂的。原因部分浏览器在file://协议下对相对路径资源加载有安全限制尤其是图片和字体。解决起一个本地静态服务器python -m http.server 8000或npx serve都行用http://localhost:8000访问。这个坑几乎每个新手都会踩一次记住“涉及资源加载就用服务器”这条经验。5.2 按方向键页面跟着滚现象游戏能玩但每按一次方向键整个页面就上下跳一下。原因方向键的默认行为是滚动页面keydown里没阻止。解决在事件处理里加e.preventDefault()并且要放在确认是方向键之后、执行移动之前。注意别加在全局所有按键上否则输入框里打字都会被拦。5.3 一次移动合并出“8”而不是“4 4”现象一行2 2 2 2向左移期望得到4 4实际得到8。原因合并循环里没有跳过已合并的元素导致新生成的 4 又和下一个 4 合并。解决合并成功后i额外跳一格或者用一个merged标记数组记录哪些位置本回合已合并。这是 2048 算法最经典的 bug建议单独写几个测试用例把[2,2,2,2]、[2,2,4]、[4,4,8,8]都跑一遍。5.4 无效移动也生成新方块现象所有方块都贴左边了再按左键棋盘没变但凭空多了个 2。原因move返回后没判断moved就直接addRandomTile。解决move内部逐行比较新旧行是否一致汇总成moved标志主流程里if (!result.moved) return。这个判断要放在生成新块和重绘之前顺序不能错。5.5 刷新后最高分归零现象玩到 5000 分刷新页面最高分变回 0。原因localStorage的 key 拼写不一致或者读取时没做空值兜底。解决统一用一个常量存 key 名读取时Number(localStorage.getItem(KEY) || 0)写入前先比较。另外注意隐私模式下localStorage可能抛异常稳妥的做法是包一层try/catch失败就退化成内存变量不影响游戏本身。6. 二次开发从能玩到好玩我一般这么改跑通之后这个项目真正的价值才显现——它是一个可以随便折腾的沙盒。我通常按三个方向动手每个方向都能练到不同的前端能力。第一个方向是加动画。前面说过全量重绘会丢过渡改法是复用 16 个格子初始化时建好之后只更新textContent和className。给.cell加transition: transform 0.1s, background 0.1s数字变化时就有缩放和变色效果。更进一步可以做“滑动”动画记录每个方块移动前后的坐标用transform: translate做位移这需要把数据结构从“二维数组”升级成“带 id 的方块对象列表”是个不小的重构但做完你对“数据驱动视图”的理解会上一个台阶。第二个方向是加撤销。因为move里已经做了深拷贝实现撤销很简单每次有效移动前把board和score压进一个历史栈撤销时弹出恢复。注意栈深度要限制比如 20 步否则长时间玩内存会涨。这个功能能让你体会“状态快照”的思路和很多编辑器、绘图工具的撤销机制是相通的。第三个方向是换交互。键盘玩腻了可以加触摸滑动监听touchstart和touchend算两个点的位移向量取绝对值大的方向作为移动方向。移动端还要处理touchmove的preventDefault防止页面滚动。加完之后这个游戏就能在手机上玩了虽然布局要配合媒体查询调一下格子尺寸。改造方向主要改动文件难度练到的能力过渡动画render.js / style.css低CSS transition、DOM 复用滑动动画game.js / render.js中数据结构重构、坐标计算撤销功能main.js / game.js低状态快照、栈结构触摸支持main.js中触摸事件、手势识别改完记得做回归验证每次改动后手动跑一遍“四个方向各按几次 触发一次合并 玩到游戏结束”确认没有引入新 bug。有条件的话把slideLeft和isGameOver抽出来写几个断言用浏览器控制台或简单的测试脚本跑比肉眼盯着屏幕靠谱得多。说个我自己的教训。早些年我改这类小游戏总喜欢一上来就重构目录、上构建工具结果半天时间全花在配环境上游戏本身一行没动。后来我给自己定了条规矩先让原版跑起来、玩两把、确认每个功能都正常再动手改而且一次只改一个点改完立刻验证。这个习惯让我少走了很多弯路。这份 2048 源码不大但正好够你把“读代码 → 跑起来 → 改一处 → 验证”这个循环完整走一遍走完你对纯前端项目的掌控感会完全不一样。希望帮到你。本文还有配套的精品资源点击获取