从零开发 2048 小游戏:开发过程、心得与关键代码解析
从零开发 2048 小游戏:开发过程、心得与关键代码解析
前言
2048 是一款经典的数字合并益智游戏,规则简单却令人上瘾。本文将分享如何使用纯 HTML + CSS + JavaScript 单文件实现一个完整的 2048 游戏,涵盖开发过程、设计思路、关键代码解析以及开发心得。
游戏已发布至华为云开发者作品展览馆:https://gallery.developer.huaweicloud.cn/gallery/0-11e20eef400000
源码托管在 GitCode:https://gitcode.com/Eddygit/game2048.git
一、开发过程
1.1 需求分析
在动手之前,我先梳理了完整的功能清单:
- 4×4 网格的经典 2048 玩法
- 支持键盘方向键、WASD、触摸滑动、鼠标拖动多种操作
- 方块滑动和合并的动画效果
- 当前分数与历史最高分(localStorage 持久化)
- 新游戏按钮和撤销按钮(最多 20 步)
- 游戏结束/胜利提示
- 响应式布局,适配移动端
1.2 架构设计
整个游戏采用 单文件 IIFE 架构,所有代码包裹在一个立即执行函数中,避免全局污染。核心设计分为三层:
┌─────────────────────────────────┐
│ 输入层 (Input) │
│ 键盘 / 触摸 / 鼠标 → move(dir) │
├─────────────────────────────────┤
│ 逻辑层 (Game Logic) │
│ Grid + Tile + move + merge │
├─────────────────────────────────┤
│ 渲染层 (Render) │
│ DOM 操作 + CSS 动画 │
└─────────────────────────────────┘
- Tile:表示单个方块,记录位置、值、前一位置(用于动画)
- Grid:4×4 二维数组,管理方块的增删查
- move:核心移动逻辑,处理滑动、合并、得分
- render:每次移动后重绘所有方块,利用 CSS transition 实现动画
1.3 开发步骤
- 搭建 HTML 骨架:游戏标题、分数栏、控制按钮、棋盘容器、覆盖层
- 编写 CSS 样式:CSS 变量驱动响应式布局、方块配色方案、动画 keyframes
- 实现游戏核心:Tile/Grid 数据结构 → 移动合并逻辑 → 随机生成方块
- 渲染与动画:DOM 渲染 + requestAnimationFrame 触发滑动 transition
- 输入处理:键盘事件 → 触摸手势 → 鼠标拖动
- 分数与撤销:localStorage 持久化 + 历史栈快照
- 游戏状态:胜负检测 + 覆盖层提示
二、关键代码解析
2.1 Tile 与 Grid 数据结构
游戏的核心数据结构非常简洁。Tile 记录每个方块的位置、数值和动画状态:
function Tile(x, y, value) {
this.x = x; // 当前 x 坐标
this.y = y; // 当前 y 坐标
this.value = value || 2;
this.prev = null; // 前一位置 {x, y},用于滑动动画
this.mergedFrom = null; // 合并来源 [tileA, tileB]
this.id = Tile.nextId++;
}
Grid 是一个 4×4 的二维数组,提供方块的管理接口:
function Grid(size) {
this.size = size;
this.cells = [];
for (var x = 0; x < size; x++) {
this.cells[x] = [];
for (var y = 0; y < this.size; y++)
this.cells[x][y] = null;
}
}
设计要点:prev 和 mergedFrom 是动画系统的关键。每次移动前,prepare() 会记录所有方块的当前位置到 prev;移动后,渲染层利用 prev 到当前位置的差值触发 CSS transition,实现平滑滑动效果。
2.2 核心移动逻辑
move() 是整个游戏最核心的函数,处理方块的滑动和合并:
function move(dir) {
var v = vec(dir); // 方向向量 {x, y}
var tr = traversals(v); // 遍历顺序(逆方向优先)
var moved = false;
var gained = 0;
prepare(); // 记录所有方块的当前位置
tr.x.forEach(function (x) {
tr.y.forEach(function (y) {
var tile = grid.content(x, y);
if (!tile) return;
var f = farthest(x, y, v); // 找到最远空位
var next = grid.content(f.next.x, f.next.y); // 下一格的方块
if (next && next.value === tile.value && !next.mergedFrom) {
// 合并:相同值且目标格本回合未合并过
var merged = new Tile(f.next.x, f.next.y, tile.value * 2);
merged.mergedFrom = [tile, next];
grid.insert(merged);
grid.remove(tile);
gained += merged.value;
if (merged.value === 2048 && !won) won = true;
} else {
// 滑动:移到最远空位
grid.remove(tile);
tile.x = f.farthest.x;
tile.y = f.farthest.y;
grid.insert(tile);
}
});
});
if (moved) {
history.push(snapshot()); // 保存撤销快照
score += gained;
addRandom(); // 生成新方块
render(); // 重绘
}
}
关键设计:
- 遍历顺序:
traversals(v)根据移动方向反转遍历顺序。向右移动时从右往左遍历,避免一个方块被多次合并。 !next.mergedFrom检查:防止一个方块在一回合内被连续合并两次(如[2, 2, 2, 2]向左移动应得到[4, 4, 0, 0]而非[8, 0, 0, 0])。farthest()函数:沿方向向量逐步前进,直到遇到边界或非空格,返回最远可达位置。
2.3 动画渲染
渲染层是整个游戏视觉体验的关键。每次 render() 都会清空并重建所有方块 DOM,但通过巧妙利用 prev 位置和 requestAnimationFrame 实现平滑动画:
function render() {
tilesEl.innerHTML = "";
for (var x = 0; x < SIZE; x++) {
for (var y = 0; y < SIZE; y++) {
var tile = grid.cells[x][y];
if (!tile) continue;
if (tile.mergedFrom) {
// 合并方块:先渲染两个源方块在旧位置,再滑动到合并位置
var a = tile.mergedFrom[0], b = tile.mergedFrom[1];
var ea = makeTileEl(a, false, false);
ea.style.transform = posTransform(a.prev.x, a.prev.y);
tilesEl.appendChild(ea);
// 下一帧触发滑动到合并位置
requestAnimationFrame(function (elA, ax, ay) {
return function () {
elA.style.transform = posTransform(ax, ay);
};
}(ea, tile.x, tile.y));
// 合并后的新方块带弹出动画
var em = makeTileEl(tile, false, true);
tilesEl.appendChild(em);
} else if (tile.prev && !samePos(tile.prev, tile)) {
// 滑动方块:从旧位置开始,下一帧滑动到新位置
var el = makeTileEl(tile, false, false);
el.style.transform = posTransform(tile.prev.x, tile.prev.y);
tilesEl.appendChild(el);
requestAnimationFrame(function (elS, tx, ty) {
return function () {
elS.style.transform = posTransform(tx, ty);
};
})(el, tile.x, tile.y);
} else {
// 新生成的方块:带淡入动画
var en = makeTileEl(tile, !tile.prev, false);
tilesEl.appendChild(en);
}
}
}
}
动画原理:
- 方块先在
prev位置创建(DOM 初始 transform 设为旧位置) requestAnimationFrame在下一帧将 transform 改为新位置- CSS
transition: transform 0.15s自动补间,产生滑动效果 - 合并方块额外添加
.mergedclass,触发pop动画
2.4 撤销系统
撤销系统通过快照实现,每次移动前保存完整状态:
function snapshot() {
var snap = { score: score, cells: [] };
for (var x = 0; x < SIZE; x++) {
snap.cells[x] = [];
for (var y = 0; y < SIZE; y++) {
var t = grid.cells[x][y];
snap.cells[x][y] = t ? { value: t.value } : null;
}
}
return snap;
}
function undo() {
if (!history.length) return;
var snap = history.pop();
score = snap.score;
grid = new Grid(SIZE);
for (var x = 0; x < SIZE; x++)
for (var y = 0; y < SIZE; y++) {
var c = snap.cells[x][y];
if (c) grid.insert(new Tile(x, y, c.value));
}
render();
}
历史栈限制为 20 步,避免内存占用过大。
2.5 多端输入处理
游戏支持三种输入方式,统一映射到 move(dir) (0=上, 1=右, 2=下, 3=左):
// 键盘
var keyMap = {
ArrowUp: 0, ArrowRight: 1, ArrowDown: 2, ArrowLeft: 3,
w: 0, d: 1, s: 2, a: 3
};
// 触摸滑动
boardWrap.addEventListener("touchend", function (e) {
var dx = t.clientX - touchStart.x;
var dy = t.clientY - touchStart.y;
if (Math.abs(dx) > Math.abs(dy))
move(dx > 0 ? 1 : 3); // 水平滑动
else
move(dy > 0 ? 2 : 0); // 垂直滑动
});
三、开发心得
3.1 单文件架构的优势
选择单文件实现并非偷懒,而是针对这个项目规模的合理决策:
- 零依赖:不需要 npm、webpack、任何构建工具
- 即开即玩:一个 HTML 文件,浏览器打开就能运行
- 部署简单:放到任意静态服务器即可,华为云 OBS 静态网站托管也能直接用
- 代码量可控:624 行代码涵盖全部功能,阅读和理解成本低
3.2 CSS 变量驱动响应式
整个棋盘的尺寸由一个 CSS 变量驱动:
:root {
--board-size: min(92vw, 460px);
--gap: calc(var(--board-size) * 0.025);
--cell-size: calc((var(--board-size) - var(--gap) * 5) / 4);
}
所有尺寸(方块大小、间距、圆角、字体)都从 --board-size 派生,修改一处即可全局缩放。这种设计让响应式适配变得极其简单。
3.3 动画的关键:prev 位置 + requestAnimationFrame
实现滑动动画最直觉的想法是用 JS 动画库或手写动画函数。但实际上,利用 CSS transition + DOM 重建就能实现:
- 在旧位置创建 DOM 元素
requestAnimationFrame下一帧改到新位置- CSS transition 自动补间
这种方案的优点是完全利用浏览器原生动画能力,性能好、代码少。关键是 Tile.prev 记录了移动前的位置,渲染时据此设置初始 transform。
3.4 遍历顺序的重要性
移动逻辑中一个容易出错的细节是遍历顺序。向右移动时,如果从左往右遍历,一个方块可能被合并后又被下一个方块合并,导致 [2, 2, 2, 2] 变成 [0, 0, 0, 8] 而非正确的 [0, 0, 4, 4]。
解决方案是逆方向遍历:向右移动时从右往左处理,这样每个方块只会被处理一次。traversals() 函数根据方向向量反转遍历顺序。
3.5 mergedFrom 双重作用
mergedFrom 字段有两个用途:
- 逻辑层:标记一个方块本回合已合并过,防止二次合并
- 渲染层:告诉渲染器这个方块是由两个方块合并而来,需要先渲染源方块的滑动动画
一个字段同时服务于逻辑和渲染,减少了状态管理的复杂度。
四、项目成果
| 特性 | 实现情况 |
|---|---|
| 代码行数 | 624 行(单文件) |
| 文件大小 | 18.9 KB |
| 外部依赖 | 0 |
| 操作方式 | 键盘 + 触摸 + 鼠标 |
| 动画效果 | 滑动 + 合并弹出 + 淡入 + 分数 bump |
| 持久化 | localStorage(最高分) |
| 撤销 | 最多 20 步 |
| 响应式 | ✅ 移动端适配 |
五、总结
2048 小游戏的开发过程展示了在不引入任何框架和依赖的情况下,如何用纯前端技术实现一个完整、流畅、多端适配的交互式游戏。核心要点在于:
- 数据结构先行:Tile/Grid 的设计决定了整个代码的清晰度
- 动画利用原生能力:CSS transition + requestAnimationFrame 胜过手写动画
- 状态管理要简洁:prev/mergedFrom 两个字段解决动画和合并逻辑
- 单文件不是反模式:在合适的项目规模下,单文件是最简洁的架构
游戏源码:https://gitcode.com/Eddygit/game2048
作品展览馆:https://gallery.developer.huaweicloud.cn/gallery/0-11e20eef400000
感谢阅读,欢迎在 GitCode 上 Star 和 Fork!
- 点赞
- 收藏
- 关注作者
评论(0)