拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

H5游戏开发实战:基于React+Canvas物理引擎的愤怒的小鸟2.0重构

H5游戏开发实战:基于React+Canvas物理引擎的愤怒的小鸟2.0重构

说实话,一开始拿到“愤怒的小鸟2.0”这个项目标题,我脑子里冒出的问题不是“要不要做”,而是“凭什么敢叫2.0”。市面上的复刻版本一大堆,基本都是拿个物理引擎糊一层贴图,能弹出去、能砸碎就万事大吉。但“2.0”意味着你得拿出点真东西——要么玩法上有突破,要么技术上能让人眼前一亮,否则就是挂羊头卖狗肉。

我这次做的是一个基于 H5 的完整重构版,技术栈选的是 React + TypeScript + Canvas 2D,物理和碰撞部分全部手写,没有引入 Box2D 这类现成引擎。核心目标有三个:第一,在移动端保持 60fps 流畅运行;第二,物理表现要足够“脆”,拆除效果要有链式反应的爽感;第三,关卡结构要做成数据驱动,后面加新关卡不改代码。这三个目标撑起了整个 2.0 的骨架。

这篇文章不写流水账,直接把我在开发过程中踩过的坑、想明白的原理、反复调过的参数都摆出来。无论你是想复刻一个类似的物理小游戏,还是纯粹对 Canvas 游戏开发感兴趣,都能从这里拿走一些直接能用的东西。

1. 整体设计与技术选型

1.1 为什么是 Canvas 2D 而不是游戏引擎

这个决定纠结了挺久。如果用 Cocos 或者 Laya,物理组件都是现成的,刚体、碰撞、材质一步到位,开发周期至少缩短三分之一。但项目要求是前端技术栈的完整重构,而且要在浏览器里通过 URL 直接访问,不能套壳打包。这个前提下,游戏引擎的重量级反而成了负担。

选择 Canvas 2D 的原因很直接:它是浏览器原生能力,兼容性最好,不需要额外加载引擎运行时。更关键的是,愤怒的小鸟这种 2D 物理游戏,物理对象无非是圆形的鸟、矩形的木块、多边形的石头,碰撞计算并不复杂。用 Box2D 属于杀鸡用牛刀,而且引擎的物理参数调校反馈不够直观,很多时候你不知道为什么某个物体就滑到地图外面去了。

手写物理引擎的好处是“一切尽在掌握”。重力系数、空气阻力、恢复系数、摩擦力,每一个参数的变化都能精确预判效果。这在做关卡设计时尤其重要——你得知道一块木头搭在另一块木头上,受到撞击后大概会怎么倒,而不是拆盲盒一样试着来。

整个架构分成三层:

  • 逻辑层:管理游戏状态机(待发射、飞行中、结算)、关卡数据加载、计分规则。
  • 物理层:负责所有物体的位置更新、速度计算、碰撞检测与响应。这是核心,但代码量其实没想象中大,稳定版本差不多 800 行。
  • 渲染层:纯 Canvas 绘制,每帧从物理层读取状态,只负责“画”,不负责“算”。

这个分层的好处是后续想换渲染方式(比如换 WebGL),物理层一点不用动,只重写渲染层就行。实际开发中我确实验证过,用新的渲染风格调试时完全没碰过物理代码。

1.2 游戏循环与坐标系设计

游戏循环这块,很多人直接用requestAnimationFrame,然后每帧里执行update()和draw()。这个方案在简单 demo 里没问题,但一旦物体数量超过 30 个,掉帧就开始明显。问题出在 frame rate 不稳定上,物理模拟的步长会跟着帧率抖动,今天在 60Hz 屏幕上物理表现正常,明天拿到 120Hz 的 iPad 上,物体像被按了快进键。

所以我采用了固定时间步长的循环方式:

const STEP_MS = 1000 / 60; let accumulator = 0; let lastTime = performance.now(); function gameLoop(timestamp: number) { const delta = timestamp - lastTime; lastTime = timestamp; accumulator += delta; while (accumulator >= STEP_MS) { physicsStep(STEP_MS / 1000); accumulator -= STEP_MS; } render(); requestAnimationFrame(gameLoop); }

