单文件 2048 的实现要点
单文件 2048 的实现要点
为什么要单文件
2048 的规则很简单,真正麻烦的是工程:起一个构建工具、配打包、部署静态资源,做完才发现游戏本身只有几百行逻辑。单文件的好处是零构建、零部署:一个 HTML 文件里同时放结构、样式和脚本,双击就能在浏览器里打开,改一行刷新就见效,分享时也只需要发一个文件。代价是所有东西挤在一起,所以更要把状态、渲染、输入三层分开,不然改一个动画就会把合并逻辑带崩。
我这次的约束就三条:不依赖任何框架和 CDN,离线也能打开;棋盘状态只用一个二维数组表示;渲染只负责把状态画出来,不反过来改状态。
状态:一个数组就够了
棋盘用 4x4 数组表示,0 代表空格,其余数字就是方块值:
let board = [
[2, 0, 0, 2],
[0, 4, 0, 0],
[0, 0, 0, 0],
[2, 0, 0, 0],
];
let score = 0;
不要给每个方块再维护对象、动画状态、唯一 ID,除非你要做滑动动画。对象一多,合并时就容易出现“两个方块指向同一份数据,改一个另一个跟着变”的问题。数组的好处是每一步都可以用 JSON 深拷贝做快照,比较移动前后的棋盘就能判断这一步是否有效。
合并:最容易写错的地方
向左滑一行,最直观的写法是先压缩、再合并、再压缩。压缩是去掉 0,合并是把相邻相等的两个数加起来,但每个方块在一回合里只能合并一次。这是新手最容易踩的坑。
反例:[2, 2, 4] 如果写成“遇到相等就一直往前加”,会得到 [8],这是错的。正确结果是 [4, 4]:前两个 2 合成 4,4 不能再和原来的 4 合并。同样,[2, 2, 2, 2] 应该变成 [4, 4, 0, 0],而不是 [8, 0, 0, 0]。
我的做法是把一行处理写成纯函数,返回值是新行和本行得分,绝不原地改数组:
function slideRow(row) {
const nums = row.filter(v => v !== 0);
const out = [];
let gained = 0;
for (let i = 0; i < nums.length; i++) {
if (i + 1 < nums.length && nums[i] === nums[i + 1]) {
const merged = nums[i] * 2;
out.push(merged);
gained += merged;
i++; // 跳过已合并的下一个, // 这一回合它已经合并过了
} else {
out.push(nums[i]);
}
}
while (out.length < 4) out.push(0);
return { row: out, gained };
}
这个函数只处理“向左”。其余三个方向不要各写一份,用矩阵变换统一到向左:向右是先反转行、滑完再反转回来;向上是先转置、滑完再转置回来;向下是转置加反转。四个方向共用同一套合并代码,改合并规则时只改一处,测试也只需要测一行。
function transpose(b) {
return b[0].map((_, c) => b.map(r => r[c]));
}
function moveLeft(b) {
let gained = 0;
const next = b.map(row => {
const r = slideRow(row);
gained += r.gained;
return r.row;
});
return { board: next, gained };
}
随机生成与无效移动
每一步有效移动之后,才在空格里随机放一个新方块:90% 放 2,10% 放 4。关键在“有效”两个字:如果玩家按了一个方向,棋盘完全没变,就不能生成新方块,否则玩家会被无意义地逼死。判断方法很直接:移动前深拷贝棋盘,移动后逐格比较,不相等才生成。
function spawnRandom(b) {
const empty = [];
b.forEach((row, r) => row.forEach((v, c) => {
if (v === 0) empty.push([r, c]);
}));
if (!empty.length) return;
const [r, c] = empty[Math.floor(Math.random() * empty.length)];
b[r][c] = Math.random() < 0.9 ? 2 : 4;
}
游戏结束的判断也别只看“棋盘满了”。满了但还有相邻相等的方块,游戏还能继续。正确条件是:没有空格,并且横向、纵向都没有相邻相等的格子,两个条件同时满足才算结束。胜利条件单独判断:出现 2048 就提示胜利,但允许继续玩下去,不要一到 2048 就锁死棋盘。
渲染:状态变了才重画
渲染层只做一件事:根据数组生成 16 个格子,按数值给不同的背景色和字号,分数和最高分同步更新。最高分用 localStorage 存,页面刷新不丢。不要在渲染里顺手改分数、生成方块或判断结束,这些都属于逻辑层,渲染一掺和,调试时就分不清是逻辑错了还是画错了。
动画是单文件里最容易超预算的部分。我的取舍是:先保证逻辑正确、重画无闪烁,再考虑过渡动画。直接全量重画 16 个格子在 2048 这种规模下完全够用,肉眼看不出性能问题;如果要做滑动动画,再给每个格子加 CSS transition,并用上一帧的位置做插值,不要为了动画把状态模型改成对象树。
输入:键盘加触屏,两处都要防误触
键盘监听方向键和 WASD,收到方向键时必须 preventDefault,否则页面会跟着滚动,手机浏览器上还会触发下拉刷新。触屏则记录 touchstart 和 touchend 的坐标差,横向、纵向位移超过 30 像素才算一次滑动,小于这个阈值的点按要忽略,不然点一下按钮就可能误滑一步。
还有一个容易忽略的点:一次按键只处理一次移动,不要在 keydown 重复触发时连续移动。按住方向键不放,浏览器会连续发 keydown,游戏会一口气滑好几步。处理方法是只响应第一次 keydown,或者在移动动画期间忽略新的输入,简单项目用前者就够了。
自测清单
写完先跑这几组用例,全部通过再谈手感:[2, 2, 2, 2] 向左应得 [4, 4, 0, 0];[2, 2, 4] 向左应得 [4, 4, 0, 0];[4, 0, 4, 4] 向左应得 [8, 4, 0, 0];无效移动后棋盘不变且不生成新方块;棋盘满且无相邻相等时才判结束。单文件的好处在这里体现得很直接:把这几组写进控制台跑一遍,不用起服务,不用等构建,逻辑对了再去调样式。
- 点赞
- 收藏
- 关注作者
评论(0)