Canvas 弹道联机实战:700 行 + 固定时间步长

Canvas 弹道联机实战:700 行 + 固定时间步长

一个浏览器里的弹弹堂:调角度、蓄力度、借风力把炮弹糊到对手脸上。全程没用任何游戏引擎------702 行原生 Canvas 前端 + 160 行 Python aiohttp 后端。这篇把它的弹道积分、AI 搜索、权威服务器和 6 个真实踩坑拆开讲,所有数值都是本机跑出来的实测数据,不是估的。

一、先看效果:三种模式,一套物理

模式 说明 是否需要后端 物理在哪算
单机对战 同屏双人轮流操作 浏览器本地
人机对战 玩家 vs 内置 AI 浏览器本地
双人联网 房间号匹配,实时轮流发射 是(Python aiohttp) 本地算,服务器裁决

三种模式共用同一套物理代码,这是整个项目最关键的架构决定------后面第四节会讲为什么。

能力清单:

  • 键盘 ← → 调角度(5°~85°)、↑ ↓ 调力度(10~100),空格发射
  • 鼠标从角色拖拽瞄准,带实时抛物线预览和落点准星
  • 风力每回合随机刷新(-0.6 ~ 0.6),HUD 用像素箭头展示方向和强度
  • 距离越近伤害越高,最高 35,血量归零判负
  • 像素风挤压回弹动画、爆炸粒子、伤害飘字、炮口火光

二、技术选型:为什么一个游戏引擎都不用

方案 体积 优点 为什么没选
Phaser 4 ~900KB 场景管理、Arcade/Matter 物理、资源加载器齐全 我只需要一条抛物线,引入 900KB 属于杀鸡用牛刀
Matter.js ~90KB 刚体、碰撞、约束成熟 抛射体不需要碰撞求解,自己积分 20 行就够,还能保证可重放
Three.js 更大 3D 表现力强 2D 像素风用不上
原生 Canvas + aiohttp 前端 0 依赖 零构建、零安装、双击即玩,物理完全可控可重放 ---

选原生 Canvas 不只是"轻",更重要的是联机需要可重放的确定性物理 。物理引擎内部有求解器迭代、休眠优化等隐式状态,两端很难保证逐帧一致;自己写的显式欧拉积分,给定 (angle, power, wind) 三个输入,落点就是一个纯函数------这对联机同步来说是决定性的。

三、核心原理一:固定时间步长 + 子步长积分

弹道的本质就是带水平风加速度的抛体运动。项目用显式欧拉逐帧推进:

javascript 复制代码
// frontend/game.js ------ 弹道数值积分
function computeTrajectory(sx, sy, angle, power, w, enemy, dsign) {
  const v0 = power * V0K;                    // 力度 -> 初速度(V0K=9)
  const rad = (angle * Math.PI) / 180;
  let vx = dsign * v0 * Math.cos(rad);       // dsign: 左侧=1,右侧=-1
  let vy = -v0 * Math.sin(rad);              // 屏幕 y 轴向下,所以取负
  let x = sx, y = sy;
  const dt = 1 / 60, sub = 4, sdt = dt / sub; // 关键:固定 dt + 子步长
  const pts = [{ x, y }];
  let impact = null;
  for (let i = 0; i < 3000; i++) {           // 最多模拟 3000 帧,防止死循环
    for (let s = 0; s < sub; s++) {          // 每个渲染帧内部再切 4 个子步
      x += vx * sdt; y += vy * sdt;          // 位置按当前速度推进
      vy += G * sdt;                         // 重力改变竖直速度
      vx += w * 60 * sdt;                    // 风力:w 是"每帧增量",乘 60 转成每秒加速度
    }
    pts.push({ x, y });
    const d = Math.hypot(x - enemy.x, y - enemy.y);
    if (d <= enemy.r + 4) { impact = { x, y, hitEnemy: true, distToEnemy: d }; break; }
    if (y >= GROUND) { impact = { x, y: GROUND, hitEnemy: false }; break; }
    if (x < -80 || x > W + 80 || y > H + 150) { impact = { x, y, hitEnemy: false, off: true }; break; }
  }
  return { points: pts, impact: impact || { x, y, hitEnemy: false, off: true } };
}

