This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
#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/Mod/生成外部模组模板";
|
||||
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<string, string> 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<string, string>(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<string, string> 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<string>();
|
||||
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<string, string> 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
|
||||
Reference in New Issue
Block a user