☰
Cocos Creator 微信小游戏俄罗斯方块源码详解与构建实践
2026/10/15 10:45:17 网站建设 项目流程

简介:小游戏开发已成为移动端流量变现的重要入口,而 Cocos Creator 作为主流跨平台引擎,凭借可视化编辑器与 TypeScript 支持,大幅降低了微信小游戏的构建门槛。其核心原理在于将游戏逻辑拆分为数据层与表现层——通过棋盘矩阵、碰撞检测、消行判定等纯逻辑模块,配合预制体节点池复用,实现高性能渲染。理解这套架构后,开发者能快速定位构建后黑屏、触摸映射异常、切后台卡顿等常见问题。本文以俄罗斯方块源码为实例,完整拆解 Cocos Creator 2.x 工程结构、核心玩法实现、微信小游戏适配与构建全流程,并附二次开发思路,帮助开发者从零跑通并扩展自己的小游戏项目。

1. 这个项目的事:Cocos 微信小游戏俄罗斯方块源码,先解决“构建之后跑不起来”的尴尬

很多人第一次用 Cocos Creator 做小游戏,最大的挫败感不是写不出玩法,而是编辑器里预览没问题,构建到微信小游戏却在手机上一片空白。这套源码选了一个最经典的俄罗斯方块来演示完整链路:场景搭建、TypeScript 游戏循环、触摸输入映射、构建导出到微信开发者工具,每一步都保留了可直接运行的工程文件。适合两类人:一类是刚接触 Cocos 想弄明白小游戏工程怎么组织的初学者;另一类是公司里需要一个干净模板来接微信小游戏业务的开发者。拿到源码后可以直接跑通,再照着改成自己的玩法。

2. 工程结构拆解:Cocos Creator 2.x 场景、脚本与预制体的分工

2.1 目录组织:一次微信小游戏构建里的 assets 应该怎么放

这份源码的工程目录是一个标准的 Cocos Creator 2.x 项目,核心逻辑全部写在 assets/scripts 下,资源和场景单独存放。我拆过不少 Cocos 项目,很多新手喜欢把脚本挂在场景节点上就不管了,结果改一行逻辑要找半天文件。这个项目的做法是把代码按职责拆成 core、ui、config 三层,场景文件里只保留节点挂载和组件引用,业务逻辑不散落在场景里。

assets/ scenes/ Main.scene # 主场景,挂载 Canvas 和 GameManager scripts/ core/ GameManager.ts # 游戏主控制器,负责循环、计分、状态流转 BoardManager.ts # 棋盘数据层,10x20 格子的占用与消行 BlockManager.ts # 当前方块管理,生成、移动、旋转、固定 ShapeFactory.ts # 方块配置表与旋转矩阵工具 ui/ ScoreView.ts # 分数、等级、下一个方块的 UI 刷新 TouchController.ts # 把触摸事件映射成游戏指令 AudioController.ts # 音效播放,封装微信小游戏音频接口 prefabs/ Cell.prefab # 单格子预制体,棋盘和方块渲染共用 NextPreview.prefab # 下一个方块预览框 textures/ blocks/ # 方块图片,每种颜色一张 bg/ # 背景与操作提示图片 resources/ sounds/ # 消除、旋转、落地音效

看这个结构就能猜到 GameManager 是整个游戏的枢纽。Main.scene 里只有一个 Canvas 根节点,下面挂着背景节点、棋盘节点、UI 节点,以及一个挂载 GameManager 的空节点。所有预制体通过资源加载拿到,场景里不直接摆放方块对象,这样运行时的节点创建和销毁都受脚本控制。

2.2 组件架构:GameManager、BoardManager、BlockManager 怎么协作

这三个类的关系是单向依赖,GameManager 持有 BoardManager 和 BlockManager 的引用,BlockManager 需要查碰撞时调用 BoardManager 的查询方法,但 BoardManager 不知道 BlockManager 的存在。这种分层是常见做法,好处是棋盘数据可以被单独测试,不依赖任何显示对象。

