# 小游戏打包流程:Unity 菜单「XWorld/生成小游戏更新」设计 日期:2026-07-07 状态:已与用户逐段确认(§1-§4) ## 0. 目标与范围 一条菜单驱动的发布流水线,对选定的小游戏: - 客户端:打**代码热更 AB**(DLL 以 .bytes/TextAsset 进 AB)与**资源热更 AB**,落到 `CDN/minigame////`,供多端下载更新(本地 `Cdn.Runner` 或外网 CDN 同一布局)。 - 服务器:`Server/games-src/<对应目录>` 编译出普通 DLL,落到 `deploy/minigame///`,整目录上传外网服务器即可作为网关 `--gamesRoot` 的 games 根热更运行。 - `Client/Assets/MiniGames` 下有多个小游戏目录时,先弹窗选择其一(单个也弹窗,用于确认版本/平台)。 本设计同时落地 2026-06-22 的「统一 AB」决策(设计 §10.1):客户端发布产物全部为 `.unity3d`, 弃用裸 `.bytes` 直下双管线,并同步改造客户端运行时消费端。 已确认的决策: - 代码热更形态:**落地统一 AB**(DLL .bytes 进代码 AB,运行时同步改造)。 - 平台维度:**加 `/` 子目录,一次菜单可勾选多平台构建**。 - 版本号:**自动递增(扫 CDN 已有版本取 max+1)+ 弹窗可改**,两端共用同一 version。 - 元数据:**每游一份 `publish.json`**(解决 RockPaperScissors ↔ RPS ↔ rps 名称映射)。 - 签名:**可选**——约定私钥路径存在则签,否则空 sig + 警告。 - 实现架构:**方案 B(Editor 编排 + 复用 PublishTool,新增瘦 CLI 壳)**。 ## 1. 产物布局与格式 ### 1.1 publish.json(每个小游戏一份,驱动全流程) `Client/Assets/MiniGames//publish.json`: ```json { "gameId": "rps", "serverSrcDir": "RPS", "serverProject": "RPS.Server/RPS.Server.csproj", "serverAssembly": "RPS.Server.dll", "serverEntryType": "RPS.Server.RpsServerRoom", "clientProject": "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj", "coreDll": "RPS.Core.dll", "clientDll": "RPS.Client.dll", "clientEntryType": "RPS.Client.RpsGameClient", "minFrameworkVersion": 1, "playerCount": 2, "tickRateHz": 10 } ``` - `serverSrcDir` 相对 `Server/games-src/`;`serverProject` 相对 `serverSrcDir`; `clientProject` 相对仓库根。 - 菜单只把「含 publish.json 的目录」视为可发布小游戏;新游戏拷模板改字段即接入。 ### 1.2 客户端产物(每平台一份):`CDN/minigame////` ``` game.json gameId/version/clientEntryType/coreDll/clientDll/minFrameworkVersion + 新字段 "codeAb": "code.unity3d" + "assets": ["res.unity3d"] files.txt {'code.unity3d','',size}, {'res.unity3d','',size}, files.txt.sig 有私钥则 RSA 签名,否则空文件(沿用两端"未配置则跳过"语义) code_.unity3d 代码 AB:.bytes + .bytes 两个 TextAsset res_.unity3d 资源 AB:整个 res/ 目录打一个包(assets 数组支持以后拆多包) ``` - 平台名沿用底包惯例:`pc / android / ios / webgl`(与 `LoadDll.sPlatform`、 `CDN/XWorld//` 一致)。 - 线上文件 md5 命名(`name_.ext`),files.txt 登记逻辑名——与现有 `MiniGameDownloader.Md5Name` 推导一致。 - 边界:`res/` 不存在或为空时跳过资源 AB,`assets` 为空数组(仅发代码 AB)。 - **不做 AES 加密**(minigame 链路现状如此,与 XWorld 底包 AB 加密体系无关)。 ### 1.3 服务器产物:`deploy/minigame///` ``` / 普通 DLL(不含 Framework.Shared.dll,Private=false 保证) game.json server 视图(serverAssembly/serverEntryType/playerCount/tickRateHz/minFrameworkVersion) files.txt + files.txt.sig 供 GameModuleLoader 配公钥后加载前验签 ``` 结构与网关 `--gamesRoot` 消费的 `//` 完全一致。 ### 1.4 配套修正:Core 单源 现状问题:`RPS.Client.csproj` 以 `` 把 Core+Client 全编进 RPS.Client.dll(内嵌 Core 类型副本,下载的 RPS.Core.dll 实为冗余);而服务器 `RPS.Core.csproj` 编译的是 `Server/games-src/RPS/RPS.Core/` 下另一份已发生格式漂移的拷贝。 两端 Core 一旦真漂移即确定性不同步。 修正: - `Server/games-src/RPS/RPS.Core/` 删除重复 .cs,csproj 改 `` 链接 `Client/Assets/MiniGames/RockPaperScissors/scripts~/Core/**` (恢复「Core 唯一源码位在客户端 scripts~/Core」的原设计)。 - `RPS.Client.csproj` 改为只编 `scripts~/Client/**` + `ProjectReference` RPS.Core (Private=false),Client.dll 不再内嵌 Core 副本;发布用的客户端 Core.dll 取自 RPS.Core 构建产物(与服务器同一 IL)。 ## 2. 构建编排(Editor 菜单 + PublishTool.Cli) ### 2.1 Editor 侧 重写 `Client/Assets/Editor/MiniGamePublishMenu.cs`(替换 stub 菜单「XWorld/生成小游戏」), 新增 `MiniGamePublishWindow`(EditorWindow): - **游戏选择**:扫 `Assets/MiniGames/*/publish.json`;多个则列表选择,仅一个预选。 - **版本号**:默认 = 扫 `CDN/minigame//` 下数字目录取 max+1(无则 1),可手改; 覆盖已有版本前弹确认。 - **平台勾选**:PC/Android/iOS/WebGL,默认勾选当前激活 BuildTarget 对应项。 - **生成按钮**:执行 §2.2 流水线,每步失败 `DisplayDialog` 报错中断。 ### 2.2 流水线 ``` 1. dotnet build Server/games-src// -c Release → + 2. dotnet build -c Release → (ProjectReference 引用步骤 1 的 Core 工程产物) 3. 拷 Core.dll/Client.dll → Assets/MiniGameStaging/code/*.dll.bytes; AssetDatabase.Refresh 导入为 TextAsset(staging 必须在 Assets 下且**不能带 `~` 后缀**—— `~` 目录 Unity 不导入、资产进不了 AB;用完即删含 .meta) 4. 对每个勾选平台: BuildPipeline.BuildAssetBundles(平台临时目录, [ code.unity3d ← 两个 .bytes 资产, res.unity3d ← AssetDatabase 枚举 Assets/MiniGames//res/ 下全部资产 ], ChunkBasedCompression, 对应 BuildTarget) ← 不切激活平台,直接传 target 5. 写 resolved-spec.json(publish.json 字段 + version + AB 文件名列表)到临时目录 6. Process 调 PublishTool.Cli(惯例同 XWorldUtil 起 dotnet 的方式): server:调一次 → 临时 out → 成功后原子 Directory.Move 入 deploy/minigame/// client:每平台一次 → 临时 out → 成功后移入 CDN/minigame//// 7. 清理 staging;汇总日志 + 完成对话框(列出全部落位路径、是否已签名) ``` 先落临时目录、成功后整体 Move 的原因:`CDN/` 可能正被 `Cdn.Runner` 对外提供下载, 避免半成品被客户端拉走。 ### 2.3 PublishTool 侧(.NET,本环境可 dotnet test 回归) - **`PublishLayout` 改造**:拆 `PublishServer(srcDir, outDir, spec, key?)` 与 `PublishClient(srcDir, outDir, spec, key?)`;客户端侧删除 `CopyBytes` 双 .bytes 逻辑, 改为登记 `codeAb + assets` 各 AB(md5 命名落盘 + files.txt 登记逻辑名)。 - **`GameSpec` / `GameJsonWriter`**:加 `CodeAb` 字段;`WriteClient` 输出 `codeAb`。 - **新增 `Server/PublishTool.Cli`**(瘦控制台,入 Server.sln): `--mode client|server --src --out --spec [--key ]` - **签名**:约定私钥 `Tools/keys/minigame-private.pem`(gitignore);Editor 检测到则传 `--key`,否则 CLI 写空 sig 并在输出中警告「未签名」。 - **`PublishTool.Tests` 同步更新**:新布局断言(codeAb 进清单、无 .bytes 散文件、 双端目录形状、无 key 时空 sig)。 ## 3. 客户端运行时改造(XWorld.Link) - **`MiniGameManifest`**:解析新字段 `codeAb`(校验非空);`assets` 语义变为 「资源 AB 文件名列表」。 - **`MiniGameDownloader`**:`RemoteBase` 加平台段 → `///`(平台名以编译宏判定,热更层内新增小工具函数, 不引用底包 LoadDll)。下载逻辑零改动(按 files.txt 通用增量下载)。 `LocalDir` 不加平台段(设备平台固定)。 - **`MiniGameAssemblyLoader`**:从 `LocalDir + codeAb`(本地为明文件名) `AssetBundle.LoadFromFile` → `LoadAsset(".bytes"/".bytes")` → `Assembly.Load`(Core 先于 Client)→ `AB.Unload(false)`。 找不到 TextAsset 时报清晰错误(列出 AB 内实际资产名)。 - **资源 AB 加载**:新增 `MiniGameAssetLoader : IAssetLoader`——按需 `AssetBundle.LoadFromFile(LocalDir + 资源AB)`,缓存已开包,按资产路径 `LoadAsset`, 小游戏退出统一 `Unload(true)`。`MiniGameHost` 注入 `GameClientCtx` 的 loader 由 `UnityAssetLoader`(走 XResLoader 大厅管线,不认识 minigame AB)换成它,并在 `ExitGame` 补上此前 TODO 的 AB 卸载(MiniGameHost.cs:111)。 ## 4. 错误处理、测试与配套 ### 4.1 错误处理 - Editor 流水线每步检查退出码/产物存在性,失败即 `DisplayDialog` + 保留日志, 临时目录不移入 CDN/deploy(无半成品)。 - 覆盖已有版本目录:确认弹窗后先删旧目录再 Move。 - 运行时:沿用 Fail/Error 路径;新增错误信息注明「需要 codeAb 格式,旧 .bytes 包不再支持」。 ### 4.2 测试与验证 - **本环境可实证**:PublishTool 新布局 + Cli 的 xUnit(`dotnet test Server/Server.sln` 全量回归);两端 csproj 改造后 `dotnet build` 0 错;客户端改动照惯例 csc 编译验证。 - **须 Unity 人工核对**(本环境无 Unity):EditorWindow 交互、BuildAssetBundles 跨平台产出、Editor Play 端到端(菜单生成 → Cdn.Runner 供 CDN → 网关 `--gamesRoot deploy/minigame` → 进 RPS 对局)。 ### 4.3 配套调整 - **删除 `Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1`**:其裸 .bytes 产物格式 运行时不再支持,且 ps1 打不了 AB;README 改指菜单。 - **本地开发网关改指 `deploy/minigame`**:`XWorldUtil.MINI_GAME_SERVER_GAMES_ROOT` 由 `../../Build/PcMiniGameSmoke/server/games` 改为指向仓库 `deploy/minigame`—— 菜单产物直接被 Play 自动起的本地网关消费,本地/外网同一份服务器产物布局; 目录缺失提示改为「先跑 XWorld/生成小游戏更新」。 - `.gitignore`:`Assets/MiniGameStaging/`、`Tools/keys/`(CDN/deploy 产物默认不 ignore; 当前 `.git` 不可用、仓库无 .gitignore,此条待启用 git 后生效)。 - WebGL 注意:`AssetBundle.LoadFromFile` 在 WebGL 不可用,运行时按平台走 `LoadFromMemory(File.ReadAllBytes(...))` 回退(微信 WASM 实测仍是已知开放风险)。 ### 4.3.1 修订(2026-07-07,用户定):本地网关无条件启动,发布即生效 原 §4.3 给本地网关加的「`deploy/minigame` 下须有 game.json 才启动」检测与热更架构矛盾—— 服务器本就支持运行中热更(`ServerLoop.BuildGameList` 每次请求重扫目录; `GameModuleRegistry.Acquire` 建房时按需加载,新版本自动淘汰旧版本),不应做启动期存在性检测。 - `XWorldUtil` 删除 `HasMiniGameServerPackage()` 及其两处调用:`CanOpenLocalServer` 只看网关 Runner DLL 存在;`StartMiniGameGatewayRunnerProcess` 删除 「PC minigame package not found」弹框分支。 - 启动前 `Directory.CreateDirectory(GetMiniGameServerGamesRoot())`(幂等)保证 `--gamesRoot` 有效;目录下暂无任何 `*/*/game.json` 时仅 `Debug.Log` 提示 「可随时用菜单发布,运行中即生效」,不打断。 - ~~已知边界:同版本覆盖重发会因 `LoadFromAssemblyPath` 文件锁失败~~(已被 §4.3.2 消除)。 - 大厅列表为实时扫描结果,客户端重新拉列表即见新游戏,不做推送(YAGNI)。 ### 4.3.2 修订(2026-07-07,用户定方案 B):字节加载去锁 + 内容指纹自动失效 消除"同版本覆盖重发被文件锁挡住"的问题,使覆盖重发也**发布即生效**(新房间用新代码, 已入房玩家打完旧代码): - **GameModuleAlc 字节加载**:`LoadEntry` 与私有依赖解析两处 `LoadFromAssemblyPath` 改为 `File.ReadAllBytes` + `LoadFromStream`——加载后不持有任何文件句柄,覆盖/删除 目录永远成功;`Unload()` 仍惰性异步(无锁故无需强制 GC)。已知约束:字节加载的 Assembly `Location` 为空,小游戏 DLL 不得依赖它。验签照旧不受影响。 - **GameModuleRegistry 内容指纹失效**:`ComputeFingerprint(moduleDir)` = 目录下全部 `*.dll`+`game.json` 按名 Ordinal 排序拼 `name=md5;` 再整体 md5。结构改为 `_current`(key=`gameId@version`,Entry 加 Fingerprint)+ `_retired` 列表。 `Acquire` 命中时重算磁盘指纹:相同 → 计数++返回;目录读不到(覆盖交换瞬间窗口)→ 视为未变化用缓存;不同 → 先加载新模块(失败旧条目原样保留),成功后旧条目标 Superseded(引用 0 即卸载,否则挪 `_retired`),新条目入 `_current`。 `Release` 改按模块实例识别(先 `_current` 同实例,否则 `_retired` ReferenceEquals)。 `RefCount/IsLoaded` 语义指 `_current`。生产环境版本不可变、指纹恒命中,行为零变化。 - 测试(TDD):字节加载后磁盘 DLL 可删/可覆盖;同 key 内容变化 → 新实例 + 旧条目归零 后弱引用 GC 断言卸载;新旧并存期各自 Release 正确;指纹未变命中缓存;现有 Registry/AlcUnload/RoomHost 测试零回归。 - 配套:PcMiniGameSmoke README 的文件锁注意事项改为"覆盖重发即生效"语义说明。 ### 4.4 兼容性说明 - 旧格式(裸 .bytes、无平台段)的既有 CDN 产物在运行时改造后不再可用;当前仅 RPS 一款、 由本流水线重新生成即可,无迁移负担。 - 服务器端 game.json/DLL 布局不变,网关/GameModuleLoader 零改动。