苍穹对决 Sky Duel · 零服务器联机 3D 空战 —— 手搓 WebRTC 的实现原理

零服务器联机:我的 3D 空战游戏如何用纯 WebRTC 实现 P2P 对战

零服务器联机:我的 3D 空战游戏如何用纯 WebRTC 实现 P2P 对战

苍穹对决 Sky Duel · 通信原理深度解析 · 2026-08-20
WebRTC P2P 联机 DataChannel 状态同步 HTML5 游戏
《苍穹对决 Sky Duel》是一款免费的 3D 空战网页游戏:机炮狗斗、弱点打击、爆炸特效,电脑手机都能玩,打开浏览器就能开战。现在就打开 game.4365754.xyz 和朋友开一局——这篇文章讲的,正是它背后那个「零服务器联机」的通信原理。整个联机系统没有一行服务器代码:没有 STUN、没有 TURN、没有信令服务器、没有云函数,游戏本体只需要一个静态托管就能跑。这篇文章用这个真实项目,把 WebRTC 点对点联机的原理完整拆一遍。
目录
  1. 难题:互联网上两台电脑,怎么互相找到?
  2. 没有信令服务器,就用「邀请码 / 应答码」手动交换 SDP
  3. 一条连接,两条通道:状态通道与事件通道
  4. 20Hz 状态同步:插值、去重与延迟测量
  5. 本地权威计算:谁来决定你中了弹?
  6. 从 1v1 到 5v5:Mesh 网状拓扑
  7. 延迟、断线与容错
  8. 局限与未来:为什么正式部署仍需要 STUN/TURN
  9. 断线、重连与人数失衡:P2P 联机的容错全景(Q&A)

一、难题:互联网上两台电脑,怎么互相找到?

在局域网里联机很简单:两台机器各有一个 192.168.x.x 的私网地址,直接互连即可。但放到公网上就麻烦了——今天几乎所有设备都躲在路由器 NAT(网络地址转换)后面,私网地址在公网里不存在,别人无法直接找到你。

传统游戏的做法是服务器中转:所有玩家的流量先发给服务器,由服务器转发给其他人。好处是稳定、可控、易做防作弊;代价是要买服务器、要维护、玩家离服务器远就延迟高。

浏览器其实自带另一条路:WebRTC。它允许两个浏览器直接建立点对点(P2P)连接,数据不经过任何中间服务器。WebRTC 的标准建连流程里有两样东西:

  • SDP(会话描述协议):一份描述「我想怎么连」的文本——包含数据通道配置、网络候选等,建连双方必须先交换它;
  • ICE 候选(ICE candidate):双方在各自网络里探测出的可用地址(私网 IP、公网映射地址等),供对方尝试连接。

标准做法是搭一个信令服务器(WebSocket 之类)来转发这些 SDP/候选。而《苍穹对决》做了一个激进的决定:信令服务器也不用了,由玩家手动复制粘贴来交换。

配置代码只有一行。整个联机层的 RTC 配置长这样:const RTC_CONFIG = { iceServers: [] };——iceServers 留空,意味着完全不做 STUN/TURN,纯直连。这是「零服务器」承诺的直接体现。

二、没有信令服务器,就用「邀请码 / 应答码」手动交换 SDP

WebRTC 建连必须「先交换信息、后建立连接」,这个交换就是信令。我们把双方要交换的所有信息打包成一段 base64 字符串,当作邀请码/应答码,由玩家用微信、QQ 之类的聊天工具互相传递:

function encodeDesc(d) {
  return btoa(JSON.stringify({ type: d.type, sdp: d.sdp }));
}
function decodeDesc(s) {
  return JSON.parse(atob(s.trim()));
}

整个建连流程分四步:

