OmniGame 这个项目最早的诞生契机,其实特别朴素——我就是想做一个能在浏览器里直接打开的网页小游戏,但按照主流的前端流程走了一遍之后发现,打开方式变成了:装Node、配React、拉几十个依赖包、折腾半小时构建环境,然后游戏本体写了五分钟。这不合理,尤其对于想快速分享、随手打开就玩的小游戏来说,这种工程重量完全是负担。后来我花了两个多月,把整套引擎重写成零依赖方案,并用WebRTC把多人联机从服务器中转改成设备直连,整个项目跑下来的体会是:网页小游戏不是只能“轻”,而是我们大部分时候用错了工具,自己把工程上限压低了。这篇文章就把OmniGame从零到能跑的完整过程,包括零依赖的具体实现、WebRTC P2P的踩坑记录、以及最终的性能实测数据,一次性写清楚。不管你是想给自己的小游戏减负,还是想了解浏览器端P2P怎么落地,这份白皮书应该都能给你省下不少时间。
1. 这个项目到底在解决什么问题
1.1 网页小游戏的传统困境
先聊聊大多数网页小游戏现在的真实处境。你随便打开一个“网页小游戏合集”,会发现相当一部分是十年前那种Flash移植过来的作品,或者干脆就是套了个iframe的H5页面。这些游戏能跑,但离“现代工程”这个词很远。而另一部分想做得更正规的开发者,一上来就上了全家桶:Webpack也好、Vite也罢,外加React或Vue,再配一套UI库、状态管理、路由、代码分割……
这套东西做中后台系统很爽,但做小游戏就有点杀鸡用牛刀了。我见过一个只有三个关卡的益智小游戏,打包出来的产物有4MB多,其中2MB是各种运行时和polyfill,真正属于游戏逻辑的代码不到三分之一。加载一个2MB的页面,在移动端弱网环境下要等好几秒,用户早划走了。
更麻烦的是构建链路带来的维护成本。依赖树一旦复杂起来,今天这个库报一个安全漏洞,明天那个库升级破坏API,游戏没写几行,光是在那儿修依赖就够喝一壶。对个人开发者或者三五人的小团队来说,这个负担非常不划算。
所以OmniGame立项的时候,我给自己定了一个很反主流的目标:不用任何构建工具,不引任何运行时依赖,所有代码写在一个HTML文件里,双击就能跑。听起来很原始,对吧?但正是这个限制,倒逼我把工程设计的每个细节都重新思考了一遍。
1.2 为什么我把标题定为“工程上限”
“工程上限”这个词,我指的是:一套技术方案在性能、并发、用户体验和可维护性这几个维度上,能撑到的最优水平。传统认知里,零依赖、单文件的游戏,上限一定很低。毕竟连模块化都没有,代码怎么写?多人联机更不用想了,没有服务器怎么做实时同步?
但实际情况恰恰相反。浏览器本身就是一个极其强大的运行时,它自带Canvas、WebGL、Audio API、WebRTC、SharedWorker、IndexedDB等一大堆能力。这些能力一直都在,只是被中间那一层又一层的框架和库给遮住了。去掉它们之后,你反而能直接站在浏览器的肩膀上。
拿WebRTC举例,它让两个浏览器之间可以直接建立加密的P2P数据通道,延迟能压到几十毫秒以内。小游戏这种玩家数量少、状态变化频繁的场景,简直是为P2P量身定做的。与其搭一台中心服务器承受所有玩家的流量,不如让玩家之间互相直连,服务器只做最轻量的信令中转。
所以OmniGame标题里的“重新定义工程上限”,并不是说我的代码写得有多神,而是想证明一件事:在合理的场景下,极简技术栈能做到的边界,远比大多数人想象的高。这篇文章的核心内容,就是把我验证过的那条路完整画出来。
2. 零依赖才是真正的硬骨头
2.1 零依赖的定义:不止是“不用npm install”
很多人听到零依赖,第一反应是“不装第三方库”。这个理解对了一半。真正的零依赖不只是不装库,而是你的代码在运行时不需要任何外部环境,打开浏览器就能执行,连本地服务器都不一定需要。
这里有一个技术点容易被忽略:浏览器的模块系统。历史上浏览器原生不支持ES Module的时候,你要写模块化代码,就必须靠构建工具把多个文件打包成一个。现在情况变了,现代浏览器原生支持ES Module,你直接在script标签里写<script type="module">,就能用import和export。更进一步,如果你把所有代码都写在一个HTML文件里,连跨文件的CORS问题都省了。
OmniGame选的是“单文件方案”:整个游戏引擎、渲染层、网络层、UI层,全部压缩在一个HTML文件里。这样做的好处有两个。第一是分发成本趋近于零,任何能打开浏览器的地方都能玩,不用装任何软件,不用搭建环境。第二是调试路径极短,没有SourceMap、没有热更新、没有构建缓存,改了代码刷新浏览器就能看到结果,开发效率反而更高了。
我踩过最大的坑是“自以为零依赖,其实偷偷依赖了构建工具”。早期的版本我为了方便,在代码里用了TypeScript的语法,然后告诉自己“运行时零依赖就行,开发时用一下tsc转译”。结果每次改完代码都要跑一遍编译,没多久就烦了。后来狠下心全改成原生JavaScript,彻底断了编译的念头,整个开发流程才真正顺起来。
2.2 我是怎么把整个引擎塞进一个HTML文件的
单文件方案最大的挑战是代码组织。没有模块系统,没有命名空间,几百个函数堆在全局作用域里,很快就会出现命名冲突、逻辑混乱。OmniGame的做法是用一个极简的模块模式:每个功能模块是一个函数,函数内部管理自己的状态,通过一个统一的全局对象暴露接口。
const OmniGame = (() => { 'use strict'; // 核心模块注册表 const modules = {}; // 模块注册接口 function register(name, factory) { if (modules[name]) { throw new Error(`Module ${name} already registered`); } modules[name] = factory(); } // 模块获取接口 function get(name) { const mod = modules[name]; if (!mod) { throw new Error(`Module ${name} not found`); } return mod; } return { register, get }; })();这个模式眼熟吧?本质上就是简化版的依赖注入容器。每个模块初始化的时候需要的其他模块,通过OmniGame.get()获取。注册顺序就是依赖顺序,所以我会在文件顶部用注释写清楚模块的拓扑排序。
// 模块注册顺序(拓扑排序) // utils -> events -> storage -> audio -> input -> render -> network -> game OmniGame.register('render', () => { /* 渲染模块 */ }); OmniGame.register('network', () => { /* 网络模块 */ });实践下来,这个方案对单文件场景完全够用。优点是不需要编译,缺点是IDE的智能提示和重构工具帮不上忙。为了弥补这个问题,我养成了写JSDoc注释的习惯,用类型注释来换一部分代码补全能力。虽然还是比不上真正的模块系统,但单文件场景下已经是性价比最高的方案了。
2.3 零依赖背后的工程哲学
如果把零依赖仅仅理解成“省事”,那就错过了一半的价值。我觉得零依赖更深刻的意义在于:它逼你重新审视每一个功能的必要性。
做传统Web开发的时候,遇到问题第一反应是搜一个库。列表需要虚拟滚动?装个react-virtualized。状态管理太乱?装个Redux。动画想做漂亮点?装个GSAP。这些库当然很好,但它们带来的一个隐性成本是:你不再去理解底层原理,你的代码变成了一堆配置项的组合。
零依赖就没有这个退路。列表性能不够,你得自己写虚拟滚动的逻辑;状态乱了,你得自己设计状态流;动画卡了,你得自己研究Canvas和requestAnimationFrame的配合。这个过程是痛苦的,但它带来的收益非常实在——你对每一行的理解都达到了源码级。有一次我排查一个Canvas绘制卡顿问题,最后发现是Canvas的shadowBlur属性在移动端GPU上有严重的性能回退,这种情况如果用的是封装好的DOM动画库,可能根本定位不到底层原因。
所以我现在的工程哲学是:能用浏览器原生能力解决的,就不引库;能用十行代码写清楚的,就不设计抽象;能在一个文件里表达的逻辑,就不拆成十几个文件。这套哲学听起来像一个返祖行为,但放到“网页小游戏”这个具体的场景里,它其实是回归了本质。
3. WebRTC P2P:从“服务器中转”到“设备直连”
3.1 WebRTC能做什么、不能做什么
WebRTC最常被人提起的场景是视频会议和直播,但它的能力远不止音视频。WebRTC的数据通道(DataChannel)允许你在两个浏览器之间建立一条低延迟、加密的、双向的数据管道,传输任意二进制或文本数据。对游戏来说,这意味着什么?意味着你不需要把每个玩家的操作先发到服务器,再由服务器广播给其他玩家。玩家A的操作直接发给玩家B,中间少了一跳,延迟更低了,服务器带宽成本也几乎降为零。
但WebRTC不是银弹,它有几个天然的限制需要提前说清楚。
第一,WebRTC需要信令过程。两个浏览器之间要建立P2P连接,必须通过一个信令服务器先交换各自的网络配置信息(SDP)和网络路径信息(ICE候选)。这个过程只在连接建立时需要,连接一旦打通,信令服务器就可以功成身退了。
第二,NAT穿透不是100%成功的。现实中很多设备在严格型NAT后面,P2P打洞会失败。这时候WebRTC会回退到TURN服务器的中继模式,也就是流量还是走服务器。一个没有TURN方案的WebRTC应用,实际的连接成功率可能只有80%左右。
第三,DataChannel的传输模式需要自己控制。它默认是类似TCP的可靠传输,顺序保证,但适合实时游戏的时候,你可能更想要“丢掉旧消息,只发最新状态”的不可靠模式。这个需要在创建通道的时候显式配置,很多人第一次用都会忽略。
我的建议是:对网页小游戏来说,WebRTC的收益远远大于成本。因为小游戏天然是房间制、人数少、状态同步频繁,是所有应用里最适合P2P的形态。你只需要把信令这一环做好,剩下的事情浏览器都替你做完了。
3.2 信令服务器到底该怎么做
信令服务器是整套方案里唯一需要后端的地方。它做的事情很简单:转发SDP和ICE候选。难点在于,你的前端是零依赖单文件,信令服务器最好也维持同等的极简。
我用的是Python内置的HTTP服务器加WebSocket协议。Python的websockets库,原生asyncio,十几行代码就能实现一个够用的信令服务。
import asyncio import json import websockets # 房间 -> 连接集合 rooms = {} async def signaling(websocket): try: async for message in websocket: data = json.loads(message) room_id = data['room'] # 创建房间或加入房间 if room_id not in rooms: rooms[room_id] = set() rooms[room_id].add(websocket) # 转发消息给同房间的其他客户端 for client in rooms[room_id]: if client != websocket: await client.send(message) finally: # 断开连接时从房间移除 for room_clients in rooms.values(): room_clients.discard(websocket) async def main(): async with websockets.serve(signaling, "0.0.0.0", 8765): await asyncio.Future() if __name__ == "__main__": asyncio.run(main())这个服务端不做任何游戏逻辑,不存任何游戏状态,只维护一个房间列表和消息转发。即使用最简单的部署方式跑在一台1核1G的小机器上,支撑几千个房间的信令转发也完全没有压力。真正有意思的是信令的时序问题。
我踩过的一个坑是:一方先进入房间,等待另一方加入,然后双方同时创建offer和answer,导致SDP交换错乱。后来我采用的策略是约定“先加入房间者为主叫方,后加入者为被叫方”,由主叫方发起offer,被叫方回answer。这个约定用简单的大小比较就能实现,不需要额外的状态机。
3.3 数据通道实战:断线重连、背压、分片
数据通道建立之后,并不意味着万事大吉。我把几个高频问题列出来,这些都是实际跑的时候一定会遇到的。
第一个是背压问题。如果发送方疯狂往通道里塞数据,接收方处理不过来,数据就会堆积,内存占用飙升,游戏卡顿甚至崩溃。WebRTC的bufferedAmount属性就是用来监控这一点的。我写了一个统一的发送接口,会在每次发送前检查缓冲积累量,超阈值就自动降频或者丢弃非关键消息。
function sendDataChannelMessage(channel, data) { // 当缓冲数据超过16KB,说明接收端处理不过来 if (channel.bufferedAmount > 16 * 1024) { // 丢弃低优先级消息,或者触发重同步 return false; } channel.send(data); return true; }第二个是分片问题。DataChannel支持的最大单条消息长度在浏览器之间不一致,实测Chrome能处理比较大尺寸的消息,但Safari会有比较严格的限制。如果一次性发送一张几MB的图片或者一大段JSON,消息会被静默丢弃或者抛异常。我的方案是把大消息切成16KB的小块,接收方拼装完整后触发回调。
第三个是断线重连。P2P连接在移动网络切换或者Wi-Fi信号不好的时候很容易断。WebRTC提供了connectionStateChange事件监听状态,我在检测到disconnected或者failed状态时,会走一套重建流程:重新创建RTCPeerConnection,重新交换SDP,过程中保留游戏的核心状态,把正在传输的中间数据安全丢弃。
这套机制做完之后,我在真实弱网环境下测试过,一次4G网络切换导致的断连,重连成功率能到95%以上,恢复时间控制在两秒以内。对一个小游戏来说,这个体感已经相当理想了。
4. 实操过程:把OmniGame从零搭起来
4.1 整体架构与目录结构
OmniGame虽然是一个单文件项目,但开发过程中我还是会用目录把源代码分开维护,最后发布的时候再合并成一个HTML。这个“开发多文件、发布单文件”的做法,兼顾了可维护性和零依赖部署。
omni-game/ ├── src/ │ ├── core/ │ │ ├── bootstrap.js # 模块容器与启动逻辑 │ │ ├── events.js # 事件总线 │ │ └── loop.js # 游戏主循环 │ ├── render/ │ │ ├── canvas.js # Canvas渲染器 │ │ └── sprites.js # 精灵与动画系统 │ ├── net/ │ │ ├── signaling.js # 信令客户端 │ │ ├── peer.js # WebRTC连接管理 │ │ └── protocol.js # 消息协议定义 │ ├── game/ │ │ └── main.js # 游戏逻辑 │ └── ui/ │ └── hud.js # 界面层 ├── server/ │ └── signaling.py # 极简信令服务 ├── tools/ │ └── bundle.py # 发布合并脚本 └── index.html # 单文件发布产物这个结构你能不能看出玄机?核心原则是:依赖方向只能从上层往下层。game依赖net、render、ui;net依赖core;render依赖core。我用一个几十行的Python合并脚本,按照依赖顺序把所有模块的代码拼进一个HTML文件,整个构建过程不需要任何第三方工具,Python标准库就够了。
顺带说一句,这个“合并”不是简单的字符串拼接。每个模块开头有JSDoc注释,合并脚本在解析的时候会把模块包裹进IIFE,再调用OmniGame.register()完成注册。所以最终产物是结构清晰的一个文件,而不是一堆代码的堆叠。
4.2 核心模块实现细节
游戏主循环是最基础也最容易写错的部分。很多人在做Canvas游戏的时候,直接用一个不控制的requestAnimationFrame递归,然后发现不同设备的帧率差异导致游戏物理逻辑忽快忽慢。OmniGame采用的是固定时间步长加插值渲染的经典方案。
const FixedTimestep = { stepMs: 1000 / 60, accumulator: 0, lastTime: 0, tick(frameCallback, dt) { const now = performance.now(); // 首次调用时初始化基准时间 if (this.lastTime === 0) { this.lastTime = now; } // 计算真实经过的时间 let frameDelta = now - this.lastTime; this.lastTime = now; // 防止割裂后台标签页导致的超大时间步长 if (frameDelta > 100) { frameDelta = this.stepMs; } this.accumulator += frameDelta; while (this.accumulator >= this.stepMs) { // 以固定步长推进游戏逻辑 frameCallback(this.stepMs); this.accumulator -= this.stepMs; } } };这个方案的核心思路是:不管屏幕刷新率是60Hz、90Hz还是120Hz,游戏逻辑每次更新都走固定的16.67毫秒步长。底层物理、碰撞、动画进度这些跟时间相关的量,永远不会因为帧率波动而乱套。渲染部分则通过当前累加器的比例做插值,让画面保持平滑。
网络同步的细节也很关键。OmniGame的同步策略不是同步每个中间状态,而是同步“操作指令”。玩家每帧产生的是自己的输入操作,比如方向、跳跃标记,这些指令通过P2P通道发给对方。接收方的状态计算完全确定,没有物理引擎随机性的话,双方就能跑出一致的结果。这种模式叫锁步同步,做即时战略游戏常用的策略,用在简单的小游戏上非常合适,带宽占用极低。
4.3 性能调优与实测数据
性能这块我做了几轮调优,每一轮都有明显的数据变化。
第一轮是渲染优化。最初版本我用的Canvas API是fillRect逐块绘制地面,实测发现单帧地面绘制耗时达到11ms,直接吃掉了一半的帧预算。后来我把地面预烘焙到一个离屏Canvas上,每帧只需要一次drawImage,耗时降到0.8ms。这个改动带来的提升比升级引擎还明显。
第二轮是对象复用。游戏里有大量弹幕类的飞行物体,最初写法是每秒new出几十个对象,用完了就等垃圾回收。结果GC一触发,帧率就跳水。我改成对象池以后,整个游戏流程中的新建对象数量减少了95%,GC几乎不再影响帧率。
第三轮是网络消息合并。原来的协议是每条操作消息单独发,每帧最多产生几十条小消息,头部开销高,而且在低带宽下容易出现排队。我改成一帧内把所有操作合并成一条二进制消息,用DataChannel的不可靠模式发送,消息数直接降了一个数量级。
最终在同一台笔记本上,我用Chrome的Performance面板记录了稳定性测试数据:60Hz帧率下,主线程长任务从平均8ms降到2ms以内,P2P消息往返延迟稳定在30到50ms之间。在Pixel 6这种中端安卓机上,同样能稳定跑满60帧,CPU占用在30%到40%之间。这个数据比很多用重型框架做出来的同类型游戏好不少,足以说明问题。
5. 常见问题与排查技巧实录
5.1 连接类问题
我整理了一张“问题现象 -> 可能原因 -> 解决思路”的速查表,这些全是实际开发中遇到过的。
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 一方一直等待对方加入 | 信令服务WebSocket连接失败 | 检查信令地址是否可访问,看浏览器控制台是否有跨域错误 |
| 连接建立后频繁断开 | 网络切换导致ICE链路失效 | 监听connectionStateChange,触发重建流程 |
| 双人连不上,但单人都正常 | NAT穿透失败 | 检查是否配置TURN服务器,配置STUN是否有返回 |
| 加入房间后看不到对方消息 | 房间ID未约定或大小写不一致 | 打印日志比较双方的roomId,规范房间ID生成方式 |
| SDP交换后一直connecting | offer和answer时序错误 | 实现主叫/被叫角色约定,避免双向同时发起offer |
这里特别提醒一句:WebRTC在本地开发的时候,localhost和局域网IP下的行为会不一样。localhost下基本都是host候选,NAT穿透逻辑根本测不到。你要验证真实的P2P效果,一定要在局域网或者公网上找两台设备测,否则很多问题会被掩盖掉。
5.2 渲染与性能类问题
移动端的Canvas渲染是重灾区。我遇到过的最离谱的问题是:在部分安卓机型上,Canvas绘制模糊的阴影效果时,GPU驱动会导致整个页面的合成器卡死,帧率直接掉到个位数。定位这个问题用了很长时间,因为我一开始以为是JavaScript代码的问题,查了半天堆栈,最后用不断减少绘制属性的方式二分定位到shadowBlur。
移动端Canvas性能排查,我总结了几个经验:
第一,优先用drawImage绘制预渲染的位图,而不是每帧都用基础API组合绘制。第二,避免在动画循环里读取需要触发布局的属性,比如读取Canvas的width、height、clientWidth这些,一次重排就够让帧率崩一次。第三,像素相关的操作要控制次数,比如getImageData这种操作,一次就可能消耗几毫秒,常规游戏逻辑里基本用不到。
5.3 零依赖带来的兼容性坑
零依赖单文件方案有一个绕不开的问题:你怎么处理不支持现代API的旧浏览器?OmniGame的目标是“现代浏览器即用”,所以我不打算兼容IE和非常老的移动端浏览器。但期间遇到一个有意思的坑:有个用户反馈说打开游戏黑屏,后来发现是浏览器版本太老,不支持??空值合并运算符,整个脚本解析阶段直接报错。
为了避免这种“解析即崩溃”的情况,OmniGame在项目后期加了一个能力检测层,在脚本最开头检测关键API是否存在,给出友好提示,而不是让用户面对一个空白的黑屏页面。
(function checkBrowserSupport() { const requiredAPIs = [ 'RTCPeerConnection', 'CanvasRenderingContext2D', 'structuredClone' ]; const missing = requiredAPIs.filter(api => !(api in window)); if (missing.length > 0) { document.body.innerHTML = `<h2>当前浏览器不支持此游戏</h2> <p>缺少 API: ${missing.join(', ')}</p> <p>请升级到最新版 Chrome、Edge、Firefox 或 Safari。</p>`; throw new Error('Unsupported browser'); } })();这个检测层放在单文件的最前面,用最基础的语法编写,确保即使在老浏览器里也能正常解析并执行。加了它以后,用户遇到的问题从“黑屏”变成了“明确的提示”,反馈量立刻少了一大半。
6. 关于“工程上限”我最后的几句实话
回头看这段开发经历,最让我意外的不是零依赖能跑通,而是极简方案带来的开发体验反而变好了。没有构建等待,没有依赖地狱,代码改完刷新就看效果,这种即时反馈是大型工程栈给不了的。我现在的习惯是,遇到新需求先问自己:浏览器原生能力能不能解决?十行代码能不能搞定?非要引库的时候,再说明理由。这个习惯让我的大部分项目都保持了足够的轻盈。
WebRTC P2P这块,它真正的价值不是省服务器带宽,而是改变了同步模型。服务器中转意味着所有逻辑都要经过一台机器,而P2P把每个玩家都变成了一个节点。小游戏里最常见的两人对战、四人房间,用P2P做天然就是分布式架构,容错性好、延迟低、部署成本几乎为零。当然,它也不是万能方案,如果游戏有强一致的金融级需求,或者运营方需要完整的对局录像,那中心服务器依然是更可靠的选择。技术选型永远是场景说了算。
最后分享一个实用的扩展方向。OmniGame目前的信令服务和P2P通道是专门为两人对战设计的,但底层的模块化结构让它很容易扩展到多人房间。如果你感兴趣,拿这份代码做基础,加一个“房间列表”和“队长确认开局”流程,就能把两人对战变成四人大乱斗。建议你先把单文件的构建流程和WebRTC的时序逻辑跑通,这两块理解了,剩下的都是体力活。