Files

545 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ShrinkDataSaver
一个可用于 Unity、Godot 和普通 .NET 宿主的模块化存档与设置管理系统。支持多存档槽、链式版本迁移、可选 AES-256 加密、关键模块保护、跨模块只读查询,以及完整的事件驱动架构。
Godot 和普通 .NET 项目安装 `ShrinkSDK.DataSaver`;可打包源码位于 `DotNet~`,持久化路径与生命周期由宿主平台服务注入。
## ✨ 特性概览
| 特性 | 说明 |
|------|------|
| 💾 **模块化存档** | 按模块拆分存档数据,注册即用,读写隔离 |
| 🔢 **多存档槽** | 槽位数量可配置,支持元数据轻量查询 |
| 🔄 **链式版本迁移** | 注册迁移规则后自动链式执行,失败时整体回滚 |
| 🔒 **可选加密** | AES-256-CBC + PBKDF2-SHA256,随机 Salt/IV,密钥由使用者管理 |
| ⚡ **事件驱动** | 8 种原生事件,覆盖保存/加载/删除/迁移的完整生命周期 |
| 🔍 **跨模块查询** | `QueryModule<T>` 只读查询其他模块的运行时数据 |
| 🛡️ **关键模块保护** | `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
// 方式 ALambda(轻量)
ShrinkSave.RegisterModule(
key: "inventory",
serialize: () => inventoryManager.GetData(),
deserialize: data => inventoryManager.LoadData(data)
);
// 方式 BLambda + 模块配置
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<InventoryData>
{
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<PlayerStatsData>("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<string, JToken>
// 监听变更
ShrinkSettings.OnChanged += (key, value) => Debug.Log($"{key} = {value}");
ShrinkSettings.Watch<float>("MasterVolume", vol => ApplyVolume(vol));
```
变更时立刻触发回调,写入磁盘有防抖延迟(默认 300ms),防止高频调用产生大量 IO。
### 加密
可选加密,默认关闭。使用 AES-256-CBC + PBKDF2-SHA25610000 次迭代),每次加密随机生成 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<float>());
}
}
```
普通 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<T>(string key, Func<T> serialize, Action<T> deserialize, ModuleConfig config = null)
ShrinkSave.UnregisterModule(string key)
ShrinkSave.HasModule(string moduleName) bool
ShrinkSave.GetModuleConfig(string key) ModuleConfig
ShrinkSave.GetRegisteredModuleNames() IReadOnlyCollection<string>
```
#### 存档操作
```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<bool>
ShrinkSave.LoadedSlot int (-1 = 未加载)
```
#### 元数据查询
```csharp
ShrinkSave.GetAllMetaAsync(CancellationToken) UniTask<SaveMeta[]>
ShrinkSave.GetMetaAsync(int slotIndex, ct) UniTask<SaveMeta>
ShrinkSave.GetRecentSlotIndex() int
ShrinkSave.GetRecommendedContinueSlotAsync(ct) UniTask<int>
```
#### 跨模块查询
```csharp
ShrinkSave.QueryModule<T>(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<JObject, JObject>)
```
#### 事件
```csharp
ShrinkSave.OnSaveStarted += Action<SaveStartedEventArgs>
ShrinkSave.OnSaveCompleted += Action<SaveCompletedEventArgs>
ShrinkSave.OnSaveFailed += Action<SaveFailedEventArgs>
ShrinkSave.OnLoadStarted += Action<LoadStartedEventArgs>
ShrinkSave.OnLoadCompleted += Action<LoadCompletedEventArgs>
ShrinkSave.OnLoadFailed += Action<LoadFailedEventArgs>
ShrinkSave.OnMigrationCompleted += Action<MigrationCompletedEventArgs>
ShrinkSave.OnDeleteCompleted += Action<SlotDeletedEventArgs>
```
### ShrinkSettings(静态门面)
```csharp
ShrinkSettings.Set<T>(string key, T value)
ShrinkSettings.Get<T>(string key, T defaultValue = default) T
ShrinkSettings.Has(string key) bool
ShrinkSettings.Remove(string key)
ShrinkSettings.GetAllRaw() IReadOnlyDictionary<string, JToken>
ShrinkSettings.Watch<T>(string key, Action<T> callback)
ShrinkSettings.Unwatch(string key, Action<object> callback)
ShrinkSettings.SaveAsync(CancellationToken) UniTask
ShrinkSettings.LoadAsync(CancellationToken) UniTask
ShrinkSettings.OnChanged += Action<string, object>
```
---
## 🏗️ 架构说明
```
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)