#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////(客户端)、deploy/minigame///(服务器)。 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 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, "../..")); // 当前 Editor 安装目录(…/Editor),传给 RPS.Client.csproj 的 UnityEditorPath 以定位 UnityEngine dll private static string UnityEditorDir => Path.GetDirectoryName(EditorApplication.applicationPath); // 默认版本号 = CDN/minigame// 下已有数字目录 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 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; // 覆盖重发只勾部分平台时,未勾的旧平台 AB 会与新服务器 DLL 不一致——需用户明确确认 if (Directory.Exists(cdnVerDir)) { var known = new[] { "pc", "android", "ios", "webgl" }; var chosen = new HashSet(platforms.Select(p => p.Name)); var stale = Directory.GetDirectories(cdnVerDir) .Select(Path.GetFileName) .Where(n => known.Contains(n) && !chosen.Contains(n)) .ToArray(); if (stale.Length > 0 && !EditorUtility.DisplayDialog("生成小游戏更新", $"版本 {spec.gameId}/{version} 下这些平台不在本次勾选中,将保留旧包并与新服务器 DLL 不一致:\n{string.Join(", ", stale)}\n\n继续?(建议全平台重发或换新版本号)", "继续", "取消")) 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;UnityEditorPath 用当前 Editor) Progress("dotnet build (client)", 0.15f); string clientProj = Path.Combine(RepoRoot, spec.clientProject); RunOrThrow("dotnet build (client)", "dotnet", $"build \"{clientProj}\" -c Release -nologo -p:UnityEditorPath=\"{UnityEditorDir}\""); 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 { 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 { ResAbName } : new List(), }; 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>(); 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(new KeyValuePair(pf.Name, outClient)); } // 7) 原子落位:客户端全部就位后才落服务器——服务器 game.json 是新版本"生效"开关, // 先落它而客户端 Move 失败会导致网关广播新版本但 CDN 缺包 Progress("落位 CDN/deploy", 0.9f); foreach (var kv in outClients) MoveIntoPlace(kv.Value, Path.Combine(cdnVerDir, kv.Key)); MoveIntoPlace(outServer, deployVerDir); var sb = new StringBuilder(); sb.AppendLine($"生成完成:{spec.gameId} v{version}" + (signed ? "(已签名)" : "(未签名:无 " + PrivateKeyRel + ")")); sb.AppendLine("服务器: " + deployVerDir); foreach (var kv in outClients) sb.AppendLine($"客户端[{kv.Key}]: " + Path.Combine(cdnVerDir, kv.Key)); 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(); return AssetDatabase.FindAssets("", new[] { resPath }) .Select(AssetDatabase.GUIDToAssetPath) .Distinct() .Where(p => !AssetDatabase.IsValidFolder(p)) .ToArray(); } // 备份交换式落位:先把旧版挪到 .old,Move 新版成功后再删 .old;失败则回滚,避免"删旧后搬新失败"两头皆空 private static void MoveIntoPlace(string src, string dst) { Directory.CreateDirectory(Path.GetDirectoryName(dst)); string backup = dst + ".old"; if (Directory.Exists(backup)) Directory.Delete(backup, true); bool hadOld = Directory.Exists(dst); if (hadOld) Directory.Move(dst, backup); try { Directory.Move(src, dst); } catch { if (hadOld && !Directory.Exists(dst)) Directory.Move(backup, dst); // 回滚旧版 throw; } if (hadOld) Directory.Delete(backup, true); } private static void RequireFile(string p) { if (!File.Exists(p)) throw new Exception("缺少构建产物: " + p); } private static void CleanupStaging() { try { 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(); } catch (Exception e) { Debug.LogWarning("[MiniGamePublish] 清理 staging 失败(不影响产物): " + e.Message); } } // 起进程并收敛 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(); if (!p.WaitForExit(600_000)) { try { p.Kill(); } catch { } throw new Exception($"{step} 超时(>600s),已终止进程"); } p.WaitForExit(); // 排干异步输出缓冲(有超时版 WaitForExit 不等 BeginOutputReadLine 完成) 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