用原生JS和Canvas复刻Flappy Bird:从零实现一个能玩的网页小游戏

张开发
2026/6/10 11:51:06 15 分钟阅读

分享文章

用原生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(); } }物理模拟的关键参数参数初始值作用velocity0当前垂直速度(正数向下)gravity0.5重力加速度(每帧增加的速度)jumpForce-10点击时施加的向上力rotation0根据速度计算的旋转角度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 titleFlappy Bird/title link relstylesheet hrefstyle.css /head body script srcdist/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 DevToolsPerformance面板分析帧率Memory面板检查内存泄漏Layers查看复合层情况Stats.jsimport 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渲染虽然复杂度更高但能学习到更底层的图形编程知识。

更多文章