做网页小游戏这件事,我身边几乎所有人都在用框架。有人在用Phaser,有人在用Pixi.js,还有人为了“省事”直接把Unity项目导出成WebGL包。但我这半年的OmniGame项目,从一开始就选了另一条路:零依赖。整个游戏核心、渲染、音频、输入和联机全部用原生HTML/CSS/JavaScript手写,一个外部库都不引,最后用WebRTC P2P把两个甚至多个浏览器直接连起来,做成了真正意义上“打开链接就能玩”的联机小游戏。
这篇文章不是理论探讨,而是把整个OmniGame从零到一的工程全过程拆给你看——从为什么抛弃框架,到自写游戏引擎的核心循环;从WebRTC的信令设计、NAT穿透到DataChannel的配置;再到联机同步、延迟补偿的取舍。适合正在做或想尝试零依赖网页游戏的人,想给网页小游戏加P2P联机但不知道怎么落地的人,以及单纯想了解WebRTC实战的人。我不保证你的项目会和我一模一样,但我可以保证每一段都是踩过坑之后写出来的。
1. 为什么我抛弃一切框架,选择从零依赖起步
1.1 框架帮你省掉的,恰好是你需要知道的
先说我为什么拒绝框架。不是框架不好,而是对“网页小游戏”这类项目来说,框架的收益其实没有想象中大。
Phaser和Pixi.js这些引擎确实厉害,渲染、动画、音频、物理、场景管理全都给你安排好了。但换了零依赖视角以后再回头看,这些能力全是“黑盒”。你写this.physics.add.sprite()的时候,它背后怎么维护刚体、怎么处理碰撞分组、怎么调度渲染队列,你一概不知。一旦出问题,Debug路线往往是这样的:先查官方文档,再搜GitHub Issue,最后在源码里翻半天——你花在“理解框架行为”上的时间,可能比写游戏逻辑还多。
Unity导出WebGL更极端。做出来的东西确实漂亮,但首次加载要拉几十兆的WebAssembly和数据包,在小游戏这种“点开链接就要玩”的场景里,体验是灾难。而且Unity WebGL和浏览器原生API之间的桥接又厚又绕,想加一点自定义的浏览器能力(比如WebRTC DataChannel),得写一堆不知道什么时候会崩的插件代码。
我并不是说框架一无是处,而是去复盘整个OmniGame项目后我发现:对于“单个小游戏”这种规模,框架替我省下的工作量,还远远抵不过它为调试、体积、构建链付出的额外成本。零依赖不是“什么都不用”,而是“把控制权全部拿回来”。当游戏只有几千行代码时,每一个功能、每一条分支我都知道它为什么存在,出问题我能直接定位到具体函数,而不是在一层层抽象里翻来翻去。
1.2 OmniGame的最小工程边界
在动手之前,我先画了一条“最小可用”的线,避免越做越膨胀:
- 游戏本体:一个基于Canvas 2D渲染的俯视角射击小游戏,支持移动端触屏与桌面端键盘。
- 联机能力:两名玩家通过WebRTC DataChannel互发指令,实时同步彼此的位置和射击事件。
- 分发方式:单个HTML文件,双击就可以跑,或者通过任意静态托管服务获得一个链接,发给朋友就能玩。
这条边界直接影响了后续的每一个技术选型。渲染用Canvas 2D,因为游戏复杂度不高,WebGL是杀鸡用牛刀;通信用WebRTC DataChannel,因为我要的是浏览器端到端直连;界面全部用DOM和CSS搞定,菜单、血条、结算页都不碰Canvas。
我还给这个项目补了一句很朴素的话:作为一个网页小游戏,用户只给你两秒钟的耐心。你让他在加载进度条上等太久,他就关掉了。零依赖最大的隐性收益就在这里——没有框架库要下载,没有构建工具链要跑,一个HTML文件丢到任何静态服务器上,秒开。
1.3 技术路线取舍:框架与原生API的血肉对比
我把候选方案摆在一起,做了一张对比表(自己整理的,非官方数据):
| 方案 | 上手难度 | 运行时开销 | 包体积 | 调试体验 | 适合场景 |
|---|---|---|---|---|---|
| 原生Canvas 2D | 低 | 低 | 极小 | 直接、无中间层 | 小型游戏、几何图形、UI |
| Pixi.js/Phaser | 中 | 中 | 100KB-500KB+ | 黑盒,需要查文档 | 中型2D游戏、动画密集型 |
| Unity WebGL | 高 | 很高 | 数十MB | 最难,跨层Debug | 大型3D、重度玩法 |
| DOM/CSS动画 | 极低 | 视复杂度而定 | 极小 | 最好 | 简单交互、卡牌、文字游戏 |
通信方面我也对比过:WebSocket和WebRTC。WebSocket本质上是“所有对话都要通过服务员中转”——你发给对方的消息,先到服务器,服务器再转发出去。延迟受服务器位置和负载影响,服务器下线则整个游戏直接终止。而WebRTC DataChannel是你和朋友交换电话号码后,加了微信直接聊天,数据从你浏览器直达对方浏览器,不再经过任何人的服务器。对“网页小游戏联机”来说,这个区别是决定性的:我不想要一台24小时开机的游戏服务器。
1.4 仔细想了一下“零依赖”的两个字该怎么落地
很多人以为零依赖等于“没工程结构”,这是最大的误解。零依赖只是不引入外部第三方库,不代表不要工程化。我在项目里用了一个很笨但很有效的组织方式:IIFE + 命名空间。没有模块加载器,没有打包器,所有JS都挂在同一个全局对象OmniGame下。
const OmniGame = (function () { const Engine = {}; const Net = {}; const Audio = {}; const Input = {}; const UI = {}; return { Engine, Net, Audio, Input, UI }; })();这样写的好处是:文件之间靠命名空间解耦,模块之间依赖关系清晰,而且每个模块都可以独立测试。更关键的是,整个游戏最后可以打包成单个HTML,因为所有代码本来就是同一个命名空间下的。我在开发时把.js文件拆成十几个小文件方便维护,发布时用一个几十行的Python脚本直接拼接进HTML,连压缩器都不需要。
2. 自写引擎:不到800行的游戏核心是如何炼成的
2.1 主循环与固定时间步长:为什么不能靠 requestAnimationFrame 直接驱动
游戏引擎的心脏是主循环。很多初学者拿到requestAnimationFrame就直接在里面既更新逻辑又渲染,看起来没什么问题,但帧率一旦抖起来,物理和碰撞检测就会飘。
我用的方案是“固定时间步长 + 累加器”:
let lastTime = 0; const STEP_MS = 1000 / 60; let accumulator = 0; function frame(timestamp) { const delta = Math.min(timestamp - lastTime, 250); // 防止页面切换标签后大跳帧 lastTime = timestamp; accumulator += delta; while (accumulator >= STEP_MS) { update(STEP_MS / 1000); accumulator -= STEP_MS; } render(); requestAnimationFrame(frame); } requestAnimationFrame(frame);为什么要这样?想象一下你的显示器是144Hz,但游戏逻辑如果按144Hz跑,一局里每个玩家在不同刷新率设备上看到的移动速度会不同。固定步长保证了一个游戏世界里的物理和逻辑按统一的节奏推进——步长固定,所有角色的位移、子弹的飞行距离、冷却时间就都确定了。渲染则每帧执行一次,用最新的游戏状态画出来。
那个Math.min(timestamp - lastTime, 250)是我踩过坑之后加上的。用户从别的标签页切回来时,浏览器会补发一个时间戳,delta可能高达几秒。如果不截断,游戏会试图把几秒内的所有帧一次性补算,结果就是卡死或者角色瞬移。截断到250毫秒后,最多也就补四帧,体验丝滑得多。
2.2 输入抽象:一套代码吃遍键盘、鼠标和触屏
网页游戏最容易被低估的是输入系统。我以为“监听几个事件”就够了,实际上移动端和桌面端的手感差距巨大。
桌面端我用keydown/keyup维护一个按键状态集合,再用keydown的code而不是key做映射,因为code不随键盘布局变化。移动端则必须用pointer事件,而不是mouse或touch。pointerdown/pointermove/pointerup是浏览器统一了鼠标、触控笔和触摸屏的标准事件,天然兼容,不需要为移动端单独写一套逻辑。
const keys = new Set(); window.addEventListener('keydown', (e) => { keys.add(e.code); if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) { e.preventDefault(); // 防止空格滚动页面 } }); window.addEventListener('keyup', (e) => keys.delete(e.code)); // 指针输入:摇杆/点按 canvas.addEventListener('pointerdown', (e) => { joystickActive = true; joystickOrigin = { x: e.clientX, y: e.clientY }; }); canvas.addEventListener('pointermove', (e) => { if (joystickActive) { joystickVector = clampVec(e.clientX - joystickOrigin.x, e.clientY - joystickOrigin.y, 50); } }); canvas.addEventListener('pointerup', () => { joystickActive = false; joystickVector = { x: 0, y: 0 }; });这里有个移动端必踩的坑:如果你不设置touch-action: none,触屏拖动会被浏览器当作滚动手势,整个页面跟着动,摇杆完全失灵。我用的是CSStouch-action: none把Canvas区域的手势控制权全部交给游戏代码,页面滚动交给外部容器。
2.3 渲染与碰撞:Canvas坐标系与AABB数学
游戏本体是俯视角射击,渲染逻辑极其直白:清除画布、遍历实体、画矩形或圆形、叠加HUD。我全部用Canvas 2D的fillRect、arc、fillText这几个API画完,没用到任何贴图。纯几何图形的风格反而让游戏有了一种干净的“设计感”。
碰撞检测用的是AABB(轴对齐包围盒),两个矩形不旋转时不重叠,判定条件就是4条边不相交:
function rectsCollide(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; }AABB的数学是O(1)的,一对实体只需要4次比较,几十个实体同时做碰撞时性能完全没问题。子弹和敌人的判定我稍微改成一个“圆和矩形”的碰撞:子弹是半径很小的圆,敌人是矩形,函数里先取矩形上离圆心最近的点,再看距离是否小于半径。这段代码我在纸上推导了一遍,写出来不到10行,比引入物理引擎的思考成本低太多了。
2.4 模块化:没有Module系统我也能拆出十几个文件
开发期我把脚本拆成engine.js、input.js、audio.js、net.js、ui.js等文件,每个文件都是一个IIFE往OmniGame命名空间上挂模块。发布时用脚本拼接成一个HTML里的一个大<script>。整个过程没有npm,没有Webpack,没有Vite。
这种“手工工程化”的好处是构建速度永远为零秒,缺点是模块之间的依赖顺序得自己维护。我的经验是:命名空间里的模块不应该互相循环引用,数据流保持单向——Engine读Input,Net收指令后改Engine状态,UI只读状态。单向数据流让这个零依赖项目在增长到几千行之后依然没有腐化,这比任何模块框架都管用。
3. WebRTC P2P链路:信令、SDP与DataChannel的实战落地
3.1 为什么联机首选WebRTC,而不是一台中转服务器
做一个网页小游戏联机方案时,很多人第一反应是上WebSocket。WebSocket确实简单,一个服务器架起来,客户端连上去就能收发消息。但它的数据面永远绕不开服务器,这意味着每个消息都要在你和朋友之间多绕一跳,延迟、带宽都受服务器限制,服务器一挂游戏就没了。
而WebRTC让浏览器之间直接建立连接。数据从我的WebRTC DataChannel发出,经过UDP协议直接奔向你,中间没有我的服务器参与。对于小游戏这种指令量不大但延迟敏感的场景,WebRTC的Latency优势几乎是决定性的——实测在不同运营商网络下,P2P直连比经过我的小服务器中转,相同距离下延迟普遍少30%-50%。
更重要的是,WebRTC的加密和NAT穿透是浏览器内置的。你不需要自己实现DTLS加密,不用自己探测NAT类型,这些极度复杂的工作全部由浏览器完成,你只需要调用几个API。
3.2 信令设计:一个JSON搞定offer/answer/ICE
WebRTC的数据面虽然是P2P,但连接建立之前,双方必须交换一些“连接元数据”——自己的SDP(含网络信息、编码能力)和ICE候选。这个交换过程叫信令,信令通道本身不承载游戏数据。
我做了一个极简信令协议,只有三种消息:
{ "type": "offer", "payload": { "sdp": "..." } } { "type": "answer", "payload": { "sdp": "..." } } { "type": "ice", "payload": { "candidate": "..." } }正常玩家流程是A、B双方先通过我部署的一个极轻量WebSocket服务器交换这些JSON,连接建立之后WebSocket就闲置了。但我也做了一个更极端的“手动模式”:A生成offer,我把整个JSON序列化进URL参数里发给朋友;朋友打开链接,页面自动解析offer、生成answer,再把answer复制回来。这个方案只适合双人低速场景,但作为应急方案非常管用,也验证了“信令通道可以非常薄”。
3.3 ICE与STUN:没有服务器也能打通的NAT原理
这里需要讲清楚一个概念:你的电脑和朋友的电脑都在各自的路由器后面,路由器用NAT技术把内网IP映射成公网IP和端口。问题是,路由器只允许“内部先发起”的通信,外部主动连进来的UDP包会被丢掉。所以两个人都在家里的时候,谁都没法直接给对方发UDP。
STUN服务器的作用是:告诉你的浏览器“经过路由器映射后,你的公网IP和公网端口是什么”。你的浏览器拿到这个“临时身份”后,通过信令通道告诉对方。对方也这么做。然后两边尝试往对方的公网IP和端口发UDP包。
用一个生活类比来解释:你和朋友分别在两栋写字楼里,大楼保安(NAT)不允许外人随便进,但允许员工出去。你想找朋友聊天,光喊“我在3楼”没用,因为你和他的大楼都不知道对方的临时会客区域在哪。你们先去楼下公共广场的公告屏(STUN服务器)看一眼——公告屏上写了“张三在3号摊位,李四在9号摊位”。然后你们互相招呼,保安见你们已经约上了,就放行。之后你们就可以直接在摊位之间对话了。
如果遇到对称型NAT,打洞可能会失败,此时需要TURN中继。TURN就是“实在打不通,就绕道公共走廊传话”。WebRTC的ICE机制会自动协商路径:先尝试P2P直连,直连失败就换TURN中继。我在项目中配置了一个公共STUN服务器(stun:stun.l.google.com:19302),生产环境再挂一个自建TURN兜底,双保险。
3.4 DataChannel配置实战:游戏指令通道的正确姿势
创建DataChannel的核心代码只有几行:
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); const channel = pc.createDataChannel('game', { ordered: false, maxRetransmits: 0 }); channel.onopen = () => { channel.send('P;0;100;200'); // 玩家0,位置 x=100, y=200 }; pc.onicecandidate = (e) => { if (e.candidate) { signaling.send({ type: 'ice', payload: { candidate: e.candidate } }); } }; pc.oniceconnectionstatechange = () => { if (pc.iceConnectionState === 'disconnected') { // 提示对方掉线 } };这里有两个参数非常关键。ordered: false允许数据包乱序到达。游戏里位置更新是“越快越好”,如果前一帧的位置包因为网络重传而堵在队列里,那后来的包也到不了,位置就会卡住。maxRetransmits: 0表示数据包发送失败后不重传,直接丢弃——位置丢了就丢了,等下一帧的更新覆盖即可。
游戏指令我用的是紧凑字符串:P;id;x;y表示玩家id坐标,F;id;x;y;angle表示发射子弹。字符串协议在双人小游戏场景绰绰有余,每个消息十几个字节,一个频道每秒发几十个消息完全无压力。
4. P2P游戏同步:延迟补偿、权威判定与断线重连
4.1 延迟补偿:为什么你的子弹总是“打不到人”
不做同步处理时,P2P游戏的体验是这样的:你按下射击键,子弹从你屏幕上立刻飞出;但对方的屏幕要等你的指令经过几十毫秒网络延迟到达后,才会显示你开火。于是你看到自己命中了,对方屏幕上你却打空了。
这个问题的根源是“事件在时间线上被延迟了”。我的方案是“本地即时 + 远端插值”的组合拳:
- 自己的位置和射击事件立刻生效,不等网络确认;
- 远处玩家的移动指令先进入一个“历史缓冲区”,渲染时用上一帧和当前帧的位置做线性插值,让视觉上的移动平滑而不是一顿一顿。
插值的核心代码:
function interpolate(prevPos, currPos, t) { return { x: prevPos.x + (currPos.x - prevPos.x) * t, y: prevPos.y + (currPos.y - prevPos.y) * t }; }t是我根据当前网络延迟和收包时间动态调整的系数。网络差时,我让插值时间窗口变长一点,牺牲一点反应速度换取视觉稳定。网络好时,窗口缩到最短,玩家操作几乎无感。最重要的是,子弹射击的判定我这个P2P游戏用了“以射击方为准”的策略:谁的屏幕显示打中了,就算打中。P2P没有权威服务器,但这个裁决规则已经足够让大多数玩家觉得公平。
4.2 谁是裁判:Host权威与状态一致性
真多人游戏里,服务器的权威性非常重要,因为服务器决定“谁被击中,谁赢了”。但P2P没有服务器,总得有一个人说了算。我采用了“房间创建者做Host”的策略:
- Host负责生成房间ID,维护一份“玩家状态表”的最权威副本;
- 客户端把位置、射击事件上报给Host;
- Host计算命中判定,把最终结果广播给所有客户端。
这样做是为了避免两个玩家各说各话,互相都觉得自己赢了。虽然P2P架构天然无法做到绝对防作弊——因为Host本来就是玩家之一——但对一个双人小游戏来说,这种一致性已经足够了。
4.3 断线重连与优雅退出
WebRTC连接断开的处理,很多人会忽略。我之前也以为“关了页面就完事了”,结果有玩家反馈:一个人刷新页面,另一个人那边直接卡死。
我后来加了状态机,监听channel.onclose和pc.iceConnectionState:
channel.onclose = () => { ui.showBanner('对方已离开'); net.reset(); engine.pause(); }; pc.oniceconnectionstatechange = () => { if (pc.iceConnectionState === 'failed') { ui.showBanner('网络连接失败,尝试重连...'); reconnect(); } if (pc.iceConnectionState === 'closed') { ui.showBanner('连接已关闭'); } };同时,Host退出时会把当前游戏状态(所有玩家位置、得分)序列化并广播给存活的客户端,这样对方刷新后还能靠URL里的房间ID重新加入。重连的流程本质上就是重新走一遍信令——新Host会被选举出来,存活的客户端更新连接。简单但能用。
这里顺带说一句,很多人问WebRTC怎么关闭,其实游戏退出时应该主动channel.close()和pc.close(),而不是直接关标签页。我在开发期开着多个标签页调试时,发现不关连接会导致页面后台持续占用UDP端口资源,开多了浏览器直接提示端口不足。
5. 踩坑实录:Chrome策略、NAT打洞失败与信令的N个坑
5.1 Chrome的自动播放策略,居然影响到了游戏音频
做小游戏不加音效是没灵魂的,但我一打开游戏就发现:页面加载了,音频却没有声音。查了很久才发现,Chrome出于用户体验考虑,阻止了没有用户交互的自动播放音频。浏览器认为,用户没点过页面,音频播放是骚扰。
修复方式很简单:在用户第一次点击“开始游戏”时,调用audioContext.resume(),把音频上下文从“挂起”状态恢复过来。但很多音频库没有这个步骤,就会卡死在自己的自动播放限制里。
const startButton = document.getElementById('start'); startButton.addEventListener('click', () => { if (audioContext.state === 'suspended') { audioContext.resume(); } startGame(); });5.2 本地调试的“双开困境”:file://与localhost根本不是一回事
一开始我想省事,直接双击HTML文件,开两个标签页测试联机。结果一个标签页永远连不上另一个。排查发现:file://协议在某些浏览器里根本不被视为安全上下文,很多现代浏览器API(包括WebRTC)在非安全上下文下功能受限或行为不一致。而localhost是明确被当作安全上下文的。
所以我后来开发的日常流程是本地开个Python静态服务器(python3 -m http.server),用http://localhost:8000访问。两个标签页、两个浏览器Profile、两台电脑都测试过,发现只要origin一致,WebRTC连接就能建立。这给我一个教训:调试环境必须和生产环境处于同一个安全上下文级别。
5.3 mDNS候选导致局域网打不通:一场隐蔽的兼容性之战
有一次我在家里两台电脑上测试,网络明明很好,P2P连接却始终处于“connecting”状态。我把两台电脑的SDP打出来对比,发现对方生成的ICE候选里的host类型地址长这样:XXXX-XXXX-XXXX.local,甚至有的浏览器会生成.onion结尾的地址。这是Chrome的mDNS隐私策略——为了保护本地IP不被暴露,Chrome在生成ICE候选时,把真实IP替换成了以.local结尾的随机主机名。
问题在于,同一局域网内应该能直接连通的场景,这样反而会失败:mDNS候选能否被对方解析,取决于浏览器是否开启了对应的mDNS解析能力。不同浏览器、不同版本的兼容性导致我这个本地联调经常“看运气”。
最终解决方式是:在开发阶段,我让信令消息严格保留并检查自己收到的每一个candidate,把type === 'host'且.local结尾的候选也记录下来;同时在ICE配置里加了一个自建TURN服务器兜底。TURN中继虽然多一跳,但至少能保证万一无法直连时,游戏依然能跑。本质上这就是“直连优先、中继兜底”的标准WebRTC容错策略。
5.4 信令服务器当机:我用URL手动传信令
有一次我的极轻量信令服务器(挂在某免费托管平台上)灰度挂了整整一个下午。玩家进不了房间,只能干瞪眼。我临时写了一个“手动模式”:
- 房主点“生成邀请”,游戏把offer SDP写入URL的查询参数(
https://your-domain/?offer=base64sdp); - 把链接发给朋友;
- 朋友打开链接,页面自动解析offer,生成answer,把answer复制到剪贴板;
- 房主在页面“粘贴应答”框里提交answer,连接完成。
这个方案让我再次确信:信令通道本质上只是“交换连接信息”的走廊,它的实现甚至可以极端到“纯手工复制粘贴”。WebRTC真正值钱的能力全在浏览器端,不依赖服务器的实时性。后来我把这个“手动模式”保留成了官方功能——虽然在产品上有点极客,但在没有服务器的情况下也能演示联机,体验非常独特。
5.5 移动端触屏延迟:touch-action与pointer事件的层级问题
前面说了pointer事件的好处,但真正落地才发现还有个坑:手机浏览器的默认手势比你的pointermove事件优先级更高。我在iPhone的Safari上测试时,摇杆拖动总会被页面滚动打断——摇杆滑到边缘,页面就跟着滚走了。加touch-action: none到游戏Canvas上之后,所有浏览器手势(滚动、缩放、双击放大)全部让位给游戏逻辑,手感立即恢复。
另外一个细节是:touchstart事件本身在iOS Safari上有300毫秒左右的延迟,这是老版本的click延迟策略,但现在用pointerdown可以完全避开。如果你还在用touchstart/mousemove各写一套,强烈建议统一到pointer事件族上。
6. 实测数据与工程结论:零依赖到底能不能打
6.1 帧率与内存:零依赖到底能跑多快
我给自己写了个简易Benchmark,在固定场景下统计update + render的耗时:
- 台式机 Chrome 120 / RTX 3060 + 1080p显示器:稳定120fps(我设了渲染上限);
- 小米11 + Chrome:稳定60fps,波动极小;
- 一台很老的MacBook Air + Safari:稳定60fps,电池耗电也不大。
Canvas 2D在像我这种纯几何图形为主的小游戏里,压力其实小得可怜。真正影响性能的不是渲染,而是JavaScript的垃圾回收(GC)。我踩过的一个大坑是:在update里频繁new子弹对象,玩游戏一分钟后内存占用暴涨,接着每一两秒就卡一下,正是GC在回收大量短命对象。
解决方式是对象池化:预先分配1000个子弹槽位,射击时从池里取,命中或出界后回收槽位,整个游戏生命周期内几乎不再动态new对象。
const bulletPool = Array.from({ length: 1000 }, () => ({ active: false, x: 0, y: 0, vx: 0, vy: 0 })); let poolIndex = 0; function spawnBullet(x, y, vx, vy) { const b = bulletPool[poolIndex]; b.active = true; b.x = x; b.y = y; b.vx = vx; b.vy = vy; poolIndex = (poolIndex + 1) % bulletPool.length; return b; }实测下来,几千行JS、几十个活动实体、每毫秒都在更新的游戏,Chrome的堆内存长期稳定在50-100MB之间,长时间挂着也不会因为内存泄漏而卡顿。
6.2 包体积:一个HTML文件就是全部
最终单HTML文件体积:约25KB(未压缩),约11KB(gzip压缩后)。这包含了完整的游戏循环、渲染、音频、输入、网络、UI代码。相比之下,一个最小化的Phaser包在200KB以上,Pixi.js也要100KB左右。11KB大约是一张普通照片的1/200,加载对网络几乎无感。
这个数字的意义不是“我能把代码写得多么紧凑”,而是“零依赖让分发成本降到了物理极限”。我可以把这个HTML文件塞进二维码、塞进邮件签名、塞进聊天窗口,甚至用data:URL直接发出去,对方不下载任何东西就能玩。
6.3 零依赖 + WebRTC把网页小游戏的工程上限推到了哪里
做这个项目之前,我一直觉得网页小游戏的“天花板”是那些用引擎做出来的、需要加载半天的重度HTML5游戏。做完OmniGame之后我意识到,真正的上限是被“零依赖 + P2P”重新定义的:
- 零安装、零插件、零服务器,点击链接即玩;
- 数据面完全P2P,不消耗你任何服务器带宽;
- 单文件可分发给任意客户端,可以塞进任何消息渠道;
- 游戏引擎的每一个细节都对开发者透明,出现问题能在源码层面定位。
这正好回应了我开头说的“重新定义工程上限”:过去我们觉得网页小游戏只能做单机、只能靠服务器联机、只能被框架绑架,现在OmniGame把这三件事同时打破了。一个纯HTML+JS的小游戏,用WebRTC P2P实现多人实时互动,而且包体小到可以忽略——这条技术路线对整个网页游戏生态都有参考价值。
7. 最后分享几点这半年的心得
如果你也想走一遍“零依赖 + WebRTC P2P”这条路,我的建议是:不要想着一步到位。先把两个标签页在局域网里P2P互通跑通,再加插值,再加房间,再加移动端适配。每一步都能独立验证,任何一步失败都能精准定位到模块,而不是在一堆依赖里迷路。
我自己下一步打算扩展三件事:一是用localStorage做临时房间码存续,让刷新后还能回到原房间;二是支持三到四人的乱斗模式,把指令协议从字符串改成更紧凑的二进制格式;三是给游戏加一个简易回放功能,因为P2P模式下所有指令都从浏览器的DataChannel走过,稍微存一下就能回放整局战斗。
最后说一个方法论层面的感想:零依赖和WebRTC不是“复古”,也不是“炫技”,它们是另一种工程思路。框架是拿来用的,不是拿来信仰的。做小游戏的时候,最可怕的事情不是“没有框架怎么办”,而是“出问题之后只能瞎猜框架内部发生了什么”。当你亲手把引擎的每一块砖、网络的每一条链路都搭建过一遍,你会发现网页小游戏的水,远比你想象得深,但也远比你想象得清澈。