feat: add minigame room player service

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ud18010
2026-07-29 12:24:58 +08:00
co-authored by Claude Opus 4.8
parent 053a95b4fb
commit 31afce6882
13 changed files with 1878 additions and 10 deletions
@@ -0,0 +1,693 @@
# 小游戏开发设计文档
## 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. 异常处理与兜底
| 场景 | 处理策略 |
|---|---|
| `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` 防重入。 |
## 13. 开发红线
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、框架版本校验失败时不要降级加载旧包。
## 14. 验收清单
新增小游戏合入前至少验证:
- `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 缺文件、入口类型错误、资源缺失等异常不会卡死。
## 15. 建议开发拆分
第一阶段:最小闭环
- 建立目录和 `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(); // 请求退出当前小游戏
}
@@ -83,6 +83,14 @@ 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);
}
return w.ToArray();
}
@@ -105,6 +113,24 @@ 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;
}
}
}
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:
@@ -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)
@@ -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,7 @@ namespace XGame.MiniGame
private MiniGameNetChannel _net;
private GameClientCtx _ctx;
private MiniGameAssetLoader _assets;
private RoomPlayerService _players;
private bool _running;
private bool _waitingForResultConfirm;
private string _gameId;
@@ -43,9 +45,22 @@ namespace XGame.MiniGame
// 大厅链路: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)
@@ -69,9 +84,14 @@ namespace XGame.MiniGame
// 4) 反射实例化 IGameClient + 注入 ctx
_client = asmLoader.CreateClient();
_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());
@@ -96,6 +116,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:
@@ -122,6 +147,10 @@ namespace XGame.MiniGame
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();
@@ -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>