Skip to content

Flappy Bird — 原生 Canvas 2D 完整实现

预计阅读 40 分钟
版本信息
最后更新:2026-07-09 · 适用基础库:≥ 3.9.0(最低兼容 ≥ 2.30.0) · 微信开发者工具:稳定版 1.06+

本章从零实现一个完整的 Flappy Bird 小游戏,使用纯 Canvas 2D + TypeScript,无需任何外部图片资源 — 所有画面均通过几何图形绘制。


项目初始化

文件结构

flappy-bird/
├── game.js                 # 入口
├── game.json               # 小游戏配置
├── project.config.json     # 项目配置
└── src/
    ├── GameManager.ts      # 游戏主控制器
    ├── Bird.ts             # 小鸟类
    ├── Pipe.ts             # 管道类
    ├── PipeManager.ts      # 管道管理器
    ├── UIManager.ts        # 分数/UI 渲染
    ├── InputManager.ts     # 输入处理
    └── utils.ts            # 工具函数
实现步骤

我们将按以下顺序实现完整游戏:

  1. 工具函数 — 碰撞检测与随机数
  2. 小鸟 Bird — 重力、跳跃、绘制
  3. 管道 Pipe — 上下管道生成与移动
  4. 管道管理器 PipeManager — 无限生成、碰撞检测、计分
  5. 输入管理器 InputManager — 触摸事件绑定
  6. UI 管理器 UIManager — 分数、开始/结束界面
  7. 游戏主控制器 GameManager — 组装所有模块并驱动游戏循环

每完成一步,你都可以对照源码检查自己的实现是否正确。

game.json

json
{
  "deviceOrientation": "portrait",
  "showStatusBar": false
}

项目配置文件

除了 game.json,微信开发者工具还需要 project.config.json 来识别项目。同时,因为我们使用 TypeScript,需要配置编译选项。

project.config.json(微信开发者工具项目配置):

json
{
  "description": "Flappy Bird 微信小游戏",
  "packOptions": {
    "ignore": [
      { "type": "folder", "value": ".git" },
      { "type": "folder", "value": "node_modules" },
      { "type": "file", "value": "tsconfig.json" }
    ]
  },
  "setting": {
    "urlCheck": true,
    "es6": true,
    "enhance": true,
    "compileWorklet": false,
    "minified": true,
    "autoAudits": false,
    "uglifyFileName": false
  },
  "compileType": "game",
  "libVersion": "3.9.0",
  "appid": "your-appid-here",
  "projectname": "flappy-bird",
  "condition": {}
}

tsconfig.json(TypeScript 编译配置):

json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ES2020",
    "lib": ["ES2020", "DOM"],
    "types": ["@types/wechat-minigame"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "sourceMap": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

说明:小游戏代码需要先编译为 ES2020 模块才能被微信运行时识别。本示例使用 tscsrc/**/*.ts 编译到 dist/,因此入口 game.js 需从 ./dist/GameManager 导入编译后的模块。你也可以改用 esbuild 等 bundler 把全部代码打包成单个 game.js


工具函数

typescript
/** 矩形碰撞检测 */
export function rectCollision(
  r1x: number,
  r1y: number,
  r1w: number,
  r1h: number,
  r2x: number,
  r2y: number,
  r2w: number,
  r2h: number
): boolean {
  return r1x < r2x + r2w && r1x + r1w > r2x && r1y < r2y + r2h && r1y + r1h > r2y
}

/** 圆形碰撞检测 */
export function circleCollision(
  cx1: number,
  cy1: number,
  r1: number,
  cx2: number,
  cy2: number,
  r2: number
): boolean {
  const dx = cx1 - cx2
  const dy = cy1 - cy2
  const dist = Math.sqrt(dx * dx + dy * dy)
  return dist < r1 + r2
}

/** 在两值之间随机 */
export function randBetween(min: number, max: number): number {
  return Math.random() * (max - min) + min
}

/** 线性插值 */
export function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t
}

