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/ShrinkAppCore.cs b/DotNet~/Runtime/ShrinkAppCore.cs
new file mode 100644
index 0000000..e9d112b
--- /dev/null
+++ b/DotNet~/Runtime/ShrinkAppCore.cs
@@ -0,0 +1,137 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Cysharp.Threading.Tasks;
+
+namespace ShrinkApp;
+
+[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+public sealed class ShrinkAppModuleInstallerAttribute : Attribute { }
+
+public interface IShrinkAppModuleInstaller
+{
+ string ModuleId { get; }
+ int Order { get; }
+ IReadOnlyList DependsOn { get; }
+ void RegisterServices(ShrinkAppContext context);
+ UniTask InitializeAsync(ShrinkAppContext context);
+}
+
+public interface IShrinkAppModuleShutdown
+{
+ UniTask ShutdownAsync(ShrinkAppContext context);
+}
+
+public sealed class ShrinkAppSettings
+{
+ public bool VerboseLogging { get; set; }
+ public string[] DisabledModuleIds { get; set; } = Array.Empty();
+}
+
+public sealed class ShrinkAppServices
+{
+ private readonly Dictionary _services = new();
+ public void Register(T service) where T : class => _services[typeof(T)] = service ?? throw new ArgumentNullException(nameof(service));
+ public bool TryGet(out T? service) where T : class
+ {
+ service = _services.TryGetValue(typeof(T), out var value) ? value as T : null;
+ return service != null;
+ }
+ public T GetRequired() where T : class => TryGet(out var value) ? value! : throw new InvalidOperationException($"Required service is not registered: {typeof(T).FullName}");
+ public bool TryUnregister(T service) where T : class => _services.TryGetValue(typeof(T), out var value) && ReferenceEquals(value, service) && _services.Remove(typeof(T));
+}
+
+public sealed class ShrinkAppContext
+{
+ public ShrinkAppContext(object? host, ShrinkAppSettings settings, ShrinkAppServices services)
+ {
+ Host = host;
+ Settings = settings ?? throw new ArgumentNullException(nameof(settings));
+ Services = services ?? throw new ArgumentNullException(nameof(services));
+ }
+ public object? Host { get; }
+ public ShrinkAppSettings Settings { get; }
+ public ShrinkAppServices Services { get; }
+ public static ShrinkAppContext CreateStandalone(ShrinkAppServices services, ShrinkAppSettings? settings = null) => new(null, settings ?? new ShrinkAppSettings(), services);
+}
+
+public sealed class ShrinkAppRuntime
+{
+ private readonly List _started = new();
+ public ShrinkAppRuntime(object? host = null, ShrinkAppSettings? settings = null, ShrinkAppServices? services = null)
+ {
+ Context = new ShrinkAppContext(host, settings ?? new ShrinkAppSettings(), services ?? new ShrinkAppServices());
+ }
+ public ShrinkAppContext Context { get; }
+ public bool IsRunning { get; private set; }
+ public IReadOnlyList StartedModules => _started.Select(item => item.ModuleId).ToArray();
+
+ public async UniTask StartAsync()
+ {
+ if (IsRunning) return;
+ var installers = Sort(CreateInstallers(), Context.Settings.DisabledModuleIds);
+ try
+ {
+ foreach (var installer in installers) installer.RegisterServices(Context);
+ foreach (var installer in installers)
+ {
+ await installer.InitializeAsync(Context);
+ _started.Add(installer);
+ }
+ IsRunning = true;
+ }
+ catch
+ {
+ await ShutdownStartedAsync();
+ throw;
+ }
+ }
+
+ public async UniTask ShutdownAsync()
+ {
+ await ShutdownStartedAsync();
+ IsRunning = false;
+ }
+
+ private async UniTask ShutdownStartedAsync()
+ {
+ for (var index = _started.Count - 1; index >= 0; index--)
+ if (_started[index] is IShrinkAppModuleShutdown shutdown) await shutdown.ShutdownAsync(Context);
+ _started.Clear();
+ }
+
+ private static IReadOnlyList CreateInstallers() =>
+ ShrinkAppInstallers.GetDiscoveredInstallerTypes().Select(type =>
+ Activator.CreateInstance(type) as IShrinkAppModuleInstaller ?? throw new InvalidOperationException($"Failed to create installer: {type.FullName}"))
+ .ToArray();
+
+ private static IReadOnlyList Sort(IEnumerable source, IEnumerable disabledIds)
+ {
+ var disabled = new HashSet(disabledIds ?? Array.Empty(), StringComparer.OrdinalIgnoreCase);
+ var byId = source.Where(item => !disabled.Contains(item.ModuleId)).ToDictionary(item => item.ModuleId, StringComparer.OrdinalIgnoreCase);
+ var states = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var result = new List();
+ foreach (var installer in byId.Values.OrderBy(item => item.Order).ThenBy(item => item.ModuleId, StringComparer.OrdinalIgnoreCase))
+ Visit(installer, byId, states, result);
+ return result;
+ }
+
+ private static void Visit(IShrinkAppModuleInstaller installer, IReadOnlyDictionary byId,
+ IDictionary states, ICollection result)
+ {
+ var state = states.TryGetValue(installer.ModuleId, out var value) ? value : 0;
+ if (state == 2) return;
+ if (state == 1) throw new InvalidOperationException($"Circular installer dependency: {installer.ModuleId}");
+ states[installer.ModuleId] = 1;
+ foreach (var dependencyId in installer.DependsOn ?? Array.Empty())
+ {
+ if (!byId.TryGetValue(dependencyId, out var dependency))
+ throw new InvalidOperationException($"Installer dependency missing. Module={installer.ModuleId}, DependsOn={dependencyId}");
+ Visit(dependency, byId, states, result);
+ }
+ states[installer.ModuleId] = 2;
+ result.Add(installer);
+ }
+}
diff --git a/DotNet~/ShrinkSDK.App.Core.csproj b/DotNet~/ShrinkSDK.App.Core.csproj
new file mode 100644
index 0000000..b51c6b2
--- /dev/null
+++ b/DotNet~/ShrinkSDK.App.Core.csproj
@@ -0,0 +1,20 @@
+
+
+ netstandard2.1
+ false
+ ShrinkApp.Core.Runtime
+ ShrinkApp
+ ShrinkSDK.App.Core
+ 0.2.0
+ ShrinkSDK engine-neutral application composition runtime.
+ 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 a94e49b..4c9ca0a 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
`ShrinkApp.Core` 是 `Shrink` 系列模块的统一宿主层。它不直接替代各个基础包的独立使用方式,而是在项目需要“导入一组包后直接起盘”时,提供统一的启动链、模块安装器、服务容器和基础生命周期事件。
+Godot 和普通 .NET 项目安装 `ShrinkSDK.App.Core`;可打包源码位于 `DotNet~`,其公开合同与 Unity 包共用同一份 Runtime 源码。
+
## 当前定位
- 统一宿主:在 `BeforeSceneLoad` 阶段创建 `ShrinkAppHost`
diff --git a/package.json b/package.json
index ace9a40..e8ed6c0 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-eventbus": "2.1.0",
- "com.cneicy.shrink-shared-codegen": "0.1.0",
+ "com.cneicy.shrink-shared-codegen": "0.1.1",
"com.cysharp.unitask": "2.5.10"
},
"keywords": [