三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Cocos Creator 3.X 实战:从零构建俄罗斯方块游戏

Cocos Creator 3.X 实战:从零构建俄罗斯方块游戏

1. 项目概述与核心价值

最近在社区里看到不少朋友想用 Cocos Creator 3.X 练手,但苦于找不到一个能贯穿引擎核心功能、逻辑又相对完整的项目。我琢磨了一下,《俄罗斯方块》这个经典游戏,简直是 Cocos Creator 3.X 的“黄金练手项目”。它麻雀虽小,五脏俱全:从基础的节点操作、UI 布局,到核心的游戏逻辑、碰撞检测、动画控制,再到数据持久化、性能优化,几乎覆盖了游戏开发中 80% 的常见需求。更重要的是,它的规则清晰,边界明确,非常适合用来理解 Cocos Creator 3.X 的组件化开发思想和 TypeScript 的工程化实践。

我这次带大家做的,不是一个简单的 Demo,而是一个功能完整、代码结构清晰、具备良好扩展性的《俄罗斯方块》项目。我们会基于 Cocos Creator 3.8 及以上版本(向下兼容 3.X 主流版本),使用 TypeScript 进行开发。过程中,我会重点分享那些官方文档里不会写的“坑”和“技巧”,比如如何优雅地管理游戏状态、如何实现高效的碰撞检测逻辑、如何处理方块旋转的边界问题,以及如何利用 Cocos Creator 3.X 的新特性(如 UI 组件、动画系统、事件系统)来提升开发效率和游戏体验。

无论你是刚接触 Cocos Creator 的新手,想通过一个完整项目快速上手;还是有一定经验的开发者,希望深化对引擎架构和游戏逻辑设计的理解,这个项目都能给你带来实实在在的收获。我们不止于“实现功能”,更关注“如何更好地实现”,我会在每一个关键步骤解释背后的设计考量。

2. 项目整体架构与设计思路

在动手写代码之前,花点时间把架构想清楚,能省去后期大量的重构和调试时间。对于《俄罗斯方块》这类逻辑驱动型游戏,清晰的数据与表现分离是重中之重。

2.1 核心模块划分

我习惯将项目分为四个核心层,自上而下分别是:表现层(View)控制层(Controller/Manager)逻辑层(Logic/Model)工具层(Utils)。这种分层不是生搬硬套 MVC/MVVM,而是为了职责清晰,方便调试和扩展。

  1. 逻辑层(GameModel):这是游戏的大脑,只关心数据。它负责维护一个二维数组表示的游戏网格(Grid),记录每个格子的状态(空、被占用、方块类型)。它还管理当前下落方块(Tetromino)的数据,包括其形状、旋转状态、在网格中的坐标。所有游戏规则,如移动是否合法、消行判断、分数计算,都在这一层纯数据层面完成。这样做的好处是,你可以脱离 Cocos Creator 环境,单独用 Node.js 或浏览器控制台测试你的核心游戏逻辑,极大提升开发效率。

  2. 表现层(GameView):这是游戏的脸面,只关心显示。它根据GameModel提供的数据,在 Cocos Creator 的场景中渲染出网格、当前方块、下一个预览方块、分数、等级等 UI 元素。每一个格子可能是一个Sprite节点,当前方块由多个这样的格子Sprite组合而成。表现层不应该包含任何游戏规则判断。

  3. 控制层(GameController):这是连接大脑和脸面的神经中枢。它监听用户的键盘输入(左右移动、旋转、加速下落),将这些操作转化为对GameModel的调用(如moveLeft()rotate())。然后,它再根据GameModel更新后的数据,调用GameView的方法刷新界面。同时,游戏的主循环(如方块自动下落)也由控制层驱动。

  4. 工具层与管理器:包括一些全局单例或工具类。

    • 资源管理器(ResManager):统一加载和管理方块纹理、音效等资源。
    • 音频管理器(AudioManager):控制背景音乐和游戏音效的播放。
    • 数据管理器(DataManager):使用localStoragesys.localStorage保存和读取最高分、游戏设置等。
    • 常量定义(Constants):集中定义网格大小(如 10x20)、方块颜色、下落初始速度等常量。

