简介:基于Cocos Creator 3.8开发的俄罗斯方块Demo项目,面向刚接触该引擎的开发者,可作为2D游戏逻辑与场景搭建的入门参考。压缩包仅213KB,共收录67个文件,涵盖TypeScript脚本、预制体、场景、PNG图片以及大量meta与json配置,完整保留了编辑器所需的元数据和项目设置。与其他精简示例不同,该项目额外提供.gitignore、tsconfig.json、package.json及profiles等工程化文件,便于理解版本管理、TypeScript编译与项目依赖;assets目录按场景、脚本和资源划分清晰,方便按模块查阅。Demo实现了俄罗斯方块的核心玩法和界面资源,开发者可借此学习组件挂载、预制体复用、场景组织方式,并掌握该类小型游戏从资源准备到逻辑驱动的完整流程。目前已有170人学习下载,适合希望快速上手Cocos Creator 3.8并参考完整小型项目结构的开发者。
1. 俄罗斯方块在 3.8 里为什么值得重写一遍:不只是情怀,是坐标系和状态机的实战
很多人看到“Cocos Creator 3.8 俄罗斯方块 Demo”,第一反应是“这有什么好写的,三年前的老古董了”。但真把这个项目下载下来跑一遍,你会发现俄罗斯方块是少有的、能把一个游戏引擎的坐标体系、渲染批次、生命周期和输入分发全部串起来的教学样本,而 3.8 的坐标系和 2.x 相比,改动大到能让你翻车三次。这个 Demo 的作用不是让你复刻一个经典玩法,而是让你在 3.8 的 Component 体系下,亲手把“方块形状定义、旋转矩阵、碰撞检测、消行判定、触顶结束”这一整条状态链路走通。对于正在从 Cocos Creator 2.x 迁移到 3.8、或者第一次接触组件化游戏开发的开发者,它是一份能直接跑的参考答案。文中所有实现都基于 3.8 的 Vec3 坐标系、UITransform 组件和节点树结构,下文从工程结构开始拆,每个模块都给代码和改法。
2. 工程结构与架构设计:场景、脚本和资源是怎么分层拆的
2.1 场景树的搭建思路和节点层级拆解
先打开场景文件,能看到这个 Demo 的节点树非常克制,一共五个层级,没有冗余挂载。最外层是 Canvas,下面依次是 GameRoot、Board、ShapeLayer、NextPreview 和 UI 层。这种分层的核心思路是:逻辑节点、渲染节点、UI 节点完全分离,避免后续改界面时动到业务代码。
GameRoot 挂了游戏主控脚本,负责状态流转;Board 是棋盘背景,挂了颜色渲染组件但没有任何逻辑;ShapeLayer 是所有活动方块和落地方块的统一父节点,这个设计在消行时非常重要——你只需要遍历 ShapeLayer 的子节点就能拿到全棋盘上的方块。NextPreview 是下一块预览区域,独立渲染,不参与主棋盘坐标计算。UI 层的分数和开始按钮单独挂在 Canvas 下面。
这里有个 3.8 特有的地方:节点坐标全部用 Vec3,不再有 2.x 的 Vec2 接口,所有涉及 setPosition 的地方都要传三个参数。另外 3.8 的 UITransform 组件接管了节点的尺寸和锚点,以前能直接通过 node.width 读取的宽高,现在必须通过 node.getComponent(UITransform).width 来拿,这个改动在计算棋盘格子时是第一个坑。
2.2 脚本目录和责任划分
脚本目录不像大项目那样按 UI、数据、工具分层,而是按“游戏状态”组织。根目录下放 GameController.ts 作为总控,BoardManager.ts 负责棋盘矩阵和消行,Block.ts 负责单块行为,ShapeFactory.ts 负责七种方块的形状数据生成,InputHandler.ts 单列出来处理键盘和触摸输入。
文件分工:
| 脚本文件 | 职责 | 核心对外接口 |
|---|---|---|
| GameController.ts | 游戏主循环、状态切换、分数统计 | startGame(), gameOver(), addScore() |
| BoardManager.ts | 20x10 矩阵维护、消行判定、边界碰撞检测 | canMove(), mergeToBoard(), clearRows() |
| ShapeFactory.ts | 生成 7 种方块(I/O/T/L/J/S/Z)的二维数组和颜色 | createShape(type), getNext() |
| Block.ts | 单一方块节点的生成移动旋转,本质是节点的坐标映射器 | moveDown(), rotate(), getBoundingBox() |
| InputHandler.ts | 键盘事件和触摸滑动的统一分发 | onKeyDown, onSwipe |
这个分层的好处是,改玩法只需要动 GameController,改消行算法只动 BoardManager,改输入方式只动 InputHandler,没有交叉依赖。后面做二次开发时会知道这个结构省了多少事。
3. 核心模块拆解:一个方块从生成到落地的完整生命周期
3.1 ShapeFactory:七种方块的形状定义与旋转中心
俄罗斯方块的所有玩法都建立在形状定义上,ShapeFactory 是整个项目的起点。每个方块用一个二维数组表示,1 代表有格子,0 代表空。I 块的形状定义:
// ShapeFactory.ts const SHAPES = { I: { matrix: [ [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0] ], color: new Color(0, 240, 255, 255) }, O: { matrix: [ [1, 1], [1, 1] ], color: new Color(255, 255, 0, 255) }, T: { matrix: [ [0, 1, 0], [1, 1, 1], [0, 0, 0] ], color: new Color(128, 0, 255, 255) } // ... 其余 L/J/S/Z 同理 };为什么用 4x4 或 3x3 的二维数组而不是直接存坐标点?答案是旋转算法的统一性。二维数组可以用“转置加行反转”实现顺时针旋转,不需要为每种方块单独写旋转规则。I 块用 4x4 矩阵是为了让旋转中心保持在几何中心,T 块用 3x3 是为了让旋转时的偏移量最小化。这是从经典版本继承下来的做法,实测在 3.8 里仍然是最稳定的方案。
生成的逻辑也简单:在 createShape 里根据传入的类型从 SHAPES 里取矩阵,然后遍历矩阵,把值为 1 的位置创建一个小方块节点,挂在传入的父节点下。每个小方块的尺寸是 30x30,通过 UITransform 设置,锚点全部设成 (0.5, 0.5),这样旋转时能围绕中心点。
3.2 Block 的移动与旋转:Vec3 坐标系下的偏移算法
Block 类负责把 ShapeFactory 生成的矩阵变成节点树上的实际节点。它的关键设计是“双坐标系”思维:逻辑上,方块的位置用行列表示,比如 (x=3, y=18) 表示在棋盘第 3 列、第 18 行;物理上,节点的位置用 Vec3 表示。每次移动或旋转,都先改逻辑坐标,再同步到物理坐标。
// Block.ts public move(offsetX: number, offsetY: number): boolean { const targetX = this.gridX + offsetX; const targetY = this.gridY + offsetY; if (this.board.canMove(this.matrix, targetX, targetY)) { this.gridX = targetX; this.gridY = targetY; this.syncPosition(); return true; } return false; } public rotate(): boolean { const rotated = this.rotateMatrix(this.matrix); if (this.board.canMove(rotated, this.gridX, this.gridY)) { this.matrix = rotated; this.rebuildChildren(); return true; } return false; } private syncPosition(): void { const offsetX = this.gridX * BOARD_BLOCK_SIZE; const offsetY = this.gridY * BOARD_BLOCK_SIZE; this.node.setPosition(new Vec3(offsetX, offsetY, 0)); }参数说明:move 方法的 offsetX 和 offsetY 是行列偏移量,不是像素偏移。board.canMove 检查目标位置是否合法,这是防止方块穿墙和重叠的唯一入口。syncPosition 里把行列坐标乘以格子尺寸映射到像素坐标——这里 3.8 的关键点出现了:setPosition 必须传 Vec3 对象,不能像 2.x 那样传两个数字。
rotateMatrix 的实现是经典的三步操作:先对角线翻转,再逐行反转。这个方法不依赖矩阵大小,3x3 和 4x4 都能正确处理。rebuildChildren 是先删除旧的小方块节点再按新矩阵重新生成,比原地更新每个格子的位置更简单,代价是性能略差,但在俄罗斯方块这种低频操作里完全够用。
有一个细节值得注意:旋转前不尝试左右微调。经典街机版俄罗斯方块有墙踢(Wall Kick)系统,但这里是简化实现。这个 Demo 里如果方块靠近左墙且旋转后超出边界,旋转会被拒绝而不是纠正——这是刻意保留的简化,后面二次开发章节会给出升级方案。
3.3 BoardManager:20x10 矩阵的碰撞检测和消行动作
棋盘矩阵是 BoardManager 维护的一张 20 行 x 10 列的二维数组,初始全部置 0。每次方块移动或旋转前,都会把“目标矩阵 + 目标坐标”映射到棋盘矩阵上检查冲突。这个检查逻辑是整个游戏性能的瓶颈,写不好会在满屏方块时出现明显掉帧。
// BoardManager.ts public canMove(matrix: number[][], gridX: number, gridY: number): boolean { for (let row = 0; row < matrix.length; row++) { for (let col = 0; col < matrix[row].length; col++) { if (matrix[row][col] === 0) continue; const boardX = gridX + col; const boardY = gridY + row; if (boardX < 0 || boardX >= BOARD_COLS) return false; if (boardY < 0) return false; if (this.board[boardY][boardX] !== 0) return false; } } return true; }参数说明:matrix 是方块当前形状的二维数组,gridX 和 gridY 是方块左上角在棋盘中的行列号。这个双重循环实际遍历的是方块的有效格子,不是整个棋盘。boardY < 0 的检查意味着方块可以出现在棋盘顶部之外——这是俄罗斯方块的通用规则,新方块生成时是部分悬空在棋盘外的。
落地判定发生在 GameController 中:当方块无法继续向下移动时,调用 mergeToBoard 把方块矩阵写进棋盘矩阵,然后调用 clearRows 检查并消除满行。消行算法的实现是检测每一行是否所有格子都非 0,如果满了就把它从数组中移除,然后在数组头部补一个空行:
// BoardManager.ts public clearRows(): number { let cleared = 0; for (let row = 0; row < this.board.length; row++) { if (this.isRowFull(row)) { this.board.splice(row, 1); this.board.unshift(new Array(BOARD_COLS).fill(0)); cleared++; row--; } } return cleared; }这个实现方式是数组级消行:用 splice 删除满行,用 unshift 在顶部补空行。它比逐格移动上方方块的做法快得多,代码也更短。unshift 回来的空行会造成棋盘内方块整体平移到下一帧——这是一个需要处理的时序问题:必须等方块落定、merge 完成后才能调用 clearRows,否则会出现你看着方块已经落地了,但分数还没加、行还没消的延迟感。
3.4 GameController:游戏主循环和触顶即结束的状态控制
GameController 是整个 Demo 的心脏,它不关心方块的具体坐标,只维护一个状态机:READY、PLAYING、PAUSED、GAMEOVER。核心是用定时器驱动下落,而不是用 update 生命周期里的帧率驱动。
// GameController.ts private gameTimer: schedule | null = null; private readonly DROP_INTERVAL = 0.8; public startGame(): void { this.boardManager.reset(); this.boardManager.createBorder(); this.score = 0; this.currentBlock = this.shapeFactory.getNext(); this.nextBlock = this.shapeFactory.getNext(); this.updatePreview(); this.schedule(this.gameTick, this.DROP_INTERVAL); } private gameTick(): void { if (!this.currentBlock.move(0, -1)) { this.boardManager.mergeToBoard(this.currentBlock); const cleared = this.boardManager.clearRows(); this.addScore(cleared); this.currentBlock = this.nextBlock; this.nextBlock = this.shapeFactory.getNext(); if (!this.boardManager.canMove( this.currentBlock.matrix, this.currentBlock.gridX, this.currentBlock.gridY )) { this.gameOver(); } } }参数说明:DROP_INTERVAL 是每次自动下落的时间间隔,单位是秒。schedule 按固定间隔调用 gameTick,这是比每帧判断累积时间更稳的方式,不会因为帧率波动导致方块下落速度不稳定。addScore 根据一次消除的行数给分——消除 1 行 100 分,2 行 300 分,3 行 500 分,4 行 800 分,这是对所有选手都熟悉的计分曲线。
触顶结束的实现值得多看两行:在生成新方块之后、立即检查新方块的初始位置能不能放得下,放不下就 gameOver。这个检查放在生成后而不是移动前,是为了避免一个视觉 Bug——如果新方块生成在半空、又移动了两步后才触发结束,玩家会觉得游戏明明还能玩就被判死了。游戏结束后要调用 unschedule 停止定时器,并用 Director 的重启机制复位整个场景,这样点重新开始按钮时节点状态是干净的。
4. 运行与调试:在 3.8 编辑器里把这套 Demo 真正跑起来
4.1 从源码导入到场景跑通的完整流程
拿到这份资源后第一件事不是看代码,而是先在 Cocos Dashboard 里创建项目。选 3.8 版本,注意项目创建时如果选了 3.7,节点组件的 API 会和 3.8 有一点差异,代码大概率编译不过。创建好项目后把源码包的 assets、settings、package.json 这些目录直接覆盖到新项目里,再回到编辑器打开场景。
打开后如果看到报错,绝大多数是脚本挂载丢失。检查 GameRoot 节点上是否挂了 GameController 脚本、Board 节点上是否挂了 BoardManager、Canvas 子节点上是否挂了 InputHandler。这个 Demo 的脚本绑定关系是手工维护的,覆盖文件时不会自动恢复,需要你重新把脚本组件拖到对应节点上。
资源导入后,点编辑器顶部的运行预览,浏览器会直接拉起游戏画面。如果看到棋盘出现但方块不落,或者在浏览器控制台看到 schedule 相关的报错,基本是脚本没挂成功。一个有效的验证方法:点击开始后看到分数变为 0,并且方块开始自动下落,说明脚本、场景和资源三层都通了。
4.2 微信小游戏预览和真机调试的注意点
3.8 跑微信小游戏比 2.x 简单,但有几处必须手工处理。首先在构建发布面板选择“微信小游戏”平台,初始设置保持默认就行。构建完成后用微信开发者工具打开 build/wechatgame 目录,然后需要在开发者工具里开启“不校验合法域名”选项——这个项目没有用任何网络请求,不开也能跑,但开了能省去可能的报错干扰。
真正容易翻车的是屏幕适配。模拟器里看起来正常的布局,到 iPhone 或安卓真机上会出现棋盘偏移或者按钮点不到,原因是 Canvas 的适配模式没有配置。在 Canvas 节点的 cc.Canvas 组件上,把 Design Resolution 设置为 720x1280,适配策略选 SHOW_ALL,这样固定画布设计尺寸,节点位置按设计坐标映射到真实屏幕,能解决 90% 的界面错位问题。
如果要在真机上看帧率和水晶球,打开微信开发者工具的真机调试,二维码识别后手机就能看到完整运行效果。3.8 的调试工具在编辑器里默认开启 Profiler,真机上的性能数据可以直接在开发者工具的调试器里查看。
4.3 浏览器预览时的输入映射和调试技巧
浏览器跑起来后,方块操作绑定在键盘上:左方向键左移,右方向键右移,上方向键旋转,下方向键加速下落,空格键直接落底。InputHandler 在浏览器环境用的是事件监听,这个入口在真机上无效,真机的输入全部走触摸滑动。
所以调试时建议准备两个分支:桌面预览用键盘走事件系统,微信小游戏预览用触摸坐标差识别方向。InputHandler 里做一次环境判断,用系统方法判断是否运行在小游戏环境,再决定启用哪套输入接口。这样能保证你不会在第一种运行方式下改好代码,切到第二种时发现按键完全无效。
遇到“方块卡在顶部一直下落不了”的情况,先别急着改算法,在浏览器控制台输入 gameController.getTimer() 看看定时器是否还在跑,如果返回 null,多半是 startGame 没有被调用,或者 startGame 里的 schedule 被 clearSchedule 误清了。
5. 常见问题排查与避坑:3.8 特有的坐标系和时序问题
5.1 节点坐标偏移了半格,方块看起来在格线交叉点上
现象:方块对齐游戏棋盘时,总有一半的格子是偏的,或者说方块明明在棋盘外却显示在棋盘边缘里面一点。
原因:创建小方块时锚点设置不对。这个 Demo 里所有小方块锚点是 (0.5, 0.5),而棋盘背景的锚点是 (0, 0),两种锚点混用时,行列坐标乘格子尺寸再直接设置节点位置,就会产生半格偏移。
解决:统一锚点策略。棋盘背景的 UITransform 锚点也改成 (0.5, 0.5),再把棋盘节点的位置向左下角做一次偏移修正:node.setPosition(Vec3(-BOARD_COLS * BLOCK_SIZE / 2, -BOARD_ROWS * BLOCK_SIZE / 2, 0))。这样棋盘中心在原点,方块从中心往上布局,视觉上完全对齐。
5.2 旋转时方块穿墙或者“瞬移”
现象:方块靠墙时按旋转键,方块直接跑到了墙外面,或者出现在一个完全不在预期位置的地方。
原因:碰撞检测在旋转时检查的是旋转后的矩阵和当前坐标的匹配,但没有做墙踢处理。当矩阵形状变化后,原来能放下的位置可能不再适合,但代码只拒绝放置而不会尝试左右平移来寻找合法位置。
解决:在 rotate 方法里加一个墙踢循环,试着依次检查当前 x-1、x+1、x-2、x+2 的位置,哪个能放下就用哪个。这是标准俄罗斯方块的墙踢实现,代码量不大,但对操作手感提升非常明显:
// Block.ts 墙踢实现 public rotateWithWallKick(): boolean { const rotated = this.rotateMatrix(this.matrix); if (this.board.canMove(rotated, this.gridX, this.gridY)) { this.matrix = rotated; this.rebuildChildren(); return true; } for (const kick of [[2, 0], [-2, 0], [1, 0], [-1, 0]]) { const kickX = this.gridX + kick[0]; if (this.board.canMove(rotated, kickX, this.gridY + kick[1])) { this.gridX = kickX; this.gridY = this.gridY + kick[1]; this.matrix = rotated; this.rebuildChildren(); return true; } } return false; }参数说明:kick 数组的按“偏移距离”优先级排列。先试偏移 2,再试偏移 1,这个顺序是踩坑总结出来的,街机版参考了经典 Arika 规则,实际测试下来手感最接近原版。
5.3 游戏结束后按开始没反应
现象:游戏显示 Game Over 后,点击开始按钮没有任何反馈,事件监听似乎失效了。
原因:输入事件被 UI 层挡住了。游戏结束时 GameController 会切换状态,但 InputHandler 里的按钮注册是独立于状态机的,当开始按钮所在节点被其他 UI 节点遮挡,触摸事件就接收不到了。
解决:检查 Game Over 弹出层是否有按钮拦截。最保险的改法是开始按钮不走事件注册,而是直接在 Canvas 的 UITransform 层用 Button 组件的点击事件绑定 GameController.startGame,全程由引擎的事件系统接管,不依赖自定义的 TOUCH_END 监听。
5.4 消行后上方方块没有下落,只是消失了一行
现象:满行消掉后,棋盘纸面上那一行的格子空了,但上方的方块没有整体下落,视觉上是直接少了一行,悬浮感明显。
原因:mergeToBoard 之后 clearRows 里的 unshift 只在逻辑数组里补了空行,还没有把逻辑矩阵的变化同步到渲染节点。
解决:在 clearRows 后强制刷新全部方块节点位置。用 shapeLayer 的所有子节点,根据它们在棋盘矩阵里的新行列重新计算像素坐标,更新节点位置。这段刷新在 GameController 的 gameTick 里调用,放在 addScore 之后:
// GameController.ts private refreshBoardDisplay(): void { const children = this.shapeLayer.children; for (let child of children) { const block = child.getComponent(Block); if (block) { block.syncPosition(); } } }注意:refreshBoardDisplay 要在 mergeToBoard 之后、下一次方块移动之前执行,否则会出现“看到一格方块悬空在半空”的瞬时帧。触发方式是把这次刷新放在同一个 gameTick 同步处。
5.5 屏幕分辨率不同,棋盘被截断或太靠上
现象:在 iPad 或宽屏显示器上预览,棋盘只显示一半,或者棋盘顶部超出屏幕。
原因:Canvas 适配策略不合适。Native 预览的显式分辨率没有按照设计分辨率设置,SHOW_ALL 或固定高度策略与不同宽高比设备不兼容。
解决:把 Canvas 的 Design Resolution 设置成 720x1280 并选择 SHOW_ALL 是治标方案,治本方案是让棋盘布局不依赖全局坐标,用 UITransform 的矩形定位。在 Block 的计算里,把棋盘的原点相对键盘位置设定为节点的父级 anchor 所在位置,这样不管屏幕怎么裁剪,棋盘都在视觉中心。
6. 二次开发改造思路:从能玩到好玩的四个具体方向
如果只是把这份 Demo 跑起来,那它还只是一个展示用的工程示例。真正有价值的改造都藏在细节里,我按影响面从大到小列四个方向。
第一个方向是升级输入系统。现在的键盘上下左右加空格是标准操作,但真机上的触摸滑动只做了方向识别,没有做快速下落的手势联动。把 InputHandler 里的触摸识别改成“记录触摸起点坐标和终点坐标的差,超过阈值 100 像素判定为滑动,再根据角度映射到上下左右”,这一个改动就能让真机手感从 60 分到 90 分。
第二个方向是增加预览队列功能。现在的 nextBlock 只展示一块,游戏里实际还会连续出现多块。改造方式是维护一个长度为 3 的队列,每取走一个就追加一个新形状,预览区域用复用的模板节点而不是动态创建。这样既减少动态创建节点的性能开销,又给玩家更充分的规划空间——在朋友圈里见到的那种“一步看三块”的俄罗斯方块,就是这么改出来的。
第三个方向是加入等级加速系统。现在 DROP_INTERVAL 固定 0.8 秒,游戏进程单调。改成积分每满 1000 分就减少 0.1 秒间隔,下限 0.2 秒,代码就一行:this.dropInterval = Math.max(0.2, 0.8 - this.score / 1000 * 0.1); 门槛低但能立刻改变难度曲线,是投入产出比最高的一处改造。
最后一个方向是性能观测。把 gameTick 的执行耗时打点,在浏览器开发者工具的 Performance 面板里看每一帧是否有长任务。3.8 的渲染批次自动合批,但如果动态创建的小方块节点太多,仍然会拖帧。我的习惯是每改完一个模块,强制跑一遍真机预览并观察帧率曲线,凡是出现掉帧超过 10 毫秒的地方,先怀疑节点数,再怀疑布局组件。
这套 Demo 让我每次做引擎接缝时都意识到一个问题:Cocos Creator 3.8 的核心不是 API 变了多少,而是坐标体系和生命周期组件化的思路换了一整套。从那以后我每次做小游戏原型,都强制先把这个俄罗斯方块跑通一遍再开始写业务代码——它像一张能验证引擎版本变化是否影响游戏逻辑的试纸。希望帮到你。
本文还有配套的精品资源,点击获取