玩家 A(主机) 玩家 B(加入方) createOffer() → setLocalDescription 等待 ICE 收集完成(_waitIce) ≤3.5s,确保候选已进 SDP ① 邀请码 join(邀请码) → setRemoteDescription createAnswer → setLocalDescription → 等 ICE ② 应答码 acceptAnswer(应答码) setRemoteDescription 完成信令 ③ ICE 直连打通 · P2P 通道建立 DataChannel 's'(状态)与 'e'(事件)就绪 之后的流量全部 P2P 直连,不再经过任何服务器 三步交换均为「复制 → 聊天工具发送 → 粘贴」,即手动信令
图 1 · 手动 SDP 交换建连流程:邀请码与应答码就是「人肉信令服务器」

一个容易被忽视的细节:主机生成邀请码时,代码里有一行 await this._waitIce()——它等待 ICE 候选收集完成(最长 3.5 秒)之后再把 SDP 编码发出去。为什么必须等?因为如果不等,粘贴给好友的邀请码里可能不含任何可连接的候选地址,对方拿着邀请码也无从连起。标准 WebRTC 会用 trickle ICE 边收集边通知,我们为了让「一次粘贴就通」,选择了等收集完成再一次性发送。

三、一条连接,两条通道:状态通道与事件通道

WebRTC 的 DataChannel 有两种传输模式,本质是 TCP 与 UDP 的区别在浏览器里的再现:

模式可靠性适合
可靠有序(默认)不丢包、不重排,类似 TCP消息类:伤害、开火、死亡、信令
不可靠无序(maxRetransmits: 0)丢了就丢,类似 UDP高频状态:位置、姿态、速度

游戏在每一条 WebRTC 连接上创建了 两个 DataChannel,各司其职:

玩家 A RTCPeerConnection 's' 状态通道 'e' 事件通道 's' 状态通道(不可靠) ordered:false · maxRetransmits:0 · 20Hz 发送(每 50ms 一帧) · 内容:位置 p、四元数 q、  速度 s、血量 hp、存活 alive · 丢帧无妨:下一帧立即覆盖 · 类比:UDP,只求快 'e' 事件通道(可靠有序) 默认配置,类似 TCP · hit 伤害 / died 死亡 · fire 开火 / ping 延迟 · hello / team / roster 信令 · 一条都不能丢:丢了就是  少扣血、少一次爆炸 同一条 WebRTC 连接内的两条逻辑管道
图 2 · 双通道设计:高频可丢的状态走 UDP 式通道,不可丢失的事件走 TCP 式通道

创建通道的代码只有两行:

// 状态通道:不可靠无序,20Hz 高频状态,丢了立刻被下一帧覆盖
this.dcState  = this.pc.createDataChannel('s', { ordered: false, maxRetransmits: 0 });
// 事件通道:可靠有序,伤害 / 死亡 / 开火等关键消息
this.dcEvent  = this.pc.createDataChannel('e');
为什么状态通道不怕丢?位置信息每 50ms 更新一次,如果某一帧丢了,接收方下一帧就会收到更新后的位置——丢的那帧早已过时,补发毫无意义。反而是「等重传」会引入延迟。所以对高频状态,宁可丢弃也要快;而伤害、死亡这类事件一旦丢了就永久丢(敌人白打你一梭子),必须可靠传输。

四、20Hz 状态同步:插值、去重与延迟测量

主循环里有一个定时器:每累计 0.05 秒(20Hz),就把自己的位置、姿态四元数、速度、血量和存活状态打包发到状态通道:

/* ---- 状态同步(20Hz) ---- */
stateT += dt;
if (stateT >= 0.05) {
  stateT = 0;
  const q = myPlane.group.quaternion;
  netSendState({
    t: 'state',
    p: myPlane.group.position.toArray(),
    q: [q.x, q.y, q.z, q.w],
    s: Math.round(me.speed),
    hp: Math.max(0, Math.round(me.hp)),
    alive: me.alive
  });
}

接收端有三个关键处理:

1. 不直接赋值,而是「插值逼近」

20Hz 意味着对端看到的画面每 50ms 跳一次。如果直接把位置赋给对方的飞机模型,飞机会一顿一顿地抽搐。正确做法是把收到的值存成目标值(tPos / tQuat),然后在每一帧渲染里用线性插值(lerp)和四元数球面插值(slerp)平滑逼近:

