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; }
}