Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
@@ -0,0 +1,299 @@
#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