ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

零依赖WebRTC P2P网页小游戏实战:从Canvas到联机同步

零依赖WebRTC P2P网页小游戏实战:从Canvas到联机同步 如果你维护过一个小游戏页面大概率遇到过同样的场景项目才写了一千行逻辑package.json里已经躺了二十几个依赖首屏脚本从几十 KB 一路涨到 2MB打开控制台还有一串第三方库的报错。我下定决心换一种思路把渲染、循环、联机全部建立在浏览器原生 API 之上——Canvas 2D、WebRTC DataChannel、WebSocket客户端不引入任何第三方 JS 运行时。这个项目后来被我命名为 OmniGame。它的核心目标很直接用零依赖的方式做一个支持 P2P 联机的网页小游戏而且要把工程细节做到可以长期维护、跨设备、在低端机上也能跑得动。这篇文章不是广告也不是纯理论介绍我想把 OmniGame 从零到可玩的完整技术路径讲清楚。内容包括游戏循环怎么写、自研数学库的取舍、WebRTC 建立 P2P 链路的完整握手流程、同步策略如何选型以及我们在真实浏览器里踩过的坑。适合三类人看想做独立网页小游戏但不想被框架绑架的开发者、需要在项目中引入点对点联机能力的同学、以及单纯对“零依赖工程化”感兴趣的人。1. 零依赖不是情怀是可控性的重新定价先说结论零依赖不是“我不想用库”的情绪化选择而是把第三方依赖带来的隐性成本一次性报销掉。OmniGame 的原则是凡是浏览器直接暴露的能力一律直接使用凡是平台没有提供的极小部分能力自己实现或者留好扩展点。1.1 第三方依赖给小游戏带来的三个隐藏成本第一个是版本穿透问题。我在做 OmniGame 之前维护过一个休闲对战页游某个第三方动画库自动升级了 minor 版本直接导致老机型 Canvas 粒子花屏。前端项目对“依赖版本锁定”普遍执行得不够严格尤其小游戏项目团队成员经常顺手npm install一个最新包没有人会为了一个小游戏去完整跑一遍回归。可小游戏对帧率和兼容性极度敏感一个绘制 API 的行为变化就可能让整局游戏崩掉。第二个是首屏成本。网页小游戏往往做完即玩、用完即走用户没有耐心等一个 2MB 的脚本。第三方库不管怎么 tree-shaking对游戏这种大量使用全局状态和频繁调用的场景来说体积都会快速膨胀。零依赖之后OmniGame 首屏脚本始终控制在 80KB 以内而且没有额外请求这对于微信内打开、海外轻游戏平台投放这类场景非常友好。第三个是供应链安全。近两年行业内因为构建链被污染导致的线上事故并不少见游戏页面因为 CSP 策略严格经常遇到第三方库要内联脚本、要用不安全的 eval 之类的问题。零依赖意味着攻击面大幅缩小没有可供投毒的中间包也没有被后端引用计数失效打挂的风险。1.2 零依赖的边界浏览器原生能力就是我们的运行时零依赖不等于脱离平台更准确地说是把浏览器当作唯一运行时。Canvas 2D、WebGL、requestAnimationFrame、WebRTC、WebSocket、BroadcastChannel、IndexedDB、Blob、URL.createObjectURL这些都是页面可以直接调用的原生能力。OmniGame 的开发规范里写得很死不引入任何第三方 JS 库不依赖任何构建框架模块之间全部用标准的 ES Module 组织。这个边界一开始会让团队很不适应。比如需要做碰撞检测时第一反应是去找physics库要做网络同步时第一反应是去 npm 上搜状态同步库。OmniGame 的做法不是“硬造轮子”而是把轮子做薄。我们自研的core/math模块只包含向量、矩阵、圆和 AABB 碰撞总共不到 400 行。如果以后确实需要更复杂的物理效果可以在render和physics之间留接口而不是默认引入重型依赖。1.3 信令服务器到底算不算依赖WebRTC 的 P2P 连接建立需要一个信令通道来交换 offer、answer 和 ICE candidate。很多朋友会困惑OmniGame 既然说零依赖那信令服务怎么解决答案是客户端零依赖不代表没有服务端。WebRTC 本身只是传输层两个浏览器之间要建立连接总得有一个方式告诉对方“我在这个房间”。信令服务的实现我用的是 Node.js 原生http和wsWebSocket 库本身属于服务端依赖不影响客户端。从架构上讲客户端只依赖浏览器内置的 WebSocket API不引第三方库这一点没有破功。对于开发调试场景OmniGame 还内置了一个“手动信令模式”创建房间后把生成的 JSON 复制给另一个浏览器粘贴双方就能建立连接。这种方式不依赖任何服务器适合局域网开发、技术演示和自动化测试。生产环境再切换到 WebSocket 信令中继。1.4 自研模块划分为了让代码长期可维护OmniGame 把自研代码分成四个模块core游戏循环、事件系统、时钟管理renderCanvas 2D 渲染、精灵表、粒子系统、摄像机netWebRTC 封装、信令协议、ICE 管理sync状态同步、帧同步、预测回滚、快照每个模块独立设计 API但又不能互相循环引用。net不知道render的存在sync只处理纯状态对象渲染层从状态对象读数据绘制。这种模块划分是 OmniGame 工程上限的关键——零依赖只是底层约束真正能长期演进的是边界清晰的架构。2. 60 FPS 的引擎骨架Canvas 2D、固定步长与自写数学库网页小游戏最容易出问题的就是循环和帧率。我见过很多项目直接在requestAnimationFrame里写update render结果不同刷新率屏幕上的角色移动速度不一样高刷屏上像是开了加速器60Hz 屏幕上又慢半拍。OmniGame 的引擎层必须解决这个问题。2.1 游戏循环rAF 驱动 固定时间步长OmniGame 的循环设计很朴素用requestAnimationFrame获取实际帧回调但逻辑更新不走“每帧一次”而是走固定时间步长累加器。核心代码大致如下let lastTime performance.now(); let accumulator 0; const FIXED_STEP 1000 / 60; // 毫秒 function frame(now) { let frameTime now - lastTime; lastTime now; // 防止切后台后回来accumulator 被塞入巨量帧数据 if (frameTime 250) { frameTime 250; } accumulator frameTime; while (accumulator FIXED_STEP) { update(FIXED_STEP / 1000); accumulator - FIXED_STEP; } render(accumulator / FIXED_STEP); // 将余量作为插值因子传给渲染层 requestAnimationFrame(frame); } requestAnimationFrame(frame);固定步长带来的好处是游戏逻辑不会随着屏幕刷新率变化而漂移。比如 120Hz 的屏幕上每帧会更新两次逻辑但每次逻辑步长都是 1/60 秒物理和速度表现一致。这里的accumulator / FIXED_STEP是渲染插值因子让画面在两次逻辑更新之间也能平滑过渡。这个循环在低端机上还有一个保护逻辑连续出现掉帧时自动把固定步长从 60Hz 降为 30Hz逻辑更新数量减半动画速度仍然一致只是物理精度略微下降。实际测试中这个机制比单纯降低分辨率的体感提升更明显。2.2 自写的向量与矩阵库够用吗小游戏用到的数学并不复杂。位置、速度、方向用二维向量表示摄像机变换用 2D 仿射矩阵核心其实只有矩阵乘法和逆变换。OmniGame 的数学库只实现了 Vector2、Matrix3、圆和 AABB以及对应的相交检测。比如角色是一个圆墙壁是一组 AABB碰撞检测就是遍历附近格子里的包围盒做圆与矩形的相交测试。为了避免低端机上滚动数组和临时对象造成 GC 压力数学库的全部接口都要求调用方传入输出对象避免每次运算都new一个新对象。这个 API 设计一开始写起来有点反人类但过了两三个游戏原型之后团队就习惯了。性能收益非常明显一个 100 个对象的场景一帧的临时对象分配量从 3000 个降到 200 个以内。2.3 渲染层对象池与离屏 CanvasCanvas 2D 最容易踩的坑不是画图本身而是状态重置。我在 OmniGame 渲染层里做了一个非常严格的规定每个精灵绘制函数都不允许改变全局画布状态必须保存和恢复。更重要的是离屏 Canvas 的用法。地图背景、方块纹理、文字标签这些不经常变化的内容OmniGame 会把它们渲染到一个离屏 Canvas 上再整体drawImage到主画布。这样一帧主循环里真正执行的绘制指令数量会大幅度减少。对于粒子系统离屏 Canvas 不适用因为粒子每帧都在变化但我给粒子模块做了对象池粒子对象创建后循环复用避免频繁分配内存。2.4 空间哈希与碰撞优化当同屏对象超过 50 个逐个两两检测碰撞的复杂度就开始拖帧率。OmniGame 的空间哈希做法很直接把屏幕划分成 64x64 像素的格子每个对象只加入它所在格子的索引检测时只检查相邻 9 个格子里的对象。这个实现也就一百多行但效果显著。从实际项目看100 个弹幕对象加 30 个敌人碰撞检测耗时从 3ms 降到了 0.2ms 左右。碰撞模块是零依赖约束下性价比最高的自研模块。因为需求非常固定完全没有必要引入一个通用物理引擎后者会带来更复杂的配置和学习成本。3. WebRTC P2P 的握手链路从 offer 到 DataChannelOmniGame 的联机层是整篇技术白皮书的重点。传统网页小游戏做多人在线通常走 WebSocket 服务器转发所有客户端把操作发给服务器服务器再广播给所有人。这样做的优点是实现简单缺点是延迟受服务器位置影响大而且服务器带宽成本会随房间数和玩家数线性上涨。OmniGame 换成了 WebRTC DataChannel玩家之间直接建立点对点连接数据不经过服务器中继。3.1 为什么小游戏联机更适合 DataChannel第一个原因是延迟。两个玩家都在同一城市时P2P 的 RTT 通常只有 5-20ms比经过云服务器中转低一个数量级。第二个原因是成本。实时对战游戏的网络流量很大如果用服务器转发每局游戏都要消耗服务器带宽。而 WebRTC P2P 建立成功后信令服务器就退出了数据传输链路只在需要时做 ICE 协商成本几乎可以忽略。第三个原因是浏览器原生支持。WebRTC 已经诞生超过十年所有主流浏览器都支持 DataChannel。它提供的不是一个“模拟实时游戏网络”的偏门能力而是一套完整成熟的 NAT 穿透体系包括 ICE、STUN、TURN 三层策略。OmniGame 要做的事情是在这套体系之上封装更适合游戏的状态同步协议。3.2 信令协议手动粘贴模式和 WebSocket 转发模式WebRTC 建立连接前双方必须交换三种信息会话描述offer/answer、ICE candidate、以及自定义的元数据房间号、玩家 ID、游戏配置。OmniGame 的信令协议是一个非常简单的 JSON 信封结构{ type: offer, fromId: player-a, toId: player-b, roomId: room-001, payload: {} }在手动信令模式下页面会把payload内容展示为一个文本框玩家复制发送给对面。这个过程对自动化测试也很友好我可以在 Node 里用两个无头浏览器实例互贴文本完成完整握手。生产模式则使用 WebSocket 信令服务器。服务器不保存任何游戏状态只做消息路由。房间创建后房主生成roomId其他玩家加入时服务器把新玩家的 ID 广播给房间内所有人。之后玩家之间直接点对点交换 offer/answer服务器不再介入。这个服务器的核心代码连一百行都不到但它是整个零依赖体系里唯一必要的服务端组件。3.3 创建连接与 DataChannel 的完整流程下面是我在 OmniGame 中实际使用的连接建立代码骨架。尽量保留了必要细节方便直接抄async function createPeerConnection(config) { const peer new RTCPeerConnection({ iceServers: config.iceServers, sdpSemantics: unified-plan, // 官方推荐默认 }); peer.onicecandidate (event) { if (event.candidate) { sendSignal({ type: candidate, candidate: event.candidate }); } }; peer.onconnectionstatechange () { if (peer.connectionState failed) { handleConnectionFailed(peer); } }; return peer; } async function createOffer(peer, roomId) { const channel peer.createDataChannel(game, { ordered: false, maxRetransmits: 0, // 不可靠通道适合高频状态同步 }); setupChannel(channel); const offer await peer.createOffer(); await peer.setLocalDescription(offer); sendSignal({ type: offer, roomId, description: offer }); } async function handleAnswer(peer, description) { await peer.setRemoteDescription(description); }需要注意一点createOffer之后不需要等待 ICE 收集完成再发送正确的做法是启用 Trickle ICE每收集到一个 candidate 就立刻通过信令通道发给对方。对方在onicecandidate里逐个添加这种做法能显著缩短连接建立时间。3.4 ICE 的角色STUN 打洞与 TURN 兜底P2P 不是总能直连成功。两个玩家可能都在对称 NAT 后面甚至更复杂的网络环境此时需要 STUN 服务器告诉浏览器自己的公网映射然后尝试 UDP 打洞。如果打洞失败就只能走 TURN 服务器中继。OmniGame 在生产环境的 ICE 配置是这样设定的STUN使用公共 STUN 服务负责发现公网地址映射TURN自建一个 TURN 服务只在 P2P 直连失败时启用用于保证连接成功率。这里有个工程经验不要把所有玩家都默认引导到 TURN因为中继流量会消耗服务器带宽和 CPU。OmniGame 的 ICE 策略顺序是“先直连再中继”同时会在页面上暴露一条内部诊断数据connectionState、selectedCandidatePair和currentRTT。如果玩家直连成功RTT 低于 30ms就完全不用 TURN 资源。实测下来国内跨网络环境下 P2P 直连成功率大约在 60%-70%加上 TURN 之后成功率可以做到 99% 以上。3.5 房间模型与主机选择OmniGame 采用经典的主机-客户端模型。每个房间有一台“主机”主机不仅负责收集所有玩家的输入还拥有权威的游戏状态。普通玩家把自己的操作指令发给主机主机计算最新状态后再广播给所有人。主机选择有两个决定性标准网络延迟和带宽能力。在房间创建时信令服务器会让每个玩家上报一个候选连接质量打分包括与 STUN 的往返延迟、主机 CPU 线程数等。得分最高的玩家成为主机。主机掉线时剩余玩家中得分最高的自动接管接管前需要从其他玩家那里同步一份状态快照这个过程我们在下一节展开。4. 同步方案与手感调优预测、插值与状态快照WebRTC 把数据传输链路打通了但游戏能不能玩得爽取决于同步层怎么做。OmniGame 的同步模块提供了两套模式帧同步和状态同步。小游戏类型不同适合的同步模型完全不同。4.1 帧同步 vs 状态同步怎么选帧同步的核心是“所有玩家在同一帧执行同样的输入序列”只要初始状态一致每个客户端推演出来的结果就是一致的。优点是网络包非常小只需要同步输入指令每一帧只要 1-2 字节缺点是对网络抖动极度敏感一旦某个玩家的帧序号落后全局都要卡顿等待。状态同步的核心是“主机计算权威状态客户端只接收状态并渲染”。优点是容错性强单个客户端掉线重连不会影响全局缺点是网络包较大而且主机在低配设备上会成为瓶颈。OmniGame 的做法是两种模式都内置。卡牌、策略类、非对称竞技类游戏用状态同步足够了真正硬核的动作游戏、格斗类才会启用帧同步模式。对于大多数“网页小游戏”的场景我的强烈建议是选状态同步 预测因为它的开发成本低一个数量级且对网络波动的容忍度高得多。4.2 主机权威 客户端预测状态同步模式下主机权威是必须的。所有玩家操作不直接改变本地游戏状态而是封装成InputMessage发送给主机。主机按固定频率通常是 30Hz处理输入生成权威状态后广播。纯权威模型的问题是手感从按下方向键到画面反馈至少会有一个 RTT 的延迟。如果 RTT 是 60ms玩家会觉得角色“慢吞吞的”。OmniGame 客户端在发送输入的同时会把自己的操作即时应用到本地状态这叫“客户端预测”。当主机的权威状态到达时客户端比对预测状态和权威状态的差异然后进行纠偏。纠偏算法是同样的逻辑function reconcile(predictedState, authoritativeState, correctionTime) { const dx authoritativeState.x - predictedState.x; const dy authoritativeState.y - predictedState.y; // 不直接跳变按 correctionTime 线性插值纠偏 return { x: predictedState.x dx * correctionTime, y: predictedState.y dy * correctionTime, }; }这里的纠偏时间系数很关键。纠正太快会让角色在高速道路上抖动纠正太慢会让角色位移明显偏离实际位置。实测中correctionTime取 0.2 是一个不错的默认值也就是 5 帧内完成纠偏。对于有位移敏感的游戏可以再叠加小范围的延迟补偿。4.3 输入缓冲与插值针对网络抖动P2P 网络有一个天然问题没有服务端做“时钟对齐”不同客户端的时钟漂移和时间戳起点都不一样。OmniGame 在状态包中直接附带主机时间戳客户端用performance.now()做时钟差采样。为了避免系统时间调整带来的误差所有时钟基准都使用performance.now()不碰Date.now()。客户端收到的状态更新是离散的但渲染需要平滑连贯。OmniGame 维护了一个 100ms 的状态缓冲区渲染层读取缓冲区中最近两帧状态做线性插值。如果收到的最新状态时间戳落后于缓冲区末尾就会触发“时间跳跃”纠正如果缓冲区不足两帧则等待下一次数据到达不重复渲染上一帧。输入侧又不一样。玩家输入极不稳定如果每次键盘按下都立即通过网络发送很容易在抖动时造成主机收到一堆重复或乱序的输入。OmniGame 把输入封装成带序号的消息主机按序号去重并根据客户端的 RTT 做输入延迟补偿主机不会“立刻”处理最新输入而是回滚到玩家操作发生时点的状态应用该输入再重算后续状态。这就是格斗游戏常见的回滚网络代码简化版。4.4 断线重连与状态快照P2P 游戏最怕的就是主机掉线。普通玩家掉线还可以重连主机掉线则整个房间逻辑停摆。OmniGame 设计了两个机制第一个是状态快照。主机每 5 秒生成一份完整的状态快照包含所有实体位置、速度、HP、游戏时钟和资源分数。快照不会发给所有玩家而只发给“备份主机”候选人。当主机掉线时备份主机用这份快照作为新状态的起点接管后续输入收集和广播。第二个是重连快速恢复。普通玩家重接新主机时只需要从备份主机那里下载最近一份快照然后继续接收增量更新。这个过程中本地渲染层会短暂使用上一份本地状态做插值让玩家看到的世界不至于完全停顿。如果快照太大我会把状态分成“核心字段”和“扩展字段”重连时只同步核心字段比如位置、血量、方向扩展字段如装饰物状态后续再补。4.5 网络指标监控联机游戏上线后的第一件事不是加功能而是看指标。OmniGame 的net模块内置了三个监控指标RTTround-trip time、抖动RTT 方差、丢包率。RTT 采样用的是 DataChannel 上发送的 ping/pong 控制消息每 500ms 一次。丢包率是接收端统计消息序号缺口算出来的。我建议所有使用这套方案的项目都应该把这个指标通过PerformanceObserver或者定时 postMessage 上报到运营后台。因为这些数据能直接告诉你某个地区玩家卡顿到底是 P2P 打洞失败还是丢包严重还是机房 TURN 带宽不足。没有量化指标联机优化就只能靠猜。5. 兼容性、降级与性能护栏任何网页项目都逃不开浏览器兼容性WebRTC 尤其敏感。虽然主流浏览器都支持 WebRTC但同一套代码在不同平台的表现差异极大。OmniGame 从设计之初就把“降级”当成一等公民。5.1 WebRTC 支持检测与降级三档在初始化 OmniGame 时会先执行一个能力检测函数检测RTCPeerConnection、RTCDataChannel、navigator.mediaDevices可选是否存在。根据检测结果网络层可以选择三种模式完整 P2P 模式支持 WebRTC两个玩家之间直接 Point-to-PointWebSocket 中继模式不支持 WebRTC 或 NAT 打洞全失败时退回信令服务器中转单机模式最终兜底游戏本地运行不参与联机。中继模式的数据格式与 P2P 模式完全一致底层传输被抽象成了一个Transport接口。Transport有send()、onMessage()、close()三个方法WebRTC DataChannel 和 WebSocket 都实现这个接口。这一点非常关键——如果不做抽象中继模式就要重写整个同步层工作量至少翻倍。5.2 低端机的动态分辨率与粒子削减网页小游戏往往面向大量中低端安卓设备。OmniGame 做了一个动态性能控制器逻辑很简单记录最近 60 帧实际帧率如果平均帧率低于 45 FPS就把渲染分辨率比例从 1.0 降到 0.75如果仍然低继续降到 0.5。分辨率降低是指内部离屏 Canvas 的尺寸缩小然后拉伸到容器显示并不是改画布 CSS 尺寸。粒子系统的密度也会跟随性能档位缩放。性能档位低于 0.75 时直接把粒子的最大数量从 200 降到 60同时关闭高精度阴影等效果。这里有一个优化技巧粒子数量削减后还要同步降低粒子的发射频率否则粒子生命周期变长会导致画面反而更卡。5.3 移动端浏览器的隐藏限制移动端不能想当然地认为跟桌面端一样。第一个大坑是页面后台挂起。当玩家切到微信回个消息再切回来可能已经过了十几秒此时 Chrome 和 Safari 会暂停requestAnimationFrame和 WebRTC 的 DataChannel 回调。OmniGame 在visibilitychange事件里做了状态标记页面恢复可见时先补发一次快照请求再继续游戏循环。绝对不要依赖“切回来之后数据自然会到”这个假设。第二个大坑是音频自动播放策略。很多小游戏有 BGM但 iOS Safari 要求用户交互后才能调用AudioContext。OmniGame 的做法是首帧检测到AudioContext.state suspended时在用户第一次点击页面时统一 resume并把整个游戏的状态机建立在“用户已经开始交互”的时间点之后。5.4 自动化测试无头浏览器可以跑通 WebRTC 吗开发阶段最痛苦的是不能手动验证两个浏览器之间的真实网络行为。后来我用 Playwright 拉起两个 Chromium 页面模拟加入同一个房间实现了完整的自动化握手测试。流程很简单页面 A 创建房间并展示 offer JSON页面 B 读取这个 JSON 并回传 answer然后两个页面通过RTCPeerConnection完成连接并互发消息。自动化测试的价值非常大。OmniGame 的 CI 流水线里每次提交都会跑一遍这条握手链路确保任何修改没有破坏最核心的传输能力。需要注意无头浏览器默认可能禁用了部分 WebRTC 能力需要在启动参数里开启--enable-featuresWebRtcHideLocalIpsWithMdns等标志否则测试环境会跟生产环境行为不一致。6. 踩坑实录六个让团队反复熬夜的问题零依赖和 P2P 的这条路线我们走了大半年。下面这六个坑每一个都是真实碰到、并且花了不少时间才排查清楚的。写在这里是希望大家可以直接绕过去。6.1 手动复制 Offer 时忽略了 Trickle ICE最开始做演示版本时我让测试同学手动复制 offer 给对方对方粘贴后才调用setRemoteDescription。但实际上输入框粘贴的时间差里ICE candidate 还在源源不断地收集。如果我按照旧的“等收集完再发”逻辑就会漏掉后来产生的 candidate导致两边都卡在checking状态。解决方式是把 candidate 的发送改成异步流式onicecandidate一触发就立刻组装消息发送不再等 offer/answer 完成。手动模式下就明文提示用户先粘贴 offer再陆续粘贴 candidate。这个改动让局域网连接的建立时间从 8 秒降到 1 秒以内。6.2 DataChannel 的超时参数理解错了DataChannel 创建时可以配置ordered、maxRetransmits和maxPacketLifeTime。我最初的直觉是“游戏状态同步用不可靠通道所以把ordered: false, maxRetransmits: 0设置好就行”。结果发现高丢包环境下大量过期状态包被丢弃后客户端状态会出现短暂乱序甚至回跳。原因在于状态同步需要的不是“绝对不可靠”而是“允许丢弃旧包但保持最新包尽快到达”。最终我采用了两条通道控制消息走可靠的 ordered 通道状态同步走ordered: false, maxRetransmits: 2的通道。这样既保证了重连信令和快照不丢又能让大部分低价值状态包快速收发。6.3 主机迁移后状态不一致第一次实现主机迁移时我简化处理备份主机直接“从当前本地状态开始接管”。结果迁移后几个玩家的画面里出现同一个敌人的位置完全不一致的情况。因为备份主机的本地状态并没有包含另一个玩家已经发送但还没被原始主机处理完的输入。修复方案是引入权威快照 输入日志回放。备份主机接管的瞬间先快速向所有玩家广播“请求最近 2 秒输入日志”然后应用这些日志重放到最新状态。这个过程一般耗时 200-500ms之后才重新开始正常状态广播。玩家体感是短暂停顿但不会出现不可逆的世界错乱。6.4 ArrayBuffer 的底层复用导致数据被覆盖WebRTC DataChannel 发送ArrayBuffer时很多浏览器并不会立即拷贝内存。如果你把一个ArrayBuffer先后塞进多个待发送消息队列当底层网络缓慢时后一次写入的内容会直接覆盖前一次还未发送完的数据造成接收端收到半新半旧的二进制块。OmniGame 的修复策略很简单所有网络消息在进入队列前必须做一次拷贝或者干脆使用Uint8Array.slice()生成独立副本。这个拷贝在发送高频状态时会带来少量 CPU 开销但完全可以接受数据完整性比那点儿性能重要得多。6.5 iOS Safari 上 PeerConnection 状态与页面生命周期纠缠iOS 上经常出现这种场景用户切出页面再回来peer.connectionState还显示 connected但 DataChannel 已经收不到任何消息。原因是浏览器为了省电在后台挂起时部分网络任务被冻结恢复后并不会自动重新建立链路。处理办法是每次页面从后台回到前台时主动发送一次 keepalive如果 3 秒内没有收到响应就强制close()旧的RTCPeerConnection然后重新走完整握手流程。这个重连动作要做得非常快所以客户端始终保留着最近的信令服务器连接方便随时换发新的 offer。6.6 Date.now 做时间戳导致同步算法反向修正早期同步模块用Date.now()打时间戳结果有个玩家在游戏过程中手动把系统时间往前调了 5 分钟整个对齐算法立刻把所有人的状态往回拉画面里角色全在倒退。后来把时间基准全部替换为performance.now()它基于进程启动后的单调时钟不受系统时间调整影响。这个问题提醒我游戏时钟与系统墙钟绝对不能混用尤其是联机游戏里的同步时钟。如果你也准备走零依赖 WebRTC P2P 路线这六个坑基本就是入门门票。踩过之后真的会少走很多弯路。回头看 OmniGame 这个项目最让我欣慰的不是最终跑通了 P2P 联机而是团队里所有人都重新认识了“浏览器原生能力”到底有多强。Canvas 2D 加一个自写的 400 行数学库能撑起 60 FPS 的弹幕对战WebRTC DataChannel 能在没有服务器转发的情况下让两个客户端稳定通信零依赖的约束反而逼着我们做出了更干净的模块划分。工程上限不是看你能堆多少个库而是看你在有限依赖下能把每一层都做到多扎实。如果你也在做一个网页小游戏项目我特别建议你拿其中一个模块试一次零依赖实现体验一下这种可控感带来的踏实。
返回列表