mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-08 17:44:29 +08:00
Merge pull request #209 from GeWuYou/feat/game-content-config
docs(config): 添加游戏内容配置系统完整文档
This commit is contained in:
commit
42efe1e04e
@ -13,6 +13,9 @@ namespace GFramework.Game.Config;
|
||||
/// </summary>
|
||||
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 +25,9 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
|
||||
private static readonly TimeSpan DefaultHotReloadDebounceDelay = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
private readonly Func<bool> _canEnableHotReload;
|
||||
private readonly IDeserializer _deserializer;
|
||||
private readonly string _hotReloadUnavailableMessage;
|
||||
|
||||
private readonly Dictionary<string, IReadOnlyCollection<string>> _lastSuccessfulDependencies =
|
||||
new(StringComparer.Ordinal);
|
||||
@ -36,6 +41,27 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
/// <param name="rootPath">配置根目录。</param>
|
||||
/// <exception cref="ArgumentException">当 <paramref name="rootPath" /> 为空时抛出。</exception>
|
||||
public YamlConfigLoader(string rootPath)
|
||||
: this(rootPath, null, null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定配置根目录与热重载可用性守卫创建 YAML 配置加载器。
|
||||
/// </summary>
|
||||
/// <param name="rootPath">配置根目录。</param>
|
||||
/// <param name="canEnableHotReload">
|
||||
/// 用于判断当前实例是否允许启用热重载的委托。
|
||||
/// 宿主适配层可借此把额外的文件系统前置条件下沉到底层加载器,避免公开实例被绕过时启用错误监听目标。
|
||||
/// </param>
|
||||
/// <param name="hotReloadUnavailableMessage">
|
||||
/// 当 <paramref name="canEnableHotReload" /> 返回 <see langword="false" /> 时抛出的异常消息;
|
||||
/// 为空时使用默认消息。
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentException">当 <paramref name="rootPath" /> 为空时抛出。</exception>
|
||||
internal YamlConfigLoader(
|
||||
string rootPath,
|
||||
Func<bool>? canEnableHotReload,
|
||||
string? hotReloadUnavailableMessage)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
@ -43,6 +69,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 +166,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
options ??= new YamlConfigHotReloadOptions();
|
||||
EnsureHotReloadCanBeEnabled();
|
||||
if (options.DebounceDelay < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
@ -154,6 +185,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<YamlTableLoadResult> loadedTables)
|
||||
{
|
||||
_lastSuccessfulDependencies.Clear();
|
||||
|
||||
4
GFramework.Game/Properties/AssemblyInfo.cs
Normal file
4
GFramework.Game/Properties/AssemblyInfo.cs
Normal file
@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("GFramework.Game.Tests")]
|
||||
[assembly: InternalsVisibleTo("GFramework.Godot")]
|
||||
482
GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs
Normal file
482
GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs
Normal file
@ -0,0 +1,482 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Godot YAML 配置加载器能够在编辑器态直读项目目录,并在导出态同步运行时缓存。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class GodotYamlConfigLoaderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 为每个测试准备独立的资源根目录与用户目录。
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理测试期间创建的临时目录。
|
||||
/// </summary>
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (Directory.Exists(_testRoot))
|
||||
{
|
||||
Directory.Delete(_testRoot, true);
|
||||
}
|
||||
}
|
||||
|
||||
private string _resourceRoot = null!;
|
||||
private string _testRoot = null!;
|
||||
private string _userRoot = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 验证导出态会把注册过的 YAML 与 schema 文本同步到运行时缓存,再交给底层加载器。
|
||||
/// </summary>
|
||||
[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<int, MonsterConfigStub>("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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证编辑器态会直接使用全局化后的项目目录,而不会额外创建运行时缓存副本。
|
||||
/// </summary>
|
||||
[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<int, MonsterConfigStub>("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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当实例必须依赖运行时缓存时,不允许再直接启用底层文件热重载。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void EnableHotReload_Should_Throw_When_Source_Root_Cannot_Be_Used_Directly()
|
||||
{
|
||||
var loader = CreateLoader(isEditor: false);
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
loader.EnableHotReload(new ConfigRegistry()));
|
||||
|
||||
Assert.That(exception!.Message, Does.Contain("Hot reload"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证即使调用方拿到底层加载器实例,也不能绕过 Godot 适配层施加的热重载守卫。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Loader_EnableHotReload_Should_Still_Respect_Godot_HotReload_Guard()
|
||||
{
|
||||
var loader = CreateLoader(isEditor: false);
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
loader.Loader.EnableHotReload(new ConfigRegistry()));
|
||||
|
||||
Assert.That(exception!.Message, Does.Contain("Hot reload"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证导出态会按父目录优先同步缓存,避免父目录重置删掉先前复制到子目录的内容。
|
||||
/// </summary>
|
||||
[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<int, MonsterConfigStub>(
|
||||
"boss",
|
||||
"monster/boss",
|
||||
keySelector: static config => config.Id);
|
||||
loader.RegisterTable<int, MonsterConfigStub>(
|
||||
"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<int, MonsterConfigStub>("boss");
|
||||
var monsterTable = registry.GetTable<int, MonsterConfigStub>("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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证运行时缓存目录无法重置时,Godot 适配层仍会返回结构化的配置加载诊断。
|
||||
/// </summary>
|
||||
[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<ConfigLoadException>(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<IOException>());
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证加载器自身会拒绝可能逃逸缓存根目录的非法配置目录路径,即使调用方绕过了公开构造约束。
|
||||
/// </summary>
|
||||
[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", configRelativePath);
|
||||
var loader = CreateLoader(
|
||||
isEditor: false,
|
||||
tableSources: [corruptedSource],
|
||||
configureLoader: static _ => { });
|
||||
|
||||
var exception = Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await loader.LoadAsync(new ConfigRegistry()));
|
||||
|
||||
Assert.That(exception!.ParamName, Is.EqualTo("relativePath"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证加载器自身会拒绝可能逃逸缓存根目录的非法 schema 路径,即使调用方绕过了公开构造约束。
|
||||
/// </summary>
|
||||
[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,
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
hp: 10
|
||||
""");
|
||||
|
||||
var corruptedSource = CreateUnsafeTableSource("monster", "monster", schemaRelativePath);
|
||||
var loader = CreateLoader(
|
||||
isEditor: false,
|
||||
tableSources: [corruptedSource],
|
||||
configureLoader: static _ => { });
|
||||
|
||||
var exception = Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await loader.LoadAsync(new ConfigRegistry()));
|
||||
|
||||
Assert.That(exception!.ParamName, Is.EqualTo("relativePath"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个基于临时目录映射的 Godot YAML 配置加载器。
|
||||
/// </summary>
|
||||
/// <param name="isEditor">是否模拟编辑器环境。</param>
|
||||
/// <param name="tableSources">要同步的配置表来源集合;为空时使用默认 monster 表。</param>
|
||||
/// <param name="configureLoader">底层 YAML 加载器注册逻辑;为空时使用默认 monster 表注册。</param>
|
||||
/// <returns>已配置好的加载器实例。</returns>
|
||||
private GodotYamlConfigLoader CreateLoader(
|
||||
bool isEditor,
|
||||
IReadOnlyCollection<GodotYamlConfigTableSource>? tableSources = null,
|
||||
Action<YamlConfigLoader>? configureLoader = null)
|
||||
{
|
||||
return new GodotYamlConfigLoader(
|
||||
new GodotYamlConfigLoaderOptions
|
||||
{
|
||||
SourceRootPath = "res://",
|
||||
RuntimeCacheRootPath = "user://config_cache",
|
||||
TableSources = tableSources ??
|
||||
[
|
||||
new GodotYamlConfigTableSource(
|
||||
"monster",
|
||||
"monster",
|
||||
"schemas/monster.schema.json")
|
||||
],
|
||||
ConfigureLoader = configureLoader ??
|
||||
(static loader =>
|
||||
loader.RegisterTable<int, MonsterConfigStub>(
|
||||
"monster",
|
||||
"monster",
|
||||
"schemas/monster.schema.json",
|
||||
static config => config.Id))
|
||||
},
|
||||
CreateEnvironment(isEditor));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个把 <c>res://</c> 与 <c>user://</c> 映射到临时目录的测试环境。
|
||||
/// </summary>
|
||||
/// <param name="isEditor">是否模拟编辑器环境。</param>
|
||||
/// <returns>测试专用环境对象。</returns>
|
||||
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)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一组最小可运行的 monster YAML 与 schema 文件。
|
||||
/// </summary>
|
||||
/// <param name="rootPath">目标根目录。</param>
|
||||
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
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把逻辑相对路径写入指定根目录。
|
||||
/// </summary>
|
||||
/// <param name="rootPath">目标根目录。</param>
|
||||
/// <param name="relativePath">相对文件路径。</param>
|
||||
/// <param name="content">文件内容。</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造一个绕过公开构造校验的配置来源对象,用于验证加载器的防御式路径校验。
|
||||
/// </summary>
|
||||
/// <param name="tableName">伪造的表名称。</param>
|
||||
/// <param name="configRelativePath">伪造的配置目录路径。</param>
|
||||
/// <param name="schemaRelativePath">伪造的 schema 路径。</param>
|
||||
/// <returns>已写入指定字段值的未初始化对象。</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 直接写入自动属性的编译器生成字段,用于构造损坏的测试对象。
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">字段值类型。</typeparam>
|
||||
/// <param name="instance">要写入字段的目标对象。</param>
|
||||
/// <param name="propertyName">对应的属性名称。</param>
|
||||
/// <param name="value">要写入的字段值。</param>
|
||||
private static void SetAutoPropertyBackingField<TValue>(
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将测试中的 Godot 路径映射到本地临时目录。
|
||||
/// </summary>
|
||||
/// <param name="path">Godot 路径或普通路径。</param>
|
||||
/// <returns>映射后的绝对路径。</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 最小 monster 配置桩类型。
|
||||
/// </summary>
|
||||
private sealed class MonsterConfigStub
|
||||
{
|
||||
/// <summary>
|
||||
/// 主键。
|
||||
/// </summary>
|
||||
public int Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 生命值。
|
||||
/// </summary>
|
||||
public int Hp { get; init; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
using GFramework.Godot.Config;
|
||||
|
||||
namespace GFramework.Godot.Tests.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Godot YAML 配置表来源描述会拒绝可能逃逸缓存根目录的不安全相对路径。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class GodotYamlConfigTableSourceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证配置目录路径必须保持为无根、无遍历段的安全相对路径。
|
||||
/// </summary>
|
||||
/// <param name="configRelativePath">待验证的配置目录路径。</param>
|
||||
[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")]
|
||||
[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<ArgumentException>(() =>
|
||||
_ = new GodotYamlConfigTableSource("monster", configRelativePath));
|
||||
|
||||
Assert.That(exception!.ParamName, Is.EqualTo("configRelativePath"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 schema 路径在提供时也必须满足同样的安全相对路径约束。
|
||||
/// </summary>
|
||||
/// <param name="schemaRelativePath">待验证的 schema 路径。</param>
|
||||
[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")]
|
||||
[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<ArgumentException>(() =>
|
||||
_ = new GodotYamlConfigTableSource("monster", "monster", schemaRelativePath));
|
||||
|
||||
Assert.That(exception!.ParamName, Is.EqualTo("schemaRelativePath"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证合法的相对目录和 schema 路径仍可正常构造元数据对象。
|
||||
/// </summary>
|
||||
[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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GFramework.Godot\GFramework.Godot.csproj"/>
|
||||
<ProjectReference Include="..\GFramework.Game.Abstractions\GFramework.Game.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\GFramework.Core.Abstractions\GFramework.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
24
GFramework.Godot.Tests/GlobalUsings.cs
Normal file
24
GFramework.Godot.Tests/GlobalUsings.cs
Normal file
@ -0,0 +1,24 @@
|
||||
// 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 NUnit.Framework;
|
||||
global using System.Globalization;
|
||||
global using System.IO;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
704
GFramework.Godot/Config/GodotYamlConfigLoader.cs
Normal file
704
GFramework.Godot/Config/GodotYamlConfigLoader.cs
Normal file
@ -0,0 +1,704 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 为 Godot 运行时提供 YAML 配置加载适配层。
|
||||
/// 编辑器态优先直接把项目目录交给 <see cref="YamlConfigLoader" />,
|
||||
/// 导出态则把显式声明的 YAML 与 schema 文本同步到运行时缓存目录后再加载。
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定选项创建一个 Godot YAML 配置加载器。
|
||||
/// </summary>
|
||||
/// <param name="options">加载器初始化选项。</param>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="options" /> 为 <see langword="null" /> 时抛出。</exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// 当 <see cref="GodotYamlConfigLoaderOptions.SourceRootPath" /> 或
|
||||
/// <see cref="GodotYamlConfigLoaderOptions.RuntimeCacheRootPath" /> 为空白字符串时抛出。
|
||||
/// </exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// 当 Godot 特殊路径无法被全局化为非空绝对路径时抛出。
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// 构造完成后,加载器会根据当前环境决定直接读取 <see cref="SourceRootPath" />,还是先同步到
|
||||
/// <see cref="RuntimeCacheRootPath" /> 再交给底层 <see cref="YamlConfigLoader" />。
|
||||
/// 只有源根目录可直接作为普通文件系统目录访问时,<see cref="CanEnableHotReload" /> 才会返回
|
||||
/// <see langword="true" />。
|
||||
/// </remarks>
|
||||
public GodotYamlConfigLoader(GodotYamlConfigLoaderOptions options)
|
||||
: this(options, GodotYamlConfigEnvironment.Default)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定选项和宿主环境抽象创建一个 Godot YAML 配置加载器。
|
||||
/// </summary>
|
||||
/// <param name="options">加载器初始化选项。</param>
|
||||
/// <param name="environment">
|
||||
/// 封装编辑器探测、Godot 路径全局化、目录枚举与文件读取行为的宿主环境抽象。
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="options" /> 或 <paramref name="environment" /> 为 <see langword="null" /> 时抛出。
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <see cref="GodotYamlConfigLoaderOptions.SourceRootPath" /> 或
|
||||
/// <see cref="GodotYamlConfigLoaderOptions.RuntimeCacheRootPath" /> 为空白字符串时抛出。
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// 该重载用于把与 Godot 引擎强耦合的环境行为收敛到可替换委托中。
|
||||
/// 编辑器态下,<c>res://</c> 可以被全局化后直接交给底层 <see cref="YamlConfigLoader" />;
|
||||
/// 导出态下,则需要先同步到 <c>user://</c> 缓存再切换到普通文件系统路径。
|
||||
/// </remarks>
|
||||
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,
|
||||
() => CanEnableHotReload,
|
||||
HotReloadUnavailableMessage);
|
||||
options.ConfigureLoader?.Invoke(_loader);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置源根目录。
|
||||
/// </summary>
|
||||
public string SourceRootPath => _options.SourceRootPath;
|
||||
|
||||
/// <summary>
|
||||
/// 获取运行时缓存根目录。
|
||||
/// </summary>
|
||||
public string RuntimeCacheRootPath => _options.RuntimeCacheRootPath;
|
||||
|
||||
/// <summary>
|
||||
/// 获取底层 <see cref="YamlConfigLoader" /> 实际使用的普通文件系统根目录。
|
||||
/// </summary>
|
||||
public string LoaderRootPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取底层 <see cref="YamlConfigLoader" /> 实例。
|
||||
/// 调用方可继续在该实例上追加注册表定义或读取注册数量。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该实例仅应用于补充注册表定义或检查注册状态。
|
||||
/// 不要直接调用 <see cref="YamlConfigLoader.LoadAsync(GFramework.Game.Abstractions.Config.IConfigRegistry,System.Threading.CancellationToken)" />
|
||||
/// 或 <see cref="YamlConfigLoader.EnableHotReload(GFramework.Game.Abstractions.Config.IConfigRegistry,YamlConfigHotReloadOptions?)" />;
|
||||
/// 应分别改为调用 <see cref="LoadAsync" /> 与 <see cref="EnableHotReload" />,以确保 Godot 适配层先执行缓存同步并维持
|
||||
/// <see cref="CanEnableHotReload" /> 守卫。
|
||||
/// </remarks>
|
||||
public YamlConfigLoader Loader => _loader;
|
||||
|
||||
/// <summary>
|
||||
/// 获取一个值,指示当前实例是否可直接针对源目录启用热重载。
|
||||
/// </summary>
|
||||
public bool CanEnableHotReload => UsesSourceDirectoryDirectly(SourceRootPath);
|
||||
|
||||
/// <summary>
|
||||
/// 执行 Godot 场景下的配置加载。
|
||||
/// 当源目录无法直接作为普通文件系统目录访问时,加载器会先把显式声明的 YAML 与 schema 文本同步到运行时缓存,
|
||||
/// 再委托底层 <see cref="YamlConfigLoader" /> 完成解析与注册。
|
||||
/// </summary>
|
||||
/// <param name="registry">用于接收配置表的注册表。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>表示加载流程的异步任务。</returns>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为 <see langword="null" /> 时抛出。</exception>
|
||||
/// <exception cref="ConfigLoadException">
|
||||
/// 当缓存同步、配置文件读取、schema 读取或底层 YAML 加载失败时抛出。
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// 运行时缓存同步阶段刻意保持同步执行。
|
||||
/// 原因在于默认宿主环境可能需要通过 Godot 的目录和文件访问 API 读取 <c>res://</c> 资源,
|
||||
/// 而这些访问边界目前仅以同步委托形式暴露;同时底层 <see cref="YamlConfigLoader" /> 也要求缓存文件在开始读取前已经完整落盘。
|
||||
/// 这意味着当实例无法直接访问源目录时,调用线程会在进入真正的异步 YAML 解析前承担一次文件系统同步成本。
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
await _loader.LoadAsync(registry, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在当前环境允许的情况下启用底层 YAML 热重载。
|
||||
/// </summary>
|
||||
/// <param name="registry">要被热重载更新的配置注册表。</param>
|
||||
/// <param name="options">热重载选项;为空时使用默认值。</param>
|
||||
/// <returns>用于停止监听的注销句柄。</returns>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为 <see langword="null" /> 时抛出。</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// 当当前实例必须通过运行时缓存访问配置源,无法直接监听真实源目录时抛出。
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// 当 <paramref name="options" /> 的防抖延迟小于 <see cref="TimeSpan.Zero" /> 时,
|
||||
/// 底层 <see cref="YamlConfigLoader" /> 会拒绝启用热重载。
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// 调用前应先检查 <see cref="CanEnableHotReload" />。
|
||||
/// 当 <see cref="SourceRootPath" /> 只能通过缓存同步访问时,拒绝启用热重载是为了避免监听缓存副本后误导调用方,
|
||||
/// 让其误以为源目录改动会被自动反映到运行时。
|
||||
/// </remarks>
|
||||
public IUnRegister EnableHotReload(
|
||||
IConfigRegistry registry,
|
||||
YamlConfigHotReloadOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
|
||||
if (!CanEnableHotReload)
|
||||
{
|
||||
throw new InvalidOperationException(HotReloadUnavailableMessage);
|
||||
}
|
||||
|
||||
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)
|
||||
// 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();
|
||||
|
||||
var representative = group.First();
|
||||
var sourceDirectoryPath = CombinePath(SourceRootPath, representative.ConfigRelativePath);
|
||||
var targetDirectoryPath = CombineAbsolutePath(LoaderRootPath, representative.ConfigRelativePath);
|
||||
|
||||
ResetDirectory(representative.TableName, sourceDirectoryPath, 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));
|
||||
}
|
||||
|
||||
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 tableName, string sourceDirectoryPath, string targetDirectoryPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(targetDirectoryPath))
|
||||
{
|
||||
Directory.Delete(targetDirectoryPath, recursive: true);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
// 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 ".."))
|
||||
{
|
||||
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)
|
||||
{
|
||||
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,
|
||||
string? detail = null,
|
||||
Exception? innerException = null)
|
||||
{
|
||||
return new ConfigLoadException(
|
||||
new ConfigLoadDiagnostic(
|
||||
failureKind,
|
||||
tableName,
|
||||
configDirectoryPath: configDirectoryPath,
|
||||
yamlPath: yamlPath,
|
||||
schemaPath: schemaPath,
|
||||
detail: detail),
|
||||
message,
|
||||
innerException);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抽象 <see cref="GodotYamlConfigLoader" /> 与具体宿主环境之间的 Godot 路径和文件访问边界。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该抽象存在的原因,是编辑器态与导出态对 <c>res://</c>、<c>user://</c> 的访问方式不同:
|
||||
/// 编辑器态通常可以把 Godot 特殊路径全局化后直接落到普通文件系统,而导出态往往只能通过 Godot API 读取原始文本资源,
|
||||
/// 再把它们复制到运行时缓存目录。<see cref="EnumerateDirectory" /> 在目录不存在或当前环境无法枚举时必须返回
|
||||
/// <see langword="null" />,用来表达“不可访问”而不是抛出未找到异常;<see cref="ReadAllBytes" /> 则应保留底层读取失败异常,
|
||||
/// 交由加载器包装成配置诊断。对于普通文件系统路径,应遵循 <see cref="Directory" /> / <see cref="File" /> 语义;
|
||||
/// 对于 Godot 特殊路径,则应使用引擎提供的路径解析和读取能力。
|
||||
/// </remarks>
|
||||
internal sealed class GodotYamlConfigEnvironment
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个可替换的 Godot YAML 配置宿主环境抽象。
|
||||
/// </summary>
|
||||
/// <param name="isEditor">返回当前进程是否处于 Godot 编辑器态的委托。</param>
|
||||
/// <param name="globalizePath">
|
||||
/// 把 Godot 特殊路径转换为普通绝对路径的委托。
|
||||
/// 当前加载器仅会在输入为 <c>res://</c> 或 <c>user://</c> 时调用它,返回值必须为非空绝对路径。
|
||||
/// </param>
|
||||
/// <param name="enumerateDirectory">
|
||||
/// 枚举指定目录直接子项的委托。
|
||||
/// 当目录不存在、无法访问或当前环境无法枚举该路径时,必须返回 <see langword="null" />。
|
||||
/// </param>
|
||||
/// <param name="fileExists">
|
||||
/// 检查指定路径上的文件是否存在的委托。
|
||||
/// 输入既可能是 Godot 特殊路径,也可能是普通绝对路径。
|
||||
/// </param>
|
||||
/// <param name="readAllBytes">
|
||||
/// 读取指定文件完整字节内容的委托。
|
||||
/// 当文件缺失或读取失败时,应抛出底层异常,由加载器统一包装为配置加载诊断。
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException">任一委托参数为 <see langword="null" /> 时抛出。</exception>
|
||||
public GodotYamlConfigEnvironment(
|
||||
Func<bool> isEditor,
|
||||
Func<string, string> globalizePath,
|
||||
Func<string, IReadOnlyList<GodotYamlConfigDirectoryEntry>?> enumerateDirectory,
|
||||
Func<string, bool> fileExists,
|
||||
Func<string, byte[]> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取默认的 Godot 运行时环境实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 默认实现使用 <see cref="OS.HasFeature(string)" /> 检测编辑器态,
|
||||
/// 使用 <see cref="ProjectSettings.GlobalizePath(string)" /> 处理 Godot 特殊路径,
|
||||
/// 并在 Godot 路径与普通路径之间切换对应的枚举和读取 API。
|
||||
/// </remarks>
|
||||
public static GodotYamlConfigEnvironment Default { get; } = new(
|
||||
static () => OS.HasFeature("editor"),
|
||||
static path => ProjectSettings.GlobalizePath(path),
|
||||
EnumerateDirectoryCore,
|
||||
FileExistsCore,
|
||||
ReadAllBytesCore);
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于判断当前进程是否处于编辑器态的委托。
|
||||
/// </summary>
|
||||
public Func<bool> IsEditor { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取把 Godot 特殊路径转换为普通绝对路径的委托。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 当前加载器只会对 <c>res://</c> 和 <c>user://</c> 路径调用该委托。
|
||||
/// 返回空字符串会被视为无效环境实现,并在后续路径解析阶段触发异常。
|
||||
/// </remarks>
|
||||
public Func<string, string> GlobalizePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于枚举目录直接子项的委托。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 当目录不存在、无法访问,或当前环境无法枚举给定路径时,该委托必须返回 <see langword="null" />。
|
||||
/// 返回的集合只应包含当前目录下的直接子项,调用方会自行过滤隐藏项、子目录与非 YAML 文件。
|
||||
/// </remarks>
|
||||
public Func<string, IReadOnlyList<GodotYamlConfigDirectoryEntry>?> EnumerateDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于检查文件是否存在的委托。
|
||||
/// </summary>
|
||||
public Func<string, bool> FileExists { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于读取文件完整字节内容的委托。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该委托在路径不存在、权限不足或 I/O 失败时应抛出底层异常,以便加载器保留失败原因并生成诊断信息。
|
||||
/// </remarks>
|
||||
public Func<string, byte[]> ReadAllBytes { get; }
|
||||
|
||||
private static IReadOnlyList<GodotYamlConfigDirectoryEntry>? 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<GodotYamlConfigDirectoryEntry>();
|
||||
var listDirectoryError = directory.ListDirBegin();
|
||||
if (listDirectoryError != Error.Ok)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 描述一次目录枚举返回的单个子项。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该结构只承载目录扫描阶段需要的最小信息。
|
||||
/// <see cref="Name" /> 必须是单个目录项名称,而不是包含父目录的完整路径;
|
||||
/// 对于 Godot 路径和普通路径都遵循相同约定,便于加载器统一做后续拼接与过滤。
|
||||
/// </remarks>
|
||||
internal readonly record struct GodotYamlConfigDirectoryEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个目录枚举结果项。
|
||||
/// </summary>
|
||||
/// <param name="name">当前目录项的名称,不包含父目录路径。</param>
|
||||
/// <param name="isDirectory">指示该目录项是否为子目录。</param>
|
||||
public GodotYamlConfigDirectoryEntry(string name, bool isDirectory)
|
||||
{
|
||||
Name = name;
|
||||
IsDirectory = isDirectory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前目录项的名称,不包含父目录路径。
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取一个值,指示当前目录项是否为子目录。
|
||||
/// </summary>
|
||||
public bool IsDirectory { get; }
|
||||
}
|
||||
36
GFramework.Godot/Config/GodotYamlConfigLoaderOptions.cs
Normal file
36
GFramework.Godot/Config/GodotYamlConfigLoaderOptions.cs
Normal file
@ -0,0 +1,36 @@
|
||||
using GFramework.Game.Config;
|
||||
|
||||
namespace GFramework.Godot.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 描述 Godot YAML 配置加载器的初始化约定。
|
||||
/// </summary>
|
||||
public sealed class GodotYamlConfigLoaderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置配置源根目录。
|
||||
/// 默认值为 <c>res://</c>,表示从项目资源路径读取 YAML 与 schema 文本。
|
||||
/// </summary>
|
||||
public string SourceRootPath { get; init; } = "res://";
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置运行时缓存根目录。
|
||||
/// 当 <see cref="SourceRootPath" /> 在当前环境下无法直接映射为普通文件系统目录时,
|
||||
/// 加载器会先把所需文本资产复制到这里,再交给底层 <see cref="YamlConfigLoader" />。
|
||||
/// </summary>
|
||||
public string RuntimeCacheRootPath { get; init; } = "user://config_cache";
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置本次启动会访问到的配置表来源描述。
|
||||
/// Godot 导出态无法假设任意文本目录都可被枚举,因此调用方应显式提供参与本轮加载的配置目录与 schema 文件。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<GodotYamlConfigTableSource> TableSources { get; init; } =
|
||||
Array.Empty<GodotYamlConfigTableSource>();
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置用于配置底层 <see cref="YamlConfigLoader" /> 的回调。
|
||||
/// 调用方通常应在这里调用生成器产出的 <c>RegisterAllGeneratedConfigTables()</c>,
|
||||
/// 或显式注册当前场景所需的手写表定义。
|
||||
/// </summary>
|
||||
public Action<YamlConfigLoader>? ConfigureLoader { get; init; }
|
||||
}
|
||||
120
GFramework.Godot/Config/GodotYamlConfigTableSource.cs
Normal file
120
GFramework.Godot/Config/GodotYamlConfigTableSource.cs
Normal file
@ -0,0 +1,120 @@
|
||||
using System.IO;
|
||||
|
||||
namespace GFramework.Godot.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 描述一个 Godot YAML 配置表在资源目录中的来源信息。
|
||||
/// </summary>
|
||||
public sealed class GodotYamlConfigTableSource
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个配置表来源描述。
|
||||
/// </summary>
|
||||
/// <param name="tableName">运行时表名称。</param>
|
||||
/// <param name="configRelativePath">
|
||||
/// 相对配置根目录的 YAML 目录。
|
||||
/// 该路径必须保持为无根相对路径,且不能包含 <c>.</c>、<c>..</c>、<c>res://</c>、<c>user://</c>、<c>:</c>
|
||||
/// 或磁盘根路径前缀。
|
||||
/// </param>
|
||||
/// <param name="schemaRelativePath">
|
||||
/// 相对配置根目录的 schema 文件路径;未启用 schema 时为空。
|
||||
/// 如果提供,同样必须保持为无根相对路径,且不能包含 <c>.</c>、<c>..</c>、<c>:</c> 或任何绝对路径前缀。
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <paramref name="tableName" />、<paramref name="configRelativePath" /> 或 <paramref name="schemaRelativePath" />
|
||||
/// 不满足非空白且安全相对路径的约束时抛出。
|
||||
/// </exception>
|
||||
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 (!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(
|
||||
"Schema relative path cannot be empty or whitespace when provided.",
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取运行时表名称。
|
||||
/// </summary>
|
||||
public string TableName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取相对配置根目录的 YAML 目录路径。
|
||||
/// 该值始终保持为无根相对路径,不会包含 <c>.</c>、<c>..</c> 或 <c>:</c> 段。
|
||||
/// </summary>
|
||||
public string ConfigRelativePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时为空。
|
||||
/// 该值在非空时始终保持为无根相对路径,不会包含 <c>.</c>、<c>..</c> 或 <c>:</c> 段。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
if (normalizedPath.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
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] == ':';
|
||||
}
|
||||
}
|
||||
3
GFramework.Godot/Properties/AssemblyInfo.cs
Normal file
3
GFramework.Godot/Properties/AssemblyInfo.cs
Normal file
@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("GFramework.Godot.Tests")]
|
||||
@ -96,7 +96,7 @@ GameProject/
|
||||
|
||||
- 必须是 JSON 字符串
|
||||
- 必须是相对路径
|
||||
- 不允许包含 `..` 段
|
||||
- 不允许包含 `.` 或 `..` 段,也不能写成绝对路径
|
||||
- 生成器会把反斜杠标准化为 `/`
|
||||
|
||||
## YAML 示例
|
||||
@ -299,6 +299,65 @@ 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`
|
||||
- 导出态:会将当前注册会访问到的 YAML 配置目录与 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://`
|
||||
缓存,热重载会被拒绝,而不是制造“监听了缓存目录却不反映真实源目录”的假象
|
||||
- 如果你通过 `GodotYamlConfigLoader.Loader` 继续追加表注册,请只把它当作“注册入口”使用;实际加载和热重载必须继续调用
|
||||
`GodotYamlConfigLoader.LoadAsync(...)` 与 `GodotYamlConfigLoader.EnableHotReload(...)`
|
||||
|
||||
### 运行时读取模板
|
||||
|
||||
推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user