Flappy Bird集成TRex:物理模型、模式切换与碰撞检测实战解析
2026/9/19 2:40:22 网站建设 项目流程

把Flappy Bird的鸟塞进TRex的跑酷世界,这个想法刚冒出来的时候,我也觉得不就是两个小游戏吗,代码拼一下就行了。真正动手才发现,问题不在于“把鸟画出来”或者“让鸟能飞”,而在于两套游戏从物理模型、输入模式到碰撞判定,根本就是拧着劲的。这篇就把我在做《TRex设计与实现》第六部分时,把Bird集成进入这个HTML5项目的完整过程和踩坑记录写清楚,给也想做同类整合或者正在复刻这两个游戏的朋友一个参照。

1. 为什么把一只鸟塞进霸王龙的世界

1.1 两个游戏看上去像,底层完全是两套思路

Chrome断网时候那只霸王龙,核心玩法是“跑酷+跳跃躲障碍”。它的物理模型是有限跳跃:角色有固定的水平速度,玩家按一下跳跃键,角色获得一个向上初速度,之后重力接管,角色在一个固定高度内起跳、下落,然后落地重置跳跃状态。霸王龙是不能“停留在空中”的,它必然会被重力拽回地面。

Flappy Bird恰恰相反。它的核心玩法是“持续下落+脉冲上浮”。小鸟从屏幕左侧出发,每帧都受重力影响不断加速下落,玩家每次点击给它一个向上的瞬时速度脉冲,让它“扑腾”一下。它没有地面限制,游戏区域的上下边界就是约束条件。

这两个模型差别有多大呢?TRex跳跃的代码通常长这样:

// TRex风格的跳跃:瞬时初速度 + 统一重力 this.vy = -600; // jumpVelocity this.y += this.vy * dt; this.vy += GRAVITY * dt; // 例如 GRAVITY = 2000

而Flappy Bird风格必须长这样:

// Flappy Bird风格:持续重力累加 + 点击脉冲 this.vy += GRAVITY * dt; // 每帧先加重力 this.y += this.vy * dt; // 再用累计速度更新位置 // 点击时: this.vy = -400; // 直接重置速度为向上的脉冲,而不是叠加

注意,TRex的跳跃是“一次性的”,跳跃过程中按空格没有二次效果;Flappy Bird的点击是在任何时刻都能触发的,而且每一次点击都是“把垂直速度重置为向上脉冲”。要是想简单地把霸王龙的玩家对象替换成小鸟,那霸王龙原本的跳跃逻辑会立刻让小鸟变成一个跳蚤,根本飞不起来。

1.2 集成路线的选型:不是换皮,是加模式

既然物理模型不一样,集成方式就有三条路可以走:

路线方案描述优点缺点
方案A:角色替换用Flappy Bird的物理替换TRex玩家的物理代码改动少破坏了霸王龙模式的核心手感,两边都不讨好
方案B:模式切换保留TRex模式,新增独立的Bird模式两种玩法手感都能完整保留状态管理、输入绑定、重置逻辑都要加分支
方案C:同屏混玩霸王龙跑酷,小鸟在旁边独立飞视觉效果有趣玩法层面没有意义,项目复杂度翻倍

我最终选了方案B。原因很简单:TRex的核心价值是“在断网页面用最少的资源做最流畅的小游戏”,Bird集成学习的目的是把Flappy Bird的机制吸收进来,而不是用新玩法毁掉旧玩法。模式切换意味着两个GameState并存,单一按键在不同模式下触发不同逻辑,物理参数、碰撞盒、渲染层级全部隔离。这正好符合系列项目“设计与实现”一贯的模块化思路——前几篇我们已经把游戏循环、输入系统、状态管理器都拆开了,这次只要在对应位置加分支就行,不需要打乱原有架构。

2. Flappy Bird的物理模型拆解:重力、脉冲与间隙

集成的第一步不是写代码,是把Flappy Bird的手感参数搞清楚。这个游戏上手简单,但参数稍微调偏一点,手感就完全不对。我参考了不少HTML5原版复刻项目的做法,核心就三个参数:重力加速度、点击脉冲速度、管道间隙。

