Files
ichni_Official/Assets/Scripts/NewStorySystem/Save/StorySaveModule.cs

318 lines
14 KiB
C#
Raw Normal View History

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