using System.IO; 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; /// /// 为 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) { } /// /// 使用指定选项和宿主环境抽象创建一个 Godot YAML 配置加载器。 /// /// 加载器初始化选项。 /// /// 封装编辑器探测、Godot 路径全局化、目录枚举与文件读取行为的宿主环境抽象。 /// /// /// 时抛出。 /// /// /// 或 /// 为空白字符串时抛出。 /// /// /// 该重载用于把与 Godot 引擎强耦合的环境行为收敛到可替换委托中。 /// 编辑器态下,res:// 可以被全局化后直接交给底层 ; /// 导出态下,则需要先同步到 user:// 缓存再切换到普通文件系统路径。 /// 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); } } /// /// 抽象 与具体宿主环境之间的 Godot 路径和文件访问边界。 /// /// /// 该抽象存在的原因,是编辑器态与导出态对 res://user:// 的访问方式不同: /// 编辑器态通常可以把 Godot 特殊路径全局化后直接落到普通文件系统,而导出态往往只能通过 Godot API 读取原始文本资源, /// 再把它们复制到运行时缓存目录。 在目录不存在或当前环境无法枚举时必须返回 /// ,用来表达“不可访问”而不是抛出未找到异常; 则应保留底层读取失败异常, /// 交由加载器包装成配置诊断。对于普通文件系统路径,应遵循 / 语义; /// 对于 Godot 特殊路径,则应使用引擎提供的路径解析和读取能力。 /// internal sealed class GodotYamlConfigEnvironment { /// /// 初始化一个可替换的 Godot YAML 配置宿主环境抽象。 /// /// 返回当前进程是否处于 Godot 编辑器态的委托。 /// /// 把 Godot 特殊路径转换为普通绝对路径的委托。 /// 当前加载器仅会在输入为 res://user:// 时调用它,返回值必须为非空绝对路径。 /// /// /// 枚举指定目录直接子项的委托。 /// 当目录不存在、无法访问或当前环境无法枚举该路径时,必须返回 。 /// /// /// 检查指定路径上的文件是否存在的委托。 /// 输入既可能是 Godot 特殊路径,也可能是普通绝对路径。 /// /// /// 读取指定文件完整字节内容的委托。 /// 当文件缺失或读取失败时,应抛出底层异常,由加载器统一包装为配置加载诊断。 /// /// 任一委托参数为 时抛出。 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)); } /// /// 获取默认的 Godot 运行时环境实现。 /// /// /// 默认实现使用 检测编辑器态, /// 使用 处理 Godot 特殊路径, /// 并在 Godot 路径与普通路径之间切换对应的枚举和读取 API。 /// public static GodotYamlConfigEnvironment Default { get; } = new( static () => OS.HasFeature("editor"), static path => ProjectSettings.GlobalizePath(path), EnumerateDirectoryCore, 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) { 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); } } /// /// 描述一次目录枚举返回的单个子项。 /// /// /// 该结构只承载目录扫描阶段需要的最小信息。 /// 必须是单个目录项名称,而不是包含父目录的完整路径; /// 对于 Godot 路径和普通路径都遵循相同约定,便于加载器统一做后续拼接与过滤。 /// internal readonly record struct GodotYamlConfigDirectoryEntry { /// /// 初始化一个目录枚举结果项。 /// /// 当前目录项的名称,不包含父目录路径。 /// 指示该目录项是否为子目录。 public GodotYamlConfigDirectoryEntry(string name, bool isDirectory) { Name = name; IsDirectory = isDirectory; } /// /// 获取当前目录项的名称,不包含父目录路径。 /// public string Name { get; } /// /// 获取一个值,指示当前目录项是否为子目录。 /// public bool IsDirectory { get; } }