Files
ichni_Official/Assets/Scripts/Saving/GameSaveManager.cs

295 lines
12 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.Collections;
using System.Collections.Generic;
using Ichni.Menu;
using Ichni.RhythmGame;
using Ichni.Story;
using Sirenix.OdinInspector;
using UnityEngine;
namespace Ichni
{
/// <summary>
/// 全局存档入口。
/// <para><see cref="SongSaveModule"/> 负责可重复游玩的歌曲成绩与谱面记录;</para>
/// <para><see cref="UnlockSaveModule"/> 负责章节、歌曲及未来内容共用的 Offline 解锁 Key</para>
/// <para><see cref="StorySaveModule"/> 负责章节故事树、对话选项和 Yarn 变量。</para>
/// 两套数据使用不同文件和不同生命周期,修改任一侧时不要把另一侧的字段或迁移规则混入其中。
/// </summary>
public class GameSaveManager : SerializedMonoBehaviour
{
public static GameSaveManager instance;
public SongSaveModule SongSaveModule;
public UnlockSaveModule UnlockSaveModule;
public StorySaveModule StorySaveModule;
private void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
private void Start()
{
// 菜单与章节 UI 会在启动后立刻读取歌曲记录,因此游戏记录必须先加载。
SongSaveModule = new SongSaveModule();
SongSaveModule.LoadSongStatuses();
// 内容解锁既不属于歌曲成绩也不属于 Yarn 剧情树。菜单在首次显示前必须先得到它的状态。
UnlockSaveModule = new UnlockSaveModule();
UnlockSaveModule.LoadUnlockKeys();
// 剧情变量属于独立存档,不参与歌曲成绩的 Schema 或 Chart Revision 迁移。
StorySaveModule = new StorySaveModule();
StorySaveModule.LoadVariables();
}
}
public partial class SongSaveModule
{
// ES3 数据 Key。一旦发布不要重命名重命名等同于修改存档格式需要 Schema Migration。
private const string SongSavesKey = "SongSaves";
// 与歌曲记录存放在同一文件。当前项目尚未正式发布,不兼容的旧版本会直接重置为新的 v1 记录。
private const string SongSaveSchemaVersionKey = "SongSaveSchemaVersion";
// v1 使用 Dictionary<saveDifficultyId, BeatmapSave> 保存成绩,彻底与选曲 UI 的列表位置分离。
// 谱面内容改变应递增 Chart Revision而不是此值。正式发布后升级 Schema 时必须恢复非破坏性迁移策略。
private const int CurrentSongSaveSchemaVersion = 1;
public Dictionary<string, SongStatusSave> songStatusSaves;
// 歌曲成绩与 Schema Version 的唯一持久化文件;剧情存档不使用此路径。
private string songSavePath => Application.persistentDataPath + "/GameSaves/SongSaves.json";
public SongSaveModule()
{
songStatusSaves = new Dictionary<string, SongStatusSave>();
//Debug.Log("Song save path: " + songSavePath);
}
}
public partial class SongSaveModule
{
/// <summary>
/// 使已加载的歌曲记录与当前 Chapter 配置保持一致。
/// 会为新增难度建立以稳定 saveDifficultyId 为 Key 的记录,并检查每个谱面的 Chart Revision。
/// 已删除难度的旧 Key 不自动删除:它不会参与当前 UI 或完成度统计,且可在内容恢复时保留历史成绩。
/// Revision 不一致时只重置该谱面的可比成绩,不回退歌曲完成状态、剧情进度或解锁状态。
/// </summary>
private static void EnsureSongStatusMatchesDefinition(SongStatusSave songStatus, SongItemData song)
{
songStatus.additionalInfo ??= string.Empty;
songStatus.beatmapSavesByDifficultyId ??= new Dictionary<int, BeatmapSave>();
foreach (DifficultyData difficulty in song.difficultyDataList)
{
if (difficulty == null || difficulty.saveDifficultyId < 0)
{
continue;
}
BeatmapSave beatmapSave = songStatus.GetOrCreateBeatmapSave(difficulty.saveDifficultyId);
beatmapSave.EnsureChartRevision(difficulty.GetChartRevision());
}
}
/// <summary>
/// 为新加入首发内容的歌曲创建空记录,并立即按当前难度定义初始化 Revision。
/// </summary>
private static SongStatusSave CreateSongStatus(SongItemData song)
{
SongStatusSave songStatus = new SongStatusSave(false, string.Empty, new Dictionary<int, BeatmapSave>());
EnsureSongStatusMatchesDefinition(songStatus, song);
return songStatus;
}
/// <summary>
/// 首次启动、尚无歌曲存档时调用。以当前 Chapter 配置生成完整的空记录集合。
/// </summary>
private void InitializeSongStatuses()
{
songStatusSaves = new Dictionary<string, SongStatusSave>();
foreach (ChapterSelectionUnit chapter in ChapterSelectionManager.instance.chapters)
{
foreach (SongItemData song in chapter.songs)
{
songStatusSaves[song.songName] = CreateSongStatus(song);
}
}
SaveSongStatuses();
}
/// <summary>
/// 每次加载后执行的内容对账与 Revision 检查。
/// 这是新增/删除歌曲、增加难度或递增 Chart Revision 后自动修正旧存档的入口。
/// </summary>
private void CheckSongStatuses()
{
foreach (ChapterSelectionUnit chapter in ChapterSelectionManager.instance.chapters)
{
foreach (SongItemData song in chapter.songs)
{
if (songStatusSaves.TryGetValue(song.songName, out SongStatusSave songStatus))
{
if (songStatus == null)
{
songStatusSaves[song.songName] = CreateSongStatus(song);
continue;
}
EnsureSongStatusMatchesDefinition(songStatus, song);
}
else
{
songStatusSaves[song.songName] = CreateSongStatus(song);
}
}
}
SaveSongStatuses();
}
[Button]
/// <summary>
/// 将歌曲记录与当前 Schema Version 一起写入 ES3。
/// 不要单独保存成绩而遗漏 Version否则下次启动会把同一份数据错误识别为旧 Schema。
/// </summary>
public void SaveSongStatuses()
{
ES3.Save(SongSavesKey, songStatusSaves, songSavePath);
ES3.Save(SongSaveSchemaVersionKey, CurrentSongSaveSchemaVersion, songSavePath);
}
/// <summary>
/// 将一次已完成演奏写入游戏记录存档。
/// 每个谱面保留当前 Chart Revision 下的最高 Accuracy、最高 Max Combo以及曾达成的 FC/AP
/// 后续较差成绩不会覆盖既有纪录。本函数不修改剧情树、Yarn 变量或歌曲解锁。
/// </summary>
/// <param name="songName">SongStatusSave 的稳定歌曲 Key。</param>
/// <param name="saveDifficultyId">存档 Key必须对应 DifficultyData.saveDifficultyId。</param>
/// <param name="currentChartRevision">当前谱面的 Revision不一致时由 BeatmapSave 重置该谱面成绩。</param>
/// <param name="recorder">仅本局有效的运行时判定记录。</param>
public bool RecordPlayResult(string songName, int saveDifficultyId, int currentChartRevision, PlayingRecorder recorder)
{
if (string.IsNullOrEmpty(songName))
{
Debug.LogWarning("Cannot save play result without a song name.");
return false;
}
if (saveDifficultyId < 0)
{
Debug.LogWarning($"Cannot save play result for '{songName}' with an invalid save difficulty ID: {saveDifficultyId}.");
return false;
}
if (recorder == null || recorder.totalCount <= 0)
{
Debug.LogWarning($"Skipped saving play result for '{songName}' because no judgments were recorded.");
return false;
}
songStatusSaves ??= new Dictionary<string, SongStatusSave>();
if (!songStatusSaves.TryGetValue(songName, out SongStatusSave songStatus) || songStatus == null)
{
songStatus = new SongStatusSave(false, string.Empty, new Dictionary<int, BeatmapSave>());
songStatusSaves[songName] = songStatus;
}
songStatus.additionalInfo ??= string.Empty;
BeatmapSave beatmapSave = songStatus.GetOrCreateBeatmapSave(saveDifficultyId);
bool recordChanged = beatmapSave.ApplyPlayResult(recorder, currentChartRevision);
bool completionChanged = !songStatus.isCompleted;
songStatus.isCompleted = true;
SaveSongStatuses();
return recordChanged || completionChanged;
}
/// <summary>
/// 读取歌曲记录并执行内容对账和 Chart Revision 检查。
/// 当前为正式发布前的重置策略:任何非 v1 的旧测试存档都会直接被新 v1 结构覆盖,
/// 因此开发者需要自行接受成绩清空。正式发布后不得沿用此策略,应改回显式迁移。
/// </summary>
public void LoadSongStatuses()
{
if (ES3.FileExists(songSavePath) && ES3.KeyExists(SongSavesKey, songSavePath))
{
int loadedSchemaVersion = ES3.KeyExists(SongSaveSchemaVersionKey, songSavePath)
? ES3.Load<int>(SongSaveSchemaVersionKey, songSavePath)
: 0;
if (loadedSchemaVersion != CurrentSongSaveSchemaVersion)
{
Debug.LogWarning($"Resetting pre-release song save schema v{loadedSchemaVersion} to v{CurrentSongSaveSchemaVersion}.");
ES3.DeleteFile(songSavePath);
InitializeSongStatuses();
return;
}
songStatusSaves = ES3.Load<Dictionary<string, SongStatusSave>>(SongSavesKey, songSavePath) ??
new Dictionary<string, SongStatusSave>();
CheckSongStatuses();
}
else
{
InitializeSongStatuses();
}
}
[Button]
/// <summary>
/// 仅清除每张谱面的可比成绩Accuracy、FC、AP、Max Combo
/// 保留 Chart Revision、歌曲完成状态、剧情进度与解锁状态适合测试成绩而不破坏流程存档。
/// </summary>
public void ClearBeatmapRecords()
{
foreach (SongStatusSave songStatus in songStatusSaves.Values)
{
if (songStatus?.beatmapSavesByDifficultyId == null)
{
continue;
}
foreach (BeatmapSave beatmapSave in songStatus.beatmapSavesByDifficultyId.Values)
{
if (beatmapSave == null)
{
continue;
}
beatmapSave.accuracy = 0.0f;
beatmapSave.isFullCombo = false;
beatmapSave.isAllPerfect = false;
beatmapSave.maxCombo = 0;
}
}
SaveSongStatuses();
}
/// <summary>
/// 按歌曲 Key 获取内存中的游戏记录;该方法不触发磁盘读取或创建新记录。
/// </summary>
public SongStatusSave GetSongStatusSave(string songName)
{
if (songStatusSaves.TryGetValue(songName, out SongStatusSave save))
{
return save;
}
Debug.LogWarning("Song status save for " + songName + " does not exist.");
return null;
}
}
// StorySaveModule 已迁移至 NewStorySystem/Save/StorySaveModule.cs按章节存树/选项,全局存变量)
}