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. 在其下创建两个子节点:TopPipeBottomPipe
  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,动态调整 spawnIntervalpipeSpeedgapSize
练习 3:动画小鸟

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

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

参考答案思路:

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

📚 相关阅读

用心学习,持续实践