小鸟(Bird)

typescript
export class Bird {
  x: number
  y: number
  radius = 20
  velocityY = 0
  gravity = 0.5
  jumpForce = -7

  /** 拍翅膀(跳跃)*/
  flap(): void {
    this.velocityY = this.jumpForce
  }

  /** 每帧更新 */
  update(): void {
    this.velocityY += this.gravity
    this.y += this.velocityY
  }

  /** 绘制小鸟(几何图形)*/
  draw(ctx: CanvasRenderingContext2D): void {
    ctx.save()
    ctx.translate(this.x, this.y)

    // 根据速度旋转鸟身
    const rotation = this.velocityY * 0.05
    ctx.rotate(Math.min(Math.max(rotation, -0.5), 0.5))

    // 身体(黄色圆)
    ctx.fillStyle = '#ffdd00'
    ctx.beginPath()
    ctx.arc(0, 0, this.radius, 0, Math.PI * 2)
    ctx.fill()

    // 翅膀(橙色)
    ctx.fillStyle = '#ff8800'
    ctx.beginPath()
    ctx.ellipse(-8, -4, 12, 6, -0.3, 0, Math.PI * 2)
    ctx.fill()

    // 眼睛(白色 + 黑色瞳孔)
    ctx.fillStyle = '#ffffff'
    ctx.beginPath()
    ctx.arc(6, -8, 8, 0, Math.PI * 2)
    ctx.fill()

    ctx.fillStyle = '#000000'
    ctx.beginPath()
    ctx.arc(8, -8, 4, 0, Math.PI * 2)
    ctx.fill()

    // 嘴(橙色三角)
    ctx.fillStyle = '#ff6600'
    ctx.beginPath()
    ctx.moveTo(this.radius, -2)
    ctx.lineTo(this.radius + 14, 3)
    ctx.lineTo(this.radius, 8)
    ctx.closePath()
    ctx.fill()

    ctx.restore()
  }

  /** 获取碰撞圆圆心和半径 */
  getBounds(): { cx: number; cy: number; r: number } {
    return { cx: this.x, cy: this.y, r: this.radius - 2 }
  }

  reset(screenHeight: number): void {
    this.y = screenHeight / 2
    this.velocityY = 0
  }
}

管道(Pipe)

typescript
export class Pipe {
  x: number
  gapY: number // 缺口中心 Y 坐标
  gapSize = 150 // 上下管道之间的缺口大小
  width = 60
  speedX = 3
  passed = false // 小鸟是否已通过(用于计分)
  screenWidth: number
  screenHeight: number

  constructor(screenWidth: number, screenHeight: number) {
    this.screenWidth = screenWidth
    this.screenHeight = screenHeight
    this.x = screenWidth
    this.gapY = randBetween(100, screenHeight - 200)
  }

  /** 移动管道 */
  update(): void {
    this.x -= this.speedX
  }

  /** 绘制管道(几何图形)*/
  draw(ctx: CanvasRenderingContext2D): void {
    const capHeight = 25
    const capWidth = 76 // 比管道宽,形成「帽」效果

    // 上面管道
    ctx.fillStyle = '#44aa44'
    ctx.fillRect(this.x, 0, this.width, this.gapY - this.gapSize / 2 - capHeight)

    // 上面帽
    ctx.fillStyle = '#55cc55'
    ctx.fillRect(this.x - 8, this.gapY - this.gapSize / 2 - capHeight, capWidth, capHeight)

    // 下面管道
    ctx.fillStyle = '#44aa44'
    const bottomPipeY = this.gapY + this.gapSize / 2 + capHeight
    ctx.fillRect(this.x, bottomPipeY, this.width, this.screenHeight - bottomPipeY)

    // 下面帽
    ctx.fillStyle = '#55cc55'
    ctx.fillRect(this.x - 8, this.gapY + this.gapSize / 2, capWidth, capHeight)

    // 管道边缘高光
    ctx.fillStyle = 'rgba(255,255,255,0.1)'
    ctx.fillRect(this.x + 4, 0, 6, this.gapY - this.gapSize / 2 - capHeight)
    ctx.fillRect(this.x + 4, bottomPipeY, 6, this.screenHeight - bottomPipeY)
  }