2.2 关键技术选型与考量

  • 语言:TypeScript (TS) vs JavaScript (JS)强烈推荐 TS。对于《俄罗斯方块》这种状态复杂的项目,TS 的静态类型检查能在编码阶段就发现很多潜在错误(比如给一个期望是number的变量赋值string)。它还能提供更好的代码提示和重构能力。Cocos Creator 3.X 对 TS 的支持已经非常完善。
  • 渲染:UI 组件 vs 普通节点:对于游戏主网格和方块,我选择使用普通的Sprite节点,通过代码动态创建和管理,这样在频繁更新位置和纹理时更灵活。对于分数、等级、下一个方块预览面板等相对静态的 UI,则使用 Cocos Creator 强大的UI 组件(Widget, Layout, Label等),可以方便地适配不同屏幕分辨率。
  • 物理与碰撞:纯数学计算 vs 物理引擎:俄罗斯方块的碰撞检测完全基于网格坐标,逻辑简单确定,不需要启用 Cocos Creator 内置的物理引擎(如 Box2D 或 Cannon.js)。我们通过计算方块未来位置的坐标,判断其是否超出边界或与已有方块重叠即可。这比使用物理引擎性能更高,也更可控。
  • 动画系统:缓动动画 vs 帧动画:对于方块移动、消行等效果,使用 Cocos Creator 的tween系统做缓动动画就足够了,性能好且易于控制。对于复杂的角色动画,才会考虑使用 Animation 组件。

设计心得:在项目初期,我常常会画一张简单的模块依赖图。明确GameController依赖GameModelGameView,而GameModelGameView之间不应该直接通信。这个简单的约束能避免代码后期变成一团乱麻。

3. 核心模块实现细节与避坑指南

接下来,我们深入到各个核心模块,看看具体怎么实现,以及会遇到哪些“坑”。

3.1 游戏网格(Grid)与方块(Tetromino)的数据建模

这是整个游戏的基石,一定要设计得健壮且高效。

网格(Grid)的数据结构: 我们用一个二维数组grid: number[][]来表示。数组的维度是[行][列]。我习惯将[0][0]定义为网格左上角。每个单元格的值代表其状态:0表示空,1-7分别代表 7 种不同颜色和形状的方块(如 I, J, L, O, S, T, Z)。

// 在 GameModel 中 export class GameModel { private _grid: number[][]; readonly ROWS = 20; readonly COLS = 10; constructor() { this.clearGrid(); } clearGrid(): void { this._grid = []; for (let r = 0; r < this.ROWS; r++) { this._grid[r] = new Array(this.COLS).fill(0); // 初始化为0(空) } } // 获取和设置格子值的方法 getCell(row: number, col: number): number { if (row >= 0 && row < this.ROWS && col >= 0 && col < this.COLS) { return this._grid[row][col]; } // 对于超出上边界的行,认为是空的。对于超出左右下边界的,认为是墙。 // 具体处理取决于碰撞检测逻辑,这里简单返回 -1 表示无效或墙。 return -1; } setCell(row: number, col: number, value: number): void { if (row >= 0 && row < this.ROWS && col >= 0 && col < this.COLS) { this._grid[row][col] = value; } } }

方块(Tetromino)的形状定义: 经典的 7 种方块,每种有 1-4 种旋转状态。最直观的方法是用一个三维数组来定义。

// Constants.ts 或 TetrominoDefs.ts export const TETROMINOS = { I: [ [[0,0,0,0], [1,1,1,1], [0,0,0,0], [0,0,0,0]], [[0,0,1,0], [0,0,1,0], [0,0,1,0], [0,0,1,0]], // ... 其他两种旋转状态 ], J: [ [[1,0,0], [1,1,1], [0,0,0]], // ... 其他旋转状态 ], // ... 定义 L, O, S, T, Z }; export type TetrominoType = keyof typeof TETROMINOS; // 'I' | 'J' | 'L' | 'O' | 'S' | 'T' | 'Z'

GameModel中,我们维护一个当前方块对象,包含其类型、旋转状态、在网格中的坐标(通常是其形状矩阵的左上角或中心点在网格中的位置)。

