295 lines
12 KiB
C#
295 lines
12 KiB
C#
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(按章节存树/选项,全局存变量)
|
||
}
|