300 lines
16 KiB
C#
300 lines
16 KiB
C#
#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 取服务器构建产物——两端同一 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<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
|