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;