# ShrinkDataSaver 一个可用于 Unity、Godot 和普通 .NET 宿主的模块化存档与设置管理系统。支持多存档槽、链式版本迁移、可选 AES-256 加密、关键模块保护、跨模块只读查询,以及完整的事件驱动架构。 Godot 和普通 .NET 项目安装 `ShrinkSDK.DataSaver`;可打包源码位于 `DotNet~`,持久化路径与生命周期由宿主平台服务注入。 ## ✨ 特性概览 | 特性 | 说明 | |------|------| | 💾 **模块化存档** | 按模块拆分存档数据,注册即用,读写隔离 | | 🔢 **多存档槽** | 槽位数量可配置,支持元数据轻量查询 | | 🔄 **链式版本迁移** | 注册迁移规则后自动链式执行,失败时整体回滚 | | 🔒 **可选加密** | AES-256-CBC + PBKDF2-SHA256,随机 Salt/IV,密钥由使用者管理 | | ⚡ **事件驱动** | 8 种原生事件,覆盖保存/加载/删除/迁移的完整生命周期 | | 🔍 **跨模块查询** | `QueryModule` 只读查询其他模块的运行时数据 | | 🛡️ **关键模块保护** | `CriticalModule` 标记的模块序列化失败将中止整个保存操作 | | ⏱️ **自动保存** | 按模块配置的最小间隔自动触发保存 | | ⚙️ **Settings 系统** | 独立于存档的键值对设置,防抖写入,本地持久化 | | 🧷 **最近游玩槽位** | 自动记录最近一次成功进入的槽位,可用于“继续游戏” | | 🛠️ **双副本备份** | 主文件原子替换,自动保留 `.bak1` / `.bak2` 双副本轮换 | | ☁️ **云存档兼容** | 每槽单文件 `.sav`,模块级 `EnableCloudSync` 开关 | | 🔗 **EventBus 集成** | 可选接入 ShrinkEventBus,所有事件自动桥接到事件总线 | | 🖥️ **Editor 工具** | 中文可视化调试窗口,实时查看/操作设置与存档 | ## 📦 依赖 - Unity 2022.3+ - [UniTask](https://github.com/Cysharp/UniTask) `2.x` - [Newtonsoft.Json](https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2/manual/index.html)(`com.unity.nuget.newtonsoft-json`) ## ⚙️ 安装 在项目的 `Packages/manifest.json` 中添加: ```json { "dependencies": { "com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask", "com.cneicy.shrink-datasaver": "https://git.crash.work/ShrinkSDK/ShrinkDataSaver.git" } } ``` 或通过 Package Manager → `+` → `Add package from git URL` 输入: ``` https://git.crash.work/ShrinkSDK/ShrinkDataSaver.git ``` ## 🚀 快速上手 ### 第一步:创建配置资产 菜单 `ShrinkSDK` → `存档` → `创建设置`。 菜单会创建 `Assets/Resources/GameAssets/Runtime/Data/ShrinkSDK/ShrinkDataSaverSettings.asset`。 运行时优先从该路径加载,并保留旧的 `Assets/Resources/ShrinkDataSaverSettings.asset` 回退;也可以在 Bootstrap 组件上手动指定。 > ⚠️ 未找到配置文件时,控制台会输出警告并使用默认配置。 ### 第二步:放置 Bootstrap 在首场景中创建 GameObject,挂载 `ShrinkDataSaverBootstrap` 组件: - **Settings Override**:可选,手动拖入配置资产(留空则自动查找) - **Current Save Version**:当前存档版本号(从 `1` 开始) Bootstrap 会自动 `DontDestroyOnLoad`,并在应用退出 / 移动端切后台时自动写入 Settings。 从当前版本开始,真正的初始化逻辑已下沉到 `ShrinkDataSaverRuntime`。这意味着: - 旧项目继续挂 `ShrinkDataSaverBootstrap` 也能正常工作 - 如果项目接入了 `ShrinkApp`,则可由宿主统一调用 `ShrinkDataSaverRuntime.Initialize(...)` - 宿主已接管时,旧 bootstrap 会自动幂等退出,不重复初始化 ### 第三步:注册存档模块 ```csharp // 方式 A:Lambda(轻量) ShrinkSave.RegisterModule( key: "inventory", serialize: () => inventoryManager.GetData(), deserialize: data => inventoryManager.LoadData(data) ); // 方式 B:Lambda + 模块配置 ShrinkSave.RegisterModule( key: "quests", serialize: () => questSystem.GetData(), deserialize: data => questSystem.LoadData(data), config: new ModuleConfig { EnableCloudSync = true, AutoSaveIntervalSeconds = 60f, // 每 60 秒自动保存 CriticalModule = true // 序列化失败将中止保存 } ); // 方式 C:接口(结构化) public class InventoryModule : ISaveModule { public string Key => "inventory"; public InventoryData Serialize() => inventoryManager.GetData(); public void Deserialize(InventoryData data) => inventoryManager.LoadData(data); } ShrinkSave.RegisterModule(new InventoryModule()); ``` ### 第四步:保存与加载 ```csharp // 保存 await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "第一周目", CaptureScreenshot = true }); // 加载 await ShrinkSave.LoadSlotAsync(0); // 删除 await ShrinkSave.DeleteSlotAsync(0); ``` --- ## 📖 核心概念 ### 事件系统 系统在关键操作时触发原生 C# 事件,所有事件携带完整载荷数据: | 事件 | 触发时机 | 载荷 | |------|----------|------| | `OnSaveStarted` | 开始保存前 | SlotIndex, Timestamp | | `OnSaveCompleted` | 保存成功后 | SlotIndex, ModuleNames[], Timestamp | | `OnSaveFailed` | 保存失败时 | SlotIndex, ErrorMessage | | `OnLoadStarted` | 开始加载前 | SlotIndex, Timestamp | | `OnLoadCompleted` | 加载成功后 | SlotIndex, ModuleNames[], Version, Timestamp | | `OnLoadFailed` | 加载失败时 | SlotIndex, ErrorMessage | | `OnMigrationCompleted` | 版本迁移完成后 | SlotIndex, FromVersion, ToVersion | | `OnDeleteCompleted` | 删除存档后 | SlotIndex | ```csharp ShrinkSave.OnSaveCompleted += args => Debug.Log($"槽位 {args.SlotIndex} 保存成功,模块: {string.Join(", ", args.ModuleNames)}"); ShrinkSave.OnLoadFailed += args => ShowErrorDialog($"加载失败: {args.ErrorMessage}"); ShrinkSave.OnMigrationCompleted += args => Debug.Log($"存档从 v{args.FromVersion} 迁移到 v{args.ToVersion}"); ``` ### 模块配置 注册模块时可传入 `ModuleConfig` 控制行为: ```csharp var config = new ModuleConfig { EnableCloudSync = false, // 不参与云存档(如本地设置) AutoSaveIntervalSeconds = 30f, // 自动保存间隔(0 = 禁用) CriticalModule = true // 序列化失败中止整个保存 }; ShrinkSave.RegisterModule("settings", () => data, d => data = d, config); ``` - **EnableCloudSync**:标记该模块是否参与云同步(供业务层查询) - **AutoSaveIntervalSeconds**:Bootstrap 会取所有模块中最小的非零间隔,定时自动保存到当前已加载的槽位 - **CriticalModule**:标记为关键模块后,序列化异常会触发 `OnSaveFailed` 并中止保存;非关键模块异常仅跳过该模块 ### 跨模块只读查询 模块间需要共享数据时,通过存档管理器提供的只读接口查询,避免直接耦合: ```csharp // 查询其他模块的当前运行时数据(序列化快照) var playerStats = ShrinkSave.QueryModule("playerStats"); if (playerStats != null) Debug.Log($"玩家等级: {playerStats.Level}"); // 检查已加载存档中某模块是否包含特定键 bool hasCoins = ShrinkSave.HasKey("inventory", "coins"); // 检查模块是否已注册 bool registered = ShrinkSave.HasModule("quests"); ``` ### 最近游玩槽位 包内会在 `LoadSlotAsync(...)` 成功后自动记录最近一次成功进入的槽位,并在删除该槽位时自动回退到其他有效槽位: ```csharp int recentSlot = ShrinkSave.GetRecentSlotIndex(); int continueSlot = await ShrinkSave.GetRecommendedContinueSlotAsync(); if (continueSlot >= 0) { await ShrinkSave.LoadSlotAsync(continueSlot); } ``` ### 版本迁移 每次存档结构变化时,注册迁移规则。**必须在 `LoadSlotAsync` 调用之前注册。** ```csharp // 注册迁移(在游戏初始化时) MigrationChain.Register(fromVersion: 1, toVersion: 2, data => { // data 是整个存档的 JObject,包含所有模块 if (data["inventory"] is JObject inv) { inv["gold"] = inv["coins"]; inv.Remove("coins"); } return data; }); MigrationChain.Register(fromVersion: 2, toVersion: 3, data => { if (data["quests"] is JObject q) q["dailyQuestReset"] = 0; return data; }); // 同步更新 Bootstrap 上的 Current Save Version = 3 ShrinkSave.SetCurrentSaveVersion(3); ``` 加载时自动检测版本差异,链式执行所有中间迁移(v1 → v2 → v3)。**迁移失败时自动回滚到迁移前的数据,不会丢失原始存档。** ### Settings 系统 独立于存档的键值对设置,本地持久化,不参与云存档: ```csharp // 读写 ShrinkSettings.Set("MasterVolume", 0.8f); ShrinkSettings.Set("Language", "zh-CN"); float volume = ShrinkSettings.Get("MasterVolume", defaultValue: 1f); bool exists = ShrinkSettings.Has("MasterVolume"); ShrinkSettings.Remove("MasterVolume"); // 查看所有设置 var all = ShrinkSettings.GetAllRaw(); // IReadOnlyDictionary // 监听变更 ShrinkSettings.OnChanged += (key, value) => Debug.Log($"{key} = {value}"); ShrinkSettings.Watch("MasterVolume", vol => ApplyVolume(vol)); ``` 变更时立刻触发回调,写入磁盘有防抖延迟(默认 300ms),防止高频调用产生大量 IO。 ### 加密 可选加密,默认关闭。使用 AES-256-CBC + PBKDF2-SHA256(10000 次迭代),每次加密随机生成 Salt 和 IV: ```csharp // 加密保存 await ShrinkSave.SaveSlotAsync(0, new SaveOptions { Encrypt = true, EncryptionKey = "your-secret-key" }); // 解密加载(需传入相同密钥) await ShrinkSave.LoadSlotAsync(0, decryptionKey: "your-secret-key"); // 通过元数据判断是否加密 var meta = await ShrinkSave.GetMetaAsync(0); if (meta.IsEncrypted) ShowPasswordPrompt(); ``` --- ## 🔗 ShrinkEventBus 集成 > 详细文档见 [ShrinkDataSaver.Integration.EventBus](https://git.crash.work/ShrinkSDK/ShrinkDataSaver.Integration.EventBus) 项目中同时包含 `ShrinkDataSaver.Integration.EventBus` 时,由 Context 组件或宿主显式管理桥接生命周期,将原生事件发布到指定的 ShrinkEventBus 2.0 Bus: ```csharp [ShrinkEventSubscriber(DefaultBus = "game")] public sealed partial class SaveUIManager : MonoBehaviour { [ShrinkSubscribe] private void OnSaveCompleted(SaveCompletedEvent e) { ShowSaveIndicator(e.SlotIndex, e.ModuleNames); } [ShrinkSubscribe] private void OnLoadFailed(LoadFailedEvent e) { ShowErrorDialog(e.ErrorMessage); } [ShrinkSubscribe] private void OnSettingsChanged(SettingsChangedEvent e) { if (e.Key == "MasterVolume") ApplyVolume(e.Get()); } } ``` 普通 C# 对象通过 `EventBus.Attach(target)` 接入;MonoBehaviour 可使用 `ShrinkMonoEventScope`,也可在 `OnEnable`/`OnDisable` 中自行持有并释放 binding。桥接器本身由 `ShrinkDataSaverEventBusComponent` 或 `DataSaverEventBusBridge.Register()`/`Unregister()` 管理。 **EventBus 事件类型完整列表:** `SettingsChangedEvent` · `SaveStartedEvent` · `SaveCompletedEvent` · `SaveFailedEvent` · `LoadStartedEvent` · `LoadCompletedEvent` · `LoadFailedEvent` · `MigrationCompletedEvent` · `SlotDeletedEvent` --- ## 🖥️ Editor 调试工具 菜单 `ShrinkSDK` → `存档` → `数据查看器` | 标签页 | 功能 | |--------|------| | **设置** | 运行时查看所有设置项(键、值、类型),支持新增/修改/删除,一键保存/加载 | | **存档槽** | 查看所有槽位元数据,每个槽位可直接加载/删除,支持加密参数 | | **工具** | 打开持久化数据目录,定位配置资源文件,一键删除所有存档 | --- ## ☁️ Steam Auto-Cloud 配置 每个逻辑存档槽默认包含一个主文件和最多两个轮换备份文件:`.sav`、`.sav.bak1`、`.sav.bak2`。 在 Steamworks 后台(App Admin → Steam Cloud)配置同步路径: | 字段 | 值 | |------|-----| | Root Path | `{userdata}` | | Subdirectory | 指向 `persistentDataPath` 的相对路径 | | File Pattern | `saves/*.sav*` | 通过 `ModuleConfig.EnableCloudSync = false` 可将特定模块(如本地设置)排除在云同步之外,供业务层在合并逻辑中过滤。 --- ## 🔧 API 参考 ### ShrinkSave(静态门面) #### 模块注册 ```csharp ShrinkSave.RegisterModule(ISaveModule module, ModuleConfig config = null) ShrinkSave.RegisterModule(string key, Func serialize, Action deserialize, ModuleConfig config = null) ShrinkSave.UnregisterModule(string key) ShrinkSave.HasModule(string moduleName) → bool ShrinkSave.GetModuleConfig(string key) → ModuleConfig ShrinkSave.GetRegisteredModuleNames() → IReadOnlyCollection ``` #### 存档操作 ```csharp ShrinkSave.SaveSlotAsync(int slotIndex, SaveOptions, CancellationToken) → UniTask ShrinkSave.LoadSlotAsync(int slotIndex, string decryptionKey, ct) → UniTask ShrinkSave.DeleteSlotAsync(int slotIndex, CancellationToken) → UniTask ShrinkSave.SlotExistsAsync(int slotIndex, CancellationToken) → UniTask ShrinkSave.LoadedSlot → int (-1 = 未加载) ``` #### 元数据查询 ```csharp ShrinkSave.GetAllMetaAsync(CancellationToken) → UniTask ShrinkSave.GetMetaAsync(int slotIndex, ct) → UniTask ShrinkSave.GetRecentSlotIndex() → int ShrinkSave.GetRecommendedContinueSlotAsync(ct) → UniTask ``` #### 跨模块查询 ```csharp ShrinkSave.QueryModule(string moduleName) → T ShrinkSave.HasKey(string moduleName, string key) → bool ``` #### 版本 ```csharp ShrinkSave.SetCurrentSaveVersion(int version) ShrinkSave.GetMinAutoSaveInterval() → float MigrationChain.Register(int from, int to, Func) ``` #### 事件 ```csharp ShrinkSave.OnSaveStarted += Action ShrinkSave.OnSaveCompleted += Action ShrinkSave.OnSaveFailed += Action ShrinkSave.OnLoadStarted += Action ShrinkSave.OnLoadCompleted += Action ShrinkSave.OnLoadFailed += Action ShrinkSave.OnMigrationCompleted += Action ShrinkSave.OnDeleteCompleted += Action ``` ### ShrinkSettings(静态门面) ```csharp ShrinkSettings.Set(string key, T value) ShrinkSettings.Get(string key, T defaultValue = default) → T ShrinkSettings.Has(string key) → bool ShrinkSettings.Remove(string key) ShrinkSettings.GetAllRaw() → IReadOnlyDictionary ShrinkSettings.Watch(string key, Action callback) ShrinkSettings.Unwatch(string key, Action callback) ShrinkSettings.SaveAsync(CancellationToken) → UniTask ShrinkSettings.LoadAsync(CancellationToken) → UniTask ShrinkSettings.OnChanged += Action ``` --- ## 🏗️ 架构说明 ``` ShrinkDataSaver/ ├── Runtime/ │ ├── ShrinkSave 存档系统静态门面(保存/加载/删除/事件/查询) │ ├── ShrinkSettings 设置系统静态门面(键值对/防抖写入/监听) │ ├── ShrinkDataSaverBootstrap 兼容旧入口的初始化组件 │ ├── ShrinkDataSaverRuntime 可重入运行时初始化入口 + 生命周期驱动 │ ├── ShrinkDataSaverSettings ScriptableObject 全局配置(支持任意目录) │ ├── SaveTypes 核心类型(SaveMeta/ISaveModule/ModuleConfig/EventArgs) │ ├── MigrationChain 版本迁移链(注册/链式执行/回滚) │ ├── DataSerializer JSON 序列化(Newtonsoft.Json) │ ├── SaveEncryptor AES-256-CBC 加密/解密 │ ├── IStorageProvider 存储后端接口 │ └── LocalStorageProvider 本地文件系统实现(异步读写 + 原子替换 + 双副本轮换) │ ├── Editor/ │ └── ShrinkDataSaverEditorWindow 中文可视化调试窗口 │ └── Tests/ ├── MockStorageProvider 内存模拟存储 └── *Tests.cs NUnit 单元测试(69 个用例) ShrinkDataSaver.Integration.EventBus/ (可选) ├── DataSaverEvents 9 个 EventBus 事件类 └── DataSaverEventBusBridge 自动桥接(RuntimeInitializeOnLoadMethod) ``` **保存流程:** ``` SaveSlotAsync(slotIndex, options) ├─ ValidateSlotIndex ├─ OnSaveStarted ← 事件 ├─ 序列化所有模块 │ ├─ CriticalModule 失败 → OnSaveFailed ← 事件,中止 │ └─ 普通模块失败 → LogError,跳过继续 ├─ 加密(可选) ├─ 写入存储 └─ OnSaveCompleted ← 事件(含 ModuleNames[]) ``` **加载流程:** ``` LoadSlotAsync(slotIndex, decryptionKey) ├─ OnLoadStarted ← 事件 ├─ 读取存储 → 解密(可选) ├─ 版本迁移(如需) │ ├─ DeepClone 备份 │ ├─ 链式执行迁移 │ ├─ 失败 → 回滚到备份 │ └─ OnMigrationCompleted ← 事件 ├─ 反序列化到各模块 ├─ 缓存模块数据(供 QueryModule/HasKey) └─ OnLoadCompleted ← 事件(含 ModuleNames[], Version) ``` --- ## ✅ 最佳实践 **设置模块标记 `EnableCloudSync = false`** ```csharp // ✅ 本地设置不上传云端,避免跨设备覆盖 ShrinkSave.RegisterModule("settings", () => localPrefs, d => localPrefs = d, new ModuleConfig { EnableCloudSync = false }); ``` **关键模块标记 `CriticalModule = true`** ```csharp // ✅ 玩家核心数据序列化失败时中止保存,防止存档损坏 ShrinkSave.RegisterModule("playerStats", () => stats, d => stats = d, new ModuleConfig { CriticalModule = true }); ``` **迁移注册必须在加载之前** ```csharp // ✅ 游戏启动时立即注册所有迁移 MigrationChain.Register(1, 2, MigrateV1ToV2); MigrationChain.Register(2, 3, MigrateV2ToV3); ShrinkSave.SetCurrentSaveVersion(3); // 之后才能调用 LoadSlotAsync ``` **加密存档先查询元数据** ```csharp // ✅ 通过 Meta 判断是否需要密钥,避免盲目加载 var meta = await ShrinkSave.GetMetaAsync(slotIndex); string key = meta?.IsEncrypted == true ? AskPlayerForPassword() : null; await ShrinkSave.LoadSlotAsync(slotIndex, key); ``` --- ## ⚠️ 注意事项 - **模块注册顺序无关**:`SaveSlotAsync` 序列化所有已注册模块,`LoadSlotAsync` 分发到对应模块,缺失的模块会跳过但不会导致加载失败。 - **宿主接管兼容**:如果项目接入了 `ShrinkApp`,推荐通过 `ShrinkDataSaver.Integration.App` 让宿主统一初始化,而不是继续依赖场景 bootstrap。 - **写入原子性**:`LocalStorageProvider` 先异步写入 `.tmp` 临时文件,再通过原子替换提交主文件,并自动轮换 `.bak1` / `.bak2` 两份备份。 - **读取恢复**:`ShrinkSave` 与 `ShrinkSettings` 读取主文件失败时,会自动回退到 `.bak1`、`.bak2`,并在成功后修复主文件。 - **Settings 防抖**:`Set()` 调用后不立刻写磁盘,在 300ms(可配置)内连续调用只触发一次写入。退出时强制跳过防抖直接写入。 - **加密密钥管理**:框架不存储密钥。密钥丢失则对应存档无法解密,建议在 UI 层给玩家明确提示。 - **截图与 Steam Cloud**:截图压缩为 JPG 并限制最大宽度(默认 256px),仍需注意模块数据体积。Steam Cloud 默认单文件限制 1MB。 - **配置文件查找顺序**:`Resources.Load` → 编辑器 `AssetDatabase` 全局搜索 → 创建默认实例并输出控制台警告。 - **自动保存**:仅在有槽位已加载(`LoadedSlot >= 0`)且存在 `AutoSaveIntervalSeconds > 0` 的模块时生效。 --- ## 📄 License [MIT](LICENSE)