Files
AIC-Project/docs/superpowers/plans/2026-06-19-client-minigame-loader-step3.md
T
2026-07-10 10:24:29 +08:00

712 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 客户端小游戏加载器 + 生命周期框架(§9 第 3 步)实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
>
> ⚠️ **本计划是 Unity / HybridCLR 客户端代码,必须在 Unity Editor 中实施与验证。** 当前 .NET 沙箱无 Unity,无法 `dotnet test`。每个任务的验证步骤是 **Unity 编辑器编译 + Play 模式人工核对**(标注「[Unity 人工核对]」)。这与子项目 1 的 asmdef 处理一致。
**Goal:** 在客户端(`XWorld.Link` 热更程序集)实现一个 C# 小游戏加载器与生命周期框架:玩家进入某小游戏时,按「小游戏 manifest」增量下载并校验其文件 → HybridCLR 加载该小游戏的 `Core`/`Client` 热更 DLL → 加载其 AssetBundle → 反射 `game.json` 指定的 `IGameClient` 入口 → 注入客户端 ctx(资源/网络/退出)→ 驱动 `OnEnter`/`OnNetMessage`/`OnUpdate`/`OnExit`;并用框架 `FrameCodec`/`FrameworkMessages``XWebSocket` 上收发帧,取代 Lua `game_module_runner` 的 C# 小游戏路径。
**Architecture:** 复用并扩展现有 `LoadDll` 的 md5 清单/增量下载(设计 C1)叠加「小游戏 manifest」维度;复用 `XResLoader` 加载 AB、`XWebSocket`(经 `lsocket`)做传输、`XWCoroutine` 跑协程。加载器与生命周期宿主放在 `XWorld.Link`(热更),引用常驻的 `XWorld.Framework.Shared``IGameClient`/`NetMessage`/`FrameCodec` 等契约类型,AOT 不随小游戏卸载)。小游戏的 `Core`/`Client` DLL 由 HybridCLR `Assembly.Load` 动态载入;入口类型按 `game.json` 反射实例化。**本步骤不接服务端匹配**(用既有/手填 roomId 直连验证),与服务端 2b-1 的 `Channel.Framework`/`Channel.Game` 帧约定对接。
**Tech Stack:** Unity + HybridCLR · `XWorld.Link`(热更,`namespace XGame.MiniGame`)· 引用 `XWorld.Framework.Shared`AOT)· `XResLoader`AB)· `lsocket`/`XWebSocket`WS)· `XWCoroutine`(协程)· `FrameCodec`/`FrameworkMessages`(协议,子项目 1)。
---
## 背景与约定(务必遵守)
- 上游设计:`docs/superpowers/specs/2026-06-18-csharp-hotfix-minigame-architecture-design.md` §3.2(客户端进入→下载→加载→运行)、§5(`IGameClient`/`IGameClientCtx`/`IAssetLoader` 契约)。
- 已完成:子项目 1`Client/Assets/Framework/Shared/`,含 `IGameClient`/`IGameClientCtx`/`IAssetLoader`/`NetMessage`/`Protocol.FrameCodec`/`FrameworkMessages`asmdef `XWorld.Framework.Shared`AOT 常驻、不入 HybridCLR 热更列表)。服务端 2a/2b-1 已用同一套 `Frame`/`Channel`/`FrameworkOpcode` 协议。
- 现有客户端关键 API(勘察确认,全部 `namespace XGame`):
- `XResLoader.coLoadRes(string path, Type type, Action<UnityEngine.Object> action)`(协程异步加载,`path` 形如 `Assets/Game/Art/...` 自动剥前缀);`XResLoader.LoadRes(path, type)`(同步);`XResLoader.coLoadAB(string abpath, Action<AssetBundle>)`
- `lsocket.connect(string addr, int port, Action<XWebSocket,string> cb)``XWebSocket``int send(byte[] data, Action<int,string> cb, ref string err)``byte[] recv(ref string err)`(拉模型,每帧轮询)、`bool IsConected()``void close()`
- `XWCoroutine.Instance.StartCo(IEnumerator)`
- `LoadDll``GetFileMd5Name(filename, md5)`(静态私有,本步骤需在小游戏下载器内复刻同款逻辑)、CDN 约定 `{UpdateServer}{name}_{md5}{ext}``Application.persistentDataPath` 落盘。
- 小游戏根:`GameObject.Find("Main")`
- **依赖铁律**:加载器/宿主依赖 `XWorld.Framework.Shared` 接口;小游戏 `Client.dll` 实现 `IGameClient`,二者都引用同一份(AOT`XWorld.Framework.Shared`,保证反射出的接口类型一致。
- **无 git**:跳过所有 git 步骤。
## 「小游戏 manifest」与发布产物(与 §9 第 4 步发布工具链对齐)
每个小游戏在 CDN 上的目录约定(第 4 步工具产出,本步骤消费):
```
{UpdateServer}minigame/{gameId}/{version}/
game.json # 元数据:gameId/version/clientEntryType/coreDll/clientDll/asset 列表/最小框架版本
{gameId}.Core_{md5}.dll.bytes # 热更 Core DLLmd5 命名,沿用 GetFileMd5Name 风格)
{gameId}.Client_{md5}.dll.bytes# 热更 Client DLL
{abName}_{md5}.unity3d # 资源 AB 若干
files.txt # 本小游戏文件清单:{'name','md5',size} 行(沿用现有 dllfiles 格式)
```
`game.json`(客户端读取字段):
```json
{
"gameId": "rps",
"version": 1,
"clientEntryType": "RPS.Client.RpsGameClient",
"coreDll": "RPS.Core.dll",
"clientDll": "RPS.Client.dll",
"minFrameworkVersion": 1,
"assets": ["assets_game_art_ui_prefab"]
}
```
## 文件结构(全部位于 `Client/Assets/Script/xmain/MiniGame/`namespace `XGame.MiniGame`
| 文件 | 职责 |
|---|---|
| `MiniGameManifest.cs` | `game.json` + `files.txt` 模型 + 解析(纯 C#,唯一可在 .NET 侧抽测的部分) |
| `MiniGameDownloader.cs` | 按 manifest 增量下载+md5 校验小游戏文件到 persistentData(复用 LoadDll 约定) |
| `MiniGameAssemblyLoader.cs` | HybridCLR:必要时补 AOT 元数据 + `Assembly.Load(Core, Client)`,反射入口类型 |
| `UnityAssetLoader.cs` | `IAssetLoader` over `XResLoader` |
| `MiniGameNetChannel.cs` | `XWebSocket` 上用 `FrameCodec` 收发;Game 帧↔`NetMessage`;框架帧(MatchFound/Snapshot/RoomEnd)事件 |
| `GameClientCtx.cs` | `IGameClientCtx` 实现:Assets/Logger/Send/Exit |
| `MiniGameHost.cs` | 编排:进入→下载→加载→反射→ctx→OnEnter;每帧 OnUpdate + 轮询网络;退出→OnExit+卸载 |
> 可抽测部分(`MiniGameManifest` 解析)建议**镜像一份到 `Server/` 下做 xUnit 抽测**(与 Hello manifest 同法),其余 Unity 部分仅 Editor 编译 + Play 核对。
---
## Task 1: 小游戏 manifest 模型与解析(可 .NET 抽测)
**Files:**
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameManifest.cs`
- (可选抽测)Create: `Server/MiniGameManifest.Tests/...`(镜像源码 + xUnit,验证解析;非必须)
> 本类是纯 C#(无 UnityEngine)。建议把它**也**放进一个 .NET 测试工程做往返/校验抽测(与子项目 2a 的 `GameManifest` 同法),因为它是加载链路里唯一不依赖 Unity 的逻辑。Unity 侧只需编译通过。
- [ ] **Step 1: 写 `MiniGameManifest.cs`**
```csharp
using System;
using System.Collections.Generic;
namespace XGame.MiniGame
{
// game.json 客户端视图(字段名大小写不敏感解析)
public sealed class MiniGameManifest
{
public string GameId;
public int Version;
public string ClientEntryType;
public string CoreDll;
public string ClientDll;
public int MinFrameworkVersion;
public List<string> Assets = new List<string>();
// files.txt 解析出的 name->(md5,size),用于增量下载校验
public Dictionary<string, FileEntry> Files = new Dictionary<string, FileEntry>();
public sealed class FileEntry { public string Md5; public int Size; }
// 极简手写 JSON 解析(避免引入 Unity JsonUtility 的字段限制;只认本 schema 的扁平字段 + assets 字符串数组)
// 注意:生产可换成项目已用的 JSON 库;此实现仅解析本 schema,键大小写不敏感。
public static MiniGameManifest ParseGameJson(string json)
{
if (string.IsNullOrEmpty(json)) throw new FormatException("game.json 为空");
var m = new MiniGameManifest
{
GameId = JsonMini.GetString(json, "gameId"),
Version = JsonMini.GetInt(json, "version"),
ClientEntryType = JsonMini.GetString(json, "clientEntryType"),
CoreDll = JsonMini.GetString(json, "coreDll"),
ClientDll = JsonMini.GetString(json, "clientDll"),
MinFrameworkVersion = JsonMini.GetInt(json, "minFrameworkVersion"),
};
m.Assets = JsonMini.GetStringArray(json, "assets");
if (string.IsNullOrEmpty(m.GameId)) throw new FormatException("game.json 缺少 gameId");
if (string.IsNullOrEmpty(m.ClientEntryType)) throw new FormatException("game.json 缺少 clientEntryType");
if (string.IsNullOrEmpty(m.CoreDll) || string.IsNullOrEmpty(m.ClientDll)) throw new FormatException("game.json 缺少 coreDll/clientDll");
if (m.Version <= 0) throw new FormatException("game.json version 必须为正");
return m;
}
// files.txt 行格式(沿用 dllfiles):{'name','md5',size},
public void LoadFilesList(string content)
{
Files.Clear();
if (string.IsNullOrEmpty(content)) return;
foreach (var raw in content.Split('\n'))
{
string line = raw.Trim();
if (line.Length < 5) continue;
// 去掉首 { 与尾 }, ,再去引号
int lb = line.IndexOf('{'); int rb = line.LastIndexOf('}');
if (lb < 0 || rb <= lb) continue;
string inner = line.Substring(lb + 1, rb - lb - 1).Replace("'", "");
string[] p = inner.Split(',');
if (p.Length < 3) continue;
int size; int.TryParse(p[2].Trim(), out size);
Files[p[0].Trim()] = new FileEntry { Md5 = p[1].Trim(), Size = size };
}
}
}
// 极小 JSON 取值器(仅供本 schema;非通用)
internal static class JsonMini
{
public static string GetString(string json, string key)
{
int i = FindKey(json, key); if (i < 0) return null;
int c = json.IndexOf(':', i); if (c < 0) return null;
int q1 = json.IndexOf('"', c + 1); if (q1 < 0) return null;
int q2 = json.IndexOf('"', q1 + 1); if (q2 < 0) return null;
return json.Substring(q1 + 1, q2 - q1 - 1);
}
public static int GetInt(string json, string key)
{
int i = FindKey(json, key); if (i < 0) return 0;
int c = json.IndexOf(':', i); if (c < 0) return 0;
int j = c + 1; while (j < json.Length && (json[j] == ' ' || json[j] == '\t')) j++;
int s = j; while (j < json.Length && (char.IsDigit(json[j]) || json[j] == '-')) j++;
int v; int.TryParse(json.Substring(s, j - s), out v); return v;
}
public static System.Collections.Generic.List<string> GetStringArray(string json, string key)
{
var list = new System.Collections.Generic.List<string>();
int i = FindKey(json, key); if (i < 0) return list;
int lb = json.IndexOf('[', i); int rb = json.IndexOf(']', lb < 0 ? i : lb);
if (lb < 0 || rb <= lb) return list;
string inner = json.Substring(lb + 1, rb - lb - 1);
foreach (var part in inner.Split(','))
{
int q1 = part.IndexOf('"'); int q2 = part.LastIndexOf('"');
if (q1 >= 0 && q2 > q1) list.Add(part.Substring(q1 + 1, q2 - q1 - 1));
}
return list;
}
private static int FindKey(string json, string key)
{
// 大小写不敏感找 "key"
string needle = "\"" + key + "\"";
int idx = json.IndexOf(needle, StringComparison.OrdinalIgnoreCase);
return idx;
}
}
}
```
- [ ] **Step 2: [Unity 人工核对] 编辑器编译**
打开 `Client/` Unity 工程,等待编译,确认 `XGame.MiniGame.MiniGameManifest` 无编译错误。
(可选)把 `MiniGameManifest.cs` 复制进一个 .NET xUnit 工程,断言 `ParseGameJson`(有效/缺字段抛错)与 `LoadFilesList`(解析 `{'a','md5',12},` 行)。
---
## Task 2: 小游戏增量下载器 MiniGameDownloader
**Files:**
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameDownloader.cs`
> 复用 LoadDll 的 md5 命名/落盘约定(设计 C1:叠加小游戏维度,不另起炉灶)。本步骤用 `UnityWebRequest` 下载 `game.json`、`files.txt`、各 DLL/AB 到 `persistentDataPath/minigame/{gameId}/{version}/`,按 `files.txt` 的 md5 跳过已存在文件。
- [ ] **Step 1: 写 `MiniGameDownloader.cs`**
```csharp
using System;
using System.Collections;
using System.IO;
using UnityEngine;
using UnityEngine.Networking;
namespace XGame.MiniGame
{
// 下载并校验某小游戏版本的全部文件;产出本地目录 + 解析好的 manifest
public sealed class MiniGameDownloader
{
public string LocalDir { get; private set; }
public MiniGameManifest Manifest { get; private set; }
public string Error { get; private set; }
private readonly string _cdnBase; // 例如 http://.../minigame/
private readonly string _gameId;
private readonly int _version;
public MiniGameDownloader(string cdnBase, string gameId, int version)
{
_cdnBase = cdnBase.EndsWith("/") ? cdnBase : cdnBase + "/";
_gameId = gameId; _version = version;
LocalDir = $"{Application.persistentDataPath}/minigame/{gameId}/{version}/";
}
private string RemoteBase => $"{_cdnBase}{_gameId}/{_version}/";
public IEnumerator Run(Action<bool> onDone)
{
Error = null;
Directory.CreateDirectory(LocalDir);
// 1) game.json(每次拉取,文件小)
string gameJson = null;
yield return Get($"{RemoteBase}game.json", t => gameJson = t, b => { });
if (gameJson == null) { Fail("下载 game.json 失败"); onDone(false); yield break; }
try { Manifest = MiniGameManifest.ParseGameJson(gameJson); }
catch (Exception e) { Fail("game.json 解析失败: " + e.Message); onDone(false); yield break; }
if (Manifest.GameId != _gameId || Manifest.Version != _version)
{ Fail("game.json 与请求的 gameId/version 不符"); onDone(false); yield break; }
// 2) files.txtmd5 清单)
string filesTxt = null;
yield return Get($"{RemoteBase}files.txt", t => filesTxt = t, b => { });
if (filesTxt == null) { Fail("下载 files.txt 失败"); onDone(false); yield break; }
Manifest.LoadFilesList(filesTxt);
// 3) 按清单逐个增量下载(md5 命名;本地已存在且 md5 匹配则跳过)
foreach (var kv in Manifest.Files)
{
string name = kv.Key; string md5 = kv.Value.Md5;
string localPath = LocalDir + name;
if (File.Exists(localPath) && Md5OfFile(localPath) == md5) continue;
string remote = $"{RemoteBase}{Md5Name(name, md5)}";
byte[] data = null;
yield return Get(remote, t => { }, b => data = b);
if (data == null) { Fail("下载失败: " + remote); onDone(false); yield break; }
if (Md5OfBytes(data) != md5) { Fail("md5 不符: " + name); onDone(false); yield break; }
Directory.CreateDirectory(Path.GetDirectoryName(localPath));
File.WriteAllBytes(localPath, data);
}
onDone(true);
}
private void Fail(string msg) { Error = msg; Debug.LogError("[MiniGameDownloader] " + msg); }
// 与 LoadDll.GetFileMd5Name 同款:name.ext -> name_md5.ext
private static string Md5Name(string filename, string md5)
{
int pos = filename.LastIndexOf('.');
return pos >= 0 ? $"{filename.Substring(0, pos)}_{md5}{filename.Substring(pos)}"
: $"{filename}_{md5}";
}
private static string Md5OfBytes(byte[] data)
{
using (var md5 = System.Security.Cryptography.MD5.Create())
{
var hash = md5.ComputeHash(data);
var sb = new System.Text.StringBuilder(hash.Length * 2);
foreach (var b in hash) sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
private static string Md5OfFile(string path) => Md5OfBytes(File.ReadAllBytes(path));
private IEnumerator Get(string url, Action<string> onText, Action<byte[]> onBytes)
{
using (var uwr = UnityWebRequest.Get(url))
{
yield return uwr.SendWebRequest();
if (uwr.result == UnityWebRequest.Result.Success)
{
onBytes(uwr.downloadHandler.data);
onText(uwr.downloadHandler.text);
}
else
{
Debug.LogError($"[MiniGameDownloader] GET 失败 {url}: {uwr.error}");
onText(null); onBytes(null);
}
}
}
}
}
```
- [ ] **Step 2: [Unity 人工核对] 编辑器编译 + 本地 CDN 联调**
确认编译通过。准备一个本地静态服务器(或 streamingAssets)放置 `minigame/rps/1/` 目录(game.json/files.txt/DLL/AB),用一个临时菜单调用 `new MiniGameDownloader(cdn, "rps", 1).Run(...)`,确认文件落到 `persistentDataPath/minigame/rps/1/` 且 md5 校验通过、二次进入跳过下载。
---
## Task 3: HybridCLR 程序集加载器 MiniGameAssemblyLoader
**Files:**
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameAssemblyLoader.cs`
> 小游戏 Core/Client 是热更 DLL(裁剪后由 HybridCLR 解释执行)。它们引用 AOT 的 `XWorld.Framework.Shared`,元数据已在主包,故**通常无需**再对它们补 AOT 元数据;但若小游戏用到了主包未实例化的 AOT 泛型,需要补对应 AOT 程序集元数据(与 LoadDll 的 `LoadMetadataForAOTAssemblies` 同理)。本步骤先做 `Assembly.Load`AOT 泛型补元数据留作 §10 风险项实测。
- [ ] **Step 1: 写 `MiniGameAssemblyLoader.cs`**
```csharp
using System;
using System.IO;
using System.Reflection;
using UnityEngine;
using XWorld.Framework;
namespace XGame.MiniGame
{
// 加载小游戏 Core/Client 热更 DLL,反射出 IGameClient 入口工厂
public sealed class MiniGameAssemblyLoader
{
public Func<IGameClient> CreateClient { get; private set; }
public string Error { get; private set; }
// localDir: MiniGameDownloader.LocalDirmanifest: 解析好的 game.json
public bool Load(string localDir, MiniGameManifest manifest)
{
try
{
// 文件名约定:<coreDll>.bytes / <clientDll>.bytes(去 .dll 留 .bytes 以走 TextAsset/字节流)
byte[] coreBytes = File.ReadAllBytes(localDir + manifest.CoreDll + ".bytes");
byte[] clientBytes = File.ReadAllBytes(localDir + manifest.ClientDll + ".bytes");
// Core 先于 Client 加载(Client 依赖 Core
Assembly.Load(coreBytes);
Assembly clientAsm = Assembly.Load(clientBytes);
Type entry = clientAsm.GetType(manifest.ClientEntryType);
if (entry == null) { Error = "找不到入口类型 " + manifest.ClientEntryType; return false; }
if (!typeof(IGameClient).IsAssignableFrom(entry))
{ Error = manifest.ClientEntryType + " 未实现 IGameClient"; return false; }
CreateClient = () => (IGameClient)Activator.CreateInstance(entry);
return true;
}
catch (Exception e)
{
Error = "加载小游戏程序集失败: " + e;
Debug.LogError("[MiniGameAssemblyLoader] " + Error);
return false;
}
}
}
}
```
- [ ] **Step 2: [Unity 人工核对] 编译 + 加载真 DLL**
确认编译通过。用第 4 步工具产出的 `rps` Core/Client DLL`.bytes`)放入本地目录,调 `Load(...)`,确认 `CreateClient()` 能反射实例化且 `is IGameClient`(验证 AOT 接口类型跨界一致)。**重点验证设计 §6.6/§10**:HybridCLR 在目标平台(含微信 WASM)下加载这些热更 DLL 是否合规/可行。
---
## Task 4: 资源加载适配 UnityAssetLoader + 客户端 ctx
**Files:**
- Create: `Client/Assets/Script/xmain/MiniGame/UnityAssetLoader.cs`
- Create: `Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs`
- [ ] **Step 1: 写 `UnityAssetLoader.cs`**
```csharp
using System;
using UnityEngine;
using XWorld.Framework;
namespace XGame.MiniGame
{
// IAssetLoader over XResLoader(同步加载已下载/缓存的小游戏 AB 资源)
public sealed class UnityAssetLoader : IAssetLoader
{
// path: 形如 "Assets/Game/Art/UI/Prefab/UI_RPS.prefab"XResLoader 内部剥 "Assets/" 前缀并按目录解析 AB
public object Load(string path)
{
return XResLoader.LoadRes(path, typeof(UnityEngine.Object));
}
}
}
```
- [ ] **Step 2: 写 `GameClientCtx.cs`**
```csharp
using System;
using UnityEngine;
using XWorld.Framework;
// 别名消歧:UnityEngine 也有遗留的 UnityEngine.ILogger,会与框架 ILogger 冲突(编译验证发现)
using IFwkLogger = XWorld.Framework.ILogger;
namespace XGame.MiniGame
{
// 注入给 IGameClient 的上下文:资源/日志/发送/退出
public sealed class GameClientCtx : IGameClientCtx
{
public IAssetLoader Assets { get; }
public IFwkLogger Logger { get; }
private readonly Action<NetMessage> _send;
private readonly Action _exit;
public GameClientCtx(IAssetLoader assets, IFwkLogger logger, Action<NetMessage> send, Action exit)
{
Assets = assets; Logger = logger; _send = send; _exit = exit;
}
public void Send(NetMessage message) => _send(message);
public void Exit() => _exit();
}
// 简单 Unity 日志器(实现框架 ILogger
public sealed class UnityLogger : IFwkLogger
{
private readonly string _prefix;
public UnityLogger(string prefix = "[minigame]") { _prefix = prefix; }
public void Info(string m) => Debug.Log($"{_prefix} {m}");
public void Warn(string m) => Debug.LogWarning($"{_prefix} {m}");
public void Error(string m) => Debug.LogError($"{_prefix} {m}");
}
}
```
- [ ] **Step 3: [Unity 人工核对] 编译**
确认编译通过;`UnityAssetLoader`/`GameClientCtx`/`UnityLogger` 均实现对应框架接口。
---
## Task 5: 网络通道 MiniGameNetChannelFrameCodec over XWebSocket
**Files:**
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameNetChannel.cs`
> 与服务端 2b-1 帧约定对接:`Channel.Game` 帧 ↔ `NetMessage(opcode,payload)``Channel.Framework` 帧承载 MatchFound/Snapshot/RoomEnd/Heartbeat。`XWebSocket` 是**拉模型**(每帧 `recv`),但每条 WS 消息即一帧(与服务端 `Connection` 一致:一条 WS 消息 = 一个 `FrameCodec.Encode` 结果)。⚠️ 注意:现有 `XWebSocket.recv` 返回的是**累积字节流**,需要消息边界。本通道假定底层按 WS 消息边界投递(若 `XWebSocket` 现实现是字节流拼接,需在此加长度前缀分帧——见 Step 2 核对项)。
- [ ] **Step 1: 写 `MiniGameNetChannel.cs`**
```csharp
using System;
using System.Collections.Generic;
using UnityEngine;
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace XGame.MiniGame
{
// 客户端到服务端的帧通道。每帧轮询底层 socket,解码出的帧分发:
// Game 帧 -> OnGameMessage(NetMessage)(交给 IGameClient.OnNetMessage
// Framework 帧 -> OnFrameworkFrame(Frame)(宿主处理 MatchFound/RoomEnd 等)
public sealed class MiniGameNetChannel
{
private readonly XWebSocket _sock;
public Action<NetMessage> OnGameMessage;
public Action<Frame> OnFrameworkFrame;
public MiniGameNetChannel(XWebSocket sock) { _sock = sock; }
public bool IsConnected => _sock != null && _sock.IsConected();
// 业务输入:IGameClient 调 ctx.Send(NetMessage) -> 这里包成 Game 帧发出
public void SendGame(NetMessage msg)
{
byte[] frame = FrameCodec.Encode(new Frame(Channel.Game, msg.Opcode, msg.Payload));
SendRaw(frame);
}
public void SendFramework(FrameworkOpcode op, byte[] payload)
{
byte[] frame = FrameCodec.Encode(new Frame(Channel.Framework, (ushort)op, payload));
SendRaw(frame);
}
private void SendRaw(byte[] frame)
{
string err = null;
_sock.send(frame, null, ref err);
if (!string.IsNullOrEmpty(err)) Debug.LogError("[MiniGameNetChannel] send err: " + err);
}
// 每帧由宿主调用:把底层收到的(按消息边界的)帧解出并分发
public void Pump()
{
if (_sock == null) return;
string err = null;
// 约定:recv 每次返回 0 或 1 条完整 WS 消息的字节(见类头注意事项)
while (_sock.isDataRecv())
{
byte[] data = _sock.recv(ref err);
if (data == null || data.Length == 0) break;
Frame f;
try { f = FrameCodec.Decode(data); }
catch (FormatException) { Debug.LogWarning("[MiniGameNetChannel] 畸形帧,丢弃"); continue; }
if (f.Channel == Channel.Game) OnGameMessage?.Invoke(new NetMessage(f.Opcode, f.Payload));
else OnFrameworkFrame?.Invoke(f);
}
}
}
}
```
- [ ] **Step 2: [Unity 人工核对] 核对 XWebSocket 分帧语义 + 联调**
⚠️ **关键核对**:确认现有 `XWebSocket.recv` 是否按 WS 消息边界返回单条消息。若它返回拼接字节流(勘察显示其内部是 64KB ring buffer),则**必须**在 `Pump` 里改为长度前缀分帧:服务端 `Connection` 发的是「一条 WS 消息 = 一帧」,但若客户端 `recv` 不保边界,则需要服务端与客户端都加 `[u32 len]` 前缀。**这是上线前必须实测拍板的协议细节**(记入 §10 风险)。
联调:连本地 2b-1 服务端,发 MatchRequest,确认能 `Pump` 出 MatchFound + Game 快照帧并正确解码(tick/counter)。
---
## Task 6: 生命周期宿主 MiniGameHost
**Files:**
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs`
> 编排整条链路:进入 → 下载 → 加载 DLL → 反射 → 连接/发 MatchRequest → 收 MatchFound → 建 ctx → `OnEnter` → 每帧 `Pump` + `OnUpdate` → 收 Game 帧 `OnNetMessage` → 收 RoomEnd/退出 → `OnExit` + 卸载 AB + 销毁根节点。挂在 `Main` 上的 `MonoBehaviour`。
- [ ] **Step 1: 写 `MiniGameHost.cs`**
```csharp
using System;
using System.Collections;
using UnityEngine;
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace XGame.MiniGame
{
// 一次小游戏会话的宿主。EnterGame 启动整条链路;每帧驱动;ExitGame 收尾。
public sealed class MiniGameHost : MonoBehaviour
{
private IGameClient _client;
private MiniGameNetChannel _net;
private GameClientCtx _ctx;
private bool _running;
private string _gameId;
private int _version;
private string _roomId;
// 入口:cdnBase 小游戏 CDN 根;sock 已连接的 XWebSocket(来自大厅/或本宿主新建)
public void EnterGame(string cdnBase, string gameId, int version, XWebSocket sock)
{
_gameId = gameId; _version = version;
XWCoroutine.StartCo(CoEnter(cdnBase, gameId, version, sock)); // StartCo 是静态方法(编译验证发现,非 .Instance)
}
private IEnumerator CoEnter(string cdnBase, string gameId, int version, XWebSocket sock)
{
// 1) 下载 + 校验
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; }
// 2) 加载 Core/Client DLL,反射入口
var asmLoader = new MiniGameAssemblyLoader();
if (!asmLoader.Load(dl.LocalDir, dl.Manifest))
{ Debug.LogError("[MiniGameHost] 加载程序集失败: " + asmLoader.Error); yield break; }
// 3) 网络通道
_net = new MiniGameNetChannel(sock);
_net.OnGameMessage = msg => { if (_client != null) SafeCall(() => _client.OnNetMessage(msg), "OnNetMessage"); };
_net.OnFrameworkFrame = OnFrameworkFrame;
// 4) 反射实例化 IGameClient + 注入 ctx
_client = asmLoader.CreateClient();
_ctx = new GameClientCtx(
new UnityAssetLoader(),
new UnityLogger($"[{gameId}]"),
msg => _net.SendGame(msg),
() => ExitGame());
// 5) 请求进入房间(本步骤无匹配:直接发 MatchRequest 让 2b-1 单人建房;多人匹配在 §9 第 5 步)
_net.SendFramework(FrameworkOpcode.MatchRequest,
new MatchRequestMsg { GameId = gameId, Version = version }.Encode());
// 6) OnEnter 接管
SafeCall(() => _client.OnEnter(_ctx), "OnEnter");
_running = true;
Debug.Log("[MiniGameHost] 小游戏已进入: " + gameId);
}
private void OnFrameworkFrame(Frame f)
{
switch ((FrameworkOpcode)f.Opcode)
{
case FrameworkOpcode.MatchFound:
_roomId = MatchFoundMsg.Decode(f.Payload).RoomId;
Debug.Log("[MiniGameHost] 房间就绪: " + _roomId);
break;
case FrameworkOpcode.RoomEnd:
Debug.Log("[MiniGameHost] 房间结束,退出小游戏");
ExitGame();
break;
// Snapshot/Heartbeat 等:本步骤交给小游戏自行通过 Game 帧处理;如需框架级快照可在此扩展
}
}
private void Update()
{
if (!_running) return;
_net?.Pump();
if (_client != null) SafeCall(() => _client.OnUpdate(Time.deltaTime), "OnUpdate");
}
public void ExitGame()
{
if (!_running && _client == null) return;
_running = false;
if (_client != null) { SafeCall(() => _client.OnExit(), "OnExit"); _client = null; }
_net = null;
// 卸载本小游戏 ABXResLoader 按 xid/AB 名管理;此处按需卸载)—— 见 Step 2 核对项
Debug.Log("[MiniGameHost] 已退出小游戏: " + _gameId);
// 返回大厅:交回 Lua game_module_runner 或大厅 UI(按现有大厅返回流程)
}
private static void SafeCall(Action a, string where)
{
try { a(); }
catch (Exception e) { Debug.LogError($"[MiniGameHost] 小游戏 {where} 抛异常: {e}"); }
}
}
}
```
- [ ] **Step 2: [Unity 人工核对] 端到端 Play 模式**
`MiniGameHost` 加到 `Main`,提供本地 2b-1 服务端 + 本地 CDN(放 rps 产物),调 `EnterGame(cdn, "rps", 1, sock)`
- 确认链路:下载→加载 DLL→反射→连接→MatchRequest→MatchFound→OnEnter→每帧 Pump/OnUpdate→收 Game 快照→OnNetMessage→RoomEnd→OnExit。
- AB 卸载/根节点销毁/内存回收按现有 `XResLoader` 卸载机制核对(与 §10「客户端内存可控」一致)。
- 退出后能正常返回大厅。
---
## 完成判据(Definition of Done)—— 均为 Unity 人工核对
- [ ] `Client/` 在 Unity Editor 编译 0 错误;`XGame.MiniGame` 全部类型引用 `XWorld.Framework.Shared`AOT)正常。
- [ ] 小游戏增量下载 + md5 校验通过;二次进入跳过已存在文件。
- [ ] HybridCLR 加载小游戏 Core/Client 热更 DLL 成功,反射 `IGameClient` 入口并 `is IGameClient`(跨界类型一致)。
- [ ] `MiniGameNetChannel` 与 2b-1 服务端帧约定互通(Game/Framework 双通道);分帧语义已核对拍板。
- [ ] 端到端:进入→下载→加载→运行→收快照→结束→退出→返回大厅闭环跑通;AB/内存可控。
- [ ] 共享层(`XWorld.Framework.Shared`)零改动;不引入对具体小游戏的静态依赖。
## 风险/待实测(记入设计 §10)
- **微信小游戏 WASM 下 HybridCLR 加载小游戏热更 DLL 的合规性与可行性**(设计 §6.6)——上线前必须实测;不行则降级为主包内置 + 仅资源/增量热更。
- **`XWebSocket.recv` 的消息边界**:若为字节流拼接,需与服务端统一加 `[u32 len]` 分帧前缀(Task 5 核对项)。
- **AOT 泛型补元数据**:小游戏若用到主包未实例化的 AOT 泛型,需补对应 AOT 程序集元数据(Task 3)。
- **AB 卸载边界**:小游戏退出时 AB/资源/根节点的卸载与内存回收需实测无泄漏。
## 实施验证与修正(2026-06-20
7 个加载器文件已落盘到 `Client/Assets/Script/xmain/MiniGame/`,并用 **csc 针对真实 Unity 2022.3.62f2 + `XWorld.Link.dll` + `XWorld.Framework.Shared.dll` 编译验证通过(0 错误)**——把「编译/类型」从人工核对升级为**实际验证**(运行时行为仍需 Unity Play 模式)。`XWorld.Link.asmdef` 已加 `XWorld.Framework.Shared` 引用。
**编译验证发现并修正的真实 API 不符(人工核对会遗漏):**
1. `XWCoroutine.StartCo(...)` 是**静态**方法(非 `.Instance.StartCo`)。
2. `ILogger``UnityEngine.ILogger`(遗留)歧义 → `GameClientCtx.cs``using IFwkLogger = XWorld.Framework.ILogger;` 别名。
3. 编译需引用 netstandard2.1 + mscorlib 兼容 shim(编译环境细节)。
**逻辑审查修正(保持编译干净,已复验):**
4. `JsonMini.GetStringArray`:键存在但无 `[` 时直接返回空(防误读其他 `]`)。
5. `JsonMini.FindKey`:要求匹配的 `"key"` 后跟 `:` 才算键(防值内嵌同名文本误匹配)。
6. `MiniGameNetChannel.Pump``recv` 返回 err 时 `break`(不再继续消耗)。
7. `MiniGameHost`send lambda 加 `_net` null 守卫 + `ExitGame` 末尾 `_ctx=null`(防退出后晚到 Send 的 NRE)。
8. `MiniGameDownloader``Get` 拆为 `GetText`/`GetBytes`(二进制 DLL/AB 不做无谓 UTF-8 解码)。
9. `MiniGameAssemblyLoader`:缺 `.bytes` 文件时给明确路径+约定的错误信息。
10. `MiniGameHost.OnEnter` 处加注释:OnEnter 早于 MatchFound 到达、房间可能尚未就绪。
**加载前验签(设计 §7 客户端侧,2026-06-20 追加,已 csc 编译验证 + 跨组件实测):**
- 新增 `ClientManifestVerifier.cs`(第 8 个文件):内置 RSA 公钥(modulus+exponent base64 常量,**发布前须填**),用 `RSAParameters`+`ImportParameters`+`VerifyData(SHA256,Pkcs1)`——netstandard2.0 即有、Unity mono 最兼容(避开 net5+ 的 `ImportFromPem``ImportRSAPublicKey`);公钥未配置时跳过(开发模式)。
- `MiniGameManifest``Digest()`(按 name Ordinal 排序拼 `name=md5;`),与发布工具 `FilesManifest.Digest()` 完全一致。
- `MiniGameDownloader` 下载 `files.txt.sig`,在下载/加载任何 DLL **之前**验签(失败即中止);随后每文件仍做 md5 校验。
- **跨组件实证**:把纯 C# 的 `MiniGameManifest.cs` 链入 `Server/PublishTool.Tests`,断言客户端 `Digest()` == 服务端 `FilesManifest.Digest()`(逐字节、顺序无关,2 测试绿)——证明验签端到端成立。
- 8 个加载器文件 csc 编译 0 错误(含 `System.Security.Cryptography`)。**Unity mono 上 RSA 运行时行为仍须 Play 核对。**
**仍须 Unity Play 模式人工核对(无法在此验证):** ① 客户端验签的 Unity mono 运行时 RSAImportParameters/VerifyData);② `XWebSocket.recv` 是否保证每次返回单条完整 WS 消息(否则需 `[u32 len]` 分帧);③ HybridCLR/IL2CPP(含 iOS、微信 WASM)下 `Assembly.Load(coreBytes)` 与 AOT 泛型补元数据;④ `XResLoader.LoadRes` 在 AB 未挂载时的行为;⑤ AB 卸载/内存回收;⑥ **微信 WASM 热更代码合规**(设计 §6.6 最大上线风险)。
## 不在本步骤范围
- 服务端匹配/AI 兜底/多人(§9 第 5 步)。
- 大厅小游戏列表 UI 与「点击进入某小游戏」的入口接线(接现有 `GameLobbyController`,本步骤只提供 `MiniGameHost.EnterGame`)。
- 发布工具链产出小游戏 manifest/DLL/AB(§9 第 4 步,本步骤消费其约定)。
- 用 C# 重写 RPS 的具体玩法(§9 第 5 步的 `RPS.Client`)。