  /** 是否已离开屏幕左侧 */
  isOffScreen(): boolean {
    return this.x < -this.width
  }

  /** 获取上下管道的碰撞矩形 */
  getTopBounds() {
    return {
      x: this.x,
      y: 0,
      w: this.width,
      h: this.gapY - this.gapSize / 2,
    }
  }

  getBottomBounds() {
    return {
      x: this.x,
      y: this.gapY + this.gapSize / 2,
      w: this.width,
      h: this.screenHeight - (this.gapY + this.gapSize / 2),
    }
  }
}

管道管理器

typescript
import { rectCollision } from './utils'
import { Bird } from './Bird'
import { Pipe } from './Pipe'

export class PipeManager {
  pipes: Pipe[] = []
  screenWidth: number
  screenHeight: number
  spawnInterval = 100 // 帧数间隔
  frameCount = 0

  constructor(screenWidth: number, screenHeight: number) {
    this.screenWidth = screenWidth
    this.screenHeight = screenHeight
  }

  update(): void {
    this.frameCount++

    // 每隔一定帧数生成新管道
    if (this.frameCount >= this.spawnInterval) {
      this.pipes.push(new Pipe(this.screenWidth, this.screenHeight))
      this.frameCount = 0
    }

    // 更新所有管道
    for (const pipe of this.pipes) {
      pipe.update()
    }

    // 移除离开屏幕的管道
    this.pipes = this.pipes.filter((p) => !p.isOffScreen())
  }

  draw(ctx: CanvasRenderingContext2D): void {
    for (const pipe of this.pipes) {
      pipe.draw(ctx)
    }
  }

  /** 检查小鸟与管道碰撞 */
  checkCollision(bird: Bird): boolean {
    for (const pipe of this.pipes) {
      // 显式引用碰撞矩形属性,避免依赖 Object.values 的枚举顺序
      const top = pipe.getTopBounds()
      if (
        rectCollision(
          bird.x - bird.radius,
          bird.y - bird.radius,
          bird.radius * 2,
          bird.radius * 2,
          top.x,
          top.y,
          top.w,
          top.h
        )
      )
        return true

      const bottom = pipe.getBottomBounds()
      if (
        rectCollision(
          bird.x - bird.radius,
          bird.y - bird.radius,
          bird.radius * 2,
          bird.radius * 2,
          bottom.x,
          bottom.y,
          bottom.w,
          bottom.h
        )
      )
        return true
    }
    return false
  }

  /** 检查是否通过新管道(计分)*/
  checkScore(bird: Bird): number {
    let scored = 0
    for (const pipe of this.pipes) {
      if (!pipe.passed && bird.x > pipe.x + pipe.width) {
        pipe.passed = true
        scored++
      }
    }
    return scored
  }

  clear(): void {
    this.pipes = []
    this.frameCount = 0
  }
}
碰撞检测精度说明

本文示例使用 AABB 矩形碰撞rectCollision)检测小鸟与管道的碰撞。但小鸟实际被绘制为圆形ctx.arc()),这会导致角落处判定偏大(玩家感觉"明明没碰到却死了")。

如果你想要更精确的碰撞体验,可以改用 circleRectCollision 替代 rectCollision

typescript
// 将 src/GameManager.ts 中的碰撞检测从:
if (rectCollision(birdRect, pipe)) {
  /* ... */
}

// 改为:
const birdCenterX = this.bird.x + this.bird.width / 2
const birdCenterY = this.bird.y + this.bird.height / 2
const birdRadius = this.bird.width / 2
if (circleRectCollision(birdCenterX, birdCenterY, birdRadius, pipe)) {
  /* ... */
}

