主题切换
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 # 工具函数实现步骤
我们将按以下顺序实现完整游戏:
- 工具函数 — 碰撞检测与随机数
- 小鸟 Bird — 重力、跳跃、绘制
- 管道 Pipe — 上下管道生成与移动
- 管道管理器 PipeManager — 无限生成、碰撞检测、计分
- 输入管理器 InputManager — 触摸事件绑定
- UI 管理器 UIManager — 分数、开始/结束界面
- 游戏主控制器 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 模块才能被微信运行时识别。本示例使用
tsc将src/**/*.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() // 碰撞时音频格式建议:微信小游戏推荐使用 mp3 或 aac 格式。短音效控制在 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() + requestAnimationFrame | React useEffect 定时器 |
| 状态管理 | GameState 枚举 + switch | Redux / Zustand store |
| 实体系统 | Bird / Pipe 独立类 | React 组件 |
| 碰撞检测 | rectCollision / circleCollision | DOM getBoundingClientRect 比对 |
| 输入处理 | wx.onTouchStart → onFlap() | onClick 事件 |
| UI 渲染 | Canvas 绘制文字/图形 | DOM/CSS 渲染 |
| 本地存储 | wx.getStorageSync | localStorage |
下一步
完成原生 Canvas 版本后,建议进入 Cocos Creator 重写版 学习如何使用引擎进行组件化开发。
📝 课后练习
练习 1:难度递增
题目: 让管道生成间隔随分数增加而缩短,移动速度逐渐加快。
参考答案思路: 在 PipeManager 中根据 score 动态调整 spawnInterval 与 speedX。例如每得 5 分,速度增加 10%,间隔减少 5%。
练习 2:暂停与恢复
题目: 实现双击屏幕暂停游戏,再次双击恢复。
参考答案思路: 增加 isPaused 状态,在 GameManager.gameLoop() 开头判断;暂停时跳过 update 调用但继续渲染暂停菜单。
练习 3:视差背景
题目: 给背景云朵和地面分别设置不同滚动速度,形成视差效果。
参考答案思路: 用多个 bgOffset 变量乘以不同系数,如云朵 0.2、地面 1.0,在 render 中分层绘制。
📚 相关阅读
- Flappy Bird — Cocos Creator 重写 — 引擎化重构
- 文件系统与本地存储 — 持久化最高分
- 粒子系统实现 — 为游戏添加特效