ARTICLE DETAIL

资讯详情

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

零依赖与WebRTC P2P:为网页小游戏打造点开即玩的联机方案

零依赖与WebRTC P2P:为网页小游戏打造点开即玩的联机方案 这几年我经常看到一类帖子求网页版小游戏合集、希望复制链接浏览器就能打开、强调不用下载App。老实说这类需求在玩家眼里是“免安装”在开发者眼里却是另一道坎——你不仅要让游戏跑得足够轻还得在实时互动场景下把网络体验做到几乎无感知。市面上已经有那类点开链接即玩的音游和卡通休闲小游戏但一旦想加入“两个人同时在线操作同一个画面”这种玩法工程复杂度会立刻翻倍。这次整理的 OmniGame 实践骨架核心就两件事零第三方依赖外加 WebRTC P2P 联机。我先把话说清楚这并不是什么新奇玩法的创新而是把网页小游戏的工程组织方式重新梳理了一遍。如果你的目标是给一个小游戏加联机、被 npm 依赖树和服务器账单搞得头疼、或者想理解浏览器原生 P2P 能力到底能撑到什么程度那这篇技术笔记应该能给你一个可以抄作业的起点。1. 为什么小游戏的上限不在玩法而在工程组织1.1 用户搜的不是“小游戏”而是“零等待的链接”搜索热词有时候比产品需求文档更诚实。我观察到大量搜索集中在“网页版小游戏合集”“不用下载App”“复制链接浏览器打开”这几类关键词上。这些词背后的真实诉求不是“我需要一个功能复杂的3A大作”而是“我现在就想玩最好点一下链接就进游戏”。这恰好暴露了网页小程序最容易被低估的地方分发成本极低但体验成本极高。打开就要能玩意味着不能有漫长的加载、不能要求用户装任何运行环境、不能因为缺了一个 CDN 资源就白屏。传统的网页项目哪怕只是一个贪吃蛇只要你引入了构建工具、引用了第三方脚本、依赖了外部字体库就已经存在三个潜在故障点。而玩家不会关心你的打包链条他们只会在链接打不开时选择离开。不要在“免安装”这件事上偷懒这是 OmniGame 立项目标的原点。零依赖不是洁癖是为了把“链接即游戏”这个承诺做实。1.2 服务器中转是如何一步步吃掉实时性的先来算一笔延迟账。假设玩家A在北京玩家B在上海物理上的网络往返大约是20毫秒到30毫秒。如果游戏逻辑走中心服务器中转那么A的操作要先从A传到服务器再从服务器传到B反之亦然。一个完整的操作反馈实际耗时就是“A-服务器”加“服务器-B”的两段路程翻倍。这还没算服务器本身的排队、跨运营商路由绕路、以及高峰期丢包。你在本地开发环境里做测试感觉不到差异一旦真实用户分布在各种网络环境里服务器中转的延迟和抖动问题会瞬间暴露。即时对战类小游戏对延迟尤其敏感一个100毫秒的输入延迟玩家在操作手感上就已经有明显“粘滞感”。带宽成本也要单独算。服务器中转模式下每个玩家的状态帧都要先上行到服务器再由服务器分发给房间里的其他所有玩家。N个人在线服务器就要处理N份上行加N×(N-1)份下行转发。房间人数一多流量成本呈指数往上走。对小游戏团队来说联机服务器本身不贵贵的是流量和运维排障的人力。1.3 关键判断小房间、低延迟、无权威判定的场景才适合P2P不是所有游戏都适合 P2P。但网页小游戏有一个共性大多数玩法是4到8人的小房间节奏快、状态简单、没有复杂的世界规则。这种场景下中心服务器的核心价值——权威判定、全局广播、持久化——并不刚需。我当时的判断很简单如果游戏不需要服务器来决定“谁赢了”只是需要把每个玩家的操作状态同步给其他人那么为什么非要让所有数据都绕一圈服务器让浏览器和浏览器直接对话反而更符合小房间低延迟的诉求。这个判断是 OmniGame 所有设计的起点。先承认自己不需要权威服务器才能把 P2P 架构的优势真正用起来。2. 零依赖的含义与 OmniGame 的工程组织2.1 零依赖的边界不是“不用库”而是“只用浏览器原生能力”很多人一听到零依赖第一反应是“这年头不用框架怎么写代码”。这里要澄清一下边界OmniGame 说的零依赖特指不依赖任何第三方 JavaScript 库、不依赖构建工具、不依赖外部 CDN 资源整个项目只使用浏览器原生提供的 API。现代浏览器实际上已经内置了相当完整的游戏开发能力DOM 和 Canvas 负责渲染requestAnimationFrame 负责游戏循环WebSocket 负责网络通信WebRTC 负责P2P连接AudioContext 负责音频甚至连压缩和二进制处理都有原生 API 支撑。我盘点了一圈除了缺一个“物理引擎”和“资源加载器”其他核心能力浏览器几乎都给了。项目目录非常朴素一个 HTML 入口文件加几个 ES Modules 文件没有 package.json没有 node_modules没有 webpack config。这不是为了显得技术高超而是为了确保一件事无论这个项目在哪个环境里被打开只要浏览器还活着它就能跑。project/ index.html js/ main.js network/ signaling.js peer.js datachannel.js sync/ serializer.js state.js game/ loop.js input.js render.js用原生 ES Modules 组织代码现代浏览器原生支持 import/export不需要任何编译步骤。开发的时候直接起一个静态服务器生产的时候把所有 JS 文件内容合并压缩进一个 HTML整个游戏的体积可以控制在 100KB 左右。2.2 单文件分发带来的工程价值零依赖带来的一个直接红利是分发形态变得极其自由。我倾向把每次构建产物打包成单个 HTML 文件这个文件可以扔到任意一个静态托管平台上也可以放在内网共享甚至可以存进对象存储生成直链。玩家拿到一个链接打开即玩不存在图片路径失效、CSS 加载失败、JS 文件版本不匹配这一类问题。产品形态的取舍也清晰很多。单文件版本适合线上分享保留 Modules 结构的版本适合继续开发调试还可以把游戏嵌入到其他页面的 iframe 里做活动入口。这三种分发方式共用同一套代码逻辑只是打包策略不同。依赖第三方库最大的隐成本是供应链风险。哪怕是一个很小的 npm 包也可能在某天因为上游版本更新引入安全漏洞。零依赖意味着项目的攻击面被压缩到浏览器本身这在做网页小游戏分发时能省掉非常多不必要的担忧。2.3 零依赖模型下必须自己补的功课零依赖不是免费午餐库和框架之所以存在是因为它们帮你解决了通用问题。不用框架就得自己补上这些能力。在 OmniGame 里我自己动手实现了几块关键基础设施状态容器管理所有游戏对象的同步状态序列化器把状态对象编码成二进制格式增量同步模块只发送两个状态帧之间的差异房间管理模块处理创建房间、加入房间、离开房间的完整流程断线重连模块应对主机掉线或者网络切换的情况。这些模块在传统框架生态里都有现成实现手写确实费时间但带来一个很大的好处每一行代码都完全可控。调试 WebRTC 连接问题时你能准确知道问题出在自己代码还是外部依赖里这种确定性的排查体验在联机功能里非常值钱。3. 为什么是 P2PWebRTC 帮网页小游戏解决了什么3.1 延迟、带宽、部署成本三个维度的硬对比我把中心服务器模型和 P2P 模型放在同一张表格里做对比结论一目了然对比维度服务器中转WebRTC P2P 直连端到端延迟A到服务器服务器到B至少翻倍A与B直接网络路径近似物理延迟服务器带宽流量随人数平方级增长服务器只做握手信令带宽消耗可忽略部署成本需要公网服务器、扩容、监控告警只需要一个极简信令服务单点故障服务器挂了全部掉线没有中心节点部分连接仍可用内网场景无法利用局域网低延迟优势同一局域网可做到极低延迟大房间支持适合几十人以上适合4到12人小房间这个表格基于我自己实测和项目经验的估算不同网络环境会有浮动但趋势没有悬念。网页小游戏通常就是小规模、强实时、低成本运营P2P 在这些指标上全面占优。3.2 浏览器如何穿越 NATICE 候选收集与连接协商从架构设计落到浏览器实现WebRTC 能直连的核心在于 ICE 框架。ICE 会同时尝试收集本机网卡地址、公网映射地址、以及中继地址这三类候选项分别叫 host 候选、srflx 候选、relay 候选然后通过连通性检查选出真正能通的那条路径。可以打个比方你在公司内网办公室对方在家里的路由器后面。两个人要直接通话得先知道对方的“地址”。host 候选是办公室分机号码srflx 候选是前台帮你转接的外部号码relay 候选则是实在无法直连时由总机转接的通路。ICE 的职责就是把所有可能的号码都收集起来逐一打给通信检查找到一条真正能接通的线路。WebRTC 之所以适合网页小游戏是因为这套连接协商机制完全由浏览器底层实现不需要开发者处理 STUN 打洞细节。我们只需要做好信令转发把 SDP 和 ICE candidate 信息交换给对端。3.3 为什么传游戏状态用 DataChannel而不是视频流很多人以为 WebRTC 只能传音视频忽略了一个更重要的组件RTCDataChannel。它基于 SCTP 协议专门用来在浏览器之间传输任意二进制数据支持可靠有序、可靠无序、不可靠无序等多种可靠性模式。游戏同步不需要传视频流只需要传状态。每个玩家的位置、角度、分数、动画状态序列化之后可能只有几十到几百字节。用 DataChannel 传这些状态帧比传视频帧的带宽消耗低两三个数量级而且延迟表现更稳定。DataChannel 的不可靠模式特别适合高频状态帧上一帧丢了还没什么下一帧马上就能补上不需要重传旧数据浪费带宽。而玩家的操作指令这种关键事件则走可靠有序通道确保不会因为乱序导致逻辑错乱。一开一收、一可靠一不可靠两条通道就把游戏同步的两个核心诉求覆盖了。4. 从信令到数据通道OmniGame 联机实现逐层拆解4.1 信令服务的最小可用设计WebRTC 本身不解决“双方如何找到对方”的问题这需要额外的信令机制。OmniGame 选择了一个最简单的方案一个极薄的 WebSocket 信令服务只干三件事——创建房间、交换 SDP 描述、转发 ICE 候选。信令服务不参与任何游戏逻辑也不转发任何游戏数据。它只存在于连接建立的握手阶段一旦 RTCPeerConnection 成功打通信令服务就可以闲置下来。这意味着信令服务器即使被流量打爆已经建立好的 P2P 连接也不会中断。信令消息格式尽量保持精简一条 JSON 就够{ type: offer, roomId: room-001, sdp: v0..., sender: player-a }{ type: candidate, roomId: room-001, candidate: {candidate: candidate:1 1 UDP..., sdpMid: 0}, sender: player-a }我用浏览器原生 WebSocket API 实现信令客户端代码量很少。真正要留意的是房间 ID 的生成策略和加入房间的权限控制这个根据实际场景设计即可核心原则是信令服务越薄越好永远不要把游戏帧数据放进信令消息里。4.2 PeerConnection 生命周期一次完整连接怎么建立开发 OmniGame 联机模块时我把 RTCPeerConnection 的建立流程收敛成一个清晰的步骤序列。下面这个骨架代码可以直接复用它是整个 P2P 联机的地基。const config { iceServers: [ { urls: stun:stun.l.google.com:19302 } ] }; const pc new RTCPeerConnection(config); // 创建 DataChannel发起方 const stateChannel pc.createDataChannel(state, { ordered: false, maxRetransmits: 0 }); const eventChannel pc.createDataChannel(event, { ordered: true }); stateChannel.onopen () console.log(state channel open); stateChannel.onmessage (e) handleStateMessage(e.data); eventChannel.onmessage (e) handleEventMessage(e.data); // 收集 ICE 候选并交给信令服务 pc.onicecandidate (e) { if (e.candidate) { signaling.send({ type: candidate, candidate: e.candidate }); } }; // 发起方创建 offer 并发送 const offer await pc.createOffer(); await pc.setLocalDescription(offer); signaling.send({ type: offer, sdp: pc.localDescription }); // 接收方收到 offer 后创建 answer async function handleOffer(message) { await pc.setRemoteDescription(message.sdp); const answer await pc.createAnswer(); await pc.setLocalDescription(answer); signaling.send({ type: answer, sdp: pc.localDescription }); } // 接收方收到 answer 后建立完整链路 async function handleAnswer(message) { await pc.setRemoteDescription(message.sdp); }这里有一个非常容易踩的坑在调用setLocalDescription(offer)之后ICE 候选收集是异步进行的。如果代码立刻就把 SDP 发出去可能会丢失后续收集到的候选。稳妥做法是拿到本地描述后先等待一小段时间或者等icegatheringstate变为complete再发送 SDP我推荐后者兼容性更好。4.3 DataChannel 的分包策略把小游戏的“心跳”做好连接建立后真正决定体验的是 DataChannel 上的数据设计。OmniGame 在联机模块里定义了两条通道用途完全不同。事件通道走可靠有序模式负责传输玩家的操作指令、开始/结束事件这类必须保证到达的数据。这类数据量小、频率低即使重传也基本没有性能压力。状态通道走不可靠且允许无序的模式负责传输每帧的位置坐标、速度、动画状态等高频数据。允许丢帧反而更适合游戏实时性因为旧状态帧延迟到达的意义也不大尽快反映最新状态才是关键。在通道消息格式上我选择了二进制编码而非 JSON。同样的一个玩家位置数据用 JSON 传输可能占用几十字节用二进制编码配合定点数压缩后只有几个字节。对于高频状态通道这个优化影响巨大。后面在实测部分会具体展开。5. 实测数据、调优手段与常见翻车点5.1 三个典型网络场景下的实测表现没有实测数据的架构建议都是空谈。我搭了一个简单的双人控制场景在一个 800×600 的 Canvas 里控制两个方块互相追逐用 P2P 同步位置状态。测试分三个场景数值取多次测试的中位数。测试场景平均端到端延迟状态帧丢帧率主观手感同一局域网2~5ms0%完全同步同一运营商同城20~40ms0.5%以下基本无感跨运营商公网直连40~80ms1%~3%偶有轻微回跳跨运营商NAT穿透失败无法直连100%连接失败不可用需中继这个结果完全符合预期P2P 最怕的不是距离远而是 NAT 穿透失败。穿透成功时跨运营商的延迟也只略高于同城完全在可玩范围内穿透失败时整个连接会卡在 connecting 状态。测试方法也很简单两个浏览器窗口分别打开游戏用一个记录时间戳的脚本打点对比发送与接收的时间差。建议你在自己的项目里也建立这么一个基础延迟观测点后续做任何网络调优都需要它有数据支撑。5.2 把状态帧压缩到“无感”序列化与增量同步我这里分享两个被验证非常有效的优化手段。第一是定点数压缩把浮点坐标乘以100转成 int16 整数再传输接收端除以100还原。浮点转整数后字节占用从 4 字节降到 2 字节损失精度在厘米级对大多数小游戏完全够用。第二是增量状态同步。不是每帧都发送完整状态而是发送相对上一帧变化的部分。比如玩家没有移动就只发送一个“位置不变”的标记只有操作和动画切换时才发送对应状态字段。实测下来一个4人房间的状态通道数据量可以减少 60% 以上。序列化格式我统一用 ArrayBuffer 和 DataView 处理避免使用 TextEncoder 做字符串编解码带来的额外开销。整个序列化和反序列化的耗时控制在 0.5ms 以内完全不会挤占主线程的游戏循环时间。5.3 联机不稳定排查链路从 ICE 候选到 TURN 中继兜底WebRTC 联机出问题90% 都出在 ICE 协商阶段。根据我的排障经验排查顺序非常重要不要一开始就怀疑自己代码逻辑。第一步打开两个浏览器控制台确认信令消息是否完整交换了 offer、answer 和 candidate。如果 candidate 消息没出现说明 ICE 候选收集没跑完常见原因是 STUN 服务器不可达或者icecandidate事件监听器绑定得太晚。第二步检查pc.iceConnectionState的状态变化。一直停在checking说明正在尝试连通但还没成功此时可以尝试在 RTCPeerConnection 配置里增加更多的 STUN 服务器。如果状态直接变成failed说明各方候选无法穿透现有 NAT 类型。第三步确认是否需要中继兜底。当玩家双方位于对称型 NAT 后面时常规打洞基本无效此时必须配置 TURN 中继服务器。我用标准 TURN 服务做兜底但在代码设计上把它定位成“应急通道”而不是默认路径只有iceTransportPolicy设置为relay时才强制走中继平时仍优先直连。最后提醒一个最隐蔽的坑不要把host候选直接过滤掉。有人为了隐私把所有主机候选都禁用结果导致同一局域网的两台设备也无法直连被迫绕公网中继。工程上要平衡隐私和连接成功率不是一刀切。6. P2P 的边界与安全护栏什么场景不要迷信直连6.1 用户为什么想“关掉”WebRTCICE 信息暴露的工程取舍“WebRTC 怎么关闭”这个搜索热词在工程视角下其实是合理的浏览器在收集 ICE 候选时会把宿主机的内网 IP 也暴露给对端。对某些隐私敏感场景来说这种本机网络信息泄露不值得承受所以很多站点会主动限制 WebRTC 能力或引导用户禁用。但站在游戏开发者的立场直接放弃 WebRTC 等于放弃了低延迟联机。OmniGame 的处理方式是对候选做收窄配置iceCandidatePoolSize限制候选数量或者通过RTCPeerConnection的iceTransportPolicy控制候选类型。在必须隐藏内网拓扑的场景才考虑用 mDNS 混淆候选地址而不是粗暴禁用所有候选。这个问题的核心不是“要不要用 WebRTC”而是“能不能在拿到连接能力的同时细粒度控制暴露哪些本地信息”。工程方案可以在这两者之间找到平衡而不是非黑即白。6.2 主机掉线、网络切换与状态接管P2P 最让人头疼的问题之一就是主机容错。传统服务器模型里服务器永远在线玩家掉线重进就行P2P 模型里房间的创建者如果掉线其他所有人的连接可能瞬间全部悬空。我实测中遇到过好几次创建房间的玩家因为切换 Wi-FiIP 变了PeerConnection 断开剩下三个玩家集体掉线。解决方案是主机迁移机制——每个客户端都持续保存最近一份完整状态快照当检测到与主机的连接断开时从其余客户端中选举一个作为新主机用最近快照继续广播状态。选举策略不需要复杂房间里加入时间最早且当前在线即可。关键是所有客户端必须保持状态快照的持续性这要求游戏状态容器设计成可整体序列化的结构。整个过程在两秒内完成玩家只会感觉瞬卡一下不会丢失整个房间。6.3 什么时候老老实实回到服务器架构P2P 不是银弹我整理了一个简单的决策清单帮助你判断当前的游戏适不适合纯 P2P。需求条件P2P 是否合适原因4~8人小房间实时对局合适延迟低带宽可控需要玩家账号和进度持久化不合适没有可信中心存储需要排行榜和跨房间匹配不合适必须有中心服务需要反作弊和权威判定不合适客户端自报状态可被篡改房间人数超过12人谨慎全互联连接数会膨胀弱网环境比例高谨慎NAT、运营商限制较多如果在需求列表里命中了两条及以上“不合适”建议不要强行 P2P。老老实实设计一个轻量权威服务器把 P2P 当作局域网或者低延迟房间的增强模式而不是唯一的联机方案。工程架构没有面子问题只有适配问题。我在实际项目里最大的体会是P2P 模式把“游戏即链接”这个体验从概念变成了现实。做分发测试时一个文件扔过去就能玩不需要解释任何依赖安装问题玩家之间的延迟低于走服务器的方案手感明显更跟手。代价也是真实的调试联机问题时要同时盯着多个浏览器的控制台主机掉线恢复机制也得花好几轮迭代才能稳定。后续我打算继续在这个骨架上扩展两个方向一个是把 WASM 编解码加进状态序列化流程进一步压榨带宽另一个是接上 BroadcastChannel 的本地多开调试模式让同一台机器上多个浏览器标签页可以互通方便做真实场景测试。网页小游戏的工程天花板其实比大多数人想象的高不少。
返回列表