三个容易被忽略的细节:

  1. dt 是写死的 1/60,不用 requestAnimationFrame 的真实 delta 。主循环 loop(ts) 里算出的 dt 只喂给动画(云飘、粒子、呼吸),从不喂给弹道。原因很简单:真实 delta 会随帧率抖动,120Hz 屏和 60Hz 屏算出的落点就不一样了,联机两端必然打架。
  2. 子步长 sub=4,等效积分步长 1/240 秒。显式欧拉的截断误差是 O(sdt),步长减半误差减半,这 4 个子步是精度和开销的甜点。
  3. 风力项 w * 60 。风力 wind 的取值范围是 -0.6~0.6,它表示的是"每 1/60 秒水平速度的增量",乘 60 才是"每秒加速度"。漏掉这个 60,风就形同虚设(实测数据见第八节)。

四、核心原理二:一套积分,三处复用

这是整个项目我最满意的设计。computeTrajectory 被三处调用,且参数完全一致

javascript 复制代码
// 用途 1:瞄准时的抛物线预览(drawAimGuide)
const t = computeTrajectory(p.x, p.y, aim.angle, aim.power, wind, enemy, dir(turn));
// 用途 2:真正开火,生成炮弹飞行轨迹(doFire)
traj = computeTrajectory(p.x, p.y, angle, power, wind, enemy, dir(side));
// 用途 3:AI 暴力搜索最优解(aiFire)
const t = computeTrajectory(shooter.x, shooter.y, a, pw, wind, target, dir(side));

好处是预览即结果------玩家看到的虚线和落点准星,跟按下空格后炮弹真实走的路径是同一串坐标,没有任何"预览是近似"的误差。反过来说,一旦这三处的积分精度不一致,就会出事。

我做了个对照实验:让 AI 用**粗积分(sdt=1/60,即 sub=1)搜索最优发射参数,再把搜出的参数放到精积分(sdt=1/240,源码真实物理)**里跑,看它实际打成什么样:

风力 精搜最优解 粗搜给出的解 粗搜自评误差 实战真实误差 劣化
-0.6 62° / 98 26° / 96 13.1px 25.7px +11.6px
-0.3 62° / 96 30° / 92 13.1px 24.0px +11.5px
0 58° / 90 26° / 94 14.1px 16.0px +2.9px
0.3 64° / 94 32° / 88 14.0px 25.4px +12.3px
0.6 28° / 92 40° / 84 13.5px 23.4px +10.4px

看清了吗?粗积分搜出来的角度有时跟最优解差了 36°(62° vs 26°),实战误差直接翻倍。角色半径才 22px、爆炸伤害半径 70px,10px 的劣化足以从"稳定命中"变成"稳定擦边"。

结论:搜索用的模型必须和执行的模型是同一个。 这条对所有带 AI 对手的游戏都成立。

五、核心原理三:伤害衰减模型

命中不命中是二元的,伤害是连续的。项目用线性衰减:

javascript 复制代码
const ER = 70;   // 爆炸伤害半径
// 距离越近伤害越高,最低保底 1 点
function damageFrom(dist) {
  if (dist > ER) return 0;
  return Math.max(1, Math.round(35 * (1 - dist / ER)));
}

实测出的伤害曲线:

落点到敌人距离 0px 10px 20px 35px 50px 60px 69px 71px
伤害 35 30 25 18 10 5 1 0

几个设计上的考量:

  • Math.max(1, ...) 保底 1 点:擦到边也给反馈,避免"明明碰上了却 Miss"的挫败感。
  • 半径 70px vs 角色半径 22px:给了约 3 倍容错,休闲向手感。
  • 线性而非平方衰减:平方衰减会让"差一点点"的惩罚过重,线性更适合这种回合制慢节奏对战。

注意命中判定用的是 d <= enemy.r + 4(26px),比伤害半径 70px 小得多。也就是说炮弹没碰到人也可能造成伤害------爆炸溅射。这是刻意的:只判定直接命中会让手感过于苛刻。

六、核心原理四:相位状态机与飞行时长

游戏用 phase 字段驱动状态流转:menu → aiming → firing → exploding → (aiming | waiting | gameover)

javascript 复制代码
// frontend/game.js ------ 主循环中的发射/爆炸相位
if (phase === "firing") {
  // 每帧推进若干个轨迹点:至少 3 个,弹道越长步进越大
  projIdx += Math.max(3, Math.floor(traj.points.length / 45));
  if (projIdx >= traj.points.length - 1) {
    projIdx = traj.points.length - 1;
    impactPt = traj.impact;
    spawnExplosion(impactPt.x, impactPt.y);   // 26 个粒子
    phase = "exploding"; explTimer = 0;
  }
} else if (phase === "exploding") {
  explTimer++;
  if (explTimer > 34) afterImpact();          // 34 帧后结算,给爆炸留表演时间
}

Math.max(3, Math.floor(len / 45)) 这行的意思是:短弹道每帧走 3 个点(快),长弹道每帧走更多点,把总时长压在一个可接受区间。实测的飞行时长:

