68 KiB
小游戏打包流程(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 断言:
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 内追加/替换断言:
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 行后):
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 改为:
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:
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:
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整文件替换为:
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整文件替换为:
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:
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:
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:
<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:
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:
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,真实文件)
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 源码语义等价(删除前提;已知有格式漂移)
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 整文件替换为:
<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 工程引用。将
<Compile Include="..\..\Assets\MiniGames\RockPaperScissors\scripts~\**\*.cs" />
替换为:
<Compile Include="..\..\Assets\MiniGames\RockPaperScissors\scripts~\Client\**\*.cs" />
并在 Framework.Shared 的 <ItemGroup> 中追加:
<ProjectReference Include="..\..\..\Server\games-src\RPS\RPS.Core\RPS.Core.csproj">
<Private>false</Private>
</ProjectReference>
- Step 5.4: 构建验证 + 产物形状断言
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: 刷新测试夹具 + 全量回归
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: 写文件(内容即模板,新游戏拷贝改字段):
{
"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:
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后)加:
public string CodeAb; // 代码 AB 文件名(统一 AB:Core/Client DLL 的 .bytes 都在里面)
ParseGameJson 的对象初始化里(ClientDll = ... 行后)加:
CodeAb = JsonMini.GetString(json, "codeAb"),
校验区(coreDll/clientDll 检查后)加:
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属性改为:
private string RemoteBase => $"{_cdnBase}{_gameId}/{_version}/{MiniGamePlatform.Name}/";
(LocalDir 不变:设备平台固定,无需平台段。)
- Step 7.5: MiniGameAssemblyLoader 整文件替换:
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:
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; 后)加:
private MiniGameAssetLoader _assets;
② CoEnter 中构造 ctx 的代码,将
_ctx = new GameClientCtx(
new UnityAssetLoader(),
改为:
_assets = new MiniGameAssetLoader(dl.LocalDir, dl.Manifest);
_ctx = new GameClientCtx(
_assets,
③ ExitGame 中,将
_net = null;
_ctx = null;
// 卸载本小游戏 AB(XResLoader 按 xid/AB 名管理;此处按需卸载)—— 见 Step 2 核对项
改为:
_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:
<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 整文件替换:
#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:
#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/生成小游戏更新;旧 stubXWorld/生成小游戏已随整文件替换消失。 BuildPipeline.BuildAssetBundles(string, AssetBundleBuild[], BuildAssetBundleOptions, BuildTarget)四参重载 ✓(Unity 2022.3 存在)。- AB 名
code.unity3d/res.unity3d与 resolved-spec 的codeAb/assets一致;与 Task 3PublishClient消费一致。 - 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,../../= 仓库根):
private const string MINI_GAME_SERVER_GAMES_ROOT = "../../deploy/minigame";
- Step 10.2: 改缺目录提示(
StartMiniGameGatewayRunnerProcess内):
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 全量回归
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/真机核对):
- Unity 打开工程:新 .cs 编译通过、生成 .meta;菜单出现
XWorld/生成小游戏更新。 - 菜单选 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)。 - Play(bLocal):本地网关自动起且
--gamesRoot指向deploy/minigame;进 RPS 对局——验证 Downloader 平台段 URL、代码 AB 取 DLL、Assembly.Load、对局可玩、退出时资源 AB 卸载无报错。 - 勾 Android 再生成:确认 BuildAssetBundles 不切激活平台可产 Android AB;真机拉
CDN/minigame/rps/<ver>/android/走通(WebGL 的 LoadFromMemory 路径与微信 WASM 合规仍是已知开放风险)。 - 上线前:生成 RSA 密钥对,私钥放
Tools/keys/minigame-private.pem,公钥填ClientManifestVerifier与网关GameServerHost.StartAsync(publicKeyPem),重发并验证验签链路。
- Unity 打开工程:新 .cs 编译通过、生成 .meta;菜单出现
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 ✓