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

从零到一:我如何给 3D 空战游戏装上「实时通信」

苍穹对决 Sky Duel · 通信原理深度解析 · 2026-08-27
WebRTC P2P 联机 状态同步 DataChannel 分布式一致
《苍穹对决 Sky Duel》是一款免费的 3D 空战网页游戏:机炮狗斗、弱点打击、爆炸特效,电脑手机都能玩,打开浏览器就能开战。现在就打开 game.4365754.xyz 和朋友开一局——这篇文章讲的,正是它背后那个「零服务器联机」的通信原理。整个联机系统没有一行服务器代码:没有 STUN、没有 TURN、没有信令服务器,游戏本体只需要一个静态托管就能跑。这篇文章用这个真实项目,把实时对战通信的核心原理完整拆一遍:从 NAT 穿透到状态同步,从双通道到分布式一致性。
目录
  1. 联机游戏的头号敌人:延迟
  2. 状态同步:把「世界」同步过去
  3. 可靠 vs 不可靠:TCP 为什么不行
  4. WebRTC:浏览器里的点对点通道
  5. NAT:互联网上两台电脑,怎么互相找到?
  6. P2P 握手:没有服务器怎么建立连接?
  7. 插值平滑:让 20Hz 看起来像 60fps
  8. 延迟测量:让玩家看见自己的 ping
  9. 从 1v1 到多人:Mesh 全互联拓扑
  10. 选阵营:没有服务器,如何让大家意见一致?
  11. 本地权威 + 事件广播:伤害怎么算?
  12. 容错:当对方突然消失
  13. 零服务器的浪漫
  14. 来试试?

一、联机游戏的头号敌人:延迟

你按下开火键,子弹要飞向屏幕里的敌机。如果是单机游戏,这一切在本地计算,一秒 60 帧,毫无压力。但联机时,敌机的位置在对方的电脑上——你的每一次射击、每一个走位,都需要通过网络告诉对方。

光速在光纤里跑一个来回大约是 10~30 毫秒(同城),跨省可能 50~80ms。而对空战这种快节奏游戏来说,人眼能感知的延迟阈值大约在 100ms 左右。更麻烦的是:网络会丢包、会抖动(jitter),而 TCP 为了保证「一个不丢」,会把所有数据排队重传——这恰恰是实时对战最不能接受的。

于是,所有实时对战游戏都面临同一个核心问题:「我该把什么数据发给对方?用什么方式发?」这就引出了通信原理里最经典的一对概念:状态同步 vs 帧同步。

二、状态同步:把「世界」同步过去

我的游戏选择的是状态同步(State Synchronization)。思路很简单:每个玩家在自己电脑上完整地跑一套物理模拟,然后周期性把自己的状态(位置、朝向、速度、血量)广播给所有人,同时也接收别人的状态来「摆放」他们的飞机。

玩家 A · 本地物理模拟 位置 / 朝向(四元数) / 速度 / 血量 每 50ms 取一次「快照」 20Hz · 只发最新值 20Hz 玩家 B · 目标值缓存 tPos / tQuat / tSpeed / tHp 收到后只更新「目标」 不直接赋值,防止抖动 B 端 60fps 渲染:向目标插值逼近(lerp / slerp) 丢一帧状态完全无感 —— 下一帧立刻被新状态覆盖
图 1 · 状态同步:20Hz 高频快照 + 目标值缓存,丢帧无感

在《苍穹对决》里,状态同步每 50ms(20Hz) 发送一次,一秒钟只发 20 个状态包:

// 主循环中:每累计 0.05 秒就发送一次自身状态
if (stateT >= 0.05) {
  stateT = 0;
  netSendState({
    t: 'state',
    p: myPlane.group.position.toArray(), // 位置 [x,y,z]
    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
  });
}

20Hz 意味着对方飞机在两次状态之间会「跳 50ms」,肉眼几乎不可见——因为后面会讲到插值平滑。更重要的是状态同步带来一个巨大好处:每个玩家对「自己」的操作是零延迟的。你按一下方向键,你的飞机立刻响应,因为你不需要等服务器确认。这就是「本地权威(Local Authority)」:自己的飞机自己说了算。

为什么不用帧同步? 帧同步(Lockstep)是《星际争霸》《帝国时代》的做法:所有玩家输入同一帧,各自用同一套物理引擎推进,保证结果一致。它省流量、防作弊,但对「确定性」要求极高——任何浮点误差、任何版本差异都会让世界分叉。对空战这种大量随机弹道、爆炸粒子的快节奏游戏,状态同步要稳健得多。

