feat(packages): 内置 SDK 包并完善 ContextLoader 集成
- 将 ShrinkEventBus、ShrinkDataSaver 及其 EventBus 集成从 gitlink 转为仓库直接维护的完整 UPM 包,补齐运行时、编辑器工具、测试与文档 - 新增 Command 和 Network 的 App 集成组件,支持 ContextLoader 服务发布、可逆注销及 Network Loopback 生命周期管理 - 更新 Starter 与演示组合逻辑,缺失模块时可注册、已有兼容安装器时可覆盖,并补充宿主启动断言 - 升级内部包依赖与 Shared CodeGen 包定义,放宽 Integration.App 包的 Git 忽略规则 - 将独立服务器生成器改为基于已编译程序集的语义扫描,支持 partial、复杂泛型、命名冲突检测及模板 SHA-256 覆写保护 - 新增 Network 语义扫描、模板保护和 App 组件生命周期测试 - 新增真实 UPM 消费工程验证脚本,校验内部版本一致性、程序集加载及 EditMode 测试 - 重构当前架构文档并归档已完成的 Cordis 迁移与旧代码地图
This commit is contained in:
+4
-4
@@ -74,10 +74,10 @@ mono_crash.*
|
||||
*.unitypackage.meta
|
||||
*.app
|
||||
|
||||
# Windows Git matches the macOS bundle pattern case-insensitively. Keep the
|
||||
# namespace-suffixed Unity package in source control.
|
||||
!Assets/Modules/ShrinkDataSaver.Integration.App/
|
||||
!Assets/Modules/ShrinkDataSaver.Integration.App/**
|
||||
# Windows Git matches the macOS bundle pattern case-insensitively. Keep all
|
||||
# namespace-suffixed Unity Integration.App packages in source control.
|
||||
!Assets/Modules/*.Integration.App/
|
||||
!Assets/Modules/*.Integration.App/**
|
||||
|
||||
# Crashlytics generated file
|
||||
crashlytics-build.properties
|
||||
|
||||
@@ -56,8 +56,19 @@ namespace Demo2.Runtime
|
||||
ShrinkAppLoaderBootstrapper.DefaultComposition = host =>
|
||||
{
|
||||
previous?.Invoke(host);
|
||||
if (host.ModuleIds.Contains("demo2.rule-factory")) host.OverrideModuleComponent("demo2.rule-factory", static () => new Demo2AppComponent());
|
||||
RegisterOrReplace(host, "demo2.rule-factory", static () => new Demo2AppComponent());
|
||||
};
|
||||
}
|
||||
|
||||
private static void RegisterOrReplace(
|
||||
ShrinkAppLoaderHost host,
|
||||
string moduleId,
|
||||
System.Func<IShrinkComponent> componentFactory)
|
||||
{
|
||||
if (host.ModuleIds.Contains(moduleId, System.StringComparer.OrdinalIgnoreCase))
|
||||
host.OverrideModuleComponent(moduleId, componentFactory);
|
||||
else
|
||||
host.AddModuleComponent(moduleId, componentFactory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"Demo2.Domain",
|
||||
"Demo2.ECS",
|
||||
"Demo2.Runtime",
|
||||
"ShrinkApp.Core.Runtime",
|
||||
"ShrinkContext.AppAdapter.Runtime",
|
||||
"Unity.Entities",
|
||||
"Unity.Collections"
|
||||
],
|
||||
|
||||
@@ -6,6 +6,7 @@ using Demo2.Domain;
|
||||
using Demo2.ECS;
|
||||
using Demo2.Runtime;
|
||||
using NUnit.Framework;
|
||||
using ShrinkContext.AppAdapter;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
@@ -23,6 +24,16 @@ namespace Demo2.Tests
|
||||
yield return null;
|
||||
var controller = Object.FindObjectOfType<Demo2DemoController>();
|
||||
Assert.That(controller, Is.Not.Null);
|
||||
for (var frame = 0; frame < 30 &&
|
||||
(ShrinkAppLoaderBootstrapper.Instance == null || !ShrinkAppLoaderBootstrapper.Instance.Host.IsRunning);
|
||||
frame++)
|
||||
yield return null;
|
||||
var appHost = ShrinkAppLoaderBootstrapper.Instance;
|
||||
Assert.That(appHost, Is.Not.Null);
|
||||
Assert.That(appHost!.Host.IsRunning, Is.True);
|
||||
Assert.That(appHost.Host.ActiveModuleIds, Does.Contain("demo2.rule-factory"));
|
||||
Assert.That(appHost.Host.Services.TryGet<Demo2GameService>(out var appService), Is.True);
|
||||
Assert.That(appService, Is.Not.Null);
|
||||
var document = controller!.GetComponentInChildren<UIDocument>();
|
||||
Assert.That(document, Is.Not.Null);
|
||||
var solo = document!.rootVisualElement.Q<Button>("solo-button");
|
||||
|
||||
@@ -67,9 +67,19 @@ namespace ReplacedPerson.Runtime
|
||||
ShrinkAppLoaderBootstrapper.DefaultComposition = host =>
|
||||
{
|
||||
previous?.Invoke(host);
|
||||
if (host.ModuleIds.Contains("demo.replaced-person"))
|
||||
host.OverrideModuleComponent("demo.replaced-person", static () => new ReplacedPersonAppComponent());
|
||||
RegisterOrReplace(host, "demo.replaced-person", static () => new ReplacedPersonAppComponent());
|
||||
};
|
||||
}
|
||||
|
||||
private static void RegisterOrReplace(
|
||||
ShrinkAppLoaderHost host,
|
||||
string moduleId,
|
||||
Func<IShrinkComponent> componentFactory)
|
||||
{
|
||||
if (host.ModuleIds.Contains(moduleId, StringComparer.OrdinalIgnoreCase))
|
||||
host.OverrideModuleComponent(moduleId, componentFactory);
|
||||
else
|
||||
host.AddModuleComponent(moduleId, componentFactory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ReplacedPerson.PlayMode.Tests",
|
||||
"rootNamespace": "ReplacedPerson.Tests",
|
||||
"references": ["ReplacedPerson.Core", "ReplacedPerson.Runtime", "UnityEngine.UI", "Unity.TextMeshPro"],
|
||||
"references": ["ReplacedPerson.Core", "ReplacedPerson.Runtime", "ShrinkApp.Core.Runtime", "ShrinkContext.AppAdapter.Runtime", "UnityEngine.UI", "Unity.TextMeshPro"],
|
||||
"optionalUnityReferences": ["TestAssemblies"],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using ReplacedPerson.Runtime;
|
||||
using ShrinkContext.AppAdapter;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
@@ -22,6 +23,16 @@ namespace ReplacedPerson.Tests
|
||||
yield return null;
|
||||
var root = GameObject.Find("ReplacedPersonDemo");
|
||||
Assert.That(root, Is.Not.Null);
|
||||
for (var frame = 0; frame < 30 &&
|
||||
(ShrinkAppLoaderBootstrapper.Instance == null || !ShrinkAppLoaderBootstrapper.Instance.Host.IsRunning);
|
||||
frame++)
|
||||
yield return null;
|
||||
var appHost = ShrinkAppLoaderBootstrapper.Instance;
|
||||
Assert.That(appHost, Is.Not.Null);
|
||||
Assert.That(appHost!.Host.IsRunning, Is.True);
|
||||
Assert.That(appHost.Host.ActiveModuleIds, Does.Contain("demo.replaced-person"));
|
||||
Assert.That(appHost.Host.Services.TryGet<ReplacedPersonGameService>(out var appService), Is.True);
|
||||
Assert.That(appService, Is.Not.Null);
|
||||
var camera = Camera.main;
|
||||
Assert.That(camera, Is.Not.Null);
|
||||
Assert.That(camera!.enabled, Is.True);
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"description": "Shrink 系列统一宿主层,提供模块安装器、服务容器与统一启动流程。",
|
||||
"unity": "2022.3",
|
||||
"dependencies": {
|
||||
"com.cneicy.shrink-eventbus": "1.1.5",
|
||||
"com.cneicy.shrink-eventbus": "1.3.0",
|
||||
"com.cneicy.shrink-shared-codegen": "0.1.0",
|
||||
"com.cysharp.unitask": "2.5.10"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ShrinkCommand.Integration.App;
|
||||
using ShrinkCommand.Integration;
|
||||
using ShrinkContext.AppAdapter;
|
||||
@@ -42,9 +43,9 @@ namespace ShrinkApp.Starter.Basic
|
||||
if (host == null)
|
||||
throw new System.ArgumentNullException(nameof(host));
|
||||
|
||||
host.OverrideModuleComponent("shrink.network", static () => new ShrinkNetworkAppComponent());
|
||||
host.OverrideModuleComponent("shrink.command", static () => new ShrinkCommandAppComponent());
|
||||
host.OverrideModuleComponent("shrink.datasaver", static () => new ShrinkDataSaverAppComponent());
|
||||
RegisterOrReplace(host, "shrink.network", static () => new ShrinkNetworkAppComponent());
|
||||
RegisterOrReplace(host, "shrink.command", static () => new ShrinkCommandAppComponent());
|
||||
RegisterOrReplace(host, "shrink.datasaver", static () => new ShrinkDataSaverAppComponent());
|
||||
host.AddModuleComponent("shrink.integration.command-network",
|
||||
static () => new ShrinkCommandNetworkIntegrationComponent());
|
||||
host.AddModuleComponent("shrink.integration.command-eventbus",
|
||||
@@ -55,6 +56,17 @@ namespace ShrinkApp.Starter.Basic
|
||||
static () => new ShrinkNetworkEventBusComponent());
|
||||
}
|
||||
|
||||
private static void RegisterOrReplace(
|
||||
ShrinkAppLoaderHost host,
|
||||
string moduleId,
|
||||
System.Func<ShrinkContext.IShrinkComponent> componentFactory)
|
||||
{
|
||||
if (host.ModuleIds.Any(id => string.Equals(id, moduleId, System.StringComparison.OrdinalIgnoreCase)))
|
||||
host.OverrideModuleComponent(moduleId, componentFactory);
|
||||
else
|
||||
host.AddModuleComponent(moduleId, componentFactory);
|
||||
}
|
||||
|
||||
public static ShrinkAppCompositionDocument CreateDefaultDocument()
|
||||
{
|
||||
var entries = new List<ShrinkAppCompositionEntry>(DefaultModuleIdValues.Length);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "ShrinkApp 最小起盘 Starter,提供向导、入口场景和 DataSaver 示例。",
|
||||
"unity": "2022.3",
|
||||
"dependencies": {
|
||||
"com.cneicy.shrink-app-core": "0.1.0",
|
||||
"com.cneicy.shrink-app-core": "0.1.1",
|
||||
"com.cneicy.shrink-context-core": "0.1.0",
|
||||
"com.cneicy.shrink-context-app-adapter": "0.1.0",
|
||||
"com.cneicy.shrink-command": "0.2.0",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
本文件记录 `ShrinkCommand.Integration.App` 的包内变更。
|
||||
|
||||
## [0.1.0] - 2026-05-18
|
||||
|
||||
### Added
|
||||
|
||||
- 新增 `ShrinkCommand` 与 `ShrinkApp.Core` 的桥接包。
|
||||
- 新增 `ShrinkCommandAppInstaller`,把默认命令服务挂入宿主服务容器。
|
||||
- 新增 `ShrinkCommandAppService` 作为宿主侧命令门面。
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1754b3bc0377768409187131b594bda6
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,23 @@
|
||||
# ShrinkCommand.Integration.App
|
||||
|
||||
`ShrinkCommand.Integration.App` 是 `ShrinkCommand` 与 `ShrinkApp.Core` 的桥接层。它的职责很简单:把默认命令服务挂进 `ShrinkApp` 的服务容器里,让项目在进入统一宿主模式后,仍能用熟悉的命令系统,而不需要到处手动拿 `ShrinkCommandRuntime.Default`。
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 提供原生 `ShrinkCommandAppComponent`,发布 `shrink.service.command`
|
||||
- 把 `ShrinkCommandRuntime.Default` 包装为 `ShrinkCommandAppService`
|
||||
- ContextLoader 下由 Starter 组合根装配,服务门面随组件停用可逆注销
|
||||
- `ShrinkCommandAppInstaller` 仅保留给 ClassicHost 兼容路径,已标记过时
|
||||
|
||||
## 使用示例
|
||||
|
||||
```csharp
|
||||
var command = ShrinkApp.ShrinkApp.Services.GetRequired<ShrinkCommand.Integration.App.ShrinkCommandAppService>();
|
||||
var result = await command.ExecuteAsync(source, "help");
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
- 本包不改变 `ShrinkCommand` 的原有 API。
|
||||
- 静态命令的默认注册仍由 `ShrinkCommandRuntime.Default` 完成。
|
||||
- 运行时实例命令仍然走 `RegisterCommands(object target)`。
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: da6f74ed70ed86e48bc9309983a690b9
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 79640806d61572d44b10ae9bbb0ace79
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "ShrinkCommand.Integration.App",
|
||||
"rootNamespace": "ShrinkCommand.Integration.App",
|
||||
"references": [
|
||||
"ShrinkCommand.Runtime",
|
||||
"ShrinkApp.Core.Runtime",
|
||||
"ShrinkContext.Core.Runtime",
|
||||
"UniTask"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e5805c0a2f81e6f47818b1dac7d15e6c
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,49 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ShrinkApp;
|
||||
using ShrinkContext;
|
||||
using ShrinkCommand;
|
||||
|
||||
namespace ShrinkCommand.Integration.App
|
||||
{
|
||||
/// <summary>
|
||||
/// ShrinkCommand 的原生 Cordis 组件(阶段 3):
|
||||
/// 提供 <c>shrink.service.command</c> 余效应键(默认命令服务),依赖者按键注入;
|
||||
/// 与经典 ShrinkCommandAppInstaller(ModuleId "shrink.command")二选一使用(同键供给冲突保护)。
|
||||
/// </summary>
|
||||
public sealed class ShrinkCommandAppComponent : IShrinkComponent
|
||||
{
|
||||
public const string ServiceKey = "shrink.service.command";
|
||||
public const string ModuleKey = "app.module.shrink.command";
|
||||
|
||||
private static readonly string[] ProvideKeys = { ModuleKey, ServiceKey };
|
||||
|
||||
public string Name => "shrink.command";
|
||||
|
||||
public IReadOnlyList<string> Inject => Array.Empty<string>();
|
||||
|
||||
public IReadOnlyList<string> Provide => ProvideKeys;
|
||||
|
||||
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
|
||||
{
|
||||
var service = ShrinkCommandRuntime.Default;
|
||||
ctx.Set(ServiceKey, service);
|
||||
ctx.Set(ModuleKey, Name);
|
||||
|
||||
if (config is ShrinkAppServices appServices)
|
||||
{
|
||||
var facade = new ShrinkCommandAppService(service);
|
||||
appServices.Register(facade);
|
||||
ctx.EffectInverse(() =>
|
||||
{
|
||||
appServices.TryUnregister(facade);
|
||||
return UniTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 507e23e02986281488456a63dc8ffdbe
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,48 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ShrinkApp;
|
||||
|
||||
namespace ShrinkCommand.Integration.App
|
||||
{
|
||||
[ShrinkAppModuleInstaller]
|
||||
[Obsolete("ClassicHost compatibility only. ContextLoader projects should use ShrinkCommandAppComponent from a composition root.")]
|
||||
public sealed class ShrinkCommandAppInstaller : IShrinkAppModuleInstaller
|
||||
{
|
||||
public string ModuleId => "shrink.command";
|
||||
public int Order => -1500;
|
||||
public System.Collections.Generic.IReadOnlyList<string> DependsOn => Array.Empty<string>();
|
||||
|
||||
public void RegisterServices(ShrinkAppContext context)
|
||||
{
|
||||
context.Services.Register(new ShrinkCommandAppService(ShrinkCommandRuntime.Default));
|
||||
}
|
||||
|
||||
public UniTask InitializeAsync(ShrinkAppContext context)
|
||||
{
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ShrinkCommandAppService
|
||||
{
|
||||
public ShrinkCommandAppService(ShrinkCommandService service)
|
||||
{
|
||||
Service = service ?? throw new ArgumentNullException(nameof(service));
|
||||
}
|
||||
|
||||
public ShrinkCommandService Service { get; }
|
||||
|
||||
public UniTask<ShrinkCommandExecutionResult> ExecuteAsync(
|
||||
IShrinkCommandSource source,
|
||||
string rawInput,
|
||||
System.Threading.CancellationToken cancellationToken = default)
|
||||
=> Service.ExecuteAsync(source, rawInput, cancellationToken);
|
||||
|
||||
public string BuildHelp(IShrinkCommandSource? source, string? prefix = null)
|
||||
=> Service.BuildHelp(source, prefix);
|
||||
|
||||
public System.Collections.Generic.IReadOnlyList<ShrinkCommandDescriptor> Commands => Service.Commands;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a71473a2ecd04c3459903841fee6da7d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "com.cneicy.shrink-command-integration-app",
|
||||
"version": "0.1.0",
|
||||
"displayName": "ShrinkCommand - App Integration",
|
||||
"description": "ShrinkCommand 与 ShrinkApp 的桥接层,把默认命令服务纳入统一宿主服务容器。",
|
||||
"unity": "2022.3",
|
||||
"dependencies": {
|
||||
"com.cneicy.shrink-command": "0.2.0",
|
||||
"com.cneicy.shrink-app-core": "0.1.1",
|
||||
"com.cysharp.unitask": "2.5.10",
|
||||
"com.cneicy.shrink-context-core": "0.1.0"
|
||||
},
|
||||
"keywords": [
|
||||
"command",
|
||||
"integration",
|
||||
"app",
|
||||
"console"
|
||||
],
|
||||
"author": {
|
||||
"name": "cneicy",
|
||||
"url": "https://github.com/cneicy"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 126abcc047b44714593c2f94aa587c75
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -5,9 +5,9 @@
|
||||
"description": "ShrinkCommand 与 ShrinkEventBus 的桥接层,支持事件请求执行命令,以及命令执行生命周期事件发布。",
|
||||
"unity": "2022.3",
|
||||
"dependencies": {
|
||||
"com.cneicy.shrink-command": "0.1.0",
|
||||
"com.cneicy.shrink-command": "0.2.0",
|
||||
"com.cneicy.shrink-context-core": "0.1.0",
|
||||
"com.cneicy.shrink-eventbus": "1.1.5"
|
||||
"com.cneicy.shrink-eventbus": "1.3.0"
|
||||
},
|
||||
"keywords": ["command", "eventbus", "integration", "bridge"],
|
||||
"author": {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"description": "ShrinkCommand 与 ShrinkNetwork 的桥接层,允许远程会话通过 RPC 执行命令。",
|
||||
"unity": "2022.3",
|
||||
"dependencies": {
|
||||
"com.cneicy.shrink-command": "0.1.0",
|
||||
"com.cneicy.shrink-network": "0.1.0",
|
||||
"com.cneicy.shrink-command": "0.2.0",
|
||||
"com.cneicy.shrink-network": "0.2.0",
|
||||
"com.cneicy.shrink-context-core": "0.1.0"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -19,4 +19,4 @@
|
||||
"name": "cneicy",
|
||||
"url": "https://github.com/cneicy"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"description": "独立于网络层的 Unity 命令系统,提供路径式命令、权限、帮助输出与属性式注册。",
|
||||
"unity": "2022.3",
|
||||
"dependencies": {
|
||||
"com.cneicy.shrink-shared-codegen": "0.1.0",
|
||||
"com.cysharp.unitask": "2.5.10"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ShrinkContext.AppAdapter
|
||||
|
||||
ShrinkApp ↔ ShrinkContext 的桥接层:把现有 `IShrinkAppModuleInstaller` 生态接入 Cordis 时空可组合性范式(阶段 2 过渡产物,方案见仓库根 `CORDIS_MIGRATION.md`)。
|
||||
ShrinkApp ↔ ShrinkContext 的桥接层:提供当前默认的 ContextLoader 宿主、声明式组合 Profile、诊断工具,并保留 `IShrinkAppModuleInstaller` 兼容适配。当前架构与边界见仓库根 `DESIGN.md`。
|
||||
|
||||
## 组成
|
||||
|
||||
@@ -62,11 +62,11 @@ Profile 的 JSON 结构如下:
|
||||
| 依赖缺失 | 排序期抛错 | 安装器保持 Waiting,依赖出现自动激活 |
|
||||
| 重复 ModuleId | 宿主级异常 | 构造期抛错(一致);运行期替换退化为供给冲突失败 |
|
||||
| 运行中禁用模块 | 不支持(需重启) | `SetModuleDisabledAsync` 增量协调,重启用会重新执行安装器初始化 |
|
||||
| `ShrinkApp.IsRunning` | Host 驱动 | 以 `ShrinkAppLoaderHost.IsRunning` 为准(静态 IsRunning 为 false,过渡期已知差异) |
|
||||
| `ShrinkApp.IsRunning` | Host 驱动 | LoaderHost 通过 `SetExternalHostRunning` 同步静态门面状态 |
|
||||
|
||||
## 已知边界
|
||||
|
||||
- `ShrinkAppServices` 无注销能力:安装器停用不撤回已注册服务(重新启用会重跑 RegisterServices,同键覆盖)。
|
||||
- `ShrinkAppServices.TryUnregister(instance)` 支持按实例撤回服务;原生 Context 组件已使用该路径。旧 installer 包装器仍取决于安装器自身是否提供完整逆操作。
|
||||
- config/isolate 变化走条目重建;intercept metadata 可原位更新而不改变 fiber generation。
|
||||
- 运行中不支持重新应用整份 Profile;资产修改在下一次宿主启动生效,运行中模块开关仍使用 `SetModuleDisabledAsync`。
|
||||
- Editor 基准的毫秒数和 GC 管理堆差值只用于同机前后对比;自动测试只断言索引候选规模和事务恢复结果。
|
||||
|
||||
@@ -14,8 +14,8 @@ namespace ShrinkContext.AppAdapter
|
||||
/// - provide 发布模块键 <c>app.module.<ModuleId></c>(安装器 Active 后对依赖者可见);
|
||||
/// - apply 依次执行 RegisterServices + InitializeAsync,config 必须传入 <see cref="ShrinkAppServices"/>。
|
||||
///
|
||||
/// 已知边界(后续阶段补齐):ShrinkAppServices 尚无注销能力,
|
||||
/// 安装器卸载时不会移除已注册的服务;重新激活会重复执行 RegisterServices(同键覆盖)。
|
||||
/// 兼容安装器只负责执行既有 RegisterServices + InitializeAsync。需要停用时撤回服务的模块应使用
|
||||
/// 原生 Context 组件,并通过 ShrinkAppServices.TryUnregister(instance) 登记对应逆操作。
|
||||
/// </summary>
|
||||
public sealed class ShrinkAppInstallerComponent : IShrinkComponent
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace ShrinkContext.AppAdapter
|
||||
|
||||
/// <summary>
|
||||
/// Starter/组合包在 SubsystemRegistration 阶段设置的默认装配表。
|
||||
/// AppAdapter 保持不依赖具体业务模块,组合根负责把原生组件挂到已发现的模块 id。
|
||||
/// AppAdapter 保持不依赖具体业务模块,组合根负责注册原生组件;若兼容 installer 已被发现则覆盖同 id。
|
||||
/// </summary>
|
||||
public static System.Action<ShrinkAppLoaderHost>? DefaultComposition { get; set; }
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"unity": "2022.3",
|
||||
"dependencies": {
|
||||
"com.cneicy.shrink-context-core": "0.1.0",
|
||||
"com.cneicy.shrink-app-core": "0.1.0",
|
||||
"com.cneicy.shrink-app-core": "0.1.1",
|
||||
"com.cysharp.unitask": "2.5.10"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -20,4 +20,4 @@
|
||||
"name": "cneicy",
|
||||
"url": "https://github.com/cneicy"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ShrinkContext.Core
|
||||
|
||||
《Cordis: A Programming Paradigm for Spatiotemporal Composability》核心机制的 Unity/C# 实现(改造方案与当前边界见仓库根 `CORDIS_MIGRATION.md`)。
|
||||
《Cordis: A Programming Paradigm for Spatiotemporal Composability》核心机制的 Unity/C# 实现。当前架构与边界见仓库根 `DESIGN.md`;已完成的迁移过程归档于 `Docs/Archive/CORDIS_MIGRATION.completed.md`。
|
||||
|
||||
## 定位
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
- 强类型版本键是新增契约,现有字符串键不会在阶段 5 被一次性重写。
|
||||
- Core 保持不依赖具体配置资产;`ShrinkContext.AppAdapter` 提供 ScriptableObject/JSON 组合、Editor 诊断窗和可重复容量基准。
|
||||
- 主线程宿主与 Unity PlayerLoop 调度仍由上层应用负责。
|
||||
- 跨进程/独立服务器上下文
|
||||
- 当前上下文只协调单进程内组件;跨进程/独立服务器使用 Network 合同,不共享 fiber 或事务。
|
||||
|
||||
## 约定
|
||||
|
||||
|
||||
Submodule Assets/Modules/ShrinkDataSaver deleted from 524a1fd11e
@@ -6,7 +6,7 @@
|
||||
"unity": "2022.3",
|
||||
"dependencies": {
|
||||
"com.cneicy.shrink-datasaver": "2.2.0",
|
||||
"com.cneicy.shrink-app-core": "0.1.0",
|
||||
"com.cneicy.shrink-app-core": "0.1.1",
|
||||
"com.cysharp.unitask": "2.5.10",
|
||||
"com.cneicy.shrink-context-core": "0.1.0"
|
||||
},
|
||||
@@ -20,4 +20,4 @@
|
||||
"name": "cneicy",
|
||||
"url": "https://github.com/cneicy"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Submodule Assets/Modules/ShrinkDataSaver.Integration.EventBus deleted from 6dbee4a992
@@ -0,0 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
本文件记录 `ShrinkDataSaver.Integration.EventBus` 这个独立 UPM 包的变更。
|
||||
|
||||
## [2.1.0] - 2026-05-18
|
||||
|
||||
### Changed
|
||||
|
||||
- 与 `ShrinkDataSaverRuntime` / `ShrinkApp` 接管初始化链路兼容,保留零配置桥接方式不变。
|
||||
|
||||
## [2.0.1] - 2026-04-06
|
||||
|
||||
### Changed
|
||||
|
||||
- 添加了变更日志。
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 916d7f3a5759d4941823219888267177
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,107 @@
|
||||
#nullable enable
|
||||
|
||||
using ShrinkEventBus;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ShrinkDataSaver.Integration
|
||||
{
|
||||
/// <summary>
|
||||
/// DataSaver 原生事件到 EventBus 的显式桥接器。
|
||||
/// 生命周期由 ShrinkDataSaverEventBusComponent 管理,不再因程序集存在而全局自动接入。
|
||||
/// </summary>
|
||||
public static class DataSaverEventBusBridge
|
||||
{
|
||||
private static bool _registered;
|
||||
|
||||
public static bool IsRegistered => _registered;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetStaticState()
|
||||
{
|
||||
Unregister();
|
||||
_registered = false;
|
||||
}
|
||||
|
||||
public static void Register()
|
||||
{
|
||||
if (_registered)
|
||||
return;
|
||||
|
||||
ShrinkSettings.OnChanged += HandleSettingsChanged;
|
||||
ShrinkSave.OnSaveStarted += HandleSaveStarted;
|
||||
ShrinkSave.OnSaveCompleted += HandleSaveCompleted;
|
||||
ShrinkSave.OnSaveFailed += HandleSaveFailed;
|
||||
ShrinkSave.OnLoadStarted += HandleLoadStarted;
|
||||
ShrinkSave.OnLoadCompleted += HandleLoadCompleted;
|
||||
ShrinkSave.OnLoadFailed += HandleLoadFailed;
|
||||
ShrinkSave.OnMigrationCompleted += HandleMigrationCompleted;
|
||||
ShrinkSave.OnDeleteCompleted += HandleSlotDeleted;
|
||||
_registered = true;
|
||||
}
|
||||
|
||||
public static void Unregister()
|
||||
{
|
||||
ShrinkSettings.OnChanged -= HandleSettingsChanged;
|
||||
ShrinkSave.OnSaveStarted -= HandleSaveStarted;
|
||||
ShrinkSave.OnSaveCompleted -= HandleSaveCompleted;
|
||||
ShrinkSave.OnSaveFailed -= HandleSaveFailed;
|
||||
ShrinkSave.OnLoadStarted -= HandleLoadStarted;
|
||||
ShrinkSave.OnLoadCompleted -= HandleLoadCompleted;
|
||||
ShrinkSave.OnLoadFailed -= HandleLoadFailed;
|
||||
ShrinkSave.OnMigrationCompleted -= HandleMigrationCompleted;
|
||||
ShrinkSave.OnDeleteCompleted -= HandleSlotDeleted;
|
||||
_registered = false;
|
||||
}
|
||||
|
||||
private static void HandleSettingsChanged(string key, object value) =>
|
||||
EventBus.TriggerEvent(new SettingsChangedEvent { Key = key, Value = value });
|
||||
|
||||
private static void HandleSaveStarted(SaveStartedEventArgs args) =>
|
||||
EventBus.TriggerEvent(new SaveStartedEvent { SlotIndex = args.SlotIndex, Timestamp = args.Timestamp });
|
||||
|
||||
private static void HandleSaveCompleted(SaveCompletedEventArgs args) =>
|
||||
EventBus.TriggerEvent(new SaveCompletedEvent
|
||||
{
|
||||
SlotIndex = args.SlotIndex,
|
||||
ModuleNames = args.ModuleNames,
|
||||
Timestamp = args.Timestamp
|
||||
});
|
||||
|
||||
private static void HandleSaveFailed(SaveFailedEventArgs args) =>
|
||||
EventBus.TriggerEvent(new SaveFailedEvent
|
||||
{
|
||||
SlotIndex = args.SlotIndex,
|
||||
ErrorMessage = args.ErrorMessage
|
||||
});
|
||||
|
||||
private static void HandleLoadStarted(LoadStartedEventArgs args) =>
|
||||
EventBus.TriggerEvent(new LoadStartedEvent { SlotIndex = args.SlotIndex, Timestamp = args.Timestamp });
|
||||
|
||||
private static void HandleLoadCompleted(LoadCompletedEventArgs args) =>
|
||||
EventBus.TriggerEvent(new LoadCompletedEvent
|
||||
{
|
||||
SlotIndex = args.SlotIndex,
|
||||
ModuleNames = args.ModuleNames,
|
||||
Version = args.Version,
|
||||
Timestamp = args.Timestamp
|
||||
});
|
||||
|
||||
private static void HandleLoadFailed(LoadFailedEventArgs args) =>
|
||||
EventBus.TriggerEvent(new LoadFailedEvent
|
||||
{
|
||||
SlotIndex = args.SlotIndex,
|
||||
ErrorMessage = args.ErrorMessage
|
||||
});
|
||||
|
||||
private static void HandleMigrationCompleted(MigrationCompletedEventArgs args) =>
|
||||
EventBus.TriggerEvent(new MigrationCompletedEvent
|
||||
{
|
||||
SlotIndex = args.SlotIndex,
|
||||
FromVersion = args.FromVersion,
|
||||
ToVersion = args.ToVersion
|
||||
});
|
||||
|
||||
private static void HandleSlotDeleted(SlotDeletedEventArgs args) =>
|
||||
EventBus.TriggerEvent(new SlotDeletedEvent { SlotIndex = args.SlotIndex });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d41cba0389b740dc80169c21db9eb259
|
||||
timeCreated: 1773053776
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using ShrinkEventBus;
|
||||
|
||||
namespace ShrinkDataSaver.Integration
|
||||
{
|
||||
public class SettingsChangedEvent : EventBase
|
||||
{
|
||||
public string Key { get; internal set; }
|
||||
public object Value { get; internal set; }
|
||||
public T Get<T>() => (T)Convert.ChangeType(Value, typeof(T));
|
||||
}
|
||||
|
||||
public class SaveStartedEvent : EventBase
|
||||
{
|
||||
public int SlotIndex { get; internal set; }
|
||||
public long Timestamp { get; internal set; }
|
||||
}
|
||||
|
||||
public class SaveCompletedEvent : EventBase
|
||||
{
|
||||
public int SlotIndex { get; internal set; }
|
||||
public string[] ModuleNames { get; internal set; }
|
||||
public long Timestamp { get; internal set; }
|
||||
}
|
||||
|
||||
public class SaveFailedEvent : EventBase
|
||||
{
|
||||
public int SlotIndex { get; internal set; }
|
||||
public string ErrorMessage { get; internal set; }
|
||||
}
|
||||
|
||||
public class LoadStartedEvent : EventBase
|
||||
{
|
||||
public int SlotIndex { get; internal set; }
|
||||
public long Timestamp { get; internal set; }
|
||||
}
|
||||
|
||||
public class LoadCompletedEvent : EventBase
|
||||
{
|
||||
public int SlotIndex { get; internal set; }
|
||||
public string[] ModuleNames { get; internal set; }
|
||||
public int Version { get; internal set; }
|
||||
public long Timestamp { get; internal set; }
|
||||
}
|
||||
|
||||
public class LoadFailedEvent : EventBase
|
||||
{
|
||||
public int SlotIndex { get; internal set; }
|
||||
public string ErrorMessage { get; internal set; }
|
||||
}
|
||||
|
||||
public class MigrationCompletedEvent : EventBase
|
||||
{
|
||||
public int SlotIndex { get; internal set; }
|
||||
public int FromVersion { get; internal set; }
|
||||
public int ToVersion { get; internal set; }
|
||||
}
|
||||
|
||||
public class SlotDeletedEvent : EventBase
|
||||
{
|
||||
public int SlotIndex { get; internal set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 154ed134a6cb40c4be44560f08362a2a
|
||||
timeCreated: 1773053752
|
||||
@@ -0,0 +1,155 @@
|
||||
# ShrinkDataSaver.Integration.EventBus
|
||||
|
||||
[ShrinkDataSaver](https://github.com/cneicy/ShrinkDataSaver) 与 [ShrinkEventBus](https://github.com/cneicy/ShrinkEventBus) 的桥接层。
|
||||
|
||||
ContextLoader 下由 `ShrinkDataSaverEventBusComponent` 注入 `shrink.service.datasaver` 后接桥,依赖撤回时拆桥;独立使用时调用 `DataSaverEventBusBridge.Register()` / `Unregister()`。仅安装程序集不会再产生全局副作用。
|
||||
|
||||
## ✨ 特性概览
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 🌉 **可逆桥接** | 由 Cordis 组件或显式 API 管理注册与注销 |
|
||||
| 📡 **完整事件覆盖** | 9 种事件类型,覆盖设置变更 + 存档保存/加载/删除/迁移全生命周期 |
|
||||
| 🔒 **类型安全** | 所有事件继承 `EventBase`,支持 `[EventBusSubscriber]` 自动注册 |
|
||||
| 📦 **丰富载荷** | 事件携带 SlotIndex、ModuleNames、Version、Timestamp、ErrorMessage 等完整数据 |
|
||||
|
||||
## 📦 依赖
|
||||
|
||||
- [ShrinkDataSaver](https://github.com/cneicy/ShrinkDataSaver) `2.0.0+`
|
||||
- [ShrinkEventBus](https://github.com/cneicy/ShrinkEventBus) `1.0.0+`
|
||||
|
||||
## ⚙️ 安装
|
||||
|
||||
在项目的 `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://github.com/cneicy/ShrinkDataSaver.git",
|
||||
"com.cneicy.shrink-datasaver-integration-eventbus": "https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus.git"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
或通过 Package Manager → `+` → `Add package from git URL` 输入:
|
||||
|
||||
```
|
||||
https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus.git
|
||||
```
|
||||
|
||||
## 📡 事件类型
|
||||
|
||||
| 事件类 | 对应原生事件 | 载荷字段 |
|
||||
|--------|-------------|----------|
|
||||
| `SettingsChangedEvent` | `ShrinkSettings.OnChanged` | Key, Value |
|
||||
| `SaveStartedEvent` | `ShrinkSave.OnSaveStarted` | SlotIndex, Timestamp |
|
||||
| `SaveCompletedEvent` | `ShrinkSave.OnSaveCompleted` | SlotIndex, ModuleNames[], Timestamp |
|
||||
| `SaveFailedEvent` | `ShrinkSave.OnSaveFailed` | SlotIndex, ErrorMessage |
|
||||
| `LoadStartedEvent` | `ShrinkSave.OnLoadStarted` | SlotIndex, Timestamp |
|
||||
| `LoadCompletedEvent` | `ShrinkSave.OnLoadCompleted` | SlotIndex, ModuleNames[], Version, Timestamp |
|
||||
| `LoadFailedEvent` | `ShrinkSave.OnLoadFailed` | SlotIndex, ErrorMessage |
|
||||
| `MigrationCompletedEvent` | `ShrinkSave.OnMigrationCompleted` | SlotIndex, FromVersion, ToVersion |
|
||||
| `SlotDeletedEvent` | `ShrinkSave.OnDeleteCompleted` | SlotIndex |
|
||||
|
||||
## 🚀 使用示例
|
||||
|
||||
### 使用 `[EventBusSubscriber]` 自动注册(推荐)
|
||||
|
||||
```csharp
|
||||
[EventBusSubscriber]
|
||||
public class SaveUIManager : MonoBehaviour
|
||||
{
|
||||
[EventSubscribe(EventPriority.NORMAL)]
|
||||
private void OnSaveCompleted(SaveCompletedEvent e)
|
||||
{
|
||||
Debug.Log($"槽位 {e.SlotIndex} 保存成功,模块: {string.Join(", ", e.ModuleNames)}");
|
||||
ShowSaveIndicator();
|
||||
}
|
||||
|
||||
[EventSubscribe(EventPriority.NORMAL)]
|
||||
private void OnLoadCompleted(LoadCompletedEvent e)
|
||||
{
|
||||
Debug.Log($"槽位 {e.SlotIndex} 加载完成 (v{e.Version})");
|
||||
TransitionToGame();
|
||||
}
|
||||
|
||||
[EventSubscribe(EventPriority.NORMAL)]
|
||||
private void OnSaveFailed(SaveFailedEvent e)
|
||||
{
|
||||
ShowErrorDialog($"保存失败: {e.ErrorMessage}");
|
||||
}
|
||||
|
||||
[EventSubscribe(EventPriority.NORMAL)]
|
||||
private void OnLoadFailed(LoadFailedEvent e)
|
||||
{
|
||||
ShowErrorDialog($"加载失败: {e.ErrorMessage}");
|
||||
}
|
||||
|
||||
[EventSubscribe(EventPriority.NORMAL)]
|
||||
private void OnMigration(MigrationCompletedEvent e)
|
||||
{
|
||||
Debug.Log($"存档已从 v{e.FromVersion} 迁移到 v{e.ToVersion}");
|
||||
}
|
||||
|
||||
[EventSubscribe(EventPriority.NORMAL)]
|
||||
private void OnSettingsChanged(SettingsChangedEvent e)
|
||||
{
|
||||
if (e.Key == "MasterVolume")
|
||||
ApplyVolume(e.Get<float>());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 手动注册
|
||||
|
||||
```csharp
|
||||
public class AnalyticsTracker : IDisposable
|
||||
{
|
||||
public AnalyticsTracker()
|
||||
{
|
||||
EventBus.RegisterEvent<SaveCompletedEvent>(OnSave, EventPriority.LOWEST);
|
||||
EventBus.RegisterEvent<LoadCompletedEvent>(OnLoad, EventPriority.LOWEST);
|
||||
}
|
||||
|
||||
private void OnSave(SaveCompletedEvent e)
|
||||
=> Analytics.Track("save", new { slot = e.SlotIndex, modules = e.ModuleNames.Length });
|
||||
|
||||
private void OnLoad(LoadCompletedEvent e)
|
||||
=> Analytics.Track("load", new { slot = e.SlotIndex, version = e.Version });
|
||||
|
||||
public void Dispose()
|
||||
=> EventBus.UnregisterAllEventsForObject(this);
|
||||
}
|
||||
```
|
||||
|
||||
## 🏗️ 架构
|
||||
|
||||
```
|
||||
ShrinkDataSaver.Integration.EventBus/
|
||||
├── DataSaverEvents.cs 9 个 EventBase 子类(事件定义)
|
||||
├── DataSaverEventBusBridge.cs 显式、可注销的静态桥接器
|
||||
├── ShrinkDataSaverEventBusComponent.cs 按服务键管理桥生命周期
|
||||
└── ShrinkDataSaver.Integration.EventBus.asmdef
|
||||
```
|
||||
|
||||
**桥接原理:**
|
||||
|
||||
```
|
||||
ShrinkDataSaver 原生事件 (Action<XxxEventArgs>)
|
||||
└─ ShrinkDataSaverEventBusComponent / 显式 Register
|
||||
└─ EventBus.TriggerEvent(new XxxEvent { ... })
|
||||
└─ ShrinkEventBus 分发到所有订阅者
|
||||
```
|
||||
|
||||
桥接器是单向的:DataSaver → EventBus。业务代码只需订阅 EventBus 事件,无需直接引用 ShrinkDataSaver 的原生事件。
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
- **生命周期**:ContextLoader 组合根负责接桥;Standalone 必须在 DataSaver 初始化后显式 `Register()`,并在退出时 `Unregister()`。
|
||||
- **不影响原生事件**:桥接是附加行为,ShrinkDataSaver 的原生 C# 事件仍然正常触发,两种订阅方式可并存。
|
||||
- **SettingsChangedEvent.Get\<T\>()**:提供泛型辅助方法获取强类型值,内部使用 `Convert.ChangeType`。
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d6a7cdf0e3e46ae418fd90acc11d6d48
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "ShrinkDataSaver.Integration.EventBus",
|
||||
"rootNamespace": "ShrinkDataSaver.Integration",
|
||||
"references": [
|
||||
"ShrinkDataSaver.Runtime",
|
||||
"ShrinkContext.Core.Runtime",
|
||||
"UniTask",
|
||||
"ShrinkEventBus.Runtime"
|
||||
],
|
||||
"optionalUnityReferences": [],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d3277e39d0a8439db56bfb6774183165
|
||||
timeCreated: 1773053684
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ShrinkContext;
|
||||
|
||||
namespace ShrinkDataSaver.Integration
|
||||
{
|
||||
/// <summary>注入 DataSaver 服务键并把其原生事件显式接到 EventBus。</summary>
|
||||
public sealed class ShrinkDataSaverEventBusComponent : IShrinkComponent
|
||||
{
|
||||
public const string DataSaverServiceKey = "shrink.service.datasaver";
|
||||
public const string ProvideKey = "shrink.integration.datasaver-eventbus";
|
||||
|
||||
private static readonly string[] InjectKeys = { DataSaverServiceKey };
|
||||
private static readonly string[] ProvideKeys = { ProvideKey };
|
||||
|
||||
public string Name => "shrink.integration.datasaver-eventbus";
|
||||
public IReadOnlyList<string> Inject => InjectKeys;
|
||||
public IReadOnlyList<string> Provide => ProvideKeys;
|
||||
|
||||
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
|
||||
{
|
||||
_ = ctx.Get<object>(DataSaverServiceKey);
|
||||
DataSaverEventBusBridge.Register();
|
||||
ctx.EffectInverse(() =>
|
||||
{
|
||||
DataSaverEventBusBridge.Unregister();
|
||||
return UniTask.CompletedTask;
|
||||
});
|
||||
ctx.Set(ProvideKey, Name);
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee66c2e8bc4958946b7c0849b11545ba
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "com.cneicy.shrink-datasaver-integration-eventbus",
|
||||
"version": "2.1.0",
|
||||
"displayName": "ShrinkDataSaver - EventBus Integration",
|
||||
"description": "ShrinkDataSaver 与 ShrinkEventBus 的桥接层,自动将存档/设置事件映射到事件总线。",
|
||||
"unity": "2022.3",
|
||||
"documentationUrl": "https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus",
|
||||
"changelogUrl": "https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus/blob/main/CHANGELOG.md",
|
||||
"licensesUrl": "https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus/blob/main/LICENSE",
|
||||
"dependencies": {
|
||||
"com.cneicy.shrink-datasaver": "2.2.0",
|
||||
"com.cneicy.shrink-context-core": "0.1.0",
|
||||
"com.cysharp.unitask": "2.5.10",
|
||||
"com.cneicy.shrink-eventbus": "1.3.0"
|
||||
},
|
||||
"keywords": ["save", "eventbus", "integration", "bridge"],
|
||||
"author": {
|
||||
"name": "cneicy",
|
||||
"url": "https://github.com/cneicy"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3d8e28bc2bab56447aab597955ca683b
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
本文件记录 `ShrinkDataSaver` 在当前工作区中的包内变更。
|
||||
|
||||
## [2.2.0] - 2026-05-18
|
||||
|
||||
### Added
|
||||
|
||||
- 新增 `ShrinkDataSaverRuntime`,把真实初始化入口与 autosave 生命周期驱动下沉到运行时层。
|
||||
|
||||
### Changed
|
||||
|
||||
- `ShrinkDataSaverBootstrap` 现主要承担旧项目兼容入口职责;实际初始化改为委托给 `ShrinkDataSaverRuntime`。
|
||||
- 该调整为 `ShrinkApp` 等统一宿主接管初始化顺序铺路,同时保留旧项目直接挂 bootstrap 的使用方式。
|
||||
|
||||
## [2.1.0] - 2026-05-15
|
||||
|
||||
### Added
|
||||
|
||||
- `ShrinkSave.GetRecentSlotIndex()` 与 `ShrinkSave.GetRecommendedContinueSlotAsync()`,用于把“最近游玩槽位 / 继续游戏”能力下沉到包内。
|
||||
- 包内测试补充了主文件损坏时的备份回退、最近槽位回退、以及本地存储双副本轮换验证。
|
||||
|
||||
### Changed
|
||||
|
||||
- `LocalStorageProvider` 升级为异步文件流读写,并在提交时使用主文件原子替换。
|
||||
- 本地存储新增 `.bak1` / `.bak2` 双副本轮换备份。
|
||||
- `ShrinkSave` 与 `ShrinkSettings` 在主文件损坏或缺失时,会自动从备份恢复并修复主文件。
|
||||
- `ShrinkSave.DeleteSlotAsync(...)` 现在会同时清理主文件、备份文件和临时文件,并在删除最近游玩槽位后自动回退记录。
|
||||
|
||||
## [2.0.1] - 2026-04-07
|
||||
|
||||
### Changed
|
||||
|
||||
- 数据查看器编辑器入口迁移到 `ShrinkSDK/存档/数据查看器`。
|
||||
- README 同步更新新的菜单路径,统一到 `ShrinkSDK` 顶栏下。
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 78b8bd668657cd546a75bf4ae7cb3b8f
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e02de79ba4c4595bc36097d194fdf52
|
||||
timeCreated: 1773047471
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "ShrinkDataSaver.Editor",
|
||||
"rootNamespace": "ShrinkDataSaver.Editor",
|
||||
"references": [
|
||||
"ShrinkDataSaver.Runtime",
|
||||
"UniTask"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"autoReferenced": false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 78cf16cc0efe432a939df0e90a4cdb14
|
||||
timeCreated: 1773047512
|
||||
@@ -0,0 +1,378 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ShrinkDataSaver.Editor
|
||||
{
|
||||
public class ShrinkDataSaverEditorWindow : EditorWindow
|
||||
{
|
||||
private int _selectedTab;
|
||||
private readonly string[] _tabs = { "设置", "存档槽", "工具" };
|
||||
private Vector2 _settingsScroll;
|
||||
private Vector2 _slotsScroll;
|
||||
|
||||
private string _setKey = "";
|
||||
private string _setValue = "";
|
||||
private int _setType; // 0=string, 1=int, 2=float, 3=bool
|
||||
private readonly string[] _typeNames = { "字符串", "整数", "浮点数", "布尔值" };
|
||||
|
||||
private int _saveSlot;
|
||||
private string _slotName = "新存档";
|
||||
private bool _encrypt;
|
||||
private string _encryptKey = "";
|
||||
private SaveMeta[] _metaCache;
|
||||
|
||||
[MenuItem("ShrinkSDK/存档/数据查看器")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var w = GetWindow<ShrinkDataSaverEditorWindow>("ShrinkDataSaver");
|
||||
w.minSize = new Vector2(460, 500);
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
DrawHeader();
|
||||
_selectedTab = GUILayout.Toolbar(_selectedTab, _tabs, EditorStyles.toolbarButton);
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
switch (_selectedTab)
|
||||
{
|
||||
case 0: DrawSettingsTab(); break;
|
||||
case 1: DrawSaveSlotsTab(); break;
|
||||
case 2: DrawToolsTab(); break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnInspectorUpdate() => Repaint();
|
||||
|
||||
|
||||
private void DrawHeader()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
|
||||
GUILayout.Label("ShrinkDataSaver", EditorStyles.boldLabel);
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
var color = GUI.color;
|
||||
GUI.color = Application.isPlaying ? Color.green : Color.gray;
|
||||
GUILayout.Label(Application.isPlaying ? "● 运行中" : "○ 未运行", EditorStyles.miniLabel);
|
||||
GUI.color = color;
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
|
||||
private void DrawSettingsTab()
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
EditorGUILayout.HelpBox("设置查看器仅在运行模式下可用。", MessageType.Info);
|
||||
DrawSettingsFilePath();
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("重新加载", EditorStyles.miniButton))
|
||||
ShrinkSettings.LoadAsync().Forget();
|
||||
if (GUILayout.Button("立即保存", EditorStyles.miniButton))
|
||||
ShrinkSettings.SaveAsync().Forget();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
|
||||
var allSettings = ShrinkSettings.GetAllRaw();
|
||||
EditorGUILayout.LabelField($"当前设置项({allSettings.Count} 项)", EditorStyles.boldLabel);
|
||||
|
||||
if (allSettings.Count == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("暂无设置项。", MessageType.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
_settingsScroll = EditorGUILayout.BeginScrollView(_settingsScroll, GUILayout.MaxHeight(240));
|
||||
string keyToRemove = null;
|
||||
|
||||
foreach (var kvp in allSettings.OrderBy(k => k.Key))
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal("box");
|
||||
EditorGUILayout.LabelField(kvp.Key, EditorStyles.boldLabel, GUILayout.Width(140));
|
||||
|
||||
var valueStr = FormatJToken(kvp.Value);
|
||||
EditorGUILayout.LabelField(valueStr, EditorStyles.wordWrappedMiniLabel);
|
||||
|
||||
var prevColor = GUI.backgroundColor;
|
||||
GUI.backgroundColor = new Color(1f, 0.6f, 0.6f);
|
||||
if (GUILayout.Button("删除", EditorStyles.miniButton, GUILayout.Width(40)))
|
||||
keyToRemove = kvp.Key;
|
||||
GUI.backgroundColor = prevColor;
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
|
||||
if (keyToRemove != null)
|
||||
ShrinkSettings.Remove(keyToRemove);
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
|
||||
EditorGUILayout.LabelField("新增 / 修改", EditorStyles.boldLabel);
|
||||
_setKey = EditorGUILayout.TextField("键", _setKey);
|
||||
_setType = EditorGUILayout.Popup("类型", _setType, _typeNames);
|
||||
_setValue = EditorGUILayout.TextField("值", _setValue);
|
||||
|
||||
EditorGUI.BeginDisabledGroup(string.IsNullOrWhiteSpace(_setKey));
|
||||
if (GUILayout.Button("设置", EditorStyles.miniButton))
|
||||
SetTypedValue(_setKey, _setValue, _setType);
|
||||
EditorGUI.EndDisabledGroup();
|
||||
}
|
||||
|
||||
private static void SetTypedValue(string key, string value, int type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case 0: // string
|
||||
ShrinkSettings.Set(key, value);
|
||||
break;
|
||||
case 1: // int
|
||||
if (int.TryParse(value, out var intVal))
|
||||
ShrinkSettings.Set(key, intVal);
|
||||
else
|
||||
Debug.LogWarning($"[ShrinkDataSaver] 无法将 \"{value}\" 解析为整数。");
|
||||
break;
|
||||
case 2: // float
|
||||
if (float.TryParse(value, out var floatVal))
|
||||
ShrinkSettings.Set(key, floatVal);
|
||||
else
|
||||
Debug.LogWarning($"[ShrinkDataSaver] 无法将 \"{value}\" 解析为浮点数。");
|
||||
break;
|
||||
case 3: // bool
|
||||
if (bool.TryParse(value, out var boolVal))
|
||||
ShrinkSettings.Set(key, boolVal);
|
||||
else
|
||||
ShrinkSettings.Set(key, value is "1" or "true" or "True");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatJToken(JToken token)
|
||||
{
|
||||
if (token == null) return "null";
|
||||
return token.Type switch
|
||||
{
|
||||
JTokenType.String => $"\"{token}\"",
|
||||
JTokenType.Boolean => token.Value<bool>() ? "true" : "false",
|
||||
JTokenType.Array or JTokenType.Object => token.ToString(Newtonsoft.Json.Formatting.None),
|
||||
_ => token.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private void DrawSettingsFilePath()
|
||||
{
|
||||
var cfg = ShrinkDataSaverSettings.Instance;
|
||||
var root = string.IsNullOrEmpty(cfg.customSavePath)
|
||||
? Application.persistentDataPath
|
||||
: cfg.customSavePath;
|
||||
EditorGUILayout.LabelField("设置文件路径:", Path.Combine(root, cfg.settingsFileName),
|
||||
EditorStyles.wordWrappedMiniLabel);
|
||||
|
||||
if (GUILayout.Button("打开目录", EditorStyles.miniButton))
|
||||
EditorUtility.RevealInFinder(root);
|
||||
}
|
||||
|
||||
|
||||
private void DrawSaveSlotsTab()
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
EditorGUILayout.HelpBox("存档操作仅在运行模式下可用。", MessageType.Info);
|
||||
DrawSavesFolderPath();
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUILayout.LabelField("快速操作", EditorStyles.boldLabel);
|
||||
|
||||
_saveSlot = EditorGUILayout.IntField("槽位索引", _saveSlot);
|
||||
_slotName = EditorGUILayout.TextField("存档名称", _slotName);
|
||||
_encrypt = EditorGUILayout.Toggle("加密", _encrypt);
|
||||
if (_encrypt)
|
||||
_encryptKey = EditorGUILayout.TextField("密钥", _encryptKey);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
|
||||
if (GUILayout.Button("保存", EditorStyles.miniButton))
|
||||
{
|
||||
ShrinkSave.SaveSlotAsync(_saveSlot, new SaveOptions
|
||||
{
|
||||
SlotName = _slotName,
|
||||
Encrypt = _encrypt,
|
||||
EncryptionKey = _encryptKey
|
||||
}).Forget();
|
||||
RefreshMetaDelayed().Forget();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("加载", EditorStyles.miniButton))
|
||||
{
|
||||
ShrinkSave.LoadSlotAsync(_saveSlot, _encrypt ? _encryptKey : null).Forget();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("删除", EditorStyles.miniButton))
|
||||
{
|
||||
if (EditorUtility.DisplayDialog("删除存档",
|
||||
$"确认删除槽位 {_saveSlot}?", "删除", "取消"))
|
||||
{
|
||||
ShrinkSave.DeleteSlotAsync(_saveSlot).Forget();
|
||||
RefreshMetaDelayed().Forget();
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField("所有存档", EditorStyles.boldLabel);
|
||||
if (GUILayout.Button("刷新", EditorStyles.miniButton, GUILayout.Width(50)))
|
||||
RefreshMeta().Forget();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
if (_metaCache == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("点击「刷新」加载存档列表。", MessageType.None);
|
||||
}
|
||||
else if (_metaCache.Length == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("暂无存档。", MessageType.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawMetaList();
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask RefreshMeta()
|
||||
{
|
||||
_metaCache = await ShrinkSave.GetAllMetaAsync();
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private async UniTaskVoid RefreshMetaDelayed()
|
||||
{
|
||||
await UniTask.Delay(300);
|
||||
await RefreshMeta();
|
||||
}
|
||||
|
||||
private void DrawMetaList()
|
||||
{
|
||||
_slotsScroll = EditorGUILayout.BeginScrollView(_slotsScroll, GUILayout.MaxHeight(360));
|
||||
|
||||
foreach (var meta in _metaCache)
|
||||
{
|
||||
EditorGUILayout.BeginVertical("box");
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField($"槽位 {meta.SlotIndex} — {meta.SlotName}", EditorStyles.boldLabel);
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (GUILayout.Button("加载", EditorStyles.miniButton, GUILayout.Width(40)))
|
||||
{
|
||||
var key = meta.IsEncrypted ? _encryptKey : null;
|
||||
ShrinkSave.LoadSlotAsync(meta.SlotIndex, key).Forget();
|
||||
}
|
||||
|
||||
var prevColor = GUI.backgroundColor;
|
||||
GUI.backgroundColor = new Color(1f, 0.6f, 0.6f);
|
||||
if (GUILayout.Button("删除", EditorStyles.miniButton, GUILayout.Width(40)))
|
||||
{
|
||||
if (EditorUtility.DisplayDialog("删除存档",
|
||||
$"确认删除槽位 {meta.SlotIndex}({meta.SlotName})?", "删除", "取消"))
|
||||
{
|
||||
ShrinkSave.DeleteSlotAsync(meta.SlotIndex).Forget();
|
||||
RefreshMetaDelayed().Forget();
|
||||
}
|
||||
}
|
||||
|
||||
GUI.backgroundColor = prevColor;
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.LabelField("最后修改", meta.LastModifiedTime.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
EditorGUILayout.LabelField("游戏时长", TimeSpan.FromSeconds(meta.PlaytimeSeconds).ToString(@"hh\:mm\:ss"));
|
||||
EditorGUILayout.LabelField("存档版本", meta.SaveVersion.ToString());
|
||||
EditorGUILayout.LabelField("已加密", meta.IsEncrypted ? "是" : "否");
|
||||
EditorGUILayout.LabelField("截图", meta.ScreenshotBase64 != null ? "有" : "无");
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
EditorGUILayout.Space(2);
|
||||
}
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private void DrawSavesFolderPath()
|
||||
{
|
||||
var cfg = ShrinkDataSaverSettings.Instance;
|
||||
var root = string.IsNullOrEmpty(cfg.customSavePath)
|
||||
? Application.persistentDataPath
|
||||
: cfg.customSavePath;
|
||||
var saves = Path.Combine(root, "saves");
|
||||
|
||||
EditorGUILayout.LabelField("存档目录:", saves, EditorStyles.wordWrappedMiniLabel);
|
||||
if (GUILayout.Button("打开目录", EditorStyles.miniButton))
|
||||
EditorUtility.RevealInFinder(saves);
|
||||
}
|
||||
|
||||
|
||||
private void DrawToolsTab()
|
||||
{
|
||||
EditorGUILayout.LabelField("实用工具", EditorStyles.boldLabel);
|
||||
|
||||
if (GUILayout.Button("打开持久化数据目录"))
|
||||
EditorUtility.RevealInFinder(Application.persistentDataPath);
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
EditorGUILayout.LabelField("配置资源", EditorStyles.boldLabel);
|
||||
var settings = ShrinkDataSaverSettings.Instance;
|
||||
if (settings)
|
||||
{
|
||||
EditorGUILayout.ObjectField("资源文件", settings, typeof(ShrinkDataSaverSettings), false);
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"未找到 ShrinkDataSaverSettings 资源文件。\n" +
|
||||
"请通过菜单 Assets → Create → ShrinkDataSaver → Settings 创建。",
|
||||
MessageType.Warning);
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
EditorGUILayout.LabelField("危险操作", EditorStyles.boldLabel);
|
||||
|
||||
GUI.backgroundColor = new Color(1f, 0.4f, 0.4f);
|
||||
if (GUILayout.Button("删除所有存档文件"))
|
||||
{
|
||||
var cfg = ShrinkDataSaverSettings.Instance;
|
||||
var root = string.IsNullOrEmpty(cfg?.customSavePath)
|
||||
? Application.persistentDataPath
|
||||
: cfg.customSavePath;
|
||||
var saves = Path.Combine(root, "saves");
|
||||
|
||||
if (EditorUtility.DisplayDialog("删除所有存档",
|
||||
$"将删除以下目录中的所有内容:\n{saves}\n\n此操作不可撤销!",
|
||||
"全部删除", "取消"))
|
||||
{
|
||||
if (Directory.Exists(saves))
|
||||
Directory.Delete(saves, true);
|
||||
_metaCache = null;
|
||||
Debug.Log("[ShrinkDataSaver] 所有存档文件已删除。");
|
||||
}
|
||||
}
|
||||
|
||||
GUI.backgroundColor = Color.white;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0c285b35e7094111b6ea986ea5af391e
|
||||
timeCreated: 1773047478
|
||||
@@ -0,0 +1,538 @@
|
||||
# ShrinkDataSaver
|
||||
|
||||
一个为 Unity C# 项目设计的模块化存档与设置管理系统。支持多存档槽、链式版本迁移、可选 AES-256 加密、关键模块保护、跨模块只读查询,以及完整的事件驱动架构。
|
||||
|
||||
## ✨ 特性概览
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 💾 **模块化存档** | 按模块拆分存档数据,注册即用,读写隔离 |
|
||||
| 🔢 **多存档槽** | 槽位数量可配置,支持元数据轻量查询 |
|
||||
| 🔄 **链式版本迁移** | 注册迁移规则后自动链式执行,失败时整体回滚 |
|
||||
| 🔒 **可选加密** | 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://github.com/cneicy/ShrinkDataSaver.git"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
或通过 Package Manager → `+` → `Add package from git URL` 输入:
|
||||
|
||||
```
|
||||
https://github.com/cneicy/ShrinkDataSaver.git
|
||||
```
|
||||
|
||||
## 🚀 快速上手
|
||||
|
||||
### 第一步:创建配置资产
|
||||
|
||||
菜单 `Assets` → `Create` → `ShrinkDataSaver` → `Settings`。
|
||||
|
||||
配置文件可放置在项目**任意目录**下,编辑器会通过 `AssetDatabase` 自动搜索。也可放在 `Resources/` 下供运行时加载,或在 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<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-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://github.com/cneicy/ShrinkDataSaver.Integration.EventBus)
|
||||
|
||||
项目中同时包含 `ShrinkDataSaver.Integration.EventBus` 时,桥接器会在游戏启动时自动初始化,将所有原生事件映射到 EventBus:
|
||||
|
||||
```csharp
|
||||
[EventBusSubscriber]
|
||||
public class SaveUIManager : MonoBehaviour
|
||||
{
|
||||
[EventSubscribe(EventPriority.NORMAL)]
|
||||
private void OnSaveCompleted(SaveCompletedEvent e)
|
||||
{
|
||||
ShowSaveIndicator(e.SlotIndex, e.ModuleNames);
|
||||
}
|
||||
|
||||
[EventSubscribe(EventPriority.NORMAL)]
|
||||
private void OnLoadFailed(LoadFailedEvent e)
|
||||
{
|
||||
ShowErrorDialog(e.ErrorMessage);
|
||||
}
|
||||
|
||||
[EventSubscribe(EventPriority.NORMAL)]
|
||||
private void OnSettingsChanged(SettingsChangedEvent e)
|
||||
{
|
||||
if (e.Key == "MasterVolume")
|
||||
ApplyVolume(e.Get<float>());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**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)
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a0a2b66f2bb84ff1bfe75303700753dd
|
||||
timeCreated: 1773048087
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4927a1e86fef478f9ab5e203485c50ce
|
||||
timeCreated: 1773046045
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("ShrinkDataSaver.Tests")]
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1e21adcea9de62441b55c09908f22530
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace ShrinkDataSaver
|
||||
{
|
||||
public static class DataSerializer
|
||||
{
|
||||
private static readonly JsonSerializerSettings Settings = new()
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
DefaultValueHandling = DefaultValueHandling.Include,
|
||||
Formatting = Formatting.None
|
||||
};
|
||||
|
||||
public static byte[] Serialize<T>(T obj)
|
||||
=> Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(obj, Settings));
|
||||
|
||||
public static T Deserialize<T>(byte[] data)
|
||||
=> JsonConvert.DeserializeObject<T>(Encoding.UTF8.GetString(data), Settings);
|
||||
|
||||
public static JObject ToJObject(byte[] data)
|
||||
=> JObject.Parse(Encoding.UTF8.GetString(data));
|
||||
|
||||
public static byte[] FromJObject(JObject obj)
|
||||
=> Encoding.UTF8.GetBytes(obj.ToString(Formatting.None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 19531b42fff04fd9b2fde3cfb450881e
|
||||
timeCreated: 1773046107
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace ShrinkDataSaver
|
||||
{
|
||||
public interface IStorageProvider
|
||||
{
|
||||
UniTask WriteAsync(string path, byte[] data, CancellationToken ct = default);
|
||||
UniTask<byte[]> ReadAsync(string path, CancellationToken ct = default);
|
||||
UniTask<bool> ExistsAsync(string path, CancellationToken ct = default);
|
||||
UniTask DeleteAsync(string path, CancellationToken ct = default);
|
||||
UniTask<string[]> ListAsync(string prefix = "", CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ea76ef6ec0a455aabd1fe2346be4ddf
|
||||
timeCreated: 1773046056
|
||||
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ShrinkDataSaver
|
||||
{
|
||||
public class LocalStorageProvider : IStorageProvider
|
||||
{
|
||||
private const string BackupPrimarySuffix = ".bak1";
|
||||
private const string BackupSecondarySuffix = ".bak2";
|
||||
private const string TempWriteSuffix = ".tmp";
|
||||
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> PathLocks =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly string _rootPath;
|
||||
|
||||
public LocalStorageProvider(string rootPath = null)
|
||||
{
|
||||
_rootPath = rootPath ?? Application.persistentDataPath;
|
||||
}
|
||||
|
||||
private string Resolve(string path) =>
|
||||
Path.IsPathRooted(path) ? path : Path.Combine(_rootPath, path);
|
||||
|
||||
public async UniTask WriteAsync(string path, byte[] data, CancellationToken ct = default)
|
||||
{
|
||||
var fullPath = Resolve(path);
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
await UniTask.RunOnThreadPool(() => Directory.CreateDirectory(dir), cancellationToken: ct);
|
||||
}
|
||||
|
||||
var tempPath = fullPath + TempWriteSuffix;
|
||||
var backupPath = fullPath + BackupPrimarySuffix;
|
||||
var secondaryBackupPath = fullPath + BackupSecondarySuffix;
|
||||
var pathLock = GetPathLock(fullPath);
|
||||
|
||||
await pathLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await WriteFileBytesAsync(tempPath, data, ct);
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
if (File.Exists(secondaryBackupPath))
|
||||
{
|
||||
File.Delete(secondaryBackupPath);
|
||||
}
|
||||
|
||||
if (File.Exists(backupPath))
|
||||
{
|
||||
File.Move(backupPath, secondaryBackupPath);
|
||||
}
|
||||
|
||||
File.Replace(tempPath, fullPath, backupPath, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(tempPath, fullPath);
|
||||
}
|
||||
}, cancellationToken: ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
}, cancellationToken: CancellationToken.None);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
pathLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async UniTask<byte[]> ReadAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
var fullPath = Resolve(path);
|
||||
var pathLock = GetPathLock(fullPath);
|
||||
await pathLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new FileNotFoundException($"ShrinkDataSaver: file not found: {fullPath}");
|
||||
}
|
||||
|
||||
return await ReadFileBytesAsync(fullPath, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pathLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public UniTask<bool> ExistsAsync(string path, CancellationToken ct = default) =>
|
||||
UniTask.RunOnThreadPool(() => File.Exists(Resolve(path)), cancellationToken: ct);
|
||||
|
||||
public async UniTask DeleteAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
var fullPath = Resolve(path);
|
||||
var pathLock = GetPathLock(fullPath);
|
||||
await pathLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
File.Delete(fullPath);
|
||||
}
|
||||
}, cancellationToken: ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pathLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async UniTask<string[]> ListAsync(string prefix = "", CancellationToken ct = default)
|
||||
{
|
||||
return await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
var dir = string.IsNullOrEmpty(prefix) ? _rootPath : Path.Combine(_rootPath, prefix);
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return Directory.GetFiles(dir)
|
||||
.Select(f => Path.GetRelativePath(_rootPath, f))
|
||||
.ToArray();
|
||||
}, cancellationToken: ct);
|
||||
}
|
||||
|
||||
private static SemaphoreSlim GetPathLock(string fullPath)
|
||||
{
|
||||
return PathLocks.GetOrAdd(fullPath, _ => new SemaphoreSlim(1, 1));
|
||||
}
|
||||
|
||||
private static async UniTask WriteFileBytesAsync(string fullPath, byte[] data, CancellationToken ct)
|
||||
{
|
||||
await using var stream = new FileStream(
|
||||
fullPath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
4096,
|
||||
FileOptions.Asynchronous);
|
||||
await stream.WriteAsync(data, 0, data.Length, ct);
|
||||
stream.Flush(true);
|
||||
}
|
||||
|
||||
private static async UniTask<byte[]> ReadFileBytesAsync(string fullPath, CancellationToken ct)
|
||||
{
|
||||
await using var stream = new FileStream(
|
||||
fullPath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
4096,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
var length = stream.Length;
|
||||
if (length > int.MaxValue)
|
||||
{
|
||||
throw new IOException($"ShrinkDataSaver: file too large to read into memory: {fullPath}");
|
||||
}
|
||||
|
||||
var buffer = new byte[length];
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, offset, buffer.Length - offset, ct);
|
||||
if (read <= 0)
|
||||
{
|
||||
throw new EndOfStreamException($"ShrinkDataSaver: unexpected EOF while reading {fullPath}");
|
||||
}
|
||||
|
||||
offset += read;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dde6d5dd1c3f47db9453a3d0122d39d4
|
||||
timeCreated: 1773046075
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ShrinkDataSaver
|
||||
{
|
||||
public static class MigrationChain
|
||||
{
|
||||
private static readonly SortedDictionary<int, Migration> Migrations = new();
|
||||
|
||||
private struct Migration
|
||||
{
|
||||
public int ToVersion;
|
||||
public Func<JObject, JObject> Migrate;
|
||||
}
|
||||
|
||||
public static void Register(int fromVersion, int toVersion, Func<JObject, JObject> migrate)
|
||||
{
|
||||
if (fromVersion >= toVersion)
|
||||
throw new ArgumentException(
|
||||
$"[ShrinkDataSaver] fromVersion({fromVersion}) 必须小于 toVersion({toVersion})");
|
||||
|
||||
Migrations[fromVersion] = new Migration { ToVersion = toVersion, Migrate = migrate };
|
||||
}
|
||||
|
||||
internal static (JObject data, int finalVersion) Apply(JObject data, int currentVersion, int targetVersion)
|
||||
{
|
||||
var version = currentVersion;
|
||||
var backup = (JObject)data.DeepClone();
|
||||
|
||||
while (version < targetVersion)
|
||||
{
|
||||
if (!Migrations.TryGetValue(version, out var migration))
|
||||
{
|
||||
Debug.LogWarning($"[ShrinkDataSaver] 未找到 v{version} → v{version + 1} 的迁移逻辑,数据可能不完整。");
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fromV = version;
|
||||
data = migration.Migrate(data) ?? data;
|
||||
version = migration.ToVersion;
|
||||
Debug.Log($"[ShrinkDataSaver] 存档已迁移 v{fromV} → v{version}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[ShrinkDataSaver] 迁移 v{version} → v{migration.ToVersion} 失败: {e.Message},已回滚至 v{currentVersion}");
|
||||
return (backup, currentVersion);
|
||||
}
|
||||
}
|
||||
|
||||
return (data, version);
|
||||
}
|
||||
|
||||
internal static void Clear() => Migrations.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 56cc513596cc4b30b82412e080e8b716
|
||||
timeCreated: 1773047408
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ShrinkDataSaver
|
||||
{
|
||||
public static class SaveEncryptor
|
||||
{
|
||||
private const int KeySize = 256;
|
||||
private const int BlockSize = 128;
|
||||
private const int IvBytes = 16;
|
||||
private const int SaltBytes = 16;
|
||||
private const int Iterations = 10000;
|
||||
|
||||
public static byte[] Encrypt(byte[] data, string password)
|
||||
{
|
||||
if (data == null) throw new ArgumentNullException(nameof(data));
|
||||
if (string.IsNullOrEmpty(password)) throw new ArgumentException("Password must not be empty.");
|
||||
|
||||
var salt = GenerateRandom(SaltBytes);
|
||||
var iv = GenerateRandom(IvBytes);
|
||||
var key = DeriveKey(password, salt);
|
||||
|
||||
using var aes = CreateAes(key, iv);
|
||||
using var encryptor = aes.CreateEncryptor();
|
||||
var cipher = encryptor.TransformFinalBlock(data, 0, data.Length);
|
||||
|
||||
var result = new byte[SaltBytes + IvBytes + cipher.Length];
|
||||
Buffer.BlockCopy(salt, 0, result, 0, SaltBytes);
|
||||
Buffer.BlockCopy(iv, 0, result, SaltBytes, IvBytes);
|
||||
Buffer.BlockCopy(cipher, 0, result, SaltBytes + IvBytes, cipher.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static byte[] Decrypt(byte[] data, string password)
|
||||
{
|
||||
if (data == null) throw new ArgumentNullException(nameof(data));
|
||||
if (string.IsNullOrEmpty(password)) throw new ArgumentException("Password must not be empty.");
|
||||
if (data.Length < SaltBytes + IvBytes)
|
||||
throw new ArgumentException("Data is too short to be valid encrypted content.");
|
||||
|
||||
var salt = new byte[SaltBytes];
|
||||
var iv = new byte[IvBytes];
|
||||
var cipher = new byte[data.Length - SaltBytes - IvBytes];
|
||||
|
||||
Buffer.BlockCopy(data, 0, salt, 0, SaltBytes);
|
||||
Buffer.BlockCopy(data, SaltBytes, iv, 0, IvBytes);
|
||||
Buffer.BlockCopy(data, SaltBytes + IvBytes, cipher, 0, cipher.Length);
|
||||
|
||||
var key = DeriveKey(password, salt);
|
||||
|
||||
using var aes = CreateAes(key, iv);
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
return decryptor.TransformFinalBlock(cipher, 0, cipher.Length);
|
||||
}
|
||||
|
||||
|
||||
private static byte[] DeriveKey(string password, byte[] salt)
|
||||
{
|
||||
using var deriveBytes = new Rfc2898DeriveBytes(
|
||||
Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256);
|
||||
return deriveBytes.GetBytes(KeySize / 8);
|
||||
}
|
||||
|
||||
private static Aes CreateAes(byte[] key, byte[] iv)
|
||||
{
|
||||
var aes = Aes.Create();
|
||||
aes.KeySize = KeySize;
|
||||
aes.BlockSize = BlockSize;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = key;
|
||||
aes.IV = iv;
|
||||
return aes;
|
||||
}
|
||||
|
||||
private static byte[] GenerateRandom(int length)
|
||||
{
|
||||
var bytes = new byte[length];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 751846b93860451e8864bf71f593d4ff
|
||||
timeCreated: 1773046087
|
||||
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace ShrinkDataSaver
|
||||
{
|
||||
[Serializable]
|
||||
public class SaveMeta
|
||||
{
|
||||
public int SlotIndex { get; set; }
|
||||
public string SlotName { get; set; } = "New Save";
|
||||
public int SaveVersion { get; set; } = 1;
|
||||
public long LastModified { get; set; }
|
||||
public float PlaytimeSeconds { get; set; }
|
||||
public string ScreenshotBase64 { get; set; }
|
||||
public bool IsEncrypted { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public DateTime LastModifiedTime => DateTimeOffset.FromUnixTimeSeconds(LastModified).LocalDateTime;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
internal class SavePacket
|
||||
{
|
||||
public SaveMeta Meta { get; set; }
|
||||
public Dictionary<string, JToken> Modules { get; set; } = new();
|
||||
public string EncryptedModules { get; set; }
|
||||
}
|
||||
|
||||
|
||||
public interface ISaveModule
|
||||
{
|
||||
string Key { get; }
|
||||
object SerializeRaw();
|
||||
void DeserializeRaw(JToken data);
|
||||
}
|
||||
|
||||
public interface ISaveModule<T> : ISaveModule
|
||||
{
|
||||
T Serialize();
|
||||
void Deserialize(T data);
|
||||
object ISaveModule.SerializeRaw() => Serialize();
|
||||
void ISaveModule.DeserializeRaw(JToken data) => Deserialize(data.ToObject<T>());
|
||||
}
|
||||
|
||||
internal class LambdaSaveModule<T> : ISaveModule<T>
|
||||
{
|
||||
public string Key { get; }
|
||||
private readonly Func<T> _serialize;
|
||||
private readonly Action<T> _deserialize;
|
||||
|
||||
public LambdaSaveModule(string key, Func<T> serialize, Action<T> deserialize)
|
||||
{
|
||||
Key = key; _serialize = serialize; _deserialize = deserialize;
|
||||
}
|
||||
public T Serialize() => _serialize();
|
||||
public void Deserialize(T data) => _deserialize(data);
|
||||
}
|
||||
|
||||
|
||||
public class SaveOptions
|
||||
{
|
||||
public string SlotName = "New Save";
|
||||
public bool CaptureScreenshot = false;
|
||||
public UnityEngine.Texture2D Screenshot = null;
|
||||
public bool Encrypt = false;
|
||||
public string EncryptionKey = null;
|
||||
}
|
||||
|
||||
|
||||
public class ModuleConfig
|
||||
{
|
||||
/// <summary>是否参与云存档同步(设置模块应设为 false)</summary>
|
||||
public bool EnableCloudSync { get; set; } = true;
|
||||
|
||||
/// <summary>自动保存间隔(秒),0 表示不自动保存</summary>
|
||||
public float AutoSaveIntervalSeconds { get; set; } = 0f;
|
||||
|
||||
/// <summary>是否为关键模块(关键模块序列化失败将中止整个保存操作)</summary>
|
||||
public bool CriticalModule { get; set; } = false;
|
||||
}
|
||||
|
||||
|
||||
public class SaveStartedEventArgs
|
||||
{
|
||||
public int SlotIndex { get; set; }
|
||||
public long Timestamp { get; set; }
|
||||
}
|
||||
|
||||
public class SaveCompletedEventArgs
|
||||
{
|
||||
public int SlotIndex { get; set; }
|
||||
public string[] ModuleNames { get; set; }
|
||||
public long Timestamp { get; set; }
|
||||
}
|
||||
|
||||
public class SaveFailedEventArgs
|
||||
{
|
||||
public int SlotIndex { get; set; }
|
||||
public string ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public class LoadStartedEventArgs
|
||||
{
|
||||
public int SlotIndex { get; set; }
|
||||
public long Timestamp { get; set; }
|
||||
}
|
||||
|
||||
public class LoadCompletedEventArgs
|
||||
{
|
||||
public int SlotIndex { get; set; }
|
||||
public string[] ModuleNames { get; set; }
|
||||
public int Version { get; set; }
|
||||
public long Timestamp { get; set; }
|
||||
}
|
||||
|
||||
public class LoadFailedEventArgs
|
||||
{
|
||||
public int SlotIndex { get; set; }
|
||||
public string ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public class MigrationCompletedEventArgs
|
||||
{
|
||||
public int SlotIndex { get; set; }
|
||||
public int FromVersion { get; set; }
|
||||
public int ToVersion { get; set; }
|
||||
}
|
||||
|
||||
public class SlotDeletedEventArgs
|
||||
{
|
||||
public int SlotIndex { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7cb5188d2a984a1b8f6a701789b8d0f1
|
||||
timeCreated: 1773047394
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "ShrinkDataSaver.Runtime",
|
||||
"rootNamespace": "ShrinkDataSaver",
|
||||
"references": [
|
||||
"UniTask",
|
||||
"Newtonsoft.Json"
|
||||
],
|
||||
"optionalUnityReferences": [],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5b60ff3063fc45e39b61d1df0c2a6339
|
||||
timeCreated: 1773047537
|
||||
@@ -0,0 +1,44 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace ShrinkDataSaver
|
||||
{
|
||||
/// <summary>
|
||||
/// 兼容旧用法:仍可把该组件挂到首场景中,
|
||||
/// 但真正初始化已下沉到 ShrinkDataSaverRuntime,便于宿主层统一接管。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-2000)]
|
||||
public class ShrinkDataSaverBootstrap : MonoBehaviour
|
||||
{
|
||||
[Header("Override (留空使用 ScriptableObject 设置)")] [SerializeField]
|
||||
private ShrinkDataSaverSettings settingsOverride;
|
||||
|
||||
[Header("Save Version")] [SerializeField]
|
||||
private int currentSaveVersion = 1;
|
||||
|
||||
private static bool _initialized;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (ShrinkDataSaverRuntime.IsInitialized)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_initialized)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
ShrinkDataSaverRuntime.Initialize(new ShrinkDataSaverRuntimeConfig
|
||||
{
|
||||
SettingsOverride = settingsOverride,
|
||||
CurrentSaveVersion = currentSaveVersion,
|
||||
DontDestroyOnLoadDriver = true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 30e24d3823c349c99bd78fa0992be0e7
|
||||
timeCreated: 1773047454
|
||||
@@ -0,0 +1,98 @@
|
||||
#nullable enable
|
||||
|
||||
using System.IO;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ShrinkDataSaver
|
||||
{
|
||||
public sealed class ShrinkDataSaverRuntimeConfig
|
||||
{
|
||||
public ShrinkDataSaverSettings? SettingsOverride { get; set; }
|
||||
public int CurrentSaveVersion { get; set; } = 1;
|
||||
public bool DontDestroyOnLoadDriver { get; set; } = true;
|
||||
}
|
||||
|
||||
public static class ShrinkDataSaverRuntime
|
||||
{
|
||||
public static bool IsInitialized { get; private set; }
|
||||
|
||||
public static void Initialize(ShrinkDataSaverRuntimeConfig? config = null)
|
||||
{
|
||||
if (IsInitialized)
|
||||
return;
|
||||
|
||||
config ??= new ShrinkDataSaverRuntimeConfig();
|
||||
var cfg = config.SettingsOverride ?? ShrinkDataSaverSettings.Instance;
|
||||
|
||||
if (config.SettingsOverride)
|
||||
ShrinkDataSaverSettings.Instance = config.SettingsOverride;
|
||||
|
||||
if (cfg == null)
|
||||
throw new System.InvalidOperationException("ShrinkDataSaverSettings instance is not available.");
|
||||
var rootPath = string.IsNullOrEmpty(cfg.customSavePath)
|
||||
? Application.persistentDataPath
|
||||
: cfg.customSavePath;
|
||||
|
||||
var savesDir = Path.Combine(rootPath, "saves");
|
||||
var settingsPath = Path.Combine(rootPath, cfg.settingsFileName);
|
||||
var storage = new LocalStorageProvider(rootPath);
|
||||
|
||||
ShrinkSettings.Initialize(storage, settingsPath);
|
||||
ShrinkSave.Initialize(storage, savesDir, cfg.saveFileExtension, config.CurrentSaveVersion);
|
||||
ShrinkSettings.LoadAsync().Forget();
|
||||
ShrinkDataSaverLifecycleDriver.EnsureCreated(config.DontDestroyOnLoadDriver);
|
||||
IsInitialized = true;
|
||||
|
||||
Debug.Log($"[ShrinkDataSaver] Runtime initialized. Root: {rootPath}");
|
||||
}
|
||||
|
||||
internal static void ResetForTesting()
|
||||
{
|
||||
IsInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
[DefaultExecutionOrder(-1999)]
|
||||
internal sealed class ShrinkDataSaverLifecycleDriver : MonoBehaviour
|
||||
{
|
||||
private static ShrinkDataSaverLifecycleDriver? _instance;
|
||||
private float _autoSaveTimer;
|
||||
|
||||
public static void EnsureCreated(bool dontDestroyOnLoad)
|
||||
{
|
||||
if (_instance != null)
|
||||
return;
|
||||
|
||||
var go = new GameObject("ShrinkDataSaverRuntimeDriver");
|
||||
_instance = go.AddComponent<ShrinkDataSaverLifecycleDriver>();
|
||||
if (dontDestroyOnLoad)
|
||||
DontDestroyOnLoad(go);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
var interval = ShrinkSave.GetMinAutoSaveInterval();
|
||||
if (interval <= 0f || ShrinkSave.LoadedSlot < 0)
|
||||
return;
|
||||
|
||||
_autoSaveTimer += Time.unscaledDeltaTime;
|
||||
if (_autoSaveTimer < interval)
|
||||
return;
|
||||
|
||||
_autoSaveTimer = 0f;
|
||||
ShrinkSave.SaveSlotAsync(ShrinkSave.LoadedSlot).Forget();
|
||||
}
|
||||
|
||||
private async void OnApplicationQuit()
|
||||
{
|
||||
await ShrinkSettings.SaveAsync();
|
||||
}
|
||||
|
||||
private async void OnApplicationPause(bool paused)
|
||||
{
|
||||
if (paused)
|
||||
await ShrinkSettings.SaveAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d9b14a9eecf6eb746b24241eac07c939
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,61 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace ShrinkDataSaver
|
||||
{
|
||||
[CreateAssetMenu(fileName = "ShrinkDataSaverSettings", menuName = "ShrinkDataSaver/Settings")]
|
||||
public class ShrinkDataSaverSettings : ScriptableObject
|
||||
{
|
||||
private static ShrinkDataSaverSettings _instance;
|
||||
|
||||
public static ShrinkDataSaverSettings Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance) return _instance;
|
||||
|
||||
_instance = Resources.Load<ShrinkDataSaverSettings>("ShrinkDataSaverSettings");
|
||||
|
||||
#if UNITY_EDITOR
|
||||
if (!_instance)
|
||||
{
|
||||
var guids = UnityEditor.AssetDatabase.FindAssets("t:ShrinkDataSaverSettings");
|
||||
if (guids.Length > 0)
|
||||
{
|
||||
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[0]);
|
||||
_instance = UnityEditor.AssetDatabase.LoadAssetAtPath<ShrinkDataSaverSettings>(path);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!_instance)
|
||||
{
|
||||
_instance = CreateInstance<ShrinkDataSaverSettings>();
|
||||
Debug.LogWarning(
|
||||
"[ShrinkDataSaver] 未找到 ShrinkDataSaverSettings 配置文件!当前使用默认配置。\n" +
|
||||
"请通过菜单 Assets → Create → ShrinkDataSaver → Settings 创建配置文件,\n" +
|
||||
"可放置在项目任意目录下(编辑器会自动搜索),或在 ShrinkDataSaverBootstrap 组件上手动指定。");
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
internal set => _instance = value;
|
||||
}
|
||||
|
||||
[Header("Storage")]
|
||||
public string customSavePath = "";
|
||||
|
||||
[Header("Settings File")]
|
||||
public string settingsFileName = "settings.json";
|
||||
public bool syncSettingsToCloud; //todo
|
||||
|
||||
[Header("Save Slots")]
|
||||
public int maxSlots;
|
||||
public string saveFileExtension = ".sav";
|
||||
|
||||
[Header("Screenshot")]
|
||||
public int screenshotMaxWidth = 256;
|
||||
|
||||
[Header("Settings Debounce")]
|
||||
public float settingsWriteDebounceSeconds = 0.3f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5df22787ec2544b9abbb40cf28536c7e
|
||||
timeCreated: 1773047342
|
||||
@@ -0,0 +1,673 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ShrinkDataSaver
|
||||
{
|
||||
public static class ShrinkSave
|
||||
{
|
||||
private const string BackupPrimarySuffix = ".bak1";
|
||||
private const string BackupSecondarySuffix = ".bak2";
|
||||
private const string TempWriteSuffix = ".tmp";
|
||||
private const string RecentSlotIndexSettingKey = "ShrinkDataSaver.RecentSlotIndex";
|
||||
|
||||
private static IStorageProvider _storage;
|
||||
private static string _savesDir;
|
||||
private static string _fileExt;
|
||||
private static int _currentSaveVersion = 1;
|
||||
|
||||
private static readonly Dictionary<string, ISaveModule> _modules = new();
|
||||
private static readonly Dictionary<string, ModuleConfig> _moduleConfigs = new();
|
||||
|
||||
private static int _loadedSlot = -1;
|
||||
private static float _sessionStart;
|
||||
private static float _storedPlaytime;
|
||||
|
||||
private static Dictionary<string, JToken> _loadedModuleData;
|
||||
|
||||
public static event Action<SaveStartedEventArgs> OnSaveStarted;
|
||||
public static event Action<SaveCompletedEventArgs> OnSaveCompleted;
|
||||
public static event Action<SaveFailedEventArgs> OnSaveFailed;
|
||||
public static event Action<LoadStartedEventArgs> OnLoadStarted;
|
||||
public static event Action<LoadCompletedEventArgs> OnLoadCompleted;
|
||||
public static event Action<LoadFailedEventArgs> OnLoadFailed;
|
||||
public static event Action<MigrationCompletedEventArgs> OnMigrationCompleted;
|
||||
public static event Action<SlotDeletedEventArgs> OnDeleteCompleted;
|
||||
|
||||
public static int LoadedSlot => _loadedSlot;
|
||||
|
||||
public static int GetRecentSlotIndex() => ShrinkSettings.Get(RecentSlotIndexSettingKey, -1);
|
||||
|
||||
internal static void Initialize(IStorageProvider storage, string savesDir, string fileExt, int saveVersion)
|
||||
{
|
||||
_storage = storage;
|
||||
_savesDir = savesDir;
|
||||
_fileExt = fileExt;
|
||||
_currentSaveVersion = saveVersion;
|
||||
}
|
||||
|
||||
public static void SetCurrentSaveVersion(int version) => _currentSaveVersion = version;
|
||||
|
||||
public static void RegisterModule(ISaveModule module, ModuleConfig config = null)
|
||||
{
|
||||
_modules[module.Key] = module;
|
||||
_moduleConfigs[module.Key] = config ?? new ModuleConfig();
|
||||
}
|
||||
|
||||
public static void RegisterModule<T>(string key, Func<T> serialize, Action<T> deserialize, ModuleConfig config = null)
|
||||
{
|
||||
_modules[key] = new LambdaSaveModule<T>(key, serialize, deserialize);
|
||||
_moduleConfigs[key] = config ?? new ModuleConfig();
|
||||
}
|
||||
|
||||
public static void UnregisterModule(string key)
|
||||
{
|
||||
_modules.Remove(key);
|
||||
_moduleConfigs.Remove(key);
|
||||
}
|
||||
|
||||
public static ModuleConfig GetModuleConfig(string key)
|
||||
=> _moduleConfigs.TryGetValue(key, out var cfg) ? cfg : null;
|
||||
|
||||
public static IReadOnlyCollection<string> GetRegisteredModuleNames() => _modules.Keys;
|
||||
|
||||
public static T QueryModule<T>(string moduleName)
|
||||
{
|
||||
if (!_modules.TryGetValue(moduleName, out var module))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var raw = module.SerializeRaw();
|
||||
if (raw is T typed)
|
||||
{
|
||||
return typed;
|
||||
}
|
||||
|
||||
return JToken.FromObject(raw).ToObject<T>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasKey(string moduleName, string key)
|
||||
{
|
||||
if (_loadedModuleData == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_loadedModuleData.TryGetValue(moduleName, out var token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return token is JObject obj && obj.ContainsKey(key);
|
||||
}
|
||||
|
||||
public static bool HasModule(string moduleName) => _modules.ContainsKey(moduleName);
|
||||
|
||||
public static async UniTask<SaveMeta[]> GetAllMetaAsync(CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<SaveMeta>();
|
||||
var files = await _storage.ListAsync(_savesDir, ct);
|
||||
var discoveredSlots = new HashSet<int>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (TryExtractSlotIndex(file, out var slotIndex))
|
||||
{
|
||||
discoveredSlots.Add(slotIndex);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var slotIndex in discoveredSlots)
|
||||
{
|
||||
try
|
||||
{
|
||||
var readResult = await ReadPacketWithFallbackAsync(slotIndex, ct);
|
||||
if (readResult.packet?.Meta != null)
|
||||
{
|
||||
results.Add(readResult.packet.Meta);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[ShrinkDataSaver] 读取元数据失败 slot_{slotIndex}: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
results.Sort((a, b) => a.SlotIndex.CompareTo(b.SlotIndex));
|
||||
return results.ToArray();
|
||||
}
|
||||
|
||||
public async static UniTask<bool> SlotExistsAsync(int slotIndex, CancellationToken ct = default)
|
||||
=> await _storage.ExistsAsync(SlotPath(slotIndex), ct);
|
||||
|
||||
public static async UniTask<SaveMeta> GetMetaAsync(int slotIndex, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var readResult = await ReadPacketWithFallbackAsync(slotIndex, ct);
|
||||
return readResult.packet?.Meta;
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static async UniTask<int> GetRecommendedContinueSlotAsync(CancellationToken ct = default)
|
||||
{
|
||||
var metas = await GetAllMetaAsync(ct);
|
||||
var resolvedSlot = ResolveRecommendedContinueSlotIndex(GetRecentSlotIndex(), metas);
|
||||
if (resolvedSlot != GetRecentSlotIndex())
|
||||
{
|
||||
await PersistRecentSlotIndexAsync(resolvedSlot, ct);
|
||||
}
|
||||
|
||||
return resolvedSlot;
|
||||
}
|
||||
|
||||
public static async UniTask SaveSlotAsync(int slotIndex, SaveOptions options = null, CancellationToken ct = default)
|
||||
{
|
||||
options ??= new SaveOptions();
|
||||
ValidateSlotIndex(slotIndex);
|
||||
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
OnSaveStarted?.Invoke(new SaveStartedEventArgs { SlotIndex = slotIndex, Timestamp = timestamp });
|
||||
|
||||
try
|
||||
{
|
||||
var packet = new SavePacket
|
||||
{
|
||||
Meta = new SaveMeta
|
||||
{
|
||||
SlotIndex = slotIndex,
|
||||
SlotName = options.SlotName,
|
||||
SaveVersion = _currentSaveVersion,
|
||||
LastModified = timestamp,
|
||||
PlaytimeSeconds = GetCurrentPlaytime(),
|
||||
IsEncrypted = options.Encrypt
|
||||
}
|
||||
};
|
||||
|
||||
if (options.Screenshot || options.CaptureScreenshot)
|
||||
{
|
||||
packet.Meta.ScreenshotBase64 = await CaptureScreenshotAsync(options, ct);
|
||||
}
|
||||
|
||||
var modulesDict = new Dictionary<string, JToken>();
|
||||
var moduleNames = new List<string>();
|
||||
|
||||
foreach (var module in _modules.Values)
|
||||
{
|
||||
try
|
||||
{
|
||||
modulesDict[module.Key] = JToken.FromObject(module.SerializeRaw());
|
||||
moduleNames.Add(module.Key);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var isCritical = _moduleConfigs.TryGetValue(module.Key, out var cfg) && cfg.CriticalModule;
|
||||
if (isCritical)
|
||||
{
|
||||
throw new InvalidOperationException($"关键模块 '{module.Key}' 序列化失败: {e.Message}", e);
|
||||
}
|
||||
|
||||
Debug.LogError($"[ShrinkDataSaver] 模块 '{module.Key}' 序列化失败(已跳过): {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.Encrypt)
|
||||
{
|
||||
if (string.IsNullOrEmpty(options.EncryptionKey))
|
||||
{
|
||||
throw new ArgumentException("EncryptionKey missing.");
|
||||
}
|
||||
|
||||
var modulesBytes = DataSerializer.Serialize(modulesDict);
|
||||
var encryptedBytes = SaveEncryptor.Encrypt(modulesBytes, options.EncryptionKey);
|
||||
packet.EncryptedModules = Convert.ToBase64String(encryptedBytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
packet.Modules = modulesDict;
|
||||
}
|
||||
|
||||
var finalBytes = DataSerializer.Serialize(packet);
|
||||
await _storage.WriteAsync(SlotPath(slotIndex), finalBytes, ct);
|
||||
|
||||
OnSaveCompleted?.Invoke(new SaveCompletedEventArgs
|
||||
{
|
||||
SlotIndex = slotIndex,
|
||||
ModuleNames = moduleNames.ToArray(),
|
||||
Timestamp = timestamp
|
||||
});
|
||||
Debug.Log($"[ShrinkDataSaver] 槽位 {slotIndex} 已保存。({moduleNames.Count} 个模块)");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
OnSaveFailed?.Invoke(new SaveFailedEventArgs
|
||||
{
|
||||
SlotIndex = slotIndex,
|
||||
ErrorMessage = e.Message
|
||||
});
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static async UniTask LoadSlotAsync(int slotIndex, string decryptionKey = null, CancellationToken ct = default)
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
OnLoadStarted?.Invoke(new LoadStartedEventArgs { SlotIndex = slotIndex, Timestamp = timestamp });
|
||||
|
||||
try
|
||||
{
|
||||
var loadResult = await ReadLoadResultWithFallbackAsync(slotIndex, decryptionKey, ct);
|
||||
var packet = loadResult.packet;
|
||||
var loadedModules = loadResult.loadedModules;
|
||||
|
||||
_loadedModuleData = new Dictionary<string, JToken>(loadedModules);
|
||||
|
||||
var moduleNames = new List<string>();
|
||||
foreach (var module in _modules.Values)
|
||||
{
|
||||
if (!loadedModules.TryGetValue(module.Key, out var token))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
module.DeserializeRaw(token);
|
||||
moduleNames.Add(module.Key);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[ShrinkDataSaver] 反序列化模块 '{module.Key}' 失败: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_loadedSlot = slotIndex;
|
||||
_storedPlaytime = packet.Meta.PlaytimeSeconds;
|
||||
_sessionStart = Time.realtimeSinceStartup;
|
||||
await PersistRecentSlotIndexAsync(slotIndex, ct);
|
||||
|
||||
OnLoadCompleted?.Invoke(new LoadCompletedEventArgs
|
||||
{
|
||||
SlotIndex = slotIndex,
|
||||
ModuleNames = moduleNames.ToArray(),
|
||||
Version = packet.Meta.SaveVersion,
|
||||
Timestamp = timestamp
|
||||
});
|
||||
Debug.Log($"[ShrinkDataSaver] 槽位 {slotIndex} 已加载。(v{packet.Meta.SaveVersion}, {moduleNames.Count} 个模块)");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
OnLoadFailed?.Invoke(new LoadFailedEventArgs
|
||||
{
|
||||
SlotIndex = slotIndex,
|
||||
ErrorMessage = e.Message
|
||||
});
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static async UniTask DeleteSlotAsync(int slotIndex, CancellationToken ct = default)
|
||||
{
|
||||
foreach (var candidatePath in EnumerateAllSlotPaths(slotIndex))
|
||||
{
|
||||
await _storage.DeleteAsync(candidatePath, ct);
|
||||
}
|
||||
|
||||
if (_loadedSlot == slotIndex)
|
||||
{
|
||||
_loadedSlot = -1;
|
||||
_storedPlaytime = 0f;
|
||||
_loadedModuleData = null;
|
||||
}
|
||||
|
||||
if (GetRecentSlotIndex() == slotIndex)
|
||||
{
|
||||
var metas = await GetAllMetaAsync(ct);
|
||||
var fallbackSlot = ResolveRecommendedContinueSlotIndex(-1, metas);
|
||||
await PersistRecentSlotIndexAsync(fallbackSlot, ct);
|
||||
}
|
||||
|
||||
OnDeleteCompleted?.Invoke(new SlotDeletedEventArgs { SlotIndex = slotIndex });
|
||||
Debug.Log($"[ShrinkDataSaver] 槽位 {slotIndex} 已删除。");
|
||||
}
|
||||
|
||||
public static float GetMinAutoSaveInterval()
|
||||
{
|
||||
var min = float.MaxValue;
|
||||
var hasAny = false;
|
||||
foreach (var cfg in _moduleConfigs.Values)
|
||||
{
|
||||
if (cfg.AutoSaveIntervalSeconds > 0f)
|
||||
{
|
||||
min = Mathf.Min(min, cfg.AutoSaveIntervalSeconds);
|
||||
hasAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasAny ? min : 0f;
|
||||
}
|
||||
|
||||
internal static void ResetForTesting()
|
||||
{
|
||||
_modules.Clear();
|
||||
_moduleConfigs.Clear();
|
||||
_loadedSlot = -1;
|
||||
_sessionStart = 0f;
|
||||
_storedPlaytime = 0f;
|
||||
_loadedModuleData = null;
|
||||
OnSaveStarted = null;
|
||||
OnSaveCompleted = null;
|
||||
OnSaveFailed = null;
|
||||
OnLoadStarted = null;
|
||||
OnLoadCompleted = null;
|
||||
OnLoadFailed = null;
|
||||
OnMigrationCompleted = null;
|
||||
OnDeleteCompleted = null;
|
||||
}
|
||||
|
||||
private static string SlotPath(int slotIndex) => Path.Combine(_savesDir, $"slot_{slotIndex}{_fileExt}");
|
||||
|
||||
private static IEnumerable<string> EnumerateAllSlotPaths(int slotIndex)
|
||||
{
|
||||
var primaryPath = SlotPath(slotIndex);
|
||||
yield return primaryPath;
|
||||
yield return primaryPath + BackupPrimarySuffix;
|
||||
yield return primaryPath + BackupSecondarySuffix;
|
||||
yield return primaryPath + TempWriteSuffix;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateReadCandidatePaths(string primaryPath)
|
||||
{
|
||||
yield return primaryPath;
|
||||
yield return primaryPath + BackupPrimarySuffix;
|
||||
yield return primaryPath + BackupSecondarySuffix;
|
||||
}
|
||||
|
||||
private static void ValidateSlotIndex(int slotIndex)
|
||||
{
|
||||
if (slotIndex < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(slotIndex), "槽位索引不能为负数。");
|
||||
}
|
||||
|
||||
var max = ShrinkDataSaverSettings.Instance.maxSlots;
|
||||
if (max > 0 && slotIndex >= max)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(slotIndex), $"超出最大槽位数 ({max})。");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryExtractSlotIndex(string path, out int slotIndex)
|
||||
{
|
||||
slotIndex = -1;
|
||||
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (string.IsNullOrWhiteSpace(fileName) || fileName.EndsWith(TempWriteSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedName = fileName;
|
||||
if (normalizedName.EndsWith(BackupSecondarySuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalizedName = normalizedName[..^BackupSecondarySuffix.Length];
|
||||
}
|
||||
else if (normalizedName.EndsWith(BackupPrimarySuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalizedName = normalizedName[..^BackupPrimarySuffix.Length];
|
||||
}
|
||||
|
||||
if (!normalizedName.StartsWith("slot_", StringComparison.OrdinalIgnoreCase) ||
|
||||
!normalizedName.EndsWith(_fileExt, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var indexText = normalizedName.Substring(5, normalizedName.Length - 5 - _fileExt.Length);
|
||||
return int.TryParse(indexText, out slotIndex);
|
||||
}
|
||||
|
||||
private static async UniTask PersistRecentSlotIndexAsync(int slotIndex, CancellationToken ct)
|
||||
{
|
||||
if (GetRecentSlotIndex() == slotIndex)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ShrinkSettings.Set(RecentSlotIndexSettingKey, slotIndex);
|
||||
await ShrinkSettings.SaveAsync(ct);
|
||||
}
|
||||
|
||||
private static int ResolveRecommendedContinueSlotIndex(int rememberedSlotIndex, IReadOnlyList<SaveMeta> metas)
|
||||
{
|
||||
if (metas == null || metas.Count == 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (rememberedSlotIndex >= 0 && ContainsSlot(metas, rememberedSlotIndex))
|
||||
{
|
||||
return rememberedSlotIndex;
|
||||
}
|
||||
|
||||
return PickMostRecentSlot(metas);
|
||||
}
|
||||
|
||||
private static bool ContainsSlot(IReadOnlyList<SaveMeta> metas, int slotIndex)
|
||||
{
|
||||
for (var i = 0; i < metas.Count; i++)
|
||||
{
|
||||
if (metas[i] != null && metas[i].SlotIndex == slotIndex)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int PickMostRecentSlot(IReadOnlyList<SaveMeta> metas)
|
||||
{
|
||||
SaveMeta best = null;
|
||||
for (var i = 0; i < metas.Count; i++)
|
||||
{
|
||||
var meta = metas[i];
|
||||
if (meta == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (best == null ||
|
||||
meta.LastModified > best.LastModified ||
|
||||
(meta.LastModified == best.LastModified && meta.SlotIndex < best.SlotIndex))
|
||||
{
|
||||
best = meta;
|
||||
}
|
||||
}
|
||||
|
||||
return best?.SlotIndex ?? -1;
|
||||
}
|
||||
|
||||
private static async UniTask<(SavePacket packet, byte[] rawBytes, string sourcePath)> ReadPacketWithFallbackAsync(
|
||||
int slotIndex,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var primaryPath = SlotPath(slotIndex);
|
||||
Exception lastError = null;
|
||||
|
||||
foreach (var candidatePath in EnumerateReadCandidatePaths(primaryPath))
|
||||
{
|
||||
if (!await _storage.ExistsAsync(candidatePath, ct))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = await _storage.ReadAsync(candidatePath, ct);
|
||||
var packet = DataSerializer.Deserialize<SavePacket>(bytes);
|
||||
if (packet?.Meta == null)
|
||||
{
|
||||
throw new InvalidDataException($"槽位 {slotIndex} 的存档包缺少 Meta。");
|
||||
}
|
||||
|
||||
if (!string.Equals(candidatePath, primaryPath, StringComparison.Ordinal))
|
||||
{
|
||||
Debug.LogWarning($"[ShrinkDataSaver] 槽位 {slotIndex} 主文件损坏或缺失,已从备份恢复:{candidatePath}");
|
||||
await _storage.WriteAsync(primaryPath, bytes, ct);
|
||||
}
|
||||
|
||||
return (packet, bytes, candidatePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex;
|
||||
Debug.LogWarning($"[ShrinkDataSaver] 读取槽位副本失败:{candidatePath} / {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError != null)
|
||||
{
|
||||
throw new InvalidDataException($"槽位 {slotIndex} 的主文件及备份均不可读取。", lastError);
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"槽位 {slotIndex} 不存在。");
|
||||
}
|
||||
|
||||
private static async UniTask<(SavePacket packet, Dictionary<string, JToken> loadedModules)> ReadLoadResultWithFallbackAsync(
|
||||
int slotIndex,
|
||||
string decryptionKey,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var primaryPath = SlotPath(slotIndex);
|
||||
Exception lastError = null;
|
||||
|
||||
foreach (var candidatePath in EnumerateReadCandidatePaths(primaryPath))
|
||||
{
|
||||
if (!await _storage.ExistsAsync(candidatePath, ct))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = await _storage.ReadAsync(candidatePath, ct);
|
||||
var packet = DataSerializer.Deserialize<SavePacket>(bytes);
|
||||
if (packet?.Meta == null)
|
||||
{
|
||||
throw new InvalidDataException($"反序列化槽位 {slotIndex} 失败。");
|
||||
}
|
||||
|
||||
Dictionary<string, JToken> loadedModules;
|
||||
if (packet.Meta.IsEncrypted)
|
||||
{
|
||||
if (string.IsNullOrEmpty(decryptionKey))
|
||||
{
|
||||
throw new ArgumentException("需要解密密钥。");
|
||||
}
|
||||
|
||||
var encryptedBytes = Convert.FromBase64String(packet.EncryptedModules);
|
||||
var decryptedBytes = SaveEncryptor.Decrypt(encryptedBytes, decryptionKey);
|
||||
loadedModules = DataSerializer.Deserialize<Dictionary<string, JToken>>(decryptedBytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedModules = packet.Modules ?? new Dictionary<string, JToken>();
|
||||
}
|
||||
|
||||
if (packet.Meta.SaveVersion < _currentSaveVersion)
|
||||
{
|
||||
var fromVersion = packet.Meta.SaveVersion;
|
||||
var modulesObj = JObject.FromObject(loadedModules);
|
||||
(modulesObj, packet.Meta.SaveVersion) = MigrationChain.Apply(modulesObj, fromVersion, _currentSaveVersion);
|
||||
loadedModules = new Dictionary<string, JToken>();
|
||||
foreach (var key in modulesObj.Properties())
|
||||
{
|
||||
loadedModules[key.Name] = modulesObj[key.Name];
|
||||
}
|
||||
|
||||
OnMigrationCompleted?.Invoke(new MigrationCompletedEventArgs
|
||||
{
|
||||
SlotIndex = slotIndex,
|
||||
FromVersion = fromVersion,
|
||||
ToVersion = packet.Meta.SaveVersion
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.Equals(candidatePath, primaryPath, StringComparison.Ordinal))
|
||||
{
|
||||
Debug.LogWarning($"[ShrinkDataSaver] 槽位 {slotIndex} 主文件损坏或缺失,已从备份恢复:{candidatePath}");
|
||||
await _storage.WriteAsync(primaryPath, bytes, ct);
|
||||
}
|
||||
|
||||
return (packet, loadedModules);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex;
|
||||
Debug.LogWarning($"[ShrinkDataSaver] 加载槽位副本失败:{candidatePath} / {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError != null)
|
||||
{
|
||||
throw new InvalidDataException($"槽位 {slotIndex} 的主文件及备份均不可加载。", lastError);
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"槽位 {slotIndex} 不存在。");
|
||||
}
|
||||
|
||||
private static float GetCurrentPlaytime() => _loadedSlot < 0 ? 0f : _storedPlaytime + (Time.realtimeSinceStartup - _sessionStart);
|
||||
|
||||
private static async UniTask<string> CaptureScreenshotAsync(SaveOptions options, CancellationToken ct)
|
||||
{
|
||||
var tex = options.Screenshot;
|
||||
if (!tex && options.CaptureScreenshot)
|
||||
{
|
||||
await UniTask.Yield(PlayerLoopTiming.PostLateUpdate, ct);
|
||||
tex = ScreenCapture.CaptureScreenshotAsTexture();
|
||||
}
|
||||
|
||||
if (!tex)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var settings = ShrinkDataSaverSettings.Instance;
|
||||
var maxW = settings.screenshotMaxWidth;
|
||||
if (tex.width > maxW)
|
||||
{
|
||||
var scale = (float)maxW / tex.width;
|
||||
var resized = new Texture2D(maxW, Mathf.RoundToInt(tex.height * scale));
|
||||
for (var y = 0; y < resized.height; y++)
|
||||
{
|
||||
for (var x = 0; x < resized.width; x++)
|
||||
{
|
||||
resized.SetPixel(x, y, tex.GetPixelBilinear((float)x / resized.width, (float)y / resized.height));
|
||||
}
|
||||
}
|
||||
|
||||
resized.Apply();
|
||||
tex = resized;
|
||||
}
|
||||
|
||||
return Convert.ToBase64String(tex.EncodeToJPG(75));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d41b28112039451ca4fca14c901a91a9
|
||||
timeCreated: 1773047436
|
||||
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ShrinkDataSaver
|
||||
{
|
||||
[Serializable]
|
||||
internal class SettingsData
|
||||
{
|
||||
public Dictionary<string, JToken> Values = new();
|
||||
}
|
||||
|
||||
public static class ShrinkSettings
|
||||
{
|
||||
private const string BackupPrimarySuffix = ".bak1";
|
||||
private const string BackupSecondarySuffix = ".bak2";
|
||||
|
||||
private static SettingsData _data = new();
|
||||
private static IStorageProvider _storage;
|
||||
private static string _filePath;
|
||||
|
||||
private static CancellationTokenSource _debounceCts;
|
||||
private static readonly Dictionary<string, List<Action<object>>> _watchers = new();
|
||||
|
||||
public static event Action<string, object> OnChanged;
|
||||
|
||||
internal static void Initialize(IStorageProvider storage, string filePath)
|
||||
{
|
||||
_storage = storage; _filePath = filePath;
|
||||
}
|
||||
|
||||
public static void Set<T>(string key, T value)
|
||||
{
|
||||
_data.Values[key] = JToken.FromObject(value);
|
||||
FireChanged(key, value);
|
||||
ScheduleWrite().Forget();
|
||||
}
|
||||
|
||||
public static T Get<T>(string key, T defaultValue = default)
|
||||
{
|
||||
if (_data.Values.TryGetValue(key, out var token))
|
||||
{
|
||||
try { return token.ToObject<T>(); } catch { /* ignore */ }
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public static bool Has(string key) => _data.Values.ContainsKey(key);
|
||||
|
||||
public static IReadOnlyDictionary<string, JToken> GetAllRaw() => _data.Values;
|
||||
|
||||
public static void Remove(string key)
|
||||
{
|
||||
if (_data.Values.Remove(key))
|
||||
{
|
||||
FireChanged(key, null);
|
||||
ScheduleWrite().Forget();
|
||||
}
|
||||
}
|
||||
|
||||
public static void Watch<T>(string key, Action<T> callback)
|
||||
{
|
||||
if (!_watchers.ContainsKey(key)) _watchers[key] = new List<Action<object>>();
|
||||
_watchers[key].Add(raw => callback((T)Convert.ChangeType(raw, typeof(T))));
|
||||
}
|
||||
|
||||
public static void Unwatch(string key, Action<object> callback)
|
||||
{
|
||||
if (_watchers.TryGetValue(key, out var list)) list.Remove(callback);
|
||||
}
|
||||
|
||||
public static async UniTask SaveAsync(CancellationToken ct = default)
|
||||
{
|
||||
_debounceCts?.Cancel();
|
||||
if (_storage == null || string.IsNullOrWhiteSpace(_filePath))
|
||||
{
|
||||
// 未初始化(独立宿主/单测环境)时跳过持久化,与 LoadAsync 的守卫保持一致
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = DataSerializer.Serialize(_data);
|
||||
await _storage.WriteAsync(_filePath, bytes, ct);
|
||||
}
|
||||
|
||||
public static async UniTask LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_storage == null || string.IsNullOrWhiteSpace(_filePath))
|
||||
{
|
||||
_data = new SettingsData();
|
||||
return;
|
||||
}
|
||||
|
||||
var loadedData = await TryLoadWithFallbackAsync(ct);
|
||||
_data = loadedData ?? new SettingsData();
|
||||
}
|
||||
|
||||
internal static void ResetForTesting()
|
||||
{
|
||||
_data = new SettingsData();
|
||||
_debounceCts?.Cancel();
|
||||
_debounceCts = null;
|
||||
_watchers.Clear();
|
||||
OnChanged = null;
|
||||
}
|
||||
|
||||
private static void FireChanged(string key, object value)
|
||||
{
|
||||
OnChanged?.Invoke(key, value);
|
||||
|
||||
if (_watchers.TryGetValue(key, out var list))
|
||||
foreach (var cb in list)
|
||||
try { cb(value); } catch (Exception e) { Debug.LogException(e); }
|
||||
}
|
||||
|
||||
private static async UniTaskVoid ScheduleWrite()
|
||||
{
|
||||
_debounceCts?.Cancel();
|
||||
_debounceCts = new CancellationTokenSource();
|
||||
var token = _debounceCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
var delay = (int)(ShrinkDataSaverSettings.Instance.settingsWriteDebounceSeconds * 1000);
|
||||
await UniTask.Delay(delay, cancellationToken: token);
|
||||
await SaveAsync(token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception e) { Debug.LogException(e); }
|
||||
}
|
||||
|
||||
private static async UniTask<SettingsData> TryLoadWithFallbackAsync(CancellationToken ct)
|
||||
{
|
||||
var primaryPath = _filePath;
|
||||
Exception lastError = null;
|
||||
|
||||
foreach (var candidatePath in EnumerateCandidatePaths(primaryPath))
|
||||
{
|
||||
if (!await _storage.ExistsAsync(candidatePath, ct))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = await _storage.ReadAsync(candidatePath, ct);
|
||||
var loaded = DataSerializer.Deserialize<SettingsData>(bytes) ?? new SettingsData();
|
||||
if (!string.Equals(candidatePath, primaryPath, StringComparison.Ordinal))
|
||||
{
|
||||
Debug.LogWarning($"[ShrinkDataSaver] Settings 主文件损坏或缺失,已从备份恢复:{candidatePath}");
|
||||
await _storage.WriteAsync(primaryPath, bytes, ct);
|
||||
}
|
||||
|
||||
return loaded;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex;
|
||||
Debug.LogWarning($"[ShrinkDataSaver] 读取 Settings 副本失败:{candidatePath} / {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError != null)
|
||||
{
|
||||
Debug.LogWarning("[ShrinkDataSaver] 所有 Settings 副本均不可用,已回退为空设置。");
|
||||
}
|
||||
|
||||
return new SettingsData();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateCandidatePaths(string primaryPath)
|
||||
{
|
||||
yield return primaryPath;
|
||||
yield return primaryPath + BackupPrimarySuffix;
|
||||
yield return primaryPath + BackupSecondarySuffix;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ffdd053c674640a9987f34ddd93080d8
|
||||
timeCreated: 1773047370
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ce4b0a2f2d4f304aa1f93b4ceccbf92
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ShrinkDataSaver.Tests
|
||||
{
|
||||
[TestFixture]
|
||||
public class DataSerializerTests
|
||||
{
|
||||
[Serializable]
|
||||
private class SimpleData
|
||||
{
|
||||
public string Name = "test";
|
||||
public int Score = 42;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class NestedData
|
||||
{
|
||||
public string Id = "root";
|
||||
public SimpleData Child = new();
|
||||
public int[] Numbers = { 1, 2, 3 };
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Serialize_Deserialize_SimpleObject()
|
||||
{
|
||||
var original = new SimpleData { Name = "Alice", Score = 100 };
|
||||
var bytes = DataSerializer.Serialize(original);
|
||||
var restored = DataSerializer.Deserialize<SimpleData>(bytes);
|
||||
|
||||
Assert.AreEqual("Alice", restored.Name);
|
||||
Assert.AreEqual(100, restored.Score);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Serialize_Deserialize_NestedObject()
|
||||
{
|
||||
var original = new NestedData
|
||||
{
|
||||
Id = "parent",
|
||||
Child = new SimpleData { Name = "child", Score = 99 },
|
||||
Numbers = new[] { 10, 20, 30 }
|
||||
};
|
||||
|
||||
var bytes = DataSerializer.Serialize(original);
|
||||
var restored = DataSerializer.Deserialize<NestedData>(bytes);
|
||||
|
||||
Assert.AreEqual("parent", restored.Id);
|
||||
Assert.AreEqual("child", restored.Child.Name);
|
||||
Assert.AreEqual(99, restored.Child.Score);
|
||||
Assert.AreEqual(new[] { 10, 20, 30 }, restored.Numbers);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToJObject_FromJObject_RoundTrip()
|
||||
{
|
||||
var original = new SimpleData { Name = "Bob", Score = 77 };
|
||||
var bytes = DataSerializer.Serialize(original);
|
||||
var jObj = DataSerializer.ToJObject(bytes);
|
||||
|
||||
Assert.AreEqual("Bob", jObj["Name"].ToString());
|
||||
Assert.AreEqual(77, (int)jObj["Score"]);
|
||||
|
||||
var bytesBack = DataSerializer.FromJObject(jObj);
|
||||
var restored = DataSerializer.Deserialize<SimpleData>(bytesBack);
|
||||
Assert.AreEqual("Bob", restored.Name);
|
||||
Assert.AreEqual(77, restored.Score);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Serialize_NullFields_Ignored()
|
||||
{
|
||||
var data = new SimpleData { Name = null, Score = 5 };
|
||||
var bytes = DataSerializer.Serialize(data);
|
||||
var json = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
|
||||
Assert.IsFalse(json.Contains("Name"));
|
||||
Assert.IsTrue(json.Contains("Score"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 818cff0034bbd7d4aa0c64c46fc28343
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace ShrinkDataSaver.Tests
|
||||
{
|
||||
[TestFixture]
|
||||
public class MigrationChainTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => MigrationChain.Clear();
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => MigrationChain.Clear();
|
||||
|
||||
[Test]
|
||||
public void Register_ValidVersions_Succeeds()
|
||||
{
|
||||
Assert.DoesNotThrow(() =>
|
||||
MigrationChain.Register(1, 2, data => data));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Register_InvalidRange_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
MigrationChain.Register(2, 1, data => data));
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
MigrationChain.Register(1, 1, data => data));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Apply_SingleMigration()
|
||||
{
|
||||
MigrationChain.Register(1, 2, data =>
|
||||
{
|
||||
data["newField"] = "added";
|
||||
return data;
|
||||
});
|
||||
|
||||
var input = new JObject { ["existing"] = "value" };
|
||||
var (result, version) = MigrationChain.Apply(input, 1, 2);
|
||||
|
||||
Assert.AreEqual(2, version);
|
||||
Assert.AreEqual("value", result["existing"].ToString());
|
||||
Assert.AreEqual("added", result["newField"].ToString());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Apply_ChainMigration_1To3()
|
||||
{
|
||||
MigrationChain.Register(1, 2, data =>
|
||||
{
|
||||
data["coins"] = data["gold"];
|
||||
data.Remove("gold");
|
||||
return data;
|
||||
});
|
||||
|
||||
MigrationChain.Register(2, 3, data =>
|
||||
{
|
||||
data["version3Field"] = 42;
|
||||
return data;
|
||||
});
|
||||
|
||||
var input = new JObject { ["gold"] = 100 };
|
||||
var (result, version) = MigrationChain.Apply(input, 1, 3);
|
||||
|
||||
Assert.AreEqual(3, version);
|
||||
Assert.AreEqual(100, result["coins"].Value<int>());
|
||||
Assert.IsFalse(result.ContainsKey("gold"));
|
||||
Assert.AreEqual(42, result["version3Field"].Value<int>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Apply_MissingStep_StopsEarly()
|
||||
{
|
||||
MigrationChain.Register(1, 2, data => data);
|
||||
// 缺少 2→3 的迁移
|
||||
|
||||
var input = new JObject { ["data"] = "test" };
|
||||
var (result, version) = MigrationChain.Apply(input, 1, 3);
|
||||
|
||||
// 应该停在 v2,因为没有 2→3 的迁移
|
||||
Assert.AreEqual(2, version);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Apply_FailedMigration_Rollback()
|
||||
{
|
||||
MigrationChain.Register(1, 2, data =>
|
||||
{
|
||||
data["step1"] = "done";
|
||||
return data;
|
||||
});
|
||||
|
||||
MigrationChain.Register(2, 3, data =>
|
||||
{
|
||||
throw new Exception("Migration failed!");
|
||||
});
|
||||
|
||||
var input = new JObject { ["original"] = "data" };
|
||||
LogAssert.Expect(LogType.Error, "[ShrinkDataSaver] 迁移 v2 → v3 失败: Migration failed!,已回滚至 v1");
|
||||
var (result, version) = MigrationChain.Apply(input, 1, 3);
|
||||
|
||||
// 应该回滚到 v1 的备份数据
|
||||
Assert.AreEqual(1, version);
|
||||
Assert.AreEqual("data", result["original"].ToString());
|
||||
// 回滚意味着 step1 的修改不会存在
|
||||
Assert.IsFalse(result.ContainsKey("step1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Apply_NoMigrationNeeded_ReturnsSameData()
|
||||
{
|
||||
var input = new JObject { ["data"] = "unchanged" };
|
||||
var (result, version) = MigrationChain.Apply(input, 3, 3);
|
||||
|
||||
Assert.AreEqual(3, version);
|
||||
Assert.AreEqual("unchanged", result["data"].ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dba29246ec289aa4d9798abaef89a7bb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace ShrinkDataSaver.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// 内存模拟存储,用于单元测试,无需文件 I/O。
|
||||
/// </summary>
|
||||
public class MockStorageProvider : IStorageProvider
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _store = new();
|
||||
|
||||
public UniTask WriteAsync(string path, byte[] data, CancellationToken ct = default)
|
||||
{
|
||||
_store[NormalizePath(path)] = data;
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
|
||||
public UniTask<byte[]> ReadAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
var key = NormalizePath(path);
|
||||
if (!_store.TryGetValue(key, out var data))
|
||||
throw new FileNotFoundException($"MockStorage: {key}");
|
||||
return UniTask.FromResult(data);
|
||||
}
|
||||
|
||||
public UniTask<bool> ExistsAsync(string path, CancellationToken ct = default)
|
||||
=> UniTask.FromResult(_store.ContainsKey(NormalizePath(path)));
|
||||
|
||||
public UniTask DeleteAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
_store.Remove(NormalizePath(path));
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
|
||||
public UniTask<string[]> ListAsync(string prefix = "", CancellationToken ct = default)
|
||||
{
|
||||
var norm = NormalizePath(prefix);
|
||||
var results = _store.Keys
|
||||
.Where(k => string.IsNullOrEmpty(norm) || k.StartsWith(norm))
|
||||
.ToArray();
|
||||
return UniTask.FromResult(results);
|
||||
}
|
||||
|
||||
public void Clear() => _store.Clear();
|
||||
public int Count => _store.Count;
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
=> path.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b20797d49fc7bfc47a22c7be5bbd6cf2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ShrinkDataSaver.Tests
|
||||
{
|
||||
[TestFixture]
|
||||
public class SaveEncryptorTests
|
||||
{
|
||||
[Test]
|
||||
public void Encrypt_Decrypt_RoundTrip()
|
||||
{
|
||||
var original = Encoding.UTF8.GetBytes("Hello, ShrinkDataSaver!");
|
||||
var password = "TestPassword123";
|
||||
|
||||
var encrypted = SaveEncryptor.Encrypt(original, password);
|
||||
var decrypted = SaveEncryptor.Decrypt(encrypted, password);
|
||||
|
||||
Assert.AreEqual(original, decrypted);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Encrypt_Decrypt_LargeData()
|
||||
{
|
||||
var original = new byte[10000];
|
||||
new System.Random(42).NextBytes(original);
|
||||
var password = "LargeDataKey";
|
||||
|
||||
var encrypted = SaveEncryptor.Encrypt(original, password);
|
||||
var decrypted = SaveEncryptor.Decrypt(encrypted, password);
|
||||
|
||||
Assert.AreEqual(original, decrypted);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Decrypt_WrongPassword_Throws()
|
||||
{
|
||||
var original = Encoding.UTF8.GetBytes("secret data");
|
||||
var encrypted = SaveEncryptor.Encrypt(original, "correct");
|
||||
|
||||
Assert.Throws<CryptographicException>(() =>
|
||||
SaveEncryptor.Decrypt(encrypted, "wrong"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Encrypt_ProducesDifferentOutput_EachTime()
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes("same data");
|
||||
var enc1 = SaveEncryptor.Encrypt(data, "key");
|
||||
var enc2 = SaveEncryptor.Encrypt(data, "key");
|
||||
|
||||
// 因为随机 salt/IV,两次加密结果不同
|
||||
Assert.AreNotEqual(enc1, enc2);
|
||||
|
||||
// 但两次都能正确解密
|
||||
Assert.AreEqual(data, SaveEncryptor.Decrypt(enc1, "key"));
|
||||
Assert.AreEqual(data, SaveEncryptor.Decrypt(enc2, "key"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Encrypt_NullData_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
SaveEncryptor.Encrypt(null, "key"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Encrypt_EmptyPassword_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
SaveEncryptor.Encrypt(new byte[] { 1, 2, 3 }, ""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Decrypt_NullData_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
SaveEncryptor.Decrypt(null, "key"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Decrypt_DataTooShort_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
SaveEncryptor.Decrypt(new byte[] { 1, 2, 3 }, "key"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e5040f6e0d2203349aea6b1d2c85a46d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ShrinkDataSaver.Tests
|
||||
{
|
||||
[TestFixture]
|
||||
public class SaveTypesTests
|
||||
{
|
||||
// ── SaveMeta ──
|
||||
|
||||
[Test]
|
||||
public void SaveMeta_LastModifiedTime_ConvertsCorrectly()
|
||||
{
|
||||
var meta = new SaveMeta
|
||||
{
|
||||
LastModified = 1700000000 // 2023-11-14 22:13:20 UTC
|
||||
};
|
||||
|
||||
var dt = meta.LastModifiedTime;
|
||||
Assert.AreEqual(2023, dt.Year);
|
||||
Assert.AreEqual(11, dt.Month);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveMeta_Defaults()
|
||||
{
|
||||
var meta = new SaveMeta();
|
||||
Assert.AreEqual("New Save", meta.SlotName);
|
||||
Assert.AreEqual(1, meta.SaveVersion);
|
||||
Assert.IsFalse(meta.IsEncrypted);
|
||||
Assert.IsNull(meta.ScreenshotBase64);
|
||||
}
|
||||
|
||||
// ── ModuleConfig ──
|
||||
|
||||
[Test]
|
||||
public void ModuleConfig_Defaults()
|
||||
{
|
||||
var cfg = new ModuleConfig();
|
||||
Assert.IsTrue(cfg.EnableCloudSync);
|
||||
Assert.AreEqual(0f, cfg.AutoSaveIntervalSeconds);
|
||||
Assert.IsFalse(cfg.CriticalModule);
|
||||
}
|
||||
|
||||
// ── SaveOptions ──
|
||||
|
||||
[Test]
|
||||
public void SaveOptions_Defaults()
|
||||
{
|
||||
var opt = new SaveOptions();
|
||||
Assert.AreEqual("New Save", opt.SlotName);
|
||||
Assert.IsFalse(opt.CaptureScreenshot);
|
||||
Assert.IsNull(opt.Screenshot);
|
||||
Assert.IsFalse(opt.Encrypt);
|
||||
Assert.IsNull(opt.EncryptionKey);
|
||||
}
|
||||
|
||||
// ── LambdaSaveModule ──
|
||||
|
||||
[Test]
|
||||
public void LambdaSaveModule_Serialize_CallsFactory()
|
||||
{
|
||||
var data = new TestData { Value = 42 };
|
||||
var module = new LambdaSaveModule<TestData>("test", () => data, d => { });
|
||||
|
||||
Assert.AreEqual("test", module.Key);
|
||||
var result = module.Serialize();
|
||||
Assert.AreEqual(42, result.Value);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LambdaSaveModule_Deserialize_CallsConsumer()
|
||||
{
|
||||
TestData received = null;
|
||||
var module = new LambdaSaveModule<TestData>("test", () => null, d => received = d);
|
||||
|
||||
((ISaveModule)module).DeserializeRaw(JToken.FromObject(new TestData { Value = 99 }));
|
||||
Assert.IsNotNull(received);
|
||||
Assert.AreEqual(99, received.Value);
|
||||
}
|
||||
|
||||
// ── 事件参数 ──
|
||||
|
||||
[Test]
|
||||
public void SaveCompletedEventArgs_Fields()
|
||||
{
|
||||
var args = new SaveCompletedEventArgs
|
||||
{
|
||||
SlotIndex = 2,
|
||||
ModuleNames = new[] { "inventory", "quests" },
|
||||
Timestamp = 1234567890
|
||||
};
|
||||
|
||||
Assert.AreEqual(2, args.SlotIndex);
|
||||
Assert.AreEqual(2, args.ModuleNames.Length);
|
||||
Assert.AreEqual(1234567890, args.Timestamp);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LoadCompletedEventArgs_Fields()
|
||||
{
|
||||
var args = new LoadCompletedEventArgs
|
||||
{
|
||||
SlotIndex = 1,
|
||||
ModuleNames = new[] { "player" },
|
||||
Version = 3,
|
||||
Timestamp = 9999
|
||||
};
|
||||
|
||||
Assert.AreEqual(1, args.SlotIndex);
|
||||
Assert.AreEqual(3, args.Version);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MigrationCompletedEventArgs_Fields()
|
||||
{
|
||||
var args = new MigrationCompletedEventArgs
|
||||
{
|
||||
SlotIndex = 0, FromVersion = 1, ToVersion = 3
|
||||
};
|
||||
|
||||
Assert.AreEqual(1, args.FromVersion);
|
||||
Assert.AreEqual(3, args.ToVersion);
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class TestData
|
||||
{
|
||||
public int Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c4fec6ba5af68dd46ae44776da037c69
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "ShrinkDataSaver.Tests",
|
||||
"rootNamespace": "ShrinkDataSaver.Tests",
|
||||
"references": [
|
||||
"ShrinkDataSaver.Runtime",
|
||||
"UniTask",
|
||||
"UnityEngine.TestRunner",
|
||||
"UnityEditor.TestRunner"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": true,
|
||||
"precompiledReferences": [
|
||||
"Newtonsoft.Json.dll"
|
||||
],
|
||||
"autoReferenced": false,
|
||||
"defineConstraints": [
|
||||
"UNITY_INCLUDE_TESTS"
|
||||
],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ea4bb7f0bb4396d4e9454b19f4630613
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,629 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace ShrinkDataSaver.Tests
|
||||
{
|
||||
[TestFixture]
|
||||
public class ShrinkSaveTests
|
||||
{
|
||||
private MockStorageProvider _storage;
|
||||
private ShrinkDataSaverSettings _settings;
|
||||
|
||||
[Serializable]
|
||||
private class PlayerData
|
||||
{
|
||||
public string Name = "TestPlayer";
|
||||
public int Level = 1;
|
||||
public int Coins = 100;
|
||||
}
|
||||
|
||||
private PlayerData _playerData;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_storage = new MockStorageProvider();
|
||||
_settings = ScriptableObject.CreateInstance<ShrinkDataSaverSettings>();
|
||||
_settings.maxSlots = 10;
|
||||
ShrinkDataSaverSettings.Instance = _settings;
|
||||
|
||||
ShrinkSave.ResetForTesting();
|
||||
MigrationChain.Clear();
|
||||
ShrinkSave.Initialize(_storage, "saves", ".sav", 1);
|
||||
|
||||
_playerData = new PlayerData();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ShrinkSave.ResetForTesting();
|
||||
MigrationChain.Clear();
|
||||
if (_settings != null) Object.DestroyImmediate(_settings);
|
||||
}
|
||||
|
||||
// ── 模块注册 ──
|
||||
|
||||
[Test]
|
||||
public void RegisterModule_Lambda()
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
Assert.IsTrue(ShrinkSave.HasModule("player"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RegisterModule_WithConfig()
|
||||
{
|
||||
var config = new ModuleConfig
|
||||
{
|
||||
EnableCloudSync = false,
|
||||
AutoSaveIntervalSeconds = 30f,
|
||||
CriticalModule = true
|
||||
};
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d, config);
|
||||
|
||||
var restored = ShrinkSave.GetModuleConfig("player");
|
||||
Assert.IsFalse(restored.EnableCloudSync);
|
||||
Assert.AreEqual(30f, restored.AutoSaveIntervalSeconds);
|
||||
Assert.IsTrue(restored.CriticalModule);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UnregisterModule_RemovesModuleAndConfig()
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d,
|
||||
new ModuleConfig { CriticalModule = true });
|
||||
|
||||
ShrinkSave.UnregisterModule("player");
|
||||
Assert.IsFalse(ShrinkSave.HasModule("player"));
|
||||
Assert.IsNull(ShrinkSave.GetModuleConfig("player"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetModuleConfig_NonExistent_ReturnsNull()
|
||||
{
|
||||
Assert.IsNull(ShrinkSave.GetModuleConfig("ghost"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HasModule_ReturnsFalse_WhenNotRegistered()
|
||||
{
|
||||
Assert.IsFalse(ShrinkSave.HasModule("nonexistent"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetRegisteredModuleNames_ReturnsAll()
|
||||
{
|
||||
ShrinkSave.RegisterModule("a", () => 1, _ => { });
|
||||
ShrinkSave.RegisterModule("b", () => 2, _ => { });
|
||||
|
||||
var names = ShrinkSave.GetRegisteredModuleNames();
|
||||
Assert.AreEqual(2, names.Count);
|
||||
Assert.IsTrue(names.Contains("a"));
|
||||
Assert.IsTrue(names.Contains("b"));
|
||||
}
|
||||
|
||||
// ── 保存 / 加载 / 删除 ──
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SaveSlotAsync_ThenExists() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "TestSave" });
|
||||
|
||||
Assert.IsTrue(await ShrinkSave.SlotExistsAsync(0));
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SaveLoad_RoundTrip_DataIntegrity() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
_playerData = new PlayerData { Name = "Alice", Level = 10, Coins = 500 };
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
|
||||
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "TestSave" });
|
||||
|
||||
// 清除内存数据
|
||||
_playerData = new PlayerData();
|
||||
Assert.AreEqual("TestPlayer", _playerData.Name);
|
||||
|
||||
// 重新加载
|
||||
await ShrinkSave.LoadSlotAsync(0);
|
||||
Assert.AreEqual("Alice", _playerData.Name);
|
||||
Assert.AreEqual(10, _playerData.Level);
|
||||
Assert.AreEqual(500, _playerData.Coins);
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SaveLoad_Encrypted_RoundTrip() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
_playerData = new PlayerData { Name = "Encrypted", Level = 99, Coins = 9999 };
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
|
||||
await ShrinkSave.SaveSlotAsync(0, new SaveOptions
|
||||
{
|
||||
SlotName = "Encrypted Save",
|
||||
Encrypt = true,
|
||||
EncryptionKey = "secret123"
|
||||
});
|
||||
|
||||
_playerData = new PlayerData();
|
||||
await ShrinkSave.LoadSlotAsync(0, "secret123");
|
||||
|
||||
Assert.AreEqual("Encrypted", _playerData.Name);
|
||||
Assert.AreEqual(99, _playerData.Level);
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator DeleteSlotAsync_RemovesSlot() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
await ShrinkSave.SaveSlotAsync(0);
|
||||
|
||||
Assert.IsTrue(await ShrinkSave.SlotExistsAsync(0));
|
||||
|
||||
await ShrinkSave.DeleteSlotAsync(0);
|
||||
Assert.IsFalse(await ShrinkSave.SlotExistsAsync(0));
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LoadSlotAsync_NonExistent_ThrowsAndFiresLoadFailed() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
LoadFailedEventArgs failArgs = null;
|
||||
ShrinkSave.OnLoadFailed += args => failArgs = args;
|
||||
|
||||
try
|
||||
{
|
||||
await ShrinkSave.LoadSlotAsync(99);
|
||||
Assert.Fail("Should have thrown");
|
||||
}
|
||||
catch (System.IO.FileNotFoundException) { }
|
||||
|
||||
Assert.IsNotNull(failArgs);
|
||||
Assert.AreEqual(99, failArgs.SlotIndex);
|
||||
Assert.IsTrue(failArgs.ErrorMessage.Contains("99"));
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SaveSlotAsync_NegativeIndex_Throws() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
try
|
||||
{
|
||||
await ShrinkSave.SaveSlotAsync(-1);
|
||||
Assert.Fail("Should have thrown");
|
||||
}
|
||||
catch (ArgumentOutOfRangeException) { }
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SaveSlotAsync_ExceedsMaxSlots_Throws() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
_settings.maxSlots = 3;
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
try
|
||||
{
|
||||
await ShrinkSave.SaveSlotAsync(5);
|
||||
Assert.Fail("Should have thrown");
|
||||
}
|
||||
catch (ArgumentOutOfRangeException) { }
|
||||
});
|
||||
|
||||
// ── 事件 ──
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Events_SaveStarted_FiredBeforeSave() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
|
||||
SaveStartedEventArgs startArgs = null;
|
||||
ShrinkSave.OnSaveStarted += args => startArgs = args;
|
||||
|
||||
await ShrinkSave.SaveSlotAsync(0);
|
||||
|
||||
Assert.IsNotNull(startArgs);
|
||||
Assert.AreEqual(0, startArgs.SlotIndex);
|
||||
Assert.IsTrue(startArgs.Timestamp > 0);
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Events_SaveCompleted_ContainsModuleNames() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
ShrinkSave.RegisterModule("settings", () => new { volume = 1f }, _ => { });
|
||||
|
||||
SaveCompletedEventArgs completedArgs = null;
|
||||
ShrinkSave.OnSaveCompleted += args => completedArgs = args;
|
||||
|
||||
await ShrinkSave.SaveSlotAsync(0);
|
||||
|
||||
Assert.IsNotNull(completedArgs);
|
||||
Assert.AreEqual(0, completedArgs.SlotIndex);
|
||||
Assert.AreEqual(2, completedArgs.ModuleNames.Length);
|
||||
Assert.IsTrue(completedArgs.Timestamp > 0);
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Events_LoadCompleted_ContainsVersionAndModules() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
await ShrinkSave.SaveSlotAsync(0);
|
||||
|
||||
LoadCompletedEventArgs loadArgs = null;
|
||||
ShrinkSave.OnLoadCompleted += args => loadArgs = args;
|
||||
|
||||
await ShrinkSave.LoadSlotAsync(0);
|
||||
|
||||
Assert.IsNotNull(loadArgs);
|
||||
Assert.AreEqual(0, loadArgs.SlotIndex);
|
||||
Assert.AreEqual(1, loadArgs.Version);
|
||||
Assert.IsTrue(loadArgs.ModuleNames.Length > 0);
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Events_LoadStarted_Fired() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
await ShrinkSave.SaveSlotAsync(0);
|
||||
|
||||
LoadStartedEventArgs startArgs = null;
|
||||
ShrinkSave.OnLoadStarted += args => startArgs = args;
|
||||
|
||||
await ShrinkSave.LoadSlotAsync(0);
|
||||
|
||||
Assert.IsNotNull(startArgs);
|
||||
Assert.AreEqual(0, startArgs.SlotIndex);
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator Events_DeleteCompleted_Fired() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
await ShrinkSave.SaveSlotAsync(0);
|
||||
|
||||
SlotDeletedEventArgs deleteArgs = null;
|
||||
ShrinkSave.OnDeleteCompleted += args => deleteArgs = args;
|
||||
|
||||
await ShrinkSave.DeleteSlotAsync(0);
|
||||
|
||||
Assert.IsNotNull(deleteArgs);
|
||||
Assert.AreEqual(0, deleteArgs.SlotIndex);
|
||||
});
|
||||
|
||||
// ── 关键模块 ──
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CriticalModule_FailedSerialize_AbortsSave() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
// 注册一个会抛异常的关键模块
|
||||
ShrinkSave.RegisterModule<object>("broken",
|
||||
() => throw new Exception("boom"),
|
||||
_ => { },
|
||||
new ModuleConfig { CriticalModule = true });
|
||||
|
||||
SaveFailedEventArgs failArgs = null;
|
||||
ShrinkSave.OnSaveFailed += args => failArgs = args;
|
||||
|
||||
try
|
||||
{
|
||||
await ShrinkSave.SaveSlotAsync(0);
|
||||
Assert.Fail("Should have thrown");
|
||||
}
|
||||
catch (InvalidOperationException) { }
|
||||
|
||||
Assert.IsNotNull(failArgs);
|
||||
Assert.IsTrue(failArgs.ErrorMessage.Contains("broken"));
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator NonCriticalModule_FailedSerialize_ContinuesSave() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
// 正常模块
|
||||
ShrinkSave.RegisterModule("good", () => _playerData, d => _playerData = d);
|
||||
// 会失败的非关键模块
|
||||
ShrinkSave.RegisterModule<object>("bad",
|
||||
() => throw new Exception("oops"),
|
||||
_ => { },
|
||||
new ModuleConfig { CriticalModule = false });
|
||||
|
||||
SaveCompletedEventArgs completedArgs = null;
|
||||
ShrinkSave.OnSaveCompleted += args => completedArgs = args;
|
||||
|
||||
LogAssert.Expect(LogType.Error, "[ShrinkDataSaver] 模块 'bad' 序列化失败(已跳过): oops");
|
||||
await ShrinkSave.SaveSlotAsync(0);
|
||||
|
||||
// 保存应该成功(跳过了失败的非关键模块)
|
||||
Assert.IsNotNull(completedArgs);
|
||||
Assert.IsTrue(await ShrinkSave.SlotExistsAsync(0));
|
||||
});
|
||||
|
||||
// ── 版本迁移事件 ──
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator MigrationCompleted_FiredOnVersionMismatch() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
// 以 v1 保存
|
||||
ShrinkSave.SetCurrentSaveVersion(1);
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
await ShrinkSave.SaveSlotAsync(0);
|
||||
|
||||
// 设置迁移并升级到 v2
|
||||
MigrationChain.Register(1, 2, data =>
|
||||
{
|
||||
data["migrated"] = true;
|
||||
return data;
|
||||
});
|
||||
ShrinkSave.SetCurrentSaveVersion(2);
|
||||
|
||||
MigrationCompletedEventArgs migArgs = null;
|
||||
ShrinkSave.OnMigrationCompleted += args => migArgs = args;
|
||||
|
||||
await ShrinkSave.LoadSlotAsync(0);
|
||||
|
||||
Assert.IsNotNull(migArgs);
|
||||
Assert.AreEqual(0, migArgs.SlotIndex);
|
||||
Assert.AreEqual(1, migArgs.FromVersion);
|
||||
Assert.AreEqual(2, migArgs.ToVersion);
|
||||
});
|
||||
|
||||
// ── 跨模块查询 ──
|
||||
|
||||
[Test]
|
||||
public void QueryModule_ReturnsCurrentData()
|
||||
{
|
||||
_playerData = new PlayerData { Name = "Query", Level = 5, Coins = 200 };
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
|
||||
var result = ShrinkSave.QueryModule<PlayerData>("player");
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("Query", result.Name);
|
||||
Assert.AreEqual(5, result.Level);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void QueryModule_NonExistentModule_ReturnsDefault()
|
||||
{
|
||||
var result = ShrinkSave.QueryModule<PlayerData>("ghost");
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator HasKey_AfterLoad_ChecksModuleData() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
_playerData = new PlayerData { Name = "KeyTest", Level = 1, Coins = 0 };
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
await ShrinkSave.SaveSlotAsync(0);
|
||||
await ShrinkSave.LoadSlotAsync(0);
|
||||
|
||||
Assert.IsTrue(ShrinkSave.HasKey("player", "Name"));
|
||||
Assert.IsTrue(ShrinkSave.HasKey("player", "Level"));
|
||||
Assert.IsFalse(ShrinkSave.HasKey("player", "NonExistentField"));
|
||||
Assert.IsFalse(ShrinkSave.HasKey("ghost_module", "Name"));
|
||||
});
|
||||
|
||||
[Test]
|
||||
public void HasKey_BeforeLoad_ReturnsFalse()
|
||||
{
|
||||
Assert.IsFalse(ShrinkSave.HasKey("player", "Name"));
|
||||
}
|
||||
|
||||
// ── 自动保存间隔 ──
|
||||
|
||||
[Test]
|
||||
public void GetMinAutoSaveInterval_NoModules_ReturnsZero()
|
||||
{
|
||||
Assert.AreEqual(0f, ShrinkSave.GetMinAutoSaveInterval());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetMinAutoSaveInterval_ReturnsMinimum()
|
||||
{
|
||||
ShrinkSave.RegisterModule("a", () => 1, _ => { },
|
||||
new ModuleConfig { AutoSaveIntervalSeconds = 60f });
|
||||
ShrinkSave.RegisterModule("b", () => 2, _ => { },
|
||||
new ModuleConfig { AutoSaveIntervalSeconds = 30f });
|
||||
ShrinkSave.RegisterModule("c", () => 3, _ => { },
|
||||
new ModuleConfig { AutoSaveIntervalSeconds = 0f }); // 不参与
|
||||
|
||||
Assert.AreEqual(30f, ShrinkSave.GetMinAutoSaveInterval());
|
||||
}
|
||||
|
||||
// ── 元数据 ──
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator GetMetaAsync_ReturnsMeta() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "MetaTest" });
|
||||
|
||||
var meta = await ShrinkSave.GetMetaAsync(0);
|
||||
Assert.IsNotNull(meta);
|
||||
Assert.AreEqual(0, meta.SlotIndex);
|
||||
Assert.AreEqual("MetaTest", meta.SlotName);
|
||||
Assert.AreEqual(1, meta.SaveVersion);
|
||||
Assert.IsFalse(meta.IsEncrypted);
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator GetMetaAsync_NonExistent_ReturnsNull() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
var meta = await ShrinkSave.GetMetaAsync(99);
|
||||
Assert.IsNull(meta);
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator GetAllMetaAsync_ReturnsAllSlots() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "Slot0" });
|
||||
await ShrinkSave.SaveSlotAsync(1, new SaveOptions { SlotName = "Slot1" });
|
||||
|
||||
var allMeta = await ShrinkSave.GetAllMetaAsync();
|
||||
Assert.AreEqual(2, allMeta.Length);
|
||||
Assert.AreEqual(0, allMeta[0].SlotIndex);
|
||||
Assert.AreEqual(1, allMeta[1].SlotIndex);
|
||||
});
|
||||
|
||||
// ── LoadedSlot ──
|
||||
|
||||
[Test]
|
||||
public void LoadedSlot_InitiallyNegative()
|
||||
{
|
||||
Assert.AreEqual(-1, ShrinkSave.LoadedSlot);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LoadedSlot_UpdatedAfterLoad() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
await ShrinkSave.SaveSlotAsync(0);
|
||||
await ShrinkSave.LoadSlotAsync(0);
|
||||
|
||||
Assert.AreEqual(0, ShrinkSave.LoadedSlot);
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LoadedSlot_ResetAfterDelete() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
await ShrinkSave.SaveSlotAsync(0);
|
||||
await ShrinkSave.LoadSlotAsync(0);
|
||||
Assert.AreEqual(0, ShrinkSave.LoadedSlot);
|
||||
|
||||
await ShrinkSave.DeleteSlotAsync(0);
|
||||
Assert.AreEqual(-1, ShrinkSave.LoadedSlot);
|
||||
});
|
||||
|
||||
// ── 多模块 ──
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator MultipleModules_SaveLoad_AllRestored() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
var inventory = new int[] { 1, 2, 3 };
|
||||
var questFlag = new bool[] { true, false, true };
|
||||
int[] loadedInv = null;
|
||||
bool[] loadedQuest = null;
|
||||
|
||||
ShrinkSave.RegisterModule("inventory", () => inventory, d => loadedInv = d);
|
||||
ShrinkSave.RegisterModule("quests", () => questFlag, d => loadedQuest = d);
|
||||
|
||||
await ShrinkSave.SaveSlotAsync(0);
|
||||
await ShrinkSave.LoadSlotAsync(0);
|
||||
|
||||
Assert.AreEqual(new[] { 1, 2, 3 }, loadedInv);
|
||||
Assert.AreEqual(new[] { true, false, true }, loadedQuest);
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LoadSlotAsync_FallsBackToBackup_WhenPrimaryCorrupted() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
_playerData = new PlayerData { Name = "BackupPlayer", Level = 7, Coins = 321 };
|
||||
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "BackupSlot" });
|
||||
|
||||
var backupBytes = await _storage.ReadAsync("saves/slot_0.sav");
|
||||
await _storage.WriteAsync("saves/slot_0.sav.bak1", backupBytes);
|
||||
await _storage.WriteAsync("saves/slot_0.sav", DataSerializer.Serialize(new { broken = true }));
|
||||
|
||||
_playerData = new PlayerData();
|
||||
await ShrinkSave.LoadSlotAsync(0);
|
||||
|
||||
Assert.AreEqual("BackupPlayer", _playerData.Name);
|
||||
|
||||
var repairedPrimary = await _storage.ReadAsync("saves/slot_0.sav");
|
||||
CollectionAssert.AreEqual(backupBytes, repairedPrimary);
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator GetAllMetaAsync_UsesBackupOnlySlot_WhenPrimaryMissing() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
await ShrinkSave.SaveSlotAsync(2, new SaveOptions { SlotName = "BackupOnly" });
|
||||
|
||||
var backupBytes = await _storage.ReadAsync("saves/slot_2.sav");
|
||||
await _storage.WriteAsync("saves/slot_2.sav.bak1", backupBytes);
|
||||
await _storage.DeleteAsync("saves/slot_2.sav");
|
||||
|
||||
var metas = await ShrinkSave.GetAllMetaAsync();
|
||||
|
||||
Assert.AreEqual(1, metas.Length);
|
||||
Assert.AreEqual(2, metas[0].SlotIndex);
|
||||
Assert.AreEqual("BackupOnly", metas[0].SlotName);
|
||||
Assert.IsTrue(await _storage.ExistsAsync("saves/slot_2.sav"));
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator GetRecommendedContinueSlotAsync_PrefersRecentAndSelfHeals() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
|
||||
|
||||
_playerData = new PlayerData { Name = "Slot0", Level = 1, Coins = 10 };
|
||||
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "Slot0" });
|
||||
|
||||
_playerData = new PlayerData { Name = "Slot3", Level = 3, Coins = 30 };
|
||||
await ShrinkSave.SaveSlotAsync(3, new SaveOptions { SlotName = "Slot3" });
|
||||
|
||||
await ShrinkSettings.LoadAsync();
|
||||
ShrinkSettings.Set("ShrinkDataSaver.RecentSlotIndex", 3);
|
||||
await ShrinkSettings.SaveAsync();
|
||||
|
||||
var preferred = await ShrinkSave.GetRecommendedContinueSlotAsync();
|
||||
Assert.AreEqual(3, preferred);
|
||||
|
||||
await ShrinkSave.DeleteSlotAsync(3);
|
||||
preferred = await ShrinkSave.GetRecommendedContinueSlotAsync();
|
||||
Assert.AreEqual(0, preferred);
|
||||
Assert.AreEqual(0, ShrinkSave.GetRecentSlotIndex());
|
||||
});
|
||||
}
|
||||
|
||||
[TestFixture]
|
||||
public class LocalStorageProviderTests
|
||||
{
|
||||
private string _rootPath;
|
||||
private LocalStorageProvider _provider;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_rootPath = Path.Combine(Path.GetTempPath(), $"ShrinkDataSaverTests_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_rootPath);
|
||||
_provider = new LocalStorageProvider(_rootPath);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (Directory.Exists(_rootPath))
|
||||
{
|
||||
Directory.Delete(_rootPath, true);
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator WriteAsync_RotatesTwoBackupsAtomically() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
var relativePath = "saves/sample.sav";
|
||||
|
||||
await _provider.WriteAsync(relativePath, DataSerializer.Serialize(new { version = 1 }));
|
||||
await _provider.WriteAsync(relativePath, DataSerializer.Serialize(new { version = 2 }));
|
||||
await _provider.WriteAsync(relativePath, DataSerializer.Serialize(new { version = 3 }));
|
||||
|
||||
var primary = DataSerializer.Deserialize<JObject>(await _provider.ReadAsync(relativePath));
|
||||
var backup1 = DataSerializer.Deserialize<JObject>(await _provider.ReadAsync(relativePath + ".bak1"));
|
||||
var backup2 = DataSerializer.Deserialize<JObject>(await _provider.ReadAsync(relativePath + ".bak2"));
|
||||
|
||||
Assert.AreEqual(3, primary["version"]?.Value<int>());
|
||||
Assert.AreEqual(2, backup1["version"]?.Value<int>());
|
||||
Assert.AreEqual(1, backup2["version"]?.Value<int>());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e2cf2ecb1a4173846bbcfb49d584d8bb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace ShrinkDataSaver.Tests
|
||||
{
|
||||
[TestFixture]
|
||||
public class ShrinkSettingsTests
|
||||
{
|
||||
private MockStorageProvider _storage;
|
||||
private ShrinkDataSaverSettings _settings;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_storage = new MockStorageProvider();
|
||||
_settings = ScriptableObject.CreateInstance<ShrinkDataSaverSettings>();
|
||||
ShrinkDataSaverSettings.Instance = _settings;
|
||||
ShrinkSettings.ResetForTesting();
|
||||
ShrinkSettings.Initialize(_storage, "settings.json");
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ShrinkSettings.ResetForTesting();
|
||||
if (_settings != null) Object.DestroyImmediate(_settings);
|
||||
}
|
||||
|
||||
// ── Get / Set ──
|
||||
|
||||
[Test]
|
||||
public void Set_Get_String()
|
||||
{
|
||||
ShrinkSettings.Set("name", "Alice");
|
||||
Assert.AreEqual("Alice", ShrinkSettings.Get<string>("name"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Set_Get_Int()
|
||||
{
|
||||
ShrinkSettings.Set("score", 42);
|
||||
Assert.AreEqual(42, ShrinkSettings.Get<int>("score"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Set_Get_Float()
|
||||
{
|
||||
ShrinkSettings.Set("volume", 0.75f);
|
||||
Assert.AreEqual(0.75f, ShrinkSettings.Get<float>("volume"), 0.001f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Set_Get_Bool()
|
||||
{
|
||||
ShrinkSettings.Set("muted", true);
|
||||
Assert.IsTrue(ShrinkSettings.Get<bool>("muted"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Set_Get_ComplexObject()
|
||||
{
|
||||
var data = new Dictionary<string, int> { { "a", 1 }, { "b", 2 } };
|
||||
ShrinkSettings.Set("map", data);
|
||||
var restored = ShrinkSettings.Get<Dictionary<string, int>>("map");
|
||||
|
||||
Assert.AreEqual(1, restored["a"]);
|
||||
Assert.AreEqual(2, restored["b"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Get_NonExistentKey_ReturnsDefault()
|
||||
{
|
||||
Assert.AreEqual(0, ShrinkSettings.Get<int>("missing"));
|
||||
Assert.IsNull(ShrinkSettings.Get<string>("missing"));
|
||||
Assert.AreEqual(99, ShrinkSettings.Get("missing", 99));
|
||||
}
|
||||
|
||||
// ── Has / Remove ──
|
||||
|
||||
[Test]
|
||||
public void Has_ExistingKey_ReturnsTrue()
|
||||
{
|
||||
ShrinkSettings.Set("key", "value");
|
||||
Assert.IsTrue(ShrinkSettings.Has("key"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Has_NonExistentKey_ReturnsFalse()
|
||||
{
|
||||
Assert.IsFalse(ShrinkSettings.Has("ghost"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Remove_ExistingKey()
|
||||
{
|
||||
ShrinkSettings.Set("temp", 123);
|
||||
Assert.IsTrue(ShrinkSettings.Has("temp"));
|
||||
|
||||
ShrinkSettings.Remove("temp");
|
||||
Assert.IsFalse(ShrinkSettings.Has("temp"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Remove_NonExistentKey_NoError()
|
||||
{
|
||||
Assert.DoesNotThrow(() => ShrinkSettings.Remove("ghost"));
|
||||
}
|
||||
|
||||
// ── GetAllRaw ──
|
||||
|
||||
[Test]
|
||||
public void GetAllRaw_ReturnsAllSettings()
|
||||
{
|
||||
ShrinkSettings.Set("a", 1);
|
||||
ShrinkSettings.Set("b", "two");
|
||||
ShrinkSettings.Set("c", true);
|
||||
|
||||
var all = ShrinkSettings.GetAllRaw();
|
||||
Assert.AreEqual(3, all.Count);
|
||||
Assert.IsTrue(all.ContainsKey("a"));
|
||||
Assert.IsTrue(all.ContainsKey("b"));
|
||||
Assert.IsTrue(all.ContainsKey("c"));
|
||||
}
|
||||
|
||||
// ── 事件 ──
|
||||
|
||||
[Test]
|
||||
public void OnChanged_FiredOnSet()
|
||||
{
|
||||
string receivedKey = null;
|
||||
object receivedValue = null;
|
||||
ShrinkSettings.OnChanged += (k, v) => { receivedKey = k; receivedValue = v; };
|
||||
|
||||
ShrinkSettings.Set("volume", 0.5f);
|
||||
|
||||
Assert.AreEqual("volume", receivedKey);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnChanged_FiredOnRemove()
|
||||
{
|
||||
ShrinkSettings.Set("temp", "data");
|
||||
|
||||
string removedKey = null;
|
||||
ShrinkSettings.OnChanged += (k, v) => { removedKey = k; };
|
||||
|
||||
ShrinkSettings.Remove("temp");
|
||||
Assert.AreEqual("temp", removedKey);
|
||||
}
|
||||
|
||||
// ── 持久化 ──
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator SaveAsync_LoadAsync_RoundTrip() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSettings.Set("persist_str", "hello");
|
||||
ShrinkSettings.Set("persist_int", 42);
|
||||
await ShrinkSettings.SaveAsync();
|
||||
|
||||
// 重置内存数据后重新加载
|
||||
ShrinkSettings.ResetForTesting();
|
||||
ShrinkSettings.Initialize(_storage, "settings.json");
|
||||
|
||||
await ShrinkSettings.LoadAsync();
|
||||
|
||||
Assert.AreEqual("hello", ShrinkSettings.Get<string>("persist_str"));
|
||||
Assert.AreEqual(42, ShrinkSettings.Get<int>("persist_int"));
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LoadAsync_EmptyStorage_GivesCleanState() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
await ShrinkSettings.LoadAsync();
|
||||
Assert.AreEqual(0, ShrinkSettings.GetAllRaw().Count);
|
||||
});
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator LoadAsync_FallsBackToBackup_WhenPrimaryCorrupted() => UniTask.ToCoroutine(async () =>
|
||||
{
|
||||
ShrinkSettings.Set("lang", "zh_cn");
|
||||
await ShrinkSettings.SaveAsync();
|
||||
|
||||
var backupBytes = await _storage.ReadAsync("settings.json");
|
||||
await _storage.WriteAsync("settings.json.bak1", backupBytes);
|
||||
// 用无法解析的字节模拟主文件损坏:结构合法但字段不符的 JSON 无法与“合法空设置”区分
|
||||
await _storage.WriteAsync("settings.json", System.Text.Encoding.UTF8.GetBytes("{ \"lang\": \"zh_cn\" , broken"));
|
||||
|
||||
ShrinkSettings.ResetForTesting();
|
||||
ShrinkSettings.Initialize(_storage, "settings.json");
|
||||
|
||||
await ShrinkSettings.LoadAsync();
|
||||
|
||||
Assert.AreEqual("zh_cn", ShrinkSettings.Get<string>("lang"));
|
||||
var repairedPrimary = await _storage.ReadAsync("settings.json");
|
||||
CollectionAssert.AreEqual(backupBytes, repairedPrimary);
|
||||
});
|
||||
|
||||
// ── Overwrite ──
|
||||
|
||||
[Test]
|
||||
public void Set_OverwriteExistingKey()
|
||||
{
|
||||
ShrinkSettings.Set("key", "first");
|
||||
ShrinkSettings.Set("key", "second");
|
||||
Assert.AreEqual("second", ShrinkSettings.Get<string>("key"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user