1. 为什么我要把网页小游戏做成"零依赖 + P2P"这套组合
先说结论:OmniGame 这个项目,本质上是在回答一个问题——一个网页小游戏,能不能不依赖中心服务器、不依赖一堆 npm 包、不依赖后端接口,就能跑起来、还能联机对战?我花了大概三周时间把这件事从想法做成了能跑的原型,中间踩的坑比我想象的多得多,但最后跑通的那一刻,确实有种"原来网页还能这么玩"的感觉。
这个项目的核心关键词其实就几个:WebRTC、P2P、Shadow DOM、Next.js、Tailwind CSS。听起来像是把当下最热的一堆技术堆在一起,但实际上每一个选型背后都有非常具体的理由,不是赶时髦。WebRTC 负责点对点通信,P2P 是它的通信模型,Shadow DOM 解决的是"游戏组件和宿主页面样式打架"这个老问题,Next.js 提供工程骨架和路由,Tailwind CSS 负责把 UI 快速搭出来。这五样东西凑在一起,刚好覆盖了"一个网页小游戏从开发到联机"的完整链路。
适合谁来读这篇东西?如果你写过一点前端,做过小游戏 demo,或者对"不靠服务器怎么让两个浏览器直接通信"这件事好奇,那这篇就是写给你的。如果你是完全的新手,也能看懂,因为我会把每个概念用生活化的方式讲一遍,再给可复现的步骤。我不打算写成教科书,就按我自己做项目的顺序,把思路、选型、代码、踩坑一条条摊开讲。
需要提前说明的是,OmniGame 目前还是一个偏工程验证性质的项目,不是商业级产品。它的价值在于把"零依赖 + P2P 联机"这条技术路线跑通并验证可行性,而不是提供一个开箱即用的游戏平台。理解这一点,后面的很多取舍你就能看懂了。
2. 整体架构设计与技术选型拆解
2.1 为什么是"零依赖",而不是"少依赖"
"零依赖"这个词容易被误解。它不是说我一个 npm 包都不用,而是指运行时的核心逻辑不依赖任何第三方运行时库——没有游戏引擎、没有网络中间件、没有状态管理库。Next.js 和 Tailwind 是构建期和样式层的工具,它们不参与游戏运行时的核心逻辑。
我这么设计的原因很直接:网页小游戏最大的敌人是"体积"和"不确定性"。你引入一个游戏引擎,动辄几百 KB,还得跟着它的生命周期走;你引入一个网络库,它可能封装了 WebRTC,但封装层一旦出问题,你排查起来比裸写还痛苦。我试过用某款流行的 2D 引擎做原型,结果光是搞清楚它的资源加载时序就花了两天,最后果断砍掉,改成裸写 Canvas + 原生 WebRTC。
零依赖带来的直接好处有三个。第一,可控性:每一行网络代码、每一帧渲染逻辑都在我手里,出问题能定位到具体行。第二,体积小:构建产物里没有冗余的运行时,首屏加载快。第三,可移植:核心逻辑是纯 JS,理论上可以搬到任何支持 WebRTC 的环境里,不被框架绑架。
当然代价也有。没有引擎,碰撞检测、精灵动画、输入处理都得自己写。我的做法是只实现"够用"的部分——AABB 碰撞、简单的帧动画、键盘和触摸输入,加起来不到 400 行。对于小游戏来说,这比引入一个引擎划算得多。
2.2 WebRTC P2P:为什么不用 WebSocket 中转
这是整个项目最核心的技术决策。传统网页联机游戏几乎都用 WebSocket:客户端连服务器,服务器转发消息。这套方案成熟、稳定,但有个绕不开的问题——你得有服务器。服务器要钱、要维护、要处理并发,对于一个小游戏项目来说,这是很重的负担。
WebRTC 的 P2P 模型不一样。它让两个浏览器直接建立连接,数据从一个浏览器直接流到另一个浏览器,中间不经过你的服务器(除了建立连接时的信令交换)。这意味着:一旦连接建立,你的服务器压力几乎为零,延迟也更低,因为数据不用绕一圈。
但 WebRTC 不是银弹,它的复杂性主要在两个地方。第一是信令:两个浏览器要建立连接,必须先交换 SDP(会话描述)和 ICE candidate(网络候选地址),这个交换过程需要一个"信令通道"。WebRTC 本身不规定信令怎么传,你可以用 WebSocket、可以用 HTTP 轮询,甚至可以用复制粘贴。我选的是最轻量的方案——一个极简的 WebSocket 信令服务,只负责转发几十字节的握手信息,连接建立后它就"退休"了。
第二是NAT 穿透。两个浏览器之间可能隔着路由器、防火墙,直接连不上。这时候需要 STUN 服务器帮忙发现公网地址,实在不行还得用 TURN 服务器中转。STUN 是免费的(有公共的),TURN 要自己搭。对于小游戏这种对延迟敏感、但对可靠性要求没那么极端的场景,我的策略是:优先直连,连不上就降级。实测下来,同一局域网内基本 100% 直连,跨网络大概 70%-80% 能直连成功,剩下的走 TURN 中转也能玩,只是延迟高一点。
提示:WebRTC 的握手信息里会包含本机的网络候选地址,这是协议本身的工作机制。在开发调试时注意不要把这些信息打到公开日志里,正式环境建议配置 ICE candidate 的过滤策略,只暴露必要的候选。
2.3 Shadow DOM:解决样式污染这个老大难
做过"把游戏嵌进别人页面"的人都知道,样式污染是个噩梦。你的游戏 CSS 可能被宿主页面的全局样式覆盖,你的样式也可能污染宿主页面。传统做法是给所有类名加前缀,或者用 iframe 隔离。前缀法容易漏,iframe 隔离又太重、通信麻烦。
Shadow DOM 提供了第三种方案:样式作用域隔离。你把游戏挂载到一个 shadow root 里,里面的样式和外面的样式互不影响,但又不是 iframe 那种完全独立的文档,通信还是同一个 JS 上下文。这对我这种"游戏要能嵌入任意页面"的需求来说,简直是量身定做。
具体实现上,我用一个自定义元素(custom element)作为游戏容器,在它的attachShadow({ mode: 'open' })里挂载整个游戏。Tailwind 的样式通过一个<style>标签注入到 shadow root 内部。这里有个坑:Tailwind 默认是全局的,直接注入 shadow root 不会生效,需要把编译后的 CSS 字符串手动塞进去。我后面会详细讲这个处理。
2.4 Next.js + Tailwind:工程骨架和样式的分工
Next.js 在这个项目里承担的是"外壳"角色:路由、页面结构、开发服务器、构建打包。我用的是 App Router,因为它的布局和嵌套路由对小游戏的多页面(大厅、房间、游戏页)很友好。Tailwind 负责快速搭 UI,尤其是大厅和房间列表这种"能用就行"的界面,用 Tailwind 写起来比手写 CSS 快得多。
这里要澄清一个常见误解:Next.js 的 SSR(服务端渲染)对游戏本身没意义,因为游戏必须在浏览器里跑。我用 Next.js 主要是图它的工程化能力——热更新、TypeScript 支持、构建优化。游戏核心逻辑全部放在客户端组件里,用'use client'标记,避免 SSR 带来的window未定义问题。
| 技术 | 角色 | 是否参与运行时核心逻辑 | 选它的核心理由 |
|---|---|---|---|
| WebRTC | P2P 通信 | 是 | 直连低延迟,无需中转服务器 |
| Shadow DOM | 样式隔离 | 是 | 嵌入任意页面不污染 |
| Next.js | 工程骨架 | 否 | 路由、构建、开发体验 |
| Tailwind CSS | 样式工具 | 否 | 快速搭 UI,配合 Shadow DOM 注入 |
| 原生 Canvas | 渲染 | 是 | 零依赖,完全可控 |
3. 核心细节解析与实操要点
3.1 WebRTC 连接建立:从信令到 DataChannel 的完整链路
WebRTC 建立连接的过程,我习惯用一个类比来理解:两个人要打电话,但都不知道对方号码,于是先通过一个中间人交换号码,然后直接拨号通话。中间人就是信令服务器,交换号码就是 SDP 和 ICE candidate 的交换,拨号通话就是 P2P 连接。
具体流程是这样的。发起方(offerer)创建一个RTCPeerConnection,调用createOffer()生成 SDP,通过信令服务器发给接收方(answerer)。接收方收到后调用setRemoteDescription(),再createAnswer()生成自己的 SDP 发回去。与此同时,双方都在收集 ICE candidate(网络候选地址),每收集到一个就通过信令发给对方。当双方都设置好对方的 SDP 和 candidate 后,连接就建立了。
代码上,核心就是这几个 API:
// 发起方 const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.example.com' }] }); const dc = pc.createDataChannel('game'); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); signaling.send({ type: 'offer', sdp: offer }); pc.onicecandidate = (e) => { if (e.candidate) signaling.send({ type: 'candidate', candidate: e.candidate }); }; // 接收方 pc.ondatachannel = (e) => { const dc = e.channel; dc.onmessage = (msg) => handleGameMessage(msg.data); };这里有几个实操要点必须强调。第一,DataChannel 的配置。默认是可靠传输(类似 TCP),但对游戏来说,有时候"丢一帧无所谓,延迟低更重要",这时候可以配置成不可靠模式:pc.createDataChannel('game', { ordered: false, maxRetransmits: 0 })。我实测下来,对于位置同步这种高频数据,不可靠模式体验更好;对于"开始游戏""结束游戏"这种关键指令,用可靠通道。
第二,ICE candidate 的收集是异步的,可能在 SDP 交换完成之后还在继续。所以信令逻辑要能处理"candidate 比 SDP 晚到"的情况,通常做法是先把 candidate 缓存起来,等 remote description 设置好再批量添加。
第三,连接状态监控。pc.onconnectionstatechange会告诉你连接是connecting、connected还是failed。我踩过的坑是:连接失败后没有重连机制,玩家就卡在那里了。后来加了一个简单的重试逻辑,失败后重新走一遍信令流程。
3.2 Shadow DOM 里注入 Tailwind 的正确姿势
前面提到 Tailwind 默认是全局的,注入 shadow root 需要特殊处理。我试过三种方案,最后选了最稳的一种。
方案一是用adoptedStyleSheets,把编译好的 CSS 构造成CSSStyleSheet对象,然后shadowRoot.adoptedStyleSheets = [sheet]。这是最现代的做法,性能好,但需要浏览器支持,而且构建流程要能把 Tailwind 的输出转成可构造的字符串。
方案二是直接在 shadow root 里插<style>标签,内容是 Tailwind 编译后的 CSS 字符串。这个方案兼容性最好,我最后用的就是这个。具体做法是在构建时把 Tailwind 的输出读成字符串,通过一个模块导出,运行时注入。
import tailwindCSS from './tailwind-output.css?inline'; class GameElement extends HTMLElement { connectedCallback() { const shadow = this.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = tailwindCSS; shadow.appendChild(style); // 挂载游戏内容 shadow.appendChild(this.createGameContainer()); } }方案三是用@import在 shadow root 内部引入,但实测下来加载时序不好控制,容易闪一下没样式,不推荐。
注意:Tailwind 的
preflight(基础样式重置)注入 shadow root 后,只影响 shadow 内部,不会影响宿主页面,这正是我们想要的。但如果你在宿主页面也用了 Tailwind,两边的 preflight 会各管各的,不会冲突。
3.3 游戏循环与状态同步的设计取舍
游戏循环我用的是requestAnimationFrame,这是网页游戏的标准做法。核心结构是一个固定时间步长的更新循环:渲染跟着屏幕刷新率走,逻辑更新用固定步长(我用的 60Hz),这样在不同刷新率的设备上物理表现一致。
状态同步是 P2P 游戏最头疼的部分。因为没有权威服务器,两个客户端都可能"自作主张"。我的方案是主机权威(host authoritative):一方作为主机,负责计算游戏状态,把状态广播给另一方;另一方只负责发送输入、渲染收到的状态。这样避免了状态冲突,代价是主机玩家有轻微优势(延迟低),但对小游戏来说可以接受。
同步频率上,我用的策略是输入即时发送,状态按固定频率广播。输入(按键)一有变化就发,保证响应快;状态(位置、分数)每 50ms 广播一次,减少带宽。实测下来,局域网内几乎感觉不到延迟,跨网络大概有 50-100ms 的延迟,对于休闲小游戏够用了。
这里有个细节:状态插值。如果只是每 50ms 收到一次位置,直接渲染会一顿一顿的。我的做法是在两次状态之间做线性插值,让移动看起来平滑。这个技巧在游戏网络编程里很常见,实现起来也就十几行代码。
3.4 零依赖下的碰撞检测与输入处理
没有引擎,碰撞检测得自己写。我用的是最经典的 AABB(轴对齐包围盒)检测,因为小游戏里的物体基本都是矩形,够用了。核心逻辑就是判断两个矩形的 x、y 范围和宽高是否重叠:
function isColliding(a, b) { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; }输入处理上,我用一个keys对象记录当前按下的键,游戏循环里读取这个对象来决定移动方向。这里有个坑:键盘事件的重复触发。按住一个键,浏览器会不断触发keydown,如果不处理,角色会"抖"。解决办法是用一个 Set 记录按下的键,keydown时加入,keyup时移除,游戏循环里只读 Set 的状态。
触摸输入要单独处理,因为移动端没有键盘。我的做法是在屏幕上画虚拟摇杆,用touchstart、touchmove、touchend计算方向向量。这块代码不多,但要注意preventDefault防止页面滚动。
4. 实操过程与核心环节实现
4.1 项目初始化与目录结构
从零开始,第一步是初始化 Next.js 项目。我用的是官方脚手架,选 TypeScript + Tailwind + App Router:
npx create-next-app@latest omnigame --typescript --tailwind --app目录结构我按"游戏核心逻辑和 UI 分离"的原则来组织:
omnigame/ ├── app/ │ ├── page.tsx # 大厅 │ ├── room/[id]/page.tsx # 房间页 │ └── layout.tsx ├── components/ │ ├── GameCanvas.tsx # 游戏渲染组件 │ └── GameElement.ts # Shadow DOM 自定义元素 ├── core/ │ ├── loop.ts # 游戏循环 │ ├── physics.ts # 碰撞检测 │ ├── input.ts # 输入处理 │ └── net.ts # WebRTC 封装 └── signaling/ └── server.js # 极简信令服务这个结构的核心思想是:core/里是纯逻辑,不依赖 React、不依赖 DOM(除了必要的 API),可以单独测试;components/里是 React 和 DOM 相关的胶水代码;app/是页面路由。这样分层之后,游戏逻辑的复用性和可测试性都好了很多。
4.2 信令服务的极简实现
信令服务是整个项目里唯一需要"服务器"的部分,但它的职责极简:转发握手消息,不存储任何状态。我用 Node.js 的ws库写了一个不到 60 行的服务:
const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); const rooms = new Map(); wss.on('connection', (ws) => { ws.on('message', (data) => { const msg = JSON.parse(data); if (msg.type === 'join') { if (!rooms.has(msg.room)) rooms.set(msg.room, []); rooms.get(msg.room).push(ws); ws.room = msg.room; } else { // 转发给同房间的其他人 const peers = rooms.get(ws.room) || []; peers.forEach((peer) => { if (peer !== ws && peer.readyState === WebSocket.OPEN) { peer.send(data); } }); } }); ws.on('close', () => { const peers = rooms.get(ws.room) || []; rooms.set(ws.room, peers.filter((p) => p !== ws)); }); });这个服务的资源占用极低,一个 1 核 1G 的小机器能扛几千个并发连接,因为连接建立后消息量就趋近于零了。这也是 P2P 方案相比传统中转方案最大的优势——服务器成本几乎可以忽略。
提示:信令服务虽然简单,但要注意房间号的管理和连接清理。我踩过的坑是玩家关闭页面后 WebSocket 没及时清理,导致房间列表里出现"幽灵房间"。后来加了心跳检测和超时清理才解决。
4.3 游戏循环的完整实现
游戏循环是整个游戏的心脏,我把它写成一个独立的类,方便复用:
export class GameLoop { constructor(update, render, step = 1000 / 60) { this.update = update; this.render = render; this.step = step; this.accumulator = 0; this.lastTime = 0; this.running = false; } start() { this.running = true; this.lastTime = performance.now(); requestAnimationFrame(this.tick.bind(this)); } tick(now) { if (!this.running) return; const delta = Math.min(now - this.lastTime, 250); // 防止切后台后跳帧 this.lastTime = now; this.accumulator += delta; while (this.accumulator >= this.step) { this.update(this.step); this.accumulator -= this.step; } this.render(this.accumulator / this.step); requestAnimationFrame(this.tick.bind(this)); } }这里的关键设计是固定步长 + 累加器。update用固定步长调用,保证物理模拟稳定;render每帧调用,并把"距离下一次 update 的进度"传进去,用于插值渲染。Math.min(delta, 250)这行是防止玩家切到后台再切回来时,delta 变得巨大导致游戏"瞬移"。
实测下来,这套循环在 60Hz 和 120Hz 屏幕上表现一致,物理不会因为刷新率不同而跑偏。这是很多新手容易忽略的点——直接用delta做物理更新,在高刷屏上角色会跑得更快。
4.4 WebRTC 封装的完整代码
网络层我封装成一个Net类,对外暴露connect、send、onMessage几个方法,内部处理信令和连接状态:
export class Net { constructor(signalingUrl, roomId) { this.signalingUrl = signalingUrl; this.roomId = roomId; this.pc = null; this.dc = null; this.handlers = { message: [], open: [], close: [] }; } async connect() { this.ws = new WebSocket(this.signalingUrl); this.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); this.pc.onicecandidate = (e) => { if (e.candidate) this.sendSignal({ type: 'candidate', candidate: e.candidate }); }; this.pc.ondatachannel = (e) => this.setupDataChannel(e.channel); this.ws.onmessage = async (e) => { const msg = JSON.parse(e.data); if (msg.type === 'offer') { await this.pc.setRemoteDescription(msg.sdp); const answer = await this.pc.createAnswer(); await this.pc.setLocalDescription(answer); this.sendSignal({ type: 'answer', sdp: answer }); } else if (msg.type === 'answer') { await this.pc.setRemoteDescription(msg.sdp); } else if (msg.type === 'candidate') { await this.pc.addIceCandidate(msg.candidate); } }; this.ws.onopen = () => this.sendSignal({ type: 'join', room: this.roomId }); } setupDataChannel(channel) { this.dc = channel; this.dc.onopen = () => this.emit('open'); this.dc.onmessage = (e) => this.emit('message', e.data); this.dc.onclose = () => this.emit('close'); } send(data) { if (this.dc && this.dc.readyState === 'open') { this.dc.send(typeof data === 'string' ? data : JSON.stringify(data)); } } }这段代码里,ondatachannel是接收方用的,发起方则要主动createDataChannel。我在实际项目里做了一个判断:房间里的第一个人作为发起方,第二个人作为接收方。这个角色分配通过信令服务完成。
4.5 状态同步与插值的实现细节
状态同步的核心是"主机广播,客机插值"。主机每 50ms 把游戏状态打包发出去:
// 主机侧 setInterval(() => { net.send({ type: 'state', players: players.map((p) => ({ id: p.id, x: p.x, y: p.y, score: p.score })), timestamp: performance.now(), }); }, 50);客机收到后,不直接设置位置,而是记录"目标位置",在渲染时插值:
// 客机侧 net.onMessage((msg) => { if (msg.type === 'state') { msg.players.forEach((remote) => { const local = players.find((p) => p.id === remote.id); if (local) { local.targetX = remote.x; local.targetY = remote.y; } }); } }); // 渲染时插值 function render(alpha) { players.forEach((p) => { p.renderX = p.x + (p.targetX - p.x) * 0.2; // 平滑逼近 p.renderY = p.y + (p.targetY - p.y) * 0.2; }); }这里的0.2是插值系数,越大越跟手但越容易抖,越小越平滑但越滞后。我试了 0.1 到 0.5 几个值,最后定在 0.2,兼顾了平滑和响应。这个参数没有标准答案,得根据游戏类型调。
5. 常见问题与排查技巧实录
5.1 WebRTC 连接失败排查速查表
WebRTC 连接失败是最常见的问题,原因五花八门。我整理了一张排查表,按"从易到难"的顺序检查:
| 现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 一直 connecting | 信令没通 | 看信令服务日志 | 检查 WebSocket 地址和房间号 |
| ICE 收集为空 | STUN 不可用 | 打印 candidate 事件 | 换 STUN 服务器或加 TURN |
| 连接后立即断开 | SDP 不匹配 | 对比双方 SDP | 检查编解码和 DataChannel 配置 |
| 局域网能连,跨网不能 | NAT 穿透失败 | 看 candidate 类型 | 配置 TURN 中转 |
| 连接成功但收不到消息 | DataChannel 未开 | 检查 readyState | 等 onopen 再发数据 |
我踩过最坑的一个问题是:在setLocalDescription之前就发送了 offer。因为createOffer返回的 SDP 在setLocalDescription之后才"生效",顺序错了对方就收不到有效信息。这个 bug 排查了很久,因为控制台不报错,只是连接一直卡在 connecting。
5.2 Shadow DOM 样式不生效的三种情况
Shadow DOM 用起来爽,但样式问题很隐蔽。我遇到过三种情况。
第一种是Tailwind 类名不生效。原因是 Tailwind 的 JIT 编译只扫描了常规文件,没扫描到 shadow root 里的动态类名。解决办法是在tailwind.config.js的content里把相关文件都加上,或者用 safelist 显式列出动态类名。
第二种是继承样式丢失。Shadow DOM 默认不继承外部样式,但有些属性(如color、font-family)是继承的。如果发现字体不对,检查是不是宿主页面的字体没传进来。我的做法是在 shadow root 里显式设置基础字体。
第三种是伪元素和动画失效。某些 CSS 特性在 shadow root 里的行为和普通 DOM 略有不同,尤其是::before、::after配合动态内容时。遇到问题优先用真实元素替代伪元素。
5.3 游戏性能优化的几个实操技巧
网页小游戏的性能瓶颈通常在渲染和网络。我总结了几个实测有效的技巧。
渲染上,避免每帧创建对象。比如碰撞检测时不要每帧map出新数组,复用已有对象。垃圾回收的停顿在游戏里很致命,表现为周期性卡顿。我用 Chrome 的 Performance 面板抓过,优化后 GC 停顿从每几秒一次降到几乎不可见。
网络上,压缩消息体积。JSON 虽然方便,但冗余大。我把高频的位置数据改成二进制ArrayBuffer传输,体积减少了 60% 以上。对于小游戏来说,这个优化在弱网环境下体感明显。
还有一个容易被忽略的点:requestAnimationFrame在页面不可见时会暂停。这本来是好事(省电),但如果你的游戏逻辑依赖时间,切回来时会"跳帧"。前面游戏循环里的Math.min(delta, 250)就是解决这个的。
5.4 关于 P2P 搜索与连接发现的补充说明
有读者可能会问:P2P 游戏怎么让玩家"找到彼此"?这其实是个独立于 WebRTC 的问题。WebRTC 解决的是"已知对方存在,怎么连",而"怎么知道对方存在"是发现层的事。
我的做法很简单:房间号 + 信令服务。玩家创建一个房间,得到一个房间号,分享给朋友,朋友输入房间号加入。信令服务只负责把同一房间号的两个人"撮合"到一起,不参与后续通信。这套机制简单可靠,适合小规模对战。
如果你想要更复杂的匹配(比如随机匹配陌生人),那就需要一个匹配服务,这又回到了"需要服务器"的老问题。所以我的建议是:小游戏就用房间号模式,别过度设计。等真的有了用户量,再考虑匹配系统也不迟。
6. 我在这个项目里踩过的坑和真实体会
做 OmniGame 这段时间,最大的体会是:P2P 不是"没有服务器",而是"把服务器的职责降到最低"。信令服务虽然简单,但它不可或缺;STUN/TURN 虽然免费或便宜,但配置起来也有门槛。真正"零服务器"的 P2P 在网页环境里几乎不存在,能做的只是把服务器成本压到极低。
另一个体会是零依赖不等于零复杂度。砍掉引擎和网络库之后,代码量确实少了,但你需要自己对每一个细节负责。碰撞检测的边界情况、输入的事件顺序、网络的状态机,这些原本由库处理的东西,现在都得自己想清楚。好处是你对系统的理解深了一个层次,坏处是开发速度确实慢。
Shadow DOM 这块,我的建议是能用就用,但别硬上。如果你的游戏不需要嵌入第三方页面,那用不用 Shadow DOM 都行。但如果你的目标是"让游戏能嵌到任何地方",那 Shadow DOM 是目前最优雅的方案,没有之一。
最后说个实际的:测试一定要用真机、真网络。我在本地开发时一切正常,部署到线上后,跨运营商的连接成功率明显下降。后来加了 TURN 兜底才稳定。本地环境太理想了,测不出真实问题。这个教训值不少钱,希望你别重复踩。
后续我打算把状态同步从"主机权威"升级成"帧同步 + 回滚",这样能支持更多玩家同时在线,也能减少主机玩家的优势。不过那是另一个大工程了,等做出来再分享。