三、可靠 vs 不可靠:TCP 为什么不行

网络传输分两种哲学,对应通信原理中的两大传输协议:

TCP(可靠)UDP(不可靠)
保证不丢包、有序、自动重传尽力而为,丢了就丢了
代价队头阻塞(Head-of-Line Blocking)可能乱序、可能丢失
适合聊天、文件、房间信息游戏状态、语音、视频

TCP 的问题在于「队头阻塞」:如果第 3 个包丢了,即使第 4、5 个包已经到了,接收方也得傻等第 3 个包重传完才能继续读取。对游戏来说,一个 50ms 前的旧状态包丢了完全无所谓,但为了等它而卡住 200ms 才是灾难

所以实时对战游戏几乎都走 UDP 思路:旧数据过期就过期,永远只关心「最新」的状态。浏览器里虽然没有原生 UDP,但 WebRTC 的 DataChannel 完美模拟了这一点——这正是这个项目最精彩的部分。

四、WebRTC:浏览器里的点对点通道

WebRTC 是浏览器原生的实时通信技术,让两个浏览器之间可以直接建立 P2P(点对点)连接,不需要经过任何中转服务器。它天生支持两种 DataChannel 模式:

  • 不可靠通道(等价 UDP):不重传、可乱序,适合高频状态
  • 可靠通道(等价 TCP):保证有序到达,适合关键事件

我的游戏建了两条通道,各司其职:

玩家 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 / pick 信令 · 一条都不能丢:丢了就是  少扣血、少一次爆炸 同一条 WebRTC 连接内的两条逻辑管道
图 2 · 双通道设计:高频可丢的状态走 UDP 式通道,不可丢失的事件走 TCP 式通道
// 创建两条 DataChannel:一条不可靠,一条可靠
this.dcState = this.pc.createDataChannel('s', {
  ordered: false,      // 允许乱序
  maxRetransmits: 0    // 不重传 → 等价 UDP
});
this.dcEvent = this.pc.createDataChannel('e'); // 默认可靠有序 → 等价 TCP

为什么状态必须走不可靠通道?因为状态是「快照」,永远只关心最新值;而事件是「逻辑」,比如「你打中了我,扣 12 点血」——这条消息绝对不能丢,否则双方血量就对不上了。这就是通信原理里经典的按需选择可靠性:不是所有数据都值得重传。

五、NAT:互联网上两台电脑,怎么互相找到?

聊到握手之前,得先补一个更基础的问题:互联网上两台电脑,怎么互相找到?在局域网里很简单——两台机器各有一个 192.168.x.x 的私网地址,直接互连即可。但放到公网上就麻烦了:今天几乎所有设备都躲在路由器 NAT(网络地址转换)后面,私网地址在公网里根本不存在,别人无法直接找到你。

设备 A 192.168.1.5(私网) 设备 B 192.168.1.7(私网) NAT 路由器 A 公网 IP:203.0.113.1 NAT 路由器 B 公网 IP:203.0.113.2 游戏服务器 流量中转:稳定但延迟高 ① 传统方案:服务器中转(数据绕远路) ② WebRTC:P2P 直连 绕过服务器,延迟最低 私网地址在公网里「不存在」——WebRTC 的目标就是绕过 NAT 直接互连
图 3 · NAT 让设备互不可见:服务器中转 vs WebRTC P2P 直连

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

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

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

标准做法是搭一个信令服务器来转发这些 SDP / 候选。《苍穹对决》做了一个激进的决定:信令服务器也不用了,由玩家手动复制粘贴来交换。整个联机层的 RTC 配置只有一行:

const RTC_CONFIG = { iceServers: [] }; // 不做 STUN / TURN,纯直连
零服务器的代价。「iceServers 留空」意味着不做任何 NAT 穿透中继——适合同一局域网 / 同一运营商 / 双方都有公网 IPv6 的场景。这是「连通率」和「零依赖」之间的取舍:当我想让游戏不依赖任何外部服务器、打开网页就能玩时,这个代价是值得的。

六、P2P 握手:没有服务器怎么建立连接?

现在到了关键一步:交换协商信息、建立连接。WebRTC 建连必须「先交换信息、后建立连接」,这个交换就是信令。我们把要交换的所有信息打包成一段 base64 字符串当作邀请码/应答码,由玩家用微信、QQ 之类的聊天工具互相传递——整个过程像传纸条:

