Files
AIC-Project/Client/Assets/Editor/MiniGamePublishPipeline.cs
2026-07-10 10:24:29 +08:00

300 lines
16 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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, "../.."));
// 当前 Editor 安装目录(…/Editor),传给 RPS.Client.csproj 的 UnityEditorPath 以定位 UnityEngine dll
private static string UnityEditorDir => Path.GetDirectoryName(EditorApplication.applicationPath);
// 默认版本号 = 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;
// 覆盖重发只勾部分平台时,未勾的旧平台 AB 会与新服务器 DLL 不一致——需用户明确确认
if (Directory.Exists(cdnVerDir))
{
var known = new[] { "pc", "android", "ios", "webgl" };
var chosen = new HashSet<string>(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 取服务器构建产物——两端同一 ILUnityEditorPath 用当前 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<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<KeyValuePair<string, string>>();
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<string, string>(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<string>();
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