// 收到状态 → 只更新目标值
pl.tPos.fromArray(d.p);
pl.tQuat.set(d.q[0], d.q[1], d.q[2], d.q[3]);

// 每帧渲染 → 向目标平滑逼近
pl.plane.group.position.lerp(pl.tPos, k);
pl.plane.group.quaternion.slerp(pl.tQuat, k);

2. seq 序号去重

状态通道是「不可靠无序」的,包可能乱序、可能重复。每个包带一个递增序号,接收方丢弃所有「序号不大于已收到序号」的旧包:

if (d.seq !== undefined) {
  if (d.seq <= (pl.lastSeq || 0)) return;   // 重复 / 乱序,丢弃
  pl.lastSeq = d.seq;
}

3. ping 延迟测量

每 2 秒广播一个带时间戳的 ping 消息,对方原样回传,本地用「往返时间 / 2」估算单程延迟,直接显示在 HUD 上(「延迟 xx ms」)。P2P 直连下,这个数字通常就是两台设备之间的真实网络距离。

五、本地权威计算:谁来决定你中了弹?

联机游戏绕不开一个问题:权威在哪里?传统方案是服务器权威——所有判定在服务器做,客户端只是遥控器,这样外挂无处下手。但我们没有服务器。

纯 P2P 下有两种选择:客户端全权威(每个人都自己说了算,等于没规则),或者《苍穹对决》采用的「本地权威 + 受击方结算」的折中方案:

  • 命中判定由射击方完成:子弹轨迹、碰撞检测都在射手本地模拟,因为「自己的世界」对射手来说是权威的——他看到什么,就按什么打;
  • 伤害结算由受击方完成:射手判定命中后,把伤害值、命中部位定向发给受击方;受击方在自己的世界扣血、播爆炸、判死亡、结算回合。
射手(你的本地模拟) 受击方(对方本地模拟) ① 子弹飞行 + 碰撞检测(本地完成) 命中弱点(机翼/引擎/驾驶舱)按部位算伤害 ② 发送定向事件:{t:'hit', dmg, part} 只发给被打的那个人(netSendEventTo) 事件通道(可靠) ③ 受击方本地结算:扣血 / 爆炸 / 死亡 血量由受击方自己维护(防篡改) ④ 广播结果:died / 回合结算 所有人同步看到爆炸与比分 回执 打没打中,射手说了算;扣多少血、死没死,挨打的人说了算 —— 双方互相制约,谁也无法凭空作弊
图 3 · 本地权威 + 受击方结算:无服务器下的公平性设计

射击代码里就是这个逻辑——命中后不直接扣对端血量,而是把伤害发过去让对方结算:

// 射手侧:只负责「判定命中」并通知对方
else if (mode !== 'practice') {
  if (ag.hp - dmg <= 0) killCount++;
  netSendEventTo(ag.id, { t: 'hit', dmg, part: wp.part });
}

// 受击侧:收到 hit 后本地扣血、播伤害反馈
case 'hit':
  if (!iAmHost || !d.target) applyMeDamage(d.dmg);
  break;

这个设计的巧妙之处在于双方都能验证:射手无法凭空报伤害(因为扣血在对方那里执行),受击方也无法赖账(因为对方能看到自己命中)。对休闲级 P2P 游戏来说,这是一个性价比极高的公平性方案。它甚至支持了「弱点打击」玩法——命中部位(part)随伤害一起传递,打中引擎和打中机翼的伤害完全不同。

六、从 1v1 到 5v5:Mesh 网状拓扑

1v1 只需要一条连接,而 3v3 / 5v5 需要 N 个人两两互联。这里有个现实问题:手动交换 SDP 在多人下不可行——5v5 共 10 个节点,两两建连要交换 45 次邀请码,人肉根本干不了。