circleRectCollision 函数已在 src/utils.ts 中实现,直接调用即可。对于原型阶段或休闲游戏,AABB 的轻微不精确是可以接受的——关键在于玩家的主观感受,而非物理精确度。


输入管理器

typescript
export class InputManager {
  private onFlap: () => void
  private touchHandler: (() => void) | null = null

  constructor(onFlap: () => void) {
    this.onFlap = onFlap
    this.bindEvents()
  }

  private bindEvents(): void {
    // 保存处理器引用,便于后续解绑
    this.touchHandler = () => {
      this.onFlap()
    }

    // 触摸开始(手指按下)
    wx.onTouchStart(this.touchHandler)

    // 或者使用全局 canvas 点击
    // canvas.addEventListener('touchstart', this.touchHandler)
  }

  /** 解绑输入事件,防止组件重建后监听累积 */
  destroy(): void {
    if (this.touchHandler) {
      wx.offTouchStart(this.touchHandler)
      this.touchHandler = null
    }
  }
}

UI 管理器

typescript
export class UIManager {
  score = 0
  highScore = 0
  screenWidth: number
  screenHeight: number

  constructor(screenWidth: number, screenHeight: number) {
    this.screenWidth = screenWidth
    this.screenHeight = screenHeight
    this.loadHighScore()
  }

  private loadHighScore(): void {
    try {
      this.highScore = Number(wx.getStorageSync('flappy_highscore')) || 0
    } catch {
      // 存储读取失败(首次启动或存储异常),使用默认值
      this.highScore = 0
    }
  }

  private saveHighScore(): void {
    try {
      wx.setStorageSync('flappy_highscore', String(this.highScore))
    } catch {
      /* 存储满,静默失败 */
    }
  }

  addScore(n: number): void {
    this.score += n
    if (this.score > this.highScore) {
      this.highScore = this.score
      this.saveHighScore()
    }
  }

  drawScore(ctx: CanvasRenderingContext2D): void {
    ctx.fillStyle = '#ffffff'
    ctx.font = 'bold 48px "Courier New", monospace'
    ctx.textAlign = 'center'
    ctx.strokeStyle = '#000000'
    ctx.lineWidth = 4
    ctx.strokeText(String(this.score), this.screenWidth / 2, 80)
    ctx.fillText(String(this.score), this.screenWidth / 2, 80)
  }

  drawGameOver(ctx: CanvasRenderingContext2D): void {
    // 半透明遮罩
    ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
    ctx.fillRect(0, 0, this.screenWidth, this.screenHeight)

    // Game Over 面板
    const panelW = 280
    const panelH = 240
    const px = this.screenWidth / 2 - panelW / 2
    const py = this.screenHeight / 2 - panelH / 2

    ctx.fillStyle = '#1a1a2e'
    ctx.strokeStyle = '#00e5ff'
    ctx.lineWidth = 2
    this.roundRect(ctx, px, py, panelW, panelH, 16)
    ctx.fill()
    ctx.stroke()

    // 标题
    ctx.fillStyle = '#ff6ec7'
    ctx.font = 'bold 28px sans-serif'
    ctx.textAlign = 'center'
    ctx.fillText('游戏结束', this.screenWidth / 2, py + 45)

    // 分数
    ctx.fillStyle = '#e8e8f0'
    ctx.font = '20px sans-serif'
    ctx.fillText(`得分: ${this.score}`, this.screenWidth / 2, py + 85)
    ctx.fillStyle = '#00ff88'
    ctx.fillText(`最高: ${this.highScore}`, this.screenWidth / 2, py + 115)

    // 重新开始按钮
    const btnW = 200
    const btnH = 45
    const bx = this.screenWidth / 2 - btnW / 2
    const by = py + 145

    ctx.fillStyle = '#00e5ff'
    this.roundRect(ctx, bx, by, btnW, btnH, 8)
    ctx.fill()
    ctx.fillStyle = '#0a0a0f'
    ctx.font = 'bold 18px sans-serif'
    ctx.fillText('触摸重新开始', this.screenWidth / 2, by + 30)
  }

