diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
new file mode 100644
index 0000000..f551eb6
--- /dev/null
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -0,0 +1,202 @@
+using System.IO;
+using GFramework.Game.Config;
+
+namespace GFramework.Game.Tests.Config;
+
+///
+/// 验证 YAML 配置加载器的目录扫描与注册行为。
+///
+[TestFixture]
+public class YamlConfigLoaderTests
+{
+ ///
+ /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_rootPath);
+ }
+
+ ///
+ /// 清理测试期间创建的临时目录。
+ ///
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_rootPath))
+ {
+ Directory.Delete(_rootPath, true);
+ }
+ }
+
+ private string _rootPath = null!;
+
+ ///
+ /// 验证加载器能够扫描 YAML 文件并将结果写入注册表。
+ ///
+ [Test]
+ public async Task LoadAsync_Should_Register_Table_From_Yaml_Files()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 10
+ """);
+ CreateConfigFile(
+ "monster/goblin.yml",
+ """
+ id: 2
+ name: Goblin
+ hp: 30
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ await loader.LoadAsync(registry);
+
+ var table = registry.GetTable("monster");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(table.Count, Is.EqualTo(2));
+ Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
+ Assert.That(table.Get(2).Hp, Is.EqualTo(30));
+ });
+ }
+
+ ///
+ /// 验证注册的配置目录不存在时会抛出清晰错误。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_Config_Directory_Does_Not_Exist()
+ {
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception, Is.Not.Null);
+ Assert.That(exception!.Message, Does.Contain("monster"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
+ ///
+ /// 验证某个配置表加载失败时,注册表不会留下部分成功的中间状态。
+ ///
+ [Test]
+ public void LoadAsync_Should_Not_Mutate_Registry_When_A_Later_Table_Fails()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 10
+ """);
+
+ var registry = new ConfigRegistry();
+ registry.RegisterTable(
+ "existing",
+ new InMemoryConfigTable(
+ new[]
+ {
+ new ExistingConfigStub(100, "Original")
+ },
+ static config => config.Id));
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", static config => config.Id)
+ .RegisterTable("broken", "broken", static config => config.Id);
+
+ Assert.ThrowsAsync(async () => await loader.LoadAsync(registry));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(registry.Count, Is.EqualTo(1));
+ Assert.That(registry.HasTable("monster"), Is.False);
+ Assert.That(registry.GetTable("existing").Get(100).Name, Is.EqualTo("Original"));
+ });
+ }
+
+ ///
+ /// 验证非法 YAML 会被包装成带文件路径的反序列化错误。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_With_File_Path_When_Yaml_Is_Invalid()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: [1
+ name: Slime
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception, Is.Not.Null);
+ Assert.That(exception!.Message, Does.Contain("slime.yaml"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
+ ///
+ /// 创建测试用配置文件。
+ ///
+ /// 相对根目录的文件路径。
+ /// 文件内容。
+ private void CreateConfigFile(string relativePath, string content)
+ {
+ var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
+ var directory = Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ File.WriteAllText(fullPath, content);
+ }
+
+ ///
+ /// 用于 YAML 加载测试的最小怪物配置类型。
+ ///
+ private sealed class MonsterConfigStub
+ {
+ ///
+ /// 获取或设置主键。
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// 获取或设置名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置生命值。
+ ///
+ public int Hp { get; set; }
+ }
+
+ ///
+ /// 用于验证注册表一致性的现有配置类型。
+ ///
+ /// 配置主键。
+ /// 配置名称。
+ private sealed record ExistingConfigStub(int Id, string Name);
+}
\ No newline at end of file
diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs
new file mode 100644
index 0000000..f26308d
--- /dev/null
+++ b/GFramework.Game/Config/YamlConfigLoader.cs
@@ -0,0 +1,269 @@
+using System.IO;
+using GFramework.Game.Abstractions.Config;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace GFramework.Game.Config;
+
+///
+/// 基于文件目录的 YAML 配置加载器。
+/// 该实现用于 Runtime MVP 的文本配置接入阶段,通过显式注册表定义描述要加载的配置域,
+/// 再在一次加载流程中统一解析并写入配置注册表。
+///
+public sealed class YamlConfigLoader : IConfigLoader
+{
+ 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.";
+
+ private readonly IDeserializer _deserializer;
+ private readonly List _registrations = new();
+ private readonly string _rootPath;
+
+ ///
+ /// 使用指定配置根目录创建 YAML 配置加载器。
+ ///
+ /// 配置根目录。
+ /// 当 为空时抛出。
+ public YamlConfigLoader(string rootPath)
+ {
+ if (string.IsNullOrWhiteSpace(rootPath))
+ {
+ throw new ArgumentException(RootPathCannotBeNullOrWhiteSpaceMessage, nameof(rootPath));
+ }
+
+ _rootPath = rootPath;
+ _deserializer = new DeserializerBuilder()
+ .WithNamingConvention(CamelCaseNamingConvention.Instance)
+ .IgnoreUnmatchedProperties()
+ .Build();
+ }
+
+ ///
+ /// 获取配置根目录。
+ ///
+ public string RootPath => _rootPath;
+
+ ///
+ /// 获取当前已注册的配置表定义数量。
+ ///
+ public int RegistrationCount => _registrations.Count;
+
+ ///
+ public async Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+
+ var loadedTables = new List<(string name, IConfigTable table)>(_registrations.Count);
+
+ foreach (var registration in _registrations)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ loadedTables.Add(await registration.LoadAsync(_rootPath, _deserializer, cancellationToken));
+ }
+
+ // 仅当本轮所有配置表都成功加载后才写入注册表,避免暴露部分成功的中间状态。
+ foreach (var (name, table) in loadedTables)
+ {
+ RegistrationDispatcher.Register(registry, name, table);
+ }
+ }
+
+ ///
+ /// 注册一个 YAML 配置表定义。
+ /// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。
+ ///
+ /// 配置主键类型。
+ /// 配置值类型。
+ /// 配置表名称。
+ /// 相对配置根目录的子目录。
+ /// 配置项主键提取器。
+ /// 可选主键比较器。
+ /// 当前加载器实例,以便链式注册。
+ public YamlConfigLoader RegisterTable(
+ string tableName,
+ string relativePath,
+ Func keySelector,
+ IEqualityComparer? comparer = null)
+ where TKey : notnull
+ {
+ if (string.IsNullOrWhiteSpace(tableName))
+ {
+ throw new ArgumentException(TableNameCannotBeNullOrWhiteSpaceMessage, nameof(tableName));
+ }
+
+ if (string.IsNullOrWhiteSpace(relativePath))
+ {
+ throw new ArgumentException(RelativePathCannotBeNullOrWhiteSpaceMessage, nameof(relativePath));
+ }
+
+ ArgumentNullException.ThrowIfNull(keySelector);
+
+ _registrations.Add(new YamlTableRegistration(tableName, relativePath, keySelector, comparer));
+ return this;
+ }
+
+ ///
+ /// 负责在非泛型配置表与泛型注册表方法之间做分派。
+ /// 该静态助手将运行时反射局部封装在加载器内部,避免向外暴露弱类型注册 API。
+ ///
+ private static class RegistrationDispatcher
+ {
+ ///
+ /// 将强类型配置表写入注册表。
+ ///
+ /// 目标配置注册表。
+ /// 配置表名称。
+ /// 已加载的配置表实例。
+ /// 当传入表未实现强类型配置表契约时抛出。
+ public static void Register(IConfigRegistry registry, string name, IConfigTable table)
+ {
+ var tableInterface = table.GetType()
+ .GetInterfaces()
+ .FirstOrDefault(static type =>
+ type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IConfigTable<,>));
+
+ if (tableInterface == null)
+ {
+ throw new InvalidOperationException(
+ $"Loaded config table '{name}' does not implement '{typeof(IConfigTable<,>).Name}'.");
+ }
+
+ var genericArguments = tableInterface.GetGenericArguments();
+ var method = typeof(IConfigRegistry)
+ .GetMethod(nameof(IConfigRegistry.RegisterTable))!
+ .MakeGenericMethod(genericArguments[0], genericArguments[1]);
+
+ method.Invoke(registry, new object[] { name, table });
+ }
+ }
+
+ ///
+ /// 定义 YAML 配置表注册项的统一内部契约。
+ ///
+ private interface IYamlTableRegistration
+ {
+ ///
+ /// 从指定根目录加载配置表。
+ ///
+ /// 配置根目录。
+ /// YAML 反序列化器。
+ /// 取消令牌。
+ /// 已加载的配置表名称与配置表实例。
+ Task<(string name, IConfigTable table)> LoadAsync(
+ string rootPath,
+ IDeserializer deserializer,
+ CancellationToken cancellationToken);
+ }
+
+ ///
+ /// YAML 配置表注册项。
+ ///
+ /// 配置主键类型。
+ /// 配置项值类型。
+ private sealed class YamlTableRegistration : IYamlTableRegistration
+ where TKey : notnull
+ {
+ private readonly IEqualityComparer? _comparer;
+ private readonly Func _keySelector;
+
+ ///
+ /// 初始化 YAML 配置表注册项。
+ ///
+ /// 配置表名称。
+ /// 相对配置根目录的子目录。
+ /// 配置项主键提取器。
+ /// 可选主键比较器。
+ public YamlTableRegistration(
+ string name,
+ string relativePath,
+ Func keySelector,
+ IEqualityComparer? comparer)
+ {
+ Name = name;
+ RelativePath = relativePath;
+ _keySelector = keySelector;
+ _comparer = comparer;
+ }
+
+ ///
+ /// 获取配置表名称。
+ ///
+ public string Name { get; }
+
+ ///
+ /// 获取相对配置根目录的子目录。
+ ///
+ public string RelativePath { get; }
+
+ ///
+ public async Task<(string name, IConfigTable table)> LoadAsync(
+ string rootPath,
+ IDeserializer deserializer,
+ CancellationToken cancellationToken)
+ {
+ var directoryPath = Path.Combine(rootPath, RelativePath);
+ if (!Directory.Exists(directoryPath))
+ {
+ throw new DirectoryNotFoundException(
+ $"Config directory '{directoryPath}' was not found for table '{Name}'.");
+ }
+
+ var values = new List();
+ var files = Directory
+ .EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly)
+ .Where(static path =>
+ path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) ||
+ path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase))
+ .OrderBy(static path => path, StringComparer.Ordinal)
+ .ToArray();
+
+ foreach (var file in files)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string yaml;
+ try
+ {
+ yaml = await File.ReadAllTextAsync(file, cancellationToken);
+ }
+ catch (Exception exception)
+ {
+ throw new InvalidOperationException(
+ $"Failed to read config file '{file}' for table '{Name}'.",
+ exception);
+ }
+
+ try
+ {
+ var value = deserializer.Deserialize(yaml);
+
+ if (value == null)
+ {
+ throw new InvalidOperationException("YAML content was deserialized to null.");
+ }
+
+ values.Add(value);
+ }
+ catch (Exception exception)
+ {
+ throw new InvalidOperationException(
+ $"Failed to deserialize config file '{file}' for table '{Name}' as '{typeof(TValue).Name}'.",
+ exception);
+ }
+ }
+
+ try
+ {
+ var table = new InMemoryConfigTable(values, _keySelector, _comparer);
+ return (Name, table);
+ }
+ catch (Exception exception)
+ {
+ throw new InvalidOperationException(
+ $"Failed to build config table '{Name}' from directory '{directoryPath}'.",
+ exception);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Game/GFramework.Game.csproj b/GFramework.Game/GFramework.Game.csproj
index 5bb94d9..6a81f01 100644
--- a/GFramework.Game/GFramework.Game.csproj
+++ b/GFramework.Game/GFramework.Game.csproj
@@ -14,5 +14,6 @@
+