简介:2048-html5.zip 是一份基于 HTML5 技术实现的经典 2048 游戏完整源码包,适合前端初学者、游戏开发爱好者以及希望了解 Canvas 与 Web Storage 实际应用的开发者。压缩包仅 4 个文件,体积约 6KB,包含 index.html 入口页面、main.js 游戏逻辑、main.css 样式以及 README.md 说明文档,结构精简,可直接在浏览器中打开运行,也便于逐行阅读与二次开发。项目完整演示了如何用 HTML、CSS 和 JavaScript 构建可交互的单人网页游戏:Canvas 负责界面绘制与动画刷新,JavaScript 实现数字合并与得分逻辑,Web Storage 则用于持久化游戏进度。这份资源无需服务器与客户端安装,打开 index.html 即可体验,非常适合作为 HTML5 游戏开发的入门案例或教学素材。目前已有 9351 人浏览学习。通过源码可以学习 2048 核心规则的分步实现、键盘/触摸事件的处理、界面渲染优化以及本地存储的调用方式。对于想要快速上手网页游戏开发或寻找课程设计参考的读者,是短小精悍的实用资料。
1. 2048-html5:一个不用刷后端、浏览器打开就能玩到停不下来的经典
如果你在地铁上刷到过 2048,或者在某天下午不小心点进某个网页就消磨掉两小时,那你应该能理解为什么它适合做源码阅读的入门项目。这份 2048-html5 项目源码,本质上是一份“零依赖的浏览器端游戏实现”——不需要构建工具、不需要 Node 环境、不需要服务器,双击 index.html 就能跑。它虽然只是一个小游戏,但把“状态管理 + 视图刷新 + 输入监听 + 动画反馈”这几块前端基础编排得整整齐齐。想理解一个 Web 游戏是怎么从无到有运转起来的,拿它开刀再合适不过;想快速改造成自己的作品、练手写个自定义版本,这份代码也是一块很好的起点。这篇笔记我就把它从头拆开讲清楚。
2. 技术选型与目录结构:为什么纯前端就够,以及这份源码的骨架
2.1 技术栈画像:原生三件套 + 少量 JS 逻辑,没有框架依赖
这份源码没有使用 Vue、React 这类框架,也没有引入构建步骤。整个项目就是 HTML + CSS + JavaScript 三件套,外加一个 favicon 之类的小图标文件。从工程角度看,它回归了 Web 开发最本源的运行方式:一个 HTML 文件承载结构,一个 CSS 文件负责样式,一个或多个 JS 文件承载游戏逻辑。
我一般拿到源码会先看一眼目录,因为目录结构决定了上手成本。这份包里通常就是:
2048-html5/ ├── index.html ├── style.css ├── main.js └── favicon.ico(或 PNG 图标)有的版本会把 JS 按功能拆成几个文件,比如game.js(核心逻辑)、view.js(渲染)、input.js(键盘与触摸监听),这属于代码组织习惯的问题,不影响整体玩法。我的建议是:先跑通,再决定要不要拆。
纯前端方案有它的天然优势:没有服务端就没有跨域和部署的烦恼,局域网里拷给别人也能直接打开;劣势也很明显——存档、对战这类功能需要借助浏览器存储或后端配合,原生实现做不到“真在线”。对 2048 这种单机休闲玩法来说,纯前端是恰到好处的取舍。
2.2 目录结构与文件职责:从 HTML 到 JS 的分工
先看 index.html。它的职责非常清晰:搭出棋盘容器、分数面板、游戏结束/胜利浮层、重新开始按钮。这里我不会把整段 HTML 贴出来,但它的结构骨架大概是这样的:
<div class="container"> <div class="header"> <span id="score">0</span> <button id="restart">重新开始</button> </div> <div id="board"></div> </div> <div class="overlay" id="overlay"> <span class="overlay-text" id="overlay-text"></span> </div>这段结构里,#board是棋盘挂载点,游戏中的每个格子由 JS 动态创建;#score显示当前分数;.overlay是游戏结束或胜利时覆盖在棋盘上的半透明层。注意这个 overlay 默认是隐藏的,只有触发条件才显示,这一点在理解游戏流程时很关键。
style.css 的职责是把棋盘做成方形、给格子加背景色、定义数字大小和颜色、以及过渡动画。2048 的视觉设计很讲究:不同数字对应不同背景色,比如 2 和 4 偏浅,2048 偏金黄。这个“数字到颜色”的映射一般通过 CSS 类实现,而不是在 JS 里硬编码,这样换主题会很方便。
2.3 数据模型约定:棋盘到底是怎么存的一手数据
很多初学者第一次看 2048 源码会困惑:棋盘到底存的是二维数组还是对象?我看到的绝大多数版本都是二维数组,而且固定是 4x4。每个元素存的是格子的数值,0 表示空格。
const SIZE = 4; let board = [ [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0] ];这个二维数组就是整个游戏的“黑匣子”核心:玩家的每次滑动,本质上是把这个数组按方向做一次“移动 + 合并 + 归位”;每次操作结束后,数组里多出的新数字就是新方块。渲染层做的事情,就是把数组里的值同步到 DOM 中,更新对应格子的文本和样式。理解这条线,后面读算法才不会迷路。
3. 核心玩法与界面渲染:从一次按键到画面刷新的完整链路
3.1 游戏初始化:画棋盘、放初始方块、清零分数
游戏启动后,第一件要做的事情是初始化。常见的做法是:先把棋盘容器清空,再根据二维数组创建对应的格子 DOM,最后随机找两个空位生成初始方块(一般是两个 2,或者一个 2 一个 4)。
function initGame() { board = Array.from({ length: SIZE }, () => Array(SIZE).fill(0)); score = 0; updateScore(); renderBoard(); addRandomTile(); addRandomTile(); }Array.from在这里用来快捷生成一个 4x4 的全 0 二维数组,比手写循环更干净。调用两次addRandomTile()是因为经典规则开局有两个方块。renderBoard()在addRandomTile()之前执行,所以棋盘会先渲染成空白,再依次加出两个方块——这样视觉上会有“再放两个”的效果,而不是一上来就摆好。
3.2 一次按键是怎么变成格子移动的:输入监听与方向聚合
游戏的核心交互是键盘方向键和 WASD。监听器挂在document上,按键事件通过event.key判断,再执行对应的滑动逻辑。
document.addEventListener('keydown', (e) => { const map = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', w: 'up', s: 'down', a: 'left', d: 'right' }; const dir = map[e.key]; if (dir) { e.preventDefault(); if (!isGameOver()) { move(dir); } } });这里有两个细节值得注意。第一,e.preventDefault()是为了防止方向键触发浏览器默认滚动,不然玩游戏时页面会跟着动。第二,判断isGameOver()之后才执行 move,可以避免在游戏已经结束时继续操作导致界面异常。常见做法是把这个监听函数再包一层“游戏状态”,区分 playing / over / win 三种状态,这里只是先做最基本的防护。
3.3 渲染层刷新策略:整体重建还是按格更新?
2048 有几种渲染策略,我见过三种写法:一是每次操作后innerHTML整表重建;二是只更新内容变化的格子;三是先记录移动前后的位置差异,再做平滑过渡动画。源码里最常见的是一和二,动画多见于进阶版。
第一种最简单,但每次操作都会触发布局抖动,且没有过渡效果。第二种是常规选择:遍历二维数组,把每个格子的值和颜色类同步到对应的 DOM 节点。
function renderBoard() { for (let r = 0; r < SIZE; r++) { for (let c = 0; c < SIZE; c++) { const tile = document.querySelector(`#cell-${r}-${c}`); const val = board[r][c]; tile.textContent = val === 0 ? '' : val; tile.className = `tile tile-${val}`; } } }注意这里用模板字符串生成了每个格子的 id,比如#cell-0-0是左上角,#cell-3-3是右下角。初始化时格子 DOM 必须预先创建好,并且 id 与行列一一对应。数据渲染是“从数组到 DOM”的单向同步,游戏逻辑完全不直接操作界面,这也是它好读的原因。
tile-${val}这个类名是实现“不同数字不同颜色”的关键。CSS 里预先定义了.tile-2、.tile-4、.tile-8等类的背景色,JS 只需要把数值映射成类名,样式细节全交给 CSS,职责分离得很干净。
3.4 分数与结束判定:谁在什么时候弹出了结算层
每次发生合并,分数增加合并后那个格子的数值。比如两个 2 合并成 4,分数加 4。最高分通常存localStorage,键名常见的是bestScore。这个逻辑不复杂,但要注意分数的计算时机:要在数组更新前算,还是更新后算?我习惯在合并完成、得到合并结果数组后再累加,避免重复计算。
结束判定有两个方向:一是进行任意移动后棋盘没有空位、也没有相邻可合并格;二是出现 2048 时通常会先弹胜利层,但很多版本允许继续玩。判断可合并的规则是“任意相邻两个方向相等”,不是“存在等于 2 的格子”,很多初学在这里踩坑。
4. 核心算法拆解:一次滑动背后的归一、压缩、合并与再填充
4.1 方向归一化:把四个方向的滑动转成同一个处理函数
2048 的滑动算法如果按四个方向各写一套,会有大量重复代码。行业通行做法是“方向归一化”:把四个方向统一转换成“从左到右的数组处理”,处理完再按原方向写回棋盘。
我拆过的版本里,大多数是这样做的:如果向左滑,取出每一行;如果向右滑,先把每一行反转再当左滑处理。向上向下同理,只是先取列再决定是否反转。
function move(direction) { let moved = false; for (let i = 0; i < SIZE; i++) { const line = extractLine(i, direction, board); const newLine = mergeLine(line); if (line.join(',') !== newLine.join(',')) { moved = true; } writeBack(i, direction, newLine, board); } if (moved) { addRandomTile(); renderBoard(); checkGameStatus(); } }moved变量很关键:只有棋盘真正发生变化时才生成新方块。如果玩家按了一个无法移动的方向,比如所有格子已经靠左且无相邻相等,那就不能白白多出一个新方块。这个“先判断是否有变化再决定是否生成新块”的顺序,直接影响游戏公平性。
4.2 单行合并的三步曲:过滤、合并、补零
单行合并是整个项目最值得读的函数。你可以想象一行是[2, 2, 4, 0],向左移动期望结果是[4, 4, 0, 0]。拆解下来就是三步。
function mergeLine(row) { // 第一步:过滤掉所有 0,得到非零块序列 const tiles = row.filter(v => v !== 0); // 第二步:从左到右合并相邻的相同块 const merged = []; for (let i = 0; i < tiles.length; i++) { if (i + 1 < tiles.length && tiles[i] === tiles[i + 1]) { merged.push(tiles[i] * 2); i++; // 跳过被合并的那个 } else { merged.push(tiles[i]); } } // 第三步:补零到固定长度 while (merged.length < SIZE) { merged.push(0); } return merged; }第 7 行的i++是这个函数的核心细节:当[2, 2]合并成4后,索引要跳过第二个 2,否则它会继续和后面的数比较。比如[4, 4, 4]这一行,正确的左移结果是[8, 4, 0, 0],而不是[8, 8, 0, 0]——游戏规则规定了合并不能链式触发。这个“合并一次就跳过”的规则,就是 2048 与某些魔改版最大的区别。
参数方面,row是长度为 4 的一维数组。函数返回的是合并后的新数组,原数组并不修改,所以调用方才能通过比较新旧数组是否一致来判断“有没有移动”。这种纯函数写法便于测试,也是我推荐保持的。
4.3 取列与写回:向上滑和向下滑怎么处理列数组
方向处理中,行滑动比较简单,列滑动需要先按列号取出数据。下面是一个常见的取列实现:
function extractLine(index, direction, board) { if (direction === 'left') return board[index].slice(); if (direction === 'right') return board[index].slice().reverse(); const col = [board[0][index], board[1][index], board[2][index], board[3][index]]; return direction === 'up' ? col : col.reverse(); }这里有个容易踩坑的点:board[index].slice()是浅拷贝,但数组元素是数字,所以拷贝后的数组操作不影响原始行;如果是对象数组就得深拷贝了。往右滑为什么直接反转?因为反转后原本最右边的数变成了最左边,用同一个mergeLine处理,再反转写回,效果等价于“从右侧压缩”。这个技巧在 2048 实现里几乎是标准答案。
写回时也要注意方向。左滑按原顺序写回,右滑则要把合并结果反转再写回;上滑按列写回,下滑反转后写回。写回代码通常长这样:
function writeBack(index, direction, line, board) { if (direction === 'left') { board[index] = line; } else if (direction === 'right') { board[index] = line.reverse(); } else if (direction === 'up') { for (let r = 0; r < SIZE; r++) board[r][index] = line[r]; } else { const rev = line.reverse(); for (let r = 0; r < SIZE; r++) board[r][index] = rev[r]; } }注意line.reverse()是原地反转,会修改传入的数组。如果后续还有地方用到这个数组,需要留意引用变化。我一般会在函数开头做一个防御性拷贝,避免这种“隐蔽的副作用”。
4.4 随机新方块:为什么不是永远都是 2
新方块生成的逻辑直接决定上手难度。规则是:在空白位置随机选一个,90% 概率生成 2,10% 概率生成 4。这是因为如果总是生成 2,游戏就会变得过于漫长;如果 4 太多,难度又偏高。
function addRandomTile() { 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; const pos = empty[Math.floor(Math.random() * empty.length)]; board[pos.r][pos.c] = Math.random() < 0.9 ? 2 : 4; }这里有一个提升体验的细节:某些实现会优先在“远离上一次生成位置”的空位生成方块,减少连续堆叠的挫败感,但基础版就是纯随机。如果你改造成 5x5 或 6x6 棋盘,建议把 4 的概率调低,否则大棋盘很难玩下去。
5. 运行与避坑指南:双击、触摸屏和动画那些真实的坑
5.1 双击打开没反应或排版错乱:先检查是不是直接开了文件
这个项目理论上双击 index.html 就能跑,但如果遇到棋盘不显示、按键没反应,第一个要排查的是“路径问题”。部分源码会引用./js/main.js这类相对路径,如果文件被单独移动,相对路径会失效。另外有些浏览器对本地文件中读取本地资源有限制,尤其是涉及 fetch 或 localStorage 时。
我的习惯是:拿到源码先看 index.html 里的<script>标签引用的路径,再顺着目录核对文件是否存在。如果只是为了调试,也可以在项目根目录起一个本地静态服务,比如用 Python 的http.server:
cd 2048-html5 python3 -m http.server 8080然后在浏览器打开http://localhost:8080,这样能避开大部分本地文件限制。这不是什么高深技巧,但确实是很多新手打开没反应的第一道坎。
5.2 手机上玩不了:触摸事件没处理
原版 2048 是键鼠操作,但绝大多数玩家在手机上打开,需要触摸滑动支持。源码如果没有实现 touch 事件,手机上就是“能看不能玩”。
一般的补充做法是监听touchstart和touchend,记录起点和终点的坐标差值来判断方向:
let startX, startY; document.addEventListener('touchstart', (e) => { startX = e.touches[0].clientX; startY = e.touches[0].clientY; }); document.addEventListener('touchend', (e) => { const dx = e.changedTouches[0].clientX - startX; const dy = e.changedTouches[0].clientY - startY; const absX = Math.abs(dx); const absY = Math.abs(dy); if (Math.max(absX, absY) < 20) return; // 误触阈值 if (absX > absY) { move(dx > 0 ? 'right' : 'left'); } else { move(dy > 0 ? 'down' : 'up'); } });阈值20px是经验值,太短会导致误操作,太长会让玩家觉得反应慢。如果你在自己的版本里适配移动端,建议把这段代码补充进去,顺手加上touch-action: none的 CSS,防止浏览器默认手势干扰。
5.3 动画效果出不来,只看到数字瞬移:重排和重绘的锅
很多改版会在合并时加上“滑动”或“缩放”动画,但经常出现一个问题:动画没有触发,格子像瞬移一样直接变了位置。常见的翻车原因是直接在同一个帧里修改了 DOM 的最终位置,浏览器把中间的过渡状态合并掉了。
解决办法是强制触发一次重绘分隔两个状态。比如在改变位置前读取一下element.offsetHeight,人为制造一次布局重排:
tile.classList.remove('tile-merged'); // 强制回笼中间态 void tile.offsetHeight; tile.classList.add('tile-merged');void tile.offsetHeight是业界常见的“重排开关”,它强制浏览器计算一次最新布局,让前面的 class 移除生效,后面的 class 添加才能触发过渡动画。如果你在自己改造动画时遇到“没动画”,先想想有没有做这一步。
5.4 游戏结束判断失效:不考虑“相邻相等”漏判
一个常见 bug 是:棋盘满了但还能合并,游戏却提前弹出“Game Over”。反过来,有时明明还能走,却误报结束。
原因在于结束条件只检查了“有无空格”,没检查“有无相邻相等的格子”。2048 的结束条件是:没有空格,且任意上下左右相邻格子数值不相等。
function isGameOver() { for (let r = 0; r < SIZE; r++) { for (let c = 0; c < SIZE; c++) { if (board[r][c] === 0) return false; if (c + 1 < SIZE && board[r][c] === board[r][c + 1]) return false; if (r + 1 < SIZE && board[r][c] === board[r + 1][c]) return false; } } return true; }注意这里只检查当前格子右边和下边,避免重复判断相邻对。漏掉这个“可合并”条件,是诸多自写版本最常遇到的隐藏缺陷,没有之一。
6. 二次开发思路:从改规则到换肤,让 2048 变成你自己的版本
6.1 规则改造:改棋盘大小、合并条件和特殊方块
想做出差异化的 2048,最简单的入口是改SIZE常量。4x4 改成 5x5 后,游戏难度和时长都显著变化,同时需要调winScore(比如 2048 改成 4096 才算赢)以及界面棋盘宽度。
进阶玩法是把“合并”改成“加法合并”(比如 2 和 4 合并成 6),或者加入“新方块生成时会标记是否是新块”的逻辑,让新块在一个回合内不能再次合并。这种魔改的实现都在mergeLine函数里,你只要改合并条件,游戏玩法立刻变样。
6.2 换肤:用 CSS 变量彻底改造视觉风格
我拿到一个源码包后,最先动手的通常是样式层。2048 的视觉风格由.tile系列类决定,只要把所有颜色集中成 CSS 变量,换主题就变成改几行配置:
:root { --tile-2-bg: #eee4da; --tile-2048-bg: #edc22e; --board-bg: #bbada0; }随后把.tile-2的background-color改为var(--tile-2-bg),以此类推。这样你可以做出黑金风格、赛博朋克风格,甚至把数字改成 emoji 图标(在renderBoard中用映射表替代val输出)。改动量不大,但视觉上完全是“另一个游戏”。
6.3 存档功能:localStorage 只要 10 行代码
给游戏加“断线续玩”是练手的好切入点。每次操作结束后,把board和score序列化存入localStorage;页面启动时读取,如果有存档就把存档载入。
function saveGame() { localStorage.setItem('game2048', JSON.stringify({ board, score })); } function loadGame() { const saved = JSON.parse(localStorage.getItem('game2048')); if (saved && Array.isArray(saved.board)) { board = saved.board; score = saved.score; renderBoard(); return true; } return false; }注意loadGame返回布尔值,方便启动逻辑决定是读存档还是开新局。还有一点提醒:localStorage存的是字符串,取出来必须JSON.parse,存进去必须JSON.stringify,这在任何本地存档实现里都一样,没有例外。
6.4 加「撤销」功能要有代价:别让撤销变成无限后悔药
2048 社区里最常见的扩展是撤销(Undo)。实现思路是每一次操作前把当前棋盘状态压栈,点击撤销时弹出。如果不做限制,玩家可以无限撤回,游戏就失去了策略性,玩起来很快变味。
我一般会限制最多撤销 3 次,每次撤销消耗一次“机会”,用完就不能再撤。这个机制既保留了容错体验,又守住了游戏的核心挑战感。代码上只需要在点击撤销时维护一个计数器,归零则禁用按钮。
6.5 最后说点我自己的习惯
每次拆完一个类似 2048 这样的小型项目,我都会在 README 里单独开一节写“魔改记录”:改了哪些规则、为什么改、效果如何。这个习惯帮我省了不少回头率——两个星期后你再看自己改的代码,真的会忘掉当时的意图。这个小项目最大的价值不是让你背下算法,而是让你看清一个小游戏是怎么把输入、逻辑、渲染这三件事组织在一起的。从那以后我拿到别人的源码,都习惯先画一条“输入到渲染”的链路,把每一步对应的代码位置标出来,再决定改哪里、怎么改。希望这份 2048 源码解析也能让你少走一些弯路,动手改成自己喜欢的样子。
本文还有配套的精品资源,点击获取