1524 lines
68 KiB
Markdown
1524 lines
68 KiB
Markdown
# 小游戏打包流程(XWorld/生成小游戏更新)实现计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Unity 菜单「XWorld/生成小游戏更新」:选一个小游戏 → 打客户端代码 AB + 资源 AB 到 `CDN/minigame/<id>/<ver>/<platform>/`,编服务器 DLL 到 `deploy/minigame/<id>/<ver>/`。
|
||
|
||
**Architecture:** 方案 B——Editor 只做 Unity 能做的(弹窗、dotnet build、BuildAssetBundles、staging),清单/md5/签名/落位由改造后的 `Server/PublishTool` 承担,Editor 经新增瘦壳 `PublishTool.Cli` 进程调用。同步落地「统一 AB」:客户端运行时(Manifest/Downloader/AssemblyLoader/Host)改为从代码 AB 取 DLL、新增资源 AB 加载器。
|
||
|
||
**Tech Stack:** Unity 2022.3 Editor API(EditorWindow/BuildPipeline)、.NET 10(PublishTool + xUnit)、netstandard2.1(小游戏 DLL)。
|
||
|
||
**规格:** `docs/superpowers/specs/2026-07-07-minigame-publish-pipeline-design.md`
|
||
|
||
**重要约定(本仓库特有):**
|
||
- **无 git**(`.git` 是空目录,用户明确不用 git)。所有「commit」步骤替换为「验证 checkpoint」。
|
||
- 本环境**无 Unity**:Editor/运行时 C# 代码无法在此编译运行。.NET 侧一律 `dotnet test`/`dotnet build` 实证;Unity 侧用 Task 8 的 Verify csproj 做 csc 级编译验证 + 文末人工核对清单。
|
||
- 仓库根记为 `<repo>` = `D:\UD\AI\AIC#Project`。所有 dotnet 命令在 `<repo>` 下执行。
|
||
- 现有基线:`dotnet test Server/Server.sln` = 171 通过 / 0 失败。每个 .NET 任务结束必须回到全绿。
|
||
|
||
---
|
||
|
||
## 文件结构总览
|
||
|
||
| 动作 | 路径 | 职责 |
|
||
|---|---|---|
|
||
| 改 | `Server/PublishTool/GameJsonWriter.cs` | GameSpec 加 `CodeAb`;WriteClient 输出 `codeAb` |
|
||
| 建 | `Server/PublishTool/GameSpecJson.cs` | 解析 resolved-spec.json → GameSpec |
|
||
| 改 | `Server/PublishTool/PublishLayout.cs` | 拆 PublishServer/PublishClient;统一 AB 布局;key 可空 |
|
||
| 建 | `Server/PublishTool/CliOptions.cs` | CLI 参数解析(可测) |
|
||
| 建 | `Server/PublishTool.Cli/PublishTool.Cli.csproj` + `Program.cs` | 瘦壳:parse → PublishLayout |
|
||
| 改 | `Server/Server.sln` | 挂 PublishTool.Cli |
|
||
| 改 | `Server/PublishTool.Tests/GameJsonWriterTests.cs`、`PublishLayoutTests.cs`;建 `GameSpecJsonTests.cs`、`CliOptionsTests.cs` | 测试 |
|
||
| 改 | `Server/games-src/RPS/RPS.Core/RPS.Core.csproj`(删同目录 RpsLogic.cs/RpsTypes.cs) | Core 单源:链接 scripts~/Core |
|
||
| 改 | `Client/HotUpdateGames/RPS.Client/RPS.Client.csproj` | 只编 scripts~/Client + 引用 RPS.Core |
|
||
| 建 | `Client/Assets/MiniGames/RockPaperScissors/publish.json` | RPS 发布元数据 |
|
||
| 建 | `Client/Assets/Script/xmain/MiniGame/MiniGamePlatform.cs` | 平台名 + 平台相关 AB 打开(WebGL 走 LoadFromMemory) |
|
||
| 改 | `Client/Assets/Script/xmain/MiniGame/MiniGameManifest.cs` | 解析/校验 `codeAb` |
|
||
| 改 | `Client/Assets/Script/xmain/MiniGame/MiniGameDownloader.cs` | RemoteBase 加平台段 |
|
||
| 改 | `Client/Assets/Script/xmain/MiniGame/MiniGameAssemblyLoader.cs` | 从代码 AB 取 DLL 字节 |
|
||
| 建 | `Client/Assets/Script/xmain/MiniGame/MiniGameAssetLoader.cs` | 资源 AB 的 IAssetLoader |
|
||
| 改 | `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs` | 注入新 loader + ExitGame 卸 AB |
|
||
| 建 | `Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj` | 运行时改动的 dotnet 编译验证壳 |
|
||
| 改 | `Client/Assets/Editor/MiniGamePublishMenu.cs` | 菜单 + EditorWindow |
|
||
| 建 | `Client/Assets/Editor/MiniGamePublishPipeline.cs` | 流水线(build/AB/CLI/落位) |
|
||
| 改 | `Client/Assets/Script/Editor/XWorldUtil.cs` | 本地网关 gamesRoot → deploy/minigame |
|
||
| 删 | `Tools/PcMiniGameSmoke/`(ps1 + README) | 废弃旧 smoke 打包 |
|
||
|
||
新建的 `Client/Assets/**` .cs 文件不手写 .meta——由 Unity 首次打开时生成(本仓库惯例)。
|
||
|
||
---
|
||
|
||
### Task 1: GameSpec 加 CodeAb + GameJsonWriter 输出 codeAb
|
||
|
||
**Files:**
|
||
- Modify: `Server/PublishTool/GameJsonWriter.cs`
|
||
- Modify: `Server/PublishTool.Tests/GameJsonWriterTests.cs`
|
||
|
||
- [ ] **Step 1.1: 写失败测试** —— 修改 `GameJsonWriterTests.cs`:Spec 夹具加 `CodeAb`、Assets 改为完整文件名,`Client_Json_HasRequiredFields` 加 codeAb 断言:
|
||
|
||
```csharp
|
||
private static readonly GameSpec Spec = new GameSpec
|
||
{
|
||
GameId = "rps", Version = 2, MinFrameworkVersion = 1, PlayerCount = 2, TickRateHz = 10,
|
||
ServerAssembly = "RPS.Server.dll", ServerEntryType = "RPS.Server.RpsServerRoom",
|
||
CoreDll = "RPS.Core.dll", ClientDll = "RPS.Client.dll", ClientEntryType = "RPS.Client.RpsGameClient",
|
||
CodeAb = "code.unity3d",
|
||
Assets = new System.Collections.Generic.List<string> { "res.unity3d" }
|
||
};
|
||
```
|
||
|
||
`Client_Json_HasRequiredFields` 内追加/替换断言:
|
||
|
||
```csharp
|
||
Assert.Equal("code.unity3d", r.GetProperty("codeAb").GetString());
|
||
Assert.Equal("res.unity3d", r.GetProperty("assets")[0].GetString());
|
||
```
|
||
|
||
(原 `Assert.Equal("ui_rps", ...)` 删除。)
|
||
|
||
- [ ] **Step 1.2: 跑测试确认失败**
|
||
|
||
Run: `dotnet test Server/PublishTool.Tests -nologo --filter GameJsonWriterTests 2>&1 | tail -5`
|
||
Expected: 编译失败 `CS0117 'GameSpec' does not contain a definition for 'CodeAb'`
|
||
|
||
- [ ] **Step 1.3: 实现** —— `GameJsonWriter.cs`:
|
||
|
||
GameSpec 类加字段(`ClientEntryType` 行后):
|
||
|
||
```csharp
|
||
public string ServerAssembly; public string ServerEntryType;
|
||
public string CoreDll; public string ClientDll; public string ClientEntryType;
|
||
public string CodeAb; // 代码 AB 文件名(统一 AB:DLL .bytes 打进它)
|
||
public List<string> Assets = new List<string>(); // 资源 AB 完整文件名列表
|
||
```
|
||
|
||
`WriteClient` 改为:
|
||
|
||
```csharp
|
||
public static string WriteClient(GameSpec s) => JsonSerializer.Serialize(new
|
||
{
|
||
gameId = s.GameId, version = s.Version, clientEntryType = s.ClientEntryType,
|
||
coreDll = s.CoreDll, clientDll = s.ClientDll, minFrameworkVersion = s.MinFrameworkVersion,
|
||
codeAb = s.CodeAb, assets = s.Assets
|
||
}, Opts);
|
||
```
|
||
|
||
- [ ] **Step 1.4: 跑测试确认通过**
|
||
|
||
Run: `dotnet test Server/PublishTool.Tests -nologo --filter GameJsonWriterTests 2>&1 | tail -5`
|
||
Expected: PASS(2 tests)。注意 `PublishLayoutTests` 此时仍引用旧布局——它在 Task 3 重写;若本步全项目编译失败即是它,可暂时容忍 filter 级通过、Task 3 收口。
|
||
|
||
---
|
||
|
||
### Task 2: GameSpecJson(resolved-spec.json → GameSpec)
|
||
|
||
**Files:**
|
||
- Create: `Server/PublishTool/GameSpecJson.cs`
|
||
- Create: `Server/PublishTool.Tests/GameSpecJsonTests.cs`
|
||
|
||
- [ ] **Step 2.1: 写失败测试** `GameSpecJsonTests.cs`:
|
||
|
||
```csharp
|
||
using Xunit;
|
||
using XWorld.PublishTool;
|
||
|
||
namespace XWorld.PublishTool.Tests
|
||
{
|
||
public class GameSpecJsonTests
|
||
{
|
||
[Fact]
|
||
public void Parse_LowercaseKeys_FillsAllFields()
|
||
{
|
||
// Editor 侧 JsonUtility 输出小写字段名;解析须大小写不敏感、含 fields
|
||
string json = @"{
|
||
""gameId"": ""rps"", ""version"": 3, ""minFrameworkVersion"": 1,
|
||
""playerCount"": 2, ""tickRateHz"": 10,
|
||
""serverAssembly"": ""RPS.Server.dll"", ""serverEntryType"": ""RPS.Server.RpsServerRoom"",
|
||
""coreDll"": ""RPS.Core.dll"", ""clientDll"": ""RPS.Client.dll"",
|
||
""clientEntryType"": ""RPS.Client.RpsGameClient"",
|
||
""codeAb"": ""code.unity3d"", ""assets"": [""res.unity3d""]
|
||
}";
|
||
var s = GameSpecJson.Parse(json);
|
||
Assert.Equal("rps", s.GameId);
|
||
Assert.Equal(3, s.Version);
|
||
Assert.Equal(1, s.MinFrameworkVersion);
|
||
Assert.Equal(2, s.PlayerCount);
|
||
Assert.Equal(10, s.TickRateHz);
|
||
Assert.Equal("RPS.Server.dll", s.ServerAssembly);
|
||
Assert.Equal("RPS.Server.RpsServerRoom", s.ServerEntryType);
|
||
Assert.Equal("RPS.Core.dll", s.CoreDll);
|
||
Assert.Equal("RPS.Client.dll", s.ClientDll);
|
||
Assert.Equal("RPS.Client.RpsGameClient", s.ClientEntryType);
|
||
Assert.Equal("code.unity3d", s.CodeAb);
|
||
Assert.Single(s.Assets);
|
||
Assert.Equal("res.unity3d", s.Assets[0]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_MissingAssets_YieldsEmptyList_NotNull()
|
||
{
|
||
var s = GameSpecJson.Parse(@"{""gameId"":""x"",""version"":1,""codeAb"":""code.unity3d""}");
|
||
Assert.NotNull(s.Assets);
|
||
Assert.Empty(s.Assets);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2.2: 跑测试确认失败**
|
||
|
||
Run: `dotnet test Server/PublishTool.Tests -nologo --filter GameSpecJsonTests 2>&1 | tail -5`
|
||
Expected: 编译失败 `GameSpecJson` 不存在
|
||
|
||
- [ ] **Step 2.3: 实现** `GameSpecJson.cs`:
|
||
|
||
```csharp
|
||
using System.IO;
|
||
using System.Text.Json;
|
||
|
||
namespace XWorld.PublishTool
|
||
{
|
||
// resolved-spec.json(Editor 侧 JsonUtility 产出,字段名小写)→ GameSpec。
|
||
// GameSpec 用 public 字段,故必须 IncludeFields;键名大小写不敏感。
|
||
public static class GameSpecJson
|
||
{
|
||
private static readonly JsonSerializerOptions Opts = new JsonSerializerOptions
|
||
{
|
||
IncludeFields = true,
|
||
PropertyNameCaseInsensitive = true,
|
||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||
AllowTrailingCommas = true,
|
||
};
|
||
|
||
public static GameSpec Parse(string json)
|
||
{
|
||
var s = JsonSerializer.Deserialize<GameSpec>(json, Opts);
|
||
if (s.Assets == null) s.Assets = new System.Collections.Generic.List<string>();
|
||
return s;
|
||
}
|
||
|
||
public static GameSpec ParseFile(string path) => Parse(File.ReadAllText(path));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2.4: 跑测试确认通过**
|
||
|
||
Run: `dotnet test Server/PublishTool.Tests -nologo --filter GameSpecJsonTests 2>&1 | tail -5`
|
||
Expected: PASS(2 tests)。若 `PropertyNameCaseInsensitive` 对字段不生效(第一个测试字段全空)——改用 `JsonNamingPolicy.CamelCase` 无效(同为属性向),则给 GameSpec 字段逐个加 `[JsonPropertyName]` 不可行(是字段可行):在 `GameJsonWriter.cs` 的每个 GameSpec 字段上加 `[System.Text.Json.Serialization.JsonPropertyName("gameId")]` 等注解后重跑。
|
||
|
||
---
|
||
|
||
### Task 3: PublishLayout 拆分 + 统一 AB 布局 + 可选签名
|
||
|
||
**Files:**
|
||
- Modify: `Server/PublishTool/PublishLayout.cs`(整文件替换)
|
||
- Modify: `Server/PublishTool.Tests/PublishLayoutTests.cs`(整文件替换)
|
||
|
||
- [ ] **Step 3.1: 重写测试** `PublishLayoutTests.cs` 整文件替换为:
|
||
|
||
```csharp
|
||
using System.IO;
|
||
using System.Security.Cryptography;
|
||
using Xunit;
|
||
using XWorld.PublishTool;
|
||
|
||
namespace XWorld.PublishTool.Tests
|
||
{
|
||
public class PublishLayoutTests
|
||
{
|
||
private static GameSpec MakeSpec() => new GameSpec
|
||
{
|
||
GameId = "rps", Version = 1, MinFrameworkVersion = 1, PlayerCount = 2, TickRateHz = 10,
|
||
ServerAssembly = "RPS.Server.dll", ServerEntryType = "RPS.Server.RpsServerRoom",
|
||
CoreDll = "RPS.Core.dll", ClientDll = "RPS.Client.dll", ClientEntryType = "RPS.Client.RpsGameClient",
|
||
CodeAb = "code.unity3d",
|
||
Assets = new System.Collections.Generic.List<string> { "res.unity3d" },
|
||
};
|
||
|
||
private static string TempDir() =>
|
||
Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "pub_" + System.Guid.NewGuid().ToString("N"))).FullName;
|
||
|
||
[Fact]
|
||
public void PublishServer_ProducesDlls_GameJson_Manifest_Signature()
|
||
{
|
||
string src = TempDir(); string outDir = Path.Combine(TempDir(), "s");
|
||
File.WriteAllBytes(Path.Combine(src, "RPS.Core.dll"), new byte[] { 1, 2, 3 });
|
||
File.WriteAllBytes(Path.Combine(src, "RPS.Server.dll"), new byte[] { 4, 5 });
|
||
using var rsa = RSA.Create(2048);
|
||
|
||
new PublishLayout().PublishServer(src, outDir, MakeSpec(), rsa.ExportRSAPrivateKeyPem());
|
||
|
||
Assert.True(File.Exists(Path.Combine(outDir, "RPS.Server.dll")));
|
||
Assert.True(File.Exists(Path.Combine(outDir, "RPS.Core.dll")));
|
||
Assert.True(File.Exists(Path.Combine(outDir, "game.json")));
|
||
var fm = FilesManifest.Parse(File.ReadAllText(Path.Combine(outDir, "files.txt")));
|
||
Assert.True(fm.Entries.ContainsKey("RPS.Server.dll"));
|
||
Assert.True(fm.Entries.ContainsKey("RPS.Core.dll"));
|
||
Assert.True(fm.Entries.ContainsKey("game.json"));
|
||
byte[] sig = File.ReadAllBytes(Path.Combine(outDir, "files.txt.sig"));
|
||
Assert.True(ManifestSigner.Verify(fm.Digest(), sig, rsa.ExportRSAPublicKeyPem()));
|
||
}
|
||
|
||
[Fact]
|
||
public void PublishClient_Md5NamedAbs_CodeAbInManifest_NoLooseBytes()
|
||
{
|
||
string src = TempDir(); string outDir = Path.Combine(TempDir(), "c");
|
||
File.WriteAllBytes(Path.Combine(src, "code.unity3d"), new byte[] { 6, 7, 8 });
|
||
File.WriteAllBytes(Path.Combine(src, "res.unity3d"), new byte[] { 9 });
|
||
using var rsa = RSA.Create(2048);
|
||
|
||
new PublishLayout().PublishClient(src, outDir, MakeSpec(), rsa.ExportRSAPrivateKeyPem());
|
||
|
||
var fm = FilesManifest.Parse(File.ReadAllText(Path.Combine(outDir, "files.txt")));
|
||
// files.txt 登记逻辑名;落盘 md5 命名(与 MiniGameDownloader.Md5Name 一致)
|
||
Assert.True(fm.Entries.ContainsKey("code.unity3d"));
|
||
Assert.True(fm.Entries.ContainsKey("res.unity3d"));
|
||
string codeMd5 = Md5Util.OfFile(Path.Combine(src, "code.unity3d"));
|
||
string resMd5 = Md5Util.OfFile(Path.Combine(src, "res.unity3d"));
|
||
Assert.True(File.Exists(Path.Combine(outDir, $"code_{codeMd5}.unity3d")));
|
||
Assert.True(File.Exists(Path.Combine(outDir, $"res_{resMd5}.unity3d")));
|
||
// 统一 AB:不再有散 .bytes 文件
|
||
Assert.Empty(Directory.GetFiles(outDir, "*.bytes"));
|
||
// game.json 带 codeAb
|
||
Assert.Contains("\"codeAb\": \"code.unity3d\"", File.ReadAllText(Path.Combine(outDir, "game.json")));
|
||
byte[] sig = File.ReadAllBytes(Path.Combine(outDir, "files.txt.sig"));
|
||
Assert.True(ManifestSigner.Verify(fm.Digest(), sig, rsa.ExportRSAPublicKeyPem()));
|
||
}
|
||
|
||
[Fact]
|
||
public void NullKey_WritesEmptySig_BothTrees()
|
||
{
|
||
string src = TempDir();
|
||
string outS = Path.Combine(TempDir(), "s"); string outC = Path.Combine(TempDir(), "c");
|
||
File.WriteAllBytes(Path.Combine(src, "RPS.Core.dll"), new byte[] { 1 });
|
||
File.WriteAllBytes(Path.Combine(src, "RPS.Server.dll"), new byte[] { 2 });
|
||
File.WriteAllBytes(Path.Combine(src, "code.unity3d"), new byte[] { 3 });
|
||
File.WriteAllBytes(Path.Combine(src, "res.unity3d"), new byte[] { 4 });
|
||
|
||
var layout = new PublishLayout();
|
||
layout.PublishServer(src, outS, MakeSpec(), null);
|
||
layout.PublishClient(src, outC, MakeSpec(), null);
|
||
|
||
Assert.Equal(0, new FileInfo(Path.Combine(outS, "files.txt.sig")).Length);
|
||
Assert.Equal(0, new FileInfo(Path.Combine(outC, "files.txt.sig")).Length);
|
||
}
|
||
|
||
[Fact]
|
||
public void PublishClient_EmptyCodeAb_Throws()
|
||
{
|
||
var spec = MakeSpec(); spec.CodeAb = null;
|
||
Assert.Throws<System.InvalidOperationException>(
|
||
() => new PublishLayout().PublishClient(TempDir(), Path.Combine(TempDir(), "c"), spec, null));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3.2: 跑测试确认失败**
|
||
|
||
Run: `dotnet test Server/PublishTool.Tests -nologo --filter PublishLayoutTests 2>&1 | tail -5`
|
||
Expected: 编译失败 `PublishServer` 不存在
|
||
|
||
- [ ] **Step 3.3: 实现** —— `PublishLayout.cs` 整文件替换为:
|
||
|
||
```csharp
|
||
using System;
|
||
using System.IO;
|
||
|
||
namespace XWorld.PublishTool
|
||
{
|
||
// 把构建产物落成发布目录 + 清单 + (可选)签名。
|
||
// privateKeyPem 传 null = 不签名:files.txt.sig 落空文件(两端验签均为"未配置则跳过")。
|
||
public sealed class PublishLayout
|
||
{
|
||
// 服务器树:outDir 即 <gamesRoot>/<id>/<ver>/ 的内容(普通文件名,不做 md5 命名)
|
||
public void PublishServer(string srcDir, string outDir, GameSpec spec, string privateKeyPem)
|
||
{
|
||
Directory.CreateDirectory(outDir);
|
||
CopyTo(srcDir, spec.ServerAssembly, outDir);
|
||
CopyTo(srcDir, spec.CoreDll, outDir);
|
||
// game.json 先写,确保其 md5 纳入清单
|
||
File.WriteAllText(Path.Combine(outDir, "game.json"), GameJsonWriter.WriteServer(spec));
|
||
|
||
var fm = new FilesManifest();
|
||
foreach (var name in new[] { spec.ServerAssembly, spec.CoreDll, "game.json" })
|
||
{
|
||
byte[] b = File.ReadAllBytes(Path.Combine(outDir, name));
|
||
fm.Add(name, Md5Util.OfBytes(b), b.Length);
|
||
}
|
||
WriteManifestAndSig(outDir, fm, privateKeyPem);
|
||
}
|
||
|
||
// 客户端树:outDir 即 CDN/minigame/<id>/<ver>/<platform>/ 的内容。
|
||
// spec.CodeAb / spec.Assets 均为完整 AB 文件名(code.unity3d / res.unity3d),统一 AB:无散 .bytes。
|
||
public void PublishClient(string srcDir, string outDir, GameSpec spec, string privateKeyPem)
|
||
{
|
||
if (string.IsNullOrEmpty(spec.CodeAb))
|
||
throw new InvalidOperationException("spec.CodeAb 为空:客户端发布需要代码 AB(统一 AB 格式)");
|
||
Directory.CreateDirectory(outDir);
|
||
|
||
var fm = new FilesManifest();
|
||
CopyMd5Named(srcDir, spec.CodeAb, outDir, fm);
|
||
foreach (var ab in spec.Assets) CopyMd5Named(srcDir, ab, outDir, fm);
|
||
|
||
File.WriteAllText(Path.Combine(outDir, "game.json"), GameJsonWriter.WriteClient(spec));
|
||
WriteManifestAndSig(outDir, fm, privateKeyPem);
|
||
}
|
||
|
||
private static void WriteManifestAndSig(string outDir, FilesManifest fm, string privateKeyPem)
|
||
{
|
||
File.WriteAllText(Path.Combine(outDir, "files.txt"), fm.Render());
|
||
byte[] sig = privateKeyPem == null
|
||
? Array.Empty<byte>()
|
||
: ManifestSigner.Sign(fm.Digest(), privateKeyPem);
|
||
File.WriteAllBytes(Path.Combine(outDir, "files.txt.sig"), sig);
|
||
}
|
||
|
||
private static void CopyTo(string srcDir, string name, string dstDir)
|
||
=> File.Copy(Path.Combine(srcDir, name), Path.Combine(dstDir, name), true);
|
||
|
||
// 落盘 md5 命名(name_md5.ext,与 MiniGameDownloader.Md5Name 推导一致),files.txt 登记逻辑名
|
||
private static void CopyMd5Named(string srcDir, string name, string dstDir, FilesManifest fm)
|
||
{
|
||
byte[] data = File.ReadAllBytes(Path.Combine(srcDir, name));
|
||
string md5 = Md5Util.OfBytes(data);
|
||
File.WriteAllBytes(Path.Combine(dstDir, Md5Name(name, md5)), data);
|
||
fm.Add(name, md5, data.Length);
|
||
}
|
||
|
||
// name.ext -> name_md5.ext(无扩展名则 name_md5)
|
||
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}";
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3.4: 跑 PublishTool.Tests 全量确认通过**
|
||
|
||
Run: `dotnet test Server/PublishTool.Tests -nologo 2>&1 | tail -5`
|
||
Expected: 全 PASS(原 13 - 旧 PublishLayout 1 + 新 4 + GameSpecJson 2 = 18 个左右;关键是 0 失败)
|
||
|
||
- [ ] **Step 3.5: Checkpoint——全解决方案回归**
|
||
|
||
Run: `dotnet test Server/Server.sln -nologo 2>&1 | tail -8`
|
||
Expected: 0 失败(Server.Host 引用 PublishTool 的 FilesManifest/ManifestSigner 未动,应无回归)
|
||
|
||
---
|
||
|
||
### Task 4: PublishTool.Cli(瘦壳 + 参数解析)
|
||
|
||
**Files:**
|
||
- Create: `Server/PublishTool/CliOptions.cs`
|
||
- Create: `Server/PublishTool.Tests/CliOptionsTests.cs`
|
||
- Create: `Server/PublishTool.Cli/PublishTool.Cli.csproj`
|
||
- Create: `Server/PublishTool.Cli/Program.cs`
|
||
- Modify: `Server/Server.sln`
|
||
|
||
- [ ] **Step 4.1: 写失败测试** `CliOptionsTests.cs`:
|
||
|
||
```csharp
|
||
using Xunit;
|
||
using XWorld.PublishTool;
|
||
|
||
namespace XWorld.PublishTool.Tests
|
||
{
|
||
public class CliOptionsTests
|
||
{
|
||
[Fact]
|
||
public void Parse_AllArgs_Ok()
|
||
{
|
||
var o = CliOptions.Parse(new[]
|
||
{ "--mode", "client", "--src", "S", "--out", "O", "--spec", "spec.json", "--key", "k.pem" });
|
||
Assert.Equal("client", o.Mode);
|
||
Assert.Equal("S", o.Src);
|
||
Assert.Equal("O", o.Out);
|
||
Assert.Equal("spec.json", o.SpecPath);
|
||
Assert.Equal("k.pem", o.KeyPath);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_KeyOptional()
|
||
{
|
||
var o = CliOptions.Parse(new[] { "--mode", "server", "--src", "S", "--out", "O", "--spec", "sp" });
|
||
Assert.Null(o.KeyPath);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(new object[] { new[] { "--mode", "bogus", "--src", "S", "--out", "O", "--spec", "sp" } })]
|
||
[InlineData(new object[] { new[] { "--src", "S", "--out", "O", "--spec", "sp" } })]
|
||
[InlineData(new object[] { new[] { "--mode", "client", "--out", "O", "--spec", "sp" } })]
|
||
public void Parse_Invalid_Throws(string[] args)
|
||
=> Assert.Throws<System.ArgumentException>(() => CliOptions.Parse(args));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4.2: 跑测试确认失败**
|
||
|
||
Run: `dotnet test Server/PublishTool.Tests -nologo --filter CliOptionsTests 2>&1 | tail -5`
|
||
Expected: 编译失败 `CliOptions` 不存在
|
||
|
||
- [ ] **Step 4.3: 实现** `Server/PublishTool/CliOptions.cs`:
|
||
|
||
```csharp
|
||
using System;
|
||
|
||
namespace XWorld.PublishTool
|
||
{
|
||
// PublishTool.Cli 的参数:--mode client|server --src <dir> --out <dir> --spec <json> [--key <pem>]
|
||
public sealed class CliOptions
|
||
{
|
||
public string Mode; // "client" | "server"
|
||
public string Src; // 构建产物目录
|
||
public string Out; // 落位目录(版本目录本体)
|
||
public string SpecPath; // resolved-spec.json
|
||
public string KeyPath; // 私钥 pem,可空=不签名
|
||
|
||
public const string Usage =
|
||
"用法: PublishTool.Cli --mode client|server --src <dir> --out <dir> --spec <resolved-spec.json> [--key <private.pem>]";
|
||
|
||
public static CliOptions Parse(string[] args)
|
||
{
|
||
var o = new CliOptions();
|
||
for (int i = 0; i < args.Length; i += 2)
|
||
{
|
||
if (i + 1 >= args.Length) throw new ArgumentException($"缺少 {args[i]} 的值\n{Usage}");
|
||
string v = args[i + 1];
|
||
switch (args[i])
|
||
{
|
||
case "--mode": o.Mode = v; break;
|
||
case "--src": o.Src = v; break;
|
||
case "--out": o.Out = v; break;
|
||
case "--spec": o.SpecPath = v; break;
|
||
case "--key": o.KeyPath = v; break;
|
||
default: throw new ArgumentException($"未知参数 {args[i]}\n{Usage}");
|
||
}
|
||
}
|
||
if (o.Mode != "client" && o.Mode != "server")
|
||
throw new ArgumentException($"--mode 必须是 client 或 server\n{Usage}");
|
||
if (string.IsNullOrEmpty(o.Src) || string.IsNullOrEmpty(o.Out) || string.IsNullOrEmpty(o.SpecPath))
|
||
throw new ArgumentException($"--src/--out/--spec 均必填\n{Usage}");
|
||
return o;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4.4: 跑测试确认通过**
|
||
|
||
Run: `dotnet test Server/PublishTool.Tests -nologo --filter CliOptionsTests 2>&1 | tail -5`
|
||
Expected: PASS(5 个用例)
|
||
|
||
- [ ] **Step 4.5: 建 Cli 工程** `Server/PublishTool.Cli/PublishTool.Cli.csproj`:
|
||
|
||
```xml
|
||
<Project Sdk="Microsoft.NET.Sdk">
|
||
|
||
<PropertyGroup>
|
||
<OutputType>Exe</OutputType>
|
||
<TargetFramework>net10.0</TargetFramework>
|
||
<ImplicitUsings>disable</ImplicitUsings>
|
||
<Nullable>disable</Nullable>
|
||
<AssemblyName>XWorld.PublishTool.Cli</AssemblyName>
|
||
<RootNamespace>XWorld.PublishTool.Cli</RootNamespace>
|
||
</PropertyGroup>
|
||
|
||
<ItemGroup>
|
||
<ProjectReference Include="..\PublishTool\PublishTool.csproj" />
|
||
</ItemGroup>
|
||
|
||
</Project>
|
||
```
|
||
|
||
`Server/PublishTool.Cli/Program.cs`:
|
||
|
||
```csharp
|
||
using System;
|
||
using System.IO;
|
||
using XWorld.PublishTool;
|
||
|
||
namespace XWorld.PublishTool.Cli
|
||
{
|
||
// 瘦壳:参数解析 → PublishLayout(全部逻辑在 PublishTool 库,有 xUnit 覆盖)
|
||
internal static class Program
|
||
{
|
||
// 退出码:0 成功;1 参数错误;2 发布失败
|
||
private static int Main(string[] args)
|
||
{
|
||
CliOptions o;
|
||
try { o = CliOptions.Parse(args); }
|
||
catch (ArgumentException e) { Console.Error.WriteLine(e.Message); return 1; }
|
||
|
||
try
|
||
{
|
||
GameSpec spec = GameSpecJson.ParseFile(o.SpecPath);
|
||
string key = o.KeyPath == null ? null : File.ReadAllText(o.KeyPath);
|
||
var layout = new PublishLayout();
|
||
if (o.Mode == "server") layout.PublishServer(o.Src, o.Out, spec, key);
|
||
else layout.PublishClient(o.Src, o.Out, spec, key);
|
||
Console.WriteLine($"published {o.Mode} {spec.GameId}/{spec.Version} -> {o.Out}" +
|
||
(key == null ? " [WARN] 未签名(files.txt.sig 为空)" : " [signed]"));
|
||
return 0;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Console.Error.WriteLine("publish failed: " + e.Message);
|
||
return 2;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4.6: 挂进 sln 并构建**
|
||
|
||
Run:
|
||
```bash
|
||
cd "D:/UD/AI/AIC#Project" && dotnet sln Server/Server.sln add Server/PublishTool.Cli/PublishTool.Cli.csproj && dotnet build Server/PublishTool.Cli -c Release -nologo 2>&1 | tail -3
|
||
```
|
||
Expected: `0 个错误`
|
||
|
||
- [ ] **Step 4.7: CLI 端到端冒烟(bash,真实文件)**
|
||
|
||
```bash
|
||
cd "D:/UD/AI/AIC#Project"
|
||
T=$(mktemp -d)
|
||
printf 'CORE' > "$T/RPS.Core.dll"; printf 'SRV' > "$T/RPS.Server.dll"
|
||
printf 'AB1' > "$T/code.unity3d"; printf 'AB2' > "$T/res.unity3d"
|
||
cat > "$T/spec.json" <<'EOF'
|
||
{"gameId":"rps","version":9,"minFrameworkVersion":1,"playerCount":2,"tickRateHz":10,
|
||
"serverAssembly":"RPS.Server.dll","serverEntryType":"RPS.Server.RpsServerRoom",
|
||
"coreDll":"RPS.Core.dll","clientDll":"RPS.Client.dll","clientEntryType":"RPS.Client.RpsGameClient",
|
||
"codeAb":"code.unity3d","assets":["res.unity3d"]}
|
||
EOF
|
||
CLI=Server/PublishTool.Cli/bin/Release/net10.0/XWorld.PublishTool.Cli.dll
|
||
dotnet "$CLI" --mode server --src "$T" --out "$T/out-server" --spec "$T/spec.json"
|
||
dotnet "$CLI" --mode client --src "$T" --out "$T/out-client" --spec "$T/spec.json"
|
||
ls "$T/out-server" "$T/out-client" && cat "$T/out-client/files.txt"
|
||
rm -rf "$T"
|
||
```
|
||
Expected: 两次 exit 0、输出含 `[WARN] 未签名`;out-server 有 `RPS.Server.dll RPS.Core.dll game.json files.txt files.txt.sig`;out-client 有 `code_<md5>.unity3d res_<md5>.unity3d game.json files.txt files.txt.sig`;files.txt 两行登记逻辑名。
|
||
|
||
- [ ] **Step 4.8: Checkpoint——全量回归**
|
||
|
||
Run: `dotnet test Server/Server.sln -nologo 2>&1 | tail -8`
|
||
Expected: 0 失败
|
||
|
||
---
|
||
|
||
### Task 5: RPS Core 单源(csproj 修正)
|
||
|
||
**Files:**
|
||
- Modify: `Server/games-src/RPS/RPS.Core/RPS.Core.csproj`
|
||
- Delete: `Server/games-src/RPS/RPS.Core/RpsLogic.cs`、`Server/games-src/RPS/RPS.Core/RpsTypes.cs`
|
||
- Modify: `Client/HotUpdateGames/RPS.Client/RPS.Client.csproj`
|
||
|
||
- [ ] **Step 5.1: 先核对两份 Core 源码语义等价**(删除前提;已知有格式漂移)
|
||
|
||
```bash
|
||
cd "D:/UD/AI/AIC#Project"
|
||
diff <(sed 's/\/\/.*//' "Client/Assets/MiniGames/RockPaperScissors/scripts~/Core/RpsLogic.cs" | tr -d ' \t\r\n') \
|
||
<(sed 's/\/\/.*//' "Server/games-src/RPS/RPS.Core/RpsLogic.cs" | tr -d ' \t\r\n') && echo LOGIC-EQ || echo LOGIC-DIFF
|
||
diff <(sed 's/\/\/.*//' "Client/Assets/MiniGames/RockPaperScissors/scripts~/Core/RpsTypes.cs" | tr -d ' \t\r\n') \
|
||
<(sed 's/\/\/.*//' "Server/games-src/RPS/RPS.Core/RpsTypes.cs" | tr -d ' \t\r\n') && echo TYPES-EQ || echo TYPES-DIFF
|
||
```
|
||
|
||
去空白/注释后仍有差异则**人工逐处阅读 diff**:纯改写(`var`/合并语句/AllChoices 提取等)→ 以 `scripts~/Core` 为准继续;发现真语义差异 → **停下向用户报告**,勿擅自选边。
|
||
|
||
- [ ] **Step 5.2: 改 RPS.Core.csproj** 整文件替换为:
|
||
|
||
```xml
|
||
<Project Sdk="Microsoft.NET.Sdk">
|
||
<PropertyGroup>
|
||
<TargetFramework>netstandard2.1</TargetFramework>
|
||
<LangVersion>9.0</LangVersion>
|
||
<Nullable>disable</Nullable>
|
||
<AssemblyName>RPS.Core</AssemblyName>
|
||
<RootNamespace>RPS.Core</RootNamespace>
|
||
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||
</PropertyGroup>
|
||
<ItemGroup>
|
||
<!-- Core 唯一源码位:客户端 scripts~/Core('~' 目录 Unity 不编译;服务端/客户端两端共用同一批 .cs) -->
|
||
<Compile Include="..\..\..\..\Client\Assets\MiniGames\RockPaperScissors\scripts~\Core\**\*.cs" />
|
||
</ItemGroup>
|
||
<ItemGroup>
|
||
<ProjectReference Include="..\..\..\Framework.Shared\Framework.Shared.csproj">
|
||
<Private>false</Private>
|
||
</ProjectReference>
|
||
</ItemGroup>
|
||
</Project>
|
||
```
|
||
|
||
删除 `Server/games-src/RPS/RPS.Core/RpsLogic.cs` 与 `RpsTypes.cs`。
|
||
|
||
- [ ] **Step 5.3: 改 RPS.Client.csproj**——`<Compile>` 只留 Client 子目录,并加 RPS.Core 工程引用。将
|
||
|
||
```xml
|
||
<Compile Include="..\..\Assets\MiniGames\RockPaperScissors\scripts~\**\*.cs" />
|
||
```
|
||
|
||
替换为:
|
||
|
||
```xml
|
||
<Compile Include="..\..\Assets\MiniGames\RockPaperScissors\scripts~\Client\**\*.cs" />
|
||
```
|
||
|
||
并在 Framework.Shared 的 `<ItemGroup>` 中追加:
|
||
|
||
```xml
|
||
<ProjectReference Include="..\..\..\Server\games-src\RPS\RPS.Core\RPS.Core.csproj">
|
||
<Private>false</Private>
|
||
</ProjectReference>
|
||
```
|
||
|
||
- [ ] **Step 5.4: 构建验证 + 产物形状断言**
|
||
|
||
```bash
|
||
cd "D:/UD/AI/AIC#Project"
|
||
dotnet build Server/games-src/RPS/RPS.Server/RPS.Server.csproj -c Release -nologo 2>&1 | tail -3
|
||
dotnet build Client/HotUpdateGames/RPS.Client/RPS.Client.csproj -c Release -nologo 2>&1 | tail -3
|
||
ls Server/games-src/RPS/RPS.Server/bin/Release/netstandard2.1/ | grep -E "RPS|Framework"
|
||
ls Client/HotUpdateGames/RPS.Client/bin/Release/netstandard2.1/ | grep -E "RPS|Framework|Unity"
|
||
```
|
||
Expected: 两次构建 0 错误;server bin 有 `RPS.Core.dll RPS.Server.dll` **无** Framework.Shared.dll;client bin 有 `RPS.Client.dll` **无** RPS.Core.dll/Framework.Shared.dll/UnityEngine(Private=false 全部生效)。
|
||
|
||
- [ ] **Step 5.5: 刷新测试夹具 + 全量回归**
|
||
|
||
```bash
|
||
cd "D:/UD/AI/AIC#Project" && bash Server/build-test-games.sh && dotnet test Server/Server.sln -nologo 2>&1 | tail -8
|
||
```
|
||
Expected: `TestGames ready.`;0 失败。
|
||
|
||
---
|
||
|
||
### Task 6: RPS 的 publish.json
|
||
|
||
**Files:**
|
||
- Create: `Client/Assets/MiniGames/RockPaperScissors/publish.json`
|
||
|
||
- [ ] **Step 6.1: 写文件**(内容即模板,新游戏拷贝改字段):
|
||
|
||
```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
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6.2: 校验 JSON 语法**
|
||
|
||
Run: `python -c "import json;json.load(open(r'D:/UD/AI/AIC#Project/Client/Assets/MiniGames/RockPaperScissors/publish.json',encoding='utf-8'));print('OK')"`
|
||
Expected: `OK`(.meta 由 Unity 首开生成)
|
||
|
||
---
|
||
|
||
### Task 7: 客户端运行时改造(统一 AB 消费端)
|
||
|
||
**Files:**
|
||
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGamePlatform.cs`
|
||
- Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameManifest.cs`
|
||
- Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameDownloader.cs`
|
||
- Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameAssemblyLoader.cs`(整文件替换)
|
||
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameAssetLoader.cs`
|
||
- Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs`
|
||
|
||
注意:`MiniGameManifest.cs` 被 `PublishTool.Tests.csproj` 链接编译——改动必须保持纯 C#(不引 UnityEngine),每步后跑 `dotnet test Server/PublishTool.Tests` 即时验证该文件。
|
||
|
||
- [ ] **Step 7.1: 新建 MiniGamePlatform.cs**:
|
||
|
||
```csharp
|
||
using System.IO;
|
||
using UnityEngine;
|
||
|
||
namespace XGame.MiniGame
|
||
{
|
||
// 小游戏 CDN 的平台段(与 LoadDll.sPlatform / CDN 目录命名一致),及平台相关的本地 AB 打开方式
|
||
public static class MiniGamePlatform
|
||
{
|
||
#if UNITY_ANDROID
|
||
public const string Name = "android";
|
||
#elif UNITY_IOS
|
||
public const string Name = "ios";
|
||
#elif UNITY_WEBGL
|
||
public const string Name = "webgl";
|
||
#else
|
||
public const string Name = "pc";
|
||
#endif
|
||
|
||
// WebGL 无同步文件系统 mmap,LoadFromFile 不可用 → 读字节走 LoadFromMemory;其余平台 LoadFromFile 更省内存
|
||
public static AssetBundle LoadAssetBundle(string localPath)
|
||
{
|
||
#if UNITY_WEBGL
|
||
return AssetBundle.LoadFromMemory(File.ReadAllBytes(localPath));
|
||
#else
|
||
return AssetBundle.LoadFromFile(localPath);
|
||
#endif
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7.2: MiniGameManifest 加 codeAb**——字段区(`ClientDll` 后)加:
|
||
|
||
```csharp
|
||
public string CodeAb; // 代码 AB 文件名(统一 AB:Core/Client DLL 的 .bytes 都在里面)
|
||
```
|
||
|
||
`ParseGameJson` 的对象初始化里(`ClientDll = ...` 行后)加:
|
||
|
||
```csharp
|
||
CodeAb = JsonMini.GetString(json, "codeAb"),
|
||
```
|
||
|
||
校验区(`coreDll/clientDll` 检查后)加:
|
||
|
||
```csharp
|
||
if (string.IsNullOrEmpty(m.CodeAb))
|
||
throw new FormatException("game.json 缺少 codeAb(统一 AB 新格式;旧 .bytes 直下包不再支持,请用 XWorld/生成小游戏更新 重新发布)");
|
||
```
|
||
|
||
- [ ] **Step 7.3: 即时验证 Manifest 仍纯 C# 可编译**
|
||
|
||
Run: `dotnet test Server/PublishTool.Tests -nologo 2>&1 | tail -5`
|
||
Expected: 编译通过、全 PASS(跨组件 digest 一致性测试不受 codeAb 影响)
|
||
|
||
- [ ] **Step 7.4: MiniGameDownloader 加平台段**——`RemoteBase` 属性改为:
|
||
|
||
```csharp
|
||
private string RemoteBase => $"{_cdnBase}{_gameId}/{_version}/{MiniGamePlatform.Name}/";
|
||
```
|
||
|
||
(`LocalDir` 不变:设备平台固定,无需平台段。)
|
||
|
||
- [ ] **Step 7.5: MiniGameAssemblyLoader 整文件替换**:
|
||
|
||
```csharp
|
||
using System;
|
||
using System.IO;
|
||
using System.Reflection;
|
||
using UnityEngine;
|
||
using XWorld.Framework;
|
||
|
||
namespace XGame.MiniGame
|
||
{
|
||
// 从代码 AB(manifest.CodeAb)取出 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)
|
||
{
|
||
string abPath = localDir + manifest.CodeAb;
|
||
if (!File.Exists(abPath))
|
||
{ Error = $"缺少代码 AB: {abPath}(需要 codeAb 统一 AB 格式,旧 .bytes 包不再支持)"; return false; }
|
||
|
||
AssetBundle ab = null;
|
||
try
|
||
{
|
||
ab = MiniGamePlatform.LoadAssetBundle(abPath);
|
||
if (ab == null) { Error = "代码 AB 打开失败: " + abPath; return false; }
|
||
|
||
byte[] coreBytes = LoadDllBytes(ab, manifest.CoreDll);
|
||
byte[] clientBytes = LoadDllBytes(ab, manifest.ClientDll);
|
||
if (coreBytes == null || clientBytes == null)
|
||
{
|
||
Error = $"代码 AB 内缺少 {manifest.CoreDll}/{manifest.ClientDll} 的 .bytes(AB 内资产: {string.Join(", ", ab.GetAllAssetNames())})";
|
||
return false;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
finally
|
||
{
|
||
// DLL 字节已复制进托管内存,AB 本体可卸;false=不销毁已取出的对象
|
||
if (ab != null) ab.Unload(false);
|
||
}
|
||
}
|
||
|
||
// "X.dll.bytes" 导入后资产名为 "X.dll"(Unity 剥最后一个扩展名);两种写法都试
|
||
private static byte[] LoadDllBytes(AssetBundle ab, string dllName)
|
||
{
|
||
TextAsset ta = ab.LoadAsset<TextAsset>(dllName) ?? ab.LoadAsset<TextAsset>(dllName + ".bytes");
|
||
return ta != null ? ta.bytes : null;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7.6: 新建 MiniGameAssetLoader.cs**:
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using UnityEngine;
|
||
using XWorld.Framework;
|
||
|
||
namespace XGame.MiniGame
|
||
{
|
||
// IAssetLoader over 已下载的小游戏资源 AB(manifest.Assets);首次 Load 时开全部包并缓存,退出统一卸载
|
||
public sealed class MiniGameAssetLoader : IAssetLoader
|
||
{
|
||
private readonly string _localDir;
|
||
private readonly List<string> _abNames;
|
||
private readonly List<AssetBundle> _opened = new List<AssetBundle>();
|
||
private bool _openedAll;
|
||
|
||
public MiniGameAssetLoader(string localDir, MiniGameManifest manifest)
|
||
{ _localDir = localDir; _abNames = manifest.Assets; }
|
||
|
||
// path: 打包时的资产路径(如 "Assets/MiniGames/RockPaperScissors/res/UI/Prefab/UI_RockPaperScissors.prefab")
|
||
// 或裸资产名("UI_RockPaperScissors");找不到回调 null。
|
||
public void Load(string path, Action<object> onLoaded)
|
||
{
|
||
EnsureOpened();
|
||
foreach (var ab in _opened)
|
||
{
|
||
UnityEngine.Object obj = ab.LoadAsset(path);
|
||
if (obj == null) obj = ab.LoadAsset(Path.GetFileNameWithoutExtension(path));
|
||
if (obj != null) { onLoaded?.Invoke(obj); return; }
|
||
}
|
||
Debug.LogError("[MiniGameAssetLoader] 资产未找到: " + path);
|
||
onLoaded?.Invoke(null);
|
||
}
|
||
|
||
// 小游戏退出时由 MiniGameHost 调用;true=连已实例化引用的资产一起卸
|
||
public void UnloadAll()
|
||
{
|
||
foreach (var ab in _opened) if (ab != null) ab.Unload(true);
|
||
_opened.Clear();
|
||
_openedAll = false;
|
||
}
|
||
|
||
private void EnsureOpened()
|
||
{
|
||
if (_openedAll) return;
|
||
_openedAll = true;
|
||
foreach (var name in _abNames)
|
||
{
|
||
string p = _localDir + name;
|
||
if (!File.Exists(p)) { Debug.LogError("[MiniGameAssetLoader] 资源 AB 缺失: " + p); continue; }
|
||
var ab = MiniGamePlatform.LoadAssetBundle(p);
|
||
if (ab == null) { Debug.LogError("[MiniGameAssetLoader] 资源 AB 打开失败: " + p); continue; }
|
||
_opened.Add(ab);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7.7: MiniGameHost 接线**——三处:
|
||
|
||
① 字段区(`private GameClientCtx _ctx;` 后)加:
|
||
|
||
```csharp
|
||
private MiniGameAssetLoader _assets;
|
||
```
|
||
|
||
② `CoEnter` 中构造 ctx 的代码,将
|
||
|
||
```csharp
|
||
_ctx = new GameClientCtx(
|
||
new UnityAssetLoader(),
|
||
```
|
||
|
||
改为:
|
||
|
||
```csharp
|
||
_assets = new MiniGameAssetLoader(dl.LocalDir, dl.Manifest);
|
||
_ctx = new GameClientCtx(
|
||
_assets,
|
||
```
|
||
|
||
③ `ExitGame` 中,将
|
||
|
||
```csharp
|
||
_net = null;
|
||
_ctx = null;
|
||
// 卸载本小游戏 AB(XResLoader 按 xid/AB 名管理;此处按需卸载)—— 见 Step 2 核对项
|
||
```
|
||
|
||
改为:
|
||
|
||
```csharp
|
||
_net = null;
|
||
_ctx = null;
|
||
if (_assets != null) { _assets.UnloadAll(); _assets = null; } // 卸载本小游戏资源 AB
|
||
```
|
||
|
||
(`UnityAssetLoader.cs` 保留不删——大厅管线仍可能用;本任务只换 minigame 注入。)
|
||
|
||
- [ ] **Step 7.8: Checkpoint**
|
||
|
||
Run: `dotnet test Server/PublishTool.Tests -nologo 2>&1 | tail -5`
|
||
Expected: 全 PASS。其余文件的编译验证在 Task 8。
|
||
|
||
---
|
||
|
||
### Task 8: MiniGameRuntime.Verify(运行时改动的 dotnet 编译验证壳)
|
||
|
||
**Files:**
|
||
- Create: `Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj`
|
||
|
||
原理:把 `Assets/Script/xmain/MiniGame/**` 源码 + `Library/ScriptAssemblies` 里 Unity 已编好的 `XWorld.Link.dll`(提供 XResLoader/XWCoroutine/GlobalData/XWebSocket 等类型)一起交给 dotnet csc。源码类型与 DLL 内同名旧类型冲突是预期的 → `NoWarn CS0436`(源码优先)。**不进 Server.sln、不发布,仅验证用。**
|
||
|
||
- [ ] **Step 8.1: 写 csproj**:
|
||
|
||
```xml
|
||
<Project Sdk="Microsoft.NET.Sdk">
|
||
|
||
<PropertyGroup>
|
||
<TargetFramework>netstandard2.1</TargetFramework>
|
||
<LangVersion>9.0</LangVersion>
|
||
<Nullable>disable</Nullable>
|
||
<ImplicitUsings>disable</ImplicitUsings>
|
||
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||
<AssemblyName>MiniGameRuntime.Verify</AssemblyName>
|
||
<!-- CS0436: 源码类型与 XWorld.Link.dll 内旧版本同名——验证壳的预期冲突,源码优先 -->
|
||
<NoWarn>$(NoWarn);CS0436</NoWarn>
|
||
<UnityEditorPath Condition="'$(UnityEditorPath)' == ''">D:\Program Files\Unity 2022.3.62f2c1\Editor</UnityEditorPath>
|
||
<ScriptAssemblies>..\..\Library\ScriptAssemblies</ScriptAssemblies>
|
||
<UnityManaged>$(UnityEditorPath)\Data\Managed\UnityEngine</UnityManaged>
|
||
</PropertyGroup>
|
||
|
||
<ItemGroup>
|
||
<Compile Include="..\..\Assets\Script\xmain\MiniGame\**\*.cs" />
|
||
</ItemGroup>
|
||
|
||
<ItemGroup>
|
||
<Reference Include="UnityEngine"><HintPath>$(UnityManaged)\UnityEngine.dll</HintPath><Private>false</Private></Reference>
|
||
<Reference Include="UnityEngine.CoreModule"><HintPath>$(UnityManaged)\UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
|
||
<Reference Include="UnityEngine.IMGUIModule"><HintPath>$(UnityManaged)\UnityEngine.IMGUIModule.dll</HintPath><Private>false</Private></Reference>
|
||
<Reference Include="UnityEngine.UnityWebRequestModule"><HintPath>$(UnityManaged)\UnityEngine.UnityWebRequestModule.dll</HintPath><Private>false</Private></Reference>
|
||
<Reference Include="UnityEngine.AssetBundleModule"><HintPath>$(UnityManaged)\UnityEngine.AssetBundleModule.dll</HintPath><Private>false</Private></Reference>
|
||
<Reference Include="UnityEngine.UIModule"><HintPath>$(UnityManaged)\UnityEngine.UIModule.dll</HintPath><Private>false</Private></Reference>
|
||
<Reference Include="UnityEngine.TextRenderingModule"><HintPath>$(UnityManaged)\UnityEngine.TextRenderingModule.dll</HintPath><Private>false</Private></Reference>
|
||
<Reference Include="UnityEngine.UI"><HintPath>$(ScriptAssemblies)\UnityEngine.UI.dll</HintPath><Private>false</Private></Reference>
|
||
<Reference Include="XWorld.Link"><HintPath>$(ScriptAssemblies)\XWorld.Link.dll</HintPath><Private>false</Private></Reference>
|
||
<Reference Include="XWorld.Framework.Shared"><HintPath>$(ScriptAssemblies)\XWorld.Framework.Shared.dll</HintPath><Private>false</Private></Reference>
|
||
</ItemGroup>
|
||
|
||
</Project>
|
||
```
|
||
|
||
- [ ] **Step 8.2: 构建验证**
|
||
|
||
Run: `cd "D:/UD/AI/AIC#Project" && dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify -c Release -nologo 2>&1 | tail -5`
|
||
Expected: `0 个错误`。报缺 UnityEngine 模块类型 → 按报错在 csproj 补对应 `UnityEngine.*Module.dll` 引用后重跑;报缺项目内类型(如 CSharpClientApp)→ 确认 `$(ScriptAssemblies)\XWorld.Link.dll` 存在(须用户先在 Unity 里成功编译过一次工程)。
|
||
|
||
---
|
||
|
||
### Task 9: Editor 菜单 + 流水线
|
||
|
||
**Files:**
|
||
- Modify: `Client/Assets/Editor/MiniGamePublishMenu.cs`(整文件替换,旧 stub 菜单「XWorld/生成小游戏」随之消失)
|
||
- Create: `Client/Assets/Editor/MiniGamePublishPipeline.cs`
|
||
|
||
Editor 代码本环境无法编译——写完后**逐行自查**(引用的每个 API 均为 Unity 2022.3 标准 API),最终由用户在 Unity 中核对。
|
||
|
||
- [ ] **Step 9.1: MiniGamePublishMenu.cs 整文件替换**:
|
||
|
||
```csharp
|
||
#if UNITY_EDITOR
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using UnityEditor;
|
||
using UnityEngine;
|
||
|
||
namespace XGame.Editor
|
||
{
|
||
// publish.json 视图(JsonUtility:C# 字段名须与 json 键一致,故小写开头)
|
||
[Serializable]
|
||
public sealed class MiniGamePublishSpec
|
||
{
|
||
public string gameId;
|
||
public string serverSrcDir; // 相对 Server/games-src/
|
||
public string serverProject; // 相对 serverSrcDir
|
||
public string serverAssembly;
|
||
public string serverEntryType;
|
||
public string clientProject; // 相对仓库根
|
||
public string coreDll;
|
||
public string clientDll;
|
||
public string clientEntryType;
|
||
public int minFrameworkVersion = 1;
|
||
public int playerCount = 2;
|
||
public int tickRateHz = 10;
|
||
}
|
||
|
||
public static class MiniGamePublishMenu
|
||
{
|
||
[MenuItem("XWorld/生成小游戏更新", false, 802)]
|
||
public static void Open() => MiniGamePublishWindow.Open();
|
||
}
|
||
|
||
public sealed class MiniGamePublishWindow : EditorWindow
|
||
{
|
||
private string[] _gameDirs = Array.Empty<string>();
|
||
private string[] _gameNames = Array.Empty<string>();
|
||
private int _selected;
|
||
private int _version = 1;
|
||
private bool _pc, _android, _ios, _webgl;
|
||
private Vector2 _scroll;
|
||
|
||
public static void Open()
|
||
{
|
||
var w = GetWindow<MiniGamePublishWindow>("生成小游戏更新");
|
||
w.minSize = new Vector2(360, 320);
|
||
w.RefreshGames();
|
||
w.Show();
|
||
}
|
||
|
||
private void RefreshGames()
|
||
{
|
||
string root = Path.Combine(Application.dataPath, "MiniGames");
|
||
_gameDirs = Directory.Exists(root)
|
||
? Directory.GetDirectories(root).Where(d => File.Exists(Path.Combine(d, "publish.json"))).ToArray()
|
||
: Array.Empty<string>();
|
||
_gameNames = _gameDirs.Select(Path.GetFileName).ToArray();
|
||
_selected = Mathf.Clamp(_selected, 0, Mathf.Max(0, _gameDirs.Length - 1));
|
||
|
||
// 默认平台 = 当前激活 BuildTarget
|
||
_pc = _android = _ios = _webgl = false;
|
||
switch (EditorUserBuildSettings.activeBuildTarget)
|
||
{
|
||
case BuildTarget.Android: _android = true; break;
|
||
case BuildTarget.iOS: _ios = true; break;
|
||
case BuildTarget.WebGL: _webgl = true; break;
|
||
default: _pc = true; break;
|
||
}
|
||
RefreshDefaultVersion();
|
||
}
|
||
|
||
private void RefreshDefaultVersion()
|
||
{
|
||
if (_gameDirs.Length == 0) return;
|
||
var spec = LoadSpec(_gameDirs[_selected]);
|
||
_version = (spec == null || string.IsNullOrEmpty(spec.gameId))
|
||
? 1 : MiniGamePublishPipeline.NextVersion(spec.gameId);
|
||
}
|
||
|
||
private static MiniGamePublishSpec LoadSpec(string gameDir)
|
||
{
|
||
try { return JsonUtility.FromJson<MiniGamePublishSpec>(File.ReadAllText(Path.Combine(gameDir, "publish.json"))); }
|
||
catch (Exception e) { Debug.LogError("[MiniGamePublish] publish.json 解析失败: " + e.Message); return null; }
|
||
}
|
||
|
||
private void OnGUI()
|
||
{
|
||
if (_gameDirs.Length == 0)
|
||
{
|
||
EditorGUILayout.HelpBox("Assets/MiniGames 下没有含 publish.json 的小游戏目录。", MessageType.Warning);
|
||
if (GUILayout.Button("刷新")) RefreshGames();
|
||
return;
|
||
}
|
||
|
||
_scroll = EditorGUILayout.BeginScrollView(_scroll);
|
||
EditorGUILayout.LabelField("小游戏", EditorStyles.boldLabel);
|
||
int sel = GUILayout.SelectionGrid(_selected, _gameNames, 1, EditorStyles.radioButton);
|
||
if (sel != _selected) { _selected = sel; RefreshDefaultVersion(); }
|
||
|
||
EditorGUILayout.Space();
|
||
_version = Mathf.Max(1, EditorGUILayout.IntField("版本号", _version));
|
||
|
||
EditorGUILayout.Space();
|
||
EditorGUILayout.LabelField("平台(AB 按平台构建)", EditorStyles.boldLabel);
|
||
_pc = EditorGUILayout.ToggleLeft("PC (StandaloneWindows64)", _pc);
|
||
_android = EditorGUILayout.ToggleLeft("Android", _android);
|
||
_ios = EditorGUILayout.ToggleLeft("iOS", _ios);
|
||
_webgl = EditorGUILayout.ToggleLeft("WebGL", _webgl);
|
||
EditorGUILayout.EndScrollView();
|
||
|
||
EditorGUILayout.Space();
|
||
using (new EditorGUI.DisabledScope(!(_pc || _android || _ios || _webgl)))
|
||
{
|
||
if (GUILayout.Button("生成", GUILayout.Height(32)))
|
||
{
|
||
var spec = LoadSpec(_gameDirs[_selected]);
|
||
if (spec == null || string.IsNullOrEmpty(spec.gameId))
|
||
{
|
||
EditorUtility.DisplayDialog("生成小游戏更新", "publish.json 无效(缺 gameId)", "OK");
|
||
}
|
||
else
|
||
{
|
||
var targets = new List<MiniGamePublishPipeline.PlatformChoice>();
|
||
if (_pc) targets.Add(new MiniGamePublishPipeline.PlatformChoice("pc", BuildTarget.StandaloneWindows64));
|
||
if (_android) targets.Add(new MiniGamePublishPipeline.PlatformChoice("android", BuildTarget.Android));
|
||
if (_ios) targets.Add(new MiniGamePublishPipeline.PlatformChoice("ios", BuildTarget.iOS));
|
||
if (_webgl) targets.Add(new MiniGamePublishPipeline.PlatformChoice("webgl", BuildTarget.WebGL));
|
||
MiniGamePublishPipeline.Run(spec, _gameDirs[_selected], _version, targets);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
```
|
||
|
||
- [ ] **Step 9.2: 新建 MiniGamePublishPipeline.cs**:
|
||
|
||
```csharp
|
||
#if UNITY_EDITOR
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using UnityEditor;
|
||
using UnityEngine;
|
||
|
||
namespace XGame.Editor
|
||
{
|
||
// 生成小游戏更新的流水线:dotnet build 两端 DLL → 打代码/资源 AB → PublishTool.Cli 落位。
|
||
// 产物:CDN/minigame/<id>/<ver>/<platform>/(客户端)、deploy/minigame/<id>/<ver>/(服务器)。
|
||
public static class MiniGamePublishPipeline
|
||
{
|
||
public struct PlatformChoice
|
||
{
|
||
public string Name; public BuildTarget Target;
|
||
public PlatformChoice(string name, BuildTarget target) { Name = name; Target = target; }
|
||
}
|
||
|
||
// resolved-spec.json(交给 PublishTool.Cli;字段小写对齐 GameSpecJson 大小写不敏感解析)
|
||
[Serializable]
|
||
private sealed class ResolvedSpecJson
|
||
{
|
||
public string gameId; public int version; public int minFrameworkVersion;
|
||
public int playerCount; public int tickRateHz;
|
||
public string serverAssembly; public string serverEntryType;
|
||
public string coreDll; public string clientDll; public string clientEntryType;
|
||
public string codeAb; public List<string> assets;
|
||
}
|
||
|
||
private const string CodeAbName = "code.unity3d";
|
||
private const string ResAbName = "res.unity3d";
|
||
// staging 不能用 '~' 隐藏目录:隐藏资产不入 AssetDatabase,进不了 AB
|
||
private const string StagingAssetDir = "Assets/MiniGameStaging";
|
||
private const string CliProject = "Server/PublishTool.Cli/PublishTool.Cli.csproj";
|
||
private const string CliDll = "Server/PublishTool.Cli/bin/Release/net10.0/XWorld.PublishTool.Cli.dll";
|
||
private const string PrivateKeyRel = "Tools/keys/minigame-private.pem";
|
||
|
||
private static string RepoRoot => Path.GetFullPath(Path.Combine(Application.dataPath, "../.."));
|
||
|
||
// 默认版本号 = CDN/minigame/<gameId>/ 下已有数字目录 max+1
|
||
public static int NextVersion(string gameId)
|
||
{
|
||
string dir = Path.Combine(RepoRoot, "CDN/minigame", gameId);
|
||
int max = 0;
|
||
if (Directory.Exists(dir))
|
||
foreach (var d in Directory.GetDirectories(dir))
|
||
if (int.TryParse(Path.GetFileName(d), out int v) && v > max) max = v;
|
||
return max + 1;
|
||
}
|
||
|
||
public static void Run(MiniGamePublishSpec spec, string gameDir, int version, List<PlatformChoice> platforms)
|
||
{
|
||
string cdnVerDir = Path.Combine(RepoRoot, "CDN/minigame", spec.gameId, version.ToString());
|
||
string deployVerDir = Path.Combine(RepoRoot, "deploy/minigame", spec.gameId, version.ToString());
|
||
if ((Directory.Exists(cdnVerDir) || Directory.Exists(deployVerDir)) &&
|
||
!EditorUtility.DisplayDialog("生成小游戏更新",
|
||
$"版本 {spec.gameId}/{version} 已存在,覆盖重发?\n{cdnVerDir}\n{deployVerDir}", "覆盖", "取消"))
|
||
return;
|
||
|
||
string tmp = Path.Combine(RepoRoot, "Temp/MiniGamePublish", spec.gameId);
|
||
string keyPath = Path.Combine(RepoRoot, PrivateKeyRel);
|
||
bool signed = File.Exists(keyPath);
|
||
try
|
||
{
|
||
if (Directory.Exists(tmp)) Directory.Delete(tmp, true);
|
||
Directory.CreateDirectory(tmp);
|
||
|
||
// 1) 服务器 DLL
|
||
Progress("dotnet build (server)", 0.05f);
|
||
string serverProj = Path.Combine(RepoRoot, "Server/games-src", spec.serverSrcDir, spec.serverProject);
|
||
RunOrThrow("dotnet build (server)", "dotnet", $"build \"{serverProj}\" -c Release -nologo");
|
||
string serverBin = Path.Combine(Path.GetDirectoryName(serverProj), "bin/Release/netstandard2.1");
|
||
RequireFile(Path.Combine(serverBin, spec.serverAssembly));
|
||
RequireFile(Path.Combine(serverBin, spec.coreDll));
|
||
|
||
// 2) 客户端 DLL(Core 取服务器构建产物——两端同一 IL)
|
||
Progress("dotnet build (client)", 0.15f);
|
||
string clientProj = Path.Combine(RepoRoot, spec.clientProject);
|
||
RunOrThrow("dotnet build (client)", "dotnet", $"build \"{clientProj}\" -c Release -nologo");
|
||
string clientBin = Path.Combine(Path.GetDirectoryName(clientProj), "bin/Release/netstandard2.1");
|
||
RequireFile(Path.Combine(clientBin, spec.clientDll));
|
||
|
||
// 3) DLL → Assets staging(.bytes 才能作为 TextAsset 进 AB)
|
||
Progress("导入 DLL 为 TextAsset", 0.25f);
|
||
string stagingFull = Path.Combine(Application.dataPath, "MiniGameStaging/code");
|
||
Directory.CreateDirectory(stagingFull);
|
||
File.Copy(Path.Combine(serverBin, spec.coreDll), Path.Combine(stagingFull, spec.coreDll + ".bytes"), true);
|
||
File.Copy(Path.Combine(clientBin, spec.clientDll), Path.Combine(stagingFull, spec.clientDll + ".bytes"), true);
|
||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||
string[] codeAssets =
|
||
{
|
||
$"{StagingAssetDir}/code/{spec.coreDll}.bytes",
|
||
$"{StagingAssetDir}/code/{spec.clientDll}.bytes",
|
||
};
|
||
|
||
// 4) 每平台打 AB
|
||
string[] resAssets = CollectResAssets(Path.GetFileName(gameDir));
|
||
var builds = new List<AssetBundleBuild>
|
||
{
|
||
new AssetBundleBuild { assetBundleName = CodeAbName, assetNames = codeAssets },
|
||
};
|
||
if (resAssets.Length > 0)
|
||
builds.Add(new AssetBundleBuild { assetBundleName = ResAbName, assetNames = resAssets });
|
||
else
|
||
Debug.LogWarning($"[MiniGamePublish] {gameDir}/res 为空,跳过资源 AB(仅发代码 AB)");
|
||
|
||
foreach (var pf in platforms)
|
||
{
|
||
Progress($"BuildAssetBundles ({pf.Name})", 0.35f);
|
||
string abOut = Path.Combine(tmp, "ab", pf.Name);
|
||
Directory.CreateDirectory(abOut);
|
||
var manifest = BuildPipeline.BuildAssetBundles(
|
||
abOut, builds.ToArray(), BuildAssetBundleOptions.ChunkBasedCompression, pf.Target);
|
||
if (manifest == null) throw new Exception($"BuildAssetBundles 失败: {pf.Name}");
|
||
RequireFile(Path.Combine(abOut, CodeAbName));
|
||
}
|
||
|
||
// 5) resolved-spec.json
|
||
var resolved = new ResolvedSpecJson
|
||
{
|
||
gameId = spec.gameId, version = version, minFrameworkVersion = spec.minFrameworkVersion,
|
||
playerCount = spec.playerCount, tickRateHz = spec.tickRateHz,
|
||
serverAssembly = spec.serverAssembly, serverEntryType = spec.serverEntryType,
|
||
coreDll = spec.coreDll, clientDll = spec.clientDll, clientEntryType = spec.clientEntryType,
|
||
codeAb = CodeAbName,
|
||
assets = resAssets.Length > 0 ? new List<string> { ResAbName } : new List<string>(),
|
||
};
|
||
string specPath = Path.Combine(tmp, "resolved-spec.json");
|
||
File.WriteAllText(specPath, JsonUtility.ToJson(resolved, true), new UTF8Encoding(false));
|
||
|
||
// 6) PublishTool.Cli 落位(先临时目录,成功后整体 Move,防 Cdn.Runner 提供半成品)
|
||
Progress("dotnet build (PublishTool.Cli)", 0.55f);
|
||
RunOrThrow("dotnet build (PublishTool.Cli)", "dotnet",
|
||
$"build \"{Path.Combine(RepoRoot, CliProject)}\" -c Release -nologo");
|
||
string cliDll = Path.Combine(RepoRoot, CliDll);
|
||
RequireFile(cliDll);
|
||
string keyArg = signed ? $" --key \"{keyPath}\"" : "";
|
||
|
||
// 6a) 服务器:只汇集需要的两个 DLL 作为 src
|
||
Progress("发布 server", 0.65f);
|
||
string serverSrc = Path.Combine(tmp, "server-src");
|
||
Directory.CreateDirectory(serverSrc);
|
||
File.Copy(Path.Combine(serverBin, spec.serverAssembly), Path.Combine(serverSrc, spec.serverAssembly), true);
|
||
File.Copy(Path.Combine(serverBin, spec.coreDll), Path.Combine(serverSrc, spec.coreDll), true);
|
||
string outServer = Path.Combine(tmp, "out-server");
|
||
RunOrThrow("PublishTool.Cli (server)", "dotnet",
|
||
$"\"{cliDll}\" --mode server --src \"{serverSrc}\" --out \"{outServer}\" --spec \"{specPath}\"{keyArg}");
|
||
|
||
// 6b) 客户端:每平台一次,src = 该平台 AB 输出目录
|
||
var outClients = new List<(string name, string dir)>();
|
||
foreach (var pf in platforms)
|
||
{
|
||
Progress($"发布 client ({pf.Name})", 0.75f);
|
||
string outClient = Path.Combine(tmp, "out-client-" + pf.Name);
|
||
RunOrThrow($"PublishTool.Cli (client {pf.Name})", "dotnet",
|
||
$"\"{cliDll}\" --mode client --src \"{Path.Combine(tmp, "ab", pf.Name)}\" --out \"{outClient}\" --spec \"{specPath}\"{keyArg}");
|
||
outClients.Add((pf.Name, outClient));
|
||
}
|
||
|
||
// 7) 原子落位
|
||
Progress("落位 CDN/deploy", 0.9f);
|
||
MoveIntoPlace(outServer, deployVerDir);
|
||
foreach (var (name, dir) in outClients)
|
||
MoveIntoPlace(dir, Path.Combine(cdnVerDir, name));
|
||
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine($"生成完成:{spec.gameId} v{version}" + (signed ? "(已签名)" : "(未签名:无 " + PrivateKeyRel + ")"));
|
||
sb.AppendLine("服务器: " + deployVerDir);
|
||
foreach (var (name, _) in outClients)
|
||
sb.AppendLine($"客户端[{name}]: " + Path.Combine(cdnVerDir, name));
|
||
Debug.Log("[MiniGamePublish] " + sb);
|
||
EditorUtility.DisplayDialog("生成小游戏更新", sb.ToString(), "OK");
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogError("[MiniGamePublish] 失败: " + e);
|
||
EditorUtility.DisplayDialog("生成小游戏更新失败", e.Message, "OK");
|
||
}
|
||
finally
|
||
{
|
||
EditorUtility.ClearProgressBar();
|
||
CleanupStaging();
|
||
try { if (Directory.Exists(tmp)) Directory.Delete(tmp, true); } catch { /* 占用时留给下次 Run 清 */ }
|
||
}
|
||
}
|
||
|
||
private static void Progress(string info, float p)
|
||
=> EditorUtility.DisplayProgressBar("生成小游戏更新", info, p);
|
||
|
||
private static string[] CollectResAssets(string gameDirName)
|
||
{
|
||
string resPath = $"Assets/MiniGames/{gameDirName}/res";
|
||
if (!AssetDatabase.IsValidFolder(resPath)) return Array.Empty<string>();
|
||
return AssetDatabase.FindAssets("", new[] { resPath })
|
||
.Select(AssetDatabase.GUIDToAssetPath)
|
||
.Distinct()
|
||
.Where(p => !AssetDatabase.IsValidFolder(p))
|
||
.ToArray();
|
||
}
|
||
|
||
private static void MoveIntoPlace(string src, string dst)
|
||
{
|
||
Directory.CreateDirectory(Path.GetDirectoryName(dst));
|
||
if (Directory.Exists(dst)) Directory.Delete(dst, true);
|
||
Directory.Move(src, dst);
|
||
}
|
||
|
||
private static void RequireFile(string p)
|
||
{ if (!File.Exists(p)) throw new Exception("缺少构建产物: " + p); }
|
||
|
||
private static void CleanupStaging()
|
||
{
|
||
string full = Path.Combine(Application.dataPath, "MiniGameStaging");
|
||
bool dirty = false;
|
||
if (Directory.Exists(full)) { Directory.Delete(full, true); dirty = true; }
|
||
string meta = full + ".meta";
|
||
if (File.Exists(meta)) { File.Delete(meta); dirty = true; }
|
||
if (dirty) AssetDatabase.Refresh();
|
||
}
|
||
|
||
// 起进程并收敛 stdout/stderr(事件式读取避免缓冲区互锁);非 0 退出码抛异常带输出尾部
|
||
private static string RunOrThrow(string step, string file, string args)
|
||
{
|
||
var psi = new System.Diagnostics.ProcessStartInfo
|
||
{
|
||
FileName = file, Arguments = args, WorkingDirectory = RepoRoot,
|
||
UseShellExecute = false, CreateNoWindow = true,
|
||
RedirectStandardOutput = true, RedirectStandardError = true,
|
||
StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8,
|
||
};
|
||
var sb = new StringBuilder();
|
||
using (var p = new System.Diagnostics.Process { StartInfo = psi })
|
||
{
|
||
p.OutputDataReceived += (_, e) => { if (e.Data != null) lock (sb) sb.AppendLine(e.Data); };
|
||
p.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (sb) sb.AppendLine(e.Data); };
|
||
p.Start();
|
||
p.BeginOutputReadLine();
|
||
p.BeginErrorReadLine();
|
||
p.WaitForExit();
|
||
string output = sb.ToString();
|
||
if (p.ExitCode != 0)
|
||
{
|
||
string tail = output.Length > 4000 ? output.Substring(output.Length - 4000) : output;
|
||
throw new Exception($"{step} 失败 (exit {p.ExitCode}):\n{tail}");
|
||
}
|
||
return output;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
```
|
||
|
||
- [ ] **Step 9.3: 自查清单**(无 Unity,逐项人工核对代码)
|
||
- 菜单名恰为 `XWorld/生成小游戏更新`;旧 stub `XWorld/生成小游戏` 已随整文件替换消失。
|
||
- `BuildPipeline.BuildAssetBundles(string, AssetBundleBuild[], BuildAssetBundleOptions, BuildTarget)` 四参重载 ✓(Unity 2022.3 存在)。
|
||
- AB 名 `code.unity3d`/`res.unity3d` 与 resolved-spec 的 `codeAb`/`assets` 一致;与 Task 3 `PublishClient` 消费一致。
|
||
- staging 目录无 `~` 后缀;finally 里删目录+.meta+Refresh。
|
||
- 所有路径 `Path.Combine(RepoRoot, ...)`,进程 WorkingDirectory=RepoRoot。
|
||
|
||
---
|
||
|
||
### Task 10: 配套调整(本地网关指向 deploy/minigame + 废弃 smoke ps1)
|
||
|
||
**Files:**
|
||
- Modify: `Client/Assets/Script/Editor/XWorldUtil.cs:26`(常量)与 `:585-589`(提示文案)
|
||
- Delete: `Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1`
|
||
- Modify: `Tools/PcMiniGameSmoke/README.md`
|
||
|
||
- [ ] **Step 10.1: XWorldUtil 常量改指 deploy/minigame**(路径相对 `Application.dataPath`,`../../` = 仓库根):
|
||
|
||
```csharp
|
||
private const string MINI_GAME_SERVER_GAMES_ROOT = "../../deploy/minigame";
|
||
```
|
||
|
||
- [ ] **Step 10.2: 改缺目录提示**(`StartMiniGameGatewayRunnerProcess` 内):
|
||
|
||
```csharp
|
||
EditorUtility.DisplayDialog("XWorld Server",
|
||
"PC minigame package not found:\n" + gamesRoot +
|
||
"\n\nRun Unity menu [XWorld/生成小游戏更新] first.", "OK");
|
||
```
|
||
|
||
- [ ] **Step 10.3: 删除 ps1 并重写 README**
|
||
|
||
删除 `Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1`。`Tools/PcMiniGameSmoke/README.md` 重写为(要点):旧 ps1 已废弃(裸 .bytes 直下格式运行时不再支持,且 ps1 打不了 AB);本地打包/联调改为 Unity 菜单 `XWorld/生成小游戏更新`(客户端产物 → `CDN/minigame/`,服务器产物 → `deploy/minigame/`,Play 自动起的本地网关 `--gamesRoot` 已指向后者);`XWorld/PC Smoke/...` 两个 Launcher 菜单仍可用。
|
||
|
||
- [ ] **Step 10.4: 全仓引用清扫**
|
||
|
||
Run: `grep -rn "Build-PcMiniGameSmoke\|PcMiniGameSmoke/server" "D:/UD/AI/AIC#Project" --include="*.cs" --include="*.md" --include="*.ps1" | grep -v Library | grep -v docs/superpowers`
|
||
Expected: 无残留(有则逐处改为指向新菜单/新路径)。
|
||
|
||
---
|
||
|
||
### Task 11: 终验 + 人工核对清单
|
||
|
||
- [ ] **Step 11.1: .NET 全量回归**
|
||
|
||
```bash
|
||
cd "D:/UD/AI/AIC#Project"
|
||
dotnet test Server/Server.sln -nologo 2>&1 | tail -8
|
||
dotnet build Server/PublishTool.Cli -c Release -nologo 2>&1 | tail -3
|
||
dotnet build Server/games-src/RPS/RPS.Server/RPS.Server.csproj -c Release -nologo 2>&1 | tail -3
|
||
dotnet build Client/HotUpdateGames/RPS.Client/RPS.Client.csproj -c Release -nologo 2>&1 | tail -3
|
||
dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify -c Release -nologo 2>&1 | tail -3
|
||
```
|
||
Expected: 测试 0 失败;4 个构建全部 0 错误。
|
||
|
||
- [ ] **Step 11.2: 模拟 Editor 产物的 CLI 端到端**(bash 造假 AB 文件走一遍双端布局,校验 files.txt/md5 命名/game.json codeAb——同 Task 4 Step 4.7 但按 `<platform>` 目录形状落到临时 CDN/deploy 树后 `find` 打印核对)。
|
||
|
||
- [ ] **Step 11.3: 向用户提交人工核对清单**(本环境无 Unity,以下必须在 Unity/真机核对):
|
||
1. Unity 打开工程:新 .cs 编译通过、生成 .meta;菜单出现 `XWorld/生成小游戏更新`。
|
||
2. 菜单选 RPS、版本默认 1、勾 PC → 生成:核对 `CDN/minigame/rps/1/pc/`(game.json 含 codeAb、files.txt、code_/res_ md5 命名 AB、空 sig)与 `deploy/minigame/rps/1/`(两 DLL + game.json + files.txt + sig)。
|
||
3. Play(bLocal):本地网关自动起且 `--gamesRoot` 指向 `deploy/minigame`;进 RPS 对局——验证 Downloader 平台段 URL、代码 AB 取 DLL、Assembly.Load、对局可玩、退出时资源 AB 卸载无报错。
|
||
4. 勾 Android 再生成:确认 BuildAssetBundles 不切激活平台可产 Android AB;真机拉 `CDN/minigame/rps/<ver>/android/` 走通(WebGL 的 LoadFromMemory 路径与微信 WASM 合规仍是已知开放风险)。
|
||
5. 上线前:生成 RSA 密钥对,私钥放 `Tools/keys/minigame-private.pem`,公钥填 `ClientManifestVerifier` 与网关 `GameServerHost.StartAsync(publicKeyPem)`,重发并验证验签链路。
|
||
|
||
---
|
||
|
||
## Self-Review 记录
|
||
|
||
- **Spec 覆盖**:§1 布局(Task 3/6/9)、§1.4 Core 单源(Task 5)、§2 编排(Task 4/9)、§3 运行时(Task 7)、§4.3 配套(Task 10)、§4.2 验证(Task 8/11)✓
|
||
- **占位符**:无 TBD/TODO;每个代码步骤给出完整代码 ✓
|
||
- **类型一致性**:`GameSpec.CodeAb`(Task 1)↔ `GameSpecJson`(Task 2)↔ `PublishClient`(Task 3)↔ CLI(Task 4)↔ `ResolvedSpecJson.codeAb`(Task 9)↔ `MiniGameManifest.CodeAb`(Task 7);AB 名 `code.unity3d`/`res.unity3d` 全链一致;`PublishServer/PublishClient` 签名在 Task 3/4/9 一致 ✓
|
||
- **无 git**:所有 commit 步骤已替换为验证 checkpoint ✓
|