单文件 Canvas 游戏软件架构设计
从状态机、帧循环、输入抽象到实体生命周期,复盘一款 58 KB 单文件飞行游戏背后的软件架构与算法取舍。
《云隙计划 V9》看起来是一款轻量的竖版飞行游戏:拖动战机、自动射击、击落编队、收集补给。但从软件角度看,它更像一个被压缩进浏览器页面的实时系统——输入持续到达,世界持续演化,画面持续输出,而暂停、失焦和性能波动都必须被明确定义。
游戏、样式与声音都封装在同一个 HTML 文件中。手机可拖动,桌面可用方向键、WASD 或鼠标。
本文依据当前 V9 源码逐段整理。下面的“层”是为了理解而划分的逻辑边界;交付文件本身仍是一个 IIFE 中的原生 JavaScript,并没有虚构出源码中不存在的类或框架。
一份文件,五个逻辑层
单文件不等于没有架构。V9 的关键,是让不同职责虽然共处一份文件,却沿着清晰的数据方向协作:浏览器事件只修改输入状态;update() 只推进世界;draw() 只读取状态并绘制;UI 与音频在确定的事件点同步。
这样的组织方式适合小型实时作品:省去了工程化框架的装载成本,同时保留“输入—状态—模拟—输出”的主干。代价也很明确:当内容继续增长时,全局变量与长函数会变得难以测试,届时再把输入、世界和渲染拆成独立模块会更合适。
状态机先于玩法
V9 使用四个明确状态:idle、playing、paused、over。setState(next) 不仅改变量,也统一维护暂停按钮、模式文字和遮罩层。因此,“游戏是否在运行”不会散落成多组互相矛盾的布尔值。
reset()。两个细节很重要:
- 页面失焦或进入后台时自动暂停,避免玩家回到页面后发现游戏已经结束;
- 动画帧没有在暂停时停止。
requestAnimationFrame()仍然驱动星空和残余粒子,因此遮罩后的世界不是一张僵硬截图。
function togglePause() {
if (state === 'playing') setState('paused');
else if (state === 'paused') {
setState('playing');
last = performance.now(); // 丢弃暂停期间累积的时间
}
}
恢复时重置 last,是一个很小但关键的实时系统防护。如果不做,恢复后的第一帧会把整段暂停时间当作 dt,所有对象都会突然跃迁。
帧循环:时间是唯一时钟
游戏没有依赖固定的“每帧移动多少像素”,而是把位移写成速度乘以秒数 dt。这样 60 Hz 与 120 Hz 屏幕上的运动速度基本一致。
源码把 dt 上限限制为 0.033 秒。即使浏览器偶发卡顿,下一帧也不会把战机或子弹一次推进过远,从而降低“穿透碰撞”和失控跳跃的概率。
function frame(now) {
const elapsed = last ? Math.max(0, now - last) : 16.7;
const dt = Math.min(.033, elapsed / 1000);
last = now;
frameEMA = frameEMA * .94 + elapsed * .06;
// 根据帧时间缓慢调节 fxScale
update(dt);
draw();
requestAnimationFrame(frame);
}
这里还有一条适合移动端的自适应支路:帧时间的指数移动平均值 frameEMA 高于 27 ms 时,系统逐步降低 fxScale;低于 19 ms 时再缓慢恢复。fxScale 控制爆炸粒子数量与星点抽样密度,触屏设备的初始值和上限本来就更低。也就是说,首先牺牲的是装饰性效果,不是碰撞、输入或射击节奏。
“命中停顿”同样没有暂停整个程序。hitStop > 0 时,世界模拟暂缓,而粒子仍以 0.18 × dt 慢速更新,制造有重量的短暂停顿。
输入抽象:把三种设备变成一个目标点
V9 的内部坐标始终是 720 × 900。Canvas 在 CSS 中缩放以适应屏幕,但指针坐标会先通过元素的实际边界换算回逻辑坐标。碰撞和速度因此不受手机分辨率影响。
键盘同时按两个方向时,代码先除以向量长度,避免斜向移动比水平移动快。触屏则把目标点向上偏移 145 个逻辑像素,让战机悬浮在手指上方;再用 min(1, dt × 16) 做平滑追随,既保留直接感,又减少手指细小抖动。自动射击进一步移除了手机上的第二个高频操作,使一根手指就能完成核心循环。
实体不是对象树,而是短命的数据记录
玩家之外,V9 的动态对象按用途保存在数组中。生成时推入,更新时写入 dead,帧尾再统一过滤。这种“标记—清扫”比在嵌套碰撞循环中立刻删除数组元素更安全。
| 集合 | 诞生 | 每帧演化 | 结束条件 |
|---|---|---|---|
enemies | 生成时钟到零 | 纵向速度 + 正弦横摆 + 射击计时 | 越界、碰撞或 HP 归零 |
bullets | 玩家自动开火 / 敌机瞄准 | 速度积分 | 越界或首次命中 |
powers | 教学节点或救援额度 | 下落与旋转 | 越界或收集 |
particles | 命中、爆炸、受伤 | 阻尼、寿命衰减 | 寿命归零;数量有上限 |
rings | 冲击反馈 | 逼近目标半径 | 寿命归零;数量有上限 |
碰撞采用圆形近似:比较两者中心距离与半径之和。玩家与敌机、敌弹、补给分别使用略有不同的容差。玩家子弹对敌机是一个嵌套遍历,理论复杂度为 O(B × E);但 V9 把在场敌机限制在 28、34 或红线模式下的 42 架,所以在这个规模内,简单算法比空间索引更容易验证,也足够快。
难度曲线是一组彼此咬合的函数
难度不是简单地“每关加血”。V9 每 15 秒提高一个空域编号,从第 5 空域进入 hard。敌机生成间隔随时间逐渐缩短,同时叠加随机扰动;hard 与红线模式还会继续压缩间隔,并提高在场数量上限。
敌机有四类参数模板:scout、wing、ace 与 carrier。其中 carrier 不是纯随机出现:第 3 空域后,每累计 28 次击落且场上没有 carrier 时触发。连续击落会把 combo 提升到最多 8 倍,但 2.1 秒内没有续上就归一;分数达到 800000 后进入红线模式。
系统里还藏着一条“失败后的扶正”算法:玩家受伤但仍存活时会积累一个救援额度,之后通过击落推进倒计时并投放补给。补给恢复 1 点生命,并把增强火力延续 6 秒;红线模式下延续 9 秒并切换四列弹道。这条支路让难度升高时仍保留恢复窗口,而不是只把压力单向叠加。
反馈系统:同一个事件,同时抵达眼睛、耳朵与手
一次重型击落会同时触发粒子、冲击环、屏幕震动、闪光、短暂停顿、音高组合和触屏振动。它们读取的是同一个 gameplay 事件,所以反馈不会脱离规则独自播放。
声音也采用两条路径:激光与补给音效是内嵌的 Base64 Ogg,首次用户交互后解码为 AudioBuffer;命中、受伤和模式提示则由 Web Audio 振荡器即时合成。所有声音经过 sfxBus → master → DynamicsCompressor → destination,在密集射击时限制峰值。Web Audio 不可用或解码失败时,游戏仍可无声运行。
单文件交付不是打包技巧,而是可靠性设计
V9 当前文件约 58 KB,没有外链脚本、字体、图片或声音请求。CSS、JavaScript 和两个 Ogg 样本都在 HTML 内;最高分通过带 try/catch 的 safeStore 访问 localStorage,即便浏览器禁用存储也不会阻断主循环。
这带来三个直接结果:
- 双击文件即可运行,没有构建产物与源文件错配的问题;
- 缓存、网络中断或第三方 CDN 故障不会破坏游戏主体;
- 一个文件就是一个确定版本,便于分享、归档与回归检查。
它当然不是所有项目的答案。多人协作、关卡数据持续增长或需要自动化测试时,模块化工程更合适。但对于边界清楚、资源有限、强调离线可运行的互动作品,V9 证明了另一件事:架构的本质不是文件数量,而是状态归属、数据方向和失败边界是否清楚。