玩家 A(主机) 玩家 B(加入方) createOffer() → setLocalDescription 等待 ICE 收集完成(_waitIce) ≤3.5s,确保候选已进 SDP ① 邀请码 join(邀请码) → setRemoteDescription createAnswer → setLocalDescription → 等 ICE ② 应答码 acceptAnswer(应答码) setRemoteDescription 完成信令 ③ ICE 直连打通 · P2P 通道建立 DataChannel 's'(状态)与 'e'(事件)就绪 之后的流量全部 P2P 直连,不再经过任何服务器 三步交换均为「复制 → 聊天工具发送 → 粘贴」,即手动信令
图 4 · 手动 SDP 交换建连流程:邀请码与应答码就是「人肉信令服务器」
// 邀请码本质就是 base64 编码的 SDP
function encodeDesc(d) {
  return btoa(JSON.stringify({ type: d.type, sdp: d.sdp }));
}
function decodeDesc(s) {
  return JSON.parse(atob(s.trim()));
}
一个容易被忽视的细节:主机生成邀请码时有一行 await this._waitIce()——等待 ICE 候选收集完成(最多 3.5 秒)之后才编码发送。为什么必须等?因为 ICE 候选是「对方能连上你的地址清单」,如果不等收集完就把码发出去,邀请码里可能一个可连接地址都没有,对方拿着码也无从连起。标准 WebRTC 用 trickle ICE 边收集边通知,我们为了「一次粘贴就通」,选择了等收集完成再一次性发送。

七、插值平滑:让 20Hz 看起来像 60fps

对方每 50ms 才发来一个新位置,可画面要 60fps 渲染。中间差出来的 40 多帧怎么办?答案是插值(Interpolation):两个已知状态之间,按时间比例平滑过渡。

0 50ms 100ms 150ms 200ms 不插值:位置一跳一跳(肉眼可见卡顿) 插值后:平滑曲线(60fps 丝滑)
图 5 · 插值平滑:红色折线是 20Hz 原始位置,蓝色曲线是插值后的渲染轨迹

我用的是指数插值——每帧朝目标位置「追」一定比例,距离越近追得越慢,天然自带缓动效果:

// 每帧把对方飞机往最新目标状态插值
const k = 1 - Math.exp(-12 * dt);   // dt 是帧间隔
pl.plane.group.position.lerp(pl.tPos, k);     // 位置线性插值
pl.plane.group.quaternion.slerp(pl.tQuat, k); // 朝向球面插值

位置用 lerp(线性插值),朝向用 slerp(球面插值)——后者保证四元数旋转路径不「走歪路」。配合不可靠通道的特性,偶尔丢一个状态包,画面只是少一次微调,完全无感。这就是为什么「20Hz + 不可靠 + 插值」是实时对战最经典的组合拳。

插值解决了「平滑」,还要处理「乱序」。状态通道是「不可靠无序」的,包可能乱序、可能重复到达。每个状态包带一个递增序号,接收方丢弃所有「序号不大于已收到序号」的旧包——用序号而不是时间戳,因为网络延迟会让时间戳失真:

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

这是通信协议里的经典套路:用单调递增序号对抗乱序与重复——TCP 的 seq、RTP 的 sequence number 都是同一个思路。

八、延迟测量:让玩家看见自己的 ping

HUD 上那个「延迟 xx ms」怎么来的?经典的时间戳法:每 2 秒发一个 ping 消息带上本地时间,对方收到后立即回包,本地用「收到时刻 - 发送时刻」算出差值,就是往返延迟 RTT

// 每 2 秒广播一次 ping
if (pingT >= 2) { pingT = 0; netBroadcast({ t: 'ping', ts: now }); }

RTT 除以 2 就是单向延迟的近似。它能帮玩家判断「卡顿是网络的锅还是我操作的问题」,也是排查网络问题最直接的证据。

九、从 1v1 到多人:Mesh 全互联拓扑

1v1 只需要一条连接。但 3v3、5v5 怎么办?我采用的是Mesh 全互联:每个玩家与房间里每一个其他玩家都建立一条 P2P 连接,自己的状态广播给所有人。

