From 2c48d247b892fbe65e3daf28b1dd9a3302c5b91f Mon Sep 17 00:00:00 2001 From: cneicy Date: Sat, 5 Sep 2026 03:40:41 +0800 Subject: [PATCH] feat: add NuGet and Godot distribution --- .gitea/workflows/publish-nuget.yml | 52 +++++++++++++++++ .gitignore | 8 +++ .npmignore | 4 ++ Directory.Build.props | 13 +++++ DotNet~/Runtime/ShrinkModContext.cs | 25 ++++++++ DotNet~/ShrinkSDK.ModFramework.csproj | 21 +++++++ Godot~/ShrinkGodotModLoader.cs | 66 ++++++++++++++++++++++ Godot~/ShrinkSDK.ModFramework.Godot.csproj | 15 +++++ NuGet.Config | 8 +++ README.md | 4 +- 10 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/publish-nuget.yml create mode 100644 Directory.Build.props create mode 100644 DotNet~/Runtime/ShrinkModContext.cs create mode 100644 DotNet~/ShrinkSDK.ModFramework.csproj create mode 100644 Godot~/ShrinkGodotModLoader.cs create mode 100644 Godot~/ShrinkSDK.ModFramework.Godot.csproj create mode 100644 NuGet.Config diff --git a/.gitea/workflows/publish-nuget.yml b/.gitea/workflows/publish-nuget.yml new file mode 100644 index 0000000..06dc7b7 --- /dev/null +++ b/.gitea/workflows/publish-nuget.yml @@ -0,0 +1,52 @@ +name: Publish NuGet packages + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + NUGET_AUTH_TOKEN: ${{ secrets.SHRINKSDK_PACKAGE_TOKEN }} + steps: + - name: Checkout tagged source + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Validate tag and pack projects + env: + GITEA_REF: ${{ gitea.ref }} + shell: bash + run: | + set -euo pipefail + tag="${GITEA_REF#refs/tags/}" + version="$(node -p "require('./package.json').version")" + test "$tag" = "v$version" + mkdir -p packages + mapfile -d '' projects < <(find DotNet~ Godot~ -type f -name '*.csproj' -print0 2>/dev/null || true) + test "${#projects[@]}" -gt 0 + for project in "${projects[@]}"; do + dotnet restore "$project" --configfile NuGet.Config + dotnet pack "$project" --configuration Release --no-restore --output "$PWD/packages" --include-symbols --include-source + done + find packages -maxdepth 1 -name '*.nupkg' -type f | grep -q . + + - name: Publish packages + shell: bash + run: | + set -euo pipefail + : "${NUGET_AUTH_TOKEN:?SHRINKSDK_PACKAGE_TOKEN is required}" + dotnet nuget push 'packages/*.nupkg' --api-key "$NUGET_AUTH_TOKEN" --source https://git.crash.work/api/packages/ShrinkSDK/nuget/index.json --skip-duplicate + if compgen -G 'packages/*.snupkg' > /dev/null; then + dotnet nuget push 'packages/*.snupkg' --api-key "$NUGET_AUTH_TOKEN" --source https://git.crash.work/api/packages/ShrinkSDK/nuget/index.json --skip-duplicate + fi diff --git a/.gitignore b/.gitignore index 246935d..c3b8764 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,11 @@ /Tools~/**/[Oo]bj/ *.user *.DotSettings.user +/DotNet~/**/[Bb]in/ +/DotNet~/**/[Oo]bj/ +/Godot~/**/[Bb]in/ +/Godot~/**/[Oo]bj/ +/artifacts/ +/packages/ +!DotNet~/**/*.csproj +!Godot~/**/*.csproj diff --git a/.npmignore b/.npmignore index 700f03d..6a59c77 100644 --- a/.npmignore +++ b/.npmignore @@ -6,3 +6,7 @@ Tools~/ *.sln *.user *.DotSettings.user +DotNet~/ +Godot~/ +NuGet.Config +Directory.Build.props diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..43b188b --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,13 @@ + + + latest + enable + true + true + ShrinkSDK + ShrinkSDK + https://git.crash.work/ShrinkSDK + true + snupkg + + diff --git a/DotNet~/Runtime/ShrinkModContext.cs b/DotNet~/Runtime/ShrinkModContext.cs new file mode 100644 index 0000000..e38807e --- /dev/null +++ b/DotNet~/Runtime/ShrinkModContext.cs @@ -0,0 +1,25 @@ +#nullable enable + +using System; +using System.Collections.Generic; + +namespace ShrinkModFramework; + +public sealed class ShrinkModContext +{ + private readonly Dictionary _services = new(); + public ShrinkModContext(string modDirectory) => ModDirectory = modDirectory ?? throw new ArgumentNullException(nameof(modDirectory)); + public string ModDirectory { get; } + public void RegisterService(T service) where T : class => _services[typeof(T)] = service ?? throw new ArgumentNullException(nameof(service)); + public bool TryGetService(out T? service) where T : class + { + service = _services.TryGetValue(typeof(T), out var value) ? value as T : null; + return service != null; + } + public T GetRequiredService() where T : class => TryGetService(out var value) ? value! : throw new InvalidOperationException($"Service is not registered: {typeof(T).FullName}"); +} + +public interface IShrinkModUnload +{ + void OnUnload(ShrinkModContext context); +} diff --git a/DotNet~/ShrinkSDK.ModFramework.csproj b/DotNet~/ShrinkSDK.ModFramework.csproj new file mode 100644 index 0000000..d3539ff --- /dev/null +++ b/DotNet~/ShrinkSDK.ModFramework.csproj @@ -0,0 +1,21 @@ + + + netstandard2.1 + false + ShrinkModFramework.Runtime + ShrinkModFramework + ShrinkSDK.ModFramework + 0.3.0 + Engine-neutral ShrinkSDK mod contracts and lifecycle. + disable + false + + + + + + + + + + diff --git a/Godot~/ShrinkGodotModLoader.cs b/Godot~/ShrinkGodotModLoader.cs new file mode 100644 index 0000000..93d29c3 --- /dev/null +++ b/Godot~/ShrinkGodotModLoader.cs @@ -0,0 +1,66 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; + +namespace ShrinkModFramework.Godot; + +public sealed class ShrinkGodotModLoader : IDisposable +{ + private sealed record LoadedMod(AssemblyLoadContext LoadContext, WeakReference UnloadReference, + ShrinkModContext Context, IReadOnlyList Instances); + private readonly Dictionary _loaded = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyCollection LoadedAssemblyPaths => _loaded.Keys; + + public IReadOnlyList Load(string assemblyPath) + { + assemblyPath = Path.GetFullPath(assemblyPath); + if (_loaded.ContainsKey(assemblyPath)) throw new InvalidOperationException($"Mod assembly is already loaded: {assemblyPath}"); + var loadContext = new AssemblyLoadContext($"ShrinkMod:{Path.GetFileNameWithoutExtension(assemblyPath)}", true); + loadContext.Resolving += (_, name) => ResolveDependency(loadContext, Path.GetDirectoryName(assemblyPath)!, name); + try + { + var assembly = loadContext.LoadFromAssemblyPath(assemblyPath); + var entries = assembly.GetTypes().Where(type => !type.IsAbstract && typeof(IShrinkMod).IsAssignableFrom(type) && + type.GetCustomAttribute() != null).OrderBy(type => type.GetCustomAttribute()!.LoadOrder).ToArray(); + var context = new ShrinkModContext(Path.GetDirectoryName(assemblyPath)!); + var instances = entries.Select(type => (IShrinkMod)(Activator.CreateInstance(type) ?? + throw new InvalidOperationException($"Failed to create mod entry: {type.FullName}"))).ToArray(); + foreach (var mod in instances) mod.OnConstruct(context); + foreach (var mod in instances) mod.OnRegisterContent(context); + foreach (var mod in instances) mod.OnInitialize(context); + foreach (var mod in instances) mod.OnReady(context); + _loaded[assemblyPath] = new LoadedMod(loadContext, new WeakReference(loadContext), context, instances); + return instances; + } + catch { loadContext.Unload(); throw; } + } + + public bool Unload(string assemblyPath) + { + assemblyPath = Path.GetFullPath(assemblyPath); + if (!_loaded.Remove(assemblyPath, out var loaded)) return false; + for (var index = loaded.Instances.Count - 1; index >= 0; index--) + if (loaded.Instances[index] is IShrinkModUnload unload) unload.OnUnload(loaded.Context); + loaded.LoadContext.Unload(); + return true; + } + + public void Dispose() + { + foreach (var path in _loaded.Keys.ToArray()) Unload(path); + } + + private static Assembly? ResolveDependency(AssemblyLoadContext context, string directory, AssemblyName name) + { + var shared = AssemblyLoadContext.Default.Assemblies.FirstOrDefault(assembly => assembly.GetName().Name == name.Name); + if (shared != null) return shared; + var path = Path.Combine(directory, name.Name + ".dll"); + return File.Exists(path) ? context.LoadFromAssemblyPath(path) : null; + } +} diff --git a/Godot~/ShrinkSDK.ModFramework.Godot.csproj b/Godot~/ShrinkSDK.ModFramework.Godot.csproj new file mode 100644 index 0000000..dd855d9 --- /dev/null +++ b/Godot~/ShrinkSDK.ModFramework.Godot.csproj @@ -0,0 +1,15 @@ + + + net8.0 + ShrinkModFramework.Godot + ShrinkModFramework.Godot + ShrinkSDK.ModFramework.Godot + 0.1.0 + Collectible AssemblyLoadContext host for ShrinkSDK mods in Godot. + false + + + + + + diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..43e134b --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md index d6a26d0..c7bc2a1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # ShrinkModFramework -一个面向 Unity 的轻量模组框架,目标是给 `Shrink` 系列提供接近 Forge 的核心能力: +一个面向 Unity 与 Godot C# 的轻量模组框架,目标是给 `Shrink` 系列提供接近 Forge 的核心能力: + +共享模组合同和加载规则位于 `DotNet~`,Godot `AssemblyLoadContext` 适配与打包工程位于 `Godot~`。Godot 项目安装 `ShrinkSDK.ModFramework.Godot`,会同时取得共享核心包。 - 模组声明 - 模组发现