☰
FlyBird简易飞行游戏开发实战:Canvas循环、物理与碰撞检测全解析
2026/10/14 3:56:21 网站建设 项目流程

开始写。这是扎根在实战里的东西,不用绕弯子。

FlyBird 这种类型的项目,很多人以为只是个简单的“小游戏练手”,真做起来才发现,它把游戏开发里最核心的几块硬骨头全占了:循环、物理、碰撞、状态管理、资源加载、屏幕适配。基本就是“一锅端”。我最早写它也抱着“半天搞定”的心态,结果在碰撞判定和计分时序上反复折腾到深夜。但这恰恰是它最大的价值——花一两天时间,把游戏开发的主干走通一遍,后面再做任何带“动起来”的项目,思路都会清晰得多。

这篇文章就以“FlyBird - 简易飞行游戏”的完整实现为例,把从设计拆解、逻辑实现、界面拼装到上线前打磨的整个流程掰开揉碎了讲。无论你是刚啃完语法想找项目练手,还是已经做过几个页面想往游戏方向转,都可以直接照着走一遍,不绕弯子。

1. 整体设计与机制拆解

动手写一个游戏,最忌讳上来就敲代码。在动手前先把设计理顺,后面能少走很多弯路。FlyBird 看起来功能简单,但它的几个核心机制都很有代表性,值得先逐一拆解。

1.1 核心需求解析

FlyBird 的核心玩法用一个句子就能讲清楚:玩家控制一只鸟,持续点击/触摸让鸟向上飞,松开就下落,穿过上下管道之间的缺口,每穿过一个缺口得一分,撞到管道或地面就算失败。

从技术实现的角度看,这套玩法背后隐藏着五个关键模块:

  • 游戏主循环:不断刷新画面和更新对象位置,这是所有动作游戏的发动机。
  • 重力与跳跃:鸟本身不飞,而是被重力往下拉;点击操作给鸟一个向上的速度,形成“一跳一落”的节奏。
  • 碰撞检测:判断鸟是否撞到管道、上边界或地面,是游戏结束的唯一条件。
  • 计分逻辑:穿过一组管道的水平位置后,分数加一,同时保证同一组管道不能重复加分。
  • 状态管理:至少要有“待开始/游戏中/已结束”三种状态,不同状态对应不同的场景渲染和交互逻辑。

这个项目的巧妙之处在于,它把游戏编程的通用问题全部压缩在一个小场景里。你在 FlyBird 里解决的每一个问题,换到其他游戏里思路都是通用的。比如重力系统就是 Mario 的重力系统简化版,碰撞检测就是格斗游戏碰撞检测的二维简化。

1.2 技术方案选型与理由

选技术栈时,我倾向于给基础练手项目用“零依赖”方案:直接用原生 JavaScript 加 Canvas 绘图,不用引擎,不用框架。

为什么不直接上手现成的游戏引擎?原因很简单。引擎帮你封装了场景管理、精灵渲染、物理运算,你只需要填配置和逻辑。但这样一来,你就看不到“游戏到底是怎么转起来的”。用原生 Canvas 写,渲染逻辑、物理更新、碰撞检测全部暴露在眼前,每一行代码都在为后续能力打地基。对初学者来说,这种“裸奔”式实现是理解游戏原理最快的路径。

整个项目最终形态就是三个文件:

文件职责
index.html页面结构、Canvas画布、样式布局
bird.js游戏所有逻辑(循环、物理、碰撞、渲染)
sprites/ 目录鸟、管道、背景等图片资源

如果你想做成单文件,把脚本内联进 HTML 也行;但分文件管理更贴近真实项目习惯,后面再扩展模块时就能直接往目录里加。

1.3 游戏状态机设计

状态机这个概念听起来高级,其实它就是“程序此刻处于什么阶段”的标记。FlyBird 里我设计了四种状态:

  • READY(待开始):鸟在初始位置悬停,等待玩家点击屏幕启动。
  • PLAYING(游戏中):所有元素开始运动,玩家控制鸟的起落。
  • PAUSED(暂停):玩法暂停,通常用于中途离开场景。
  • GAMEOVER(已结束):鸟撞到障碍物,显示本局得分与最纪录。

状态机最直接的好处是,所有交互入口只有通过状态判断,才能保证逻辑不乱。比如在 READY 状态点击屏幕只做“开始”,不会让鸟先飞一段再开始;在 GAMEOVER 状态点击屏幕则只做“重新开始”,不会误触发起跳逻辑。用代码来表达就是:

function handleTap() { switch (gameState) { case 'READY': startGame(); break; case 'PLAYING': flap(); break; case 'GAMEOVER': restartGame(); break; } }

