docs(game): 添加游戏模块完整文档

- 创建了 GFramework.Game 模块的全面文档
- 包含架构模块系统、资产管理、存储系统和序列化系统详解
- 提供了 AbstractModule、AbstractAssetCatalogUtility 等核心组件使用示例
- 添加了分层存储、加密存储和缓存存储的实现方案
- 集成了 JSON 序列化、自定义转换器和版本化数据管理
- 提供了完整的游戏数据管理系统和自动保存系统实现
- 修改了 VitePress 配置文件
This commit is contained in:
GeWuYou 2026-03-31 22:30:33 +08:00
parent 91f0375461
commit b87e511334
9 changed files with 940 additions and 7 deletions

View File

@ -200,6 +200,17 @@ bash scripts/validate-csharp-naming.sh
- The main documentation site lives under `docs/`, with Chinese content under `docs/zh-CN/`. - The main documentation site lives under `docs/`, with Chinese content under `docs/zh-CN/`.
- Keep code samples, package names, and command examples aligned with the current repository state. - Keep code samples, package names, and command examples aligned with the current repository state.
- Prefer documenting behavior and design intent, not only API surface. - Prefer documenting behavior and design intent, not only API surface.
- When a feature is added, removed, renamed, or substantially refactored, contributors MUST update or create the
corresponding user-facing integration documentation in `docs/zh-CN/` in the same change.
- For integration-oriented features such as the AI-First config system, documentation MUST cover:
- project directory layout and file conventions
- required project or package wiring
- minimal working usage example
- migration or compatibility notes when behavior changes
- If an existing documentation page no longer reflects the current implementation, fixing the code without fixing the
documentation is considered incomplete work.
- Do not rely on “the code is self-explanatory” for framework features that consumers need to adopt; write the
adoption path down so future users do not need to rediscover it from source.
### Documentation Preview ### Documentation Preview
@ -218,3 +229,4 @@ Before considering work complete, confirm:
- Relevant tests were added or updated - Relevant tests were added or updated
- Sensitive or unsafe behavior was not introduced - Sensitive or unsafe behavior was not introduced
- User-facing documentation is updated when needed - User-facing documentation is updated when needed
- Feature adoption docs under `docs/zh-CN/` were added or updated when functionality was added, removed, or refactored

View File

@ -9,6 +9,8 @@ namespace GFramework.Game.Tests.Config;
[TestFixture] [TestFixture]
public class YamlConfigLoaderTests public class YamlConfigLoaderTests
{ {
private string _rootPath = null!;
/// <summary> /// <summary>
/// 为每个测试创建独立临时目录,避免文件系统状态互相污染。 /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。
/// </summary> /// </summary>
@ -31,8 +33,6 @@ public class YamlConfigLoaderTests
} }
} }
private string _rootPath = null!;
/// <summary> /// <summary>
/// 验证加载器能够扫描 YAML 文件并将结果写入注册表。 /// 验证加载器能够扫描 YAML 文件并将结果写入注册表。
/// </summary> /// </summary>
@ -155,6 +155,182 @@ public class YamlConfigLoaderTests
}); });
} }
/// <summary>
/// 验证启用 schema 校验后,缺失必填字段会在反序列化前被拒绝。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Required_Property_Is_Missing_According_To_Schema()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
hp: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"hp": { "type": "integer" }
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("name"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证启用 schema 校验后,类型不匹配的标量字段会被拒绝。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Property_Type_Does_Not_Match_Schema()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: high
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"hp": { "type": "integer" }
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("hp"));
Assert.That(exception!.Message, Does.Contain("integer"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Unknown_Property_Is_Present_In_Schema_Bound_Mode()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
attackPower: 2
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"hp": { "type": "integer" }
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("attackPower"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证数组字段的元素类型会按 schema 校验。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Array_Item_Type_Does_Not_Match_Schema()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
dropRates:
- 1
- potion
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"dropRates": {
"type": "array",
"items": { "type": "integer" }
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConfigIntegerArrayStub>(
"monster",
"monster",
"schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("dropRates"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary> /// <summary>
/// 创建测试用配置文件。 /// 创建测试用配置文件。
/// </summary> /// </summary>
@ -172,6 +348,16 @@ public class YamlConfigLoaderTests
File.WriteAllText(fullPath, content); File.WriteAllText(fullPath, content);
} }
/// <summary>
/// 创建测试用 schema 文件。
/// </summary>
/// <param name="relativePath">相对根目录的文件路径。</param>
/// <param name="content">文件内容。</param>
private void CreateSchemaFile(string relativePath, string content)
{
CreateConfigFile(relativePath, content);
}
/// <summary> /// <summary>
/// 用于 YAML 加载测试的最小怪物配置类型。 /// 用于 YAML 加载测试的最小怪物配置类型。
/// </summary> /// </summary>
@ -193,6 +379,27 @@ public class YamlConfigLoaderTests
public int Hp { get; set; } public int Hp { get; set; }
} }
/// <summary>
/// 用于数组 schema 校验测试的最小怪物配置类型。
/// </summary>
private sealed class MonsterConfigIntegerArrayStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 获取或设置掉落率列表。
/// </summary>
public IReadOnlyList<int> DropRates { get; set; } = Array.Empty<int>();
}
/// <summary> /// <summary>
/// 用于验证注册表一致性的现有配置类型。 /// 用于验证注册表一致性的现有配置类型。
/// </summary> /// </summary>

