主题切换
Flappy Bird — Cocos Creator 重写
预计阅读 45 分钟
版本信息
最后更新:2026-07-09 · 推荐引擎版本:Cocos Creator 3.8.8 LTS · 适用基础库:≥ 3.9.0(最低兼容 ≥ 2.30.0)
在完成原生 Canvas 版本后,本章用 Cocos Creator 3.8.x 重写 Flappy Bird。你将学习引擎的场景、节点、组件、预制体、物理碰撞、动画、分包等核心概念,并最终得到一个可运行的完整项目。
项目创建
- 打开 Cocos Dashboard → 新建项目 → 选择「空白 2D 项目」。
- 项目名称
FlappyBird-Cocos,选择 TypeScript 模板。 - 进入编辑器后,点击菜单 项目 → 构建发布,添加预设:
- 发布平台:微信小游戏
- 构建任务:选择项目目录
- 初始场景:
assets/scenes/Game.scene
- 点击 项目 → 项目设置 → 功能裁剪,确保勾选:
- 2D Physics(Box2D)
- Spine / DragonBones(如需骨骼动画,本示例可不选)
实现步骤
Cocos Creator 版本按以下顺序搭建:
- 创建项目 — 配置微信小游戏构建预设
- 搭建场景 — Canvas、Camera、Layer、节点层级
- 制作 PipePair 预制体 — 上下管道 + 碰撞体
- 编写核心组件 — GameController、BirdController、PipeManager、PipePair
- 绑定 UI 事件 — 开始按钮、重新开始按钮
- 配置物理与碰撞 — RigidBody2D、BoxCollider2D、碰撞分组
- 构建发布 — 导出到微信开发者工具并真机测试
资源准备
本示例使用纯几何图形替代美术资源,便于复现。你也可以替换为真实图片。
| 资源 | 类型 | 说明 |
|---|---|---|
assets/textures/bird.png | Sprite | 小鸟图片(可选,可用颜色方块代替) |
assets/textures/pipe.png | Sprite | 管道图片(可选) |
assets/textures/ground.png | Sprite | 地面图片(可选) |
assets/audio/flap.mp3 | AudioClip | 跳跃音效 |
assets/audio/score.mp3 | AudioClip | 得分音效 |
assets/audio/hit.mp3 | AudioClip | 碰撞音效 |
assets/prefabs/PipePair.prefab | Prefab | 上下管道预制体 |
无美术资源替代方案
如果使用几何图形,可直接在 Cocos 中创建空节点并添加 Graphics 组件绘制。
场景结构
在 assets/scenes/Game.scene 中搭建如下层级:
Canvas (根节点)
├── Camera
├── Background (背景层)
│ ├── Sky (纯色背景或 Sprite)
│ └── Clouds (装饰)
├── GameLayer (游戏层)
│ ├── Bird (小鸟节点 — 挂载 BirdController + RigidBody2D + BoxCollider2D)
│ ├── PipeContainer (管道容器 — 挂载 PipeManager)
│ └── Ground (地面 — 挂载 BoxCollider2D)
├── UILayer (UI 层)
│ ├── ScoreLabel (Label 组件)
│ ├── StartPanel (开始面板)
│ │ └── StartButton (Button)
│ └── GameOverPanel (结束面板 — 默认隐藏)
│ ├── FinalScoreLabel
│ ├── BestScoreLabel
│ └── RestartButton (Button)
└── GameManager (空节点 — 挂载 GameController)关键节点属性
- Canvas:Design Resolution 设为
750 × 1334(iPhone 标准),Fit Height。 - Bird:
- 添加
RigidBody2D:Type = Dynamic,Gravity Scale = 1 - 添加
BoxCollider2D:Size 覆盖小鸟 - 添加
BirdController自定义组件 - 添加
AudioSource组件(用于播放 flap/hit 音效)
- 添加
- Ground:
- 添加
BoxCollider2D:勾选Sensor = false - 位置放在屏幕底部
- 添加
- PipeContainer:空节点,添加
PipeManager组件
核心组件完整代码
GameController.ts — 游戏主控制器
typescript
import { _decorator, Component, Node, Label } from 'cc'
import { BirdController } from './BirdController'
import { PipeManager } from './PipeManager'
const { ccclass, property } = _decorator
export enum GameState {
Ready,
Playing,
GameOver,
}
@ccclass('GameController')
export class GameController extends Component {
@property(Label) scoreLabel: Label | null = null
@property(Label) finalScoreLabel: Label | null = null
@property(Label) bestScoreLabel: Label | null = null
@property(Node) startPanel: Node | null = null
@property(Node) gameOverPanel: Node | null = null
@property(Node) bird: Node | null = null
@property(PipeManager) pipeManager: PipeManager | null = null
private _state = GameState.Ready
private _score = 0
private _bestScore = 0
get state() {
return this._state
}
get score() {
return this._score
}
start() {
this._bestScore = this.loadBestScore()
this.showStartScreen()
}
showStartScreen() {
this._state = GameState.Ready
this._score = 0
this.updateScore()
this.startPanel!.active = true
this.gameOverPanel!.active = false
// 重置小鸟
if (this.bird) {
this.bird.setPosition(0, 0)
this.bird.angle = 0
}
}
startGame() {
if (this._state !== GameState.Ready) return
this._state = GameState.Playing
this._score = 0
this.updateScore()
this.startPanel!.active = false
this.gameOverPanel!.active = false
// 通知小鸟开始飞行
this.bird?.getComponent(BirdController)?.startGame()
// 通知管道管理器开始生成
this.pipeManager?.startSpawn()
}
gameOver() {
if (this._state === GameState.GameOver) return
this._state = GameState.GameOver
// 更新最高分
if (this._score > this._bestScore) {
this._bestScore = this._score
this.saveBestScore(this._bestScore)
}
if (this.finalScoreLabel) this.finalScoreLabel.string = String(this._score)
if (this.bestScoreLabel) this.bestScoreLabel.string = String(this._bestScore)
this.gameOverPanel!.active = true
// 停止管道生成和移动
this.pipeManager?.stopSpawn()
}
restartGame() {
// 清理现有管道
this.pipeManager?.reset()
this.showStartScreen()
}
addScore(n = 1) {
if (this._state !== GameState.Playing) return
this._score += n
this.updateScore()
}
private updateScore() {
if (this.scoreLabel) {
this.scoreLabel.string = String(this._score)
}
}
private loadBestScore(): number {
try {
return Number(wx.getStorageSync('flappy_best_score')) || 0
} catch {
return 0
}
}
private saveBestScore(score: number) {
try {
wx.setStorageSync('flappy_best_score', String(score))
} catch (err) {
console.error('保存最高分失败', err)
}
}
}BirdController.ts — 小鸟行为
typescript
import {
_decorator,
Component,
RigidBody2D,
Vec2,
input,
Input,
EventTouch,
AudioClip,
AudioSource,
BoxCollider2D,
Contact2DType,
IPhysics2DContact,
} from 'cc'
import { GameController, GameState } from './GameController'
const { ccclass, property } = _decorator
@ccclass('BirdController')
export class BirdController extends Component {
@property jumpForce = 300
@property(AudioClip) flapSound: AudioClip | null = null
@property(AudioClip) hitSound: AudioClip | null = null
private _rigidBody: RigidBody2D | null = null
private _game: GameController | null = null
private _started = false
private _audioSource: AudioSource | null = null
onLoad() {
this._rigidBody = this.getComponent(RigidBody2D)
this._audioSource = this.getComponent(AudioSource)
// 通过类型安全的方式获取 GameController;注意 GameController 与 BirdController 互相引用,
// 若出现循环依赖报错,可改用事件总线或延迟获取(在 start 中再查找)
this._game = this.node.scene.getChildByName('GameManager')?.getComponent(GameController)
// 注册触摸事件
input.on(Input.EventType.TOUCH_START, this.onTouch, this)
// 注册碰撞事件
const collider = this.getComponent(BoxCollider2D)
if (collider) {
collider.on(Contact2DType.BEGIN_CONTACT, this.onCollision, this)
}
}
onDestroy() {
input.off(Input.EventType.TOUCH_START, this.onTouch, this)
const collider = this.getComponent(BoxCollider2D)
if (collider) {
collider.off(Contact2DType.BEGIN_CONTACT, this.onCollision, this)
}
}
startGame() {
this._started = true
// 初始给一个向上的小速度,避免直接下落
this._rigidBody?.setLinearVelocity(new Vec2(0, 50))
}
reset() {
this._started = false
this._rigidBody?.setLinearVelocity(new Vec2(0, 0))
this._rigidBody?.sleep()
this.node.setPosition(0, 0)
this.node.angle = 0
}
private onTouch() {
if (!this._started || !this._game || this._game.state !== GameState.Playing) return
this.flap()
}
flap() {
if (this._rigidBody) {
this._rigidBody.setLinearVelocity(new Vec2(0, this.jumpForce))
}
if (this.flapSound) {
this._audioSource?.playOneShot(this.flapSound, 0.8)
}
// 旋转动画
this.node.angle = -20
this.scheduleOnce(() => {
this.node.angle = 0
}, 0.1)
}
private onCollision(
selfCollider: BoxCollider2D,
otherCollider: BoxCollider2D,
contact: IPhysics2DContact | null
) {
if (!this._game || this._game.state !== GameState.Playing) return
// 撞到管道或地面都结束
// PipePair 预制体的子节点名为 TopPipe / BottomPipe,通过父节点判断
const parentName = otherCollider.node.parent?.name ?? ''
if (parentName === 'PipePair' || otherCollider.node.name === 'Ground') {
if (this.hitSound) {
this._audioSource?.playOneShot(this.hitSound, 0.8)
}
this._game.gameOver()
}
}
}PipePair.ts — 单组管道
typescript
import { _decorator, Component, Node, BoxCollider2D, Contact2DType, IPhysics2DContact } from 'cc'
import { GameController, GameState } from './GameController'
const { ccclass, property } = _decorator
@ccclass('PipePair')
export class PipePair extends Component {
@property speed = 200
@property gapSize = 240
private _scored = false
private _game: GameController | null = null
private _bird: Node | null = null
onLoad() {
this._game = this.node.scene.getChildByName('GameManager')?.getComponent(GameController)
this._bird = this.node.scene.getChildByName('GameLayer')?.getChildByName('Bird')
// 上下管道的碰撞体都会触发结束
const colliders = this.getComponentsInChildren(BoxCollider2D)
for (const c of colliders) {
c.on(Contact2DType.BEGIN_CONTACT, this.onCollision, this)
}
}
onDestroy() {
const colliders = this.getComponentsInChildren(BoxCollider2D)
for (const c of colliders) {
c.off(Contact2DType.BEGIN_CONTACT, this.onCollision, this)
}
}
update(dt: number) {
if (!this._game || this._game.state !== GameState.Playing) return
const pos = this.node.position
this.node.setPosition(pos.x - this.speed * dt, pos.y, pos.z)
// 计分:小鸟中心点越过管道右边缘时加 1 分
// 管道宽度估算为 104(与原生 Canvas 版一致),若使用 Sprite 可读取 contentSize
const PIPE_WIDTH = 104
const birdX = this._bird?.position.x ?? 0
if (!this._scored && birdX > pos.x + PIPE_WIDTH / 2) {
this._scored = true
this._game.addScore(1)
}
// 移出屏幕左侧后销毁
if (pos.x < -500) {
this.node.destroy()
}
}
private onCollision(
selfCollider: BoxCollider2D,
otherCollider: BoxCollider2D,
contact: IPhysics2DContact | null
) {
if (otherCollider.node.name === 'Bird') {
this._game?.gameOver()
}
}
}PipeManager.ts — 管道生成器
typescript
import { _decorator, Component, Prefab, instantiate, Node } from 'cc'
import { PipePair } from './PipePair'
const { ccclass, property } = _decorator
@ccclass('PipeManager')
export class PipeManager extends Component {
@property(Prefab) pipePrefab: Prefab | null = null
@property spawnInterval = 1.8
@property pipeSpeed = 200
@property gapSize = 240
@property minGapY = -150
@property maxGapY = 250
private _elapsed = 0
private _spawning = false
startSpawn() {
this._spawning = true
this._elapsed = 0
}
stopSpawn() {
this._spawning = false
}
reset() {
this._spawning = false
this._elapsed = 0
// 销毁所有子节点
const children = [...this.node.children]
for (const child of children) {
child.destroy()
}
}
update(dt: number) {
if (!this._spawning) return
this._elapsed += dt
if (this._elapsed >= this.spawnInterval) {
this.spawnPipe()
this._elapsed -= this.spawnInterval
}
}
spawnPipe() {
if (!this.pipePrefab) {
console.warn('PipePair 预制体未绑定')
return
}
const pipe = instantiate(this.pipePrefab)
pipe.parent = this.node
const gapY = this.minGapY + Math.random() * (this.maxGapY - this.minGapY)
pipe.setPosition(400, gapY, 0)
const mover = pipe.getComponent(PipePair)
if (mover) {
mover.speed = this.pipeSpeed
mover.gapSize = this.gapSize
} else {
console.error('PipePair 预制体上未挂载 PipePair 组件')
}
}
}UI 按钮事件绑定
- StartButton:在编辑器中选中按钮节点 → Inspector → Button → Click Events,拖拽
GameManager节点到Target,选择GameController.startGame。 - RestartButton:同样方式绑定
GameController.restartGame。
预制体制作:PipePair.prefab
- 在场景中创建一个空节点
PipePair,作为预制体根节点。该名称会被BirdController碰撞检测逻辑使用,请勿随意修改。 - 在其下创建两个子节点:
TopPipe和BottomPipe。 - 每个管道节点:
- 添加
Sprite组件或Graphics组件显示绿色矩形 - 添加
BoxCollider2D,Size 覆盖整个管道 - 添加
RigidBody2D,Type = Static(管道不动,小鸟撞上去)
- 添加
- 给
PipePair根节点添加PipePair脚本组件。 - 在 Assets 面板中将
PipePair节点拖入,创建 Prefab。 - 删除场景中的
PipePair实例,保留 Prefab 资源,供PipeManager引用。
为什么用 Static RigidBody?
管道本身不运动,只是整个 PipePair 节点在 update 中平移。管道碰撞体用 Static 可以避免每帧重新计算物理速度,性能更好。
物理与碰撞设置
项目设置
- 菜单 项目 → 项目设置 → 物理:
- 勾选
Enable Physics - 2D 物理引擎选择
Box2D - Gravity 设为
(0, -1000)或根据手感调整
- 勾选
- 菜单 项目 → 项目设置 → 功能裁剪:
- 确保
2D Physics已勾选
- 确保
碰撞分组
为了简化,本示例所有碰撞体默认使用 DEFAULT 分组即可。如果后续要区分"可穿过区域"和"实体",可以在 项目 → 物理 → Collision Matrix 中配置分组。
构建发布到微信小游戏
- 点击菜单 项目 → 构建发布。
- 配置:
- 发布平台:微信小游戏
- 构建任务名称:
wechat-game - 初始场景:
Game.scene - AppID:填写你的小游戏 AppID(测试号可填
wx6af3...) - 小游戏根目录:
build/wechat-game
- 点击 构建。
- 构建完成后,打开 微信开发者工具 → 导入项目 → 选择
build/wechat-game目录。 - 在开发者工具中点击 预览 或 真机调试。
常见问题
- 真机白屏:检查
game.json中deviceOrientation是否设为portrait。 - 碰撞不生效:确认
RigidBody2D和BoxCollider2D都已挂载,且物理引擎已启用;同时检查PipePair预制体根节点名称是否为PipePair(BirdController依赖该名称判断管道碰撞)。 - 小鸟穿墙:可能是物理步长与帧率不匹配,尝试在 项目设置 → 物理 中调整
Time Scale或FixedTimeStep。 - iOS 音频不播放:需要在首次触摸事件中激活音频,参见 音频系统。
与原生 Canvas 版本的对比
| 概念 | 原生 Canvas 版本 | Cocos Creator 版本 |
|---|---|---|
| 游戏循环 | 手写 requestAnimationFrame | 引擎自动驱动 update(dt) |
| 物理 | 手动计算重力/速度 | RigidBody2D + Box2D |
| 碰撞检测 | 手写 AABB | Collider2D + 碰撞回调 |
| 坐标变换 | 手动计算 | 节点层级 + 世界/本地坐标 |
| UI | Canvas 绘制文字 | Label / Button 组件 |
| 资源管理 | 无/手动管理 | resources 加载 + 自动释放 |
| 音频 | wx.createInnerAudioContext | AudioSource 组件 |
| 分包 | 手写 game.json | 构建面板自动生成 |
| 发布流程 | 复制 HTML/JS 文件 | 一键构建到微信开发者工具 |
下一步
Cocos 版本完成后,进入商业化集成版学习如何添加广告变现、皮肤内购、数据埋点和 A/B 测试。
本章小结
通过本章,你完成了从原生 Canvas 到 Cocos Creator 的重写:
- 场景搭建:Canvas、Camera、Layer 的层级组织
- 物理系统:RigidBody2D + BoxCollider2D 的碰撞检测
- 组件化:GameController、BirdController、PipeManager、PipePair 的职责划分
- 预制体:PipePair 的复用与动态生成
- UI 交互:Button 事件绑定、分数显示、最高分本地存储
- 构建发布:从 Cocos 编辑器到微信开发者工具的完整流程
引擎学习的核心心法
引擎并没有改变游戏开发的本质——游戏循环、碰撞、状态机、资源管理依然存在。引擎做的是把这些概念封装成可视化的节点、组件和工具。理解底层原理后,学习任何引擎都会事半功倍。
📝 课后练习
练习 1:添加地面滚动效果
题目: 给地面添加连续滚动动画,当地面块移出屏幕左侧时,从右侧重新进入,形成无限地面效果。
参考答案思路:
- 创建两个
Ground节点拼接在一起 - 每帧以相同速度向左移动
- 当左侧地面完全移出屏幕时,将其
x坐标增加两倍宽度
练习 2:难度递增
题目: 随着分数增加,逐渐提高游戏难度:
- 管道生成间隔从 1.8s 缩短到 1.2s
- 管道移动速度从 200 提升到 280
- 管道缺口大小从 240 缩小到 180
参考答案思路:
- 在
GameController.addScore中根据_score计算难度系数 - 将难度系数传给
PipeManager,动态调整spawnInterval、pipeSpeed、gapSize
练习 3:动画小鸟
题目: 用帧动画或旋转效果让小鸟更有表现力:
- 飞行时小鸟上下轻微摆动
- 下落时根据速度旋转(速度向下时角度增大)
- 跳跃时短暂上扬
参考答案思路:
- 在
BirdController.update中根据linearVelocity.y设置node.angle - 或使用
Tween做上下浮动动画
📚 相关阅读
- Flappy Bird 原生 Canvas 实战 — 同一个游戏的底层实现
- Flappy Bird 商业化集成 — 在 Cocos 版本基础上接入变现
- 引擎对比与选型 — 为什么选择 Cocos Creator
- 音频系统 — Cocos 中的音效与 BGM 管理