// core/GameManager.ts 节选 const { ccclass, property } = cc._decorator; @ccclass export default class GameManager extends cc.Component { @property(cc.Prefab) cellPrefab: cc.Prefab = null; private board: BoardManager = null; private block: BlockManager = null; private dropInterval = 800; // 毫秒,初始下落间隔 private lastDropTime = 0; // 计时累计 private score = 0; private level = 1; onLoad() { this.board = new BoardManager(10, 20); this.block = new BlockManager(this.board); const touch = this.node.getComponent(TouchController); touch.onCommand = (cmd: string) => this.handleCommand(cmd); } update(dt: number) { // 微信小游戏环境下 update 仍然由 Cocos 驱动, // 后台切回来时要防止一次性补完所有 tick this.lastDropTime += dt * 1000; if (this.lastDropTime >= this.dropInterval) { this.lastDropTime = 0; this.stepDown(); } } private handleCommand(cmd: string) { if (cmd === 'left') this.block.move(-1, 0); if (cmd === 'right') this.block.move(1, 0); if (cmd === 'rotate') this.block.rotate(); if (cmd === 'drop') this.block.hardDrop(); if (cmd === 'softDrop') this.stepDown(true); } }

update 里只做一件事:判断当前是否该自动下落一格。这里传入的 dt 单位是秒,所以乘以 1000 转成毫秒累计。TouchController 通过命令回调把输入动作抛给 GameManager,界面上的点击、键盘、手柄未来都可以复用同一套命令接口,这是这套源码里我最喜欢的一个设计。

2.3 预制体与资源:单格子复用的渲染思路

俄罗斯方块最忌讳每生成一个方块就 new 一堆 Sprite 节点,性能会非常难看。这个项目准备了一个 Cell.prefab,只有一个 Sprite 节点加一个颜色属性,BlockManager 在生成方块时从节点池里取 Cell 实例,用完再还回去。

// ui/TouchController.ts 节选:把触摸滑动翻译成游戏指令 this.node.on(cc.Node.EventType.TOUCH_START, (event: cc.EventTouch) => { this.startX = event.getLocation().x; this.startY = event.getLocation().y; }, this); this.node.on(cc.Node.EventType.TOUCH_MOVE, (event: cc.EventTouch) => { const dx = event.getLocation().x - this.startX; const dy = event.getLocation().y - this.startY; if (Math.abs(dx) > 60) { this.sendCommand(dx > 0 ? 'right' : 'left'); this.startX = event.getLocation().x; } if (dy > 60) this.sendCommand('rotate'); }, this);

这里的阈值 60 是像素值,不是游戏世界坐标。微信小游戏在 iPhone 和 Android 上的逻辑分辨率不同,60 这个值在竖屏 750px 设计宽度下体验正好。如果你改成横屏或者自适应宽度,建议把阈值也换成相对屏幕宽度的比例,否则老机型上会非常灵敏。

3. 核心玩法逻辑:7种方块、碰撞检测、消行判定是怎么写出来的

3.1 方块数据与旋转矩阵:用二维数组描述形状

俄罗斯方块的全部形状用二维数组表示,1 表示有格子,0 表示空。ShapeFactory.ts 里维护了一张配置表,所有方块的颜色、形状、旋转偏移都集中在这里。这样做的好处是后续加道具方块时,只需要往配置表里追加一行,不需要动任何逻辑代码。

// core/ShapeFactory.ts 节选 export interface ShapeConfig { color: cc.Color; matrix: number[][]; } export const SHAPES: Record<string, ShapeConfig> = { I: { color: cc.hexToColor('#00e5ff'), matrix: [[1, 1, 1, 1]] }, O: { color: cc.hexToColor('#ffeb3b'), matrix: [[1, 1], [1, 1]] }, T: { color: cc.hexToColor('#9c27b0'), matrix: [[0, 1, 0], [1, 1, 1]] }, S: { color: cc.hexToColor('#4caf50'), matrix: [[0, 1, 1], [1, 1, 0]] }, Z: { color: cc.hexToColor('#f44336'), matrix: [[1, 1, 0], [0, 1, 1]] }, J: { color: cc.hexToColor('#2196f3'), matrix: [[1, 0, 0], [1, 1, 1]] }, L: { color: cc.hexToColor('#ff9800'), matrix: [[0, 0, 1], [1, 1, 1]] }, }; export function rotateMatrix(mat: number[][]): number[][] { const rows = mat.length; const cols = mat[0].length; const rotated: number[][] = []; for (let col = 0; col < cols; col++) { rotated[col] = []; for (let row = rows - 1; row >= 0; row--) { rotated[col].push(mat[row][col]); } } return rotated; }