View File

@ -1,7 +1,5 @@
using System.IO; using System.IO;
using GFramework.Game.Abstractions.Config; using GFramework.Game.Abstractions.Config;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace GFramework.Game.Config; namespace GFramework.Game.Config;
@ -16,6 +14,9 @@ public sealed class YamlConfigLoader : IConfigLoader
private const string TableNameCannotBeNullOrWhiteSpaceMessage = "Table name 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 const string RelativePathCannotBeNullOrWhiteSpaceMessage = "Relative path cannot be null or whitespace.";
private const string SchemaRelativePathCannotBeNullOrWhiteSpaceMessage =
"Schema relative path cannot be null or whitespace.";
private readonly IDeserializer _deserializer; private readonly IDeserializer _deserializer;
private readonly List<IYamlTableRegistration> _registrations = new(); private readonly List<IYamlTableRegistration> _registrations = new();
private readonly string _rootPath; private readonly string _rootPath;
@ -86,6 +87,41 @@ public sealed class YamlConfigLoader : IConfigLoader
Func<TValue, TKey> keySelector, Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer = null) IEqualityComparer<TKey>? comparer = null)
where TKey : notnull where TKey : notnull
{
return RegisterTableCore(tableName, relativePath, null, keySelector, comparer);
}
/// <summary>
/// 注册一个带 schema 校验的 YAML 配置表定义。
/// 该重载会在 YAML 反序列化之前使用指定 schema 拒绝未知字段、缺失必填字段和基础类型错误,
/// 以避免错误配置以默认值形式悄悄进入运行时。
/// </summary>
/// <typeparam name="TKey">配置主键类型。</typeparam>
/// <typeparam name="TValue">配置值类型。</typeparam>
/// <param name="tableName">配置表名称。</param>
/// <param name="relativePath">相对配置根目录的子目录。</param>
/// <param name="schemaRelativePath">相对配置根目录的 schema 文件路径。</param>
/// <param name="keySelector">配置项主键提取器。</param>
/// <param name="comparer">可选主键比较器。</param>
/// <returns>当前加载器实例,以便链式注册。</returns>
public YamlConfigLoader RegisterTable<TKey, TValue>(
string tableName,
string relativePath,
string schemaRelativePath,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer = null)
where TKey : notnull
{
return RegisterTableCore(tableName, relativePath, schemaRelativePath, keySelector, comparer);
}
private YamlConfigLoader RegisterTableCore<TKey, TValue>(
string tableName,
string relativePath,
string? schemaRelativePath,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer)
where TKey : notnull
{ {
if (string.IsNullOrWhiteSpace(tableName)) if (string.IsNullOrWhiteSpace(tableName))
{ {
@ -99,7 +135,20 @@ public sealed class YamlConfigLoader : IConfigLoader
ArgumentNullException.ThrowIfNull(keySelector); ArgumentNullException.ThrowIfNull(keySelector);
_registrations.Add(new YamlTableRegistration<TKey, TValue>(tableName, relativePath, keySelector, comparer)); if (schemaRelativePath != null && string.IsNullOrWhiteSpace(schemaRelativePath))
{
throw new ArgumentException(
SchemaRelativePathCannotBeNullOrWhiteSpaceMessage,
nameof(schemaRelativePath));
}
_registrations.Add(
new YamlTableRegistration<TKey, TValue>(
tableName,
relativePath,
schemaRelativePath,
keySelector,
comparer));
return this; return this;
} }
@ -172,16 +221,19 @@ public sealed class YamlConfigLoader : IConfigLoader
/// </summary> /// </summary>
/// <param name="name">配置表名称。</param> /// <param name="name">配置表名称。</param>
/// <param name="relativePath">相对配置根目录的子目录。</param> /// <param name="relativePath">相对配置根目录的子目录。</param>
/// <param name="schemaRelativePath">相对配置根目录的 schema 文件路径;未启用 schema 校验时为空。</param>
/// <param name="keySelector">配置项主键提取器。</param> /// <param name="keySelector">配置项主键提取器。</param>
/// <param name="comparer">可选主键比较器。</param> /// <param name="comparer">可选主键比较器。</param>
public YamlTableRegistration( public YamlTableRegistration(
string name, string name,
string relativePath, string relativePath,
string? schemaRelativePath,
Func<TValue, TKey> keySelector, Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer) IEqualityComparer<TKey>? comparer)
{ {
Name = name; Name = name;
RelativePath = relativePath; RelativePath = relativePath;
SchemaRelativePath = schemaRelativePath;
_keySelector = keySelector; _keySelector = keySelector;
_comparer = comparer; _comparer = comparer;
} }
@ -196,6 +248,11 @@ public sealed class YamlConfigLoader : IConfigLoader
/// </summary> /// </summary>
public string RelativePath { get; } public string RelativePath { get; }
/// <summary>
/// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。
/// </summary>
public string? SchemaRelativePath { get; }
/// <inheritdoc /> /// <inheritdoc />
public async Task<(string name, IConfigTable table)> LoadAsync( public async Task<(string name, IConfigTable table)> LoadAsync(
string rootPath, string rootPath,
@ -209,6 +266,13 @@ public sealed class YamlConfigLoader : IConfigLoader
$"Config directory '{directoryPath}' was not found for table '{Name}'."); $"Config directory '{directoryPath}' was not found for table '{Name}'.");
} }
YamlConfigSchema? schema = null;
if (!string.IsNullOrEmpty(SchemaRelativePath))
{
var schemaPath = Path.Combine(rootPath, SchemaRelativePath);
schema = await YamlConfigSchemaValidator.LoadAsync(schemaPath, cancellationToken);
}
var values = new List<TValue>(); var values = new List<TValue>();
var files = Directory var files = Directory
.EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly) .EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly)
@ -234,6 +298,12 @@ public sealed class YamlConfigLoader : IConfigLoader
exception); exception);
} }
if (schema != null)
{
// 先按 schema 拒绝结构问题,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。
YamlConfigSchemaValidator.Validate(schema, file, yaml);
}
try try
{ {
var value = deserializer.Deserialize<TValue>(yaml); var value = deserializer.Deserialize<TValue>(yaml);

View File

@ -0,0 +1,441 @@
using System.Globalization;
using System.IO;
using System.Text.Json;
namespace GFramework.Game.Config;
/// <summary>
/// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。
/// 该校验器与当前配置生成器支持的 schema 子集保持一致,
/// 以便在配置进入运行时注册表之前就拒绝缺失字段、未知字段和基础类型错误。
/// </summary>
internal static class YamlConfigSchemaValidator
{
/// <summary>
/// 从磁盘加载并解析一个 JSON Schema 文件。
/// </summary>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>解析后的 schema 模型。</returns>
/// <exception cref="ArgumentException">当 <paramref name="schemaPath" /> 为空时抛出。</exception>
/// <exception cref="FileNotFoundException">当 schema 文件不存在时抛出。</exception>
/// <exception cref="InvalidOperationException">当 schema 内容不符合当前运行时支持的子集时抛出。</exception>
internal static async Task<YamlConfigSchema> LoadAsync(
string schemaPath,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(schemaPath))
{
throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath));
}
if (!File.Exists(schemaPath))
{
throw new FileNotFoundException($"Schema file '{schemaPath}' was not found.", schemaPath);
}
string schemaText;
try
{
schemaText = await File.ReadAllTextAsync(schemaPath, cancellationToken);
}
catch (Exception exception)
{
throw new InvalidOperationException($"Failed to read schema file '{schemaPath}'.", exception);
}
try
{
using var document = JsonDocument.Parse(schemaText);
var root = document.RootElement;
if (!root.TryGetProperty("type", out var typeElement) ||
!string.Equals(typeElement.GetString(), "object", StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Schema file '{schemaPath}' must declare a root object schema.");
}
if (!root.TryGetProperty("properties", out var propertiesElement) ||
propertiesElement.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException(
$"Schema file '{schemaPath}' must declare an object-valued 'properties' section.");
}
var requiredProperties = new HashSet<string>(StringComparer.Ordinal);
if (root.TryGetProperty("required", out var requiredElement) &&
requiredElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in requiredElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
if (item.ValueKind != JsonValueKind.String)
{
continue;
}
var propertyName = item.GetString();
if (!string.IsNullOrWhiteSpace(propertyName))
{
requiredProperties.Add(propertyName);
}
}
}
var properties = new Dictionary<string, YamlConfigSchemaProperty>(StringComparer.Ordinal);
foreach (var property in propertiesElement.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
properties.Add(property.Name, ParseProperty(schemaPath, property));
}
return new YamlConfigSchema(schemaPath, properties, requiredProperties);
}
catch (JsonException exception)
{
throw new InvalidOperationException($"Schema file '{schemaPath}' contains invalid JSON.", exception);
}
}
/// <summary>
/// 使用已解析的 schema 校验 YAML 文本。
/// </summary>
/// <param name="schema">已解析的 schema 模型。</param>
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
/// <param name="yamlText">YAML 文本内容。</param>
/// <exception cref="ArgumentNullException">当参数为空时抛出。</exception>
/// <exception cref="InvalidOperationException">当 YAML 内容与 schema 不匹配时抛出。</exception>
internal static void Validate(
YamlConfigSchema schema,
string yamlPath,
string yamlText)
{
ArgumentNullException.ThrowIfNull(schema);
ArgumentNullException.ThrowIfNull(yamlPath);
ArgumentNullException.ThrowIfNull(yamlText);
YamlStream yamlStream = new();
try
{
using var reader = new StringReader(yamlText);
yamlStream.Load(reader);
}
catch (Exception exception)
{
throw new InvalidOperationException(
$"Config file '{yamlPath}' could not be parsed as YAML before schema validation.",
exception);
}
if (yamlStream.Documents.Count != 1 ||
yamlStream.Documents[0].RootNode is not YamlMappingNode rootMapping)
{
throw new InvalidOperationException(
$"Config file '{yamlPath}' must contain a single root mapping object.");
}
var seenProperties = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in rootMapping.Children)
{
if (entry.Key is not YamlScalarNode keyNode ||
string.IsNullOrWhiteSpace(keyNode.Value))
{
throw new InvalidOperationException(
$"Config file '{yamlPath}' contains a non-scalar or empty top-level property name.");
}
var propertyName = keyNode.Value;
if (!seenProperties.Add(propertyName))
{
throw new InvalidOperationException(
$"Config file '{yamlPath}' contains duplicate property '{propertyName}'.");
}
if (!schema.Properties.TryGetValue(propertyName, out var property))
{
throw new InvalidOperationException(
$"Config file '{yamlPath}' contains unknown property '{propertyName}' that is not declared in schema '{schema.SchemaPath}'.");
}
ValidateNode(yamlPath, propertyName, entry.Value, property);
}
foreach (var requiredProperty in schema.RequiredProperties)
{
if (!seenProperties.Contains(requiredProperty))
{
throw new InvalidOperationException(
$"Config file '{yamlPath}' is missing required property '{requiredProperty}' defined by schema '{schema.SchemaPath}'.");
}
}
}
private static YamlConfigSchemaProperty ParseProperty(string schemaPath, JsonProperty property)
{
if (!property.Value.TryGetProperty("type", out var typeElement) ||
typeElement.ValueKind != JsonValueKind.String)
{
throw new InvalidOperationException(
$"Property '{property.Name}' in schema file '{schemaPath}' must declare a string 'type'.");
}
var typeName = typeElement.GetString() ?? string.Empty;
var propertyType = typeName switch
{
"integer" => YamlConfigSchemaPropertyType.Integer,
"number" => YamlConfigSchemaPropertyType.Number,
"boolean" => YamlConfigSchemaPropertyType.Boolean,
"string" => YamlConfigSchemaPropertyType.String,
"array" => YamlConfigSchemaPropertyType.Array,
_ => throw new InvalidOperationException(
$"Property '{property.Name}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.")
};
if (propertyType != YamlConfigSchemaPropertyType.Array)
{
return new YamlConfigSchemaProperty(property.Name, propertyType, null);
}
if (!property.Value.TryGetProperty("items", out var itemsElement) ||
itemsElement.ValueKind != JsonValueKind.Object ||
!itemsElement.TryGetProperty("type", out var itemTypeElement) ||
itemTypeElement.ValueKind != JsonValueKind.String)
{
throw new InvalidOperationException(
$"Array property '{property.Name}' in schema file '{schemaPath}' must declare an item type.");
}
var itemTypeName = itemTypeElement.GetString() ?? string.Empty;
var itemType = itemTypeName switch
{
"integer" => YamlConfigSchemaPropertyType.Integer,
"number" => YamlConfigSchemaPropertyType.Number,
"boolean" => YamlConfigSchemaPropertyType.Boolean,
"string" => YamlConfigSchemaPropertyType.String,
_ => throw new InvalidOperationException(
$"Array property '{property.Name}' in schema file '{schemaPath}' uses unsupported item type '{itemTypeName}'.")
};
return new YamlConfigSchemaProperty(property.Name, propertyType, itemType);
}
private static void ValidateNode(
string yamlPath,
string propertyName,
YamlNode node,
YamlConfigSchemaProperty property)
{
if (property.PropertyType == YamlConfigSchemaPropertyType.Array)
{
if (node is not YamlSequenceNode sequenceNode)
{
throw new InvalidOperationException(
$"Property '{propertyName}' in config file '{yamlPath}' must be an array.");
}
foreach (var item in sequenceNode.Children)
{
ValidateScalarNode(yamlPath, propertyName, item, property.ItemType!.Value, isArrayItem: true);
}
return;
}
ValidateScalarNode(yamlPath, propertyName, node, property.PropertyType, isArrayItem: false);
}
private static void ValidateScalarNode(
string yamlPath,
string propertyName,
YamlNode node,
YamlConfigSchemaPropertyType expectedType,
bool isArrayItem)
{
if (node is not YamlScalarNode scalarNode)
{
var subject = isArrayItem
? $"Array item in property '{propertyName}'"
: $"Property '{propertyName}'";
throw new InvalidOperationException(
$"{subject} in config file '{yamlPath}' must be a scalar value of type '{GetTypeName(expectedType)}'.");
}
var value = scalarNode.Value;
if (value is null)
{
var subject = isArrayItem
? $"Array item in property '{propertyName}'"
: $"Property '{propertyName}'";
throw new InvalidOperationException(
$"{subject} in config file '{yamlPath}' cannot be null when schema type is '{GetTypeName(expectedType)}'.");
}
var tag = scalarNode.Tag.ToString();
var isValid = expectedType switch
{
YamlConfigSchemaPropertyType.String => IsStringScalar(tag),
YamlConfigSchemaPropertyType.Integer => long.TryParse(
value,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out _),
YamlConfigSchemaPropertyType.Number => double.TryParse(
value,
NumberStyles.Float | NumberStyles.AllowThousands,
CultureInfo.InvariantCulture,
out _),
YamlConfigSchemaPropertyType.Boolean => bool.TryParse(value, out _),
_ => false
};
if (isValid)
{
return;
}
var subjectName = isArrayItem
? $"Array item in property '{propertyName}'"
: $"Property '{propertyName}'";
throw new InvalidOperationException(
$"{subjectName} in config file '{yamlPath}' must be of type '{GetTypeName(expectedType)}', but the current YAML scalar value is '{value}'.");
}
private static string GetTypeName(YamlConfigSchemaPropertyType type)
{
return type switch
{
YamlConfigSchemaPropertyType.Integer => "integer",
YamlConfigSchemaPropertyType.Number => "number",
YamlConfigSchemaPropertyType.Boolean => "boolean",
YamlConfigSchemaPropertyType.String => "string",
YamlConfigSchemaPropertyType.Array => "array",
_ => type.ToString()
};
}
private static bool IsStringScalar(string tag)
{
if (string.IsNullOrWhiteSpace(tag))
{
return true;
}
return !string.Equals(tag, "tag:yaml.org,2002:int", StringComparison.Ordinal) &&
!string.Equals(tag, "tag:yaml.org,2002:float", StringComparison.Ordinal) &&
!string.Equals(tag, "tag:yaml.org,2002:bool", StringComparison.Ordinal) &&
!string.Equals(tag, "tag:yaml.org,2002:null", StringComparison.Ordinal);
}
}
/// <summary>
/// 表示已解析并可用于运行时校验的 JSON Schema。
/// 该模型只保留当前运行时加载器真正需要的最小信息,以避免在游戏运行时引入完整 schema 引擎。
/// </summary>
internal sealed class YamlConfigSchema
{
/// <summary>
/// 初始化一个可用于运行时校验的 schema 模型。
/// </summary>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="properties">Schema 属性定义。</param>
/// <param name="requiredProperties">必填属性集合。</param>
public YamlConfigSchema(
string schemaPath,
IReadOnlyDictionary<string, YamlConfigSchemaProperty> properties,
IReadOnlyCollection<string> requiredProperties)
{
ArgumentNullException.ThrowIfNull(schemaPath);
ArgumentNullException.ThrowIfNull(properties);
ArgumentNullException.ThrowIfNull(requiredProperties);
SchemaPath = schemaPath;
Properties = properties;
RequiredProperties = requiredProperties;
}
/// <summary>
/// 获取 schema 文件路径。
/// </summary>
public string SchemaPath { get; }
/// <summary>
/// 获取按属性名索引的 schema 属性定义。
/// </summary>
public IReadOnlyDictionary<string, YamlConfigSchemaProperty> Properties { get; }
/// <summary>
/// 获取 schema 声明的必填属性集合。
/// </summary>
public IReadOnlyCollection<string> RequiredProperties { get; }
}
/// <summary>
/// 表示单个 schema 属性的最小运行时描述。
/// </summary>
internal sealed class YamlConfigSchemaProperty
{
/// <summary>
/// 初始化一个 schema 属性描述。
/// </summary>
/// <param name="name">属性名称。</param>
/// <param name="propertyType">属性类型。</param>
/// <param name="itemType">数组元素类型;仅当属性类型为数组时有效。</param>
public YamlConfigSchemaProperty(
string name,
YamlConfigSchemaPropertyType propertyType,
YamlConfigSchemaPropertyType? itemType)
{
ArgumentNullException.ThrowIfNull(name);
Name = name;
PropertyType = propertyType;
ItemType = itemType;
}
/// <summary>
/// 获取属性名称。
/// </summary>
public string Name { get; }
/// <summary>
/// 获取属性类型。
/// </summary>
public YamlConfigSchemaPropertyType PropertyType { get; }
/// <summary>
/// 获取数组元素类型;非数组属性时返回空。
/// </summary>
public YamlConfigSchemaPropertyType? ItemType { get; }
}
/// <summary>
/// 表示当前运行时 schema 校验器支持的属性类型。
/// </summary>
internal enum YamlConfigSchemaPropertyType
{
/// <summary>
/// 整数类型。
/// </summary>
Integer,
/// <summary>
/// 数值类型。
/// </summary>
Number,
/// <summary>
/// 布尔类型。
/// </summary>
Boolean,
/// <summary>
/// 字符串类型。
/// </summary>
String,
/// <summary>
/// 数组类型。
/// </summary>
Array
}

