← 全部文章
NOTE 01JavaScript · Canvas · Architecture

单文件 Canvas 游戏软件架构设计

从状态机、帧循环、输入抽象到实体生命周期,复盘一款 58 KB 单文件飞行游戏背后的软件架构与算法取舍。

发布于 2026/08/1512 分钟阅读通用公开示例

《云隙计划 V9》看起来是一款轻量的竖版飞行游戏:拖动战机、自动射击、击落编队、收集补给。但从软件角度看,它更像一个被压缩进浏览器页面的实时系统——输入持续到达,世界持续演化,画面持续输出,而暂停、失焦和性能波动都必须被明确定义。

RUNNABLE SOFTWARE · V9 先体验,再看它如何工作

游戏、样式与声音都封装在同一个 HTML 文件中。手机可拖动,桌面可用方向键、WASD 或鼠标。

打开 V9 ↗

本文依据当前 V9 源码逐段整理。下面的“层”是为了理解而划分的逻辑边界;交付文件本身仍是一个 IIFE 中的原生 JavaScript,并没有虚构出源码中不存在的类或框架。

一份文件,五个逻辑层

单文件不等于没有架构。V9 的关键,是让不同职责虽然共处一份文件,却沿着清晰的数据方向协作:浏览器事件只修改输入状态;update() 只推进世界;draw() 只读取状态并绘制;UI 与音频在确定的事件点同步。

01 · DELIVERY SHELL HTML + CSS + 内嵌 Ogg 页面结构、响应式容器、HUD、离线资源
02 · INPUT键盘 · Pointer · Touch统一写入 keys / pointer
03 · STATE状态机 · 实体数组唯一的世界事实来源
04 · SIMULATIONupdate(dt)移动、生成、碰撞、回收
05 · OUTPUTdraw() · HUD · AudioCanvas、DOM 与反馈
图 1 V9 的逻辑分层。箭头表达数据方向,而不是源码目录。

这样的组织方式适合小型实时作品:省去了工程化框架的装载成本,同时保留“输入—状态—模拟—输出”的主干。代价也很明确:当内容继续增长时,全局变量与长函数会变得难以测试,届时再把输入、世界和渲染拆成独立模块会更合适。

状态机先于玩法

V9 使用四个明确状态:idle、playing、paused、over。setState(next) 不仅改变量,也统一维护暂停按钮、模式文字和遮罩层。因此,“游戏是否在运行”不会散落成多组互相矛盾的布尔值。

初始IDLE开始 / Enter / Space
运行PLAYINGP / Esc / 失焦
冻结PAUSED继续
结算OVER再次起飞 / R
图 2 可达状态与主要触发条件。重新开始总会经过 reset()。

两个细节很重要:

  1. 页面失焦或进入后台时自动暂停,避免玩家回到页面后发现游戏已经结束;
  2. 动画帧没有在暂停时停止。requestAnimationFrame() 仍然驱动星空和残余粒子,因此遮罩后的世界不是一张僵硬截图。
function togglePause() {
  if (state === 'playing') setState('paused');
  else if (state === 'paused') {
    setState('playing');
    last = performance.now(); // 丢弃暂停期间累积的时间
  }
}

恢复时重置 last,是一个很小但关键的实时系统防护。如果不做,恢复后的第一帧会把整段暂停时间当作 dt,所有对象都会突然跃迁。

帧循环:时间是唯一时钟

游戏没有依赖固定的“每帧移动多少像素”,而是把位移写成速度乘以秒数 dt。这样 60 Hz 与 120 Hz 屏幕上的运动速度基本一致。

浏览器时间戳elapsed = now − last
安全边界0 ≤ dt ≤ 0.033 s
推进世界update(dt)
输出一帧draw()
图 3 每帧的数据管线。模拟与绘制保持单向关系。

源码把 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 中缩放以适应屏幕,但指针坐标会先通过元素的实际边界换算回逻辑坐标。碰撞和速度因此不受手机分辨率影响。

DESKTOP方向键 / WASD归一化方向向量
POINTER鼠标 / 触屏屏幕坐标 → 720 × 900
CONTROL STATEkeys + pointer只保存意图,不直接绘制
SIMULATIONplayer.x / player.y边界钳位后进入碰撞系统
图 4 设备事件在进入模拟前被压缩成两种控制状态。

键盘同时按两个方向时,代码先除以向量长度,避免斜向移动比水平移动快。触屏则把目标点向上偏移 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 与红线模式还会继续压缩间隔,并提高在场数量上限。

时间 →
每 15 sSECTOR +1第 5 区起:hard
生成节奏0.74 − time × 0.0048下限 0.22 s
敌机上限28 → 34REDLINE:42
ACE 概率随时间缓慢增加封顶 24% / 30%
图 5 时间、模式与容量限制共同塑造压力,而非单一等级变量。

敌机有四类参数模板: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 证明了另一件事:架构的本质不是文件数量,而是状态归属、数据方向和失败边界是否清楚。