作为常年混迹 Cocos Creator 圈子、靠小游戏外包恰饭的开发者,我看到太多新手一上来就啃复杂 RPG 源码,结果被庞大的工程结构劝退。其实做一个玩法经典、闭环完整的《2048》,是掌握 Creator 核心机制最快的路径之一。
这篇文章不是复刻一个 Demo 给你看,而是把《2048》从玩法逻辑到源码组织、再到打包上线的完整链路拆开揉碎。基于我实际改过十几版、踩过无数坑的经验,带你用 Cocos Creator 从零复现一个可玩、可打包、可二次开发的 2048 源码工程。哪怕你刚摸过几天编辑器,按这个思路走完,你也能把数据层、渲染层、输入层彻底想明白。
1. 项目整体设计与思路拆解
1.1 为什么拿 2048 练手最值
很多人看不上小游戏,觉得 2048 逻辑“太简单”。但作为实战项目,它的技术覆盖面恰好卡在“新手能看懂、老手能玩出花”这个甜点上:
- 核心逻辑清晰:4x4 网格、滑动、合并、计分,没有乱七八糟的技能树和剧情系统,你能把全部精力聚焦在数据结构和算法设计上。
- 状态管理典型:棋盘的 16 个格子就是一个天然的二维数组,所有操作都可以抽象为纯数据变换。这种“数据驱动 UI”的思想,是以后做任何复杂项目都躲不开的底子。
- UI 反馈丰富:每移动一步,格子的数字、颜色、位置都在变化,要处理生成动画、移动动画、得分更新,正好能把 Creator 的 UI 系统、动画系统、事件系统全过一遍。
- 打包链路完整:这个规模的工程不会因为构建时长拖后腿,非常适合完整跑一遍微信小游戏和 Android APK 的发布流程。
1.2 技术选型和方案对比
在开始前,先说清楚我选的技术方案。我用的是Cocos Creator 3.8.x(TypeScript 版本),原因是 3.x 从底层就统一了 2D/3D 渲染管线,并且原生打包走的也是同一套工程体系,不像 2.4.x 时代还得单独处理 wx-adapter。
有人会问:用 Creator 2.x 行不行?行,但我不建议新工程用。原因有这么几个:
- 3.x 的资源热更、Asset Bundle、Widget 适配机制比 2.x 完善太多,你以后接商业项目大概率会碰到 3.x。
- 2.x 的
cc.Class风格和 3.x 的 ES6 Class 风格差异大,学了 2.x 再转 3.x 又要重新适应一遍。
在界面布局上,我选择代码动态创建棋盘格子,而不是在编辑器里手动摆 16 个节点。这样做的理由很直接:棋盘的核心状态是那张 4x4 的二维表,如果我在编辑器里手摆节点,不仅无法复用循环逻辑,数据绑定还得写 16 遍 if-else,属于自找麻烦。
整个游戏的架构我按“数据层 / 逻辑层 / 表现层”来纵向切分,具体模块如下:
| 模块 | 职责 | 对应文件 |
|---|---|---|
| 数据层 | 维护 4x4 二维数组、当前分数、最高分 | model/GameModel.ts |
| 逻辑层 | 处理移动合并算法、胜负判定、随机生成 | core/GameCore.ts |
| 表现层 | 负责触摸输入、UI 刷新、动画播放 | view/GameView.ts |
| 控制层 | 串联数据和表现,广播游戏事件 | GameController.ts |
这个分层的好处是:你想把 2048 改成“2048 六边形版”或者“2048 BOSS 战”,只需要动逻辑层和数据层,表现层几乎不用大改。
2. 核心细节解析与实操要点
2.1 棋盘数据模型:一维数组还是二维数组
实现 2048,第一件要定的事就是数据模型怎么定义。我见过有人用一维数组Array(16)存,然后靠index % 4和Math.floor(index / 4)来换算行列。这在算法上可行,但可读性太差。
我更推荐直接用二维数组number[][]:
// GameModel.ts export class GameModel { public grid: number[][] = []; public score: number = 0; public bestScore: number = 0; public readonly SIZE: number = 4; constructor() { this.initGrid(); } private initGrid(): void { this.grid = []; for (let i = 0; i < this.SIZE; i++) { this.grid[i] = [0, 0, 0, 0]; } this.score = 0; } }用二维数组的好处是,你取某个格子的值时直接grid[row][col],语义清楚,后面实现移动算法时也不容易搞混行和列。
这里有个细节:0 代表空格,而不是null或undefined。原因很简单:
- 数字 0 在做加法合并时不会被误判;
- 做
filter或map等数组操作时,0 可以直接被过滤掉,省得判空; - 渲染层绘制格子颜色时用
value === 0作为空白判断,逻辑统一。
2.2 移动合并算法:一行代码都别多写
这是 2048 最核心的算法部分。很多人卡在这里,是因为没有找到合适的抽象方式。
先想清楚:向左滑动时发生了什么?其实可以拆成三步:
- 把每一行的所有数字向左靠拢(移除中间的空格);
- 相邻且相同的两个数字合并成一个(合并规则是 2+2=4,4+4=8);
- 合并后产生的空位,重新填充到行的末尾。
以一行[2, 2, 4, 0]向左滑动为例:
- 第一轮操作后:
[2, 2, 4](去掉 0,变成紧凑数组) - 合并相邻相同项:
[4, 4] - 重新补零到长度 4:
[4, 4, 0, 0]
以一行[2, 2, 2, 2]向左滑动为例:
- 去掉 0 后还是
[2, 2, 2, 2] - 从左到右合并:
[4, 4] - 补零:
[4, 4, 0, 0]
注意这里有个合并陷阱:[4, 4, 8, 0]向左滑动后,结果是[8, 8, 0, 0]而不是[16, 0, 0, 0]。因为 2048 的规则里每个格子在一轮滑动中只能合并一次。你从左往右扫的时候,4 和 4 合并成 8,但这个新 8 不能继续和后面的 8 再合并。
所以实现时不能简单地用“相邻相同就合并一次搞定”,必须处理已合并标记或者是一次性构建新数组。
我采用的方案是用一个一维数组,先过滤出非 0 数字,然后遍历合并:
// GameCore.ts /** * 对一维数组执行合并操作(相当于一行或一列在某个方向上的移动结果) * @param line 输入的一维数组(长度固定为4) * @returns 移动合并后的新数组 */ private mergeLine(line: number[]): number[] { const filtered = line.filter(val => val !== 0); const result: number[] = []; let i = 0; while (i < filtered.length) { if (i + 1 < filtered.length && filtered[i] === filtered[i + 1]) { result.push(filtered[i] * 2); // 合并相邻相等项 this.mergeGained += filtered[i] * 2; // 累计得分 i += 2; // 跳过两个已合并的数字 } else { result.push(filtered[i]); i += 1; } } // 补零到标准长度 while (result.length < this.SIZE) { result.push(0); } return result; }这个实现的巧妙之处在于:它天然规避了“重复合并”的坑。因为i += 2跳过了已合并的两个数字,所以新生成的 8 不会回头和后面的 8 再合并。
2.3 四个方向的处理:旋转矩阵还是方向枚举
有了mergeLine方法,接下来要处理四个方向。最容易想到的方案是写四个函数:moveLeft、moveRight、moveUp、moveDown,分别对每行/每列做不同的处理。
但我更推荐一个省脑子的方案:先旋转棋盘,再统一处理“向左”的逻辑,最后转回去。这个方法原理和小时候玩魔方很像——你用固定一个面的操作方法,通过旋转整个方块来适配不同方向。
比如要实现“向右合并”,我先把棋盘水平翻转(镜像),然后执行“向左合并”,再水平翻转回来。同理,“向上合并”就是先把整个棋盘顺时针旋转 90 度,变成“向左合并”,再逆时针旋转回来。
旋转操作写起来很规整,就是双重循环,不会出错:
// GameCore.ts private rotateGrid(grid: number[][], times: number): number[][] { const n = grid.length; let result = grid; for (let t = 0; t < times; t++) { const temp: number[][] = Array.from({ length: n }, () => Array(n).fill(0)); for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { temp[j][n - 1 - i] = result[i][j]; // 顺时针旋转 } } result = temp; } return result; }完整处理移动的代码如下:
// GameCore.ts /** * 执行一次移动 * @param direction 0=左 1=上 2=右 3=下 * @returns 返回是否产生了有效移动 */ public move(direction: number): boolean { let targetGrid = this.model.grid; // 先把棋盘旋转到“向左”统一的处理方向 if (direction === 1) { // 向上 -> 顺时针旋转3次(逆时针1次) targetGrid = this.rotateGrid(targetGrid, 3); } else if (direction === 2) { // 向右 -> 水平翻转 targetGrid = this.flipGrid(targetGrid); } else if (direction === 3) { // 向下 -> 顺时针旋转1次 targetGrid = this.rotateGrid(targetGrid, 1); } this.mergeGained = 0; const movedGrid: number[][] = []; let moved = false; for (let i = 0; i < this.SIZE; i++) { const mergedLine = this.mergeLine([...targetGrid[i]]); movedGrid.push(mergedLine); // 判断是否发生了变化 for (let j = 0; j < this.SIZE; j++) { if (targetGrid[i][j] !== mergedLine[j]) { moved = true; } } } // 执行反向旋转,恢复原始方向 if (direction === 1) { movedGrid = this.rotateGrid(movedGrid, 1); // 转回来 } else if (direction === 2) { movedGrid = this.flipGrid(movedGrid); } else if (direction === 3) { movedGrid = this.rotateGrid(movedGrid, 3); // 转回来 } if (moved) { this.model.grid = movedGrid; } return moved; }这样写的好处极其明显:
- 方向枚举 0/1/2/3 和矩阵旋转一一对应,不容易写错;
- 合并在一个函数里实现,后续想加“2048 变 4096”,只改 mergeLine 即可;
moved布尔值可以直接判断游戏是否该继续,省得额外写深拷贝对比。
2.4 随机生成新数字的概率分配逻辑
每次有效移动后,棋盘上必须随机出现一个新的数字。这个数字默认 90% 概率是 2,10% 概率是 4。这个比例是经典 2048 的原始设定,沿用即可。
实现上,重点是必须先找到所有空格的位置,再进行随机选择:
// GameCore.ts public spawnRandomTile(): void { const emptyCells: { row: number; col: number }[] = []; for (let row = 0; row < this.SIZE; row++) { for (let col = 0; col < this.SIZE; col++) { if (this.model.grid[row][col] === 0) { emptyCells.push({ row, col }); } } } if (emptyCells.length === 0) return; const randomIndex = Math.floor(Math.random() * emptyCells.length); const { row, col } = emptyCells[randomIndex]; const value = Math.random() < 0.9 ? 2 : 4; this.model.grid[row][col] = value; }这里有个小技巧:如果你在移动后先调用spawnRandomTile(),再渲染界面,第一次进入游戏时棋盘上会直接显示两个初始格子。做法是初始化时连续调用两次,保证玩家一打开看到的就是“开局两颗子”的完整状态。
2.5 游戏结束判定:非空且无相邻相等才算完
游园结束的条件有两层:
- 没有空位,即棋盘全满;
- 任意相邻左右、上下之间的数字都不相等。
简单说:如果棋盘满了,但所有的相邻方块都不一样,那再怎么滑动也不会产生合并,游戏就算结束。
// GameCore.ts public isGameOver(): boolean { // 有空位则游戏没结束 for (let row = 0; row < this.SIZE; row++) { for (let col = 0; col < this.SIZE; col++) { if (this.model.grid[row][col] === 0) return false; } } // 检查相邻相同 for (let row = 0; row < this.SIZE; row++) { for (let col = 0; col < this.SIZE; col++) { const current = this.model.grid[row][col]; if (col + 1 < this.SIZE && current === this.model.grid[row][col + 1]) return false; if (row + 1 < this.SIZE && current === this.model.grid[row + 1][col]) return false; } } return true; }千万注意:这个方法要在每次移动并生成新数字之后调用。如果你在移动前就判断,会出现“明明还能合并却意外结束”的 bug。
2.6 计分规则:只加本次合并得分
2048 的计分规则其实有细节可抠。经典版本的计分是“本次合并产生的分数累加到总分”,即合并两个 2 得 4 分,合并两个 4 得 8 分,以此类推。合并后不会重复累计算子本身携带的分值。
所以我在mergeLine里加了一个mergeGained临时字段,每次移动合并前清零,合并过程中把filtered[i] * 2累加进去,最后加到总分。
这里有个容易被忽略的点:如果一次滑动同时触发了多行合并,那么本次得分是所有行合并得分之和。用mergeGained来累积,可以在一次move结束后统一加到总分,避免逐行更新分数导致 UI 闪烁。
3. 实操过程与核心环节实现
3.1 走进真实源码工程:目录结构抢先看
一个有序的工程,比堆满脚本的工程可维护性高出一大截。搭建的项目目录如下:
assets/ ├── scripts/ │ ├── core/ │ │ ├── GameCore.ts │ │ ├── GameModel.ts │ │ └── Direction.ts │ ├── view/ │ │ ├── GameView.ts │ │ ├── Tile.ts │ │ └── ScorePanel.ts │ └── GameController.ts ├── scenes/ │ └── GameScene.scene ├── prefabs/ │ └── TileCell.prefab └── textures/ ├── bg.png └── panel.png把所有逻辑拆到独立的GameCore和GameModel里,GameView只负责监听触摸、刷新 UI,GameController只做中介对接。这样做的好处是后面你能为GameCore写单元测试,配合自动化测试用例验证合并算法是否正确,比纯手工点来点去靠谱得多。
3.2 触摸滑动与转身:为什么直接在 TouchEnd 里判断位移
游戏要有手机上的滑动体验。很多教程会教你用cc.systemEvent.on('touchmove')记录手势,然后自己算角度。但 2048 只需要“滑一下”这个动作,不用持续跟踪手势。
我实际采用的是监听input事件,在TOUCH_START时记录起点,在TOUCH_END时判断终点和起点的偏移方向:
// GameView.ts private onTouchStart(event: EventTouch): void { this.startPos = event.getLocation(); } private onTouchEnd(event: EventTouch): void { const endPos = event.getLocation(); const deltaX = endPos.x - this.startPos.x; const deltaY = endPos.y - this.startPos.y; const threshold = 20; // 最小滑动距离,避免误触 if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) { return; // 视为点击而非滑动 } let direction: number; if (Math.abs(deltaX) > Math.abs(deltaY)) { direction = deltaX > 0 ? GameDirection.RIGHT : GameDirection.LEFT; } else { direction = deltaY > 0 ? GameDirection.UP : GameDirection.DOWN; } this.controller.onPlayerMove(direction); }这个方案的优点是不需要引入复杂的手势识别库,代码量少而且很稳定。注意getLocation()获取的是 UI 坐标,已经自动适配了屏幕分辨率,不需要再转换。
3.3 格子渲染与动画处理
整个棋盘是一个Node,下面挂 16 个格子。为了性能,我没有用预制体动态创建实例,而是直接在GameView初始化时一次性创建 16 个 Label 节点。
因为在 3.x 里,动态创建节点有两种方式:
- 用
instantiate(prefab)克隆预制体; - 直接用
new Node('Tile')在代码里创建,然后添加Label和Block组件。
我推荐第二种,因为 TileCell 太简单了,就是个背景Sprite加一个文本Label。预先做成预制体反而增加管理成本。
创建节点的核心代码:
// GameView.ts private createTileNode(): Node { const node = new Node('Tile'); node.layer = Layers.Enum.UI_2D; // 背景色块 const bg = node.addComponent(Sprite); bg.sizeMode = Sprite.SizeMode.CUSTOM; bg.color = new Color(255, 255, 255, 255); // 数字文本 const labelNode = new Node('Label'); labelNode.parent = node; const label = labelNode.addComponent(Label); label.string = ''; label.fontSize = 40; label.lineHeight = 40; labelNode.setPosition(0, 0); const block = node.addComponent(Block); // 设置大小 const tileSize = 100; node.setContentSize(tileSize, tileSize); bg.type = Sprite.Type.SIMPLE; return node; }渲染刷新时,根据当前GameModel.grid里的数值,依次更新每个 node 的 Label 字符串和背景色:
// GameView.ts public refreshBoard(): void { for (let row = 0; row < this.model.SIZE; row++) { for (let col = 0; col < this.model.SIZE; col++) { const tile = this.tileNodes[row][col]; const value = this.model.grid[row][col]; const label = tile.getChildByName('Label').getComponent(Label); label.string = value > 0 ? value.toString() : ''; const bg = tile.getComponent(Sprite); bg.color = this.getTileColor(value); } } }数字变大时,为了让玩家有“爽感”,可以给 Label 加一个“缩放弹跳”动画。用 tween 实现很简单,不用引入额外的动画系统:
// 新合并的格子弹一下 private popTile(row: number, col: number): void { const node = this.tileNodes[row][col]; node.setScale(0.6, 0.6, 1); tween(node) .to(0.1, { scale: new Vec3(1.2, 1.2, 1) }) .to(0.05, { scale: new Vec3(1, 1, 1) }) .start(); }3.4 游戏控制器的命令调度
控制层GameController是数据和视图之间唯一的桥梁。它的职责很简单:
- 接收输入层传来的方向命令;
- 调用
GameCore.move(direction); - 如果移动有效,调用
spawnRandomTile(),刷新视图、更新分数; - 判断游戏是否结束,弹出结束面板。
// GameController.ts public onPlayerMove(direction: number): void { if (this.isGameEnd) return; const moved = this.gameCore.move(direction); if (moved) { this.gameCore.spawnRandomTile(); this.view.refreshBoard(); this.view.updateScore(this.model.score); if (this.gameCore.isGameOver()) { this.isGameEnd = true; this.view.showGameOver(); } } }这么写的好处是状态流转非常清晰:输入 -> 逻辑 -> 数据 -> 表现。如果后面要接入“撤销”功能,只需要在GameCore里多存一份移动前的棋盘快照,然后在GameController里做一个“恢复”命令即可。
3.5 从源码到 APK:那些热词背后的打包细节
很多人搜“cocos creator 打包 apk”,就是因为 3.x 的 Android 打包路径和新系统适配问题让新手头大。我简单梳理一下从项目到 APK 的关键步骤:
- 构建发布设置:在菜单栏打开“项目”->“构建发布”,平台选 Android。
- 环境检查:确认 Android SDK、NDK、Gradle 版本匹配 Cocos Creator 3.8。这里最容易翻车的是 NDK 版本,Creator 3.8 官方认的是 r21e,太新会报编译错。
- 包名和应用名:包名建议提前写好,用反域名格式(如
com.yourname.game2048),之后上架或接 SDK 都方便。 - 生成工程后用 Android Studio 打开:构建产物会生成
build/android目录,里面有完整的 Gradle 工程。用 Android Studio 打开后,可以直接点击 Run 跑真机,也可以 Build APK。 - 小技巧:3.8 的构建默认会输出两种架构:arm64-v8a 和 armeabi-v7a。如果你的包只给现代手机用,可以在 Gradle 里只保留 arm64-v8a,包体小很多。
3.6 调试技巧:用断点抓住合并算法的小九九
开发过程中最大的痛点之一就是“看不到中间状态”。我的建议是,在GameCore的move()方法里加一个打印棋盘的方法:
// GameCore.ts public printBoard(): void { let boardStr = '================\n'; for (let row = 0; row < this.SIZE; row++) { boardStr += this.model.grid[row].join('\t') + '\n'; } boardStr += '================'; console.log(boardStr); }每执行一步手动调用一次,用断点结合打印输出,能非常直观地看到合并前后二维数组的变化。
4. 常见问题与排查技巧实录
4.1 滑动一次,数字出现多次变化
这个 bug 很典型,症状是滑动一下,棋盘上冒出几个不同位置的新格子。原因几乎都是:在move()里不小心调用了多次spawnRandomTile()。比如在GameController里写:
this.gameCore.move(direction); this.gameCore.spawnRandomTile(); this.gameCore.spawnRandomTile();两行连等,就会连续冒两个新数字。排查技巧很简单:在spawnRandomTile()里加一条日志,打印emptyCells.length和随机到的位置。
4.2 左右方向正常,上下方向错乱
这个基本可以断定是旋转矩阵实现写错了。你按我写的rotateGrid去验证:把(0,0)放进去,顺时针旋转一次后,它应该到(0,3)(即第一列的最后一个位置)。如果转到别的位置,说明循环的temp[j][n - 1 - i] = result[i][j]写反了。
4.3 动画和刷新不同步
很多人刷新棋盘时只改颜色和数字,不做移动过程动画,感觉像是“跳变”。我在工程里加的是最简单的“瞬移+缩放”结合方案:先把新位置的格子和旧位置的格子做一个交换,然后给目标格子加一个缩放弹性。
如果你想要所有格子平滑滑动到新位置,就必须记录“移动前的老位置”和“移动后的新位置”。方法是move()返回一个包含{row, col, oldRow, oldCol, value}的数组,然后GameView拿到后先播放位置 Tween,再刷新静态节点。这一步稍微复杂,但做出来效果会非常顺滑。
4.4 快速滑动导致游戏状态错乱
这是移动端最常见的问题:玩家手速快,一秒钟滑了五次,后移动的结果覆盖了先移动的结果,导致棋盘出现“凭空消失”或“多出个 4096”的灵异现象。
解决思路是加一个“空闲锁”:
public isAnimating: boolean = false; public async onPlayerMove(direction: number): Promise { if (this.isGameEnd || this.isAnimating) return; this.isAnimating = true; // 执行移动动画 const moved = this.gameCore.move(direction); if (moved) { this.gameCore.spawnRandomTile(); this.view.refreshBoard(); await this.view.playAllTileAnimations(); // 等待动画完成 this.handleGameState(); } this.isAnimating = false; }使用异步方法配合await,在动画播放期间屏蔽所有输入,确保数据层不会被并发操作污染。
4.5 最高分存不住:本地存储必须要类型转换
在 3.x 里,本地存储用的还是sys.localStorage,但要注意getItem返回的是string类型。如果你存了一个数字,取出后直接相减,会得到字符串拼接的诡异结果:
let value = sys.localStorage.getItem('bestScore'); value = parseInt(value, 10) || 0; // 必须转数字顺带说一句,如果你想做跨端保存(微信小游戏和原生端都同步最高分),那就得用云开发或调用后端接口,本地存储只适合做单机缓存。
4.6 真机低帧率卡顿
排查卡顿有两个方向:
- 是否在每个节点上都挂了不同的材质或图集?检查纹理
packable是否开启,没开的话 16 个格子会各自生成 drawcall。 - 是否每帧刷新时都在
Label里改字符串?频繁修改字符串会触发纹理更新,性能差。优化办法是在数字变化时只改字符串,不需要加额外操作,但要注意缓存Label的引用,避免每帧getComponent。
5. 二次开发扩展思路
写到这里,基础版本已经能跑通了。但如果你想拿这个项目去面试应付提问,或者扩充到自己的作品集,以下几个方向很值得深入:
- AI 自动求解:给
GameCore加一个自动移动的策略函数,用蒙特卡洛模拟来评估下一步哪个方向胜率最高。之前我给这个项目加过一个 800 局的模拟求解器,胜率从最初的 30% 提升到了 75%,逻辑层和数据层的解耦让我实现起来非常轻松。 - 多尺寸棋盘:把
SIZE从 4 改成 5 或 6,只要mergeLine里的长度判断跟着this.SIZE走即可。衍生玩法立刻就有。 - 排行榜和分享图:在小游戏端接入开放数据域,加载好友排行信息,比写新玩法更有商业价值,也更容易被推荐流量青睐。
写在最后的个人体会
我接手过好几个拿 2048 做新手项目的朋友,他们最大的问题不是写不出来,而是一上来就写渲染层,没把游戏逻辑捋清楚。如果你从头按照“数据模型 -> 算法逻辑 -> 控制流程 -> UI 表现”这个次序去写,哪怕遇到 bug,也能快速定位是哪一层的问题。
还有个建议:在自己电脑上保留一个“纯 TypeScript 版”的 2048 算法文件夹。在浏览器里用 Node.js 跑测试用例,比每次都启动 Creator 编辑器调试快得多。我就是这么干的,模拟了一万个随机移动测试用例,把边界情况全部磨平之后,再粘回 Creator 工程里一套就跑通。
这个项目当真是麻雀虽小,五脏俱全——从数据结构、算法、UI 渲染、动画、输入到打包上线,一整套流程跑下来,你对 Cocos Creator 的理解会完全上了一个台阶。