2.1 让鸟落下来:帧独立重力与速度累加

Flappy Bird里小鸟的垂直运动非常纯粹——每一帧都在做匀加速运动。重力加速度决定下落速度增加的快慢,小鸟的位置更新公式为:

update(dt) { // 先累加速度,再更新位置,顺序不能反 this.velocityY += GRAVITY * dt; this.y += this.velocityY * dt; // 触顶限制 if (this.y < this.topBoundary) { this.y = this.topBoundary; this.velocityY = 0; } }

这里有个容易踩的坑:update顺序决定了物理稳定性。如果把“先更新位置再累加速度”写成this.y += this.velocityY * dt; this.velocityY += GRAVITY * dt;,虽然单帧误差不大,但累积下来会让小鸟的下降轨迹少了一段加速度增量,手感会偏轻。真正的做法是先速度后位置,符合“匀加速运动的位置公式需要用到本帧初始速度”的物理逻辑。

还有一个关键点是用dt帧独立更新。TRex项目前几篇我们已经封装过一个GameLoop,它传递的dt是以秒为单位的帧间隔。IMPORTANT是:Flappy Bird原版有些实现是固定帧率累加,放到现代浏览器的高刷新率屏幕上就会越快越难。既然项目里已经统一用dt了,Bird模式也必须走同一套帧独立逻辑,不然60Hz和144Hz屏幕手感会差出一大截。

2.2 点一下跳多高:脉冲速度的标定过程

点击脉冲速度的大小决定了小鸟每次扑腾能上升多高。很多初学者直接抄一个-400或者-300,然后用起来发现小鸟要么飞不起来要么一飞冲天。这个值其实可以用物理公式倒推。

假设我们希望每次点击后,小鸟在重力作用下能爬升的目标高度h,那么初始向上的速度v0应该满足:

v0 = Math.sqrt(2 * g * h)

其中g是重力加速度,h是目标爬升高度。比如重力g = 1800,我们希望每次点击大约爬升60像素,那么:

v0 = Math.sqrt(2 * 1800 * 60) ≈ 464.7

实际取值在-460-480之间就差不多。但这个公式只是基础,真正的调参还得考虑点击频次屏幕尺寸。同比例下,手机竖屏和PC横屏的可玩区域差别很大,通常是固定物理参数,然后根据屏幕高度调整管道间距和判定边界,而不是反过来调重力。

我在测试中还发现一个细节:脉冲速度重置而不是累加。有些新手会把点击处理写成this.velocityY -= 400,这会导致连续点击时小鸟越飞越高,完全失控。Flappy Bird原版的设计是“每次点击将垂直速度设置为向上脉冲”,所以:

onTap() { // 关键:直接赋值,不能叠加 this.velocityY = -460; }

这样不管上一次是上升还是下降,点击后都会从同一个脉冲速度重新开始。这个细节我觉得是Flappy Bird手感的最核心所在——玩家的每一次点击都是一次“重新起步”,而不是“加速上冲”。

2.3 管道间隙与水平速度的匹配

管道间隙是另一个决定难度的核心参数。间隙太小,玩家反应不过来;间隙太大,玩起来没有压迫感。合理的间隙值需要配合水平速度和玩家反应时间一起看。

设小鸟的水平飞行速度为vx,管道间隙为gap,玩家从看到间隙到完成点击操作的反应时间大约为200~300ms。最理想的设计是:在玩家飞抵管道前的最后200ms内,能看清间隙位置并且用一次点击完成穿越。所以间隙至少满足:

gap >= vx * (humanReaction + 穿越容错时间)

一个比较常见的参数组合是:vx = 120像素/秒,gap = 160~180像素,反应时间按0.25s算,那么最小间隙是120 * 0.25 = 30像素?等等,这里有人会疑惑,那为什么实际用160?因为这里的“穿越路径”不是水平直线,而是一条抛物线弧线,玩家需要在间隙范围内先爬升或下落对准位置,再飞过去,弧线轨迹的长度远比水平距离大。所以实践中gap通常取小鸟高度+60到80个像素的余量。我的做法是:小鸟高度约24px,gap取160px,效果比较合适;如果调快水平速度,gap要按比例加到180~200px,否则玩家在很高速度下根本没有微调时间。

3. Bird的代码落地:一个独立于Trex的角色类

物理参数理解之后,就可以写角色类了。我强调的是“独立于Trex”,因为如果把Bird塞进原来的Trex类,后续修改会互相干扰。单独建一个Bird类,把物理、动画、状态都封装进去,是这次集成里最明智的决定。

3.1 Bird类骨架设计

Bird类的主要职责有四个:更新物理状态、播放翅膀动画、绘制自身、提供碰撞盒数据。核心代码结构如下:

class Bird { constructor(config) { this.x = config.x; this.y = config.y; this.vx = config.vx || 120; // 水平恒定速度 this.velocityY = 0; // 垂直速度 this.gravity = config.gravity || 1800; this.jumpVelocity = config.jumpVelocity || -460; this.radius = config.radius || 12; // 碰撞半径 this.rotation = 0; // 旋转角度 this.state = 'READY'; // READY / FLY / DEAD this.frameTimer = 0; this.currentFrame = 0; } reset(initialY) { this.x = 80; this.y = initialY; this.velocityY = 0; this.rotation = 0; this.state = 'READY'; } update(dt) { if (this.state !== 'FLY') return; this.frameTimer += dt; if (this.frameTimer > 0.1) { this.currentFrame = (this.currentFrame + 1) % 2; this.frameTimer -= 0.1; } this.velocityY += this.gravity * dt; this.y += this.velocityY * dt; // 根据垂直速度计算倾斜角 const targetRotation = Math.atan2(this.velocityY, this.vx); this.rotation = this.clampRotation(targetRotation); } flap() { if (this.state === 'FLY') { this.velocityY = this.jumpVelocity; } } clampRotation(target) { // 限制在 -35度 到 80度 之间,避免翻转过头 const maxUp = -35 * Math.PI / 180; const maxDown = 80 * Math.PI / 180; return Math.min(maxDown, Math.max(maxUp, target)); } }

这里state非常关键。READY状态下小鸟待在原地,等待游戏开始;FLY状态下物理完全接管;DEAD状态下停止一切物理和动画,等玩家重新开始。集成到TRex项目后,reset方法由模式切换入口调用,每次进入Bird模式都会把状态重置成READY。

3.2 翅膀动画与旋转角度的联动

Flappy Bird原版用两帧翅膀切换就能实现非常生动的扑腾效果。在HTML5 Canvas里,不必加载外部图片资源,直接用路径绘制或者内联绘制函数即可。我这里的做法是维护currentFrame,每0.1秒切换一帧。动画本身不复杂,但旋转角度才是让鸟看起来“活”的关键。

小鸟上升时头朝上,下落时头朝下,速度和角度的联动直接决定视觉流畅度。我用Math.atan2(velocityY, vx)算出速度向量角度,然后限制在-35度到80度之间。为什么上限是80度而不是90度?因为Math.atan2在接近90度时数值变化非常剧烈,加上限制后下落姿态会显得自然收敛,不会出现鸟头直接朝下的夸张翻转。

这里有个视觉细节需要注意:Canvas旋转时要以鸟的中心为原点,而不是左上角。否则鸟会绕着一个奇怪的锚点旋转,看起来像在画圈圈而非自然俯仰。绘制时先ctx.translate(this.x, this.y),再ctx.rotate(this.rotation),最后从(-radius, -radius)开始绘制,这一步不可省。

3.3 把Bird接进TRex的游戏循环

TRex项目前几篇已经建立了一套清晰的游戏循环结构:GameLoop负责总调度,每帧调用update(dt)render(ctx),然后由PlayState决定当前场景下要更新哪些对象。

把Bird接进循环的方式是这样的:

class PlayState { constructor() { this.trex = new Trex(); this.bird = new Bird({ x: 80, y: 280 }); this.currentMode = 'TREX'; // 'TREX' | 'BIRD' } switchMode(mode) { this.currentMode = mode; if (mode === 'TREX') { this.trex.reset(); } else { this.bird.reset(); } } update(dt) { if (this.currentMode === 'TREX') { this.trex.update(dt); this.obstacleManager.update(dt); } else { this.bird.update(dt); this.pipeManager.update(dt); } this.bgManager.update(dt); // 背景滚动两个模式共用 } render(ctx) { this.bgManager.render(ctx); if (this.currentMode === 'TREX') { this.trex.render(ctx); this.obstacleManager.render(ctx); } else { this.bird.render(ctx); this.pipeManager.render(ctx); } } }

这样改完后,背景管理器、Canvas尺寸适配、地面滚动逻辑两个模式都能共用,而角色和障碍物管理完全隔离。这个结构也让我后续加“难度提升”机制的时候不用动模式切换的代码。

4. 游戏状态与输入层改造:两个模式共存不打架

模式切换看起来简单,真正麻烦的是同一个按键在不同模式下的语义不同,以及切换模式后重置状态的时序问题。这两点也是我在实际联调时反复改最多的地方。

4.1 GameState扩展:MODE_TREX与MODE_BIRD

TRex项目原来的状态机大概是INIT -> RUNNING -> CRASHED,可能还有一个PAUSED。要支持Bird模式,我选择在RUNNING内部再拆一个子状态——也就是用currentMode字段区分TREX模式和BIRD模式,而不是把状态机大改。

为什么不直接加BIRD_RUNNING这种状态?因为很多逻辑(比如暂停、恢复、画面适配)是两种玩法共有的。如果拆成独立状态,每个状态都要复制一份暂停处理逻辑,代码会冗余。子状态的做法是:GameState保持原来的生命周期,PlayState内部用currentMode决定具体更新和渲染分支,这样暂停和重开都能复用原有代码。

4.2 输入绑定:同一按键在不同模式下的不同语义

TRex模式中,空格和鼠标点击是“起跳”——并且有按下时间长短影响跳跃高度的设定。Flappy Bird模式中,空格和鼠标点击是“脉冲”——每次按下立刻触发,不管按多长时间都是一样的速度。

改造前,输入处理可能是这样:

handleKeyDown(e) { if (e.code === 'Space') { this.trex.jump(); } }

改造后变成了:

handleKeyDown(e) { if (this.currentMode === 'TREX') { // TRex:keyup触发跳跃,keydown只做预按下标记 if (e.code === 'Space') this.trex.setJumpKeyDown(true); } else { // Bird:keydown立即触发脉冲,而且要做防重复 if (e.code === 'Space' && !e.repeat) { this.bird.flap(); } } }

这里有几个非常现实的坑:

  1. 连续触发问题:长按空格,浏览器会不断触发keydown事件。Flappy Bird中这会导致小鸟一按飞上天,一放就掉下来,连点两次的效果完全乱套。解决办法是检测e.repeat,只在非重复事件里触发flap()。这一点是复刻Flappy Bird时最容易遗漏的细节。

  2. keyup的位置:TRex的跳跃是keyup还是keydown触发各版本实现不一样。我的TRex版本是keydown记录按键、keyup触发起跳(模拟按住蓄力)。但Bird模式必须在keydown立即响应,否则脉冲延迟会让人感觉“没跟上我的手”。

  3. 防止默认行为:空格键在页面里默认会滚动。一定得e.preventDefault(),不然游戏玩到一半页面自己滚走了。

4.3 暂停与重开的边界处理

两个模式都共用同一个Pause按钮和同样R键重开逻辑。但重开时做的重置不一样:

restartGame() { if (this.currentMode === 'TREX') { this.trex.reset(); this.obstacleManager.clear(); } else { this.bird.reset(); this.pipeManager.clear(); } this.score = 0; this.gameState = 'RUNNING'; }

这里我犯过一次错误:最开始直接调this.trex.reset(),然后把pipeManager.clear()漏了,导致从Trex模式切到Bird模式时,旧的仙人掌障碍物还挂在画面里,小鸟撞上去直接穿模。所以模式切换的重置顺序必须是:先清空当前模式障碍物,再重置角色,最后重置分数。顺序反了容易出现角色已经复位,但障碍物还没清除的闪烁帧。

5. 碰撞检测的差异与实现细节

霸王龙模式里的碰撞盒是矩形对矩形,判断方法用轴对齐包围盒(AABB)就够;Bird模式里小鸟本身是一个近似圆形,而管道是两块矩形,最佳碰撞检测方案是圆对矩形。这部分是整个集成中细节最多的地方。

5.1 圆盒碰撞 vs 方盒碰撞

TRex模式里的碰撞判断大概是:

// AABB碰撞:矩形相交 function intersects(r1, r2) { return r1.x < r2.x + r2.w && r1.x + r1.w > r2.x && r1.y < r2.y + r2.h && r1.y + r1.h > r2.y; }

Bird模式里,小鸟的碰撞盒是一个圆心+半径的圆,管道是上矩形+下矩形的组合。圆心和矩形碰撞的数学判断是:先找到圆心上最接近矩形的点(钳位到矩形区域内),然后计算这个点和圆心的距离平方是否小于半径平方。

function circleRectCollision(circle, rect) { const closestX = Math.max(rect.x, Math.min(circle.x, rect.x + rect.w)); const closestY = Math.max(rect.y, Math.min(circle.y, rect.y + rect.h)); const dx = circle.x - closestX; const dy = circle.y - closestY; return (dx * dx + dy * dy) < (circle.radius * circle.radius); }

这个方法也是Flappy Bird HTML5原版复刻里用得最多的做法。它的好处是:就算小鸟以很快速度冲向管道,只要圆心没有瞬间穿过矩形内部,判定结果依然准确,不会出现肉眼看着撞了但没判定的情况。

5.2 难度曲线:管道间距的动态调整

Trex模式原本有一套“游戏时间越长速度越快”的难度增长逻辑,但Bird模式不太一样——小鸟的水平速度如果也无限增长,玩家很快就会因为反应不过来而崩溃。我的做法是水平速度保持恒定,但管道间距随得分动态缩小

具体参数表:从基础间隙180px开始,每得10分缩小4px,最小间隙130px。这样在保证可玩性的前提下,用越来越窄的通路制造压力,难度曲线比单纯加速更平滑,也更接近Flappy Bird原作的设计思路。

管道生成逻辑里还要注意一个点:上下管道的间距要围绕一个中间基准线浮动,而不是固定在同一高度。如果不做浮动,玩几十个管道后玩家会形成机械式的肌肉记忆,游戏变得无聊。我用了一个正弦变化加随机偏移的方式:

generatePipe() { const minGap = this.getCurrentGap(); // 动态间隙 const midY = canvasHeight / 2 + Math.sin(this.pipeCount * 0.6) * 50 // 基准线上下浮动 + (Math.random() - 0.5) * 30; // 随机偏移 const topPipeBottom = midY - minGap / 2; const bottomPipeTop = midY + minGap / 2; ... }

注意:每对管道的垂直间隙必须一致,但整体位置可以浮动。如果间隙本身不一样,玩家飞过去时会遇到一种“看起来能钻过去但其实过不去”的挫败感,这种手感问题在测试中非常容易被发现。

5.3 常见碰撞Bug:鸟“钻进”管道缝隙的判定误差

测试阶段我见过两个非常典型的碰撞Bug:

第一个是只用左上角判定。如果碰撞检测写成this.x < rect.x + rect.w && this.x > rect.x && this.y < rect.y + rect.h && this.y > rect.y,那么鸟的右下部分已经撞进管道时,左上角可能还在管道外,导致看不见的穿模。应该始终用圆心和半径,或者至少用包围鸟的矩形和管道做AABB检测。

第二个是帧率过低导致“隧道效应”。当FPS掉到30帧以下,dt变大,小鸟单帧位移可能超过20像素,这时圆对矩形的检测有可能出现“上一帧在管道左侧,下一帧在管道右侧”的穿越。解决的办法有两种:要么在低帧率时限制最大dt(比如dt = Math.min(dt, 0.033)),要么做更精细的“扫描碰撞”。实用做法还是限制最大dt,这样实现简单,对其他系统的影响也小。

6. 调试技巧与手感调校

最后这部分其实是经验性的东西,参数文档里不会写。

6.1 打开调试绘制:碰撞盒可视化

集成期间如果不开可视化,光靠肉眼判断“撞没撞”非常不靠谱。我给Bird模式加了一个调试开关,把碰撞盒直接画出来:

renderDebug(ctx) { // 圆圈 ctx.beginPath(); ctx.arc(this.bird.x, this.bird.y, this.bird.radius, 0, Math.PI * 2); ctx.strokeStyle = '#00ff00'; ctx.stroke(); // 管道矩形 this.pipeManager.pipes.forEach(pipe => { ctx.strokeStyle = '#ff0000'; ctx.strokeRect(pipe.topRect.x, pipe.topRect.y, pipe.topRect.w, pipe.topRect.h); ctx.strokeRect(pipe.bottomRect.x, pipe.bottomRect.y, pipe.bottomRect.w, pipe.bottomRect.h); }); }

这个开关在调试和向别人演示碰撞逻辑时都很管用,能看到碰撞盒是否比角色实际形状太大或太小。我最后把碰撞半径设定为12px,比小鸟视觉羽毛范围小两三个像素,这样玩家会感觉判定“稍微有点宽松”,在快速点击时不容易有挫败感,这也是Flappy Bird原作手感的隐藏细节之一。

6.2 物理参数表:手感调节的经验方向

下面这组参数是我在测试后觉得比较均衡的配置,给读者做个参考:

参数推荐值调节说明
重力加速度1800 px/s²增大则下落更快,反应时间缩短
点击脉冲-460 px/s绝对值增大则单次爬升更高,操控更宽松
水平速度120 px/s增大则节奏更快,需要同步加大管道间隙
初始管道间隙180 px根据屏幕高度和玩家水平可调
最小管道间隙130 px低于这个值会接近极限手速
最大dt0.033s防止低帧率下的隧道穿越

这套参数在PC和手机上都能保持相对一致的手感。真机测试中手机触控比键盘延迟略高,所以如果你要发移动端,可以把点击脉冲稍微提高到-480,补偿一下触屏延迟。

6.3 让手感“肉”或“脆”的技巧

最后分享一个很实用的调参心得:Flappy Bird类游戏的方向感其实就两个词,“肉”和“脆”。

  • “肉”:重力偏低(比如1500),脉冲偏低(比如-420),小鸟上升和下落都比较缓和,玩家有更多时间微调,新手友好,但玩久了会觉得不够刺激。
  • “脆”:重力高(比如2000),脉冲高(比如-500),小鸟动作非常跟手,上升快、下落也快,高手能玩出很精细的控制,但对新手极不友好。

我做的是中间偏“脆”一点的设置,原因是要跟TRex模式的节奏保持一致——TRex模式里霸王龙跳跃很迅速,如果Bird模式的鸟飞得软绵绵的,两个模式之间切换会觉得很突兀。

调试过程中一定要多测连续点击快速连按两种操作。有些参数在单次点击时手感完美,连续点击时小鸟会上下抖动得很夸张,这通常是因为脉冲速度对快速点击过于敏感。解决方法是甚至可以给点击加一个最小间隔(比如50ms的冷却),让触摸屏上的快速连续点击不会触发幽灵弹跳。不过根据我实际测试,代码里不额外加冷却也问题不大,只要确保e.repeat被过滤掉,手感就已经足够干净了。

这次集成Bird的学习过程中,我发现调好一个游戏的手感,不是靠猜,而是靠把参数拆解成“物理数值+操作反馈”两个维度去量化分析。做完这一部分,再回头看TRex原来的跳跃参数,就明白为什么当初会选那些数字了。项目目前已经可以在线试玩,后续我在考虑把“恐龙踩管道”这种混合模式也做进去,不过在动手之前,估计还得先解决一版好玩的地形生成器。到时再接着写。

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

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

立即咨询