2048 棋盘模型与滑动合并算法:鸿蒙核心逻辑设计


实例:2048 小游戏(Game 2048)|技术:二维数组棋盘、滑动合并算法、随机生成、胜负判定
一、为什么选 2048 收官
1.1 经典算法游戏
2048 是风靡全球的数字合并游戏:4×4 棋盘,滑动合并相同数字,目标合成 2048。它比贪吃蛇(34)的算法更有趣:
- 二维数组棋盘——状态是 4×4 矩阵,比贪吃蛇的坐标队列更"结构化";
- 滑动合并算法——四个方向的"移动 + 合并"是核心算法题(面试常考);
- 随机性——每次滑动后随机生成新方块,游戏不确定性;
- 胜负判定——2048 胜利、无路可走失败。
2048 是算法 + 绘制 + 交互的完美收官:把本系列所有 Canvas 技巧(网格绘制、触摸滑动、状态管理)集成到一个经典游戏中。
1.2 游戏规则
| 规则 | 说明 |
|---|---|
| 棋盘 | 4×4,初始两个随机方块(2 或 4) |
| 滑动 | 上下左右滑动,所有方块朝该方向移动 |
| 合并 | 相邻相同数字滑动中合并(每个方块每轮只合并一次) |
| 生成 | 每次有效滑动后,随机空位生成 2(90%)或 4(10%) |
| 计分 | 合并得分 = 合并后的数值 |
| 胜负 | 出现 2048 胜利;无空位且无法合并失败 |
二、棋盘数据模型
/** 棋盘 4×4 */
const SIZE = 4;
/** 方块颜色映射 */
const TILE_COLORS: Record<number, string> = {
2: '#EEE4DA', 4: '#EDE0C8', 8: '#F2B179', 16: '#F59563',
32: '#F67C5F', 64: '#F65E3B', 128: '#EDCF72', 256: '#EDCC61',
512: '#EDC850', 1024: '#EDC53F', 2048: '#EDC22E',
};
private board: number[][] = [];
二维数组棋盘:board[r][c] 存第 r 行第 c 列的数值(0 = 空)。颜色映射表:数值 → 背景色(经典 2048 配色),数字越大颜色越深——颜色与数值语义绑定。
三、滑动合并算法 ★核心算法
3.1 单行压缩合并
private slideLine(line: number[]): { newLine: number[]; gained: number } {
// ① 去掉 0
const nums = line.filter((v) => v !== 0);
const result: number[] = [];
let gained = 0;
// ② 相邻相同合并(每个元素最多合并一次)
for (let i = 0; i < nums.length; i++) {
if (i + 1 < nums.length && nums[i] === nums[i + 1]) {
result.push(nums[i] * 2);
gained += nums[i] * 2;
i++; // 跳过被合并的
} else {
result.push(nums[i]);
}
}
// ③ 补零到 SIZE
while (result.length < SIZE) {
result.push(0);
}
return { newLine: result, gained: gained };
}
核心算法"去零 → 合并 → 补零"三步:
① 去零:filter(v => v !== 0)——把非零元素紧凑排列。如 [2, 0, 2, 4] → [2, 2, 4]。
② 合并:遍历紧凑数组,nums[i] === nums[i+1] 时合并(×2 入结果 + 计分),i++ 跳过被合并元素——"每个元素每轮只合并一次"的关键(否则 [2,2,2,2] 会连乘成 16,而正确结果应是 [4,4,0,0])。如 [2,2,4] → [4,4]。
③ 补零:结果不足 SIZE 补 0——[4,4] → [4,4,0,0](左对齐压缩)。
返回值:新行 + 本行得分(合并值之和)。
3.2 四方向滑动
private move(direction: number): boolean {
let moved = false;
let gained = 0;
const newBoard: number[][] = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
for (let i = 0; i < SIZE; i++) {
// 取出当前行/列(方向决定正序/倒序)
let line: number[] = [];
for (let j = 0; j < SIZE; j++) {
if (direction === 0) line.push(this.board[i][j]); // 左:行正序
else if (direction === 1) line.push(this.board[i][SIZE-1-j]); // 右:行倒序
else if (direction === 2) line.push(this.board[j][i]); // 上:列正序
else line.push(this.board[SIZE-1-j][i]); // 下:列倒序
}
const r = this.slideLine(line);
gained += r.gained;
// 写回(方向决定写回位置)
for (let j = 0; j < SIZE; j++) {
if (direction === 0) newBoard[i][j] = r.newLine[j];
else if (direction === 1) newBoard[i][SIZE-1-j] = r.newLine[j];
else if (direction === 2) newBoard[j][i] = r.newLine[j];
else newBoard[SIZE-1-j][i] = r.newLine[j];
}
}
// 比较是否变化(无变化 = 无效滑动,不生成新方块)
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
if (newBoard[r][c] !== this.board[r][c]) {
moved = true;
}
}
}
if (moved) {
this.board = newBoard;
this.score += gained;
this.spawnTile(); // 有效滑动才生成新方块
this.checkEnd();
}
this.draw();
return moved;
}
四方向统一处理的技巧——“正序/倒序 + 写回”:
| 方向 | 取行/列 | 取序 | 写回 |
|---|---|---|---|
| 左 | 行 | 正序 [0…3] | 正序 |
| 右 | 行 | 倒序 [3…0] | 倒序 |
| 上 | 列 | 正序 | 正序 |
| 下 | 列 | 倒序 | 倒序 |
核心洞察:所有方向的滑动都可以归结为"把一条线朝’起点’方向压缩"。向左 = 行正序压缩;向右 = 行倒序压缩后写回倒序(等效于"向右压缩");上下同理用列。方向只是取序与写回的镜像,合并算法只写一遍(slideLine 永远压缩到左侧)。
有效性判定:新棋盘与旧棋盘逐格比较,有任何变化才算有效滑动——无效滑动(方块已顶满无法移动)不生成新方块、不计分。这是 2048 规则的关键细节。
四、随机生成
private spawnTile(): void {
const empty: Array<{ r: number; c: number }> = [];
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
if (this.board[r][c] === 0) {
empty.push({ r: r, c: c });
}
}
}
if (empty.length === 0) {
return;
}
const cell = empty[Math.floor(Math.random() * empty.length)];
this.board[cell.r][cell.c] = Math.random() < 0.9 ? 2 : 4;
}
随机空位:收集所有空位坐标,随机选一个;数值 90% 概率 2、10% 概率 4(经典概率)。无空位直接返回(此时游戏应已结束,防御)。
五、胜负判定
private canMove(): boolean {
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
if (this.board[r][c] === 0) return true; // 有空位
if (c + 1 < SIZE && this.board[r][c] === this.board[r][c+1]) return true; // 横向可合并
if (r + 1 < SIZE && this.board[r][c] === this.board[r+1][c]) return true; // 纵向可合并
}
}
return false;
}
private checkEnd(): void {
// 胜利:任一格 = 2048
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
if (this.board[r][c] === 2048) {
this.statusText = '🎉 2048!你赢了!';
return;
}
}
}
// 失败:无空位且无相邻相同
if (!this.canMove()) {
this.statusText = `💀 游戏结束,得分 ${this.score}`;
}
}
死局判定(canMove):① 有空位 → 还能走;② 横向相邻相同 → 能合并;③ 纵向相邻相同 → 能合并。三者皆无 = 死局。"是否还有合法操作"的全局判定——遍历棋盘检查三种可能性。
胜负优先级:先查胜利(2048 出现即赢),再查失败(死局)。2048 达成后仍可继续玩(本实例胜利即停提示,不强制结束——经典版本可选继续)。
六、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 棋盘模型 | 二维数组 board[r][c] | 结构化状态 |
| 去零合并补零 | filter + 相邻合并 + 补零 | 单行算法 |
| 只合并一次 | i++ 跳过 | 规则正确性 |
| 四方向统一 | 正序/倒序 + 镜像写回 | 算法只写一遍 |
| 有效性判定 | 新旧棋盘比较 | 无效滑动不生成 |
| 随机生成 | 空位数组 + 概率 2/4 | 不确定性 |
| 死局判定 | 空位/横/纵三查 | 全局判断 |
| 颜色映射 | Record 数值→颜色 | 语义配色 |
七、文章小结
本篇完成了 2048 的数据与算法层:二维数组棋盘、"去零 → 合并 → 补零"单行算法(含"只合并一次"的 i++ 关键)、四方向统一的镜像技巧、有效性判定、随机生成与胜负判定。这套算法是 2048 的灵魂——尤其"四方向统一到 slideLine"的设计,把 4 个方向的逻辑收敛为 1 个函数 + 取序写回,是"算法复用"的教科书示范。
下一篇《页面 UI 与滑动交互》将搭建:棋盘画布(aspectRatio 正方形)、方块渲染(颜色/字号分级)、触摸滑动判定、得分显示。
八、深度扩展
1. 动画过渡
真实 2048 有滑动动画(方块平滑移动 + 合并缩放)。Canvas 实现:记录"上一帧位置 + 当前帧位置",帧循环插值。插值动画提升质感,本实例聚焦算法教学,动画留作扩展。
2. 撤销(悔棋)
保存历史棋盘栈(每次移动前 push),撤销 = pop 恢复。历史栈是状态回退的通用结构(26 快递的撤销同款思路)。
3. 最高分持久化
用 preferences 存最高分(34 贪吃蛇已演示)。跨局记录激励重玩。
4. 大棋盘(5×5/6×6)
SIZE 参数化——slideLine 补零到 SIZE、绘制循环按 SIZE,改常量即扩展。算法与尺寸解耦。
5. FAQ
Q1:为什么合并时 i++ 跳过被合并的元素?
A:保证"每个元素每轮最多参与一次合并"。[2,2,2,2] 左滑:i=0 合并 2+2=4,i++ 到 2(跳过第二个 2),i=2 再合并 4——结果 [4,4,0,0]。若不清跳,会连乘成 16。一次合并的规则靠指针跳跃实现。
Q2:为什么右滑用"倒序取 + 倒序写回"?
A:slideLine 固定向左压缩。向右压缩 = 先把行倒序(让"最右"变"最左"),压缩后写回倒序位置——镜像变换让方向归一。
Q3:无效滑动真的不生成方块吗?
A:是。moved 标志判定——方块已顶满无法移动时滑动无变化,不生成新方块(避免"白嫖"空位)。有效性判定防规则漏洞。
Q4:死局判定会不会漏判?
A:不会——空位、横向相邻同、纵向相邻同三查覆盖全部合法操作。相邻相同一定能合并(滑动使它们相邻)。判定完备性可证明。
Q5:2048 胜利后还能继续吗?
A:本实例提示胜利但棋盘可继续滑(checkEnd 只设文案不锁棋盘)。经典版本可加"继续/结束"选择。胜利不是硬终止。
九、下篇预告
下一篇《页面 UI 与滑动交互》将完成:正方形棋盘画布(aspectRatio(1))、方块渲染(颜色映射 + 字号分级)、触摸滑动(主导轴判定)、得分与状态提示、新游戏按钮。
更多推荐




所有评论(0)