712 lines
36 KiB
Markdown
712 lines
36 KiB
Markdown
# 客户端小游戏加载器 + 生命周期框架(§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 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<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.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`**
|
||
|
||
```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.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`**
|
||
|
||
```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: 网络通道 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<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;
|
||
// 卸载本小游戏 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`)。
|