diff --git a/README.md b/README.md index 611c6d0..f2df4c4 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # ShrinkSDK.Godot -Godot 4.6 C# 宿主与平台服务适配包。 +Godot 4.7 C# 宿主与平台服务适配包。 ```powershell -dotnet add package ShrinkSDK.Godot --version 0.1.0 +dotnet add package ShrinkSDK.Godot --version 0.2.1 ``` -主场景根节点继承 `ShrinkGodotHost`,在 `_Ready` 中先调用 `base._Ready()`,再调用 `StartAppAsync()`。CodeGen 由依赖模块的 `buildTransitive` 自动启用。 +主场景根节点继承 `ShrinkGodotHost`,在 `_Ready` 中先调用 `base._Ready()`,再调用 `StartAppAsync()`。`Runtime` 属性暴露 `ShrinkGodotRuntime`,可在非节点入口中复用同一套平台服务、派发队列和网络泵。`ShutdownAppAsync()` 只停止 App;`ShutdownAsync()` 关闭整个运行时。CodeGen 由依赖模块的 `buildTransitive` 自动启用。 ## 可复用能力 @@ -14,6 +14,6 @@ dotnet add package ShrinkSDK.Godot --version 0.1.0 Capability: Godot 宿主生命周期、主线程派发与网络每帧处理预算 Aliases: Godot 主线程 派发 队列 预算 Limits: Godot C# 需要 .NET SDK;Unity 编译输入不包含此宿主,符号查询使用 --project Godot/Packages/ShrinkSDK.Godot/ShrinkSDK.Godot.csproj -Extension: ShrinkGodotHost.NetworkDispatchBudgetPerFrame 与 TryPost -Evidence: [ShrinkGodotHost](ShrinkGodotHost.cs) +Extension: ShrinkGodotRuntime 与 ShrinkGodotHost.NetworkDispatchBudgetPerFrame、TryPost +Evidence: [ShrinkGodotRuntime](ShrinkGodotRuntime.cs); [ShrinkGodotHost](ShrinkGodotHost.cs) diff --git a/ShrinkGodotHost.cs b/ShrinkGodotHost.cs index 60ffcbd..e166072 100644 --- a/ShrinkGodotHost.cs +++ b/ShrinkGodotHost.cs @@ -1,15 +1,11 @@ #nullable enable using System; -using System.Collections.Concurrent; -using System.Collections.Generic; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Cysharp.Threading.Tasks; using Godot; -using ShrinkEventBus; -using ShrinkDataSaver; using ShrinkApp; using ShrinkNetwork; using ShrinkSDK.Runtime; @@ -17,138 +13,131 @@ using ShrinkSDK.Runtime; namespace ShrinkSDK.Godot; public partial class ShrinkGodotHost : Node, IShrinkMainThreadDispatcher, - IShrinkLogger, IShrinkClock, IShrinkPathProvider, IShrinkScreenshotProvider, - IShrinkApplicationLifecycle + IShrinkDeferredMainThreadDispatcher, IShrinkLogger, IShrinkClock, IShrinkPathProvider, + IShrinkScreenshotProvider, IShrinkApplicationLifecycle { - private readonly ConcurrentQueue _dispatchQueue = new(); - private readonly Dictionary _networkServices = new(); - private int _mainThreadId; - private bool _networkPumpRunning; - private ShrinkAppRuntime? _appRuntime; + private readonly ShrinkGodotRuntime _runtime = new(); + private SceneTree? _tree; + private ulong _lastTickFrame = ulong.MaxValue; + private bool _signalsConnected; - public event Action? Paused; - public event Action? Resumed; - public event Action? Exiting; + public ShrinkGodotRuntime Runtime => _runtime; - [Export] public int DispatchBudgetPerFrame { get; set; } = 256; - [Export] public int NetworkDispatchBudgetPerFrame { get; set; } = 256; + [Export] + public int DispatchBudgetPerFrame + { + get => _runtime.DispatchBudgetPerFrame; + set => _runtime.DispatchBudgetPerFrame = value; + } - public bool IsMainThread => Thread.CurrentThread.ManagedThreadId == _mainThreadId; - public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; - public double UnscaledTimeSeconds => Time.GetTicksMsec() / 1000d; - public string PersistentDataPath => ProjectSettings.GlobalizePath("user://"); - public string TemporaryDataPath => OS.GetCacheDir(); + [Export] + public int NetworkDispatchBudgetPerFrame + { + get => _runtime.NetworkDispatchBudgetPerFrame; + set => _runtime.NetworkDispatchBudgetPerFrame = value; + } + + public event Action? Paused + { + add => _runtime.Paused += value; + remove => _runtime.Paused -= value; + } + + public event Action? Resumed + { + add => _runtime.Resumed += value; + remove => _runtime.Resumed -= value; + } + + public event Action? Exiting + { + add => _runtime.Exiting += value; + remove => _runtime.Exiting -= value; + } + + public bool IsMainThread => _runtime.IsMainThread; + public bool IsInitialized => _runtime.IsInitialized; + public bool IsShutdown => _runtime.IsShutdown; + public DateTimeOffset UtcNow => _runtime.UtcNow; + public double UnscaledTimeSeconds => _runtime.UnscaledTimeSeconds; + public string PersistentDataPath => _runtime.PersistentDataPath; + public string TemporaryDataPath => _runtime.TemporaryDataPath; public override void _Ready() { - _mainThreadId = Thread.CurrentThread.ManagedThreadId; - ShrinkRuntimeServices.Configure(this, this, this, this, this, this); - ShrinkEventBusRuntime.ConfigureMainThreadDispatcher(this); - ShrinkDataSaverPlatform.Configure(this, this, this, this); + _runtime.Initialize(this); SetProcess(true); + if (_signalsConnected || !IsInsideTree()) + return; + _tree = GetTree(); + _tree.ProcessFrame += OnProcessFrame; + TreeExiting += OnTreeExiting; + TreeExited += OnTreeExited; + _signalsConnected = true; } - public override void _Process(double delta) + public override void _Process(double delta) => Tick(delta); + + public override void _Notification(int what) => _runtime.Notify(what); + + public override void _ExitTree() { - var budget = Math.Max(1, DispatchBudgetPerFrame); - while (budget-- > 0 && _dispatchQueue.TryDequeue(out var action)) - action(); - PumpNetworkAsync().Forget(); + if (_runtime.IsInitialized && !_runtime.IsShutdown) + _runtime.ShutdownAsync().Forget(); } - public override void _Notification(int what) + private void OnProcessFrame() => Tick(GetProcessDeltaTime()); + + private void Tick(double delta) { - switch ((long)what) + var frame = Engine.GetProcessFrames(); + if (frame == _lastTickFrame) + return; + _lastTickFrame = frame; + _runtime.Tick(delta); + } + + private void OnTreeExiting() + { + if (_runtime.IsInitialized && !_runtime.IsShutdown) + _runtime.ShutdownAsync().Forget(); + } + + private void OnTreeExited() + { + if (_tree != null) { - case NotificationApplicationPaused: - case NotificationApplicationFocusOut: - Paused?.Invoke(); - break; - case NotificationApplicationResumed: - case NotificationApplicationFocusIn: - Resumed?.Invoke(); - break; - case NotificationWMCloseRequest: - case NotificationPredelete: - Exiting?.Invoke(); - break; + _tree.ProcessFrame -= OnProcessFrame; + _tree = null; } + TreeExiting -= OnTreeExiting; + TreeExited -= OnTreeExited; + _signalsConnected = false; + if (_runtime.IsInitialized && !_runtime.IsShutdown) + _runtime.ShutdownAsync().Forget(); } - public async UniTask StartAppAsync(ShrinkAppSettings? settings = null, ShrinkAppServices? services = null) - { - if (_appRuntime?.IsRunning == true) return _appRuntime; - _appRuntime = new ShrinkAppRuntime(this, settings, services); - await _appRuntime.StartAsync(); - return _appRuntime; - } + public UniTask StartAppAsync(ShrinkAppSettings? settings = null, + ShrinkAppServices? services = null) => _runtime.StartAppAsync(settings, services); - public async UniTask ShutdownAppAsync() - { - if (_appRuntime == null) return; - await _appRuntime.ShutdownAsync(); - _appRuntime = null; - } + public UniTask ShutdownAsync() => _runtime.ShutdownAsync(); - public bool TryPost(Action action) - { - if (action == null) throw new ArgumentNullException(nameof(action)); - _dispatchQueue.Enqueue(action); - return true; - } + public UniTask ShutdownAppAsync() => _runtime.StopAppAsync(); - public void AddNetworkService(ShrinkNetworkService service) - { - if (service == null) throw new ArgumentNullException(nameof(service)); - if (_networkServices.ContainsKey(service)) return; - var queue = new ShrinkNetworkDispatchQueue(Math.Max(1, NetworkDispatchBudgetPerFrame * 4)); - service.DispatchScheduler = queue; - _networkServices.Add(service, queue); - } + public bool TryPost(Action action) => _runtime.TryPost(action); - public bool RemoveNetworkService(ShrinkNetworkService service) - { - if (!_networkServices.Remove(service, out var queue)) return false; - service.DispatchScheduler = ShrinkNetworkDispatchSchedulers.Inline; - queue.Dispose(); - return true; - } + public bool TryPostDeferred(Action action) => _runtime.TryPostDeferred(action); - public static ShrinkCodeGenWovenAttribute RequireWoven(Assembly assembly) - { - if (assembly == null) throw new ArgumentNullException(nameof(assembly)); - return assembly.GetCustomAttribute() ?? - throw new InvalidOperationException( - $"Assembly '{assembly.GetName().Name}' was not woven by ShrinkSDK.CodeGen. Ensure the package is installed and ShrinkCodeGenEnabled is not false."); - } + public void AddNetworkService(ShrinkNetworkService service) => _runtime.AddNetworkService(service); - public void Log(ShrinkLogLevel level, string message, Exception? exception = null) - { - var text = exception == null ? message : $"{message}{System.Environment.NewLine}{exception}"; - if (level >= ShrinkLogLevel.Error) GD.PushError(text); - else if (level >= ShrinkLogLevel.Warning) GD.PushWarning(text); - else GD.Print(text); - } + public bool RemoveNetworkService(ShrinkNetworkService service) => _runtime.RemoveNetworkService(service); - public ValueTask CapturePngAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var image = GetViewport().GetTexture().GetImage(); - return ValueTask.FromResult(image.SavePngToBuffer()); - } + public static ShrinkCodeGenWovenAttribute RequireWoven(Assembly assembly) => + ShrinkGodotRuntime.RequireWoven(assembly); - private async UniTaskVoid PumpNetworkAsync() - { - if (_networkPumpRunning) return; - _networkPumpRunning = true; - try - { - foreach (var queue in _networkServices.Values) - await queue.PumpAsync(Math.Max(1, NetworkDispatchBudgetPerFrame)); - } - finally - { - _networkPumpRunning = false; - } - } + public void Log(ShrinkLogLevel level, string message, Exception? exception = null) => + _runtime.Log(level, message, exception); + + public ValueTask CapturePngAsync(CancellationToken cancellationToken = default) => + _runtime.CapturePngAsync(cancellationToken); } diff --git a/ShrinkGodotRuntime.cs b/ShrinkGodotRuntime.cs new file mode 100644 index 0000000..3943743 --- /dev/null +++ b/ShrinkGodotRuntime.cs @@ -0,0 +1,297 @@ +#nullable enable + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Cysharp.Threading.Tasks; +using Godot; +using ShrinkApp; +using ShrinkDataSaver; +using ShrinkEventBus; +using ShrinkNetwork; +using ShrinkSDK.Runtime; + +namespace ShrinkSDK.Godot; + +public sealed class ShrinkGodotRuntime : IShrinkMainThreadDispatcher, IShrinkDeferredMainThreadDispatcher, + IShrinkLogger, IShrinkClock, IShrinkPathProvider, IShrinkScreenshotProvider, + IShrinkApplicationLifecycle +{ + private readonly ConcurrentQueue _dispatchQueue = new(); + private readonly Dictionary _networkServices = new(); + private readonly object _dispatchGate = new(); + private KeyValuePair[] _networkSnapshot = []; + private readonly SemaphoreSlim _lifecycleGate = new(1, 1); + private Node? _owner; + private int _mainThreadId; + private volatile bool _initialized; + private volatile bool _shutdown; + private bool _networkPumpRunning; + private ShrinkAppRuntime? _appRuntime; + + public event Action? Paused; + public event Action? Resumed; + public event Action? Exiting; + + public int DispatchBudgetPerFrame { get; set; } = 256; + public int NetworkDispatchBudgetPerFrame { get; set; } = 256; + + public bool IsInitialized => _initialized; + public bool IsShutdown => _shutdown; + public bool IsMainThread => Thread.CurrentThread.ManagedThreadId == _mainThreadId; + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; + public double UnscaledTimeSeconds => Time.GetTicksMsec() / 1000d; + public string PersistentDataPath => ProjectSettings.GlobalizePath("user://"); + public string TemporaryDataPath => OS.GetCacheDir(); + + public void Initialize(Node owner) + { + if (owner == null) + throw new ArgumentNullException(nameof(owner)); + if (_shutdown) + throw new InvalidOperationException("ShrinkGodotRuntime has already shut down."); + if (_initialized) + { + if (!ReferenceEquals(_owner, owner)) + throw new InvalidOperationException("ShrinkGodotRuntime is already bound to another owner."); + return; + } + + _owner = owner; + _mainThreadId = Thread.CurrentThread.ManagedThreadId; + ShrinkRuntimeServices.Configure(this, this, this, this, this, this); + ShrinkEventBusRuntime.ConfigureMainThreadDispatcher(this); + ShrinkDataSaverPlatform.Configure(this, this, this, this); + _initialized = true; + } + + public void Tick(double delta) + { + if (!_initialized || _shutdown) return; + RequireMainThread(); + + var budget = Math.Max(1, DispatchBudgetPerFrame); + var count = Math.Min(budget, _dispatchQueue.Count); + while (count-- > 0 && _dispatchQueue.TryDequeue(out var action)) + { + try + { + action(); + } + catch (Exception exception) + { + Log(ShrinkLogLevel.Error, "Godot dispatch callback failed.", exception); + } + } + + PumpNetworkAsync().Forget(); + } + + public void Notify(int what) + { + if (_shutdown) + return; + switch ((long)what) + { + case MainLoop.NotificationApplicationPaused: + case MainLoop.NotificationApplicationFocusOut: + Paused?.Invoke(); + break; + case MainLoop.NotificationApplicationResumed: + case MainLoop.NotificationApplicationFocusIn: + Resumed?.Invoke(); + break; + case Node.NotificationWMCloseRequest: + case Node.NotificationPredelete: + Exiting?.Invoke(); + break; + } + } + + public async UniTask StartAppAsync(ShrinkAppSettings? settings = null, + ShrinkAppServices? services = null) + { + await _lifecycleGate.WaitAsync(); + try + { + if (!_initialized) + throw new InvalidOperationException("Initialize the Godot runtime before starting the app."); + if (_shutdown) + throw new InvalidOperationException("ShrinkGodotRuntime has already shut down."); + if (_appRuntime?.IsRunning == true) + return _appRuntime; + if (_appRuntime != null) + { + await _appRuntime.ShutdownAsync(); + _appRuntime = null; + } + + _appRuntime = new ShrinkAppRuntime(_owner, settings, services); + await _appRuntime.StartAsync(); + return _appRuntime; + } + finally + { + _lifecycleGate.Release(); + } + } + + public async UniTask ShutdownAsync() + { + await _lifecycleGate.WaitAsync(); + try + { + if (_shutdown) + return; + var appRuntime = _appRuntime; + _appRuntime = null; + try + { + if (appRuntime != null) + await appRuntime.ShutdownAsync(); + } + finally + { + lock (_dispatchGate) + { + _shutdown = true; + while (_dispatchQueue.TryDequeue(out _)) { } + } + + foreach (var pair in _networkServices) + { + pair.Key.DispatchScheduler = ShrinkNetworkDispatchSchedulers.Inline; + pair.Value.Dispose(); + } + _networkServices.Clear(); + _networkSnapshot = []; + } + } + finally + { + _lifecycleGate.Release(); + } + } + + public async UniTask StopAppAsync() + { + await _lifecycleGate.WaitAsync(); + try + { + if (_shutdown || _appRuntime == null) + return; + var appRuntime = _appRuntime; + _appRuntime = null; + await appRuntime.ShutdownAsync(); + } + finally + { + _lifecycleGate.Release(); + } + } + + public bool TryPost(Action action) + { + if (action == null) + throw new ArgumentNullException(nameof(action)); + lock (_dispatchGate) + { + if (!_initialized || _shutdown) return false; + _dispatchQueue.Enqueue(action); + return true; + } + } + + public bool TryPostDeferred(Action action) => TryPost(action); + + public bool AddNetworkService(ShrinkNetworkService service) + { + if (service == null) + throw new ArgumentNullException(nameof(service)); + if (!_initialized || _shutdown) return false; + RequireMainThread(); + if (_networkServices.ContainsKey(service)) + return true; + var queue = new ShrinkNetworkDispatchQueue(Math.Max(1, NetworkDispatchBudgetPerFrame * 4)); + service.DispatchScheduler = queue; + _networkServices.Add(service, queue); + UpdateNetworkSnapshot(); + return true; + } + + public bool RemoveNetworkService(ShrinkNetworkService service) + { + if (service == null) + throw new ArgumentNullException(nameof(service)); + RequireMainThread(); + if (!_networkServices.Remove(service, out var queue)) + return false; + service.DispatchScheduler = ShrinkNetworkDispatchSchedulers.Inline; + queue.Dispose(); + UpdateNetworkSnapshot(); + return true; + } + + public static ShrinkCodeGenWovenAttribute RequireWoven(Assembly assembly) + { + if (assembly == null) + throw new ArgumentNullException(nameof(assembly)); + return assembly.GetCustomAttribute() ?? + throw new InvalidOperationException( + $"Assembly '{assembly.GetName().Name}' was not woven by ShrinkSDK.CodeGen. Ensure the package is installed and ShrinkCodeGenEnabled is not false."); + } + + public void Log(ShrinkLogLevel level, string message, Exception? exception = null) + { + var text = exception == null ? message : $"{message}{System.Environment.NewLine}{exception}"; + if (level >= ShrinkLogLevel.Error) + GD.PushError(text); + else if (level >= ShrinkLogLevel.Warning) + GD.PushWarning(text); + else + GD.Print(text); + } + + public ValueTask CapturePngAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_owner == null || !GodotObject.IsInstanceValid(_owner)) + throw new InvalidOperationException("ShrinkGodotRuntime has no valid owner viewport."); + var image = _owner.GetViewport().GetTexture().GetImage(); + return ValueTask.FromResult(image.SavePngToBuffer()); + } + + private void RequireMainThread() + { + if (!IsMainThread) throw new InvalidOperationException("Godot lifecycle and network registration require the owner thread."); + } + + private void UpdateNetworkSnapshot() + { + _networkSnapshot = new KeyValuePair[_networkServices.Count]; + ((ICollection>)_networkServices).CopyTo(_networkSnapshot, 0); + } + + private async UniTaskVoid PumpNetworkAsync() + { + if (_networkPumpRunning || _shutdown) + return; + _networkPumpRunning = true; + try + { + foreach (var pair in _networkSnapshot) + { + if (_shutdown) break; + if (_networkServices.TryGetValue(pair.Key, out var queue) && ReferenceEquals(queue, pair.Value)) + await queue.PumpAsync(Math.Max(1, NetworkDispatchBudgetPerFrame)); + } + } + finally + { + _networkPumpRunning = false; + } + } +} diff --git a/ShrinkGodotStorage.cs b/ShrinkGodotStorage.cs new file mode 100644 index 0000000..3b64878 --- /dev/null +++ b/ShrinkGodotStorage.cs @@ -0,0 +1,11 @@ +using System.IO; +using ShrinkDataSaver; + +namespace ShrinkSDK.Godot; + +public static class ShrinkGodotStorage +{ + public static LocalStorageProvider CreateRenameOnly(string rootPath) => + new(rootPath, LocalStorageCommitStrategy.RenameOnly, + (source, destination) => File.Move(source, destination, overwrite: true)); +} diff --git a/ShrinkSDK.Godot.csproj b/ShrinkSDK.Godot.csproj index 3da04c8..0582ca3 100644 --- a/ShrinkSDK.Godot.csproj +++ b/ShrinkSDK.Godot.csproj @@ -4,17 +4,17 @@ ShrinkSDK.Godot ShrinkSDK.Godot ShrinkSDK.Godot - 0.1.5 - Godot 4.6 host and platform services for ShrinkSDK. + 0.2.1 + Godot 4.7 host and platform services for ShrinkSDK. false - + - - - - - + + + + +