#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 ShrinkExternalModTemplateGenerator { private const string GenerateMenuPath = "ShrinkSDK/模组/生成外部模板"; private const string TemplateRelativePath = "Assets/Modules/ShrinkModFramework/Editor/Scaffolding/ExternalModProjectTemplate"; private const string DefaultGeneratedModsRelativePath = "GeneratedMods"; private const string TemplateStampFileName = ".shrink-mod-template.json"; private const string TemplateManifestFileName = ".shrink-mod-template-files.txt"; [MenuItem(GenerateMenuPath)] public static void GenerateTemplate() { try { var context = BuildContext(); if (context == null) return; CopyTemplateProject(context.Value.TemplateRoot, context.Value.OutputRoot, context.Value.Replacements); AssetDatabase.Refresh(); UnityEngine.Debug.Log($"[ShrinkModFramework] 已生成外部模组模板:{context.Value.OutputRoot}"); } catch (Exception ex) { UnityEngine.Debug.LogError($"[ShrinkModFramework] 生成外部模组模板失败:{ex.Message}"); UnityEngine.Debug.LogException(ex); } } private static (string ProjectRoot, string TemplateRoot, string OutputRoot, Dictionary Replacements)? BuildContext() { var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); var templateRoot = Path.Combine(projectRoot, TemplateRelativePath.Replace('/', Path.DirectorySeparatorChar)); if (!Directory.Exists(templateRoot)) throw new DirectoryNotFoundException($"未找到模组模板目录:{templateRoot}"); var generatedModsRoot = Path.Combine(projectRoot, DefaultGeneratedModsRelativePath); Directory.CreateDirectory(generatedModsRoot); var outputRoot = EditorUtility.SaveFolderPanel( "选择外部模组模板输出目录", generatedModsRoot, "SampleShrinkMod"); if (string.IsNullOrWhiteSpace(outputRoot)) return null; outputRoot = Path.GetFullPath(outputRoot); var directoryName = new DirectoryInfo(outputRoot).Name; var projectName = SanitizeProjectName(directoryName); var rootNamespace = projectName; var displayName = projectName; var modId = BuildDefaultModId(projectName); var commandRoot = ToCommandToken(projectName); var routePrefix = modId.Replace('.', '/'); var sdkRootRelativePath = NormalizePath(Path.GetRelativePath(outputRoot, projectRoot)); var replacements = new Dictionary(StringComparer.Ordinal) { ["__PROJECT_NAME__"] = projectName, ["__ROOT_NAMESPACE__"] = rootNamespace, ["__DISPLAY_NAME__"] = displayName, ["__MOD_ID__"] = modId, ["__COMMAND_ROOT__"] = commandRoot, ["__ROUTE_PREFIX__"] = routePrefix, ["__SDK_ROOT_RELATIVE__"] = sdkRootRelativePath, ["__DOTNET_SDK_VERSION__"] = GetPreferredSdkVersion(), ["__REQUEST_OPCODE__"] = "31001", ["__RESPONSE_OPCODE__"] = "31002" }; return (projectRoot, templateRoot, outputRoot, replacements); } private static void CopyTemplateProject(string templateRoot, string outputRoot, IReadOnlyDictionary replacements) { Directory.CreateDirectory(outputRoot); var stampPath = Path.Combine(outputRoot, TemplateStampFileName); if (Directory.EnumerateFileSystemEntries(outputRoot).Any() && !File.Exists(stampPath)) { throw new InvalidOperationException( $"目标目录已存在且不是 ShrinkModFramework 自动生成的模组模板,为避免覆盖人工修改,已中止:{outputRoot}"); } var managedFiles = new List(); foreach (var templateFile in Directory.EnumerateFiles(templateRoot, "*", SearchOption.AllDirectories)) { var relativePath = Path.GetRelativePath(templateRoot, templateFile); relativePath = ReplaceTokens(relativePath, replacements); var outputRelativePath = relativePath.EndsWith(".txt", StringComparison.Ordinal) ? relativePath[..^".txt".Length] : relativePath; managedFiles.Add(NormalizePath(outputRelativePath)); var outputFile = Path.Combine(outputRoot, outputRelativePath); var outputDir = Path.GetDirectoryName(outputFile); if (!string.IsNullOrWhiteSpace(outputDir)) Directory.CreateDirectory(outputDir); var content = File.ReadAllText(templateFile, Encoding.UTF8); content = ReplaceTokens(content, replacements); File.WriteAllText(outputFile, content, new UTF8Encoding(false)); } var manifestPath = Path.Combine(outputRoot, TemplateManifestFileName); if (File.Exists(manifestPath)) { var previousManagedFiles = File.ReadAllLines(manifestPath, Encoding.UTF8) .Where(line => !string.IsNullOrWhiteSpace(line)) .ToArray(); foreach (var previousManagedFile in previousManagedFiles) { if (managedFiles.Contains(previousManagedFile, StringComparer.Ordinal)) continue; var staleFile = Path.Combine(outputRoot, previousManagedFile.Replace('/', Path.DirectorySeparatorChar)); if (File.Exists(staleFile)) File.Delete(staleFile); } } File.WriteAllLines(manifestPath, managedFiles.OrderBy(item => item, StringComparer.Ordinal), new UTF8Encoding(false)); var stamp = new StringBuilder(); stamp.AppendLine("{"); stamp.AppendLine(@" ""generatedBy"": ""ShrinkExternalModTemplateGenerator"","); stamp.AppendLine($@" ""generatedAt"": ""{DateTime.UtcNow:O}"""); stamp.AppendLine("}"); File.WriteAllText(stampPath, stamp.ToString(), new UTF8Encoding(false)); } private static string ReplaceTokens(string input, IReadOnlyDictionary replacements) { var result = input; foreach (var pair in replacements) result = result.Replace(pair.Key, pair.Value); return result; } private static string SanitizeProjectName(string rawName) { if (string.IsNullOrWhiteSpace(rawName)) return "SampleShrinkMod"; var builder = new StringBuilder(); var capitalizeNext = true; foreach (var ch in rawName) { if (char.IsLetterOrDigit(ch)) { builder.Append(capitalizeNext ? char.ToUpperInvariant(ch) : ch); capitalizeNext = false; } else { capitalizeNext = true; } } if (builder.Length == 0) builder.Append("SampleShrinkMod"); if (char.IsDigit(builder[0])) builder.Insert(0, 'M'); return builder.ToString(); } private static string BuildDefaultModId(string projectName) { var token = ToKebabCase(projectName); return string.IsNullOrWhiteSpace(token) ? "example.sample-mod" : $"example.{token}"; } private static string ToCommandToken(string projectName) { var token = ToKebabCase(projectName).Replace("-", "_"); return string.IsNullOrWhiteSpace(token) ? "sample_mod" : token; } private static string ToKebabCase(string value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; var builder = new StringBuilder(); for (var i = 0; i < value.Length; i++) { var ch = value[i]; if (!char.IsLetterOrDigit(ch)) { if (builder.Length > 0 && builder[^1] != '-') builder.Append('-'); continue; } if (char.IsUpper(ch) && i > 0 && builder.Length > 0 && builder[^1] != '-') builder.Append('-'); builder.Append(char.ToLowerInvariant(ch)); } return builder.ToString().Trim('-'); } private static string NormalizePath(string path) => path.Replace('\\', '/'); private static string GetPreferredSdkVersion() { try { var startInfo = new ProcessStartInfo { FileName = "dotnet", Arguments = "--version", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var process = Process.Start(startInfo); if (process == null) return "9.0.312"; var output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(3000); return string.IsNullOrWhiteSpace(output) ? "9.0.312" : output; } catch { return "9.0.312"; } } } #endif