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

36 KiB
Raw Permalink Blame History

客户端小游戏加载器 + 生命周期框架(§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/FrameworkMessagesXWebSocket 上收发帧,取代 Lua game_module_runner 的 C# 小游戏路径。

Architecture: 复用并扩展现有 LoadDll 的 md5 清单/增量下载(设计 C1)叠加「小游戏 manifest」维度;复用 XResLoader 加载 AB、XWebSocket(经 lsocket)做传输、XWCoroutine 跑协程。加载器与生命周期宿主放在 XWorld.Link(热更),引用常驻的 XWorld.Framework.SharedIGameClient/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.SharedAOT)· XResLoaderAB)· lsocket/XWebSocketWS)· XWCoroutine(协程)· FrameCodec/FrameworkMessages(协议,子项目 1)。


背景与约定(务必遵守)

  • 上游设计:docs/superpowers/specs/2026-06-18-csharp-hotfix-minigame-architecture-design.md §3.2(客户端进入→下载→加载→运行)、§5(IGameClient/IGameClientCtx/IAssetLoader 契约)。
  • 已完成:子项目 1Client/Assets/Framework/Shared/,含 IGameClient/IGameClientCtx/IAssetLoader/NetMessage/Protocol.FrameCodec/FrameworkMessagesasmdef XWorld.Framework.SharedAOT 常驻、不入 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)XWebSocketint send(byte[] data, Action<int,string> cb, ref string err)byte[] recv(ref string err)(拉模型,每帧轮询)、bool IsConected()void close()
    • XWCoroutine.Instance.StartCo(IEnumerator)
    • LoadDllGetFileMd5Name(filename, md5)(静态私有,本步骤需在小游戏下载器内复刻同款逻辑)、CDN 约定 {UpdateServer}{name}_{md5}{ext}Application.persistentDataPath 落盘。
    • 小游戏根:GameObject.Find("Main")
  • 依赖铁律:加载器/宿主依赖 XWorld.Framework.Shared 接口;小游戏 Client.dll 实现 IGameClient,二者都引用同一份(AOTXWorld.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(客户端读取字段):

{
  "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
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.jsonfiles.txt、各 DLL/AB 到 persistentDataPath/minigame/{gameId}/{version}/,按 files.txt 的 md5 跳过已存在文件。

  • Step 1: 写 MiniGameDownloader.cs
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
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

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
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
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
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.SharedAOT)正常。
  • 小游戏增量下载 + 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. ILoggerUnityEngine.ILogger(遗留)歧义 → GameClientCtx.csusing IFwkLogger = XWorld.Framework.ILogger; 别名。
  3. 编译需引用 netstandard2.1 + mscorlib 兼容 shim(编译环境细节)。

逻辑审查修正(保持编译干净,已复验): 4. JsonMini.GetStringArray:键存在但无 [ 时直接返回空(防误读其他 ])。 5. JsonMini.FindKey:要求匹配的 "key" 后跟 : 才算键(防值内嵌同名文本误匹配)。 6. MiniGameNetChannel.Pumprecv 返回 err 时 break(不再继续消耗)。 7. MiniGameHostsend lambda 加 _net null 守卫 + ExitGame 末尾 _ctx=null(防退出后晚到 Send 的 NRE)。 8. MiniGameDownloaderGet 拆为 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+ 的 ImportFromPemImportRSAPublicKey);公钥未配置时跳过(开发模式)。
  • MiniGameManifestDigest()(按 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)。