固定步长保证了物理模拟的确定性——同一关卡、同一初始条件,每次运行的物理结果一致。这一点对提供“克敌制胜”的挑战性很重要,如果物理表现随帧率漂移,玩家会觉得手感忽好忽坏。

坐标系这里也踩了坑。Canvas 默认坐标原点在左上角,y 轴向下,物理计算里的重力加速度用负数表示。但这不符合直觉。我在物理层内部全部使用标准笛卡尔坐标系(y 轴向上),做渲染时才换算成 Canvas 坐标:

const renderY = canvasHeight - physicsY;

这样物理公式写起来跟教科书一致,不容易出符号错误。换算逻辑集中在渲染层一个函数里,没有散落各处。

2. 物理核心:斜抛运动与碰撞拆除

2.1 斜抛运动的数学建模

小鸟发射后的轨迹是经典斜抛运动。很多教程会给一个公式直接套,但实际开发里直接把公式写死会很难调手感。我做了一个简单的速度分解,然后每帧积分:

export class Bird { x: number; y: number; vx: number; vy: number; update(dt: number) { // 重力加速度 const gravity = 980; // 像素/秒^2 this.vy -= gravity * dt; this.x += this.vx * dt; this.y += this.vy * dt; } }

这里最关键的是重力加速度的取值。真实世界大约是 9.8 m/s²,但游戏里单位是像素,直接换算会导致物体要么轻飘飘要么沉重得飞不起来。经过反复试玩,我把重力定为980 px/s²,弹弓的初速度范围控制在350 ~ 900 px/s,配合屏幕高度 768px,小鸟能飞过大约 1.5 个屏幕宽度的距离,这个手感比较接近原版。

还有一个容易忽略的点:空气阻力。原版游戏里小鸟飞出去后轨迹并不完全是抛物线,而是略微受到阻力影响。我看证据不完全对应,但按经验加了与速度成正比的线性阻力:

const damping = 0.995; this.vx *= Math.pow(damping, dt * 60); this.vy *= Math.pow(damping, dt * 60);

第一次做的时候阻尼系数给了0.98,结果小鸟飞出去像是泡在水里,没一会就软趴趴掉下来了。调到0.995之后,手感接近于“几乎没有空气但有那么一点拖拽”的微妙感觉,轨迹仍然平滑漂亮。

2.2 碰撞检测:圆与矩形的分离轴定理

愤怒的小鸟的碰撞对象简单但有花样:圆形小鸟、各种尺寸的矩形木块、石块的三角形轮廓。碰撞检测就分圆-圆和圆-矩形,以及矩形-矩形。

圆-矩形碰撞用最近点法,这是效率高又容易实现的方法:

function circleRectCollision(circle: Circle, rect: Rect): boolean { const dx = Math.abs(circle.x - rect.x); const dy = Math.abs(circle.y - rect.y); if (dx > rect.w / 2 + circle.r) return false; if (dy > rect.h / 2 + circle.r) return false; if (dx <= rect.w / 2) return true; if (dy <= rect.h / 2) return true; const cornerDistSq = Math.pow(dx - rect.w / 2, 2) + Math.pow(dy - rect.h / 2, 2); return cornerDistSq <= Math.pow(circle.r, 2); }

矩形和矩形之间的碰撞,用分离轴定理(SAT)也可以轻松处理。SAT 的核心思想是:如果两个凸多边形没有碰撞,那么一定存在一条分离轴,使得两个形状在这条轴上的投影不相交。对于矩形来说,只需要检查两个轴:x 轴和 y 轴。碰撞响应则通过计算最小平移距离,把物体推开。

碰撞响应的原则:要先修正位置,再改变速度。如果顺序反过来,物体会在下一个帧又嵌进另一个物体里,产生抖动的视觉鬼畜。

材料分三档:木头最脆、石头最硬、玻璃强度居中但分数更高。碰撞响应的力度由物体的质量决定,质量公式取面积乘以密度:

const densityMap = { wood: 0.6, stone: 1.5, glass: 0.4, }; mass = width * height * densityMap[type];

质量大的物体惯性大,被撞后不易飞出去,能形成稳定的压迫感,这也是物理表现“真实感”的来源。

2.3 碰撞避免隧穿的处理

高速物体碰撞隧穿是手写物理引擎里最严重的坑。小鸟初速 900px/s,如果以 60fps 跑,每帧位移 15px,确实不会穿。但当小鸟撞到木块后,木块会向四周高速弹射,这些碎片速度轻松超过 500px/s,帧位移接近 9px。如果木块本身只有 10px 厚,就有一定概率擦边穿过。

隧穿的根源是:离散的碰撞检测是“看终点”,但物体运动是连续过程,两个物体可能在帧中间位置就相交了,但一帧结束后已经穿过彼此,检测不到。

处理隧穿的方案有好几种,我选了最实用的子步进方法:

function physicsStep(dt: number) { const subSteps = 4; const subDt = dt / subSteps; for (let i = 0; i < subSteps; i++) { updatePositions(subDt); handleCollisions(); } }

把每帧的物理时间切成 4 个更小的步长,相当于检测频率提高到 240Hz。这个方案简单、可靠,代价是 CPU 占用增加了 3 倍多。移动端低端机我测试下来依旧能跑满 60fps,因为整个场景的物体数控制在 60 个以内。

还有一对更聪明的方案是连续碰撞检测(CCD),通过射线扫掠判断路径上的交点。但实现复杂度高,而且碰撞回弹处理容易出 bug。对小项目来说,子步进是性价比最高的解法。

3. 弹弓交互与瞄准手感

3.1 拖拽交互的边界约束

弹弓的核心交互:按住小鸟拖拽,松手后发射。原版里弹弓是个 V 形,小鸟固定在皮筋上。实现时我先确定弹弓中心点的位置和最大拉拽半径(80px),然后玩家的手指(或鼠标)只要超出这个半径,就按比例缩回到边界上。

const maxPullRadius = 80; const dx = currentX - slingX; const dy = currentY - slingY; const dist = Math.sqrt(dx * dx + dy * dy); let pullX = dx; let pullY = dy; if (dist > maxPullRadius) { const scale = maxPullRadius / dist; pullX = dx * scale; pullY = dy * scale; }

这个“拉拽半径限制”看起来简单,实际手感上的讲究可不少。如果没有边界,玩家可以无限往远处拉,发射力度会失控;如果边界太小,又感觉弹弓使不上劲。我最终调到 80px,对应最大发射初速度 900px/s,刚好能飞到屏幕最远端的敌人阵地,同时又有明显小时候玩弹弓时“皮筋拉到极限”的紧绷感。

发射方向有一个细节容易搞反:拉拽方向是朝屏幕内,发射方向是拉拽方向的反向延长线。皮筋的弹力方向是朝向弹弓中心,也就是我们说的“拉左射右”。发射时的初始速度应该是从弹弓中心指向当前位置方向的反向量:

const direction = Math.atan2(slingY - pullY, slingX - pullX); bird.vx = Math.cos(direction) * initialSpeed; bird.vy = Math.sin(direction) * initialSpeed;

3.2 预判轨迹线:一条虚线如何提升游戏体验

没有轨迹线的时候,新手玩家需要两三局才能摸清发射角度和落点的关系,“失败感”很强。加上虚线轨迹后,玩家的学习成本急剧下降,每一发都会觉得自己“是瞄准过的”。

轨迹线实现方式很简单:不实际发射,而是模拟一个小鸟的副本,让它跑 60 帧物理更新,把每帧位置记录下来,绘制成虚线。这个副本不参与碰撞检测,纯粹做预测展示。

function drawTrajectory(from: Point, vx: number, vy: number) { const sim = new Bird(from.x, from.y, vx, vy); ctx.setLineDash([6, 4]); for (let i = 0; i < 60; i++) { sim.update(1 / 60); if (sim.x < 300 || sim.x > 900 || sim.y < 300) break; ctx.moveTo(sim.x, renderY(sim.y)); ctx.arc(sim.x, renderY(sim.y), 2, 0, Math.PI * 2); } }

真正调起来发现最有用的是轨迹线在转成斜抛运动时需要考虑阻尼系数对速度的影响。如果预判轨迹是理想抛物线,而实际物理有阻尼,轨迹线最后一段会出现“断片”,视觉上像是轨迹在撒谎,玩家会因此不信任它。我后来直接把同一套update()用于轨迹模拟和真实飞行,保证轨迹完全一致,这也是保持“真实感”的关键决策。

拖拽时轨迹线要跟随手指实时更新,这里注意性能开销:轨迹模拟的 60 次循环每帧都要跑,不能直接硬算。好在副本的更新很快,实际运行中没有成为瓶颈。

4. 关卡数据驱动与编辑器

4.1 关卡 JSON 格式设计

做 1.0 版本时,每个关卡都是写在代码里的new Block()一坨一坨拼,想调整一个木块的位置就得改代码重新构建。到 2.0 必须改变思路:关卡是数据,不是代码。

最终确定的 JSON 格式:

{ "id": "level-2", "name": "绿猪堡垒", "difficulty": 2, "birds": 3, "groundY": 120, "slingPosition": [150, 260], "enemies": [ { "type": "pig", "x": 700, "y": 300, "size": "normal" } ], "blocks": [ { "type": "wood", "shape": "rect", "x": 650, "y": 400, "w": 20, "h": 60, "angle": 0 }, { "type": "stone", "shape": "rect", "x": 620, "y": 350, "w": 30, "h": 30, "angle": 0 } ] }

所有物体支持一个角度参数,用来实现斜放的木板、倾斜的石台。角度这个参数加进去以后,物理世界一下子丰富了很多——通过木板搭出三角形支架、用石头垫高塔层,关卡的策略深度完全不一样。

这里有个细节:JSON 里物体的坐标是物理坐标,也就是笛卡尔坐标系,不是 Canvas 渲染坐标。加载时统一转换。如果不做这个区分,后面做关卡编辑器时数据格式会跟渲染格式强耦合,改起来很痛苦。

4.2 关卡编辑器:自动化的布局与平衡

我也写了个简单的关卡编辑器,本质上是一个可视化工具,拖动物体到预定位置,点击保存生成 JSON。编辑器跑在浏览器里,用同一个项目,只是入口路由不同,复用渲染层代码。

编辑器最有价值的不是“摆放物体”,而是“测试平衡性”。开发中我经常会摆一个看起来摇摇欲坠的塔,实际测试时怎么撞都不倒,或者一个看似牢固的碉堡,碰一下全塌了。这个“看起来稳与实测稳”的落差正是关卡设计的核心难点。

我的做法是在编辑器里放一个“快速试玩”按钮,点击后直接在当前布局中发射小鸟,不用切换到正式游戏模式。配合自动记录“剩余小鸟数”和“拆除比例”,能快速判断一个关卡的难度曲线是不是合理。实测下来,设计一个新的优质关卡流程大约从 40 分钟压缩到了 15 分钟。

4.3 存档与关卡进度

这里用 localStorage 正好,数据量小,都是每个关卡的最高分、解锁状态。但 localStorage 有 5MB 限制,而且同步读取,存大体积数据会卡 UI。我实际测试下来,把进度存成 JSON 字符串,每个关卡不到 2KB,全部关卡加起来 50KB 以内,完全够用。

用的封装很简单:

export function saveProgress(levelId: string, score: number) { const key = `angry2:progress:${levelId}`; const current = { score, stars: calcStars(score), timestamp: Date.now() }; localStorage.setItem(key, JSON.stringify(current)); }

跨浏览器同步不是目标,所以 localStorage 足够了。如果以后要加账号体系,再把存储层抽成接口,后端换成 API 就行。架构上提前留好这个口子,不然后面改造很别扭。

5. 性能优化与移动端适配

5.1 渲染层的开销控制

Canvas 2D 性能优化的关键是减少绘制路径和减少状态切换。一帧里最耗时间的操作是设置fillStyle、绘制路径、填充。频繁切换fillStyle会导致 Canvas 内部状态机反复重算,在低端安卓机上表现尤其明显。

优化方案有几个,亲测有效:

1. 同类型物体批量绘制:把当前帧所有木块、石头、玻璃分别按颜色分组,一次设置 fillStyle,绘制完再切下一个颜色。优先级最高,效果最明显。 2. 静态物体不重复绘制:场景背景、远处装饰用的云朵和树,预渲染到离屏 Canvas,每帧直接 drawImage 贴上去,一帧能省大约 2ms。 3. 禁止阴影和模糊效果:Canvas 的 shadowBlur 在移动端是性能杀手,开启后帧率直接掉到 30 以下。2.0 里用渐变色替代阴影表达立体感,效果接近但性能天差地别。

最容易忽视的是 Canvas 的像素比适配。如果不处理 devicePixelRatio,高分屏(比如 iPhone Retina)上 Canvas 会以逻辑分辨率渲染,再由浏览器拉伸,导致画面模糊。适配方法很常规但必须做:

const dpr = window.devicePixelRatio || 1; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; ctx.scale(dpr, dpr);

注意设置完后,后续绘图坐标全部按 CSS 像素来写,别混用。

5.2 物体数量管理与内存抖动

游戏运行中场景里会有大量“已经失效但还没销毁”的碎片物体。比如一个木块被撞碎成 5 个小碎块,这些碎块如果继续参与物理模拟和碰撞检测,场景很快会卡顿。

这里我用了一个简单的对象池 + 状态标记机制:

interface Body { active: boolean; x: number; y: number; // ... }

所有物理物体在初始化时塞进一个池子,后续不再新建对象,只是把active置为 true/false。销毁时不是直接删引用,而是标记active = false,然后每帧的物理循环跳过非活跃物体。这样一来,GC 压力小很多,也不会出现频繁创建对象导致的卡顿。

碎片的处理逻辑是:倒计时小于 2 秒或active刚变为 false 的碎块,逐步降低绘制 alpha,最后一帧之后才彻底从池子里释放。这个“残影渐隐”效果既优化了性能,也让拆除瞬间的视觉“层”更有质感。

音频这块容易踩坑。Google Chrome 对自动播放有严格策略,没有用户手势的情况下,audio.play()会返回一个 rejected Promise。所以首次进入游戏时,我会在“开始”按钮的点击回调里先调用一次静音音频的加载:

startButton.addEventListener('click', () => { const silent = new Audio(); silent.volume = 0; silent.play().then(() => { // 解锁音频上下文 }); });

有了这个动作之后,后续所有音效才能正常播放。如果不做这个处理,大部分玩家会抱怨“为什么没有声音”,但其实是浏览器的策略卡住了。这个也算移动端 H5 游戏的一个必修课。

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

6.1 物理“鬼畜”:物体莫名抖动或飞向宇宙

排查过程最有代表性的一次是:搭建好的木头塔,在没有任何外力的情况下,静止几秒后突然开始轻微抖动,然后越抖越厉害,最终散架。

这个 bug 的根本原因是碰撞响应过度修正。我在处理圆-矩形碰撞时,把物体推开的量设为最小平移距离,但这个距离跟速度无关。当物体速度很低、两物体刚好接触时,每帧都会把物体推离一小段距离,然后在重力作用下又掉回接触状态,如此反复,就形成了肉眼可见的抖动。

解决方案是在碰撞响应里加一个“静止阈值”判断:如果两个物体的相对速度小于某个值(比如 0.5px/s),就不再施加弹性形变,而是视为静态接触,只做位置合并,不改变速度。

if (relativeVelocity < 0.5) { // 静摩擦模式 resolvePositionOnly(); } else { resolvePositionAndVelocity(); }

这个阈值调高了会让物体看起来“粘”在一起,调低了抖动依旧存在。经过多机型验证,0.5 是一个安全的取值。

6.2 发射后小鸟突然消失:脱离世界边界

大概是第 4 关开始会出现这种情况:小鸟明明飞得好好的,突然不见了。排查后发现是物体的坐标变成了 NaN。NaN 产生的原因是有一个碎块从场景中被高速弹飞,坐标超过了 Canvas 作图范围,然后和边界外的物体计算距离时产生了 Infinity,Infinity 参与计算后变成 NaN,再传导给了其他物体。

解决思路很粗暴但有效:物理更新循环里,每帧检查所有物体的坐标,如果超出世界边界(比如 x 不在 [-200, maxWidth + 200] 范围)就直接标记为销毁,不再参与后续碰撞计算。

if (body.x < -200 || body.x > worldWidth + 200 || body.y < -200) { body.active = false; }

这个操作必须在物理更新之前做,防止 NaN 扩散。避免使用 isNaN 直接判断每个值,因为 NaN 的传播是在计算链中发生的,源头排查成本高、风险大,不如设好边界。

6.3 若玩家快速连续拖拽,小鸟卡在弹弓里

测试反馈中有一条:快速连续拖拽小鸟时,偶尔会卡在弹弓皮筋的位置,拖不动也发射不了。原因是拖拽的状态没有正确重置。比如上一颗小鸟发射后,状态机还没有完全回到“待发射”状态,玩家又开始拖拽了新生成的小鸟,导致新旧两代小鸟的引用发生了冲突。

修复方式是在进入“待发射”状态时,强制销毁前一颗小鸟的活动引用,并重置弹弓的皮筋位置:

transitionTo('ready'); currentBird = spawnBird(); slingPulled = false; slingOffset.set(0, 0);

这种状态机问题,用“分离出显式的状态流转”能杜绝大部分隐性 bug。我后来把游戏状态改成了'ready' | 'pulling' | 'flying' | 'settling' | 'done',每个状态只能从特定的前置状态进入,跨状态操作会被直接忽略。修改后这个 bug 没有再出现。

6.4 音频在部分安卓机型上出现延迟

iOS 的音频解锁流程和安卓不完全一样。安卓机器上,用户点击屏幕后立即播放音效,经常能听到约 100ms 的延迟。这个延迟来自创建 AudioContext 时的启动开销。

解决方案是预创建 AudioContext,并且首次点击就调用resume():

const audioCtx = new AudioContext(); document.body.addEventListener('pointerdown', () => { if (audioCtx.state === 'suspended') { audioCtx.resume(); } });

之后的短音效用 AudioBufferSourceNode 播放,延迟可以控制在 20ms 以内,基本感觉不到。

7. 2.0 的版本演进回顾

做 2.0 最需要时刻清楚的是:它不只是一次“换皮重做”,而是从“能玩”到“能品”的跨越。1.0 版本功能都完整,但代码是面条式的,物理参数写死在各个函数里,改一个数值可能导致所有关卡行为漂移。2.0 花了最多精力在“结构”上,把物理、渲染、逻辑彻底分离。

事实证明这个投入非常值得。后期增加“炸弹鸟”新角色时,我只在物理层的角色类型里加了一个爆炸半径参数,渲染层加了一组爆炸动画帧,逻辑层加了一个触发条件,总共改动不到 50 行代码,全部关卡自动支持新角色。如果还在 1.0 的面条结构里,这种改动基本意味着重写半套游戏。

2.0 整体工程沉淀下来的技术资产,不仅是一份可运行代码,更是一套“轻量级物理游戏架构”的模板。后来我把核心物理模块抽成 npm 包,在前端团队的另外两个项目里直接复用,开发效率提升立竿见影。

根据我这几年的经验,做一个让人愿意反复玩的物理小游戏,手感打磨比画面精致重要得多。画面好骗得了一时,手感好才能留得住人。而手感的本质,就是对物理参数的反复试错和对细节的极限追求。这篇记录没有写具体所有数值,但只要你沿着同样的排查思路去迭代,最终一定能找到属于你的那一组“最佳手感参数”。

返回列表