View File

@ -3,14 +3,30 @@
<!-- This file is automatically generated by the NuGet package --> <!-- This file is automatically generated by the NuGet package -->
<!-- It ensures that the source generators are properly registered during build --> <!-- It ensures that the source generators are properly registered during build -->
<PropertyGroup>
<!--
消费项目默认从 schemas/ 目录收集配置 schema。
这样可以让配置生成器开箱即用,同时保留通过属性重定向目录的余地。
-->
<GFrameworkConfigSchemaDirectory Condition="'$(GFrameworkConfigSchemaDirectory)' == ''">schemas</GFrameworkConfigSchemaDirectory>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.dll"/> <Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.dll"/>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Abstractions.dll"/> <Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Abstractions.dll"/>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Common.dll"/> <Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Common.dll"/>
</ItemGroup> </ItemGroup>
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)/$(GFrameworkConfigSchemaDirectory)')">
<!--
将消费者项目中的 schema 文件自动暴露为 AdditionalFiles
避免每个游戏项目都手工维护同样的 MSBuild 配置。
-->
<AdditionalFiles Include="$(MSBuildProjectDirectory)/$(GFrameworkConfigSchemaDirectory)/**/*.schema.json"/>
</ItemGroup>
<!-- Ensure the analyzers are loaded --> <!-- Ensure the analyzers are loaded -->
<Target Name="EnsureGFrameworkAnalyzers" BeforeTargets="CoreCompile"> <Target Name="EnsureGFrameworkAnalyzers" BeforeTargets="CoreCompile">
<Message Text="Loading GFramework source generators" Importance="high"/> <Message Text="Loading GFramework source generators" Importance="high"/>
</Target> </Target>
</Project> </Project>

