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://` + 缓存,热重载会被拒绝,而不是制造“监听了缓存目录却不反映真实源目录”的假象 + ### 运行时读取模板 推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口: