Compare commits

...
3 Commits
Author SHA1 Message Date
ud18010 42c3874d82 fix: improve minigame reconnect flow 2026-07-29 19:30:50 +08:00
ud18010andClaude Opus 4.8 6a5e534d00 feat: isolate lobby during minigames
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:26:54 +08:00
ud18010andClaude Opus 4.8 31afce6882 feat: add minigame room player service
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 12:24:58 +08:00
36 changed files with 4261 additions and 66 deletions
@@ -0,0 +1,709 @@
# 小游戏开发设计文档
## 1. 目标与适用范围
本文档定义本项目小游戏的统一开发、接入、发布与联调规范。与参考项目中基于 `MiniGameStage` / `IMiniGame` 的内嵌 Stage 方案不同,本项目已落地独立热更小游戏框架:小游戏以独立版本包发布,客户端通过 `XGame.MiniGame` 宿主下载、校验、加载热更 DLL 与资源 AssetBundle,并通过统一网络帧与服务端房间逻辑通信。
适用范围:
- 新增一个独立小游戏,例如 `RockPaperScissors``FishingQuick``TreasureCatch`
- 维护小游戏客户端热更逻辑、共享 Core 逻辑、服务端房间逻辑。
- 生成小游戏 UI Prefab、贴图、音效等资源。
- 发布小游戏客户端 CDN 包和服务端 deploy 包。
- PC 本地 smoke、网关/CDN 联调、真机资源更新排查。
核心目标:
1. 小游戏逻辑全部使用 C# 实现,通过热更 DLL 加载,不使用 Lua 写小游戏专属逻辑。
2. 小游戏与主工程解耦:主工程只保留宿主、下载、加载、网络、结算和基础服务。
3. 小游戏客户端、服务端共享同一份 Core 规则,避免双端规则漂移。
4. 资源加载统一异步,避免真机读取旧底包资源。
5. 发布产物有版本、MD5、签名和框架版本门槛,加载失败时安全拒绝进入。
## 2. 总体架构
本项目小游戏由四层组成:
```text
大厅/入口层
MiniGameHost(主工程常驻宿主)
热更小游戏客户端 DLLIGameClient
共享 Core DLL + 服务端房间 DLLIGameServerRoom
```
### 2.1 大厅/入口层
大厅负责决定进入哪个小游戏、使用哪个版本、连接哪个网关和 CDN。进入小游戏时将以下信息传给宿主:
- `cdnBase`:小游戏 CDN 根地址,例如本地 `http://127.0.0.1:15081/`
- `gameId`:小游戏标识,例如 `rps`
- `version`:小游戏版本号。
- `XWebSocket`:已连接的网关 socket。
- 是否由宿主发送匹配请求。
大厅链路如果已经消费了 `MatchFound`,需要通过 `MiniGameHost.SetMatchInfo(roomId, selfPlayerId)` 把房间信息注入宿主,确保结算弹窗能判断胜负。
### 2.2 宿主层
宿主层在 `Client/Assets/Script/xmain/MiniGame/` 下,主要类如下:
| 类 | 职责 |
|---|---|
| `MiniGameHost` | 小游戏会话宿主,负责下载、加载、创建 `IGameClient`、每帧驱动、结算弹窗和退出清理。 |
| `MiniGameDownloader` | 从 CDN 下载 `game.json``files.txt``files.txt.sig` 和所有清单文件,校验版本、框架版本、签名和 MD5。 |
| `MiniGameManifest` | 客户端 `game.json` 视图,包含 `gameId``version`、DLL 名、入口类型、资源 AB 列表和文件 MD5 清单。 |
| `MiniGameAssemblyLoader` | 从代码 AB 中取出 Core/Client DLL `.bytes`,按 Core → Client 顺序 `Assembly.Load`,反射创建 `IGameClient`。 |
| `MiniGameAssetLoader` | 实现 `IAssetLoader`,从已下载的小游戏资源 AB 中异步回调返回 Unity 对象,退出时统一卸载。 |
| `MiniGameNetChannel` | 负责 socket 收发、帧解码和 Channel 分发;Game 帧给小游戏,Framework 帧给宿主。 |
| `GameClientCtx` | 注入给小游戏客户端的上下文,包含资源、日志、发送 Game 消息、退出请求。 |
宿主只处理通用流程,不写任何具体小游戏玩法逻辑。
### 2.3 热更小游戏层
每个小游戏至少包含:
- `Core`:纯 C# 共享规则、状态、消息编解码、胜负判定,不引用 UnityEngine。
- `Client`:实现 `XWorld.Framework.IGameClient`,负责 UI、输入、表现、客户端状态展示、通过通用玩家服务显示房间用户信息,并向服务端发送 Game 消息。
- `Server`:实现 `XWorld.Framework.IGameServerRoom`,负责权威房间逻辑、AI 兜底、Tick、广播快照和结束房间。
- `res`:Prefab、贴图、音效、动画等资源,打包为资源 AB。
- `publish.json`:发布配置,描述 DLL、入口类型、玩家人数、tickRate 等元信息。
### 2.4 服务端层
服务端加载 `deploy/minigame/{gameId}/{version}/` 下的服务端产物,通过 `GameModuleLoader` 校验 `minFrameworkVersion` 后加载 `IGameServerRoom`。服务端房间只接收并广播 Game 通道消息;匹配、房间开始、房间结束等框架流程由宿主与网关处理。
## 3. 推荐目录规范
### 3.1 客户端小游戏目录
```text
Client/Assets/MiniGames/{GameId}/
├── README.md
├── publish.json
├── scripts~/
│ ├── Core/
│ │ ├── GameTypes.cs
│ │ ├── GameLogic.cs
│ │ └── GameCodec.cs
│ └── Client/
│ └── GameClient.cs
└── res/
├── UI/
│ ├── Prefab/
│ │ └── UI_{GameId}.prefab
│ └── Texture/
│ └── game_icon.png
├── Sound/
└── Effect/
```
说明:
- `Assets/MiniGames/{GameId}/res` 会被发布流水线收集并打进 `res.unity3d`
- `scripts~` 为 Unity 隐藏源码目录,避免直接进入 Unity AssetDatabase;实际编译由对应 csproj 控制。
- 示例可参考 `Client/Assets/MiniGames/RockPaperScissors`
### 3.2 客户端热更工程目录
```text
Client/HotUpdateGames/{GameId}.Client/
└── {GameId}.Client.csproj
```
客户端 csproj 编译出 `{GameId}.Client.dll`。如果 `Core` 使用单源共享,Client 工程应链接同一份 Core 源码或引用发布流程指定的 Core DLL,保证 `publish.json` 中的 `coreDll``clientDll` 能被流水线找到。
### 3.3 服务端小游戏目录
```text
Server/games-src/{GameId}/
├── {GameId}.Core/
│ └── {GameId}.Core.csproj
└── {GameId}.Server/
├── {GameId}.Server.csproj
└── GameServerRoom.cs
```
服务端工程编译出:
- `{GameId}.Core.dll`
- `{GameId}.Server.dll`
服务端薄层应尽量只处理房间上下文、玩家输入、AI、广播和房间结束,玩法规则放在 Core。
### 3.4 发布产物目录
```text
CDN/minigame/{gameId}/{version}/{platform}/
├── game.json
├── files.txt
├── files.txt.sig
├── code_<md5>.unity3d
└── res_<md5>.unity3d
deploy/minigame/{gameId}/{version}/
├── game.json
├── files.txt
├── files.txt.sig
├── {server/core dll md5 命名产物}
└── {server room dll md5 命名产物}
```
客户端只有在 CDN 侧完整产物落位后,服务端版本才应该生效。发布流水线当前按“客户端先落 CDN,最后落 deploy”的顺序处理,避免服务器广播了新版本但 CDN 缺包。
## 4. `publish.json` 规范
每个小游戏根目录必须包含 `publish.json`。示例:
```json
{
"gameId": "rps",
"serverSrcDir": "RPS",
"serverProject": "RPS.Server/RPS.Server.csproj",
"serverAssembly": "RPS.Server.dll",
"serverEntryType": "RPS.Server.RpsServerRoom",
"clientProject": "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj",
"coreDll": "RPS.Core.dll",
"clientDll": "RPS.Client.dll",
"clientEntryType": "RPS.Client.RpsGameClient",
"minFrameworkVersion": 1,
"playerCount": 2,
"tickRateHz": 10
}
```
字段说明:
| 字段 | 说明 |
|---|---|
| `gameId` | 小游戏唯一标识,推荐小写短名,例如 `rps`。 |
| `serverSrcDir` | 服务端源码目录,相对 `Server/games-src/`。 |
| `serverProject` | 服务端 room 工程路径,相对 `Server/games-src/{serverSrcDir}/`。 |
| `serverAssembly` | 服务端房间 DLL 文件名。 |
| `serverEntryType` | 实现 `IGameServerRoom` 的完整类型名。 |
| `clientProject` | 客户端热更工程路径,相对仓库根目录。 |
| `coreDll` | 共享 Core DLL 文件名。 |
| `clientDll` | 客户端 DLL 文件名。 |
| `clientEntryType` | 实现 `IGameClient` 的完整类型名。 |
| `minFrameworkVersion` | 最低框架版本;高于当前 `FrameworkInfo.Version` 时客户端/服务端均拒绝加载。 |
| `playerCount` | 匹配人数。 |
| `tickRateHz` | 服务端房间 Tick 频率,常用 10-20Hz。 |
## 5. 客户端接口与生命周期
### 5.1 `IGameClient`
小游戏客户端入口必须实现 `XWorld.Framework.IGameClient`
```csharp
public interface IGameClient
{
void OnEnter(IGameClientCtx ctx);
void OnNetMessage(NetMessage message);
void OnUpdate(float deltaTime);
void OnExit();
}
```
职责划分:
- `OnEnter`:缓存 `ctx`,读取房间玩家列表,加载 UI/资源,初始化本地状态,绑定按钮事件。此时房间可能尚未 ready,不要依赖 `MatchFound` 已到达。
- `OnNetMessage`:处理服务端 Game 通道消息,例如快照、对手操作、倒计时、结算预告。
- `OnUpdate`:处理客户端表现层更新、倒计时显示、动画过渡和输入冷却。权威胜负不要放在客户端。
- `OnExit`:解绑事件、销毁 UI、停止计时器、清理对象引用。
### 5.2 `IGameClientCtx`
宿主注入的上下文:
```csharp
public interface IGameClientCtx
{
IAssetLoader Assets { get; }
ILogger Logger { get; }
void Send(NetMessage message);
void Exit();
}
```
使用约束:
- 加载资源只能通过 `ctx.Assets.Load(path, onLoaded)`,不要直接同步 `AssetBundle.LoadAsset``Resources.Load`
- 房间玩家列表和玩家展示数据通过 `ctx.Players` 读取或异步请求,不要在小游戏内重复实现玩家信息协议。
- 向服务端发送玩法消息使用 `ctx.Send(new NetMessage(opcode, payload))`
- 玩家主动退出时调用 `ctx.Exit()`,不要直接销毁宿主或 socket。
- 日志使用 `ctx.Logger`,便于按小游戏前缀过滤。
### 5.3 房间玩家与用户数据服务
通用层应向小游戏提供统一的房间玩家列表和玩家展示数据查询能力,避免每个小游戏自行拼协议、缓存昵称或重复请求角色数据。
建议在框架共享层扩展通用数据结构:
```csharp
public sealed class PlayerInfo
{
public int PlayerId;
public string Name;
public bool IsAI;
public int Level;
public int AppearanceType;
public int AvatarId;
}
public interface IRoomPlayerService
{
IReadOnlyList<PlayerInfo> GetRoomPlayers();
bool TryGetRoomPlayer(int playerId, out PlayerInfo player);
void RequestPlayerInfo(int playerId, Action<PlayerInfo> onLoaded);
}
```
客户端建议通过 `IGameClientCtx` 暴露该服务:
```csharp
public interface IGameClientCtx
{
IAssetLoader Assets { get; }
ILogger Logger { get; }
IRoomPlayerService Players { get; }
void Send(NetMessage message);
void Exit();
}
```
语义约定:
- `GetRoomPlayers()` 返回当前房间玩家快照,至少包含 `PlayerId``Name``IsAI`
- `TryGetRoomPlayer()` 用于读取已缓存的玩家数据,不触发网络请求。
- `RequestPlayerInfo()` 用于异步补全昵称、等级、头像、外形类型等展示字段,内部由通用层复用主工程已有玩家信息协议或缓存。
- AI 玩家由框架或小游戏服务端提供稳定的 `PlayerInfo`,例如 `Name = "AI"``IsAI = true`,不再向账号服查询。
- 小游戏只消费 `PlayerInfo`,不要直接依赖主工程角色系统、排行榜面板或具体协议类。
服务端侧 `IGameServerRoom.OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx)` 已能拿到房间玩家列表;后续可把 `PlayerInfo` 扩展为同一套基础展示字段,让服务端可按等级、外形类型或 AI 标记做玩法初始化。服务端仍然只信任房间上下文传入的玩家数据,不接受客户端上报的名称、等级或外形。
客户端显示玩家信息的推荐流程:
```text
IGameClient.OnEnter(ctx)
ctx.Players.GetRoomPlayers() 显示基础座位和占位信息
对缺失展示字段的真人调用 ctx.Players.RequestPlayerInfo(playerId, callback)
回调中刷新昵称、等级、头像、外形类型
后续 Game 快照只携带 playerId,通过本地玩家缓存映射到展示信息
```
这样小游戏消息只需要传 `playerId`,玩家展示信息由通用层统一维护,减少带宽和重复实现。
### 5.4 客户端进入流程
```text
大厅选择小游戏
MiniGameHost.EnterGame(cdnBase, gameId, version, socket)
MiniGameDownloader.Run
下载 game.json / files.txt / files.txt.sig / AB 文件
校验 gameId、version、minFrameworkVersion、签名、MD5
MiniGameAssemblyLoader.Load
加载 Core DLL,再加载 Client DLL
反射 clientEntryType,创建 IGameClient
创建 MiniGameNetChannel + MiniGameAssetLoader + GameClientCtx
必要时发送 FrameworkOpcode.MatchRequest
IGameClient.OnEnter(ctx)
每帧 Pump 网络 + IGameClient.OnUpdate(deltaTime)
```
### 5.5 客户端退出流程
```text
玩家主动退出 / 房间结束确认 / 宿主异常兜底
MiniGameHost.ExitGame()
销毁结算弹窗
IGameClient.OnExit()
清空网络通道和 ctx
MiniGameAssetLoader.UnloadAll()
OnGameExited 回调大厅
```
小游戏不得绕过 `ctx.Exit()` 或宿主 `ExitGame()` 自行切主流程。
## 6. 服务端接口与生命周期
服务端房间入口必须实现 `XWorld.Framework.IGameServerRoom`
```csharp
public interface IGameServerRoom
{
void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx);
void OnMessage(int playerId, NetMessage message);
void OnTick(float deltaTime);
void OnRoomEnd();
}
```
推荐职责:
- `OnRoomStart`:缓存玩家、初始化 Core 状态、发送初始快照、启动倒计时。
- `OnMessage`:校验玩家输入合法性,转换为 Core 操作,不信任客户端结果。
- `OnTick`:以 `tickRateHz` 调用 Core Step,必要时广播快照。
- `OnRoomEnd`:释放房间内临时状态。
`IRoomCtx` 提供权威随机、计时器、存储、日志、单播、广播和结束房间能力。小游戏结束时调用:
```csharp
ctx.EndRoom(new RoomEndResult
{
WinnerPlayerId = winnerId,
ResultBlob = resultBytes
});
```
`ResultBlob` 建议放 UTF-8 文本,客户端宿主会解码并显示在通用结算弹窗中。文本不应超过 4096 字节。
## 7. 网络消息设计
### 7.1 Channel 分工
| Channel | 消费方 | 内容 |
|---|---|---|
| `Framework` | 宿主/框架 | 匹配请求、`MatchFound``RoomEnd`、心跳等通用流程。 |
| `Game` | 小游戏客户端/服务端 | 玩法输入、状态快照、回合事件、表现提示等。 |
小游戏客户端只处理 `IGameClient.OnNetMessage` 收到的 Game 消息;不要解析 Framework 帧。宿主会在 `MiniGameNetChannel` 中把 Framework 帧分给 `MiniGameHost.OnFrameworkFrame`
### 7.2 Opcode 规划
每个小游戏自行维护 Game 通道 opcode。建议:
```text
1-99 客户端 → 服务端输入
100-199 服务端 → 客户端快照/事件
200-299 双向调试/扩展
```
同一小游戏内 opcode 必须稳定,不同小游戏之间可以独立编号。消息编解码放在 Core,客户端和服务端复用,避免协议漂移。
### 7.3 权威性原则
- 客户端只发送意图,例如“选择石头”“点击发射”“移动方向”。
- 服务端校验时序、玩家身份、冷却、距离、分数和胜负。
- 客户端显示可以预测,但最终结果以服务端快照/RoomEnd 为准。
- 随机数使用服务端 `IRoomCtx.Random`,不要客户端自判随机结果。
## 8. UI 与资源规范
### 8.1 UI 制作流程
本项目禁止手写 prefab YAML。小游戏 UI 必须遵循 `Client/Assets/Doc/Rule/UnityProject.md` 的 JSON → Prefab 管线:
1.`Client/Assets/Doc/UIPrefabCreater/` 新建 UI JSON,例如 `UI_RockPaperScissors.json`
2. JSON 中声明 `schemaVersion``prefabName``prefabPath``canvas``assets``nodes``bindings``events`
3. `canvas.referenceResolution` 默认使用 `[2048, 1024]`
4. 文本使用 `TextMeshProUGUI`
5. 需要运行时脚本时声明 `MonoBehaviour` 组件和 `typeName`
6. 切回或重新激活 Unity,让工具自动生成 Prefab。
7. Prefab 生成后移动或直接输出到 `Assets/MiniGames/{GameId}/res/UI/Prefab/`,由发布流水线打入资源 AB。
运行时客户端 C# 通过 `transform.Find`、递归按名查找、`GetComponent<Button/Image/TextMeshProUGUI>` 绑定控件和事件。
### 8.2 资源目录
小游戏专属资源放在小游戏自己的目录,不放入通用 `Assets/Game/Art` 目录:
```text
Client/Assets/MiniGames/{GameId}/res/UI/Prefab/
Client/Assets/MiniGames/{GameId}/res/UI/Texture/
Client/Assets/MiniGames/{GameId}/res/Sound/
Client/Assets/MiniGames/{GameId}/res/Effect/
```
如果需要生成新贴图,按照项目规则使用对应生成工具,输出到小游戏自己的 `res/UI/Texture` 目录。
### 8.3 资源加载红线
本项目 DLL 与资源是两套独立更新机制。真机资源包只有异步加载时才会按需下载;同步加载只读本地,可能读到 APK 底包旧资源。因此:
- 禁止新写 `XResLoader.LoadRes(path, type)` 同步接口。
- 禁止新写 `LoadResAB(path, type)` 同步接口。
- 小游戏热更客户端内使用 `ctx.Assets.Load(path, onLoaded)`
- 主工程宿主加载通用结算弹窗时使用 `XResLoader.coLoadRes`
- 加载完成前不要访问资源对象;需要立即显示时先放占位,再在回调里替换。
示例:
```csharp
_ctx.Assets.Load("Assets/MiniGames/RockPaperScissors/res/UI/Prefab/UI_RockPaperScissors.prefab", obj =>
{
var prefab = obj as GameObject;
if (prefab == null)
{
_ctx.Logger.Error("UI 资源加载失败");
return;
}
_view = UnityEngine.Object.Instantiate(prefab);
BindView(_view.transform);
});
```
## 9. 发布流程
### 9.1 Unity 菜单发布
小游戏发布由编辑器流水线 `MiniGamePublishPipeline` 执行,产物为 CDN 客户端包和 deploy 服务端包。流程:
```text
读取 Assets/MiniGames/{GameId}/publish.json
dotnet build server room 工程
dotnet build client 热更工程
复制 Core/Client DLL 为 .bytes 到 Assets/MiniGameStaging/code
BuildAssetBundles 生成 code.unity3d / res.unity3d
生成 resolved-spec.json
dotnet build PublishTool.Cli
PublishTool.Cli 生成带 MD5 命名的文件、game.json、files.txt、files.txt.sig
先落位 CDN/minigame/{gameId}/{version}/{platform}
最后落位 deploy/minigame/{gameId}/{version}
```
### 9.2 版本号
默认版本号由 `CDN/minigame/{gameId}/` 下已有数字目录取最大值加 1。覆盖重发旧版本时要确认平台包与服务端 DLL 一致;如果只重发部分平台,旧平台 AB 可能与新服务端不兼容,建议全平台重发或使用新版本号。
### 9.3 签名与校验
客户端加载前会校验:
- `game.json` 是否能解析。
- `gameId` / `version` 是否与请求一致。
- `minFrameworkVersion` 是否小于等于当前 `FrameworkInfo.Version`
- `files.txt.sig` 是否存在并能通过签名验证(配置公钥时)。
- 每个文件下载后的 MD5 是否与 `files.txt` 一致。
任一校验失败都必须拒绝加载,不能降级加载旧 DLL 或旧 AB。
## 10. 本地联调与 Smoke
### 10.1 本地网关与 CDN
编辑器本地联调会使用:
- PC MiniGame Gateway:默认 `ws://127.0.0.1:5005/ws?pid=1`
- PC CDN:默认 `http://127.0.0.1:15081/`
- 客户端 CDN 根:`CDN/minigame/`
- 服务端游戏根:`deploy/minigame/`
相关编辑器逻辑在 `Client/Assets/Script/Editor/XWorldUtil.cs`
### 10.2 PC Smoke Launcher
可通过编辑器菜单创建 smoke launcher
- `XWorld/PC Smoke/Create Lobby Flow Launcher`
- `XWorld/PC Smoke/Create MiniGame Smoke Launcher`
`PcMiniGameSmokeLauncher` 默认配置:
- `GatewayUrl = "ws://127.0.0.1:5005/ws?pid=1"`
- `GameId = "rps"`
- `Version = 1`
联调步骤:
1. 发布小游戏版本。
2. 启动本地 Gateway 和 CDN。
3. 创建或选择 Smoke Launcher。
4. 设置 `GameId``Version`
5. Play 进入小游戏。
6. 验证下载、加载、匹配、输入、结算、退出回大厅。
## 11. 新小游戏接入步骤
### 11.1 建立目录
```text
Client/Assets/MiniGames/{GameId}/
Client/HotUpdateGames/{GameId}.Client/
Server/games-src/{GameId}/
```
`GameId` 推荐使用稳定英文标识。目录名可用 PascalCase,`publish.json.gameId` 推荐小写短名,但必须全链路保持一致。
### 11.2 编写 Core
Core 放置:
- 玩法状态结构。
- 消息 opcode 常量。
- 消息编解码。
- 规则判断。
- 纯逻辑 Step。
Core 不引用 UnityEngine,不访问 UI,不访问 socket,不读写 Unity 资源。
### 11.3 编写 Server Room
实现 `IGameServerRoom`
1. `OnRoomStart` 初始化 Core 状态并广播初始快照。
2. `OnMessage` 处理玩家输入并调用 Core。
3. `OnTick` 驱动倒计时、AI、胜负判定和快照广播。
4. 结束时调用 `ctx.EndRoom(result)`
5. `OnRoomEnd` 清理引用。
多人对战小游戏要考虑匹配超时 AI 兜底,AI 逻辑放在具体小游戏服务端薄层,不放进通用框架。
### 11.4 编写 Client
实现 `IGameClient`
1. `OnEnter` 加载 UI Prefab 和贴图资源。
2. 加载完成后实例化 UI,绑定按钮和文本。
3. 玩家输入通过 `ctx.Send` 发送给服务端。
4. `OnNetMessage` 根据服务端快照更新 UI。
5. `OnUpdate` 只做表现层更新。
6. `OnExit` 解绑按钮、销毁 GameObject、清空引用。
### 11.5 制作 UI 与资源
1. 先写 UI JSON。
2. 用 JSON → Prefab 管线生成 Prefab。
3. Prefab 放到 `Assets/MiniGames/{GameId}/res/UI/Prefab/`
4. 贴图放到 `Assets/MiniGames/{GameId}/res/UI/Texture/`
5. 在客户端代码里通过 `ctx.Assets.Load` 加载。
### 11.6 配置 `publish.json`
补齐 `serverProject``serverAssembly``serverEntryType``clientProject``coreDll``clientDll``clientEntryType``playerCount``tickRateHz`
### 11.7 发布和验证
1. 运行发布菜单生成版本。
2. 检查 `CDN/minigame/{gameId}/{version}/{platform}` 是否存在 `game.json/files.txt/files.txt.sig/code/res`
3. 检查 `deploy/minigame/{gameId}/{version}` 是否存在服务端产物。
4. 本地 smoke 进入小游戏。
5. 验证异常路径:CDN 缺文件、版本不存在、资源缺失、服务端断开、重复退出。
## 12. 大厅与小游戏阶段隔离
进入小游戏后,大厅必须暂停并隐藏,直到小游戏退出后再恢复。大厅与小游戏是互斥阶段,不允许大厅 UI、输入、场景单位渲染与小游戏同时处于可操作状态。
隔离由 `MiniGameStageIsolation` 负责,调用方在进入小游戏前调用 `SuspendLobbyForMiniGame()`,在 `MiniGameHost.OnGameExited` 中调用 `ResumeLobbyAfterMiniGame()``MiniGameHost` 只负责小游戏生命周期,不直接依赖大厅对象。
隔离范围包括:
- 大厅操作 UI 根节点:隐藏并在恢复时还原原始 active 状态。
- 大厅场景/单位/特效根节点:隐藏并停止渲染。
- 大厅输入脚本:禁用,避免小游戏期间响应大厅点击、摇杆或快捷键。
- 大厅 Tick/表现脚本:禁用,避免后台继续驱动大厅单位。
- 需要保持 active 的 CanvasGroup:关闭交互和射线阻挡。
进入失败也必须恢复大厅。下载失败、校验失败、DLL 加载失败或热更客户端 `OnEnter` 抛异常时,`MiniGameHost` 会触发 `OnGameExited`,调用方应统一在该回调中恢复大厅。
## 13. 异常处理与兜底
| 场景 | 处理策略 |
|---|---|
| `game.json` 下载失败 | 打印错误并拒绝进入小游戏。 |
| `game.json` 解析失败 | 打印具体字段错误并拒绝进入。 |
| `gameId/version` 不一致 | 拒绝加载,避免串包。 |
| `minFrameworkVersion` 过高 | 提示框架版本不足,拒绝加载。 |
| `files.txt.sig` 缺失或验签失败 | 配置签名时拒绝加载。 |
| 文件 MD5 不一致 | 删除或覆盖重新下载;仍失败则拒绝加载。 |
| 代码 AB 缺失 Core/Client DLL | 拒绝加载并输出 AB 内资产列表。 |
| `clientEntryType` 找不到 | 拒绝加载,检查命名空间和 `publish.json`。 |
| 入口未实现 `IGameClient` | 拒绝加载。 |
| 小游戏 `OnEnter/OnUpdate/OnNetMessage/OnExit` 抛异常 | 宿主 `SafeCall` 捕获并打印,避免主循环崩溃。 |
| 结算 Prefab 缺失或结构不符 | 宿主回退代码构建简易结算窗。 |
| 玩家重复退出/重复结算 | 宿主通过 `_running``_waitingForResultConfirm` 防重入。 |
## 14. 开发红线
1. 小游戏专属逻辑必须使用 C#,不要新增 Lua 小游戏逻辑。
2. 新资源加载必须异步,不要使用同步 `XResLoader.LoadRes` / `LoadResAB`
3. 不要手写 Unity Prefab YAMLUI 走 JSON → Prefab 管线。
4. 小游戏资源不要放进通用 `Assets/Game/Art`,必须放在 `Assets/MiniGames/{GameId}/res`
5. 客户端不要直接判定权威胜负、分数、奖励和随机结果。
6. 小游戏不要直接操作宿主 socket;通过 `ctx.Send` 发 Game 消息。
7. 小游戏不要直接销毁宿主;通过 `ctx.Exit` 请求退出。
8. 不要在 `MiniGameHost` 中写具体小游戏分支逻辑。
9. 发布旧版本时不要只覆盖部分平台,除非明确确认平台与服务端兼容。
10. 签名、MD5、框架版本校验失败时不要降级加载旧包。
## 15. 验收清单
新增小游戏合入前至少验证:
- `publish.json` 字段完整且入口类型正确。
- Core 编译通过,客户端和服务端引用同一套规则/编解码。
- Client 工程 `dotnet build -c Release` 通过。
- Server 工程 `dotnet build -c Release` 通过。
- Unity 发布流水线能生成 CDN/deploy 产物。
- `game.json``gameId/version/clientEntryType/coreDll/clientDll/codeAb/assets/minFrameworkVersion` 正确。
- 首次进入能从 CDN 下载完整包。
- 第二次进入能命中本地 MD5 缓存,不重复下载未变文件。
- UI Prefab 可加载,按钮可点击,文本可更新。
- 真机或模拟真机环境不使用同步资源加载。
- 服务端能匹配、Tick、广播快照、结束房间。
- 客户端能显示结算并退出回大厅。
- CDN 缺文件、入口类型错误、资源缺失等异常不会卡死。
## 16. 建议开发拆分
第一阶段:最小闭环
- 建立目录和 `publish.json`
- 编写 Core 消息和规则。
- 编写 Server Room。
- 编写 Client 入口,先用简单 UI。
- 发布 PC 包并 smoke 跑通进入、输入、结算、退出。
第二阶段:表现完善
- 用 JSON → Prefab 管线制作正式 UI。
- 接入贴图、音效、动画。
- 优化 UI 适配 2048x1024 设计分辨率。
- 增加加载中、等待匹配、断线提示。
第三阶段:联调强化
- 增加 AI 兜底或多人异常处理。
- 验证重复进入、重复退出、服务端异常结束。
- 验证 Android/iOS/WebGL 平台包。
- 验证 CDN 更新、旧缓存、签名失败和 MD5 失败路径。
第四阶段:运营扩展
- 接入大厅入口配置。
- 接入活动、任务、埋点或纯展示排行榜时,必须保持服务端权威和防刷分原则。
- 如需奖励,奖励只能由可信服务端结算链路发放,不能由客户端小游戏结果直接驱动。
@@ -8,6 +8,9 @@ namespace XWorld.Framework
public int PlayerId;
public string Name;
public bool IsAI;
public int Level;
public int AppearanceType;
public int AvatarId;
}
public sealed class RoomConfig
@@ -11,6 +11,7 @@ namespace XWorld.Framework
{
IAssetLoader Assets { get; }
ILogger Logger { get; }
IRoomPlayerService Players { get; }
void Send(NetMessage message); // 发往服务端(Game 通道)
void Exit(); // 请求退出当前小游戏
}
@@ -23,6 +23,7 @@ namespace XWorld.Framework.Protocol
LobbyLeave = 15,
WorldChatSend = 16,
WorldChatMessage = 17,
MatchCancel = 18,
}
public sealed class HeartbeatMsg
@@ -47,19 +48,23 @@ namespace XWorld.Framework.Protocol
{
public string GameId; // null 编码为空字符串
public int Version;
public int RequestId;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteString(GameId);
w.WriteVarInt(Version);
w.WriteVarInt(RequestId);
return w.ToArray();
}
public static MatchRequestMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new MatchRequestMsg { GameId = r.ReadString(), Version = r.ReadVarInt() };
var m = new MatchRequestMsg { GameId = r.ReadString(), Version = r.ReadVarInt() };
if (r.HasMore) m.RequestId = r.ReadVarInt();
return m;
}
}
@@ -67,7 +72,9 @@ namespace XWorld.Framework.Protocol
{
public string RoomId; // null 编码为空字符串
public int Version;
public string GameId;
public int SelfPlayerId;
public int RequestId;
public List<PlayerInfo> Players = new List<PlayerInfo>();
public byte[] Encode()
@@ -83,6 +90,16 @@ namespace XWorld.Framework.Protocol
w.WriteString(p.Name);
w.WriteBool(p.IsAI);
}
w.WriteVarUInt((uint)Players.Count);
foreach (var p in Players)
{
w.WriteVarInt(p.PlayerId);
w.WriteVarInt(p.Level);
w.WriteVarInt(p.AppearanceType);
w.WriteVarInt(p.AvatarId);
}
w.WriteVarInt(RequestId);
w.WriteString(GameId);
return w.ToArray();
}
@@ -105,6 +122,32 @@ namespace XWorld.Framework.Protocol
IsAI = r.ReadBool(),
});
}
if (r.HasMore)
{
uint extendedCount = r.ReadVarUInt();
uint count = extendedCount < (uint)m.Players.Count ? extendedCount : (uint)m.Players.Count;
for (uint i = 0; i < extendedCount; i++)
{
int playerId = r.ReadVarInt();
int level = r.ReadVarInt();
int appearanceType = r.ReadVarInt();
int avatarId = r.ReadVarInt();
if (i < count && m.Players[(int)i].PlayerId == playerId)
{
m.Players[(int)i].Level = level;
m.Players[(int)i].AppearanceType = appearanceType;
m.Players[(int)i].AvatarId = avatarId;
}
}
}
if (r.HasMore)
{
m.RequestId = r.ReadVarInt();
}
if (r.HasMore)
{
m.GameId = r.ReadString();
}
return m;
}
}
@@ -118,6 +161,24 @@ namespace XWorld.Framework.Protocol
public int MaxPlayers;
}
public sealed class MatchControlMsg
{
public int RequestId;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteVarInt(RequestId);
return w.ToArray();
}
public static MatchControlMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return r.HasMore ? new MatchControlMsg { RequestId = r.ReadVarInt() } : new MatchControlMsg();
}
}
public sealed class GameListResponseMsg
{
public List<GameInfoMsg> Games = new List<GameInfoMsg>();
@@ -161,19 +222,23 @@ namespace XWorld.Framework.Protocol
{
public string GameId;
public int Version;
public int RequestId;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteString(GameId);
w.WriteVarInt(Version);
w.WriteVarInt(RequestId);
return w.ToArray();
}
public static MatchAssignedMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new MatchAssignedMsg { GameId = r.ReadString(), Version = r.ReadVarInt() };
var m = new MatchAssignedMsg { GameId = r.ReadString(), Version = r.ReadVarInt() };
if (r.HasMore) m.RequestId = r.ReadVarInt();
return m;
}
}
@@ -242,19 +307,23 @@ namespace XWorld.Framework.Protocol
{
public int Code;
public string Message; // null 编码为空字符串
public int RequestId;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteVarInt(Code);
w.WriteString(Message);
w.WriteVarInt(RequestId);
return w.ToArray();
}
public static ErrorMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new ErrorMsg { Code = r.ReadVarInt(), Message = r.ReadString() };
var m = new ErrorMsg { Code = r.ReadVarInt(), Message = r.ReadString() };
if (r.HasMore) m.RequestId = r.ReadVarInt();
return m;
}
}
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
namespace XWorld.Framework
{
public interface IRoomPlayerService
{
IReadOnlyList<PlayerInfo> GetRoomPlayers();
bool TryGetRoomPlayer(int playerId, out PlayerInfo player);
void RequestPlayerInfo(int playerId, Action<PlayerInfo> onLoaded);
}
public sealed class RoomPlayerService : IRoomPlayerService
{
private readonly List<PlayerInfo> _players = new List<PlayerInfo>();
private readonly Dictionary<int, PlayerInfo> _byId = new Dictionary<int, PlayerInfo>();
public void SetRoomPlayers(IEnumerable<PlayerInfo> players)
{
_players.Clear();
_byId.Clear();
if (players == null)
{
return;
}
foreach (PlayerInfo player in players)
{
if (player == null)
{
continue;
}
PlayerInfo copy = Clone(player);
_players.Add(copy);
_byId[copy.PlayerId] = copy;
}
}
public IReadOnlyList<PlayerInfo> GetRoomPlayers()
{
var copy = new List<PlayerInfo>(_players.Count);
for (int i = 0; i < _players.Count; i++)
{
copy.Add(Clone(_players[i]));
}
return copy;
}
public bool TryGetRoomPlayer(int playerId, out PlayerInfo player)
{
if (_byId.TryGetValue(playerId, out PlayerInfo cached))
{
player = Clone(cached);
return true;
}
player = null;
return false;
}
public void RequestPlayerInfo(int playerId, Action<PlayerInfo> onLoaded)
{
TryGetRoomPlayer(playerId, out PlayerInfo player);
onLoaded?.Invoke(player);
}
private static PlayerInfo Clone(PlayerInfo source)
{
return new PlayerInfo
{
PlayerId = source.PlayerId,
Name = source.Name,
IsAI = source.IsAI,
Level = source.Level,
AppearanceType = source.AppearanceType,
AvatarId = source.AvatarId,
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7b46a69ff45443a48a1dd13fb5a8e3d1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1066,7 +1066,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 414, y: -333}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 720, y: 500}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &2156710259985382035
@@ -1766,8 +1766,8 @@ RectTransform:
- {fileID: 6853786249801907381}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
@@ -21,11 +21,14 @@ namespace RPS.Client
private RpsState _state;
private Choice _localChoice = Choice.None;
private string _status = "waiting for match...";
private string _leftPlayerName = "You";
private string _rightPlayerName = "Opponent";
private float _elapsed;
public void OnEnter(IGameClientCtx ctx)
{
_ctx = ctx;
ApplyRoomPlayers();
EnsureEventSystem();
_ctx.Assets.Load(UiPrefabPath, OnPrefabLoaded);
_ctx.Logger.Info("rps client entered");
@@ -43,6 +46,7 @@ namespace RPS.Client
_status = _state.Phase == RpsPhase.Finished
? (_state.Winner == 2 ? "finished: draw" : "finished: seat " + _state.Winner + " wins")
: "round " + _state.Round + " / " + _state.Phase;
ApplyRoomPlayers();
Render();
}
@@ -93,10 +97,42 @@ namespace RPS.Client
GameObject instance = Object.Instantiate(prefab);
instance.name = "UI_RockPaperScissors";
Object.DontDestroyOnLoad(instance);
_view = new RpsView(instance, SubmitChoice);
_view = new RpsView(instance, SubmitChoice, _leftPlayerName, _rightPlayerName);
Render();
}
private void ApplyRoomPlayers()
{
if (_ctx?.Players == null)
{
return;
}
var players = _ctx.Players.GetRoomPlayers();
if (players.Count > 0)
{
_leftPlayerName = DisplayName(players[0], "You");
}
if (players.Count > 1)
{
_rightPlayerName = DisplayName(players[1], players[1].IsAI ? "AI" : "Opponent");
}
_view?.SetPlayerNames(_leftPlayerName, _rightPlayerName);
}
private static string DisplayName(PlayerInfo player, string fallback)
{
if (player == null || string.IsNullOrEmpty(player.Name))
{
return fallback;
}
if (player.Level > 0)
{
return player.Name + " Lv." + player.Level;
}
return player.Name;
}
private void Render()
{
_view?.Render(_state, _localChoice, _status, _elapsed);
@@ -145,7 +181,7 @@ namespace RPS.Client
private readonly Sprite _paperSprite;
private readonly Sprite _scissorsSprite;
public RpsView(GameObject root, System.Action<Choice> choose)
public RpsView(GameObject root, System.Action<Choice> choose, string leftPlayerName, string rightPlayerName)
{
_root = root;
_rockButton = Find<Button>("Btn_Rock");
@@ -178,8 +214,8 @@ namespace RPS.Client
if (_scissorsButton != null) _scissorsButton.onClick.AddListener(() => choose(Choice.Scissors));
if (_confirmButton != null) _confirmButton.gameObject.SetActive(false);
SetText(_leftNameText, "You");
SetText(_rightNameText, "Opponent");
SetText(_leftNameText, string.IsNullOrEmpty(leftPlayerName) ? "You" : leftPlayerName);
SetText(_rightNameText, string.IsNullOrEmpty(rightPlayerName) ? "Opponent" : rightPlayerName);
}
public void Render(RpsState state, Choice localChoice, string status, float elapsed)
@@ -214,7 +250,8 @@ namespace RPS.Client
SetText(_rightRevealText, ChoiceDisplay(state.Choices[1], state.Phase));
SetText(_resultText, ResultText(state));
SetText(_finalRowsText, FinalRows(state));
SetText(_rightNameText, state.IsAi[1] ? "AI" : "Opponent");
if (state.IsAi[1] && _rightNameText != null && string.IsNullOrEmpty(_rightNameText.text))
SetText(_rightNameText, "AI");
SetIcon(_leftChoiceIcon, state.Choices[0]);
SetIcon(_rightChoiceIcon, state.Choices[1]);
SetButtons(state.Phase == RpsPhase.Choosing && localChoice == Choice.None);
@@ -226,6 +263,12 @@ namespace RPS.Client
SetText(_totalTimeText, elapsed.ToString("0.0") + "s / 60s");
}
public void SetPlayerNames(string leftPlayerName, string rightPlayerName)
{
SetText(_leftNameText, string.IsNullOrEmpty(leftPlayerName) ? "You" : leftPlayerName);
SetText(_rightNameText, string.IsNullOrEmpty(rightPlayerName) ? "Opponent" : rightPlayerName);
}
public void SetProgress(RpsState state)
{
if (_totalProgressFill != null)
+160 -6
View File
@@ -22,9 +22,11 @@ public class XWorldUtil
private const string LOCAL_SERVER_EXE = "../../Server/build-gateway-local3/Debug/AIProjectServer.exe";
private const string LOCAL_SERVER_DLL_DIR = "../../Server/build-local-sqlite/vcpkg_installed/x64-windows/debug/bin";
private const string LOCAL_SERVER_LOG_DIR = "../../Server/logs";
private const string MINI_GAME_GATEWAY_RUNNER_PROJECT = "../../Server/Gateway.Runner/Gateway.Runner.csproj";
private const string MINI_GAME_GATEWAY_RUNNER_DLL = "../../Server/Gateway.Runner/bin/Debug/net10.0/XWorld.Server.Gateway.Runner.dll";
private const string MINI_GAME_SERVER_GAMES_ROOT = "../../deploy/minigame";
private const string MINI_GAME_GATEWAY_PID_FILE = "pc-minigame-gateway.pid";
private const string MINI_GAME_CDN_RUNNER_PROJECT = "../../Server/Cdn.Runner/Cdn.Runner.csproj";
private const string MINI_GAME_CDN_RUNNER_DLL = "../../Server/Cdn.Runner/bin/Debug/net10.0/XWorld.Server.Cdn.Runner.dll";
private const string MINI_GAME_CDN_ROOT = "../../CDN";
private const string MINI_GAME_CDN_PID_FILE = "pc-cdn.pid";
@@ -37,6 +39,7 @@ public class XWorldUtil
private const int LOCAL_INTERNAL_GAME_PORT = 7003;
private const int MINI_GAME_GATEWAY_PORT = 5005;
private const int MINI_GAME_TICK_MS = 100;
private const int MINI_GAME_RECONNECT_WINDOW_TICKS = 1800;
private const int MINI_GAME_MATCH_TIMEOUT_TICKS = 150;
private static readonly List<Process> LocalServerProcesses = new List<Process>();
@@ -323,6 +326,22 @@ public class XWorldUtil
{
StopExitedLocalServerProcesses();
SetLocalServerPrefs();
if (IsVisibleMiniGameGatewayConsoleRunning())
{
UnityEngine.Debug.Log("PC MiniGame Gateway console is already running; skipping build.");
return;
}
if (!ShouldBuildMiniGameLocalServer(IsMiniGameGatewayRunnerRunning()))
{
UnityEngine.Debug.Log("PC MiniGame Gateway is already running; skipping build.");
return;
}
if (!BuildMiniGameLocalServer())
return;
// 内网 CDN(HTTP 静态文件)独立于网关,先确保起来(端口已开则跳过)
StartMiniGameCdnRunnerProcess("XWorld PC CDN");
if (IsVisibleMiniGameGatewayConsoleRunning())
@@ -390,12 +409,26 @@ public class XWorldUtil
"OK");
}
[MenuItem("XWorld/Server/Build Local Server", false, 605)]
static public void BuildLocalServer()
{
if (BuildMiniGameLocalServer())
EditorUtility.DisplayDialog("XWorld Server", "Local server build finished.", "OK");
}
[MenuItem("XWorld/Server/Build CDN Runner", false, 606)]
static public void BuildCdnRunner()
{
if (BuildMiniGameRunnerProject("XWorld PC CDN", GetMiniGameCdnRunnerProjectPath(), GetMiniGameCdnRunnerPath()))
EditorUtility.DisplayDialog("XWorld Server", "CDN runner build finished.", "OK");
}
static public bool IsPcMiniGameGatewayReady()
{
return IsMiniGameGatewayRunnerRunning();
}
[MenuItem("XWorld/Server/Open Latest Log", false, 605)]
[MenuItem("XWorld/Server/Open Latest Log", false, 607)]
static public void OpenLatestLocalServerLog()
{
string logDir = Path.GetFullPath(Path.Combine(Application.dataPath, LOCAL_SERVER_LOG_DIR));
@@ -422,7 +455,7 @@ public class XWorldUtil
[MenuItem("XWorld/Server/Open Local Server (Separate)", true)]
static public bool CanOpenLocalServer()
{
return File.Exists(GetMiniGameGatewayRunnerPath());
return File.Exists(GetMiniGameGatewayRunnerProjectPath());
}
[MenuItem("XWorld/Server/Stop Local Server", true)]
@@ -484,6 +517,11 @@ public class XWorldUtil
return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_GATEWAY_RUNNER_DLL));
}
static private string GetMiniGameGatewayRunnerProjectPath()
{
return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_GATEWAY_RUNNER_PROJECT));
}
static private string GetMiniGameServerGamesRoot()
{
return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_SERVER_GAMES_ROOT));
@@ -501,6 +539,11 @@ public class XWorldUtil
return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_CDN_RUNNER_DLL));
}
static private string GetMiniGameCdnRunnerProjectPath()
{
return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_CDN_RUNNER_PROJECT));
}
static private string GetMiniGameCdnRoot()
{
return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_CDN_ROOT));
@@ -523,6 +566,101 @@ public class XWorldUtil
return Path.Combine(logDir, safeTitle + "-" + DateTime.Now.ToString("yyyyMMdd-HHmmss") + ".log");
}
static private bool BuildMiniGameLocalServer()
{
if (!BuildMiniGameRunnerProject("XWorld PC minigame gateway", GetMiniGameGatewayRunnerProjectPath(), GetMiniGameGatewayRunnerPath()))
return false;
return true;
}
static private bool ShouldBuildMiniGameLocalServer(bool isGatewayRunning)
{
return !isGatewayRunning;
}
static private bool BuildMiniGameRunnerProject(string title, string projectPath, string runnerPath)
{
if (!File.Exists(projectPath))
{
EditorUtility.DisplayDialog("XWorld Server", "Server project not found:\n" + projectPath, "OK");
return false;
}
string logPath = GetLocalServerLogPath(title + " build");
UnityEngine.Debug.Log("Building " + title + " from " + projectPath + "\nLog: " + logPath);
EditorUtility.DisplayProgressBar("XWorld Server", "Building " + title, 0.5f);
try
{
StringBuilder output = new StringBuilder();
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.FileName = "dotnet";
startInfo.Arguments = "build " + QuoteProcessArg(projectPath) + " -c Debug --no-incremental -m:1";
startInfo.WorkingDirectory = GetProjectRoot();
startInfo.UseShellExecute = false;
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.CreateNoWindow = true;
using (Process process = new Process())
{
process.StartInfo = startInfo;
process.OutputDataReceived += (sender, args) =>
{
if (args.Data != null)
output.AppendLine(args.Data);
};
process.ErrorDataReceived += (sender, args) =>
{
if (args.Data != null)
output.AppendLine(args.Data);
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
process.WaitForExit();
process.WaitForExit(1000);
File.WriteAllText(logPath, output.ToString(), new UTF8Encoding(false));
if (process.ExitCode != 0)
{
EditorUtility.DisplayDialog("XWorld Server",
title + " build failed. Old DLL will not be started.\n\nLog:\n" + logPath,
"OK");
UnityEngine.Debug.LogError(title + " build failed with exit code " + process.ExitCode + ". Log: " + logPath);
return false;
}
}
}
catch (Exception ex)
{
File.WriteAllText(logPath, ex.ToString(), new UTF8Encoding(false));
EditorUtility.DisplayDialog("XWorld Server",
title + " build failed before launch. Old DLL will not be started.\n\n" + ex.Message + "\n\nLog:\n" + logPath,
"OK");
UnityEngine.Debug.LogError(title + " build failed: " + ex);
return false;
}
finally
{
EditorUtility.ClearProgressBar();
}
if (!IsDotnetRunnerLaunchable(runnerPath))
{
EditorUtility.DisplayDialog("XWorld Server",
title + " build finished, but runner output is incomplete:\n" + runnerPath +
"\n" + GetDotnetRunnerRuntimeConfigPath(runnerPath),
"OK");
return false;
}
UnityEngine.Debug.Log(title + " build finished. Runner: " + runnerPath);
return true;
}
static private void StartServerProcess(string arguments, string title)
{
string exePath = GetLocalServerExePath();
@@ -573,10 +711,12 @@ public class XWorldUtil
static private void StartMiniGameGatewayRunnerProcess(string title)
{
string runnerPath = GetMiniGameGatewayRunnerPath();
if (!File.Exists(runnerPath))
if (!IsDotnetRunnerLaunchable(runnerPath))
{
EditorUtility.DisplayDialog("XWorld Server",
"Gateway runner not found:\n" + runnerPath + "\n\nBuild Server/Gateway.Runner first.", "OK");
"Gateway runner output is incomplete:\n" + runnerPath +
"\n" + GetDotnetRunnerRuntimeConfigPath(runnerPath) +
"\n\nBuild Server/Gateway.Runner first.", "OK");
return;
}
@@ -612,6 +752,7 @@ public class XWorldUtil
"Write-Host ''\r\n" +
"& dotnet $runner --gamesRoot $gamesRoot --port " + MINI_GAME_GATEWAY_PORT +
" --tickMs " + MINI_GAME_TICK_MS +
" --reconnectWindowTicks " + MINI_GAME_RECONNECT_WINDOW_TICKS +
" --matchTimeoutTicks " + MINI_GAME_MATCH_TIMEOUT_TICKS +
" --lan --devToken " + DEV_DISCOVERY_TOKEN + // 开 DevDiscovery(UDP48923) 响应 + 广播 LAN IPCDN 地址由 --lan 兜底为 http://<lanip>:15081/
" *>&1 | Tee-Object -FilePath $logPath\r\n" +
@@ -645,9 +786,10 @@ public class XWorldUtil
static private void StartMiniGameCdnRunnerProcess(string title)
{
string runnerPath = GetMiniGameCdnRunnerPath();
if (!File.Exists(runnerPath))
if (!IsDotnetRunnerLaunchable(runnerPath))
{
UnityEngine.Debug.LogWarning("[CDN] 未找到 Cdn.Runner" + runnerPath + "\n请先构建 Server/Cdn.Runnerdotnet build Server/Server.sln)。");
UnityEngine.Debug.LogWarning("[CDN] Cdn.Runner output is incomplete; skipping CDN startup.\nDLL: " +
runnerPath + "\nRuntimeConfig: " + GetDotnetRunnerRuntimeConfigPath(runnerPath));
return;
}
@@ -722,6 +864,18 @@ public class XWorldUtil
return (text ?? string.Empty).Replace("'", "''");
}
static private string GetDotnetRunnerRuntimeConfigPath(string runnerPath)
{
string directory = Path.GetDirectoryName(runnerPath);
string fileName = Path.GetFileNameWithoutExtension(runnerPath) + ".runtimeconfig.json";
return Path.Combine(directory ?? string.Empty, fileName);
}
static private bool IsDotnetRunnerLaunchable(string runnerPath)
{
return File.Exists(runnerPath) && File.Exists(GetDotnetRunnerRuntimeConfigPath(runnerPath));
}
static private void StopExitedLocalServerProcesses()
{
LocalServerProcesses.RemoveAll(process =>
@@ -0,0 +1,30 @@
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
public sealed class MatchWaitingPrefabLayoutTests
{
private const string PrefabPath = "Assets/Game/Art/UI/Prefab/UI_MatchWaiting.prefab";
[Test]
public void WaitingPanel_IsCenteredInSafeArea()
{
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
Assert.That(prefab, Is.Not.Null);
RectTransform root = prefab.GetComponent<RectTransform>();
Assert.That(root.anchorMin, Is.EqualTo(Vector2.zero));
Assert.That(root.anchorMax, Is.EqualTo(Vector2.one));
Assert.That(root.anchoredPosition, Is.EqualTo(Vector2.zero));
Assert.That(root.sizeDelta, Is.EqualTo(Vector2.zero));
Transform panel = prefab.transform.Find("SafeArea/Panel_Waiting");
Assert.That(panel, Is.Not.Null);
RectTransform rect = panel.GetComponent<RectTransform>();
Assert.That(rect.anchorMin, Is.EqualTo(new Vector2(0.5f, 0.5f)));
Assert.That(rect.anchorMax, Is.EqualTo(new Vector2(0.5f, 0.5f)));
Assert.That(rect.pivot, Is.EqualTo(new Vector2(0.5f, 0.5f)));
Assert.That(rect.anchoredPosition, Is.EqualTo(Vector2.zero));
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7b43c5afbe3e421488be033e25c0c28a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,160 @@
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
using XGame;
using XGame.MiniGame;
public sealed class MiniGameStageIsolationTests
{
private GameObject _root;
private sealed class TestBehaviour : MonoBehaviour
{
}
[TearDown]
public void TearDown()
{
if (_root != null)
{
Object.DestroyImmediate(_root);
_root = null;
}
}
[Test]
public void SuspendAndResume_RestoresOriginalActiveAndEnabledStates()
{
_root = new GameObject("root");
GameObject uiActive = new GameObject("ui-active");
GameObject uiInactive = new GameObject("ui-inactive");
GameObject sceneRoot = new GameObject("scene-root");
uiActive.transform.SetParent(_root.transform);
uiInactive.transform.SetParent(_root.transform);
sceneRoot.transform.SetParent(_root.transform);
uiInactive.SetActive(false);
var enabledInput = _root.AddComponent<TestBehaviour>();
var disabledTick = _root.AddComponent<TestBehaviour>();
disabledTick.enabled = false;
var canvas = _root.AddComponent<CanvasGroup>();
canvas.interactable = true;
canvas.blocksRaycasts = true;
var isolation = _root.AddComponent<MiniGameStageIsolation>();
isolation.LobbyUiRoots = new[] { uiActive, uiInactive };
isolation.LobbySceneRoots = new[] { sceneRoot };
isolation.LobbyInputBehaviours = new Behaviour[] { enabledInput };
isolation.LobbyTickBehaviours = new Behaviour[] { disabledTick };
isolation.LobbyCanvasGroups = new[] { canvas };
isolation.SuspendLobbyForMiniGame();
Assert.IsTrue(isolation.IsSuspended);
Assert.IsFalse(uiActive.activeSelf);
Assert.IsFalse(uiInactive.activeSelf);
Assert.IsFalse(sceneRoot.activeSelf);
Assert.IsFalse(enabledInput.enabled);
Assert.IsFalse(disabledTick.enabled);
Assert.IsFalse(canvas.interactable);
Assert.IsFalse(canvas.blocksRaycasts);
isolation.ResumeLobbyAfterMiniGame();
Assert.IsFalse(isolation.IsSuspended);
Assert.IsTrue(uiActive.activeSelf);
Assert.IsFalse(uiInactive.activeSelf);
Assert.IsTrue(sceneRoot.activeSelf);
Assert.IsTrue(enabledInput.enabled);
Assert.IsFalse(disabledTick.enabled);
Assert.IsTrue(canvas.interactable);
Assert.IsTrue(canvas.blocksRaycasts);
}
[Test]
public void SuspendAndResume_AreIdempotent()
{
_root = new GameObject("root");
GameObject ui = new GameObject("ui");
ui.transform.SetParent(_root.transform);
var behaviour = _root.AddComponent<TestBehaviour>();
var isolation = _root.AddComponent<MiniGameStageIsolation>();
isolation.LobbyUiRoots = new[] { ui };
isolation.LobbyInputBehaviours = new Behaviour[] { behaviour };
isolation.SuspendLobbyForMiniGame();
ui.SetActive(true);
behaviour.enabled = true;
isolation.SuspendLobbyForMiniGame();
Assert.IsFalse(ui.activeSelf);
Assert.IsFalse(behaviour.enabled);
isolation.ResumeLobbyAfterMiniGame();
isolation.ResumeLobbyAfterMiniGame();
Assert.IsTrue(ui.activeSelf);
Assert.IsTrue(behaviour.enabled);
}
[Test]
public void SuspendAndResume_AutoCapturesCharacterSwitchUiRoot()
{
_root = new GameObject("root");
GameObject characterObject = new GameObject("character-switch");
characterObject.transform.SetParent(_root.transform);
GameObject characterUi = new GameObject("UI_CharacterSwitch");
characterUi.transform.SetParent(_root.transform);
var controller = characterObject.AddComponent<CharacterSwitchController>();
typeof(CharacterSwitchController)
.GetField("uiPanel", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(controller, characterUi);
Assert.AreSame(characterUi, controller.StageIsolationUiRoot);
var isolation = _root.AddComponent<MiniGameStageIsolation>();
isolation.SuspendLobbyForMiniGame();
Assert.IsFalse(characterUi.activeSelf);
isolation.ResumeLobbyAfterMiniGame();
Assert.IsTrue(characterUi.activeSelf);
}
[Test]
public void CharacterSwitchUiLoadedWhileSuspended_IsHidden()
{
_root = new GameObject("root");
var isolation = _root.AddComponent<MiniGameStageIsolation>();
isolation.SuspendLobbyForMiniGame();
GameObject characterObject = new GameObject("character-switch");
characterObject.transform.SetParent(_root.transform);
GameObject characterUi = new GameObject("UI_CharacterSwitch");
characterUi.transform.SetParent(_root.transform);
var controller = characterObject.AddComponent<CharacterSwitchController>();
typeof(CharacterSwitchController)
.GetField("uiPanel", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(controller, characterUi);
controller.ApplyStageIsolationState();
Assert.IsFalse(characterUi.activeSelf);
}
[Test]
public void SuspendAndResume_IgnoreNullEntries()
{
_root = new GameObject("root");
var isolation = _root.AddComponent<MiniGameStageIsolation>();
isolation.LobbyUiRoots = new GameObject[] { null };
isolation.LobbySceneRoots = new GameObject[] { null };
isolation.LobbyInputBehaviours = new Behaviour[] { null };
isolation.LobbyTickBehaviours = new Behaviour[] { null };
isolation.LobbyCanvasGroups = new CanvasGroup[] { null };
Assert.DoesNotThrow(() => isolation.SuspendLobbyForMiniGame());
Assert.DoesNotThrow(() => isolation.ResumeLobbyAfterMiniGame());
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0db89d5629e34042af0af9a432d1efb7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,3 +1,4 @@
using System.IO;
using System.Reflection;
using NUnit.Framework;
using UnityEditor;
@@ -33,3 +34,84 @@ public sealed class XWorldJsonPrefabAutoConvertTests
return (bool)method.Invoke(null, null);
}
}
public sealed class XWorldLocalServerBuildTests
{
[Test]
public void LocalServerMenuUsesGatewayProjectInsteadOfPrebuiltDll()
{
string gatewayProjectPath = InvokeStringPath("GetMiniGameGatewayRunnerProjectPath");
Assert.That(gatewayProjectPath.Replace('\\', '/'), Does.EndWith("/Server/Gateway.Runner/Gateway.Runner.csproj"));
Assert.That(File.Exists(gatewayProjectPath), Is.True);
Assert.That(XWorldUtil.CanOpenLocalServer(), Is.True);
}
[Test]
public void CdnRunnerBuildMenuUsesCdnProject()
{
string cdnProjectPath = InvokeStringPath("GetMiniGameCdnRunnerProjectPath");
Assert.That(cdnProjectPath.Replace('\\', '/'), Does.EndWith("/Server/Cdn.Runner/Cdn.Runner.csproj"));
Assert.That(File.Exists(cdnProjectPath), Is.True);
}
[Test]
public void LocalServerBuildIsSkippedWhenGatewayIsAlreadyRunning()
{
Assert.That(InvokeShouldBuildLocalServer(false), Is.True);
Assert.That(InvokeShouldBuildLocalServer(true), Is.False);
}
[Test]
public void DotnetRunnerLaunchableRequiresRuntimeConfigNextToDll()
{
string tempDir = Path.Combine(Path.GetTempPath(), "XWorldRunnerTest-" + System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
string runnerPath = Path.Combine(tempDir, "Example.Runner.dll");
File.WriteAllText(runnerPath, string.Empty);
Assert.That(InvokeLaunchable(runnerPath), Is.False);
File.WriteAllText(Path.Combine(tempDir, "Example.Runner.runtimeconfig.json"), "{}");
Assert.That(InvokeLaunchable(runnerPath), Is.True);
}
finally
{
Directory.Delete(tempDir, true);
}
}
private static string InvokeStringPath(string methodName)
{
MethodInfo method = typeof(XWorldUtil).GetMethod(
methodName,
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
return (string)method.Invoke(null, null);
}
private static bool InvokeLaunchable(string runnerPath)
{
MethodInfo method = typeof(XWorldUtil).GetMethod(
"IsDotnetRunnerLaunchable",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
return (bool)method.Invoke(null, new object[] { runnerPath });
}
private static bool InvokeShouldBuildLocalServer(bool isGatewayRunning)
{
MethodInfo method = typeof(XWorldUtil).GetMethod(
"ShouldBuildMiniGameLocalServer",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
return (bool)method.Invoke(null, new object[] { isGatewayRunning });
}
}
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using XGame.MiniGame;
using UObject = UnityEngine.Object;
namespace XGame
@@ -35,6 +36,11 @@ namespace XGame
get { return currentConfig == null ? string.Empty : currentConfig.id; }
}
public GameObject StageIsolationUiRoot
{
get { return uiPanel; }
}
private void Start()
{
LoadConfig();
@@ -178,6 +184,15 @@ namespace XGame
CacheUI();
BuildCharacterButtons();
ApplyStageIsolationState();
}
public void ApplyStageIsolationState()
{
if (uiPanel != null && MiniGameStageIsolation.HasSuspendedLobby())
{
uiPanel.SetActive(false);
}
}
private void CacheUI()
@@ -5,6 +5,7 @@ using System.Globalization;
using System.IO;
using UnityEngine;
using XGame.MiniGame;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using UObject = UnityEngine.Object;
@@ -37,13 +38,19 @@ namespace XGame
private XWebSocket socket;
private MiniGameNetChannel lobbyNet;
private MiniGameHost gameHost;
private MiniGameStageIsolation stageIsolation;
private LobbyWorldController lobbyWorld;
private WorldChatModule worldChat;
private readonly List<GameInfoMsg> games = new List<GameInfoMsg>();
private readonly List<NetMessage> pendingGameMessages = new List<NetMessage>();
private readonly HashSet<int> canceledMatchRequestIds = new HashSet<int>();
private bool lobbyActive;
private bool openingLogin;
private bool openingLobby;
private bool openingWaiting;
private bool matchRequestActive;
private int nextMatchRequestId;
private int currentMatchRequestId;
private string matchMessage = string.Empty;
private string pendingGameId = string.Empty;
private int pendingGameVersion;
@@ -409,6 +416,7 @@ namespace XGame
socket = sock;
lobbyNet = new MiniGameNetChannel(sock);
lobbyNet.OnGameMessage = OnLobbyGameMessage;
lobbyNet.OnFrameworkFrame = OnLobbyFrameworkFrame;
lobbyActive = true;
SetLobbyStatus("Lobby connected.");
@@ -455,6 +463,9 @@ namespace XGame
private void OnMatchClicked(string gameId)
{
matchRequestActive = true;
currentMatchRequestId = ++nextMatchRequestId;
canceledMatchRequestIds.Remove(currentMatchRequestId);
matchMessage = "Matching...";
pendingGameId = string.Empty;
pendingGameVersion = 0;
@@ -468,7 +479,7 @@ namespace XGame
if (string.IsNullOrEmpty(gameId))
{
lobbyNet.SendFramework(FrameworkOpcode.RandomMatchRequest, Array.Empty<byte>());
lobbyNet.SendFramework(FrameworkOpcode.RandomMatchRequest, new MatchControlMsg { RequestId = currentMatchRequestId }.Encode());
}
else
{
@@ -483,7 +494,7 @@ namespace XGame
}
pendingGameId = gameId;
pendingGameVersion = version;
lobbyNet.SendFramework(FrameworkOpcode.MatchRequest, new MatchRequestMsg { GameId = gameId, Version = version }.Encode());
lobbyNet.SendFramework(FrameworkOpcode.MatchRequest, new MatchRequestMsg { GameId = gameId, Version = version, RequestId = currentMatchRequestId }.Encode());
}
}
@@ -530,6 +541,8 @@ namespace XGame
private void OnCloseWaiting()
{
CancelCurrentMatchRequest();
matchRequestActive = false;
matchMessage = string.Empty;
pendingGameId = string.Empty;
pendingGameVersion = 0;
@@ -537,6 +550,34 @@ namespace XGame
OpenLobby();
}
private void CancelPendingMatch()
=> CancelPendingMatch(currentMatchRequestId);
private void CancelCurrentMatchRequest()
{
if (currentMatchRequestId != 0)
{
canceledMatchRequestIds.Add(currentMatchRequestId);
}
CancelPendingMatch(currentMatchRequestId);
}
private void CancelPendingMatch(int requestId)
{
if (lobbyNet == null || !lobbyNet.IsConnected)
{
return;
}
lobbyNet.SendFramework(FrameworkOpcode.MatchCancel, new MatchControlMsg { RequestId = requestId }.Encode());
}
private bool IsCurrentMatchResponse(int requestId)
=> matchRequestActive && (requestId == 0 || requestId == currentMatchRequestId);
private bool IsCanceledMatchResponse(int requestId)
=> requestId != 0 && canceledMatchRequestIds.Contains(requestId);
private void OnLobbyFrameworkFrame(Frame frame)
{
switch ((FrameworkOpcode)frame.Opcode)
@@ -549,6 +590,16 @@ namespace XGame
break;
case FrameworkOpcode.MatchAssigned:
MatchAssignedMsg assigned = MatchAssignedMsg.Decode(frame.Payload);
if (IsCanceledMatchResponse(assigned.RequestId))
{
Debug.Log("[CSharpClientApp] ignore canceled match assigned req=" + assigned.RequestId);
break;
}
if (!IsCurrentMatchResponse(assigned.RequestId))
{
Debug.Log("[CSharpClientApp] ignore stale match assigned req=" + assigned.RequestId);
break;
}
pendingGameId = assigned.GameId;
pendingGameVersion = assigned.Version;
Debug.Log("[CSharpClientApp] match assigned " + pendingGameId + "@" + pendingGameVersion);
@@ -556,10 +607,31 @@ namespace XGame
break;
case FrameworkOpcode.MatchFound:
MatchFoundMsg found = MatchFoundMsg.Decode(frame.Payload);
if (IsCanceledMatchResponse(found.RequestId))
{
Debug.Log("[CSharpClientApp] ignore canceled match found req=" + found.RequestId);
CancelPendingMatch(found.RequestId);
break;
}
if (!matchRequestActive && !string.IsNullOrEmpty(found.GameId))
{
Debug.Log("[CSharpClientApp] resume game room=" + found.RoomId + " game=" + found.GameId + "@" + found.Version);
EnterAssignedGame(found.GameId, found.Version, found);
break;
}
if (!IsCurrentMatchResponse(found.RequestId))
{
Debug.Log("[CSharpClientApp] ignore stale match found req=" + found.RequestId);
if (found.RequestId != 0)
{
CancelPendingMatch(found.RequestId);
}
break;
}
Debug.Log("[CSharpClientApp] match found room=" + found.RoomId + " self=" + found.SelfPlayerId);
if (string.IsNullOrEmpty(pendingGameId))
{
SetWaitingStatus("Room ready, but no game was assigned.", true);
AbortMatchAndReturnLobby("Match expired, please retry.", found.RequestId);
break;
}
SetWaitingStatus("Room ready.", false);
@@ -567,6 +639,16 @@ namespace XGame
break;
case FrameworkOpcode.Error:
ErrorMsg err = ErrorMsg.Decode(frame.Payload);
if (IsCanceledMatchResponse(err.RequestId))
{
Debug.Log("[CSharpClientApp] ignore canceled match error req=" + err.RequestId);
break;
}
if (!IsCurrentMatchResponse(err.RequestId))
{
Debug.Log("[CSharpClientApp] ignore stale match error req=" + err.RequestId);
break;
}
pendingGameId = string.Empty;
pendingGameVersion = 0;
SetWaitingStatus("Server error " + err.Code + ": " + err.Message, true);
@@ -590,11 +672,28 @@ namespace XGame
}
}
private void OnLobbyGameMessage(NetMessage message)
{
if (gameHost != null)
{
gameHost.QueueInitialMessage(message);
return;
}
pendingGameMessages.Add(message);
if (pendingGameMessages.Count > 16)
{
pendingGameMessages.RemoveAt(0);
}
}
private void EnterAssignedGame(string gameId, int version, MatchFoundMsg found)
{
matchRequestActive = false;
pendingGameId = string.Empty;
pendingGameVersion = 0;
lobbyActive = false;
SuspendLobbyStage();
lobbyWorld?.SendLeave();
CloseWorldChat();
lobbyNet = null;
@@ -607,17 +706,73 @@ namespace XGame
}
gameHost.OnGameExited = () =>
{
ResumeLobbyStage();
matchMessage = "Returned to lobby.";
lobbyNet = new MiniGameNetChannel(socket);
lobbyNet.OnGameMessage = OnLobbyGameMessage;
lobbyNet.OnFrameworkFrame = OnLobbyFrameworkFrame;
lobbyActive = true;
OpenLobby();
JoinLobbyWorld();
RequestGameList();
};
gameHost.EnterGame(ResolvedCdnBase(), gameId, version, socket, false);
// 大厅链路 MatchFound 由大厅通道消费,宿主收不到,须显式注入房间信息(结算弹框判胜负用)
gameHost.SetMatchInfo(found.RoomId, found.SelfPlayerId);
gameHost.SetMatchInfo(found.RoomId, found.SelfPlayerId, found.Players);
gameHost.EnterGame(ResolvedCdnBase(), gameId, version, socket, false);
for (int i = 0; i < pendingGameMessages.Count; i++)
{
gameHost.QueueInitialMessage(pendingGameMessages[i]);
}
pendingGameMessages.Clear();
}
private void AbortMatchAndReturnLobby(string message, int requestId)
{
int cancelRequestId = requestId != 0 ? requestId : currentMatchRequestId;
if (cancelRequestId != 0)
{
canceledMatchRequestIds.Add(cancelRequestId);
}
CancelPendingMatch(cancelRequestId);
matchRequestActive = false;
pendingGameId = string.Empty;
pendingGameVersion = 0;
CloseWaiting();
OpenLobby();
SetLobbyStatus(message);
}
private MiniGameStageIsolation ResolveStageIsolation()
{
if (stageIsolation != null)
{
return stageIsolation;
}
stageIsolation = GetComponent<MiniGameStageIsolation>();
if (stageIsolation == null)
{
stageIsolation = gameObject.AddComponent<MiniGameStageIsolation>();
}
return stageIsolation;
}
private void SuspendLobbyStage()
{
MiniGameStageIsolation isolation = ResolveStageIsolation();
if (isolation != null)
{
isolation.SuspendLobbyForMiniGame();
}
}
private void ResumeLobbyStage()
{
MiniGameStageIsolation isolation = ResolveStageIsolation();
if (isolation != null)
{
isolation.ResumeLobbyAfterMiniGame();
}
}
private string ResolvedCdnBase()
@@ -11,13 +11,18 @@ namespace XGame.MiniGame
{
public IAssetLoader Assets { get; }
public IFwkLogger Logger { get; }
public IRoomPlayerService Players { get; }
private readonly Action<NetMessage> _send;
private readonly Action _exit;
public GameClientCtx(IAssetLoader assets, IFwkLogger logger, Action<NetMessage> send, Action exit)
public GameClientCtx(IAssetLoader assets, IFwkLogger logger, IRoomPlayerService players, Action<NetMessage> send, Action exit)
{
Assets = assets; Logger = logger; _send = send; _exit = exit;
Assets = assets;
Logger = logger;
Players = players;
_send = send;
_exit = exit;
}
public void Send(NetMessage message) => _send(message);
@@ -1,5 +1,6 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using TMPro;
using UnityEngine;
@@ -20,6 +21,8 @@ namespace XGame.MiniGame
private MiniGameNetChannel _net;
private GameClientCtx _ctx;
private MiniGameAssetLoader _assets;
private RoomPlayerService _players;
private readonly List<NetMessage> _queuedInitialMessages = new List<NetMessage>();
private bool _running;
private bool _waitingForResultConfirm;
private string _gameId;
@@ -36,16 +39,41 @@ namespace XGame.MiniGame
public void EnterGame(string cdnBase, string gameId, int version, XWebSocket sock, bool sendMatchRequest)
{
_gameId = gameId; _version = version;
_queuedInitialMessages.Clear();
// FIX: XWCoroutine.StartCo is a static method (not Instance.StartCo)
XWCoroutine.StartCo(CoEnter(cdnBase, gameId, version, sock, sendMatchRequest));
}
public void QueueInitialMessage(NetMessage message)
{
if (_client != null)
{
SafeCall(() => _client.OnNetMessage(message), "OnNetMessage");
return;
}
_queuedInitialMessages.Add(message);
}
// 大厅链路:MatchFound 由大厅通道消费(sendMatchRequest=false 时宿主收不到),
// 由外部把房间信息注入,否则结算弹框无法判断胜负(_selfPlayerId 恒 0)。
public void SetMatchInfo(string roomId, int selfPlayerId)
{
SetMatchInfo(roomId, selfPlayerId, null);
}
public void SetMatchInfo(string roomId, int selfPlayerId, IReadOnlyList<PlayerInfo> players)
{
_roomId = roomId;
_selfPlayerId = selfPlayerId;
if (_players == null)
{
_players = new RoomPlayerService();
}
if (players != null)
{
_players.SetRoomPlayers(players);
}
}
private IEnumerator CoEnter(string cdnBase, string gameId, int version, XWebSocket sock, bool sendMatchRequest)
@@ -54,12 +82,19 @@ namespace XGame.MiniGame
var dl = new MiniGameDownloader(cdnBase, gameId, version);
bool ok = false;
yield return dl.Run(r => ok = r);
if (!ok) { Debug.LogError("[MiniGameHost] 下载失败: " + dl.Error); yield break; }
if (!ok)
{
FailEnterAndExit("下载失败: " + dl.Error);
yield break;
}
// 2) 加载 Core/Client DLL,反射入口
var asmLoader = new MiniGameAssemblyLoader();
if (!asmLoader.Load(dl.LocalDir, dl.Manifest))
{ Debug.LogError("[MiniGameHost] 加载程序集失败: " + asmLoader.Error); yield break; }
{
FailEnterAndExit("加载程序集失败: " + asmLoader.Error);
yield break;
}
// 3) 网络通道
_net = new MiniGameNetChannel(sock);
@@ -67,11 +102,25 @@ namespace XGame.MiniGame
_net.OnFrameworkFrame = OnFrameworkFrame;
// 4) 反射实例化 IGameClient + 注入 ctx
try
{
_client = asmLoader.CreateClient();
}
catch (Exception e)
{
FailEnterAndExit("创建客户端失败: " + e.Message);
yield break;
}
_assets = new MiniGameAssetLoader(dl.LocalDir, dl.Manifest);
if (_players == null)
{
_players = new RoomPlayerService();
}
_ctx = new GameClientCtx(
_assets,
new UnityLogger($"[{gameId}]"),
_players,
msg => { if (_net != null) _net.SendGame(msg); },
() => ExitGame());
@@ -83,7 +132,16 @@ namespace XGame.MiniGame
// 6) OnEnter 接管
// 注意:OnEnter 在 MatchFound 到达之前调用,房间可能尚未就绪;小游戏应等待后续网络消息再依赖房间状态
SafeCall(() => _client.OnEnter(_ctx), "OnEnter");
try
{
_client.OnEnter(_ctx);
FlushQueuedInitialMessages();
}
catch (Exception e)
{
FailEnterAndExit("OnEnter 抛异常: " + e.Message);
yield break;
}
_running = true;
Debug.Log("[MiniGameHost] 小游戏已进入: " + gameId);
}
@@ -96,6 +154,11 @@ namespace XGame.MiniGame
MatchFoundMsg found = MatchFoundMsg.Decode(f.Payload);
_roomId = found.RoomId;
_selfPlayerId = found.SelfPlayerId;
if (_players == null)
{
_players = new RoomPlayerService();
}
_players.SetRoomPlayers(found.Players);
Debug.Log("[MiniGameHost] 房间就绪: " + _roomId);
break;
case FrameworkOpcode.RoomEnd:
@@ -116,18 +179,45 @@ namespace XGame.MiniGame
public void ExitGame()
{
if (!_running && _client == null) return;
_queuedInitialMessages.Clear();
DestroyResultDialog();
_waitingForResultConfirm = false;
_running = false;
if (_client != null) { SafeCall(() => _client.OnExit(), "OnExit"); _client = null; }
_net = null;
_ctx = null;
if (_players != null)
{
_players.SetRoomPlayers(null);
}
if (_assets != null) { _assets.UnloadAll(); _assets = null; } // 卸载本小游戏资源 AB
Debug.Log("[MiniGameHost] 已退出小游戏: " + _gameId);
OnGameExited?.Invoke();
// 返回大厅:交回 Lua game_module_runner 或大厅 UI(按现有大厅返回流程)
}
private void FailEnterAndExit(string reason)
{
_queuedInitialMessages.Clear();
DestroyResultDialog();
_waitingForResultConfirm = false;
_running = false;
_client = null;
_net = null;
_ctx = null;
if (_players != null)
{
_players.SetRoomPlayers(null);
}
if (_assets != null)
{
_assets.UnloadAll();
_assets = null;
}
Debug.LogError("[MiniGameHost] 进入小游戏失败: " + reason);
OnGameExited?.Invoke();
}
private void ShowResultDialog(RoomEndMsg roomEnd)
{
if (_waitingForResultConfirm)
@@ -369,6 +459,18 @@ namespace XGame.MiniGame
return button;
}
private void FlushQueuedInitialMessages()
{
if (_client == null || _queuedInitialMessages.Count == 0) return;
NetMessage[] messages = _queuedInitialMessages.ToArray();
_queuedInitialMessages.Clear();
for (int i = 0; i < messages.Length; i++)
{
NetMessage message = messages[i];
SafeCall(() => _client.OnNetMessage(message), "OnNetMessage");
}
}
private static void SafeCall(Action a, string where)
{
try { a(); }
@@ -0,0 +1,252 @@
using System.Collections.Generic;
using UnityEngine;
using XGame;
namespace XGame.MiniGame
{
public sealed class MiniGameStageIsolation : MonoBehaviour
{
public GameObject[] LobbyUiRoots;
public GameObject[] LobbySceneRoots;
public Behaviour[] LobbyInputBehaviours;
public Behaviour[] LobbyTickBehaviours;
public CanvasGroup[] LobbyCanvasGroups;
private readonly List<GameObjectState> _gameObjectStates = new List<GameObjectState>();
private readonly List<BehaviourState> _behaviourStates = new List<BehaviourState>();
private readonly List<CanvasGroupState> _canvasGroupStates = new List<CanvasGroupState>();
public bool IsSuspended { get; private set; }
public static bool HasSuspendedLobby()
{
MiniGameStageIsolation[] isolations = FindObjectsOfType<MiniGameStageIsolation>(true);
for (int i = 0; i < isolations.Length; i++)
{
if (isolations[i] != null && isolations[i].IsSuspended)
{
return true;
}
}
return false;
}
public void SuspendLobbyForMiniGame()
{
if (IsSuspended)
{
ApplySuspendedState();
return;
}
CaptureState();
IsSuspended = true;
ApplySuspendedState();
}
public void ResumeLobbyAfterMiniGame()
{
if (!IsSuspended)
{
return;
}
for (int i = 0; i < _gameObjectStates.Count; i++)
{
GameObjectState state = _gameObjectStates[i];
if (state.Target != null)
{
state.Target.SetActive(state.ActiveSelf);
}
}
for (int i = 0; i < _behaviourStates.Count; i++)
{
BehaviourState state = _behaviourStates[i];
if (state.Target != null)
{
state.Target.enabled = state.Enabled;
}
}
for (int i = 0; i < _canvasGroupStates.Count; i++)
{
CanvasGroupState state = _canvasGroupStates[i];
if (state.Target != null)
{
state.Target.interactable = state.Interactable;
state.Target.blocksRaycasts = state.BlocksRaycasts;
}
}
_gameObjectStates.Clear();
_behaviourStates.Clear();
_canvasGroupStates.Clear();
IsSuspended = false;
}
private void CaptureState()
{
_gameObjectStates.Clear();
_behaviourStates.Clear();
_canvasGroupStates.Clear();
CaptureGameObjects(LobbyUiRoots);
CaptureCharacterSwitchUiRoots();
CaptureGameObjects(LobbySceneRoots);
CaptureBehaviours(LobbyInputBehaviours);
CaptureBehaviours(LobbyTickBehaviours);
CaptureCanvasGroups(LobbyCanvasGroups);
}
private void ApplySuspendedState()
{
SetGameObjectsActive(LobbyUiRoots, false);
SetCharacterSwitchUiRootsActive(false);
SetGameObjectsActive(LobbySceneRoots, false);
SetBehavioursEnabled(LobbyInputBehaviours, false);
SetBehavioursEnabled(LobbyTickBehaviours, false);
SetCanvasGroupsBlocked(LobbyCanvasGroups, false, false);
}
private void CaptureGameObjects(GameObject[] targets)
{
if (targets == null) return;
for (int i = 0; i < targets.Length; i++)
{
GameObject target = targets[i];
if (target != null)
{
_gameObjectStates.Add(new GameObjectState(target, target.activeSelf));
}
}
}
private void CaptureCharacterSwitchUiRoots()
{
CharacterSwitchController[] controllers = FindObjectsOfType<CharacterSwitchController>(true);
for (int i = 0; i < controllers.Length; i++)
{
GameObject target = controllers[i] != null ? controllers[i].StageIsolationUiRoot : null;
if (target != null)
{
_gameObjectStates.Add(new GameObjectState(target, target.activeSelf));
}
}
}
private void CaptureBehaviours(Behaviour[] targets)
{
if (targets == null) return;
for (int i = 0; i < targets.Length; i++)
{
Behaviour target = targets[i];
if (target != null)
{
_behaviourStates.Add(new BehaviourState(target, target.enabled));
}
}
}
private void CaptureCanvasGroups(CanvasGroup[] targets)
{
if (targets == null) return;
for (int i = 0; i < targets.Length; i++)
{
CanvasGroup target = targets[i];
if (target != null)
{
_canvasGroupStates.Add(new CanvasGroupState(target, target.interactable, target.blocksRaycasts));
}
}
}
private static void SetGameObjectsActive(GameObject[] targets, bool active)
{
if (targets == null) return;
for (int i = 0; i < targets.Length; i++)
{
if (targets[i] != null)
{
targets[i].SetActive(active);
}
}
}
private static void SetCharacterSwitchUiRootsActive(bool active)
{
CharacterSwitchController[] controllers = FindObjectsOfType<CharacterSwitchController>(true);
for (int i = 0; i < controllers.Length; i++)
{
GameObject target = controllers[i] != null ? controllers[i].StageIsolationUiRoot : null;
if (target != null)
{
target.SetActive(active);
}
}
}
private static void SetBehavioursEnabled(Behaviour[] targets, bool enabled)
{
if (targets == null) return;
for (int i = 0; i < targets.Length; i++)
{
if (targets[i] != null)
{
targets[i].enabled = enabled;
}
}
}
private static void SetCanvasGroupsBlocked(CanvasGroup[] targets, bool interactable, bool blocksRaycasts)
{
if (targets == null) return;
for (int i = 0; i < targets.Length; i++)
{
CanvasGroup target = targets[i];
if (target != null)
{
target.interactable = interactable;
target.blocksRaycasts = blocksRaycasts;
}
}
}
private readonly struct GameObjectState
{
public readonly GameObject Target;
public readonly bool ActiveSelf;
public GameObjectState(GameObject target, bool activeSelf)
{
Target = target;
ActiveSelf = activeSelf;
}
}
private readonly struct BehaviourState
{
public readonly Behaviour Target;
public readonly bool Enabled;
public BehaviourState(Behaviour target, bool enabled)
{
Target = target;
Enabled = enabled;
}
}
private readonly struct CanvasGroupState
{
public readonly CanvasGroup Target;
public readonly bool Interactable;
public readonly bool BlocksRaycasts;
public CanvasGroupState(CanvasGroup target, bool interactable, bool blocksRaycasts)
{
Target = target;
Interactable = interactable;
BlocksRaycasts = blocksRaycasts;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 51e84fcb0d45460891d9f0f8e60bf2a6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -12,6 +12,7 @@ namespace XGame.MiniGame
public string GatewayUrl = "ws://127.0.0.1:5005/ws?pid=1";
public string CdnBase = "";
public bool AutoStart;
public MiniGameStageIsolation StageIsolation;
private readonly List<GameInfoMsg> _games = new List<GameInfoMsg>();
private string _status = "idle";
@@ -141,10 +142,12 @@ namespace XGame.MiniGame
{
_lobbyActive = false;
_lobbyNet = null;
SuspendLobbyStage();
_gameHost = gameObject.GetComponent<MiniGameHost>();
if (_gameHost == null) _gameHost = gameObject.AddComponent<MiniGameHost>();
_gameHost.OnGameExited = () =>
{
ResumeLobbyStage();
_status = "returned to lobby";
_matching = false;
_lobbyNet = new MiniGameNetChannel(_socket);
@@ -155,6 +158,35 @@ namespace XGame.MiniGame
_gameHost.EnterGame(ResolvedCdnBase(), gameId, version, _socket, false);
}
private MiniGameStageIsolation ResolveStageIsolation()
{
if (StageIsolation != null)
{
return StageIsolation;
}
StageIsolation = GetComponent<MiniGameStageIsolation>();
return StageIsolation;
}
private void SuspendLobbyStage()
{
MiniGameStageIsolation isolation = ResolveStageIsolation();
if (isolation != null)
{
isolation.SuspendLobbyForMiniGame();
}
}
private void ResumeLobbyStage()
{
MiniGameStageIsolation isolation = ResolveStageIsolation();
if (isolation != null)
{
isolation.ResumeLobbyAfterMiniGame();
}
}
private string ResolvedCdnBase()
{
#if XW_DEVTEST
@@ -18,6 +18,12 @@
<Compile Include="..\..\Assets\Script\xmain\MiniGame\**\*.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Server\Framework.Shared\Framework.Shared.csproj">
<Private>false</Private>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<Reference Include="UnityEngine"><HintPath>$(UnityManaged)\UnityEngine.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.CoreModule"><HintPath>$(UnityManaged)\UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
@@ -29,7 +35,6 @@
<Reference Include="UnityEngine.UI"><HintPath>$(ScriptAssemblies)\UnityEngine.UI.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Unity.TextMeshPro"><HintPath>$(ScriptAssemblies)\Unity.TextMeshPro.dll</HintPath><Private>false</Private></Reference>
<Reference Include="XWorld.Link"><HintPath>$(ScriptAssemblies)\XWorld.Link.dll</HintPath><Private>false</Private></Reference>
<Reference Include="XWorld.Framework.Shared"><HintPath>$(ScriptAssemblies)\XWorld.Framework.Shared.dll</HintPath><Private>false</Private></Reference>
</ItemGroup>
</Project>
@@ -18,10 +18,11 @@ namespace XWorld.Framework.Tests
[Fact]
public void MatchRequest_RoundTrips()
{
var m = new MatchRequestMsg { GameId = "rock_paper_scissors", Version = 3 };
var m = new MatchRequestMsg { GameId = "rock_paper_scissors", Version = 3, RequestId = 42 };
var d = MatchRequestMsg.Decode(m.Encode());
Assert.Equal(m.GameId, d.GameId);
Assert.Equal(m.Version, d.Version);
Assert.Equal(m.RequestId, d.RequestId);
}
[Fact]
@@ -29,25 +30,50 @@ namespace XWorld.Framework.Tests
{
var m = new MatchFoundMsg
{
GameId = "rps",
RoomId = "room-42",
Version = 3,
SelfPlayerId = 1001,
RequestId = 99,
Players =
{
new PlayerInfo { PlayerId = 1001, Name = "Alice", IsAI = false },
new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true },
new PlayerInfo
{
PlayerId = 1001,
Name = "Alice",
IsAI = false,
Level = 12,
AppearanceType = 3,
AvatarId = 77,
},
new PlayerInfo
{
PlayerId = -1,
Name = "AI",
IsAI = true,
Level = 1,
AppearanceType = 0,
AvatarId = 0,
},
},
};
var d = MatchFoundMsg.Decode(m.Encode());
Assert.Equal("rps", d.GameId);
Assert.Equal("room-42", d.RoomId);
Assert.Equal(3, d.Version);
Assert.Equal(1001, d.SelfPlayerId);
Assert.Equal(99, d.RequestId);
Assert.Equal(2, d.Players.Count);
Assert.Equal(1001, d.Players[0].PlayerId);
Assert.Equal("Alice", d.Players[0].Name);
Assert.False(d.Players[0].IsAI);
Assert.Equal(12, d.Players[0].Level);
Assert.Equal(3, d.Players[0].AppearanceType);
Assert.Equal(77, d.Players[0].AvatarId);
Assert.Equal(-1, d.Players[1].PlayerId);
Assert.Equal("AI", d.Players[1].Name);
Assert.True(d.Players[1].IsAI);
Assert.Equal(1, d.Players[1].Level);
}
[Fact]
@@ -80,10 +106,30 @@ namespace XWorld.Framework.Tests
[Fact]
public void Error_RoundTrips()
{
var m = new ErrorMsg { Code = 404, Message = "version mismatch" };
var m = new ErrorMsg { Code = 404, Message = "version mismatch", RequestId = 7 };
var d = ErrorMsg.Decode(m.Encode());
Assert.Equal(404, d.Code);
Assert.Equal("version mismatch", d.Message);
Assert.Equal(7, d.RequestId);
}
[Fact]
public void MatchControl_RoundTrips()
{
var m = new MatchControlMsg { RequestId = 123 };
var d = MatchControlMsg.Decode(m.Encode());
Assert.Equal(123, d.RequestId);
Assert.Equal(0, MatchControlMsg.Decode(System.Array.Empty<byte>()).RequestId);
}
[Fact]
public void MatchAssigned_RoundTrips()
{
var m = new MatchAssignedMsg { GameId = "rps", Version = 2, RequestId = 88 };
var d = MatchAssignedMsg.Decode(m.Encode());
Assert.Equal("rps", d.GameId);
Assert.Equal(2, d.Version);
Assert.Equal(88, d.RequestId);
}
[Fact]
@@ -120,6 +166,7 @@ namespace XWorld.Framework.Tests
Assert.Equal((ushort)7, (ushort)FrameworkOpcode.Error);
Assert.Equal((ushort)16, (ushort)FrameworkOpcode.WorldChatSend);
Assert.Equal((ushort)17, (ushort)FrameworkOpcode.WorldChatMessage);
Assert.Equal((ushort)18, (ushort)FrameworkOpcode.MatchCancel);
}
[Fact]
@@ -169,5 +216,36 @@ namespace XWorld.Framework.Tests
Assert.NotNull(d.Players);
Assert.Empty(d.Players);
}
[Fact]
public void MatchFound_DecodesLegacyPlayersWithoutDisplayFields()
{
var w = new PacketWriter();
w.WriteString("legacy-room");
w.WriteVarInt(1);
w.WriteVarInt(1001);
w.WriteVarUInt(2);
w.WriteVarInt(1001);
w.WriteString("LegacyAlice");
w.WriteBool(false);
w.WriteVarInt(-1);
w.WriteString("AI");
w.WriteBool(true);
MatchFoundMsg d = MatchFoundMsg.Decode(w.ToArray());
Assert.Equal("legacy-room", d.RoomId);
Assert.Equal(0, d.RequestId);
Assert.Equal(2, d.Players.Count);
Assert.Equal(1001, d.Players[0].PlayerId);
Assert.Equal("LegacyAlice", d.Players[0].Name);
Assert.False(d.Players[0].IsAI);
Assert.Equal(0, d.Players[0].Level);
Assert.Equal(0, d.Players[0].AppearanceType);
Assert.Equal(0, d.Players[0].AvatarId);
Assert.Equal(-1, d.Players[1].PlayerId);
Assert.Equal("AI", d.Players[1].Name);
Assert.True(d.Players[1].IsAI);
}
}
}
@@ -0,0 +1,107 @@
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
namespace XWorld.Framework.Tests
{
public class RoomPlayerServiceTests
{
[Fact]
public void SetRoomPlayers_StoresSnapshotAndSupportsLookup()
{
var service = new RoomPlayerService();
var source = new List<PlayerInfo>
{
new PlayerInfo
{
PlayerId = 10,
Name = "Alice",
IsAI = false,
Level = 7,
AppearanceType = 2,
AvatarId = 100,
},
new PlayerInfo
{
PlayerId = -1,
Name = "AI",
IsAI = true,
Level = 1,
AppearanceType = 0,
AvatarId = 0,
},
};
service.SetRoomPlayers(source);
source[0].Name = "Mutated";
IReadOnlyList<PlayerInfo> players = service.GetRoomPlayers();
Assert.Equal(2, players.Count);
Assert.Equal("Alice", players[0].Name);
Assert.True(service.TryGetRoomPlayer(10, out PlayerInfo alice));
Assert.Equal(7, alice.Level);
Assert.Equal(2, alice.AppearanceType);
Assert.Equal(100, alice.AvatarId);
Assert.True(service.TryGetRoomPlayer(-1, out PlayerInfo ai));
Assert.True(ai.IsAI);
}
[Fact]
public void ReturnedPlayers_AreDefensiveCopies()
{
var service = new RoomPlayerService();
service.SetRoomPlayers(new[]
{
new PlayerInfo { PlayerId = 10, Name = "Alice", IsAI = false },
});
IReadOnlyList<PlayerInfo> first = service.GetRoomPlayers();
first[0].Name = "Changed";
IReadOnlyList<PlayerInfo> second = service.GetRoomPlayers();
Assert.Equal("Alice", second[0].Name);
Assert.True(service.TryGetRoomPlayer(10, out PlayerInfo lookup));
lookup.Name = "ChangedAgain";
Assert.True(service.TryGetRoomPlayer(10, out PlayerInfo lookupAgain));
Assert.Equal("Alice", lookupAgain.Name);
}
[Fact]
public void RequestPlayerInfo_ReturnsCachedPlayerOrNull()
{
var service = new RoomPlayerService();
service.SetRoomPlayers(new[]
{
new PlayerInfo { PlayerId = 10, Name = "Alice", IsAI = false, Level = 5 },
});
PlayerInfo? loaded = null;
service.RequestPlayerInfo(10, p => loaded = p);
Assert.NotNull(loaded);
Assert.Equal("Alice", loaded.Name);
Assert.Equal(5, loaded.Level);
PlayerInfo? missing = new PlayerInfo { PlayerId = 99, Name = "should be replaced" };
service.RequestPlayerInfo(99, p => missing = p);
Assert.Null(missing);
}
[Fact]
public void SetRoomPlayers_NullClearsTheRoom()
{
var service = new RoomPlayerService();
service.SetRoomPlayers(new[]
{
new PlayerInfo { PlayerId = 10, Name = "Alice", IsAI = false },
});
service.SetRoomPlayers(null);
Assert.Empty(service.GetRoomPlayers());
Assert.False(service.TryGetRoomPlayer(10, out _));
}
}
}
+7 -2
View File
@@ -14,6 +14,9 @@ namespace XWorld.Server.Gateway.Runner
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Server.Host.Tests", "TestGames"));
int port = int.TryParse(GetArg(args, "--port"), out int p) ? p : 5005;
int tickMs = int.TryParse(GetArg(args, "--tickMs"), out int t) ? t : 100;
long reconnectWindowTicks = long.TryParse(GetArg(args, "--reconnectWindowTicks"), out long rw)
? rw
: ServerLoop.DefaultReconnectWindowTicks;
long matchTimeoutTicks = long.TryParse(GetArg(args, "--matchTimeoutTicks"), out long mt) ? mt : 150;
string devToken = GetArg(args, "--devToken");
string advertiseWs = GetArg(args, "--advertiseWs");
@@ -49,13 +52,15 @@ namespace XWorld.Server.Gateway.Runner
}
var host = new GameServerHost();
await host.StartAsync(gamesRoot, tickMs, matchTimeoutTicks: matchTimeoutTicks, listenPort: port,
await host.StartAsync(gamesRoot, tickMs, reconnectWindowTicks: reconnectWindowTicks,
matchTimeoutTicks: matchTimeoutTicks, listenPort: port,
bindAllInterfaces: lan,
devDiscoveryToken: devToken, advertiseGatewayUrl: advertiseWs, advertiseResourceBaseUrl: advertiseCdn,
authSecret: authSecret, authDbPath: authDbPath);
Console.WriteLine("XWorld Gateway smoke server started.");
Console.WriteLine(" gamesRoot: " + gamesRoot);
Console.WriteLine(" tickMs: " + tickMs + ", matchTimeoutTicks: " + matchTimeoutTicks);
Console.WriteLine(" tickMs: " + tickMs + ", reconnectWindowTicks: " + reconnectWindowTicks +
", matchTimeoutTicks: " + matchTimeoutTicks);
Console.WriteLine(" ws: " + host.WsBaseUrl + "/ws?pid=1");
if (!string.IsNullOrEmpty(devToken))
Console.WriteLine(" dev-discovery: ON (UDP " + host.DevDiscoveryPort + "), advertise ws=" +
@@ -192,6 +192,67 @@ namespace XWorld.Server.Gateway.Tests
Assert.Contains(Frames(c), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error);
}
[Fact]
public void MatchCancel_AfterRoomCreated_ClearsRoomAndAllowsPlayerToMatchAgain()
{
var loop = NewLoop(matchTimeoutTicks: 0);
var c = new FakeConnection(5);
loop.Submit(new ConnectCommand { PlayerId = 5, Connection = c });
loop.Submit(new FrameCommand { PlayerId = 5, Frame = FrameworkFrame(FrameworkOpcode.RandomMatchRequest) });
loop.DrainAndTick(1f);
Assert.True(HasMatchFound(c), "precondition: player should be in a room before cancel");
c.Sent.Clear();
loop.Submit(new FrameCommand { PlayerId = 5, Frame = FrameworkFrame(FrameworkOpcode.MatchCancel) });
loop.DrainAndTick(1f);
loop.Submit(new FrameCommand { PlayerId = 5, Frame = FrameworkFrame(FrameworkOpcode.RandomMatchRequest) });
loop.DrainAndTick(1f);
Assert.Contains(Frames(c), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchAssigned);
Assert.DoesNotContain(Frames(c), f =>
f.Channel == Channel.Framework &&
f.Opcode == (ushort)FrameworkOpcode.Error &&
ErrorMsg.Decode(f.Payload).Message == "already in room or queue");
}
[Fact]
public void MatchCancel_InTwoPlayerRoom_ClearsRoomForAllPlayers()
{
var loop = NewLoop(matchTimeoutTicks: 100);
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(RpsJoinFrame()) });
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(RpsJoinFrame()) });
loop.DrainAndTick(1f);
Assert.True(HasMatchFound(c1), "precondition: player 1 should be in a room before cancel");
Assert.True(HasMatchFound(c2), "precondition: player 2 should be in a room before cancel");
c1.Sent.Clear();
c2.Sent.Clear();
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameworkFrame(FrameworkOpcode.MatchCancel) });
loop.DrainAndTick(1f);
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(RpsJoinFrame()) });
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(RpsJoinFrame()) });
loop.DrainAndTick(1f);
Assert.True(HasMatchFound(c1), "player 1 should be able to match again after cancel");
Assert.True(HasMatchFound(c2), "player 2 should be able to match again after the room was cancelled");
Assert.DoesNotContain(Frames(c1), f =>
f.Channel == Channel.Framework &&
f.Opcode == (ushort)FrameworkOpcode.Error &&
ErrorMsg.Decode(f.Payload).Message == "already in room or queue");
Assert.DoesNotContain(Frames(c2), f =>
f.Channel == Channel.Framework &&
f.Opcode == (ushort)FrameworkOpcode.Error &&
ErrorMsg.Decode(f.Payload).Message == "already in room or queue");
}
/// <summary>
/// Player 1 disconnects while still queued (before match forms).
/// DrainAndTick processes the DisconnectCommand (which dequeues pid=1) BEFORE Poll,
+57
View File
@@ -140,6 +140,42 @@ namespace XWorld.Server.Gateway.Tests
Assert.Contains(Frames(c1b), f => f.Channel == Channel.Game);
}
[Fact]
public void Reconnect_ReplaysMatchFoundAndLatestGameSnapshot()
{
var loop = NewLoop(reconnectWindow: 100);
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
loop.DrainAndTick(1f);
loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c1 });
loop.DrainAndTick(1f);
var c1b = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1b });
loop.DrainAndTick(0f);
Frame resumeFrame = Frames(c1b).Find(f =>
f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound);
Assert.NotEqual(default, resumeFrame);
MatchFoundMsg resume = MatchFoundMsg.Decode(resumeFrame.Payload);
Assert.Equal("rps", resume.GameId);
Assert.Equal(1, resume.Version);
Assert.Equal(1, resume.SelfPlayerId);
Assert.Equal(2, resume.Players.Count);
Assert.Contains(Frames(c1b), f => f.Channel == Channel.Game);
}
[Fact]
public void DefaultReconnectWindow_IsThreeMinutesAtDefaultTickRate()
{
Assert.Equal(1800, ServerLoop.DefaultReconnectWindowTicks);
}
[Fact]
public void ExpiredSession_IsSwept()
{
@@ -173,6 +209,27 @@ namespace XWorld.Server.Gateway.Tests
Assert.Contains(Frames(c1), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error);
}
[Fact]
public void MatchCancel_WhileQueued_CancelsQueuedMatchAndAllowsPlayerToMatchAgain()
{
var loop = NewLoop();
var c = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
loop.DrainAndTick(1f);
c.Sent.Clear();
loop.Submit(new FrameCommand { PlayerId = 1, Frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchCancel, System.Array.Empty<byte>()) });
loop.Submit(new FrameCommand { PlayerId = 1, Frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.RandomMatchRequest, System.Array.Empty<byte>()) });
loop.DrainAndTick(1f);
Assert.Contains(Frames(c), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchAssigned);
Assert.DoesNotContain(Frames(c), f =>
f.Channel == Channel.Framework &&
f.Opcode == (ushort)FrameworkOpcode.Error &&
ErrorMsg.Decode(f.Payload).Message == "already in room or queue");
}
[Fact]
public void GameFrame_WithNoRoom_IsIgnoredSafely()
{
+1 -1
View File
@@ -31,7 +31,7 @@ namespace XWorld.Server.Gateway
public string WsBaseUrl { get; private set; } // 例如 ws://127.0.0.1:54321
public int DevDiscoveryPort => _discovery?.Port ?? 0;
public async Task StartAsync(string gamesRoot, int tickIntervalMs, long reconnectWindowTicks = 100,
public async Task StartAsync(string gamesRoot, int tickIntervalMs, long reconnectWindowTicks = ServerLoop.DefaultReconnectWindowTicks,
long matchTimeoutTicks = 150, float? logicalDt = null, string publicKeyPem = null, int listenPort = 0,
bool bindAllInterfaces = false,
string devDiscoveryToken = null, string advertiseGatewayUrl = null, string advertiseResourceBaseUrl = null,
+5 -5
View File
@@ -4,10 +4,10 @@ namespace XWorld.Server.Gateway
{
public sealed class Matchmaker
{
public sealed class Seat { public int PlayerId; public bool IsAi; }
public sealed class Seat { public int PlayerId; public bool IsAi; public int RequestId; }
public sealed class Match { public string GameId; public int Version; public List<Seat> Seats = new List<Seat>(); }
private sealed class Waiter { public int Pid; public long EnqueuedTick; }
private sealed class Waiter { public int Pid; public int RequestId; public long EnqueuedTick; }
private sealed class Bucket { public int PlayerCount; public List<Waiter> Waiters = new List<Waiter>(); }
private readonly long _timeoutTicks;
@@ -17,11 +17,11 @@ namespace XWorld.Server.Gateway
public Matchmaker(long timeoutTicks) { _timeoutTicks = timeoutTicks; }
private static string Key(string g, int v) => $"{g}@{v}";
public void Enqueue(int pid, string gameId, int version, int playerCount, long tick)
public void Enqueue(int pid, string gameId, int version, int playerCount, long tick, int requestId = 0)
{
string k = Key(gameId, version);
if (!_buckets.TryGetValue(k, out var b)) { b = new Bucket { PlayerCount = playerCount }; _buckets[k] = b; }
b.Waiters.Add(new Waiter { Pid = pid, EnqueuedTick = tick });
b.Waiters.Add(new Waiter { Pid = pid, RequestId = requestId, EnqueuedTick = tick });
}
// 从等待队列移除某玩家(断线时调用);返回是否移除
@@ -51,7 +51,7 @@ namespace XWorld.Server.Gateway
var m = new Match { GameId = gv[0], Version = int.Parse(gv[1]) };
int take = b.Waiters.Count >= b.PlayerCount ? b.PlayerCount : b.Waiters.Count;
for (int i = 0; i < take; i++)
m.Seats.Add(new Seat { PlayerId = b.Waiters[i].Pid, IsAi = false });
m.Seats.Add(new Seat { PlayerId = b.Waiters[i].Pid, RequestId = b.Waiters[i].RequestId, IsAi = false });
b.Waiters.RemoveRange(0, take);
while (m.Seats.Count < b.PlayerCount)
m.Seats.Add(new Seat { PlayerId = -(++_aiSeq), IsAi = true }); // AI 用负 pid
+5 -2
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using XWorld.Framework;
using XWorld.Framework.Protocol;
@@ -11,14 +12,16 @@ namespace XWorld.Server.Gateway
private readonly string _roomId; // 保留作诊断/日志标识
private readonly IReadOnlyList<int> _players;
private readonly SessionManager _sessions;
private readonly Action<NetMessage> _onBroadcast;
public RoomOutputSink(string roomId, IReadOnlyList<int> players, SessionManager sessions)
public RoomOutputSink(string roomId, IReadOnlyList<int> players, SessionManager sessions, Action<NetMessage> onBroadcast = null)
{
_roomId = roomId; _players = players; _sessions = sessions;
_roomId = roomId; _players = players; _sessions = sessions; _onBroadcast = onBroadcast;
}
public void Broadcast(NetMessage message)
{
_onBroadcast?.Invoke(message);
byte[] frame = FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload));
foreach (var pid in _players)
_sessions.GetConnection(pid)?.Send(frame);
+163 -21
View File
@@ -21,16 +21,31 @@ namespace XWorld.Server.Gateway
private readonly Matchmaker _matchmaker;
private readonly ChannelsNS.Channel<ServerCommand> _cmds =
ChannelsNS.Channel.CreateUnbounded<ServerCommand>();
public const long DefaultReconnectWindowTicks = 1800;
private const int MaxWorldChatTextLength = 200;
private readonly Dictionary<string, List<int>> _roomPlayers = new Dictionary<string, List<int>>();
private readonly Dictionary<string, ActiveRoomInfo> _activeRooms = new Dictionary<string, ActiveRoomInfo>();
private readonly Dictionary<int, LobbyPlayerMsg> _lobbyPlayers = new Dictionary<int, LobbyPlayerMsg>();
private readonly Dictionary<string, int> _playerCountCache = new Dictionary<string, int>(); // "gameId@version" -> playerCount
private readonly HashSet<int> _queuedPids = new HashSet<int>(); // pids currently in matchmaker queue
private readonly Dictionary<int, int> _queuedRequestIds = new Dictionary<int, int>();
private readonly Dictionary<int, int> _activeRoomRequestIds = new Dictionary<int, int>();
private readonly Random _random = new Random(1);
private long _tick;
private int _roomSeq;
public ServerLoop(string gamesRoot, ILogger logger, long reconnectWindowTicks = 100,
private sealed class ActiveRoomInfo
{
public string RoomId;
public string GameId;
public int Version;
public List<PlayerInfo> Players;
public Dictionary<int, int> RequestIds = new Dictionary<int, int>();
public bool HasLastGameMessage;
public NetMessage LastGameMessage;
}
public ServerLoop(string gamesRoot, ILogger logger, long reconnectWindowTicks = DefaultReconnectWindowTicks,
long matchTimeoutTicks = 150, string publicKeyPem = null)
{
_logger = logger;
@@ -104,11 +119,12 @@ namespace XWorld.Server.Gateway
case ConnectCommand cc:
_sessions.OnConnect(cc.PlayerId, cc.Connection);
_logger.Info($"session connect pid={cc.PlayerId}");
SendRoomResumeIfNeeded(cc.PlayerId);
break;
case DisconnectCommand dc:
_sessions.OnDisconnect(dc.PlayerId, dc.Connection, _tick);
_logger.Info($"session disconnect pid={dc.PlayerId}");
if (_queuedPids.Remove(dc.PlayerId)) _matchmaker.Remove(dc.PlayerId);
CancelQueuedMatch(dc.PlayerId);
if (_lobbyPlayers.Remove(dc.PlayerId)) BroadcastLobbyState();
break;
case FrameCommand fc: Route(fc.PlayerId, fc.Frame); break;
@@ -140,13 +156,16 @@ namespace XWorld.Server.Gateway
break;
case FrameworkOpcode.MatchRequest:
var req = MatchRequestMsg.Decode(frame.Payload);
EnqueueMatch(pid, req.GameId, req.Version);
EnqueueMatch(pid, req.GameId, req.Version, req.RequestId);
break;
case FrameworkOpcode.GameListRequest:
SendFramework(pid, FrameworkOpcode.GameListResponse, BuildGameList().Encode());
break;
case FrameworkOpcode.RandomMatchRequest:
EnqueueRandomMatch(pid);
EnqueueRandomMatch(pid, MatchControlMsg.Decode(frame.Payload).RequestId);
break;
case FrameworkOpcode.MatchCancel:
CancelMatchState(pid, MatchControlMsg.Decode(frame.Payload).RequestId);
break;
case FrameworkOpcode.LobbyJoin:
HandleLobbyJoin(pid, frame.Payload);
@@ -155,6 +174,7 @@ namespace XWorld.Server.Gateway
HandleLobbyMove(pid, frame.Payload);
break;
case FrameworkOpcode.LobbyLeave:
CancelQueuedMatch(pid);
if (_lobbyPlayers.Remove(pid)) BroadcastLobbyState();
break;
case FrameworkOpcode.WorldChatSend:
@@ -272,21 +292,23 @@ namespace XWorld.Server.Gateway
}
}
private void EnqueueRandomMatch(int pid)
private void EnqueueRandomMatch(int pid, int requestId)
{
var games = BuildGameList().Games;
if (games.Count == 0)
{
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 1, Message = "no minigame available" }.Encode());
new ErrorMsg { Code = 1, Message = "no minigame available", RequestId = requestId }.Encode());
return;
}
var selected = games[_random.Next(games.Count)];
if (EnqueueMatch(pid, selected.GameId, selected.Version, requestId))
{
_logger.Info($"random match pid={pid} assigned {selected.GameId}@{selected.Version}");
SendFramework(pid, FrameworkOpcode.MatchAssigned,
new MatchAssignedMsg { GameId = selected.GameId, Version = selected.Version }.Encode());
EnqueueMatch(pid, selected.GameId, selected.Version);
new MatchAssignedMsg { GameId = selected.GameId, Version = selected.Version, RequestId = requestId }.Encode());
}
}
private GameListResponseMsg BuildGameList()
@@ -332,17 +354,17 @@ namespace XWorld.Server.Gateway
return msg;
}
private void EnqueueMatch(int pid, string gameId, int version)
private bool EnqueueMatch(int pid, string gameId, int version, int requestId = 0)
{
var s = _sessions.Get(pid);
if (s == null) return; // 未建立会话,忽略
if (s == null) return false; // 未建立会话,忽略
if (s.RoomId != null || _queuedPids.Contains(pid))
{
// 已在房间内或已在队列中:拒绝
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 2, Message = "already in room or queue" }.Encode());
return;
new ErrorMsg { Code = 2, Message = "already in room or queue", RequestId = requestId }.Encode());
return false;
}
// 读取游戏的 playerCount(缓存)
@@ -360,14 +382,76 @@ namespace XWorld.Server.Gateway
{
_logger.Error($"读取 game.json 失败 {gameId}@{version}: {ex.Message}");
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 1, Message = "game not found" }.Encode());
return;
new ErrorMsg { Code = 1, Message = "game not found", RequestId = requestId }.Encode());
return false;
}
}
_queuedPids.Add(pid);
_matchmaker.Enqueue(pid, gameId, version, playerCount, _tick);
_logger.Info($"enqueue match pid={pid} game={gameId}@{version} playerCount={playerCount} tick={_tick}");
_queuedRequestIds[pid] = requestId;
_matchmaker.Enqueue(pid, gameId, version, playerCount, _tick, requestId);
_logger.Info($"enqueue match pid={pid} req={requestId} game={gameId}@{version} playerCount={playerCount} tick={_tick}");
return true;
}
private void CancelQueuedMatch(int pid, int requestId = 0)
{
if (requestId != 0 &&
_queuedRequestIds.TryGetValue(pid, out int queuedRequestId) &&
queuedRequestId != requestId)
{
return;
}
if (_queuedPids.Remove(pid))
{
_queuedRequestIds.Remove(pid);
_matchmaker.Remove(pid);
_logger.Info($"cancel queued match pid={pid} req={requestId}");
}
}
private void CancelMatchState(int pid, int requestId)
{
CancelQueuedMatch(pid, requestId);
CancelActiveRoom(pid, requestId);
}
private void CancelActiveRoom(int pid, int requestId)
{
var s = _sessions.Get(pid);
if (s?.RoomId == null) return;
if (requestId != 0 &&
_activeRoomRequestIds.TryGetValue(pid, out int activeRequestId) &&
activeRequestId != requestId)
{
return;
}
string roomId = s.RoomId;
if (_roomPlayers.TryGetValue(roomId, out var players))
{
var roomPids = new List<int>(players);
_roomPlayers.Remove(roomId);
_activeRooms.Remove(roomId);
for (int i = 0; i < roomPids.Count; i++)
{
int roomPid = roomPids[i];
var roomSession = _sessions.Get(roomPid);
if (roomSession?.RoomId == roomId) roomSession.RoomId = null;
_activeRoomRequestIds.Remove(roomPid);
}
_host.EndRoom(roomId);
}
else
{
_activeRooms.Remove(roomId);
s.RoomId = null;
_activeRoomRequestIds.Remove(pid);
}
_logger.Info($"cancel active match pid={pid} req={requestId} room={roomId}");
}
private void CreateRoomForSeats(Matchmaker.Match match)
@@ -397,10 +481,23 @@ namespace XWorld.Server.Gateway
{
realPids.Add(seat.PlayerId);
_queuedPids.Remove(seat.PlayerId);
_queuedRequestIds.Remove(seat.PlayerId);
}
}
var sink = new RoomOutputSink(roomId, realPids, _sessions);
var allPlayers = new List<PlayerInfo>(playerInfos);
var roomInfo = new ActiveRoomInfo
{
RoomId = roomId,
GameId = match.GameId,
Version = match.Version,
Players = allPlayers
};
var sink = new RoomOutputSink(roomId, realPids, _sessions, message =>
{
roomInfo.HasLastGameMessage = true;
roomInfo.LastGameMessage = new NetMessage(message.Opcode, message.Payload ?? Array.Empty<byte>());
});
var cfg = new RoomConfig
{
GameId = match.GameId,
@@ -409,6 +506,7 @@ namespace XWorld.Server.Gateway
PlayerCount = seats.Count
};
_activeRooms[roomId] = roomInfo;
Room room;
try
{
@@ -417,6 +515,7 @@ namespace XWorld.Server.Gateway
catch (Exception ex)
{
_logger.Error($"建房失败 {match.GameId}@{match.Version}: {ex.Message}");
_activeRooms.Remove(roomId);
// Notify real players of error
var errPayload = new ErrorMsg { Code = 1, Message = "create room failed" }.Encode();
foreach (int rpid in realPids)
@@ -424,20 +523,30 @@ namespace XWorld.Server.Gateway
return;
}
// Build MatchFound message with all players info
var allPlayers = new List<PlayerInfo>(playerInfos);
// For each real player: set session.RoomId + send MatchFound
foreach (int rpid in realPids)
{
var s = _sessions.Get(rpid);
if (s != null) s.RoomId = roomId;
int requestId = 0;
for (int i = 0; i < seats.Count; i++)
{
if (!seats[i].IsAi && seats[i].PlayerId == rpid)
{
requestId = seats[i].RequestId;
break;
}
}
_activeRoomRequestIds[rpid] = requestId;
roomInfo.RequestIds[rpid] = requestId;
var found = new MatchFoundMsg
{
GameId = match.GameId,
RoomId = roomId,
Version = match.Version,
SelfPlayerId = rpid
SelfPlayerId = rpid,
RequestId = requestId
};
foreach (var pi in allPlayers) found.Players.Add(pi);
SendFramework(rpid, FrameworkOpcode.MatchFound, found.Encode());
@@ -452,6 +561,7 @@ namespace XWorld.Server.Gateway
{
if (!_roomPlayers.TryGetValue(roomId, out var players)) return;
_roomPlayers.Remove(roomId);
_activeRooms.Remove(roomId);
var msg = new RoomEndMsg
{
WinnerPlayerId = result?.WinnerPlayerId ?? 0,
@@ -464,6 +574,7 @@ namespace XWorld.Server.Gateway
SendFramework(pid, FrameworkOpcode.RoomEnd, payload);
var s = _sessions.Get(pid);
if (s != null) s.RoomId = null;
_activeRoomRequestIds.Remove(pid);
}
}
@@ -472,5 +583,36 @@ namespace XWorld.Server.Gateway
_sessions.GetConnection(pid)?.Send(
FrameCodec.Encode(new Frame(Channel.Framework, (ushort)op, payload)));
}
private void SendGame(int pid, NetMessage message)
{
_sessions.GetConnection(pid)?.Send(
FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload)));
}
private void SendRoomResumeIfNeeded(int pid)
{
var session = _sessions.Get(pid);
if (session?.RoomId == null) return;
if (!_activeRooms.TryGetValue(session.RoomId, out var room))
{
session.RoomId = null;
_activeRoomRequestIds.Remove(pid);
return;
}
var found = new MatchFoundMsg
{
GameId = room.GameId,
RoomId = room.RoomId,
Version = room.Version,
SelfPlayerId = pid,
RequestId = room.RequestIds.TryGetValue(pid, out int requestId) ? requestId : 0
};
for (int i = 0; i < room.Players.Count; i++) found.Players.Add(room.Players[i]);
SendFramework(pid, FrameworkOpcode.MatchFound, found.Encode());
if (room.HasLastGameMessage) SendGame(pid, room.LastGameMessage);
_logger.Info($"resume room pid={pid} room={room.RoomId} game={room.GameId}@{room.Version}");
}
}
}
+17
View File
@@ -74,6 +74,23 @@ namespace XWorld.Server.Host.Tests
() => host.CreateRoom("dup", "rps", 1, Cfg(), Players, new Capture()));
}
[Fact]
public void EndRoom_ReleasesRoomImmediately()
{
var host = NewHost();
var cap = new Capture();
host.CreateRoom("manual", "rps", 1, Cfg(), Players, cap);
Assert.True(host.EndRoom("manual"));
Assert.Equal(0, host.ActiveRoomCount);
int broadcasts = cap.Broadcasts.Count;
host.DeliverTo("manual", 1, new NetMessage(1, new byte[] { (byte)Choice.Rock }));
host.TickAll(0.1f);
Assert.Equal(broadcasts, cap.Broadcasts.Count);
}
[Fact]
public void TickAll_ReturnsEndedRoomIds()
{
+8
View File
@@ -84,6 +84,14 @@ namespace XWorld.Server.Host
entry.Room.Deliver(playerId, message);
}
public bool EndRoom(string roomId)
{
if (!_rooms.TryGetValue(roomId, out var entry)) return false;
entry.Room.End();
ReleaseRoom(roomId);
return true;
}
private void ReleaseRoom(string roomId)
{
if (!_rooms.TryGetValue(roomId, out var entry)) return;
@@ -0,0 +1,768 @@
# MiniGame Lobby Stage Isolation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the lobby and minigame mutually exclusive stages by suspending and hiding lobby UI/render/input/tick while a minigame runs, then restoring the lobby after minigame exit or enter failure.
**Architecture:** Add a reusable `MiniGameStageIsolation` MonoBehaviour that snapshots configured lobby object state, suspends it for minigame, and restores it exactly. Keep `MiniGameHost` focused on minigame lifecycle, but guarantee `OnGameExited` fires on enter failure so the caller can restore lobby. Wire `PcLobbySmokeLauncher` to call isolation before `MiniGameHost.EnterGame` and resume it from `OnGameExited`.
**Tech Stack:** Unity 2022.3 C#, NUnit EditMode tests, existing `MiniGameRuntime.Verify` dotnet build project, `XGame.MiniGame` runtime code.
---
## File Map
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs`
- Runtime component that snapshots and restores lobby UI roots, scene roots, input behaviours, tick behaviours, and canvas groups.
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta`
- Unity meta for the new runtime script.
- Create: `Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs`
- EditMode tests for suspend/resume, original-state restore, idempotency, and null safety.
- Create: `Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta`
- Unity meta for the new test script.
- Modify: `Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs`
- Call stage isolation before entering a minigame and resume it when `MiniGameHost.OnGameExited` fires.
- Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs`
- Call `OnGameExited` on minigame enter failure so suspended lobby always resumes.
- Modify: `Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj`
- Already compiles `Assets/Script/xmain/MiniGame/**/*.cs`; only verify no extra changes needed.
- Modify: `Client/Assets/Doc/MiniGameDevelopmentDesign.md`
- Add a “大厅与小游戏阶段隔离” section matching the implementation.
- Modify: `docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md`
- Keep final spec aligned with implementation if any naming changes occur.
---
## Task 1: Add `MiniGameStageIsolation` With EditMode Tests
**Files:**
- Create: `Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs`
- Create: `Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta`
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs`
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta`
- [ ] **Step 1: Write failing EditMode tests**
Create `Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs`:
```csharp
using NUnit.Framework;
using UnityEngine;
using XGame.MiniGame;
public sealed class MiniGameStageIsolationTests
{
private GameObject _root;
private sealed class TestBehaviour : MonoBehaviour
{
}
[TearDown]
public void TearDown()
{
if (_root != null)
{
Object.DestroyImmediate(_root);
_root = null;
}
}
[Test]
public void SuspendAndResume_RestoresOriginalActiveAndEnabledStates()
{
_root = new GameObject("root");
GameObject uiActive = new GameObject("ui-active");
GameObject uiInactive = new GameObject("ui-inactive");
GameObject sceneRoot = new GameObject("scene-root");
uiActive.transform.SetParent(_root.transform);
uiInactive.transform.SetParent(_root.transform);
sceneRoot.transform.SetParent(_root.transform);
uiInactive.SetActive(false);
var enabledInput = _root.AddComponent<TestBehaviour>();
var disabledTick = _root.AddComponent<TestBehaviour>();
disabledTick.enabled = false;
var canvas = _root.AddComponent<CanvasGroup>();
canvas.interactable = true;
canvas.blocksRaycasts = true;
var isolation = _root.AddComponent<MiniGameStageIsolation>();
isolation.LobbyUiRoots = new[] { uiActive, uiInactive };
isolation.LobbySceneRoots = new[] { sceneRoot };
isolation.LobbyInputBehaviours = new Behaviour[] { enabledInput };
isolation.LobbyTickBehaviours = new Behaviour[] { disabledTick };
isolation.LobbyCanvasGroups = new[] { canvas };
isolation.SuspendLobbyForMiniGame();
Assert.IsTrue(isolation.IsSuspended);
Assert.IsFalse(uiActive.activeSelf);
Assert.IsFalse(uiInactive.activeSelf);
Assert.IsFalse(sceneRoot.activeSelf);
Assert.IsFalse(enabledInput.enabled);
Assert.IsFalse(disabledTick.enabled);
Assert.IsFalse(canvas.interactable);
Assert.IsFalse(canvas.blocksRaycasts);
isolation.ResumeLobbyAfterMiniGame();
Assert.IsFalse(isolation.IsSuspended);
Assert.IsTrue(uiActive.activeSelf);
Assert.IsFalse(uiInactive.activeSelf);
Assert.IsTrue(sceneRoot.activeSelf);
Assert.IsTrue(enabledInput.enabled);
Assert.IsFalse(disabledTick.enabled);
Assert.IsTrue(canvas.interactable);
Assert.IsTrue(canvas.blocksRaycasts);
}
[Test]
public void SuspendAndResume_AreIdempotent()
{
_root = new GameObject("root");
GameObject ui = new GameObject("ui");
ui.transform.SetParent(_root.transform);
var behaviour = _root.AddComponent<TestBehaviour>();
var isolation = _root.AddComponent<MiniGameStageIsolation>();
isolation.LobbyUiRoots = new[] { ui };
isolation.LobbyInputBehaviours = new Behaviour[] { behaviour };
isolation.SuspendLobbyForMiniGame();
ui.SetActive(true);
behaviour.enabled = true;
isolation.SuspendLobbyForMiniGame();
Assert.IsFalse(ui.activeSelf);
Assert.IsFalse(behaviour.enabled);
isolation.ResumeLobbyAfterMiniGame();
isolation.ResumeLobbyAfterMiniGame();
Assert.IsTrue(ui.activeSelf);
Assert.IsTrue(behaviour.enabled);
}
[Test]
public void SuspendAndResume_IgnoreNullEntries()
{
_root = new GameObject("root");
var isolation = _root.AddComponent<MiniGameStageIsolation>();
isolation.LobbyUiRoots = new GameObject[] { null };
isolation.LobbySceneRoots = new GameObject[] { null };
isolation.LobbyInputBehaviours = new Behaviour[] { null };
isolation.LobbyTickBehaviours = new Behaviour[] { null };
isolation.LobbyCanvasGroups = new CanvasGroup[] { null };
Assert.DoesNotThrow(() => isolation.SuspendLobbyForMiniGame());
Assert.DoesNotThrow(() => isolation.ResumeLobbyAfterMiniGame());
}
}
```
- [ ] **Step 2: Create test meta file**
Create `Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta`:
```text
fileFormatVersion: 2
guid: 0db89d5629e34042af0af9a432d1efb7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
```
- [ ] **Step 3: Run tests to verify RED**
Run Unity EditMode test command:
```bash
"D:/Program Files/Unity 2022.3.62f3/Editor/Unity.exe" -batchmode -projectPath "D:/UD/AI/AIC#Project/Client" -runTests -testPlatform EditMode -testResults "D:/UD/AI/AIC#Project/Temp/minigame-stage-isolation-editmode.xml" -quit
```
Expected: FAIL because `XGame.MiniGame.MiniGameStageIsolation` does not exist.
- [ ] **Step 4: Implement `MiniGameStageIsolation`**
Create `Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs`:
```csharp
using System.Collections.Generic;
using UnityEngine;
namespace XGame.MiniGame
{
public sealed class MiniGameStageIsolation : MonoBehaviour
{
public GameObject[] LobbyUiRoots;
public GameObject[] LobbySceneRoots;
public Behaviour[] LobbyInputBehaviours;
public Behaviour[] LobbyTickBehaviours;
public CanvasGroup[] LobbyCanvasGroups;
private readonly List<GameObjectState> _gameObjectStates = new List<GameObjectState>();
private readonly List<BehaviourState> _behaviourStates = new List<BehaviourState>();
private readonly List<CanvasGroupState> _canvasGroupStates = new List<CanvasGroupState>();
public bool IsSuspended { get; private set; }
public void SuspendLobbyForMiniGame()
{
if (IsSuspended)
{
ApplySuspendedState();
return;
}
CaptureState();
IsSuspended = true;
ApplySuspendedState();
}
public void ResumeLobbyAfterMiniGame()
{
if (!IsSuspended)
{
return;
}
for (int i = 0; i < _gameObjectStates.Count; i++)
{
GameObjectState state = _gameObjectStates[i];
if (state.Target != null)
{
state.Target.SetActive(state.ActiveSelf);
}
}
for (int i = 0; i < _behaviourStates.Count; i++)
{
BehaviourState state = _behaviourStates[i];
if (state.Target != null)
{
state.Target.enabled = state.Enabled;
}
}
for (int i = 0; i < _canvasGroupStates.Count; i++)
{
CanvasGroupState state = _canvasGroupStates[i];
if (state.Target != null)
{
state.Target.interactable = state.Interactable;
state.Target.blocksRaycasts = state.BlocksRaycasts;
}
}
_gameObjectStates.Clear();
_behaviourStates.Clear();
_canvasGroupStates.Clear();
IsSuspended = false;
}
private void CaptureState()
{
_gameObjectStates.Clear();
_behaviourStates.Clear();
_canvasGroupStates.Clear();
CaptureGameObjects(LobbyUiRoots);
CaptureGameObjects(LobbySceneRoots);
CaptureBehaviours(LobbyInputBehaviours);
CaptureBehaviours(LobbyTickBehaviours);
CaptureCanvasGroups(LobbyCanvasGroups);
}
private void ApplySuspendedState()
{
SetGameObjectsActive(LobbyUiRoots, false);
SetGameObjectsActive(LobbySceneRoots, false);
SetBehavioursEnabled(LobbyInputBehaviours, false);
SetBehavioursEnabled(LobbyTickBehaviours, false);
SetCanvasGroupsBlocked(LobbyCanvasGroups, false, false);
}
private void CaptureGameObjects(GameObject[] targets)
{
if (targets == null) return;
for (int i = 0; i < targets.Length; i++)
{
GameObject target = targets[i];
if (target != null)
{
_gameObjectStates.Add(new GameObjectState(target, target.activeSelf));
}
}
}
private void CaptureBehaviours(Behaviour[] targets)
{
if (targets == null) return;
for (int i = 0; i < targets.Length; i++)
{
Behaviour target = targets[i];
if (target != null)
{
_behaviourStates.Add(new BehaviourState(target, target.enabled));
}
}
}
private void CaptureCanvasGroups(CanvasGroup[] targets)
{
if (targets == null) return;
for (int i = 0; i < targets.Length; i++)
{
CanvasGroup target = targets[i];
if (target != null)
{
_canvasGroupStates.Add(new CanvasGroupState(target, target.interactable, target.blocksRaycasts));
}
}
}
private static void SetGameObjectsActive(GameObject[] targets, bool active)
{
if (targets == null) return;
for (int i = 0; i < targets.Length; i++)
{
if (targets[i] != null)
{
targets[i].SetActive(active);
}
}
}
private static void SetBehavioursEnabled(Behaviour[] targets, bool enabled)
{
if (targets == null) return;
for (int i = 0; i < targets.Length; i++)
{
if (targets[i] != null)
{
targets[i].enabled = enabled;
}
}
}
private static void SetCanvasGroupsBlocked(CanvasGroup[] targets, bool interactable, bool blocksRaycasts)
{
if (targets == null) return;
for (int i = 0; i < targets.Length; i++)
{
CanvasGroup target = targets[i];
if (target != null)
{
target.interactable = interactable;
target.blocksRaycasts = blocksRaycasts;
}
}
}
private readonly struct GameObjectState
{
public readonly GameObject Target;
public readonly bool ActiveSelf;
public GameObjectState(GameObject target, bool activeSelf)
{
Target = target;
ActiveSelf = activeSelf;
}
}
private readonly struct BehaviourState
{
public readonly Behaviour Target;
public readonly bool Enabled;
public BehaviourState(Behaviour target, bool enabled)
{
Target = target;
Enabled = enabled;
}
}
private readonly struct CanvasGroupState
{
public readonly CanvasGroup Target;
public readonly bool Interactable;
public readonly bool BlocksRaycasts;
public CanvasGroupState(CanvasGroup target, bool interactable, bool blocksRaycasts)
{
Target = target;
Interactable = interactable;
BlocksRaycasts = blocksRaycasts;
}
}
}
}
```
- [ ] **Step 5: Create runtime meta file**
Create `Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta`:
```text
fileFormatVersion: 2
guid: 51e84fcb0d45460891d9f0f8e60bf2a6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
```
- [ ] **Step 6: Run tests to verify GREEN**
Run:
```bash
"D:/Program Files/Unity 2022.3.62f3/Editor/Unity.exe" -batchmode -projectPath "D:/UD/AI/AIC#Project/Client" -runTests -testPlatform EditMode -testResults "D:/UD/AI/AIC#Project/Temp/minigame-stage-isolation-editmode.xml" -quit
```
Expected: `MiniGameStageIsolationTests` pass.
---
## Task 2: Wire Isolation Into `PcLobbySmokeLauncher`
**Files:**
- Modify: `Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs`
- [ ] **Step 1: Add isolation field and helper methods**
In `PcLobbySmokeLauncher`, add a field near `AutoStart`:
```csharp
public MiniGameStageIsolation StageIsolation;
```
Add these helper methods near `EnterAssignedGame`:
```csharp
private MiniGameStageIsolation ResolveStageIsolation()
{
if (StageIsolation != null)
{
return StageIsolation;
}
StageIsolation = GetComponent<MiniGameStageIsolation>();
return StageIsolation;
}
private void SuspendLobbyStage()
{
MiniGameStageIsolation isolation = ResolveStageIsolation();
if (isolation != null)
{
isolation.SuspendLobbyForMiniGame();
}
}
private void ResumeLobbyStage()
{
MiniGameStageIsolation isolation = ResolveStageIsolation();
if (isolation != null)
{
isolation.ResumeLobbyAfterMiniGame();
}
}
```
- [ ] **Step 2: Suspend before entering minigame**
In `EnterAssignedGame`, immediately after `_lobbyNet = null;`, add:
```csharp
SuspendLobbyStage();
```
- [ ] **Step 3: Resume from `OnGameExited` before restoring lobby network/UI state**
In the `_gameHost.OnGameExited = () =>` callback, add `ResumeLobbyStage();` as the first statement:
```csharp
_gameHost.OnGameExited = () =>
{
ResumeLobbyStage();
_status = "returned to lobby";
_matching = false;
_lobbyNet = new MiniGameNetChannel(_socket);
_lobbyNet.OnFrameworkFrame = OnLobbyFrameworkFrame;
_lobbyActive = true;
RequestGameList();
};
```
- [ ] **Step 4: Build runtime verify project**
Run:
```bash
dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj -c Release -nologo
```
Expected: build succeeds with 0 warnings and 0 errors.
---
## Task 3: Ensure Enter Failure Restores Lobby
**Files:**
- Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs`
- [ ] **Step 1: Add failure exit helper**
In `MiniGameHost`, add this method near `ExitGame()`:
```csharp
private void FailEnterAndExit(string reason)
{
DestroyResultDialog();
_waitingForResultConfirm = false;
_running = false;
_client = null;
_net = null;
_ctx = null;
if (_players != null)
{
_players.SetRoomPlayers(null);
}
if (_assets != null)
{
_assets.UnloadAll();
_assets = null;
}
Debug.LogError("[MiniGameHost] 进入小游戏失败: " + reason);
OnGameExited?.Invoke();
}
```
- [ ] **Step 2: Use helper on download failure**
Replace:
```csharp
if (!ok) { Debug.LogError("[MiniGameHost] 下载失败: " + dl.Error); yield break; }
```
with:
```csharp
if (!ok)
{
FailEnterAndExit("下载失败: " + dl.Error);
yield break;
}
```
- [ ] **Step 3: Use helper on assembly load failure**
Replace:
```csharp
if (!asmLoader.Load(dl.LocalDir, dl.Manifest))
{ Debug.LogError("[MiniGameHost] 加载程序集失败: " + asmLoader.Error); yield break; }
```
with:
```csharp
if (!asmLoader.Load(dl.LocalDir, dl.Manifest))
{
FailEnterAndExit("加载程序集失败: " + asmLoader.Error);
yield break;
}
```
- [ ] **Step 4: Wrap client creation and OnEnter failures**
Replace the block from `_client = asmLoader.CreateClient();` through `SafeCall(() => _client.OnEnter(_ctx), "OnEnter");` with:
```csharp
try
{
_client = asmLoader.CreateClient();
}
catch (Exception e)
{
FailEnterAndExit("创建客户端失败: " + e.Message);
yield break;
}
_assets = new MiniGameAssetLoader(dl.LocalDir, dl.Manifest);
if (_players == null)
{
_players = new RoomPlayerService();
}
_ctx = new GameClientCtx(
_assets,
new UnityLogger($"[{gameId}]"),
_players,
msg => { if (_net != null) _net.SendGame(msg); },
() => ExitGame());
if (sendMatchRequest)
{
_net.SendFramework(FrameworkOpcode.MatchRequest,
new MatchRequestMsg { GameId = gameId, Version = version }.Encode());
}
try
{
_client.OnEnter(_ctx);
}
catch (Exception e)
{
FailEnterAndExit("OnEnter 抛异常: " + e.Message);
yield break;
}
```
- [ ] **Step 5: Keep success path unchanged**
After the `try/catch`, keep:
```csharp
_running = true;
Debug.Log("[MiniGameHost] 小游戏已进入: " + gameId);
```
- [ ] **Step 6: Build runtime verify project**
Run:
```bash
dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj -c Release -nologo
```
Expected: build succeeds with 0 warnings and 0 errors.
---
## Task 4: Sync Documentation
**Files:**
- Modify: `Client/Assets/Doc/MiniGameDevelopmentDesign.md`
- Modify: `docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md`
- [ ] **Step 1: Add stage isolation section to `MiniGameDevelopmentDesign.md`**
Append a section before “异常处理与兜底”:
```markdown
## 大厅与小游戏阶段隔离
进入小游戏后,大厅必须暂停并隐藏,直到小游戏退出后再恢复。大厅与小游戏是互斥阶段,不允许大厅 UI、输入、场景单位渲染与小游戏同时处于可操作状态。
隔离由 `MiniGameStageIsolation` 负责,调用方在进入小游戏前调用 `SuspendLobbyForMiniGame()`,在 `MiniGameHost.OnGameExited` 中调用 `ResumeLobbyAfterMiniGame()``MiniGameHost` 只负责小游戏生命周期,不直接依赖大厅对象。
隔离范围包括:
- 大厅操作 UI 根节点:隐藏并在恢复时还原原始 active 状态。
- 大厅场景/单位/特效根节点:隐藏并停止渲染。
- 大厅输入脚本:禁用,避免小游戏期间响应大厅点击、摇杆或快捷键。
- 大厅 Tick/表现脚本:禁用,避免后台继续驱动大厅单位。
- 需要保持 active 的 CanvasGroup:关闭交互和射线阻挡。
进入失败也必须恢复大厅。下载失败、校验失败、DLL 加载失败或热更客户端 `OnEnter` 抛异常时,`MiniGameHost` 会触发 `OnGameExited`,调用方应统一在该回调中恢复大厅。
```
- [ ] **Step 2: Ensure spec matches final method names**
In `docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md`, ensure it references:
```text
MiniGameStageIsolation.SuspendLobbyForMiniGame()
MiniGameStageIsolation.ResumeLobbyAfterMiniGame()
MiniGameHost.OnGameExited
```
- [ ] **Step 3: Scan docs for placeholders**
Run:
```bash
rg -n "TBD|TODO|FIXME|待定|占位" Client/Assets/Doc/MiniGameDevelopmentDesign.md docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md
```
Expected: no matches.
---
## Task 5: Final Verification And Commit
**Files:**
- All files from Tasks 1-4.
- [ ] **Step 1: Run EditMode isolation tests**
Run:
```bash
"D:/Program Files/Unity 2022.3.62f3/Editor/Unity.exe" -batchmode -projectPath "D:/UD/AI/AIC#Project/Client" -runTests -testPlatform EditMode -testResults "D:/UD/AI/AIC#Project/Temp/minigame-stage-isolation-editmode.xml" -quit
```
Expected: `MiniGameStageIsolationTests` pass. If unrelated pre-existing EditMode tests fail, report them separately and still verify the XML contains passing `MiniGameStageIsolationTests`.
- [ ] **Step 2: Build runtime verify project**
Run:
```bash
dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj -c Release -nologo
```
Expected: build succeeds with 0 warnings and 0 errors.
- [ ] **Step 3: Run shared framework tests to ensure previous room player work stays green**
Run:
```bash
dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --no-restore
```
Expected: 57 tests pass, 0 fail.
- [ ] **Step 4: Inspect relevant diff only**
Run:
```bash
git diff -- Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs Client/Assets/Doc/MiniGameDevelopmentDesign.md docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md docs/superpowers/plans/2026-07-29-minigame-lobby-stage-isolation.md
```
Expected: diff only contains stage isolation feature and related docs/plan.
- [ ] **Step 5: Commit only relevant files**
Run:
```bash
git add Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs.meta Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs.meta Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs Client/Assets/Doc/MiniGameDevelopmentDesign.md docs/superpowers/specs/2026-07-29-minigame-lobby-stage-isolation-design.md docs/superpowers/plans/2026-07-29-minigame-lobby-stage-isolation.md
git commit -m "feat: isolate lobby during minigames" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
Expected: commit succeeds on the current feature branch without staging unrelated art/animation changes.
---
## Self-Review
- Spec coverage: Tasks cover a reusable isolation component, lobby smoke integration, enter-failure recovery through `OnGameExited`, docs, EditMode tests, and runtime build verification.
- Placeholder scan: No implementation placeholders are left in this plan.
- Type consistency: The plan consistently uses `MiniGameStageIsolation`, `SuspendLobbyForMiniGame`, `ResumeLobbyAfterMiniGame`, `StageIsolation`, and `MiniGameHost.OnGameExited`.
@@ -0,0 +1,812 @@
# MiniGame Room Player Service Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a shared room-player service so hot-update minigames can read the room player list and query cached player display data such as name, level, appearance type, and avatar id through `ctx.Players`.
**Architecture:** Extend the shared framework contract (`XWorld.Framework`) with richer `PlayerInfo`, `IRoomPlayerService`, and a pure C# `RoomPlayerService` cache implementation. Populate the service from `MatchFoundMsg.Players` inside `MiniGameHost`, inject it through `GameClientCtx`, and update RPS to consume `ctx.Players` for player names.
**Tech Stack:** C# 9, Unity 2022.3, netstandard2.1 shared framework, xUnit tests in `Server/Framework.Shared.Tests`, Unity runtime code under `Client/Assets/Script/xmain/MiniGame`.
---
## File Map
- Modify: `Client/Assets/Framework/Shared/GameTypes.cs`
- Add `Level`, `AppearanceType`, and `AvatarId` fields to `PlayerInfo`.
- Modify: `Client/Assets/Framework/Shared/IGameClient.cs`
- Add `IRoomPlayerService Players { get; }` to `IGameClientCtx`.
- Create: `Client/Assets/Framework/Shared/RoomPlayerService.cs`
- Define `IRoomPlayerService` and pure C# `RoomPlayerService` implementation.
- Modify: `Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs`
- Encode/decode the new `PlayerInfo` display fields in `MatchFoundMsg`; decode remains compatible with old payloads by checking `PacketReader.HasMore`.
- Create: `Server/Framework.Shared.Tests/RoomPlayerServiceTests.cs`
- Verify room player cache snapshots, lookup, async-style callback, and defensive copies.
- Modify: `Server/Framework.Shared.Tests/FrameworkMessagesTests.cs`
- Verify `MatchFoundMsg` round-trips new display fields and can decode legacy player payloads.
- Modify: `Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs`
- Store and expose `IRoomPlayerService Players`.
- Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs`
- Create `RoomPlayerService`, populate it from `MatchFound`, add `SetMatchInfo` overload with players, and inject it into `GameClientCtx`.
- Modify: `Client/Assets/MiniGames/RockPaperScissors/scripts~/Client/RpsGameClient.cs`
- Read player names from `ctx.Players` and pass them to the view.
- Modify: `Client/Assets/Doc/MiniGameDevelopmentDesign.md`
- Update the room-player service section if implementation details differ from the draft.
---
## Task 1: Shared Player Contract And Protocol Tests
**Files:**
- Modify: `Client/Assets/Framework/Shared/GameTypes.cs`
- Modify: `Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs`
- Modify: `Server/Framework.Shared.Tests/FrameworkMessagesTests.cs`
- [ ] **Step 1: Extend the existing MatchFound round-trip test first**
In `Server/Framework.Shared.Tests/FrameworkMessagesTests.cs`, replace `MatchFound_RoundTrips_WithPlayers` with:
```csharp
[Fact]
public void MatchFound_RoundTrips_WithPlayers()
{
var m = new MatchFoundMsg
{
RoomId = "room-42",
Version = 3,
SelfPlayerId = 1001,
Players =
{
new PlayerInfo
{
PlayerId = 1001,
Name = "Alice",
IsAI = false,
Level = 12,
AppearanceType = 3,
AvatarId = 77,
},
new PlayerInfo
{
PlayerId = -1,
Name = "AI",
IsAI = true,
Level = 1,
AppearanceType = 0,
AvatarId = 0,
},
},
};
var d = MatchFoundMsg.Decode(m.Encode());
Assert.Equal("room-42", d.RoomId);
Assert.Equal(3, d.Version);
Assert.Equal(1001, d.SelfPlayerId);
Assert.Equal(2, d.Players.Count);
Assert.Equal(1001, d.Players[0].PlayerId);
Assert.Equal("Alice", d.Players[0].Name);
Assert.False(d.Players[0].IsAI);
Assert.Equal(12, d.Players[0].Level);
Assert.Equal(3, d.Players[0].AppearanceType);
Assert.Equal(77, d.Players[0].AvatarId);
Assert.Equal(-1, d.Players[1].PlayerId);
Assert.Equal("AI", d.Players[1].Name);
Assert.True(d.Players[1].IsAI);
Assert.Equal(1, d.Players[1].Level);
}
```
- [ ] **Step 2: Add legacy payload decode test**
Append this test to `FrameworkMessagesTests`:
```csharp
[Fact]
public void MatchFound_DecodesLegacyPlayersWithoutDisplayFields()
{
var w = new PacketWriter();
w.WriteString("legacy-room");
w.WriteVarInt(1);
w.WriteVarInt(1001);
w.WriteVarUInt(1);
w.WriteVarInt(1001);
w.WriteString("LegacyAlice");
w.WriteBool(false);
MatchFoundMsg d = MatchFoundMsg.Decode(w.ToArray());
Assert.Equal("legacy-room", d.RoomId);
Assert.Single(d.Players);
Assert.Equal(1001, d.Players[0].PlayerId);
Assert.Equal("LegacyAlice", d.Players[0].Name);
Assert.False(d.Players[0].IsAI);
Assert.Equal(0, d.Players[0].Level);
Assert.Equal(0, d.Players[0].AppearanceType);
Assert.Equal(0, d.Players[0].AvatarId);
}
```
- [ ] **Step 3: Run tests and verify they fail**
Run:
```bash
dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --filter "FullyQualifiedName~FrameworkMessagesTests.MatchFound" --no-restore
```
Expected: FAIL because `PlayerInfo` does not yet define `Level`, `AppearanceType`, or `AvatarId`.
- [ ] **Step 4: Extend `PlayerInfo`**
In `Client/Assets/Framework/Shared/GameTypes.cs`, replace the current `PlayerInfo` class with:
```csharp
public sealed class PlayerInfo
{
public int PlayerId;
public string Name;
public bool IsAI;
public int Level;
public int AppearanceType;
public int AvatarId;
}
```
- [ ] **Step 5: Extend MatchFound encode/decode**
In `Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs`, update `MatchFoundMsg.Encode()` player loop to:
```csharp
foreach (var p in Players)
{
w.WriteVarInt(p.PlayerId);
w.WriteString(p.Name);
w.WriteBool(p.IsAI);
w.WriteVarInt(p.Level);
w.WriteVarInt(p.AppearanceType);
w.WriteVarInt(p.AvatarId);
}
```
Update `MatchFoundMsg.Decode()` player loop to:
```csharp
for (uint i = 0; i < n; i++)
{
var player = new PlayerInfo
{
PlayerId = r.ReadVarInt(),
Name = r.ReadString(),
IsAI = r.ReadBool(),
};
if (r.HasMore)
{
player.Level = r.ReadVarInt();
}
if (r.HasMore)
{
player.AppearanceType = r.ReadVarInt();
}
if (r.HasMore)
{
player.AvatarId = r.ReadVarInt();
}
m.Players.Add(player);
}
```
- [ ] **Step 6: Run focused tests and verify they pass**
Run:
```bash
dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --filter "FullyQualifiedName~FrameworkMessagesTests.MatchFound" --no-restore
```
Expected: PASS for `MatchFound_RoundTrips_WithPlayers`, `MatchFound_EmptyPlayerList_RoundTrips`, and `MatchFound_DecodesLegacyPlayersWithoutDisplayFields`.
---
## Task 2: Shared RoomPlayerService
**Files:**
- Modify: `Client/Assets/Framework/Shared/IGameClient.cs`
- Create: `Client/Assets/Framework/Shared/RoomPlayerService.cs`
- Create: `Server/Framework.Shared.Tests/RoomPlayerServiceTests.cs`
- [ ] **Step 1: Write RoomPlayerService tests first**
Create `Server/Framework.Shared.Tests/RoomPlayerServiceTests.cs` with:
```csharp
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
namespace XWorld.Framework.Tests
{
public class RoomPlayerServiceTests
{
[Fact]
public void SetRoomPlayers_StoresSnapshotAndSupportsLookup()
{
var service = new RoomPlayerService();
var source = new List<PlayerInfo>
{
new PlayerInfo
{
PlayerId = 10,
Name = "Alice",
IsAI = false,
Level = 7,
AppearanceType = 2,
AvatarId = 100,
},
new PlayerInfo
{
PlayerId = -1,
Name = "AI",
IsAI = true,
Level = 1,
AppearanceType = 0,
AvatarId = 0,
},
};
service.SetRoomPlayers(source);
source[0].Name = "Mutated";
IReadOnlyList<PlayerInfo> players = service.GetRoomPlayers();
Assert.Equal(2, players.Count);
Assert.Equal("Alice", players[0].Name);
Assert.True(service.TryGetRoomPlayer(10, out PlayerInfo alice));
Assert.Equal(7, alice.Level);
Assert.Equal(2, alice.AppearanceType);
Assert.Equal(100, alice.AvatarId);
Assert.True(service.TryGetRoomPlayer(-1, out PlayerInfo ai));
Assert.True(ai.IsAI);
}
[Fact]
public void ReturnedPlayers_AreDefensiveCopies()
{
var service = new RoomPlayerService();
service.SetRoomPlayers(new[]
{
new PlayerInfo { PlayerId = 10, Name = "Alice", IsAI = false },
});
IReadOnlyList<PlayerInfo> first = service.GetRoomPlayers();
first[0].Name = "Changed";
IReadOnlyList<PlayerInfo> second = service.GetRoomPlayers();
Assert.Equal("Alice", second[0].Name);
Assert.True(service.TryGetRoomPlayer(10, out PlayerInfo lookup));
lookup.Name = "ChangedAgain";
Assert.True(service.TryGetRoomPlayer(10, out PlayerInfo lookupAgain));
Assert.Equal("Alice", lookupAgain.Name);
}
[Fact]
public void RequestPlayerInfo_ReturnsCachedPlayerOrNull()
{
var service = new RoomPlayerService();
service.SetRoomPlayers(new[]
{
new PlayerInfo { PlayerId = 10, Name = "Alice", IsAI = false, Level = 5 },
});
PlayerInfo loaded = null;
service.RequestPlayerInfo(10, p => loaded = p);
Assert.NotNull(loaded);
Assert.Equal("Alice", loaded.Name);
Assert.Equal(5, loaded.Level);
PlayerInfo missing = new PlayerInfo { PlayerId = 99, Name = "should be replaced" };
service.RequestPlayerInfo(99, p => missing = p);
Assert.Null(missing);
}
[Fact]
public void SetRoomPlayers_NullClearsTheRoom()
{
var service = new RoomPlayerService();
service.SetRoomPlayers(new[]
{
new PlayerInfo { PlayerId = 10, Name = "Alice", IsAI = false },
});
service.SetRoomPlayers(null);
Assert.Empty(service.GetRoomPlayers());
Assert.False(service.TryGetRoomPlayer(10, out _));
}
}
}
```
- [ ] **Step 2: Run tests and verify they fail**
Run:
```bash
dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --filter FullyQualifiedName~RoomPlayerServiceTests --no-restore
```
Expected: FAIL because `RoomPlayerService` does not exist.
- [ ] **Step 3: Add `Players` to `IGameClientCtx`**
In `Client/Assets/Framework/Shared/IGameClient.cs`, replace `IGameClientCtx` with:
```csharp
public interface IGameClientCtx
{
IAssetLoader Assets { get; }
ILogger Logger { get; }
IRoomPlayerService Players { get; }
void Send(NetMessage message); // 发往服务端(Game 通道)
void Exit(); // 请求退出当前小游戏
}
```
- [ ] **Step 4: Create shared room player service**
Create `Client/Assets/Framework/Shared/RoomPlayerService.cs`:
```csharp
using System;
using System.Collections.Generic;
namespace XWorld.Framework
{
public interface IRoomPlayerService
{
IReadOnlyList<PlayerInfo> GetRoomPlayers();
bool TryGetRoomPlayer(int playerId, out PlayerInfo player);
void RequestPlayerInfo(int playerId, Action<PlayerInfo> onLoaded);
}
public sealed class RoomPlayerService : IRoomPlayerService
{
private readonly List<PlayerInfo> _players = new List<PlayerInfo>();
private readonly Dictionary<int, PlayerInfo> _byId = new Dictionary<int, PlayerInfo>();
public void SetRoomPlayers(IEnumerable<PlayerInfo> players)
{
_players.Clear();
_byId.Clear();
if (players == null)
{
return;
}
foreach (PlayerInfo player in players)
{
if (player == null)
{
continue;
}
PlayerInfo copy = Clone(player);
_players.Add(copy);
_byId[copy.PlayerId] = copy;
}
}
public IReadOnlyList<PlayerInfo> GetRoomPlayers()
{
var copy = new List<PlayerInfo>(_players.Count);
for (int i = 0; i < _players.Count; i++)
{
copy.Add(Clone(_players[i]));
}
return copy;
}
public bool TryGetRoomPlayer(int playerId, out PlayerInfo player)
{
if (_byId.TryGetValue(playerId, out PlayerInfo cached))
{
player = Clone(cached);
return true;
}
player = null;
return false;
}
public void RequestPlayerInfo(int playerId, Action<PlayerInfo> onLoaded)
{
TryGetRoomPlayer(playerId, out PlayerInfo player);
onLoaded?.Invoke(player);
}
private static PlayerInfo Clone(PlayerInfo source)
{
return new PlayerInfo
{
PlayerId = source.PlayerId,
Name = source.Name,
IsAI = source.IsAI,
Level = source.Level,
AppearanceType = source.AppearanceType,
AvatarId = source.AvatarId,
};
}
}
}
```
- [ ] **Step 5: Run service tests and verify they pass**
Run:
```bash
dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --filter FullyQualifiedName~RoomPlayerServiceTests --no-restore
```
Expected: PASS for all `RoomPlayerServiceTests`.
- [ ] **Step 6: Run all shared framework tests**
Run:
```bash
dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --no-restore
```
Expected: PASS for all shared framework tests.
---
## Task 3: Inject RoomPlayerService Into MiniGameHost
**Files:**
- Modify: `Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs`
- Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs`
- [ ] **Step 1: Update `GameClientCtx` constructor and property**
In `Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs`, replace the class body with:
```csharp
public sealed class GameClientCtx : IGameClientCtx
{
public IAssetLoader Assets { get; }
public IFwkLogger Logger { get; }
public IRoomPlayerService Players { get; }
private readonly Action<NetMessage> _send;
private readonly Action _exit;
public GameClientCtx(IAssetLoader assets, IFwkLogger logger, IRoomPlayerService players, Action<NetMessage> send, Action exit)
{
Assets = assets;
Logger = logger;
Players = players;
_send = send;
_exit = exit;
}
public void Send(NetMessage message) => _send(message);
public void Exit() => _exit();
}
```
- [ ] **Step 2: Add player cache field to `MiniGameHost`**
In `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs`, add this field near `_assets`:
```csharp
private RoomPlayerService _players;
```
- [ ] **Step 3: Add `SetMatchInfo` overload with players**
Replace the existing `SetMatchInfo(string roomId, int selfPlayerId)` method with:
```csharp
public void SetMatchInfo(string roomId, int selfPlayerId)
{
SetMatchInfo(roomId, selfPlayerId, null);
}
public void SetMatchInfo(string roomId, int selfPlayerId, IReadOnlyList<PlayerInfo> players)
{
_roomId = roomId;
_selfPlayerId = selfPlayerId;
if (_players == null)
{
_players = new RoomPlayerService();
}
if (players != null)
{
_players.SetRoomPlayers(players);
}
}
```
- [ ] **Step 4: Instantiate and inject `RoomPlayerService` during enter**
In `CoEnter`, before creating `GameClientCtx`, ensure the service exists:
```csharp
if (_players == null)
{
_players = new RoomPlayerService();
}
```
Then replace the `GameClientCtx` construction with:
```csharp
_ctx = new GameClientCtx(
_assets,
new UnityLogger($"[{gameId}]"),
_players,
msg => { if (_net != null) _net.SendGame(msg); },
() => ExitGame());
```
- [ ] **Step 5: Populate players from `MatchFound`**
In `MiniGameHost.OnFrameworkFrame`, update the `FrameworkOpcode.MatchFound` case to:
```csharp
case FrameworkOpcode.MatchFound:
MatchFoundMsg found = MatchFoundMsg.Decode(f.Payload);
_roomId = found.RoomId;
_selfPlayerId = found.SelfPlayerId;
if (_players == null)
{
_players = new RoomPlayerService();
}
_players.SetRoomPlayers(found.Players);
Debug.Log("[MiniGameHost] 房间就绪: " + _roomId);
break;
```
- [ ] **Step 6: Clear room player cache on exit**
In `ExitGame`, after `_ctx = null;`, add:
```csharp
if (_players != null)
{
_players.SetRoomPlayers(null);
}
```
- [ ] **Step 7: Build runtime verify project**
Run:
```bash
dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj -c Release -nologo
```
Expected: build succeeds with exit code 0. If Unity generated `Library/ScriptAssemblies/XWorld.Framework.Shared.dll` is stale and does not contain `IRoomPlayerService`, open Unity once to regenerate script assemblies, then rerun the command.
---
## Task 4: Consume Room Players In RPS Client
**Files:**
- Modify: `Client/Assets/MiniGames/RockPaperScissors/scripts~/Client/RpsGameClient.cs`
- [ ] **Step 1: Capture room player names in `RpsGameClient`**
In `RpsGameClient`, add fields near `_status`:
```csharp
private string _leftPlayerName = "You";
private string _rightPlayerName = "Opponent";
```
In `OnEnter`, after `_ctx = ctx;`, add:
```csharp
ApplyRoomPlayers();
```
Add this method to `RpsGameClient`:
```csharp
private void ApplyRoomPlayers()
{
if (_ctx?.Players == null)
{
return;
}
var players = _ctx.Players.GetRoomPlayers();
if (players.Count > 0)
{
_leftPlayerName = DisplayName(players[0], "You");
}
if (players.Count > 1)
{
_rightPlayerName = DisplayName(players[1], players[1].IsAI ? "AI" : "Opponent");
}
}
private static string DisplayName(PlayerInfo player, string fallback)
{
if (player == null || string.IsNullOrEmpty(player.Name))
{
return fallback;
}
if (player.Level > 0)
{
return player.Name + " Lv." + player.Level;
}
return player.Name;
}
```
- [ ] **Step 2: Pass names into the view**
In `OnPrefabLoaded`, replace:
```csharp
_view = new RpsView(instance, SubmitChoice);
```
with:
```csharp
_view = new RpsView(instance, SubmitChoice, _leftPlayerName, _rightPlayerName);
```
Replace the `RpsView` constructor signature:
```csharp
public RpsView(GameObject root, System.Action<Choice> choose)
```
with:
```csharp
public RpsView(GameObject root, System.Action<Choice> choose, string leftPlayerName, string rightPlayerName)
```
Inside the constructor, replace:
```csharp
SetText(_leftNameText, "You");
SetText(_rightNameText, "Opponent");
```
with:
```csharp
SetText(_leftNameText, string.IsNullOrEmpty(leftPlayerName) ? "You" : leftPlayerName);
SetText(_rightNameText, string.IsNullOrEmpty(rightPlayerName) ? "Opponent" : rightPlayerName);
```
- [ ] **Step 3: Preserve AI fallback during render**
In `RpsView.Render`, replace:
```csharp
SetText(_rightNameText, state.IsAi[1] ? "AI" : "Opponent");
```
with:
```csharp
if (state.IsAi[1] && _rightNameText != null && string.IsNullOrEmpty(_rightNameText.text))
{
SetText(_rightNameText, "AI");
}
```
This prevents each snapshot from overwriting the name supplied by the room player service.
- [ ] **Step 4: Build RPS client**
Run:
```bash
dotnet build Client/HotUpdateGames/RPS.Client/RPS.Client.csproj -c Release -nologo
```
Expected: build succeeds with exit code 0.
---
## Task 5: Documentation And Final Verification
**Files:**
- Modify: `Client/Assets/Doc/MiniGameDevelopmentDesign.md`
- [ ] **Step 1: Align the design doc with final code names**
Ensure section `5.3 房间玩家与用户数据服务` states the final contract exactly:
```csharp
public interface IRoomPlayerService
{
IReadOnlyList<PlayerInfo> GetRoomPlayers();
bool TryGetRoomPlayer(int playerId, out PlayerInfo player);
void RequestPlayerInfo(int playerId, Action<PlayerInfo> onLoaded);
}
```
Ensure the documented `PlayerInfo` fields are:
```csharp
public int PlayerId;
public string Name;
public bool IsAI;
public int Level;
public int AppearanceType;
public int AvatarId;
```
- [ ] **Step 2: Run all relevant verification commands**
Run:
```bash
dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --no-restore
```
Expected: PASS.
Run:
```bash
dotnet build Client/HotUpdateGames/RPS.Client/RPS.Client.csproj -c Release -nologo
```
Expected: build succeeds with exit code 0.
Run:
```bash
dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj -c Release -nologo
```
Expected: build succeeds with exit code 0, or reports stale Unity script assemblies; if stale, open Unity to regenerate, then rerun and require exit code 0 before completion.
- [ ] **Step 3: Inspect changed files**
Run:
```bash
git diff -- Client/Assets/Framework/Shared/GameTypes.cs Client/Assets/Framework/Shared/IGameClient.cs Client/Assets/Framework/Shared/RoomPlayerService.cs Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs Client/Assets/MiniGames/RockPaperScissors/scripts~/Client/RpsGameClient.cs Client/Assets/Doc/MiniGameDevelopmentDesign.md Server/Framework.Shared.Tests/FrameworkMessagesTests.cs Server/Framework.Shared.Tests/RoomPlayerServiceTests.cs
```
Expected: diff only contains the room-player service feature and design-doc alignment.
- [ ] **Step 4: Optional commit only if the user explicitly asks**
Do not commit by default. If the user asks for a commit, run:
```bash
git add Client/Assets/Framework/Shared/GameTypes.cs Client/Assets/Framework/Shared/IGameClient.cs Client/Assets/Framework/Shared/RoomPlayerService.cs Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs Client/Assets/MiniGames/RockPaperScissors/scripts~/Client/RpsGameClient.cs Client/Assets/Doc/MiniGameDevelopmentDesign.md Server/Framework.Shared.Tests/FrameworkMessagesTests.cs Server/Framework.Shared.Tests/RoomPlayerServiceTests.cs docs/superpowers/plans/2026-07-29-minigame-room-player-service.md
git commit -m "feat: add minigame room player service"
```
Commit message body must end with:
```text
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
```
---
## Self-Review
- Spec coverage: The plan implements the requested common room player list and unified player data lookup through `IRoomPlayerService` and `ctx.Players`; it includes name, level, appearance type, avatar id, AI handling, host injection, protocol transport, and an example consumer.
- Placeholder scan: No `TBD`, `TODO`, `FIXME`, or unspecified implementation steps remain.
- Type consistency: `PlayerInfo`, `IRoomPlayerService`, `RoomPlayerService`, `IGameClientCtx.Players`, and `GameClientCtx` constructor signatures match across tests, shared framework, host runtime, and RPS client usage.
@@ -0,0 +1,194 @@
# MiniGame Lobby Stage Isolation Design
## 目标
进入小游戏后,大厅必须进入暂停并隐藏状态:大厅操作界面关闭或不可交互,大厅场景和单位不再渲染,大厅输入和大厅 Tick 停止,直到小游戏完全退出后再恢复大厅。这样大厅和小游戏成为两个互斥阶段,避免大厅 UI、输入、场景渲染和小游戏同时存在导致遮挡、误操作或性能浪费。
本设计采用“暂停并隐藏大厅”,不销毁大厅对象。原因是当前大厅/小游戏链路复用同一 `XWebSocket`,小游戏结束后要快速回到大厅;销毁重建大厅会引入额外状态恢复和网络重连复杂度。
## 范围
本设计覆盖:
- 进入小游戏前暂停大厅 UI、输入、Tick 和渲染。
- 小游戏运行期间保持大厅不可见、不可操作。
- 小游戏退出或进入失败时恢复大厅。
- 保持 `MiniGameHost` 只负责小游戏生命周期,不让它直接依赖大厅对象。
- 先支持 `PcLobbySmokeLauncher` 链路,后续正式大厅入口复用同一隔离组件。
不覆盖:
- 销毁并重新加载完整大厅场景。
- 大厅业务状态持久化重建。
- 小游戏内部 UI 设计。
## 架构
新增通用组件 `MiniGameStageIsolation`,放在 `Client/Assets/Script/xmain/MiniGame/`。它是大厅阶段隔离控制器,只负责保存大厅对象原始状态、暂停大厅、恢复大厅,不包含具体小游戏逻辑。
`MiniGameHost` 继续只处理:下载、校验、加载 DLL、创建 `IGameClient`、网络 Pump、结算、退出清理。它不直接关闭大厅 UI 或大厅场景。
调用方负责把隔离控制器串到流程里。当前 `PcLobbySmokeLauncher` 在收到 `MatchAssigned` 后调用:
```text
EnterAssignedGame(gameId, version)
MiniGameStageIsolation.SuspendLobbyForMiniGame()
MiniGameHost.EnterGame(...)
MiniGameHost.OnGameExited
MiniGameStageIsolation.ResumeLobbyAfterMiniGame()
恢复大厅网络 Pump 和大厅操作界面
```
## `MiniGameStageIsolation` 职责
建议接口:
```csharp
public sealed class MiniGameStageIsolation : MonoBehaviour
{
public GameObject[] LobbyUiRoots;
public GameObject[] LobbySceneRoots;
public Behaviour[] LobbyInputBehaviours;
public Behaviour[] LobbyTickBehaviours;
public CanvasGroup[] LobbyCanvasGroups;
public bool IsSuspended { get; }
public void SuspendLobbyForMiniGame();
public void ResumeLobbyAfterMiniGame();
}
```
字段语义:
- `LobbyUiRoots`:大厅普通操作界面根节点。Suspend 时 `SetActive(false)`Resume 时恢复原始 active 状态。
- `LobbySceneRoots`:大厅 3D 场景、单位、特效、地图等渲染根节点。Suspend 时 `SetActive(false)`Resume 时恢复原始 active 状态。
- `LobbyInputBehaviours`:大厅输入、摇杆、点击、快捷键等脚本。Suspend 时 `enabled = false`Resume 时恢复原始 enabled 状态。
- `LobbyTickBehaviours`:大厅逻辑 Tick、AI 展示、场景表现控制等脚本。Suspend 时 `enabled = false`Resume 时恢复原始 enabled 状态。
- `LobbyCanvasGroups`:需要保留 active 但禁止交互的 UI 根。Suspend 时 `interactable=false``blocksRaycasts=false`Resume 时恢复原状态。
实现要求:
1. 防重入:重复调用 `SuspendLobbyForMiniGame()` 不重复覆盖原始状态;重复调用 `ResumeLobbyAfterMiniGame()` 不报错。
2. 恢复原状:Resume 恢复的是 Suspend 前的原始状态,不是简单全部打开。
3. 空引用安全:数组里存在 null 时跳过并打印 warning 或静默跳过。
4. 不销毁对象:只隐藏或禁用,避免破坏大厅状态。
5. 不管理小游戏对象:小游戏 UI/资源由 `MiniGameHost` 和热更客户端清理。
## 大厅链路接入
`PcLobbySmokeLauncher` 当前已经有 `_lobbyActive` 控制大厅网络 Pump,有 `_matching` 控制匹配状态,有 `OnGameExited` 恢复大厅网络和刷新游戏列表。
接入后:
- `EnterAssignedGame()` 中,在 `MiniGameHost.EnterGame()` 前调用隔离器 Suspend。
- `OnGameExited` 中,先 Resume 大厅,再恢复 `_lobbyNet``_lobbyActive``_matching`,最后 `RequestGameList()`
- 如果 `MiniGameHost.EnterGame()` 下载失败或加载失败,也必须触发恢复,避免大厅被隐藏后卡死。
建议 `MiniGameHost` 在进入失败路径调用一个统一失败退出函数,确保 `OnGameExited` 被调用。例如:
```text
CoEnter 下载失败 / DLL 加载失败 / CreateClient 失败
FailEnterAndExit(reason)
清理已创建资源
OnGameExited?.Invoke()
```
这样大厅隔离恢复不需要知道失败原因,只要监听 `OnGameExited`
## 生命周期
正常进入:
```text
大厅收到 MatchAssigned
暂停大厅网络 Pump_lobbyActive=false
MiniGameStageIsolation.SuspendLobbyForMiniGame()
MiniGameHost.EnterGame(...)
小游戏下载、加载、OnEnter
小游戏运行,期间大厅不可见不可操作
```
正常退出:
```text
小游戏 RoomEnd / 玩家确认结算 / 主动退出
MiniGameHost.ExitGame()
热更客户端 OnExit + 资源卸载
OnGameExited
MiniGameStageIsolation.ResumeLobbyAfterMiniGame()
恢复大厅网络 Pump 和大厅 UI
刷新大厅列表或大厅状态
```
失败恢复:
```text
小游戏下载失败 / 校验失败 / DLL 加载失败 / 入口类型错误
MiniGameHost 清理部分初始化状态
OnGameExited
MiniGameStageIsolation.ResumeLobbyAfterMiniGame()
大厅恢复可见和可操作,并显示错误日志或状态
```
## 错误处理
- Suspend 后如果 EnterGame 失败,必须恢复大厅。
- Resume 前如果部分对象已被外部销毁,跳过该对象,不抛异常。
- 小游戏结算弹窗显示期间大厅仍保持隐藏,避免结算窗和大厅 UI 叠加。
- 玩家重复点击进入小游戏时,如果 `IsSuspended == true`,不再次进入或不再次保存状态。
- 如果大厅本身某些 UI 在进入前就是关闭状态,退出小游戏后仍保持关闭。
## 测试策略
编辑器/纯 C# 可验证:
1. `MiniGameStageIsolation` Suspend 后:
- 配置的 UI root inactive。
- 配置的 scene root inactive。
- 输入 Behaviour disabled。
- Tick Behaviour disabled。
- CanvasGroup 不可交互且不挡射线。
2. Resume 后恢复 Suspend 前状态。
3. 重复 Suspend/Resume 不破坏状态。
4. null 配置不抛异常。
运行时 smoke 验证:
1. 进入小游戏后,大厅操作 GUI 不再显示或不可操作。
2. 大厅 3D/单位根节点不可见。
3. 小游戏结算弹窗期间大厅仍隐藏。
4. 点击结算确认退出小游戏后,大厅恢复可见可操作。
5. CDN 缺包或 DLL 加载失败时,大厅也能恢复。
## 文档同步
`Client/Assets/Doc/MiniGameDevelopmentDesign.md` 应补充“大厅与小游戏阶段隔离”章节,说明:
- 小游戏期间大厅必须暂停并隐藏。
- 隔离职责属于调用方/隔离组件,不属于具体小游戏。
- `MiniGameHost.OnGameExited` 是恢复大厅的统一时机。
- 新小游戏不得直接操作大厅对象。