  drawStartScreen(ctx: CanvasRenderingContext2D): void {
    // 标题
    ctx.fillStyle = '#ff6ec7'
    ctx.font = 'bold 42px sans-serif'
    ctx.textAlign = 'center'
    ctx.fillText('Flappy Bird', this.screenWidth / 2, this.screenHeight / 3)

    // 副标题
    ctx.fillStyle = '#a0a0b8'
    ctx.font = '18px sans-serif'
    ctx.fillText('触摸屏幕开始游戏', this.screenWidth / 2, this.screenHeight / 3 + 45)
  }

  /** 绘制圆角矩形 */
  private roundRect(
    ctx: CanvasRenderingContext2D,
    x: number,
    y: number,
    w: number,
    h: number,
    r: number
  ): void {
    ctx.beginPath()
    ctx.moveTo(x + r, y)
    ctx.lineTo(x + w - r, y)
    ctx.arcTo(x + w, y, x + w, y + r, r)
    ctx.lineTo(x + w, y + h - r)
    ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
    ctx.lineTo(x + r, y + h)
    ctx.arcTo(x, y + h, x, y + h - r, r)
    ctx.lineTo(x, y + r)
    ctx.arcTo(x, y, x + r, y, r)
    ctx.closePath()
  }
}

音频系统

原版 Flappy Bird 最简单的音效方案——使用 wx.createInnerAudioContext 实现轻量音效管理。

AudioManager 实现

typescript
/**
 * 简单音频管理器——管理游戏中的背景音乐和音效
 * 依赖 wx.createInnerAudioContext(微信基础库 ≥ 1.0.0 即可)
 */
export class AudioManager {
  private static instance: AudioManager
  private bgm = wx.createInnerAudioContext()
  private sfxBoom = wx.createInnerAudioContext()

  // 音频资源路径(建议放在分包中)
  private readonly SFX_FLAP = 'audio/flap.mp3'
  private readonly SFX_SCORE = 'audio/score.mp3'
  private readonly SFX_DIE = 'audio/die.mp3'

  static getInstance(): AudioManager {
    if (!AudioManager.instance) {
      AudioManager.instance = new AudioManager()
    }
    return AudioManager.instance
  }

  /** 初始化——设置基础属性 */
  init(): void {
    // BGM 设置
    this.bgm.loop = true
    this.bgm.volume = 0.5
    this.bgm.src = 'audio/bgm.mp3'

    // 短音效设置
    this.sfxBoom.volume = 0.8
  }

  /** 播放背景音乐(需在用户首次交互后调用) */
  playBGM(): void {
    this.bgm.seek(0)
    this.bgm.play()
  }

  /** 暂停背景音乐 */
  pauseBGM(): void {
    this.bgm.pause()
  }

  /** 播放音效(使用短音频复用模式) */
  playSFX(src: string): void {
    // 小技巧:对于短音效,直接 seek(0) + play() 避免创建多个实例
    this.sfxBoom.src = src
    this.sfxBoom.seek(0)
    this.sfxBoom.play()
  }

  /** 播放小鸟扇翅膀音效 */
  playFlap(): void {
    this.playSFX(this.SFX_FLAP)
  }

  /** 播放得分音效 */
  playScore(): void {
    this.playSFX(this.SFX_SCORE)
  }

  /** 播放死亡音效 */
  playDie(): void {
    this.playSFX(this.SFX_DIE)
  }

  /** 静音/取消静音 */
  setMuted(muted: boolean): void {
    this.bgm.volume = muted ? 0 : 0.5
    this.sfxBoom.volume = muted ? 0 : 0.8
  }

  /** 销毁释放资源 */
  destroy(): void {
    this.bgm.destroy()
    this.sfxBoom.destroy()
  }
}