解决方案是引入一个「轻量中心」:房主。但房主只做信令中转,不做流量中转。

  • 新玩家只需要和房主手动交换一次邀请码/应答码,即可入局;
  • 房主随后帮新玩家与已在局内的其他玩家互相转发 SDP(meshOffer / meshAnswer),让每对玩家之间自动建立 P2P 直连;
  • 所有连接建立后,形成全网状(Mesh)拓扑——每个人和每个人都是直连,房主退居为普通节点。
房主 (只做信令中转) 玩家 B team 0 玩家 C team 1 玩家 D team 0 玩家 E team 1 P2P P2P P2P P2P P2P P2P P2P P2P 任意两名玩家之间都有一条直连 —— 全网状。SDP 交换阶段由房主中转,建连后流量全走直连
图 4 · 5 人 Mesh 拓扑:实线为 P2P 直连,虚线为经房主中转的信令交换

代码层面,游戏用一个字典 nets{} 管理所有连接实例,每个对端一个 Net

const nets = {};   // peerId -> Net(房主连接 + mesh 直连)

// 每 50ms 把自己的状态广播给所有已连接的 peers
function netBroadcast(msg, isState) {
  for (const id in nets) {
    if (!nets[id].connected) continue;
    if (isState) nets[id].sendState(msg);
    else         nets[id].sendEvent(msg);
  }
}

还有两个工程细节值得一提:

  • 定向路由:像「伤害」这种只和两个人相关的消息,走 netSendEventTo 直接发给目标;若两人之间还没有直连(建连中),消息会带 target 字段交给房主转发(hostRelay);
  • 全局去重:直连 + 房主转发两条通道可能让同一条消息到达两次,因此每条消息都带 sender + seq,接收方按来源去重,保证「广播了一次就只处理一次」。
// 所有消息统一带上真实来源和序号
const msg = Object.assign({ sender: myPeerId, seq: isState ? ++stateSeq : ++eventSeq }, o);

// 接收方去重:直连 + 转发双通道可能重复到达
if (d.seq !== undefined) {
  if (d.seq <= (lastEventSeq[rid] || 0)) return;
  lastEventSeq[rid] = d.seq;
}

七、延迟、断线与容错

P2P 联机天然要面对「对方随时可能消失」的问题。游戏的容错设计分三层:

  • 连接状态监听:RTCPeerConnection 的 connectionState 一旦进入 disconnected / failed / closed,立即触发断线回调;
  • 实体清理:断线后立刻从场景里移除对方的飞机模型、从名单里剔除、并广播 peerGone 让所有人都知道;
  • 简单重入:因为是纯 P2P 无会话管理,重新开局只需要再交换一次邀请码,没有复杂的状态恢复。
this.pc.onconnectionstatechange = () => {
  const st = this.pc.connectionState;
  if (st === 'disconnected' || st === 'failed' || st === 'closed') {
    if (this.connected && this.onClose) { this.connected = false; this.onClose(); }
  }
};

八、局限与未来:为什么正式部署仍需要 STUN/TURN

诚实地说,「iceServers 留空」是有代价的,这套架构适合「双方网络能直连」的场景(同一校园网 / 同一运营商 / 双方都有公网 IPv6)。如果两台设备都躲在严格的对称 NAT 后面,ICE 会失败,这时就需要补上:

组件作用当前状态
STUN探测公网映射地址,帮 P2P 穿透 NAT未启用(iceServers 为空)
TURN对称 NAT 下无法直连时,作为中继兜底未启用(需要服务器,违背零服务器初衷)
信令服务器自动交换 SDP/候选手动粘贴邀请码替代

未来路线其实很清晰:信令层可以升级为「6 位房间码 + 云数据库做匹配」,让玩家输入房间码即自动建连(免去手动复制粘贴),同时保留「先 P2P、失败才走中继」的原则。另外,纯客户端模拟对「透视 / 加速」类外挂的防御是天生不足的——这是所有无服务器 P2P 游戏的共同取舍,对休闲对战来说,用零成本换来「开箱即玩」,是笔划算的买卖。


九、断线、重连与人数失衡:P2P 联机的容错全景(Q&A)