但这里有个现实问题:手动交换 SDP 在多人下不可行——5v5 共 10 个节点,两两建连要交换 45 次码,人肉根本干不了。所以新玩家只需要和房主手动交换一次邀请码/应答码,房主随后把他的 SDP 转发给已在局内的其他玩家(meshOffer / meshAnswer),让每对玩家之间自动建立 P2P 直连。房主只做信令中转,不做流量中转。

房主 (只做信令中转) 玩家 B team 0 玩家 C team 1 玩家 D team 0 玩家 E team 1 P2P P2P P2P P2P P2P P2P P2P P2P 任意两名玩家之间都有一条直连 —— 全网状。SDP 交换阶段由房主中转,建连后流量全走直连
图 6 · 5 人 Mesh 拓扑:实线为 P2P 直连,虚线为经房主中转的信令交换
// nets 字典管理「与每个玩家的独立连接」
for (const id in nets) if (nets[id].connected) nets[id].sendEvent(msg);
通信原理里的拓扑选择:Mesh(全网状)延迟最低——每个人直连所有人,不经过任何转发;代价是连接数随人数平方增长(5 人 = 10 条连接)。对于 10 人以内的小规模对战,Mesh 是延迟敏感型游戏的最佳选择。真要做 50 人战场,就得换星型(服务器转发)或混合架构了。

十、选阵营:没有服务器,如何让大家意见一致?

3v3、5v5 开局前有一个「选阵营」环节:每个玩家在红队/蓝队(防空车玩法下是飞机/防空车)里二选一。这个环节看起来简单,但在纯 P2P 架构里,它藏着一个分布式系统的经典难题——没有中心服务器仲裁,怎么保证所有玩家对「谁在哪个队」达成一致?

先到先得:选择与满员

房主发起选阵营后,每 800ms 向所有人广播一次两队的实时人数(pickCount)。玩家点击阵营,选择后不可更改;某队已满(达到总人数一半)就会被房主拒绝(pickReject),只能选另一队。全员选满的瞬间,房主广播最终名单(pickList):

// 全员选满 → 广播最终名单
if (countPickTeam(0) + countPickTeam(1) >= netSize) {
  netBroadcast({ t: 'pickList', teams, round: pickRound });
}

对账:P2P 世界的一致性校验

名单广播出去了,问题来了:Mesh 网络里消息可能乱序、可能丢包,甚至可能被恶意玩家篡改。如果某个玩家拿到的名单和别人不一样,就会出现「我以为我是红队,你以为我是蓝队」的灾难。所以每个玩家收到名单后,要做的第一件事是找自己的队友对账

房主广播 pickList(最终名单) 玩家 A(红队) checkReq 发自己的名单副本 队友 B(红队) 收到后与自己名单比对 对账 checkOk · 名单一致 → 就绪 ready → 房主收到全员就绪 go → 开局 checkBad · 名单不一致 pickReset 全员重新选阵营(round 递增,旧消息一律丢弃)
图 7 · 选阵营对账:与队友交叉核对名单,一致才就绪,不一致全员重选
// 向队友广播自己的名单副本,请对方比对
netBroadcast({ t: 'checkReq', to: mate, from: myPeerId, teams });
// 队友回 checkOk(一致)→ 就绪
// 队友回 checkBad(不一致)→ 上报房主,全员重新选阵营

只有和队友核对一致,玩家才会向房主报告「ready」;只要有人发现不一致,就上报房主触发全员重选。这就是分布式系统里的一致性校验:没有裁判,就让参与者互相验证。它的思路和区块链共识同源——用「多方交叉确认」代替「单一权威裁决」。

两个工程细节值得一提:

  • 幂等轮次编号:每次选阵营都有一个递增的 round,所有消息都带编号,旧轮次的迟到消息一律丢弃——防止网络延迟让「上一轮的决定」干扰「这一轮」;
  • 超时兜底:和队友核对 1.5 秒无响应就换下一个队友;所有队友都失联时,信任房主名单直接就绪——网络再差,选阵营也能推进。
有趣的细节:1v1 没有选阵营环节(人数不对称没有意义),由房主在连接建立时直接随机分配;防空车玩法下,两个阵营的名称会动态变成「✈ 飞机 / 🛡 防空车」——同一个选阵营系统,套上不同的玩法语义。

十一、本地权威 + 事件广播:伤害怎么算?

