作为常年混迹 Cocos Creator 圈子、靠小游戏外包恰饭的开发者,我看到太多新手一上来就啃复杂 RPG 源码,结果被庞大的工程结构劝退。其实做一个玩法经典、闭环完整的《2048》,是掌握 Creator 核心机制最快的路径之一。
这篇文章不是复刻一个 Demo 给你看,而是把《2048》从玩法逻辑到源码组织、再到打包上线的完整链路拆开揉碎。基于我实际改过十几版、踩过无数坑的经验,带你用 Cocos Creator 从零复现一个可玩、可打包、可二次开发的 2048 源码工程。哪怕你刚摸过几天编辑器,按这个思路走完,你也能把数据层、渲染层、输入层彻底想明白。
1. 项目整体设计与思路拆解
1.1 为什么拿 2048 练手最值
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 行不行?行,但我不建议新工程用。原因有这么几个: 在界面布局上,我选择代码动态创建棋盘格子 ,而不是在编辑器里手动摆 16 个节点。这样做的理由很直接:棋盘的核心状态是那张 4x4 的二维表,如果我在编辑器里手摆节点,不仅无法复用循环逻辑,数据绑定还得写 16 遍 if-else,属于自找麻烦。 整个游戏的架构我按“数据层 / 逻辑层 / 表现层”来纵向切分,具体模块如下: 这个分层的好处是:你想把 2048 改成“2048 六边形版”或者“2048 BOSS 战”,只需要动逻辑层和数据层,表现层几乎不用大改。 实现 2048,第一件要定的事就是数据模型怎么定义。我见过有人用一维数组 来换算行列。这在算法上可行,但可读性太差。 我更推荐直接用二维数组 : 用二维数组的好处是,你取某个格子的值时直接 ,语义清楚,后面实现移动算法时也不容易搞混行和列。 这里有个细节:0 代表空格,而不是 。原因很简单: 这是 2048 最核心的算法部分。很多人卡在这里,是因为没有找到合适的抽象方式。 先想清楚:向左滑动时发生了什么?其实可以拆成三步: 以一行 向左滑动为例: 以一行 向左滑动为例: 注意这里有个合并陷阱: 。因为 2048 的规则里每个格子在一轮滑动中只能合并一次。你从左往右扫的时候,4 和 4 合并成 8,但这个新 8 不能继续和后面的 8 再合并。 所以实现时不能简单地用“相邻相同就合并一次搞定”,必须处理已合并标记或者是一次性构建新数组 。 我采用的方案是用一个一维数组,先过滤出非 0 数字,然后遍历合并: 这个实现的巧妙之处在于:它天然规避了“重复合并”的坑。因为 跳过了已合并的两个数字,所以新生成的 8 不会回头和后面的 8 再合并。 有了 ,分别对每行/每列做不同的处理。 但我更推荐一个省脑子的方案:先旋转棋盘,再统一处理“向左”的逻辑,最后转回去 。这个方法原理和小时候玩魔方很像——你用固定一个面的操作方法,通过旋转整个方块来适配不同方向。 比如要实现“向右合并”,我先把棋盘水平翻转(镜像),然后执行“向左合并”,再水平翻转回来。同理,“向上合并”就是先把整个棋盘顺时针旋转 90 度,变成“向左合并”,再逆时针旋转回来。 旋转操作写起来很规整,就是双重循环,不会出错: 完整处理移动的代码如下: 这样写的好处极其明显: 每次有效移动后,棋盘上必须随机出现一个新的数字。这个数字默认 90% 概率是 2,10% 概率是 4。这个比例是经典 2048 的原始设定,沿用即可。 实现上,重点是必须先找到所有空格的位置,再进行随机选择: 这里有个小技巧:如果你在移动后先调用 ,再渲染界面,第一次进入游戏时棋盘上会直接显示两个初始格子。做法是初始化时连续调用两次,保证玩家一打开看到的就是“开局两颗子”的完整状态。 游园结束的条件有两层: 简单说:如果棋盘满了,但所有的相邻方块都不一样,那再怎么滑动也不会产生合并,游戏就算结束。 千万注意:这个方法要在每次移动并生成新数字之后 调用。如果你在移动前就判断,会出现“明明还能合并却意外结束”的 bug。 2048 的计分规则其实有细节可抠。经典版本的计分是“本次合并产生的分数累加到总分”,即合并两个 2 得 4 分,合并两个 4 得 8 分,以此类推。合并后不会重复累计算子本身携带的分值。 所以我在 累加进去,最后加到总分。 这里有个容易被忽略的点:如果一次滑动同时触发了多行合并,那么本次得分是所有行合并得分之和。用 结束后统一加到总分,避免逐行更新分数导致 UI 闪烁。 一个有序的工程,比堆满脚本的工程可维护性高出一大截。搭建的项目目录如下: 把所有逻辑拆到独立的 写单元测试,配合自动化测试用例验证合并算法是否正确,比纯手工点来点去靠谱得多。 游戏要有手机上的滑动体验。很多教程会教你用 这个动作,不用持续跟踪手势。 我实际采用的是监听 时判断终点和起点的偏移方向: 这个方案的优点是不需要引入复杂的手势识别库,代码量少而且很稳定。注意 获取的是 UI 坐标,已经自动适配了屏幕分辨率,不需要再转换。 整个棋盘是一个 初始化时一次性创建 16 个 Label 节点。 因为在 3.x 里,动态创建节点有两种方式: 我推荐第二种,因为 TileCell 太简单了,就是个背景 。预先做成预制体反而增加管理成本。 创建节点的核心代码: 渲染刷新时,根据当前 里的数值,依次更新每个 node 的 Label 字符串和背景色: 数字变大时,为了让玩家有“爽感”,可以给 Label 加一个“缩放弹跳”动画。用 tween 实现很简单,不用引入额外的动画系统: 控制层 是数据和视图之间唯一的桥梁。它的职责很简单: 这么写的好处是状态流转非常清晰:输入 -> 逻辑 -> 数据 -> 表现。如果后面要接入“撤销”功能,只需要在 里做一个“恢复”命令即可。 很多人搜“cocos creator 打包 apk”,就是因为 3.x 的 Android 打包路径和新系统适配问题让新手头大。我简单梳理一下从项目到 APK 的关键步骤: 开发过程中最大的痛点之一就是“看不到中间状态”。我的建议是,在 方法里加一个打印棋盘的方法: 每执行一步手动调用一次,用断点结合打印输出,能非常直观地看到合并前后二维数组的变化。 这个 bug 很典型,症状是滑动一下,棋盘上冒出几个不同位置的新格子。原因几乎都是:在 里写: 两行连等,就会连续冒两个新数字。排查技巧很简单:在 和随机到的位置。 这个基本可以断定是旋转矩阵实现写错了。你按我写的 写反了。 很多人刷新棋盘时只改颜色和数字,不做移动过程动画,感觉像是“跳变”。我在工程里加的是最简单的“瞬移+缩放”结合方案:先把新位置的格子和旧位置的格子做一个交换,然后给目标格子加一个缩放弹性。 如果你想要所有格子平滑滑动到新位置,就必须记录“移动前的老位置”和“移动后的新位置”。方法是 拿到后先播放位置 Tween,再刷新静态节点。这一步稍微复杂,但做出来效果会非常顺滑。 这是移动端最常见的问题:玩家手速快,一秒钟滑了五次,后移动的结果覆盖了先移动的结果,导致棋盘出现“凭空消失”或“多出个 4096”的灵异现象。 解决思路是加一个“空闲锁”: 使用异步方法配合 ,在动画播放期间屏蔽所有输入,确保数据层不会被并发操作污染。 在 3.x 里,本地存储用的还是 类型。如果你存了一个数字,取出后直接相减,会得到字符串拼接的诡异结果: 顺带说一句,如果你想做跨端保存(微信小游戏和原生端都同步最高分),那就得用云开发或调用后端接口,本地存储只适合做单机缓存。 排查卡顿有两个方向: 写到这里,基础版本已经能跑通了。但如果你想拿这个项目去面试应付提问,或者扩充到自己的作品集,以下几个方向很值得深入: 我接手过好几个拿 2048 做新手项目的朋友,他们最大的问题不是写不出来,而是一上来就写渲染层,没把游戏逻辑捋清楚 。如果你从头按照“数据模型 -> 算法逻辑 -> 控制流程 -> UI 表现”这个次序去写,哪怕遇到 bug,也能快速定位是哪一层的问题。 还有个建议:在自己电脑上保留一个“纯 TypeScript 版”的 2048 算法文件夹。在浏览器里用 Node.js 跑测试用例,比每次都启动 Creator 编辑器调试快得多。我就是这么干的,模拟了一万个随机移动测试用例,把边界情况全部磨平之后,再粘回 Creator 工程里一套就跑通。 这个项目当真是麻雀虽小,五脏俱全——从数据结构、算法、UI 渲染、动画、输入到打包上线,一整套流程跑下来,你对 Cocos Creator 的理解会完全上了一个台阶。cc.Class风格和 3.x 的 ES6 Class 风格差异大,学了 2.x 再转 3.x 又要重新适应一遍。模块 职责 对应文件 数据层 维护 4x4 二维数组、当前分数、最高分 model/GameModel.ts逻辑层 处理移动合并算法、胜负判定、随机生成 core/GameCore.ts表现层 负责触摸输入、UI 刷新、动画播放 view/GameView.ts控制层 串联数据和表现,广播游戏事件 GameController.ts2. 核心细节解析与实操要点
2.1 棋盘数据模型:一维数组还是二维数组
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]null或undefinedfilter或map等数组操作时,0 可以直接被过滤掉,省得判空;value === 0作为空白判断,逻辑统一。2.2 移动合并算法:一行代码都别多写
[2, 2, 4, 0][2, 2, 4](去掉 0,变成紧凑数组)[4, 4][4, 4, 0, 0][2, 2, 2, 2][2, 2, 2, 2][4, 4][4, 4, 0, 0][4, 4, 8, 0]向左滑动后,结果是[8, 8, 0, 0]而不是[16, 0, 0, 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 += 22.3 四个方向的处理:旋转矩阵还是方向枚举
mergeLine方法,接下来要处理四个方向。最容易想到的方案是写四个函数:moveLeft、moveRight、moveUp、moveDown// 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; }moved布尔值可以直接判断游戏是否该继续,省得额外写深拷贝对比。2.4 随机生成新数字的概率分配逻辑
// 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; }2.6 计分规则:只加本次合并得分
mergeLine里加了一个mergeGained临时字段,每次移动合并前清零,合并过程中把filtered[i] * 2mergeGained来累积,可以在一次move3. 实操过程与核心环节实现
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.pngGameCore和GameModel里,GameView只负责监听触摸、刷新 UI,GameController只做中介对接。这样做的好处是后面你能为GameCore3.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()3.3 格子渲染与动画处理
Node,下面挂 16 个格子。为了性能,我没有用预制体动态创建实例,而是直接在GameViewinstantiate(prefab)克隆预制体;new Node('Tile')在代码里创建,然后添加Label和Block组件。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// 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); } } }// 新合并的格子弹一下 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 游戏控制器的命令调度
GameControllerGameCore.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里多存一份移动前的棋盘快照,然后在GameController3.5 从源码到 APK:那些热词背后的打包细节
com.yourname.game2048),之后上架或接 SDK 都方便。build/android目录,里面有完整的 Gradle 工程。用 Android Studio 打开后,可以直接点击 Run 跑真机,也可以 Build APK。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 滑动一次,数字出现多次变化
move()里不小心调用了多次spawnRandomTile()。比如在GameControllerthis.gameCore.move(direction); this.gameCore.spawnRandomTile(); this.gameCore.spawnRandomTile();spawnRandomTile()里加一条日志,打印emptyCells.length4.2 左右方向正常,上下方向错乱
rotateGrid去验证:把(0,0)放进去,顺时针旋转一次后,它应该到(0,3)(即第一列的最后一个位置)。如果转到别的位置,说明循环的temp[j][n - 1 - i] = result[i][j]4.3 动画和刷新不同步
move()返回一个包含{row, col, oldRow, oldCol, value}的数组,然后GameView4.4 快速滑动导致游戏状态错乱
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; }await4.5 最高分存不住:本地存储必须要类型转换
sys.localStorage,但要注意getItem返回的是stringlet value = sys.localStorage.getItem('bestScore'); value = parseInt(value, 10) || 0; // 必须转数字4.6 真机低帧率卡顿
packable是否开启,没开的话 16 个格子会各自生成 drawcall。Label里改字符串?频繁修改字符串会触发纹理更新,性能差。优化办法是在数字变化时只改字符串,不需要加额外操作,但要注意缓存Label的引用,避免每帧getComponent。5. 二次开发扩展思路
GameCore加一个自动移动的策略函数,用蒙特卡洛模拟来评估下一步哪个方向胜率最高。之前我给这个项目加过一个 800 局的模拟求解器,胜率从最初的 30% 提升到了 75%,逻辑层和数据层的解耦让我实现起来非常轻松。SIZE从 4 改成 5 或 6,只要mergeLine里的长度判断跟着this.SIZE走即可。衍生玩法立刻就有。写在最后的个人体会