ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

零依赖WebRTC P2P:网页小游戏联机工程实践

零依赖WebRTC P2P:网页小游戏联机工程实践 做了六年网页小游戏我最怕听到一句话“你这东西怎么还要下载”网页小游戏本该是复制链接、点开浏览器就能玩但实际工程里资源和联机往往做不到这个标准。我这两年把大量时间花在一套叫 OmniGame 的运行时上从零依赖起步用浏览器原生的 WebRTC P2P 打通玩家之间的实时数据通道不搭游戏服务器、不引入任何第三方库只靠 HTML 和 JS 就把联机小游戏跑起来。这个项目想解决的问题很具体当网页小游戏不再依赖安装包、不再依赖 CDN 预加载、不再依赖一台 7x24 小时的中转服务器时工程上限到底能抬到多高。这篇技术白皮书就是我实践下来的完整复盘。如果你也在做轻量联机小游戏或者对 WebRTC 好奇但不想一上来就铺重型框架可以按这篇的思路走一遍。1. 为什么从零依赖开始重做网页小游戏工程1.1 传统网页小游戏的假轻盈打开快不代表工程简单很多网页小游戏看起来“轻”是因为用户只看到一个页面但背后的工程一点都不轻。常见的架构是前端一个 React/Vue 工程打包成几 MB 的 JS后端要有 WebSocket 网关、房间管理服务、数据库联机数据全部经过一台中转服务器玩家之间的每一次移动都要先上行到云端再下行到对方。人少的时候没事一旦同时在线几千人带宽和 CPU 账单会让人清醒。我见过太多团队做了一个 100 KB 的游戏却要搭一台 4C8G 的云服务器来维持“延迟小于 100ms”的体验。更麻烦的是分发。玩家要打开页面先得等 DNS、等 CDN 命中、等 JS 执行中间任何一个环节崩了用户就流失。所谓“不用下载 app、复制链接浏览器打开”这种场景本质上是在要求一个网页游戏能做到“零安装、零等待、零服务器中转”。OmniGame 想做的就是把这三件事全部拉回浏览器本身能够提供的极限。1.2 零依赖不等于没有依赖我们只依赖浏览器零依赖这个词经常被误解。有些人一看“零依赖”就以为是不用任何技术栈其实真正的意思是不依赖任何需要安装、编译、单独部署的外部组件只依赖浏览器已经内置的能力。OmniGame 的核心依赖清单只有四项HTML、CSS、JavaScript以及浏览器提供的 Web API。联机走 WebRTC界面渲染走 Canvas输入走 Pointer Events 和 Keyboard Events音效走 Web Audio API。没有 node_modules没有 webpack 配置文件没有 npm install 这一步。你想跑一个任意项目只需要一个.html文件把它拖进浏览器或者放到任意静态托管上就行。这对“复制全部代码就能跑”的分发方式非常友好。这样设计有很实际的好处。第一交付物只有一个文件不会出现“代码在本地能跑到服务器上缺依赖”的问题。第二没有构建步骤改完代码刷新页面就能看到结果调试成本极低。第三所有逻辑都摊在明面上新人想学可以从头读一遍不会被抽象框架挡住。1.3 零依赖的边界服务器还是省不掉的必须说清楚一件事零依赖不等于零服务器。WebRTC 的 P2P 连接在建立过程中需要一个信令交换渠道玩家之间哪怕最后是直连也得先知道对方“在哪里”。这个信令可以是一台服务器也可以是你复制粘贴的一段文本。区别只在于服务器不需要持续中转游戏数据它只在建立连接的那一瞬间帮一个忙。所以 OmniGame 的定位是常规游戏逻辑、状态同步、消息转发全部走 P2P信令服务静态托管或临时手动完成。这样服务器的压力趋近于零也就自然省掉了带宽费和 7x24 小时运维。真正写过联机游戏的人应该能理解省掉高频数据中转比省掉一个静态页面有价值得多。2. 用 WebRTC DataChannel 搭起 P2P 游戏网络2.1 WebRTC 的数据通道不是 WebSocket 换皮很多第一次接触 WebRTC 的人会把它理解成“更高级的 WebSocket”这种类比最容易误导人。WebSocket 的模型是客户端连服务器服务器再转发给另一个客户端本质上是一条“中继管道”。WebRTC DataChannel 的模型是浏览器与浏览器直接建立数据通道中间不需要业务服务器帮忙搬运数据。底层走的是 SCTP over DTLS over UDP直连成功后延迟经常能做到 10ms 级别远好于绕服务器一圈。DataChannel 和 WebSocket 还有一个关键区别WebSocket 只能做到 TCP 一样可靠有序DataChannel 却可以配置成不可靠、无序、允许丢包的通道。这对游戏非常重要。比如玩家位置这种高频数据晚到一帧就过时了你反而希望它丢包后立刻发新状态而不是卡在队列里等旧数据补齐。普通 WebSocket 做不到这种细粒度控制。但 WebRTC 也有限制。它不能凭空打通所有网络如果两个玩家都在复杂的对称 NAT 后面又没有 TURN 中继服务器直连就会失败。另外建立连接前必须交换 SDP 和 ICE 候选信息这个过程需要一个信令渠道。所以它并不是“打开浏览器就能自动连”而是“一旦连上就不再需要服务器持续参与”。2.2 建立 P2P 连接的三个阶段SDP、ICE、DataChannelWebRTC 建立连接的过程我拆成三步来理解。第一步是 SDP 协商。SDP 是一段描述会话能力的文本包括要使用的编解码器、传输地址、数据通道参数。发起方调用createOffer生成一个 offer接收方拿到后调用createAnswer生成 answer。这个阶段很像两个人见面先交换名片告诉对方“我能用这些方式通信”。第二步是 ICE 候选收集。浏览器会通过 STUN 服务器拿到自己的公网映射地址同时收集本地网卡地址然后把这些候选地址交换给对方。双方不断尝试用不同的候选地址对连通直到找到一条能用的路径。第三步是 DataChannel 打开。DataChannel 其实在 SDP 协商前就要创建它会被写进 offer 里一起协商。数据通道打开后双方就可以通过onmessage收发消息。一段最简代码看起来是这样的const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.l.google.com:19302 } ] }); const dc pc.createDataChannel(game, { ordered: true }); dc.onopen () console.log(P2P channel opened); dc.onmessage (e) handlePeerMessage(e.data); async function createOffer() { const offer await pc.createOffer(); await pc.setLocalDescription(offer); return offer.sdp; }整个过程中最容易被忽略的是 ICE 候选交换。很多人只看connectionState变成 connected 就以为完事了实际上如果只交换了 SDP 而没有交换 ICE 候选连接大概率会卡死在 connecting。我调试时踩过的最多的坑就是忘记把一个玩家的icecandidate事件里的候选信息发给对方。2.3 零依赖信令URL 参数和复制粘贴就够了如果是正经商业项目信令服务通常用 WebSocket 做但 OmniGame 的目标是零依赖那就要想办法省掉这个 WebSocket 服务。最朴素的办法手动复制 SDP。玩法是这样A 打开页面点击“创建房间”页面生成 offer 文本A 复制这段文本通过任何方式聊天窗口、邮件、甚至口头念发给 B。B 在页面里粘贴这段 offer点击“加入房间”生成 answer 文本再复制给 A。A 粘贴 answer 后连接完成。整个过程只需要一次静态页面不需要任何后端。这个方法听着很原始但对 2 到 4 人的熟人联机完全够用。SDP 文本虽然长压缩成 Base64 后大概几百到一千字节丢聊天窗口里不突兀。更自动化一点可以把 SDP 塞进 URL 参数里让用户直接打开带参链接。比如 A 生成一个index.html?offerxxx的链接发给 BB 打开后自动解析并生成 answer。这样连复制粘贴都省了只是 URL 会特别长适合局域网内部测试。我建议所有零依赖方案里至少保留“手动粘贴”作为兜底。因为你没法保证用户的网络环境允许你自由部署信令服务但文本交换在任何环境都能工作。2.4 多玩家房间的拓扑全连接、星型中继和主机中继两人游戏用一条 DataChannel 就够了三人以上就要考虑拓扑。我整理过三种最常用的方案用表格可以直接对比拓扑原理优点缺点适用人数全连接每个玩家与其他所有玩家各建一条 P2P 通道延迟最低单点故障影响小连接数和带宽随人数平方增长2~4 人星型中继一名玩家做主机接收所有人的数据再转发实现简单逻辑集中在主机主机带宽压力大主机掉线全盘崩5~8 人主机权威 混合主机负责状态计算和广播其他玩家之间视情况建 P2P平衡延迟与带宽需要处理复杂的角色切换5 人以上对于大多数网页小游戏我建议 2 到 4 人无脑选全连接因为每人只维护两三条通道代码最简单延迟也最理想。超过 4 人再考虑主机中继因为浏览器对并发连接的稳定性会下降而且大量上行带宽会卡在少数几个人的家用网络里。3. OmniGame 的运行时设计游戏数据怎么传才不卡3.1 选主机权威状态同步而不是每个人各算一份联机游戏有两个技术流派状态同步和输入同步。状态同步是“一个世界状态多个客户端显示”客户端把自己的操作发给服务器或主机主机计算完把结果广播回来。输入同步是“每个客户端都运行完整游戏逻辑”只互相广播输入指令要求所有客户端模拟结果完全一致也就是常说的帧同步锁步。P2P 环境里没有天然权威服务器很多项目会尝试输入同步因为这样数据量最小延迟也最敏感。但纯 P2P 输入同步非常难每个玩家的浏览器 JS 引擎相同不代表浮点运算结果相同不同设备的帧率波动、随机数种子、事件顺序都会导致状态分叉。一旦分叉要花很大代价去回滚对齐。OmniGame 默认走的是主机权威状态同步选一名玩家当主机主机持有完整世界状态其他客户端只上传输入指令主机按固定 tick 广播状态快照。对小游戏来说这个方案最稳。主机客户端一共 2 到 4 个状态快照每 tick 也就几百字节带宽占用完全可以接受。主机掉线的问题后面单独讲但至少逻辑上很清晰。3.2 消息格式从 JSON 到二进制能省一点是一点第一版 OmniGame 全用 JSON 传消息调试确实爽但实机跑起来问题明显。一个 40 字节的状态对象JSON 序列化后可能变成 80 字节再加上字符串解析时间对低端手机压力不小。后来我把高频消息全部改成二进制帧用DataView手动读写。一个典型的位置消息帧可以设计成下面这样// [0] type: 0x01 // [1] playerId: 0-255 // [2] action: 0idle, 1move, 2shoot // [3-4] x: int16 // [5-6] y: int16 // [7] angle: uint8 (0-255 映射 0-360 度) // [8-9] sequence: uint169 个字节能装下一条完整的位置更新比 JSON 至少省一半。实际项目里我还加了消息头包含 magic 和版本号防止不同脚本版本混用导致解析错位。用二进制也有代价调试时看不到明文必须写专门的日志解析函数。我的经验是低频控制消息继续用 JSON比如加入房间、游戏结束、聊天高频同步消息用二进制。这样兼容可读性和性能又不会把代码搞得太难维护。3.3 可靠通道和不可靠通道混用DataChannel 的优势是可以在创建时决定传输模式。我建议一个连接上开两条通道可靠有序通道用于重要事件比如游戏开始、玩家进出、得分、确认帧同步。不可靠无序通道用于高频位置同步允许丢包设置maxRetransmits: 0。创建方法很简单const reliable pc.createDataChannel(reliable, { ordered: true }); const unreliable pc.createDataChannel(unreliable, { ordered: false, maxRetransmits: 0 });关键是理解为什么不可靠更好。一个玩家的位置每秒更新 30 次如果某次更新丢了下一次更新 33ms 后就到。但如果你要求可靠传输那条丢包就会触发重传而重传的数据到达时早就过期了不但没意义还会占用通道带宽让后续的新数据排队。对高频状态丢旧保新才是正确策略。同样道理也适用于操作指令。如果是出拳、跳跃这种关键动作就必须可靠如果是准星方向这种持续变化的值用不可靠通道也没问题。3.4 延迟、抖动和预测插值即使直连公网延迟也会有波动。手机上常见的延迟抖动在 20ms 到 80ms 之间游戏里直接表现为“瞬移”和“漂移”。OmniGame 用了三层处理。第一层客户端本地预测自己的位置。玩家按方向键时本地立刻移动不发等待确认等主机状态快照回来再用快照里自己的位置做纠正。第二层远端实体插值。对主播过来的其他玩家位置不直接硬切而是维护一个 100ms 的缓冲区让精灵在到达点之间平滑插值。第三层每个消息带序号客户端只处理序号递增的快照旧序号直接丢弃。这套组合拳下来体感上延迟明显降低。哪怕实际网络延迟有 60ms玩家也只觉得轻微“肉”不会觉得自己的角色不受控制。代价是逻辑代码稍微复杂一点但相比帧同步的确定性要求这个复杂度完全可以接受。4. 实操端到端搭一个 2 人 P2P 对战小游戏4.1 准备一个不需要构建的页面骨架OmniGame 的页面骨架很简单一个 HTML 文件内联 JavaScript全部代码不到 300 行就能跑起来。核心只有三块按钮区、消息区、Canvas 游戏区。基础结构长这样!DOCTYPE html html head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 titleOmniGame P2P Demo/title style body { margin: 0; font-family: sans-serif; } #toolbar { padding: 12px; } #log { height: 80px; overflow-y: auto; font-size: 12px; } /style /head body div idtoolbar button idcreateBtn创建房间/button textarea idsignalInput rows3 placeholder粘贴对端 SDP/textarea button idjoinBtn加入房间/button button idsendSignalBtn发送应答/button /div div idlog/div canvas idgame width800 height450/canvas script // 所有逻辑在这里 /script /body /html这里刻意不用 ES Module只用传统script标签因为零依赖项目要保证双击打开文件也能运行。有些浏览器对file://协议下的 Module 加载有限制内联普通脚本则完全没有问题。4.2 完整联机流程A 创建、B 加入、开打我用一段核心代码展示完整流程的关键路径。省略 UI 绑定只留 P2P 逻辑let pc null; let dcReliable null; let host false; async function createRoom() { host true; pc new RTCPeerConnection({ iceServers: [{ urls: stun:stun.l.google.com:19302 }] }); dcReliable pc.createDataChannel(reliable, { ordered: true }); pc.onicecandidate (e) { if (e.candidate) { // 这里把 candidate 追加到 signal 文本框供对方复制 appendSignal(JSON.stringify(e.candidate)); } }; const offer await pc.createOffer(); await pc.setLocalDescription(offer); appendSignal(JSON.stringify(offer.sdp)); } async function joinRoom(remoteSdp) { host false; pc new RTCPeerConnection({ iceServers: [{ urls: stun:stun.l.google.com:19302 }] }); pc.ondatachannel (event) { dcReliable event.channel; bindChannel(dcReliable); }; pc.onicecandidate (e) { if (e.candidate) { appendSignal(JSON.stringify(e.candidate)); } }; await pc.setRemoteDescription({ type: offer, sdp: remoteSdp }); const answer await pc.createAnswer(); await pc.setLocalDescription(answer); appendSignal(JSON.stringify(answer.sdp)); }第一次跑通时B 需要把“offer 文本 A 的 ICE 候选文本”一起粘贴进来。标准做法是把所有信令拼成一行 JSON像我上面用 JSON 字符串追加B 那边解析出一整个对象再循环添加 candidate。这一步很多人漏掉结果永远卡在 connecting。连接建立后A 的dcReliable.onopen触发B 的dcReliable.onopen也触发。此时如果 A 是主机可以让 A 发送一条包含世界初始状态的 JSON 消息比如每个玩家的出生位置。之后每帧非主机客户端发送自己的按键状态主机计算完把结果通过二进制位置帧广播回去。4.3 本地双开调试没有第二台设备也能测开发 WebRTC 最难受的是测试环境。我的习惯是用两个浏览器窗口调一个 Chrome 普通窗口一个 Chrome 隐身窗口。因为隐身窗口的 storage 和缓存是隔离的两边不会抢同一个页面状态。操作顺序是先在一个窗口点击“创建房间”把生成的 offer 和 candidate 复制到剪贴板在另一个隐身窗口粘贴点击“加入房间”把生成的 answer 和 candidate 复制回第一个窗口粘贴点击“应用应答”。连接后两个窗口可以各自控制一个角色用键盘不同按键操作我非常推荐用这种方式验证联机逻辑。如果嫌复制麻烦可以在页面代码里加一个调试开关const params new URLSearchParams(location.search); if (params.get(autoOffer)) { // 页面加载后自动解析 offer 并加入房间 }把 offer 塞进 URL 参数第二个窗口直接打开带参数的地址就能自动加入。这个方法只适合本机联调真实公网场景还是需要手动复制文本或一个最小信令服务。5. 上线前后最容易踩的五个坑5.1 NAT 穿透失败P2P 不是百分百成功WebRTC 的直连成功率在普通公网环境大概能到八成以上但遇到对称 NAT 就会失败。表现是双方的状态一直connecting然后超时变成failed。原因很简单两边都只能拿到不稳定的外网映射地址无法建立 UDP 会话。解决办法是部署一个 TURN 中继服务器。TURN 的作用是当直连失败时让数据先经过一台公网服务器中转虽然延迟高一些但至少能通。开源方案用 coturn一个服务就能同时实现 STUN 和 TURN。对于 OmniGame我建议把 TURN 作为可选项直连成功优先直连失败再降级到 TURN。如果你完全不想维护服务器也有一个土办法在游戏里提示玩家“如果无法连接请把双方都切到同一个局域网环境”。这限制了传播但对熟人小游戏来说已经够用。5.2 主机掉线不能让整局崩盘主机权威方案最怕的就是主机掉线。我用过一个简单有效的机制分阶段迁移。首先所有非主机客户端从开局就定期接收主机发出的世界快照并在本地缓存最近 30 秒的状态。其次每 10 秒所有客户端互相交换一次“健康状态”记录谁的网速最好、谁的活跃度高。最后一旦发现主机连接断开所有客户端暂停游戏按预设优先级选出新主机。新主机把缓存的世界状态作为基准其他人继续上传输入从下一 tick 开始恢复。这个机制对 2 人游戏其实用不上因为另一个玩家直接作为新主机接管即可。对 4 人以上房间迁移过程会带来 1 到 2 秒的停顿但总比整个房间解散强。5.3 数据校验与防作弊纯 P2P 的天然短板P2P 架构没有中心服务器主机拥有最高权限它既能运算也能篡改这就导致纯 P2P 无法做到竞技级防作弊。毕竟每个客户端都能看到别人的内存和网络包修改成本很低。我的建议是不要硬刚。OmniGame 的定位是轻量娱乐所以防作弊只做了三层第一输入消息带上时间戳和递增序号防止重放第二主机广播的状态快照里加一个简单的 CRC 校验防止随机错误第三游戏逻辑里对关键数值做范围校验角色位置不会突然横跨整个地图。这些不能挡住刻意作弊的人但能挡住大部分误操作和低级篡改。如果未来要上排行赛还是得引入一个观战仲裁服务器。5.4 隐私顾虑为什么有人在网上搜“怎么关闭 WebRTC”网上搜“webrtc怎么关闭”的人多半是担心浏览器在后台偷偷建立连接、泄露本地 IP。这种担心有道理。WebRTC 在设计上确实会让页面获得一些网络探测能力所以 OmniGame 做了几个明确的选择不用getUserMedia不采集摄像头和麦克风页面顶部显示当前 P2P 连接数、对端地址类型、收发字节数提供“断开所有 P2P”按钮一旦点击就关闭所有 DataChannel 并释放连接。我自己不会建议用户去全局禁用 WebRTC因为那会同时干掉视频会议、在线白板等很多正常功能。更合理的做法是让每一个使用 P2P 的页面都公开自己的连接状态把选择权还给用户。OmniGame 在实践中把这一点写成规范任何嵌入了 OmniGame 的页面必须展示一个“当前 P2P 状态”面板这是零依赖项目里最廉价也最必要的隐私保护手段。5.5 浏览器差异与移动端适配WebRTC 在主流浏览器里支持得都不错但细节差异会坑人。我整理了一张表浏览器常见问题处理建议iOS Safari页面锁屏后 WebRTC 连接容易断开检测visibilitychange回到前台后自动重建连接macOS Safari部分旧版本不支持RTCPeerConnection的某些候选类型升级 Safari或降级到 TURNFirefox默认maxRetransmits行为与 Chrome 略有差异两条通道显式配置ordered和maxRetransmits移动端浏览器触摸事件和键盘事件混用用 Pointer Events 统一处理并适配 500ms 点击延迟移动端还有一个所有小游戏都躲不开的问题声音播放。iOS 要求音频上下文必须由用户手势激活否则会被静音。所以页面启动后第一次点击就要调用audioCtx.resume()这个动作最好放在“开始游戏”按钮里不要只在 WebRTC 连接成功后才处理。说回到 OmniGame 这套方案做下来最大的体会是零依赖的漂亮不在于“什么都没有”而在于所有承担关键任务的点都是浏览器标准能力遇到问题时你能从协议层去排查而不是被某个框架的抽象层绕晕。P2P 从来不是银弹NAT 穿透、主机掉线、防作弊这些坑一个都不会少。但当你亲手把一个只包含 HTML 和 JS 的文件发给朋友他打开浏览器就能和你对战的那一瞬间你会觉得前期踩的每个坑都值。如果你也想做类似的东西我建议你从“复制粘贴 SDP”这个最原始的信令方式开始跑通链路之后再逐步加 TURN、加主机迁移、加二进制协议。这条路不复杂但每一步都会让你对网页小游戏的工程上限有新的认识。
返回列表