#if UNITY_EDITOR #nullable enable using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Text; using UnityEditor; using UnityEngine; public static class ShrinkModSdkExporter { private const string ExportMenuPath = "ShrinkSDK/Mod/导出 Mod SDK 开发包"; private const string DefaultOutputRootRelativePath = "GeneratedModSdk"; private const string StampFileName = ".shrink-mod-sdk.json"; private const string TemplateProjectName = "SampleShrinkMod"; private const string EventBusPackageName = "com.cneicy.shrink-eventbus"; private const string ModFrameworkPackageName = "com.cneicy.shrink-mod-framework"; private const string EventBusGeneratorProjectRelativePath = "Tools~/DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj"; private const string BundledGeneratorRelativePath = "Editor/Scaffolding/Support/ShrinkEventBus.Generator.dll.bytes"; private static readonly string[] ExportAssemblyNames = { "ShrinkModFramework.Runtime", "ShrinkEventBus.Runtime", "ShrinkEventBus.Generator", "ShrinkCommand.Runtime", "ShrinkCommand.Integration.EventBus", "ShrinkCommand.Integration.Network", "ShrinkNetwork.Runtime", "ShrinkNetwork.Integration.EventBus", "ShrinkDataSaver.Runtime", "ShrinkDataSaver.Integration.EventBus", "Assembly-CSharp", "UniTask", "Newtonsoft.Json", "Kcp-CSharp", "System.Runtime.CompilerServices.Unsafe", "UnityEngine", "UnityEngine.CoreModule" }; [MenuItem(ExportMenuPath)] public static void ExportSdk() { try { var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); BuildSolution(projectRoot); var generatorAssemblyPath = BuildOrResolveEventBusGenerator(projectRoot); var defaultOutputRoot = Path.Combine(projectRoot, DefaultOutputRootRelativePath); Directory.CreateDirectory(defaultOutputRoot); var outputRoot = EditorUtility.SaveFolderPanel( "选择 Mod SDK 导出目录", defaultOutputRoot, "ShrinkModSdk"); if (string.IsNullOrWhiteSpace(outputRoot)) return; outputRoot = Path.GetFullPath(outputRoot); PrepareOutputDirectory(outputRoot); var libsRoot = Path.Combine(outputRoot, "Libs"); var templateRoot = Path.Combine(outputRoot, "Templates", "ExternalMod", TemplateProjectName); Directory.CreateDirectory(libsRoot); Directory.CreateDirectory(templateRoot); var exportedFiles = ExportLibraries(projectRoot, generatorAssemblyPath, libsRoot); WriteSdkReadme(outputRoot, exportedFiles); WriteSdkManifest(outputRoot, exportedFiles); WriteExternalTemplate(templateRoot); WriteStamp(outputRoot); UnityEngine.Debug.Log($"[ShrinkModFramework] 已导出 Mod SDK 开发包:{outputRoot}"); EditorUtility.RevealInFinder(outputRoot); } catch (Exception ex) { UnityEngine.Debug.LogError($"[ShrinkModFramework] 导出 Mod SDK 开发包失败:{ex.Message}"); UnityEngine.Debug.LogException(ex); } } private static void BuildSolution(string projectRoot) { var solutionPath = Path.Combine(projectRoot, "ShrinkSDK.sln"); if (!File.Exists(solutionPath)) { UnityEngine.Debug.Log("[ShrinkModFramework] 未检测到 Workspace 解决方案,直接使用当前 Unity 已编译程序集导出 Mod SDK。"); return; } var startInfo = new ProcessStartInfo { FileName = "dotnet", Arguments = "build .\\ShrinkSDK.sln /m:1 /nr:false -p:BaseIntermediateOutputPath=Temp\\CliObj\\ModSdkExport\\", WorkingDirectory = projectRoot, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var process = Process.Start(startInfo); if (process == null) throw new InvalidOperationException("无法启动 dotnet build。"); var stdout = process.StandardOutput.ReadToEnd(); var stderr = process.StandardError.ReadToEnd(); process.WaitForExit(); if (process.ExitCode != 0) { throw new InvalidOperationException( "构建 ShrinkSDK.sln 失败。\n" + stdout + "\n" + stderr); } } private static string BuildOrResolveEventBusGenerator(string projectRoot) { var generatorProjectPath = ResolveEventBusGeneratorProjectPath(projectRoot); if (generatorProjectPath == null) { var bundledGeneratorPath = Path.Combine(ResolveModFrameworkPackagePath(projectRoot), BundledGeneratorRelativePath); if (File.Exists(bundledGeneratorPath)) return bundledGeneratorPath; throw new InvalidOperationException( "未找到 ShrinkEventBus.Generator 源码或随包分发的分析器。请安装 com.cneicy.shrink-eventbus,或使用包含 ShrinkModFramework 支持资源的完整包版本。"); } var startInfo = new ProcessStartInfo { FileName = "dotnet", Arguments = "build \"" + generatorProjectPath + "\" -c Release /nr:false", WorkingDirectory = Path.GetDirectoryName(generatorProjectPath), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var process = Process.Start(startInfo); if (process == null) throw new InvalidOperationException("无法启动 ShrinkEventBus.Generator 构建。"); var stdout = process.StandardOutput.ReadToEnd(); var stderr = process.StandardError.ReadToEnd(); process.WaitForExit(); if (process.ExitCode != 0) throw new InvalidOperationException("构建 ShrinkEventBus.Generator 失败。\n" + stdout + "\n" + stderr); var generatorAssemblyPath = Path.Combine( Path.GetDirectoryName(generatorProjectPath) ?? string.Empty, "bin", "Release", "netstandard2.0", "ShrinkEventBus.Generator.dll"); if (!File.Exists(generatorAssemblyPath)) throw new InvalidOperationException("ShrinkEventBus.Generator 构建完成但未找到输出 DLL:" + generatorAssemblyPath); return generatorAssemblyPath; } private static string? ResolveEventBusGeneratorProjectPath(string projectRoot) { var workspaceProjectPath = Path.Combine( projectRoot, "Assets", "Modules", "ShrinkEventBus", EventBusGeneratorProjectRelativePath); if (File.Exists(workspaceProjectPath)) return workspaceProjectPath; var package = UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages() .FirstOrDefault(candidate => string.Equals(candidate.name, EventBusPackageName, StringComparison.Ordinal)); if (package == null) return null; var packageProjectPath = Path.Combine(package.resolvedPath, EventBusGeneratorProjectRelativePath); return File.Exists(packageProjectPath) ? packageProjectPath : null; } private static string ResolveModFrameworkPackagePath(string projectRoot) { var workspacePackagePath = Path.Combine(projectRoot, "Assets", "Modules", "ShrinkModFramework"); if (File.Exists(Path.Combine(workspacePackagePath, "package.json"))) return workspacePackagePath; var package = UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages() .FirstOrDefault(candidate => string.Equals(candidate.name, ModFrameworkPackageName, StringComparison.Ordinal)); if (package != null && Directory.Exists(package.resolvedPath)) return package.resolvedPath; throw new InvalidOperationException("无法解析 ShrinkModFramework 包路径。"); } private static void PrepareOutputDirectory(string outputRoot) { Directory.CreateDirectory(outputRoot); var stampPath = Path.Combine(outputRoot, StampFileName); if (Directory.EnumerateFileSystemEntries(outputRoot).Any() && !File.Exists(stampPath)) { throw new InvalidOperationException( $"目标目录已存在且不是 ShrinkModFramework 自动导出的 Mod SDK 目录,为避免覆盖人工内容,已中止:{outputRoot}"); } foreach (var entry in Directory.EnumerateFileSystemEntries(outputRoot).ToArray()) { if (Directory.Exists(entry)) Directory.Delete(entry, true); else File.Delete(entry); } } private static List ExportLibraries(string projectRoot, string generatorAssemblyPath, string libsRoot) { var candidateDirectories = new[] { Path.Combine(projectRoot, "Temp", "Bin", "Debug", "Assembly-CSharp"), Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkModFramework.Runtime"), Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkEventBus.Runtime"), Path.GetDirectoryName(generatorAssemblyPath) ?? string.Empty, Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Runtime"), Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Integration.EventBus"), Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Integration.Network"), Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkNetwork.Runtime"), Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkNetwork.Integration.EventBus"), Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkDataSaver.Runtime"), Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkDataSaver.Integration.EventBus") }; var copied = new HashSet(StringComparer.OrdinalIgnoreCase); var exported = new List(); foreach (var assemblyName in ExportAssemblyNames) { foreach (var extension in new[] { ".dll", ".pdb", ".xml" }) { var fileName = assemblyName + extension; if (copied.Contains(fileName)) continue; var source = candidateDirectories .Select(directory => Path.Combine(directory, fileName)) .FirstOrDefault(File.Exists); if (string.IsNullOrWhiteSpace(source)) continue; var destination = Path.Combine(libsRoot, fileName); File.Copy(source, destination, true); copied.Add(fileName); exported.Add(fileName.Replace('\\', '/')); } } return exported.OrderBy(item => item, StringComparer.OrdinalIgnoreCase).ToList(); } private static void WriteSdkReadme(string outputRoot, IReadOnlyList exportedFiles) { var builder = new StringBuilder(); builder.AppendLine("# Shrink Mod SDK"); builder.AppendLine(); builder.AppendLine("这是给外部模组开发者使用的开发包。"); builder.AppendLine(); builder.AppendLine("## 内容"); builder.AppendLine(); builder.AppendLine("- `Libs/`"); builder.AppendLine(" - 已编译好的框架运行时 DLL、EventBus 代码生成器、当前游戏 DLL,以及模板需要的基础依赖"); builder.AppendLine("- `Templates/ExternalMod/SampleShrinkMod/`"); builder.AppendLine(" - 可直接复制一份开始写外部模组的模板工程"); builder.AppendLine(); builder.AppendLine("## 推荐用法"); builder.AppendLine(); builder.AppendLine("1. 复制 `Templates/ExternalMod/SampleShrinkMod/` 到你自己的工作目录"); builder.AppendLine("2. 修改项目名、命名空间、`modId`、命令路径、消息 opcode"); builder.AppendLine("3. 优先使用模板里的 `build.cmd` 构建"); builder.AppendLine("4. 把构建出来的 DLL 投放到游戏的 `Mods` 目录"); builder.AppendLine(); builder.AppendLine("## 为什么不用直接引用仓库 csproj"); builder.AppendLine(); builder.AppendLine("- 外部开发者通常拿不到完整仓库环境"); builder.AppendLine("- 独立打开 `Sdk-style csproj` 时,有些 IDE/MSBuild 环境会碰到 workload SDK 解析问题"); builder.AppendLine("- 这个开发包的目标就是让外部开发者只依赖 `Libs/` 就能开始写模组"); builder.AppendLine(); builder.AppendLine("## 当前游戏 API"); builder.AppendLine(); builder.AppendLine("- 当前先直接导出 `Assembly-CSharp.dll` 作为游戏侧 API"); builder.AppendLine("- 长期更推荐单独抽一个稳定的 `Game.ModAPI.dll`,避免把整个游戏主程序集暴露给外部模组"); builder.AppendLine(); builder.AppendLine("## 已导出的文件"); builder.AppendLine(); foreach (var exportedFile in exportedFiles) builder.AppendLine("- `Libs/" + exportedFile + "`"); File.WriteAllText(Path.Combine(outputRoot, "README.md"), builder.ToString(), new UTF8Encoding(false)); } private static void WriteSdkManifest(string outputRoot, IReadOnlyList exportedFiles) { var unityVersion = File.Exists(Path.Combine(Path.GetFullPath(Path.Combine(Application.dataPath, "..")), "ProjectSettings", "ProjectVersion.txt")) ? File.ReadAllLines(Path.Combine(Path.GetFullPath(Path.Combine(Application.dataPath, "..")), "ProjectSettings", "ProjectVersion.txt")) .FirstOrDefault(line => line.StartsWith("m_EditorVersion:", StringComparison.Ordinal))? .Split(':').Last().Trim() ?? string.Empty : string.Empty; var builder = new StringBuilder(); builder.AppendLine("{"); builder.AppendLine($@" ""exportedAt"": ""{DateTime.UtcNow:O}"","); builder.AppendLine($@" ""unityVersion"": ""{unityVersion}"","); builder.AppendLine(@" ""assemblies"": ["); for (var i = 0; i < exportedFiles.Count; i++) { var suffix = i == exportedFiles.Count - 1 ? string.Empty : ","; builder.AppendLine($@" ""{exportedFiles[i]}""{suffix}"); } builder.AppendLine(" ]"); builder.AppendLine("}"); File.WriteAllText(Path.Combine(outputRoot, "manifest.json"), builder.ToString(), new UTF8Encoding(false)); } private static void WriteExternalTemplate(string templateRoot) { Directory.CreateDirectory(Path.Combine(templateRoot, "src")); File.WriteAllText(Path.Combine(templateRoot, "README.md"), BuildTemplateReadme(), new UTF8Encoding(false)); File.WriteAllText(Path.Combine(templateRoot, "global.json"), BuildTemplateGlobalJson(), new UTF8Encoding(false)); File.WriteAllText(Path.Combine(templateRoot, "build.cmd"), BuildTemplateBuildCmd(), new UTF8Encoding(false)); File.WriteAllText(Path.Combine(templateRoot, "dev-shell.cmd"), BuildTemplateDevShellCmd(), new UTF8Encoding(false)); File.WriteAllText(Path.Combine(templateRoot, TemplateProjectName + ".csproj"), BuildTemplateCsproj(), new UTF8Encoding(false)); File.WriteAllText(Path.Combine(templateRoot, "src", TemplateProjectName + "Contracts.cs"), BuildTemplateContractsCs(), new UTF8Encoding(false)); File.WriteAllText(Path.Combine(templateRoot, "src", TemplateProjectName + "Mod.cs"), BuildTemplateModCs(), new UTF8Encoding(false)); } private static string BuildTemplateReadme() { return @"# SampleShrinkMod 这是给外部模组开发者的 DLL 模组模板。 ## 目录 - `SampleShrinkMod.csproj` - 引用 `..\..\..\Libs\` 下的 SDK DLL - `build.cmd` - 推荐的构建入口,会先设置 `MSBuildEnableWorkloadResolver=false` - `dev-shell.cmd` - 打开一个已经设置好环境变量的命令行 - `src/` - 示例模组代码 EventBus handler 只使用 `[ShrinkEventSubscriber]` / `[ShrinkSubscribe]`。项目已把 `Libs/ShrinkEventBus.Generator.dll` 作为 Roslyn analyzer 引入,因此 Attach/Post 路径不依赖运行时反射。 ## 推荐构建方式 双击或在命令行运行: ```cmd build.cmd ``` ## 如果你想开 IDE 先运行: ```cmd dev-shell.cmd ``` 然后在这个 shell 里再启动 IDE,或者继续执行 `build.cmd`。 ## 注意 - 当前模板默认引用 `..\..\..\Libs\Assembly-CSharp.dll` 作为游戏 API - 长期更推荐游戏方单独提供一个稳定的 `Game.ModAPI.dll` "; } private static string BuildTemplateGlobalJson() { return @"{ ""sdk"": { ""version"": ""9.0.312"", ""rollForward"": ""latestPatch"" } }"; } private static string BuildTemplateBuildCmd() { return @"@echo off setlocal set MSBuildEnableWorkloadResolver=false dotnet build .\SampleShrinkMod.csproj -c Release /nr:false endlocal"; } private static string BuildTemplateDevShellCmd() { return @"@echo off set MSBuildEnableWorkloadResolver=false echo MSBuildEnableWorkloadResolver=false echo Current dotnet: dotnet --version cmd /k"; } private static string BuildTemplateCsproj() { var references = new[] { "ShrinkModFramework.Runtime", "ShrinkEventBus.Runtime", "ShrinkCommand.Runtime", "ShrinkCommand.Integration.EventBus", "ShrinkCommand.Integration.Network", "ShrinkNetwork.Runtime", "ShrinkNetwork.Integration.EventBus", "ShrinkDataSaver.Runtime", "ShrinkDataSaver.Integration.EventBus", "Assembly-CSharp", "UniTask", "Newtonsoft.Json", "Kcp-CSharp", "System.Runtime.CompilerServices.Unsafe", "UnityEngine", "UnityEngine.CoreModule" }; var builder = new StringBuilder(); builder.AppendLine(""); builder.AppendLine(" "); builder.AppendLine(" net471"); builder.AppendLine(" 9.0"); builder.AppendLine(" enable"); builder.AppendLine(" disable"); builder.AppendLine(" false"); builder.AppendLine(" SampleShrinkMod"); builder.AppendLine(" SampleShrinkMod"); builder.AppendLine(" false"); builder.AppendLine(" true"); builder.AppendLine(" artifacts\\$(Configuration)\\"); builder.AppendLine(" false"); builder.AppendLine(" "); builder.AppendLine(); builder.AppendLine(" "); foreach (var reference in references) { builder.AppendLine($" "); builder.AppendLine($" ..\\..\\..\\Libs\\{reference}.dll"); builder.AppendLine(" true"); builder.AppendLine(" "); } builder.AppendLine(" "); builder.AppendLine(); builder.AppendLine(" "); builder.AppendLine(" "); builder.AppendLine(" "); builder.AppendLine(""); return builder.ToString(); } private static string BuildTemplateContractsCs() { return @"#nullable enable using ShrinkEventBus; using ShrinkNetwork; namespace SampleShrinkMod { public sealed class SampleShrinkModEvent : IShrinkEvent { public string Message { get; set; } = string.Empty; } [ShrinkNetworkMessage(41001, ""sample.shrink.mod/ping"")] public sealed class SampleShrinkModPingRequest : IShrinkNetworkRequest { public string Message { get; set; } = string.Empty; } [ShrinkNetworkMessage(41002, ""sample.shrink.mod/ping_response"")] public sealed class SampleShrinkModPingResponse : ShrinkRpcResponseBase { public string Message { get; set; } = string.Empty; public int Counter { get; set; } } }"; } private static string BuildTemplateModCs() { return @"#nullable enable using ShrinkCommand; using ShrinkEventBus; using ShrinkModFramework; using ShrinkNetwork; namespace SampleShrinkMod { [ShrinkMod(""sample.shrink.mod"", ""Sample Shrink Mod"", ""1.0.0"")] [ShrinkEventSubscriber(OwnerId = ""sample.shrink.mod"", DefaultBus = ""mod:sample.shrink.mod"")] [ShrinkCommandSubscriber] [ShrinkNetworkSubscriber] public sealed partial class SampleShrinkModMod : ShrinkModBase { private readonly RuntimeState _state = new(); public override void OnConstruct(ShrinkModContext context) { context.Log(""模组已构造。""); } public override void OnRegisterContent(ShrinkModContext context) { context.RegisterContent(""example.items"", ""example_item"", ""Sample Shrink Mod Item""); } public override void OnInitialize(ShrinkModContext context) { context.RegisterNetworkHandler(""sync.counter"", (_, value) => { _state.Counter = value; context.Log(""收到模组网络计数:"" + value); }); } [ShrinkSubscribe] private void OnExampleEvent(SampleShrinkModEvent evt) { _state.LastEventMessage = evt.Message ?? string.Empty; _state.Counter++; } [ShrinkCommand(""sample_shrink_mod ping"", Description = ""测试模组命令"", Permission = ""sample.shrink.mod.command.ping"")] private string Ping() { return ""pong:"" + _state.Counter; } [ShrinkNetworkSubscribe(Permission = ""sample.shrink.mod.rpc.ping"")] private SampleShrinkModPingResponse HandlePing(SampleShrinkModPingRequest request) { return new SampleShrinkModPingResponse { Message = ""pong:"" + (request.Message ?? string.Empty), Counter = _state.Counter }; } private sealed class RuntimeState { public int Counter { get; set; } public string LastEventMessage { get; set; } = string.Empty; } } }"; } private static void WriteStamp(string outputRoot) { var builder = new StringBuilder(); builder.AppendLine("{"); builder.AppendLine(@" ""generatedBy"": ""ShrinkModSdkExporter"","); builder.AppendLine($@" ""generatedAt"": ""{DateTime.UtcNow:O}"""); builder.AppendLine("}"); File.WriteAllText(Path.Combine(outputRoot, StampFileName), builder.ToString(), new UTF8Encoding(false)); } } #endif