diff --git a/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs new file mode 100644 index 00000000..9324b7b8 --- /dev/null +++ b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs @@ -0,0 +1,20 @@ +using GFramework.Game.Abstractions.Data; +using GFramework.Game.Abstractions.Enums; + +namespace GFramework.Game.Tests.Data; + +internal sealed record TestDataLocation( + string Key, + StorageKinds Kinds = StorageKinds.Local, + string? Namespace = null, + IReadOnlyDictionary? Metadata = null) : IDataLocation; + +internal sealed class TestSaveData : IData +{ + public string Name { get; set; } = string.Empty; +} + +internal sealed class TestSimpleData : IData +{ + public int Value { get; set; } +} \ No newline at end of file diff --git a/GFramework.Game.Tests/Data/PersistenceTests.cs b/GFramework.Game.Tests/Data/PersistenceTests.cs new file mode 100644 index 00000000..c5937034 --- /dev/null +++ b/GFramework.Game.Tests/Data/PersistenceTests.cs @@ -0,0 +1,95 @@ +using System.IO; +using GFramework.Game.Abstractions.Data; +using GFramework.Game.Data; +using GFramework.Game.Serializer; +using GFramework.Game.Storage; + +namespace GFramework.Game.Tests.Data; + +[TestFixture] +public class PersistenceTests +{ + private static string CreateTempRoot() + { + var path = Path.Combine(Path.GetTempPath(), "gframework-persistence", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + [Test] + public async Task FileStorage_PersistsDataAndRejectsIllegalKeys() + { + var root = CreateTempRoot(); + using var storage = new FileStorage(root, new JsonSerializer(), ".json"); + + var saved = new TestSimpleData { Value = 5 }; + await storage.WriteAsync("folder/item", saved); + + var loaded = await storage.ReadAsync("folder/item"); + Assert.That(loaded.Value, Is.EqualTo(saved.Value)); + + Assert.ThrowsAsync(async () => await storage.WriteAsync("../escape", new TestSimpleData())); + } + + [Test] + public async Task SaveRepository_ManagesSlots() + { + var root = CreateTempRoot(); + using var storage = new FileStorage(root, new JsonSerializer()); + var config = new SaveConfiguration + { + SaveRoot = "saves", + SaveSlotPrefix = "slot_", + SaveFileName = "save.json" + }; + + var repository = new SaveRepository(storage, config); + var data = new TestSaveData { Name = "hero" }; + + await repository.SaveAsync(1, data); + Assert.That(await repository.ExistsAsync(1)); + + var loaded = await repository.LoadAsync(1); + Assert.That(loaded.Name, Is.EqualTo(data.Name)); + + var slots = await repository.ListSlotsAsync(); + Assert.That(slots, Is.EqualTo(new[] { 1 })); + + await repository.DeleteAsync(1); + Assert.That(await repository.ExistsAsync(1), Is.False); + } + + [Test] + public async Task UnifiedSettingsDataRepository_RoundTripsDataAndLoadAll() + { + var root = CreateTempRoot(); + using var storage = new FileStorage(root, new JsonSerializer()); + var serializer = new JsonSerializer(); + var repo = new UnifiedSettingsDataRepository( + storage, + serializer, + new DataRepositoryOptions { EnableEvents = false }, + "settings.json"); + + var location = new TestDataLocation("settings/choice"); + repo.RegisterDataType(location, typeof(TestSimpleData)); + + var data = new TestSimpleData { Value = 42 }; + await repo.SaveAsync(location, data); + + using var storage2 = new FileStorage(root, new JsonSerializer()); + var repo2 = new UnifiedSettingsDataRepository( + storage2, + serializer, + new DataRepositoryOptions { EnableEvents = false }, + "settings.json"); + repo2.RegisterDataType(location, typeof(TestSimpleData)); + + var loaded = await repo2.LoadAsync(location); + Assert.That(loaded.Value, Is.EqualTo(data.Value)); + + var all = await repo2.LoadAllAsync(); + Assert.That(all.Keys, Contains.Item(location.Key)); + Assert.That(all[location.Key], Is.TypeOf()); + } +} \ No newline at end of file diff --git a/GFramework.Game.Tests/Serializer/JsonSerializerTests.cs b/GFramework.Game.Tests/Serializer/JsonSerializerTests.cs new file mode 100644 index 00000000..b6eb1a49 --- /dev/null +++ b/GFramework.Game.Tests/Serializer/JsonSerializerTests.cs @@ -0,0 +1,177 @@ +using Newtonsoft.Json; +using GameJsonSerializer = GFramework.Game.Serializer.JsonSerializer; + +namespace GFramework.Game.Tests.Serializer; + +[TestFixture] +public sealed class JsonSerializerTests +{ + [Test] + public void Serialize_And_Deserialize_Should_RoundTrip_Object() + { + var serializer = new GameJsonSerializer(); + var original = new PlayerStateStub + { + Name = "Player1", + Level = 7 + }; + + var json = serializer.Serialize(original); + var restored = serializer.Deserialize(json); + + Assert.Multiple(() => + { + Assert.That(restored.Name, Is.EqualTo("Player1")); + Assert.That(restored.Level, Is.EqualTo(7)); + }); + } + + [Test] + public void Serialize_Should_Honor_Injected_Settings() + { + var serializer = new GameJsonSerializer(new JsonSerializerSettings + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore + }); + + var json = serializer.Serialize(new OptionalStateStub + { + Name = "Configured", + Description = null + }); + + Assert.Multiple(() => + { + Assert.That(json, Does.Contain(Environment.NewLine)); + Assert.That(json, Does.Contain("\"Name\": \"Configured\"")); + Assert.That(json, Does.Not.Contain("Description")); + }); + } + + [Test] + public void Converters_Should_Be_Used_For_Serialization_And_Deserialization() + { + var serializer = new GameJsonSerializer(); + serializer.Converters.Add(new CoordinateStubConverter()); + + var json = serializer.Serialize(new CoordinateStub { X = 3, Y = 9 }); + var restored = serializer.Deserialize(json); + + Assert.Multiple(() => + { + Assert.That(json, Is.EqualTo("\"3:9\"")); + Assert.That(restored.X, Is.EqualTo(3)); + Assert.That(restored.Y, Is.EqualTo(9)); + }); + } + + [Test] + public void Deserialize_Should_Throw_With_Target_Type_Context_When_Json_Is_Invalid() + { + var serializer = new GameJsonSerializer(); + + var exception = + Assert.Throws(() => serializer.Deserialize("{invalid json}")); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain(typeof(PlayerStateStub).FullName)); + Assert.That(exception.InnerException, Is.Not.Null); + }); + } + + [Test] + public void Deserialize_With_Runtime_Type_Should_Throw_With_Target_Type_Context_When_Json_Is_Invalid() + { + var serializer = new GameJsonSerializer(); + + var exception = + Assert.Throws(() => + serializer.Deserialize("{invalid json}", typeof(PlayerStateStub))); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain(typeof(PlayerStateStub).FullName)); + Assert.That(exception.InnerException, Is.Not.Null); + }); + } + + [Test] + public void Deserialize_With_Runtime_Type_Should_Return_Target_Runtime_Type() + { + var serializer = new GameJsonSerializer(); + + var restored = serializer.Deserialize("{\"Name\":\"Runtime\",\"Level\":11}", typeof(PlayerStateStub)); + + Assert.That(restored, Is.TypeOf()); + Assert.That(((PlayerStateStub)restored).Level, Is.EqualTo(11)); + } + + [Test] + public void Serialize_With_Runtime_Type_Should_Allow_Null_Object() + { + var serializer = new GameJsonSerializer(); + + var json = serializer.Serialize(null!, typeof(PlayerStateStub)); + + Assert.That(json, Is.EqualTo("null")); + } + + [Test] + public void Deserialize_Should_Preserve_ArgumentException_For_Invalid_Input_Arguments() + { + var serializer = new GameJsonSerializer(); + + var exception = Assert.Throws(() => serializer.Deserialize(string.Empty)); + + Assert.That(exception, Is.Not.Null); + } + + private sealed class PlayerStateStub + { + public string Name { get; set; } = string.Empty; + + public int Level { get; set; } + } + + private sealed class OptionalStateStub + { + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + } + + private sealed class CoordinateStub + { + public int X { get; set; } + + public int Y { get; set; } + } + + private sealed class CoordinateStubConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, CoordinateStub? value, JsonSerializer serializer) + { + writer.WriteValue($"{value?.X}:{value?.Y}"); + } + + public override CoordinateStub ReadJson( + JsonReader reader, + Type objectType, + CoordinateStub? existingValue, + bool hasExistingValue, + JsonSerializer serializer) + { + var raw = (string?)reader.Value ?? throw new JsonSerializationException("Coordinate value cannot be null."); + var parts = raw.Split(':'); + return new CoordinateStub + { + X = int.Parse(parts[0]), + Y = int.Parse(parts[1]) + }; + } + } +} \ No newline at end of file diff --git a/GFramework.Game.Tests/Setting/SettingsModelTests.cs b/GFramework.Game.Tests/Setting/SettingsModelTests.cs new file mode 100644 index 00000000..984c517c --- /dev/null +++ b/GFramework.Game.Tests/Setting/SettingsModelTests.cs @@ -0,0 +1,181 @@ +using GFramework.Core.Abstractions.Architectures; +using GFramework.Core.Abstractions.Lifecycle; +using GFramework.Core.Abstractions.Rule; +using GFramework.Game.Abstractions.Data; +using GFramework.Game.Abstractions.Enums; +using GFramework.Game.Abstractions.Setting; +using GFramework.Game.Setting; + +namespace GFramework.Game.Tests.Setting; + +[TestFixture] +public sealed class SettingsModelTests +{ + [Test] + public void GetData_After_Initialize_Should_Register_New_Type_In_Repository() + { + var locationProvider = new TestDataLocationProvider(); + var repository = new FakeSettingsDataRepository(); + var model = new SettingsModel(locationProvider, repository); + ((IContextAware)model).SetContext(new Mock(MockBehavior.Loose).Object); + + ((IInitializable)model).Initialize(); + + _ = model.GetData(); + + Assert.That(repository.RegisteredTypes, Contains.Key("TestSettingsData")); + Assert.That(repository.RegisteredTypes["TestSettingsData"], Is.EqualTo(typeof(TestSettingsData))); + } + + [Test] + public async Task RegisterMigration_After_Cache_Warmup_Should_Invalidate_Type_Cache() + { + var locationProvider = new TestDataLocationProvider(); + var repository = new FakeSettingsDataRepository(); + var model = new SettingsModel(locationProvider, repository); + ((IContextAware)model).SetContext(new Mock(MockBehavior.Loose).Object); + + _ = model.GetData(); + ((IInitializable)model).Initialize(); + + repository.Stored["TestSettingsData"] = new TestSettingsData + { + Version = 1, + Value = "legacy" + }; + + await model.InitializeAsync(); + Assert.That(model.GetData().Version, Is.EqualTo(1)); + + model.RegisterMigration(new TestSettingsMigration()); + + repository.Stored["TestSettingsData"] = new TestSettingsData + { + Version = 1, + Value = "legacy" + }; + + await model.InitializeAsync(); + + var current = model.GetData(); + Assert.Multiple(() => + { + Assert.That(current.Version, Is.EqualTo(2)); + Assert.That(current.Value, Is.EqualTo("legacy-migrated")); + }); + } + + private sealed class TestSettingsData : ISettingsData + { + public string Value { get; set; } = "default"; + + public int Version { get; set; } = 1; + + public DateTime LastModified { get; } = DateTime.UtcNow; + + public void Reset() + { + Value = "default"; + Version = 1; + } + + public void LoadFrom(ISettingsData source) + { + if (source is not TestSettingsData data) + { + return; + } + + Value = data.Value; + Version = data.Version; + } + } + + private sealed class TestSettingsMigration : ISettingsMigration + { + public Type SettingsType => typeof(TestSettingsData); + + public int FromVersion => 1; + + public int ToVersion => 2; + + public ISettingsSection Migrate(ISettingsSection oldData) + { + var data = (TestSettingsData)oldData; + return new TestSettingsData + { + Version = 2, + Value = $"{data.Value}-migrated" + }; + } + } + + private sealed class FakeSettingsDataRepository : ISettingsDataRepository + { + public Dictionary RegisteredTypes { get; } = new(StringComparer.Ordinal); + + public Dictionary Stored { get; } = new(StringComparer.Ordinal); + + public Task LoadAsync(IDataLocation location) where T : class, IData, new() + { + return Task.FromResult(Stored.TryGetValue(location.Key, out var data) ? (T)data : new T()); + } + + public Task SaveAsync(IDataLocation location, T data) where T : class, IData + { + Stored[location.Key] = data; + return Task.CompletedTask; + } + + public Task ExistsAsync(IDataLocation location) + { + return Task.FromResult(Stored.ContainsKey(location.Key)); + } + + public Task DeleteAsync(IDataLocation location) + { + Stored.Remove(location.Key); + return Task.CompletedTask; + } + + public Task SaveAllAsync(IEnumerable<(IDataLocation location, IData data)> dataList) + { + foreach (var (location, data) in dataList) + { + Stored[location.Key] = data; + } + + return Task.CompletedTask; + } + + public Task> LoadAllAsync() + { + IDictionary snapshot = new Dictionary(Stored, StringComparer.Ordinal); + return Task.FromResult(snapshot); + } + + public void RegisterDataType(IDataLocation location, Type type) + { + RegisteredTypes[location.Key] = type; + } + } + + private sealed class TestDataLocationProvider : IDataLocationProvider + { + public IDataLocation GetLocation(Type type) + { + return new TestDataLocation(type.Name); + } + } + + private sealed class TestDataLocation(string key) : IDataLocation + { + public string Key { get; } = key; + + public StorageKinds Kinds => StorageKinds.Memory; + + public string? Namespace => "tests"; + + public IReadOnlyDictionary? Metadata => null; + } +} \ No newline at end of file diff --git a/GFramework.Game/Data/UnifiedSettingsDataRepository.cs b/GFramework.Game/Data/UnifiedSettingsDataRepository.cs index 56db1cbe..8fd693a7 100644 --- a/GFramework.Game/Data/UnifiedSettingsDataRepository.cs +++ b/GFramework.Game/Data/UnifiedSettingsDataRepository.cs @@ -80,11 +80,10 @@ public class UnifiedSettingsDataRepository( public async Task SaveAsync(IDataLocation location, T data) where T : class, IData { + await EnsureLoadedAsync(); await _lock.WaitAsync(); try { - await EnsureLoadedAsync(); - var key = location.Key; var serialized = Serializer.Serialize(data); @@ -211,7 +210,9 @@ public class UnifiedSettingsDataRepository( var key = UnifiedKey; - _file = await Storage.ExistsAsync(key) ? await Storage.ReadAsync(key) : new UnifiedSettingsFile { Version = 1 }; + _file = await Storage.ExistsAsync(key) + ? await Storage.ReadAsync(key) + : new UnifiedSettingsFile { Version = 1 }; _loaded = true; } diff --git a/GFramework.Game/Serializer/JsonSerializer.cs b/GFramework.Game/Serializer/JsonSerializer.cs index df761850..3e498840 100644 --- a/GFramework.Game/Serializer/JsonSerializer.cs +++ b/GFramework.Game/Serializer/JsonSerializer.cs @@ -9,6 +9,27 @@ namespace GFramework.Game.Serializer; public sealed class JsonSerializer : IRuntimeTypeSerializer { + private readonly JsonSerializerSettings _settings; + + /// + /// 初始化 JSON 序列化器。 + /// + /// 可选的 Newtonsoft.Json 配置;不提供时使用默认配置。 + public JsonSerializer(JsonSerializerSettings? settings = null) + { + _settings = settings ?? new JsonSerializerSettings(); + } + + /// + /// 获取当前序列化器使用的 Newtonsoft.Json 配置实例。 + /// + public JsonSerializerSettings Settings => _settings; + + /// + /// 获取当前序列化器使用的自定义转换器集合。 + /// + public IList Converters => _settings.Converters; + /// /// 将指定类型的对象序列化为JSON字符串 /// @@ -17,21 +38,9 @@ public sealed class JsonSerializer /// 序列化后的JSON字符串 public string Serialize(T value) { - return JsonConvert.SerializeObject(value); + return JsonConvert.SerializeObject(value, _settings); } - /// - /// 将JSON字符串反序列化为指定类型的对象 - /// - /// 要反序列化的目标类型 - /// 要反序列化的JSON字符串数据 - /// 反序列化后的对象实例 - /// 当无法反序列化数据时抛出 - public T Deserialize(string data) - { - return JsonConvert.DeserializeObject(data) - ?? throw new ArgumentException("Cannot deserialize data"); - } /// /// 将对象序列化为JSON字符串(使用运行时类型) @@ -41,7 +50,24 @@ public sealed class JsonSerializer /// 序列化后的JSON字符串 public string Serialize(object obj, Type type) { - return JsonConvert.SerializeObject(obj, type, null); + ArgumentNullException.ThrowIfNull(type); + + return JsonConvert.SerializeObject(obj, type, _settings); + } + + /// + /// 将JSON字符串反序列化为指定类型的对象 + /// + /// 要反序列化的目标类型 + /// 要反序列化的JSON字符串数据 + /// 反序列化后的对象实例 + /// 当无法反序列化数据时抛出 + public T Deserialize(string data) + { + return (T)DeserializeCore( + data, + typeof(T), + static (json, _, settings) => JsonConvert.DeserializeObject(json, settings)); } /// @@ -50,10 +76,43 @@ public sealed class JsonSerializer /// 要反序列化的JSON字符串数据 /// 反序列化目标类型 /// 反序列化后的对象实例 - /// 当无法反序列化到指定类型时抛出 + /// 当无法反序列化到指定类型时抛出 public object Deserialize(string data, Type type) { - return JsonConvert.DeserializeObject(data, type) - ?? throw new ArgumentException($"Cannot deserialize to {type.Name}"); + return DeserializeCore( + data, + type, + static (json, targetType, settings) => JsonConvert.DeserializeObject(json, targetType, settings)); + } + + private object DeserializeCore( + string data, + Type targetType, + Func deserialize) + { + ArgumentException.ThrowIfNullOrWhiteSpace(data); + ArgumentNullException.ThrowIfNull(targetType); + ArgumentNullException.ThrowIfNull(deserialize); + + object? result; + + try + { + result = deserialize(data, targetType, _settings); + } + catch (Exception ex) when (ex is not ArgumentException) + { + throw new InvalidOperationException( + $"Failed to deserialize JSON to target type '{targetType.FullName}'.", + ex); + } + + if (result == null) + { + throw new InvalidOperationException( + $"Deserialization returned null for target type '{targetType.FullName}'."); + } + + return result; } } \ No newline at end of file diff --git a/GFramework.Game/Setting/SettingsModel.cs b/GFramework.Game/Setting/SettingsModel.cs index d480589c..1c8f8915 100644 --- a/GFramework.Game/Setting/SettingsModel.cs +++ b/GFramework.Game/Setting/SettingsModel.cs @@ -59,7 +59,9 @@ public class SettingsModel(IDataLocationProvider? locationProvider, public T GetData() where T : class, ISettingsData, new() { // 使用_data字典获取或添加指定类型的实例,确保唯一性 - return (T)_data.GetOrAdd(typeof(T), _ => new T()); + var data = (T)_data.GetOrAdd(typeof(T), _ => new T()); + TryRegisterDataType(typeof(T)); + return data; } /// @@ -84,6 +86,7 @@ public class SettingsModel(IDataLocationProvider? locationProvider, { _applicators[typeof(T)] = applicator; _data[applicator.DataType] = applicator.Data; + TryRegisterDataType(applicator.DataType); return this; } @@ -114,6 +117,7 @@ public class SettingsModel(IDataLocationProvider? locationProvider, public ISettingsModel RegisterMigration(ISettingsMigration migration) { _migrations[(migration.SettingsType, migration.FromVersion)] = migration; + _migrationCache.TryRemove(migration.SettingsType, out _); return this; } @@ -258,11 +262,21 @@ public class SettingsModel(IDataLocationProvider? locationProvider, // 遍历所有已知的数据类型,为其分配位置并注册到数据仓库中 foreach (var type in _data.Keys) { - var location = _locationProvider.GetLocation(type); - DataRepository.RegisterDataType(location, type); + TryRegisterDataType(type); } } + private void TryRegisterDataType(Type type) + { + if (_repository == null || _locationProvider == null) + { + return; + } + + var location = _locationProvider.GetLocation(type); + _repository.RegisterDataType(location, type); + } + private ISettingsData MigrateIfNeeded(ISettingsData data) { if (data is not IVersionedData versioned) diff --git a/docs/zh-CN/game/data.md b/docs/zh-CN/game/data.md index 53a48738..c6c92dad 100644 --- a/docs/zh-CN/game/data.md +++ b/docs/zh-CN/game/data.md @@ -1,21 +1,21 @@ --- title: 数据与存档系统 -description: 数据与存档系统提供了完整的数据持久化解决方案,支持多槽位存档、版本管理和数据迁移。 +description: 数据与存档系统提供统一的数据持久化基础能力,支持多槽位存档、版本化数据和仓库抽象。 --- # 数据与存档系统 ## 概述 -数据与存档系统是 GFramework.Game 中用于管理游戏数据持久化的核心组件。它提供了统一的数据加载和保存接口,支持多槽位存档管理、数据版本控制和自动迁移,让你可以轻松实现游戏存档、设置保存等功能。 - -通过数据系统,你可以将游戏数据保存到本地存储,支持多个存档槽位,并在数据结构变化时自动进行版本迁移。 +数据与存档系统是 GFramework.Game 中用于管理游戏数据持久化的核心组件。它提供了统一的数据加载和保存接口,支持多槽位存档管理、版本化数据模式,以及与存储系统、序列化系统的组合使用。 + +通过数据系统,你可以将游戏数据保存到本地存储,支持多个存档槽位,并在需要时于应用层实现版本迁移。 **主要特性**: - 统一的数据持久化接口 - 多槽位存档管理 -- 数据版本控制和迁移 +- 数据版本控制模式 - 异步加载和保存 - 批量数据操作 - 与存储系统集成 @@ -69,10 +69,11 @@ public interface ISaveRepository : IUtility `IVersionedData` 支持数据版本管理: ```csharp -public interface IVersionedData : IData -{ - int Version { get; set; } -} +public interface IVersionedData : IData +{ + int Version { get; } + DateTime LastModified { get; } +} ``` ## 基本用法 @@ -262,9 +263,13 @@ public partial class AutoSaveController : IController } ``` -### 数据版本迁移 - -```csharp +### 数据版本迁移 + +`SaveRepository` 当前负责槽位存档的读取、写入、删除和列举,并没有内建“注册迁移器后自动升级存档”的统一迁移管线。 + +下面示例展示的是应用层迁移策略:加载后检查版本,调用你自己的迁移逻辑,再决定是否回写新版本数据。 + +```csharp // 版本 1 的数据 public class SaveDataV1 : IVersionedData { @@ -299,18 +304,18 @@ public class SaveDataMigrator } } -// 加载时自动迁移 -public async Task LoadWithMigration(int slot) -{ - var saveRepo = this.GetUtility>(); +// 加载后由应用层决定是否迁移 +public async Task LoadWithMigration(int slot) +{ + var saveRepo = this.GetUtility>(); var data = await saveRepo.LoadAsync(slot); if (data.Version < 2) { - // 需要迁移 - var oldData = data as SaveDataV1; - var migrator = new SaveDataMigrator(); - var newData = migrator.Migrate(oldData); + // 需要迁移:此处调用应用层迁移器 + var oldData = data as SaveDataV1; + var migrator = new SaveDataMigrator(); + var newData = migrator.Migrate(oldData); // 保存迁移后的数据 await saveRepo.SaveAsync(slot, newData); @@ -506,10 +511,10 @@ await saveRepo.SaveAsync(2, saveData); // 槽位 2 await saveRepo.SaveAsync(3, saveData); // 槽位 3 ``` -### 问题:如何处理数据版本升级? - -**解答**: -实现 `IVersionedData` 并在加载时检查版本: +### 问题:如何处理数据版本升级? + +**解答**: +实现 `IVersionedData` 并在加载后检查版本。当前框架不会自动为 `ISaveRepository` 执行迁移,需要由业务层决定迁移规则与回写时机: ```csharp var data = await saveRepo.LoadAsync(slot); diff --git a/docs/zh-CN/game/index.md b/docs/zh-CN/game/index.md index 2919e278..7beee74b 100644 --- a/docs/zh-CN/game/index.md +++ b/docs/zh-CN/game/index.md @@ -675,6 +675,7 @@ public class CachedStorage : IStorage ```csharp using GFramework.Game.Serializer; +using Newtonsoft.Json; public class GameDataSerializer { diff --git a/docs/zh-CN/game/setting.md b/docs/zh-CN/game/setting.md index 7b452aa2..bbf16f2d 100644 --- a/docs/zh-CN/game/setting.md +++ b/docs/zh-CN/game/setting.md @@ -1,192 +1,199 @@ -# 设置系统 (Settings System) +# 设置系统 -## 概述 +设置系统负责管理 `ISettingsData`、持久化加载/保存,以及把设置真正应用到运行时环境。 -设置系统是 GFramework.Game 的核心组件之一,负责管理游戏中各种设置配置。该系统采用了模型-系统分离的设计模式,支持设置部分(Section)的管理和设置应用器模式。 +当前实现以 `SettingsModel` 和 `SettingsSystem` 为核心,已经不是旧文档中的 +`Get() / Register(IApplyAbleSettings)` 接口模型。 -## 核心类 +## 核心概念 -### SettingsModel +### ISettingsData -设置模型类,继承自 `AbstractModel` 并实现 `ISettingsModel` 接口。 - -**主要功能:** - -- 管理不同类型的设置部分(Settings Section) -- 提供类型安全的设置访问 -- 支持可应用设置对象的注册 - -**关键方法:** - -- `Get()` - 获取或创建指定类型的设置部分 -- `TryGet(Type, out ISettingsSection)` - 尝试获取设置部分 -- `Register(IApplyAbleSettings)` - 注册可应用的设置对象 -- `All()` - 获取所有设置部分 - -### SettingsSystem - -设置系统类,继承自 `AbstractSystem` 并实现 `ISettingsSystem` 接口。 - -**主要功能:** - -- 应用设置配置到相应系统 -- 支持单个或批量设置应用 -- 自动识别可应用设置类型 - -**关键方法:** - -- `ApplyAll()` - 应用所有设置配置 -- `Apply()` - 应用指定类型的设置 -- `Apply(IEnumerable)` - 应用指定类型集合的设置 - -## 架构设计 - -```mermaid -graph TD - A[ISettingsModel] --> B[SettingsModel] - C[ISettingsSystem] --> D[SettingsSystem] - - B --> E[Dictionary] - D --> B - - F[ISettingsSection] --> G[IApplyAbleSettings] - H[AudioSettings] --> G - I[GraphicsSettings] --> G - - E --> H - E --> I - - J[Application] --> D - D --> G -``` - -## 使用示例 - -### 基本使用 +设置数据对象负责保存设置值、提供默认值,并在加载后把外部数据回填到当前实例。 ```csharp -// 获取设置模型 -var settingsModel = this.GetModel(); - -// 获取或创建音频设置 -var audioSettings = settingsModel.Get(); -audioSettings.MasterVolume = 0.8f; -audioSettings.BgmVolume = 0.6f; -audioSettings.SfxVolume = 0.9f; - -// 注册设置到模型 -settingsModel.Register(audioSettings); +public interface ISettingsData : IResettable, IVersionedData, ILoadableFrom; ``` -### 应用设置 +这意味着一个设置数据类型通常需要实现: + +- `Reset()`:恢复默认值 +- `Version` / `LastModified`:暴露版本化信息 +- `LoadFrom(ISettingsData)`:把已加载或迁移后的数据复制到当前实例 + +### IResetApplyAbleSettings + +应用器负责把设置数据作用到引擎或运行时环境: ```csharp -// 获取设置系统 -var settingsSystem = this.GetSystem(); - -// 应用所有设置 -await settingsSystem.ApplyAll(); - -// 应用特定类型设置 -await settingsSystem.Apply(); - -// 应用多个类型设置 -var types = new[] { typeof(GodotAudioSettings), typeof(GodotGraphicsSettings) }; -await settingsSystem.Apply(types); -``` - -### 创建自定义设置 - -```csharp -public class GameSettings : ISettingsSection +public interface IResetApplyAbleSettings : IResettable, IApplyAbleSettings { - public float GameSpeed { get; set; } = 1.0f; - public int Difficulty { get; set; } = 1; - public bool AutoSave { get; set; } = true; + ISettingsData Data { get; } + Type DataType { get; } } - -// 使用自定义设置 -var gameSettings = settingsModel.Get(); -gameSettings.GameSpeed = 1.5f; ``` -### 创建可应用设置 +常见用途包括: + +- 把音量设置同步到音频总线 +- 把图形设置同步到窗口系统 +- 把语言设置同步到本地化管理器 + +## ISettingsModel + +当前 `ISettingsModel` 的主要 API 如下: ```csharp -public class GameSettings : ISettingsSection, IApplyAbleSettings +public interface ISettingsModel : IModel +{ + bool IsInitialized { get; } + + T GetData() where T : class, ISettingsData, new(); + IEnumerable AllData(); + + ISettingsModel RegisterApplicator(T applicator) + where T : class, IResetApplyAbleSettings; + T? GetApplicator() where T : class, IResetApplyAbleSettings; + IEnumerable AllApplicators(); + + ISettingsModel RegisterMigration(ISettingsMigration migration); + + Task InitializeAsync(); + Task SaveAllAsync(); + Task ApplyAllAsync(); + void Reset() where T : class, ISettingsData, new(); + void ResetAll(); +} +``` + +行为说明: + +- `GetData()` 返回某个设置数据的唯一实例 +- `RegisterApplicator()` 注册应用器,并把其 `Data` 纳入模型管理 +- `InitializeAsync()` 从 `ISettingsDataRepository` 读取所有已注册设置,并在需要时执行迁移 +- `SaveAllAsync()` 持久化当前所有设置数据 +- `ApplyAllAsync()` 依次调用所有 applicator 的 `Apply()` + +## SettingsSystem + +`SettingsSystem` 是对模型的系统级封装,面向业务代码提供更直接的入口: + +```csharp +public interface ISettingsSystem : ISystem +{ + Task ApplyAll(); + Task Apply() where T : class, IResetApplyAbleSettings; + Task SaveAll(); + Task Reset() where T : class, ISettingsData, IResetApplyAbleSettings, new(); + Task ResetAll(); +} +``` + +它不会自己保存数据,而是把保存、重置和应用逻辑委托给 `ISettingsModel`。 + +## 基本用法 + +### 定义设置数据 + +```csharp +public sealed class GameplaySettings : ISettingsData { public float GameSpeed { get; set; } = 1.0f; - public int Difficulty { get; set; } = 1; - + + public int Version { get; private set; } = 1; + public DateTime LastModified { get; } = DateTime.UtcNow; + + public void Reset() + { + GameSpeed = 1.0f; + } + + public void LoadFrom(ISettingsData source) + { + if (source is not GameplaySettings settings) + { + return; + } + + GameSpeed = settings.GameSpeed; + Version = settings.Version; + } +} +``` + +### 定义 applicator + +```csharp +public sealed class GameplaySettingsApplicator : IResetApplyAbleSettings +{ + public GameplaySettingsApplicator(GameplaySettings data) + { + Data = data; + } + + public ISettingsData Data { get; } + public Type DataType => typeof(GameplaySettings); + + public void Reset() + { + Data.Reset(); + } + public Task Apply() { - // 应用游戏速度 - Time.timeScale = GameSpeed; - - // 应用难度设置 - GameDifficulty.Current = Difficulty; - + var settings = (GameplaySettings)Data; + TimeScale.Current = settings.GameSpeed; return Task.CompletedTask; } } ``` -## 接口定义 - -### ISettingsSection +### 使用模型和系统 ```csharp -public interface ISettingsSection +var settingsModel = this.GetModel(); + +var gameplayData = settingsModel.GetData(); +gameplayData.GameSpeed = 1.25f; + +settingsModel.RegisterApplicator(new GameplaySettingsApplicator(gameplayData)); + +await settingsModel.InitializeAsync(); +await settingsModel.SaveAllAsync(); + +var settingsSystem = this.GetSystem(); +await settingsSystem.ApplyAll(); +``` + +## 迁移 + +设置系统内建了迁移注册入口: + +```csharp +public interface ISettingsMigration { - // 设置部分的标识接口 + Type SettingsType { get; } + int FromVersion { get; } + int ToVersion { get; } + ISettingsData Migrate(ISettingsData oldData); } ``` -### IApplyAbleSettings +当 `InitializeAsync()` 读取到旧版本设置时,会按已注册迁移链逐步升级,再通过 `LoadFrom` 回填到当前实例。 -```csharp -public interface IApplyAbleSettings : ISettingsSection -{ - Task Apply(); -} -``` +## 依赖项 -### ISettingsModel +要让设置系统完整工作,通常需要准备: -```csharp -public interface ISettingsModel -{ - T Get() where T : class, ISettingsSection, new(); - bool TryGet(Type type, out ISettingsSection section); - IEnumerable All(); - void Register(IApplyAbleSettings applyAble); -} -``` +- `ISettingsDataRepository` +- `IDataLocationProvider` +- 一个具体的存储实现和序列化器 -### ISettingsSystem +如果使用 `UnifiedSettingsDataRepository`,多个设置节会被合并到单个设置文件中统一保存。 -```csharp -public interface ISettingsSystem -{ - Task ApplyAll(); - Task Apply() where T : class, ISettingsSection; - Task Apply(Type settingsType); - Task Apply(IEnumerable settingsTypes); -} -``` +## 当前边界 -## 设计模式 - -该系统使用了以下设计模式: - -1. **Repository Pattern** - SettingsModel 作为设置数据的仓库 -2. **Command Pattern** - IApplyAbleSettings 的 Apply 方法作为命令 -3. **Factory Pattern** - Get``() 方法创建设置实例 -4. **Template Method** - AbstractSystem 提供初始化模板 - -## 最佳实践 - -1. **设置分类** - 将相关设置组织到同一个设置类中 -2. **延迟应用** - 批量修改后再应用,而不是每次修改都应用 -3. **类型安全** - 使用泛型方法确保类型安全 -4. **可测试性** - 通过接口实现便于单元测试 \ No newline at end of file +- 设置迁移是内建能力 +- 设置持久化是内建能力 +- 设置如何应用到具体引擎由 applicator 决定 +- 存档系统的迁移能力不等同于设置系统;`ISaveRepository` 当前仍需要业务层自己实现迁移策略