最后一个通信设计的灵魂问题:谁说了算?我的答案是「本地权威」:命中判定由射击方完成(射手本地模拟子弹轨迹与碰撞),伤害结算由受击方完成(在自己的世界扣血、播爆炸、判死亡、结算回合),再把结果作为事件广播出去。这样既避免了复杂的服务器仲裁,又让双方都能验证:射手无法凭空报伤害(扣血在对方那里执行),受击方也无法赖账(对方能看到自己命中)。

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

这些事件(命中、击杀、回合结果)全部走可靠通道——血量的加减顺序绝不能乱,这正是「事件可靠、状态尽力」分工的意义。命中部位(part)随伤害一起传递,还顺带支持了「弱点打击」玩法——打中引擎和打中机翼的伤害完全不同。

十二、容错:当对方突然消失

P2P 联机天然要面对一个现实:对方随时可能消失——关浏览器、断 WiFi、拔网线。更麻烦的是,没有中心服务器裁决「谁掉线了」,所有容错逻辑只能散落在每个客户端本地,靠连接层回调 + 广播互相拼凑。这恰恰是去中心化系统故障检测的标准形态:超时 + 心跳

游戏的容错设计分三层:

  • 连接监听:RTCPeerConnection 的 connectionState 一旦进入 disconnected / failed / closed,立即触发断线回调;
  • 实体清理:断线后立刻移除对方飞机、从名单剔除、并广播给所有人;
  • 简单重入:纯 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(); }
  }
};
场景 A · 普通玩家掉线(分布式感知,无中心仲裁) 玩家 D 掉线 网络断开 · 无预兆 直连对端最先感知 连接层变化 · 秒级延迟 房主广播 peerGone 唯一广播者 · seq 去重 全员清理 · 视为阵亡 checkRoundEnd 判回合 场景 B · 房主掉线(star 拓扑根节点断开) 房主掉线 根节点断开 所有玩家本地感知 无主机迁移机制 比赛判负 / 大厅返回菜单 提前结束 · 房间解散
图 9 · 掉线检测与传播路径:直连者本地感知,房主唯一广播,房主掉线则房间解散

两个设计决策值得一提:普通玩家掉线 = 当场阵亡——删实体、从名单剔除,若本队全灭立即判负,回合不会被「悬空」卡住;而房主掉线则直接解散房间——比赛进行中判房主队伍负,大厅阶段全员提示返回菜单。原因很直白:roster 权威、阵营分配、SDP 中转全都挂在房主身上,根节点一断,散伙是最简单且正确的解。

一个容易被忽略的细节:掉线检测不是即时的。浏览器只能靠 ICE 连通性检查判定连接失效,通常要等数秒超时。在这段窗口里,断线者的飞机仍静止在场上——它不打人,但可以被击中、可以被撞上。这就像网络世界里的「残影」,是所有 P2P 游戏的共同课题。

十三、零服务器的浪漫

整个联机层没有一台游戏服务器:没有信令服务器、没有 STUN、没有 TURN、没有数据库。两个浏览器之间,通过一串邀请码,直接建立加密的 P2P 通道。全部文件是纯静态资源,部署在 Cloudflare Pages 上,成本几乎为零。

从通信原理的角度回头看,这个项目其实就是做对了三件事:状态用不可靠通道高频快照、事件用可靠通道保证顺序、用插值掩盖网络抖动。这三板斧,就是绝大多数实时对战游戏的底层逻辑。

诚实地说,「iceServers 留空」是有代价的。这套架构适合「双方网络能直连」的场景——同一校园网、同一运营商、或者双方都有公网 IPv6。如果两台设备都躲在严格的对称 NAT 后面,ICE 会失败,这时就需要补上 STUN(探测公网映射地址,帮 P2P 打洞)和 TURN(无法直连时作为中继兜底)。

未来的路线其实很清晰:信令层升级为「6 位房间码 + 云数据库匹配」,让玩家输入房间码即自动建连,免去手动复制粘贴;网络层保留「先 P2P、失败才走中继」的原则,必要时再引入 STUN / TURN——建连与传输的架构早已就位,加这些只是插上配置而已。


十四、来试试?

游戏完全免费、无需下载,打开浏览器就能玩。叫上朋友一起:一人创建房间,把邀请码发过去,对方粘贴生成应答码,主机粘贴即连。整个过程你甚至可以拿微信/QQ 传码——因为我们连信令服务器都不需要

立即试玩 · game.4365754.xyz

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

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

评论

此博客中的热门博文

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

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

gemini转发国内的部署教程