前段时间折腾了一个叫 game-bird 的小项目——一款基于 HTML5 Canvas 的休闲小游戏,玩家控制一只不断扇翅膀的小鸟,在无限滚动的场景里躲开迎面而来的障碍物。最初我只是想给刚接触前端的同事做个教学示例,但真正动手以后发现,这个看似简单的游戏背后几乎覆盖了前端开发的所有基础知识点:游戏循环、渲染性能、物理模拟、碰撞检测、状态管理,甚至还有移动端触摸适配。写完以后我最大的感触是:越是小游戏,越能练出扎实的前端基本功。这篇文章我会把 game-bird 从零到完成的整个思考过程、代码实现和踩坑记录全部拆解出来,适合想用 JavaScript 做小游戏的新手,也适合想了解浏览器动画原理的进阶玩家。
1. 项目整体设计与思路拆解
1.1 名字背后的产品定位
"game-bird" 这个名字一语双关,既是"被游戏操控的鸟",又暗指这类"点击跳跃闯关"玩法。一开始我只是想做一个 Flappy Bird 的复刻,但在定名的时候刻意去掉了"Flappy",因为我不想局限在抄袭一个爆款。game-bird 可以是任何一只被你控制的鸟——它可以是翅膀煽动、上下漂浮、甚至带一点惯性旋转。这个自由度决定了后续技术实现的高度。
我把它定位成"低门槛、高上限"的前端练手项目:核心玩法控制在 10 分钟内能讲清楚,但留下的优化空间足够你再折腾两周。比如你可以给小鸟加入蓄力系统,可以生成不同速度的障碍物,也可以把背景做成视差滚动。骨架稳了,扩展只是加模块的事,而不是推倒重来。
1.2 技术方案选型的心路历程
游戏渲染无非几条路:直接用 DOM 元素加 CSS animation、用 SVG、用 Canvas,或者上 WebGL。我第一时间排除了 DOM 动画,因为游戏需要每帧更新小鸟位置和障碍物位置,如果改成操作若干 div 的 transform,浏览器会在样式计算和重绘上消耗大量性能,而且多个元素频繁移动时很难保证 60fps。SVG 的优势是方便做矢量图形,但碰撞检测要拿每个元素的 boundingBox 去算,图形复杂后也会吃力。WebGL 显然杀鸡用牛刀,还需要额外学习着色器。
Canvas 是最终的答案:它有一块独立的位图画布,游戏世界里的一切都在 JavaScript 里更新坐标,然后批量绘制。Canvas 没有 DOM 节点,不需要每帧触发布局计算,只要控制好绘制频率,60fps 非常轻松。另外 Canvas 的 2D context API 足够简单,getContext('2d') 之后就是画矩形、画圆、画图片,学习曲线几乎为零。我选择它不是因为"流行",而是因为这个项目所有的核心操作——坐标变换、碰撞判断、粒子特效——在 Canvas 里都是最直接的数学运算,不需要框架帮你藏东西。
1.3 游戏循环:从 setInterval 到 requestAnimationFrame
很多初学者写游戏循环习惯用 setInterval(gameLoop, 16.7),但这有几个问题:定时器在标签页切到后台时会被强制节流,最小间隔可能变成 1000ms;其次 setInterval 的触发时机和系统刷屏率无关,容易出现跳帧或者撕裂。我在 game-bird 里用的是 requestAnimationFrame(后面统一叫 rAF),这个 API 会让浏览器在每次刷新屏幕之前恰好调用一次回调。
rAF 还有一个隐藏好处:当页面被切到后台时,浏览器自动暂停回调,回到前台后又能无缝恢复,这对游戏和用户设备都非常友好。代码上只需要在每一帧里递归调用 rAF,并通过传入的时间戳计算帧间隔,而不是傻傻地认为每帧固定 16.7ms。这一点直接决定了游戏在不同帧率的设备上能保持一致的物理表现,也为后面"帧率适配"做好了准备。
2. 核心功能拆解:小鸟运动的每一帧
2.1 重力与升力:一场简单的数值博弈
小鸟的垂直运动是整个游戏手感的灵魂。我不想用复杂的Box2D物理引擎,实际上 Canvas 小游戏根本不需要。我只需要两个基本参数:重力加速度和升力。每帧小鸟的垂直速度增加一个固定值,模拟重力;玩家点击或按键时,垂直速度立刻被重置成一个负数,模拟向上扑腾。
初始参数我设定为:重力加速度 0.4(像素/帧),升力 -6.5(像素/帧)。为什么是这两个数字?这需要从帧率倒推。如果游戏跑在 60fps,那么一帧大约 16.7ms,0.4 的重力意味着每秒速度增量约为 24 像素/秒。这个力度下,小鸟会自然下落,但不会快到失控。升力 -6.5 能让小鸟瞬间获得向上的速度,然后又被重力慢慢拉回来,形成一条流畅的抛物线。你可以试着改一改,重力高于 0.5 会感觉下坠太重,低于 0.3 又会漂得不跟手。
为了避免不同刷新率设备上速度不一致,我在更新函数里不再固定一帧一算,而是通过 rAF 提供的时间戳计算 deltaTime,将重力乘以 deltaTime / 16.7 来归一化。这个细节非常重要,否则 120Hz 手机上小鸟会飞得比 60Hz 电脑上慢一半。
2.2 旋转角度:让小鸟的姿势带感情
只是垂直位移的小鸟看起来像个纸片。为了让动作更自然,我加了一个倾斜角:上升时鸟嘴朝上仰,下落时鸟头向下扎。实现并不复杂,用一个 targetAngle 表示目标倾斜角,根据当前垂直速度映射:上升时角度为 -25 度,下落时角度逐渐向 75 度靠近。然后每一帧做线性插值,让当前角度缓慢靠向目标角度,而不是瞬间跳变。
插值公式用的是 currentAngle += (targetAngle - currentAngle) * 0.1。这个 0.1 是每一帧的"逼近速度",数值越大转向越快,越小越平滑。0.1 在 60fps 下视觉效果很柔和,不会让人觉得"鸟头直接甩过去了"。如果哪天你想做"喝醉的小鸟",把 0.1 改成 0.02,鸟的转动会迟钝很多,也很有喜感。这种小参数调整就是制作手感的关键,很多游戏引擎调了半天,其实就是在调这种插值系数。
2.3 碰撞检测:规定矩形框就有了一切
真做像素级碰撞检测会非常昂贵:假设画布是 400x600,要逐像素比较小鸟与障碍物的透明度,在每帧 60 次的情况下性能直接崩。所以绝大多数 2D 游戏都用矩形包围盒近似碰撞。我给小鸟的碰撞框设置得比视觉尺寸小一圈:视觉小鸟是 34x26 像素,碰撞框则只有 24x18 像素,并且在绘制时整体向上偏移 2 像素。
为什么缩小碰撞框?因为视觉误差是游戏体验的一部分。玩家看到的是"鸟毛擦到柱子",但如果用完整的矩形框,鸟的翅膀边缘最先碰到柱子,明明视觉上没有压到,却判定了死亡,玩家会觉得很冤枉。缩小一圈碰撞框能大幅提升容错率,尤其对于移动端手指遮挡屏幕的情况。障碍物的碰撞框倒是不需要缩小,因为它们本来就是规则矩形,但可以给每根柱子的"管道口"留一点视觉装饰,实际碰撞范围仍然是矩形部分。
矩形与矩形的碰撞判断非常简单:如果 A 的右侧大于 B 的左侧,且 A 的左侧小于 B 的右侧,同时 A 的底部大于 B 的顶部,且 A 的顶部小于 B 的底部,那么两个图形重叠。把每个障碍物拆成上柱和下柱两个矩形,再加上地面和天花板,循环判断即可。
3. 实操过程与核心环节实现
3.1 搭建最小可运行骨架
我不想上来就写一堆配置,而是先做一个能跑起来的极简目录。项目只需要三个文件:index.html、style.css、game.js。HTML 里放一个 canvas 标签,然后在 JS 里获取画布上下文,设置尺寸为 480x640,这个比例接近手机竖屏,又不会太窄。
下面的代码是 game-bird 的骨架,把 canvas 画出来,并启动游戏循环:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <title>game-bird</title> <style> html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #1e1e2f; } canvas { display: block; margin: 0 auto; background: linear-gradient(to bottom, #87ceeb, #e0f6ff); } </style> </head> <body> <canvas id="gameCanvas" width="480" height="640"></canvas> <script src="game.js"></script> </body> </html>// game.js const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); let lastTime = 0; let bird = { x: 80, y: 320, vy: 0, angle: 0, width: 34, height: 26 }; const GRAVITY = 0.4; // 每帧重力加速度 const JUMP_FORCE = -6.5; // 每次点击的升力 const FRAME_TIME = 1000 / 60; function update(deltaTime) { // 用时间戳归一化 const dt = deltaTime / FRAME_TIME; bird.vy += GRAVITY * dt; bird.y += bird.vy * dt; bird.angle = Math.max(-Math.PI / 4, Math.min(Math.PI / 2, bird.vy * 0.1)); } function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(bird.x, bird.y); ctx.rotate(bird.angle); // 画一个小圆点或者图片占位 ctx.fillStyle = '#ffcc00'; ctx.beginPath(); ctx.arc(0, 0, bird.width / 2, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } function gameLoop(timestamp) { const deltaTime = timestamp - lastTime; lastTime = timestamp; if (deltaTime > 100) { // 防止切后台回来时产生巨大跳跃 lastTime = timestamp; requestAnimationFrame(gameLoop); return; } update(deltaTime); render(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);这个骨架已经能显示一个在重力作用下下落的小鸟。注意我在 update 里用了 dt 系数,把固定帧率的时间戳换算成标准 60fps 的增量,这样无论浏览器是 60Hz 还是 120Hz,重力表现基本一致。如果你直接把这段代码跑起来,会发现小鸟很快就掉出屏幕,这是正常的,因为还没加点击控制。
3.2 加入控制:点击与键盘
用户交互是游戏的第一步。桌面端监听 keydown,按下空格或上方向键;移动端监听 touchstart,点击屏幕任意位置。事件触发时,把 bird.vy 重置为 JUMP_FORCE,同时加一个"翅膀扇动"的动画标记,这样每一帧可以切换翅膀的上下姿态。
事件绑定需要注意两个坑:移动端 touchstart 之后,手指可能滑动触发浏览器的滚动或缩放,需要在事件处理函数里调用 e.preventDefault(),同时在 CSS 里设置 user-scalable=no。另一个坑是重复触发:如果你按下空格不放,需要设置一个冷却机制,或者只在 keydown 的重复事件里过滤掉 e.repeat。否则小鸟会连续往上飞,那是火箭不是鸟。
更新后的交互代码:
canvas.addEventListener('touchstart', function(e) { e.preventDefault(); flap(); }); document.addEventListener('keydown', function(e) { if (e.code === 'Space' || e.code === 'ArrowUp') { if (!e.repeat) { flap(); } e.preventDefault(); } }); function flap() { bird.vy = JUMP_FORCE; bird.flapFrame = 0; // 用于翅膀动画 }在 update 里增加翅膀动画:
if (bird.flapFrame !== undefined) { bird.flapFrame += 1; if (bird.flapFrame > 6) bird.flapFrame = 0; }然后绘制的时候根据 flapFrame 判断翅膀是抬还是压。这一步我建议直接用两张简单的三角形来表示翅膀,不需要加载图片素材。画代码时用 Canvas 的 beginPath、moveTo、lineTo 就能搞定,又轻又无外部依赖。
3.3 障碍物生成:用数组管理无限世界
障碍物在游戏里是无限生成的,但实际上是"回收再利用"。我维护一个 pipes 数组,每过一定帧数就生成一组新管道,由上方和下方两个矩形组成。管道从右侧出现,以恒定速度向左移动,移出左边界后从数组中移除。
管道之间的水平间隔我设置为 220 像素,垂直开口高度在 140 到 180 像素之间随机。开口位置不能完全随机,否则会出现连续两个极端开口导致玩家无法通过。我做了简单的缓存:每次生成的开口中心点与上一个开口中心点差值限制在 ±80 像素以内,保证难度曲线平滑。这个设计其实来自很多跑酷游戏的"动态难度"机制,只是我这里用最简单的方式实现了。
生成和移动的代码如下:
let pipes = []; let pipeTimer = 0; const PIPE_SPACING = 220; const PIPE_SPEED = 2.5; const PIPE_WIDTH = 60; let lastGapCenter = 320; function spawnPipe() { const gapSize = 160 + Math.random() * 40; // 140~200 const minCenter = 100; const maxCenter = canvas.height - 100; const center = Math.max(minCenter, Math.min(maxCenter, lastGapCenter + (Math.random() * 160 - 80))); lastGapCenter = center; const topHeight = center - gapSize / 2; const bottomY = center + gapSize / 2; pipes.push({ x: canvas.width + PIPE_WIDTH, topHeight: topHeight, bottomY: bottomY, counted: false }); } function updatePipes(dt) { pipeTimer += dt; if (pipeTimer >= PIPE_SPACING) { pipeTimer = 0; spawnPipe(); } for (let i = pipes.length - 1; i >= 0; i--) { pipes[i].x -= PIPE_SPEED * dt; if (pipes[i].x + PIPE_WIDTH < 0) { pipes.splice(i, 1); } } }这里有一个小的经验:不要把 pipeTimer 累加后再除以间距,因为这样会丢失余数,导致管道间隔忽长忽短。我在累加后判断是否大于等于间距,然后减去间距保留余数,这样生成节奏才稳定。
3.4 分数与游戏状态管理
游戏状态最少要分三种:READY、PLAYING、OVER。READY 状态在用户点击后进入 PLAYING,小鸟撞到管道或地面时进入 OVER,OVER 状态点击一次回到 READY 而不是直接重开,方便玩家看自己的分数。
我用一个简单的状态机函数管理,每个状态下有不同的 update 行为。例如 READY 状态下小鸟固定在一个位置上下漂浮,PLAYING 状态下才应用完整的重力与移动逻辑。这样做的好处是代码不会因为一堆 if 分支变得混乱。
分数计算规则是:每当一组管道的 x 位置越过小鸟的 x 坐标且未被计数时,score 加一。注意管道是成对生成的,需要给每个管道对象加一个 counted 布尔值,防止左右两根都被计数两次。我见过很多新手写 Flappy Bird 时会在检测到碰撞时判断得分,这会导致一次穿越算三遍分数,原因就是没有用 counted 标记。
4. 视觉与音效:细节把产品从"能玩"变成"好玩"
4.1 视差滚动:让背景动起来
如果背景静止,玩家就感受不到自己在前进。我做了两层视差:远景云朵以 0.5 倍速移动,近景建筑轮廓以 1.5 倍速移动。这样鸟类移动时,背景层的速度差异会带来很强的空间纵深感。
视差实现不需要任何库,只需要为每层维护一个偏移量 offset,绘制时用 ctx.save() 和 ctx.translate(offset, 0) 把整个层移动。当 offset 小于一张背景图的宽度时,在右侧再补一张相同的图,循环往复。比如云朵背景宽度 480,你就在 offset 和 offset + 480 两个位置各画一次,然后每帧 offset -= 0.5 * dt,这样用最少的资源实现无限背景。如果背景是纯色渐变,甚至可以不做视差,只掉几朵云也有效果。
4.2 粒子与死亡特效
直接切换"游戏结束"画面很生硬,我给小鸟加了一个死亡时的羽毛粒子效果。实现方法是在 died 事件触发时,生成 10 到 15 个粒子对象,每个粒子有随机初始速度、重力和生命周期。在死亡状态下的 update 里更新粒子坐标,render 里把它们画成小圆形或羽毛形状,透明度随着生命周期衰减。
粒子系统最忌讳的是数组无限增长。我在粒子对象里加了 life 属性,每帧递减,life <= 0 时从数组里移除。还要限制粒子总数上限,如果超过 200 个就忽略新生成,否则一次大型碰撞可能让粒子数量上千,Canvas 绘制帧率立刻掉到十几。对独立游戏来说,粒子动画的吸引力仅次于物理反馈,但性能永远是第一位的。
4.3 音效:不写代码的替换方案
我并不是音频工程师,所以音效这块我选择了最省力的方式:用 Web Audio API 生成短促的合成音,而不是加载音频资源。点击跳跃时可以生成一个上升的锯齿波音效:设置一个 oscillator,frequency 从 300Hz 快速升到 700Hz,持续 0.1 秒。这个"哔"的音效比任何下载的素材都更贴合游戏氛围,而且不会因为异步加载音频文件导致首次点击延迟。
死亡音效可以做一个下降的方波,从 800Hz 降到 100Hz。得分音效则是一个短促的三角波「叮」。Web Audio API 上手很快,代码量也很少,建议小游戏都试试直接用它的 oscillator。省去了音频格式兼容和 CDN 依赖的麻烦。
5. 常见问题与排查技巧实录
5.1 为什么有时候小鸟会"瞬移"或者卡住
这个问题的根源是 rAF 的时间戳处理不当。浏览器切到后台几分钟后,再切回来时 timestamp 会是当前时间,与 lastTime 差值可能达到几万毫秒,如果直接拿这个 deltaTime 去做移动计算,小鸟会瞬间飞出屏幕。我在游戏循环里加了保护,如果 deltaTime 大于 100ms,就直接重置 lastTime 并跳过这一帧更新。另外,即使不切后台,在低端安卓机上,rAF 的触发间隔可能不稳定,所以一定不能用固定的速度增量,必须根据 deltaTime 做缩放。
如果你发现自己写了 dt 系数后移动仍然不一致,请检查你的 dt 计算基准。我统一用 60fps 作为基准,即 dt = deltaTime / (1000 / 60)。如果浏览器是 120Hz,deltaTime 约是 8.3ms,dt 约 0.5,那么鸟的移动和重力都会减半,正好匹配帧率翻倍。但需要注意的是,其他逻辑比如旋转插值系数 0.1,这个看似固定值的参数实际是"每帧逼近",不经过 dt 修正时在高帧率下会转得更快。要么固定逻辑帧率,要么给插值系数也做帧率归一化,否则会出现显示结果不一致。
5.2 碰撞检测明明有重叠却没触发
这个问题排查起来很有迷惑性。最常见的原因是坐标系不一致:碰撞框是基于小鸟的世界坐标,比如 bird.x = 80,bird.y = 320,但进入 PLAYING 状态前你可能会把 bird.y 重新赋值为一个初始位置,却忘了同步碰撞框偏移。我建议把所有碰撞框都用一个独立的 getCollisionBox() 函数计算,而不是每次在 update 里手动维护一个碰撞框数组。这样所有逻辑统一从一个位置推导。
另一个问题是旋转后的碰撞框没有更新。小鸟在游戏里会旋转,但矩形的碰撞框是未旋转的。直接拿 AABB(轴对齐包围盒)来碰撞,在小鸟 rotate 较大时会产生"明明视觉没碰到,却判定死亡"的情况。我实际测试后,在小鸟旋转角度小于 30 度时,未旋转的矩形碰撞框误差可以接受。如果你追求更精确的碰撞,可以改用圆或椭圆碰撞,或者做旋转矩形的分离轴定理碰撞检测,但那样对于这个项目来说性能反而吃紧。折中方案就是上文提到的把碰撞框缩得比视觉小一点,用视觉上的"宽容"掩盖几何误差。
5.3 移动端触摸延迟和误触
移动端浏览器对触摸事件默认会有约 300ms 的点击延迟,虽然现代浏览器已经逐渐取消,但为了确保手感,我在 touchstart 事件里直接处理,而不是用 click。同时 touchstart 会在手指刚接触屏幕时触发,省掉了 click 等待时间。另外,为了避免用户在游戏结束画面误触导致直接重开,我加了一个 300ms 的"死亡冷却时间",在 OVER 状态的前 300ms 内忽略一切交互输入。这个冷却不仅提升了手感,还减少了误操作。
实际测试中我还发现,ios Safari 在 body 上有默认的橡皮筋滚动效果。即使 canvas 撑满屏幕,用户下拉时依然会看到背景抖动。此时不仅要给 body 加 overflow: hidden,还要在触摸事件里调用 e.preventDefault()。不过这会导致页面上的其他按钮无法滚动,好在 game-bird 是纯游戏页面,没有其他交互,所以用起来很安全。
5.4 常见问题速查表
下面是一些我在调试过程中整理的高频问题,每一条都对应着我在 game-bird 里做过的妥协或修正。
| 问题表现 | 根本原因 | 我的解决办法 |
|---|---|---|
| 游戏越跑越慢 | 每帧都 new 对象,垃圾回收频繁 | 复用粒子与管道对象,避免在循环里创建临时数组 |
| 画面闪烁 | clearRect 没有清干净或者颜色混合模式异常 | 每帧先 clearRect 整个 canvas,再按顺序绘制背景、管道、小鸟 |
| 点击后小鸟没反应 | 事件绑定到 canvas 但 canvas 被 CSS 层覆盖 | 检查是否给 canvas 设置了 position 相关样式,并确认事件绑定元素 |
| 管道生成间隔不均匀 | 用了 pipeTimer / PIPE_SPACING 取整 | 使用累减余数法,保持精确间隔 |
| 移动端画面变形 | 没有处理设备像素比 | 在画笔 ctx.scale(dpr, dpr) 并设置 canvas.width 为逻辑宽乘以 dpr |
设备像素比这个问题要单独提一下。如果只写 canvas.width = 480,那么在不同 DPR 的屏幕上会被浏览器自动拉伸,导致文字和线条模糊。正确做法是在初始化时读取 window.devicePixelRatio,把 canvas 的物理尺寸设为逻辑尺寸乘以 dpr,再用 ctx.scale(dpr, dpr) 让绘图坐标保持逻辑尺寸。我在 game-bird 里就踩了这个坑,在 2x 屏下面显示出的画面模糊得不行,改完瞬间清晰。
6. 优化与后续扩展建议
6.1 性能优化的三条铁律
第一个优化点是用对象池管理障碍物和分数粒子。数组频繁的 push 和 splice 会带来大量的内存碎片和 GC 压力,尤其当管道数量变多之后,每秒钟都在创建、销毁对象。我简单写了一个长度为 8 的管道对象池,元素移出屏幕后不销毁,而是标记为 inactive,下次生成时直接复用。这能让 GC 停顿显著减少,低端安卓机上帧率更稳定。
第二个优化点是只绘制可视区域内的对象。Canvas 绘制不是越少越好,而是要保证"该画的必须画"。对于 pipes 数组,我过滤掉 x > canvas.width + 100 和 x + PIPE_WIDTH < 0 的元素,虽然管道数组本身不大,但养成这个习惯能让你后期加特效时不会卡顿。
第三个优化点是合并绘制状态。每画一个矩形就设置一次 fillStyle 会频繁切换 Canvas 的画笔状态。我在渲染循环里把所有上方管道用同一个 fillStyle 画完,再设置一次 fillStyle 画下方管道,然后再画鸟和粒子。这样画笔状态切换从 O(n) 降到了常数级别。
6.2 玩法扩展:从一个骨架变成多边形世界
现在 game-bird 是一个标准的跳跃闯关游戏,但你如果想让这个项目继续进化,我建议从三个方面入手:增加道具系统、加入关卡主题、改成双人竞技。道具可以做成"护盾"和"磁铁",本质上是给鸟增加一个临时状态,在碰撞检测里跳过死亡判定。关卡主题可以做成白天、黄昏、沙漠,每套主题对应一组调和背景色、管道样式和重力系数。双人竞技则需要改造成左右分屏,用同一个管道数组但两个独立的小鸟状态,这个难度会突然上升,但也是对游戏架构的最好考验。
无论怎么扩展,核心的 rAF 循环、状态机、碰撞检测都不会变。把基础打牢的好处就在这里,新功能更像是在已有的逻辑上搭积木,而不是重新发明轮子。
6.3 发布前的小检查清单
最后说一下我自己每次发布一个 Canvas 小项目都会做一遍的检查。第一,确认所有事件都有防重复触发和 preventDefault,避免移动端滑动页面。第二,在隐私模式下打开一次游戏,确认没有外部资源加载失败,所有素材尽量内联。第三,把 FPS 显示打开跑十分钟,观察有没有内存上涨。我习惯在游戏循环里用累加器每 60 帧计算一次实际 FPS,低于 50 就要检查渲染复杂度。第四,测试一下窗口缩放。虽然游戏是固定尺寸,但浏览器窗口大小变化后 Canvas 的 CSS 缩放可能会导致坐标偏移,最好把 canvas 设置为最大高度适配,并在 resize 事件里重新计算逻辑尺寸。做到这几点,基本上成品就能放心拿给朋友玩了。
在我个人的实操感受里,game-bird 这样的项目最值得投入的反而不是炫酷的画面,而是那些看不见的细节——稳定的物理表现、宽容的碰撞、自适应各种屏幕的手感。这些小东西单独拿出来都不起眼,但拼在一起就把一个普通的小游戏变成了别人愿意多玩几局的小作品。如果你也想试着写一个自己的小游戏,不妨从复制我这段代码开始,然后试着改掉一个参数、加一个新玩法,你会发现游戏开发的乐趣,就是在不断调参和试错中慢慢长出来的。