ARTICLE DETAIL

资讯详情

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

OmniGame:零依赖纯静态部署的WebRTC P2P网页小游戏联机实战

OmniGame:零依赖纯静态部署的WebRTC P2P网页小游戏联机实战 1. 为什么我要做 OmniGame 这个项目先说说背景。我在前端和游戏工程这个圈子里摸爬滚打十来年做过不少网页小游戏也接过不少“把现有游戏搬到浏览器里”的活儿。每次遇到最头疼的问题几乎都一样依赖链太重、部署太麻烦、联机太贵。一个简单的双人对战小游戏为了联机要搭 WebSocket 服务器要维护房间状态要处理断线重连服务器一挂全完蛋。更别提那些第三方 SDK 和运行时依赖装完之后 node_modules 比游戏本身还大。OmniGame 就是在这个背景下折腾出来的。它的核心目标很明确让网页小游戏做到零运行时依赖、纯静态部署、联机走 WebRTC P2P 直连。换句话说你打开一个静态托管的页面两个玩家就能直接对战中间不需要任何游戏服务器参与逻辑运算。听起来像是老生常谈但真正落地的时候坑比想象中多得多。这篇文章我会把整个项目的设计思路、技术选型、核心实现、踩过的坑全部摊开讲。适合谁看如果你正在做网页小游戏、对 WebRTC 联机感兴趣、或者单纯想看看一个“零依赖”的工程到底能抠到什么程度那这篇应该对你有用。我会尽量用大白话把原理讲清楚同时给出可以直接抄的代码和配置。2. 整体架构设计与技术选型思路2.1 为什么是“零依赖”而不是“少依赖”很多人第一反应是零依赖是不是有点极端用个游戏引擎不好吗我的答案是看场景。如果你做的是 3A 级网页游戏那当然该用成熟引擎。但 OmniGame 瞄准的是轻量级、即开即玩、可嵌入的小游戏场景——比如一个页面里嵌个小游戏当互动彩蛋或者一个教学演示里放个可操作的小 demo。这种场景下依赖就是负担。我给自己定了几条硬规矩运行时不允许有任何第三方 JS 库游戏逻辑、渲染、网络全部手写或用浏览器原生 API构建产物必须是纯静态文件扔到任何静态托管上就能跑联机不依赖自建信令服务器之外的后端逻辑游戏状态完全在客户端之间同步为什么敢这么定因为现代浏览器原生能力已经足够强了。Canvas 2D、Web Audio、WebRTC DataChannel、Shadow DOM这些 API 组合起来能覆盖绝大多数小游戏需求。用框架反而是在给自己加抽象层。2.2 渲染层Shadow DOM 隔离 Canvas 双轨渲染这块我做了个双轨设计。UI 层用 Shadow DOM 做样式隔离游戏画面用 Canvas 渲染。为什么要分开因为小游戏经常需要嵌入到别人的页面里。如果直接用全局样式宿主页面的 CSS 分分钟把你的按钮样式冲掉。Shadow DOM 的attachShadow({ mode: closed })能把整个 UI 树封在一个独立的样式作用域里外面的样式进不来里面的也出不去。实测下来嵌入到任何页面都不会出现样式污染。Canvas 负责游戏主画面因为小游戏的绘制频率高用 DOM 操作性能扛不住。Canvas 2D 在大多数设备上跑 60fps 的小游戏绰绰有余而且 API 简单直接不需要引入 WebGL 那套复杂度。2.3 联机层WebRTC DataChannel 做 P2P联机是整个项目最核心也最难的部分。传统方案是 WebSocket 走服务器中转但这样服务器要承担所有流量和状态同步成本高、延迟也高。WebRTC 的 DataChannel 允许两个浏览器直接建立点对点连接数据不走服务器延迟能压到最低。但 WebRTC 有个绕不开的问题建立连接需要信令交换。两个浏览器要先交换 SDP会话描述和 ICE candidate网络候选地址才能协商出连接。这个交换过程必须有个中间人来传递这就是信令服务器。注意信令服务器只负责“牵线”不参与游戏数据传输所以它的负载极低一个最简陋的 WebSocket 服务甚至静态轮询都能胜任。我选的是极简信令 全 P2P 数据的架构。信令服务器只做房间管理和消息转发游戏逻辑和状态同步全部在客户端之间完成。这样服务器成本几乎可以忽略而且玩家越多服务器压力不会线性增长。2.4 构建层Next.js 只用来做静态导出构建工具我用了 Next.js但用法很克制——只用它的静态导出功能。next export能把整个项目编译成纯 HTML/JS/CSS没有任何服务端运行时。为什么不用 Vite 或者纯手写因为 Next.js 的路由和代码分割开箱即用对于多页面小游戏集合的场景比较省事。但要注意用了 Next.js 就千万别碰它的 SSR 和 API Routes否则就破坏了“纯静态”这个前提。这里有个关键配置next.config.js里必须设置output: export同时关掉图片优化因为静态导出不支持服务端图片处理// next.config.js /** type {import(next).NextConfig} */ const nextConfig { output: export, images: { unoptimized: true }, trailingSlash: true, }; module.exports nextConfig;trailingSlash: true是为了让导出的静态文件路径更规范避免某些静态托管平台的路由问题。3. WebRTC P2P 联机的核心实现细节3.1 信令交换的完整流程拆解WebRTC 建连的过程说白了就是两个陌生人要通过一个中间人互相交换“联系方式”。具体分几步玩家 A 加入房间信令服务器分配一个房间 IDA 创建 RTCPeerConnection 并生成 offerA 把 offer 通过信令服务器发给房间里的其他玩家玩家 B 收到 offer创建自己的 RTCPeerConnection设置 remote description然后生成 answerB 把 answer 发回给 AA 设置 remote description双方通过 ICE 框架收集自己的网络候选地址互相交换候选地址匹配成功后DataChannel 打开P2P 连接建立这个过程听起来线性实际上 ICE candidate 的交换是异步的可能在 offer/answer 之前、之中、之后任何时间点发生。所以代码里必须处理好“候选地址先到、描述后到”的情况把它们暂存起来等描述设置好再添加。3.2 DataChannel 的配置与可靠性取舍DataChannel 有两种模式可靠有序和不可靠无序。游戏联机要根据数据类型分别选择。玩家操作指令比如“向左移动”“发射子弹”必须可靠有序丢了就出 bug实时位置同步可以用不可靠模式丢一帧无所谓下一帧就补上了配置代码大概长这样const channel peerConnection.createDataChannel(game, { ordered: true, maxRetransmits: 3, });maxRetransmits: 3表示最多重传 3 次超过就放弃。这个值是我实测调出来的——设太小容易丢关键指令设太大在网络差的时候会堆积延迟。3 次是个比较平衡的点。3.3 NAT 穿透的现实与降级策略这里必须说句实话WebRTC 的 P2P 直连不是 100% 能成功的。在复杂的网络环境下比如双方都在严格的企业防火墙后面UDP 打洞可能失败。这时候就需要 STUN/TURN 服务器辅助。STUN 服务器帮客户端发现自己公网地址TURN 服务器则在直连失败时做流量中转。我的策略是默认配置公共 STUN 服务器如果直连失败降级到 TURN 中转如果 TURN 也连不上最后降级到信令服务器做 WebSocket 中转这个三级降级保证了“最差也能玩”虽然延迟会高一些但至少不会直接连不上。降级逻辑要写在连接状态监听里iceConnectionState变成failed或disconnected时触发。3.4 状态同步帧同步还是状态同步小游戏联机有两种主流方案帧同步和状态同步。帧同步是每个客户端只发送操作指令所有客户端用相同的逻辑推演游戏状态。优点是流量极小缺点是要求所有客户端逻辑完全一致浮点数运算的微小差异都可能导致状态分叉。状态同步是主机权威模式一个玩家作为 host定期广播完整游戏状态。优点是逻辑简单、不会分叉缺点是流量大、host 有优势。OmniGame 默认用主机权威的状态同步因为小游戏玩家数量少通常 2-4 人流量压力不大而且实现简单不容易出 bug。对于操作指令这种小数据走可靠通道对于位置这种高频数据走不可靠通道并做插值平滑。4. 从零搭建 OmniGame 的完整实操流程4.1 项目初始化与目录结构先建项目。用 Next.js 的静态导出模式起步npx create-next-applatest omnigame --typescript --app cd omnigame目录结构我建议这样组织omnigame/ ├── app/ # Next.js 页面路由 │ ├── page.tsx # 游戏列表页 │ └── play/[id]/page.tsx # 游戏运行页 ├── core/ # 零依赖核心引擎 │ ├── renderer.ts # Canvas 渲染器 │ ├── loop.ts # 游戏主循环 │ ├── input.ts # 输入管理 │ └── shadow-ui.ts # Shadow DOM UI 封装 ├── net/ # 网络层 │ ├── signaling.ts # 信令客户端 │ ├── peer.ts # WebRTC 封装 │ └── sync.ts # 状态同步 ├── games/ # 具体游戏实现 │ └── pong/ │ ├── logic.ts │ └── render.ts └── public/ # 静态资源core和net目录下的代码完全不依赖任何第三方库纯 TypeScript 手写。games目录下每个游戏只关心自己的逻辑和渲染复用核心引擎。4.2 游戏主循环的实现要点游戏主循环用requestAnimationFrame驱动但要注意固定时间步长的问题。如果直接用 rAF 的时间差做物理计算不同刷新率的设备上游戏速度会不一样。我的做法是固定逻辑步长比如 60Hz渲染帧率跟随显示器const FIXED_STEP 1000 / 60; let accumulator 0; let lastTime performance.now(); function loop(now: number) { const delta now - lastTime; lastTime now; accumulator delta; while (accumulator FIXED_STEP) { update(FIXED_STEP); accumulator - FIXED_STEP; } render(accumulator / FIXED_STEP); requestAnimationFrame(loop); }render接收一个插值因子用来在两次逻辑更新之间做画面平滑。这个模式在联机游戏里尤其重要因为网络同步的数据到达时间不均匀插值能让画面看起来流畅。4.3 Shadow DOM UI 的封装方法Shadow DOM 的封装我写了个小工具函数export function createShadowHost(styles: string) { const host document.createElement(div); const shadow host.attachShadow({ mode: closed }); const styleEl document.createElement(style); styleEl.textContent styles; shadow.appendChild(styleEl); return { host, shadow }; }用mode: closed是为了彻底隔离外部拿不到 shadow root 的引用。UI 元素全部创建在 shadow 内部样式写在传入的styles字符串里。实测下来这套方案嵌入到任何宿主页面都不会有样式冲突宿主页面的全局 CSS reset 也影响不到内部。4.4 信令服务器的极简实现信令服务器我用 Node.js 的ws库写了个最简版本核心逻辑就是房间管理和消息转发const rooms new Map(); wss.on(connection, (ws) { ws.on(message, (raw) { const msg JSON.parse(raw); if (msg.type join) { if (!rooms.has(msg.room)) rooms.set(msg.room, new Set()); rooms.get(msg.room).add(ws); ws.roomId msg.room; } else { const peers rooms.get(ws.roomId) || []; for (const peer of peers) { if (peer ! ws peer.readyState 1) { peer.send(raw); } } } }); ws.on(close, () { const peers rooms.get(ws.roomId); if (peers) { peers.delete(ws); if (peers.size 0) rooms.delete(ws.roomId); } }); });就这么点代码。它不解析游戏消息不维护游戏状态只做转发。部署成本极低一个 1 核 1G 的小机器能扛几千个房间。4.5 参数选择与性能调优记录几个关键参数我调了很久记录一下参数取值调整理由逻辑步长16.67ms60Hz兼顾精度和性能DataChannel maxRetransmits3平衡丢包和延迟状态同步频率20Hz位置数据再高没必要插值缓冲100ms平滑网络抖动ICE 超时5s超过就降级状态同步频率设 20Hz 是因为人眼对位置变化的感知有限20Hz 配合插值已经足够流畅。设太高反而增加带宽和 CPU 负担。5. 常见问题排查与避坑经验实录5.1 WebRTC 连接失败的排查思路连接失败是最常见的问题排查要按顺序来先看信令是否正常offer/answer 有没有成功交换再看 ICE candidate 有没有收集到iceGatheringState是否变成complete然后看iceConnectionState如果是failed多半是 NAT 穿透失败最后检查防火墙和浏览器权限我遇到最多的情况是信令消息顺序错乱。因为 WebSocket 是异步的offer 和 candidate 可能乱序到达。解决办法是在 peer 端维护一个待处理队列等 remote description 设置好之后再统一添加缓存的 candidate。5.2 状态不同步的典型原因状态不同步通常有三个原因浮点数精度差异不同 CPU 架构的浮点运算结果可能有微小差异长时间累积会分叉。解决办法是主机权威模式客户端只发操作不发状态。消息丢失不可靠通道丢包导致状态更新丢失。解决办法是定期发送完整状态快照做校正。时钟不同步各客户端本地时间不一致。解决办法是用主机时间戳做基准客户端做偏移校正。5.3 性能问题的定位方法小游戏性能问题一般出在渲染和网络两块。渲染用 Chrome DevTools 的 Performance 面板看帧率网络用chrome://webrtc-internals看 DataChannel 的吞吐和延迟。我踩过的一个坑是每帧创建新对象导致 GC 频繁。游戏循环里如果每帧都new一堆临时对象垃圾回收会周期性卡顿。解决办法是对象池把常用的向量、矩形等对象复用起来。5.4 常见问题速查表现象可能原因解决方向连接一直 pending信令未交换检查信令服务器日志连接建立后立即断开ICE 失败配置 TURN 服务器画面卡顿GC 频繁引入对象池操作延迟高走了 TURN 中转检查网络环境状态分叉客户端逻辑不一致改主机权威模式样式被污染未用 Shadow DOM封装 UI 到 shadow5.5 几个我踩过的坑第一个坑是在 Shadow DOM 里用 Canvas 的尺寸问题。Shadow DOM 内的元素getBoundingClientRect返回的是相对于 shadow root 的坐标如果直接用来设置 Canvas 尺寸会出错。解决办法是用host.getBoundingClientRect()拿宿主元素的尺寸。第二个坑是Next.js 静态导出后路由 404。因为静态导出生成的是play/pong/index.html这种结构某些托管平台需要配置 rewrite 规则。加trailingSlash: true能解决大部分情况。第三个坑是DataChannel 的 buffer 溢出。如果发送速度超过网络吞吐bufferedAmount会持续增长最终导致消息堆积。解决办法是发送前检查bufferedAmount超过阈值就跳过这一帧的同步。6. 这套架构还能怎么扩展OmniGame 目前的实现覆盖了核心的渲染、输入、联机三块。后续可以扩展的方向不少比如加入回放系统——因为所有操作指令都走可靠通道录下来就能完整回放。再比如观战模式观战者作为只读 peer 加入接收状态同步但不发送操作。还有一个我觉得很有意思的方向是AI 对手。因为游戏逻辑是纯函数式的把 AI 逻辑跑在 Web Worker 里通过同样的 DataChannel 接口和主线程通信就能实现一个“本地 AI 玩家”而且代码结构和联机玩家完全一致。网络层这块如果要做房间匹配可以在信令服务器上加一个简单的匹配队列。因为信令服务器本来就在转发消息加个队列逻辑成本很低。最后分享一个我在实际项目里验证过的小技巧把游戏逻辑写成纯函数输入是状态和操作输出是新状态。这样逻辑层完全不碰 DOM 和网络测试起来极其方便而且天然支持回放、AI、联机三种模式复用同一套逻辑。这个设计决策在项目后期帮我省了大量重构时间。
返回列表