用原生JS和Canvas复刻Flappy Bird:从零实现一个能玩的网页小游戏
用原生JS和Canvas复刻Flappy Bird:从零实现一个能玩的网页小游戏
在游戏开发的世界里,没有什么比亲手实现一个经典游戏更能检验和提升编程技能了。Flappy Bird这个看似简单的游戏,实际上包含了游戏开发中最核心的几个概念:游戏循环、物理模拟、碰撞检测和用户交互。对于前端开发者来说,用原生JavaScript和Canvas来复刻它,不仅能巩固基础,还能深入理解游戏开发的底层原理。
与使用现成游戏引擎不同,原生实现让我们有机会从零开始构建每一个游戏组件,理解每一行代码背后的意义。本文将带你一步步实现一个完整的Flappy Bird游戏,重点不是复制代码,而是理解"为什么这么做"——为什么选择Canvas而不是CSS动画?为什么游戏循环要这样设计?不同的碰撞检测方法各有什么优劣?
1. 游戏基础架构搭建
1.1 Canvas画布初始化
任何Canvas游戏的第一步都是创建和配置画布。我们不仅需要设置画布尺寸,还要考虑如何组织代码结构以便后续扩展:
const Game = { canvas: document.createElement('canvas'), ctx: null, width: 360, height: 640, init() { this.canvas.width = this.width; this.canvas.height = this.height; this.ctx = this.canvas.getContext('2d'); document.body.appendChild(this.canvas); // 游戏状态管理 this.state = { current: 'ready', // ready, playing, gameover score: 0 }; // 初始化游戏对象 this.bird = new Bird(this); this.pipes = new Pipes(this); // 开始游戏循环 this.lastTime = 0; requestAnimationFrame(this.loop.bind(this)); }, loop(timestamp) { const deltaTime = timestamp - this.lastTime; this.lastTime = timestamp; this.update(deltaTime); this.render(); requestAnimationFrame(this.loop.bind(this)); }, update(deltaTime) { // 根据游戏状态更新不同对象 if (this.state.current === 'playing') { this.bird.update(deltaTime); this.pipes.update(deltaTime); } }, render() { // 清空画布 this.ctx.clearRect(0, 0, this.width, this.height); // 绘制背景 this.ctx.fillStyle = '#70c5ce'; this.ctx.fillRect(0, 0, this.width, this.height); // 根据游戏状态渲染不同内容 this.pipes.render(this.ctx); this.bird.render(this.ctx); // 显示分数 this.ctx.fillStyle = '#fff'; this.ctx.font = '30px Arial'; this.ctx.fillText(this.state.score, 20, 40); } }; // 启动游戏 window.onload = () => Game.init();这个基础架构有几个关键设计点:
- 使用requestAnimationFrame:相比setInterval,它能提供更流畅的动画效果,并自动匹配显示器的刷新率
- deltaTime计算:记录帧间隔时间,确保在不同刷新率设备上游戏速度一致
- 状态管理:通过state对象管理游戏的不同阶段(准备、进行中、结束)
1.2 游戏对象抽象
良好的面向对象设计能让代码更易维护和扩展。我们为游戏中的主要元素创建类:
class GameObject { constructor(game) { this.game = game; this.x = 0; this.y = 0; this.width = 0; this.height = 0; } update(deltaTime) { // 由子类实现具体逻辑 } render(ctx) { // 由子类实现具体绘制 } get bounds() { return { left: this.x, right: this.x + this.width, top: this.y, bottom: this.y + this.height }; } }这个基类定义了游戏对象的基本属性和方法,后续的Bird和Pipes类都将继承它。这种设计模式的优势在于:
- 代码复用:公共方法和属性只需定义一次
- 统一接口:所有游戏对象都有update和render方法,便于管理
- 类型检查:可以通过instanceof判断对象类型
2. 游戏核心机制实现
2.1 小鸟物理系统
Flappy Bird的核心玩法在于控制小鸟飞行,这需要模拟重力和跳跃物理效果:
class Bird extends GameObject { constructor(game) { super(game); this.width = 34; this.height = 24; this.x = 60; this.y = game.height / 2 - this.height / 2; // 物理参数 this.velocity = 0; this.gravity = 0.5; this.jumpForce = -10; this.rotation = 0; // 控制标志 this.isFlapping = false; // 加载图像资源 this.image = new Image(); this.image.src = 'bird.png'; } update(deltaTime) { // 应用重力 this.velocity += this.gravity; this.y += this.velocity; // 旋转效果 this.rotation = Math.min(Math.max(this.velocity * 5, -25), 90); // 边界检测 if (this.y < 0) { this.y = 0; this.velocity = 0; } if (this.y > this.game.height - this.height) { this.y = this.game.height - this.height; this.game.state.current = 'gameover'; } } jump() { this.velocity = this.jumpForce; this.isFlapping = true; setTimeout(() => this.isFlapping = false, 100); } render(ctx) { ctx.save(); ctx.translate(this.x + this.width / 2, this.y + this.height / 2); ctx.rotate(this.rotation * Math.PI / 180); // 绘制小鸟 ctx.drawImage( this.image, -this.width / 2, -this.height / 2, this.width, this.height ); ctx.restore(); } }物理模拟的关键参数:
| 参数 | 初始值 | 作用 |
|---|---|---|
| velocity | 0 | 当前垂直速度(正数向下) |
| gravity | 0.5 | 重力加速度(每帧增加的速度) |
| jumpForce | -10 | 点击时施加的向上力 |
| rotation | 0 | 根据速度计算的旋转角度 |
2.2 管道系统实现
管道是游戏的主要障碍物,需要随机生成并移动:
class Pipes extends GameObject { constructor(game) { super(game); this.pipes = []; this.gap = 120; // 上下管道间的空隙 this.frequency = 1500; // 生成新管道的间隔(ms) this.speed = 2; // 管道移动速度 this.lastPipeTime = 0; this.width = 52; } update(deltaTime) { const now = Date.now(); // 生成新管道 if (now - this.lastPipeTime > this.frequency) { this.createPipe(); this.lastPipeTime = now; } // 更新所有管道位置 for (let i = this.pipes.length - 1; i >= 0; i--) { this.pipes[i].x -= this.speed; // 移除屏幕外的管道 if (this.pipes[i].x < -this.width) { this.pipes.splice(i, 1); this.game.state.score++; // 通过一对管道得1分 } } } createPipe() { const height = Math.random() * 200 + 100; // 随机高度 const topPipe = { x: this.game.width, y: 0, height }; const bottomPipe = { x: this.game.width, y: height + this.gap, height: this.game.height - height - this.gap }; this.pipes.push(topPipe, bottomPipe); } render(ctx) { ctx.fillStyle = '#74bf2e'; this.pipes.forEach(pipe => { ctx.fillRect(pipe.x, pipe.y, this.width, pipe.height); // 管道顶部/底部的装饰 ctx.fillStyle = '#5da22d'; ctx.fillRect(pipe.x - 3, pipe.y, this.width + 6, 20); }); } }管道系统的关键设计:
- 随机生成:通过Math.random()创建不同高度的管道
- 对象池模式:复用移出屏幕的管道对象,避免频繁创建销毁
- 碰撞体积:虽然绘制了装饰部分,但碰撞检测只考虑主体矩形
3. 碰撞检测与交互
3.1 精确碰撞检测
游戏需要检测小鸟与管道、地面的碰撞:
class Game { // ...其他代码... checkCollisions() { // 地面碰撞 if (this.bird.y + this.bird.height >= this.height) { this.state.current = 'gameover'; return; } // 管道碰撞 for (const pipe of this.pipes.pipes) { if ( this.bird.x < pipe.x + this.pipes.width && this.bird.x + this.bird.width > pipe.x && this.bird.y < pipe.y + pipe.height && this.bird.y + this.bird.height > pipe.y ) { this.state.current = 'gameover'; return; } } } update(deltaTime) { if (this.state.current === 'playing') { this.bird.update(deltaTime); this.pipes.update(deltaTime); this.checkCollisions(); } } }碰撞检测的几种实现方式对比:
| 方法 | 精度 | 性能 | 适用场景 |
|---|---|---|---|
| 矩形检测 | 中 | 高 | 简单几何形状 |
| 圆形检测 | 中 | 高 | 圆形或近似圆形物体 |
| 像素检测 | 高 | 低 | 需要精确碰撞 |
| 分离轴定理 | 高 | 中 | 复杂多边形 |
对于Flappy Bird这种简单游戏,矩形检测完全够用。如果需要更精确的检测,可以考虑:
- 使用多个小矩形组合复杂形状
- 为小鸟实现圆形检测(更符合其形状)
- 预先计算碰撞遮罩
3.2 用户输入处理
游戏通过点击或按键控制小鸟跳跃:
class Game { // ...其他代码... init() { // ...其他初始化... this.setupControls(); } setupControls() { // 鼠标/触摸控制 this.canvas.addEventListener('click', () => { if (this.state.current === 'ready') { this.state.current = 'playing'; } this.bird.jump(); }); // 键盘控制 document.addEventListener('keydown', (e) => { if (e.code === 'Space') { if (this.state.current === 'ready') { this.state.current = 'playing'; } this.bird.jump(); } }); // 移动端触摸控制 this.canvas.addEventListener('touchstart', (e) => { e.preventDefault(); if (this.state.current === 'ready') { this.state.current = 'playing'; } this.bird.jump(); }); } }输入处理的最佳实践:
- 多平台支持:同时考虑鼠标、键盘和触摸输入
- 事件委托:在canvas上监听事件而非整个文档
- 防误触:移动端使用touchstart而非click减少延迟
- 状态检查:根据游戏状态决定输入效果
4. 游戏优化与扩展
4.1 性能优化技巧
即使是这样的小游戏,优化也很重要:
// 图像资源预加载 const assets = { bird: 'bird.png', background: 'bg.png', pipe: 'pipe.png' }; const loadedAssets = {}; let assetsLoaded = 0; function loadAssets() { Object.keys(assets).forEach(key => { const img = new Image(); img.src = assets[key]; img.onload = () => { loadedAssets[key] = img; assetsLoaded++; if (assetsLoaded === Object.keys(assets).length) { Game.init(); } }; }); } // 使用离屏canvas缓存静态元素 const backgroundCache = document.createElement('canvas'); backgroundCache.width = Game.width; backgroundCache.height = Game.height; const bgCtx = backgroundCache.getContext('2d'); // 绘制背景到缓存 bgCtx.fillStyle = '#70c5ce'; bgCtx.fillRect(0, 0, Game.width, Game.height);优化策略对比:
| 优化方法 | 实现难度 | 效果 | 适用场景 |
|---|---|---|---|
| 资源预加载 | 低 | 中 | 有图像/音频资源时 |
| 对象池 | 中 | 高 | 频繁创建销毁对象 |
| 离屏缓存 | 中 | 高 | 静态或重复绘制内容 |
| 脏矩形 | 高 | 极高 | 局部更新的复杂场景 |
4.2 游戏状态与特效
增强游戏体验的视觉效果:
class Game { // ...其他代码... render() { // 绘制缓存的背景 this.ctx.drawImage(backgroundCache, 0, 0); // 游戏状态相关渲染 switch (this.state.current) { case 'ready': this.renderReadyScreen(); break; case 'gameover': this.renderGameOver(); break; } // 绘制游戏对象 this.pipes.render(this.ctx); this.bird.render(this.ctx); this.renderScore(); } renderReadyScreen() { this.ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; this.ctx.fillRect(0, 0, this.width, this.height); this.ctx.fillStyle = '#fff'; this.ctx.font = '30px Arial'; this.ctx.textAlign = 'center'; this.ctx.fillText('点击屏幕开始游戏', this.width / 2, this.height / 2); this.ctx.textAlign = 'left'; } renderGameOver() { this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; this.ctx.fillRect(0, 0, this.width, this.height); this.ctx.fillStyle = '#fff'; this.ctx.font = '30px Arial'; this.ctx.textAlign = 'center'; this.ctx.fillText('游戏结束', this.width / 2, this.height / 2 - 40); this.ctx.fillText(`得分: ${this.state.score}`, this.width / 2, this.height / 2); this.ctx.fillText('点击重新开始', this.width / 2, this.height / 2 + 40); this.ctx.textAlign = 'left'; } renderScore() { this.ctx.fillStyle = '#fff'; this.ctx.font = '30px Arial'; this.ctx.fillText(this.state.score, 20, 40); } }可以进一步添加的特效:
- 粒子效果:小鸟撞击时的爆炸粒子
- 视差滚动:多层背景营造深度感
- 动画过渡:游戏状态切换时的平滑过渡
- 音效反馈:跳跃、得分和碰撞的音效
5. 项目结构与构建
5.1 模块化组织代码
随着功能增加,需要更好的代码组织方式:
/flappy-bird ├── index.html ├── assets/ │ ├── images/ │ ├── sounds/ ├── src/ │ ├── game.js # 主游戏类 │ ├── bird.js # 小鸟类 │ ├── pipes.js # 管道系统 │ ├── utils.js # 工具函数 │ └── main.js # 入口文件 └── style.css使用ES6模块拆分代码:
// game.js export default class Game { // ...游戏主逻辑... } // bird.js export default class Bird extends GameObject { // ...小鸟实现... } // main.js import Game from './game.js'; import Bird from './bird.js'; import Pipes from './pipes.js'; const game = new Game(); game.init();模块化的优势:
- 关注点分离:每个类/模块职责单一
- 可维护性:更容易定位和修改特定功能
- 可测试性:可以单独测试各个模块
- 团队协作:不同开发者可以并行工作
5.2 构建与部署
现代前端项目通常需要构建步骤:
安装必要工具:
npm init -y npm install --save-dev webpack webpack-cli babel-loader @babel/core @babel/preset-envwebpack配置:
// webpack.config.js module.exports = { entry: './src/main.js', output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist') }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'] } } } ] } };添加构建脚本:
{ "scripts": { "build": "webpack --mode production", "dev": "webpack --mode development --watch" } }HTML引入:
<!DOCTYPE html> <html> <head> <title>Flappy Bird</title> <link rel="stylesheet" href="style.css"> </head> <body> <script src="dist/bundle.js"></script> </body> </html>
构建流程带来的好处:
- 代码压缩:减少文件体积
- 浏览器兼容:通过Babel转译ES6+语法
- 资源优化:可以集成图片压缩等插件
- 开发体验:支持热更新等功能
6. 进阶方向与扩展思路
6.1 添加更多游戏功能
基础版本完成后,可以考虑扩展:
难度系统:
- 随分数增加管道移动速度
- 缩小管道间隙
- 增加特殊障碍物
道具系统:
- 临时无敌
- 分数加倍
- 磁铁吸引金币
成就系统:
- 连续通过多个管道的连击奖励
- 特定分数里程碑
- 特殊动作成就
多人模式:
- 本地双人轮流游戏
- 网络对战看谁坚持更久
- 异步比分排行榜
6.2 跨平台适配
让游戏适应不同平台:
响应式设计:
function resize() { const ratio = window.innerHeight / Game.height; Game.canvas.style.width = `${Game.width * ratio}px`; Game.canvas.style.height = `${window.innerHeight}px`; } window.addEventListener('resize', resize); resize();移动端优化:
- 调整控制灵敏度
- 添加虚拟按钮
- 优化触控反馈
PWA支持:
- 添加manifest文件
- 实现Service Worker缓存
- 支持离线游玩
6.3 性能监控与调试
开发过程中的性能工具:
Chrome DevTools:
- Performance面板分析帧率
- Memory面板检查内存泄漏
- Layers查看复合层情况
Stats.js:
import Stats from 'stats.js'; const stats = new Stats(); stats.showPanel(0); // 0: fps, 1: ms, 2: mb document.body.appendChild(stats.dom); function loop() { stats.begin(); // 游戏逻辑 stats.end(); requestAnimationFrame(loop); }自定义性能标记:
console.time('render'); // 渲染代码 console.timeEnd('render');
7. 从项目中学到的经验
实现这个Flappy Bird复刻版的过程中,有几个特别值得注意的教训:
物理参数的微调比预期中更重要 - 最初的重力值和跳跃力设置让游戏要么太难要么太简单,经过多次测试才找到平衡点。一个实用的调试方法是暴露这些参数到URL查询字符串中,方便快速调整测试。
移动端触摸延迟是个大问题。最初的click事件在手机上响应明显迟缓,后来改用touchstart并添加
e.preventDefault()才解决。这个细节会极大影响游戏体验。Canvas绘制顺序容易出错。有次背景覆盖了分数显示,调试半天才发现是render调用顺序不对。现在我会在代码中明确注释绘制层次。
游戏状态管理随着功能增加变得越来越复杂。最初只用几个布尔标志,后来改用状态模式才让代码清晰起来。这也让我意识到即使是小游戏,良好的架构也很重要。
对于想要进一步挑战的开发者,可以尝试用TypeScript重写这个项目,静态类型检查能避免很多潜在错误。或者尝试使用WebGL渲染,虽然复杂度更高,但能学习到更底层的图形编程知识。
