下载 App
10/1022 浏览BUG反馈
Tap制造踩坑实录:persistent_world 常驻服多玩家冷启动分裂,以及我们被 RestartGuard 逼出来的无奈解法
引擎:TapTap Maker(UrhoX WebGL)
模式:多人游戏 + 常驻服(persistent_world)+ 自定义大厅(lobby_runtime)
日期:2026-10-11


一、问题是什么
我们的游戏用了 TapTap Maker 的常驻服模式(persistent_world)——也就是说,玩家启动游戏后,引擎会自动查询一个"中厅"(middle room),所有玩家都应该进到同一个常驻服实例里对战。
正常情况下流程是这样的:
玩家冷启动 → 引擎 QUICK_QUERY_MIDDLE_ROOM → 找到中厅 → JOIN → 连服 → 开局
但我们发现一个致命问题:
当两个玩家几乎同时启动游戏、而此时中厅还不存在时,两个人会各自建房,变成两个互不相通的"孤房",永远无法汇合。
玩家A 冷启动 → QUICK_QUERY 失败(中厅还没建好)→ 建房 #111
玩家B 冷启动 → QUICK_QUERY 失败(中厅还没建好)→ 建房 #222
→ A 在 #111,B 在 #222,谁也看不到谁
线上如果有 10 个玩家同时打开游戏、而中厅恰好处于无人状态(比如服务器刚重启、或所有人都退了),就会分裂成 10 个孤房,每个房 1 个人。
这在实时对战游戏里是不可接受的。


二、我们怎么定位的
先上结论:这个问题的根因不在客户端逻辑,在引擎的调度黑盒里。
2.1 服务端选举:谁该退房?
我们先做了一个客户端+服务端配合的"选举"机制:
- 每个孤房里的玩家,每 2 秒把自己的 uid 写到服务端的 serverCloud 键里(lone_claim)
- 服务端每 1 秒聚合所有孤房的声明,找出 uid 最小的那个(它是"锚点房",应该留守)
- 其他所有 uid 更大的玩家,通过 lobby_state 广播收到一个 E=1(eject)标记
- 收到 E=1 的客户端执行退房,试图回流到锚点房
这部分是好做的,服务端选举判定也正确——真机测试里,小 uid 玩家收到 E=2(留守),大 uid 玩家收到 E=1(退房),完全符合预期。
2.2 问题出在:退房之后呢?
大 uid 玩家收到 E=1,执行 ReturnToLobby() 退房回大厅。然后呢?
我们以为退房回大厅后,引擎会像冷启动一样自动 QUICK_QUERY_MIDDLE_ROOM 重新查中厅。
并没有。
翻文档(后来用云端 AI 帮我们翻了引擎内部文档)才确认:
ReturnToLobby 是主动离开语义。引擎设计上就不会自动重连、不会自动查房,只恢复大厅 UI 等玩家手动操作。
冷启动会自动查房,是因为它走的是 persistent_world 的连接流程;ReturnToLobby 走的是"主动返回",两条路径的引擎契约本就不同。
这不是 bug,是产品设计语义。普通模式下玩家退房后手动点"开始游戏"就行了,但我们是常驻服——根本没有"开始游戏"按钮,玩家是被引擎自动拉进中厅的。
更坑的是,我们客户端调 client:GetRoomList() 想自己找中厅,返回永远是空数组——中厅不在普通房间列表里。


三、我们试过的所有方法(全部失败)
方法 1:服务端下发锚点 roomId,客户端回流后 JoinRoom
服务端在 eject 时把锚点房的 roomId 通过 A 字段下发,客户端回流大厅后直接 client:JoinRoom(锚点roomId)。
结果:A=nil。服务端写的锚点 roomId 没下发成功——GetRankList 聚合时读不到我们写的第二个 iscores 键,或者 PERSISTENT_WORLD_KEY 根本不是普通意义上的 roomId。
这条路我们加了诊断日志想查服务端到底写了什么,但服务端日志的 watcher 只跟单实例,拉不到我们要的东西。
方法 2:RequestRestartGame(薄壳加载时立即发)
既然退房后引擎不查房,那我们就重启整个游戏——重启后 Lua 状态重建、Network/Lobby 保留,重走冷启动连接流程,自然就会 QUICK_QUERY。
引擎确实有这个本地事件:SendEvent("RequestRestartGame", VariantMap())。官方 i18n 切语言就是这么做的(i18n.lua:1648)。
结果:真机日志直接报 [RestartGuard] blocked RequestRestartGame。
引擎在某个相位会吞掉这个事件。文档里对 RestartGuard 零命中——它是 C++ 内部实现,没有任何公开文档。
方法 3:PersistentApp 壳延迟 5s 再发
我们猜 RestartGuard 拦的是"薄壳加载瞬间",于是改成:退房后落盘一个标志,PersistentApp 壳连服稳定后(大厅相位)延迟 5 秒再发。
结果:倒计时还没跑完(5 秒内),新的 lobby_state E=1 又来了,触发 eject 退房,脚本卸载,倒计时被打断。
而且更要命的是——这个逻辑放在 PersistentApp 壳里,正常冷启动建房的玩家也会被误武装倒计时(因为 pending 标志上次没清干净)。
方法 4:薄壳里延迟 10s 再发
又猜:那等 10 秒够了吧?RestartGuard 冷却总该过了。
结果:10 秒到点发,日志还是 [RestartGuard] blocked RequestRestartGame。
不是冷却问题,是整个大厅相位都被 guard 拦。
方法 5:游戏脚本相位连发 ReturnToLobby + RequestRestartGame
文档说 i18n 能发是因为它在游戏脚本稳定运行中发(不是大厅相位)。那我们就在 PersistentApp 壳里(游戏脚本环境)同一帧连发两个事件:
luaSendEvent("ReturnToLobby", VariantMap()) -- 先退房 SendEvent("RequestRestartGame", VariantMap()) -- 紧接着重启
引擎 C++ 层排队处理:先退房再重启。重启时已经退房了(Left,不是断线),服务端孤房已销毁,Lobby 重连走冷启动 QUICK_QUERY。
结果:日志还是 [RestartGuard] blocked RequestRestartGame。
不管在哪个相位发,只要和大厅/lobby 状态沾边,全拦。
方法 6:给个"开始游戏"按钮,让玩家手动点了再进中厅
这是我们最期望的方案——不自动连中厅,大厅界面放一个按钮,玩家点了才连。这样 eject 分裂后退回大厅,玩家再点一次按钮就行,不用退出游戏。
结果:走不通。
引擎文档写得很明确:
客户端连接流程(Lobby 自动处理,游戏脚本无需关心):
Lobby 加载 → 发现 persistent_world 已启用 → 查询可加入的房间 → 找到就加入/没找到就建房 → 连服
这个流程是 Lobby C++ 层自动跑的,薄壳 Lua 既拦不住、也触发不了:
- 冷启动时引擎自动查房连服,薄壳代码里 grep 不到任何 QUICK_QUERY 相关字符串——全是引擎内部行为
- ReturnToLobby 回大厅后引擎不自动查房(设计如此),但薄壳里也没有"手动触发查房"的 API
- client:StartMatch 是普通房间匹配,在 persistent_world 模式下走的不是中厅查房(实测 GetRoomList 永远返回空,中厅不在普通房间列表里)
中厅必须自动进,没有手动入口。