这是我特别喜欢 FlyBird 这类项目的另一个原因:它让你必须及早考虑“状态”这个高级概念,而不是把所有逻辑堆在一个函数里,最后自己都看不懂。

2. 核心模块与代码实现要点

接下来进入代码层面。你需要先把整个游戏的“物理心脏”“碰撞逻辑”“渲染系统”三个部分分别搞清楚,再回一起把它们串起来。

2.1 游戏主循环:时间驱动的核心

游戏循环是所有动作游戏的基础。我的做法是用requestAnimationFrame驱动,它会在浏览器每次刷新屏幕前调用回调函数,理论上刷新频率跟屏幕刷新率一致(通常 60Hz),非常流畅。

关键点在于“时间驱动”而非“帧驱动”。很多人刚写的时候会写成“每帧移动鸟3像素”,这在 60Hz 屏幕上看起来没问题,但在 120Hz 或 144Hz 屏幕上,鸟移动速度会变快,游戏难度会畸变。正确做法是以时间为单位:

let lastTime = 0; function gameLoop(timestamp) { const deltaTime = (timestamp - lastTime) / 1000; // 秒为单位 lastTime = timestamp; update(deltaTime); // 更新逻辑 render(); // 绘制画面 requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);

有了统一的 deltaTime 时间增量,鸟的移动、管道的滑动、分数的判定都跟帧率解耦了。比如移动速度是 120 像素/秒,那一帧里移动多少就自动计算为120 * deltaTime像素。这只是一个小改动,但对程序的健壮性影响极大,属于“早改早受益”的优化。

2.2 物理与运动:重力、跳跃、加速度

鸟类行为的核心是一道非常经典的物理公式。我维护两个小变量:

  • bird.y:鸟的中心在 Canvas 上的纵坐标。
  • bird.velocity:鸟的当前垂直速度。

每一帧更新时,先施加重力加速度(比如 600 像素/秒²),再依速度移动位置:

bird.velocity += GRAVITY * deltaTime; bird.y += bird.velocity * deltaTime;

跳跃(flap)操作则直接给速度一个向上的冲量:bird.velocity = -300;。此时鸟会先上升,随后重力慢慢让速度变为正,之后再开始下落,形成抛物线运动。这也是“点击一下,快速升一小截;不点击,则维持下落”手感的主要来源。

这里有个调节手感的好办法。两个关键参数:重力加速度和跳跃冲量。如果你觉得鸟跳得太“飘”,就把重力调大,或把跳跃冲量调低;如果你觉得鸟太重、一点就坠,就反向调。我最终调参数定为重力 550、跳跃冲量 -320,鸟的落感和抬起感都比较迅捷,刚好让玩家能连续操作产生节奏感。

2.3 碰撞逻辑:矩形相交与边界处理

碰撞检测是 FlyBird 最容易踩坑的地方,但原理并不难。核心算法是 AABB(轴对齐包围盒相交检测),把鸟和管道都看成一个“不会旋转的矩形”,当两个矩形的边缘发生重叠,就判定为碰撞。

判断两个矩形在坐标系中是重叠,只需要同时满足横向相交和纵向相交两个条件:

function isRectCollide(rectA, rectB) { return ( rectA.x < rectB.x + rectB.width && rectA.x + rectA.width > rectB.x && rectA.y < rectB.y + rectB.height && rectA.y + rectA.height > rectB.y ); }

注意,鸟的“碰撞盒子”不能直接取整张图片的宽高。按图片大小碰撞的话,鸟明明只是尾巴尖蹭到管道边缘,也会判失败,玩家会觉得不公平。真正的做法是用一个略小的“碰撞区”,只覆盖鸟身体的主要部分,这样视觉上给玩家缓冲空间,手感会明显变好。

边界方面也要单独管:如果鸟飞出 Canvas 顶或撞到地面,就直接判定 GAMEOVER。管道本身也参与碰撞检测,垂直方向上一组管道分上管和下管,上下管之间预留出大约 168 像素的垂直缺口,保证玩家能穿过去。

2.4 渲染体系:精灵、背景与缓冲

Canvas 渲染我的经验是“能用固定绘制就用固定绘制,能提前绘制完的就提前绘制完”。鸟、管道这些简单形状直接绘制即可。精灵图资源我没额外绘制——纯代码画鸟的好处是免去美术资源加载的麻烦,跨平台部署更省心。

管道在不移动的时候其实是固定样式,只有水平位置在变。所以我把管道、背景色这些静态元素抽出来,每帧都是一笔画出所有内容的“全量重绘”,也可以用离屏 Canvas 先把管道绘制好,再通过drawImage整块复制到主画布,性能优化,代码也清爽。

鸟儿可以用三色身体加一只眼睛、一对翅膀来绘制,颜色由你自己定。有一点注意:任何绘制都会受画布缩放影响,所以要在代码开头把 Canvas 的实际像素宽高设置好,并且针对移动端做分辨率适配(后面专门讲)。

3. 实操过程与核心环节实现

理论部分告一段落,下面进入整个项目的实操环节。这里会给出可直接落地的步骤:搭页面、写循环、调手感、接计分,每做一步都能在浏览器里看到即时反馈。

3.1 项目准备与页面骨架

第一步是创建基础文件。先写index.html,把画布竖着摆,再在手机浏览器里是全屏适配:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <title>FlyBird - 简易飞行游戏</title> <style> html, body { margin: 0; height: 100%; background: #1a1a2e; display: flex; justify-content: center; align-items: center; overflow: hidden; } canvas { background: linear-gradient(#70c5ce, #d6f3f7); border-radius: 8px; box-shadow: 0 10px 20px rgba(0,0,0,0.3); } </style> </head> <body> <canvas id="gameCanvas" width="360" height="640"></canvas> <script src="bird.js"></script> </body> </html>

这里的关键决策:画布逻辑尺寸是 360×640。这是一个介于手机与电脑屏幕之间的通用规格,横向不会太宽,纵向也够管道排布。真实屏幕的适配窗口由 CSS 去控制比例缩放,Canvas 内部逻辑坐标始终不变,这样所有碰撞检测都不需要跟着屏幕变。

3.2 定义游戏常量和状态

在bird.js一开头,把所有可调参数集中定义,方便后面调手感,这也是“把参数暴露出来而不是写在代码深处”的工程习惯:

const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const GRAVITY = 550; // 重力加速度,像素/秒² const FLAP_POWER = -320; // 点击跳跃冲量(向上为负) const PIPE_SPEED = 120; // 管道向左移动速度,像素/秒 const PIPE_INTERVAL = 1.8; // 生成一组新管道的时间间隔,秒 const GAP_HEIGHT = 168; // 上下管道之间的垂直缺口 const PIPE_WIDTH = 62; // 管道宽度 const BIRD_X = 100; // 鸟的水平位置固定,只有垂直移动 const BIRD_SIZE = 26; // 鸟的碰撞盒边长(略小于实际图) let gameState = 'READY'; let bird, pipes, score, bestScore;

用const管住全局常量,用let声明运行期变量。等以后你想把难度动态调到越来越快时,直接改PIPE_SPEED或GAP_HEIGHT就能完成,非常方便。

3.3 初始化与重置函数

每局游戏开始或重开,都要把变量归零。我通常把“重置”写成一个独立函数,在 READY 和 GAMEOVER 时统一调用:

function initGame() { bird = { x: BIRD_X, y: canvas.height / 2, velocity: 0, rotation: 0 }; pipes = []; score = 0; spawnTimer = 0; }

另外记录一个bestScore,存在localStorage里,展示在屏幕上,让玩家有目标感。

3.4 更新逻辑:鸟、管道、计分

这是整个游戏最核心的函数update(deltaTime)。我按顺序处理三块逻辑:

第一块是鸟的运动和旋转:

function updateBird(deltaTime) { bird.velocity += GRAVITY * deltaTime; bird.y += bird.velocity * deltaTime; // 鸟的旋转角度与速度联动:上升时仰头,下落时俯冲 const targetRotation = Math.min(Math.max(bird.velocity / 400, -0.25), 0.6); bird.rotation += (targetRotation - bird.rotation) * 8 * deltaTime; }

注意,这里旋转是“插值逼近”而不是瞬间跳变,否则每一帧鸟的头都会猛烈转向,看起来会很生硬。用插值让旋转平滑过渡,画面立刻舒服很多。

第二块是管道的移动与移除。每帧把所有管道左边移动,超出屏幕左侧的整组管道直接移出数组(释放内存和运算):

function updatePipes(deltaTime) { if (gameState !== 'PLAYING') return; spawnTimer -= deltaTime; if (spawnTimer <= 0) { spawnPipes(); spawnTimer = PIPE_INTERVAL; } for (let i = pipes.length - 1; i >= 0; i--) { pipes[i].x -= PIPE_SPEED * deltaTime; if (pipes[i].x + PIPE_WIDTH < 0) { pipes.splice(i, 1); } } }

关于循环里删除数组元素的顺序,这里有一个小坑:如果正着循环再splice,每删除一个元素索引就会混乱,容易漏删;所以一定从后往前遍历。

第三块是计分。每组管道有一个scored标记。一旦管道组的右边缘越过鸟的右边缘(即整组管道已经完全滑到鸟左侧),就算“穿过”一次:

for (const pipeGroup of pipes) { if (!pipeGroup.scored && pipeGroup.x + PIPE_WIDTH < bird.x) { pipeGroup.scored = true; score++; // 可以在这里播放短音效(如 Web Audio API) } }

为什么判断的是“管道右边缘越过鸟的左边缘”而不是“管道与鸟碰撞”?因为只有整根管道完全通过鸟时,才能确切知道玩家没有撞上去,此时才该加分。若改为“管道中轴越过鸟”则会出现鸟其实是紧贴着管道边缘滑过的视觉争议。

3.5 渲染顺序与画面拼装

渲染部分就是把所有对象按层次关系画到画布上,我的顺序是:背景 → 管道 → 地面和分数 → 鸟(最后画,确保鸟在最上层)。画管道时,上下管道要一起画,中间留出缺口:

function renderPipes() { for (const p of pipes) { // 上管道:从画布顶部画到 p.gapY ctx.fillStyle = '#3a9d4e'; ctx.fillRect(p.x, 0, PIPE_WIDTH, p.gapY); // 下管道:从 p.gapY + GAP_HEIGHT 画到画布底部 ctx.fillRect(p.x, p.gapY + GAP_HEIGHT, PIPE_WIDTH, canvas.height - p.gapY - GAP_HEIGHT); } }

到这里,代码已成型,整个循环已经在跑。你可以先打开页面,正常情况应会看到鸟悬停或进入致游戏状态。接下来要做的就是不断试玩、调节手感参数。

4. 常见问题与排查技巧实录

在实操过程中,新手最容易撞上的问题基本就集中在那几个模块。我自己和周围的小伙伴也都踩过相同或相似的坑,这里把高频问题整理成速查表,并提供排查路径。

症状原因排查与解决
鸟下落极快,点得像没反应重力值过大,或跳跃冲量不足以抵消重力先将重力降到 400~600,跳跃冲量设为 -300 左右,再逐 20 调节
游戏开始时鸟直接往下掉在 READY 状态未暂停物理更新只有PLAYING状态才执行updateBird和updatePipes
同一组管道加分好几次scored标记没有正确写入确保每个管道组对象拥有独立scored标志,并只在穿过时置true
管道莫名消失或数组越界正向遍历pipes时删除元素导致索引错乱改为从数组末尾向开头遍历,删除更安全
游戏帧率不低但仍卡顿每帧在 Canvas 上重复绘制大量重复图案把静态背景、管道图案预渲染到离屏 Canvas;减少每帧fillRect数量
真机上点击事件没反应用的是click事件,移动端点击有延迟统一用pointerdown或touchstart,并调用preventDefault()阻止双指缩放

4.1 碰撞判定过宽产生“冤枉死”

我调试中发现手感最差的一个问题是鸟的碰撞区“过大”——直接用图片矩形去碰撞管道边缘,玩家明明肉眼觉得没碰到,却判定游戏结束。解决思路跟很多成熟游戏的方案一样:设置一个缩小版的逻辑碰撞盒。

具体做法:

const HITBOX_PADDING = 4; // 四周缩小4像素 const birdBox = { x: bird.x - 8, y: bird.y - 10, width: 16, height: 20 };

我最终用的碰撞盒尺寸比画面中小鸟的视觉尺寸还要再小一圈。这样玩家在贴近管道瞬间仍有容错空间。但要小心:缩小过多会导致“看着碰上了却没判罚”的作弊感。建议是一个让 80% 玩家在正常情况下都不会觉得“亏”的值。

4.2 屏幕适配与视觉缩放

PC 和手机屏幕比例不同,直接 CSS 拉伸会失真,我见过不少项目在这个地方栽跟头。最稳妥的方案是保持 Canvas 逻辑尺寸(360×640)不变,用 CSS 做等比缩放并居中。

function fitCanvasToScreen() { const scale = Math.min(window.innerWidth / 360, window.innerHeight / 640); canvas.style.width = (360 * scale) + 'px'; canvas.style.height = (640 * scale) + 'px'; } window.addEventListener('resize', fitCanvasToScreen);

这里有一个细节:Canvas 的style.width/height只影响它在页面上显示的大小,不影响内部逻辑像素,所以碰撞检测全部沿用 360×640 坐标,一步适配所有设备。

4.3 音效与动效增强体验

游戏做完后,要不要加音效?我建议一定要加,但别依赖外部音频文件,否则加载资源和兼容性问题很麻烦。用 Web Audio API 直接生成短音效是最轻量的方案,它不需要任何素材文件,只靠代码产生振荡波形。

我目前用的两种音效:

  • 跳跃音:短促的上升音调,频率从 400Hz 快速升到 700Hz,时长 0.08 秒。
  • 得分音:轻快的两声叮咚,频率 900Hz、1200Hz 各响 0.06 秒。

实现代码如下(贴在主文件里,简洁无外部依赖):

function playTone(freqA, freqEnd, duration) { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(freqA, audioCtx.currentTime); osc.frequency.exponentialRampToValueAtTime(freqEnd, audioCtx.currentTime + duration); gain.gain.setValueAtTime(0.2, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime + duration); }

有个跨域细节:移动端浏览器要求音频上下文必须由用户交互触发,否则会静音。因为游戏里的跳跃和得分都是玩家点击后触发,恰好满足这个条件,所以不用担心。

4.4 手感调试方法:参数化的艺术

FlyBird 这类游戏,成败在“手感”二字。调节手感时我强烈建议你在代码开头把所有与手感相关的参数集中定义好,然后每次只改一个量,边改边玩,记录下最舒服的那组参数。我自己调试时的记录表长这样:

参数初始值偏重偏轻
GRAVITY550640420
FLAP_POWER-320-260-380
GAP_HEIGHT168150190
PIPE_SPEED12016090

调试时一次只动一个值,不然很难定位哪个变化造成哪个手感差异。另外,不同屏幕分辨率下感知的速度会不同,尽量在目标设备(尤其手机)上做最终微调。

4.5 防误触与开始引导

玩家在“READY”状态下会连点点点,如果没有控制好,可能开局第一帧就乱飞。我的处理是:在 READY 状态时,即使收到点击,也只重置鸟到初始位置,不响应跳跃;正式开始后再响应。同时,我加了一个透明半透明的“点击开始”提示文字,引导明确。

再有一个容易忽略的点:浏览器默认会响应 Canvas 点击后的高亮、文本选区、拖拽等行为。建议在事件处理函数里统一加上event.preventDefault(),同时 CSS 选择canvas { user-select: none; -webkit-user-select: none; },避免移动端长按弹出系统菜单。

5. 工具的选型与进一步扩展

项目跑通后,可以用一些工具提升效率。编辑器方面我用普通的代码编辑器搭配 Live Server 做本地预览,改动代码浏览器实时刷新,省心。若你需要用移动端真机调试,启动一个本地静态服务器,让手机与电脑同一局域网内访问电脑 IP 即可。地址形如http://<电脑IP>:5500。调试时我习惯打开 Chrome 开发者工具的移动端模拟模式,在“设备工具栏”里切到竖屏实时测。

FlyBird 的可玩法远不止于此。我后续扩展时做了几个方向的改动,既保持项目简单,又让玩法更有张力:

  • 加速模式:每隔 10 分把PIPE_SPEED提升 8%,同时把GAP_HEIGHT缩窄 2 像素,难度呈阶梯式上升。
  • 皮肤机制:将鸟的颜色由固定改为可配置,在画布上“选中皮肤”按钮即可切换,实质上只是把绘制颜色换成配置值,改造成本极低。
  • 昼夜模式:背景渐变、管道颜色做成两套配色,根据分数自动切换,可以作为视觉节奏的调剂。
  • 联网排行榜:将分数上传到一个小型后端接口。这一项会引入异步请求、防作弊校验、接口签名等概念,适合作为进阶学习的第二个项目。

而这些扩展通用的原则,是“先让基础逻辑足够清晰,再做加法”。FlyBird 的核心代码只有几百行,很好维护。若盲目把扩展堆在一起,很容易陷入老代码的重构泥潭。所以我后期的做法是每加一个功能,都先在纸上画最终效果和状态变化,再回到代码里找最小改动切口,绝不为了功能而功能。

最后分享一点实战感受。写游戏和写业务系统的不同在于,游戏需要“手感”。代码哪里写错了,你一眼就能看到鸟穿墙、飞空了;可“怎么调到手感顺畅”是文档教不出来的,只能靠反复试。FlyBird 正是在你面前放了一个极小的打磨循环:改参数、刷新、试手感、记录,再改。整个流程在两小时内可以跑好多轮,这样的反馈密度,是练习编程手感的绝佳素材。

如果你做完这个项目,下次再看到任何动作类小游戏,脑子里基本都能自动拆解出它的循环、物理、碰撞、状态机。到那时,你就真正从“写代码的人”迈进了“做游戏的人”的门槛。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询