1. 为什么我要折腾一个“零依赖”的网页小游戏框架
先说结论:OmniGame 是我在过去几个月里反复推倒重来三次之后,最终沉淀下来的一套网页小游戏工程方案。它的核心目标很明确——让一个网页小游戏从“能跑”变成“跑得稳、传得快、嵌得进”。如果你做过 H5 小游戏,大概率经历过这些场景:首屏加载慢得让人想砸键盘、多人联机必须自己搭服务器、把游戏嵌到别人页面里样式和事件全乱套。OmniGame 就是冲着这三个痛点去的。
它适合谁?如果你是一个前端开发者,想快速做一个可联机的小游戏原型;或者你是一个独立开发者,不想在服务器运维上花太多钱;又或者你是一个技术负责人,需要把游戏模块嵌入到已有的 Next.js 项目里——这套方案都能直接抄作业。我下面会把整个设计思路、核心实现、踩过的坑全部摊开讲,尽量做到你看完就能动手复现。
关键词我先自然带出来:OmniGame是项目代号,WebRTC和P2P是联机底座,Shadow DOM解决嵌入隔离,Next.js负责工程化和首屏优化。这几个东西单独拎出来都不新鲜,但把它们组合成一个完整的游戏工程方案,中间有很多细节值得聊。
2. 整体架构设计与技术选型逻辑
2.1 为什么坚持“零依赖”而不是堆库
市面上做网页小游戏的方案很多,Phaser、PixiJS、Three.js 各有各的定位。我一开始也想过直接用 Phaser,毕竟生态成熟。但实际用下来发现一个问题:对于一个轻量级的小游戏,引擎本身的体积可能比游戏逻辑还大。Phaser 压缩后大概 1MB 左右,PixiJS 也要几百 KB,而我的游戏核心逻辑可能就几十 KB。这就好比为了搬一个箱子,叫了一辆卡车。
所以 OmniGame 的第一个原则是:核心运行时零第三方依赖。渲染直接用 Canvas 2D API,游戏循环用 requestAnimationFrame,状态管理自己写一个极简的发布订阅。整个核心包压缩后控制在 15KB 以内。这不是为了炫技,而是为了让首屏加载时间尽可能短——尤其是在移动端弱网环境下,每减少 100KB 都是实实在在的体验提升。
当然,“零依赖”不等于“不用工具”。工程化层面我用了 Next.js 来做构建、路由和 SSR,这是另一回事。运行时零依赖和工程化工具链是两码事,不要混淆。
2.2 WebRTC P2P 做联机的取舍
多人联机是很多小游戏的刚需,但传统方案要么用 WebSocket 走中心服务器,要么用轮询。前者需要你维护一个长连接服务,后者延迟高得离谱。WebRTC 的 DataChannel 提供了一条第三条路:浏览器之间直接建立点对点连接,数据不经过服务器中转。
这里要解释一下为什么 P2P 对小游戏特别合适。假设你做一个双人对战的小游戏,用中心服务器的话,数据流向是 A→服务器→B,延迟至少是两段网络往返。而 P2P 直连的话,A→B 只有一段,延迟理论上减半。而且服务器只需要在建立连接时帮忙交换一下信令,之后就可以完全退出,带宽成本几乎为零。
但 P2P 不是银弹。它最大的问题是 NAT 穿透——两个在不同局域网里的设备要直接通信,需要 STUN/TURN 服务器辅助。STUN 服务器成本很低,但 TURN 服务器在中转流量时会产生带宽费用。我的做法是:优先直连,直连失败再走 TURN 中转,并且在小游戏场景下,数据量本身很小(每秒几 KB 的坐标同步),即使走 TURN 成本也可控。
2.3 Shadow DOM 解决嵌入隔离问题
这个点是我在实际项目中踩坑之后才重视起来的。当时我把游戏嵌入到一个客户的页面里,结果游戏的 CSS 和页面的 CSS 互相污染,按钮样式全乱了,键盘事件也被页面上的其他组件拦截。后来改用 Shadow DOM 把整个游戏容器封装起来,问题一次性解决。
Shadow DOM 的核心价值是样式隔离和事件边界。游戏内部的样式不会泄漏出去,外部的样式也不会影响进来。事件方面,虽然 Shadow DOM 不能完全阻止事件冒泡,但可以通过 composed 属性和 retargeting 机制来控制。对于嵌入场景来说,这几乎是必备的。
2.4 Next.js 在其中的角色
有人可能会问:一个零依赖的游戏运行时,为什么要搭配 Next.js?答案在于工程化和首屏体验。Next.js 提供了代码分割、静态生成、图片优化等能力,这些对于游戏的加载页、排行榜、说明页等外围页面非常有用。而且 Next.js 的 API Routes 可以顺便承载 WebRTC 的信令交换逻辑,不需要额外起一个服务。
整体架构可以这样理解:Next.js 是外壳和基础设施,OmniGame 核心是游戏运行时,WebRTC 是联机通道,Shadow DOM 是嵌入边界。四者各司其职,组合起来就是一个完整的方案。
3. 核心模块拆解与关键实现细节
3.1 游戏循环与渲染层的极简设计
游戏循环是所有小游戏的心脏。我见过很多新手写成这样:
setInterval(() => { update(); render(); }, 16);这种写法有两个问题:一是 setInterval 的时间精度不够,二是它不考虑浏览器的刷新节奏,容易产生画面撕裂。正确的做法是用 requestAnimationFrame,并且把更新和渲染分离:
let lastTime = 0; function loop(timestamp) { const deltaTime = timestamp - lastTime; lastTime = timestamp; update(deltaTime); render(); requestAnimationFrame(loop); } requestAnimationFrame(loop);这里的关键是deltaTime。不同设备的刷新率不一样,有的是 60Hz,有的是 120Hz,如果更新逻辑不乘以 deltaTime,游戏速度就会不一致。我一般会把 deltaTime 归一化到 60fps 的基准上,也就是const factor = deltaTime / 16.67,这样物理计算在不同设备上表现一致。
渲染层我封装了一个极简的 Draw 对象,提供 rect、circle、text、sprite 几个方法,内部直接调用 Canvas 2D API。没有场景图,没有精灵批处理,因为小游戏的绘制对象通常不超过几百个,直接画完全够用。过早优化是万恶之源。
注意:Canvas 的 width 和 height 属性(绘图缓冲区大小)和 CSS 的 width 和 height(显示大小)是两回事。在高分屏上,如果只设置 CSS 大小,画面会模糊。正确做法是把绘图缓冲区设置为
cssSize * devicePixelRatio,然后用ctx.scale(dpr, dpr)缩放坐标系。
3.2 WebRTC P2P 连接建立全流程
WebRTC 建立连接的过程,我习惯用一个生活类比来解释:两个人想打电话,但都不知道对方的电话号码,于是先通过一个中间人交换号码,然后直接拨号通话。这个中间人就是信令服务器,交换的“号码”就是 SDP(会话描述协议)和 ICE candidate。
具体流程分四步:
- 创建 RTCPeerConnection 实例:配置 STUN 服务器地址,用于获取本机的公网地址。
- 交换 SDP:发起方创建 offer,接收方创建 answer,通过信令服务器互相传递。
- 交换 ICE candidate:双方把各自收集到的网络候选地址通过信令服务器互传。
- 连接建立:当双方找到一条可通的路径后,DataChannel 打开,可以开始发数据。
在 OmniGame 里,我把信令逻辑放在 Next.js 的 API Route 里,用 Server-Sent Events 做实时推送。为什么不用 WebSocket?因为 Next.js 的 API Route 原生不支持 WebSocket,而 SSE 足够满足信令交换这种低频场景。
// 发起方 const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.example.com' }] }); const channel = pc.createDataChannel('game', { ordered: false, // 游戏状态同步不需要严格有序 maxRetransmits: 0 // 不重传,丢包就丢包,下一帧会补上 }); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); // 通过信令服务器发送 offer这里有个关键参数:ordered 和 maxRetransmits。对于实时性要求高的游戏状态同步,我建议设置ordered: false和maxRetransmits: 0,也就是不可靠、不重传的模式。因为游戏状态每帧都在更新,丢了一帧没关系,下一帧会覆盖。如果开启重传,反而会造成队头阻塞,延迟更大。
3.3 Shadow DOM 封装游戏容器的正确姿势
Shadow DOM 的使用本身不复杂,但有几个细节容易踩坑。先看基本用法:
class GameElement extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = ` :host { display: block; position: relative; } canvas { width: 100%; height: 100%; } `; const canvas = document.createElement('canvas'); shadow.appendChild(style); shadow.appendChild(canvas); } } customElements.define('omni-game', GameElement);第一个坑是:host 选择器。Shadow DOM 内部的样式默认不影响宿主元素,要用 :host 才能设置宿主自身的样式。第二个坑是事件穿透。Shadow DOM 内部的事件默认不会冒泡到外部,但如果设置了 composed: true,就会穿透边界。对于游戏来说,键盘事件需要特别注意——如果游戏没有获得焦点,键盘事件可能被外部页面捕获。
我的做法是在游戏容器上设置 tabindex="0",点击时自动 focus,然后在 Shadow DOM 内部监听键盘事件。同时用event.stopPropagation()阻止游戏内的按键事件冒泡出去,避免影响外部页面。
提示:Shadow DOM 的 mode 建议用 'open' 而不是 'closed'。'closed' 看起来更安全,但实际上调试非常痛苦,而且并不能真正阻止外部访问(通过 element.shadowRoot 仍然可以拿到)。'open' 模式下调试方便,隔离效果已经足够。
3.4 状态同步策略:帧同步还是状态同步
联机游戏绕不开这个问题。帧同步是只传操作指令,所有客户端跑相同的逻辑;状态同步是直接传游戏状态。小游戏我推荐状态同步,原因很简单:帧同步要求所有客户端的逻辑完全一致,浮点数计算、随机数、物理引擎的微小差异都会导致不同步,调试成本极高。状态同步虽然流量大一点,但逻辑简单,不容易出问题。
具体做法是:每个客户端每帧把自己的关键状态(位置、速度、动作)打包成一个二进制消息,通过 DataChannel 广播给其他玩家。接收方收到后做插值平滑,避免画面抖动。
function packState(player) { const buffer = new ArrayBuffer(12); const view = new DataView(buffer); view.setFloat32(0, player.x); view.setFloat32(4, player.y); view.setUint16(8, player.action); view.setUint16(10, player.frame); return buffer; }用 ArrayBuffer 而不是 JSON,是因为二进制体积小很多。一个玩家状态用 JSON 大概 80 字节,用二进制只要 12 字节。四个人联机,每秒 30 帧,JSON 是 9.6KB/s,二进制只要 1.4KB/s。差距很明显。
4. 完整实操流程:从零搭一个可联机的小游戏
4.1 项目初始化与目录结构
先创建 Next.js 项目:
npx create-next-app@latest omnigame --typescript --app cd omnigame目录结构我建议这样组织:
omnigame/ ├── app/ │ ├── page.tsx # 首页 │ ├── game/ │ │ └── page.tsx # 游戏页面 │ └── api/ │ └── signal/ │ └── route.ts # 信令接口 ├── core/ │ ├── loop.ts # 游戏循环 │ ├── render.ts # 渲染封装 │ ├── net.ts # WebRTC 封装 │ └── state.ts # 状态管理 ├── components/ │ └── GameContainer.tsx # Shadow DOM 容器 └── public/ └── sprites/ # 游戏素材core 目录是零依赖的核心运行时,不引入任何第三方包。components 和 app 是 Next.js 层面的工程代码。这样分层的好处是核心运行时可以独立打包,甚至可以在非 Next.js 项目里复用。
4.2 信令服务的实现
信令服务负责在建立连接阶段帮两个客户端交换信息。我用 Next.js 的 API Route 配合内存存储来实现:
// app/api/signal/route.ts const rooms = new Map<string, any[]>(); export async function POST(request: Request) { const { roomId, type, data } = await request.json(); if (!rooms.has(roomId)) { rooms.set(roomId, []); } const room = rooms.get(roomId)!; room.push({ type, data, timestamp: Date.now() }); // 清理超过 30 秒的旧消息 const now = Date.now(); const filtered = room.filter(m => now - m.timestamp < 30000); rooms.set(roomId, filtered); return Response.json({ ok: true }); } export async function GET(request: Request) { const { searchParams } = new URL(request.url); const roomId = searchParams.get('roomId')!; const since = Number(searchParams.get('since') || 0); const room = rooms.get(roomId) || []; const messages = room.filter(m => m.timestamp > since); return Response.json({ messages }); }客户端用轮询的方式拉取信令消息,间隔 500ms。虽然轮询不够优雅,但信令交换只在连接建立阶段发生,通常几秒内就完成了,对服务器压力很小。如果你要做得更实时,可以把轮询间隔降到 200ms,或者换成 SSE。
注意:这个内存存储方案只适合单实例部署。如果你部署了多个实例,需要用 Redis 之类的共享存储。但对于小游戏原型来说,单实例完全够用。
4.3 游戏核心循环的接入
在 GameContainer 组件里初始化游戏:
useEffect(() => { const canvas = canvasRef.current!; const ctx = canvas.getContext('2d')!; // 处理高分屏 const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); const game = new Game(ctx, rect.width, rect.height); // 接入联机 const net = new NetClient(roomId); net.onState((playerId, state) => { game.updateRemotePlayer(playerId, state); }); game.onLocalState((state) => { net.broadcast(state); }); game.start(); return () => { game.stop(); net.close(); }; }, [roomId]);这里的顺序很重要:先处理 canvas 尺寸,再创建游戏实例,最后接入网络。如果顺序反了,游戏初始化时拿到的 canvas 尺寸是错的,画面会变形。
4.4 联机对战的数据流
完整的数据流是这样的:
- 玩家 A 按下方向键,本地游戏状态更新。
- 游戏循环每帧调用 onLocalState 回调,把状态传给 NetClient。
- NetClient 把状态打包成二进制,通过 DataChannel 广播。
- 玩家 B 的 NetClient 收到数据,解包后调用 onState 回调。
- 玩家 B 的游戏实例更新远程玩家位置,渲染时做插值平滑。
插值平滑是必须的,因为网络数据到达的间隔不均匀。我的做法是维护一个缓冲区,渲染时取当前时间往前推 100ms 的状态进行插值。这样虽然增加了一点延迟,但画面流畅度大幅提升。
function interpolate(remotePlayer, renderTime) { const buffer = remotePlayer.buffer; // 找到 renderTime 前后的两个状态 let prev = buffer[0], next = buffer[buffer.length - 1]; for (let i = 0; i < buffer.length - 1; i++) { if (buffer[i].time <= renderTime && buffer[i + 1].time > renderTime) { prev = buffer[i]; next = buffer[i + 1]; break; } } const t = (renderTime - prev.time) / (next.time - prev.time); return { x: prev.x + (next.x - prev.x) * t, y: prev.y + (next.y - prev.y) * t }; }5. 实际踩坑记录与问题排查
5.1 WebRTC 连接失败的常见原因
这是最常见的问题,我整理了一个排查表:
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 一直处于 connecting | STUN 服务器不可达 | 检查 iceServers 配置 | 换用公共 STUN 或自建 |
| ICE 收集超时 | 防火墙拦截 UDP | 查看 chrome://webrtc-internals | 配置 TURN 中转 |
| 连接建立后立即断开 | SDP 交换不完整 | 检查信令消息是否丢失 | 增加消息确认机制 |
| 只有一方能收到数据 | DataChannel 配置不一致 | 检查双方 createDataChannel 参数 | 统一 ordered 和 maxRetransmits |
chrome://webrtc-internals 是我最常用的排查工具,它能显示 ICE candidate 的收集过程、连接状态变化、数据通道的收发统计。遇到连接问题,先打开这个页面看日志,大部分问题都能定位。
5.2 Shadow DOM 里 Canvas 尺寸异常
这个问题我踩了两次。第一次是 canvas 显示模糊,原因是只设置了 CSS 尺寸没设置绘图缓冲区尺寸。第二次是 canvas 尺寸为 0,原因是 Shadow DOM 里的元素在 connectedCallback 触发时还没有被布局,getBoundingClientRect 返回 0。
解决方案是用 ResizeObserver 监听容器尺寸变化:
const observer = new ResizeObserver((entries) => { for (const entry of entries) { const { width, height } = entry.contentRect; if (width > 0 && height > 0) { resizeCanvas(width, height); } } }); observer.observe(container);ResizeObserver 比 window.resize 事件更可靠,因为它能监听任意元素的尺寸变化,而且不会在尺寸没变时触发。
5.3 Next.js SSR 与浏览器 API 的冲突
Next.js 默认会在服务端渲染组件,但 WebRTC、Canvas、Shadow DOM 这些 API 在服务端不存在。如果直接在组件顶层调用,会报 "window is not defined"。
解决方案有两种:一是用 dynamic import 配合 ssr: false,二是把浏览器相关代码放到 useEffect 里。我推荐后者,因为 useEffect 只在客户端执行,天然避开了 SSR 问题。
// 错误写法 const pc = new RTCPeerConnection(); // 服务端会报错 // 正确写法 useEffect(() => { const pc = new RTCPeerConnection(); // ... }, []);如果某个组件完全依赖浏览器 API,可以用 dynamic 导入:
const GameContainer = dynamic(() => import('@/components/GameContainer'), { ssr: false, loading: () => <div>加载中...</div> });5.4 移动端触摸事件的坑
移动端的触摸事件和桌面端的鼠标事件差异很大。首先是 300ms 延迟问题,虽然现代浏览器加了 viewport meta 之后基本没有了,但还是要确保<meta name="viewport" content="width=device-width, initial-scale=1">存在。其次是多点触控,如果游戏需要同时处理多个触摸点,要用 touchstart/touchmove/touchend 而不是 click。
还有一个坑是触摸滚动。在游戏区域滑动时,页面可能会跟着滚动。解决方案是在 canvas 上设置touch-action: none,告诉浏览器这个区域不处理默认的触摸行为。
canvas { touch-action: none; -webkit-user-select: none; user-select: none; }提示:iOS Safari 上还有一个双击缩放的问题。虽然 touch-action: none 能解决大部分情况,但保险起见可以在 touchend 里调用 preventDefault。不过要注意,preventDefault 会阻止所有默认行为,包括输入框的聚焦,所以要精确控制调用时机。
6. 性能优化与扩展思路
6.1 首屏加载的极致优化
零依赖的核心包只有 15KB,但游戏素材可能很大。我的做法是分级加载:首屏只加载必要的素材(比如背景和主角),其他素材在游戏过程中按需加载。Next.js 的 dynamic import 配合 React.lazy 可以很好地实现这一点。
另外,游戏代码和页面代码要分开打包。游戏页面用 dynamic 导入,这样首页不会加载游戏相关的代码。实测下来,首页的 First Contentful Paint 能控制在 1 秒以内,游戏页面的 Time to Interactive 在 2 秒左右。
6.2 联机人数的扩展
WebRTC 的 P2P 模式在人数少的时候很合适,但人数一多,每个客户端都要和其他所有客户端建立连接,形成网状结构。4 个人是 6 条连接,8 个人是 28 条连接,带宽和 CPU 消耗增长很快。
如果要做更多人联机,可以考虑混合架构:选一个客户端作为主机(host),其他客户端只和主机连接,主机负责转发。这样连接数从 N² 降到 N,但主机需要承担更多计算和带宽。对于小游戏来说,8 人以内用网状结构完全够用,超过 8 人建议上专用服务器。
6.3 后续可以扩展的方向
这套方案目前覆盖了渲染、联机、嵌入三个核心场景。后续可以扩展的方向包括:回放系统(记录每帧状态,支持回放)、观战模式(旁观者只接收状态不发送)、AI 对手(本地跑一个简单的状态机)。这些都不需要改动核心架构,只是在现有基础上叠加功能。
我个人在实际操作中的体会是:小游戏工程最难的不是写游戏逻辑,而是处理边界情况。网络断了怎么办、对方掉线了怎么办、设备性能不够怎么办——这些问题在 demo 阶段不会出现,但一到真实环境就全冒出来了。OmniGame 的设计原则就是把这些边界情况提前考虑进去,让开发者能专注于游戏玩法本身。