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