From 40f5fd34b7051674cd281109fe031ac437e98b73 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:05:25 +0800 Subject: [PATCH 01/14] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=AE=8C=E6=95=B4=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增面向静态游戏内容的 AI-First 配表方案介绍 - 详细说明 YAML 作为配置源文件和 JSON Schema 结构描述功能 - 提供推荐目录结构和 Schema 示例配置 - 添加 VS Code 插件工具支持说明 - 包含 Godot 文本配置桥接使用指南 - 提供运行时读取和热重载模板示例 - 说明生成器接入约定和运行时校验行为 - 添加开发期热重载和工具支持详细说明 - 创建 Godot 测试项目配置文件 - 实现 GodotYamlConfigLoader 配置加载适配层 --- .../Config/GodotYamlConfigLoaderTests.cs | 286 +++++++++++ .../GFramework.Godot.Tests.csproj | 2 + .../Config/GodotYamlConfigLoader.cs | 468 ++++++++++++++++++ .../Config/GodotYamlConfigLoaderOptions.cs | 36 ++ .../Config/GodotYamlConfigTableSource.cs | 56 +++ GFramework.Godot/Properties/AssemblyInfo.cs | 3 + docs/zh-CN/game/config-system.md | 57 +++ 7 files changed, 908 insertions(+) create mode 100644 GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs create mode 100644 GFramework.Godot/Config/GodotYamlConfigLoader.cs create mode 100644 GFramework.Godot/Config/GodotYamlConfigLoaderOptions.cs create mode 100644 GFramework.Godot/Config/GodotYamlConfigTableSource.cs create mode 100644 GFramework.Godot/Properties/AssemblyInfo.cs diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs new file mode 100644 index 00000000..52ef30fa --- /dev/null +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs @@ -0,0 +1,286 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GFramework.Godot.Config; + +namespace GFramework.Godot.Tests.Config; + +/// +/// 验证 Godot YAML 配置加载器能够在编辑器态直读项目目录,并在导出态同步运行时缓存。 +/// +[TestFixture] +public sealed class GodotYamlConfigLoaderTests +{ + private string _resourceRoot = null!; + private string _testRoot = null!; + private string _userRoot = null!; + + /// + /// 为每个测试准备独立的资源根目录与用户目录。 + /// + [SetUp] + public void SetUp() + { + _testRoot = Path.Combine( + Path.GetTempPath(), + "GFramework.GodotYamlConfigLoaderTests", + Guid.NewGuid().ToString("N")); + _resourceRoot = Path.Combine(_testRoot, "res-root"); + _userRoot = Path.Combine(_testRoot, "user-root"); + Directory.CreateDirectory(_resourceRoot); + Directory.CreateDirectory(_userRoot); + } + + /// + /// 清理测试期间创建的临时目录。 + /// + [TearDown] + public void TearDown() + { + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, true); + } + } + + /// + /// 验证导出态会把注册过的 YAML 与 schema 文本同步到运行时缓存,再交给底层加载器。 + /// + [Test] + public async Task LoadAsync_Should_Copy_Registered_Text_Assets_Into_Runtime_Cache_When_Source_Is_Res_Path() + { + CreateMonsterFiles(_resourceRoot); + + var loader = CreateLoader(isEditor: false); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + var cacheRoot = Path.Combine(_userRoot, "config_cache"); + + Assert.Multiple(() => + { + Assert.That(loader.CanEnableHotReload, Is.False); + Assert.That(loader.LoaderRootPath, Is.EqualTo(cacheRoot)); + Assert.That(table.Count, Is.EqualTo(2)); + Assert.That(table.Get(1).Name, Is.EqualTo("Slime")); + Assert.That(File.Exists(Path.Combine(cacheRoot, "monster", "slime.yaml")), Is.True); + Assert.That(File.Exists(Path.Combine(cacheRoot, "monster", "goblin.yml")), Is.True); + Assert.That(File.Exists(Path.Combine(cacheRoot, "schemas", "monster.schema.json")), Is.True); + Assert.That(File.Exists(Path.Combine(cacheRoot, "monster", "notes.txt")), Is.False); + Assert.That(Directory.Exists(Path.Combine(cacheRoot, "monster", "nested")), Is.False); + }); + } + + /// + /// 验证编辑器态会直接使用全局化后的项目目录,而不会额外创建运行时缓存副本。 + /// + [Test] + public async Task LoadAsync_Should_Use_Globalized_Res_Directory_Directly_When_Running_In_Editor() + { + CreateMonsterFiles(_resourceRoot); + + var loader = CreateLoader(isEditor: true); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(loader.CanEnableHotReload, Is.True); + Assert.That(loader.LoaderRootPath, Is.EqualTo(_resourceRoot)); + Assert.That(table.Count, Is.EqualTo(2)); + Assert.That(table.Get(2).Hp, Is.EqualTo(30)); + Assert.That(Directory.Exists(Path.Combine(_userRoot, "config_cache")), Is.False); + }); + } + + /// + /// 验证当实例必须依赖运行时缓存时,不允许再直接启用底层文件热重载。 + /// + [Test] + public void EnableHotReload_Should_Throw_When_Source_Root_Cannot_Be_Used_Directly() + { + var loader = CreateLoader(isEditor: false); + + var exception = Assert.Throws(() => + loader.EnableHotReload(new ConfigRegistry())); + + Assert.That(exception!.Message, Does.Contain("Hot reload")); + } + + /// + /// 创建一个基于临时目录映射的 Godot YAML 配置加载器。 + /// + /// 是否模拟编辑器环境。 + /// 已配置好的加载器实例。 + private GodotYamlConfigLoader CreateLoader(bool isEditor) + { + return new GodotYamlConfigLoader( + new GodotYamlConfigLoaderOptions + { + SourceRootPath = "res://", + RuntimeCacheRootPath = "user://config_cache", + TableSources = + [ + new GodotYamlConfigTableSource( + "monster", + "monster", + "schemas/monster.schema.json") + ], + ConfigureLoader = static loader => + loader.RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id) + }, + CreateEnvironment(isEditor)); + } + + /// + /// 创建一个把 res://user:// 映射到临时目录的测试环境。 + /// + /// 是否模拟编辑器环境。 + /// 测试专用环境对象。 + private GodotYamlConfigEnvironment CreateEnvironment(bool isEditor) + { + return new GodotYamlConfigEnvironment( + () => isEditor, + path => MapGodotPath(path), + path => + { + var absolutePath = MapGodotPath(path); + if (!Directory.Exists(absolutePath)) + { + return null; + } + + return Directory + .EnumerateFileSystemEntries(absolutePath, "*", SearchOption.TopDirectoryOnly) + .Select(static entryPath => new GodotYamlConfigDirectoryEntry( + Path.GetFileName(entryPath), + Directory.Exists(entryPath))) + .ToArray(); + }, + path => File.Exists(MapGodotPath(path)), + path => File.ReadAllBytes(MapGodotPath(path))); + } + + /// + /// 创建一组最小可运行的 monster YAML 与 schema 文件。 + /// + /// 目标根目录。 + private static void CreateMonsterFiles(string rootPath) + { + WriteFile( + rootPath, + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" } + } + } + """); + WriteFile( + rootPath, + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + WriteFile( + rootPath, + "monster/goblin.yml", + """ + id: 2 + name: Goblin + hp: 30 + """); + WriteFile( + rootPath, + "monster/notes.txt", + "ignored"); + WriteFile( + rootPath, + "monster/nested/ghost.yaml", + """ + id: 3 + name: Ghost + hp: 99 + """); + } + + /// + /// 把逻辑相对路径写入指定根目录。 + /// + /// 目标根目录。 + /// 相对文件路径。 + /// 文件内容。 + private static void WriteFile(string rootPath, string relativePath, string content) + { + var fullPath = Path.Combine(rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var directoryPath = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + File.WriteAllText(fullPath, content); + } + + /// + /// 将测试中的 Godot 路径映射到本地临时目录。 + /// + /// Godot 路径或普通路径。 + /// 映射后的绝对路径。 + private string MapGodotPath(string path) + { + if (path.StartsWith("res://", StringComparison.Ordinal)) + { + return Path.Combine( + _resourceRoot, + path["res://".Length..].Replace('/', Path.DirectorySeparatorChar)); + } + + if (path.StartsWith("user://", StringComparison.Ordinal)) + { + return Path.Combine( + _userRoot, + path["user://".Length..].Replace('/', Path.DirectorySeparatorChar)); + } + + return path; + } + + /// + /// 最小 monster 配置桩类型。 + /// + private sealed class MonsterConfigStub + { + /// + /// 主键。 + /// + public int Id { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 生命值。 + /// + public int Hp { get; init; } + } +} diff --git a/GFramework.Godot.Tests/GFramework.Godot.Tests.csproj b/GFramework.Godot.Tests/GFramework.Godot.Tests.csproj index 3c277233..fea6d616 100644 --- a/GFramework.Godot.Tests/GFramework.Godot.Tests.csproj +++ b/GFramework.Godot.Tests/GFramework.Godot.Tests.csproj @@ -18,6 +18,8 @@ + + diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs new file mode 100644 index 00000000..35b59196 --- /dev/null +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -0,0 +1,468 @@ +using System.IO; +using GFramework.Core.Abstractions.Events; +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; +using GFramework.Godot.Extensions; + +namespace GFramework.Godot.Config; + +/// +/// 为 Godot 运行时提供 YAML 配置加载适配层。 +/// 编辑器态优先直接把项目目录交给 , +/// 导出态则把显式声明的 YAML 与 schema 文本同步到运行时缓存目录后再加载。 +/// +public sealed class GodotYamlConfigLoader : IConfigLoader +{ + private readonly GodotYamlConfigEnvironment _environment; + private readonly YamlConfigLoader _loader; + private readonly GodotYamlConfigLoaderOptions _options; + + /// + /// 使用指定选项创建一个 Godot YAML 配置加载器。 + /// + /// 加载器初始化选项。 + public GodotYamlConfigLoader(GodotYamlConfigLoaderOptions options) + : this(options, GodotYamlConfigEnvironment.Default) + { + } + + internal GodotYamlConfigLoader( + GodotYamlConfigLoaderOptions options, + GodotYamlConfigEnvironment environment) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(environment); + + if (string.IsNullOrWhiteSpace(options.SourceRootPath)) + { + throw new ArgumentException("SourceRootPath cannot be null or whitespace.", nameof(options)); + } + + if (string.IsNullOrWhiteSpace(options.RuntimeCacheRootPath)) + { + throw new ArgumentException("RuntimeCacheRootPath cannot be null or whitespace.", nameof(options)); + } + + _options = options; + _environment = environment; + LoaderRootPath = ResolveLoaderRootPath(); + _loader = new YamlConfigLoader(LoaderRootPath); + options.ConfigureLoader?.Invoke(_loader); + } + + /// + /// 获取配置源根目录。 + /// + public string SourceRootPath => _options.SourceRootPath; + + /// + /// 获取运行时缓存根目录。 + /// + public string RuntimeCacheRootPath => _options.RuntimeCacheRootPath; + + /// + /// 获取底层 实际使用的普通文件系统根目录。 + /// + public string LoaderRootPath { get; } + + /// + /// 获取底层 实例。 + /// 调用方可继续在该实例上追加注册表定义或读取注册数量。 + /// + public YamlConfigLoader Loader => _loader; + + /// + /// 获取一个值,指示当前实例是否可直接针对源目录启用热重载。 + /// + public bool CanEnableHotReload => UsesSourceDirectoryDirectly(SourceRootPath); + + /// + public async Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(registry); + + if (!CanEnableHotReload) + { + SynchronizeRuntimeCache(cancellationToken); + } + + await _loader.LoadAsync(registry, cancellationToken); + } + + /// + /// 在当前环境允许的情况下启用底层 YAML 热重载。 + /// + /// 要被热重载更新的配置注册表。 + /// 热重载选项;为空时使用默认值。 + /// 用于停止监听的注销句柄。 + public IUnRegister EnableHotReload( + IConfigRegistry registry, + YamlConfigHotReloadOptions? options = null) + { + ArgumentNullException.ThrowIfNull(registry); + + if (!CanEnableHotReload) + { + throw new InvalidOperationException( + "Hot reload is only available when the source root can be accessed as a normal filesystem directory."); + } + + return _loader.EnableHotReload(registry, options); + } + + private string ResolveLoaderRootPath() + { + if (UsesSourceDirectoryDirectly(SourceRootPath)) + { + return EnsureAbsolutePath(SourceRootPath, nameof(GodotYamlConfigLoaderOptions.SourceRootPath)); + } + + return EnsureAbsolutePath(RuntimeCacheRootPath, nameof(GodotYamlConfigLoaderOptions.RuntimeCacheRootPath)); + } + + private bool UsesSourceDirectoryDirectly(string sourceRootPath) + { + if (!sourceRootPath.IsGodotPath()) + { + return true; + } + + if (sourceRootPath.IsUserPath()) + { + return true; + } + + return sourceRootPath.IsResPath() && _environment.IsEditor(); + } + + private void SynchronizeRuntimeCache(CancellationToken cancellationToken) + { + foreach (var group in _options.TableSources + .GroupBy(static source => NormalizeRelativePath(source.ConfigRelativePath), + StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var representative = group.First(); + var sourceDirectoryPath = CombinePath(SourceRootPath, representative.ConfigRelativePath); + var targetDirectoryPath = CombineAbsolutePath(LoaderRootPath, representative.ConfigRelativePath); + + ResetDirectory(targetDirectoryPath); + CopyYamlFilesInDirectory( + representative.TableName, + sourceDirectoryPath, + targetDirectoryPath, + cancellationToken); + } + + foreach (var group in _options.TableSources + .Where(static source => !string.IsNullOrEmpty(source.SchemaRelativePath)) + .GroupBy(static source => NormalizeRelativePath(source.SchemaRelativePath!), + StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var representative = group.First(); + var sourceSchemaPath = CombinePath(SourceRootPath, representative.SchemaRelativePath!); + var targetSchemaPath = CombineAbsolutePath(LoaderRootPath, representative.SchemaRelativePath!); + + CopySingleFile( + representative.TableName, + sourceSchemaPath, + targetSchemaPath, + ConfigLoadFailureKind.SchemaFileNotFound, + ConfigLoadFailureKind.SchemaReadFailed); + } + } + + private void CopyYamlFilesInDirectory( + string tableName, + string sourceDirectoryPath, + string targetDirectoryPath, + CancellationToken cancellationToken) + { + var entries = _environment.EnumerateDirectory(sourceDirectoryPath); + if (entries == null) + { + throw CreateConfigLoadException( + ConfigLoadFailureKind.ConfigDirectoryNotFound, + tableName, + $"Config directory '{DescribePath(sourceDirectoryPath)}' was not found while preparing the Godot runtime cache.", + configDirectoryPath: DescribePath(sourceDirectoryPath)); + } + + Directory.CreateDirectory(targetDirectoryPath); + + foreach (var entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (entry.IsDirectory || entry.Name is "." or ".." || entry.Name.StartsWith(".", StringComparison.Ordinal)) + { + continue; + } + + if (!IsYamlFile(entry.Name)) + { + continue; + } + + var sourceFilePath = CombinePath(sourceDirectoryPath, entry.Name); + var targetFilePath = Path.Combine(targetDirectoryPath, entry.Name); + CopySingleFile( + tableName, + sourceFilePath, + targetFilePath, + ConfigLoadFailureKind.ConfigFileReadFailed, + ConfigLoadFailureKind.ConfigFileReadFailed, + configDirectoryPath: DescribePath(sourceDirectoryPath), + yamlPath: DescribePath(sourceFilePath)); + } + } + + private void CopySingleFile( + string tableName, + string sourceFilePath, + string targetAbsolutePath, + ConfigLoadFailureKind missingFailureKind, + ConfigLoadFailureKind readFailureKind, + string? configDirectoryPath = null, + string? yamlPath = null) + { + if (!_environment.FileExists(sourceFilePath)) + { + var missingMessage = missingFailureKind == ConfigLoadFailureKind.SchemaFileNotFound + ? $"Schema file '{DescribePath(sourceFilePath)}' was not found while preparing the Godot runtime cache." + : $"Config file '{DescribePath(sourceFilePath)}' was not found while preparing the Godot runtime cache."; + + throw CreateConfigLoadException( + missingFailureKind, + tableName, + missingMessage, + configDirectoryPath: configDirectoryPath, + yamlPath: missingFailureKind == ConfigLoadFailureKind.SchemaFileNotFound + ? null + : yamlPath ?? DescribePath(sourceFilePath), + schemaPath: missingFailureKind == ConfigLoadFailureKind.SchemaFileNotFound + ? DescribePath(sourceFilePath) + : null); + } + + try + { + var parentDirectory = Path.GetDirectoryName(targetAbsolutePath); + if (!string.IsNullOrWhiteSpace(parentDirectory)) + { + Directory.CreateDirectory(parentDirectory); + } + + File.WriteAllBytes(targetAbsolutePath, _environment.ReadAllBytes(sourceFilePath)); + } + catch (Exception exception) + { + var readMessage = readFailureKind == ConfigLoadFailureKind.SchemaReadFailed + ? $"Failed to copy schema file '{DescribePath(sourceFilePath)}' into the Godot runtime cache." + : $"Failed to copy config file '{DescribePath(sourceFilePath)}' into the Godot runtime cache."; + + throw CreateConfigLoadException( + readFailureKind, + tableName, + readMessage, + configDirectoryPath: configDirectoryPath, + yamlPath: readFailureKind == ConfigLoadFailureKind.SchemaReadFailed + ? null + : yamlPath ?? DescribePath(sourceFilePath), + schemaPath: readFailureKind == ConfigLoadFailureKind.SchemaReadFailed + ? DescribePath(sourceFilePath) + : null, + innerException: exception); + } + } + + private void ResetDirectory(string directoryPath) + { + if (Directory.Exists(directoryPath)) + { + Directory.Delete(directoryPath, recursive: true); + } + + Directory.CreateDirectory(directoryPath); + } + + private string EnsureAbsolutePath(string path, string optionName) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("Path cannot be null or whitespace.", optionName); + } + + if (path.IsGodotPath()) + { + var absolutePath = _environment.GlobalizePath(path); + if (string.IsNullOrWhiteSpace(absolutePath)) + { + throw new InvalidOperationException( + $"Path option '{optionName}' resolved to an empty absolute path. Value='{path}'."); + } + + return absolutePath; + } + + return Path.GetFullPath(path); + } + + private string DescribePath(string path) + { + if (path.IsGodotPath()) + { + var absolutePath = _environment.GlobalizePath(path); + return string.IsNullOrWhiteSpace(absolutePath) ? path : absolutePath; + } + + return Path.GetFullPath(path); + } + + private static string CombinePath(string rootPath, string relativePath) + { + var normalizedRelativePath = NormalizeRelativePath(relativePath); + if (rootPath.IsGodotPath()) + { + if (rootPath.EndsWith("://", StringComparison.Ordinal)) + { + return $"{rootPath}{normalizedRelativePath}"; + } + + return $"{rootPath.TrimEnd('/')}/{normalizedRelativePath}"; + } + + return Path.Combine(rootPath, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar)); + } + + private static string CombineAbsolutePath(string rootPath, string relativePath) + { + return Path.Combine(rootPath, NormalizeRelativePath(relativePath).Replace('/', Path.DirectorySeparatorChar)); + } + + private static string NormalizeRelativePath(string relativePath) + { + return relativePath.Replace('\\', '/').TrimStart('/'); + } + + private static bool IsYamlFile(string fileName) + { + return fileName.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(".yml", StringComparison.OrdinalIgnoreCase); + } + + private static ConfigLoadException CreateConfigLoadException( + ConfigLoadFailureKind failureKind, + string tableName, + string message, + string? configDirectoryPath = null, + string? yamlPath = null, + string? schemaPath = null, + Exception? innerException = null) + { + return new ConfigLoadException( + new ConfigLoadDiagnostic( + failureKind, + tableName, + configDirectoryPath: configDirectoryPath, + yamlPath: yamlPath, + schemaPath: schemaPath), + message, + innerException); + } +} + +internal sealed class GodotYamlConfigEnvironment +{ + public GodotYamlConfigEnvironment( + Func isEditor, + Func globalizePath, + Func?> enumerateDirectory, + Func fileExists, + Func readAllBytes) + { + IsEditor = isEditor ?? throw new ArgumentNullException(nameof(isEditor)); + GlobalizePath = globalizePath ?? throw new ArgumentNullException(nameof(globalizePath)); + EnumerateDirectory = enumerateDirectory ?? throw new ArgumentNullException(nameof(enumerateDirectory)); + FileExists = fileExists ?? throw new ArgumentNullException(nameof(fileExists)); + ReadAllBytes = readAllBytes ?? throw new ArgumentNullException(nameof(readAllBytes)); + } + + public static GodotYamlConfigEnvironment Default { get; } = new( + static () => OS.HasFeature("editor"), + static path => ProjectSettings.GlobalizePath(path), + EnumerateDirectoryCore, + FileExistsCore, + ReadAllBytesCore); + + public Func IsEditor { get; } + + public Func GlobalizePath { get; } + + public Func?> EnumerateDirectory { get; } + + public Func FileExists { get; } + + public Func ReadAllBytes { get; } + + private static IReadOnlyList? EnumerateDirectoryCore(string path) + { + if (!path.IsGodotPath()) + { + if (!Directory.Exists(path)) + { + return null; + } + + return Directory + .EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly) + .Select(static entryPath => new GodotYamlConfigDirectoryEntry( + Path.GetFileName(entryPath), + Directory.Exists(entryPath))) + .ToArray(); + } + + using var directory = DirAccess.Open(path); + if (directory == null) + { + return null; + } + + var entries = new List(); + directory.ListDirBegin(); + while (true) + { + var name = directory.GetNext(); + if (string.IsNullOrEmpty(name)) + { + break; + } + + entries.Add(new GodotYamlConfigDirectoryEntry(name, directory.CurrentIsDir())); + } + + directory.ListDirEnd(); + return entries; + } + + private static bool FileExistsCore(string path) + { + return path.IsGodotPath() + ? FileAccess.FileExists(path) + : File.Exists(path); + } + + private static byte[] ReadAllBytesCore(string path) + { + return path.IsGodotPath() + ? FileAccess.GetFileAsBytes(path) + : File.ReadAllBytes(path); + } +} + +internal readonly record struct GodotYamlConfigDirectoryEntry( + string Name, + bool IsDirectory); diff --git a/GFramework.Godot/Config/GodotYamlConfigLoaderOptions.cs b/GFramework.Godot/Config/GodotYamlConfigLoaderOptions.cs new file mode 100644 index 00000000..9520f534 --- /dev/null +++ b/GFramework.Godot/Config/GodotYamlConfigLoaderOptions.cs @@ -0,0 +1,36 @@ +using GFramework.Game.Config; + +namespace GFramework.Godot.Config; + +/// +/// 描述 Godot YAML 配置加载器的初始化约定。 +/// +public sealed class GodotYamlConfigLoaderOptions +{ + /// + /// 获取或设置配置源根目录。 + /// 默认值为 res://,表示从项目资源路径读取 YAML 与 schema 文本。 + /// + public string SourceRootPath { get; init; } = "res://"; + + /// + /// 获取或设置运行时缓存根目录。 + /// 当 在当前环境下无法直接映射为普通文件系统目录时, + /// 加载器会先把所需文本资产复制到这里,再交给底层 。 + /// + public string RuntimeCacheRootPath { get; init; } = "user://config_cache"; + + /// + /// 获取或设置本次启动会访问到的配置表来源描述。 + /// Godot 导出态无法假设任意文本目录都可被枚举,因此调用方应显式提供参与本轮加载的配置目录与 schema 文件。 + /// + public IReadOnlyCollection TableSources { get; init; } = + Array.Empty(); + + /// + /// 获取或设置用于配置底层 的回调。 + /// 调用方通常应在这里调用生成器产出的 RegisterAllGeneratedConfigTables(), + /// 或显式注册当前场景所需的手写表定义。 + /// + public Action? ConfigureLoader { get; init; } +} diff --git a/GFramework.Godot/Config/GodotYamlConfigTableSource.cs b/GFramework.Godot/Config/GodotYamlConfigTableSource.cs new file mode 100644 index 00000000..7bba7858 --- /dev/null +++ b/GFramework.Godot/Config/GodotYamlConfigTableSource.cs @@ -0,0 +1,56 @@ +namespace GFramework.Godot.Config; + +/// +/// 描述一个 Godot YAML 配置表在资源目录中的来源信息。 +/// +public sealed class GodotYamlConfigTableSource +{ + /// + /// 初始化一个配置表来源描述。 + /// + /// 运行时表名称。 + /// 相对配置根目录的 YAML 目录。 + /// 相对配置根目录的 schema 文件路径;未启用 schema 时为空。 + public GodotYamlConfigTableSource( + string tableName, + string configRelativePath, + string? schemaRelativePath = null) + { + if (string.IsNullOrWhiteSpace(tableName)) + { + throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName)); + } + + if (string.IsNullOrWhiteSpace(configRelativePath)) + { + throw new ArgumentException("Config relative path cannot be null or whitespace.", + nameof(configRelativePath)); + } + + if (schemaRelativePath != null && string.IsNullOrWhiteSpace(schemaRelativePath)) + { + throw new ArgumentException( + "Schema relative path cannot be empty or whitespace when provided.", + nameof(schemaRelativePath)); + } + + TableName = tableName; + ConfigRelativePath = configRelativePath; + SchemaRelativePath = schemaRelativePath; + } + + /// + /// 获取运行时表名称。 + /// + public string TableName { get; } + + /// + /// 获取相对配置根目录的 YAML 目录路径。 + /// + public string ConfigRelativePath { get; } + + /// + /// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时为空。 + /// + public string? SchemaRelativePath { get; } +} diff --git a/GFramework.Godot/Properties/AssemblyInfo.cs b/GFramework.Godot/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..2159042b --- /dev/null +++ b/GFramework.Godot/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GFramework.Godot.Tests")] diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 6157ab7e..77294352 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -299,6 +299,63 @@ public sealed class GameConfigHost : IDisposable - `InitializeAsync()` 只在首次加载完整成功后才公开运行时状态,避免半初始化对象泄漏到业务层 - 热重载既可以在初始化时自动启用,也可以在初次加载后显式调用 `StartHotReload(...)` +### Godot 文本配置桥接 + +如果你的项目运行在 Godot,并且 YAML / schema 文本来自 `res://` 下的原始资源文件,推荐优先使用 +`GFramework.Godot.Config.GodotYamlConfigLoader`,而不是在项目侧手写一层 +“`res://` 遍历 + `user://` 缓存 + `YamlConfigLoader`”桥接代码。 + +原因很简单: + +- `YamlConfigLoader` 需要普通文件系统根目录 +- Godot 编辑器内的 `res://` 可以全局化到项目目录 +- Godot 导出后若仍读取原始文本资产,通常需要先把显式声明的 YAML / schema 文件同步到运行时缓存目录 + +`GodotYamlConfigLoader` 会按环境自动处理这两条路径: + +- 编辑器态:直接把 `ProjectSettings.GlobalizePath("res://...")` 交给底层 `YamlConfigLoader` +- 导出态:把当前注册会访问到的配置目录与 schema 文件同步到 `user://` 缓存,再交给底层 `YamlConfigLoader` + +推荐搭配生成器元数据一起使用,这样项目不需要再自己维护一份重复的配置目录清单: + +```csharp +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; +using GFramework.Game.Config.Generated; +using GFramework.Godot.Config; + +var registrationOptions = new GeneratedConfigRegistrationOptions +{ + IncludedConfigDomains = new[] { "gameplay", "ui" } +}; + +var tableSources = GeneratedConfigCatalog + .GetTablesForRegistration(registrationOptions) + .Select(static metadata => new GodotYamlConfigTableSource( + metadata.TableName, + metadata.ConfigRelativePath, + metadata.SchemaRelativePath)) + .ToArray(); + +var loader = new GodotYamlConfigLoader( + new GodotYamlConfigLoaderOptions + { + SourceRootPath = "res://", + RuntimeCacheRootPath = "user://config_cache", + TableSources = tableSources, + ConfigureLoader = yamlLoader => yamlLoader.RegisterAllGeneratedConfigTables(registrationOptions) + }); + +var registry = new ConfigRegistry(); +await loader.LoadAsync(registry); +``` + +使用这条路径时,还需要注意两点: + +- 导出预设必须显式包含 `.yaml`、`.yml`、`.json`、`.schema.json` 等原始文本资产;否则导出包里根本没有这些文件,任何加载器都无法读取 +- 只有当源根目录可直接映射到普通文件系统目录时,`EnableHotReload(...)` 才可用;如果当前实例依赖 `user://` + 缓存,热重载会被拒绝,而不是制造“监听了缓存目录却不反映真实源目录”的假象 + ### 运行时读取模板 推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口: From 0ea3c0ad9da9d319d807068247a60c5ee3ebafd0 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:10:06 +0800 Subject: [PATCH 02/14] =?UTF-8?q?refactor(config):=20=E6=9B=B4=E6=96=B0God?= =?UTF-8?q?ot=20YAML=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E7=9A=84?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E7=A9=BA=E9=97=B4=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为Godot.FileAccess添加using别名以避免冲突 - 添加GFramework.Game.Config命名空间引用 - 添加NUnit.Framework测试框架引用 - 优化配置加载器的依赖管理 - 提升代码可读性和维护性 - 确保测试类的正确引用关系 --- .../Config/GodotYamlConfigLoaderTests.cs | 10 ++++++---- GFramework.Godot/Config/GodotYamlConfigLoader.cs | 1 + 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs index 52ef30fa..7fa43f71 100644 --- a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs @@ -2,7 +2,9 @@ using System; using System.IO; using System.Linq; using System.Threading.Tasks; +using GFramework.Game.Config; using GFramework.Godot.Config; +using NUnit.Framework; namespace GFramework.Godot.Tests.Config; @@ -12,10 +14,6 @@ namespace GFramework.Godot.Tests.Config; [TestFixture] public sealed class GodotYamlConfigLoaderTests { - private string _resourceRoot = null!; - private string _testRoot = null!; - private string _userRoot = null!; - /// /// 为每个测试准备独立的资源根目录与用户目录。 /// @@ -44,6 +42,10 @@ public sealed class GodotYamlConfigLoaderTests } } + private string _resourceRoot = null!; + private string _testRoot = null!; + private string _userRoot = null!; + /// /// 验证导出态会把注册过的 YAML 与 schema 文本同步到运行时缓存,再交给底层加载器。 /// diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs index 35b59196..3cd9de8c 100644 --- a/GFramework.Godot/Config/GodotYamlConfigLoader.cs +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -3,6 +3,7 @@ using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Godot.Extensions; +using FileAccess = Godot.FileAccess; namespace GFramework.Godot.Config; From 411d4cb14a30394b8b550632533218162e48add4 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:25:53 +0800 Subject: [PATCH 03/14] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=AE=8C=E6=95=B4=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增配置系统概述和核心能力介绍 - 添加Schema和YAML配置文件格式示例 - 提供推荐目录结构和接入模板 - 详细说明Generator集成和运行时加载流程 - 介绍VS Code工具和热重载功能 - 添加Godot引擎桥接适配器文档 - 说明运行时校验行为和错误处理机制 - 提供Architecture模块集成模板 - 记录当前限制和未来规划评估 --- .../Config/GodotYamlConfigTableSourceTests.cs | 71 +++++++++++ .../Config/GodotYamlConfigLoader.cs | 119 +++++++++++++++++- .../Config/GodotYamlConfigTableSource.cs | 62 ++++++++- docs/zh-CN/game/config-system.md | 4 +- 4 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs new file mode 100644 index 00000000..36f56e52 --- /dev/null +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs @@ -0,0 +1,71 @@ +using System; +using GFramework.Godot.Config; +using NUnit.Framework; + +namespace GFramework.Godot.Tests.Config; + +/// +/// 验证 Godot YAML 配置表来源描述会拒绝可能逃逸缓存根目录的不安全相对路径。 +/// +[TestFixture] +public sealed class GodotYamlConfigTableSourceTests +{ + /// + /// 验证配置目录路径必须保持为无根、无遍历段的安全相对路径。 + /// + /// 待验证的配置目录路径。 + [TestCase("../outside")] + [TestCase("./monster")] + [TestCase("monster/../outside")] + [TestCase("monster/./child")] + [TestCase("/monster")] + [TestCase("C:/monster")] + [TestCase("res://monster")] + [TestCase("user://monster")] + public void Constructor_Should_Throw_When_Config_Relative_Path_Is_Not_Safe(string configRelativePath) + { + var exception = Assert.Throws(() => + _ = new GodotYamlConfigTableSource("monster", configRelativePath)); + + Assert.That(exception!.ParamName, Is.EqualTo("configRelativePath")); + } + + /// + /// 验证 schema 路径在提供时也必须满足同样的安全相对路径约束。 + /// + /// 待验证的 schema 路径。 + [TestCase("../schemas/monster.schema.json")] + [TestCase("./schemas/monster.schema.json")] + [TestCase("schemas/../monster.schema.json")] + [TestCase("schemas/./monster.schema.json")] + [TestCase("/schemas/monster.schema.json")] + [TestCase("C:/schemas/monster.schema.json")] + [TestCase("res://schemas/monster.schema.json")] + [TestCase("user://schemas/monster.schema.json")] + public void Constructor_Should_Throw_When_Schema_Relative_Path_Is_Not_Safe(string schemaRelativePath) + { + var exception = Assert.Throws(() => + _ = new GodotYamlConfigTableSource("monster", "monster", schemaRelativePath)); + + Assert.That(exception!.ParamName, Is.EqualTo("schemaRelativePath")); + } + + /// + /// 验证合法的相对目录和 schema 路径仍可正常构造元数据对象。 + /// + [Test] + public void Constructor_Should_Accept_Safe_Relative_Paths() + { + var source = new GodotYamlConfigTableSource( + "monster", + "monster/configs", + "schemas/monster.schema.json"); + + Assert.Multiple(() => + { + Assert.That(source.TableName, Is.EqualTo("monster")); + Assert.That(source.ConfigRelativePath, Is.EqualTo("monster/configs")); + Assert.That(source.SchemaRelativePath, Is.EqualTo("schemas/monster.schema.json")); + }); + } +} diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs index 3cd9de8c..41282753 100644 --- a/GFramework.Godot/Config/GodotYamlConfigLoader.cs +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -27,6 +27,25 @@ public sealed class GodotYamlConfigLoader : IConfigLoader { } + /// + /// 使用指定选项和宿主环境抽象创建一个 Godot YAML 配置加载器。 + /// + /// 加载器初始化选项。 + /// + /// 封装编辑器探测、Godot 路径全局化、目录枚举与文件读取行为的宿主环境抽象。 + /// + /// + /// 时抛出。 + /// + /// + /// 或 + /// 为空白字符串时抛出。 + /// + /// + /// 该重载用于把与 Godot 引擎强耦合的环境行为收敛到可替换委托中。 + /// 编辑器态下,res:// 可以被全局化后直接交给底层 ; + /// 导出态下,则需要先同步到 user:// 缓存再切换到普通文件系统路径。 + /// internal GodotYamlConfigLoader( GodotYamlConfigLoaderOptions options, GodotYamlConfigEnvironment environment) @@ -376,8 +395,40 @@ public sealed class GodotYamlConfigLoader : IConfigLoader } } +/// +/// 抽象 与具体宿主环境之间的 Godot 路径和文件访问边界。 +/// +/// +/// 该抽象存在的原因,是编辑器态与导出态对 res://user:// 的访问方式不同: +/// 编辑器态通常可以把 Godot 特殊路径全局化后直接落到普通文件系统,而导出态往往只能通过 Godot API 读取原始文本资源, +/// 再把它们复制到运行时缓存目录。 在目录不存在或当前环境无法枚举时必须返回 +/// ,用来表达“不可访问”而不是抛出未找到异常; 则应保留底层读取失败异常, +/// 交由加载器包装成配置诊断。对于普通文件系统路径,应遵循 / 语义; +/// 对于 Godot 特殊路径,则应使用引擎提供的路径解析和读取能力。 +/// internal sealed class GodotYamlConfigEnvironment { + /// + /// 初始化一个可替换的 Godot YAML 配置宿主环境抽象。 + /// + /// 返回当前进程是否处于 Godot 编辑器态的委托。 + /// + /// 把 Godot 特殊路径转换为普通绝对路径的委托。 + /// 当前加载器仅会在输入为 res://user:// 时调用它,返回值必须为非空绝对路径。 + /// + /// + /// 枚举指定目录直接子项的委托。 + /// 当目录不存在、无法访问或当前环境无法枚举该路径时,必须返回 。 + /// + /// + /// 检查指定路径上的文件是否存在的委托。 + /// 输入既可能是 Godot 特殊路径,也可能是普通绝对路径。 + /// + /// + /// 读取指定文件完整字节内容的委托。 + /// 当文件缺失或读取失败时,应抛出底层异常,由加载器统一包装为配置加载诊断。 + /// + /// 任一委托参数为 时抛出。 public GodotYamlConfigEnvironment( Func isEditor, Func globalizePath, @@ -392,6 +443,14 @@ internal sealed class GodotYamlConfigEnvironment ReadAllBytes = readAllBytes ?? throw new ArgumentNullException(nameof(readAllBytes)); } + /// + /// 获取默认的 Godot 运行时环境实现。 + /// + /// + /// 默认实现使用 检测编辑器态, + /// 使用 处理 Godot 特殊路径, + /// 并在 Godot 路径与普通路径之间切换对应的枚举和读取 API。 + /// public static GodotYamlConfigEnvironment Default { get; } = new( static () => OS.HasFeature("editor"), static path => ProjectSettings.GlobalizePath(path), @@ -399,14 +458,40 @@ internal sealed class GodotYamlConfigEnvironment FileExistsCore, ReadAllBytesCore); + /// + /// 获取用于判断当前进程是否处于编辑器态的委托。 + /// public Func IsEditor { get; } + /// + /// 获取把 Godot 特殊路径转换为普通绝对路径的委托。 + /// + /// + /// 当前加载器只会对 res://user:// 路径调用该委托。 + /// 返回空字符串会被视为无效环境实现,并在后续路径解析阶段触发异常。 + /// public Func GlobalizePath { get; } + /// + /// 获取用于枚举目录直接子项的委托。 + /// + /// + /// 当目录不存在、无法访问,或当前环境无法枚举给定路径时,该委托必须返回 。 + /// 返回的集合只应包含当前目录下的直接子项,调用方会自行过滤隐藏项、子目录与非 YAML 文件。 + /// public Func?> EnumerateDirectory { get; } + /// + /// 获取用于检查文件是否存在的委托。 + /// public Func FileExists { get; } + /// + /// 获取用于读取文件完整字节内容的委托。 + /// + /// + /// 该委托在路径不存在、权限不足或 I/O 失败时应抛出底层异常,以便加载器保留失败原因并生成诊断信息。 + /// public Func ReadAllBytes { get; } private static IReadOnlyList? EnumerateDirectoryCore(string path) @@ -464,6 +549,34 @@ internal sealed class GodotYamlConfigEnvironment } } -internal readonly record struct GodotYamlConfigDirectoryEntry( - string Name, - bool IsDirectory); +/// +/// 描述一次目录枚举返回的单个子项。 +/// +/// +/// 该结构只承载目录扫描阶段需要的最小信息。 +/// 必须是单个目录项名称,而不是包含父目录的完整路径; +/// 对于 Godot 路径和普通路径都遵循相同约定,便于加载器统一做后续拼接与过滤。 +/// +internal readonly record struct GodotYamlConfigDirectoryEntry +{ + /// + /// 初始化一个目录枚举结果项。 + /// + /// 当前目录项的名称,不包含父目录路径。 + /// 指示该目录项是否为子目录。 + public GodotYamlConfigDirectoryEntry(string name, bool isDirectory) + { + Name = name; + IsDirectory = isDirectory; + } + + /// + /// 获取当前目录项的名称,不包含父目录路径。 + /// + public string Name { get; } + + /// + /// 获取一个值,指示当前目录项是否为子目录。 + /// + public bool IsDirectory { get; } +} diff --git a/GFramework.Godot/Config/GodotYamlConfigTableSource.cs b/GFramework.Godot/Config/GodotYamlConfigTableSource.cs index 7bba7858..4d28826a 100644 --- a/GFramework.Godot/Config/GodotYamlConfigTableSource.cs +++ b/GFramework.Godot/Config/GodotYamlConfigTableSource.cs @@ -1,3 +1,5 @@ +using System.IO; + namespace GFramework.Godot.Config; /// @@ -9,8 +11,18 @@ public sealed class GodotYamlConfigTableSource /// 初始化一个配置表来源描述。 /// /// 运行时表名称。 - /// 相对配置根目录的 YAML 目录。 - /// 相对配置根目录的 schema 文件路径;未启用 schema 时为空。 + /// + /// 相对配置根目录的 YAML 目录。 + /// 该路径必须保持为无根相对路径,且不能包含 ...res://user:// 或磁盘根路径前缀。 + /// + /// + /// 相对配置根目录的 schema 文件路径;未启用 schema 时为空。 + /// 如果提供,同样必须保持为无根相对路径,且不能包含 ... 或任何绝对路径前缀。 + /// + /// + /// + /// 不满足非空白且安全相对路径的约束时抛出。 + /// public GodotYamlConfigTableSource( string tableName, string configRelativePath, @@ -27,6 +39,13 @@ public sealed class GodotYamlConfigTableSource nameof(configRelativePath)); } + if (!IsSafeRelativePath(configRelativePath)) + { + throw new ArgumentException( + "Config relative path must be a safe relative path without root segments or traversal markers.", + nameof(configRelativePath)); + } + if (schemaRelativePath != null && string.IsNullOrWhiteSpace(schemaRelativePath)) { throw new ArgumentException( @@ -34,6 +53,13 @@ public sealed class GodotYamlConfigTableSource nameof(schemaRelativePath)); } + if (schemaRelativePath != null && !IsSafeRelativePath(schemaRelativePath)) + { + throw new ArgumentException( + "Schema relative path must be a safe relative path without root segments or traversal markers.", + nameof(schemaRelativePath)); + } + TableName = tableName; ConfigRelativePath = configRelativePath; SchemaRelativePath = schemaRelativePath; @@ -46,11 +72,43 @@ public sealed class GodotYamlConfigTableSource /// /// 获取相对配置根目录的 YAML 目录路径。 + /// 该值始终保持为无根相对路径,不会包含 ... 段。 /// public string ConfigRelativePath { get; } /// /// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时为空。 + /// 该值在非空时始终保持为无根相对路径,不会包含 ... 段。 /// public string? SchemaRelativePath { get; } + + private static bool IsSafeRelativePath(string path) + { + var normalizedPath = path.Replace('\\', '/'); + if (normalizedPath.StartsWith("/", StringComparison.Ordinal) || + normalizedPath.StartsWith("res://", StringComparison.Ordinal) || + normalizedPath.StartsWith("user://", StringComparison.Ordinal) || + Path.IsPathRooted(path) || + HasWindowsDrivePrefix(normalizedPath)) + { + return false; + } + + foreach (var segment in normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + if (segment is "." or "..") + { + return false; + } + } + + return true; + } + + private static bool HasWindowsDrivePrefix(string path) + { + return path.Length >= 2 && + char.IsLetter(path[0]) && + path[1] == ':'; + } } diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 77294352..23f204cf 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -96,7 +96,7 @@ GameProject/ - 必须是 JSON 字符串 - 必须是相对路径 -- 不允许包含 `..` 段 +- 不允许包含 `.` 或 `..` 段,也不能写成绝对路径 - 生成器会把反斜杠标准化为 `/` ## YAML 示例 @@ -314,7 +314,7 @@ public sealed class GameConfigHost : IDisposable `GodotYamlConfigLoader` 会按环境自动处理这两条路径: - 编辑器态:直接把 `ProjectSettings.GlobalizePath("res://...")` 交给底层 `YamlConfigLoader` -- 导出态:把当前注册会访问到的配置目录与 schema 文件同步到 `user://` 缓存,再交给底层 `YamlConfigLoader` +- 导出态:会将当前注册会访问到的 YAML 配置目录与 schema 文件同步到 `user://` 缓存,再交给底层 `YamlConfigLoader` 推荐搭配生成器元数据一起使用,这样项目不需要再自己维护一份重复的配置目录清单: From e746297496b1d00e97524c9ea1c91237103c82a8 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:04:29 +0800 Subject: [PATCH 04/14] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=20Godo?= =?UTF-8?q?t=20YAML=20=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 GodotYamlConfigLoader 类,提供 YAML 配置加载适配层 - 支持编辑器态直接读取项目目录和导出态运行时缓存同步 - 添加 GodotYamlConfigEnvironment 抽象处理 Godot 路径和文件访问 - 实现配置文件同步机制,支持 YAML 和 schema 文件复制 - 提供热重载功能,在编辑器态下支持配置实时更新 - 添加完整的单元测试验证各种场景下的配置加载行为 --- .../Config/GodotYamlConfigLoaderTests.cs | 172 +++++++++++++++++- .../Config/GodotYamlConfigLoader.cs | 40 +++- 2 files changed, 202 insertions(+), 10 deletions(-) diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs index 7fa43f71..b38bdcec 100644 --- a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using GFramework.Game.Config; using GFramework.Godot.Config; @@ -115,31 +118,140 @@ public sealed class GodotYamlConfigLoaderTests Assert.That(exception!.Message, Does.Contain("Hot reload")); } + /// + /// 验证导出态会按父目录优先同步缓存,避免父目录重置删掉先前复制到子目录的内容。 + /// + [Test] + public async Task LoadAsync_Should_Synchronize_Parent_Directories_Before_Children() + { + WriteFile( + _resourceRoot, + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + WriteFile( + _resourceRoot, + "monster/boss/dragon.yaml", + """ + id: 99 + name: Dragon + hp: 500 + """); + + var loader = CreateLoader( + isEditor: false, + tableSources: + [ + new GodotYamlConfigTableSource("boss", "monster/boss"), + new GodotYamlConfigTableSource("monster", "monster") + ], + configureLoader: loader => + { + loader.RegisterTable( + "boss", + "monster/boss", + keySelector: static config => config.Id); + loader.RegisterTable( + "monster", + "monster", + keySelector: static config => config.Id); + }); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var cacheRoot = Path.Combine(_userRoot, "config_cache"); + var bossTable = registry.GetTable("boss"); + var monsterTable = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(monsterTable.Count, Is.EqualTo(1)); + Assert.That(bossTable.Count, Is.EqualTo(1)); + Assert.That(bossTable.Get(99).Name, Is.EqualTo("Dragon")); + Assert.That(File.Exists(Path.Combine(cacheRoot, "monster", "boss", "dragon.yaml")), Is.True); + }); + } + + /// + /// 验证加载器自身会拒绝可能逃逸缓存根目录的非法配置目录路径,即使调用方绕过了公开构造约束。 + /// + [Test] + public void LoadAsync_Should_Reject_Invalid_Config_Relative_Path_When_Metadata_Is_Corrupted() + { + var corruptedSource = CreateUnsafeTableSource("monster", "../outside"); + var loader = CreateLoader( + isEditor: false, + tableSources: [corruptedSource], + configureLoader: static _ => { }); + + var exception = Assert.ThrowsAsync(async () => + await loader.LoadAsync(new ConfigRegistry())); + + Assert.That(exception!.ParamName, Is.EqualTo("relativePath")); + } + + /// + /// 验证加载器自身会拒绝可能逃逸缓存根目录的非法 schema 路径,即使调用方绕过了公开构造约束。 + /// + [Test] + public void LoadAsync_Should_Reject_Invalid_Schema_Relative_Path_When_Metadata_Is_Corrupted() + { + WriteFile( + _resourceRoot, + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + + var corruptedSource = CreateUnsafeTableSource("monster", "monster", "../schemas/monster.schema.json"); + var loader = CreateLoader( + isEditor: false, + tableSources: [corruptedSource], + configureLoader: static _ => { }); + + var exception = Assert.ThrowsAsync(async () => + await loader.LoadAsync(new ConfigRegistry())); + + Assert.That(exception!.ParamName, Is.EqualTo("relativePath")); + } + /// /// 创建一个基于临时目录映射的 Godot YAML 配置加载器。 /// /// 是否模拟编辑器环境。 + /// 要同步的配置表来源集合;为空时使用默认 monster 表。 + /// 底层 YAML 加载器注册逻辑;为空时使用默认 monster 表注册。 /// 已配置好的加载器实例。 - private GodotYamlConfigLoader CreateLoader(bool isEditor) + private GodotYamlConfigLoader CreateLoader( + bool isEditor, + IReadOnlyCollection? tableSources = null, + Action? configureLoader = null) { return new GodotYamlConfigLoader( new GodotYamlConfigLoaderOptions { SourceRootPath = "res://", RuntimeCacheRootPath = "user://config_cache", - TableSources = + TableSources = tableSources ?? [ new GodotYamlConfigTableSource( "monster", "monster", "schemas/monster.schema.json") ], - ConfigureLoader = static loader => - loader.RegisterTable( - "monster", - "monster", - "schemas/monster.schema.json", - static config => config.Id) + ConfigureLoader = configureLoader ?? + (static loader => + loader.RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id)) }, CreateEnvironment(isEditor)); } @@ -241,6 +353,50 @@ public sealed class GodotYamlConfigLoaderTests File.WriteAllText(fullPath, content); } + /// + /// 构造一个绕过公开构造校验的配置来源对象,用于验证加载器的防御式路径校验。 + /// + /// 伪造的表名称。 + /// 伪造的配置目录路径。 + /// 伪造的 schema 路径。 + /// 已写入指定字段值的未初始化对象。 + private static GodotYamlConfigTableSource CreateUnsafeTableSource( + string tableName, + string configRelativePath, + string? schemaRelativePath = null) + { + var source = + (GodotYamlConfigTableSource)RuntimeHelpers.GetUninitializedObject(typeof(GodotYamlConfigTableSource)); + SetAutoPropertyBackingField(source, nameof(GodotYamlConfigTableSource.TableName), tableName); + SetAutoPropertyBackingField(source, nameof(GodotYamlConfigTableSource.ConfigRelativePath), configRelativePath); + SetAutoPropertyBackingField(source, nameof(GodotYamlConfigTableSource.SchemaRelativePath), schemaRelativePath); + return source; + } + + /// + /// 直接写入自动属性的编译器生成字段,用于构造损坏的测试对象。 + /// + /// 字段值类型。 + /// 要写入字段的目标对象。 + /// 对应的属性名称。 + /// 要写入的字段值。 + private static void SetAutoPropertyBackingField( + object instance, + string propertyName, + TValue value) + { + var field = instance.GetType().GetField( + $"<{propertyName}>k__BackingField", + BindingFlags.Instance | BindingFlags.NonPublic); + if (field == null) + { + throw new InvalidOperationException( + $"Backing field for property '{propertyName}' was not found on type '{instance.GetType().FullName}'."); + } + + field.SetValue(instance, value); + } + /// /// 将测试中的 Godot 路径映射到本地临时目录。 /// diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs index 41282753..7ba1e9e5 100644 --- a/GFramework.Godot/Config/GodotYamlConfigLoader.cs +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -159,7 +159,11 @@ public sealed class GodotYamlConfigLoader : IConfigLoader { foreach (var group in _options.TableSources .GroupBy(static source => NormalizeRelativePath(source.ConfigRelativePath), - StringComparer.Ordinal)) + StringComparer.Ordinal) + // Parent directories must be reset before children, otherwise resetting "a" later + // would erase files that were already synchronized into "a/b" during the same pass. + .OrderBy(static group => CountPathDepth(group.Key)) + .ThenBy(static group => group.Key, StringComparer.Ordinal)) { cancellationToken.ThrowIfCancellationRequested(); @@ -365,7 +369,39 @@ public sealed class GodotYamlConfigLoader : IConfigLoader private static string NormalizeRelativePath(string relativePath) { - return relativePath.Replace('\\', '/').TrimStart('/'); + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + + var normalizedPath = relativePath.Replace('\\', '/').Trim(); + if (normalizedPath.StartsWith("/", StringComparison.Ordinal) || + normalizedPath.StartsWith("res://", StringComparison.Ordinal) || + normalizedPath.StartsWith("user://", StringComparison.Ordinal) || + Path.IsPathRooted(normalizedPath) || + HasWindowsDrivePrefix(normalizedPath)) + { + throw new ArgumentException("Relative path must be an unrooted path.", nameof(relativePath)); + } + + var segments = normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Any(static segment => segment is "." or "..")) + { + throw new ArgumentException( + "Relative path must not contain '.' or '..' segments.", + nameof(relativePath)); + } + + return string.Join('/', segments); + } + + private static int CountPathDepth(string normalizedRelativePath) + { + return normalizedRelativePath.Count(static ch => ch == '/'); + } + + private static bool HasWindowsDrivePrefix(string path) + { + return path.Length >= 2 && + char.IsLetter(path[0]) && + path[1] == ':'; } private static bool IsYamlFile(string fileName) From 1bf5d287e9ae1b35075a866373ea9116cc57d174 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:28:49 +0800 Subject: [PATCH 05/14] =?UTF-8?q?fix(config):=20=E4=BF=AE=E5=A4=8DGodot=20?= =?UTF-8?q?YAML=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E7=9A=84?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E9=87=8D=E7=BD=AE=E5=BC=82=E5=B8=B8=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为构造函数添加ArgumentNullException和ArgumentException异常说明 - 为EnableHotReload方法添加InvalidOperationException异常说明 - 重构ResetDirectory方法以捕获目录操作异常并包装为ConfigLoadException - 添加detail参数到CreateConfigLoadException方法用于提供更详细的错误信息 - 新增单元测试验证运行时缓存目录重置失败时的异常处理 - 添加GodotYamlConfigTableSourceTests测试类验证安全相对路径约束 --- .../Config/GodotYamlConfigLoaderTests.cs | 26 ++++++++ .../Config/GodotYamlConfigTableSourceTests.cs | 10 ++++ .../Config/GodotYamlConfigLoader.cs | 59 ++++++++++++++++--- 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs index b38bdcec..6aa7a8e2 100644 --- a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Threading.Tasks; +using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Godot.Config; using NUnit.Framework; @@ -176,6 +177,31 @@ public sealed class GodotYamlConfigLoaderTests }); } + /// + /// 验证运行时缓存目录无法重置时,Godot 适配层仍会返回结构化的配置加载诊断。 + /// + [Test] + public void LoadAsync_Should_Wrap_Runtime_Cache_Directory_Reset_Failure_As_ConfigLoadException() + { + CreateMonsterFiles(_resourceRoot); + WriteFile(_userRoot, "config_cache", "occupied"); + + var loader = CreateLoader(isEditor: false); + + var exception = Assert.ThrowsAsync(async () => + await loader.LoadAsync(new ConfigRegistry())); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConfigFileReadFailed)); + Assert.That(exception.Diagnostic.TableName, Is.EqualTo("monster")); + Assert.That(exception.Diagnostic.ConfigDirectoryPath, Is.EqualTo(Path.Combine(_resourceRoot, "monster"))); + Assert.That(exception.Diagnostic.Detail, Does.Contain(Path.Combine(_userRoot, "config_cache", "monster"))); + Assert.That(exception.InnerException, Is.InstanceOf()); + }); + } + /// /// 验证加载器自身会拒绝可能逃逸缓存根目录的非法配置目录路径,即使调用方绕过了公开构造约束。 /// diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs index 36f56e52..5e4998ce 100644 --- a/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs @@ -15,11 +15,16 @@ public sealed class GodotYamlConfigTableSourceTests /// /// 待验证的配置目录路径。 [TestCase("../outside")] + [TestCase(@"..\outside")] [TestCase("./monster")] + [TestCase(@".\monster")] [TestCase("monster/../outside")] + [TestCase(@"monster\..\outside")] [TestCase("monster/./child")] + [TestCase(@"monster\.\child")] [TestCase("/monster")] [TestCase("C:/monster")] + [TestCase(@"C:\monster")] [TestCase("res://monster")] [TestCase("user://monster")] public void Constructor_Should_Throw_When_Config_Relative_Path_Is_Not_Safe(string configRelativePath) @@ -35,11 +40,16 @@ public sealed class GodotYamlConfigTableSourceTests /// /// 待验证的 schema 路径。 [TestCase("../schemas/monster.schema.json")] + [TestCase(@"..\schemas\monster.schema.json")] [TestCase("./schemas/monster.schema.json")] + [TestCase(@".\schemas\monster.schema.json")] [TestCase("schemas/../monster.schema.json")] + [TestCase(@"schemas\..\monster.schema.json")] [TestCase("schemas/./monster.schema.json")] + [TestCase(@"schemas\.\monster.schema.json")] [TestCase("/schemas/monster.schema.json")] [TestCase("C:/schemas/monster.schema.json")] + [TestCase(@"C:\schemas\monster.schema.json")] [TestCase("res://schemas/monster.schema.json")] [TestCase("user://schemas/monster.schema.json")] public void Constructor_Should_Throw_When_Schema_Relative_Path_Is_Not_Safe(string schemaRelativePath) diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs index 7ba1e9e5..68943834 100644 --- a/GFramework.Godot/Config/GodotYamlConfigLoader.cs +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -22,6 +22,20 @@ public sealed class GodotYamlConfigLoader : IConfigLoader /// 使用指定选项创建一个 Godot YAML 配置加载器。 /// /// 加载器初始化选项。 + /// 时抛出。 + /// + /// 当 或 + /// 为空白字符串时抛出。 + /// + /// + /// 当 Godot 特殊路径无法被全局化为非空绝对路径时抛出。 + /// + /// + /// 构造完成后,加载器会根据当前环境决定直接读取 ,还是先同步到 + /// 再交给底层 。 + /// 只有源根目录可直接作为普通文件系统目录访问时, 才会返回 + /// 。 + /// public GodotYamlConfigLoader(GodotYamlConfigLoaderOptions options) : this(options, GodotYamlConfigEnvironment.Default) { @@ -115,6 +129,19 @@ public sealed class GodotYamlConfigLoader : IConfigLoader /// 要被热重载更新的配置注册表。 /// 热重载选项;为空时使用默认值。 /// 用于停止监听的注销句柄。 + /// 时抛出。 + /// + /// 当当前实例必须通过运行时缓存访问配置源,无法直接监听真实源目录时抛出。 + /// + /// + /// 当 的防抖延迟小于 时, + /// 底层 会拒绝启用热重载。 + /// + /// + /// 调用前应先检查 。 + /// 当 只能通过缓存同步访问时,拒绝启用热重载是为了避免监听缓存副本后误导调用方, + /// 让其误以为源目录改动会被自动反映到运行时。 + /// public IUnRegister EnableHotReload( IConfigRegistry registry, YamlConfigHotReloadOptions? options = null) @@ -171,7 +198,7 @@ public sealed class GodotYamlConfigLoader : IConfigLoader var sourceDirectoryPath = CombinePath(SourceRootPath, representative.ConfigRelativePath); var targetDirectoryPath = CombineAbsolutePath(LoaderRootPath, representative.ConfigRelativePath); - ResetDirectory(targetDirectoryPath); + ResetDirectory(representative.TableName, sourceDirectoryPath, targetDirectoryPath); CopyYamlFilesInDirectory( representative.TableName, sourceDirectoryPath, @@ -215,8 +242,6 @@ public sealed class GodotYamlConfigLoader : IConfigLoader configDirectoryPath: DescribePath(sourceDirectoryPath)); } - Directory.CreateDirectory(targetDirectoryPath); - foreach (var entry in entries) { cancellationToken.ThrowIfCancellationRequested(); @@ -303,14 +328,28 @@ public sealed class GodotYamlConfigLoader : IConfigLoader } } - private void ResetDirectory(string directoryPath) + private void ResetDirectory(string tableName, string sourceDirectoryPath, string targetDirectoryPath) { - if (Directory.Exists(directoryPath)) + try { - Directory.Delete(directoryPath, recursive: true); - } + if (Directory.Exists(targetDirectoryPath)) + { + Directory.Delete(targetDirectoryPath, recursive: true); + } - Directory.CreateDirectory(directoryPath); + Directory.CreateDirectory(targetDirectoryPath); + } + catch (Exception exception) + { + var describedSourceDirectoryPath = DescribePath(sourceDirectoryPath); + throw CreateConfigLoadException( + ConfigLoadFailureKind.ConfigFileReadFailed, + tableName, + $"Failed to reset runtime cache directory '{targetDirectoryPath}' while preparing config directory '{describedSourceDirectoryPath}'.", + configDirectoryPath: describedSourceDirectoryPath, + detail: $"Runtime cache directory: {targetDirectoryPath}.", + innerException: exception); + } } private string EnsureAbsolutePath(string path, string optionName) @@ -417,6 +456,7 @@ public sealed class GodotYamlConfigLoader : IConfigLoader string? configDirectoryPath = null, string? yamlPath = null, string? schemaPath = null, + string? detail = null, Exception? innerException = null) { return new ConfigLoadException( @@ -425,7 +465,8 @@ public sealed class GodotYamlConfigLoader : IConfigLoader tableName, configDirectoryPath: configDirectoryPath, yamlPath: yamlPath, - schemaPath: schemaPath), + schemaPath: schemaPath, + detail: detail), message, innerException); } From 86ff04680beee934d4e0642ae4297acccd3fcb62 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:33:36 +0800 Subject: [PATCH 06/14] =?UTF-8?q?docs(config):=20=E6=9B=B4=E6=96=B0=20Godo?= =?UTF-8?q?tYamlConfigLoader=20=E5=BC=82=E6=AD=A5=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E7=9A=84=E6=96=87=E6=A1=A3=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加了详细的 XML 文档注释说明方法功能和参数 - 补充了异常情况的详细说明包括 ArgumentNullException 和 ConfigLoadException - 添加了关于运行时缓存同步执行原因的技术备注 - 保留了原有的继承特性标记并添加了完整的文档结构 --- .../Config/GodotYamlConfigLoader.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs index 68943834..5a6b6550 100644 --- a/GFramework.Godot/Config/GodotYamlConfigLoader.cs +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -3,7 +3,6 @@ using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Godot.Extensions; -using FileAccess = Godot.FileAccess; namespace GFramework.Godot.Config; @@ -110,13 +109,33 @@ public sealed class GodotYamlConfigLoader : IConfigLoader /// public bool CanEnableHotReload => UsesSourceDirectoryDirectly(SourceRootPath); - /// + /// + /// 执行 Godot 场景下的配置加载。 + /// 当源目录无法直接作为普通文件系统目录访问时,加载器会先把显式声明的 YAML 与 schema 文本同步到运行时缓存, + /// 再委托底层 完成解析与注册。 + /// + /// 用于接收配置表的注册表。 + /// 取消令牌。 + /// 表示加载流程的异步任务。 + /// 时抛出。 + /// + /// 当缓存同步、配置文件读取、schema 读取或底层 YAML 加载失败时抛出。 + /// + /// + /// 运行时缓存同步阶段刻意保持同步执行。 + /// 原因在于默认宿主环境可能需要通过 Godot 的目录和文件访问 API 读取 res:// 资源, + /// 而这些访问边界目前仅以同步委托形式暴露;同时底层 也要求缓存文件在开始读取前已经完整落盘。 + /// 这意味着当实例无法直接访问源目录时,调用线程会在进入真正的异步 YAML 解析前承担一次文件系统同步成本。 + /// public async Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(registry); if (!CanEnableHotReload) { + // Runtime cache preparation must finish before the underlying loader starts enumerating files. + // This step intentionally stays synchronous because the default Godot environment exposes + // directory enumeration and file reads through synchronous engine/file-system APIs only. SynchronizeRuntimeCache(cancellationToken); } From 82091be03c036f0ccb7c7c2ee4665f31287c0ec9 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:36:03 +0800 Subject: [PATCH 07/14] =?UTF-8?q?refactor(config):=20=E6=9B=B4=E6=96=B0God?= =?UTF-8?q?ot=20YAML=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E7=9A=84?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=AE=BF=E9=97=AE=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将Godot.FileAccess重命名为FileAccess以避免命名冲突 - 优化了配置加载器中的文件操作引用 - 提高了代码的可读性和维护性 --- GFramework.Godot/Config/GodotYamlConfigLoader.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs index 5a6b6550..e4cf2c09 100644 --- a/GFramework.Godot/Config/GodotYamlConfigLoader.cs +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -3,6 +3,7 @@ using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Godot.Extensions; +using FileAccess = Godot.FileAccess; namespace GFramework.Godot.Config; From c29c9fe8f46932d7f865304b9093411ced581890 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:37:22 +0800 Subject: [PATCH 08/14] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=A1=A8=E6=9D=A5=E6=BA=90=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=80=A7=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 GodotYamlConfigLoader 中增加对路径中冒号字符的验证,防止 Windows 无效名称和 ADS 类似语法 - 新增 GodotYamlConfigTableSource 类用于描述配置表来源信息,并实现安全路径验证 - 添加对配置路径和 schema 路径的严格安全检查,拒绝包含根路径、遍历标记或冒号字符的路径 - 扩展测试用例覆盖多种不安全路径场景,包括路径遍历、绝对路径前缀和冒号字符 - 为新功能添加完整的单元测试验证安全路径验证逻辑 --- .../Config/GodotYamlConfigLoaderTests.cs | 26 ++++++++++--------- .../Config/GodotYamlConfigTableSourceTests.cs | 5 +++- .../Config/GodotYamlConfigLoader.cs | 9 ++++++- .../Config/GodotYamlConfigTableSource.cs | 14 +++++++--- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs index 6aa7a8e2..bef0737a 100644 --- a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs @@ -6,9 +6,7 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Threading.Tasks; using GFramework.Game.Abstractions.Config; -using GFramework.Game.Config; using GFramework.Godot.Config; -using NUnit.Framework; namespace GFramework.Godot.Tests.Config; @@ -18,6 +16,10 @@ namespace GFramework.Godot.Tests.Config; [TestFixture] public sealed class GodotYamlConfigLoaderTests { + private string _resourceRoot = null!; + private string _testRoot = null!; + private string _userRoot = null!; + /// /// 为每个测试准备独立的资源根目录与用户目录。 /// @@ -46,10 +48,6 @@ public sealed class GodotYamlConfigLoaderTests } } - private string _resourceRoot = null!; - private string _testRoot = null!; - private string _userRoot = null!; - /// /// 验证导出态会把注册过的 YAML 与 schema 文本同步到运行时缓存,再交给底层加载器。 /// @@ -205,10 +203,12 @@ public sealed class GodotYamlConfigLoaderTests /// /// 验证加载器自身会拒绝可能逃逸缓存根目录的非法配置目录路径,即使调用方绕过了公开构造约束。 /// - [Test] - public void LoadAsync_Should_Reject_Invalid_Config_Relative_Path_When_Metadata_Is_Corrupted() + [TestCase("../outside")] + [TestCase("schemas:bad/monster")] + public void LoadAsync_Should_Reject_Invalid_Config_Relative_Path_When_Metadata_Is_Corrupted( + string configRelativePath) { - var corruptedSource = CreateUnsafeTableSource("monster", "../outside"); + var corruptedSource = CreateUnsafeTableSource("monster", configRelativePath); var loader = CreateLoader( isEditor: false, tableSources: [corruptedSource], @@ -223,8 +223,10 @@ public sealed class GodotYamlConfigLoaderTests /// /// 验证加载器自身会拒绝可能逃逸缓存根目录的非法 schema 路径,即使调用方绕过了公开构造约束。 /// - [Test] - public void LoadAsync_Should_Reject_Invalid_Schema_Relative_Path_When_Metadata_Is_Corrupted() + [TestCase("../schemas/monster.schema.json")] + [TestCase("schemas:bad/monster.schema.json")] + public void LoadAsync_Should_Reject_Invalid_Schema_Relative_Path_When_Metadata_Is_Corrupted( + string schemaRelativePath) { WriteFile( _resourceRoot, @@ -235,7 +237,7 @@ public sealed class GodotYamlConfigLoaderTests hp: 10 """); - var corruptedSource = CreateUnsafeTableSource("monster", "monster", "../schemas/monster.schema.json"); + var corruptedSource = CreateUnsafeTableSource("monster", "monster", schemaRelativePath); var loader = CreateLoader( isEditor: false, tableSources: [corruptedSource], diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs index 5e4998ce..ccb8ab6a 100644 --- a/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs @@ -1,6 +1,5 @@ using System; using GFramework.Godot.Config; -using NUnit.Framework; namespace GFramework.Godot.Tests.Config; @@ -27,6 +26,8 @@ public sealed class GodotYamlConfigTableSourceTests [TestCase(@"C:\monster")] [TestCase("res://monster")] [TestCase("user://monster")] + [TestCase("schemas:bad/monster")] + [TestCase(@"schemas:bad\monster")] public void Constructor_Should_Throw_When_Config_Relative_Path_Is_Not_Safe(string configRelativePath) { var exception = Assert.Throws(() => @@ -52,6 +53,8 @@ public sealed class GodotYamlConfigTableSourceTests [TestCase(@"C:\schemas\monster.schema.json")] [TestCase("res://schemas/monster.schema.json")] [TestCase("user://schemas/monster.schema.json")] + [TestCase("schemas:bad/monster.schema.json")] + [TestCase(@"schemas:bad\monster.schema.json")] public void Constructor_Should_Throw_When_Schema_Relative_Path_Is_Not_Safe(string schemaRelativePath) { var exception = Assert.Throws(() => diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs index e4cf2c09..0e32867a 100644 --- a/GFramework.Godot/Config/GodotYamlConfigLoader.cs +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -3,7 +3,6 @@ using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Godot.Extensions; -using FileAccess = Godot.FileAccess; namespace GFramework.Godot.Config; @@ -440,6 +439,14 @@ public sealed class GodotYamlConfigLoader : IConfigLoader throw new ArgumentException("Relative path must be an unrooted path.", nameof(relativePath)); } + // Reject ':' in later segments as well so Windows-invalid names and ADS-like syntax never reach file APIs. + if (normalizedPath.Contains(':', StringComparison.Ordinal)) + { + throw new ArgumentException( + "Relative path must not contain ':' characters.", + nameof(relativePath)); + } + var segments = normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries); if (segments.Any(static segment => segment is "." or "..")) { diff --git a/GFramework.Godot/Config/GodotYamlConfigTableSource.cs b/GFramework.Godot/Config/GodotYamlConfigTableSource.cs index 4d28826a..c851751d 100644 --- a/GFramework.Godot/Config/GodotYamlConfigTableSource.cs +++ b/GFramework.Godot/Config/GodotYamlConfigTableSource.cs @@ -13,11 +13,12 @@ public sealed class GodotYamlConfigTableSource /// 运行时表名称。 /// /// 相对配置根目录的 YAML 目录。 - /// 该路径必须保持为无根相对路径,且不能包含 ...res://user:// 或磁盘根路径前缀。 + /// 该路径必须保持为无根相对路径,且不能包含 ...res://user://: + /// 或磁盘根路径前缀。 /// /// /// 相对配置根目录的 schema 文件路径;未启用 schema 时为空。 - /// 如果提供,同样必须保持为无根相对路径,且不能包含 ... 或任何绝对路径前缀。 + /// 如果提供,同样必须保持为无根相对路径,且不能包含 ...: 或任何绝对路径前缀。 /// /// /// @@ -72,13 +73,13 @@ public sealed class GodotYamlConfigTableSource /// /// 获取相对配置根目录的 YAML 目录路径。 - /// 该值始终保持为无根相对路径,不会包含 ... 段。 + /// 该值始终保持为无根相对路径,不会包含 ...: 段。 /// public string ConfigRelativePath { get; } /// /// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时为空。 - /// 该值在非空时始终保持为无根相对路径,不会包含 ... 段。 + /// 该值在非空时始终保持为无根相对路径,不会包含 ...: 段。 /// public string? SchemaRelativePath { get; } @@ -94,6 +95,11 @@ public sealed class GodotYamlConfigTableSource return false; } + if (normalizedPath.Contains(':', StringComparison.Ordinal)) + { + return false; + } + foreach (var segment in normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries)) { if (segment is "." or "..") From aedc30cfd29d923a243227d4e329c32a275670ea Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:37:59 +0800 Subject: [PATCH 09/14] =?UTF-8?q?refactor(config):=20=E6=9B=B4=E6=96=B0God?= =?UTF-8?q?ot=20YAML=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E7=9A=84?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=AE=BF=E9=97=AE=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将Godot.FileAccess别名为FileAccess以提高代码可读性 - 保持现有功能不变,仅优化命名空间引用方式 --- GFramework.Godot/Config/GodotYamlConfigLoader.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs index 0e32867a..8e680600 100644 --- a/GFramework.Godot/Config/GodotYamlConfigLoader.cs +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -3,6 +3,7 @@ using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Godot.Extensions; +using FileAccess = Godot.FileAccess; namespace GFramework.Godot.Config; From abf78aa9343ed7f7ba3e36a495e7fa609156eedc Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:39:19 +0800 Subject: [PATCH 10/14] =?UTF-8?q?refactor(tests):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=A1=B9=E7=9B=AE=E7=9A=84=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 GodotYamlConfigLoaderTests 中添加 GFramework.Game.Config 引用 - 从 GodotYamlConfigTableSourceTests 中移除未使用的 System 引用 - 新增 GlobalUsings.cs 文件统一管理所有全局引用 - 将常用的系统命名空间配置为全局引用以减少重复导入 - 优化测试代码的引用管理和项目结构 --- .../Config/GodotYamlConfigLoaderTests.cs | 14 ++++------- .../Config/GodotYamlConfigTableSourceTests.cs | 1 - GFramework.Godot.Tests/GlobalUsings.cs | 25 +++++++++++++++++++ 3 files changed, 30 insertions(+), 10 deletions(-) create mode 100644 GFramework.Godot.Tests/GlobalUsings.cs diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs index bef0737a..32b16f9a 100644 --- a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs @@ -1,11 +1,7 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; -using System.Threading.Tasks; using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; using GFramework.Godot.Config; namespace GFramework.Godot.Tests.Config; @@ -16,10 +12,6 @@ namespace GFramework.Godot.Tests.Config; [TestFixture] public sealed class GodotYamlConfigLoaderTests { - private string _resourceRoot = null!; - private string _testRoot = null!; - private string _userRoot = null!; - /// /// 为每个测试准备独立的资源根目录与用户目录。 /// @@ -48,6 +40,10 @@ public sealed class GodotYamlConfigLoaderTests } } + private string _resourceRoot = null!; + private string _testRoot = null!; + private string _userRoot = null!; + /// /// 验证导出态会把注册过的 YAML 与 schema 文本同步到运行时缓存,再交给底层加载器。 /// diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs index ccb8ab6a..d0e491d8 100644 --- a/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs @@ -1,4 +1,3 @@ -using System; using GFramework.Godot.Config; namespace GFramework.Godot.Tests.Config; diff --git a/GFramework.Godot.Tests/GlobalUsings.cs b/GFramework.Godot.Tests/GlobalUsings.cs new file mode 100644 index 00000000..8a7c367a --- /dev/null +++ b/GFramework.Godot.Tests/GlobalUsings.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2025 GeWuYou +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Threading; +global using System.Threading.Tasks; +global using System.Collections.Immutable; +global using Microsoft.CodeAnalysis; +global using NUnit.Framework; +global using System.Globalization; +global using System.IO; +global using System.Text; +global using System.Text.Json; From 1c064bfe66e99f82c8543a047bb4e0f5f89ef20b Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:41:55 +0800 Subject: [PATCH 11/14] =?UTF-8?q?fix(config):=20=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=88=97=E8=A1=A8=E5=8A=A0=E8=BD=BD=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加了 ListDirBegin 方法的错误检查 - 在遇到错误时返回 null 避免异常 - 确保目录遍历前检查操作状态 --- GFramework.Godot/Config/GodotYamlConfigLoader.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs index 8e680600..217034fc 100644 --- a/GFramework.Godot/Config/GodotYamlConfigLoader.cs +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -623,7 +623,12 @@ internal sealed class GodotYamlConfigEnvironment } var entries = new List(); - directory.ListDirBegin(); + var listDirectoryError = directory.ListDirBegin(); + if (listDirectoryError != Error.Ok) + { + return null; + } + while (true) { var name = directory.GetNext(); From 0f1319334ea84d5e1faae4397786957caf9d519c Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:41:30 +0800 Subject: [PATCH 12/14] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=AE=8C=E6=95=B4=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增游戏内容配置系统详细文档,涵盖 YAML 配置、JSON Schema 结构描述 - 添加运行时只读查询、Source Generator 类型生成等功能说明 - 提供推荐目录结构、Schema 示例和 YAML 示例配置 - 添加 VS Code 插件配置浏览、校验和表单编辑功能介绍 - 提供 Godot 文本配置桥接、运行时读取模板和 Architecture 接入指南 - 说明热重载、跨表引用、查询辅助等高级功能使用方法 - 添加开发期工具和当前限制说明,提供完整的配置系统接入流程 --- GFramework.Game/Config/YamlConfigLoader.cs | 47 +++++++++++++++++-- GFramework.Game/Properties/AssemblyInfo.cs | 4 ++ .../Config/GodotYamlConfigLoaderTests.cs | 23 +++++++-- .../Config/GodotYamlConfigLoader.cs | 19 ++++++-- docs/zh-CN/game/config-system.md | 2 + 5 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 GFramework.Game/Properties/AssemblyInfo.cs diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index 15e38b1b..68f448f2 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -1,8 +1,5 @@ using System.Diagnostics; -using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; namespace GFramework.Game.Config; @@ -13,6 +10,9 @@ namespace GFramework.Game.Config; /// public sealed class YamlConfigLoader : IConfigLoader { + private const string DefaultHotReloadUnavailableMessage = + "Hot reload is not available for the current loader configuration."; + private const string RootPathCannotBeNullOrWhiteSpaceMessage = "Root path cannot be null or whitespace."; private const string TableNameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace."; private const string RelativePathCannotBeNullOrWhiteSpaceMessage = "Relative path cannot be null or whitespace."; @@ -22,7 +22,9 @@ public sealed class YamlConfigLoader : IConfigLoader private static readonly TimeSpan DefaultHotReloadDebounceDelay = TimeSpan.FromMilliseconds(200); + private readonly Func _canEnableHotReload; private readonly IDeserializer _deserializer; + private readonly string _hotReloadUnavailableMessage; private readonly Dictionary> _lastSuccessfulDependencies = new(StringComparer.Ordinal); @@ -36,6 +38,27 @@ public sealed class YamlConfigLoader : IConfigLoader /// 配置根目录。 /// 为空时抛出。 public YamlConfigLoader(string rootPath) + : this(rootPath, null, null) + { + } + + /// + /// 使用指定配置根目录与热重载可用性守卫创建 YAML 配置加载器。 + /// + /// 配置根目录。 + /// + /// 用于判断当前实例是否允许启用热重载的委托。 + /// 宿主适配层可借此把额外的文件系统前置条件下沉到底层加载器,避免公开实例被绕过时启用错误监听目标。 + /// + /// + /// 当 返回 时抛出的异常消息; + /// 为空时使用默认消息。 + /// + /// 为空时抛出。 + internal YamlConfigLoader( + string rootPath, + Func? canEnableHotReload, + string? hotReloadUnavailableMessage) { if (string.IsNullOrWhiteSpace(rootPath)) { @@ -43,6 +66,10 @@ public sealed class YamlConfigLoader : IConfigLoader } _rootPath = rootPath; + _canEnableHotReload = canEnableHotReload ?? (() => true); + _hotReloadUnavailableMessage = string.IsNullOrWhiteSpace(hotReloadUnavailableMessage) + ? DefaultHotReloadUnavailableMessage + : hotReloadUnavailableMessage; _deserializer = new DeserializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .IgnoreUnmatchedProperties() @@ -136,6 +163,7 @@ public sealed class YamlConfigLoader : IConfigLoader { ArgumentNullException.ThrowIfNull(registry); options ??= new YamlConfigHotReloadOptions(); + EnsureHotReloadCanBeEnabled(); if (options.DebounceDelay < TimeSpan.Zero) { throw new ArgumentOutOfRangeException( @@ -154,6 +182,19 @@ public sealed class YamlConfigLoader : IConfigLoader options.DebounceDelay); } + private void EnsureHotReloadCanBeEnabled() + { + if (_canEnableHotReload()) + { + return; + } + + // Host adapters can attach additional filesystem constraints to the loader instance. + // Enforcing the guard here prevents callers from bypassing the adapter by invoking + // EnableHotReload directly on the exposed loader reference. + throw new InvalidOperationException(_hotReloadUnavailableMessage); + } + private void UpdateLastSuccessfulDependencies(IEnumerable loadedTables) { _lastSuccessfulDependencies.Clear(); diff --git a/GFramework.Game/Properties/AssemblyInfo.cs b/GFramework.Game/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..61807842 --- /dev/null +++ b/GFramework.Game/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GFramework.Game.Tests")] +[assembly: InternalsVisibleTo("GFramework.Godot")] diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs index 32b16f9a..d3c0f84c 100644 --- a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs @@ -1,7 +1,6 @@ using System.Reflection; using System.Runtime.CompilerServices; using GFramework.Game.Abstractions.Config; -using GFramework.Game.Config; using GFramework.Godot.Config; namespace GFramework.Godot.Tests.Config; @@ -12,6 +11,10 @@ namespace GFramework.Godot.Tests.Config; [TestFixture] public sealed class GodotYamlConfigLoaderTests { + private string _resourceRoot = null!; + private string _testRoot = null!; + private string _userRoot = null!; + /// /// 为每个测试准备独立的资源根目录与用户目录。 /// @@ -40,10 +43,6 @@ public sealed class GodotYamlConfigLoaderTests } } - private string _resourceRoot = null!; - private string _testRoot = null!; - private string _userRoot = null!; - /// /// 验证导出态会把注册过的 YAML 与 schema 文本同步到运行时缓存,再交给底层加载器。 /// @@ -113,6 +112,20 @@ public sealed class GodotYamlConfigLoaderTests Assert.That(exception!.Message, Does.Contain("Hot reload")); } + /// + /// 验证即使调用方拿到底层加载器实例,也不能绕过 Godot 适配层施加的热重载守卫。 + /// + [Test] + public void Loader_EnableHotReload_Should_Still_Respect_Godot_HotReload_Guard() + { + var loader = CreateLoader(isEditor: false); + + var exception = Assert.Throws(() => + loader.Loader.EnableHotReload(new ConfigRegistry())); + + Assert.That(exception!.Message, Does.Contain("Hot reload")); + } + /// /// 验证导出态会按父目录优先同步缓存,避免父目录重置删掉先前复制到子目录的内容。 /// diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs index 217034fc..81b1d0d8 100644 --- a/GFramework.Godot/Config/GodotYamlConfigLoader.cs +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -3,7 +3,6 @@ using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Godot.Extensions; -using FileAccess = Godot.FileAccess; namespace GFramework.Godot.Config; @@ -14,6 +13,9 @@ namespace GFramework.Godot.Config; /// public sealed class GodotYamlConfigLoader : IConfigLoader { + private const string HotReloadUnavailableMessage = + "Hot reload is only available when the source root can be accessed as a normal filesystem directory."; + private readonly GodotYamlConfigEnvironment _environment; private readonly YamlConfigLoader _loader; private readonly GodotYamlConfigLoaderOptions _options; @@ -80,7 +82,10 @@ public sealed class GodotYamlConfigLoader : IConfigLoader _options = options; _environment = environment; LoaderRootPath = ResolveLoaderRootPath(); - _loader = new YamlConfigLoader(LoaderRootPath); + _loader = new YamlConfigLoader( + LoaderRootPath, + () => CanEnableHotReload, + HotReloadUnavailableMessage); options.ConfigureLoader?.Invoke(_loader); } @@ -103,6 +108,13 @@ public sealed class GodotYamlConfigLoader : IConfigLoader /// 获取底层 实例。 /// 调用方可继续在该实例上追加注册表定义或读取注册数量。 /// + /// + /// 该实例仅应用于补充注册表定义或检查注册状态。 + /// 不要直接调用 + /// 或 ; + /// 应分别改为调用 ,以确保 Godot 适配层先执行缓存同步并维持 + /// 守卫。 + /// public YamlConfigLoader Loader => _loader; /// @@ -170,8 +182,7 @@ public sealed class GodotYamlConfigLoader : IConfigLoader if (!CanEnableHotReload) { - throw new InvalidOperationException( - "Hot reload is only available when the source root can be accessed as a normal filesystem directory."); + throw new InvalidOperationException(HotReloadUnavailableMessage); } return _loader.EnableHotReload(registry, options); diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 23f204cf..fa5b520c 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -355,6 +355,8 @@ await loader.LoadAsync(registry); - 导出预设必须显式包含 `.yaml`、`.yml`、`.json`、`.schema.json` 等原始文本资产;否则导出包里根本没有这些文件,任何加载器都无法读取 - 只有当源根目录可直接映射到普通文件系统目录时,`EnableHotReload(...)` 才可用;如果当前实例依赖 `user://` 缓存,热重载会被拒绝,而不是制造“监听了缓存目录却不反映真实源目录”的假象 +- 如果你通过 `GodotYamlConfigLoader.Loader` 继续追加表注册,请只把它当作“注册入口”使用;实际加载和热重载必须继续调用 + `GodotYamlConfigLoader.LoadAsync(...)` 与 `GodotYamlConfigLoader.EnableHotReload(...)` ### 运行时读取模板 From 8c8373d844247589ec9f76f7c82a252baa27e416 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:44:37 +0800 Subject: [PATCH 13/14] =?UTF-8?q?refactor(config):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加事件抽象依赖项到YAML配置加载器 - 添加YamlDotNet序列化库依赖项 - 为Godot平台配置文件访问添加别名引用 - 在测试文件中添加配置依赖项引用 --- GFramework.Game/Config/YamlConfigLoader.cs | 3 +++ .../Config/GodotYamlConfigLoaderTests.cs | 9 +++++---- GFramework.Godot/Config/GodotYamlConfigLoader.cs | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index 68f448f2..ce6d9ae3 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -1,5 +1,8 @@ using System.Diagnostics; +using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; namespace GFramework.Game.Config; diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs index d3c0f84c..33a0feb6 100644 --- a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Runtime.CompilerServices; using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; using GFramework.Godot.Config; namespace GFramework.Godot.Tests.Config; @@ -11,10 +12,6 @@ namespace GFramework.Godot.Tests.Config; [TestFixture] public sealed class GodotYamlConfigLoaderTests { - private string _resourceRoot = null!; - private string _testRoot = null!; - private string _userRoot = null!; - /// /// 为每个测试准备独立的资源根目录与用户目录。 /// @@ -43,6 +40,10 @@ public sealed class GodotYamlConfigLoaderTests } } + private string _resourceRoot = null!; + private string _testRoot = null!; + private string _userRoot = null!; + /// /// 验证导出态会把注册过的 YAML 与 schema 文本同步到运行时缓存,再交给底层加载器。 /// diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs index 81b1d0d8..51742da9 100644 --- a/GFramework.Godot/Config/GodotYamlConfigLoader.cs +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -3,6 +3,7 @@ using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Godot.Extensions; +using FileAccess = Godot.FileAccess; namespace GFramework.Godot.Config; From 35849f705302997a736bcc9d92380eb7ba909948 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:52:55 +0800 Subject: [PATCH 14/14] =?UTF-8?q?refactor(tests):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20Roslyn=20=E5=88=86?= =?UTF-8?q?=E6=9E=90=E5=99=A8=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从 GlobalUsings.cs 中删除 Microsoft.CodeAnalysis 的全局引用 - 清理测试项目中不再需要的依赖项 --- GFramework.Godot.Tests/GlobalUsings.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/GFramework.Godot.Tests/GlobalUsings.cs b/GFramework.Godot.Tests/GlobalUsings.cs index 8a7c367a..70989071 100644 --- a/GFramework.Godot.Tests/GlobalUsings.cs +++ b/GFramework.Godot.Tests/GlobalUsings.cs @@ -17,7 +17,6 @@ global using System.Linq; global using System.Threading; global using System.Threading.Tasks; global using System.Collections.Immutable; -global using Microsoft.CodeAnalysis; global using NUnit.Framework; global using System.Globalization; global using System.IO;