
在做 OmniGame 这个项目时我最想搞清楚的是一个网页小游戏从被人看到这一眼到真正上手去玩中间到底可以隔着几道门槛。答案是可以做到一道都不隔。OmniGame 的技术白皮书里前半段是一套零依赖的网页小游戏工程方案——不装客户端、不引框架、不依赖任何第三方库和构建产物一个链接打开就能玩后半段是给这套框架接上 WebRTC P2P让原本单机的体验变成浏览器到浏览器的点对点联机。这两个词组合起来其实是在重新定义网页小游戏的工程上限能用浏览器原生能力做到的事就没必要让用户为“多一层依赖”买单。这篇文章就来完整复盘一下这条路线怎么推演、怎么落地以及中途踩过哪些坑适合所有想用手写前端做小游戏、或者想低成本给网页游戏加联机能力的朋友。1. 零依赖才是入口网页小游戏的“无负担”玩法设计1.1 链接即游戏mikutap、奶蛙们验证过的低门槛逻辑你可能已经注意到一个现象隔一阵子朋友圈就会出现一款刷屏级的网页小游戏。不管是 mikutap 这种音游、奶蛙那种带魔性节奏的点击游戏还是“夜间飞行”这种用 HTML CSS JS 做的小飞机它们的传播逻辑几乎一模一样发过去一个链接打开就是游戏没有安装包没有注册流程连加载进度条都短得让人注意不到。这就是零依赖在产品层面的胜利。用户看到链接之后大脑里完成“要不要玩”这个决策的速度会直接决定这个游戏能不能在聊天窗口里活过半分钟。如果中途冒出“下载App”“安装插件”“注册账号”任何一个字眼这场传播就已经死了。所以零依赖并不仅仅是一个前端工程上的洁癖它首先是一种面向用户的承诺你只需要一个现代浏览器剩下的事情我来搞定。OmniGame 在这个问题上比别人更较真。立项的时候我把“零依赖”写成了硬性指标不引入任何运行时框架不需要构建步骤源码拿出来就能跑产物就是一个或者最多两个静态文件。这个指标带来的效果是实打实的——玩家从打开链接到看到主菜单体感上是瞬间完成而不是盯着进度条等那几秒钟。这几秒钟在单机场景里无所谓但在社交传播场景里它就是留存率的分水岭。1.2 零依赖的开发者视角源码即产物没有供应链焦虑很多人听到“零依赖”的第一反应是“那代码得多难写”。我的真实体验恰恰相反零依赖让开发过程变得更轻松了。没有 node_modules没有版本锁文件没有构建脚本没有“昨天还能跑今天一更新就报错”的供应链魔咒。整个 OmniGame 单机版核心就是几个纯 JavaScript 文件浏览器一打开就能调改一行代码刷一下页面效果立刻可见。这种“源码即产物”的开发节奏对小型游戏这种项目来说幸福指数非常高。更深一层的价值是维护性。你可以想象一下一个依赖了十几个 npm 包的小游戏项目五年之后再打开是什么样子——大概率是脚手架都建不起来了。但一个零依赖项目五年后打开就是一段干净利落的 JavaScript哪怕浏览器版本迭代了几轮只要 Canvas、Web Audio 这些标准 API 还在它就能继续跑。对小游戏这种体量的项目来说这种“不会腐烂”的底子比任何花哨的架构都值钱。不过这里要澄清一个概念零依赖指的是运行时零依赖而不是开发期不能用工具。我后来为了让体积更小也用过 terser 之类的压缩器但那只是发布阶段的可选项不是项目的运行时组成部分。真正的原则是用户浏览器里跑的每一行代码都要物有所值不能让框架运行时、工具链产物这种吃资源但不产出玩法的东西混进来。1.3 原生浏览器能力远比你想象的多Canvas、Web Audio、手柄一个不少零依赖不代表裸奔。现代浏览器的原生能力其实是网页小游戏最被低估的“弹药库”。做 2D 画面有 Canvas 2D做音频有 Web Audio API 的实时合成做输入有 Pointer Events、Touch Events、Gamepad API做动效有 requestAnimationFrame 的精准帧回调。这一整套东西全部是浏览器内置能力不需要你 import 任何东西就能撑起一个完整的小游戏体验。很多人在做网页游戏时条件反射地去搜引擎、搜框架但我建议你先把原生 API 的边界摸清楚。尤其是音频这块Web Audio 的 OscillatorNode 可以现场合成各种音效根本不需要加载音频文件mikutap 那种让人停不下来的反馈感就是靠即时合成音效实现的。这听起来像是“为了零依赖而零依赖”实际不是——不加载音频文件意味着加载体积继续变小首屏速度继续变快这对小游戏来说就是核心竞争力。而在联机这个方向上原生浏览器能力还有一个王炸就是 WebRTC。它让浏览器之间可以直接建立点对点数据通道不需要游戏业务服务器在中间转发每一个消息。零依赖加 WebRTC P2P本质上就是在说我不光要把单机体验做得轻还要把联机体验也做得尽量轻。这就引出了整个项目最关键的技术选型问题。2. 网页联机方案选型WebRTC P2P 凭什么上位2.1 三条路摆在一起传统服务器、WebSocket 房间、P2P 直连给网页小游戏加联机功能传统上有三条路。第一条是经典的游戏服务器模式跑一个权威服务器所有玩家把操作发给它服务器统一计算再广播结果。这是大型网游的标准做法但对小游戏来说过于沉重了——服务器成本不说光是把状态同步、反作弊、掉线重连这一套做完就已经远超小游戏本身的开发量。第二条是用 WebSocket 做房间中继服务器不计算游戏逻辑只负责把消息转发给房里其他玩家。这个方案实现起来比权威服务器简单很多很多休闲联机游戏都在用。但它有一个非常现实的问题所有玩家的操作都要经过你的服务器中转房间人数一多带宽消耗就直线上升。一个只有几十个人的小游戏火了服务器带宽账单可能会先把你吓退。第三条就是 WebRTC P2P。浏览器与浏览器之间直接建立数据通道打洞成功之后游戏里的每一个操作消息都不再经过业务服务器直接从你的电脑发到朋友的电脑。作为开发者你只需要一个极简的信令服务来帮双方完成最初的握手握手结束之后它就可以退到一边喝茶了。这个架构对小规模私密房间来说成本结构非常友好。方案服务器成本延迟开发复杂度适合体量权威服务器高随在线人数增长中玩家-服务器-玩家很高要写状态同步和反作弊大型多人在线WebSocket 中继中带宽随游戏消息量增长中服务器转发中消息编解码即可中小型房间WebRTC P2P极低只需极小信令服务低数据面直连中高NAT 与信令细节多2-8 人私密房间2.2 DataChannel 的工作原理SDP、ICE 与 NAT 打洞WebRTC 底层是怎么做到浏览器直连的这要从一次完整的建连过程说起。第一步双方各自创建一个 RTCPeerConnection 对象然后由发起方生成一个 offer里面包含这端的 SDP 信息——说白了就是“我支持哪些能力、我在哪、我想怎么传”。这个 offer 不能直接发给对方需要一个中间人转交这个中间人就是信令服务。对方拿到 offer 之后生成一个 answer 回传同样是走信令服务。到这里SDP 交换完毕双方已经知道“对方确实在网络上”。但光知道还不够因为双方都在 NAT 后面各自拿到的通常是内网地址直接使用内网地址根本找不到对方。于是 WebRTC 要在后台偷偷做一件很神奇的事通过 STUN 服务器告诉浏览器“你在公网上的映射地址是什么”然后双方把各自的公网映射地址作为 ICE candidate通过信令服务交换给对方。交换完 candidate浏览器就开始“打洞”。可以把它理解成两个人在不同楼里互相找窗户双方各自试着往对方报出来的地址发起连接能打通就直接通话打不通就退而求其次用 TURN 服务器做中继转发。一旦连接建立媒体通道和数据通道就都通了。这里面的数据通道就是 RTCDataChannel底层走的是 SCTP over DTLS默认情况下有序且可靠跟 TCP 类似但也可以通过参数调成“部分可靠”模式后面讲同步策略的时候我会细说。整个过程听起来复杂但好消息是浏览器把底层都封装好了。对开发者来说核心任务其实只有一个把 SDP 和 ICE candidate 通过你设计的信令通道准确地送到对方手里。这两份东西送对了连接就成了一半。2.3 为什么小游戏最适合 P2P小房间、低延迟、低成本WebRTC 不是万能的它适合的场景非常明确小规模房间、低延迟交互、不想为服务器带宽持续烧钱。刚好网页小游戏的联机需求就长这样。一个房间少则两个人多则八个人连全连接拓扑也就是每个端跟另外七个端各建一条通道数量完全可控。延迟上P2P 连接成功后一个消息从玩家 A 的浏览器到玩家 B 的浏览器路径就是双方之间的网络路由不再绕道服务器。实际体验上在同一城市的两台机器之间数据通道的延迟通常在几十毫秒以内对于派对类、反应类小游戏来说已经非常够用。对比 WebSocket 中继模式每一跳都要多一个服务器往返P2P 相当于直接把传输路径缩短了一截。成本结构上的优势更直接。传统的游戏服务器或 WebSocket 中继玩家在线时长直接等于你的带宽账单但 P2P 模式下数据面流量完全走玩家之间的网络你的信令服务只是在建连时轻量介入一下之后几乎零成本。这意味着 OmniGame 可以放心地把联机功能开放给所有玩家而不需要担心某个晚上突然来了一千个玩家把服务器打爆。说白了P2P 让小游戏的联机门槛从“要养服务器”降到了“只需要一个几十行的握手服务”这个账怎么算都是划算的。3. 零依赖单机核心模块是怎么落地的3.1 目录结构与主循环一个 HTML 加一个 JS 就是全部工程OmniGame 单机版的目录结构简单到让人怀疑是不是走错了片场一个 index.html 加一个 game.js。没有 src 目录没有构建配置没有资源文件夹。如果你愿意把 CSS 和 JS 全部内联进 HTML那就是一个单文件游戏拷到 U 盘里都能直接双机开玩。核心驱动的代码非常传统就是一个标准的 requestAnimationFrame 主循环let lastTime performance.now(); function frame(now) { // dt 是两帧之间的间隔单位秒 const dt Math.min((now - lastTime) / 1000, 1 / 20); lastTime now; update(dt); // 更新游戏逻辑 render(); // 渲染画面 requestAnimationFrame(frame); } requestAnimationFrame(frame);这里有一个细节很多人会忽略dt 要做一个上限钳制也就是上面代码里的Math.min(..., 1/20)。为什么要这么做因为当浏览器切到后台再切回来时now - lastTime可能会是好几秒如果用这个真实间隔去更新游戏逻辑角色会瞬间瞬移一段距离碰撞检测很容易直接穿模。钳制到 1/20 秒意思是即使中间卡了十秒游戏也只会按“只过去了 50 毫秒”去模拟玩家的观感是流畅的逻辑也不会崩坏。至于状态管理小游戏真的不需要引入任何状态管理库。我用的就是一个简单的gameState对象里面放玩家坐标、实体数组、全局参数update 阶段改数据render 阶段画数据。小游戏的状态量就那么一点强行上响应式框架反而是在给自己制造心智负担。3.2 输入层键盘、触摸、手柄统一成一样的动作很多网页小游戏死在操作适配不完整上PC 上只能用键盘手机上只能点屏幕手柄玩家直接劝退。OmniGame 从第一版开始就把三种输入统一抽象成了一套 action 体系。键盘的 keydown、keyup触摸的 touchstart、touchmove、touchend还有 Gamepad API 的按钮轮询全部映射到left、right、jump、fire这几个逻辑动作上。实现上其实不复杂核心就是做一个输入管理器对外只暴露“当前哪个动作处于按下状态”的查询接口。比如跳跃不管你是按了空格、点了屏幕还是按了手柄的 A 键最终都会走同一个函数去触发 jump 事件。这样游戏的玩法逻辑只跟“跳跃”这个动作打交道完全不关心物理输入源是什么。移动端适配有几个坑需要提前踩。第一触摸事件要用{ passive: false }监听然后主动preventDefault()阻止浏览器默认的滚动和缩放行为否则手指在屏幕上滑动的时候页面会跟着滚。第二要设置meta nameviewport contentwidthdevice-width, initial-scale1, user-scalableno禁止用户双击缩放否则游戏过程中频繁误触放大体验会非常糟。第三iOS 上点击事件会有几百毫秒的延迟直接用 touchstart 或者 Pointer Events不要跟 click 死磕。3.3 音效不引用任何音频文件用 Web Audio 现场合成零依赖的音效怎么做答案是让浏览器现场“演奏”。Web Audio API 提供了 OscillatorNode、GainNode 这些基础积木可以组合出方波、锯齿波等各种合成音效。一个跳跃音效、一个得分音效、一个爆炸音效本质上就是不同频率和包络的组合。我封装了一个十来行的函数就够用了function blip(freq 440, duration 0.1) { const ctx sharedCtx(); const osc ctx.createOscillator(); const gain ctx.createGain(); osc.frequency.value freq; osc.type square; gain.gain.setValueAtTime(0.2, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime duration); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime duration); }这段代码的核心思路是振荡器负责产生声音增益节点负责控制音量在极短时间内衰减到接近零听起来就是一个短促的“哔”声。通过调整频率和时长你可以组合出完全不同的效果反馈。mikutap 那种上手就停不下来的魔性本质上就是把这种即时合成音效做到了极致每一次点击都有对应的声音反馈玩家会有很强的“我在操控音乐”的快感。这里有一个 Web Audio 的大坑浏览器不会允许页面在用户没有任何交互的情况下自动播放声音。尤其 iOS 上的 SafariAudioContext 在用户点击屏幕之前会一直处于 suspended 状态。解决办法是注册一个一次性的用户手势监听在用户第一次点击屏幕时调用ctx.resume()。这个细节我后来在联机版里又踩了一次后面踩坑实录会单独讲。4. 联机模块实战从房间码到 DataChannel 的全部细节4.1 极简信令服务几十行代码只干“握手”这一件事P2P 建连必须有一个信令服务它就是双方交换 SDP 和 ICE candidate 的邮局。OmniGame 的信令服务设计原则是“能省则省”不做认证不做存储不介入游戏逻辑唯一要做的就是把一个玩家发来的消息推送到同一房间的其他玩家面前。具体流程是这样的。玩家 A 点击“创建房间”信令服务分配一个四位房间码玩家 B 输入房间码发出“加入”请求服务把 B 的加入请求转给 AA 收到后开始创建 offer之后 A 和 B 之间所有的 SDP 交换、ICE candidate 交换全部走“A 发一条服务转发给 BB 发一条服务转发给 A”这个循环。等 DataChannel 的 open 事件一旦触发信令服务就可以彻底休息了后续所有游戏消息都走 P2P 通道。用 Node.js 配合ws库实现这样一个服务核心不到一百行。就是维护一个 Map 来记录房间号和 WebSocket 连接的关系收到消息之后查一下目标房间里的其他连接然后转发。这里我要强调一下这个服务虽然小但它是一个常驻服务没法像纯静态页面那样白嫖静态托管。只是它的负载极低因为每次游戏会话只在建连阶段才使用它所以一个跑在小机器上的常驻进程就能支撑几万个房间的握手请求成本可以忽略不计。4.2 offer、answer 与 ICE 交换浏览器直连是怎么建立的具体的建连代码其实比想象中要直白。创建 RTCPeerConnection 时需要配置 ICE 服务器最基础的就是公共 STUN 服务用来帮浏览器发现公网映射地址const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.l.google.com:19302 } ] }); const dc pc.createDataChannel(game, { ordered: true, negotiated: true, id: 0 }); dc.onopen () { console.log(P2P通道已建立); startGame(); }; dc.binaryType arraybuffer;这里有个小细节值得说明negotiated: true配合id: 0表示这个 DataChannel 不是由一方主动创建、另一方被动接收而是双方约定好“我们用的就是这个 id 为 0 的通道”。这样在创建 offer 之前两端就已经有了相同的 channel 配置可以少处理一套“channel 是何时被对端创建”的事件逻辑对写代码来说更省心。offer 和 answer 的交换逻辑也很固定。发起方创建 offer 后先把本地描述设置好再通过信令发给对方接收方拿到 offer设置远程描述创建 answer再发回去。在描述交换的整个过程中双方还要不断通过pc.onicecandidate事件收集 ICE candidate并实时发给对方pc.onicecandidate (event) { if (event.candidate) { signaling.send({ type: ice, candidate: event.candidate }); } };我第一版实现时偷懒只等icegatheringstate变成 complete 后把候选一次性发过去。实测下来遇到复杂网络环境就容易失败因为有些候选生成得慢等它全部收集完对端可能已经因为没有候选而超时了。正确做法是像上面这样只要有新 candidate 就赶紧发过去让两端随时都有最新的候选可用。4.3 帧同步、状态同步、操作同步哪种策略更适合 OmniGameP2P 通道建立之后紧接着的问题就是游戏消息怎么同步业界常见的三种策略分别是帧同步、状态同步和操作同步它们各有各的适用场景。策略同步内容优点缺点帧同步广播每一帧的输入序列带宽低逻辑一致要求所有端确定性模拟排错困难状态同步定期广播实体位置/属性实现简单容错好带宽消耗大延迟感明显操作同步只广播离散动作事件实现轻适合事件型玩法连续运动类玩法没法直接套用帧同步是格斗游戏和 RTS 的经典方案但它有一个硬性前提所有客户端必须用完全一致的逻辑去模拟同一帧的输入任何浮点数计算差异都会导致“世界分叉”。在小游戏这种开发节奏很快的项目里确定性模拟的调试成本相当高。状态同步的缺点是每秒钟都要不停发位置、血量这些高频变化的数据带宽消耗大而且因为是“事后同步”玩家看到的其他人总是带一点延迟。操作同步则适合 OmniGame 这种以点击、跳跃、摆放为主要玩法的小游戏——玩家按下跳跃键这个动作本身是离散的不需要连续广播坐标只需要把这个操作事件发到对端对端在自己的本地逻辑里也执行一次跳跃即可。OmniGame 最后选的是混合策略玩家是连续移动的由本地预测直接控制保证手感跟单机一样灵敏同时把跳跃、攻击、状态变化这类离散事件通过 DataChannel 广播出去。这样既避免了帧同步的高实现门槛又比纯状态同步省流量对派对类小游戏可以说是性价比最高的方案。5. 联机工程细节延迟、断线、兼容性的处理方式5.1 房间生命周期玩家加入、掉线、迟到怎么处理一个稳定的联机游戏不能只处理“大家同时进入游戏”这种完美剧本。玩家中途退出、网络闪断、有人打到一半才加入这些情况必须在设计阶段就想好。OmniGame 的房间模型把玩家生命周期分成了几个阶段加入中、游戏中、掉线中、已退出。加入阶段的处理相对简单。房主创建房间后会维护一个玩家列表新人加入时房主会把当前房间的快照也就是“现在有哪些玩家、各自是什么状态、游戏进行到哪个阶段”打包发给新人。如果游戏已经开始新人可以选择观战也可以直接加入成为下局玩家具体看玩法怎么设计。掉线检测靠的是心跳机制。DataChannel 建立后双方每五秒互相发一个极小的 ping 包收到 pong 就认为对方还活着。连续三次没有回应就判定对方掉线。这里有个细节不要一掉就立刻从房间里把人踢掉因为网络抖动可能只是几秒的事。OmniGame 的做法是给对方保留 15 秒的“重连窗口期”窗口期内如果 P2P 连接能重新建立就把对方放回原来的位置超时未归才广播“玩家已离开”。同时P2P 模式下如果房主掉线整个房间就失去了“核心”我目前的做法是直接结束本局并提示其他玩家重新建房这也算是一个可以接受的取舍。5.2 把延迟“藏”起来预测、插值与二进制消息即使 P2P 延迟很低网络抖动还是会存在。玩家的视觉体验不能直接跟原始网络延迟挂钩否则稍有波动游戏画面就会一卡一卡的。我的做法是用三层手段把延迟藏起来。第一层是本地预测。自己的玩家角色不做网络同步按键按下立刻响应这一层保证玩家自己的操作手感跟单机完全一致。第二层是远端插值。对于其他玩家的位置不要收到一帧就硬切到新坐标而是维护上一帧坐标和当前目标坐标在渲染时用线性插值平滑过渡这样对方的移动看起来是连贯的而不是一顿一顿跳过去的。第三层是接收缓冲。在接收端设置一个约 80 毫秒的缓冲区故意让消息先攒一攒再应用以换取消除抖动后的平滑效果。80 毫秒这个值是我实测下来比较合适的太小了缓冲不了抖动太大了又会增加操作反馈的延迟感。消息格式上我强烈建议尽早从 JSON 切到二进制。JSON 在调试期很方便但一次“跳跃”事件带上键名可能就上百字节频率一高带宽就很吃亏。OmniGame 里我用了定长的ArrayBuffer配合DataView读写function encodeAction(action) { const buf new ArrayBuffer(12); const view new DataView(buf); view.setUint8(0, 0xAA); // 消息类型标识 view.setUint16(1, action.seq); // 序号用于去重排序 view.setUint8(3, action.type); // 动作类型 view.setFloat64(4, action.t); // 时间戳 return buf; }12 字节和一个动辄两三百字节的 JSON 字符串相比节省的不只是每一条消息的带宽更是解析开销和 GC 压力。联机游戏跑起来每秒可能有一二十条消息省下来的这些资源都能转化成更流畅的画面。5.3 浏览器兼容性清单哪些机型和环境最容易出问题WebRTC 虽然已经是现代浏览器的标配但兼容性的细节依然能让你怀疑人生。我把 OmniGame 实测中遇到过的环境问题整理了一下按常见程度排序环境常见问题处理方式iOS SafariAudioContext 初始为 suspended用户首次触摸时调用 resume()旧版 iOS SafariDataChannel 偶发需要更长时间握手加长建连超时时间不要 10 秒就放弃安卓微信内置浏览器部分系统版本缺 WebRTC 接口检测RTCPeerConnection是否存在并给提示FirefoxSDP 格式与 Chrome 有细微差异不手动修改 SDP让浏览器原生生成Chrome公共 STUN 偶发超时配置两三个 STUN 服务提高成功率未知环境用户扩展或隐私策略会禁用 WebRTC检测失败时建议换用系统浏览器并提供单人模式兜底这里要多说一句WebRTC 的建连是有超时机制的不同网络环境下握手时间差异很大。OmniGame 把建连超时设置在 20 秒期限内如果有任意一方持续收到 ICE candidate就继续等待超过 20 秒还没有 DataChannel 的 open 事件才判定建连失败并提示用户检查网络。最开始我只给 10 秒结果好几个正常的玩家在稍慢的网络下被误判为失败后来放宽到 20 秒误判率明显下降。6. 发布与传播把零依赖变成传播势能6.1 体积控制为什么 P2P 方案没有把包体撑大有人可能会担心给零依赖项目加上 WebRTC 联机包体是不是就膨胀了其实不会。WebRTC 是浏览器原生能力它不是你要加载的 JavaScript 库所以无论你用它还是不用它你的包体体积都不增加。对比一下如果你用 WebSocket 库做联机至少要多加载几十 KB 的第三方代码如果用某些重型的游戏框架几百 KB 起步。而 OmniGame 的方案里联机功能所需的 RTCPeerConnection、RTCDataChannel 这些对象全部是浏览器环境里已经存在的 API我们写进代码的只是业务逻辑那一小层调用。我一直把体积当做一个需要持续维护的指标来对待。实话说一个网页小游戏 Gzip 之后如果能压到 30-50 KB加载体验就已经非常快了OmniGame 的整体做得更狠一点哪怕加上联机模块也尽量控制在几十 KB 内。实操上控制体积的方法无非那几条避免引入任何运行时库、常用的辅助函数自己封装精简版本、命名压缩交给 terser、用 Gzip/Brotli 静态压缩。零依赖项目做压缩特别顺手因为没有第三方的代码混在里面压缩器可以肆无忌惮地做变量名缩短和死代码清除。6.2 部署链路静态托管、短链、二维码零依赖项目在部署上最大的优势就是“哪都能放”。GitHub Pages、Vercel、Netlify、Cloudflare Pages 甚至对象存储桶只要能托管静态文件你就多一个发布渠道。游戏本身只有一两个文件放到任何静态托管上都能直接跑完全不需要服务器处理能力也几乎不存在被访问量打崩的风险。唯一的例外是那几十行的信令服务它需要一个常驻进程。但它负载极低随便找一台小机器就能跑把它跟静态托管分开部署完全没有问题。我的做法是信令服务单独部署在一个极小的进程里页面里配置它的地址游戏静态文件放静态托管。考虑到信令服务只处理握手消息既不存储游戏状态也不转发游戏数据它的可用性压力非常小几个月的运行经验下来基本没有出过问题。分享链路的搭建也值得花心思。一个短链接域名配上自动生成的二维码游戏就能从网页链接轻松转移到线下场景——桌游派对上扫码加入朋友聚会里一起对战。零依赖在这里又发挥了一次作用玩家扫码后不需要跳转 App 商店不需要犹豫要不要下载浏览器直接就打开了这让“扫码即玩”从一个营销口号变成了真实落地的体验。配合二维码生成工具把这些流程串起来传播链路就完整了。6.3 踩坑实录五个典型问题每个都让游戏晚发布一天做 OmniGame 的过程中有几个问题卡得我印象深刻这里统一做成速查表遇到类似问题可以少走弯路。DataChannel 一直进不了 open 状态。最典型的被杀原因是没有完整转发 ICE candidate。排查思路是先确认 offer 和 answer 是否都成功 setLocalDescription 和 setRemoteDescription然后在两端控制台分别打印收到的 candidate 数量看看是不是某一端始终收不到任何候选。如果两端都能收到候选但还连不上再检查防火墙和网络环境必要时配置 TURN 做中继兜底。双人对战时帧率骤降但 CPU 占用不高。这是典型的渲染分辨率问题。不少高分屏手机的 CSS 像素是 1但 devicePixelRatio 是 2 或 3如果你把 Canvas 缓冲区开成物理像素大小每帧要处理的像素数量可能翻三倍甚至五倍。解决方式是限制渲染的缩放比移动端最多按 2 倍渲染不用追求物理像素级的极致清晰。iOS Safari 进游戏后一直没声音。AudioContext 在用户手势之前处于 suspended 状态。处理方案是在页面根元素上注册一次touchend监听触发时调用ctx.resume()只要有一次成功的用户交互之后的声音就正常了。网络状况不差但消息延迟忽高忽低。有序可靠通道在大消息积压时会产生“队头阻塞”后面再紧急的信息也得排队。解决方式是拆分通道一个通道用默认参数传可靠消息另一个通道对低优先级非关键消息设置{ ordered: false, maxRetransmits: 0 }让实时性要求高的消息走快速通道关键状态才走可靠通道。游戏在安卓微信内置浏览器里打开后无法联机。部分老版本系统 WebView 对 WebRTC 支持不完整。检测到RTCPeerConnection不存在时我会在页面上显示一个非常明显的提示引导用户点击右上角用系统浏览器打开。这个兜底提示一定要写得足够友好不然玩家会以为游戏坏了直接关掉页面。做完 OmniGame 再回头看我最深的体会是零依赖和 WebRTC P2P 看似是两个独立的技术决策实际上是同一种产品哲学的两种表达——把不该让用户承担的东西全部拿掉。不该装 App 就不要装不该等加载就不要等不该让开发者养服务器就不要养。以后我再做新玩法会把零依赖和 P2P 直接当成默认配置来设计。你如果也想做类似的网页小游戏放心从原生 API 开始折腾碰到连接问题、延迟问题、兼容性问题这篇清单基本上能帮你解决一大半。剩下的就交给你的玩法创意了。