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] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?=
=?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=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://`
+ 缓存,热重载会被拒绝,而不是制造“监听了缓存目录却不反映真实源目录”的假象
+
### 运行时读取模板
推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口: