Files
ichni_Official/Assets/Scripts/NewStorySystem/Save/StorySaveModule.cs
SoulliesOfficial 810d019619 剧情+对话完善
2026-07-21 15:24:42 -04:00

318 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using Ichni.Menu;
using Ichni.Story;
using UnityEngine;
namespace Ichni
{
/// <summary>
/// 剧情存档模块。
/// <para>一个章节对应一个 ES3 文件,文件内的 <see cref="ChapterStorySave"/> 同时保存 Block 进度、
/// 选项结果、章节变量与 Timeline 回滚快照。</para>
/// <para>章节之间不共享剧情变量。此设计让“重开本章节”能够精确回滚本章剧情,且绝不触及歌曲成绩、
/// Offline 解锁 Key、设置或其它章节。</para>
/// </summary>
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<string, ChapterStorySave> _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";
/// <summary>
/// 补齐运行时会用到的字典。该方法只做空值修复,不承担跨 Schema 的迁移职责。
/// </summary>
private static ChapterStorySave NormalizeChapterSave(ChapterStorySave save, string chapterIndex)
{
save ??= new ChapterStorySave();
save.chapterIndex = chapterIndex;
save.completedBlockIds ??= new List<string>();
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>
/// 补齐章节变量的三种底层字典。数值统一使用 float 字典保存,整数读取时由 StoryVariables 转回整数。
/// </summary>
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;
}
/// <summary>
/// 当前是否没有可用的 v1 章节存档。此方法仅用于调试/展示;实际读取仍应调用 <see cref="GetChapter"/>。
/// </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;
}
/// <summary>
/// 从磁盘加载一个章节的剧情存档。开发期结构替换后若 ES3 无法反序列化旧 v1 文件,
/// 会安全重置该章节文件;这是用户已确认的发布前删档策略。
/// </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;
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<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);
_chapterSaves[chapterIndex] = save;
return save;
}
/// <summary>
/// 将一个完整章节容器写入 ES3。调用方应先更新 Block、变量、选项或快照再一次性调用本方法。
/// </summary>
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);
}
/// <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);
}
/// <summary>
/// 获取章节的内存存档;首次访问时自动从磁盘加载。
/// </summary>
public ChapterStorySave GetChapter(string chapterIndex)
{
return _chapterSaves.TryGetValue(chapterIndex, out ChapterStorySave save)
? save
: LoadChapter(chapterIndex);
}
/// <summary>
/// 返回当前已打开章节的变量容器。章节尚未建立时返回非持久化兜底容器,供独立场景安全运行。
/// </summary>
public StoryVariablesSave GetActiveChapterVariables()
{
string chapterIndex = StoryManager.instance?.treeController?.ActiveChapterIndex;
return string.IsNullOrEmpty(chapterIndex)
? _fallbackVariables
: GetChapter(chapterIndex).storyVariables;
}
/// <summary>
/// 立即保存当前已打开章节。返回 false 代表当前没有章节上下文,因此没有产生磁盘写入。
/// </summary>
public bool SaveActiveChapter()
{
string chapterIndex = StoryManager.instance?.treeController?.ActiveChapterIndex;
if (string.IsNullOrEmpty(chapterIndex))
return false;
SaveChapter(GetChapter(chapterIndex));
return true;
}
// ── Yarn 选项(章节内) ─────────────────────────────────────────────────
/// <summary>
/// 保存一次 Yarn 选项。选项写入应在对应 Timeline 快照已创建后发生,
/// 由 StoryChoiceMemory 统一保证该顺序;若当前 TextBlock 事务仍在进行,只更新内存,
/// 直到对话正常结束后才统一落盘。
/// </summary>
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;
}
/// <summary>
/// 删除一个失效选项记录。例如 Yarn 改稿后已选 Text ID 不再存在,下一次进入应让玩家重新选择。
/// </summary>
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);
}
/// <summary>
/// 清除全部章节剧情存档及遗留全局变量文件。不会清除歌曲成绩、设置或 Offline 解锁 Key。
/// </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();
_fallbackVariables.floatVariables.Clear();
_fallbackVariables.stringVariables.Clear();
_fallbackVariables.boolVariables.Clear();
if (ES3.FileExists(LegacyVariablesPath))
ES3.DeleteFile(LegacyVariablesPath);
Debug.Log("[StorySave] Cleared all chapter story saves.");
}
}
}