零服务器架构有一个绕不开的问题:「谁掉线了」这件事,没有任何中心节点来裁决。所有容错逻辑都散落在每个客户端本地,靠连接层的回调 + 房主的广播互相拼凑。这一章用实战视角,把最常见的断线问题一次问透——以下全部是当前代码的真实行为。

9.1 掉线后,第一个知道的人是谁?

没有「中心仲裁者」,是和掉线者存在直连的那个对端——谁的浏览器监听到 RTCPeerConnectionconnectionState 变成 disconnected / failed / closed,谁就先知道。在 Mesh 全直连下,理论上所有人几乎同时感知,但有两点要说明:

  • 检测不是即时的:ICE 需要超时才能判定连接失效,通常有几秒到十几秒的延迟;
  • 非直连者感知更晚:如果某人与掉线者没有直连(mesh 建连超时、走房主转发兜底),他无法自己感知,只能等房主广播 peerGone——这种场景下房主才是第一个知道的(房主必然与掉线者直连)。
场景 A · 普通玩家掉线(分布式感知,无中心仲裁) 玩家 D 掉线 网络断开 · 无预兆 直连对端最先感知 连接层变化 · 秒级延迟 房主广播 peerGone 唯一广播者 · seq 去重 全员清理 · 视为阵亡 checkRoundEnd 判回合 场景 B · 房主掉线(star 拓扑根节点断开) 房主掉线 根节点断开 所有玩家本地感知 无主机迁移机制 比赛判负 / 大厅返回菜单 提前结束 · 房间解散
图 5 · 掉线检测与传播路径:直连者本地感知,房主唯一广播,房主掉线则房间解散

9.2 多人广播同一人掉线,会冲突吗?

不会,有三重保险:

  • 广播者唯一onPeerClose 里只有 if (iAmHost) 才广播 peerGone;其他玩家检测到掉线只做本地清理,绝不广播,且 peerGone 不在房主的 hostRelay 转发名单里,不会二次转发;
  • seq 去重:所有广播消息带 sender + seq,接收端 d.seq <= lastEventSeq[rid] 直接丢弃——即使将来出现重复路径,也只处理一次;
  • 清理幂等removePeerLocal 全是 delete / splice / findIndex,重复调用无害。

9.3 房主掉线会怎么样?

代码有专门分支(onPeerClosepeerId === hostPeerId),没有主机迁移

  • 比赛进行中:立即结束比赛,判房主队伍负——显示「房主已断线 · 比赛提前结束」,并隐藏重赛按钮(房主离线无法重赛);
  • 凑人阶段:提示「房主已离线,返回菜单」,2.5 秒后自动刷新回菜单。

原因很直接:roster 权威、阵营分配、mesh 的 SDP 中转、peerGone 广播全都挂在房主身上,根节点一断,剩余玩家之间既没有信令通道也没有对局权威,散伙是最简单的正确解。

9.4 掉线后想重新加回来?

  • 房主还在:掉线者刷新页面重新打开游戏 → 房主重新生成邀请码 → 互传一次邀请码/应答码,连接即恢复,hello → team → roster 流程重走;
  • 房主也掉了:房间已解散,只能重新开房;
  • 注意:比赛进行中重连,实际回不到战场(原因见 9.6)。

9.5 一方掉线一人,会多打少吗?

会,但机制是「掉线即阵亡」,不会出现卡死的悬置:

  • 掉线立即删实体、从 roster 剔除,然后 checkRoundEnd()——掉线方全灭就当场判对方胜(0.8 秒淘汰宽限后结算),回合不会卡住;
  • 掉线者还有队友活着:本回合继续,队友当场少打多;进入下一回合时由于掉线者已从 roster 移除,该队后续回合固定少一人——真实的「多打少」确实会出现;
  • 没有 AI 托管、没有替补补位。想拉平只有两条路:对面也有人掉线,或者掉线者按 9.4 重连——但重连也回不来(见 9.6)。

9.6 重赛会变 4v5 吗?比赛中途掉线能加回来吗?

