零依赖原生JS+WebRTC:手写引擎与P2P联机小游戏实战
做网页小游戏这件事我身边几乎所有人都在用框架。有人在用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低低极小直接、无中间层小型游戏、几何图形、UIPixi.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读InputNet收指令后改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/ICEWebRTC的数据面虽然是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位置 x100, y200 }; 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维护一份“玩家状态表”的最权威副本客户端把位置、射击事件上报给HostHost计算命中判定把最终结果广播给所有客户端。这样做是为了避免两个玩家各说各话互相都觉得自己赢了。虽然P2P架构天然无法做到绝对防作弊——因为Host本来就是玩家之一——但对一个双人小游戏来说这种一致性已经足够了。4.3 断线重连与优雅退出WebRTC连接断开的处理很多人会忽略。我之前也以为“关了页面就完事了”结果有玩家反馈一个人刷新页面另一个人那边直接卡死。我后来加了状态机监听channel.onclose和pc.iceConnectionStatechannel.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/?offerbase64sdp把链接发给朋友朋友打开链接页面自动解析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未压缩约11KBgzip压缩后。这包含了完整的游戏循环、渲染、音频、输入、网络、UI代码。相比之下一个最小化的Phaser包在200KB以上Pixi.js也要100KB左右。11KB大约是一张普通照片的1/200加载对网络几乎无感。这个数字的意义不是“我能把代码写得多么紧凑”而是“零依赖让分发成本降到了物理极限”。我可以把这个HTML文件塞进二维码、塞进邮件签名、塞进聊天窗口甚至用data:URL直接发出去对方不下载任何东西就能玩。6.3 零依赖 WebRTC把网页小游戏的工程上限推到了哪里做这个项目之前我一直觉得网页小游戏的“天花板”是那些用引擎做出来的、需要加载半天的重度HTML5游戏。做完OmniGame之后我意识到真正的上限是被“零依赖 P2P”重新定义的零安装、零插件、零服务器点击链接即玩数据面完全P2P不消耗你任何服务器带宽单文件可分发给任意客户端可以塞进任何消息渠道游戏引擎的每一个细节都对开发者透明出现问题能在源码层面定位。这正好回应了我开头说的“重新定义工程上限”过去我们觉得网页小游戏只能做单机、只能靠服务器联机、只能被框架绑架现在OmniGame把这三件事同时打破了。一个纯HTMLJS的小游戏用WebRTC P2P实现多人实时互动而且包体小到可以忽略——这条技术路线对整个网页游戏生态都有参考价值。7. 最后分享几点这半年的心得如果你也想走一遍“零依赖 WebRTC P2P”这条路我的建议是不要想着一步到位。先把两个标签页在局域网里P2P互通跑通再加插值再加房间再加移动端适配。每一步都能独立验证任何一步失败都能精准定位到模块而不是在一堆依赖里迷路。我自己下一步打算扩展三件事一是用localStorage做临时房间码存续让刷新后还能回到原房间二是支持三到四人的乱斗模式把指令协议从字符串改成更紧凑的二进制格式三是给游戏加一个简易回放功能因为P2P模式下所有指令都从浏览器的DataChannel走过稍微存一下就能回放整局战斗。最后说一个方法论层面的感想零依赖和WebRTC不是“复古”也不是“炫技”它们是另一种工程思路。框架是拿来用的不是拿来信仰的。做小游戏的时候最可怕的事情不是“没有框架怎么办”而是“出问题之后只能瞎猜框架内部发生了什么”。当你亲手把引擎的每一块砖、网络的每一条链路都搭建过一遍你会发现网页小游戏的水远比你想象得深但也远比你想象得清澈。