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 _modules = new(); private static readonly Dictionary _moduleConfigs = new(); private static int _loadedSlot = -1; private static float _sessionStart; private static float _storedPlaytime; private static Dictionary _loadedModuleData; public static event Action OnSaveStarted; public static event Action OnSaveCompleted; public static event Action OnSaveFailed; public static event Action OnLoadStarted; public static event Action OnLoadCompleted; public static event Action OnLoadFailed; public static event Action OnMigrationCompleted; public static event Action 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(string key, Func serialize, Action deserialize, ModuleConfig config = null) { _modules[key] = new LambdaSaveModule(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 GetRegisteredModuleNames() => _modules.Keys; public static T QueryModule(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(); } 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 GetAllMetaAsync(CancellationToken ct = default) { var results = new List(); var files = await _storage.ListAsync(_savesDir, ct); var discoveredSlots = new HashSet(); 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 SlotExistsAsync(int slotIndex, CancellationToken ct = default) => await _storage.ExistsAsync(SlotPath(slotIndex), ct); public static async UniTask 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 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(); var moduleNames = new List(); 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(loadedModules); var moduleNames = new List(); 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 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 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 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 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 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(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 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(bytes); if (packet?.Meta == null) { throw new InvalidDataException($"反序列化槽位 {slotIndex} 失败。"); } Dictionary 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>(decryptedBytes); } else { loadedModules = packet.Modules ?? new Dictionary(); } 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(); 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 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)); } } }