苍穹对决 Sky Duel · 零服务器联机 3D 空战 —— 手搓 WebRTC 的实现原理
从零到一:我如何给 3D 空战游戏装上「实时通信」
- 联机游戏的头号敌人:延迟
- 状态同步:把「世界」同步过去
- 可靠 vs 不可靠:TCP 为什么不行
- WebRTC:浏览器里的点对点通道
- NAT:互联网上两台电脑,怎么互相找到?
- P2P 握手:没有服务器怎么建立连接?
- 插值平滑:让 20Hz 看起来像 60fps
- 延迟测量:让玩家看见自己的 ping
- 从 1v1 到多人:Mesh 全互联拓扑
- 选阵营:没有服务器,如何让大家意见一致?
- 本地权威 + 事件广播:伤害怎么算?
- 容错:当对方突然消失
- 零服务器的浪漫
- 来试试?
一、联机游戏的头号敌人:延迟
你按下开火键,子弹要飞向屏幕里的敌机。如果是单机游戏,这一切在本地计算,一秒 60 帧,毫无压力。但联机时,敌机的位置在对方的电脑上——你的每一次射击、每一个走位,都需要通过网络告诉对方。
光速在光纤里跑一个来回大约是 10~30 毫秒(同城),跨省可能 50~80ms。而对空战这种快节奏游戏来说,人眼能感知的延迟阈值大约在 100ms 左右。更麻烦的是:网络会丢包、会抖动(jitter),而 TCP 为了保证「一个不丢」,会把所有数据排队重传——这恰恰是实时对战最不能接受的。
于是,所有实时对战游戏都面临同一个核心问题:「我该把什么数据发给对方?用什么方式发?」这就引出了通信原理里最经典的一对概念:状态同步 vs 帧同步。
二、状态同步:把「世界」同步过去
我的游戏选择的是状态同步(State Synchronization)。思路很简单:每个玩家在自己电脑上完整地跑一套物理模拟,然后周期性把自己的状态(位置、朝向、速度、血量)广播给所有人,同时也接收别人的状态来「摆放」他们的飞机。
在《苍穹对决》里,状态同步每 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)」:自己的飞机自己说了算。
三、可靠 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):保证有序到达,适合关键事件
我的游戏建了两条通道,各司其职:
// 创建两条 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(网络地址转换)后面,私网地址在公网里根本不存在,别人无法直接找到你。
传统游戏用服务器中转绕开这个问题:所有玩家的流量先发给服务器,由服务器转发给其他人。好处是稳定、可控、易做防作弊;代价是要买服务器、要维护,而且玩家离服务器越远延迟越高。
浏览器其实自带另一条路:WebRTC。它允许两个浏览器直接建立点对点(P2P)连接,数据不经过任何中间服务器。WebRTC 建连流程里有两样东西:
- SDP(会话描述协议):一份描述「我想怎么连」的文本——包含数据通道配置、网络候选地址等,建连双方必须先交换它;
- ICE 候选:双方在各自网络里探测出的可用地址(私网 IP、公网映射地址等),供对方尝试连接。
标准做法是搭一个信令服务器来转发这些 SDP / 候选。《苍穹对决》做了一个激进的决定:信令服务器也不用了,由玩家手动复制粘贴来交换。整个联机层的 RTC 配置只有一行:
const RTC_CONFIG = { iceServers: [] }; // 不做 STUN / TURN,纯直连
六、P2P 握手:没有服务器怎么建立连接?
现在到了关键一步:交换协商信息、建立连接。WebRTC 建连必须「先交换信息、后建立连接」,这个交换就是信令。我们把要交换的所有信息打包成一段 base64 字符串当作邀请码/应答码,由玩家用微信、QQ 之类的聊天工具互相传递——整个过程像传纸条:
// 邀请码本质就是 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):两个已知状态之间,按时间比例平滑过渡。
我用的是指数插值——每帧朝目标位置「追」一定比例,距离越近追得越慢,天然自带缓动效果:
// 每帧把对方飞机往最新目标状态插值
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 直连。房主只做信令中转,不做流量中转。
// nets 字典管理「与每个玩家的独立连接」
for (const id in nets) if (nets[id].connected) nets[id].sendEvent(msg);
十、选阵营:没有服务器,如何让大家意见一致?
3v3、5v5 开局前有一个「选阵营」环节:每个玩家在红队/蓝队(防空车玩法下是飞机/防空车)里二选一。这个环节看起来简单,但在纯 P2P 架构里,它藏着一个分布式系统的经典难题——没有中心服务器仲裁,怎么保证所有玩家对「谁在哪个队」达成一致?
先到先得:选择与满员
房主发起选阵营后,每 800ms 向所有人广播一次两队的实时人数(pickCount)。玩家点击阵营,选择后不可更改;某队已满(达到总人数一半)就会被房主拒绝(pickReject),只能选另一队。全员选满的瞬间,房主广播最终名单(pickList):
// 全员选满 → 广播最终名单
if (countPickTeam(0) + countPickTeam(1) >= netSize) {
netBroadcast({ t: 'pickList', teams, round: pickRound });
}
对账:P2P 世界的一致性校验
名单广播出去了,问题来了:Mesh 网络里消息可能乱序、可能丢包,甚至可能被恶意玩家篡改。如果某个玩家拿到的名单和别人不一样,就会出现「我以为我是红队,你以为我是蓝队」的灾难。所以每个玩家收到名单后,要做的第一件事是找自己的队友对账:
// 向队友广播自己的名单副本,请对方比对
netBroadcast({ t: 'checkReq', to: mate, from: myPeerId, teams });
// 队友回 checkOk(一致)→ 就绪
// 队友回 checkBad(不一致)→ 上报房主,全员重新选阵营
只有和队友核对一致,玩家才会向房主报告「ready」;只要有人发现不一致,就上报房主触发全员重选。这就是分布式系统里的一致性校验:没有裁判,就让参与者互相验证。它的思路和区块链共识同源——用「多方交叉确认」代替「单一权威裁决」。
两个工程细节值得一提:
- 幂等轮次编号:每次选阵营都有一个递增的
round,所有消息都带编号,旧轮次的迟到消息一律丢弃——防止网络延迟让「上一轮的决定」干扰「这一轮」; - 超时兜底:和队友核对 1.5 秒无响应就换下一个队友;所有队友都失联时,信任房主名单直接就绪——网络再差,选阵营也能推进。
十一、本地权威 + 事件广播:伤害怎么算?
最后一个通信设计的灵魂问题:谁说了算?我的答案是「本地权威」:命中判定由射击方完成(射手本地模拟子弹轨迹与碰撞),伤害结算由受击方完成(在自己的世界扣血、播爆炸、判死亡、结算回合),再把结果作为事件广播出去。这样既避免了复杂的服务器仲裁,又让双方都能验证:射手无法凭空报伤害(扣血在对方那里执行),受击方也无法赖账(对方能看到自己命中)。
// 受击方结算后广播命中事件
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(); }
}
};
两个设计决策值得一提:普通玩家掉线 = 当场阵亡——删实体、从名单剔除,若本队全灭立即判负,回合不会被「悬空」卡住;而房主掉线则直接解散房间——比赛进行中判房主队伍负,大厅阶段全员提示返回菜单。原因很直白:roster 权威、阵营分配、SDP 中转全都挂在房主身上,根节点一断,散伙是最简单且正确的解。
十三、零服务器的浪漫
整个联机层没有一台游戏服务器:没有信令服务器、没有 STUN、没有 TURN、没有数据库。两个浏览器之间,通过一串邀请码,直接建立加密的 P2P 通道。全部文件是纯静态资源,部署在 Cloudflare Pages 上,成本几乎为零。
从通信原理的角度回头看,这个项目其实就是做对了三件事:状态用不可靠通道高频快照、事件用可靠通道保证顺序、用插值掩盖网络抖动。这三板斧,就是绝大多数实时对战游戏的底层逻辑。
诚实地说,「iceServers 留空」是有代价的。这套架构适合「双方网络能直连」的场景——同一校园网、同一运营商、或者双方都有公网 IPv6。如果两台设备都躲在严格的对称 NAT 后面,ICE 会失败,这时就需要补上 STUN(探测公网映射地址,帮 P2P 打洞)和 TURN(无法直连时作为中继兜底)。
未来的路线其实很清晰:信令层升级为「6 位房间码 + 云数据库匹配」,让玩家输入房间码即自动建连,免去手动复制粘贴;网络层保留「先 P2P、失败才走中继」的原则,必要时再引入 STUN / TURN——建连与传输的架构早已就位,加这些只是插上配置而已。
十四、来试试?
游戏完全免费、无需下载,打开浏览器就能玩。叫上朋友一起:一人创建房间,把邀请码发过去,对方粘贴生成应答码,主机粘贴即连。整个过程你甚至可以拿微信/QQ 传码——因为我们连信令服务器都不需要。
免费免下载 · 电脑 / 手机浏览器打开即玩 · 支持 1v1 / 3v3 / 5v5 联机