rotateMatrix 是顺时针旋转 90 度,思路是先按列遍历,从下往上取值。拿 T 方块举例子:原始矩阵是 [[0,1,0],[1,1,1]],旋转后得到 [[1,0],[1,1],[1,0]],对应 T 顺时针转 90 度的样子。O 方块转不转都一样,但代码不需要做特判,因为旋转后矩阵一模一样,碰撞结果自然一致。

3.2 碰撞检测与固定:坐标系是怎么对齐的

棋盘的每个格子坐标从左上角开始,x 向右增长,y 向下增长。BlockManager 里保存当前方块类型、当前矩阵、当前坐标(左上角的棋盘坐标),移动和旋转前先用目标坐标做一次碰撞检测,通过了才真正更新位置。

// core/BoardManager.ts 节选 private board: boolean[][]; // true 表示格子已被占用 isCollision(matrix: number[][], posX: number, posY: number): boolean { for (let row = 0; row < matrix.length; row++) { for (let col = 0; col < matrix[row].length; col++) { if (!matrix[row][col]) continue; const boardX = posX + col; const boardY = posY + row; if (boardX < 0 || boardX >= this.width) return true; if (boardY < 0 || boardY >= this.height) return true; if (this.board[boardY][boardX]) return true; } } return false; } freeze(matrix: number[][], posX: number, posY: number): void { for (let row = 0; row < matrix.length; row++) { for (let col = 0; col < matrix[row].length; col++) { if (matrix[row][col]) { this.board[posY + row][posX + col] = true; } } } }

isCollision 里的二维数组遍历顺序是先行后列,但判断越界时 boardX 和 boardY 是分开计算的,所以不会因为行列顺序不同而出错。参数 posX、posY 用的是棋盘坐标,不是像素坐标,这一点在写 UI 层调用时需要特别注意,别直接把触摸事件里的像素坐标传进来。

3.3 消行与计分加速:从底向上的扫描顺序

消行逻辑并不复杂,但扫描顺序很有讲究。为什么必须从最底行往上扫?因为 splice 掉一行后,上面所有行都会往下掉,如果从顶部往下扫,遇到连续两行都满的情况会漏掉第二行。

// core/BoardManager.ts 节选 clearRows(): number { let cleared = 0; for (let row = this.height - 1; row >= 0; row--) { if (this.board[row].every(cell => cell)) { this.board.splice(row, 1); this.board.unshift(new Array(this.width).fill(false)); row++; // 抵消 for 循环的 row--,重新检查当前位置 cleared++; } } return cleared; }

splice 删掉满行,unshift 在顶部补一个空行,这两个操作后数组长度没变,所以 row++ 后从当前位置继续扫描没问题。如果连续两行都是满的,第一次消掉倒数第二行后,原本倒数第三行变成倒数第二行,row++ 后检查的还是同一个索引位置,正好把新的满行也消掉。计分公式在 GameManager 里,单次消一行 100 分,两行 300 分,三行 600 分,四行 1000 分,每累计 2000 分升一级,下落间隔按 800 - (level - 1) * 80 递减,最低 100 毫秒封顶。

4. 微信小游戏适配与构建:从编辑器到手机预览的完整链路

4.1 构建导出:Cocos Creator 2.x 构建到微信小游戏平台

在 Cocos Creator 2.x 里构建微信小游戏非常简单,菜单栏点「项目 - 构建发布」,发布平台选「微信小游戏」,填上你的 AppID,构建完成后会生成一个 build/wechatgame 目录。然后用微信开发者工具「导入项目」导入这个目录,就能预览和调试了。这段源码构建后直接在微信开发者工具里打开,真机预览也没问题。

常见的一个坑是忘记在构建面板里勾选「初始场景」和「MD5 缓存」,这两项影响的是首包加载速度。MD5 缓存可以让小游戏分包和资源更新的文件名带哈希值,更新代码后旧的缓存不会污染新版本,强烈建议做小游戏发布时都勾选上。首次预览时微信开发者工具会提示版本库选择,一般选最新稳定版就行,Cocos 2.x 构建出来的代码适配的是基础库 2.x 以上版本,老版本基础库里可能缺少 wx.createGameClubButton 这类 API。

4.2 屏幕适配:竖屏设计分辨率与安全区

