你有没有遇到过这样的情况:朋友往群里甩了个游戏链接,你点开,转圈三秒,白屏,再刷新,资源又一次从 CDN 加载。于是你想,一个网页小游戏而已,凭什么要这么重?
这个痛点我太熟悉了。做 OmniGame 的初衷,就是想把“网页小游戏”这件事从头到尾重新捋一遍:它到底能不能做到零依赖、秒开、断网能玩、复制一个 HTML 文件就能跑?等我把这条路走到尽头,发现真正的天花板根本不在加载速度上,而在实时互动上——当你想让两个人、甚至八个人在一个网页里同屏对战,靠传统服务器转发那一套,成本和复杂度会迅速失控。这才是 OmniGame 技术白皮书真正想回答的问题:如何用零依赖的极致轻量,加上 WebRTC P2P 的原生点对点能力,把网页小游戏的工程上限拉到真正能打的高度。
这篇文章不是产品宣传软文,而是我实际踩坑、重构、验证后的技术复盘。所有思路都围绕一个核心:网页小游戏的工程化,能不能抛弃 Node 依赖、抛弃服务器中转、抛弃安装流程,依然做到好玩、能联机、可维护。适合独立开发者、小游戏团队,以及所有对 WebRTC 实时架构感兴趣的 Web 工程师参考。
1. 从零依赖到 WebRTC P2P:为什么要把网页游戏的工程上限顶上去
1.1 零依赖不是偷懒,而是对“分发链路”重新做减法
很多人一听“零依赖”,第一反应是“不就是图省事吗”。真不是。零依赖是一种非常刻意的工程约束,它逼你把所有资源都变成代码的一部分,逼你直面一个灵魂拷问:你的游戏到底有没有资格留在用户设备里。
我最初做单文件小游戏时,给自己的硬性要求是:一个 .html 文件,浏览器打开就能玩。这意味着图片素材不能外链、音频不能外链、字体不能外链、框架不能外链。所有美术资源要么程序化生成,要么用极小的 base64 内联,逻辑全部用原生 JavaScript 手写。这个约束带来的好处是立竿见影的:加载只需要解析一个文件,离线双击就能玩,丢到微信、飞书、Discord 的聊天窗口里也永远不会出现“CDN 过期导致白屏”这种问题。
后来我把这套思路命名为“零依赖分发链路”,核心是把“网络依赖”从分发的每一个环节里剔除。普通网页游戏的分发链路是:用户点击链接 → DNS 解析 → 服务器响应 → 加载 HTML → 再加载 JS/CSS/图片 → 初始化框架 → 启动游戏。OmniGame 的分发链路是:用户拿到文件 → 浏览器打开 → 启动游戏。加载时间和失败率都降到了几乎为零,这种东西用在实际社交裂变场景里,效果比任何性能优化都猛。
当然,零依赖也有代价:你不能用现成的游戏引擎,不能用 React/Vue 的响应式帮助管理状态,更不能指望 webpack 帮你处理模块依赖。所以 OmniGame 在工程上做了一个关键决策:开发时用现代工具链提升效率,发布时把产物压缩、内联成一个单文件。模块化是给开发者服务的,单文件是给用户服务的,两者并不冲突,关键在于构建脚本要做一次彻底的“资源收编”。
1.2 为什么做完零依赖之后,还要折腾 WebRTC P2P
单文件游戏做到极致之后,我很快就触碰到了一个新的工程上限:单人游戏。独乐乐可以没有服务器,但众乐乐怎么办?
最初我想的是传统方案:WebSocket 连接服务器,服务器做房间管理、消息转发、状态同步。这套方案成熟,但有一个绕不开的问题:带宽成本和服务器压力。网页小游戏的实时性要求非常高,比如射击游戏每秒要同步几十次位置和朝向,一个房间 8 个人,每个人每秒钟都要把自己的状态发给服务器,再由服务器广播给其他所有人。服务器转发带来的额外一跳延迟,在本地同城对战也许感觉不明显,一旦跨地域,那 50 到 100 毫秒的超额延迟会让操作明显变“肉”。
WebRTC P2P 的出发点恰恰是消灭中间人。浏览器原生支持 WebRTC,两个客户端可以直接建立 UDP 数据通道,没有服务器中转,延迟更低,带宽成本直接降为零(除了打洞失败时的 TURN 兜底流量)。这和网页游戏的结合有一个天然优势:WebRTC 是内置于 Chrome、Firefox、Safari 的标准能力,不需要用户安装任何软件,不需要下载插件,这和“零依赖”本质上是一回事——依赖浏览器原生能力,而不是依赖额外安装的软件。
我把 OmniGame 的联网设计成“零依赖优先,P2P 升级”:单人模式不联网,多人模式走 WebRTC 数据通道,信令阶段才短暂依赖一个轻量服务器。这样,单人场景保持了原来的零依赖优势,多人场景则获得了超低延迟和极低的服务器开销,两种模式在同一个代码库里共生,而不是互相妥协。
1.3 对比传统方案:为什么说 P2P 是网页小游戏的最优解之一
先别急着下结论,我换个视角对比一下四种可选方案,这样你能更清楚地理解 OmniGame 的技术选型逻辑。
| 方案 | 延迟 | 服务器成本 | 实现复杂度 | 浏览器兼容 | 离线可用 |
|---|---|---|---|---|---|
| 纯本地单机 | 无 | 无 | 低 | 最好 | 最好 |
| WebSocket 服务器中转 | 中高 | 随人数线性增长 | 中 | 很好 | 差 |
| WebRTC 数据通道(P2P) | 很低 | 仅信令 | 高 | 很好 | 中 |
| WebRTC + TURN 兜底 | 低 | 仅兜底流量 | 高 | 很好 | 中 |
这里多说一句 TURN。WebRTC 的 P2P 并不是百分百总能直连成功,当双方网络环境特别复杂(比如企业 NAT 双向对称)时,STUN 打洞可能会失败。此时 TURN 服务器作为中继,流量经过服务器转发,是保底方案。但好消息是:大多数家宽和 4G/5G 场景下,P2P 直连成功率都在八成以上,TURN 只是用来兜最后那 20% 的。
至于为什么不用成熟的游戏引擎自带的联机方案——那些方案很多依赖引擎的后端服务或商业 SDK,与 OmniGame“不引入外部依赖”的宗旨相悖。自己做一套基于 WebRTC 的薄封装,虽然初期成本高一点,但长期来看,代码在自己手里,行为可控,不欠技术债。
2. 零依赖单文件游戏的核心实现:怎么从零开始撑起一个完整小游戏
2.1 Canvas 渲染与主循环:动起来的第一步
单文件游戏的技术底座是 Canvas 2D 和 requestAnimationFrame。我见过很多初学者把主循环写在 setTimeout 里,那其实是一个常见的误区。requestAnimationFrame 是浏览器专门为动画设计的 API,它会在每次屏幕刷新前回调,保证帧率与显示器刷新率同步,避免撕裂与不必要的重复绘制。而在后台标签页里,requestAnimationFrame 会自动暂停,这恰恰是游戏“后台挂机不跑资源”的最优解。
OmniGame 的主循环被我拆成了三段式:输入采集 → 状态更新 → 渲染绘制。每一帧开始时统一读取键盘和鼠标状态,更新游戏世界中的所有实体(玩家的位置、子弹的飞行、敌人的 AI、碰撞检测),最后一口气把 Canvas 清空并绘制所有元素。这三步顺序固定,防止输入和渲染互相插队,出现“明明按键了但它不响应”的诡异问题。
这里有个提高画面流畅度的细节:Canvas 的尺寸要和 DPR(devicePixelRatio,设备像素比)对齐。如果你直接把 Canvas 设置成 CSS 像素 800x600,在 Retina 屏幕上会明显发糊。正确做法是先把 Canvas 内部宽高乘以 window.devicePixelRatio,再用 CSS 把它设置为原来的逻辑尺寸。我最初没注意这个细节,游戏在自己电脑上还行,一到同事的 MacBook 上就全是马赛克,还以为是代码渲染逻辑写错了。
我用一个约 50 行的最小示例来抽象说明核心循环的结构,你直接套到自己的游戏里也能跑:
const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); const DPR = window.devicePixelRatio || 1; canvas.width = 800 * DPR; canvas.height = 600 * DPR; canvas.style.width = '800px'; canvas.style.height = '600px'; ctx.scale(DPR, DPR); const state = { keys: new Set(), bullets: [], enemies: [], player: { x: 400, y: 300, hp: 100 }, lastTime: performance.now() }; function update(dt) { if (state.keys.has('ArrowLeft')) state.player.x -= 200 * dt; if (state.keys.has('ArrowRight')) state.player.x += 200 * dt; // 子弹移动、碰撞检测、敌人生成都放这里 } function render() { ctx.clearRect(0, 0, 800, 600); ctx.fillStyle = '#333'; ctx.fillRect(state.player.x - 20, state.player.y - 20, 40, 40); // 绘制所有子弹、敌人、HUD } function loop(t) { const dt = Math.min((t - state.lastTime) / 1000, 0.05); state.lastTime = t; update(dt); render(); requestAnimationFrame(loop); } requestAnimationFrame(loop);注意 dt 被钳制在 0.05 秒以内,这是为了防“切后台回来之后瞬间跳一大步”的问题。浏览器切后台再切回来时,requestAnimationFrame 的回调时间差可能达到几秒钟,如果不做钳制,游戏里的物体会直接瞬移出地图。这个细节虽然小,但没有它,你的游戏就会在最不该出错的时刻崩坏体验。
2.2 程序化资源生成:美术和音频怎么做到“零外部文件”
做零依赖游戏,最大的拦路虎不是逻辑,而是资源。你要画面好看、音效带感,但又不能把 png 和 mp3 塞进 HTML(虽然 base64 可以,但会让文件膨胀得非常难看)。这里我推荐的路线是走程序化资源:用极简几何体 + 颜色渐变来做视觉,用 Web Audio API 实时合成音效。
视觉方面,用 Canvas 绘制圆形、矩形、多边形,再叠加阴影和渐变,就能做出相当不错的像素风或扁平风效果。比如一个小飞机,完全可以由三个几何体组成:机身是一个圆角矩形,机翼是两个三角形,发动机尾焰是一个带透明度的渐变椭圆。配合一点点的粒子系统(每一个粒子就是一个带速度的圆形),视觉张力立刻就有了。这套思路尤其适合射击游戏、躲避游戏、音乐节奏游戏。
音频方面,Web Audio API 可以让你完全通过代码合成音效。射击音效本质上是一段极短的噪音或方波衰减;爆炸音效是低频正弦波的下滑;得分音效是几个不同频率的短音按顺序播放。我把这些封装成几个函数,比如 shoot()、explode(),内部创建 AudioContext 的 OscillatorNode 和 GainNode,再包一层不复杂的包络线控制音量衰减。这套做法做出来的音效,风格统一、体积为零,还顺便帮你省掉了给音频配 licensing 的麻烦。
程序化资源还有一个隐藏优势:它让你天然获得了“动态生成”能力。敌人颜色可以随关卡动态改,武器音效可以随角色状态变化,所有东西都是参数驱动的,改一改数值游戏风格就完全不同。对一个想要快速迭代原型的人来说,这比切图快得多。
2.3 空间管理与对象池:性能稳定性的两个隐藏功臣
零依赖游戏很容易陷入一个性能误区:“反正一个页面也就几百个元素,直接每帧全量遍历不就好了”。对,小规模场景下这样写没问题,但一旦子弹数量上来(比如弹幕类游戏),每帧遍历所有对象做碰撞检测,就会很快出现卡顿。原因是,两个循环嵌套的全量碰撞检测,复杂度是 O(n²)。
轻量级的解决方式有两个:对象池和空间网格。
对象池的意思是,子弹和敌人的对象不随地创建销毁,而是维护一个空闲列表。射击时从空闲列表里取一个对象,激活它;子弹飞出边界后,不是把对象从数组里删除,而是把它标记为“空闲”放回池子。这样做能大幅减少 JavaScript 的 GC(垃圾回收)压力,尤其适合子弹高频生成与销毁的射击类游戏。GC 停顿在 PC 上不明显,但在中低端移动设备上就是帧率杀手。
空间网格则是一个朴素而高效的空间索引方案。把整个游戏区域划分成固定大小的小格子,每个对象在移动时把自己的 ID 加入对应格子的对象列表里。碰撞检测时,只检查同一个格子里的对象对,而不是全图两两匹配。200 个对象全量检测是 19900 对,用空间网格后通常每帧只需要检测几十到几百对,性能和规模基本脱钩。
2.4 状态机与回调解耦:零依赖不等于零架构
零依赖最容易产生的负面倾向是:所有代码堆在一个 HTML 里,全局变量满天飞,最后变成一团乱麻。OmniGame 做了两个非常轻量但有效的架构约束:UI 状态机和逻辑事件总线。
状态机用来处理游戏流程。菜单、游戏中、暂停、结算、等待联机,这五种状态互斥,所有游戏循环只在“游戏中”状态更新实体;“暂停”状态只渲染不更新;“结算”状态只响应“再来一局”的输入。这样做可以把每个状态的复杂度隔离,不至于在菜单状态下误触发游戏逻辑。
事件总线则用来解耦逻辑模块。比如音效模块监听“SHOOT”事件,UI 模块监听“HP_CHANGE”事件,游戏逻辑本身不直接调用音效函数。这样做的好处是,当你后续引入 WebRTC 联机、要同步战斗事件时,只需要从事件总线里“偷听”并转发,而不需要改动原有逻辑代码。事件总线在零依赖场景下,可以是一个极简的发布订阅器,十几行代码就够用。
3. WebRTC P2P 联机方案落地:从信令服务器到数据通道
3.1 信令服务器:唯一一点“依赖性”怎么做到极简
WebRTC 建立连接之前,双方需要交换 SDP(会话描述协议)信息和 ICE 候选地址。这些交换内容本身是纯文本,但交换动作需要一个双方都能访问的通道,这就是信令服务器。OmniGame 的信令服务器设计原则是:只做一件事,房间号到连接状态的映射;不做业务逻辑,不转发游戏数据,不保存对局记录。
我用的是一个不到 200 行的 WebSocket 信令服务。客户端连接后发送 join 消息,附带一个房间号;服务器把同一个房间里的其他客户端描述返回给对方;之后双方各自创建 RTCPeerConnection,通过信令通道交换 SDP 和 ICE 候选。等 DataChannel 一打开,信令服务器就可以退居二线了,后续所有游戏数据不再经过它。
这里有一个容易被忽略的设计点:信令服务断开后不应影响已经建立的对等连接。也就是说,P2P 通道一旦打通,即使在游戏中途信令服务器挂掉,对局双方仍然可以通过已有的 DataChannel 继续通信。我专门做过这个测试:游戏进行到一半时直接 kill 掉信令服务,两台客户端毫无知觉地打完了整局。
对于需要快速验证的开发者,我会推荐先写一个极简的公共信令服务(比如跑在 Vercel 上的 Serverless WebSocket),或者直接用 webRTC 官方示例里的信令格式,先把连通性跑通。信令本身不要做得太重,后续完全可以替换成自己部署的服务,确保核心逻辑不受影响。
3.2 RTCPeerConnection 与 DataChannel 配置的真实用法
建立一条 P2P 连接,核心 API 不多:RTCPeerConnection 负责管理连接,createDataChannel 创建数据通道,onicecandidate 处理 ICE 候选,oniceconnectionstatechange 监听连接状态,ondatachannel 接收远程创建的数据通道。
OmniGame 用的 DataChannel 配置有讲究:ordered: false,maxRetransmits: 0。这意味着数据不保证顺序,且不重传。为什么这么配?因为游戏状态同步最在意的不是“每条消息都到达”,而是“最新的位置是不是最新”,如果用可靠且有序的模式(默认),一旦中间丢包,发送端会等待重传,后续数据全部排队,延迟急剧升高。关掉可靠性和顺序,就相当于用 UDP 的行为跑数据,丢包时立刻发下一个状态包,玩家感受到的是微妙的位置抖动,而不是整体卡顿。
我给出一个建立连接的最小结构,信令消息用 JSON 传输:
// A 端创建连接 const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); const dc = pc.createDataChannel('game', { ordered: false, maxRetransmits: 0 }); dc.onopen = () => console.log('DataChannel open'); dc.onmessage = (e) => handleRemoteMessage(JSON.parse(e.data)); pc.onicecandidate = (e) => { if (e.candidate) signal.send({ type: 'candidate', candidate: e.candidate }); }; pc.createOffer().then((offer) => pc.setLocalDescription(offer)) .then(() => signal.send({ type: 'offer', sdp: pc.localDescription })); // B 端接收 offer 后 await pc.setRemoteDescription(offer); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); signal.send({ type: 'answer', sdp: pc.localDescription }); pc.ondatachannel = (e) => { const dc = e.channel; dc.onmessage = (msg) => handleRemoteMessage(JSON.parse(msg.data)); };这不是完整的轮子,但骨架是对的。在实际项目里,你还需要处理 ICE 候选的批量转发、连接状态的超时重试、多房间人数限制等逻辑。记住一个原则:信令阶段可以慢(几十毫秒的差距不重要),DataChannel 阶段必须快(每一毫秒的延迟都直接影响手感)。
3.3 主机迁移与角色分工:谁来当权威
P2P 连接建立后,最大的架构挑战不是“能不能连”,而是“谁说了算”。两个人对战,一个人的画面说“我打中你了”,另一个的画面说“我躲开了”,到底信谁的?
OmniGame 的做法是“主机权威 + 客户端预测”。每个房间在首名玩家加入时被确定为“主机(Host)”,其他玩家为“客户端(Client)”。主机拥有所有实体状态的最终决定权,所有客户端把输入上报给主机,主机在每个状态帧周期内运行完整的游戏模拟,然后把权威状态广播给各客户端。客户端本地预测自己的输入结果以降低延迟,同时根据主机的权威状态做校正。
这里涉及到帧同步和状态同步的经典争论。帧同步要求所有客户端跑同一个逻辑帧,输入完全一致,这样计算出来的状态也一致;它带宽效率高,但是对网络抖动非常敏感,一个包丢了,所有客户端必须等待或者回滚。状态同步则是直接同步结果(位置、血量、朝向),对丢包容忍度高,实现也简单,更适合小团队快速迭代。OmniGame 选了状态同步,因为零依赖的单文件游戏没有能力也没有必要去实现复杂的帧同步回滚机制。
客户端预测和校正的实现要点是:客户端记录自己发出的输入序号和对应预测位置;主机广播状态时附带最后处理的输入序号;当客户端的预测和权威状态偏差超过阈值时,立刻用权威状态覆盖本地位置,并做一次简短的插值过渡,避免画面瞬移。这套机制在射击游戏里尤其重要:你开枪的瞬间如果还要等服务器点头才显示枪口火光,体验就全毁了。
4. 联机实时对战中的几个硬骨头:排查思路与实操避坑
4.1 ICE 连接失败:为什么有人能连上,有人永远连不上
做 P2P 联机,最常撞的墙就是:自己两台设备测试一切正常,室友一加入就失败,客户在另一个城市也连不上。这不一定是代码 bug,更多是 NAT 打洞失败的产物。
ICE 打洞的成功率取决于双方 NAT 类型。如果双方都在普通家宽路由器后面(锥形 NAT),STUN 打洞大概率成功;如果其中一方在企业防火墙后面、或者运营商网络做了对称 NAT,直连就会失败,这时候必须由 TURN 服务器中继。OmniGame 的处理方式是:配置多个 STUN/TURN 服务,ICE 候选会自然包含 host、srflx、relay 三层,浏览器会自己尝试所有组合,最终选一条可用路径。
排查 ICE 问题时,我几乎会在控制台打印每一个 onicecandidate 的类型和地址,然后用下面的列表快速定位:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 只有 host 候选 | 没有配置 STUN | 检查 iceServers 配置 |
| host + srflx,连不上 | NAT 类型对称或限制 | 需要配置 TURN 服务 |
| relay 候选出现但慢 | 直连失败走了中继 | 检查两端网络策略限制 |
| oniceconnectionstatechange 一直 checking | 信令转发不完整 | 打印 ICE 候选交换日志 |
这里有一个容易踩的坑:ICE 候选的交换时机。有些实现会在所有候选收集完毕后再一次性发送,如果收集过程持续几秒钟,双方可能早就超时了。正确做法是 onicecandidate 一回调就立刻通过信令通道转发给对方,让连接尽早“试跑”。候选可以后到,连接可以先成。
4.2 DataChannel 卡顿:为什么带宽够,游戏还是“一顿一顿”
有人会误以为 WebRTC 的传输质量一定比服务器转发好,真不一定。DataChannel 用的同样是网络链路,丢包、乱序、带宽饱和都会带来问题。尤其是 ordered: false 的模式下,如果你每一帧都发送全量状态(位置、朝向、血量、子弹列表),当玩家数量增加、实体数变多后,带宽会瞬间爆掉。
OmniGame 的压缩策略是“差分同步”:只发送变化的部分。玩家位置如果和上一帧没差太多,就把它压缩成相对偏移(用 16 位整数存 dx/dy);血量没变化就不发;子弹只在生成和销毁时各发一条元数据,而不是每一帧都全量遍历。这样一来,一个 8 人对局的带宽占用可以从每秒几百 KB 降到几十 KB。
另一个关键点是“状态同步频率要和渲染帧率解耦”。你的游戏可能是 60 FPS 渲染,但状态同步不需要 60 次每秒。通常 10 到 20 Hz 的状态同步已经完全够用,剩下的帧间变化用本地插值补足。想想看,服务器或者主机的逻辑帧率是 20 Hz,广播间隔 50 毫秒一次,客户端在两次广播之间根据上一帧状态和最新状态做线性插值,人眼几乎感知不到区别。
DataChannel 的背压(backpressure)也值得提一下。如果你用 dc.send() 发送数据的速度太快,而接收端处理不过来,内部缓冲会持续增长,延迟随之拉高。OmniGame 里做了一个简单的背压控制:检查 dc.bufferedAmount,超过阈值后暂缓生成下一帧状态,优先把旧的、已经落后的状态清掉。
4.3 实时同步中的抖动与延迟补偿:代码层面的三个实用招
即使有了 P2P 低延迟,网络抖动依然存在。我分享一下 OmniGame 里实际用到的三个延迟补偿招数,它们都属于“只要几十行代码就能见效”的类型。
第一招是时间戳插值。每个状态包带上主机端的逻辑时间戳,客户端根据当前时间与包时间戳的差值,选择最近的快照进行插值渲染。前提是客户端和主机的时间基准要尽量接近,最简单的做法是对局开始时做一次 ping 测量,计算单程延迟,然后用“主机时间戳 + 单程延迟”作为本地期望时间。
第二招是延迟自适应。客户端保留最近 20 个状态包的到达间隔,实时估算当前网络抖动,动态调整本地缓冲区的等待时间。如果网络突然变卡,就多等一下再插值,避免画面疯狂回退。
第三招是预测与回滚。客户端的本地预测如果持续被权威状态纠正,说明预测误差偏大,这时可以给预测算法增加一个“惯性因子”:预测位移偏向上一帧,而不是完全跟随最新输入。这会降低操作灵敏度,但换来明显更少的回弹感。对动作游戏来说,玩家的感受是“操作偏顺滑”,而不是“位置总被拉回去”。
5. OmniGame 的工程化闭环:构建、部署与我的实测心得
5.1 构建流程:开发时模块化,发布时单文件,两手都硬
零依赖是发布时的形态,不等于开发时也要把所有代码塞进一个 HTML。OmniGame 的源码是一个标准的前端工程,用 ES Modules 组织,目录大致是:core(游戏循环、事件总线)、render(Canvas 绘制、粒子系统)、audio(Web Audio 合成)、net(WebRTC封装、信令客户端)、games(具体游戏逻辑)。构建脚本做三件事:打包成一个 JS 文件、把所有代码内联进 HTML 模板、压缩混淆。
构建时最需要注意的是:别让 tree-shaking 把你的“副作用模块”当垃圾摇掉。Web Audio 合成模块和 canvas 渲染模块经常被静态分析误判为“未使用”,因为它们只向事件总线注册回调、没有显式导出被引用的函数。我的解决方案是,在构建配置里把 net、audio、render 模块声明为 sideEffects 保留,或者统一走一个 plugin 入口,确保每个模块都被引入。
构建产物还应该做“作品指纹”:把游戏版本号、构建时间、Git 短哈希写进一个全局变量。这在联机调试时极其有用——如果主机是 1.2.0、客户端是 1.2.1,很多时候不是代码逻辑 bug,而是版本不一致导致的状态结构错位。我在写了版本校验后的第一周,就靠这个字段定位了三个原本以为是“玄学”的断连问题。
5.2 部署与分发:从单文件到 P2P 对局的三种形态
OmniGame 的部署形态有三种,对应不同使用场景。
第一种是离线单文件:直接把 HTML 分发到任意环境,适合个人电脑存储、U 盘拷贝、局域网共享。这种形态继承零依赖的全部优点,断网也能玩,也是你手里的“最后一版保底包”。
第二种是静态网页托管:把单文件放到任意对象存储或者静态托管平台上(GitHub Pages、Cloudflare Pages 都可以),用户通过 URL 访问,加载一次之后天然走浏览器缓存。这种形态适合线上分享和社交裂变。
第三种是持续在线联机版:静态页面 + 一个极轻量的信令服务。信令服务可以用平台的 Serverless Functions 承载,也可以在小型虚拟机上跑。这个形态下,页面本身仍然是零依赖的,用户感知不到任何安装、登录、插件流程。
值得一提是,我不会把信令服务做成高可用集群。P2P 对局的健壮性来自对等连接,信令服务只是一个“握手引导”,它的可用性要求远低于传统游戏服务器。即使某个时段信令服务不可用,已有连接的对局也不会受影响,只是不能再开新房间而已。
5.3 实测经验:零依赖与 P2P 结合后,我踩过的最后三个坑
做完整套工程,我想把三个真实踩过的坑记录下来,它们都不是“看文档就能发现”的问题。
第一个坑是移动端浏览器的 WebRTC 兼容性差异。桌面 Chrome 和 Firefox 的 DataChannel 行为基本一致,但 iOS Safari 在某些版本里对 DataChannel 的消息类型支持有限,强制要求 ArrayBuffer,不支持字符串。我在消息包装层加了一个自动转换:发送时统一转成 ArrayBuffer,接收时按字节流解析,彻底绕开这个兼容地狱。
第二个坑是 Canvas 在移动设备上的“省电模式”降频。部分安卓手机会在后台一段时间后自动降低屏幕刷新率,导致 requestAnimationFrame 变成 30 FPS,游戏整体变慢。这不是网络问题,而是设备问题,不能通过代码强制改变,我只能选择尽量让游戏在 30 FPS 下也能可玩,所有动画都要做 time-based(基于时间差)而非 frame-based(基于帧计数)。
第三个坑是“P2P 成功 ≠ 延迟低”。WebRTC 会优先选择 host 候选(即局域网或本机回环),但当你和朋友跨地域联机时,iceCandidatePair 选择的可能是一个延迟 100ms 的 relay 路径,即使直连候选也能用。后来我增加了对 ICE candidate pair 的延迟探测:选路完成后发一个 RTT 探测包,如果延迟超过阈值且存在更优候选,就尝试切换。这个逻辑在真实网络里不一定总能成功,但至少让问题可视化。
我的体会是,做 OmniGame 最大的收获不是“我用了 WebRTC”或者“我是零依赖”,而是建立了一套完全可复用的网页游戏工程范式。它让我明白了,工程上限从来不是一个单点指标,而是分发、性能、实时性、可维护性四个维度的合力。如果你也想做类似的事,我建议从最小闭环开始:先做一个单文件对战游戏,再加 P2P 联机,最后再考虑信令和压缩。每一层都稳扎稳打做过一遍,你对“网页游戏到底能有多强”的感知才会真实。