diff --git a/Assets/Scripts/AssetRefs/ConstRef.cs b/Assets/Scripts/AssetRefs/ConstRef.cs
index b4c2dfb6e..fbca620f5 100644
--- a/Assets/Scripts/AssetRefs/ConstRef.cs
+++ b/Assets/Scripts/AssetRefs/ConstRef.cs
@@ -23,6 +23,16 @@ namespace AibisDream.Utility
public static readonly string SaveFilePath = Path.Combine(Application.persistentDataPath, "AllOurBrokenParts", "saves");
+ /// P2 槽位相关常量。
+ public const int SaveSlotCount = 8;
+ public const int SaveThumbnailWidth = 640;
+ public const int SaveThumbnailHeight = 360;
+ public const string SaveSlotDirPrefix = "slot_";
+ public const string SaveSnapshotFileName = "snapshot";
+ public const string SaveMetaFileName = "meta";
+ public const string SaveThumbnailFileName = "thumbnail";
+ public const string LatestSlotFileName = "latest_slot";
+
/// P1 快照层自测落盘路径(P2 槽位层接管后迁移)。
public static readonly string SnapshotTestPath =
Path.Combine(Application.persistentDataPath, "AllOurBrokenParts", "snapshot_test");
diff --git a/Assets/Scripts/SaveSystem/CloudSave.meta b/Assets/Scripts/SaveSystem/CloudSave.meta
new file mode 100644
index 000000000..308741781
--- /dev/null
+++ b/Assets/Scripts/SaveSystem/CloudSave.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 520cc163c88e4c00895fe201004b4384
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/SaveSystem/CloudSave/CloudSaveManager.cs b/Assets/Scripts/SaveSystem/CloudSave/CloudSaveManager.cs
new file mode 100644
index 000000000..d8db81960
--- /dev/null
+++ b/Assets/Scripts/SaveSystem/CloudSave/CloudSaveManager.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using UnityEngine;
+
+namespace AibisDream.SaveSystem.CloudSave
+{
+ ///
+ /// 云存档协调器:负责选择当前平台的 backend,并在槽位写入/读取后触发同步钩子。
+ ///
+ /// P2 不实现真实云同步逻辑,仅保留扩展点。
+ ///
+ ///
+ public static class CloudSaveManager
+ {
+ private static readonly List Backends = new();
+
+ /// 注册一个云存档后端。应在游戏启动时调用一次。
+ public static void RegisterBackend(ICloudSaveBackend backend)
+ {
+ if (backend == null)
+ {
+ return;
+ }
+
+ Backends.Add(backend);
+ }
+
+ /// 清空已注册后端(主要用于测试)。
+ public static void ClearBackends()
+ {
+ Backends.Clear();
+ }
+
+ /// 槽位写入后调用:触发所有可用后端的异步上传。
+ public static void OnSlotWritten(int slotIndex)
+ {
+ _ = UploadSlotAsync(slotIndex);
+ }
+
+ /// 槽位读取前调用:可触发下载(P2 默认不自动下载,避免覆盖本地进度)。
+ public static void OnSlotRestored(int slotIndex)
+ {
+ // P2 占位:未来可在此触发冲突检测与下载。
+ }
+
+ /// 手动触发指定槽位的云同步上传。
+ public static async Task UploadSlotAsync(int slotIndex)
+ {
+ var filePaths = CollectSlotFilePaths(slotIndex);
+ if (filePaths.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var backend in Backends)
+ {
+ if (!backend.IsAvailable)
+ {
+ continue;
+ }
+
+ try
+ {
+ await backend.UploadSlotAsync(slotIndex, filePaths.ToArray());
+ Debug.Log($"[CloudSaveManager] {backend.PlatformName} 上传槽位 {slotIndex} 完成。");
+ }
+ catch (Exception ex)
+ {
+ Debug.LogWarning($"[CloudSaveManager] {backend.PlatformName} 上传槽位 {slotIndex} 失败: {ex.Message}");
+ }
+ }
+ }
+
+ /// 收集槽位下需要同步的文件路径。
+ private static List CollectSlotFilePaths(int slotIndex)
+ {
+ var paths = new List();
+
+ var snapshotPath = SlotDirectory.GetSnapshotPath(slotIndex);
+ if (File.Exists(snapshotPath))
+ {
+ paths.Add(snapshotPath);
+ }
+
+ var metaPath = SlotDirectory.GetMetaPath(slotIndex);
+ if (File.Exists(metaPath))
+ {
+ paths.Add(metaPath);
+ }
+
+ var thumbnailPath = SlotDirectory.GetThumbnailPath(slotIndex);
+ if (File.Exists(thumbnailPath))
+ {
+ paths.Add(thumbnailPath);
+ }
+
+ return paths;
+ }
+ }
+}
diff --git a/Assets/Scripts/SaveSystem/CloudSave/CloudSaveManager.cs.meta b/Assets/Scripts/SaveSystem/CloudSave/CloudSaveManager.cs.meta
new file mode 100644
index 000000000..5d33b3aa0
--- /dev/null
+++ b/Assets/Scripts/SaveSystem/CloudSave/CloudSaveManager.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 87e980f299ac4341947c076cff16dc81
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/SaveSystem/CloudSave/ICloudSaveBackend.cs b/Assets/Scripts/SaveSystem/CloudSave/ICloudSaveBackend.cs
new file mode 100644
index 000000000..867440ef2
--- /dev/null
+++ b/Assets/Scripts/SaveSystem/CloudSave/ICloudSaveBackend.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Threading.Tasks;
+
+namespace AibisDream.SaveSystem.CloudSave
+{
+ ///
+ /// 云存档后端适配器接口。
+ ///
+ /// P2 仅定义接口与本地 no-op 实现,未来 Steamworks.NET / TapTap TDS SDK 通过实现此接口接入。
+ /// 每个槽位在本地对应一组文件(snapshot.json / meta.json / thumbnail.png),
+ /// backend 可选择逐文件上传,或将整组文件打包后上传。
+ ///
+ ///
+ public interface ICloudSaveBackend
+ {
+ /// 平台名称,用于日志。
+ string PlatformName { get; }
+
+ /// 当前平台是否可用。
+ bool IsAvailable { get; }
+
+ /// 上传槽位文件集。
+ /// 槽位编号。
+ /// 该槽位下需要同步的文件绝对路径。
+ Task UploadSlotAsync(int slotIndex, string[] filePaths);
+
+ /// 下载槽位文件集到目标目录。
+ Task DownloadSlotAsync(int slotIndex, string targetDirectory);
+
+ /// 删除云端槽位。
+ Task DeleteSlotAsync(int slotIndex);
+
+ /// 获取云端该槽位的时间戳,用于冲突检测。
+ /// 远程保存时间;不存在返回 null。
+ Task GetRemoteTimestampAsync(int slotIndex);
+ }
+}
diff --git a/Assets/Scripts/SaveSystem/CloudSave/ICloudSaveBackend.cs.meta b/Assets/Scripts/SaveSystem/CloudSave/ICloudSaveBackend.cs.meta
new file mode 100644
index 000000000..71ddf8481
--- /dev/null
+++ b/Assets/Scripts/SaveSystem/CloudSave/ICloudSaveBackend.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 783cf442f0484df9a89c7841604c0b49
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/SaveSystem/SlotFileSystem.cs b/Assets/Scripts/SaveSystem/SlotFileSystem.cs
new file mode 100644
index 000000000..4083eb4b6
--- /dev/null
+++ b/Assets/Scripts/SaveSystem/SlotFileSystem.cs
@@ -0,0 +1,120 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using AibisDream.Utility;
+using Newtonsoft.Json;
+using UnityEngine;
+
+namespace AibisDream.SaveSystem
+{
+ ///
+ /// 存档槽位目录与文件路径工具。
+ ///
+ public static class SlotDirectory
+ {
+ public static string GetSlotPath(int slotIndex)
+ {
+ return Path.Combine(ConstRef.SaveFilePath, $"{ConstRef.SaveSlotDirPrefix}{slotIndex}");
+ }
+
+ public static string GetSnapshotPath(int slotIndex)
+ {
+ return Path.Combine(GetSlotPath(slotIndex), $"{ConstRef.SaveSnapshotFileName}.json");
+ }
+
+ public static string GetMetaPath(int slotIndex)
+ {
+ return Path.Combine(GetSlotPath(slotIndex), $"{ConstRef.SaveMetaFileName}.json");
+ }
+
+ public static string GetThumbnailPath(int slotIndex)
+ {
+ return Path.Combine(GetSlotPath(slotIndex), $"{ConstRef.SaveThumbnailFileName}.png");
+ }
+
+ public static string GetLatestSlotIndexPath()
+ {
+ return Path.Combine(ConstRef.SaveFilePath, $"{ConstRef.LatestSlotFileName}.json");
+ }
+
+ public static bool Exists(int slotIndex)
+ {
+ return File.Exists(GetMetaPath(slotIndex));
+ }
+
+ public static IEnumerable EnumerateExistingSlots()
+ {
+ if (!Directory.Exists(ConstRef.SaveFilePath))
+ {
+ yield break;
+ }
+
+ for (var i = SlotIndex.Auto; i <= SlotIndex.ManualEnd; i++)
+ {
+ if (Exists(i))
+ {
+ yield return i;
+ }
+ }
+ }
+
+ public static void EnsureSlotDirectory(int slotIndex)
+ {
+ Directory.CreateDirectory(GetSlotPath(slotIndex));
+ }
+ }
+
+ ///
+ /// 槽位文件原子写工具。
+ /// 先写入临时文件,再重命名为最终文件,避免写入过程中崩溃导致存档损坏。
+ ///
+ public static class SlotAtomicWriter
+ {
+ public static void WriteJson(T obj, string finalPath)
+ {
+ var json = JsonConvert.SerializeObject(obj, Formatting.Indented);
+ WriteText(json, finalPath);
+ }
+
+ public static void WriteText(string text, string finalPath)
+ {
+ var tempPath = finalPath + ".tmp";
+ var dir = Path.GetDirectoryName(finalPath);
+ if (!string.IsNullOrEmpty(dir))
+ {
+ Directory.CreateDirectory(dir);
+ }
+
+ File.WriteAllText(tempPath, text);
+ CommitTempFile(tempPath, finalPath);
+ }
+
+ public static void WriteBytes(byte[] data, string finalPath)
+ {
+ if (data == null)
+ {
+ return;
+ }
+
+ var tempPath = finalPath + ".tmp";
+ var dir = Path.GetDirectoryName(finalPath);
+ if (!string.IsNullOrEmpty(dir))
+ {
+ Directory.CreateDirectory(dir);
+ }
+
+ File.WriteAllBytes(tempPath, data);
+ CommitTempFile(tempPath, finalPath);
+ }
+
+ private static void CommitTempFile(string tempPath, string finalPath)
+ {
+ if (File.Exists(finalPath))
+ {
+ File.Delete(finalPath);
+ }
+
+ File.Move(tempPath, finalPath);
+ }
+ }
+}
diff --git a/Assets/Scripts/SaveSystem/SlotFileSystem.cs.meta b/Assets/Scripts/SaveSystem/SlotFileSystem.cs.meta
new file mode 100644
index 000000000..0bf2766fd
--- /dev/null
+++ b/Assets/Scripts/SaveSystem/SlotFileSystem.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d819a034669842418c5e6b63b4a67cd3
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/SaveSystem/SlotManager.cs b/Assets/Scripts/SaveSystem/SlotManager.cs
new file mode 100644
index 000000000..78f5373f9
--- /dev/null
+++ b/Assets/Scripts/SaveSystem/SlotManager.cs
@@ -0,0 +1,381 @@
+using System;
+using System.IO;
+using System.Collections.Generic;
+using AibisDream.Utility;
+using Newtonsoft.Json;
+using UnityEngine;
+
+namespace AibisDream.SaveSystem
+{
+ ///
+ /// 存档槽位业务核心:管理自动档、手动档、读档、继续游戏与 P1 迁移。
+ ///
+ public static class SlotManager
+ {
+ private static bool _migrated;
+
+ /// 将快照与缩略图写入指定槽位。
+ public static void SaveToSlot(int slotIndex, SaveSnapshot snapshot, byte[] thumbnailPng)
+ {
+ EnsureMigrated();
+ WriteSlot(slotIndex, snapshot, thumbnailPng);
+ }
+
+ /// 将快照与缩略图写入自动档(slot 0)。
+ public static void SaveToAutoSlot(SaveSnapshot snapshot, byte[] thumbnailPng)
+ {
+ SaveToSlot(SlotIndex.Auto, snapshot, thumbnailPng);
+ SetLatestSlotIndex(SlotIndex.Auto);
+ CloudSaveManager.OnSlotWritten(SlotIndex.Auto);
+ }
+
+ /// 将当前自动档复制到指定手动档。
+ public static void CopyAutoToManual(int manualSlotIndex)
+ {
+ EnsureMigrated();
+
+ if (manualSlotIndex < SlotIndex.ManualStart || manualSlotIndex > SlotIndex.ManualEnd)
+ {
+ Debug.LogError($"[SlotManager] 手动槽位编号非法: {manualSlotIndex}");
+ return;
+ }
+
+ if (!SlotDirectory.Exists(SlotIndex.Auto))
+ {
+ Debug.LogError("[SlotManager] 自动档不存在,无法复制手动档。");
+ return;
+ }
+
+ SlotDirectory.EnsureSlotDirectory(manualSlotIndex);
+
+ var sourceDir = SlotDirectory.GetSlotPath(SlotIndex.Auto);
+ var targetDir = SlotDirectory.GetSlotPath(manualSlotIndex);
+
+ CopyFileIfExists(
+ Path.Combine(sourceDir, $"{ConstRef.SaveSnapshotFileName}.json"),
+ Path.Combine(targetDir, $"{ConstRef.SaveSnapshotFileName}.json"));
+ CopyFileIfExists(
+ Path.Combine(sourceDir, $"{ConstRef.SaveMetaFileName}.json"),
+ Path.Combine(targetDir, $"{ConstRef.SaveMetaFileName}.json"));
+ CopyFileIfExists(
+ Path.Combine(sourceDir, $"{ConstRef.SaveThumbnailFileName}.png"),
+ Path.Combine(targetDir, $"{ConstRef.SaveThumbnailFileName}.png"));
+
+ // 修正 meta 中的 slotIndex 为手动档编号
+ var meta = LoadMeta(manualSlotIndex);
+ if (meta != null)
+ {
+ meta.slotIndex = manualSlotIndex;
+ WriteMeta(manualSlotIndex, meta);
+ }
+
+ SetLatestSlotIndex(manualSlotIndex);
+ CloudSaveManager.OnSlotWritten(manualSlotIndex);
+
+ Debug.Log($"[SlotManager] 已复制自动档到手动槽位 {manualSlotIndex}。");
+ }
+
+ /// 删除指定槽位。
+ public static void DeleteSlot(int slotIndex)
+ {
+ EnsureMigrated();
+
+ if (slotIndex == SlotIndex.Auto)
+ {
+ // 自动档只清空内容,不删除目录
+ DeleteSlotFiles(slotIndex);
+ Debug.Log("[SlotManager] 已清空自动档。");
+ return;
+ }
+
+ if (slotIndex < SlotIndex.ManualStart || slotIndex > SlotIndex.ManualEnd)
+ {
+ Debug.LogError($"[SlotManager] 槽位编号非法: {slotIndex}");
+ return;
+ }
+
+ var dir = SlotDirectory.GetSlotPath(slotIndex);
+ if (Directory.Exists(dir))
+ {
+ Directory.Delete(dir, recursive: true);
+ Debug.Log($"[SlotManager] 已删除槽位 {slotIndex}。");
+ }
+
+ CloudSaveManager.OnSlotWritten(slotIndex);
+ }
+
+ /// 读取槽位的完整快照。
+ public static SaveSnapshot LoadSnapshot(int slotIndex)
+ {
+ EnsureMigrated();
+
+ var path = SlotDirectory.GetSnapshotPath(slotIndex);
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ try
+ {
+ var json = File.ReadAllText(path);
+ return SnapshotPersistence.Deserialize(json);
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[SlotManager] 读取槽位 {slotIndex} 快照失败: {ex.Message}");
+ return null;
+ }
+ }
+
+ /// 读取槽位元数据。
+ public static SlotMeta LoadMeta(int slotIndex)
+ {
+ var path = SlotDirectory.GetMetaPath(slotIndex);
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ try
+ {
+ var json = File.ReadAllText(path);
+ return JsonConvert.DeserializeObject(json);
+ }
+ catch (Exception ex)
+ {
+ Debug.LogWarning($"[SlotManager] 读取槽位 {slotIndex} meta 失败: {ex.Message}");
+ return null;
+ }
+ }
+
+ /// 读取槽位缩略图字节。
+ public static byte[] LoadThumbnail(int slotIndex)
+ {
+ var path = SlotDirectory.GetThumbnailPath(slotIndex);
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ try
+ {
+ return File.ReadAllBytes(path);
+ }
+ catch (Exception ex)
+ {
+ Debug.LogWarning($"[SlotManager] 读取槽位 {slotIndex} 缩略图失败: {ex.Message}");
+ return null;
+ }
+ }
+
+ /// 获取最近写入的槽位编号;不存在返回 null。
+ public static int? GetLatestSlotIndex()
+ {
+ EnsureMigrated();
+
+ var path = SlotDirectory.GetLatestSlotIndexPath();
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ try
+ {
+ var json = File.ReadAllText(path);
+ var record = JsonConvert.DeserializeObject(json);
+ if (record != null && SlotDirectory.Exists(record.latestSlot))
+ {
+ return record.latestSlot;
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.LogWarning($"[SlotManager] 读取 latest_slot 失败: {ex.Message}");
+ }
+
+ return null;
+ }
+
+ /// 更新最近写入槽位索引。
+ public static void SetLatestSlotIndex(int slotIndex)
+ {
+ var record = new LatestSlotRecord
+ {
+ latestSlot = slotIndex,
+ updatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
+ };
+ SlotAtomicWriter.WriteJson(record, SlotDirectory.GetLatestSlotIndexPath());
+ }
+
+ /// 获取供 UI 展示的全部槽位视图模型。
+ public static SlotViewModel[] GetSlotViewModels()
+ {
+ EnsureMigrated();
+
+ var result = new List();
+ for (var i = SlotIndex.Auto; i <= SlotIndex.ManualEnd; i++)
+ {
+ result.Add(BuildViewModel(i));
+ }
+
+ return result.ToArray();
+ }
+
+ #region Private Helpers
+
+ private static void WriteSlot(int slotIndex, SaveSnapshot snapshot, byte[] thumbnailPng)
+ {
+ SlotDirectory.EnsureSlotDirectory(slotIndex);
+
+ // snapshot.json
+ var snapshotPath = SlotDirectory.GetSnapshotPath(slotIndex);
+ var json = SnapshotPersistence.Serialize(snapshot);
+ SlotAtomicWriter.WriteText(json, snapshotPath);
+
+ // meta.json
+ var summary = SaveSnapshotSummary.From(snapshot);
+ var meta = new SlotMeta
+ {
+ slotIndex = slotIndex,
+ savedAt = snapshot.savedAt,
+ sceneName = summary.SceneName,
+ sceneSoName = summary.SceneSoName,
+ yarnProjectId = summary.YarnProjectId,
+ schemaVersion = snapshot.schemaVersion,
+ gameVersion = snapshot.gameVersion,
+ thumbnailFile = $"{ConstRef.SaveThumbnailFileName}.png"
+ };
+ WriteMeta(slotIndex, meta);
+
+ // thumbnail.png
+ if (thumbnailPng != null)
+ {
+ SlotAtomicWriter.WriteBytes(thumbnailPng, SlotDirectory.GetThumbnailPath(slotIndex));
+ }
+ }
+
+ private static void WriteMeta(int slotIndex, SlotMeta meta)
+ {
+ SlotAtomicWriter.WriteJson(meta, SlotDirectory.GetMetaPath(slotIndex));
+ }
+
+ private static void CopyFileIfExists(string sourcePath, string targetPath)
+ {
+ if (!File.Exists(sourcePath))
+ {
+ return;
+ }
+
+ var dir = Path.GetDirectoryName(targetPath);
+ if (!string.IsNullOrEmpty(dir))
+ {
+ Directory.CreateDirectory(dir);
+ }
+
+ File.Copy(sourcePath, targetPath, overwrite: true);
+ }
+
+ private static void DeleteSlotFiles(int slotIndex)
+ {
+ var dir = SlotDirectory.GetSlotPath(slotIndex);
+ if (!Directory.Exists(dir))
+ {
+ return;
+ }
+
+ foreach (var file in Directory.GetFiles(dir))
+ {
+ File.Delete(file);
+ }
+ }
+
+ private static SlotViewModel BuildViewModel(int slotIndex)
+ {
+ var meta = LoadMeta(slotIndex);
+ if (meta == null)
+ {
+ return new SlotViewModel(
+ slotIndex,
+ isEmpty: true,
+ isAutoSlot: slotIndex == SlotIndex.Auto,
+ savedAt: null,
+ sceneName: null,
+ sceneSoName: null,
+ thumbnailPath: null);
+ }
+
+ var thumbnailPath = SlotDirectory.GetThumbnailPath(slotIndex);
+ if (!File.Exists(thumbnailPath))
+ {
+ thumbnailPath = null;
+ }
+
+ return new SlotViewModel(
+ slotIndex,
+ isEmpty: false,
+ isAutoSlot: slotIndex == SlotIndex.Auto,
+ savedAt: meta.savedAt,
+ sceneName: meta.sceneName,
+ sceneSoName: meta.sceneSoName,
+ thumbnailPath: thumbnailPath);
+ }
+
+ private static void EnsureMigrated()
+ {
+ if (_migrated)
+ {
+ return;
+ }
+
+ _migrated = true;
+ TryMigrateP1TestSnapshot();
+ }
+
+ private static void TryMigrateP1TestSnapshot()
+ {
+ var testPath = Path.Combine(ConstRef.SnapshotTestPath, $"{ConstRef.SnapshotTestFileName}.json");
+ if (!File.Exists(testPath))
+ {
+ return;
+ }
+
+ if (SlotDirectory.Exists(SlotIndex.Auto))
+ {
+ return;
+ }
+
+ try
+ {
+ var snapshot = SnapshotPersistence.Load(testPath);
+ if (snapshot == null)
+ {
+ return;
+ }
+
+ SaveToAutoSlot(snapshot, null);
+
+ var migratedPath = testPath + ".migrated";
+ if (File.Exists(migratedPath))
+ {
+ File.Delete(migratedPath);
+ }
+ File.Move(testPath, migratedPath);
+
+ Debug.Log($"[SlotManager] 已将 P1 测试存档从 {testPath} 迁移到 slot_0。");
+ }
+ catch (Exception ex)
+ {
+ Debug.LogWarning($"[SlotManager] P1 测试存档迁移失败: {ex.Message}");
+ }
+ }
+
+ [Serializable]
+ private class LatestSlotRecord
+ {
+ public int latestSlot;
+ public string updatedAt;
+ }
+
+ #endregion
+ }
+}
diff --git a/Assets/Scripts/SaveSystem/SlotManager.cs.meta b/Assets/Scripts/SaveSystem/SlotManager.cs.meta
new file mode 100644
index 000000000..14cb7c7d8
--- /dev/null
+++ b/Assets/Scripts/SaveSystem/SlotManager.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: af4d8624a0d645ce9b07f73024290cd6
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/SaveSystem/SlotThumbnailCapture.cs b/Assets/Scripts/SaveSystem/SlotThumbnailCapture.cs
new file mode 100644
index 000000000..3d4b3561d
--- /dev/null
+++ b/Assets/Scripts/SaveSystem/SlotThumbnailCapture.cs
@@ -0,0 +1,48 @@
+using AibisDream.Utility;
+using UnityEngine;
+
+namespace AibisDream.SaveSystem
+{
+ ///
+ /// 存档槽位缩略图捕获工具。
+ ///
+ /// 使用主相机渲染到 RenderTexture 后读像素并编码为 PNG。
+ /// 捕获失败时安全返回 null,不会阻塞存档流程。
+ ///
+ ///
+ public static class SlotThumbnailCapture
+ {
+ /// 捕获当前画面的 PNG 字节数组。
+ public static byte[] CapturePng()
+ {
+ var cam = Camera.main;
+ if (cam == null)
+ {
+ Debug.LogWarning("[SlotThumbnailCapture] 未找到 Camera.main,跳过截图。");
+ return null;
+ }
+
+ var width = ConstRef.SaveThumbnailWidth;
+ var height = ConstRef.SaveThumbnailHeight;
+
+ var rt = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.Default);
+ var prevTarget = cam.targetTexture;
+ cam.targetTexture = rt;
+ cam.Render();
+ cam.targetTexture = prevTarget;
+
+ RenderTexture.active = rt;
+ var tex = new Texture2D(width, height, TextureFormat.RGB24, false);
+ tex.ReadPixels(new Rect(0, 0, width, height), 0, 0);
+ tex.Apply();
+ RenderTexture.active = null;
+
+ var bytes = tex.EncodeToPNG();
+
+ Object.Destroy(tex);
+ RenderTexture.ReleaseTemporary(rt);
+
+ return bytes;
+ }
+ }
+}
diff --git a/Assets/Scripts/SaveSystem/SlotThumbnailCapture.cs.meta b/Assets/Scripts/SaveSystem/SlotThumbnailCapture.cs.meta
new file mode 100644
index 000000000..41a496b69
--- /dev/null
+++ b/Assets/Scripts/SaveSystem/SlotThumbnailCapture.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 63f375c0f57444058bef24100d912ece
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/SaveSystem/SlotTypes.cs b/Assets/Scripts/SaveSystem/SlotTypes.cs
new file mode 100644
index 000000000..b7c3ae0bf
--- /dev/null
+++ b/Assets/Scripts/SaveSystem/SlotTypes.cs
@@ -0,0 +1,60 @@
+using System;
+
+namespace AibisDream.SaveSystem
+{
+ ///
+ /// 存档槽位编号常量。
+ /// slot_0 为自动档;slot_1..slot_8 为手动档。
+ ///
+ public static class SlotIndex
+ {
+ public const int Auto = 0;
+ public const int ManualStart = 1;
+ public const int ManualEnd = 8;
+ public const int ManualSlotCount = ManualEnd - ManualStart + 1;
+ }
+
+ ///
+ /// 单个存档槽位的轻量 sidecar 元数据。
+ /// 与完整 分离,避免 UI 枚举槽位时反复解析大 JSON。
+ ///
+ [Serializable]
+ public class SlotMeta
+ {
+ public int slotIndex;
+ public string savedAt;
+ public string sceneName;
+ public string sceneSoName;
+ public string yarnProjectId;
+ public int schemaVersion;
+ public string gameVersion;
+ public string thumbnailFile;
+ }
+
+ ///
+ /// 存档槽位 UI 展示用的只读数据契约。
+ /// 由 产出,供 P6 的 SlotPanel 消费。
+ ///
+ public readonly struct SlotViewModel
+ {
+ public readonly int SlotIndex;
+ public readonly bool IsEmpty;
+ public readonly bool IsAutoSlot;
+ public readonly string SavedAt;
+ public readonly string SceneName;
+ public readonly string SceneSoName;
+ public readonly string ThumbnailPath;
+
+ public SlotViewModel(int slotIndex, bool isEmpty, bool isAutoSlot, string savedAt,
+ string sceneName, string sceneSoName, string thumbnailPath)
+ {
+ SlotIndex = slotIndex;
+ IsEmpty = isEmpty;
+ IsAutoSlot = isAutoSlot;
+ SavedAt = savedAt;
+ SceneName = sceneName;
+ SceneSoName = sceneSoName;
+ ThumbnailPath = thumbnailPath;
+ }
+ }
+}
diff --git a/Assets/Scripts/SaveSystem/SlotTypes.cs.meta b/Assets/Scripts/SaveSystem/SlotTypes.cs.meta
new file mode 100644
index 000000000..e38018807
--- /dev/null
+++ b/Assets/Scripts/SaveSystem/SlotTypes.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 3fec09cdbac7484ba1f6c6c361aae980
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: