36 KiB
客户端小游戏加载器 + 生命周期框架(§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,asmdefXWorld.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 DLL(md5 命名,沿用 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.json、files.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.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<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.LocalDir;manifest: 解析好的 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: 网络通道 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
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;
// 卸载本小游戏 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 不符(人工核对会遗漏):
XWCoroutine.StartCo(...)是静态方法(非.Instance.StartCo)。ILogger与UnityEngine.ILogger(遗留)歧义 →GameClientCtx.cs用using IFwkLogger = XWorld.Framework.ILogger;别名。- 编译需引用 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)。