力度 轨迹点数 每帧步进 飞行帧数 时长
20 26 3 9 0.15s
40 40 3 13 0.22s
60 56 3 19 0.32s
80 72 3 24 0.40s
100 84 3 28 0.47s

满力度也只要 0.47 秒,加上爆炸的 34 帧(0.57s),一个回合约 1 秒出头,节奏很紧凑。

七、联机架构:aiohttp 权威服务器

后端只有 160 行,同时干两件事:托管静态文件 + WebSocket 房间。

协议极简,客户端 → 服务端三种消息:

action 载荷 说明
join room, name 进房,服务端分配 left/right,满 2 人自动开局
fire angle, power, dmg 上报发射参数与自算伤害
restart --- 重开一局

服务端 → 客户端:joined / start / state / opponent_left / error

核心裁决逻辑:

python 复制代码
# backend/server.py ------ 服务器权威裁决
elif action == "fire":
    if room is None or not room.started or room.state["gameOver"]:
        continue
    me = room.players.get(ws)
    if not me:
        continue
    if me["side"] != room.state["turn"]:
        await ws.send_json({"action": "error", "msg": "还没轮到你"})
        continue
    angle = data.get("angle")
    power = data.get("power")
    dmg = int(data.get("dmg", 0))            # 客户端上报的伤害
    target = "right" if me["side"] == "left" else "left"
    new_hp = max(0, room.state["hp"][target] - dmg)
    room.state["hp"][target] = new_hp
    room.state["lastShot"] = {"side": me["side"], "angle": angle, "power": power}
    if new_hp <= 0:
        room.state["gameOver"] = True
        room.state["winner"] = me["side"]
    else:
        room.state["turn"] = target          # 换手
        room.state["wind"] = round(random.uniform(-0.6, 0.6), 2)  # 刷新风力
    await broadcast(room, {"action": "state", "state": room.state})

这里有个刻意的架构取舍 :服务器不重算弹道,而是采信客户端上报的 dmg,同时自己牢牢掌握回合、血量和胜负。好处是服务器零物理代码、1v1 场景下完全够用;代价是客户端可以改包作弊。真要上公网,把 computeTrajectory 用 Python 重写一遍放到服务端即可------因为积分是确定性的,重写后的落点和前端必然一致,这正是第三节"固定时间步长"设计的红利。

对手开火的回放用的是服务器血量反推,而不是直接用对方上报的伤害:

javascript 复制代码
// frontend/game.js ------ 远端开火回放
function animateRemoteShot(shot) {
  const side = shot.side;
  const before = players[other(side)].hp;     // 本地记录的对手血量
  doFire(side, shot.angle, shot.power, true); // 用对方的角度/力度重放弹道
  isRemote = true;
  pendingEnemyHp = serverHp[other(side)];     // 服务器下发的权威血量
  firingDmg = Math.max(0, before - pendingEnemyHp);  // 反推出这一炮打掉多少
}

伤害数字从"权威血量差"来,本地画面却是用对方真实参数重放的------表现是本地的,数字是服务器的

八、6 个真实踩坑

坑 1:风力漏乘 60,风等于没开

第一版写成 vx += w * sdt,结果风完全感觉不到。实测数据(angle=45, power=60, wind=0.6):

写法 落点 x 说明
vx += w * 60 * sdt(正确) 515.07px 相对无风右移 15.06px
vx += w * sdt(错误) 500.27px 相对无风只右移 0.25px
无风 wind=0 500.02px 基准

漏乘 60 之后,风的贡献从 15px 缩水到 0.25px------整整差了 60 倍,玩家只会觉得"这游戏的风是摆设"。

坑 2:不同积分步长,落点差出 20px

固定 angle=45, power=60, wind=0.6,只改步长:

积分设置 落点 x 与源码偏差 积分步数
sub=4, dt=1/60(源码,sdt=1/240) 515.07px 基准 220
sub=1, dt=1/60(sdt=1/60) 521.78px +6.71px 56
sub=4, dt=1/30(sdt=1/120) 521.92px +6.85px 112
sub=1, dt=1/30(sdt=1/30) 535.35px +20.28px 29
sub=16, dt=1/60(sdt=1/960) 515.13px +0.05px 880

sub=4 已经很接近收敛值(sub=16 只差 0.05px),是性价比最优解。子步长是"四个子步换 6.7px 精度",非常划算。

坑 3:Canvas 鼠标坐标没做缩放映射

Canvas 内部逻辑分辨率是 960×540,但 CSS 可能会被拉伸缩放。直接拿 e.clientX 当画布坐标,在缩放过的页面上瞄哪打哪都不准。必须换算:

