Skip to content

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。你将学习引擎的场景、节点、组件、预制体、物理碰撞、动画、分包等核心概念,并最终得到一个可运行的完整项目。


项目创建 ​

  1. 打开 Cocos Dashboard → 新建项目 → 选择「空白 2D 项目」。
  2. 项目名称 FlappyBird-Cocos,选择 TypeScript 模板。
  3. 进入编辑器后,点击菜单 项目 → 构建发布,添加预设:
    • 发布平台:微信小游戏
    • 构建任务:选择项目目录
    • 初始场景:assets/scenes/Game.scene
  4. 点击 项目 → 项目设置 → 功能裁剪,确保勾选:
    • 2D Physics(Box2D)
    • Spine / DragonBones(如需骨骼动画,本示例可不选)
实现步骤

Cocos Creator 版本按以下顺序搭建:

  1. 创建项目 — 配置微信小游戏构建预设
  2. 搭建场景 — Canvas、Camera、Layer、节点层级
  3. 制作 PipePair 预制体 — 上下管道 + 碰撞体
  4. 编写核心组件 — GameController、BirdController、PipeManager、PipePair
  5. 绑定 UI 事件 — 开始按钮、重新开始按钮
  6. 配置物理与碰撞 — RigidBody2D、BoxCollider2D、碰撞分组
  7. 构建发布 — 导出到微信开发者工具并真机测试

资源准备 ​

本示例使用纯几何图形替代美术资源,便于复现。你也可以替换为真实图片。

资源类型说明
assets/textures/bird.pngSprite小鸟图片(可选,可用颜色方块代替)
assets/textures/pipe.pngSprite管道图片(可选)
assets/textures/ground.pngSprite地面图片(可选)
assets/audio/flap.mp3AudioClip跳跃音效
assets/audio/score.mp3AudioClip得分音效
assets/audio/hit.mp3AudioClip碰撞音效
assets/prefabs/PipePair.prefabPrefab上下管道预制体
无美术资源替代方案

如果使用几何图形,可直接在 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 按钮事件绑定 ​

  1. StartButton:在编辑器中选中按钮节点 → Inspector → Button → Click Events,拖拽 GameManager 节点到 Target,选择 GameController.startGame。
  2. RestartButton:同样方式绑定 GameController.restartGame。

预制体制作:PipePair.prefab ​

  1. 在场景中创建一个空节点 PipePair,作为预制体根节点。该名称会被 BirdController 碰撞检测逻辑使用,请勿随意修改。
  2. 在其下创建两个子节点:TopPipe 和 BottomPipe。
  3. 每个管道节点:
    • 添加 Sprite 组件或 Graphics 组件显示绿色矩形
    • 添加 BoxCollider2D,Size 覆盖整个管道
    • 添加 RigidBody2D,Type = Static(管道不动,小鸟撞上去)
  4. 给 PipePair 根节点添加 PipePair 脚本组件。
  5. 在 Assets 面板中将 PipePair 节点拖入,创建 Prefab。
  6. 删除场景中的 PipePair 实例,保留 Prefab 资源,供 PipeManager 引用。
为什么用 Static RigidBody?

管道本身不运动,只是整个 PipePair 节点在 update 中平移。管道碰撞体用 Static 可以避免每帧重新计算物理速度,性能更好。


物理与碰撞设置 ​

项目设置 ​

  1. 菜单 项目 → 项目设置 → 物理:
    • 勾选 Enable Physics
    • 2D 物理引擎选择 Box2D
    • Gravity 设为 (0, -1000) 或根据手感调整
  2. 菜单 项目 → 项目设置 → 功能裁剪:
    • 确保 2D Physics 已勾选

碰撞分组 ​

为了简化,本示例所有碰撞体默认使用 DEFAULT 分组即可。如果后续要区分"可穿过区域"和"实体",可以在 项目 → 物理 → Collision Matrix 中配置分组。


构建发布到微信小游戏 ​

  1. 点击菜单 项目 → 构建发布。
  2. 配置:
    • 发布平台:微信小游戏
    • 构建任务名称:wechat-game
    • 初始场景:Game.scene
    • AppID:填写你的小游戏 AppID(测试号可填 wx6af3...)
    • 小游戏根目录:build/wechat-game
  3. 点击 构建。
  4. 构建完成后,打开 微信开发者工具 → 导入项目 → 选择 build/wechat-game 目录。
  5. 在开发者工具中点击 预览 或 真机调试。

与原生 Canvas 版本的对比 ​

概念原生 Canvas 版本Cocos Creator 版本
游戏循环手写 requestAnimationFrame引擎自动驱动 update(dt)
物理手动计算重力/速度RigidBody2D + Box2D
碰撞检测手写 AABBCollider2D + 碰撞回调
坐标变换手动计算节点层级 + 世界/本地坐标
UICanvas 绘制文字Label / Button 组件
资源管理无/手动管理resources 加载 + 自动释放
音频wx.createInnerAudioContextAudioSource 组件
分包手写 game.json构建面板自动生成
发布流程复制 HTML/JS 文件一键构建到微信开发者工具
下一步

Cocos 版本完成后,进入商业化集成版学习如何添加广告变现、皮肤内购、数据埋点和 A/B 测试。


本章小结 ​

通过本章,你完成了从原生 Canvas 到 Cocos Creator 的重写:

  1. 场景搭建:Canvas、Camera、Layer 的层级组织
  2. 物理系统:RigidBody2D + BoxCollider2D 的碰撞检测
  3. 组件化:GameController、BirdController、PipeManager、PipePair 的职责划分
  4. 预制体:PipePair 的复用与动态生成
  5. UI 交互:Button 事件绑定、分数显示、最高分本地存储
  6. 构建发布:从 Cocos 编辑器到微信开发者工具的完整流程
引擎学习的核心心法

引擎并没有改变游戏开发的本质——游戏循环、碰撞、状态机、资源管理依然存在。引擎做的是把这些概念封装成可视化的节点、组件和工具。理解底层原理后,学习任何引擎都会事半功倍。


📝 课后练习 ​

练习 1:添加地面滚动效果

题目: 给地面添加连续滚动动画,当地面块移出屏幕左侧时,从右侧重新进入,形成无限地面效果。

参考答案思路:

  • 创建两个 Ground 节点拼接在一起
  • 每帧以相同速度向左移动
  • 当左侧地面完全移出屏幕时,将其 x 坐标增加两倍宽度
练习 2:难度递增

题目: 随着分数增加,逐渐提高游戏难度:

  1. 管道生成间隔从 1.8s 缩短到 1.2s
  2. 管道移动速度从 200 提升到 280
  3. 管道缺口大小从 240 缩小到 180

参考答案思路:

  • 在 GameController.addScore 中根据 _score 计算难度系数
  • 将难度系数传给 PipeManager,动态调整 spawnInterval、pipeSpeed、gapSize
练习 3:动画小鸟

题目: 用帧动画或旋转效果让小鸟更有表现力:

  1. 飞行时小鸟上下轻微摆动
  2. 下落时根据速度旋转(速度向下时角度增大)
  3. 跳跃时短暂上扬

参考答案思路:

  • 在 BirdController.update 中根据 linearVelocity.y 设置 node.angle
  • 或使用 Tween 做上下浮动动画

📚 相关阅读 ​

用心学习,持续实践