系列说明前七篇讲的都是单机:引擎、数据、重复实现、测试、平衡、玩家反馈、前端。 这一篇讲联机—— 一个没有后端的项目,怎么做联机。
我特意把「怎么玩起来」放在第一节。联机这东西,讲不清楚就等于没做: 功能全对、但朋友连不进来,那这个模式在体验上就是零。 讲完怎么用,后面才是设计取舍:为什么是长轮询而不是 WebSocket、 服务端到底管多少、以及一个让我卡了很久的死循环。
🎮 边玩边读
👉 Super Auto Pets · 单机版
纯网页版,点开就能玩——不用下载、不用注册、不用登录。 ⚠️ 但这个公网链接只能单机,联机要看下面第一节。
📦 源码(联机要用)
👉 GitHub - DSJ0812/Super_Auto_Pets · GitHub
(点进去后,点击绿色的Code按钮,选择Download ZIP即可下载全部源码),联机需要你自己开一个小服务器,代码都在这个仓库里。原因见第二节。
一、先把"怎么玩起来"讲清楚
联机的门槛不在代码,在上手。所以先说人话版本。
1.1 谁要准备什么
| 主持人(开房间的人) | 其他人(加入的人) | |
|---|---|---|
| 装 Node.js | ✅ 要,只装一次 | ❌ 不用 |
| 有这份文件夹 | ✅ 要(从上面的仓库下载) | ❌ 不用 |
| 设备 | 一台电脑 | 手机 / 平板 / 电脑都行 |
其他人什么都不用装。用浏览器打开一个网址就能玩 —— 这是我在设计时刻意保住的: 如果连加入的人都要装环境,那这个模式在现实中根本用不起来。
1.2 三步
第 1 步 · 主持人开房
双击文件夹里的开服.bat。(macOS / Linux,或不想用 bat:在文件夹里执行node server.js。)
会弹出一个黑窗口,里面写着两个地址:
🐾 Super Auto Pets · 局域网联机服务器已启动 ---------------------------------------------- 自己玩(本机): http://localhost:8000 发给朋友(局域网): http://192.168.1.23:8000 [WLAN] 👈 用这个 ---------------------------------------------- 宠物包: turtle(龟包,61 只) 人不够时剩下的座位会自动由电脑补位。 关掉这个窗口 = 服务器停止,大家都会掉线。
第 2 步 · 把地址发出去
把http://192.168.x.x:8000那一行原样发给朋友(微信 / QQ 都行)。
列了好几个地址就先用标👈 用这个的那个,不行再换
⚠️ 要发的是
192.168.x.x那一行,别发localhost——localhost的意思是"这台电脑自己",别人打开只会连到他自己
第 3 步 · 大家进来,房主开局
朋友连同一个 WiFi→ 浏览器打开那个地址 → 看到这个:
联机大厅(新版界面)。输入昵称点「加入房间」就进去了 —— 不用注册、不用密码
输入昵称 → 加入房间 →房主点「开始游戏」。人不够也没关系:8 个座位里空着的会自动由电脑补位,1 个人也能开一局练手。
房主还能顺手选一下宠物包
那个大厅里,除了地址和成员名单,还有一行「宠物包」:
房主视角的大厅。中间那行「宠物包」:当前用的包是高亮的(图里是星包),点另一个就换 —— 所有人都会跟着变
所有人都看得见这一局用的是哪个包
只有房主能点(别人看到的是灰的),而且只有开局前能改—— 想换但已经开局了,点「🔄 再开一局」回到大厅再换
为什么不能各选各的:一局只用一个包,两个包的宠物混进同一个池子会把重复率砍半, 而本作的升级完全依赖反复出现同名宠物 —— 混池等于让合成升级变得几乎不可能
1.3 开始之后会发生什么
| 商店是私密的 | 每人自己管自己的商店和队伍,别人看不见你的商店 |
| 操作是即时的 | 买宠物 / 刷新 / 卖宠点完就生效(服务端算完立刻回给你) |
| 回合要一起推进 | 看完战斗动画各自点「进入下一回合」,所有人都点了才开下一回合 |
| 有人挂机 | 房主可以点「⏭ 强制开打」,服务器替没准备的人用电脑打完这一回合 |
| 刷新不掉线 | 凭证存在浏览器里,刷新页面自动回到自己的座位 |
| 种子由服务器定 | 顶部会显示「房间种子」,所有人同一条随机流(服务器决定,所有人同一局) |
| 出局能观战 | 血量归零后可以看完剩下的人打完,最后会告诉你拿了第几名 |
联机商店。顶部是 8 个人的座位面板(血量 / 队伍人数),每个人只看得见自己的商店;右侧那个「出售」区是卖宠物用的
联机混战的一回合:顶部是 8 个人的座位和血量,中间是这一回合的战报(谁打谁、输的扣多少血),下面的「对手 / 我的队伍」才是自己那一场
💡 注意「回合要一起推进」的观感:战报是所有人共享的(谁在哪一局输了、掉多少血), 但商店是自己的。这两件事在界面上是分开的,别混在一起。
1.4 打不开?按这个顺序查
| 现象 | 原因 | 怎么办 |
|---|---|---|
双击开服.bat一闪而过 | 没装 Node.js,或没在 PATH 里 | 命令行敲node -v,能打印版本号才算装好 |
| 想看真正的报错 | bat 窗口关太快,报错看不见 | 在文件夹里打开命令行执行node server.js,报错会留在窗口里 |
| 朋友打开转圈 / 超时 | 不在同一个 WiFi;或防火墙拦了 | 确认连的是同一个路由器;Windows 首次会弹「允许访问网络」,点允许 |
❌ 端口 8000 已被占用 | 上次的服务器没关干净 | 换端口:node server.js 8001,地址端口跟着换 |
黑窗口里没有192.168.x.x | 只检测到 WSL / 虚拟机这类虚拟网卡 | 服务器已自动跳过这类网卡(它们的地址别人连不上);确认真的连上了 WiFi 或网线 |
💡 这几条不是凭空想的 ——每一条都是我自己或者朋友踩过的。 尤其是"一闪而过":批处理挂了但不报错,窗口一闪就没,最容易被当成"电脑坏了"。 所以
开服.bat里专门加了一段"没检测到 Node.js 就把怎么装打印出来"的逻辑。
二、为什么公网链接上没有联机入口
这是整个联机设计的起点,也是我花最久想清楚的一件事。
这个项目是纯静态的:把文件夹丢到 GitHub Pages 上,任何人点开就能玩。静态托管只发文件,不会算游戏。所以它上面没有"大家一起连的服务器"。
那联机怎么办?我把选择权交给了探测:
主页启动时会去问一次服务器(一个
/api/ping请求)探测到了→ 显示「🌐 联机混战」入口
探测不到→ 只显示两个单机模式,页脚提示"想和朋友联机?在主机上双击 开服.bat"
公网链接上看到的就是后者:
新版界面下的「连不上联机服务器」。这段提示直接把出路告诉玩家:联机需要有人在主机上双击 开服.bat
为什么不干脆都显示?因为一个点了没反应的按钮,比一个不存在的按钮更糟。
如果公网上也显示"联机混战",用户点进去只会看到一个连不上的加载界面 —— 他会以为是网络问题,反复刷新,最后觉得"这游戏联机是坏的"。 不如从一开始就不给这个入口,并且明确告诉他"联机需要在主机上开服务器"。
⚠️ 这里有个探测本身的坑:静态托管对不存在的路径通常返回404 或者一份 HTML, 所以不能只看状态码。必须校验返回内容里有没有
online: true这个字段 —— 否则任何一台服务器都会"探测成功"。
三、为什么是长轮询,不是 WebSocket
"联机"这个词在很多人脑子里等于 WebSocket。我这里用的是长轮询(long polling)。
先解释它是什么,再解释为什么:
客户端: GET /api/poll?token=xxx&since=42 服务器: ……(挂着不回复,直到有新事件或超时) 服务器: { seq: 45, events: [...] } 客户端: 处理完,立刻再发一个同样的请求就是"一直问,但服务器会挂住不马上答"。有新事件就立刻答,没有就挂到超时才答。
为什么不用 WebSocket:
| WebSocket | 长轮询 | |
|---|---|---|
| 依赖 | 需要ws之类的第三方库 | 零依赖——node自带的http就够 |
| 部署 | 某些反向代理要专门配置 | 就是个普通 HTTP 请求,哪都能跑 |
| 断线重连 | 要自己写 | 天然就是重连—— 下一轮请求就是重连 |
| 实时性 | 更好(真·双向推送) | 对这种回合制完全够用 |
这个项目的一条硬约束是零第三方依赖:整个仓库除了自己的代码,没有node_modules。 选长轮询之后,server.js直接require('http')就能跑,用户下载下来双击开服.bat就行, 不需要npm install。
代价:每个客户端一直在占着一个 HTTP 连接。8 个玩家就是 8 个挂着的请求。 对局域网朋友局来说这完全无所谓;但如果要撑几百人,长轮询就不合适了。
💡技术选型要看场景,不是看先进程度。一局 8 个人、每个回合几秒到几十秒推一次,用 WebSocket 是拿高射炮打蚊子 —— 而且要付出"用户得先
npm install"的代价,那才是真正会劝退人的地方。
四、服务端权威:客户端只说"我想干嘛"
联机里最要命的一类 bug 是两边算得不一样。
比如价格:客户端本地算出来"这只宠物 3 金",服务端算出来是 2 金 —— 玩家看到扣 3 金,实际扣 2 金;或者反过来。这种问题不报错,但玩家会觉得"这游戏不对劲"。
我把架构定成服务端权威(server authoritative):
客户端 服务器 │ { type:'buyPet', slot:2 } ────────► │ │ │ 用【服务端】的规则算 │ ◄──────── { ok:true, msg:'购买了…' } │ │ ◄──────── { you:{…}, roster:[…] } │ 只把你该看的那份回给你客户端只发"意图",不发结果。它不说"我花了 3 金买了猫",只说"我想买 2 号位"。 价格、合法性、扣钱、队伍变化,全部服务端算完再回。
那客户端为什么还留着一份价格计算?为了显示。得让玩家在点之前看到"3 金"这个数字。 于是就有了这个坑:两份实现,必须对齐。
/* 房间配置在【开房这一刻快照】。 * ⚠️ 不要写成每次动态读 CFG:那样任何人在同进程里改了 CFG(比如本地切到别的模式), * 这个房间报出去的配置就会跟着漂移,而客户端的价格显示是拿它算的。 * 经济模式一局之内固定不变;宠物包可以在大厅阶段由房主改(见 setPack), * 但也仅限大厅 —— 开局后再换就会和已经刷出来的商店对不上。 */ this.cfg = { economy: CFG.ECONOMY, pack: activePack() };配置(经济模式、宠物包)由服务器在握手时下发,客户端收到就应用。 这样"显示用的那份"和"结算用的那份"永远同源。
🔁 第一节那个「房主选宠物包」走的也是同一条路子: 房主点一下 → 客户端发出一个
setPack的意图→ 服务器改自己的配置、 再广播给所有人 → 客户端收到就应用。客户端自己不去改这个值—— 一改,两边就又分叉了。
💡 这是第 3 篇「同一个功能我写了三遍」的同一个病根:只要有两个地方算同一件事,就一定会分叉。这里的解法不是"让两边算得一样",而是让其中一边只负责显示、另一边只负责算。
五、你只能看见自己那份
联机里"信息"是要管的。
如果服务器把所有玩家的完整状态广播给所有人,那看一眼别人的商店就知道他下回合要干嘛—— 这个模式最有趣的部分(互相猜阵容)就没了。所以服务器是按座位过滤的:
OnlineGame.prototype.eventsSince = function (seatIdx, since) { const out = []; for (const ev of this.events) { if (ev.seq <= since) continue; if (ev.to === null || ev.to === seatIdx) out.push(ev); // 广播 or 发给我的 } return out; };每条事件带一个to:null表示广播(比如"第 3 回合开始"),否则只发给某个座位。 客户端拉取时,服务器只挑"广播 + 发给他的"给他。
别人的商店、别人的手牌,从来没进过你的浏览器。
六、刷新不掉线
浏览器刷新 = 页面状态全没。如果没处理,刷新一下就丢座位, 而且另外 7 个人得等你重新加入。
做法很朴素:加入房间时发一个 token,客户端存在localStorage里。 刷新后带着 token 请求/api/rejoin,服务器按 token 找回你的座位,把完整快照给你。
/* ⚠️ 必须把游标推到服务器当前的 seq。 * 收到 reset 才来 rejoin 的场合,本地 since 一定【落在事件裁剪线之前】, * 不推的话下一轮 poll 立刻又判定 reset —— reset ↔ rejoin 无限空转。 * 快照是完整状态,跳到当前 seq 不会漏事件。 */ if (r.seq != null) NET.since = r.seq;
上面这段注释,就是下一节那个 bug 的补丁。
七、翻车:reset ↔ rejoin 死循环
症状
联机玩到一半,某个客户端卡住不动了。不报错、不断线,就是再也不更新。
根因链
服务器为了不让事件无限堆积,会保留最近若干条;太旧的会被丢掉。 客户端如果落后太多(比如挂后台很久、或者网络断了很久), 它的游标就落在"已经被丢掉"的那段里了。这时服务器会说:"你别增量拉了,重新拉一份完整快照吧。"
于是有个约定:
客户端 poll → 服务器:{ reset: true } 客户端 → 服务器:/api/rejoin (把完整快照给我) 客户端 → 服务器:/api/poll?since=旧游标 ← 问题在这rejoin只回了快照,没有回"当前游标"。客户端把手里的快照应用了, 但它本地的since还是那个旧值—— 下一轮 poll 一开口,服务器一看: "你还是落后于裁剪线啊",又 reset。
第 1 轮:reset (since=10 < 裁剪线 100) → rejoin ok → 客户端 since 还是 10 第 2 轮:reset (since=10 < 裁剪线 101) → rejoin ok → 还是 10 第 3 轮:reset ……
更糟的是rejoin内部会顺手推一条"大厅状态"事件,于是裁剪线自己还在往前跑—— 缺口只会越拉越大,永远恢复不了。
而且它还是全速空转:reset是立刻返回的,不是等超时,所以这两步会疯狂循环打服务器。
为什么熔断没救回来
客户端本来有个保险:连续失败 40 次就提示"与服务器断开了,请刷新页面"。 但那个计数在每次成功收到响应时清零—— 而 reset 是一次成功的HTTP 响应。
"熔断"如果只统计"网络错误",那它就拦不住"逻辑错误造成的循环"。
修法
rejoin把当前seq一起回传,客户端据此把游标推到位:
/* ⚠️ 必须把 seq 一起给客户端。客户端收到 poll 的 reset(事件被裁剪 / 房间换代) * 后会来 rejoin,如果这里不回 seq,客户端就无从校正它本地的游标, * 于是下一轮 poll 又落在裁剪线之前 → 又 reset → 又 rejoin …… 死循环。 */ return { ok: true, seat: seat.idx, name: seat.name, seq: this.seq, snapshot: this.snapshot(seat), config: this.roomConfig() };修完拿探针跑:
【修前】rejoin 不动 NET.since → 50 轮内是否收敛:否 ← 死循环 【修后】把 NET.since 推到 seq → 是否收敛:是 ✓ 空转轮数:1
修前 50 轮不收敛,修后 1 轮收敛。
💡 这个 bug 有两个地方值得记:
缺一个字段就够造成死循环。不是逻辑写错,是"该传的东西没传"。
它完全没有报错。表现是"卡住",而"卡住"会被归因成网络问题 —— 我第一反应也是"网络不好",直到把 reset 的次数打出来。
八、这套设计付了什么代价
前端那篇我给每个技术点都写了代价,这篇也一样:
| 选择 | 换来什么 | 代价 |
|---|---|---|
| 长轮询 | 零依赖、双击就能开服 | 每个客户端常占一个 HTTP 连接;实时性不如 WebSocket |
| 服务端权威 | 两边不会算出不同结果 | 客户端要多留一份"只用来显示"的规则,必须和服务器同源 |
| 按座位过滤事件 | 看不见别人的商店 | 服务器每条事件都要判一次"该发给谁",且裁剪逻辑要正确(否则就是上一节那个坑) |
token 存localStorage | 刷新不掉线 | 换浏览器/无痕模式就要重新加入;token 泄露 = 别人能坐你的位子(局域网朋友局里可接受) |
| 人不够用电脑补位 | 1 个人也能开一局 | "8 人混战"里到底几个真人,界面得说清楚,不然会误会 |
最后一条补充一下:局域网朋友局的信任模型和公网服务不一样。这个服务器假设"连上来的人都是朋友",所以没有做防作弊、没有做身份校验。 如果哪天要放到公网上给人随便连,那得从头加一整套东西 —— 现在这套不够用。
小结
| 这件事 | 学到什么 |
|---|---|
| 把"怎么玩起来"放在文章第一节 | 联机讲不清楚就等于没做—— 功能对、朋友连不进来,体验上就是零 |
| 探测不到就不显示入口 | 一个点了没反应的按钮,比一个不存在的按钮更糟 |
| 探测要校验内容 | 静态托管对不存在的路径也可能回一份 HTML(甚至 200),只看状态码会全部"探测成功" |
| 选长轮询不选 WebSocket | 技术选型看场景:零依赖(双击就能开服)比"更先进"重要得多 |
| 服务端权威 | 两个地方算同一件事一定会分叉;解法是让一边只显示、一边只算 |
| 按座位过滤 | 联机里"信息"本身是要管理的,不然最好玩的部分(猜阵容)就没了 |
| 熔断只统计网络错误 | 它拦不住逻辑错误造成的循环—— reset 是一次"成功的"响应 |
| reset ↔ rejoin 死循环 | 缺一个字段就够造成死循环,而且它不报错,表现只是"卡住" |
还有一条是这个项目反复出现的:联机是"三个模式通病"的高发区。同一份商店逻辑要同时活在单机、8 人混战、联机三个地方, 每次动其中一处,都得回头看看另外两个 —— 这个条件反射已经形成很多轮了。
💬 说说你的想法
联机这块我最想知道的是它到底能不能顺利跑起来:
| 如果你遇到了 | 告诉我 |
|---|---|
| 按第一节的步骤卡在某一步 | 卡在哪一步、报什么错(或者是不是"一闪而过") |
| 手机连不上 | 是安卓还是 iPhone、地址打开后是什么样 |
| 8 个人一起玩有问题 | 人多了之后有没有变慢、有没有人掉线 |
| "我们都在一起玩了,但哪里怪怪的" | 这类最宝贵 —— 比如某个人看到的和别人不一样 |
联机有个特点:我在自己电脑上测是测不出真实网络环境的。 不同路由器、不同 WiFi、手机浏览器、防火墙、公司网络…… 这些我很难覆盖到,所以如果你卡住了,请一定说。
另外,如果你想把它改成能公网联机(现在这套只适合局域网), 那是个不小的工程(要加身份、防作弊、断线兜底),可以单独聊。
下一篇写什么
《排行榜:把别人的队伍拿来打》
单机打久了会腻 —— 不是玩法腻,是没有对手。 排行榜要解决的是这件事:让你能打到"别人真的摆过的那套阵容"。
下一篇讲三件事:
怎么用Firebase做一个不需要自建后端的榜单 —— 以及它为什么在国内要用代理 (一个本地跑得好好的功能,换到真实网络环境就"连不上",这类坑很难在自测里发现)
「挑战上榜」这个玩法:从最后一名往上打,赢一场就把名次挤下去
一个很实际的序列化坑:Firestore 拒绝
undefined—— 少写一个字段,整条记录就存不进去
(在线版还在持续迭代中——你点进去玩到的,可能比我写这篇时又新了一些。如果发现哪里和文章对不上,多半是我后来改了。)