diff --git a/Assets/Scripts/AssetRefs/ConstRef.cs b/Assets/Scripts/AssetRefs/ConstRef.cs index 9980b77a9..b4c2dfb6e 100644 --- a/Assets/Scripts/AssetRefs/ConstRef.cs +++ b/Assets/Scripts/AssetRefs/ConstRef.cs @@ -23,6 +23,12 @@ namespace AibisDream.Utility public static readonly string SaveFilePath = Path.Combine(Application.persistentDataPath, "AllOurBrokenParts", "saves"); + /// P1 快照层自测落盘路径(P2 槽位层接管后迁移)。 + public static readonly string SnapshotTestPath = + Path.Combine(Application.persistentDataPath, "AllOurBrokenParts", "snapshot_test"); + + public const string SnapshotTestFileName = "latest_snapshot"; + public static readonly string ChapterProgressPath = Path.Combine(Application.persistentDataPath, "AllOurBrokenParts", "Config", "chapter.json"); public static readonly string CharacterConfigPath = Path.Combine(Application.streamingAssetsPath, "Config", "character.csv"); diff --git a/Assets/Scripts/SaveSystem/DeepRepairDataRegistry.cs b/Assets/Scripts/SaveSystem/DeepRepairDataRegistry.cs new file mode 100644 index 000000000..f65b80477 --- /dev/null +++ b/Assets/Scripts/SaveSystem/DeepRepairDataRegistry.cs @@ -0,0 +1,39 @@ +using System.Collections; +using AibisDream.Framework; +using Newtonsoft.Json.Linq; + +namespace AibisDream.SaveSystem +{ + /// + /// 深度维修子系统的旧 IData 注册表(基于 )。 + /// + /// FixSystem / Eye / Chip 等仍通过此处 RegisterData;尚未纳入新 。 + /// P5 评估后迁移或废弃;与 、快照 I/O 分离。 + /// + /// + public static class DeepRepairDataRegistry + { + private static readonly DataContainer Container = new(); + + public static void RegisterData(T data) where T : IData + { + Container.Register(data); + } + + public static void UnregisterData() where T : class, IData + { + Container.Unregister(); + } + + /// 旧格式存档 systemData 段还原;按 LoadIndex 顺序执行各 IData.Load()。 + public static IEnumerator LoadByJson(JObject dataJson) + { + if (dataJson == null) + { + yield break; + } + + yield return Container.LoadByJson(dataJson); + } + } +} diff --git a/Assets/Scripts/SaveSystem/DeepRepairDataRegistry.cs.meta b/Assets/Scripts/SaveSystem/DeepRepairDataRegistry.cs.meta new file mode 100644 index 000000000..f3174e2cb --- /dev/null +++ b/Assets/Scripts/SaveSystem/DeepRepairDataRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ba4cbc590c8177045b16b8a02db31121 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/DeepRepairSnapshot.cs b/Assets/Scripts/SaveSystem/DeepRepairSnapshot.cs new file mode 100644 index 000000000..499498276 --- /dev/null +++ b/Assets/Scripts/SaveSystem/DeepRepairSnapshot.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace AibisDream.SaveSystem +{ + /// + /// 深度维修是否纳入新快照、以何种粒度纳入(P5 逐个定案)。 + /// + public enum DeepRepairSavePolicy + { + /// 完整阶段终点状态写入 deepRepair.sections。 + Supported, + + /// 只存简化后的关键字段。 + Simplified, + + /// 不存;读档回到进入该维修前的可存点。 + Dropped + } + + /// + /// 占位段。 + /// P1 捕获时不写入;读档时缺省或 sections 为空则不报错。 + /// 正式实现后替代/收敛 的旧 IData 路径。 + /// + [Serializable] + public class DeepRepairSnapshot + { + public DeepRepairSavePolicy policy = DeepRepairSavePolicy.Dropped; + + /// 按维修模块 id 分子段,具体 key 在 P5 定义。 + public Dictionary sections = new(); + } +} diff --git a/Assets/Scripts/SaveSystem/DeepRepairSnapshot.cs.meta b/Assets/Scripts/SaveSystem/DeepRepairSnapshot.cs.meta new file mode 100644 index 000000000..a64eebf57 --- /dev/null +++ b/Assets/Scripts/SaveSystem/DeepRepairSnapshot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6ee71c8af7b93d64aa87335a011eccf5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/ISnapshotProvider.cs b/Assets/Scripts/SaveSystem/ISnapshotProvider.cs new file mode 100644 index 000000000..459f708f5 --- /dev/null +++ b/Assets/Scripts/SaveSystem/ISnapshotProvider.cs @@ -0,0 +1,31 @@ +using System.Collections; + +namespace AibisDream.SaveSystem +{ + /// + /// 快照提供者契约:将某一子系统的运行时状态与纯数据 DTO 互相转换。 + /// + /// 设计约定:DTO 不含事件、协程或 Unity 引用; 只读, + /// 负责逐项写回。实现类通常很薄,实际逻辑在各 Manager 中。 + /// + /// + public interface ISnapshotProvider + { + /// + /// 稳定显式 id,用作 的 key。 + /// 见 ,不使用 C# 类型全名。 + /// + string SaveId { get; } + + /// + /// 读档还原顺序。数值越小越先执行(如 scene=10 先于 actor=40)。 + /// + int RestoreOrder { get; } + + /// 从当前运行时捕获该子系统的快照 DTO;无可存内容时可返回 null。 + object Capture(); + + /// 将 DTO 逐项还原到运行时;可为协程以等待异步加载。 + IEnumerator Restore(object dto); + } +} diff --git a/Assets/Scripts/SaveSystem/ISnapshotProvider.cs.meta b/Assets/Scripts/SaveSystem/ISnapshotProvider.cs.meta new file mode 100644 index 000000000..87500a0da --- /dev/null +++ b/Assets/Scripts/SaveSystem/ISnapshotProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6a7b39d650ea48340bba1daa7387d119 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/Providers.meta b/Assets/Scripts/SaveSystem/Providers.meta new file mode 100644 index 000000000..7c09ab0fa --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ce5fddeb465208c4ea3dbd9eacaa8d7b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/Providers/ActorSnapshotProvider.cs b/Assets/Scripts/SaveSystem/Providers/ActorSnapshotProvider.cs new file mode 100644 index 000000000..97b7d66c6 --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/ActorSnapshotProvider.cs @@ -0,0 +1,29 @@ +using System.Collections; + +namespace AibisDream.SaveSystem +{ + /// + /// sections["actor"]:场景中各角色显隐、槽位、动画状态、透明度等(ActorManager)。 + /// RestoreOrder=40,依赖 scene 与 env 已就绪。 + /// + public class ActorSnapshotProvider : ISnapshotProvider + { + public string SaveId => SnapshotProviderIds.Actor; + public int RestoreOrder => 40; + + public object Capture() + { + return ActorManager.Instance.CaptureSnapshot(); + } + + public IEnumerator Restore(object dto) + { + if (dto is not ActorSnapshotDto actors) + { + yield break; + } + + yield return ActorManager.Instance.RestoreSnapshot(actors); + } + } +} diff --git a/Assets/Scripts/SaveSystem/Providers/ActorSnapshotProvider.cs.meta b/Assets/Scripts/SaveSystem/Providers/ActorSnapshotProvider.cs.meta new file mode 100644 index 000000000..7f522ba8d --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/ActorSnapshotProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0e062fe96fad9b545bc01559345476cb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/Providers/AudioSnapshotProvider.cs b/Assets/Scripts/SaveSystem/Providers/AudioSnapshotProvider.cs new file mode 100644 index 000000000..92a74ac04 --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/AudioSnapshotProvider.cs @@ -0,0 +1,30 @@ +using System.Collections; +using AibisDream.Kit; + +namespace AibisDream.SaveSystem +{ + /// + /// sections["audio"]:FMOD 音乐/SFX 实例路径与 AMB 状态终态(AudioManager)。 + /// 不记录播放进度,读档后按路径重新 CreateInstance 并 start。 + /// + public class AudioSnapshotProvider : ISnapshotProvider + { + public string SaveId => SnapshotProviderIds.Audio; + public int RestoreOrder => 50; + + public object Capture() + { + return AudioManager.Instance.CaptureSnapshot(); + } + + public IEnumerator Restore(object dto) + { + if (dto is not AudioSnapshotDto audio) + { + yield break; + } + + yield return AudioManager.Instance.RestoreSnapshot(audio); + } + } +} diff --git a/Assets/Scripts/SaveSystem/Providers/AudioSnapshotProvider.cs.meta b/Assets/Scripts/SaveSystem/Providers/AudioSnapshotProvider.cs.meta new file mode 100644 index 000000000..e8fe23045 --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/AudioSnapshotProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2158be13aca7fb74985d11effbf0304d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/Providers/EnvironmentSnapshotProvider.cs b/Assets/Scripts/SaveSystem/Providers/EnvironmentSnapshotProvider.cs new file mode 100644 index 000000000..ca84c6f3f --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/EnvironmentSnapshotProvider.cs @@ -0,0 +1,29 @@ +using System.Collections; + +namespace AibisDream.SaveSystem +{ + /// + /// sections["env"]:场景内时间/天气(EnvironmentManager)。 + /// RestoreOrder=20,在 scene 加载完成后、macro 之前还原环境。 + /// + public class EnvironmentSnapshotProvider : ISnapshotProvider + { + public string SaveId => SnapshotProviderIds.Environment; + public int RestoreOrder => 20; + + public object Capture() + { + return EnvironmentManager.Instance.CaptureSnapshot(); + } + + public IEnumerator Restore(object dto) + { + if (dto is not EnvironmentSnapshotDto env) + { + yield break; + } + + yield return EnvironmentManager.Instance.RestoreSnapshot(env); + } + } +} diff --git a/Assets/Scripts/SaveSystem/Providers/EnvironmentSnapshotProvider.cs.meta b/Assets/Scripts/SaveSystem/Providers/EnvironmentSnapshotProvider.cs.meta new file mode 100644 index 000000000..8bb529916 --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/EnvironmentSnapshotProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a879f9bc4b096c346be7af81876c792c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/Providers/MacroSnapshotProvider.cs b/Assets/Scripts/SaveSystem/Providers/MacroSnapshotProvider.cs new file mode 100644 index 000000000..1a20cb8a2 --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/MacroSnapshotProvider.cs @@ -0,0 +1,56 @@ +using System.Collections; +using UnityEngine; + +namespace AibisDream.SaveSystem +{ + /// + /// sections["macro"]:章节 TalkSceneSO 与 YarnProject 绑定。 + /// RestoreOrder=30,在场景与环境就绪后设置 GameManager 并 LoadDialog(不 Start 节点,锚点由 RestoreAnchor 负责)。 + /// + public class MacroSnapshotProvider : ISnapshotProvider + { + public string SaveId => SnapshotProviderIds.Macro; + public int RestoreOrder => 30; + + public object Capture() + { + var gameManager = GameManager.Instance; + var dialog = DialogController.Instance; + var yarnProject = dialog?.DialogueRunner?.YarnProject; + + return new MacroSnapshotDto + { + sceneSoName = gameManager != null ? gameManager.GetSceneSoName() : null, + yarnProjectId = yarnProject != null ? yarnProject.name : null + }; + } + + public IEnumerator Restore(object dto) + { + if (dto is not MacroSnapshotDto macro) + { + yield break; + } + + if (!string.IsNullOrEmpty(macro.sceneSoName)) + { + GameManager.Instance.SetSceneSoByName(macro.sceneSoName); + } + + if (!string.IsNullOrEmpty(macro.yarnProjectId)) + { + var sceneSo = GameManager.Instance.GetCurrentTalkSceneSo(); + if (sceneSo?.yarnProject != null && sceneSo.yarnProject.name == macro.yarnProjectId) + { + DialogController.Instance.LoadDialog(sceneSo.yarnProject); + } + else + { + Debug.LogWarning($"[MacroSnapshotProvider] 无法匹配 YarnProject: {macro.yarnProjectId}"); + } + } + + yield break; + } + } +} diff --git a/Assets/Scripts/SaveSystem/Providers/MacroSnapshotProvider.cs.meta b/Assets/Scripts/SaveSystem/Providers/MacroSnapshotProvider.cs.meta new file mode 100644 index 000000000..d2883a8a3 --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/MacroSnapshotProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: af663fafb5b2a9748902e2e34e1f6be3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/Providers/SceneSnapshotProvider.cs b/Assets/Scripts/SaveSystem/Providers/SceneSnapshotProvider.cs new file mode 100644 index 000000000..ef56fd906 --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/SceneSnapshotProvider.cs @@ -0,0 +1,33 @@ +using System.Collections; + +namespace AibisDream.SaveSystem +{ + /// + /// sections["scene"]:当前 Addressable 场景。 + /// RestoreOrder=10,读档时最先加载场景,供后续 env/actor 等依赖。 + /// + public class SceneSnapshotProvider : ISnapshotProvider + { + public string SaveId => SnapshotProviderIds.Scene; + public int RestoreOrder => 10; + + public object Capture() + { + var sceneLoader = SceneLoader.Instance; + return new SceneSnapshotDto + { + sceneName = sceneLoader != null ? sceneLoader.CurrentSceneName : null + }; + } + + public IEnumerator Restore(object dto) + { + if (dto is not SceneSnapshotDto scene || string.IsNullOrEmpty(scene.sceneName)) + { + yield break; + } + + yield return SceneLoader.Instance.LoadSceneAsync(scene.sceneName); + } + } +} diff --git a/Assets/Scripts/SaveSystem/Providers/SceneSnapshotProvider.cs.meta b/Assets/Scripts/SaveSystem/Providers/SceneSnapshotProvider.cs.meta new file mode 100644 index 000000000..7c5c2fa6d --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/SceneSnapshotProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d814b6c56374b4446baa86e09f510098 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/Providers/ScreenSnapshotProvider.cs b/Assets/Scripts/SaveSystem/Providers/ScreenSnapshotProvider.cs new file mode 100644 index 000000000..6fba58af8 --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/ScreenSnapshotProvider.cs @@ -0,0 +1,30 @@ +using System.Collections; + +namespace AibisDream.SaveSystem +{ + /// + /// sections["screen"]:屏幕饱和度与演出淡入淡出遮罩。 + /// 委托 ;RestoreOrder=70,通常在表现类最后还原。 + /// + public class ScreenSnapshotProvider : ISnapshotProvider + { + public string SaveId => SnapshotProviderIds.Screen; + public int RestoreOrder => 70; + + public object Capture() + { + return ScreenSnapshotHelper.Capture(); + } + + public IEnumerator Restore(object dto) + { + if (dto is not ScreenSnapshotDto screen) + { + yield break; + } + + ScreenSnapshotHelper.Restore(screen); + yield break; + } + } +} diff --git a/Assets/Scripts/SaveSystem/Providers/ScreenSnapshotProvider.cs.meta b/Assets/Scripts/SaveSystem/Providers/ScreenSnapshotProvider.cs.meta new file mode 100644 index 000000000..11722efc6 --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/ScreenSnapshotProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 07b9ceaefa3550f4583465dd32e420f2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/Providers/TimelineSnapshotProvider.cs b/Assets/Scripts/SaveSystem/Providers/TimelineSnapshotProvider.cs new file mode 100644 index 000000000..b0ff05e04 --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/TimelineSnapshotProvider.cs @@ -0,0 +1,30 @@ +using System.Collections; + +namespace AibisDream.SaveSystem +{ + /// + /// sections["timeline"]:各 PlayableDirector 的终/初态(TimelineCenter)。 + /// 按 D1.1 只存 AtStart/AtEnd/Stopped,不存时间轴中间帧。 + /// + public class TimelineSnapshotProvider : ISnapshotProvider + { + public string SaveId => SnapshotProviderIds.Timeline; + public int RestoreOrder => 60; + + public object Capture() + { + return TimelineCenter.Instance.CaptureSnapshot(); + } + + public IEnumerator Restore(object dto) + { + if (dto is not TimelineSnapshotDto timeline) + { + yield break; + } + + TimelineCenter.Instance.RestoreSnapshot(timeline); + yield break; + } + } +} diff --git a/Assets/Scripts/SaveSystem/Providers/TimelineSnapshotProvider.cs.meta b/Assets/Scripts/SaveSystem/Providers/TimelineSnapshotProvider.cs.meta new file mode 100644 index 000000000..1da746c01 --- /dev/null +++ b/Assets/Scripts/SaveSystem/Providers/TimelineSnapshotProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2630a928f06aef14bb4a8520c743f3b5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/README.md b/Assets/Scripts/SaveSystem/README.md new file mode 100644 index 000000000..4094a8f78 --- /dev/null +++ b/Assets/Scripts/SaveSystem/README.md @@ -0,0 +1,148 @@ +# SaveSystem 存档快照层 + +> P1 实现:纯数据快照 + 逐项还原。设计文档见 [`Docs/存档系统设计方案.md`](../../../Docs/存档系统设计方案.md)(**§4.1 / D6** 描述读档编排终态)。 + +## 职责边界 + +| 模块 | 位置 | 做什么 | +| --- | --- | --- | +| Yarn 变量 | `Game Loop/YarnVariableStorage.cs` | 运行时 `$` / `$global_` 读写 | +| **本目录** | `SaveSystem/` | 快照结构、捕获/还原、落盘、流程编排 | +| 深度维修(旧) | `DeepRepairDataRegistry.cs` | FixSystem 等 IData,P5 前临时保留 | +| 槽位 / 截图 | P2 待做 | 接管 `SnapshotPersistence` 与目录布局 | + +**不要**再往 `YarnVariableStorage` 上堆存档逻辑。 + +## 对外入口 + +```csharp +// 存盘(<>、调试) +SaveRestoreOrchestrator.SaveToTestPath(); + +// 读盘 +yield return SaveRestoreOrchestrator.RestoreFromFile(path); + +// 仅内存快照(P2 手动档固化等) +var snap = SnapshotService.Capture(); +yield return SnapshotService.Restore(snap); + +// Yarn 变量(与存档无关) +YarnVariableStorage.Instance.SetValue("$foo", 1f); +``` + +P1 测试落盘路径:`persistentDataPath/AllOurBrokenParts/snapshot_test/latest_snapshot.json` + +## 目录结构 + +``` +SaveSystem/ +├── README.md ← 本文件 +├── ISnapshotProvider.cs 契约:Capture / Restore(P1 临时形态,见下) +├── SaveSnapshot.cs 快照根对象 + 各 section DTO +├── SnapshotProviderIds.cs section 稳定 id("actor" 等) +├── SnapshotRegistry.cs provider 注册表 +├── SnapshotBootstrap.cs 启动时注册 7 个 provider +├── SnapshotCapture.cs 组装 SaveSnapshot +├── SnapshotRestore.cs 写回运行时 + 锚点重进节点 +├── SnapshotService.cs Capture / Restore 对外 API +├── SnapshotPersistence.cs 文件读写、旧档检测 +├── SnapshotSerializer.cs JSON + schemaVersion +├── SnapshotSectionDeserializer.cs JObject → 强类型 DTO +├── SaveRestoreOrchestrator.cs UI + 落盘 + 读盘编排 +├── DeepRepairSnapshot.cs 深度维修占位(P5) +├── DeepRepairDataRegistry.cs 旧 IData 容器(P5 前) +├── ScreenSnapshotHelper.cs screen section 实现细节 +└── Providers/ 各子系统薄适配层 + ├── SceneSnapshotProvider.cs order 10 + ├── EnvironmentSnapshotProvider.cs order 20 + ├── MacroSnapshotProvider.cs order 30 + ├── ActorSnapshotProvider.cs order 40 + ├── AudioSnapshotProvider.cs order 50 + ├── TimelineSnapshotProvider.cs order 60 + └── ScreenSnapshotProvider.cs order 70 +``` + +## 存盘 / 读盘流程 + +``` +存盘: + SaveRestoreOrchestrator + → SnapshotService.Capture() + → SnapshotCapture(锚点 + Yarn 变量 + 各 Provider) + → SnapshotPersistence.Save() + +读盘: + SaveRestoreOrchestrator + → SnapshotPersistence.Load() (或 legacy 分支) + → SnapshotService.Restore() + → SnapshotRestore(见「读档还原编排」) +``` + +## 读档还原编排(终态 vs P1 代码) + +> **勿将当前代码当作终态。** P1 中 `ISnapshotProvider.Restore` 统一返回 `IEnumerator`,`SnapshotRestore` 对每个 Provider 做 `yield return`——这是受旧 `IData.Load()` 影响的**临时 scaffolding**。终态见设计文档 **§4.1 / D6**;P4 实施前应 refactor。 + +### 终态:Phase + Barrier + +读档不是「每个 Provider 串行协程链」,而是分阶段推进,仅在**必须等待**的边界上 `yield`: + +``` +Phase 0 Yarn 变量(sync) + ↓ +Phase 1 场景加载(Barrier:LoadSceneAsync) + ↓ +Phase 2 macro / env / actor / audio / timeline / screen(sync 批量,同帧连续调用) + ↓ +Phase 2′ 可选 Barrier(如 Timeline Addressable 须显式等待) + ↓ +Phase 3 RestoreAnchor(Barrier:StartDialogue) + ↓ +Phase 4 P4:淡入淡出等演出时序 +``` + +- **逐项还原(D1)**指各子系统各自写回状态,**不是** Provider 之间逐步 `yield return`。 +- **`RestoreOrder`** 表示同 Phase 内的建议顺序或软依赖(scene 先于 env/actor 等),**不是**「每步必须挂协程」。硬依赖用 Phase / Barrier 表达。 + +### P1 临时形态 vs P4 目标 + +| | P1(当前) | P4 目标 | +| --- | --- | --- | +| Provider 还原 | 全部 `IEnumerator Restore` | 默认 `void Restore`;仅 scene 等实现显式 async | +| 编排 | `foreach` 逐步 `yield return` | Phase 编排,仅 Barrier 步骤 `yield` | +| Manager | 部分 `IEnumerator RestoreSnapshot` 仅 `yield break` | 默认 `void`;真有 async 才保留协程 | + +### 已知偏离(tech debt) + +- `DirectorHandler` 在 Addressable Timeline 路径下内部 `StartCoroutine`,Provider 已返回,编排层无法感知——P4 应改为可等待路径。 +- P2/P3 新增代码**不要**再复制「全 Provider 协程链」模式。 + +## 快照 JSON 结构(schemaVersion = 1) + +| 字段 | 含义 | +| --- | --- | +| `anchor` | 恢复锚点:`nodeName` + `yarnProjectId`(独立阶段重进节点的自洽校验) | +| `yarnVariables` | floats / strings / bools | +| `sections` | key 为 `SnapshotProviderIds`,值为各 DTO;**宏观信息(场景/SceneSO/YarnProject)以 `scene`/`macro` 两节为唯一来源** | +| `deepRepair` | P1 通常为空,P5 再填 | + +> 宏观阶段摘要不再冗余到顶层;槽位 / 「继续游戏」UI 需要展示场景名、章节、YarnProject 时,调用 `SaveSnapshotSummary.From(snapshot)` 从 `sections` 现算派生。 + +## 新增一个可存子系统 + +1. 在 `SaveSnapshot.cs` 增加 DTO 类。 +2. 在 `SnapshotProviderIds` 增加稳定 id,并加入 `RequiredForCapture`(若 P1 必须存)。 +3. 在对应 Manager 实现 `CaptureSnapshot` / `RestoreSnapshot`(**默认 sync `void`**;仅确有 async 加载/状态切换时用 `IEnumerator` 并向编排层上报)。 +4. 新建 `Providers/XxxSnapshotProvider.cs` 实现 `ISnapshotProvider`(P4 后:sync 默认 + 显式 async 接口)。 +5. 在 `SnapshotBootstrap.EnsureInitialized` 中 `Register`。 +6. 在 `SnapshotSectionDeserializer.Coerce` 增加 JObject 转换分支。 + +**禁止**在 Provider 内私自 `StartCoroutine` 而不纳入编排 Barrier。 + +## 相关阶段(未在本目录完整实现) + +| 阶段 | 内容 | +| --- | --- | +| P2 | 槽位、原子写、截图 sidecar → 扩展 `SnapshotPersistence` | +| P3 | 可存点判定、写盘门控 | +| P4 | 按 §4.1 重写 `SnapshotRestore`(Phase + Barrier);refactor Provider 契约;`SaveRestoreOrchestrator` 接入淡入淡出 | +| P5 | `deepRepair` 段、维修模块清单;还原模型单独定案,不默认套用 P1 Provider 协程链 | diff --git a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs new file mode 100644 index 000000000..15f485554 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs @@ -0,0 +1,77 @@ +using System.Collections; +using System.Collections.Generic; +using AibisDream.Framework; +using AibisDream.Kit; +using AibisDream.UI; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace AibisDream.SaveSystem +{ + /// + /// 存读档流程编排层:连接 UI、。 + /// + /// 对外入口:(<>)、(读档)。 + /// P4 在此扩展淡入淡出与完整时序;P2 将替换测试路径为槽位 API。 + /// + /// + public static class SaveRestoreOrchestrator + { + /// 捕获快照并写入 P1 测试路径;含保存 Loading UI。 + public static void SaveToTestPath() + { + var host = YarnVariableStorage.Instance; + if (host == null) + { + Debug.LogError("[SaveRestoreOrchestrator] YarnVariableStorage 未初始化"); + return; + } + + ActionKit.Sequence() + .Callback(() => UIManager.Instance.GetPanel().ShowSaveLoading()) + .Delay(1f) + .Callback(() => UIManager.Instance.GetPanel().HideSaveLoading()) + .Start(host); + + using (new CodeTimer("SaveSnapshot")) + { + var snapshot = SnapshotService.Capture(); + SnapshotPersistence.Save(snapshot, SnapshotPersistence.GetTestFilePath()); + } + } + + /// 从文件读档并还原;自动区分新快照格式与 legacy 格式。 + public static IEnumerator RestoreFromFile(string savePath) + { + if (SnapshotPersistence.IsLegacyFormat(savePath)) + { + Debug.LogWarning("[SaveRestoreOrchestrator] 检测到旧格式存档,请使用新快照格式重新保存。"); + yield return RestoreLegacy(savePath); + yield break; + } + + SaveSnapshot snapshot; + using (new CodeTimer("LoadSnapshot")) + { + snapshot = SnapshotPersistence.Load(savePath); + } + + yield return SnapshotService.Restore(snapshot); + } + + /// 旧档:Yarn 变量 + DeepRepairDataRegistry systemData。 + private static IEnumerator RestoreLegacy(string savePath) + { + var saveData = SnapshotPersistence.ReadLegacySaveRoot(savePath); + var floatDict = saveData["floatDict"]?.ToObject>(); + var stringDict = saveData["stringDict"]?.ToObject>(); + var boolDict = saveData["boolDict"]?.ToObject>(); + YarnVariableStorage.Instance.SetAllVariables(floatDict, stringDict, boolDict); + + if (saveData["systemData"] is JObject systemDataJson) + { + yield return DeepRepairDataRegistry.LoadByJson(systemDataJson); + } + } + } +} diff --git a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs.meta b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs.meta new file mode 100644 index 000000000..de093d088 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 359343f1db2912f49bdcad4fbe443f6a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/SaveSnapshot.cs b/Assets/Scripts/SaveSystem/SaveSnapshot.cs new file mode 100644 index 000000000..181859ec3 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SaveSnapshot.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; + +namespace AibisDream.SaveSystem +{ + /// + /// 当前快照 schema 的版本号。结构变更时递增。 + /// + public static class SaveSnapshotSchema + { + public const int CurrentVersion = 1; + } + + /// + /// 存档快照根对象:描述「进入可存节点那一刻」的完整游戏状态。 + /// + /// 与槽位/文件路径解耦;序列化后写入 JSON。对应需求§3 四分类: + /// anchor(恢复锚点)、sections(宏观 scene/macro + 表现类)、yarnVariables、deepRepair(深度维修,P5)。 + /// + /// + /// 宏观阶段(场景 / 章节 SO / YarnProject)不单独冗余存储,统一以 sections["scene"] / sections["macro"] + /// 为唯一来源;槽位 UI 等需要摘要时通过 现算派生,避免重复字段漂移。 + /// + /// + [Serializable] + public class SaveSnapshot + { + /// 快照结构版本,对应 + public int schemaVersion = SaveSnapshotSchema.CurrentVersion; + + /// 保存时的 Application.version + public string gameVersion; + + /// 保存时间(本地),格式 yyyy-MM-dd HH:mm:ss + public string savedAt; + + /// 恢复锚点:读档后从此 Yarn 节点重新进入(所见即所存)。 + public AnchorSnapshot anchor = new(); + + /// 全部 Yarn 运行时变量(来自 )。 + public YarnVariablesSnapshot yarnVariables = new(); + + /// + /// 各子系统表现类快照,key 为 中的稳定 id。 + /// + public Dictionary sections = new(); + + /// + /// 深度维修段。P1 捕获时不写入(null);P5 按模块策略填充。 + /// + public DeepRepairSnapshot deepRepair; + } + + /// + /// 恢复锚点。读档时在场景与状态还原完成后,从 重新 StartDialogue。 + /// + [Serializable] + public class AnchorSnapshot + { + public string yarnProjectId; + public string nodeName; + } + + /// Yarn 变量三分组,与 Yarn Spinner 支持的类型一致。 + [Serializable] + public class YarnVariablesSnapshot + { + public Dictionary floats = new(); + public Dictionary strings = new(); + public Dictionary bools = new(); + } + + #region Provider DTOs + + /// sections["scene"]:当前加载的场景。 + [Serializable] + public class SceneSnapshotDto + { + public string sceneName; + } + + /// sections["macro"]:章节与 Yarn 工程绑定。 + [Serializable] + public class MacroSnapshotDto + { + public string sceneSoName; + public string yarnProjectId; + } + + /// sections["env"]:环境时间/天气。 + [Serializable] + public class EnvironmentSnapshotDto + { + /// 枚举名。 + public string curTime; + + /// 枚举名。 + public string curWeather; + + public bool isInitialized; + } + + /// sections["actor"]:场景中所有角色的逐项状态。 + [Serializable] + public class ActorSnapshotDto + { + public List actors = new(); + } + + /// 单个角色的快照条目。 + [Serializable] + public class ActorEntrySnapshotDto + { + public string actorName; + public string slotName; + public string actorType; + public string stateName; + public float alpha; + public float faceParam; + } + + /// sections["audio"]:FMOD 音乐/SFX 终态与 AMB 状态(不记播放进度)。 + [Serializable] + public class AudioSnapshotDto + { + /// key → FMOD event path。 + public Dictionary musicPaths = new(); + + public Dictionary sfxPaths = new(); + + /// 枚举名。 + public string ambState; + } + + /// + /// Timeline 终/初态标记(决策 D1.1:不按时间轴逐帧还原)。 + /// 序列化进 JSON 时使用字符串形式存入 。 + /// + public enum TimelinePlaybackPhase + { + AtStart, + AtEnd, + Stopped + } + + /// sections["timeline"]:各 PlayableDirector 的终/初态。 + [Serializable] + public class TimelineSnapshotDto + { + public List entries = new(); + } + + [Serializable] + public class TimelineEntrySnapshotDto + { + public string directorName; + public bool isActive; + + /// 的字符串名。 + public string phase; + + /// 若由 Addressables 加载 Timeline,记录 key;否则为空。 + public string loadedAddressableKey; + } + + /// sections["screen"]:屏幕饱和度与演出淡入淡出遮罩。 + [Serializable] + public class ScreenSnapshotDto + { + /// Post-process Volume weight(0=正常,1=完全褪色)。 + public float saturationWeight; + + /// + /// 演出遮罩终态:true = 黑屏盖住(FadeIn 完成); + /// false = 画面可见(FadeOut 完成或遮罩未启用)。不记录转场中间 alpha。 + /// + public bool isFadeScreenCovered; + } + + #endregion +} diff --git a/Assets/Scripts/SaveSystem/SaveSnapshot.cs.meta b/Assets/Scripts/SaveSystem/SaveSnapshot.cs.meta new file mode 100644 index 000000000..21744ec4f --- /dev/null +++ b/Assets/Scripts/SaveSystem/SaveSnapshot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 067e34fac1df0c1409cabbab3af2084b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/SaveSnapshotSummary.cs b/Assets/Scripts/SaveSystem/SaveSnapshotSummary.cs new file mode 100644 index 000000000..15f1d487e --- /dev/null +++ b/Assets/Scripts/SaveSystem/SaveSnapshotSummary.cs @@ -0,0 +1,60 @@ +namespace AibisDream.SaveSystem +{ + /// + /// 从 现算的宏观摘要(场景 / 章节 SO / YarnProject)。 + /// + /// 替代已移除的顶层 macroStage 冗余字段:sections["scene"] / sections["macro"] 为唯一来源, + /// 槽位 / 「继续游戏」等 UI 在需要展示时调用 派生,避免重复字段与读写漂移。 + /// + /// + public readonly struct SaveSnapshotSummary + { + /// Addressable 场景 key,如 Scene/ClinicOut(来自 sections["scene"])。 + public readonly string SceneName; + + /// 当前 TalkSceneSO 的 asset 名称(来自 sections["macro"])。 + public readonly string SceneSoName; + + /// 当前 YarnProject 的 name(来自 sections["macro"],缺省回退到 anchor)。 + public readonly string YarnProjectId; + + public SaveSnapshotSummary(string sceneName, string sceneSoName, string yarnProjectId) + { + SceneName = sceneName; + SceneSoName = sceneSoName; + YarnProjectId = yarnProjectId; + } + + /// 从快照派生宏观摘要;兼容 sections 为强类型 DTO 或读盘后的 JObject。 + public static SaveSnapshotSummary From(SaveSnapshot snapshot) + { + if (snapshot?.sections == null) + { + return new SaveSnapshotSummary(null, null, snapshot?.anchor?.yarnProjectId); + } + + string sceneName = null; + if (snapshot.sections.TryGetValue(SnapshotProviderIds.Scene, out var sceneRaw) + && SnapshotSectionDeserializer.Coerce(SnapshotProviderIds.Scene, sceneRaw) is SceneSnapshotDto scene) + { + sceneName = scene.sceneName; + } + + string sceneSoName = null; + string yarnProjectId = null; + if (snapshot.sections.TryGetValue(SnapshotProviderIds.Macro, out var macroRaw) + && SnapshotSectionDeserializer.Coerce(SnapshotProviderIds.Macro, macroRaw) is MacroSnapshotDto macro) + { + sceneSoName = macro.sceneSoName; + yarnProjectId = macro.yarnProjectId; + } + + if (string.IsNullOrEmpty(yarnProjectId)) + { + yarnProjectId = snapshot.anchor?.yarnProjectId; + } + + return new SaveSnapshotSummary(sceneName, sceneSoName, yarnProjectId); + } + } +} diff --git a/Assets/Scripts/SaveSystem/SaveSnapshotSummary.cs.meta b/Assets/Scripts/SaveSystem/SaveSnapshotSummary.cs.meta new file mode 100644 index 000000000..1da3b3180 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SaveSnapshotSummary.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5b1c9a2f7d3e4c8a9b6f0d1e2a3c4b5d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/ScreenSnapshotHelper.cs b/Assets/Scripts/SaveSystem/ScreenSnapshotHelper.cs new file mode 100644 index 000000000..75d0c44f7 --- /dev/null +++ b/Assets/Scripts/SaveSystem/ScreenSnapshotHelper.cs @@ -0,0 +1,50 @@ +using AibisDream.UI; +using UnityEngine; + +namespace AibisDream.SaveSystem +{ + /// + /// screen section 的捕获/还原实现:屏幕后处理饱和度 + PlayToolPanel 遮罩终态(黑屏盖住 / 画面可见)。 + /// screen section:饱和度 + PlayToolPanel 遮罩终态()。 + /// 供 委托,避免 Provider 直接依赖 UI 细节。 + /// + public static class ScreenSnapshotHelper + { + public static ScreenSnapshotDto Capture() + { + var dto = new ScreenSnapshotDto(); + + if (ScreenEffectManager.Instance != null && ScreenEffectManager.Instance.saturation != null) + { + dto.saturationWeight = ScreenEffectManager.Instance.saturation.weight; + } + + if (UIManager.Instance != null) + { + var panel = UIManager.Instance.GetPanel(); + if (panel != null) + { + dto.isFadeScreenCovered = panel.CaptureFadeScreenCovered(); + } + } + + return dto; + } + + public static void Restore(ScreenSnapshotDto dto) + { + if (dto == null) return; + + if (ScreenEffectManager.Instance != null && ScreenEffectManager.Instance.saturation != null) + { + ScreenEffectManager.Instance.saturation.weight = dto.saturationWeight; + } + + if (UIManager.Instance != null) + { + var panel = UIManager.Instance.GetPanel(); + panel?.ApplyFadeScreenCovered(dto.isFadeScreenCovered); + } + } + } +} diff --git a/Assets/Scripts/SaveSystem/ScreenSnapshotHelper.cs.meta b/Assets/Scripts/SaveSystem/ScreenSnapshotHelper.cs.meta new file mode 100644 index 000000000..a5d42bef9 --- /dev/null +++ b/Assets/Scripts/SaveSystem/ScreenSnapshotHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c1dd311abda2a88428fbc0d2b4e21236 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/SnapshotBootstrap.cs b/Assets/Scripts/SaveSystem/SnapshotBootstrap.cs new file mode 100644 index 000000000..7b337e755 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotBootstrap.cs @@ -0,0 +1,26 @@ +namespace AibisDream.SaveSystem +{ + /// + /// 一次性注册 P1 所需的全部 。 + /// 由 在首次使用时调用。 + /// + public static class SnapshotBootstrap + { + private static bool _initialized; + + /// 幂等;重复调用无副作用。 + public static void EnsureInitialized() + { + if (_initialized) return; + _initialized = true; + + SnapshotRegistry.Register(new SceneSnapshotProvider()); + SnapshotRegistry.Register(new MacroSnapshotProvider()); + SnapshotRegistry.Register(new EnvironmentSnapshotProvider()); + SnapshotRegistry.Register(new ActorSnapshotProvider()); + SnapshotRegistry.Register(new AudioSnapshotProvider()); + SnapshotRegistry.Register(new TimelineSnapshotProvider()); + SnapshotRegistry.Register(new ScreenSnapshotProvider()); + } + } +} diff --git a/Assets/Scripts/SaveSystem/SnapshotBootstrap.cs.meta b/Assets/Scripts/SaveSystem/SnapshotBootstrap.cs.meta new file mode 100644 index 000000000..f5858fb00 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotBootstrap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8deaa7a6856bb384fba9e42453a06e1d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/SnapshotCapture.cs b/Assets/Scripts/SaveSystem/SnapshotCapture.cs new file mode 100644 index 000000000..44246a85d --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotCapture.cs @@ -0,0 +1,67 @@ +using System; +using UnityEngine; +using Yarn.Unity; + +namespace AibisDream.SaveSystem +{ + /// + /// 从运行时组装 (纯内存,不写盘)。 + /// + /// 流程:元数据 → 锚点 → Yarn 变量 → 各 Provider.Capture。 + /// 宏观信息(scene/macro)由对应 Provider 写入 sections,不再额外汇总到顶层。 + /// + /// + public static class SnapshotCapture + { + /// 捕获当前完整快照。调用前需已注册全部 provider。 + public static SaveSnapshot Capture(YarnVariableStorage storage) + { + SnapshotRegistry.ValidateRequiredProviders(); + + var snapshot = new SaveSnapshot + { + gameVersion = Application.version, + savedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + }; + + CaptureAnchor(snapshot); + CaptureYarnVariables(storage, snapshot); + + foreach (var provider in SnapshotRegistry.GetOrderedProviders()) + { + var dto = provider.Capture(); + if (dto == null) continue; + snapshot.sections[provider.SaveId] = dto; + } + + return snapshot; + } + + private static void CaptureAnchor(SaveSnapshot snapshot) + { + var dialog = DialogController.Instance; + if (dialog == null) return; + + var (nodeName, _) = dialog.GetCurrentNodeContext(); + var yarnProject = dialog.DialogueRunner?.YarnProject; + var projectId = yarnProject != null ? yarnProject.name : string.Empty; + + snapshot.anchor = new AnchorSnapshot + { + yarnProjectId = projectId, + nodeName = nodeName ?? string.Empty + }; + } + + private static void CaptureYarnVariables(YarnVariableStorage storage, SaveSnapshot snapshot) + { + var (floats, strings, bools) = storage.GetAllVariables(); + snapshot.yarnVariables = new YarnVariablesSnapshot + { + floats = floats ?? new(), + strings = strings ?? new(), + bools = bools ?? new() + }; + } + } +} diff --git a/Assets/Scripts/SaveSystem/SnapshotCapture.cs.meta b/Assets/Scripts/SaveSystem/SnapshotCapture.cs.meta new file mode 100644 index 000000000..63e2672db --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotCapture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8980db10670d2ec4685bc1564407cb9e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/SnapshotPersistence.cs b/Assets/Scripts/SaveSystem/SnapshotPersistence.cs new file mode 100644 index 000000000..d1c9fea55 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotPersistence.cs @@ -0,0 +1,51 @@ +using System.IO; +using AibisDream.Utility; +using Newtonsoft.Json.Linq; + +namespace AibisDream.SaveSystem +{ + /// + /// 快照与磁盘的边界:读写 JSON 文件、检测旧档格式。 + /// + /// P1 使用固定测试路径(); + /// P2 将扩展为槽位目录、原子写、sidecar,接口可保持不变。 + /// + /// + public static class SnapshotPersistence + { + /// P1 自测落盘:SnapshotTestPath/latest_snapshot.json + public static string GetTestFilePath() + { + return Path.Combine(ConstRef.SnapshotTestPath, ConstRef.SnapshotTestFileName); + } + + /// 将快照序列化并写入 path(自动补 .json 后缀)。 + public static void Save(SaveSnapshot snapshot, string pathWithoutExtension) + { + SnapshotSerializer.SaveToFile(snapshot, pathWithoutExtension); + } + + /// 从 path 读取并反序列化为 + public static SaveSnapshot Load(string pathWithoutExtension) + { + return SnapshotSerializer.LoadFromFile(pathWithoutExtension); + } + + /// + /// 是否为旧 StorageSystem 格式(含 systemData、无 schemaVersion)。 + /// 此类文件应走 的 legacy 分支。 + /// + public static bool IsLegacyFormat(string savePath) + { + var json = File.ReadAllText(JsonUtil.FormatAsJsonPath(savePath)); + var jobj = JObject.Parse(json); + return jobj.Value("schemaVersion") == null && jobj["systemData"] != null; + } + + /// 读取旧格式存档根 JObject,供 legacy 还原使用。 + public static JObject ReadLegacySaveRoot(string savePath) + { + return JsonUtil.ReadJObject(savePath); + } + } +} diff --git a/Assets/Scripts/SaveSystem/SnapshotPersistence.cs.meta b/Assets/Scripts/SaveSystem/SnapshotPersistence.cs.meta new file mode 100644 index 000000000..85905f285 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotPersistence.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4d7e9858d7009984ab1d2c139945e65e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/SnapshotProviderIds.cs b/Assets/Scripts/SaveSystem/SnapshotProviderIds.cs new file mode 100644 index 000000000..3c902f458 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotProviderIds.cs @@ -0,0 +1,26 @@ +namespace AibisDream.SaveSystem +{ + /// + /// 快照 section 的稳定字符串 id。 + /// 用于 JSON key、Provider 注册与反序列化,避免使用 AssemblyQualifiedName。 + /// + public static class SnapshotProviderIds + { + public const string Scene = "scene"; + public const string Macro = "macro"; + public const string Environment = "env"; + public const string Actor = "actor"; + public const string Audio = "audio"; + public const string Timeline = "timeline"; + public const string Screen = "screen"; + + /// + /// P1 应参与 Capture 的 provider 清单。 + /// 据此告警缺失项;不含深度维修。 + /// + public static readonly string[] RequiredForCapture = + { + Scene, Macro, Environment, Actor, Audio, Timeline, Screen + }; + } +} diff --git a/Assets/Scripts/SaveSystem/SnapshotProviderIds.cs.meta b/Assets/Scripts/SaveSystem/SnapshotProviderIds.cs.meta new file mode 100644 index 000000000..041c1bf2a --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotProviderIds.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 961a9f94f713b334388b4d1a0b1018c4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/SnapshotRegistry.cs b/Assets/Scripts/SaveSystem/SnapshotRegistry.cs new file mode 100644 index 000000000..7f2d5fdfe --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotRegistry.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace AibisDream.SaveSystem +{ + /// + /// 运行时登记全部 。 + /// Capture/Restore 时按 迭代; + /// 不使用「谁注册了谁才存」的隐式行为,RequiredForCapture 用于显式校验。 + /// + public static class SnapshotRegistry + { + private static readonly Dictionary Providers = new(); + + /// 注册 provider;同 SaveId 后者覆盖前者。 + public static void Register(ISnapshotProvider provider) + { + if (provider == null) return; + Providers[provider.SaveId] = provider; + } + + public static void Unregister(string saveId) + { + Providers.Remove(saveId); + } + + public static bool TryGet(string saveId, out ISnapshotProvider provider) + { + return Providers.TryGetValue(saveId, out provider); + } + + /// 按 RestoreOrder 升序返回,供还原流水线使用。 + public static IReadOnlyList GetOrderedProviders() + { + return Providers.Values.OrderBy(p => p.RestoreOrder).ToList(); + } + + /// + /// 校验 ; + /// 缺失时 LogWarning 但不阻断 Capture。 + /// + public static void ValidateRequiredProviders() + { + foreach (var saveId in SnapshotProviderIds.RequiredForCapture) + { + if (!Providers.ContainsKey(saveId)) + { + Debug.LogWarning($"[SnapshotRegistry] 缺少快照 provider: {saveId}"); + } + } + } + } +} diff --git a/Assets/Scripts/SaveSystem/SnapshotRegistry.cs.meta b/Assets/Scripts/SaveSystem/SnapshotRegistry.cs.meta new file mode 100644 index 000000000..e7ba300a4 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: be5181c78333ff348987644e3653b4d8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/SnapshotRestore.cs b/Assets/Scripts/SaveSystem/SnapshotRestore.cs new file mode 100644 index 000000000..89ae1ddea --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotRestore.cs @@ -0,0 +1,85 @@ +using System.Collections; +using UnityEngine; + +namespace AibisDream.SaveSystem +{ + /// + /// 将 逐项写回运行时(纯内存,不读盘)。 + /// + /// 顺序:Yarn 变量 → 各 Provider(按 RestoreOrder)→(可选)锚点重进节点。 + /// 完整淡入淡出时序由 P4 在 扩展。 + /// + /// + public static class SnapshotRestore + { + /// 还原 Yarn 变量与各 section;不包含锚点重进(由 或上层编排)。 + public static IEnumerator Restore(YarnVariableStorage storage, SaveSnapshot snapshot) + { + if (snapshot == null) + { + Debug.LogError("[SnapshotRestore] snapshot 为 null"); + yield break; + } + + RestoreYarnVariables(storage, snapshot); + + foreach (var provider in SnapshotRegistry.GetOrderedProviders()) + { + if (!snapshot.sections.TryGetValue(provider.SaveId, out var dto) || dto == null) + { + continue; + } + + dto = SnapshotSectionDeserializer.Coerce(provider.SaveId, dto); + yield return provider.Restore(dto); + } + + if (snapshot.deepRepair != null && snapshot.deepRepair.sections != null + && snapshot.deepRepair.sections.Count > 0) + { + Debug.LogWarning("[SnapshotRestore] DeepRepair 段尚未实现,已跳过(P5)。"); + } + } + + /// + /// 按 重新进入 Yarn 节点,实现「所见即所存」。 + /// 调用前应已完成场景加载与各 provider 还原。 + /// + public static IEnumerator RestoreAnchor(SaveSnapshot snapshot) + { + if (snapshot?.anchor == null || string.IsNullOrEmpty(snapshot.anchor.nodeName)) + { + yield break; + } + + var dialog = DialogController.Instance; + if (dialog == null) yield break; + + var runner = dialog.DialogueRunner; + if (runner == null) yield break; + + if (!string.IsNullOrEmpty(snapshot.anchor.yarnProjectId) + && runner.YarnProject != null + && runner.YarnProject.name != snapshot.anchor.yarnProjectId) + { + Debug.LogWarning( + $"[SnapshotRestore] YarnProject 不匹配:当前 {runner.YarnProject.name},存档 {snapshot.anchor.yarnProjectId}"); + } + + if (runner.IsDialogueRunning) + { + yield return runner.Stop(); + } + + yield return runner.StartDialogue(snapshot.anchor.nodeName); + } + + private static void RestoreYarnVariables(YarnVariableStorage storage, SaveSnapshot snapshot) + { + var vars = snapshot.yarnVariables; + if (vars == null) return; + + storage.SetAllVariables(vars.floats, vars.strings, vars.bools); + } + } +} diff --git a/Assets/Scripts/SaveSystem/SnapshotRestore.cs.meta b/Assets/Scripts/SaveSystem/SnapshotRestore.cs.meta new file mode 100644 index 000000000..9d49a8397 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotRestore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3bcdb9cb4405940409b03b54446a2c5e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/SnapshotSectionDeserializer.cs b/Assets/Scripts/SaveSystem/SnapshotSectionDeserializer.cs new file mode 100644 index 000000000..f8f2b76fc --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotSectionDeserializer.cs @@ -0,0 +1,32 @@ +using Newtonsoft.Json.Linq; + +namespace AibisDream.SaveSystem +{ + /// + /// 读盘后 中的值常为 ; + /// 本类按 SaveId 转回各 Provider 所需的强类型 DTO。 + /// + public static class SnapshotSectionDeserializer + { + /// 若 dto 已是强类型则原样返回;若为 JObject 则 ToObject 到对应 DTO。 + public static object Coerce(string saveId, object dto) + { + if (dto is not JObject jobj) + { + return dto; + } + + return saveId switch + { + SnapshotProviderIds.Scene => jobj.ToObject(), + SnapshotProviderIds.Macro => jobj.ToObject(), + SnapshotProviderIds.Environment => jobj.ToObject(), + SnapshotProviderIds.Actor => jobj.ToObject(), + SnapshotProviderIds.Audio => jobj.ToObject(), + SnapshotProviderIds.Timeline => jobj.ToObject(), + SnapshotProviderIds.Screen => jobj.ToObject(), + _ => jobj + }; + } + } +} diff --git a/Assets/Scripts/SaveSystem/SnapshotSectionDeserializer.cs.meta b/Assets/Scripts/SaveSystem/SnapshotSectionDeserializer.cs.meta new file mode 100644 index 000000000..a62a9a546 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotSectionDeserializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d7def29588434ab48b6f6768daf76635 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/SnapshotSerializer.cs b/Assets/Scripts/SaveSystem/SnapshotSerializer.cs new file mode 100644 index 000000000..dc3ffdc00 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotSerializer.cs @@ -0,0 +1,115 @@ +using System; + +using AibisDream.Utility; + +using Newtonsoft.Json; + +using UnityEngine; + + + +namespace AibisDream.SaveSystem + +{ + + /// + + /// 与 JSON 互转;写入时带上 。 + + /// 被 调用,业务层通常不直接使用。 + + /// + + public static class SnapshotSerializer + + { + + private static readonly JsonSerializerSettings Settings = new() + + { + + TypeNameHandling = TypeNameHandling.None, + + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + + NullValueHandling = NullValueHandling.Ignore + + }; + + + + public static string Serialize(SaveSnapshot snapshot) + + { + + snapshot.schemaVersion = SaveSnapshotSchema.CurrentVersion; + + return JsonConvert.SerializeObject(snapshot, Formatting.Indented, Settings); + + } + + + + public static SaveSnapshot Deserialize(string json) + + { + + var snapshot = JsonConvert.DeserializeObject(json, Settings); + + if (snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion) + + { + + Debug.LogWarning( + + $"[SnapshotSerializer] schemaVersion {snapshot.schemaVersion} != {SaveSnapshotSchema.CurrentVersion},按当前结构读取。"); + + } + + + + return snapshot; + + } + + + + public static void SaveToFile(SaveSnapshot snapshot, string pathWithoutExtension) + + { + + var json = Serialize(snapshot); + + var dir = System.IO.Path.GetDirectoryName(JsonUtil.FormatAsJsonPath(pathWithoutExtension)); + + if (!string.IsNullOrEmpty(dir)) + + { + + System.IO.Directory.CreateDirectory(dir); + + } + + + + System.IO.File.WriteAllText(JsonUtil.FormatAsJsonPath(pathWithoutExtension), json); + + } + + + + public static SaveSnapshot LoadFromFile(string pathWithoutExtension) + + { + + var json = System.IO.File.ReadAllText(JsonUtil.FormatAsJsonPath(pathWithoutExtension)); + + return Deserialize(json); + + } + + } + +} + + diff --git a/Assets/Scripts/SaveSystem/SnapshotSerializer.cs.meta b/Assets/Scripts/SaveSystem/SnapshotSerializer.cs.meta new file mode 100644 index 000000000..12a8127fb --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotSerializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f57d93779e83c064f9ba2490e623e5b4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/SnapshotService.cs b/Assets/Scripts/SaveSystem/SnapshotService.cs new file mode 100644 index 000000000..7e6d6b4b1 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotService.cs @@ -0,0 +1,38 @@ +using System.Collections; + +namespace AibisDream.SaveSystem +{ + /// + /// 快照层对外 API:Capture / Restore,不涉及文件与 UI。 + /// 游戏逻辑应优先使用 完成存读档流程。 + /// + public static class SnapshotService + { + /// 捕获当前运行时快照。 + public static SaveSnapshot Capture() + { + SnapshotBootstrap.EnsureInitialized(); + return SnapshotCapture.Capture(YarnVariableStorage.Instance); + } + + /// + /// 还原快照。默认在 provider 还原后继续执行 。 + /// + /// 为 false 时仅还原状态,不重进 Yarn 节点(供 P4 编排使用)。 + public static IEnumerator Restore(SaveSnapshot snapshot, bool restoreAnchor = true) + { + if (snapshot == null) + { + yield break; + } + + SnapshotBootstrap.EnsureInitialized(); + yield return SnapshotRestore.Restore(YarnVariableStorage.Instance, snapshot); + + if (restoreAnchor) + { + yield return SnapshotRestore.RestoreAnchor(snapshot); + } + } + } +} diff --git a/Assets/Scripts/SaveSystem/SnapshotService.cs.meta b/Assets/Scripts/SaveSystem/SnapshotService.cs.meta new file mode 100644 index 000000000..efd8b259a --- /dev/null +++ b/Assets/Scripts/SaveSystem/SnapshotService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2326ffbf13edbdb4fb0a438e7724edaa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: