ARTICLE DETAIL

资讯详情

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

零依赖引擎与WebRTC P2P:打造单文件网页游戏实战

零依赖引擎与WebRTC P2P:打造单文件网页游戏实战 1. 这一切的起点网页小游戏为什么需要重新定义工程上限先讲个我自己的经历。有段时间我沉迷研究 mikutap 那类网页音游还有网上各种复制全部代码保存成 html 就能玩的夜间飞行小游戏。它们有一种共同的气质打开链接就能玩不用下载 app不用装任何软件连安装包这个概念都不存在。一个 htmljs 文件就是全部。但等我自己真去写这种游戏时撞墙了。想做双人玩法好得有一个服务器。想做房间、做匹配、做排行榜得买服务器、写后端、部署、维护。想压缩加载时间npm 装了一堆依赖一个小游戏光框架就几百 KB。这些问题几乎把打开即玩的优势全毁掉了。所以就有了 OmniGame。这个项目要解决的问题很直接能不能把网页小游戏的工程能力推到一个新边界——单文件、零依赖、极速加载同时通过 WebRTC 实现真正的 P2P 多人联机让开发者不需要拥有任何服务器就能让一群人在同一个网页里实时对战。零依赖意味着我不去碰 npm、不去拉框架整个引擎从循环、渲染、实体管理到音频全部手写WebRTC P2P意味着玩家之间直接建连数据不经过我的服务器中转。这两个词合在一起重新定义一个网页小游戏的工程上限你可以把一个几十 KB 的 html 文件发给朋友他双击打开你俩就能在同一个局域网甚至跨网络对战。这个项目的意义不在于我发明了新东西。WebRTC 和 Canvas 都是老技术。OmniGame 的意义在于把这些技术按网页小游戏这个具体场景重新组装并且把组装过程中遇到的坑、取舍、工程决策完整沉淀出来。这篇文章就是一份实践记录适合三类人看想写零依赖、开箱即玩单文件网页游戏的前端开发者想给 HTML5 游戏加多人联机、但又不想买服务器的小团队/独立开发者对 WebRTC 感兴趣、想理解 DataChannel 在真实游戏场景里怎么用的工程师如果你只是想做个小工具自己玩这篇文章也能帮你少走不少弯路。我下面会从引擎核心一直讲到网络层的坑所有代码都来自 OmniGame 真实实现不是 Demo 教学。2. 零依赖引擎先把手上的轮子全部拆掉再重造2.1 游戏循环固定步长 渲染插值这是所有玩法正确性的地基零依赖不代表没有架构。OmniGame 第一个要解决的就是游戏循环。很多初学者直接写requestAnimationFrame每次回调里又更新又渲染结果在 60Hz 屏幕和 120Hz 屏幕上的手感完全不同帧率波动时物理和移动还会跳。原因很简单requestAnimationFrame的回调频率跟随显示器刷新率不是稳定的游戏时间。我的方案是经典的 fixed timestep(function () { use strict; const FIXED_STEP 1000 / 60; let lastTs 0; let acc 0; function frame(ts) { const dt Math.min(ts - lastTs, 250); lastTs ts; acc dt; while (acc FIXED_STEP) { update(FIXED_STEP / 1000); acc - FIXED_STEP; } render(acc / FIXED_STEP); requestAnimationFrame(frame); } requestAnimationFrame(frame); })();update永远以 1/60 秒为步长执行不管屏幕是 60Hz 还是 144Hz。render之间多出来的时间用acc / FIXED_STEP做插值因子传给渲染层做位置补间。这样 60Hz 屏和 144Hz 屏上玩家的移动速度一致、物理表现一致、判定逻辑一致。这个循环是所有后续模块的地基尤其是联机模块。P2P 对局里每个客户端都要在相同的时间语义下运行同一套规则没有固定步长跨端同步就是空谈。2.2 渲染层Canvas 2D 够用关键是别每帧造垃圾网页小游戏最常见的误区动不动就上 WebGL或者引入 PixiJS/Three.js 这类引擎。OmniGame 直接走 Canvas 2D。原因有两个一是零依赖哲学下没必要为一个休闲小游戏引入几百 KB 的库二是 Canvas 2D 的 API 处理 2D 吃豆人、飞行躲避、音游下落物这些场景绰绰有余瓶颈从来不在 API 而在绘制习惯。我的渲染层只做了四件事变换矩阵栈、精灵绘制、文字绘制、裁剪。所有对象每帧只调用一次drawImage不做逐像素操作。真正要命的是 GC。如果在update或render里频繁创建对象——比如每帧Array.prototype.slice、拼字符串生成坐标、new Vector2()——V8 的垃圾回收会周期性停顿移动端上直接表现为掉帧。OmniGame 的做法实体坐标直接存在Float32Array里渲染时用索引访问不创建临时对象动画插值用对象池复用向量每帧的绘制列表是预分配数组length 0清空而非重新 new这里分享一个从项目里总结的硬经验Canvas 2D 下尽量不要用shadowBlur、globalCompositeOperation的某些模式还有大范围fillRect半透明遮罩这些在移动端 GPU 上会触发离屏合成开销成倍增长。真需要阴影用预生成的阴影贴图。2.3 实体系统不用 ECS 框架自己写 30 行的组件容器零依赖不代表不设计。OmniGame 的实体系统刻意模仿了 ECS 的组件组合思想但实现只有薄薄一层class Entity { constructor() { this.id Entity.seq; this.comps new Map(); this.dead false; } add(comp) { this.comps.set(comp.constructor, comp); return this; } get(Ctor) { return this.comps.get(Ctor); } has(Ctor) { return this.comps.has(Ctor); } } Entity.seq 0;用法很简单。玩家实体 EntityTransform组件PlayerControl组件Sprite组件子弹实体 EntityTransform组件Bullet组件Sprite组件。逻辑系统扫描时用entity.has(需要的组件)过滤。这个设计的核心价值在于逻辑与数据解耦。后面做联机状态同步时我只需要序列化每个实体的Transform和少量业务组件不需要关心它是什么类型的实体。这比传统面向对象的类继承在同步场景下好写太多。2.4 输入层键盘、鼠标、触摸统一成状态而不是事件零依赖游戏最容易忽略的是输入抽象。很多单文件游戏直接监听keydown在回调里改玩家位置——这在联机里是完全错误的做法。因为回调里的改位置没有走游戏循环它的更新时机是不确定的。OmniGame 的输入层把所有事件源统一成一个InputState对象keydown/keyup/mousedown/touchstart只负责修改状态真正的逻辑在update轮询状态class InputState { constructor() { this.keys new Set(); this.pointer { x: 0, y: 0, down: false }; } attach() { window.addEventListener(keydown, e this.keys.add(e.code)); window.addEventListener(keyup, e this.keys.delete(e.code)); window.addEventListener(pointerdown, e { this.pointer.down true; this.setPointer(e); }); window.addEventListener(pointermove, e this.setPointer(e)); window.addEventListener(pointerup, e { this.pointer.down false; }); window.addEventListener(blur, () this.reset()); } reset() { this.keys.clear(); this.pointer.down false; } }这里有两个关键点值得展开。第一触摸设备和鼠标设备用 Pointer Events 统一这是现代浏览器的标准做法不要自己分别监听 mouse 和 touch会引入大量兼容性 bug。第二每次窗口失焦blur时必须 reset 输入。否则玩家切走浏览器再回来按键状态卡住角色可能一直自动往左走。这种 bug 在单机里只是烦人在 P2P 对局里会直接导致玩家掉线重连。2.5 关于物理与音频没有 Box2D、没有 Howler照样做完整游戏很多人会问零依赖碰撞怎么办音效怎么办OmniGame 的做法是只实现需求所需的最小子集。碰撞就用 AABB 包围盒配合圆形碰撞不了几个对象。休闲游戏不需要刚体旋转、不需要关节约束自己写 50 行就够了function aabbOverlap(a, b) { return Math.abs(a.x - b.x) * 2 (a.w b.w) Math.abs(a.y - b.y) * 2 (a.h b.h); }音频更彻底——OmniGame 用 Web Audio API 程序化合成音效。跳跃、击中、得分这类游戏音效本质就是方波/锯齿波的频率变化包络。我写了一个sfx(freq, duration, type, volume)的函数几十行代码就能覆盖 90% 的休闲游戏音效需求。这也直接促成了第 5 章会讲的资源内联没有外部音频文件整个游戏才能塞进一个文件。3. WebRTC P2P 联机放弃服务器的第一英里3.1 为什么是 DataChannel而不是 WebSocket要多人联机传统的思路是 WebSocket。客户端连上服务器服务器转发消息。这个模型简单可靠但有两个绕不开的问题服务器成本一个房间服务不仅要扛连接数还要转发游戏状态延迟敏感的游戏对服务器地理位置要求极高多一跳延迟两个玩家哪怕坐在同一间屋子里数据也要先上服务器再下来WebRTC 的 DataChannel 走的是完全不同的路线两个浏览器直接建立点对点连接数据不经过任何中转。它本质上是浏览器内置的翻版 UDP/TCP区别在于维度WebSocketWebRTC DataChannel服务器必须不需要信令除外数据路径客户端→服务器→客户端客户端→客户端直连加密TLSDTLS 强制可靠性仅可靠有序可靠有序 / 不可靠无序 可选NAT 穿透无STUN/TURN 自动协商其中不可靠无序模式对动作游戏是杀手锏。后面我会详细讲它的用处。3.2 信令困境WebRTC 有一个绕不开的第一次握手必须说实话WebRTC 不是纯 P2P 魔法。两个浏览器要建立连接必须先交换 SDP会话描述和 ICE 候选这个交换过程必须有通道叫信令。很多项目卡在信令上因为它需要一台服务器。OmniGame 的核心判断是信令服务器不是游戏服务器。信令只负责让两个浏览器找到彼此一旦连接建立信令就可以死掉游戏数据照常跑。基于这个判断我做了三档方案同一设备/局域网手输模式A 生成带完整 SDP 的文本复制给 BB 粘贴后生成 answer 复制回给 A。零服务器适合双人面对面。扫码配对模式A 生成二维码B 用手机扫码浏览器通过本地 HTTP 页面完成 SDP 交换。适合手机双人。房间信令模式用一个极简的 WebSocket 房间服务转发 SDP/ICE不做任何游戏逻辑。这是面向互联网对战的默认方案服务端代码不足 200 行任何人都可以自建也可以在免费服务商那里直接跑。这里我想强调一个工程上的误区别把信令服务器越做越重。我见过有人把聊天、房间状态、匹配都塞进信令服务最后等于还是做了一台游戏服务器。OmniGame 的信令服务只有三个动作createRoom、joinRoom、relay。其他一概不管。3.3 双通道设计可靠通道管规则不可靠通道管手感DataChannel 创建时可以选择可靠性。OmniGame 一建立连接就开两条通道const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.l.google.com:19302 }, { urls: stun:stun1.l.google.com:19302 } ] }); const controlCh pc.createDataChannel(control, { ordered: true }); const gameCh pc.createDataChannel(game, { ordered: false, maxRetransmits: 0 });controlCh用于加入房间、离开房间、帧号确认、主机迁移这类必须到达且按顺序的消息。gameCh用于高频游戏状态同步。maxRetransmits: 0表示如果数据发送失败直接丢弃不重传。这很重要网络抖动时游戏状态旧了就旧了下一个快照马上就到重传旧状态反而会导致卡顿和延迟堆积。这是 P2P 游戏延迟观感的核心优化。TCP包括有序可靠通道在丢包时会自动重传重传期间后续数据全部排队表现为卡住然后突然跳一大段。UDP 风格通道对应的是短暂抽搐一下但整体流畅对动作游戏更友好。3.4 地址簿与二进制协议JSON 只用于低频高频走 DataView新手的通病是用 JSON 做所有消息。JSON.stringify在低频消息每秒几次里没问题但游戏状态同步每秒 30~60 次时JSON 的解析开销、字符串内存分配、键名冗余全都成了负担。OmniGame 的游戏快照全部是二进制。举个例子同步实体状态的结构是消息头1 字节 magic 2 字节帧号 每个实体 20 字节4 字节 id 4 个 float 分别表示 x/y/vx/vy。序列化和反序列化都直接用DataViewfunction encodeSnapshot(entities, frame) { const buf new ArrayBuffer(3 entities.length * 20); const view new DataView(buf); view.setUint8(0, 0xAB); view.setUint16(1, frame, true); let off 3; for (const e of entities) { view.setUint32(off, e.id, true); off 4; view.setFloat32(off, e.x, true); off 4; view.setFloat32(off, e.y, true); off 4; view.setFloat32(off, e.vx, true); off 4; view.setFloat32(off, e.vy, true); off 4; } return buf; }8 个实体、30Hz 的快照一个数据包也才 163 字节。换算下来主机上行带宽约 34KB/s对家庭宽带毫无压力。3.5 主机权威模型P2P 也得有裁判P2P 不等于无政府。OmniGame 采用主机权威模型Host-Authoritative所有客户端只上报自己的输入主机运行权威模拟再把结果广播给所有客户端。为什么要这样因为彻底的点对点同步每个客户端都跑完整模拟要求所有客户端游戏状态完全一致这在确定性模拟里可以实现但休闲游戏里一点浮点误差就会积累成状态分裂排查极其痛苦。主机权威模型让谁说了算这个问题永远有答案客户端 A 的跳跃是否命中主机判定两个玩家同时拾取同一个道具主机裁决某一帧客户端 B 觉得有人开挂主机有全量状态可以直接踢人客户端收到的永远是主机的快照所以它们显示的世界永远一致。代价是主机承担额外的上行带宽和算力但对 8 人以内的小游戏完全够用。4. 穿透、时钟校准与主机迁移P2P 真正难啃的骨头4.1 NAT、STUN、TURN一次连接背后发生了什么WebRTC 的 P2P 并不是总能直连。大多数玩家在家用路由器后面路由器上有 NAT网络地址转换它把内网 IP 映射成公网 IP端口。问题是 NAT 的映射规则各不相同有些 NAT 只允许来自曾经主动通信过的外网地址的包进入这叫对称 NAT。对称 NAT 之间即使在公网上交换了地址也无法直连因为双方都只接受之前联系过的对方端口。这就是为什么 OmniGame 的配置里必须有 STUN 和 TURNSTUN比如stun:stun.l.google.com:19302的作用是问镜子我长什么样——让浏览器发现自己看到的公网 IP 和端口TURN的作用是我帮你中转——当直连失败时数据走中继服务器转发实际数据显示直连成功率大约在 80%~90%。剩下的 10%~20% 必须走 TURN。所以开发时要有意识没有 TURN 兜底的 WebRTC 联机在真实互联网上是不可用的。OmniGame 把 TURN 服务器做成可选配置项默认内置一组公共 STUNTURN 交给部署者自建或购买。这里还有一个细节值得注意ICE 候选收集是异步且会持续更新的。很多 p2p 库只在连接建立时处理一次 ICE 候选导致早期候选失败后没有后续候选。OmniGame 的做法是持续监听onicecandidate并转发给对端直到connectionState变为connected。同时要处理 Trickle ICE——也就是候选产生了就立即发送不要等所有候选收集完再一起发那样会白白增加几百毫秒的建连时间。4.2 时间同步与状态插值把 80ms 延迟藏起来P2P 联机最大的隐患是延迟不稳定。跨城对战时 RTT 可能 30ms~80ms无线网络下抖动更夸张。OmniGame 的解法是三层时钟偏移校准、状态插值、轻微外推。时钟偏移校准每个快照都带主机侧的 game time。客户端通过最经典的 NTP 式四次握手估算时钟偏移——发 ping 带本地时间收 pong 再算 RTT用offset (t1 t4 - t2 - t3) / 2做滑动平均。有了偏移客户端就知道主机快照对应的主机时间换算到本地是什么时刻。状态插值客户端保留最近几个快照渲染时取当前本地时间减去固定延迟比如 100ms对应的两个快照之间做线性插值。这个固定延迟是故意加的它的作用是缓冲网络抖动。当 RTT 突然从 30ms 跳到 100ms 时由于渲染点始终在真实时刻的 100ms 之后玩家的画面不会突然停顿而是平滑地保持。这个藏着延迟的工程思路比盲目追求低延迟更重要。主机权威模型下你没法做到真正的零延迟——你看到的总是主机在过去某一刻的状态。但通过插值玩家感知到的不是延迟而是流畅。休闲游戏追求的不是电竞级的毫秒响应而是持续稳定的平滑观感。4.3 主机掉线了迁移协议是怎么设计的主机是 P2P 对局里的单点故障。主机掉线整局游戏就没了。OmniGame 的迁移协议设计基于两个原则迁移要快和状态不能凭空消失。具体做法每个客户端都缓存最近收到的完整快照这个快照带有主机时间戳和帧号客户端检测到主机 DataChannel 关闭立即进入migrating状态暂停更新但继续渲染按照进入房间的顺序排序确定新主机候选人。我是用进入房间时主机分配的 joinOrder 排序总是取最小的在线者新主机选出后所有客户端把自己缓存的最新快照时间戳发给新主机。新主机取时间戳最新且大多数客户端共同认可的快照作为续局基准新主机广播NewHost消息大家重新连接、重新计时这里的关键洞察是迁移不追求状态完美追求大家一致。哪怕新主机用的快照不是绝对最新只要所有客户端都从同一个快照继续游戏就能无缝续下去。如果有人手里的状态比别人新一点点新主机宁可采用大家都有的那份旧状态也不能造成客户端之间的分歧。4.4 断线重连别让一次弱网杀死整局除了主机迁移普通客户端也有断线重连需求。OmniGame 的 DataChannel 断线后先不判定玩家离开而是进入最长 8 秒的宽限期宽限期内主机继续给断线玩家的实体做简单的 AI 托管或原地待机宽限期内若玩家网络恢复执行 ICE restart重新协商连接恢复后主机立即补发最近快照超过宽限期仍未恢复才正式判定离开执行实体移除和主机迁移检查这个设计让游戏对地铁里信号闪断这类场景有很高的容忍度。如果你不做宽限期一次 2 秒的弱网就可能把玩家踢出房间对用户体验是致命的。5. 单文件分发从打开即玩到发链接即联机5.1 为什么坚持单文件OmniGame 的分发模型从一开始就定了整个游戏就是一个 html 文件双击或用浏览器打开即玩。这个选择背后的逻辑很简单网页小游戏最大的优势就是零安装、零信任成本。现代人不会为了玩一个休闲小游戏去下载安装包。单文件 html 意味着加载几乎零延迟本地文件秒开分发零摩擦QQ/微信/邮件发文件即可部署零成本丢到任何静态托管就能全球访问但单文件也会带来反直觉的代价。零依赖引擎本身没有构建步骤代码可以直接在 html 里script内联这意味着我失去了打包工具提供的按需加载和懒加载能力。所有代码在页面打开时就全部解析完毕。不过对于几十 KB 的游戏V8 解析一遍纯 JavaScript 代码通常只有几毫秒到几十毫秒完全不是瓶颈。唯一的代价是要自己控制代码量别写冗余。5.2 资源内联策略图片走程序化音频走合成如果一个 html 里嵌了外部图片img srcplayer.png那它就不是单文件发给朋友就打不开。OmniGame 的资源策略有两级第一级程序化生成视觉资源。休闲游戏里 80% 的素材是几何图形——色块、圆、圆角矩形、简单渐变。这些直接在启动时用离屏 Canvas 画一遍转成CanvasPattern或返回一个 canvas 对象当贴图。零字节成本。第二级音频用 Web Audio 合成。第 2 章提过sfx()函数用振荡器生成音效。音乐方面轻量 loop 可以用多个 oscillator 分层播放或者用极短的采样用 base64 内联。我专门做过一个实验一首简单的 8 小节循环背景乐用 Web Audio 合成后代码不到 2KB效果完全够用。如果确实需要位图素材比如精心绘制的角色图OmniGame 支持把它们转成 base64 嵌入。但这会让文件体积明显膨胀图片内联后体积膨胀约 33%。所以我通常把图片内联作为最后的选择优先程序化。5.3 体积实测与首屏优化我拿一个示例游戏测过完整的双人 P2P 对战小游戏包含引擎核心、网络层、碰撞、粒子特效、程序化音效、3 个基础关卡全部代码压缩后约 28KBgzip 后不到 9KB。这是任何传统引擎都无法想象的体积。作为对比仅引入一个常用前端框架的 min.js 就已经 90KB 往上了。这个体积上的优势直接转换为加载速度。在普通 4G 网络下一个 30KB 的 html 文件几乎是瞬开。配合 CDN 的话全球玩家的首屏时间都能控制在 1 秒以内。对于强调即点即玩的小游戏场景体积就是体验体积就是转化率。启动性能还有两个小优化值得一提所有初始化工作放在 DOMContentLoaded 后一次完成避免阻塞首帧渲染启动画面和游戏循环同时开始背景用requestAnimationFrame绘制加载进度不要让玩家面对白屏5.4 一个绕不开的边界WebRTC 需要 HTTPS这里必须泼一盆冷水你的 html 文件可以在file://协议下本地打开并流畅运行游戏循环但WebRTC 的 DataChannel 只能在 secure context 下工作也就是 HTTPS 页面或 localhost。这意味着file://打开本地文件单机玩法没问题联机功能大概率不可用部署到任何 HTTPS 静态托管P2P 联机完整可用所以 OmniGame 的策略是游戏必须有单机模式联机模式是渐进增强。朋友拿到文件后即使不部署也能单机玩想联机就把它丢到 GitHub Pages、Cloudflare Pages 这类免费 HTTPS 托管上。这也符合网页小游戏的传播习惯——真正要分享给全世界玩的时候没有人会给别人发文件都是发链接。6. 实测数据、翻车记录与性能红线6.1 带宽与帧率实测我搭建了一个 8 人同房的测试场景每个玩家控制一个小飞船在共享画布里移动主机 30Hz 广播快照。三个核心数据指标实测结果主机上行带宽约 34KB/s8 人 × 30Hz × 163B/包客户端上行带宽约 1KB/s仅上报输入局域网 RTT2~5ms跨城 RTT无线40~90ms8 人同房帧率60FPS中端手机带宽根本不是瓶颈。真正决定体验的是插值和丢包处理也就是第 4 章讲的那套机制。只要插值缓冲区稳健RTT 在 150ms 以内时玩家几乎感知不到延迟。6.2 iOS Safari 的坑音频手势和用户激活移动端测试时我被 iOS Safari 的自动播放策略坑过一次。现象是游戏页面打开后音效完全沉默点了按钮才恢复。原因iOS Safari 要求 AudioContext 必须由用户手势触发后才允许播放。解决方式很简单let started false; function resumeAudio() { if (!started) { ctx.resume(); started true; } } window.addEventListener(pointerdown, resumeAudio, { once: true });这个监听必须在游戏加载后就挂上不能等玩家真正点开始按钮时再挂。更隐蔽的是iOS 上某些手势比如系统返回手势不会触发pointerdown所以挂一个兜底的touchend监听更稳妥。iOS 上 WebRTC 还有个心智负担虽然 DataChannel 不需要摄像头权限但早期 iOS 版本里部分 Safari 在建立 RTCPeerConnection 时会弹出允许使用麦克风/摄像头的权限提示。这是系统 bug 级的行为你无法在代码层面完全绕开只能提示用户如果出现权限弹窗请点允许不会真的打开摄像头。我在 OmniGame 的联机文档里专门标注了这一点。6.3 GC 与 Canvas 性能的隐藏杀手我在真机调试时遇到过一个经典问题游戏运行 5 分钟后帧率断崖式下降从 60 降到 30 以下。起初怀疑内存泄漏后来用 Chrome 的 Performance monitor 一看是频繁的 Minor GC。根源在渲染层——我把每帧要绘制的精灵坐标拼成了字符串去查缓存表字符串拼接在 V8 里会不断创建新字符串直接触发 GC 压力。修法是把缓存表从字符串 key 改成数字 key并用对象池复用渲染上下文。修复后 GC 活动降为原来的 1/10 左右。在 JavaScript 游戏里垃圾回收是比帧率更容易忽视的敌人——它不直接表现为卡顿统计而是表现为玩了 5 分钟开始卡。还有一个移动端必踩的坑Canvas 的getContext(2d)在不同设备上可能返回不同实现有些设备对drawImage的 float 坐标会做额外抗锯齿导致性能下降。把坐标Math.round到整数或者至少保证精灵尺寸是整数能明显降低光栅化开销。6.4 安全与隐私的工程注意点WebRTC 会暴露一些信息给对端比如本地 IP即使经过 STUNICE 候选里也可能包含内网 IP 和 IPv6 地址。这是协议特性不是 bug。OmniGame 提供两个工程级的应对隐私优先模式设置iceTransportPolicy: relay强制所有流量走 TURN不暴露本地网络地址。代价是延迟和带宽成本上升适合对隐私要求高的用户或强监管环境。数据校验主机权威模型下客户端上报的输入必须做范围限制比如移动速度不超过设计上限防止恶意客户端通过注入超常输入作弊。P2P 环境里信任每个对端是不现实的OmniGame 对每条输入消息都做边界校验非法输入直接丢弃并计数超过阈值自动断连。这里还需要注意一个细节浏览器实现差异导致的兼容性坑。RTCPeerConnection在部分浏览器里不加前缀但旧版 Safari 需要webkitRTCPeerConnection。OmniGame 用一个十几行的兼容层解决不值得为它引整个 polyfill 库。6.5 我没解决的部分诚实交代P2P 方案也有它做不到的事NAT 穿透失败且没有 TURN 时会完全无法联机。公共 STUN 服务只解决一半问题完整方案必须要有 TURN 兜底主机迁移做不到完全无缝。1 到 2 秒的卡顿是不可避免的这是 P2P 架构的物理极限无法跨版本混玩。不同浏览器、不同 WebRTC 实现的 SDP 协商偶尔会失败这类问题只能靠升级浏览器解决这些限制我不会当它不存在。每位架构师在做技术选型时都应该清楚P2P 适合的是休闲小游戏和中小规模好友局真要做到全球范围的大规模匹配对战服务器中继仍然是更稳妥的方案。7. 工程体会零依赖和 P2P 教会我的三件事做 OmniGame 这段时间我最大的体会不是技术上的而是工程心态上的。第一不加依赖是一种持续的设计约束而不是一次性的初始状态。每当我冒出这个功能用某个库更省事的念头时都要先问这个库解决了什么问题我自己实现要多久大多数时候库解决的是通用问题而我需要的是特定 20% 的功能。自己写那 20%长期看代码反而更少、更可控。第二P2P 不是没有服务器而是把服务器最不值得做的那部分去掉了。信令仍然需要TURN 仍然可能需要主机仍然是某种意义上的特权节点。P2P 真正带给开发者的是你不再需要为一款几十 KB 的小游戏去维护一台常年待命的游戏服务器不再需要为突发的玩家数量准备扩容方案。这让小游戏的联机成本从运维项目降级成了配置项。第三把打开即玩和发链接即联机这两件事做好本身就是一种产品护城河。现在的网页小游戏市场里功能性同质化很严重但免安装、免下载、打开链接就能和好友对战的体验依然稀缺。如果你愿意花心思把这一层细节打磨到位——加载速度、首屏体验、弱网表现——用户记住你的不会是某个框架或协议而是这个链接点开就能一起玩的爽快感。这篇白皮书没有讲完 OmniGame 的全部比如后续计划中的 WebRTC 数据加密扩展、更多程序化素材模板、基于房间信令的自动匹配。但工程思路已经全部摊开零依赖的引擎核心如何搭WebRTC 联机如何真正落地单文件分发如何做得干净利落。如果你按照这篇文章的路子走一遍你会得到一个和 OmniGame 类似的小东西。接下来决定你游戏高度的就不是工程细节而是玩法本身了。
返回列表