我最近把网页小游戏合集项目 OmniGame 从零重构了一遍,从立项第一天起就给自己上了两条硬约束:运行时零依赖,联机能走 WebRTC P2P 就绝不绕服务器中转。先把这俩约束摆出来,是因为网页小游戏这个品类太容易走上“为了省事堆依赖”的邪路了。第三方库越堆越多,加载越来越慢,联机部分一上来就买服务器、开 WebSocket 网关,结果一个二十兆以内的游戏项目,光工程骨架就好几公斤重。
这篇内容就是我整理出来的 OmniGame 技术白皮书,核心想回答三个问题:零依赖到底省掉了什么、省掉了这些之后拿什么顶上;WebRTC P2P 在网页小游戏里能干什么、怎么从握手到传输完整落地;以及这套方案的真实上限在哪,哪些场景该用、哪些场景别硬用。全文没有什么高深理论,全是工程取舍和实操记录,适合所有想做网页小游戏联机、或者对零依赖和 P2P 感兴趣的前端开发者参考。
我自己做下来的结论是:这两条约束不是自虐,而是把网页小游戏带回了它本来的位置——一个链接、一个浏览器、打开就能玩。下面直接进主题。
1. 零依赖不是玄学,是网页小游戏的工程选择
1.1 为什么我先把第三方库清零
网页小游戏有一个天然优势:不需要下载安装,打开链接就能玩。但这个优势非常脆弱,加载速度一旦慢下来,用户随手就关掉了。第三方依赖是加载速度的最大杀手,一个动画辅助库、一个状态管理工具、一套 UI 组件,即便做了 gzip 和 tree-shaking,几张页面加起来往往就吃掉了几十甚至上百 KB。而我的整个游戏逻辑核心,压缩之后可能才四五十 KB。为了一个库再背几十 KB 的运行时,这个账怎么算都不划算。
零依赖省掉的远不止体积。构建依赖串联起来之后,每次升级都可能引入行为差异,跨浏览器兼容问题也会从“我自己写的代码”变成“这个库在某个内核版本上表现异常”。调试的时候你要先分清是哪一层在出错,修 bug 变成排查供应链问题。更现实的是安全审计成本:你引用了二十个包,就要信任二十个维护者和他们各自的传递依赖,这对一个想长期运营的网页小游戏项目来说,是一笔隐藏但持续的负债。
我清零依赖之后,整个项目的运行时栈变成了纯浏览器原生 API:DOM 操作用原生接口,动画用 requestAnimationFrame 自己调度,渲染用 Canvas 2D 直接画,音效用 WebAudio 合成,存档用 IndexedDB。有人会觉得这是在重复造轮子,但实际并不是。现代浏览器原生 API 的能力已经相当完善,游戏所需的基础设施,绝大多数场景下有原生方案兜底,真正需要自己补的只是薄薄的一层封装。
1.2 从技术清单看“零依赖”怎么落地
零依赖不代表“所有东西从汇编开始写”,而是“选对原生默认值,不为可有可无的便利买单”。我在 OmniGame 里做了一张替换表,每遇到一个需求,先看浏览器给了什么,再决定要不要动手写。
| 需求场景 | 原生方案 | 落地说明 |
|---|---|---|
| 界面与场景渲染 | DOM + Canvas 2D | 2D 小游戏用 Canvas 足够,像素级控制比引擎抽象更直接 |
| 动画与循环调度 | requestAnimationFrame | 自带屏幕刷新同步,配合 dt 做插值和预测 |
| 音频播放与合成 | WebAudio API | 音效可以直接用振荡器合成,连音频文件体积都省了 |
| 网络与联机 | WebSocket + WebRTC | 信令用 WebSocket,游戏数据走 WebRTC DataChannel |
| 本地持久化 | IndexedDB / localStorage | 存档直接用原生存储,不引任何封装库 |
| 模块组织 | 原生 ES Modules | 浏览器原生支持 import,开发期不需要打包器也能跑 |
这张表看起来是个技术选型清单,本质上是约束我自己的冲动:不要把“想省事”当成“必须引入依赖”的理由。只要原生方案能覆盖 90% 的场景,我就先不引库,真到了 10% 覆盖不到的地方,再针对性地手写一小段工具函数。
整个过程下来,单游戏页面从请求到可交互,慢速网络下也能压在两三秒内,本地开发不装 Node、不跑构建,一个静态服务器就能起项目。这种感觉很纯粹,甚至有点回到早期网页开发的味道。
1.3 零依赖的真正代价:想清楚边界
我不能把零依赖吹成万能药。这个方案也有硬成本,最现实的一点是:你需要对浏览器原生 API 有更细的了解。比如 Canvas 2D 的绘制性能瓶颈在状态切换和 drawcall 数量,如果不能像设计 DOM 结构一样去设计绘制批次,画面复杂度一上来就容易卡。再比如音效合成,虽然 WebAudio 完全能做出效果不错的声音,但你要理解振荡器、增益节点、包络这些概念,比直接丢一个 mp3 文件进去门槛高不少。
第二个代价是“生态红利消失”。遇到成熟库提供的复杂能力,比如物理引擎、骨骼动画、复杂寻路,自己手写的成本可能远超省下的加载时间。我的原则是:核心玩法相关的能力自己写,通用且复杂度极高的能力才考虑引入,而且尽量选体积小、无依赖、可内联的单文件方案。
所以说到底,零依赖是一条“默认值正确”的路线,不是“绝对正确”的教条。它确保了项目保持轻盈,但对开发者本人提出了更高的要求。把边界想清楚之后,这条路是值得走的。
2. 从“服务器中转”到 WebRTC P2P,我为什么换赛道
2.1 网页联机小游戏的延迟困局
做网页小游戏联机,绝大多数人会选择最稳妥的路线:跑一台服务器,前端通过 WebSocket 连上去,所有玩家消息都先到服务器,再由服务器广播回各客户端。这个模型实现简单,也好维护,但它有一个天然问题——延迟叠加。假设两个玩家一个在城市 A、一个在城市 B,消息要先从 A 上行到服务器(可能在中国中部或某个云厂商节点),再下行到 B。折腾下来,正常网络下也会多出几十毫秒甚至上百毫秒的开销。
对回合制或慢节奏休闲游戏来说,这点延迟无感。但动作类、反应类、派对类小游戏对延迟天生敏感,哪怕只是五十毫秒的抖动,玩家旋转镜头时能明显感觉到“肉”。WebRTC P2P 的价值就在这里:让数据直接在两个浏览器之间流动,不再绕行中心服务器。对等连接建立后,A 到 B 的数据路径往往比经过服务器中转少了三分之一甚至一半的往返距离。
这也是我把连入 OmniGame 的核心联机层从“服务器广播”改成 WebRTC P2P 的直接原因。注意,这里说的 P2P 仅指“浏览器之间点对点传输游戏数据”,不涉及任何资源索引和文件搜索场景,这是两码事,别混淆。
2.2 一条 DataChannel 从握手到传输的完整生命周期
WebRTC P2P 落地有个前提:虽然数据面是点对点的,但连接建立前仍然需要一次信令握手。因为两个浏览器互相不知道对方在哪,必须有一个人帮忙牵线。我的信令服务是一个极其轻量的 WebSocket 服务,只负责转发三种消息:offer、answer、ICE candidate。游戏流量一旦跑起来,信令服务基本就没事干了。
下面是我在 OmniGame 里实际使用的建连核心代码,TypeScript 写法,逻辑上可以直接参考:
function createGamePeer(role: "host" | "guest") { const peer = new RTCPeerConnection({ iceServers: [ { urls: "stun:stun.cloudflare.com:3478" }, { urls: "stun:stun.l.google.com:19302" }, ], }); const channel = role === "host" ? peer.createDataChannel("game", { ordered: true }) : null; peer.onicecandidate = (e) => { if (e.candidate) { signal.send({ type: "candidate", candidate: e.candidate.toJSON(), }); } }; if (channel) { channel.onopen = () => { transport.emit("open"); }; channel.onmessage = (e) => { transport.dispatch(e.data); }; } else { peer.ondatachannel = (e) => { const recv = e.channel; recv.onopen = () => transport.emit("open"); recv.onmessage = (e) => transport.dispatch(e.data); }; } return { peer, channel }; }建连流程从代码里能看个大概,我再把完整时序讲一遍。Host 创建 RTCPeerConnection 并 createDataChannel,然后 createOffer、setLocalDescription,再把产生的 SDP offer 通过信令发给 Guest。Guest 收到 offer 后 setRemoteDescription,createAnswer,setLocalDescription,把 answer 发回。与此同时,两边各自通过 onicecandidate 收集 ICE 候选,并异步地发给对方,对方收到后 addIceCandidate。当两端的 ICE 协商完成,DataChannel 就会触发 onopen,一条游戏数据的点到点通道就算通了。
这里有个很多人第一次做会搞混的点:SDP 交换要严格按“offer 先、answer 后”的顺序来,但 ICE candidate 的交换完全可以在 SDP 交换的过程中并行进行。所以我在信令处理里刻意做了异步队列:先保证 offer/answer 顺序,candidate 来了就先存下来,等 setRemoteDescription 完成后再统一 addIceCandidate,避免出现“candidate 到了但远端描述还没设置”的错误。
STUN 服务器解决的是公网地址发现问题,默认加的这两个公共 STUN 在大部分场景下够用。但 WebRTC 有一个残酷现实:不少网络环境(对称型 NAT、企业网、部分移动网络)只靠 STUN 打洞打不通,这时候就需要 TURN 服务器做中继兜底。TURN 本质上还是走服务器转发,所以它只是保底手段,不是优先路径。我的建议是先不加 TURN,通过日志统计直连成功率,如果确实有一成以上的连接走不通,再部署 TURN 也不迟。
2.3 可靠与不可靠:小游戏数据通道的传输策略
DataChannel 底层是 SCTP over DTLS,这个机制天然提供了两种传输风格:可靠有序通道和不可靠无序通道。默认不额外设置参数时,DataChannel 是可靠有序的,数据不会丢、顺序不会乱,类似 TCP 的行为,适合传递房间状态、玩家加入退出、结算数据这类不允许出错的协议消息。
但可靠有序是有代价的:一旦某条消息在网络中丢了,底层协议会重传,后续所有消息都要等它补上才能继续,这就是头部阻塞。在实时游戏里,这种阻塞会导致明显卡顿感。所以我给高频、可丢失的数据单独设计了不可靠通道:通过构造参数里的 maxRetransmits 或 maxPacketLifeTime 来限制重传次数或存活时间,超过阈值直接丢。位置同步、输入帧这类数据就适合走这里,因为玩家每秒钟都在产生大量新状态,丢一帧旧数据根本无所谓,等重传反而让画面更卡。
我在 OmniGame 里实际开了两条 DataChannel,一条可靠通道传控制类消息,一条不可靠通道传高频游戏状态。两条通道互相独立,不会因为一条通道的拥塞阻塞另一条。这就是为什么我在建连代码里把 createDataChannel 写成可配置的,而不是固定一个通道用到底。
做个简单对照:
| 数据类别 | 通道策略 | 原因 |
|---|---|---|
| 加入/退出房间、游戏开始、结算 | 可靠有序 | 任何一条丢失都会导致状态错乱 |
| 玩家操作输入、位置/朝向更新 | 不可靠+限时 | 高频增量数据,旧帧价值低,重传反而延迟 |
| 聊天消息、表情 | 可靠有序或不可靠均可 | 按交互成本取舍,我选可靠,聊天不用太频繁 |
| 关键事件(炸弹爆炸、道具拾取) | 可靠有序 | 这类事件需要所有玩家一致性看到 |
2.4 什么时候不要用 P2P
写这节是为了避免读者把 WebRTC P2P 当成银弹。我在 OmniGame 里敢大范围用 P2P,是因为项目定位就是 2 到 8 人的小房间派对游戏,每房间数据量低、人数少。一旦场景变化,P2P 的限制就暴露出来了。
第一个不适用场景是大型多人房间,比如 50 人同场。全连接网状拓扑的数据量是 O(n²) 级别增长,每加一个人,其他所有人的上行和下行都要增加一条通道,浏览器根本吃不消。第二个不适用场景是强服务端权威,比如严肃的竞技对局,需要服务器校验每个玩家操作来防作弊,P2P 天然做不到这一点。第三个是极端 NAT 环境,如果大量用户打洞失败被迫走 TURN,那我等于花钱买了一台“形式上是中继、本质是代理”的服务器,延迟和带宽成本比直接用 WebSocket 中转还高。
所以我的结论很明确:P2P 适合小规模、低数据量、对延迟敏感的玩法;如果游戏要做大规模并发、严格反作弊,或者用户网络环境太恶劣,就老老实实用服务器中转,不要为了炫技牺牲稳定性。边界清晰,工程才能走远。
3. OmniGame 的核心系统设计与落地实现
3.1 三层架构:Network / Room / Game
为了让 WebRTC 不污染游戏业务代码,我把 OmniGame 整个联机模块拆成了三层。网络层(Network)负责封装 RTCPeerConnection、DataChannel 的创建与生命周期管理,对外只暴露 send(type, payload) 和 on(type, handler) 这类语义接口。房间层(Room)负责玩家加入退出、角色分配、主机选举和消息路由,它会根据消息类型决定走可靠通道还是不可靠通道。游戏层(Game)只关心玩法本身,比如玩家按了什么键、角色当前位置在哪、得分多少,完全不感知底层是 P2P 还是服务器中转。
这个分层最大的好处是:如果某天某个玩法决定改用服务器权威,我只需要替换网络层的内部实现,Room 和 Game 几乎不用动。我在做 OmniGame 时反复提醒自己,WebRTC 的天花板之一就是“技术和业务耦合”,耦合一旦发生,后面每次调连接策略都要牵动游戏逻辑,那种改动是很痛苦的。
分层以后,联机消息在代码层面变得非常干净。比如玩家发送输入帧,Game 层只要调用网络接口发送一个对象,网络层负责序列化、加序号、塞进不可靠通道发出去。收消息时同理,网络层解包后按类型分发,Game 层只处理自己关心的事件。整个链路清晰,出问题也好定位。
3.2 消息帧协议怎么设计才不打架
P2P 通道里传递的是二进制或字符串数据,如果直接在 onmessage 里各传各的,项目一长就会变成一团乱麻。我从一开始就定义了一个统一的消息帧格式,所有 WebRTC 数据都走同一个序列化入口,协议头包含几个关键字段:opcode(消息类型)、seq(发送端递增序号)、roomId(房间标识)、tick(游戏逻辑帧号)、payload(具体数据负载,统一为 Uint8Array)。
单独把 seq 和 tick 摘出来讲一下。seq 是网络层的序号,用来做发送端去重和乱序检测,不需要所有消息都用,但对不可靠通道的场景很关键,接收端能通过 seq 判断某条状态是否过期。tick 是游戏层的帧号,比如我可以规定游戏每 50 毫秒一个逻辑帧,所有输入和状态的载体都打上 tick 标记,这样接收端可以根据 tick 做插值和回放,而不是依赖消息到达的墙钟时间。
interface GameMessage { opcode: number; roomId: string; seq: number; tick: number; payload: Uint8Array; } function encodeMessage(msg: GameMessage): ArrayBuffer { const header = new ArrayBuffer(12); const view = new DataView(header); view.setUint8(0, msg.opcode); view.setUint16(1, msg.seq); view.setUint32(3, msg.tick); return header; }实际项目中我用的是更紧凑的二进制协议,12 字节头 + 变长负载,gzip 后几乎不会产生额外开销。设计消息帧时还有一个小细节:一定不要往不可靠通道里塞超过单条 SCTP 消息合理尺寸的数据。DataChannel 虽然可以传大消息,但大消息发生分片重组时,在丢包场景下效率极差。我给自己定的经验值是单条消息尽量控制在 1KB 以内,游戏状态同步超过这个量就拆成多条增量消息,效果会好很多。
3.3 Host 掉线怎么办:迁移与重连
P2P 拓扑里有一个无法回避的问题:如果建连时选了一个 Host 负责集中接收和转发数据,这个 Host 掉线,整个房间就断了。我在 OmniGame 里为了避免这种情况,做了两层设计:优先采用“全连接网状拓扑”,即每个客户端与其他所有客户端都建立单独的 P2P 连接,没有中心节点,任何一个人掉线不影响其他人;同时保留定期心跳和 Host 迁移机制,保证像裁判、计分这类“权威角色”始终有人接替。
全连接在 2 到 8 人房间内完全可行,连接数是 n(n-1)/2,8 人时也就 28 条通道,现代浏览器轻松承载。真正需要 Host 迁移的场景集中在“共享游戏公共状态”上,比如房间主持人手里的随机种子、房间设置、比分台账。我的做法是:每个客户端都持有完整的房间状态副本,Host 只是最终写入者。当 Host 心跳超时后,存活玩家通过选举规则选出新 Host,规则简单可靠——存活玩家中 playerId 字典序最小者当选。
function chooseNewHost(players: Player[]) { const alive = players.filter((p) => p.connected); return alive.sort((a, b) => a.playerId.localeCompare(b.playerId))[0]; }选举完成后,新 Host 广播一条“状态快照”消息给所有客户端,其他客户端用快照覆盖本地状态,继续游戏,整个迁移过程控制在几百毫秒内,玩家体感上只是轻微卡顿一下。这类迁移动作在传统服务器模型里压根不需要考虑,但在 P2P 里必须做扎实,否则“掉线体验”会把前面所有延迟优化带来的好感全部清零。
4. 实测数据、兼容性矩阵与问题排查实录
4.1 我测的一组真实数据
所有纸上谈兵都该用数据验证。我在 OmniGame 联机模块开发完成后,分别在几种不同的网络场景下做了实测,样本不算大,但至少能说明趋势。测试方式是两台电脑、两个浏览器标签,通过同一链接进入同一房间,跑一局完整的派对小游戏,过程中统计建连耗时和数据通道延迟。
| 测试场景 | 建连成功率 | 建连中位耗时 | 数据通道延迟中位数 |
|---|---|---|---|
| 同一局域网 | 100%(20/20) | 0.6s | 3ms - 8ms |
| 同城跨运营商宽带 | 约 95%(19/20) | 1.2s | 15ms - 35ms |
| 跨省宽带直连 | 约 85%(17/20) | 1.8s | 35ms - 80ms |
| 直连失败走 TURN 中继 | 100%(配置后) | 2.5s | 60ms - 120ms |
这组数据有几个值得注意的点。直连成功率不是 100%,说明 STUN 打洞确实有天然上限;走 TURN 之后成功率上去了,但延迟明显涨了一档。对 OmniGame 这类派对型小游戏来说,只要不是每帧都必须 10ms 内同步的硬核格斗,六七十毫秒的延迟完全能接受。如果你做的是对延迟更敏感的游戏,设计时建议把“直连/中继”作为一个可观测指标,对不同玩家群体调整匹配策略,而不是一刀切。
4.2 兼容性矩阵:不同端怎么表现
WebRTC 虽然已经是浏览器标准,但各家的实现和默认行为还是有差异。我在 OmniGame 里做了一个兼容矩阵,方便上线前自检。
| 运行环境 | DataChannel 支持 | 实测表现 | 备注 |
|---|---|---|---|
| Chrome / Edge(桌面) | 完整 | 最稳,ICE 和 SCTP 表现优秀 | 首选用 Chrome 调试 |
| Firefox(桌面) | 完整 | 稳定,个别老版本 SDP 协商有差异 | 建议保持 Firefox 122+ |
| Safari(桌面 / iOS) | 完整 | iOS 14.3+ 可用性大幅提升 | 旧 WKWebView 要回避 |
| Android WebView / Chrome | 完整 | Android 高版本 WebView 表现良好 | 部分国产内核需要实机验证 |
| iOS WKWebView 内嵌 | 部分受限 | 老版本对 WebRTC 支持不完整 | 建议优先让用户跳 Safari |
踩过的坑里最典型的是 iOS 上的状况:同样是 WebView,系统版本不同,WebRTC 的行为差异很大。为了不让用户体验“门都进不去”,我在入口页面做了环境检测,如果识别到旧的 WKWebView 环境,就提示用户用系统浏览器打开,而不是在一个不可控的容器里硬跑。这个妥协成本很低,但能让用户流失率明显下降。
4.3 从问题到答案:常见故障速查表
做完整套联机系统,我整理了一份速查表,按网上遇到的求助高频问题分类。这里直接分享出来,基本覆盖了新手做 WebRTC 游戏联机时会碰到的九成问题。
| 问题现象 | 可能原因 | 处理建议 |
|---|---|---|
| 一直停在“连接中”,通道不 open | ICE 协商没完成,常见是 candidate 没交换 | 检查信令日志,确认两端都 addIceCandidate |
| 远端能收到 offer,但我这边没有 candidate | STUN 服务器不可达;或网络屏蔽 UDP | 换公共 STUN 地址,确认 UDP 35000+ 可用 |
| 直连偶尔成功偶尔失败 | 对端 NAT 类型属于对称型,打洞不稳定 | 部署 TURN 作为兜底,不要反复调 STUN |
| 消息偶发丢失,顺序错乱 | 走了不可靠通道,但业务假设它是可靠通道 | 检查消息类型和通道策略是否匹配 |
| 画面卡顿但网络延迟不高 | 数据量超过单通道吞吐,或消息体积过大触发分片 | 拆小消息、合并冗余增量、降低发送频率 |
| 多人房间延迟逐渐变差 | 网状拓扑带宽占用叠加,或某端上行带宽受限 | 控制房间人数;考虑转星型结构并做转发 |
| onicecandidate 收到 null 后通道仍未开 | 忽略了一个关键点:收到 null 只代表本地候选发完,不代表协商完成 | 检查对方是否已 setRemoteDescription 并完成 addIceCandidate |
| 移动端断线频繁 | 移动网络信号切换导致网络路径变化 | 开启 ICE 重启,实现自动重协商;必要时退化为 TURN |
| 房间内某个人掉线,所有人跟着卡 | 星型拓扑中 Host 走到瓶颈 | 改用网状拓扑,或实现 Host 迁移 |
速查表之外还有一个非常容易被忽略的细节:onicecandidate 收到 null 的时候,只代表本地收集结束,并不代表整个 ICE 协商结束。很多新手在这里误判“连接完成”,导致明明两边 SDP 都换完了,candidate 却只发了一半。正确的做法是始终以 DataChannel 的 onopen 或 peerConnectionState 变为 connected 作为成功标志,不要用“本地 candidate 收完了”来判断。
5. 工程上限在哪:哪些事该做,哪些事要止步
5.1 OmniGame 把上限推到哪一步
做完整套方案后,我对“重新定义网页小游戏的工程上限”这句话有了更具体的理解。这里说的上限,不是“能做 3A 大作”,而是把网页小游戏最核心的特性发挥到位:极快加载、极低门槛、延迟可控的多人体验。
零依赖重新定义了加载上限。一个 OmniGame 的单局玩法,HTML 加 CSS 加 JS 全部加起来可以压到几十 KB,意味着用户在 2G 网络下也能在几秒内开始玩。对比动辄几百 KB 起步的框架型项目,这个差距直接决定了用户愿不愿意等。WebRTC P2P 重新定义了联机成本上限。传统服务器中转模型里,一个房间的实时流量全部经过自己的服务器,一百个房间就可能让带宽账单起飞。P2P 模式下,数据流量全部在玩家之间流动,服务器只承担建连瞬间的信令负载,这是一笔非常明显的成本优化。
这两个上限叠加起来,OmniGame 可以做“复制链接发给朋友,打开即玩,自动进入同一房间”的完整体验。对派对类小游戏来说,这个体验闭环比任何广告投放都有效,因为传播路径短到几乎没有摩擦。
5.2 不适合的场景与明确边界
从工程师角度,我不建议所有网页游戏都抄这套方案。OmniGame 能成功,因为它精准匹配了 P2P 的优势区间。换一批需求,这套架构就会裂开。
第一类是 MMO 类型或大型多人在线。P2P 网状连接在 20 人以上就接近崩溃边缘,50 人以上基本不可行,这种场景必须由服务器进行区域管理和状态广播,而不是让每个人跟所有人都建立连接。第二类是严肃竞技对抗,比如竞技场射击、排位赛这类对防作弊有强要求的玩法,服务器权威结算绕不开,P2P 的“玩家直连”无法提供可信的胜负判定。第三类是超低延迟强同步类玩法,我指那种要求逻辑帧严格统一的格斗、音游对战,P2P 网络状况不受你控制,抖动稍微大一点,整个同步体验就崩了。
所以我在架构文档里明确写了一句:P2P 是工具,不是信仰。OmniGame 用它是因为它适合,其他项目要用,先做容量评估和风险分析,别因为“看起来很酷”就把不适合的架构硬套在玩法上。
5.3 后续规划:把网络层做成可复用资产
做完 OmniGame 联机模块后,我一直在想怎么让这套工程经验发挥更大价值。当前的方向是把 Network 层从项目里抽成独立的纯 ES Module 模块,保持零依赖特性,让其他想做网页联机小游戏的开发者可以直接 import 使用。模块会内置信令客户端、P2P 建连、DataChannel 可靠/不可靠通道管理、ICE 状态统计、Host 迁移等核心能力,同时提供一个 debug 面板来查看当前各个通道的状态。
另一个跟进方向是关注 WebTransport 这类更新的传输协议,它跟 WebRTC 并不互斥,反而能互补解决部分场景的中继和队头阻塞问题。但这不是为了追新,而是为了在“网页小游戏联机”这个目标下,让工具选择始终服务于体验上限。
把 OmniGame 完整跑通之后,我个人最有体感的经验是:信令服务只是开门钥匙,不是游戏的路。整个联机过程中最重的流量都在浏览器之间直接流动,信令端只需处理秒级建连时的少量请求,一台轻量服务器就够一百个房间同时建连使用,成本压力几乎可以忽略。另一条经验是 TURN 服务器千万别急着部署,先通过日志统计直连成功率,很多项目实际直连率在七成以上,为剩下三成用户直接买带宽总觉得亏得慌。
OmniGame 的下一步,是把这套 Transport 层整理成一份可以直接 import 的零依赖 ES Module,让其他想做网页联机小游戏的人不用再把 WebRTC 的坑重新踩一遍,把精力花在游戏机制和体验上。这次从零依赖到 P2P 的完整走位,也让我重新理解了网页小游戏该有的工程形态:轻到极致,快得自然,玩法本身才是最有分量的东西。