微信小游戏运行在手机上,屏幕比例五花八门,Cocos 的 Canvas 组件默认按设计分辨率适配。这个项目用的是 750x1334 的竖屏设计分辨率,fitHeight + fitWidth 同时开启,这样在不同比例的屏幕上,背景始终完整显示,不会出现黑边。但如果你打算改造成横屏,记得修改 game.json 里的 deviceOrientation 为 landscape,否则小游戏在手机上还是竖屏。

// ui/ScoreView.ts 节选:获取安全区避免 UI 被刘海遮挡 const sysInfo = wx.getSystemInfoSync(); const safeArea = sysInfo.safeArea || { top: 0, left: 0, width: sysInfo.windowWidth, height: sysInfo.windowHeight, }; this.scoreLabel.node.setPosition( (safeArea.left + safeArea.width / 2 - this.designWidth / 2) * this.scaleFactor, 0 );

这段代码把分数标签的水平位置挪到安全区中心。safeArea 是微信基础库提供的,不是所有手机都有值,所以给了一个兜底对象。为什么偏要用 safeArea 而不是 screenWidth 的一半?因为 iPhone X 以后机型全面屏,屏幕物理尺寸和可视安全区不一致,UI 靠底部会被 home indicator 挡住。

4.3 触摸输入映射:把鼠标键盘逻辑换成小游戏触摸事件

俄罗斯方块在电脑上靠键盘方向键控制,到了手机上没有键盘,常见的做法是把屏幕划分成左右两个点击区域,滑动或点按触发对应动作。TouchController 监听 Canvas 根节点的触摸事件,将触摸坐标变化翻译成 left、right、rotate、drop 命令,再抛给 GameManager。

// ui/TouchController.ts 完整逻辑 @ccclass export default class TouchController extends cc.Component { onCommand: (cmd: string) => void = null; private startX = 0; private startY = 0; onLoad() { this.node.on(cc.Node.EventType.TOUCH_START, this.onStart, this); this.node.on(cc.Node.EventType.TOUCH_END, this.onEnd, this); } private onStart(event: cc.EventTouch) { const pos = event.getLocation(); this.startX = pos.x; this.startY = pos.y; } private onEnd(event: cc.EventTouch) { const dx = event.getLocation().x - this.startX; const dy = event.getLocation().y - this.startY; if (Math.abs(dx) < 20 && Math.abs(dy) < 20) { // 短按是硬降,竖滑超过阈值是旋转 this.sendCommand(dy > 0 ? 'rotate' : 'drop'); } else { // 横向滑动是左右移动,按位移分段发送 while (Math.abs(dx) >= 40) { this.sendCommand(dx > 0 ? 'right' : 'left'); dx = dx > 0 ? dx - 40 : dx + 40; } } } private sendCommand(cmd: string) { if (this.onCommand) this.onCommand(cmd); } }

这里用 TOUCH_END 而不是 TOUCH_MOVE 算差值,可以避免手指滑动时误触发多个指令。滑动距离 40 是棋盘的格子宽度换算出来的,设计分辨率 750 下,棋盘 10 列,一列正好 75px,但为了快速操作,设置为 40px 滑一次会更跟手。你改成自己的游戏时,这个阈值一定要按实际棋盘列数重新算。

5. 避坑与常见问题:轮询卡顿、旋转越界、返回桌面重进这些坑

5.1 切后台再回来,方块瞬间堆满

现象:玩到一半切去微信聊天,回来发现游戏已经 game over,或者方块堆得很高。

原因:微信小游戏在切后台时会暂停 JS 引擎,Cocos Creator 的 update 也可能不再触发。但有些定时器或者 requestAnimationFrame 的驱动方式不同,回到前台瞬间会把这段时间欠掉的 dt 一次性补回来,导致#36;lastDropTime >= dropInterval连续成立,脚本在几十帧内疯狂下落。

解决:在 GameManager 里监听微信的生命周期事件,切后台时记录 paused = true,回到前台先重置 lastDropTime 再取消暂停。

// GameManager.ts 生命周期处理 onEnable() { if (typeof wx !== 'undefined') { wx.onHide(() => { this.paused = true; }); wx.onShow(() => { this.paused = false; this.lastDropTime = 0; }); } } update(dt: number) { if (this.paused) return; this.lastDropTime += dt * 1000; if (this.lastDropTime >= this.dropInterval) { this.lastDropTime = 0; this.stepDown(); } }

