# 客户端小游戏加载器 + 生命周期框架(§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 action)`(协程异步加载,`path` 形如 `Assets/Game/Art/...` 自动剥前缀);`XResLoader.LoadRes(path, type)`(同步);`XResLoader.coLoadAB(string abpath, Action)`。 - `lsocket.connect(string addr, int port, Action cb)` → `XWebSocket`:`int send(byte[] data, Action 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 DLL(md5 命名,沿用 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 Assets = new List(); // files.txt 解析出的 name->(md5,size),用于增量下载校验 public Dictionary Files = new Dictionary(); 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 GetStringArray(string json, string key) { var list = new System.Collections.Generic.List(); 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 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.txt(md5 清单) 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 onText, Action 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 CreateClient { get; private set; } public string Error { get; private set; } // localDir: MiniGameDownloader.LocalDir;manifest: 解析好的 game.json public bool Load(string localDir, MiniGameManifest manifest) { try { // 文件名约定:.bytes / .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 _send; private readonly Action _exit; public GameClientCtx(IAssetLoader assets, IFwkLogger logger, Action 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: 网络通道 MiniGameNetChannel(FrameCodec 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 OnGameMessage; public Action 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; // 卸载本小游戏 AB(XResLoader 按 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 运行时 RSA(ImportParameters/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`)。