ARTICLE DETAIL

资讯详情

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

零依赖WebRTC P2P网页小游戏框架实战

零依赖WebRTC P2P网页小游戏框架实战 1. 为什么我要折腾一个“零依赖”的网页小游戏框架先说结论OmniGame 是我在过去几个月里断断续续搞出来的一个网页小游戏工程模板核心目标只有两个——零运行时依赖和基于 WebRTC 的 P2P 联机能力。它不是引擎不是平台更不是什么“颠覆行业”的东西就是一个把网页小游戏从“能跑”推到“跑得干净、跑得稳、还能两个人对着玩”的工程底座。我做这件事的起因很朴素。去年帮朋友做一个双人对战的反应力小游戏需求听起来简单打开网页就能玩两个人各自用手机扫码进房间实时同步分数。我一开始用最常见的方案——Socket.IO 加一个 Node 服务端做房间转发。本地跑没问题部署上去之后问题全来了服务器要一直开着延迟受中转节点影响玩家一多带宽成本就上去了而且那个小游戏本身逻辑不到 500 行结果为了联机硬是拖了一个后端服务、一个数据库存房间状态、一堆心跳保活逻辑。整个项目的复杂度被“联机”这两个字撑大了十倍。后来我就在想网页小游戏真的需要服务端吗如果只是两个玩家之间的状态同步WebRTC 的 DataChannel 完全可以直接点对点传数据服务端只需要在最开始帮忙交换一下连接信息之后就可以彻底退场。这就是 OmniGame 的技术主线用最小的服务端职责换取最大的客户端自治。这篇文章适合谁看如果你写过一点前端想做网页小游戏但被“联机”劝退过如果你用过 Next.js 但没认真想过 Shadow DOM 在游戏 UI 里的价值如果你对 WebRTC 的印象还停留在“视频通话”不知道它还能传游戏状态——那这篇应该能给你一些可以直接抄的东西。我会把设计取舍、核心实现、踩过的坑都摊开讲不藏私。2. 整体架构设计与技术选型取舍2.1 为什么是“零依赖”而不是“少依赖”“零依赖”这个词容易被误解。我不是说项目里不能有构建工具而是说运行时的第三方库依赖为零。也就是说最终跑在浏览器里的那份代码除了我自己写的模块不引入任何 npm 包。React、Vue、游戏引擎、状态管理库统统不要。这个决定背后有三个考量。第一是加载速度。网页小游戏最怕的就是“点进去等三秒”一个 React 运行时压缩后 40 多 KB一个游戏引擎动辄几百 KB对一个小游戏来说是巨大的浪费。第二是可控性。第三方库的更新、破坏性变更、安全漏洞都是你要替它背的锅。第三是学习价值。当你不能用库的时候你会被迫理解 DOM 操作、事件循环、状态同步这些底层机制这对工程能力的提升是实打实的。当然代价也很明显没有虚拟 DOMUI 更新要自己管没有响应式系统状态变化要手动触发渲染。所以我在架构上做了一个关键决策——把游戏逻辑和 UI 渲染彻底分离。游戏逻辑跑在一个纯数据的世界里UI 只是这个数据世界的一个“投影”。这样 UI 层就算写得笨一点也不会污染核心逻辑。2.2 Next.js 在这里扮演什么角色有人会问既然追求零依赖为什么还用 Next.js这不是自相矛盾吗不矛盾。Next.js 是构建期和部署期的工具不是运行时的依赖。我用它主要图三件事一是文件路由省得自己配路由二是静态导出next export出来的就是纯静态文件扔到任何静态托管上都能跑三是开发体验热更新、TypeScript 支持开箱即用。关键在于我全程只用 Next.js 的App Router 加静态导出模式不碰任何服务端渲染的数据获取逻辑。所有页面都是客户端组件use client一挂剩下的就是纯浏览器环境。这样 Next.js 的价值就退化成“一个带路由的打包器”运行时不会往浏览器里塞任何框架代码。提示如果你用 Next.js 做纯客户端游戏记得在next.config.js里设置output: export并且避免使用任何依赖服务端的 API。否则导出会失败或者导出后运行时报错。2.3 Shadow DOM 解决的是“样式污染”这个老大难网页小游戏最烦的问题之一是游戏 UI 的样式和宿主页面的样式互相打架。你写了个.btn类结果宿主页面也有.btn两边样式一混按钮就变形了。传统解法是给所有类名加前缀比如omni-btn但这治标不治本而且类名会越来越长。Shadow DOM 提供的是样式隔离。把游戏挂载到一个 Shadow Root 里里面的样式出不去外面的样式进不来。这在小游戏嵌入到别人页面、或者一个页面里同时跑多个小游戏时特别有用。但 Shadow DOM 也有坑。第一事件冒泡会被阻断Shadow Root 内部的事件不会自动冒泡到外部需要手动composed: true。第二全局样式无法穿透比如你想用宿主页面的字体得用 CSS 变量或者::part显式暴露。第三调试稍微麻烦DevTools 里要多点一层。我的做法是游戏容器用一个自定义元素omni-game包起来内部创建 Shadow Root所有游戏 UI 都塞进去。宿主页面只需要引入这个元素剩下的什么都不用管。这样游戏就变成了一个真正的“黑盒组件”。2.4 WebRTC P2P 的定位只做状态同步不做权威服务器WebRTC 的 DataChannel 支持两种模式可靠有序类似 TCP和不可靠无序类似 UDP。游戏状态同步一般用可靠有序就够了因为小游戏的状态包很小丢包重传的代价可以接受。这里有个关键设计谁是权威。在 P2P 架构里如果没有权威服务器两个玩家各自算各自的很容易出现状态不一致。我的方案是主机权威制——房间创建者是 HostHost 的游戏逻辑是唯一真相Guest 只负责发送输入和渲染 Host 同步过来的状态。这样逻辑简单不用做复杂的冲突解决。代价是 Host 的网络质量决定了所有人的体验。如果 Host 卡了Guest 也会卡。但对于双人小游戏来说这个代价可以接受。如果要做三人以上或者对公平性要求极高的竞技游戏那就得上权威服务器或者帧同步加回滚那是另一个量级的工程。3. 核心模块拆解与关键实现细节3.1 游戏循环requestAnimationFrame 的正确打开方式游戏循环是整个框架的心跳。我用的是最经典的requestAnimationFrame加固定时间步长fixed timestep的方案。为什么不用可变步长因为可变步长会让物理模拟和逻辑更新变得不可预测同样的输入在不同帧率下结果不一样联机时就会出问题。固定步长的核心思路是逻辑更新以固定频率跑比如每秒 60 次渲染以浏览器刷新率为准。如果浏览器刷新率是 120Hz那渲染跑 120 次但逻辑只跑 60 次中间用插值平滑。如果某一帧卡了逻辑会补跑几次保证时间不丢失。const FIXED_DT 1 / 60; let accumulator 0; let lastTime performance.now(); function loop(now) { const frameTime Math.min((now - lastTime) / 1000, 0.25); lastTime now; accumulator frameTime; while (accumulator FIXED_DT) { update(FIXED_DT); accumulator - FIXED_DT; } const alpha accumulator / FIXED_DT; render(alpha); requestAnimationFrame(loop); }这里有个细节frameTime我做了 0.25 秒的上限截断。为什么因为如果玩家切到别的标签页再切回来now - lastTime可能是几十秒这时候如果老老实实补跑几千次逻辑页面直接卡死。截断之后最多补跑 15 次剩下的时间就当“丢失”了。这是游戏开发里的常见做法叫“螺旋死亡保护”。注意update函数里绝对不能做 DOM 操作或者耗时计算。它只应该更新纯数据状态。渲染相关的所有事情都放到render里。这个边界一旦模糊性能就会失控。3.2 状态同步协议怎么把游戏状态塞进 DataChannelWebRTC DataChannel 传的是字节流或者字符串。我选的是JSON 字符串因为小游戏的状态包很小JSON 的可读性和调试便利性远大于那点序列化开销。如果以后状态包变大再考虑用 ArrayBuffer 加自定义二进制协议。同步协议我设计得很简单就三种消息类型消息类型方向内容频率inputGuest → Host玩家输入按键、点击坐标事件触发stateHost → Guest完整游戏状态快照每逻辑帧control双向开始、暂停、重开等控制指令事件触发Guest 每产生一个输入就立刻发一个input消息给 Host。Host 收到后把输入应用到自己的游戏状态里然后在下一个逻辑帧把完整状态广播给 Guest。Guest 收到state后直接覆盖本地状态然后渲染。这里有个优化点状态快照不要每帧都发。如果游戏是 60 帧逻辑每秒发 60 个状态包虽然包很小但没必要。我的做法是状态变化时才发或者降频到 20Hz 发送中间用插值平滑。对于反应力小游戏20Hz 的状态同步加上客户端插值肉眼几乎看不出差别。// Host 端发送状态 function broadcastState() { const snapshot { t: state, frame: currentFrame, players: players.map(p ({ id: p.id, x: p.x, y: p.y, score: p.score })), ball: { x: ball.x, y: ball.y } }; channel.send(JSON.stringify(snapshot)); }提示DataChannel 有发送缓冲区上限如果发得太快bufferedAmount会涨上去。生产环境里要监控这个值超过阈值就跳过本次发送避免内存暴涨。3.3 信令交换服务端只做一件事WebRTC 建立连接需要交换 SDP 和 ICE candidate这个过程叫信令signaling。信令必须通过一个双方都能访问的通道来完成通常是 WebSocket 或者 HTTP 轮询。我的设计是信令服务极简化只负责房间的创建、加入和消息转发不存任何游戏状态不参与游戏逻辑。房间用 6 位数字码标识Host 创建房间后拿到房间码Guest 输入房间码加入。信令服务收到消息后直接转发给房间里的另一个人转发完就忘。// 信令服务核心逻辑伪代码 const rooms new Map(); function handleMessage(ws, msg) { if (msg.type create) { const code generateCode(); rooms.set(code, { host: ws, guest: null }); ws.send({ type: created, code }); } else if (msg.type join) { const room rooms.get(msg.code); if (room !room.guest) { room.guest ws; room.host.send({ type: peer-joined }); ws.send({ type: joined }); } } else if (msg.type signal) { const room findRoomByWs(ws); const peer room.host ws ? room.guest : room.host; peer.send({ type: signal, data: msg.data }); } }这个服务端可以用任何语言写我用的是 Node.js 加ws库总共不到 100 行。部署的时候它只处理信令阶段的流量游戏开始后基本就闲置了。这意味着带宽成本极低一台最便宜的云主机能扛住大量房间。3.4 Shadow DOM 封装让游戏成为真正的黑盒前面提到用自定义元素加 Shadow DOM 做封装。具体实现是这样的class OmniGame extends HTMLElement { connectedCallback() { const shadow this.attachShadow({ mode: open }); const style document.createElement(style); style.textContent GAME_STYLES; const container document.createElement(div); container.id game-root; shadow.appendChild(style); shadow.appendChild(container); this.game new GameCore(container); } } customElements.define(omni-game, OmniGame);GAME_STYLES是一整段 CSS 字符串里面所有选择器都只作用于 Shadow Root 内部。宿主页面无论怎么写样式都影响不到游戏。反过来游戏里的样式也不会泄漏出去。但这里有个坑字体和 CSS 变量不会自动继承。如果游戏想用宿主页面的字体得在宿主页面把字体定义成 CSS 变量然后在 Shadow Root 里用var(--game-font)引用。CSS 变量是少数能穿透 Shadow 边界的样式机制要善用。另一个坑是事件重定向。Shadow Root 内部的事件event.target在外部看来会被重定向成宿主元素。如果你在外部监听点击事件拿到的target是omni-game而不是内部的具体元素。要拿到真实目标得用event.composedPath()。4. 从零搭建的完整实操流程4.1 项目初始化与目录结构我用create-next-app起手选 TypeScript、App Router、不要 Tailwind因为游戏样式我自己写。初始化完之后目录结构是这样的omni-game/ ├── app/ │ ├── page.tsx # 首页创建/加入房间 │ └── room/[code]/ │ └── page.tsx # 游戏房间页 ├── components/ │ └── OmniGame.tsx # 自定义元素封装 ├── core/ │ ├── loop.ts # 游戏循环 │ ├── state.ts # 状态管理 │ ├── net.ts # WebRTC 网络层 │ └── game.ts # 具体游戏逻辑 ├── signal/ │ └── server.js # 信令服务 └── next.config.jscore目录是纯逻辑不依赖任何框架。components是 UI 封装。app是路由页面。signal是独立的信令服务单独部署。next.config.js里关键配置const nextConfig { output: export, reactStrictMode: true, images: { unoptimized: true } };output: export让 Next.js 生成纯静态文件。images.unoptimized是因为静态导出不支持 Next.js 的图片优化服务。4.2 网络层的建立从信令到 DataChannel网络层是整个项目最复杂的部分。我把它拆成几个阶段创建/加入房间 → 交换 SDP → 交换 ICE → 建立 DataChannel → 开始游戏。Host 端的流程async function createRoom() { const ws new WebSocket(SIGNAL_URL); ws.onopen () ws.send(JSON.stringify({ type: create })); ws.onmessage async (e) { const msg JSON.parse(e.data); if (msg.type created) { roomCode msg.code; pc new RTCPeerConnection(ICE_CONFIG); channel pc.createDataChannel(game, { ordered: true }); setupChannel(channel); const offer await pc.createOffer(); await pc.setLocalDescription(offer); ws.send(JSON.stringify({ type: signal, data: offer })); } else if (msg.type signal) { await pc.setRemoteDescription(msg.data); if (msg.data.type answer) { // 连接建立中 } } }; }Guest 端类似只是把createOffer换成createAnswer并且用pc.ondatachannel接收 Host 创建的通道。ICE_CONFIG里我配了公共的 STUN 服务器。STUN 的作用是帮双方发现自己的公网地址。如果双方都在复杂的网络环境里比如对称型 NATSTUN 可能不够需要 TURN 中继。但 TURN 会消耗服务器带宽能不用就不用。对于大多数家庭网络和移动网络STUN 足够建立直连。注意STUN 服务器地址要用可靠的公共服务并且准备多个备用。单个 STUN 挂掉会导致新房间无法建立连接。我一般配两到三个。4.3 游戏逻辑与渲染的分离实践以我做的反应力小游戏为例游戏状态就是一个纯对象const state { frame: 0, players: { host: { x: 0, y: 0, score: 0, ready: false }, guest: { x: 0, y: 0, score: 0, ready: false } }, target: { x: 100, y: 100, active: false }, phase: waiting // waiting | playing | ended };update(dt)函数只改这个对象不碰 DOM。render(alpha)函数读取这个对象把变化映射到 DOM 上。映射的方式我用的是直接操作 DOM 属性没有虚拟 DOM。function render(alpha) { hostEl.style.transform translate(${state.players.host.x}px, ${state.players.host.y}px); guestEl.style.transform translate(${state.players.guest.x}px, ${state.players.guest.y}px); targetEl.style.display state.target.active ? block : none; scoreEl.textContent ${state.players.host.score} : ${state.players.guest.score}; }为什么不用left/top而用transform因为transform走的是合成层不触发重排性能好得多。这是游戏渲染的基本功。Guest 端的渲染有个特殊处理Guest 不跑update只接收 Host 发来的状态然后直接渲染。但为了平滑Guest 会维护一个状态缓冲区收到新状态后不立刻切换而是插值过渡。这样即使状态包 20Hz 发送画面也能保持 60fps 的流畅感。4.4 静态导出与部署next build之后out目录里就是完整的静态站点。扔到任何静态托管上都能跑。信令服务单独部署用一个环境变量把地址注入到前端。这里有个细节静态导出后room/[code]这种动态路由需要配置 fallback。Next.js 的静态导出对动态路由支持有限我的做法是不用动态路由房间码通过 URL query 参数传递比如/room?code123456。这样所有页面都是静态的部署零配置。信令服务的部署更简单一个 Node 进程监听一个端口前面挂个反向代理处理 WebSocket 升级。内存占用极低一个 1 核 1G 的实例能扛几千个并发连接。5. 踩坑实录与常见问题排查5.1 WebRTC 连接建立失败的排查思路这是最高频的问题。连接建不起来原因可能出在信令、ICE、SDP 任何一个环节。我的排查顺序是这样的现象可能原因排查方法房间创建后对方加入无反应信令消息没转发看信令服务日志确认双方消息都到了SDP 交换了但连接状态卡在 connectingICE 候选没交换检查onicecandidate是否触发并发送连接状态变成 failedNAT 穿透失败看pc.iceConnectionState考虑加 TURNDataChannel 打开了但收不到消息通道标签不一致确认双方createDataChannel的 label 相同我遇到最多的是ICE candidate 收集太慢。默认情况下浏览器会等所有候选收集完才触发onicecandidate的 null 事件。如果网络环境复杂可能要等好几秒。优化方法是边收集边发送每收到一个候选就立刻通过信令发给对方不要等收集完。pc.onicecandidate (e) { if (e.candidate) { ws.send(JSON.stringify({ type: signal, data: { type: candidate, candidate: e.candidate } })); } };5.2 Shadow DOM 里的事件处理陷阱前面提过事件重定向的问题。具体表现是你在 Shadow Root 内部给按钮绑了点击事件外部用document.addEventListener(click)监听拿到的target是宿主元素。如果你在外部做事件委托就会失效。解法有两个一是在 Shadow Root 内部处理事件不要让事件冒泡出去二是如果必须外部处理用composedPath()[0]拿真实目标。另一个坑是焦点管理。Shadow DOM 内部的输入框document.activeElement会返回宿主元素而不是输入框本身。如果游戏里有输入框比如玩家昵称要特别注意焦点切换的逻辑。5.3 状态不同步的典型场景P2P 架构下状态不同步几乎必然会发生关键是能不能快速恢复。我遇到过的典型场景场景一Guest 输入丢失。Guest 发了一个输入但 DataChannel 那一刻正好在重传Host 没及时收到。表现是 Guest 按了键但角色没动。解法是输入带序号Host 收到后如果发现序号跳跃就请求 Guest 重发。场景二Host 状态包乱序。虽然 DataChannel 默认可靠有序但如果配置成不可靠模式状态包可能乱序到达。解法是状态包带帧号Guest 只接受比当前帧号大的包旧的直接丢弃。场景三双方对“游戏结束”的判断不一致。因为网络延迟Host 已经判定游戏结束Guest 还在操作。解法是所有关键判定都由 Host 做Guest 只负责显示。Host 发一个control: gameover消息Guest 收到后立刻停止输入并显示结果。提示联机游戏里永远不要让两个端各自做关键判定。哪怕逻辑再简单也要指定一个权威端。这是血的教训。5.4 性能优化的几个实用技巧技巧一状态包瘦身。不要发完整状态只发变化的部分。比如玩家位置如果这一帧没动就不发。这叫脏标记同步。技巧二渲染降频。如果游戏画面不复杂渲染不需要 60fps。30fps 对大多数小游戏足够能省一半的 CPU。技巧三离屏 Canvas。如果游戏用 Canvas 渲染把静态背景画到离屏 Canvas 上每帧只画动态元素。这个优化在复杂场景下能提升好几倍性能。技巧四避免布局抖动。不要在render里读取offsetWidth、getBoundingClientRect这类会触发重排的属性。如果必须读提前缓存。6. 后续可以怎么扩展OmniGame 目前只支持双人这是 P2P 架构的自然边界。如果要扩展到三人以上有两条路一是网状 P2P每个人和其他所有人建连接状态广播给所有人。但连接数会随人数平方增长四个人就是六条连接管理起来很麻烦。二是星型 P2PHost 作为中心所有 Guest 只和 Host 连Host 负责转发。这个方案简单但 Host 的上行带宽是瓶颈。我个人的建议是双人以内用 P2P三人以上老老实实上权威服务器。P2P 的优势在于零服务器成本和低延迟一旦人数上去这两个优势都会被稀释不如用成熟的客户端-服务器架构。另一个扩展方向是游戏内容的模块化。目前游戏逻辑是写死的如果能把游戏规则抽象成配置让不同的小游戏共用同一套网络层和渲染层那这个框架的复用价值就更高了。我下一步打算把core里的网络层和循环层抽成独立包游戏逻辑做成插件这样换游戏只需要换插件。最后分享一个我在调试 WebRTC 时的小技巧Chrome 的chrome://webrtc-internals页面能看到所有连接的详细状态包括 ICE 候选、码率、丢包率。遇到连接问题时先打开这个页面比看日志快得多。
返回列表