- 将 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 迁移与旧代码地图
674 lines
25 KiB
C#
674 lines
25 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|