// 使用示例(在 GameManager 中集成):
// const audio = AudioManager.getInstance()
// audio.init()
// audio.playFlap()  // 小鸟扇翅膀时
// audio.playScore() // 通过管道时
// audio.playDie()   // 碰撞时

音频格式建议:微信小游戏推荐使用 mp3aac 格式。短音效控制在 100KB 以内,BGM 控制在 1-3MB。由于示例项目无额外资源,实际使用时可将音频文件放入 audio/ 目录或从 CDN 加载。


游戏主控制器

typescript
import { Bird } from './Bird'
import { PipeManager } from './PipeManager'
import { UIManager } from './UIManager'
import { InputManager } from './InputManager'

enum GameState {
  Ready,
  Playing,
  GameOver,
}

export class GameManager {
  private canvas: WechatMinigame.Canvas
  private ctx: CanvasRenderingContext2D
  private bird: Bird
  private pipes: PipeManager
  private ui: UIManager
  private input: InputManager

  private state = GameState.Ready
  screenWidth: number
  screenHeight: number
  private bgOffset = 0
  private rafId = 0
  private isPaused = false

  constructor() {
    // 创建全屏画布
    this.canvas = wx.createCanvas()
    this.ctx = this.canvas.getContext('2d')!
    this.screenWidth = this.canvas.width
    this.screenHeight = this.canvas.height

    // 初始化子系统
    this.bird = new Bird()
    this.bird.x = 120
    this.bird.reset(this.screenHeight)

    this.pipes = new PipeManager(this.screenWidth, this.screenHeight)
    this.ui = new UIManager(this.screenWidth, this.screenHeight)

    // 输入处理
    this.input = new InputManager(() => this.onFlap())

    // 监听小游戏前后台切换,避免后台空跑游戏循环
    wx.onShow(() => {
      this.isPaused = false
      this.gameLoop()
    })
    wx.onHide(() => {
      this.isPaused = true
    })

    // 启动游戏循环
    this.gameLoop()
  }

  private onFlap(): void {
    switch (this.state) {
      case GameState.Ready:
        this.startGame()
        break
      case GameState.Playing:
        this.bird.flap()
        break
      case GameState.GameOver:
        this.restart()
        break
    }
  }

  private startGame(): void {
    this.state = GameState.Playing
    this.bird.flap()
  }

  private restart(): void {
    this.state = GameState.Ready
    this.bird.reset(this.screenHeight)
    this.pipes.clear()
    this.ui.score = 0
  }

  private gameLoop(): void {
    if (this.isPaused) return

    this.update()
    this.draw()

    this.rafId = requestAnimationFrame(() => this.gameLoop())
  }

  /** 清理游戏资源与监听,防止页面/场景切换后循环继续运行 */
  destroy(): void {
    this.isPaused = true
    cancelAnimationFrame(this.rafId)
    this.input.destroy()
    wx.offShow()
    wx.offHide()
  }

  private update(): void {
    if (this.state === GameState.Ready) {
      // Ready 状态只更新背景
      this.bgOffset = (this.bgOffset - 0.5) % this.screenWidth
      return
    }

    if (this.state === GameState.GameOver) return

    // 更新小鸟
    this.bird.update()

    // 更新管道
    this.pipes.update()

    // 碰撞检测
    if (this.pipes.checkCollision(this.bird)) {
      this.gameOver()
    }

    // 边界检查
    if (this.bird.y - this.bird.radius < 0 || this.bird.y + this.bird.radius > this.screenHeight) {
      this.gameOver()
    }

    // 计分
    const scored = this.pipes.checkScore(this.bird)
    if (scored > 0) {
      this.ui.addScore(scored)
    }

    // 背景滚动
    this.bgOffset = (this.bgOffset - 1) % this.screenWidth
  }

  private gameOver(): void {
    this.state = GameState.GameOver
  }