避坑指南 1:坐标原点的选择。定义方块形状时,要明确其“局部坐标系”的原点。通常选择形状矩阵的左上角作为原点,这样方块的位置(x, y)就代表其左上角在网格中的坐标。但有些旋转算法(如“墙踢” Wall Kick)可能围绕一个中心点旋转,这时原点可能需要定义在形状内部。我建议初学者先从左上角原点开始,逻辑更直观。

避坑指南 2:O 方块的旋转。O 方块(正方形)旋转后形状不变。很多新手在实现旋转逻辑时,会忘记处理 O 方块这个特例,导致不必要的计算或错误。在旋转函数里,可以首先判断if (type === 'O') return;

3.2 游戏主循环与状态管理

游戏主循环驱动着方块自动下落、游戏状态更新。在 Cocos Creator 中,我们有多种选择:

  1. 使用update(dt: number)方法:这是最常用的方式。在GameController的组件中启用update,利用dt(距离上一帧的时间,单位秒)来累计时间,控制方块下落间隔。

    export class GameController extends Component { private _fallTimer: number = 0; private _fallInterval: number = 1.0; // 初始下落间隔,单位秒 update(dt: number): void { if (this._gameState !== GameState.Playing) return; this._fallTimer += dt; if (this._fallTimer >= this._fallInterval) { this._fallTimer = 0; this.moveCurrentPieceDown(); // 尝试将当前方块向下移动一格 } // ... 其他更新,如处理输入 } }
  2. 使用schedule定时器:可以更精确地控制固定时间间隔的任务。例如,this.schedule(this.moveDown, this._fallInterval);。但要注意在游戏暂停、结束时正确取消调度。

游戏状态管理: 定义一个枚举来清晰管理游戏状态,避免用一堆布尔变量isPaused,isGameOver导致逻辑混乱。

export enum GameState { Idle, // 初始,未开始 Playing, // 进行中 Paused, // 暂停 GameOver, // 结束 ClearingLines, // 正在消除行(播放动画) }

GameController中维护一个_currentState: GameState。任何操作(开始、暂停、移动方块)前,都先检查当前状态是否允许该操作。

实操心得:在update中,我习惯将“时间累积”和“逻辑更新”分开。比如,_fallTimer累积时间,当达到间隔时,才执行moveDown逻辑。这样即使帧率有波动,方块的下落速度也是稳定的。千万不要在update里直接写piece.y -= speed * dt然后立刻做碰撞检测,这会导致下落不跟手,且碰撞检测时机难以控制。

3.3 输入处理与方块操控

我们需要响应键盘事件来控制方块。Cocos Creator 提供了systemEvent.on来监听全局键盘事件。

import { input, Input, KeyCode } from 'cc'; export class GameController extends Component { onLoad(): void { // 监听键盘按下事件 input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this); } onDestroy(): void { // 别忘了移除监听! input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this); } private onKeyDown(event: EventKeyboard): void { if (this._currentState !== GameState.Playing) return; switch(event.keyCode) { case KeyCode.ARROW_LEFT: this.moveCurrentPieceLeft(); break; case KeyCode.ARROW_RIGHT: this.moveCurrentPieceRight(); break; case KeyCode.ARROW_DOWN: this.moveCurrentPieceDown(); // 手动加速下落 break; case KeyCode.ARROW_UP: case KeyCode.KEY_W: this.rotateCurrentPiece(); break; case KeyCode.SPACE: this.hardDrop(); // 一键硬降到底 break; case KeyCode.KEY_P: this.togglePause(); break; } } }

移动与旋转的逻辑实现: 以moveLeft为例,其核心是“先验证,后执行”。

private moveCurrentPieceLeft(): boolean { // 1. 根据当前方块数据,计算左移一格后的虚拟位置 const virtualPos = { x: this._currentPieceX - 1, y: this._currentPieceY }; // 2. 调用碰撞检测函数,判断虚拟位置是否合法 if (this._gameModel.isValidPosition(this._currentPieceType, this._currentPieceRotation, virtualPos)) { // 3. 如果合法,更新模型中的数据 this._currentPieceX = virtualPos.x; // 4. 通知视图更新 this._gameView.updateCurrentPieceDisplay(); return true; } return false; // 移动失败 }

isValidPosition函数是核心中的核心。它需要遍历方块形状矩阵中所有为 1 的格子,加上偏移量(virtualPos.x, virtualPos.y),计算出每个格子在网格中的坐标,然后检查:

  1. 是否超出网格左右边界 (col < 0 || col >= COLS)。
  2. 是否超出网格底部边界 (row >= ROWS)。
  3. 是否与网格中已有方块重叠 (grid[row][col] !== 0)。

避坑指南 3:输入延迟与重复触发。直接使用KEY_DOWN事件,当你长按方向键时,会先触发一次,然后有短暂的停顿,再开始连续触发。这不符合游戏操作习惯。更好的做法是:在update中检测按键状态。Cocos Creator 的input.getKeyState(KeyCode.ARROW_LEFT)可以获取按键是按下还是抬起。然后你可以自己实现一个计时器,实现“长按连续触发”的效果:首次按下立即响应,按住超过一定时间(如0.2秒)后,以固定间隔(如0.05秒)连续触发移动。

3.4 碰撞检测、消行与分数计算

碰撞检测:上面已经提到了isValidPosition。这里补充一个技巧:对于“旋转”操作,除了检查旋转后的位置是否合法,还需要实现“墙踢”机制。即当旋转后卡在墙或方块里时,尝试将方块向左、右、上微调一个单位,如果某个微调位置合法,就允许旋转并移动到这个位置。这是现代俄罗斯方块的标准规则,能让游戏体验更流畅。墙踢的偏移量数据是固定的,可以查表实现。

消行逻辑

  1. 当方块落地固定后,遍历网格的每一行。
  2. 如果某一行所有格子都不为0,则该行已满。
  3. 记录所有满行的索引。
  4. 从下往上,将满行上方的所有行整体向下移动一行(或从最底部的满行开始,将其上方的所有行数据向下覆盖)。
  5. 在最顶部补充新的空行。
  6. 根据一次消除的行数(1, 2, 3, 4),计算得分(通常行数越多,单行得分倍数越高)。
private clearLines(): number { let linesCleared = 0; const linesToClear: number[] = []; // 找出所有满行 for (let row = 0; row < this.ROWS; row++) { let isFull = true; for (let col = 0; col < this.COLS; col++) { if (this._grid[row][col] === 0) { isFull = false; break; } } if (isFull) { linesToClear.push(row); } } linesCleared = linesToClear.length; if (linesCleared === 0) return 0; // 从下往上处理,避免行索引变动影响 linesToClear.sort((a, b) => b - a); // 降序排列 for (const rowIndex of linesToClear) { // 将 rowIndex 上方的所有行下移 for (let r = rowIndex; r > 0; r--) { for (let c = 0; c < this.COLS; c++) { this._grid[r][c] = this._grid[r-1][c]; } } // 清空最顶行 for (let c = 0; c < this.COLS; c++) { this._grid[0][c] = 0; } } return linesCleared; }

分数与等级计算: 分数通常与消除的行数和当前等级有关。等级随着消除的总行数增加而提高,等级越高,方块下落速度 (_fallInterval减小) 越快,挑战性越大。这是一个经典的正反馈循环。

private addScore(lines: number): void { const baseScore = [100, 300, 500, 800]; // 1,2,3,4 行的基础分 this._score += baseScore[lines - 1] * (this._level + 1); this._totalLinesCleared += lines; // 每清除10行升一级 const newLevel = Math.floor(this._totalLinesCleared / 10); if (newLevel > this._level) { this._level = newLevel; this._fallInterval = Math.max(0.05, this._initialFallInterval - this._level * 0.05); // 设置速度上限 } // 更新UI this._gameView.updateScoreAndLevel(this._score, this._level); }

4. 表现层实现与性能优化

游戏逻辑跑通了,接下来要让它在屏幕上漂亮地跑起来。

4.1 网格与方块的渲染

我们不会为 10x20=200 个格子创建 200 个静态节点。更高效的做法是使用节点池(NodePool)

  1. 创建格子预制体(Prefab):在编辑器中制作一个方块格子的预制体,包含一个Sprite组件,用于显示颜色。
  2. 初始化节点池:游戏开始时,预先实例化一定数量(如 250 个)的格子节点放入池中。
  3. 渲染网格:遍历GameModel中的_grid数据,对于每个非空单元格,从池中取出一个节点,设置其纹理(根据方块类型)、位置(根据行列索引计算),然后添加到网格容器节点下。
  4. 渲染当前方块:同样,根据当前方块的类型、旋转状态和位置,从池中取出 4 个节点,设置好颜色和位置,添加到一个“当前方块”容器节点下。
  5. 更新渲染:当方块移动或网格变化时,不需要销毁节点,只需将相关节点还回池中,再根据新数据重新取出和设置。这比反复创建销毁节点性能高得多。
export class BlockPoolManager { private _blockPool: NodePool; private _blockPrefab: Prefab; init(prefab: Prefab): void { this._blockPrefab = prefab; this._blockPool = new NodePool('Block'); // 预创建节点 for (let i = 0; i < 250; i++) { let blockNode = instantiate(prefab); this._blockPool.put(blockNode); } } getBlock(): Node { let blockNode: Node; if (this._blockPool.size() > 0) { blockNode = this._blockPool.get(); } else { blockNode = instantiate(this._blockPrefab); } // 初始化节点状态(如重置位置、缩放等) blockNode.setPosition(Vec3.ZERO); return blockNode; } putBlock(blockNode: Node): void { this._blockPool.put(blockNode); } }

GameView中,维护两个Node容器:gridContainercurrentPieceContainer。更新视图时,先清空容器(将节点还回池),再根据最新数据从池中取节点重新组装。

4.2 UI 界面搭建

使用 Cocos Creator 的 UI 系统来构建游戏界面非常方便。

  • Canvas作为根节点,设置合适的适配策略(如Fit HeightFit Width)。
  • 使用Widget组件将分数、等级、下一个方块预览面板等元素锚定到屏幕合适位置。
  • 使用Layout组件(如Grid Layout)可以轻松排列“下一个方块预览”中的格子。
  • 按钮使用Button组件,并绑定GameController中的对应方法。

性能提示:对于需要频繁更新的文本(如分数),将其Label组件的Cache Mode设置为CHAR模式,可以提升文本更新性能,特别是在 Web 和小游戏平台。

4.3 动画与特效

适当的动画能极大提升游戏体验。

  1. 方块移动动画:当方块左右移动或旋转时,可以使用tween做一个短暂的位移或旋转缓动,而不是瞬间跳变。
    tween(blockNode) .to(0.05, { position: new Vec3(targetX, targetY, 0) }, { easing: 'quadOut'}) .start();
  2. 消行动画:消行时,可以让被消除的行闪烁几下再消失。可以通过tween重复改变该行所有格子的透明度或颜色来实现。
  3. 粒子特效:在方块落地或消行时,可以播放简单的粒子特效。Cocos Creator 的ParticleSystem组件很容易实现。但要注意控制粒子数量,避免性能问题。
  4. 声音效果:使用AudioSource组件播放移动、旋转、消行、落地等音效。建议使用一个统一的AudioManager来管理,避免多个音效同时播放造成冲突。

5. 高级功能扩展与打包发布

基础功能完成后,可以考虑添加一些增强功能。

5.1 “下一个方块”预览

GameModel中,不仅维护当前方块,还维护一个“下一个方块”对象。当当前方块落地后,“下一个方块”变为当前方块,并随机生成一个新的“下一个方块”。在GameView中,用一个独立的区域来渲染这个预览方块。这能增加游戏的策略性。

5.2 游戏暂停与继续

实现暂停时,除了切换GameState,还要注意:

  • 停止游戏主循环(在update中根据状态判断)。
  • 停止所有正在进行的tween动画。
  • 暂停背景音乐(但通常保留音效设置?看设计)。
  • 显示暂停菜单界面。

5.3 数据持久化

使用localStorage保存最高分。

// DataManager.ts saveHighScore(score: number): void { try { sys.localStorage.setItem('tetris_high_score', score.toString()); } catch (e) { console.warn('Failed to save high score:', e); } } loadHighScore(): number { try { const score = sys.localStorage.getItem('tetris_high_score'); return score ? parseInt(score, 10) : 0; } catch (e) { console.warn('Failed to load high score:', e); return 0; } }

5.4 打包与发布

Cocos Creator 3.X 的构建发布面板非常强大。

  • Web 平台:这是最简单的。在构建发布面板选择Web Mobile,配置好TitleStart Scene,点击构建即可。你可以将构建出的文件部署到任何静态服务器。
  • 微信小游戏:需要先安装并配置好微信开发者工具。在 Cocos Creator 的构建发布面板选择WeChat Game,填入小游戏 AppID。构建完成后,用微信开发者工具打开生成的项目目录即可进行预览和上传。

    注意:微信小游戏环境有代码包大小限制(最初 4MB,可通过分包扩展)。注意优化资源,纹理使用压缩格式,并合理规划分包。

  • 打包为单 HTML 文件:Cocos Creator 本身不直接支持输出为单个 HTML 文件。但可以通过一些构建配置和后期脚本,将资源内联或 Base64 编码,最终合并成一个文件。不过这会显著增大文件体积,并可能带来加载和兼容性问题,一般用于特定演示场景,生产环境不推荐。

5.5 常见问题排查

  • 编辑器报错:cannot read property 'uuid' of null:这通常是资源引用丢失导致的。检查你的 Prefab、Texture 等资源是否被正确导入,并且在属性检查器中引用的路径是否有效。尝试在资源管理器中定位到红色报错的资源,重新选择或导入。
  • 游戏在手机上运行卡顿
    1. 检查 Draw Call:在构建发布项目设置中,开启动态合图。确保使用的纹理图集尽可能少,将多个小图打包成一张大图(Sprite Atlas)。
    2. 减少节点数量:务必使用节点池管理方块格子节点。
    3. 优化 JavaScript/TypeScript:避免在update中频繁创建临时对象(如new Vec3),可以将常用对象缓存起来复用。
    4. 使用性能分析工具:Cocos Creator 编辑器自带的性能分析工具和 Chrome DevTools 的 Performance 面板是定位性能瓶颈的利器。
  • 方块旋转或移动时闪烁:这通常是渲染顺序问题。确保你的gridContainercurrentPieceContainerzIndex或节点顺序正确,当前方块容器应在网格容器之上。另外,更新视图时,确保是“先还旧节点入池,再取新节点设置”的原子操作,避免中间状态被看到。

6. 项目总结与进阶思考

走完整个开发流程,你会发现一个看似简单的《俄罗斯方块》,涉及了游戏循环、状态机、数据建模、碰撞检测、资源管理、UI 系统、动画、音频、数据持久化、性能优化和跨平台发布等几乎全部核心知识点。用 Cocos Creator 3.X 实现它,是对引擎能力一次非常全面的实践。

我个人在多次实现这类经典游戏后,最大的体会是:前期花在架构设计上的时间,后期会加倍地省回来。清晰的模块边界能让调试变得异常轻松。比如,当旋转逻辑出现问题时,我只需要关注GameModel.isValidPosition这个函数和方块形状的定义,完全不用管画面是怎么渲染的。

这个项目还有很大的扩展空间,你可以尝试:

  • 实现“保持(Hold)”功能:让玩家可以暂存一个当前方块。
  • 添加更多方块类型:设计新的异形方块。
  • 引入攻击与对战模式:实现像《Tetris 99》那样的多人对战,消行会给对手增加垃圾行。
  • 优化视觉效果:使用 Shader 实现方块格子的流光、阴影、镜面等高级效果(就像热词里提到的“会卷边的贴纸shader”,虽然那可能更复杂)。
  • 接入游戏服务:接入排行榜、成就系统。

最后,记得将你的代码托管到 GitHub,写好README.md,这既是一个备份,也是你技术能力的最好证明。希望这个详细的指南能帮你少走弯路,顺利打造出属于自己的《俄罗斯方块》。如果在实现过程中遇到任何具体问题,欢迎在社区交流,很多时候,一个看似棘手的问题,可能就是一两行代码的顺序或者一个参数的设置问题。

← 返回列表