Flappy Bird集成TRex:物理模型、模式切换与碰撞检测实战解析
把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个像素的余量。我的做法是小鸟高度约24pxgap取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_BIRDTRex项目原来的状态机大概是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) { // TRexkeyup触发跳跃keydown只做预按下标记 if (e.code Space) this.trex.setJumpKeyDown(true); } else { // Birdkeydown立即触发脉冲而且要做防重复 if (e.code Space !e.repeat) { this.bird.flap(); } } }这里有几个非常现实的坑连续触发问题长按空格浏览器会不断触发keydown事件。Flappy Bird中这会导致小鸟一按飞上天一放就掉下来连点两次的效果完全乱套。解决办法是检测e.repeat只在非重复事件里触发flap()。这一点是复刻Flappy Bird时最容易遗漏的细节。keyup的位置TRex的跳跃是keyup还是keydown触发各版本实现不一样。我的TRex版本是keydown记录按键、keyup触发起跳模拟按住蓄力。但Bird模式必须在keydown立即响应否则脉冲延迟会让人感觉“没跟上我的手”。防止默认行为空格键在页面里默认会滚动。一定得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原来的跳跃参数就明白为什么当初会选那些数字了。项目目前已经可以在线试玩后续我在考虑把“恐龙踩管道”这种混合模式也做进去不过在动手之前估计还得先解决一版好玩的地形生成器。到时再接着写。