javascript 复制代码
// frontend/game.js ------ 鼠标坐标 -> 画布逻辑坐标
const r = cv.getBoundingClientRect();
updateAimFromMouse(
  (e.clientX - r.left) * (W / r.width),    // 关键:乘上逻辑宽/实际宽
  (e.clientY - r.top) * (H / r.height)
);

坑 4:本地和联机都切换回合,回合直接错乱

afterImpact() 里必须区分:

javascript 复制代码
if (online) phase = turn === mySide ? "aiming" : "waiting";  // 联机:等服务器下发 state
else {
  turn = other(turn); wind = rand(-0.6, 0.6);                // 本地:自己换手
  ...
}

早期版本两边都执行 turn = other(turn),结果本地抢先换手、服务器又换一次,直接跳到对手的对手------回合永远对不上。联机状态下,回合、风力、血量的唯一来源是服务器。

坑 5:AI 生成图的水印,简单阈值去不干净

像素素材是 AI 生成的,自带水印。透明图(角色/炮弹)用连通分量区分"水印"和"角色身上的高光":

javascript 复制代码
// frontend/game.js ------ 去水印:只删"大块"的近白区域
const isWhite = (r, g, b) => r > 200 && g > 200 && b > 200;
// BFS 求连通块大小
sizes[cid] = sz;
// 只删除像素数 > 12 的连通块,小亮点(眼睛、炮管高光)保留
if (comp[idx] && sizes[comp[idx]] > 12) {
  d[gi + 3] = 0;    // 直接置透明
}

这个 > 12 的阈值是调出来的:太小会误删眼睛高光,太大会留水印残字。不透明的背景图则用更宽松的 isMildWhite(>175)配合 findNearbyColor 螺旋搜索邻近颜色填补,否则会留下一片白斑。

坑 6:AI 搜索看着暴力,实测其实很便宜

AI 每回合要枚举 30 个角度 × 36 个力度 = 1080 次完整弹道模拟。听起来很吓人,实测(Node 22,本机):

指标 源码精度(sdt=1/240) 粗积分(sdt=1/60)
模拟次数 1080 1080
总积分步数 252,684 63,915
总耗时 5.9ms 5.6ms
单次模拟 0.0043ms 0.0033ms

5.9ms,在一帧 16.6ms 预算内绰绰有余。所以没必要为这个搜索做剪枝或缓存------先测再优化,别凭感觉优化。而且第四节已经证明,用粗积分省下的那 0.3ms,代价是 AI 命中率显著劣化,得不偿失。

九、还能怎么改

  • 服务器重算弹道 :把 computeTrajectory 移植到 Python,杜绝改包作弊(确定性积分让这件事变得很容易)。
  • 地形破坏 :目前地面是固定 GROUND=440,加上可破坏地形,弹道搜索会更有意思。
  • 弹道搜索加速:1080 次全量模拟虽然只有 6ms,但如果加入地形,可以先粗搜(步长 4)锁定区间再精搜(步长 1),两级搜索能砍掉 70% 开销。

十、小结

这个项目真正值得复用的不是"弹弹堂",而是三件事:用固定时间步长把物理变成纯函数让预览/执行/AI 共用同一套积分联机时把权威状态收敛到服务器、把表现留在本地。这三板斧换个皮就是坦克大战、愤怒的小鸟、任何回合制对战。

完整工程已经整理好了(含一键启动脚本和部署文档,Python 后端 + 纯静态前端,拿去就能二次开发),需要的同学评论区扣「源码」,我看到会一一回复;也欢迎点个关注,后面会把这个系列继续往下更。

相关推荐
奇思妙想聪明勤奋的小羊4 小时前
DeepAgents第5章:子Agent 与上下文隔离—让 Agent学会委派
人工智能·python·学习·语言模型
lpfasd1234 小时前
2026年第38周GitHub趋势周报
python·科技·github
IZero074 小时前
Jev 与 Laya
python·语言模型
2601_962218615 小时前
万象生鲜系统称重自动多退少补算法解决生鲜非标品痛点
大数据·数据库·人工智能·python·算法
Jasmin Tin Wei5 小时前
ai辅助逆向
python
醇氧5 小时前
Python`__pycache__` 被 Git 跟踪的排查与解决
python·django
XUEYUAN52126 小时前
ASN 自治系统号风控:平台如何通过 IP 所属自治域批量识别代理流量
python·网络协议·http·网络安全·socks5
Zenova EdgeOS6 小时前
Linux ss 工业边缘网络分析实战
linux·python·边缘计算·工业网关