两个问题答案都是会 / 不能,而且根因是同一个:实体只在比赛开始时创建一次,之后没有任何「按当前 roster 补建实体」的路径

  • 重赛变 4v5:掉线者的实体已被删除、roster 已被剔除;点重赛时 applyRematch() → nextRound(),而 nextRound()重置 planes{} / aaguns{} 里已存在的实体(回血、回出生点),从不创建新实体。实体只会在 beginMatch()(开局那一次)里由 buildAllUnits() 按 roster 批量生成。人数差一旦形成就固化成 4v5 打到比赛结束;若掉线后整队只剩 0 人(如 1v1),重赛则直接进入空地图挂起(见 9.8,最新版已修复);
  • 比赛中途加不回来:重连者重新建连后 hello → team 流程照走,房主也会重新把他加进 roster 并广播——但 case 'team' 里只有 if (!STATE.started) 才会进入建实体流程;比赛已开始则直接跳过,没有任何「中途复活 / 补位」的代码路径;
  • 刷新页面 = 新身份:掉线后重新打开游戏,makePeerId() 会生成全新的 peerId,他在房主眼里是个「新玩家」——而新玩家只有开局前加入才会被创建实体。
结果:重连者连接层面「连上了」、roster 名单里「有他了」,但双方本地都没有他的飞机——他无法操控、别人也看不到他,是个「连接存在但对局不存在」的幽灵状态。想真正回归,只能等房间散伙后重新开房。

9.7 比赛结束后多人点重赛或退出,会怎样?

  • 一个人点重赛就够全员重开:按钮广播 rematch 给所有人,每个接收者都执行 applyRematch()
  • 多人点重赛无害(幂等)applyRematch() 没有入口守卫,但它是全量覆盖式重置——比分清零、回合归零、实体全重置。无论重复执行多少次,最终状态都是同一个「0:0、第 1 回合」,不会出现比分被减、回合跳号;
  • 退出 = 掉线:点「返回菜单」就是 location.reload() 离开房间,对其他人等同掉线。比赛已结束时退出,只做清理、不动比分(结果已定格);但重赛已开始后退出,会被当作「阵亡」,可能让刚重开的比赛立即判负给退出者所在队伍;
  • 竞态缝隙:A 点重赛的同时 B 点退出,如果 B 的掉线回调在重赛生效之后被处理,刚重开的比赛会因 B「阵亡」立即结算回合 1;反过来则直接缺人。而对 1v1 来说,还有一种更糟的结果——重赛者进入空空如也的地图,回合永远不结束(见 9.8)。

9.8 1v1 的极端场景:一人重赛、一人退出,会不会进入空空如也的地图?

更新(已修复):1v1「一人重赛、一人退出」与 3v3 / 5v5「一边全员退出、一边点重赛」导致的空地图挂起问题,均已在最新版本修复。修复方案:回合开始时按当前 roster 补建缺失实体,并补一次双方人数检查(若一方 0 人则立即判负)。以下分析基于开发副本的旧代码,描述的是修复前的行为,具体实现以最新版本为准。

会——而且结果比「空地图」更微妙:重赛者进入只有自己的地图,回合不会自动结束,也不会自动判胜。直觉上「对方 0 人应该直接判胜」,但实际不会,原因在结算机制:

  • 回合结算(checkRoundEnd)是事件驱动的:它只在 5 个时机被调用——自己死亡、自己阵亡(防空车)、收到对方 died、对方防空车阵亡、对方掉线。主循环不会每帧主动检查「场上还剩几个人」,「对方 0 人」这件事本身不会触发任何结算,比分不会自动滚到 3:0;
  • 两种时序,结果略有不同:先点重赛、后点退出 → 掉线事件发生在重赛回合 1 内,回合 1 正常判给重赛者,但 3.2 秒后进入的回合 2 里对方已不存在、没有事件 → 从回合 2 起永久挂起(「只会判一个回合胜利」对应这个时序);先点退出、后点重赛 → 掉线在重赛前已被清理,回合 1 就直接挂起,一个回合都不会判
  • 挂起即无出口:重赛者独自在地图里飞行,没有敌人、没有回合结束、没有结算界面,只能手动刷新退出。
