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。
// 正式发布后若需要变更结构,必须新增显式迁移,而不能沿用删除文件的开发期策略。
2026-07-18 16:51:18 -04:00
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" ;
2026-07-18 16:51:18 -04:00
/// <summary>
2026-07-21 15:24:42 -04:00
/// 补齐运行时会用到的字典。该方法只做空值修复,不承担跨 Schema 的迁移职责。
2026-07-18 16:51:18 -04:00
/// </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 ) ;
}
2026-07-18 16:51:18 -04:00
return save ;
}
/// <summary>
2026-07-21 15:24:42 -04:00
/// 补齐章节变量的三种底层字典。数值统一使用 float 字典保存,整数读取时由 StoryVariables 转回整数。
2026-07-18 16:51:18 -04:00
/// </summary>
2026-07-21 15:24:42 -04:00
internal static StoryVariablesSave NormalizeVariables ( StoryVariablesSave storyVariables )
2026-07-18 16:51:18 -04:00
{
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>
2026-07-18 16:51:18 -04:00
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 ) ;
2026-07-18 16:51:18 -04:00
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
2026-07-18 16:51:18 -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}." ) ;
2026-07-18 16:51:18 -04:00
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 ( ) ;
}
2026-07-18 16:51:18 -04:00
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 )
{
2026-07-18 16:51:18 -04:00
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." ) ;
2026-07-18 16:51:18 -04:00
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 )
{
2026-07-18 16:51:18 -04:00
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-18 16:51:18 -04:00
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-18 16:51:18 -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
}
}
}