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 } };
}
三个容易被忽略的细节:
dt是写死的 1/60,不用requestAnimationFrame的真实 delta 。主循环loop(ts)里算出的dt只喂给动画(云飘、粒子、呼吸),从不喂给弹道。原因很简单:真实 delta 会随帧率抖动,120Hz 屏和 60Hz 屏算出的落点就不一样了,联机两端必然打架。- 子步长
sub=4,等效积分步长 1/240 秒。显式欧拉的截断误差是 O(sdt),步长减半误差减半,这 4 个子步是精度和开销的甜点。 - 风力项
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 后端 + 纯静态前端,拿去就能二次开发),需要的同学评论区扣「源码」,我看到会一一回复;也欢迎点个关注,后面会把这个系列继续往下更。