根因:实体只在 beginMatch() 创建一次,nextRound() 只重置不创建、也不检查「对方是否还有人」。1v1 中一人重赛一人退出,几乎必然踩中;3v3 / 5v5 中某一队全部掉光后重赛,同样会进入空地图挂起。最新版本的修复正是沿这个方向:nextRound() 开头按当前 roster 补建缺失实体,并在回合开始时补一次人数检查(若一方 0 人则立即判负)。

9.9 容错行为速查表

场景系统行为
普通玩家掉线(比赛中)视为阵亡:删实体 + checkRoundEnd;本队最后一人当场判负
普通玩家掉线(大厅)仅清理名单,房主刷新连接状态
房主掉线(比赛中)判房主队伍负,比赛提前结束,无法重赛
房主掉线(大厅)全员提示并返回菜单
掉线后重连(比赛中)连接恢复但无实体,回不到战场;只能等散伙重开
掉线后重赛人数差固化,4v5 直到比赛结束
多人点重赛幂等无害,一人点全员重开
重赛时有人退出退出者视为阵亡,可能让刚重开的比赛立即结算(竞态)
1v1 一人重赛、一人退出重赛者进空地图:先重赛后退出→判 1 回合后挂起;先退出后重赛→直接挂起(不会自动判胜)【已修复】
某一队全部掉光后重赛同上:新回合无事件触发结算,空地图挂起(不会自动判胜)【已修复】

9.10 顺着同样的思路:还有哪些「类似」的坑?

9.8 的根因是「回合结算由事件驱动 + 实体只建一次」。顺着「事件驱动 / 本地权威 / 双通道 / 掉线检测」这几条线继续深挖,还有五个值得知道的点——一个实际存在的延迟窗口问题、一个理论风险、两处澄清,以及三个刻意设计:

① 掉线残影:掉线=阵亡最终会消失,但检测有数秒延迟

先确认设计:这个游戏确实是「掉线 = 阵亡」——一旦对端检测到连接断开,onPeerClose → removePeerLocal 会把飞机模型从场景移除,残影最终会消失,不会像 CS2 / MOBA 那样长期保留挂机模型。但「检测到断开」本身有延迟:

  • 掉线检测不是即时的:浏览器只能靠 WebRTC 底层的 ICE 连通性检查判定连接失效,通常需要数秒(主动关标签页会快一些,拔网线 / 断 WiFi 只能等 ICE 超时);
  • 窗口期内是「临时残影」:在这数秒里,断线者的飞机实体仍在场上——状态不再更新、静止在空中,且照常参与受击与碰撞;
  • 两个实际影响:攻击残影 = 浪费弹药(hit 是单播,对方通道已关,伤害不结算、无击杀反馈);撞上残影 = 可能被判同归于尽平局(碰撞检测仍把它当作存活实体)。

所以问题不是「要不要保留挂机模型」,而是「如何消除检测延迟窗口内残影造成的不公平」:给残影加一个「离线」标识(半透明 / 灰色),并让它不参与碰撞结算(撞上不判平局)即可。代码里其实预留了字段——pl.lastState 记录了最后收到状态的时间,但从未被使用,实现成本很低。

② 碰撞同归于尽的理论窗口(实际可忽略)

敌机相撞时:本地立即标记双方阵亡 → checkRoundEnd 检测到双亡 → 直接平局,比分两端一致;同时广播 crash 兜底,强制对方端也自爆。理论上存在一个窗口:若收到 crash 的一端在 died 到达前先走完 0.8 秒淘汰宽限,会出现「一端记平局、一端记一负」的比分不一致。

但这个窗口实际几乎不可能触发:0.8 秒对正常网络是极大的余量(国内常规延迟远低于 100ms),而延迟真到 0.8 秒以上的网络,20Hz 状态同步 + 即时命中的玩法本就无法进行。故仅作理论记录,无需处理;若真要根治,收到 crash 时把发送者也本地标记为阵亡即可,双亡判定便不再依赖 died 的到达时序。