View File

@ -174,6 +174,7 @@ export default defineConfig({
text: 'Game 游戏模块', text: 'Game 游戏模块',
items: [ items: [
{ text: '概览', link: '/zh-CN/game/' }, { text: '概览', link: '/zh-CN/game/' },
{ text: '内容配置系统', link: '/zh-CN/game/config-system' },
{ text: '数据管理', link: '/zh-CN/game/data' }, { text: '数据管理', link: '/zh-CN/game/data' },
{ text: '场景系统', link: '/zh-CN/game/scene' }, { text: '场景系统', link: '/zh-CN/game/scene' },
{ text: 'UI 系统', link: '/zh-CN/game/ui' }, { text: 'UI 系统', link: '/zh-CN/game/ui' },

View File

@ -0,0 +1,126 @@
# 游戏内容配置系统
> 面向静态游戏内容的 AI-First 配表方案
该配置系统用于管理怪物、物品、技能、任务等静态内容数据。
它与 `GFramework.Core.Configuration` 不同,后者面向运行时键值配置;它也不同于 `GFramework.Game.Setting`,后者面向玩家设置和持久化。
## 当前能力
- YAML 作为配置源文件
- JSON Schema 作为结构描述
- 一对象一文件的目录组织
- 运行时只读查询
- Source Generator 生成配置类型和表包装
- VS Code 插件提供配置浏览、raw 编辑、schema 打开和轻量校验入口
## 推荐目录结构
```text
GameProject/
├─ config/
│ ├─ monster/
│ │ ├─ slime.yaml
│ │ └─ goblin.yaml
│ └─ item/
│ └─ potion.yaml
├─ schemas/
│ ├─ monster.schema.json
│ └─ item.schema.json
```
## Schema 示例
```json
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"hp": { "type": "integer" },
"dropItems": {
"type": "array",
"items": { "type": "string" }
}
}
}
```
## YAML 示例
```yaml
id: 1
name: Slime
hp: 10
dropItems:
- potion
- slime_gel
```
## 运行时接入
当你希望加载后的配置在运行时以只读表形式暴露时,可以使用 `YamlConfigLoader``ConfigRegistry`
```csharp
using GFramework.Game.Config;
var registry = new ConfigRegistry();
var loader = new YamlConfigLoader("config-root")
.RegisterTable<int, MonsterConfig>(
"monster",
"monster",
"schemas/monster.schema.json",
static config => config.Id);
await loader.LoadAsync(registry);
var monsterTable = registry.GetTable<int, MonsterConfig>("monster");
var slime = monsterTable.Get(1);
```
这个重载会先按 schema 校验,再进行反序列化和注册。
## 运行时校验行为
绑定 schema 的表在加载时会拒绝以下问题:
- 缺失必填字段
- 未在 schema 中声明的未知字段
- 标量类型不匹配
- 数组元素类型不匹配
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
## 生成器接入约定
配置生成器会从 `*.schema.json` 生成配置类型和表包装类。
通过已打包的 Source Generator 使用时,默认会自动收集 `schemas/**/*.schema.json` 作为 `AdditionalFiles`
如果你在仓库内直接使用项目引用而不是打包后的 NuGet请确认 schema 文件同样被加入 `AdditionalFiles`
## VS Code 工具
仓库中的 `tools/vscode-config-extension` 当前提供以下能力:
- 浏览 `config/` 目录
- 打开 raw YAML 文件
- 打开匹配的 schema 文件
- 对必填字段和基础标量类型做轻量校验
- 对顶层标量字段提供轻量表单入口
当前仍建议把复杂数组、嵌套对象和批量修改放在 raw YAML 中完成。
## 当前限制
以下能力尚未完全完成:
- 运行时热重载
- 跨表引用校验
- 更完整的 JSON Schema 支持
- 更强的 VS Code 表单编辑器
因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。

View File

@ -8,6 +8,7 @@ GFramework.Game 是 GFramework 框架的游戏特定功能模块,提供了游
- [概述](#概述) - [概述](#概述)
- [核心特性](#核心特性) - [核心特性](#核心特性)
- [内容配置系统](#内容配置系统)
- [架构模块系统](#架构模块系统) - [架构模块系统](#架构模块系统)
- [资产管理](#资产管理) - [资产管理](#资产管理)
- [存储系统](#存储系统) - [存储系统](#存储系统)
@ -57,6 +58,24 @@ GFramework.Game 为游戏开发提供了专门的功能模块,与 GFramework.C
- **性能优化**:序列化缓存和优化策略 - **性能优化**:序列化缓存和优化策略
- **类型安全**:强类型的序列化和反序列化 - **类型安全**:强类型的序列化和反序列化
## 内容配置系统
`GFramework.Game` 当前包含面向静态游戏内容的 AI-First 配表能力,用于怪物、物品、技能、任务等只读内容数据。
这一能力的核心定位是:
- 使用 `YAML` 作为配置源文件
- 使用 `JSON Schema` 描述结构和约束
- 在运行时以只读配置表形式暴露
- 通过 Source Generator 生成配置类型和表包装
- 配套 VS Code 工具提供浏览、校验和轻量编辑入口
如果你准备在游戏项目中接入这套系统,请先阅读:
- [游戏内容配置系统](/zh-CN/game/config-system)
该页面包含目录约定、运行时注册方式、schema 绑定方式、生成器接入约定和当前限制说明。
## 架构模块系统 ## 架构模块系统
### AbstractModule 基础使用 ### AbstractModule 基础使用
@ -1395,4 +1414,4 @@ graph TD
- **.NET**: 6.0+ - **.NET**: 6.0+
- **Newtonsoft.Json**: 13.0.3+ - **Newtonsoft.Json**: 13.0.3+
- **GFramework.Core**: 与 Core 模块版本保持同步 - **GFramework.Core**: 与 Core 模块版本保持同步
--- ---

View File

@ -10,6 +10,7 @@ GFramework.SourceGenerators 是 GFramework 框架的源代码生成器包,通
- [核心特性](#核心特性) - [核心特性](#核心特性)
- [安装配置](#安装配置) - [安装配置](#安装配置)
- [Log 属性生成器](#log-属性生成器) - [Log 属性生成器](#log-属性生成器)
- [Config Schema 生成器](#config-schema-生成器)
- [ContextAware 属性生成器](#contextaware-属性生成器) - [ContextAware 属性生成器](#contextaware-属性生成器)
- [GenerateEnumExtensions 属性生成器](#generateenumextensions-属性生成器) - [GenerateEnumExtensions 属性生成器](#generateenumextensions-属性生成器)
- [Priority 属性生成器](#priority-属性生成器) - [Priority 属性生成器](#priority-属性生成器)
@ -36,6 +37,7 @@ GFramework.SourceGenerators 利用 Roslyn 源代码生成器技术,在编译
### 🎯 主要生成器 ### 🎯 主要生成器
- **[Log] 属性**:自动生成 ILogger 字段和日志方法 - **[Log] 属性**:自动生成 ILogger 字段和日志方法
- **Config Schema 生成器**:根据 `*.schema.json` 生成配置类型和表包装
- **[ContextAware] 属性**:自动实现 IContextAware 接口 - **[ContextAware] 属性**:自动实现 IContextAware 接口
- **[GenerateEnumExtensions] 属性**:自动生成枚举扩展方法 - **[GenerateEnumExtensions] 属性**:自动生成枚举扩展方法
- **[Priority] 属性**:自动实现 IPrioritized 接口,为类添加优先级标记 - **[Priority] 属性**:自动实现 IPrioritized 接口,为类添加优先级标记
@ -68,6 +70,45 @@ GFramework.SourceGenerators 利用 Roslyn 源代码生成器技术,在编译
</Project> </Project>
``` ```
### Config Schema 文件约定
当项目引用 `GeWuYou.GFramework.SourceGenerators` 的打包产物时,生成器会默认从 `schemas/**/*.schema.json` 收集配置 schema
文件并作为 `AdditionalFiles` 输入。
这意味着消费者项目通常只需要维护如下结构:
```text
GameProject/
├─ config/
│ └─ monster/
│ └─ slime.yaml
└─ schemas/
└─ monster.schema.json
```
如果你需要完整接入运行时加载、schema 校验和 VS Code 工具链,请继续阅读:
- [游戏内容配置系统](/zh-CN/game/config-system)
## Config Schema 生成器
Config Schema 生成器会扫描 `*.schema.json` 文件,并生成:
- 配置数据类型
- 与 `IConfigTable<TKey, TValue>` 对齐的表包装类型
这一生成器适合与 `GFramework.Game.Config.YamlConfigLoader` 配合使用,让 schema、运行时和工具链共享同一份结构约定。
当前支持的 schema 子集以内容配置系统文档中的说明为准,重点覆盖:
- `object` 根节点
- `required`
- `integer`
- `number`
- `boolean`
- `string`
- `array`
### 项目文件配置 ### 项目文件配置
```xml ```xml