ARTICLE DETAIL

资讯详情

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

零依赖网页小游戏:用WebRTC P2P实现多人联机的工程实践

零依赖网页小游戏:用WebRTC P2P实现多人联机的工程实践 做网页小游戏这几年我越来越觉得“工程化”对小项目来说是双刃剑。一提到做游戏大多数人默认要引框架、配构建链、接引擎光是让“Hello World”跑起来就要花掉一个晚上。而 OmniGame 这个项目的出发点恰恰相反——它想验证一件近乎反潮流的事一个正经能玩、能多人联机的网页小游戏能不能做到零第三方依赖并且用浏览器原生的 WebRTC 建立 P2P 连接让玩家和玩家的浏览器直接互传数据答案是能而且效果比预期好不少。整个项目跑完之后我重新理解了“网页小游戏的工程上限”这句话上限不取决于你引了多少工具而取决于你对浏览器原生能力的掌控程度。这篇文章就把 OmniGame 从零到一的工程决策、核心实现和踩坑过程全部拆开包括零依赖的项目骨架怎么组织、WebRTC 信令握手怎么设计、P2P 联机里数据通道怎么跑、延迟补偿怎么做以及我实测下来的参数和经验。不管你是想做一个“复制链接就能玩”的小游戏还是想了解 WebRTC 在游戏场景里的落地姿势这篇都可以直接抄作业。1. OmniGame 到底做了什么零依赖 P2P 的组合拳1.1 网页小游戏的现状大多数人一上来就引框架现在的网页小游戏市场很有意思。用户要的其实特别简单点开链接就玩不要下载不要注册最好连加载进度条都别让我看太久。mikutap 这类音乐节奏小游戏之所以能爆火就是因为它是纯 HTML JS 的产物打开网页立刻就是节奏点一分钟内就能完成一次完整体验。但开发者这边完全是另一个画风。不做复杂画面就先用 Canvas 加个循环行不行行。可一旦团队习惯成型大家会下意识地在项目里塞进 Vue/React、引入 Phaser/Cocos 引擎、配置 Vite 打包、装一堆 npm 包。这一套组合拳打下来一个“双击 HTML 就能跑”的轻巧项目变成了一个依赖树深不见底、构建日志几百行的前端工程。最尴尬的是这些复杂度换来的能力在小游戏场景里大部分时间用不上。我做 OmniGame 时故意把这一整套全砍掉整个游戏逻辑、渲染、音频、网络全部基于浏览器原生 API结果项目意外地清爽不用管依赖冲突不用管框架升级代码放在那里两年再打开也不会坏。我并不是说框架不好。而是想说网页小游戏服务的是“轻量、即开即玩、低门槛”的需求这种需求本来就不需要重武器。零依赖不只是技术洁癖它是一种主动的工程约束当你的每个能力都来自浏览器时你能拿到的就是一整套经过多年沉淀、跨端兼容的升级版 API并且这些 API 没有供应链风险没有版本碎片。对“5 分钟一局”的小游戏来说这是最高效的起点。1.2 WebRTC P2P 给网页小游戏带来什么变量单机小游戏做得再精致终归是“一个人自嗨”。一旦想加入联机传统路线通常是 WebSocket 服务器中转客户端把手势指令发给服务器服务器再分发给房间里的其他玩家。这套模式成熟、稳定但有两个非常现实的问题。一是延迟翻倍一份数据从 A 到 B 必须绕行服务器尽管服务器通常放在 IDC 里物理距离还是会让体感打折二是服务器带宽和并发是长期成本玩家人数一多机器成本直线上升个人开发者和小型团队很难放心往里砸钱。WebRTC 的出现改变了这个局面。它本身就是浏览器原生的点对点通信能力游戏数据可以在玩家之间直连互传不需要经过应用层服务器。OmniGame 在做联机时正是围绕 WebRTC 的 RTCPeerConnection 和 RTCDataChannel 来搭建的。P2P 带来的最直接收益就是延迟逼近物理极限假设两个玩家都在国内同一城市公网直连的延迟通常在 20 到 50 毫秒之间比任何服务器中转方案都低。当然P2P 不是没有代价。WebRTC 建立连接前需要一个信令服务来交换双方的 SDP 和 ICE 候选信息这个服务只负责“握手撮合”不碰游戏数据而且在某些 NAT 网络环境下P2P 打洞会失败必须退回到 TURN 中继。这两个问题我在第 3 章和第 5 章会详细讲。这里先给出结论对于 2 到 8 人的轻量对战、协作小游戏WebRTC P2P 在体验上、成本上、工程复杂度上都是一个可以认真考虑的选择它让“没有后端”的单人小游戏一下子跃迁成了“没有游戏服务器”的多人小游戏。1.3 “重新定义工程上限”这句话的落点很多人看到“零依赖 WebRTC P2P”这个组合第一反应是“炫技”。但 OmniGame 想证明的恰恰相反这是一种被低估的工程务实。当你不依赖框架时你会发现浏览器的原生能力已经走了非常远Canvas 2D 足够应付绝大多数轻量场景Web Audio API 能完成音频合成RTCPeerConnection 能直接打通端到端连接。零依赖不是“从简”而是“足够深地理解浏览器之后用最小集合实现最大能力”。所谓工程上限在这个项目里具体落在几件事上模块化的代码组织、多人联机的可靠握手、丢包乱序下的游戏状态同步、跨浏览器兼容的兼容适配、以及几十 KB 级别的加载体积。把这五件事在零依赖的约束下全部跑通比“引引擎后一键生成房间”难得多但得到的控制感和迁移自由也完全不同。这整篇文章的每一章本质上都是在讲这个上限是怎么被一点点抬高的。2. 零依赖工程骨架不用框架项目照样有条理2.1 项目结构与模块化ES Modules 就是免费的模块系统零依赖不意味着代码可以随便堆。最关键的第一步是选好模块机制。现代浏览器全都支持 ES Modules所以 OmniGame 直接在浏览器里用 import / export 来组织代码完全不需要打包器。项目目录是这样规划的omni-game/ ├─ public/ │ ├─ index.html │ ├─ css/ │ │ └─ main.css │ └─ js/ │ ├─ main.js │ ├─ engine/ │ │ ├─ loop.js │ │ ├─ render.js │ │ ├─ input.js │ │ └─ assets.js │ ├─ game/ │ │ ├─ state.js │ │ ├─ entities.js │ │ └─ config.js │ ├─ net/ │ │ ├─ signaling.js │ │ ├─ peer.js │ │ └─ protocol.js │ └─ ui/ │ └─ screens.js └─ server/ └─ signaling.js浏览器加载模块只需在 HTML 里写一行script typemodule src./js/main.js/scriptmain.js 再按需 import 其他模块即可。这样做的好处是依赖关系一目了然任何人打开项目都能看到清晰的层次engine 负责底层循环和渲染game 负责游戏逻辑net 负责人联机ui 负责界面。有人问不用 TypeScript 怎么保证代码质量我在 OmniGame 里的做法是给关键函数写 JSDoc 注释IDE 能直接识别类型提示。对于这种规模的项目JSDoc 的收益已经超过引入 TypeScript 编译链的成本。还有个小细节开发期不需要任何构建工具一条python3 -m http.server 8080或者随便一个静态服务器命令就能跑起来。如果你想追求“双击 HTML 就能玩”把代码合并成一个 script 标签也是分分钟的事零依赖的意义就在这里——你随时可以退回到最原始的形态不会因为“没装依赖”而瘫痪。2.2 游戏循环为什么要写固定时间步长小游戏的心脏是游戏循环。初学者喜欢用setInterval(fn, 16)模拟 60FPS这在小游戏里是典型的坑。浏览器对 setInterval 没有帧同步概念后台标签页还会强制节流计时器会漂移帧率不稳的时候游戏逻辑也会跟着抽风。OmniGame 用的是requestAnimationFrame 固定时间步长fixed timestep方案。核心思路rAF 负责提供准确的帧时间戳逻辑更新则每次固定推进 16.667ms渲染可以每帧执行。代码如下export class GameLoop { constructor(update, render, step 16.667) { this.update update; this.render render; this.step step; this.accumulator 0; this.lastTime performance.now(); this.rafId 0; this.running false; } start() { this.running true; this.lastTime performance.now(); this.rafId requestAnimationFrame(this.tick); } tick (now) { if (!this.running) return; let frameTime now - this.lastTime; this.lastTime now; // 防止切后台回来时 accumulator 一下子堆积太多帧 if (frameTime 250) frameTime 250; this.accumulator frameTime; while (this.accumulator this.step) { this.update(this.step / 1000); this.accumulator - this.step; } this.render(); this.rafId requestAnimationFrame(this.tick); }; stop() { this.running false; cancelAnimationFrame(this.rafId); } }这里的step / 1000就是每次更新对应的秒数所有游戏逻辑里的速度都用这个值计算保证任何刷新率下角色速度一致。上限 250ms 的判断很重要否则用户切换标签页回来时循环会瞬间补算几十帧轻则卡顿重则物理穿透。这个细节如果少了你会在联机对战时遇到“切出去再切回来我的角色飞出了地图”这种奇奇怪怪的 bug。2.3 Canvas 渲染与资源策略零依赖不等于零优化渲染层选了 Canvas 2D这是零依赖小游戏最稳妥的选择。相比 WebGLCanvas 2D API 上手成本低浏览器兼容性好性能对 2D 小游戏完全够用。不过 Canvas 2D 也有自己的性能陷阱最典型的就是 fillStyle/strokeStyle 切换。Canvas 的绘制状态切换成本很高每帧里频繁改颜色、切字体比多画几个圆还慢。我的做法是所有实体先按种类排序一次性设置好绘制状态再批量画完避免同帧内反复横跳。Retina 屏的适配也是老生常谈。直接按 CSS 像素画高分屏上会糊。我在 render.js 里统一做了缩放const dpr window.devicePixelRatio || 1; canvas.width canvas.clientWidth * dpr; canvas.height canvas.clientHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);画布的逻辑尺寸始终用 CSS 像素渲染时系统自动按物理像素输出既保持清晰度又不用改游戏逻辑里的坐标。资源加载方面OmniGame 做了一个非常小的 manifest 机制图片资源清单写在一个 JSON 里启动时 Promise.all 并行加载配合一个简单的进度条。相比直接在代码里 new Image() 后赌运气这种清单式管理更可控需要加资源时不用改代码逻辑。音频则用 Web Audio API 合成小体积的提示音效全部用振荡器生成不额外加载音频文件这也是保持“复制链接就能玩”理念的一部分。2.4 UI 切换与场景管理别把显示逻辑焊死在游戏循环里零依赖项目最常见的失控点是游戏逻辑和 UI 逻辑混在一起。OmniGame 把 UI 层单独拆成 screens.js每个场景主菜单、房间、对战、结算是一组独立的 DOM 容器切换场景时只做看板和隐藏游戏循环只关心当前场景是否允许更新。这个简单的分层避免了很多后期维护时的痛苦尤其是联机对战时房间状态和战斗画面是两个节奏完全不同的系统混在一起必然是灾难。3. WebRTC P2P 联机模块从信令握手到数据通道3.1 信令服务只做媒人不碰游戏数据WebRTC 有一个特性常被人误解它号称“P2P”但建立连接之前必须有一个“带外”通道让双方交换元数据。这个通道叫信令服务signaling。在 OmniGame 里信令服务是一个独立的小型 Node.js 服务用 WebSocket 实现只做两件事维护房间成员列表、转发 SDP 与 ICE Candidate。房间机制我采用了最简单的邀请码制。房主创建一个房间得到一个 6 位数字码其他人输入码加入。服务端代码的核心逻辑如下import { WebSocketServer } from ws; const wss new WebSocketServer({ port: 8787 }); const rooms new Map(); wss.on(connection, (ws) { ws.on(message, (msg) { const data JSON.parse(msg); if (data.type create) { const roomId Math.floor(100000 Math.random() * 900000).toString(); rooms.set(roomId, new Set([ws])); ws.send(JSON.stringify({ type: created, roomId })); ws.roomId roomId; } if (data.type join) { const room rooms.get(data.roomId); if (!room) { ws.send(JSON.stringify({ type: error, message: 房间不存在 })); return; } ws.roomId data.roomId; room.add(ws); // 通知房主有新成员加入 for (const member of room) { member.send(JSON.stringify({ type: peer-ready, count: room.size })); } } if (data.type signal) { // 把信令数据转发给指定 peer不解析内容 const sender ws; for (const member of rooms.get(ws.roomId) || []) { if (member ! sender) { member.send(JSON.stringify({ type: signal, payload: data.payload })); } } } }); ws.on(close, () { const room rooms.get(ws.roomId); if (room) { room.delete(ws); if (room.size 0) rooms.delete(ws.roomId); } }); });关键设计信令服务对 SDP 和 ICE 数据完全“黑盒”转发不解析、不落地、不缓存。这样保证信令服务的实现极简同时游戏数据永远不会经过它。开发时直接node server/signaling.js起服务简单可靠。有人问如果不想维护信令服务能不能用免费公共信令服务可以比如 PeerServer 这类现成方案但那就引入了第三方依赖和 OmniGame 的零依赖理念冲突。而且自建信令服务非常简单20 行核心代码完全没有偷懒的必要。3.2 RTCPeerConnection 与 RTCDataChannel 初始化全流程WebRTC 的连接建立流程可以浓缩成四句话创建 RTCPeerConnection生成 offer交换 SDP交换 ICE Candidate。我用一个统一函数把 host 和 client 两侧的对称式代码封装在一起function createPeer(isHost) { const config { iceServers: [ { urls: stun:stun.l.google.com:19302 }, // TURN 按需配置见 3.3 ], }; const pc new RTCPeerConnection(config); let channel null; if (isHost) { // 房主主动创建 DataChannel channel pc.createDataChannel(game, { ordered: false, maxRetransmits: 0, }); setupChannel(channel); } else { // 加入房间的客户端等待房主创建 channel pc.ondatachannel (ev) { channel ev.channel; setupChannel(channel); }; } pc.onicecandidate (ev) { if (ev.candidate) { sendSignal({ ice: ev.candidate }); } }; return { pc, getChannel: () channel }; } async function startOffer(pc) { const offer await pc.createOffer(); await pc.setLocalDescription(offer); sendSignal({ sdp: pc.localDescription }); } async function handleOffer(pc, sdp) { await pc.setRemoteDescription(sdp); const answer await pc.createAnswer(); await pc.setLocalDescription(answer); sendSignal({ sdp: pc.localDescription }); }这里的 sendSignal 就是上文的信令通道。整个握手过程是一个状态机我在 peer.js 里维护了一个简单状态字段从new → connecting → connected → closed流转UI 层根据状态显示“连接中 / 已连接 / 已断开”。很多新手写 WebRTC 时容易忽略状态机结果连接失败后界面没有任何反馈用户一脸懵。关于 DataChannel 参数我用了ordered: false, maxRetransmits: 0也就是“无序 不可靠”通道这非常适合实时游戏游戏指令是时间敏感的旧指令重传没有任何意义——丢失的一帧输入你补发一帧“两帧前的输入”反而会把游戏状态搞乱。初始化流程里如果需要可靠通道比如交换玩家昵称、房间配置我会再创建一个ordered: true的 channel和实时指令通道隔离各司其职。3.3 ICE 与 STUN/TURN能否连上主要看这几项ICEInteractive Connectivity Establishment是 WebRTC 打洞的核心机制。一个 RTCPeerConnection 会收集三类 candidate候选类型来源含义host本机网卡直连 IP比如局域网内互打srflxSTUN 反射NAT 映射后的公网地址打洞成功的关键relayTURN 中继中继服务器的转发地址兜底方案两端拿到彼此的候选列表后会尝试所有候选对的连通性检查。如果双方都在一个局域网里host 类型的候选直接就连上了速度极快如果双方在不同公网 NAT 后面则依赖 srflx 候选打洞如果打洞失败且配了 TURN则退化为 relay 中继。STUN 的作用是“让浏览器知道自己的公网 IP端口”。我在 OmniGame 里沿用了 Google 公共 STUNstun:stun.l.google.com:19302开发联机时很方便。但注意STUN 不是万能的如果你的网络是对称 NAT很多公司网和校园网就是这样打洞成功率会明显下降。这时候只有两条路换网络环境或者配置 TURN。TURN 可以理解为“WebRTC 最后的救生圈”。免费的公共 TURN 服务基本不可靠自建的话可以部署开源 coturn 方案配置也不复杂。我的建议是原型阶段先用 STUN楼起来后再决定要不要为所有玩家撑 TURN。因为 TURN 服务器需要带宽成本只有当打洞失败率显著影响玩家体验时才值得投入。调试时打开chrome://webrtc-internals可以看到两端的 candidate 类型和 ICE 状态这是排查一切连接问题的第一现场。还有一点很多开发者在 localhost 下测试时一切正常因为浏览器把 localhost 当作安全上下文且本地网络环境不存在 NAThost candidate 直接通了。一旦部署到公网、两个真实玩家连接时才发现一堆问题。所以 WebRTC 联机功能必须提前放到真实公网环境测试end-to-end 打通一次后再谈优化。3.4 游戏协议DataChannel 里到底传什么DataChannel 提供的是“通道”传什么格式完全由你决定。最初 OmniGame 图省事直接传 JSON后来发现虽然在开发期方便但每 50ms 一条 JSON 的序列化开销其实并不小而且包体大对移动网络的弱网环境很不友好。后期我把实时指令改成了二进制协议。一条玩家输入帧的二进制结构长这样// 7 bytes4字节时间戳 1字节按键位域 2字节坐标增量 const buf new ArrayBuffer(7); const dv new DataView(buf); const timestamp performance.now(); dv.setFloat32(0, timestamp); dv.setUint8(4, keyMask); // 每个bit代表一个按键比如0x01左0x02右 dv.setInt16(5, deltaX); // 左右移动的增量 channel.send(buf);游戏状态快照也做了类似压缩每个实体固定写入坐标、速度、血量这几个关键数据。整个快照算下来通常不超过 64 字节每 50ms 发一次带宽占用几乎可以忽略不计。这带来的体感是延迟更低、弱网下更抗丢包。这里有个重要的设计原则PKT 时间戳一定要用单调时钟performance.now()而不是Date.now()。Date.now()依赖系统时钟可能被 NTP 校准甚至用户手动改时间会导致插值缓冲直接错乱。performance.now()是单调递增的从页面加载开始计时双端各用自己的时间戳但只需要相对偏移量就能对齐。简易做法是收到第一条快照时记录偏移后续都按偏移修正。4. P2P 游戏同步实战主机权威 客户端预测4.1 为什么不用帧同步P2P 没有权威帧同步容易把局面搞崩做联机游戏很多人第一反应是“帧同步”。帧同步的核心理念是所有客户端播同一段输入序列每个端各自运行完全确定性的模拟只要初始状态一致、输入一致、算法完全确定性各个端的世界就会一直一致。听起来很完美但我强烈不建议在 P2P 小游戏里用帧同步。原因有三。第一浏览器端的游戏逻辑很难做到严格确定性浮点数运算在不同设备上可能会有细微误差JavaScript 的对象遍历顺序、Math.random() 如果要确定性必须自己实现伪随机器这些细节会不断在“看似无关紧要”的地方制造分歧。第二帧同步对丢包处理要求极高一个关键输入帧丢了、重传晚到整个模拟就从分歧点开始永久分叉。第三P2P 网络没有权威节点玩家之间的物理延迟不一致帧同步还需要每帧等最慢的那个人整体节奏会被拖累。所以 OmniGame 采用的方案是主机权威Host Authority。房主的浏览器是“服务器”完整跑游戏世界逻辑加入游戏的客户端只负责两件事发自己的输入指令接收房主广播的状态快照并渲染。这个模型天然适合小游戏实现简单又不牺牲手感。4.2 主机权威 客户端预测实现一个够用的插值缓冲主机权威模式下最直接的做法是客户端每收到一帧快照就把角色坐标“瞬移”到快照位置。坏处是快照频率如果不够高客户端看到的都是“跳帧”卡顿明显。解决手段是采用插值缓冲客户端不直接使用最新快照而是把快照存在一个有延迟的缓冲队列里渲染时按时间轴在两个历史快照之间做线性插值。插值缓冲的简化实现如下const snapshots []; const BUFFER_SIZE 8; function pushSnapshot(snap) { if (snapshots.length BUFFER_SIZE) snapshots.shift(); snapshots.push(snap); } function renderInterpolated(nowMs, entity) { for (let i 1; i snapshots.length; i) { const prev snapshots[i - 1]; const next snapshots[i]; if (nowMs prev.ts nowMs next.ts) { const t Math.min(Math.max((nowMs - prev.ts) / (next.ts - prev.ts), 0), 1); entity.x prev.x (next.x - prev.x) * t; entity.y prev.y (next.y - prev.y) * t; return; } } }缓冲队列本质上是有意引入 50~100ms 的渲染延迟换来的是平滑的运动轨迹。插值缓冲对“其他人的运动”效果很好但对于玩家自己控制的那个角色就不够用了没有人希望自己按方向键后 100ms 才看到角色转向。所以客户端预测的核心是本地先跑一份“乐观世界”玩家输入立即改变自机位置同时上报给主机主机回传快照后本地把预测误差平滑纠正回去。我在 OmniGame 里做了一套极简的误差修正核心逻辑是如果本地预测和主机快照的位置偏差超过一个阈值就用线性插值在两三帧内把角色拉回正确位置偏差小时不强行纠正保留本地手感的流畅性等下一个快照自然修正。这种“按需纠正”的策略在小游戏里完全够用体验远好于每帧硬性对齐。4.3 参数调优实测经验同步参数没有标准答案但有一套从 OmniGame 实测中总结出的起点配置参数取值说明状态快照频率20~30 次/秒单人/平缓场景取 20对抗/竞速场景取 30输入上报频率30~50 次/秒触摸事件节流后 30键盘高频按下时 50插值缓冲长度2~4 个快照相同于 50~100ms 渲染延迟越小越跟手无序通道ordered: false实时指令丢包不重传可靠通道ordered: true初始化配置、房间信息等实测下来局域网 P2P 的往返延迟通常在 5ms 以内公网直连在 20~80ms手机 4G 网络下 80~150ms。在插值缓冲 客户端预测的双重加持下玩家感知到的“操作跟手感”和“其他人的流畅度”都能保持在一个不错的范围。还有一个容易忽略的参数主机快照发送的时间戳。我建议用performance.now()直接取主机侧的单调时钟并用首帧握手时记录的时间偏移来对齐双端时间轴。否则插值函数里nowMs和prev.ts不在同一个时钟体系插值会变得完全混乱。5. 常见问题与排查技巧实录5.1 连不上如何面对 NAT 打洞失败P2P 联机最容易遇到的就是“明明都在线就是连不上”。遇到这种情况第一件事就是打开 Chrome 的chrome://webrtc-internals看两端的 candidate 和 ICE 状态。根据状态可以快速定位最常见的失败场景是 ICE 状态长时间停在checking没有任何候选对被选中。这时打开 candidate 列表如果双方交换的候选基本都是 host 类型说明无法完成公网打洞如果交换里只有 srflx 却没有 relay则说明没配 TURN。这种情况下要么加 TURN要么让玩家换到同一内网测试。还有一种场景是两个玩家都在同一公司网、同一 NAT 后面host 候选明明互相可达却因为 NAT 的端口限制导致 host 不通这种时候反而是加上 srflx 和 relay 能帮忙绕过去。排查这类问题有个技巧在 WebRTC 状态机里把连接阶段状态打印出来比如new → gathering → checking → connected → completed。哪个阶段卡住就去查哪个阶段对应的配置。前期把状态日志做好后面排查能省一半时间。5.2 浏览器兼容性和后台冻结的坑零依赖项目的另一个隐藏成本是浏览器兼容。虽然现代浏览器的 WebRTC 支持已经非常普及但细节差异依然存在。比如 Safari 的 ICE 候选收集在某些版本里慢得出奇对 trickle ICE 的支持也不如 Chrome 积极微信内置浏览器和部分国产浏览器的 WebRTC 实现是滞后且不标准的。我在 OmniGame 里做了一层能力检测进入联机页面前先RTCPeerConnection是否存在不存在就直接弹提示避免用户卡在“连接中”页面干等。另外ES Modules 在现代浏览器均支持但如果你要兼容特别老的内核零依赖反而成了优势自己写的代码合并成单个 script 标签非常简单不需要为框架跑构建链。还有两个纯浏览器层面的现实问题。第一是后台标签页会冻结requestAnimationFrame和定时器玩家在对战时切走标签页再回来连接可能已经超时断开。解决方案是监听visibilitychange事件页面回到前台时立刻检查连接状态并重建同时在空闲时发心跳包保持链路活跃。第二是有部分用户出于隐私保护会在浏览器设置或插件中主动关闭 WebRTC 功能这对 P2P 游戏来说就是“天然无法连接”的直接原因。这类情况属于用户侧行为游戏侧没法绕过只能在 UI 上给出清晰的提示让用户去检查自己的浏览器设置。5.3 部署与体积控制零依赖项目部署起来出奇地轻游戏静态资源直接丢到任意静态托管GitHub Pages、Netlify、OSS 等信令服务则是唯一需要持续运行的 Node 进程。开发时两者可以在同一台机器上跑上线后建议分开信令服务的 WebSocket 需要一个支持长连接的运行环境普通的云函数对 WebSocket 支持不友好优先考虑轻量应用服务或者容器。体积方面的数据很有意思OmniGame 的整个游戏逻辑代码压缩后约 60KB远小于一个典型 Vite React 项目的首屏产物。加载速度带来的体验差异是决定性的玩家点开链接后几乎零等待直接进入主菜单。这个数字本身就是零依赖理念的胜利你不用做太多性能优化就天然处在一个非常低的开销区间。6. 写在最后几个只有踩过坑才写得出的经验做完 OmniGame 最深的体会是零依赖的真正价值不是“不用装包”这个表象而是它逼着你在更高的层面思考每一个功能——游戏循环为什么这样写数据通道为什么这样配同步为什么选这个模型。你没法靠调用一个现成引擎来逃避这些决策弯路走得多了反而把浏览器原生 API 的边界摸得很透。如果让我给准备做网页小游戏的人一个建议先在零依赖里把雏形跑通再考虑要不要引引擎。这个项目最终能跑通 P2P 联机说明浏览器原生能力已经比想象中走得远得多。另外一个实操层面的小技巧是从最开始就做一套浏览器层面的状态日志埋点WebRTC 连接阶段的每个状态转变都印出来。这套日志在后续排查中会被反复用到而且零依赖项目的代码控制力够强加这种埋点成本极低收益却巨大。
返回列表