ARTICLE DETAIL

资讯详情

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

零依赖+WebRTC P2P:打造无需服务器的网页小游戏

零依赖+WebRTC P2P:打造无需服务器的网页小游戏 1. 为什么我要把网页小游戏做成零依赖 P2P这套组合先说结论OmniGame 这个项目本质上是在回答一个问题——一个网页小游戏能不能不依赖中心服务器、不依赖一堆 npm 包、不依赖后端接口就能跑起来、还能联机对战我花了大概三周时间把这件事从想法做成了能跑的原型中间踩的坑比我想象的多得多但最后跑通的那一刻确实有种原来网页还能这么玩的感觉。这个项目的核心关键词其实就几个WebRTC、P2P、Shadow DOM、Next.js、Tailwind CSS。听起来像是把当下最热的一堆技术堆在一起但实际上每一个选型背后都有非常具体的理由不是赶时髦。WebRTC 负责点对点通信P2P 是它的通信模型Shadow DOM 解决的是游戏组件和宿主页面样式打架这个老问题Next.js 提供工程骨架和路由Tailwind CSS 负责把 UI 快速搭出来。这五样东西凑在一起刚好覆盖了一个网页小游戏从开发到联机的完整链路。适合谁来读这篇东西如果你写过一点前端做过小游戏 demo或者对不靠服务器怎么让两个浏览器直接通信这件事好奇那这篇就是写给你的。如果你是完全的新手也能看懂因为我会把每个概念用生活化的方式讲一遍再给可复现的步骤。我不打算写成教科书就按我自己做项目的顺序把思路、选型、代码、踩坑一条条摊开讲。需要提前说明的是OmniGame 目前还是一个偏工程验证性质的项目不是商业级产品。它的价值在于把零依赖 P2P 联机这条技术路线跑通并验证可行性而不是提供一个开箱即用的游戏平台。理解这一点后面的很多取舍你就能看懂了。2. 整体架构设计与技术选型拆解2.1 为什么是零依赖而不是少依赖零依赖这个词容易被误解。它不是说我一个 npm 包都不用而是指运行时的核心逻辑不依赖任何第三方运行时库——没有游戏引擎、没有网络中间件、没有状态管理库。Next.js 和 Tailwind 是构建期和样式层的工具它们不参与游戏运行时的核心逻辑。我这么设计的原因很直接网页小游戏最大的敌人是体积和不确定性。你引入一个游戏引擎动辄几百 KB还得跟着它的生命周期走你引入一个网络库它可能封装了 WebRTC但封装层一旦出问题你排查起来比裸写还痛苦。我试过用某款流行的 2D 引擎做原型结果光是搞清楚它的资源加载时序就花了两天最后果断砍掉改成裸写 Canvas 原生 WebRTC。零依赖带来的直接好处有三个。第一可控性每一行网络代码、每一帧渲染逻辑都在我手里出问题能定位到具体行。第二体积小构建产物里没有冗余的运行时首屏加载快。第三可移植核心逻辑是纯 JS理论上可以搬到任何支持 WebRTC 的环境里不被框架绑架。当然代价也有。没有引擎碰撞检测、精灵动画、输入处理都得自己写。我的做法是只实现够用的部分——AABB 碰撞、简单的帧动画、键盘和触摸输入加起来不到 400 行。对于小游戏来说这比引入一个引擎划算得多。2.2 WebRTC P2P为什么不用 WebSocket 中转这是整个项目最核心的技术决策。传统网页联机游戏几乎都用 WebSocket客户端连服务器服务器转发消息。这套方案成熟、稳定但有个绕不开的问题——你得有服务器。服务器要钱、要维护、要处理并发对于一个小游戏项目来说这是很重的负担。WebRTC 的 P2P 模型不一样。它让两个浏览器直接建立连接数据从一个浏览器直接流到另一个浏览器中间不经过你的服务器除了建立连接时的信令交换。这意味着一旦连接建立你的服务器压力几乎为零延迟也更低因为数据不用绕一圈。但 WebRTC 不是银弹它的复杂性主要在两个地方。第一是信令两个浏览器要建立连接必须先交换 SDP会话描述和 ICE candidate网络候选地址这个交换过程需要一个信令通道。WebRTC 本身不规定信令怎么传你可以用 WebSocket、可以用 HTTP 轮询甚至可以用复制粘贴。我选的是最轻量的方案——一个极简的 WebSocket 信令服务只负责转发几十字节的握手信息连接建立后它就退休了。第二是NAT 穿透。两个浏览器之间可能隔着路由器、防火墙直接连不上。这时候需要 STUN 服务器帮忙发现公网地址实在不行还得用 TURN 服务器中转。STUN 是免费的有公共的TURN 要自己搭。对于小游戏这种对延迟敏感、但对可靠性要求没那么极端的场景我的策略是优先直连连不上就降级。实测下来同一局域网内基本 100% 直连跨网络大概 70%-80% 能直连成功剩下的走 TURN 中转也能玩只是延迟高一点。提示WebRTC 的握手信息里会包含本机的网络候选地址这是协议本身的工作机制。在开发调试时注意不要把这些信息打到公开日志里正式环境建议配置 ICE candidate 的过滤策略只暴露必要的候选。2.3 Shadow DOM解决样式污染这个老大难做过把游戏嵌进别人页面的人都知道样式污染是个噩梦。你的游戏 CSS 可能被宿主页面的全局样式覆盖你的样式也可能污染宿主页面。传统做法是给所有类名加前缀或者用 iframe 隔离。前缀法容易漏iframe 隔离又太重、通信麻烦。Shadow DOM 提供了第三种方案样式作用域隔离。你把游戏挂载到一个 shadow root 里里面的样式和外面的样式互不影响但又不是 iframe 那种完全独立的文档通信还是同一个 JS 上下文。这对我这种游戏要能嵌入任意页面的需求来说简直是量身定做。具体实现上我用一个自定义元素custom element作为游戏容器在它的attachShadow({ mode: open })里挂载整个游戏。Tailwind 的样式通过一个style标签注入到 shadow root 内部。这里有个坑Tailwind 默认是全局的直接注入 shadow root 不会生效需要把编译后的 CSS 字符串手动塞进去。我后面会详细讲这个处理。2.4 Next.js Tailwind工程骨架和样式的分工Next.js 在这个项目里承担的是外壳角色路由、页面结构、开发服务器、构建打包。我用的是 App Router因为它的布局和嵌套路由对小游戏的多页面大厅、房间、游戏页很友好。Tailwind 负责快速搭 UI尤其是大厅和房间列表这种能用就行的界面用 Tailwind 写起来比手写 CSS 快得多。这里要澄清一个常见误解Next.js 的 SSR服务端渲染对游戏本身没意义因为游戏必须在浏览器里跑。我用 Next.js 主要是图它的工程化能力——热更新、TypeScript 支持、构建优化。游戏核心逻辑全部放在客户端组件里用use client标记避免 SSR 带来的window未定义问题。技术角色是否参与运行时核心逻辑选它的核心理由WebRTCP2P 通信是直连低延迟无需中转服务器Shadow DOM样式隔离是嵌入任意页面不污染Next.js工程骨架否路由、构建、开发体验Tailwind CSS样式工具否快速搭 UI配合 Shadow DOM 注入原生 Canvas渲染是零依赖完全可控3. 核心细节解析与实操要点3.1 WebRTC 连接建立从信令到 DataChannel 的完整链路WebRTC 建立连接的过程我习惯用一个类比来理解两个人要打电话但都不知道对方号码于是先通过一个中间人交换号码然后直接拨号通话。中间人就是信令服务器交换号码就是 SDP 和 ICE candidate 的交换拨号通话就是 P2P 连接。具体流程是这样的。发起方offerer创建一个RTCPeerConnection调用createOffer()生成 SDP通过信令服务器发给接收方answerer。接收方收到后调用setRemoteDescription()再createAnswer()生成自己的 SDP 发回去。与此同时双方都在收集 ICE candidate网络候选地址每收集到一个就通过信令发给对方。当双方都设置好对方的 SDP 和 candidate 后连接就建立了。代码上核心就是这几个 API// 发起方 const pc new RTCPeerConnection({ iceServers: [{ urls: stun:stun.example.com }] }); const dc pc.createDataChannel(game); const offer await pc.createOffer(); await pc.setLocalDescription(offer); signaling.send({ type: offer, sdp: offer }); pc.onicecandidate (e) { if (e.candidate) signaling.send({ type: candidate, candidate: e.candidate }); }; // 接收方 pc.ondatachannel (e) { const dc e.channel; dc.onmessage (msg) handleGameMessage(msg.data); };这里有几个实操要点必须强调。第一DataChannel 的配置。默认是可靠传输类似 TCP但对游戏来说有时候丢一帧无所谓延迟低更重要这时候可以配置成不可靠模式pc.createDataChannel(game, { ordered: false, maxRetransmits: 0 })。我实测下来对于位置同步这种高频数据不可靠模式体验更好对于开始游戏结束游戏这种关键指令用可靠通道。第二ICE candidate 的收集是异步的可能在 SDP 交换完成之后还在继续。所以信令逻辑要能处理candidate 比 SDP 晚到的情况通常做法是先把 candidate 缓存起来等 remote description 设置好再批量添加。第三连接状态监控。pc.onconnectionstatechange会告诉你连接是connecting、connected还是failed。我踩过的坑是连接失败后没有重连机制玩家就卡在那里了。后来加了一个简单的重试逻辑失败后重新走一遍信令流程。3.2 Shadow DOM 里注入 Tailwind 的正确姿势前面提到 Tailwind 默认是全局的注入 shadow root 需要特殊处理。我试过三种方案最后选了最稳的一种。方案一是用adoptedStyleSheets把编译好的 CSS 构造成CSSStyleSheet对象然后shadowRoot.adoptedStyleSheets [sheet]。这是最现代的做法性能好但需要浏览器支持而且构建流程要能把 Tailwind 的输出转成可构造的字符串。方案二是直接在 shadow root 里插style标签内容是 Tailwind 编译后的 CSS 字符串。这个方案兼容性最好我最后用的就是这个。具体做法是在构建时把 Tailwind 的输出读成字符串通过一个模块导出运行时注入。import tailwindCSS from ./tailwind-output.css?inline; class GameElement extends HTMLElement { connectedCallback() { const shadow this.attachShadow({ mode: open }); const style document.createElement(style); style.textContent tailwindCSS; shadow.appendChild(style); // 挂载游戏内容 shadow.appendChild(this.createGameContainer()); } }方案三是用import在 shadow root 内部引入但实测下来加载时序不好控制容易闪一下没样式不推荐。注意Tailwind 的preflight基础样式重置注入 shadow root 后只影响 shadow 内部不会影响宿主页面这正是我们想要的。但如果你在宿主页面也用了 Tailwind两边的 preflight 会各管各的不会冲突。3.3 游戏循环与状态同步的设计取舍游戏循环我用的是requestAnimationFrame这是网页游戏的标准做法。核心结构是一个固定时间步长的更新循环渲染跟着屏幕刷新率走逻辑更新用固定步长我用的 60Hz这样在不同刷新率的设备上物理表现一致。状态同步是 P2P 游戏最头疼的部分。因为没有权威服务器两个客户端都可能自作主张。我的方案是主机权威host authoritative一方作为主机负责计算游戏状态把状态广播给另一方另一方只负责发送输入、渲染收到的状态。这样避免了状态冲突代价是主机玩家有轻微优势延迟低但对小游戏来说可以接受。同步频率上我用的策略是输入即时发送状态按固定频率广播。输入按键一有变化就发保证响应快状态位置、分数每 50ms 广播一次减少带宽。实测下来局域网内几乎感觉不到延迟跨网络大概有 50-100ms 的延迟对于休闲小游戏够用了。这里有个细节状态插值。如果只是每 50ms 收到一次位置直接渲染会一顿一顿的。我的做法是在两次状态之间做线性插值让移动看起来平滑。这个技巧在游戏网络编程里很常见实现起来也就十几行代码。3.4 零依赖下的碰撞检测与输入处理没有引擎碰撞检测得自己写。我用的是最经典的 AABB轴对齐包围盒检测因为小游戏里的物体基本都是矩形够用了。核心逻辑就是判断两个矩形的 x、y 范围和宽高是否重叠function isColliding(a, b) { return a.x b.x b.w a.x a.w b.x a.y b.y b.h a.y a.h b.y; }输入处理上我用一个keys对象记录当前按下的键游戏循环里读取这个对象来决定移动方向。这里有个坑键盘事件的重复触发。按住一个键浏览器会不断触发keydown如果不处理角色会抖。解决办法是用一个 Set 记录按下的键keydown时加入keyup时移除游戏循环里只读 Set 的状态。触摸输入要单独处理因为移动端没有键盘。我的做法是在屏幕上画虚拟摇杆用touchstart、touchmove、touchend计算方向向量。这块代码不多但要注意preventDefault防止页面滚动。4. 实操过程与核心环节实现4.1 项目初始化与目录结构从零开始第一步是初始化 Next.js 项目。我用的是官方脚手架选 TypeScript Tailwind App Routernpx create-next-applatest omnigame --typescript --tailwind --app目录结构我按游戏核心逻辑和 UI 分离的原则来组织omnigame/ ├── app/ │ ├── page.tsx # 大厅 │ ├── room/[id]/page.tsx # 房间页 │ └── layout.tsx ├── components/ │ ├── GameCanvas.tsx # 游戏渲染组件 │ └── GameElement.ts # Shadow DOM 自定义元素 ├── core/ │ ├── loop.ts # 游戏循环 │ ├── physics.ts # 碰撞检测 │ ├── input.ts # 输入处理 │ └── net.ts # WebRTC 封装 └── signaling/ └── server.js # 极简信令服务这个结构的核心思想是core/里是纯逻辑不依赖 React、不依赖 DOM除了必要的 API可以单独测试components/里是 React 和 DOM 相关的胶水代码app/是页面路由。这样分层之后游戏逻辑的复用性和可测试性都好了很多。4.2 信令服务的极简实现信令服务是整个项目里唯一需要服务器的部分但它的职责极简转发握手消息不存储任何状态。我用 Node.js 的ws库写了一个不到 60 行的服务const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); const rooms new Map(); wss.on(connection, (ws) { ws.on(message, (data) { const msg JSON.parse(data); if (msg.type join) { if (!rooms.has(msg.room)) rooms.set(msg.room, []); rooms.get(msg.room).push(ws); ws.room msg.room; } else { // 转发给同房间的其他人 const peers rooms.get(ws.room) || []; peers.forEach((peer) { if (peer ! ws peer.readyState WebSocket.OPEN) { peer.send(data); } }); } }); ws.on(close, () { const peers rooms.get(ws.room) || []; rooms.set(ws.room, peers.filter((p) p ! ws)); }); });这个服务的资源占用极低一个 1 核 1G 的小机器能扛几千个并发连接因为连接建立后消息量就趋近于零了。这也是 P2P 方案相比传统中转方案最大的优势——服务器成本几乎可以忽略。提示信令服务虽然简单但要注意房间号的管理和连接清理。我踩过的坑是玩家关闭页面后 WebSocket 没及时清理导致房间列表里出现幽灵房间。后来加了心跳检测和超时清理才解决。4.3 游戏循环的完整实现游戏循环是整个游戏的心脏我把它写成一个独立的类方便复用export class GameLoop { constructor(update, render, step 1000 / 60) { this.update update; this.render render; this.step step; this.accumulator 0; this.lastTime 0; this.running false; } start() { this.running true; this.lastTime performance.now(); requestAnimationFrame(this.tick.bind(this)); } tick(now) { if (!this.running) return; const delta Math.min(now - this.lastTime, 250); // 防止切后台后跳帧 this.lastTime now; this.accumulator delta; while (this.accumulator this.step) { this.update(this.step); this.accumulator - this.step; } this.render(this.accumulator / this.step); requestAnimationFrame(this.tick.bind(this)); } }这里的关键设计是固定步长 累加器。update用固定步长调用保证物理模拟稳定render每帧调用并把距离下一次 update 的进度传进去用于插值渲染。Math.min(delta, 250)这行是防止玩家切到后台再切回来时delta 变得巨大导致游戏瞬移。实测下来这套循环在 60Hz 和 120Hz 屏幕上表现一致物理不会因为刷新率不同而跑偏。这是很多新手容易忽略的点——直接用delta做物理更新在高刷屏上角色会跑得更快。4.4 WebRTC 封装的完整代码网络层我封装成一个Net类对外暴露connect、send、onMessage几个方法内部处理信令和连接状态export class Net { constructor(signalingUrl, roomId) { this.signalingUrl signalingUrl; this.roomId roomId; this.pc null; this.dc null; this.handlers { message: [], open: [], close: [] }; } async connect() { this.ws new WebSocket(this.signalingUrl); this.pc new RTCPeerConnection({ iceServers: [{ urls: stun:stun.l.google.com:19302 }], }); this.pc.onicecandidate (e) { if (e.candidate) this.sendSignal({ type: candidate, candidate: e.candidate }); }; this.pc.ondatachannel (e) this.setupDataChannel(e.channel); this.ws.onmessage async (e) { const msg JSON.parse(e.data); if (msg.type offer) { await this.pc.setRemoteDescription(msg.sdp); const answer await this.pc.createAnswer(); await this.pc.setLocalDescription(answer); this.sendSignal({ type: answer, sdp: answer }); } else if (msg.type answer) { await this.pc.setRemoteDescription(msg.sdp); } else if (msg.type candidate) { await this.pc.addIceCandidate(msg.candidate); } }; this.ws.onopen () this.sendSignal({ type: join, room: this.roomId }); } setupDataChannel(channel) { this.dc channel; this.dc.onopen () this.emit(open); this.dc.onmessage (e) this.emit(message, e.data); this.dc.onclose () this.emit(close); } send(data) { if (this.dc this.dc.readyState open) { this.dc.send(typeof data string ? data : JSON.stringify(data)); } } }这段代码里ondatachannel是接收方用的发起方则要主动createDataChannel。我在实际项目里做了一个判断房间里的第一个人作为发起方第二个人作为接收方。这个角色分配通过信令服务完成。4.5 状态同步与插值的实现细节状态同步的核心是主机广播客机插值。主机每 50ms 把游戏状态打包发出去// 主机侧 setInterval(() { net.send({ type: state, players: players.map((p) ({ id: p.id, x: p.x, y: p.y, score: p.score })), timestamp: performance.now(), }); }, 50);客机收到后不直接设置位置而是记录目标位置在渲染时插值// 客机侧 net.onMessage((msg) { if (msg.type state) { msg.players.forEach((remote) { const local players.find((p) p.id remote.id); if (local) { local.targetX remote.x; local.targetY remote.y; } }); } }); // 渲染时插值 function render(alpha) { players.forEach((p) { p.renderX p.x (p.targetX - p.x) * 0.2; // 平滑逼近 p.renderY p.y (p.targetY - p.y) * 0.2; }); }这里的0.2是插值系数越大越跟手但越容易抖越小越平滑但越滞后。我试了 0.1 到 0.5 几个值最后定在 0.2兼顾了平滑和响应。这个参数没有标准答案得根据游戏类型调。5. 常见问题与排查技巧实录5.1 WebRTC 连接失败排查速查表WebRTC 连接失败是最常见的问题原因五花八门。我整理了一张排查表按从易到难的顺序检查现象可能原因排查方法解决思路一直 connecting信令没通看信令服务日志检查 WebSocket 地址和房间号ICE 收集为空STUN 不可用打印 candidate 事件换 STUN 服务器或加 TURN连接后立即断开SDP 不匹配对比双方 SDP检查编解码和 DataChannel 配置局域网能连跨网不能NAT 穿透失败看 candidate 类型配置 TURN 中转连接成功但收不到消息DataChannel 未开检查 readyState等 onopen 再发数据我踩过最坑的一个问题是在setLocalDescription之前就发送了 offer。因为createOffer返回的 SDP 在setLocalDescription之后才生效顺序错了对方就收不到有效信息。这个 bug 排查了很久因为控制台不报错只是连接一直卡在 connecting。5.2 Shadow DOM 样式不生效的三种情况Shadow DOM 用起来爽但样式问题很隐蔽。我遇到过三种情况。第一种是Tailwind 类名不生效。原因是 Tailwind 的 JIT 编译只扫描了常规文件没扫描到 shadow root 里的动态类名。解决办法是在tailwind.config.js的content里把相关文件都加上或者用 safelist 显式列出动态类名。第二种是继承样式丢失。Shadow DOM 默认不继承外部样式但有些属性如color、font-family是继承的。如果发现字体不对检查是不是宿主页面的字体没传进来。我的做法是在 shadow root 里显式设置基础字体。第三种是伪元素和动画失效。某些 CSS 特性在 shadow root 里的行为和普通 DOM 略有不同尤其是::before、::after配合动态内容时。遇到问题优先用真实元素替代伪元素。5.3 游戏性能优化的几个实操技巧网页小游戏的性能瓶颈通常在渲染和网络。我总结了几个实测有效的技巧。渲染上避免每帧创建对象。比如碰撞检测时不要每帧map出新数组复用已有对象。垃圾回收的停顿在游戏里很致命表现为周期性卡顿。我用 Chrome 的 Performance 面板抓过优化后 GC 停顿从每几秒一次降到几乎不可见。网络上压缩消息体积。JSON 虽然方便但冗余大。我把高频的位置数据改成二进制ArrayBuffer传输体积减少了 60% 以上。对于小游戏来说这个优化在弱网环境下体感明显。还有一个容易被忽略的点requestAnimationFrame在页面不可见时会暂停。这本来是好事省电但如果你的游戏逻辑依赖时间切回来时会跳帧。前面游戏循环里的Math.min(delta, 250)就是解决这个的。5.4 关于 P2P 搜索与连接发现的补充说明有读者可能会问P2P 游戏怎么让玩家找到彼此这其实是个独立于 WebRTC 的问题。WebRTC 解决的是已知对方存在怎么连而怎么知道对方存在是发现层的事。我的做法很简单房间号 信令服务。玩家创建一个房间得到一个房间号分享给朋友朋友输入房间号加入。信令服务只负责把同一房间号的两个人撮合到一起不参与后续通信。这套机制简单可靠适合小规模对战。如果你想要更复杂的匹配比如随机匹配陌生人那就需要一个匹配服务这又回到了需要服务器的老问题。所以我的建议是小游戏就用房间号模式别过度设计。等真的有了用户量再考虑匹配系统也不迟。6. 我在这个项目里踩过的坑和真实体会做 OmniGame 这段时间最大的体会是P2P 不是没有服务器而是把服务器的职责降到最低。信令服务虽然简单但它不可或缺STUN/TURN 虽然免费或便宜但配置起来也有门槛。真正零服务器的 P2P 在网页环境里几乎不存在能做的只是把服务器成本压到极低。另一个体会是零依赖不等于零复杂度。砍掉引擎和网络库之后代码量确实少了但你需要自己对每一个细节负责。碰撞检测的边界情况、输入的事件顺序、网络的状态机这些原本由库处理的东西现在都得自己想清楚。好处是你对系统的理解深了一个层次坏处是开发速度确实慢。Shadow DOM 这块我的建议是能用就用但别硬上。如果你的游戏不需要嵌入第三方页面那用不用 Shadow DOM 都行。但如果你的目标是让游戏能嵌到任何地方那 Shadow DOM 是目前最优雅的方案没有之一。最后说个实际的测试一定要用真机、真网络。我在本地开发时一切正常部署到线上后跨运营商的连接成功率明显下降。后来加了 TURN 兜底才稳定。本地环境太理想了测不出真实问题。这个教训值不少钱希望你别重复踩。后续我打算把状态同步从主机权威升级成帧同步 回滚这样能支持更多玩家同时在线也能减少主机玩家的优势。不过那是另一个大工程了等做出来再分享。
返回列表