从这次踩坑之后,我做任何小游戏都会在启动后第一件事注册 wx.onShow 和 wx.onHide,哪怕当前玩法不需要暂停逻辑,也会至少强制刷新一次界面数据。

5.2 旋转方块时瞬间“闪跳”

现象:快速连点旋转,方块突然跑到棋盘边缘或者重叠位置,然后又恢复正常。

原因:BlockManager 的旋转函数是先旋转再判断碰撞,碰撞失败时回退,但回退用的是旋转后的坐标去还原,如果旋转前后矩阵尺寸变化(I 方块从 1x4 变成 4x1),方块左上角坐标没有同步调整偏移,就会出现瞬移。

解决:旋转前先取当前坐标的副本,生成旋转后的矩阵,用临时坐标做碰撞检测,如果碰撞不通过就直接丢弃旋转结果。

// BlockManager.ts 节选 rotate(): boolean { const rotatedMatrix = rotateMatrix(this.currentMatrix); if (this.board.isCollision(rotatedMatrix, this.posX, this.posY)) { return false; } this.currentMatrix = rotatedMatrix; this.refreshView(); return true; }

5.3 iOS 上播放音效有明显延迟

现象:方块落地和消行时,音效总是慢半拍,Android 上正常,iOS 上特别明显。

原因:Cocos 的 AudioEngine 在浏览器和微信小游戏上的底层实现不一样,iOS 系统对音频解码有预加载机制,如果用cc.audioEngine.playEffect每次临时加载音频文件,第一次播放会等解码完成,造成几百毫秒延迟。

解决:在 onLoad 阶段把所有音效文件预加载到 AudioClip 缓存里,播放时直接从内存取。

// AudioController.ts 节选 onLoad() { cc.resources.load('sounds/clear', cc.AudioClip, (err, clip) => { if (!err) this.clearClip = clip; }); cc.resources.load('sounds/rotate', cc.AudioClip, (err, clip) => { if (!err) this.rotateClip = clip; }); } playClear() { if (this.clearClip) cc.audioEngine.playEffect(this.clearClip, false); }

5.4 真机预览黑屏但开发者工具正常

现象:微信开发者工具里跑得好好的,手机扫码预览直接黑屏,控制台没有任何报错。

原因:最常见的是工程里用了eval或者动态执行代码,微信小游戏平台不运行 eval;另一类原因是纹理加载用了远程路径但手机没网。这个项目没用到 eval,黑屏大概率是开发者工具里「不校验合法域名」勾上了,但手机端关闭了调试模式,wx.loadSubpackage 或远程资源被拦截。

解决:真机预览时保持调试模式打开,或者把项目中所有图片、音频都放到 resources 目录本地加载,不走远程 URL。游戏如果需要动态下发配置,建议用微信云开发,而不是自己的 HTTP 服务器。

6. 二次开发思路:把单机俄罗斯方块改造成带道具的联机版本

这套源码最值得借鉴的地方是数据与表现分离。ShapeFactory 的配置表、BoardManager 的纯数据棋盘、BlockManager 的状态机,三层互不干扰。我做休闲游戏习惯了先把数据层用 TypeScript 纯类写完,再接 Cocos 渲染,因为 Cocos 的组件生命周期和 UI 状态耦合太多,后期做玩法迭代时改起来很痛苦。

一个立刻可以动手的改造:在 ShapeFactory 配置表里新增两个道具方块,一个是炸弹方块,消行时把四周格子一起消掉;另一个是加行方块,固定到棋盘时随机给底部添加一行空格,给对手造成压力。实现时只需要在 SHAPES 里追加配置,然后在 clearRows 里判断一下方块类型,其他逻辑完全不用动,这就是配置表设计的红利。

如果你想接联机对战,最简单的方案是在单机逻辑外再包一层网络同步服务,本地每固定一个方块就上报给服务器,服务器定时把对手的棋盘行数广播过来。服务端可以用腾讯云开发或 Node.js 加 WebSocket,小游戏端只需要在 GameManager 里增加两个事件:onRemoteAddRows和onScoreSync,改动量控制在一百行以内。

最后分享一个走过弯路的教训:以前我为了让某次演示上线,把方块数据直接写死在逻辑里,结果后面改一列宽度要同时调七八个数组,差点重写整个玩法。从那以后我每次做小游戏都强制把配置、数据、表现三层分开,哪怕只是十几行的小游戏也不偷懒。希望这套源码能帮你少踩一次这个坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询