diff --git a/AGENTS.md b/AGENTS.md index 4fd3b7b8..9cf4ec07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,24 @@ All AI agents and contributors must follow these rules when writing, reviewing, - After resolving the host Windows Git path, prefer an explicit session-local binding for subsequent commands so the shell does not fall back to Linux `/usr/bin/git` later in the same WSL session. +## Git Workflow Rules + +- Every completed task MUST pass at least one build validation before it is considered done. +- If the task changes multiple projects or shared abstractions, prefer a solution-level or affected-project + `dotnet build ... -c Release`; otherwise use the smallest build command that still proves the result compiles. +- If the required build passes and there are task-related staged or unstaged changes, contributors MUST create a Git + commit automatically instead of leaving the task uncommitted, unless the user explicitly says not to commit. +- Commit messages MUST use Conventional Commits format: `(): `. +- The commit `summary` MUST use simplified Chinese and briefly describe the main change. +- The commit `body` MUST use unordered list items, and each item MUST start with a verb such as `新增`、`修复`、`优化`、 + `更新`、`补充`、`重构`. +- Each commit body bullet MUST describe one independent change point; avoid repeated or redundant descriptions. +- Keep technical terms in English when they are established project terms, such as `API`、`Model`、`System`. +- If a new task starts while the current branch is `main`, contributors MUST first try to update local `main` from the + remote, then create and switch to a dedicated branch before making substantive changes. +- The branch naming rule for a new task branch is `/`, where `` should match the intended + Conventional Commit category as closely as practical. + ## Subagent Usage Rules - Use subagents only when the task is complex, the context is likely to grow too large, or the work can be split into diff --git a/GFramework.Core.Abstractions/Resource/IResourceManager.cs b/GFramework.Core.Abstractions/Resource/IResourceManager.cs index 267f24f6..36655180 100644 --- a/GFramework.Core.Abstractions/Resource/IResourceManager.cs +++ b/GFramework.Core.Abstractions/Resource/IResourceManager.cs @@ -22,11 +22,14 @@ public interface IResourceManager : IUtility T? Load(string path) where T : class; /// - /// 异步加载资源 + /// 异步加载指定路径的资源,并在缓存中对并发加载进行去重。 /// /// 资源类型 - /// 资源路径 - /// 资源实例,如果加载失败返回 null + /// 资源路径,不能为空或空白。 + /// 加载成功返回资源实例;加载失败返回 + /// 为空或空白时抛出。 + /// 当未注册对应资源加载器时抛出。 + /// 实现内部可能使用 ConfigureAwait(false),异步延续不保证回到调用线程。 Task LoadAsync(string path) where T : class; /// @@ -70,10 +73,14 @@ public interface IResourceManager : IUtility void UnregisterLoader() where T : class; /// - /// 预加载资源(加载但不返回) + /// 预加载资源到缓存中。 /// /// 资源类型 - /// 资源路径 + /// 资源路径,不能为空或空白。 + /// 表示预加载流程完成的任务。 + /// 为空或空白时抛出。 + /// 当未注册对应资源加载器时抛出。 + /// 内部委托给 ,同样不捕获同步上下文。 Task PreloadAsync(string path) where T : class; /// @@ -86,4 +93,4 @@ public interface IResourceManager : IUtility /// /// 资源释放策略 void SetReleaseStrategy(IResourceReleaseStrategy strategy); -} \ No newline at end of file +} diff --git a/GFramework.Core.Tests/Extensions/NumericExtensionsTests.cs b/GFramework.Core.Tests/Extensions/NumericExtensionsTests.cs index 51a06b65..31fbbc54 100644 --- a/GFramework.Core.Tests/Extensions/NumericExtensionsTests.cs +++ b/GFramework.Core.Tests/Extensions/NumericExtensionsTests.cs @@ -118,6 +118,24 @@ public class NumericExtensionsTests Assert.Throws(() => value.Between(100, 0)); } + /// + /// 测试Between方法在引用类型参数为null时抛出ArgumentNullException + /// + [Test] + public void Between_Should_Throw_ArgumentNullException_When_Reference_Arguments_Are_Null() + { + string? value = "m"; + string? min = "a"; + string? max = "z"; + + Assert.Multiple(() => + { + Assert.Throws(() => NumericExtensions.Between(value!, null!, max!)); + Assert.Throws(() => NumericExtensions.Between(value!, min!, null!)); + Assert.Throws(() => NumericExtensions.Between(null!, min!, max!)); + }); + } + /// /// 测试Lerp方法在t为0时返回起始值 /// @@ -205,4 +223,4 @@ public class NumericExtensionsTests // Arrange & Act & Assert Assert.Throws(() => 50f.InverseLerp(100f, 100f)); } -} \ No newline at end of file +} diff --git a/GFramework.Core.Tests/Extensions/ResultExtensionsTests.cs b/GFramework.Core.Tests/Extensions/ResultExtensionsTests.cs index 11992e00..9d363da5 100644 --- a/GFramework.Core.Tests/Extensions/ResultExtensionsTests.cs +++ b/GFramework.Core.Tests/Extensions/ResultExtensionsTests.cs @@ -319,7 +319,12 @@ public class ResultExtensionsTests { var result = Result.Succeed(-1); var ensured = result.Ensure(x => x > 0, "Value must be positive"); - Assert.That(ensured.Exception.Message, Is.EqualTo("Value must be positive")); + Assert.Multiple(() => + { + Assert.That(ensured.Exception.Message, Does.StartWith("Value must be positive")); + Assert.That(ensured.Exception, Is.TypeOf()); + Assert.That(((ArgumentException)ensured.Exception).ParamName, Is.EqualTo("result")); + }); } /// @@ -635,4 +640,4 @@ public class ResultExtensionsTests Assert.That(result.IsSuccess, Is.True); } -} \ No newline at end of file +} diff --git a/GFramework.Core.Tests/Functional/ResultTests.cs b/GFramework.Core.Tests/Functional/ResultTests.cs index a290e3f1..e3a3ad28 100644 --- a/GFramework.Core.Tests/Functional/ResultTests.cs +++ b/GFramework.Core.Tests/Functional/ResultTests.cs @@ -429,4 +429,20 @@ public class ResultTests // Assert Assert.That(str, Is.EqualTo("Fail(Test error)")); } -} \ No newline at end of file + + /// + /// 测试default(Result)不会在相等性比较、哈希计算和字符串格式化中触发空引用异常 + /// + [Test] + public void Default_Result_Should_Be_Safe_For_Equality_HashCode_And_ToString() + { + var result = default(Result); + + Assert.Multiple(() => + { + Assert.That(result.Equals(default(Result)), Is.True); + Assert.That(result.GetHashCode(), Is.EqualTo(0)); + Assert.That(result.ToString(), Is.EqualTo("Fail(null)")); + }); + } +} diff --git a/GFramework.Core.Tests/Logging/LoggingConfigurationTests.cs b/GFramework.Core.Tests/Logging/LoggingConfigurationTests.cs index be1e33f5..edae0876 100644 --- a/GFramework.Core.Tests/Logging/LoggingConfigurationTests.cs +++ b/GFramework.Core.Tests/Logging/LoggingConfigurationTests.cs @@ -143,6 +143,35 @@ public class LoggingConfigurationTests Assert.That(logger3.IsInfoEnabled(), Is.True); } + [Test] + public void CreateFactory_WithOverlappingLoggerPrefixes_ShouldPreferLongestPrefixMatch() + { + var json = @"{ + ""minLevel"": ""Info"", + ""appenders"": [ + { + ""type"": ""Console"", + ""formatter"": ""Default"" + } + ], + ""loggerLevels"": { + ""GFramework"": ""Warning"", + ""GFramework.Core"": ""Trace"" + } + }"; + + var config = LoggingConfigurationLoader.LoadFromJsonString(json); + var factory = LoggingConfigurationLoader.CreateFactory(config); + + var logger = factory.GetLogger("GFramework.Core.Logging"); + + Assert.Multiple(() => + { + Assert.That(logger.IsTraceEnabled(), Is.True); + Assert.That(logger.IsDebugEnabled(), Is.True); + }); + } + [Test] public void CreateFactory_WithInvalidAppenderType_ShouldThrowException() { @@ -308,4 +337,4 @@ public class LoggingConfigurationTests Assert.That(config.Appenders[2].MaxFileSize, Is.EqualTo(10485760)); Assert.That(config.LoggerLevels.Count, Is.EqualTo(3)); } -} \ No newline at end of file +} diff --git a/GFramework.Core/Coroutine/Instructions/WaitForTask.cs b/GFramework.Core/Coroutine/Instructions/WaitForTask.cs index f8e09cf1..d214d259 100644 --- a/GFramework.Core/Coroutine/Instructions/WaitForTask.cs +++ b/GFramework.Core/Coroutine/Instructions/WaitForTask.cs @@ -2,9 +2,6 @@ using GFramework.Core.Abstractions.Coroutine; namespace GFramework.Core.Coroutine.Instructions; -/// -/// 等待Task完成的等待指令 -/// /// /// 等待Task完成的等待指令 /// diff --git a/GFramework.Core/Extensions/NumericExtensions.cs b/GFramework.Core/Extensions/NumericExtensions.cs index eaa079e8..31e0b471 100644 --- a/GFramework.Core/Extensions/NumericExtensions.cs +++ b/GFramework.Core/Extensions/NumericExtensions.cs @@ -25,6 +25,10 @@ public static class NumericExtensions /// public static bool Between(this T value, T min, T max, bool inclusive = true) where T : IComparable { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(min); + ArgumentNullException.ThrowIfNull(max); + if (min.CompareTo(max) > 0) throw new ArgumentException($"最小值 ({min}) 不能大于最大值 ({max})", nameof(min)); diff --git a/GFramework.Core/Functional/Result.cs b/GFramework.Core/Functional/Result.cs index 7701eda8..034a4c5d 100644 --- a/GFramework.Core/Functional/Result.cs +++ b/GFramework.Core/Functional/Result.cs @@ -125,7 +125,10 @@ public readonly struct Result : IEquatable if (_isSuccess) return true; - return _exception!.GetType() == other._exception!.GetType() && + if (_exception is null || other._exception is null) + return _exception is null && other._exception is null; + + return _exception.GetType() == other._exception.GetType() && string.Equals(_exception.Message, other._exception.Message, StringComparison.Ordinal); } @@ -144,7 +147,12 @@ public readonly struct Result : IEquatable [Pure] public override int GetHashCode() { - return _isSuccess ? 1 : HashCode.Combine(_exception!.GetType(), _exception.Message); + if (_isSuccess) + return 1; + + return _exception is null + ? 0 + : HashCode.Combine(_exception.GetType(), _exception.Message); } /// @@ -171,7 +179,7 @@ public readonly struct Result : IEquatable /// Result 的字符串表示 [Pure] public override string ToString() => - _isSuccess ? "Success" : $"Fail({_exception!.Message})"; + _isSuccess ? "Success" : (_exception != null ? $"Fail({_exception.Message})" : "Fail(null)"); /// /// 尝试执行一个无返回值的操作,并根据执行结果返回成功或失败的 Result diff --git a/GFramework.Core/Functional/ResultExtensions.cs b/GFramework.Core/Functional/ResultExtensions.cs index 3d9d7d2d..650b4577 100644 --- a/GFramework.Core/Functional/ResultExtensions.cs +++ b/GFramework.Core/Functional/ResultExtensions.cs @@ -177,7 +177,7 @@ public static class ResultExtensions return result.Match( succ: value => predicate(value) ? result - : Result.Fail(new ArgumentException(errorMessage)), + : Result.Fail(new ArgumentException(errorMessage, nameof(result))), fail: _ => result ); } diff --git a/GFramework.Core/Logging/AppenderConfiguration.cs b/GFramework.Core/Logging/AppenderConfiguration.cs index 5922ee8b..0528b111 100644 --- a/GFramework.Core/Logging/AppenderConfiguration.cs +++ b/GFramework.Core/Logging/AppenderConfiguration.cs @@ -1,5 +1,3 @@ -using GFramework.Core.Abstractions.Logging; - namespace GFramework.Core.Logging; /// diff --git a/GFramework.Core/Logging/ConfigurableLoggerFactory.cs b/GFramework.Core/Logging/ConfigurableLoggerFactory.cs index 9bea072f..18c3400c 100644 --- a/GFramework.Core/Logging/ConfigurableLoggerFactory.cs +++ b/GFramework.Core/Logging/ConfigurableLoggerFactory.cs @@ -10,7 +10,7 @@ internal sealed class ConfigurableLoggerFactory : ILoggerFactory, IDisposable { private readonly ILogAppender[] _appenders; private readonly LoggingConfiguration _config; - private bool _disposed; + private int _disposed; /// /// 初始化一个基于日志配置创建输出管线的工厂实例。 @@ -27,7 +27,7 @@ internal sealed class ConfigurableLoggerFactory : ILoggerFactory, IDisposable /// public void Dispose() { - if (_disposed) + if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; } @@ -40,7 +40,6 @@ internal sealed class ConfigurableLoggerFactory : ILoggerFactory, IDisposable } } - _disposed = true; } /// @@ -52,15 +51,24 @@ internal sealed class ConfigurableLoggerFactory : ILoggerFactory, IDisposable public ILogger GetLogger(string name, LogLevel minLevel = LogLevel.Info) { var effectiveLevel = _config.MinLevel; + var bestMatchLength = -1; foreach (var kvp in _config.LoggerLevels) { - if (string.Equals(name, kvp.Key, StringComparison.Ordinal) || - name.StartsWith(kvp.Key + ".", StringComparison.Ordinal)) + var isExactMatch = string.Equals(name, kvp.Key, StringComparison.Ordinal); + if (isExactMatch) { effectiveLevel = kvp.Value; break; } + + var isPrefixMatch = name.StartsWith(kvp.Key + ".", StringComparison.Ordinal); + if (isPrefixMatch && kvp.Key.Length > bestMatchLength) + { + // 多个命名空间前缀都能命中时,最长前缀代表最具体的配置。 + bestMatchLength = kvp.Key.Length; + effectiveLevel = kvp.Value; + } } if (_appenders.Length == 0) diff --git a/GFramework.Core/Logging/Filters/SamplingFilter.cs b/GFramework.Core/Logging/Filters/SamplingFilter.cs index 0d170ef3..20f2965a 100644 --- a/GFramework.Core/Logging/Filters/SamplingFilter.cs +++ b/GFramework.Core/Logging/Filters/SamplingFilter.cs @@ -69,7 +69,10 @@ public sealed class SamplingFilter : ILogFilter foreach (var kvp in _samplingStates) { - if (string.Equals(kvp.Key, "*", StringComparison.Ordinal)) continue; // 不清理共享状态 + if (string.Equals(kvp.Key, "*", StringComparison.Ordinal)) + { + continue; // 不清理共享状态 + } if (kvp.Value.IsStale(now, staleThreshold)) { diff --git a/GFramework.Core/Resource/ResourceManager.cs b/GFramework.Core/Resource/ResourceManager.cs index ebd30bc7..52e3a253 100644 --- a/GFramework.Core/Resource/ResourceManager.cs +++ b/GFramework.Core/Resource/ResourceManager.cs @@ -82,8 +82,14 @@ public class ResourceManager : IResourceManager } /// - /// 异步加载资源 + /// 异步加载指定路径的资源,并在缓存中进行并发去重。 /// + /// 资源类型 + /// 资源路径,不能为空或空白。 + /// 加载成功返回资源实例;加载失败返回 + /// 为空或空白时抛出。 + /// 当未注册对应资源加载器时抛出。 + /// 内部使用 ConfigureAwait(false),后续延续不保证回到调用线程。 public async Task LoadAsync(string path) where T : class { if (string.IsNullOrWhiteSpace(path)) @@ -227,8 +233,14 @@ public class ResourceManager : IResourceManager } /// - /// 预加载资源 + /// 预加载资源到缓存中。 /// + /// 资源类型 + /// 资源路径,不能为空或空白。 + /// 表示预加载流程完成的任务。 + /// 为空或空白时抛出。 + /// 当未注册对应资源加载器时抛出。 + /// 内部委托给 ,同样不捕获同步上下文。 public async Task PreloadAsync(string path) where T : class { await LoadAsync(path).ConfigureAwait(false); diff --git a/GFramework.Cqrs/Internal/WeakKeyCache.cs b/GFramework.Cqrs/Internal/WeakKeyCache.cs index 2d97bd3d..54241dda 100644 --- a/GFramework.Cqrs/Internal/WeakKeyCache.cs +++ b/GFramework.Cqrs/Internal/WeakKeyCache.cs @@ -25,10 +25,8 @@ internal sealed class WeakKeyCache /// 缓存键。 /// 创建缓存值的工厂方法。 /// 已存在或新创建的缓存值。 - /// - /// 。 - /// 或 返回 。 - /// + /// + /// 返回 public TValue GetOrAdd(TKey key, Func valueFactory) { ArgumentNullException.ThrowIfNull(key); @@ -59,10 +57,8 @@ internal sealed class WeakKeyCache /// 创建缓存值时复用的附加状态。 /// 基于键与附加状态创建缓存值的工厂方法。 /// 已存在或新创建的缓存值。 - /// - /// 。 - /// 或 返回 。 - /// + /// + /// 返回 public TValue GetOrAdd(TKey key, TState state, Func valueFactory) { ArgumentNullException.ThrowIfNull(key); diff --git a/GFramework.Cqrs/Internal/WeakTypePairCache.cs b/GFramework.Cqrs/Internal/WeakTypePairCache.cs index 4742a908..10aff72b 100644 --- a/GFramework.Cqrs/Internal/WeakTypePairCache.cs +++ b/GFramework.Cqrs/Internal/WeakTypePairCache.cs @@ -8,6 +8,8 @@ namespace GFramework.Cqrs.Internal; /// /// 第一层和第二层键都使用弱键缓存,因此只要任一类型不再被外部引用, /// 对应条目都允许被 GC 清理,并在后续首次访问时重新建立。 +/// 线程安全:该类型支持并发访问,读写与清理由底层弱键缓存实现保证一致性。 +/// 失败模式:键被 GC 回收或调用 后,后续读取可能未命中并触发重建。 /// internal sealed class WeakTypePairCache where TValue : class @@ -25,6 +27,7 @@ internal sealed class WeakTypePairCache /// 或 /// 。 /// + /// 返回 public TValue GetOrAdd(Type primaryType, Type secondaryType, Func valueFactory) { ArgumentNullException.ThrowIfNull(primaryType); diff --git a/GFramework.Game.Abstractions/Setting/IApplyAbleSettings.cs b/GFramework.Game.Abstractions/Setting/IApplyAbleSettings.cs index bf0d43f0..2f5f199d 100644 --- a/GFramework.Game.Abstractions/Setting/IApplyAbleSettings.cs +++ b/GFramework.Game.Abstractions/Setting/IApplyAbleSettings.cs @@ -6,7 +6,11 @@ public interface IApplyAbleSettings : ISettingsSection { /// - /// 应用当前设置到系统中 + /// 异步应用当前设置到目标系统中。 /// - Task Apply(); -} \ No newline at end of file + /// + /// 表示应用流程完成的任务。 + /// 对于仅执行同步引擎调用的实现,可以返回已完成任务。 + /// + Task ApplyAsync(); +} diff --git a/GFramework.Game.Tests/Setting/GodotLocalizationSettingsTests.cs b/GFramework.Game.Tests/Setting/GodotLocalizationSettingsTests.cs index 2d651627..b431867b 100644 --- a/GFramework.Game.Tests/Setting/GodotLocalizationSettingsTests.cs +++ b/GFramework.Game.Tests/Setting/GodotLocalizationSettingsTests.cs @@ -13,7 +13,7 @@ namespace GFramework.Game.Tests.Setting; public sealed class GodotLocalizationSettingsTests { [Test] - public async Task Apply_ShouldSyncEnglishToGodotLocaleAndFrameworkLanguage() + public async Task ApplyAsync_ShouldSyncEnglishToGodotLocaleAndFrameworkLanguage() { var manager = new Mock(MockBehavior.Strict); manager.Setup(it => it.SetLanguage("eng")); @@ -21,14 +21,14 @@ public sealed class GodotLocalizationSettingsTests var applicator = CreateApplicator("English", manager.Object, locale => appliedLocale = locale); - await applicator.Apply(); + await applicator.ApplyAsync(); Assert.That(appliedLocale, Is.EqualTo("en")); manager.Verify(it => it.SetLanguage("eng"), Times.Once); } [Test] - public async Task Apply_ShouldSyncChineseToGodotLocaleAndFrameworkLanguage() + public async Task ApplyAsync_ShouldSyncChineseToGodotLocaleAndFrameworkLanguage() { var manager = new Mock(MockBehavior.Strict); manager.Setup(it => it.SetLanguage("zhs")); @@ -36,14 +36,14 @@ public sealed class GodotLocalizationSettingsTests var applicator = CreateApplicator("简体中文", manager.Object, locale => appliedLocale = locale); - await applicator.Apply(); + await applicator.ApplyAsync(); Assert.That(appliedLocale, Is.EqualTo("zh_CN")); manager.Verify(it => it.SetLanguage("zhs"), Times.Once); } [Test] - public async Task Apply_ShouldFallbackUnknownLanguageToEnglish() + public async Task ApplyAsync_ShouldFallbackUnknownLanguageToEnglish() { var manager = new Mock(MockBehavior.Strict); manager.Setup(it => it.SetLanguage("eng")); @@ -51,7 +51,7 @@ public sealed class GodotLocalizationSettingsTests var applicator = CreateApplicator("Esperanto", manager.Object, locale => appliedLocale = locale); - await applicator.Apply(); + await applicator.ApplyAsync(); Assert.That(appliedLocale, Is.EqualTo("en")); manager.Verify(it => it.SetLanguage("eng"), Times.Once); @@ -71,4 +71,4 @@ public sealed class GodotLocalizationSettingsTests return new GodotLocalizationSettings(settingsModel.Object, new LocalizationMap(), () => manager, applyGodotLocale); } -} \ No newline at end of file +} diff --git a/GFramework.Game.Tests/Setting/SettingsSystemTests.cs b/GFramework.Game.Tests/Setting/SettingsSystemTests.cs index 76fe04c5..372985e4 100644 --- a/GFramework.Game.Tests/Setting/SettingsSystemTests.cs +++ b/GFramework.Game.Tests/Setting/SettingsSystemTests.cs @@ -314,7 +314,7 @@ public sealed class SettingsSystemTests } /// - public async Task Apply() + public async Task ApplyAsync() { ApplyCount++; diff --git a/GFramework.Game/Setting/SettingsModel.cs b/GFramework.Game/Setting/SettingsModel.cs index 1c8f8915..3a187351 100644 --- a/GFramework.Game/Setting/SettingsModel.cs +++ b/GFramework.Game/Setting/SettingsModel.cs @@ -198,7 +198,7 @@ public class SettingsModel(IDataLocationProvider? locationProvider, foreach (var applicator in _applicators) try { - await applicator.Value.Apply(); + await applicator.Value.ApplyAsync(); } catch (Exception ex) { @@ -302,4 +302,4 @@ public class SettingsModel(IDataLocationProvider? locationProvider, return current; } -} \ No newline at end of file +} diff --git a/GFramework.Game/Setting/SettingsSystem.cs b/GFramework.Game/Setting/SettingsSystem.cs index 40515025..6b5b2aed 100644 --- a/GFramework.Game/Setting/SettingsSystem.cs +++ b/GFramework.Game/Setting/SettingsSystem.cs @@ -87,7 +87,7 @@ public class SettingsSystem : AbstractSystem, ISettingsSystem try { - await applyAbleSettings.Apply(); + await applyAbleSettings.ApplyAsync(); // 发送设置应用成功事件 this.SendEvent(new SettingsAppliedEvent(section, true)); } @@ -97,4 +97,4 @@ public class SettingsSystem : AbstractSystem, ISettingsSystem this.SendEvent(new SettingsAppliedEvent(section, false, ex)); } } -} \ No newline at end of file +} diff --git a/GFramework.Godot/Architectures/AbstractArchitecture.cs b/GFramework.Godot/Architectures/AbstractArchitecture.cs index aebb2df5..621e6a9a 100644 --- a/GFramework.Godot/Architectures/AbstractArchitecture.cs +++ b/GFramework.Godot/Architectures/AbstractArchitecture.cs @@ -85,7 +85,7 @@ public abstract class AbstractArchitecture( Name = _architectureAnchorName }; - _anchor.Bind(() => _ = DestroyAsync().AsTask()); + _anchor.Bind(ObserveDestroyAsync); tree.Root.CallDeferred(Node.MethodName.AddChild, _anchor); } @@ -97,16 +97,23 @@ public abstract class AbstractArchitecture( /// 模块类型,必须实现IGodotModule接口 /// 要安装的模块实例 /// 异步任务 + /// 时抛出。 + /// 当架构锚点尚未初始化时抛出。 + /// + /// 该方法会等待锚点进入场景树后再继续执行附加回调,避免模块在非主线程或未就绪状态下访问 Godot 节点 API。 + /// protected async Task InstallGodotModule(TModule module) where TModule : IGodotModule { + ArgumentNullException.ThrowIfNull(module); + module.Install(this); // 检查锚点是否已初始化,未初始化则抛出异常 if (_anchor == null) throw new InvalidOperationException("Anchor not initialized"); - // 等待锚点准备就绪 - await _anchor.WaitUntilReadyAsync().ConfigureAwait(false); + // 等待锚点准备就绪,并保持 Godot 同步上下文,以便后续附加逻辑安全访问节点 API。 + await _anchor.WaitUntilReadyAsync(); // 延迟调用将扩展节点添加为锚点的子节点 _anchor.CallDeferred(Node.MethodName.AddChild, module.Node); @@ -138,4 +145,32 @@ public abstract class AbstractArchitecture( await base.DestroyAsync().ConfigureAwait(false); } + + /// + /// 观察架构异步销毁流程,确保退出树时触发的 fire-and-forget 清理失败可见。 + /// + /// + /// Godot 的 回调是同步入口,无法直接等待异步销毁完成; + /// 因此这里显式附加错误观察器,把异常写入 Godot 错误输出,避免未观测任务异常被静默吞掉。 + /// + private void ObserveDestroyAsync() + { + _ = ObserveDestroyCoreAsync(); + } + + /// + /// 执行并观察异步销毁流程。 + /// + /// 表示观察任务本身完成的任务。 + private async Task ObserveDestroyCoreAsync() + { + try + { + await DestroyAsync(); + } + catch (Exception ex) + { + GD.PushError($"Architecture destruction failed: {ex}"); + } + } } diff --git a/GFramework.Godot/Config/GodotYamlConfigEnvironment.cs b/GFramework.Godot/Config/GodotYamlConfigEnvironment.cs index 79a40390..ec98e9b7 100644 --- a/GFramework.Godot/Config/GodotYamlConfigEnvironment.cs +++ b/GFramework.Godot/Config/GodotYamlConfigEnvironment.cs @@ -156,8 +156,28 @@ internal sealed class GodotYamlConfigEnvironment private static byte[] ReadAllBytesCore(string path) { - return path.IsGodotPath() - ? FileAccess.GetFileAsBytes(path) - : File.ReadAllBytes(path); + if (!path.IsGodotPath()) + { + return File.ReadAllBytes(path); + } + + var bytes = FileAccess.GetFileAsBytes(path); + var error = FileAccess.GetOpenError(); + if (error == Error.Ok) + { + return bytes; + } + + throw CreateReadException(path, error); + } + + private static Exception CreateReadException(string path, Error error) + { + return error switch + { + Error.FileNotFound => new FileNotFoundException($"Godot file not found: {path}", path), + Error.FileCantOpen => new IOException($"Godot could not open file '{path}'. Error: {error}"), + _ => new IOException($"Godot failed to read file '{path}'. Error: {error}") + }; } } diff --git a/GFramework.Godot/Scene/SceneBehaviorBase.cs b/GFramework.Godot/Scene/SceneBehaviorBase.cs index e3a0b083..bfee418f 100644 --- a/GFramework.Godot/Scene/SceneBehaviorBase.cs +++ b/GFramework.Godot/Scene/SceneBehaviorBase.cs @@ -144,7 +144,7 @@ public abstract class SceneBehaviorBase : ISceneBehavior public virtual async ValueTask OnPauseAsync() { if (_scene != null) - await _scene.OnPauseAsync().ConfigureAwait(false); + await _scene.OnPauseAsync(); // 暂停处理 Owner.SetProcess(false); @@ -165,7 +165,7 @@ public abstract class SceneBehaviorBase : ISceneBehavior return; if (_scene != null) - await _scene.OnResumeAsync().ConfigureAwait(false); + await _scene.OnResumeAsync(); // 恢复处理 Owner.SetProcess(true); @@ -198,7 +198,7 @@ public abstract class SceneBehaviorBase : ISceneBehavior public virtual async ValueTask OnUnloadAsync() { if (_scene != null) - await _scene.OnUnloadAsync().ConfigureAwait(false); + await _scene.OnUnloadAsync(); // 释放节点 Owner.QueueFreeX(); diff --git a/GFramework.Godot/Setting/GodotAudioSettings.cs b/GFramework.Godot/Setting/GodotAudioSettings.cs index 41abc764..7d7d1d7d 100644 --- a/GFramework.Godot/Setting/GodotAudioSettings.cs +++ b/GFramework.Godot/Setting/GodotAudioSettings.cs @@ -16,8 +16,8 @@ public class GodotAudioSettings(ISettingsModel model, AudioBusMap audioBusMap) /// /// 应用音频设置到Godot音频系统 /// - /// 表示异步操作的任务 - public Task Apply() + /// 已完成的任务;该实现只执行同步音频总线更新。 + public Task ApplyAsync() { var settings = model.GetData(); SetBus(audioBusMap.Master, settings.MasterVolume); @@ -67,4 +67,4 @@ public class GodotAudioSettings(ISettingsModel model, AudioBusMap audioBusMap) Mathf.LinearToDb(Mathf.Clamp(linear, 0.0001f, 1f)) ); } -} \ No newline at end of file +} diff --git a/GFramework.Godot/Setting/GodotGraphicsSettings.cs b/GFramework.Godot/Setting/GodotGraphicsSettings.cs index 6f2db267..6f0a3459 100644 --- a/GFramework.Godot/Setting/GodotGraphicsSettings.cs +++ b/GFramework.Godot/Setting/GodotGraphicsSettings.cs @@ -11,10 +11,10 @@ namespace GFramework.Godot.Setting; public class GodotGraphicsSettings(ISettingsModel model) : IResetApplyAbleSettings { /// - /// 应用图形设置到Godot引擎 + /// 将当前图形设置同步应用到 Godot 引擎。 /// - /// 异步任务 - public Task Apply() + /// 已完成的任务;该实现只执行同步引擎调用,不会启动后台异步工作。 + public Task ApplyAsync() { var settings = model.GetData(); // 创建分辨率向量 diff --git a/GFramework.Godot/Setting/GodotLocalizationSettings.cs b/GFramework.Godot/Setting/GodotLocalizationSettings.cs index ddcfd6fc..e34064d5 100644 --- a/GFramework.Godot/Setting/GodotLocalizationSettings.cs +++ b/GFramework.Godot/Setting/GodotLocalizationSettings.cs @@ -77,8 +77,8 @@ public class GodotLocalizationSettings : IResetApplyAbleSettings /// /// 应用本地化设置到 Godot 引擎与 GFramework 本地化管理器。 /// - /// 完成的任务 - public Task Apply() + /// 已完成的任务;该实现通过同步 API 推进语言切换。 + public Task ApplyAsync() { var settings = _model.GetData(); var locale = _localizationMap.ResolveGodotLocale(settings.Language); @@ -122,4 +122,4 @@ public class GodotLocalizationSettings : IResetApplyAbleSettings return null; } } -} \ No newline at end of file +} diff --git a/GFramework.Godot/UI/UiPageBehaviorFactory.cs b/GFramework.Godot/UI/UiPageBehaviorFactory.cs index 8b5f8319..3737e466 100644 --- a/GFramework.Godot/UI/UiPageBehaviorFactory.cs +++ b/GFramework.Godot/UI/UiPageBehaviorFactory.cs @@ -30,6 +30,7 @@ public static class UiPageBehaviorFactory /// UI 标识键 /// 目标层级 /// 对应层级的 IUiPageBehavior 实例 + /// 不是受支持的 UI 层级时抛出。 public static IUiPageBehavior Create(T owner, string key, UiLayer layer) where T : CanvasItem { diff --git a/docs/zh-CN/game/setting.md b/docs/zh-CN/game/setting.md index 33a4313d..619146db 100644 --- a/docs/zh-CN/game/setting.md +++ b/docs/zh-CN/game/setting.md @@ -72,7 +72,7 @@ public interface ISettingsModel : IModel - `RegisterApplicator()` 注册应用器,并把其 `Data` 纳入模型管理 - `InitializeAsync()` 从 `ISettingsDataRepository` 读取所有已注册设置,并在需要时执行迁移 - `SaveAllAsync()` 持久化当前所有设置数据 -- `ApplyAllAsync()` 依次调用所有 applicator 的 `Apply()` +- `ApplyAllAsync()` 依次调用所有 applicator 的 `ApplyAsync()` ## SettingsSystem @@ -139,7 +139,7 @@ public sealed class GameplaySettingsApplicator : IResetApplyAbleSettings Data.Reset(); } - public Task Apply() + public Task ApplyAsync() { var settings = (GameplaySettings)Data; TimeScale.Current = settings.GameSpeed; diff --git a/docs/zh-CN/godot/setting.md b/docs/zh-CN/godot/setting.md index 5a323626..f1c75ae5 100644 --- a/docs/zh-CN/godot/setting.md +++ b/docs/zh-CN/godot/setting.md @@ -44,7 +44,7 @@ GodotAudioSettings (Godot 特定实现) → IApplyAbleSettings (可应用设置 **功能:** - 接收 AudioSettings 配置对象和 AudioBusMap 总线映射 -- 实现 Apply() 方法,将音量设置应用到指定音频总线 +- 实现 `ApplyAsync()` 方法,将音量设置应用到指定音频总线 - 支持自定义音频总线映射 - 自动处理音量格式转换(线性值到分贝) @@ -106,7 +106,7 @@ graph TD F --> L[TranslationServer API] F --> M[ILocalizationManager] - N[SettingsSystem] --> O[Apply Method] + N[SettingsSystem] --> O[ApplyAsync Method] O --> B O --> D O --> F @@ -131,7 +131,7 @@ var settings = new AudioSettings var audioSettings = new GodotAudioSettings(settings, new AudioBusMap()); // 应用设置 -audioSettings.Apply(); +await audioSettings.ApplyAsync(); ``` #### 自定义音频总线映射 @@ -155,7 +155,7 @@ var settings = new AudioSettings // 使用自定义总线映射应用设置 var audioSettings = new GodotAudioSettings(settings, customBusMap); -await audioSettings.Apply(); +await audioSettings.ApplyAsync(); ``` #### 通过设置系统使用 @@ -170,7 +170,7 @@ audioSettingsData.SfxVolume = 0.9f; // 创建 Godot 音频设置应用器 var godotAudioSettings = new GodotAudioSettings(audioSettingsData, new AudioBusMap()); -await godotAudioSettings.Apply(); +await godotAudioSettings.ApplyAsync(); ``` ### 图形设置配置 @@ -187,7 +187,7 @@ var graphicsSettings = new GodotGraphicsSettings }; // 应用设置 -await graphicsSettings.Apply(); +await graphicsSettings.ApplyAsync(); ``` #### 窗口模式切换 @@ -205,7 +205,7 @@ public class DisplayManager : Node public async Task ToggleFullscreen() { _graphicsSettings.Fullscreen = !_graphicsSettings.Fullscreen; - await _graphicsSettings.Apply(); + await _graphicsSettings.ApplyAsync(); } public async Task SetResolution(int width, int height) @@ -213,7 +213,7 @@ public class DisplayManager : Node _graphicsSettings.ResolutionWidth = width; _graphicsSettings.ResolutionHeight = height; _graphicsSettings.Fullscreen = false; // 窗口化时自动关闭全屏 - await _graphicsSettings.Apply(); + await _graphicsSettings.ApplyAsync(); } } ``` @@ -237,7 +237,7 @@ public class ResolutionPresets settings.ResolutionWidth = width; settings.ResolutionHeight = height; settings.Fullscreen = false; - await settings.Apply(); + await settings.ApplyAsync(); } } ``` @@ -266,7 +266,7 @@ public sealed class AudioBusMap ```csharp public class GodotAudioSettings(AudioSettings settings, AudioBusMap busMap) : IApplyAbleSettings { - public Task Apply(); + public Task ApplyAsync(); } ``` @@ -278,7 +278,7 @@ public class GodotAudioSettings(AudioSettings settings, AudioBusMap busMap) : IA **Apply 方法实现:** ```csharp -public Task Apply() +public Task ApplyAsync() { SetBus(busMap.Master, settings.MasterVolume); SetBus(busMap.Bgm, settings.BgmVolume); @@ -292,7 +292,7 @@ public Task Apply() ```csharp public class GodotGraphicsSettings : GraphicsSettings, IApplyAbleSettings { - public Task Apply(); + public Task ApplyAsync(); } ``` @@ -382,7 +382,7 @@ public class AudioManager : Node { var settings = new AudioSettings { MasterVolume = linearVolume }; var audioSettings = new GodotAudioSettings(settings, new AudioBusMap()); - audioSettings.Apply(); + await audioSettings.ApplyAsync(); } } @@ -423,7 +423,7 @@ public class CustomAudioManager : Node { var audioSettingsData = new AudioSettings { MasterVolume = linearVolume }; var audioSettings = new GodotAudioSettings(audioSettingsData, _customBusMap); - audioSettings.Apply(); + await audioSettings.ApplyAsync(); } } ``` @@ -522,7 +522,7 @@ public class GraphicsSettingsManager : Node public async Task ApplyAndSave() { - await _settings.Apply(); + await _settings.ApplyAsync(); SaveSettings(); } }