四、被堵死之后的无奈解法
自动重启这条路彻底走不通了。客户端又没有 JS 桥(不能 location.reload()),engine:Exit() 是直接退游戏进程不是刷新页面。给按钮手动进中厅也走不通——引擎不支持。
那只能让玩家自己动手退出游戏再重进。
最终方案:强制弹窗,红色按钮退出游戏
既然 E=1(被 eject)的客户端就是"该退的那个",那我们直接弹一个关不掉的全屏弹窗:
- 红色标题:"大厅环境异常"
- 白色提示:"请退出游戏后重新进入"
- 一个红色按钮:"退出游戏" → 点击调 engine:Exit()
没有关闭按钮,没有遮罩点击穿透,玩家只能点退出。
然后玩家重新打开游戏 = 真正的冷启动 → 引擎自动 QUICK_QUERY → 这时候锚点房(小 uid 玩家建的那个)已经存在了,直接 JOIN 成功,两人进同一个房。
判据
lua-- onLobbyState 收到 (R, S, G, E, A) 时 if R == 1 and E == 1 then -- 自己在孤房,且被服务端标记为 eject 方(大 uid) -- 弹强制退出弹窗 ShowSplitAlert() end
- E=1(大 uid,被 eject)→ 弹窗
- E=2(小 uid,锚点留守)→ 正常待着,不弹窗


五、踩坑总结(给后来者)
| 方法 | 为什么失败 |
|---|---|
| 服务端下发锚点 roomId 客户端 JoinRoom | rankList 聚合读不到第二键,A=nil |
| 薄壳加载即发 RequestRestartGame | RestartGuard 拦 |
| PersistentApp 壳延迟 5s 发 | 倒计时被新 eject 打断;误武装正常冷启动 |
| 薄壳延迟 10s 发 | RestartGuard 拦整个大厅相位,不是冷却 |
| 游戏脚本相位连发 ReturnToLobby+Restart | 还是被拦 |
| 给"开始游戏"按钮手动触发中厅连接 | 引擎不支持——Lobby 自动查房,Lua 拦不住也触发不了 |
| client:GetRoomList 找中厅 | 中厅不在普通房间列表,永远空数组 |
| Lua JS 桥刷新页面 | 引擎没有 EvalJS / location.reload 接口 |
核心教训:
- TapTap Maker 的 persistent_world 模式假设"中厅只有一个、且由服务端管理"。如果出现客户端同时冷启动各自建房的分裂,客户端层几乎没有干净的解法。
- RequestRestartGame 不是你想发就能发的——RestartGuard 在 C++ 内部,文档零命中,我们试了所有相位全被拦。只有 i18n 那种"对局中切语言"的场景能成功。
- ReturnToLobby 是主动离开语义,引擎不自动重连不自动查房——别指望退房后引擎帮你重新匹配。
- GetRoomList 返回的是普通房间,不含 persistent_world 中厅。别想着客户端自己找中厅。
- 中厅连接是 Lobby 自动跑的,Lua 拦不住也触发不了——没有"手动触发查房"的 API,给不了玩家一个"点了才进中厅"的按钮。
- 最后能用的,就是让玩家手动退出重进。丑,但可靠。。。。。。


六、还没解决的问题
- RestartGuard 到底在什么条件下放行?冷却窗口多长?文档里完全没有,只能靠真机逐相位试。
- 如果线上真的同时来了 100 个玩家,小 uid 那个建房后,其他 99 个人都要弹"请退出重进"——体验很糟,但这是目前客户端能做的极限。
- 真正的解法应该在服务端:冷启动建房时加全局锁,或者由服务端主动创建中厅而不是让客户端建。但我们管不到服务端。
如果你也在做 TapTap Maker 常驻服游戏,希望这篇能帮你少踩几个坑。












