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:
@@ -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
|
||||
Reference in New Issue
Block a user