  private draw(): void {
    const { ctx } = this

    // 背景
    this.drawBackground(ctx)

    // 管道
    this.pipes.draw(ctx)

    // 小鸟
    this.bird.draw(ctx)

    // UI 层
    if (this.state === GameState.Ready) {
      this.ui.drawStartScreen(ctx)
    }

    if (this.state === GameState.Playing || this.state === GameState.GameOver) {
      this.ui.drawScore(ctx)
    }

    if (this.state === GameState.GameOver) {
      this.ui.drawGameOver(ctx)
    }
  }

  private drawBackground(ctx: CanvasRenderingContext2D): void {
    // 天空渐变
    const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight)
    gradient.addColorStop(0, '#1a1a40')
    gradient.addColorStop(0.5, '#2a2a60')
    gradient.addColorStop(1, '#0a0a20')
    ctx.fillStyle = gradient
    ctx.fillRect(0, 0, this.screenWidth, this.screenHeight)

    // 云朵装饰
    ctx.fillStyle = 'rgba(255, 255, 255, 0.05)'
    this.drawCloud(ctx, (100 + this.bgOffset * 0.3) % this.screenWidth, 80, 60)
    this.drawCloud(ctx, (350 + this.bgOffset * 0.2) % this.screenWidth, 150, 40)
    this.drawCloud(ctx, (600 + this.bgOffset * 0.4) % this.screenWidth, 60, 50)

    // 地面
    ctx.fillStyle = '#2d5a1e'
    ctx.fillRect(0, this.screenHeight - 60, this.screenWidth, 60)
    ctx.fillStyle = '#44aa44'
    ctx.fillRect(0, this.screenHeight - 60, this.screenWidth, 6)
  }

  private drawCloud(ctx: CanvasRenderingContext2D, x: number, y: number, size: number): void {
    ctx.beginPath()
    ctx.arc(x, y, size * 0.5, 0, Math.PI * 2)
    ctx.arc(x + size * 0.3, y - size * 0.1, size * 0.4, 0, Math.PI * 2)
    ctx.arc(x + size * 0.6, y, size * 0.45, 0, Math.PI * 2)
    ctx.arc(x - size * 0.3, y - size * 0.05, size * 0.35, 0, Math.PI * 2)
    ctx.fill()
  }
}

入口文件

javascript
// game.js — 项目入口
import { GameManager } from './dist/GameManager'

// 游戏启动
new GameManager()

完整运行

至此,一个完整的 Flappy Bird 已经可以运行了。总结核心概念:

概念对应实现前端类比
游戏循环GameManager.gameLoop() + requestAnimationFrameReact useEffect 定时器
状态管理GameState 枚举 + switchRedux / Zustand store
实体系统Bird / Pipe 独立类React 组件
碰撞检测rectCollision / circleCollisionDOM getBoundingClientRect 比对
输入处理wx.onTouchStartonFlap()onClick 事件
UI 渲染Canvas 绘制文字/图形DOM/CSS 渲染
本地存储wx.getStorageSynclocalStorage
下一步

完成原生 Canvas 版本后,建议进入 Cocos Creator 重写版 学习如何使用引擎进行组件化开发。


📝 课后练习

练习 1:难度递增

题目: 让管道生成间隔随分数增加而缩短,移动速度逐渐加快。

参考答案思路:PipeManager 中根据 score 动态调整 spawnIntervalspeedX。例如每得 5 分,速度增加 10%,间隔减少 5%。

练习 2:暂停与恢复

题目: 实现双击屏幕暂停游戏,再次双击恢复。

参考答案思路: 增加 isPaused 状态,在 GameManager.gameLoop() 开头判断;暂停时跳过 update 调用但继续渲染暂停菜单。

练习 3:视差背景

题目: 给背景云朵和地面分别设置不同滚动速度,形成视差效果。

参考答案思路: 用多个 bgOffset 变量乘以不同系数,如云朵 0.2、地面 1.0,在 render 中分层绘制。

📚 相关阅读

用心学习,持续实践