③ 关于 hit 的传输与血量同步(澄清)

先澄清:hit定向单播netSendEventTo 只发给被击中的那位玩家),不会出现「击中一人、另一人掉血」的情况——正常单播路径下,那段 if (!iAmHost || !d.target) applyMeDamage(d.dmg) 永远不会误伤,它只是一行「防御性较弱」的代码(未校验 d.target === myPeerId),只有出现双路径时才有隐患,当前不会触发。

顺带回答一个衍生问题:血量是如何传播的?每个玩家在 20Hz 状态同步里广播自己的 hp 字段(状态通道),所以其他人的血量数据一直可用;多人模式的名单只显示存活状态、不显示血条,1v1 的 HUD 血条也只画自己的血量。游戏没有做本地伤害预测——伤害由受击方结算后,再经状态通道把新血量广播回来(50ms 级延迟)。因为 UI 上根本没有对方血条,这点延迟无感知;如果未来要加对方血条,就需要引入预测或提高状态频率。

④ 多人延迟显示互相覆盖(影响轻微,暂不修改)

每 2 秒每个人广播一次 ping(ts),接收方直接 pingMs = now - ts——没有 pong 回程,显示的是「发送者到我」的单向近似时间而非严格 RTT;5v5 时每人每 2 秒收到 9 条 ping,pingMs 被最后到达的一条覆盖,HUD 延迟数字会轻微跳动。这不影响任何判定逻辑,仅数字观感,按当前优先级暂不修改;将来若想精确,可改为只对房主测延迟或 ping/pong 配对。

⑤ 三个设计取舍(均有意为之)

  • fire 走可靠通道:开火消息确实会在弱网下因重传造成队头阻塞,但当前阶段优先保证网络通畅玩家的完整体验,弱网玩家本就难以进行即时空战,故保留现状;
  • 平局不计分是故意设计:同归于尽记为平局、双方都不加胜场,保证比分只会是整数胜场、不会出现 3:3;「两队每回合都同归于尽导致无限局」的概率极低,接受;
  • 房主中转是照顾弱网玩家的设计:建连时以房主为信令与转发中心,mesh 直连失败会退化为房主转发——房主通常由网络最好的玩家担任(如拥有 IPv6 公网的玩家),牺牲房主一点带宽,换来其他人更大概率连得上。
一句话总结:普通玩家掉线 = 当回合阵亡 + 队伍人数固化;房主掉线 = 全房间解散;比赛中重连 = 有连接无实体;掉线残影可作为挂机模型保留,但需加离线标识并豁免碰撞。多数边界问题有一个共同根源——「事件驱动」的结算与清理,缺少主动的「超时 / 人数检查」兜底。最值得补的两处:回合开始时复查双方人数(治空地图挂起)、状态超时判定 + 残影碰撞豁免(治挂机模型问题),加起来几十行代码。

结语

《苍穹对决》证明了一件事:不是所有联机都需要服务器。WebRTC 把「建连」和「传输」两大难题都封装进了浏览器,开发者只需要想清楚三个问题:信令怎么交换、状态怎么同步、权威放在哪里。本文的答案分别是:手动 SDP 交换、20Hz + 双通道 + 插值去重、本地权威 + 受击方结算。

整个联机层只有一百多行代码,却撑起了 1v1 / 3v3 / 5v5 的实时空战。如果你也在做小规模实时对战,不妨试试这条路——浏览器即战场,零服务器,开箱即战。

立即试玩 · game.4365754.xyz

免费免下载 · 电脑 / 手机浏览器打开即玩 · 支持 1v1 / 3v3 / 5v5 联机

—— 苍穹对决 Sky Duel · 免费 3D 联机空战网页游戏 · game.4365754.xyz ——

评论

此博客中的热门博文

深度解析:Xray 核心技术 REALITY、Vision、xhttp 与 anytls 的协同工作原理

重新定义流媒体:Media over QUIC (MoQ) 为何是下个时代的终解?

gemini转发国内的部署教程