ARTICLE DETAIL

资讯详情

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

零依赖WebRTC P2P:单文件浏览器双人小游戏实战

零依赖WebRTC P2P:单文件浏览器双人小游戏实战 先说结论OmniGame 这个项目其实就是把一个网页小游戏从“依赖框架、依赖构建工具、依赖服务端”的常规套路里拽出来回到浏览器最原始的形态——一个 HTML 文件、一个 JS 文件再加上 WebRTC 原生的 P2P 能力就能让两个玩家在浏览器里直接对战。没有 Node.js 服务端承载游戏逻辑没有 WebSocket 中间层转发每一帧更没有 npm install 之后长达几分钟的依赖安装提示。它避开的是“工程化堆砌”的惯性用的却是足够硬核的 WebRTC 协商、ICE 穿透和 DataChannel 传输最终把网页小游戏的工程上限拉到了一个新位置单文件、零部署、点对点、可审计。这篇文章适合三种人看想搞懂 WebRTC 数据通道到底怎么落地到真实游戏的人被“现代前端工程”折磨到想回归极简的人以及正在做小游戏原型、又不想为了一局对战写一套后端的独立开发者。我会从零依赖的设计动机讲起拆清楚 P2P 建连的完整链路再落到可复制的代码实现和坑位清单。整篇都按我实际踩过的路子讲有代码、有原因、有翻车现场。1. 零依赖到底在解决什么问题1.1 零依赖不是“没有依赖”而是“依赖边界清晰”很多人一听到零依赖就以为项目里不能出现任何第三方库这其实是个误解。OmniGame 的零依赖准确说是“零第三方运行时依赖”——不引入任何需要单独下载、安装、打包的 JS 库。但它依然依赖浏览器依赖 WebRTC 标准依赖 HTTP 协议。这里要做个区分浏览器本身就是运行时协议本身就是能力。WebRTC 不是库而是浏览器内置的 API信令交换用的 HTTP 也是浏览器自带的 fetch。所以零依赖的本质是把依赖收敛到“浏览器标准能力”这一层之后的一切都由自己控制。这个选择带来的最直接好处是审计透明。一个项目里有什么代码掰着手指头都能数清楚信令服务是纯 Node 内置模块写的客户端是一个原生 JS 文件。没有 node_modules没有锁文件没有“某个传递依赖存在漏洞所以升级了另一个包结果导致构建报错”的连锁反应。你拿到的就是你能看到的。对于小游戏这种追求轻快的场景这种确定性值不少钱。1.2 为什么小游戏适合走这条路大游戏走零依赖是自找麻烦但小游戏完全相反。网页小游戏的特点是玩法规则简单、状态量小、同屏实体少、对帧率敏感但单帧数据量往往只有几百字节。这就决定了它天然适合走 P2P——不需要服务器托管全量状态也不需要中心服务器为每帧转发广播。另一个现实原因是部署形态。OmniGame 从设计之初就想清楚了一件事这游戏应该能像一张图片一样被分享。静态托管也好、局域网里直接打开文件也好、拿 U 盘拷到另一台电脑也好只要浏览器支持 WebRTC游戏就能跑。如果走 npm 安装加打包上线那它必然被绑死在某个域名上失去这种“可携带性”。零依赖让这个游戏变成了一枚可移动的棋子而不是一只拴在桩上的风筝。1.3 零依赖能走多远边界与代价零依赖当然有代价。最明显的代价是信令服务仍然存在——P2P 的连接并不是凭空建立的两个浏览器之间需要先通过某种方式交换各自的连接描述SDP Offer/Answer和网络候选ICE Candidate。这部分协议交换被 WebRTC 强制要求绕不开。OmniGame 的处理方式是把信令服务做成一个 150 行以内的极简 HTTP 服务承担“房间号登记 消息转发”两个职责连接建立之后它就可以退休了。之后所有游戏数据都走 DataChannel信令服务器连接会保持但不产生游戏流量。代价之二是不能什么都自己造。如果你想让游戏支持观战、录像回放、跨 N 个 NAT 的复杂网络那确实需要一个更重的信令架构甚至需要 TURN 中转服务器。但那是业务扩展的选项不是工程基础的必要条件。OmniGame 的定位是“一局双人对战”在明确这个边界之后零依赖就是最优解——足够小足够清晰也足够展示 WebRTC 的全部关键路径。提示零依赖的真正价值不是“厉害”而是可解释性和可移植性。做小游戏原型或教学演示时它让“为什么这样做”这件事变得极其透明新人也能在半小时内读完全部代码。2. WebRTC P2P把浏览器变成游戏服务器2.1 完整链路SDP、ICE、NAT、DataChannel理解 WebRTC 的关键是忘掉“浏览器只能当客户端”这个印象。WebRTC 让两个浏览器之间能建立一条加密的、点对点的 UDP 通道即使双方都在 NAT 后面。这里面有几个核心角色SDPSession Description Protocol一种描述媒体和连接参数的文本协议。由发起方创建 Offer应答方返回 Answer。它像是一封信写清楚“我支持哪些编码、我的网络候选地址是什么”。ICEInteractive Connectivity Establishment用于在两个端点之间寻找可用传输路径的机制。它把本机所有候选地址局域网 IP、公网映射地址、中继地址收集起来然后通过信令通道交换再逐步测通。STUN用来获取自己公网映射地址的辅助服务。它只解决“我对外看起来是什么地址”的问题不转发任何游戏数据。NAT 穿透两个设备在不同局域网内时ICE 会尝试用 UDP 打洞方式建立直连。不是所有网络环境都能成功成功率取决于双方 NAT 类型。建连过程的真实流程是浏览器 A 创建 RTCPeerConnection生成 Offer通过信令服务把它发给 BB 收到 Offer 后生成 Answer 回传给 A。同时双方各自触发 onicecandidate 事件把候选地址通过信令服务转发给对方。这个过程通常叫“信令交换”。一旦 ICE 找到可用路径连接状态会从 connecting 变成 connected此时 DataChannel 才可以正式收发数据。很多人第一次写 WebRTC 时等 DataChannel 的 open 事件立刻发数据结果什么都发不出去就是因为没等连接状态就绪。2.2 为什么选 DataChannel 而不是 MediaStream网页游戏同步数据的方式有三种用 WebSocket 中转、用 WebRTC MediaStream 传音视频、用 WebRTC DataChannel 传任意数据。OmniGame 选第三种理由很直接游戏对战里 90% 的流量都是“操作描述”“位置状态”“碰撞事件”这类小包它们需要的是一条低延迟的数据通道而不是音视频流。DataChannel 是基于 SCTP 协议的可以配置为可靠有序传输类似 TCP 语义也可以配置为不可靠无序传输类似 UDP 语义。对于小游戏默认用可靠有序模式就够了丢包少、实现简单、不会出现状态错乱。MediaStream 的问题在于模型不匹配。它本质上是连续的媒体帧流适合音频视频但你如果要传一个“玩家按下左键”这样的事件把它编码进媒体流里纯属自找麻烦。而 DataChannel 像一根自定义水管你往里塞 JSON 字符串、二进制数组、甚至整个游戏快照都行。尤其是小游戏状态同步时会频繁发送“蛇头坐标 方向 计分”这类紧凑的结构化数据在 DataChannel 里发送和接收都非常自然。2.3 主机权威谁说了算P2P 对战的常见设计问题是状态由谁决定。两个浏览器地位平等如果各自跑一套模拟那么双方看到的状态必然因网络延迟而发散。OmniGame 采用“主机权威”模式创建房间的玩家是 Host对局状态全部由 Host 的浏览器维护加入房间的玩家是 Client只发送操作指令方向键、暂停键并接收 Host 广播的状态快照。这样做的好处是避免双写冲突。设想两个玩家都在自己本地移动蛇然后通过 P2P 互发位置那你必须处理“双方都认为自己在同一时刻吃到了同一个苹果”的矛盾。主机权威把这个矛盾消灭在源头只有一台设备负责跑逻辑所有判定都发生在同一处其他玩家只是“遥控器”。代价是把 Host 浏览器的计算压力变成了性能瓶颈但对贪吃蛇这种规模的小游戏来说一帧里做几百次碰撞计算和若干次数组操作性能开销完全可以忽略。注意主机权威模式要求 Host 的设备不能太差。遇到性能极低的老设备要么降帧率要么把逻辑搬到纯计算里优化避免频繁 DOM 操作。但不要为了“公平”改成双权威那会引入回滚同步的复杂逻辑对工程体量不划算。3. 核心协议与同步机制小游戏怎么保证流畅3.1 消息协议JSON 足够二进制锦上添花游戏消息分为三类连接建立类的信令消息、游戏中的操作消息、以及状态广播消息。在 OmniGame 里信令消息走 HTTP 长轮询为了零依赖而游戏消息在 DataChannel 里用 JSON 字符串发送。JSON 有三个优势调试时能直接在控制台打印阅读、结构灵活、解析成本低。对每帧 1KB 以下的数据量来说JSON 的序列化和解析时间远小于网络传输本身所以不要过早优化成二进制协议。但每个消息必须有清晰的类型字段用type区分。例如{type: join, room: 1234}客户端请求加入房间{type: input, dir: left, seq: 42}客户端操作指令{type: snapshot, snake: [...], score: 10, tick: 1024}主机广播的状态快照关键的一点是用seq给每条操作编号主机在状态快照里带回lastProcessedSeq来告诉客户端哪条操作已经生效。客户端可以根据这个编号判断自己发送的操作是否被主机接收如果差距过大就知道网络出现明显延迟。3.2 操作采样与输入预测客户端每帧都在监听键盘但不会每帧都发送一条消息。因为键盘状态在游戏帧率 30Hz 下会产生 30 条/秒的输入消息而实际有效的只是“按下方向键的瞬间”。OmniGame 的做法是做操作采样只在方向变化时发送输入消息方向不变就静默。这与传统游戏行业里“只在事件发生时同步”的思路一致能极大降低频道压力。输入预测client-side prediction则是为了缓解延迟体验。客户端在发送“左转”指令后不等待主机回包而是立即在本地将蛇头方向改掉。如果主机后续确认的快照和自己的预测一致就继续运行如果不一致则以主机快照为准做一次修正。因为小游戏的操作窗口极短这个修正往往只是几帧的微调玩家几乎感知不到。但注意预测只针对“本地玩家自己的蛇”对对手的状态绝不预测否则会出现对撞判断不公平的问题。3.3 快照与增量同步的组合主机权威模式下主机需要定期把游戏状态推给客户端。OmniGame 用的是快照 增量混合策略关键帧每 10 个 tick发送完整快照中间的 tick 只发送变化量。完整快照包含蛇的全部坐标、苹果位置、分数增量消息则只包含“蛇头坐标、方向、苹果是否被吃、分数变化”。这套组合的好处是完整快照负责兜底客户端掉帧或状态异常时可以强制对齐增量消息负责日常的低延迟更新。实现时用两个字段表达snap表示是否为完整快照tick表示当前逻辑帧序号。客户端收到完整快照时直接覆盖本地状态收到增量时按 tick 序号补间更新。这里有个容易踩的坑增量消息不可丢一旦丢失客户端会沿用旧状态等收到下一帧增量时就会产生小跳变。所以 DataChannel 一定要用可靠有序模式ordered: true在这个场景下省掉乱序重排的心智负担值得那一点点延迟。3.4 断线与重连怎么处理P2P 的断线检测比中心服务器难做——没有服务端心跳你无法立刻区分“对方是被墙了”还是“只是失去焦点暂时停止响应”。OmniGame 的方案是游戏层心跳加超时判定双方每秒互发一次心跳消息附带本端逻辑帧号。如果 3 秒没有收到对端心跳就把连接状态标记为 lost棋盘上显示“连接已断开”同时尝试一次 ICE 重协商重新生成 Offer/Answer来拉回连接。实践中真正的断线重连成功率不高因为底层网络变化比如从 Wi-Fi 切到 4G几乎无法用 JS 层逻辑恢复。所以 OmniGame 的目标不是“断线后一定要连回来”而是“断线能及时发现、给出明确提示、允许快速重开一局”。小游戏要的是体验上的坦白不是系统层面上的完美保活。把预期设置对工程难度完全不同。4. 实操实现从零搭出一个可玩的 P2P 小游戏4.1 目录结构与开发环境OmniGame 的整个工程静态文件只有两个index.html页面结构、画布、UI 按钮game.js全部游戏逻辑P2P 建连、消息协议、渲染、输入处理信令服务是独立的server.js用 Node.js 内置模块http实现。根目录下不需要 package.json不需要任何依赖安装。开发时只需要node server.js然后浏览器打开http://localhost:3000。这里有一个很容易被忽略的前提WebRTC 在非安全上下文中无法使用。访问地址必须是https://或者http://localhost。如果你部署在局域网里要记得给服务器加上 HTTPS或者使用内网 http 时确保浏览器允许http://下的 WebRTC实际上 Chrome 对 localhost 有豁免但局域网 IP 没有。我最初在局域网用 http 测试时RTCPeerConnection 直接创建失败排查了很久才意识到是安全上下文问题。4.2 信令服务器的实现信令服务的核心职责是房间管理、消息转发。代码量不大但要把两个细节做对一是 Room 的创建和加入用 HTTP POST二是轮询拉取消息要用 GET 并支持长轮询避免频繁发起请求。下面是一个简化示例// server.js 核心逻辑零依赖仅使用 Node 内置模块 const http require(http); const rooms new Map(); function createRoom() { const roomId Math.random().toString(36).slice(2, 8); rooms.set(roomId, { host: null, client: null, messages: [] }); return roomId; } function pushMessage(roomId, from, data) { const room rooms.get(roomId); if (!room) return; room.messages.push({ from, data }); // 唤醒等待长轮询的另一端 } const server http.createServer((req, res) { const url new URL(req.url, http://localhost); if (req.method GET url.pathname /poll) { // 长轮询实现 } else if (req.method POST url.pathname /signal) { let body ; req.on(data, chunk body chunk); req.on(end, () { const msg JSON.parse(body); pushMessage(msg.room, msg.from, msg.data); res.end({}); }); } }); server.listen(3000);长轮询的注意事项是不要让客户端一直挂着一个请求。设置超时时间比如 25 秒后返回空响应客户端立刻重新发起这个模式叫“定长轮询”实现简单且能忍受。真正的长轮询需要服务器端挂起请求等消息到了再返回代码稍微复杂一点但对信令这种低频通道没必要。提示信令服务传递的是 Offer、Answer、ICE Candidate 这类连接建立数据。这些数据只是文本描述不包含游戏流量。连接建立后就算信令服务宕机正在进行的游戏也不受影响——这点可以用来做“断服不断局”的演示。4.3 客户端 P2P 建连模块客户端用原生 WebRTC API。核心流程是创建 RTCPeerConnection设置 ICE 服务器至少配置一个 STUN然后分主机和客户端走两条分支——主机先创建 DataChannel 并等待客户端加入客户端加入后主动发起 Offer。代码骨架如下const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.l.google.com:19302 } ] }); let channel null; pc.onicecandidate (e) { // 把 e.candidate 通过信令服务发给对端 sendSignal({ kind: candidate, data: e.candidate }); }; if (isHost) { channel pc.createDataChannel(game, { ordered: true }); channel.onopen startGame; // 主机作为被叫方等待对端 Offer 后 setRemoteDescription createAnswer } else { pc.ondatachannel (e) { channel e.channel; channel.onopen startGame; }; // 客户端发起 Offer发送给主机 const offer await pc.createOffer(); await pc.setLocalDescription(offer); sendSignal({ kind: offer, data: pc.localDescription }); }有几个细节直接影响成功率ICE 候选需要在 setLocalDescription 之后才会开始收集所以先 setLocalDescription再监听 onicecandidate顺序不能反。收到对端 ICE 候选时要判断pc.remoteDescription是否已设置。如果还没设置 Answer调用addIceCandidate会报错。稳妥做法是先把候选暂存等 remoteDescription 设置好后再统一添加。iceConnectionState要监听connected表示通道可用failed表示无法打通disconnected表示通道丢失。这三个状态是游戏 UI 判断网络情况的依据。4.4 游戏状态同步落地的核心代码游戏逻辑这里以双人贪吃蛇为例。主机维护一个简洁的状态对象蛇的坐标数组、当前方向、苹果位置、分数、tick 计数。每个 tick这里用 100ms 一次更新一次蛇的位置广播增量消息或完整快照。// 主机侧逻辑帧循环 function hostTick() { tick; // 更新蛇位置 updateSnake(); // 检查碰撞与吃苹果 checkCollision(); if (tick % 10 0) { // 每 10 tick 发送完整快照 channel.send(JSON.stringify({ type: snapshot, snap: true, tick, snake: snake.getPositions(), apple: apple.position, score })); } else { // 中间帧只发增量 channel.send(JSON.stringify({ type: snapshot, snap: false, tick, head: snake.head(), dir: snake.direction, ate: ateThisTick, score })); } }客户端接收消息后分两种情况处理完整快照直接覆盖增量消息检查 tick 是否连续如果发现跳号则请求一次完整快照发送带request_snap字段的消息。这个请求虽然增加了一次往返但能避免长期状态错乱。操作消息则更简单// 客户端侧方向变化时发送 document.addEventListener(keydown, (e) { const dir getDirectionFromKey(e.key); if (dir dir ! currentDir) { channel.send(JSON.stringify({ type: input, dir, seq: inputSeq })); } });主机收到输入消息后做一个合法性校验防止玩家直接把方向变成反向导致蛇穿自己身体function handleInput(msg) { const dir msg.dir; const opposite { up: down, down: up, left: right, right: left }; if (dir opposite[snake.direction]) return; // 非法输入忽略 snake.setDirection(dir); }5. 常见问题排查与工程避坑5.1 ICE 失败NAT 类型与 TURN 的必要性实践中WebRTC 直连成功率并不是 100%。两个客户端都处在对称型 NAT 后面时常规 STUN 打洞无法成功这就需要 TURN 服务器作为中继转发。OmniGame 默认只配置了 STUN因此会有少量网络环境下建连失败。排查思路是看pc.iceConnectionState的状态变化日志如果一直是 checking 然后变成 failed基本可以判定为无法穿透。此时有两个选择一是部署一个自建 TURNcoturn 是常用方案在iceServers里配置 TURN 地址二是提示用户检查路由器的 NAT 类型或网关设置。小游戏场景下我还是建议直接加 TURN成本不高但对兼容性的提升立竿见影。注意ICE 候选里有三种类型——host本机局域网地址、srflxSTUN 映射地址、relayTURN 中继地址。调试时如果发现只用 relay 成功说明直连失败但你靠 TURN 兜住了。这是正常现象不代表代码有问题。5.2 DataChannel 背压与消息拥堵DataChannel 有一个隐藏问题发送端持续高速写入时如果接收端处理不过来内部缓冲区会积压导致延迟和丢包。浏览器在中继模式下会触发流量控制但 JS 侧不感知。常见表现是帧率正常但操作响应越来越迟钝。解决方法是做发送侧背压感知。DataChannel 有bufferedAmount属性表示当前积压了多少字节配合bufferedamountlow事件可以在积压减少时继续发送。对小游戏来说更实用的做法是状态广播只保留最新一帧如果上一帧还没发送完成直接丢弃这一帧。因为状态同步场景里“最新状态”优先于“全量历史状态”丢一帧比延迟一帧要好得多。// 状态广播丢旧保新示例 let sending false; function broadcast(state) { if (sending) return; sending true; channel.send(state); channel.bufferedamountlow () { sending false; }; }5.3 调试技巧日志埋点与同屏双开P2P 程序最烦的是“两个端都在本地但连不起来”而浏览器控制台只能看到一端。我建议从一开始就把信令消息、ICE 状态、DataChannel 状态打成一个前缀清晰的日志例如[P2P][offer]、[P2P][candidate]、[DATA][open]。这样做的好处是一旦连接失败你能立刻判断卡在哪一步是信令没交换成功还是 ICE 没有候选还是 DataChannel 没有 open。另一个实战技巧是“同屏双开”在同一个浏览器开两个标签页一个创建房间Host一个加入房间Client。虽然两个标签页共享同一个浏览器进程但 WebRTC 的 P2P 通道依然会经过本地网络栈行为和真实跨机器很接近。用这种方法调试游戏逻辑比同时操作两台电脑快得多。我第一次测试时就是在左右两个标签页里分别用一个方向键控件直接观察双方画布的状态是否一致很方便。5.4 上线前的检查清单把 OmniGame 从本地跑通到对外可用的过程里有几个坑几乎每次都会碰到整理成一张表方便对照检查项容易踩的坑确认方式HTTPS / localhost 环境WebRTC 在非安全上下文不可用访问地址必须是 https 或 localhostSTUN/TURN 配置忘了 Blob 在 ICE 中的工作方式控制台查看 ICE 候选类型ICE Candidate 暂存在 remoteDescription 设置前 addIceCandidate 报错日志中确认先 setRemoteDescriptionDataChannel 有序性乱序导致状态跳变创建时使用 ordered: true消息类型字段接收端无法区分信令和游戏消息所有消息都带 type 字段心跳与超时断线后发现不及时超过 3 秒无消息标记 lost还有一个容易忽略的细节页面失焦。玩家切走浏览器标签页时游戏循环会被浏览器节流导致主机停止广播快照客户端就会开始累积延迟。解决方法是监听visibilitychange事件在页面隐藏时主动暂停游戏并向对端发送暂停消息避免因为定时器被节流而出现“两个人看到不同状态”的问题。5.5 写在最后的工程心得把 OmniGame 从“用 WebSocket Node 服务器做联机”改造成“纯 P2P 对战”之后我最大的感受是工程上限的提升不是来自更复杂的架构而是来自对浏览器的重新理解。浏览器不再是只能请求别人服务的终端它本身就是一个有能力参与对等网络通信的节点。DataChannel 提供的传输通道、RTCPeerConnection 提供的建连机制、信令服务提供的瞬间握手三者组合起来就等于把一台服务器塞进了玩家的浏览器里。我最想留给你的一句话是零依赖不是终点而是一个开始。当你不依赖任何框架时反而会被迫理解底层机制——你会认识 SDP 是干什么的、ICE 到底在穿透什么、DataChannel 和 WebSocket 的本质差异在哪里。这些东西用框架的时候永远学不到。下一次你再写小游戏不妨先想想这局对战真的需要一台中心服务器吗
返回列表