存档重构,游戏内容解锁机制;教程完善(未完成)

This commit is contained in:
SoulliesOfficial
2026-07-18 16:51:18 -04:00
parent d48ef1e65e
commit dda354ebb9
123 changed files with 4032 additions and 558 deletions

View File

@@ -1,4 +1,3 @@
using System;
using System.Collections;
using System.Collections.Generic;
using Ichni.Menu;
@@ -9,11 +8,19 @@ 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()
@@ -31,10 +38,15 @@ namespace Ichni
private void Start()
{
// 菜单与章节 UI 会在启动后立刻读取歌曲记录,因此游戏记录必须先加载。
SongSaveModule = new SongSaveModule();
SongSaveModule.LoadSongStatuses();
SongSaveModule.LoadStoryUnlockKeys();
// 内容解锁既不属于歌曲成绩也不属于 Yarn 剧情树。菜单在首次显示前必须先得到它的状态。
UnlockSaveModule = new UnlockSaveModule();
UnlockSaveModule.LoadUnlockKeys();
// 剧情变量属于独立存档,不参与歌曲成绩的 Schema 或 Chart Revision 迁移。
StorySaveModule = new StorySaveModule();
StorySaveModule.LoadVariables();
}
@@ -42,106 +54,82 @@ namespace Ichni
public partial class SongSaveModule
{
public HashSet<string> storyUnlockKeys;
public HashSet<string> paymentUnlockKeys;
// 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";
private string storyUnlockKeysPath => Application.persistentDataPath + "/GameSaves/UnlockKeys.json";
private string paymentUnlockKeysPath => Application.persistentDataPath + "/GameSaves/PaymentUnlockKeys.json";
public SongSaveModule()
{
songStatusSaves = new Dictionary<string, SongStatusSave>();
storyUnlockKeys = new HashSet<string>();
paymentUnlockKeys = new HashSet<string>();
//Debug.Log("Song save path: " + songSavePath);
}
}
public partial class SongSaveModule
{
public void SaveStoryUnlockKeys()
/// <summary>
/// 使已加载的歌曲记录与当前 Chapter 配置保持一致。
/// 会为新增难度建立以稳定 saveDifficultyId 为 Key 的记录,并检查每个谱面的 Chart Revision。
/// 已删除难度的旧 Key 不自动删除:它不会参与当前 UI 或完成度统计,且可在内容恢复时保留历史成绩。
/// Revision 不一致时只重置该谱面的可比成绩,不回退歌曲完成状态、剧情进度或解锁状态。
/// </summary>
private static void EnsureSongStatusMatchesDefinition(SongStatusSave songStatus, SongItemData song)
{
ES3.Save("UnlockKeys", storyUnlockKeys, storyUnlockKeysPath);
}
public void LoadStoryUnlockKeys()
{
if (ES3.FileExists(storyUnlockKeysPath))
songStatus.additionalInfo ??= string.Empty;
songStatus.beatmapSavesByDifficultyId ??= new Dictionary<int, BeatmapSave>();
foreach (DifficultyData difficulty in song.difficultyDataList)
{
storyUnlockKeys = ES3.Load<HashSet<string>>("UnlockKeys", storyUnlockKeysPath);
}
else
{
storyUnlockKeys = new HashSet<string>();
SaveStoryUnlockKeys();
if (difficulty == null || difficulty.saveDifficultyId < 0)
{
continue;
}
BeatmapSave beatmapSave = songStatus.GetOrCreateBeatmapSave(difficulty.saveDifficultyId);
beatmapSave.EnsureChartRevision(difficulty.GetChartRevision());
}
}
public bool CheckStoryKey(string key)
/// <summary>
/// 为新加入首发内容的歌曲创建空记录,并立即按当前难度定义初始化 Revision。
/// </summary>
private static SongStatusSave CreateSongStatus(SongItemData song)
{
return key == string.Empty || storyUnlockKeys.Contains(key);
SongStatusSave songStatus = new SongStatusSave(false, string.Empty, new Dictionary<int, BeatmapSave>());
EnsureSongStatusMatchesDefinition(songStatus, song);
return songStatus;
}
public void ClearStoryKeys()
{
storyUnlockKeys.Clear();
SaveStoryUnlockKeys();
}
}
public partial class SongSaveModule
{
public void SavePaymentUnlockKeys()
{
ES3.Save("UnlockKeys", paymentUnlockKeys, paymentUnlockKeysPath);
}
public void LoadPaymentUnlockKeys()
{
if (ES3.FileExists(paymentUnlockKeysPath))
{
paymentUnlockKeys = ES3.Load<HashSet<string>>("UnlockKeys", paymentUnlockKeysPath);
}
else
{
paymentUnlockKeys = new HashSet<string>();
SavePaymentUnlockKeys();
}
}
public bool CheckPaymentKey(string key)
{
return key == string.Empty || paymentUnlockKeys.Contains(key);
}
public void ClearPaymentKeys()
{
paymentUnlockKeys.Clear();
SavePaymentUnlockKeys();
}
}
public partial class SongSaveModule
{
/// <summary>
/// 首次启动、尚无歌曲存档时调用。以当前 Chapter 配置生成完整的空记录集合。
/// </summary>
private void InitializeSongStatuses()
{
songStatusSaves = new Dictionary<string, SongStatusSave>();
foreach (ChapterSelectionUnit chapter in ChapterSelectionManager.instance.chapters)
{
foreach (SongItemData song in chapter.songs)
{
SongStatusSave songStatus = new SongStatusSave(false, string.Empty, new List<BeatmapSave>());
foreach (DifficultyData difficulty in song.difficultyDataList)
{
songStatus.beatmapSaves.Add(new BeatmapSave(0f,false, false));
}
songStatusSaves.Add(song.songName, songStatus);
songStatusSaves[song.songName] = CreateSongStatus(song);
}
}
SaveSongStatuses();
}
/// <summary>
/// 每次加载后执行的内容对账与 Revision 检查。
/// 这是新增/删除歌曲、增加难度或递增 Chart Revision 后自动修正旧存档的入口。
/// </summary>
private void CheckSongStatuses()
{
foreach (ChapterSelectionUnit chapter in ChapterSelectionManager.instance.chapters)
@@ -150,27 +138,17 @@ namespace Ichni
{
if (songStatusSaves.TryGetValue(song.songName, out SongStatusSave songStatus))
{
int difficultiesCount = song.difficultyDataList.Count;
if (songStatus.beatmapSaves.Count < difficultiesCount)
if (songStatus == null)
{
for (int i = songStatus.beatmapSaves.Count; i < difficultiesCount; i++)
{
songStatus.beatmapSaves.Add(new BeatmapSave(0f, false, false));
}
}
else if (songStatus.beatmapSaves.Count > difficultiesCount)
{
songStatus.beatmapSaves.RemoveRange(difficultiesCount, songStatus.beatmapSaves.Count - difficultiesCount);
songStatusSaves[song.songName] = CreateSongStatus(song);
continue;
}
EnsureSongStatusMatchesDefinition(songStatus, song);
}
else
{
songStatus = new SongStatusSave(false, string.Empty, new List<BeatmapSave>());
foreach (DifficultyData difficulty in song.difficultyDataList)
{
songStatus.beatmapSaves.Add(new BeatmapSave(0f, false, false));
}
songStatusSaves.Add(song.songName, songStatus);
songStatusSaves[song.songName] = CreateSongStatus(song);
}
}
}
@@ -179,16 +157,86 @@ namespace Ichni
}
[Button]
/// <summary>
/// 将歌曲记录与当前 Schema Version 一起写入 ES3。
/// 不要单独保存成绩而遗漏 Version否则下次启动会把同一份数据错误识别为旧 Schema。
/// </summary>
public void SaveSongStatuses()
{
ES3.Save("SongSaves", songStatusSaves, songSavePath);
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))
if (ES3.FileExists(songSavePath) && ES3.KeyExists(SongSavesKey, songSavePath))
{
songStatusSaves = ES3.Load<Dictionary<string, SongStatusSave>>("SongSaves", 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
@@ -198,21 +246,38 @@ namespace Ichni
}
[Button]
/// <summary>
/// 仅清除每张谱面的可比成绩Accuracy、FC、AP、Max Combo
/// 保留 Chart Revision、歌曲完成状态、剧情进度与解锁状态适合测试成绩而不破坏流程存档。
/// </summary>
public void ClearBeatmapRecords()
{
foreach (var songStatus in songStatusSaves.Values)
foreach (SongStatusSave songStatus in songStatusSaves.Values)
{
foreach (var beatmapSave in songStatus.beatmapSaves)
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))
@@ -226,4 +291,4 @@ namespace Ichni
}
// StorySaveModule 已迁移至 NewStorySystem/Save/StorySaveModule.cs按章节存树/选项,全局存变量)
}
}