using System; using System.Collections.Generic; using Ichni.Menu; using Ichni.Story; using UnityEngine; namespace Ichni { /// /// 剧情存档模块。 /// 一个章节对应一个 ES3 文件,文件内的 同时保存 Block 进度、 /// 选项结果、章节变量与 Timeline 回滚快照。 /// 章节之间不共享剧情变量。此设计让“重开本章节”能够精确回滚本章剧情,且绝不触及歌曲成绩、 /// Offline 解锁 Key、设置或其它章节。 /// public class StorySaveModule { private const string ChapterStoryKey = "ChapterStory"; // 每个剧情存档文件独立保存 Schema Version。项目尚未发布,当前不兼容的测试存档会重置为 v1。 // 正式发布后若需要变更结构,必须新增显式迁移,而不能沿用删除文件的开发期策略。 private const string StorySaveSchemaVersionKey = "StorySaveSchemaVersion"; private const int CurrentStorySaveSchemaVersion = 1; // 旧版将剧情变量保存为全局单文件。它不再读取,但 ClearAllStoryline 会清理它, // 以免开发机遗留文件造成“已清档但磁盘仍有旧剧情数据”的误解。 private static string LegacyVariablesPath => Application.persistentDataPath + "/StorySaves/StoryVariables.json"; // 已加载章节的内存缓存(chapterIndex -> 章节存档)。 private readonly Dictionary _chapterSaves = new(); // 当前仅允许一段 Story TextBlock 对话处于存档事务中。事务期间,章节数据仍会更新内存, // 但不会写入 ES3;由 StoryDialogueController 在对话正常结束时提交,或在中途退出时恢复快照。 private string _transactionChapterIndex; // 没有进入章节时(例如独立测试场景)使用的非持久化兜底变量。 // 正式剧情流程不会写入这里;StoryTreeController 建立章节后会切换到对应 ChapterStorySave。 private readonly StoryVariablesSave _fallbackVariables = new(); private static string GetChapterSavePath(string chapterIndex) => Application.persistentDataPath + "/StorySaves/" + chapterIndex + ".json"; /// /// 补齐运行时会用到的字典。该方法只做空值修复,不承担跨 Schema 的迁移职责。 /// private static ChapterStorySave NormalizeChapterSave(ChapterStorySave save, string chapterIndex) { save ??= new ChapterStorySave(); save.chapterIndex = chapterIndex; save.completedBlockIds ??= new List(); save.selectedChoices ??= new Dictionary(); save.storyVariables = NormalizeVariables(save.storyVariables); save.markerRollbackSnapshots ??= new Dictionary(); foreach (StoryRollbackSnapshot snapshot in save.markerRollbackSnapshots.Values) { if (snapshot == null) continue; snapshot.completedBlockIds ??= new List(); snapshot.selectedChoices ??= new Dictionary(); snapshot.storyVariables = NormalizeVariables(snapshot.storyVariables); } return save; } /// /// 补齐章节变量的三种底层字典。数值统一使用 float 字典保存,整数读取时由 StoryVariables 转回整数。 /// internal static StoryVariablesSave NormalizeVariables(StoryVariablesSave storyVariables) { storyVariables ??= new StoryVariablesSave(); storyVariables.floatVariables ??= new Dictionary(); storyVariables.stringVariables ??= new Dictionary(); storyVariables.boolVariables ??= new Dictionary(); return storyVariables; } /// /// 当前是否没有可用的 v1 章节存档。此方法仅用于调试/展示;实际读取仍应调用 。 /// public bool IsNewChapter(string chapterIndex) { string path = GetChapterSavePath(chapterIndex); return !ES3.FileExists(path) || !ES3.KeyExists(ChapterStoryKey, path) || !ES3.KeyExists(StorySaveSchemaVersionKey, path) || ES3.Load(StorySaveSchemaVersionKey, path) != CurrentStorySaveSchemaVersion; } /// /// 从磁盘加载一个章节的剧情存档。开发期结构替换后若 ES3 无法反序列化旧 v1 文件, /// 会安全重置该章节文件;这是用户已确认的发布前删档策略。 /// public ChapterStorySave LoadChapter(string chapterIndex) { string path = GetChapterSavePath(chapterIndex); bool hasExistingFile = ES3.FileExists(path); int loadedSchemaVersion = hasExistingFile && ES3.KeyExists(StorySaveSchemaVersionKey, path) ? ES3.Load(StorySaveSchemaVersionKey, path) : 0; if (hasExistingFile && loadedSchemaVersion != CurrentStorySaveSchemaVersion) { Debug.LogWarning($"[StorySave] Resetting pre-release chapter '{chapterIndex}' from schema v{loadedSchemaVersion} to v{CurrentStorySaveSchemaVersion}."); ES3.DeleteFile(path); hasExistingFile = false; } ChapterStorySave save; try { save = hasExistingFile && ES3.KeyExists(ChapterStoryKey, path) ? ES3.Load(ChapterStoryKey, path) : new ChapterStorySave(); } catch (Exception exception) { // 典型原因是开发期 selectedChoices 从 int 改为 StoryChoiceRecord 后的旧 ES3 数据。 // 因 Schema 仍为 v1 且项目未发行,按约定仅重置本章节,而不影响任何其它存档系统。 Debug.LogWarning($"[StorySave] Resetting unreadable pre-release chapter '{chapterIndex}'. {exception.Message}"); if (ES3.FileExists(path)) ES3.DeleteFile(path); save = new ChapterStorySave(); } save = NormalizeChapterSave(save, chapterIndex); _chapterSaves[chapterIndex] = save; return save; } /// /// 将一个完整章节容器写入 ES3。调用方应先更新 Block、变量、选项或快照,再一次性调用本方法。 /// public void SaveChapter(ChapterStorySave save) { if (save == null || string.IsNullOrEmpty(save.chapterIndex)) { Debug.LogWarning("[StorySave] Cannot save a chapter record without chapterIndex."); return; } save = NormalizeChapterSave(save, save.chapterIndex); _chapterSaves[save.chapterIndex] = save; if (IsChapterTransactionActive(save.chapterIndex)) return; SaveChapterImmediately(save); } /// /// 开始指定章节的临时写入事务。 /// 对话播放期间的选项、变量、Marker 快照和 Block 完成状态仍会写入内存, /// 但不会立即覆盖 ES3;这样中途退出时不会把半段剧情永久记录到存档。 /// public bool BeginChapterTransaction(string chapterIndex) { if (string.IsNullOrWhiteSpace(chapterIndex)) return false; if (!string.IsNullOrEmpty(_transactionChapterIndex)) { Debug.LogWarning($"[StorySave] Chapter transaction '{_transactionChapterIndex}' is already active."); return false; } _transactionChapterIndex = chapterIndex; return true; } /// /// 提交指定章节的临时写入事务,并将该章节当前的完整内存状态一次性写入 ES3。 /// 仅应在 TextBlock 的 Yarn 对话正常结束后调用。 /// public void CommitChapterTransaction(string chapterIndex) { if (!IsChapterTransactionActive(chapterIndex)) return; _transactionChapterIndex = null; SaveChapterImmediately(GetChapter(chapterIndex)); } /// /// 放弃当前章节事务,并用进入对话前的快照恢复内存和 ES3。 /// 此方法只作用于章节剧情数据,不会回滚歌曲成绩、解锁 Key、设置或其它章节的数据。 /// public void RollbackChapterTransaction(string chapterIndex, ChapterStorySave snapshot) { if (!IsChapterTransactionActive(chapterIndex) || snapshot == null) return; ChapterStorySave restored = StorySaveCloneUtility.CloneChapter(snapshot); restored = NormalizeChapterSave(restored, chapterIndex); _transactionChapterIndex = null; _chapterSaves[chapterIndex] = restored; SaveChapterImmediately(restored); } /// 当前指定章节是否正处于由 Story TextBlock 开启的临时存档事务中。 public bool IsChapterTransactionActive(string chapterIndex) => !string.IsNullOrEmpty(chapterIndex) && _transactionChapterIndex == chapterIndex; private void SaveChapterImmediately(ChapterStorySave save) { string path = GetChapterSavePath(save.chapterIndex); ES3.Save(ChapterStoryKey, save, path); ES3.Save(StorySaveSchemaVersionKey, CurrentStorySaveSchemaVersion, path); } /// /// 获取章节的内存存档;首次访问时自动从磁盘加载。 /// public ChapterStorySave GetChapter(string chapterIndex) { return _chapterSaves.TryGetValue(chapterIndex, out ChapterStorySave save) ? save : LoadChapter(chapterIndex); } /// /// 返回当前已打开章节的变量容器。章节尚未建立时返回非持久化兜底容器,供独立场景安全运行。 /// public StoryVariablesSave GetActiveChapterVariables() { string chapterIndex = StoryManager.instance?.treeController?.ActiveChapterIndex; return string.IsNullOrEmpty(chapterIndex) ? _fallbackVariables : GetChapter(chapterIndex).storyVariables; } /// /// 立即保存当前已打开章节。返回 false 代表当前没有章节上下文,因此没有产生磁盘写入。 /// public bool SaveActiveChapter() { string chapterIndex = StoryManager.instance?.treeController?.ActiveChapterIndex; if (string.IsNullOrEmpty(chapterIndex)) return false; SaveChapter(GetChapter(chapterIndex)); return true; } // ── Yarn 选项(章节内) ───────────────────────────────────────────────── /// /// 保存一次 Yarn 选项。选项写入应在对应 Timeline 快照已创建后发生, /// 由 StoryChoiceMemory 统一保证该顺序;若当前 TextBlock 事务仍在进行,只更新内存, /// 直到对话正常结束后才统一落盘。 /// public void SetChoice(string chapterIndex, StoryChoiceRecord record) { if (string.IsNullOrEmpty(chapterIndex) || record == null || string.IsNullOrEmpty(record.choiceKey)) { Debug.LogWarning("[StorySave] Cannot save a story choice without chapterIndex and choiceKey."); return; } ChapterStorySave save = GetChapter(chapterIndex); save.selectedChoices[record.choiceKey] = record; SaveChapter(save); } public bool TryGetChoice(string chapterIndex, string choiceKey, out StoryChoiceRecord record) { if (string.IsNullOrEmpty(chapterIndex) || string.IsNullOrEmpty(choiceKey)) { record = null; return false; } return GetChapter(chapterIndex).selectedChoices.TryGetValue(choiceKey, out record) && record != null; } /// /// 删除一个失效选项记录。例如 Yarn 改稿后已选 Text ID 不再存在,下一次进入应让玩家重新选择。 /// public void RemoveChoice(string chapterIndex, string choiceKey) { if (string.IsNullOrEmpty(chapterIndex) || string.IsNullOrEmpty(choiceKey)) return; ChapterStorySave save = GetChapter(chapterIndex); if (save.selectedChoices.Remove(choiceKey)) SaveChapter(save); } /// /// 清除全部章节剧情存档及遗留全局变量文件。不会清除歌曲成绩、设置或 Offline 解锁 Key。 /// public void ClearAllStoryline() { if (ChapterSelectionManager.instance != null) { foreach (ChapterSelectionUnit chapter in ChapterSelectionManager.instance.chapters) { string path = GetChapterSavePath(chapter.chapterIndex); if (ES3.FileExists(path)) ES3.DeleteFile(path); } } _chapterSaves.Clear(); _fallbackVariables.floatVariables.Clear(); _fallbackVariables.stringVariables.Clear(); _fallbackVariables.boolVariables.Clear(); if (ES3.FileExists(LegacyVariablesPath)) ES3.DeleteFile(LegacyVariablesPath); Debug.Log("[StorySave] Cleared all chapter story saves."); } } }