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

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

@@ -4,12 +4,27 @@ using UnityEngine;
namespace Ichni.RhythmGame
{
/// <summary>
/// 单个歌曲难度的最佳成绩。
/// 这里只保存当前 Chart Revision 下可比较的最佳记录,不保存逐局历史、判定时间线或 Replay。
/// </summary>
public class BeatmapSave
{
/// <summary>历史最高 Accuracy固定使用 0 到 1显示时由 UI 乘以 100。</summary>
public float accuracy;
/// <summary>当前 Revision 下是否曾达成 Full Combo。</summary>
public bool isFullCombo;
/// <summary>当前 Revision 下是否曾达成 All Perfect。</summary>
public bool isAllPerfect;
/// <summary>成绩对应的谱面修订号0 表示尚未绑定 Revision 的新建或异常记录。</summary>
public int chartRevision;
/// <summary>当前 Revision 下的历史最高连击数。</summary>
public int maxCombo;
public BeatmapSave()
{
@@ -21,10 +36,83 @@ namespace Ichni.RhythmGame
this.isFullCombo = isFullCombo;
this.isAllPerfect = isAllPerfect;
}
/// <summary>
/// 令存档成绩匹配当前谱面 Revision。
/// <para>仅当 Note、Timing 或判定规则改变、导致成绩不再可比较时才递增 Revision。</para>
/// <para>未绑定 Revision 的记录会保留已有成绩并绑定到当前 Revision之后出现真正的 Revision
/// 变化时,才仅重置该谱面的可比成绩。</para>
/// </summary>
public bool EnsureChartRevision(int currentChartRevision)
{
currentChartRevision = Mathf.Max(1, currentChartRevision);
if (chartRevision <= 0)
{
chartRevision = currentChartRevision;
return true;
}
if (chartRevision == currentChartRevision)
{
return false;
}
accuracy = 0f;
isFullCombo = false;
isAllPerfect = false;
maxCombo = 0;
chartRevision = currentChartRevision;
return true;
}
/// <summary>
/// 合并一次完整演奏的成绩。
/// <see cref="accuracy"/> 在持久化记录中始终使用 0 到 1 的归一化值,
/// 而 <see cref="PlayingRecorder.accuracy"/> 的运行时显示值为 0 到 100。
/// 写入规则是“最高 Accuracy / 最高 Max Combo / FC 与 AP 成就粘性保留”,而不是保存最近一次成绩。
/// </summary>
public bool ApplyPlayResult(PlayingRecorder recorder, int currentChartRevision)
{
if (recorder == null || recorder.totalCount <= 0)
{
return false;
}
bool changed = EnsureChartRevision(currentChartRevision);
float normalizedAccuracy = Mathf.Clamp01(recorder.accuracy / 100f);
if (normalizedAccuracy > accuracy)
{
accuracy = normalizedAccuracy;
changed = true;
}
bool achievedFullCombo = recorder.isFullCombo || recorder.isAllPerfect;
if (achievedFullCombo && !isFullCombo)
{
isFullCombo = true;
changed = true;
}
if (recorder.isAllPerfect && !isAllPerfect)
{
isAllPerfect = true;
changed = true;
}
if (recorder.maxCombo > maxCombo)
{
maxCombo = recorder.maxCombo;
changed = true;
}
return changed;
}
/// <summary>用于章节完成度统计Revision 本身不代表玩家已经游玩过该谱面。</summary>
public bool IsEmpty()
{
return accuracy == 0f && !isFullCombo && !isAllPerfect;
return accuracy == 0f && !isFullCombo && !isAllPerfect && maxCombo == 0;
}
}
}

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按章节存树/选项,全局存变量)
}
}

View File

@@ -2,24 +2,69 @@ using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Collections.Generic;
namespace Ichni.RhythmGame
{
/// <summary>
/// 单首歌曲的持久化容器。
/// <see cref="isCompleted"/> 只表示玩家曾完成过该歌曲具体难度的成绩、Revision 与 Max Combo
/// 由 <see cref="beatmapSavesByDifficultyId"/> 中对应稳定难度 ID 的 <see cref="BeatmapSave"/> 管理。
/// </summary>
public class SongStatusSave
{
/// <summary>至少有一次有效结算完成;谱面 Revision 变化不会回退该流程状态。</summary>
public bool isCompleted;
/// <summary>预留的歌曲级扩展字段;赋予新语义前须评估 Schema Version 是否需要递增。</summary>
public string additionalInfo;
public List<BeatmapSave> beatmapSaves;
/// <summary>
/// 以 <c>DifficultyData.saveDifficultyId</c> 为 Key 的谱面成绩。
/// Key 不依赖选曲列表位置,因此重排、隐藏或删除中间难度不会让其他难度的成绩错位。
/// 被内容定义移除的 Key 暂时保留在存档中,但 UI 与章节完成度只读取当前仍存在的难度。
/// </summary>
public Dictionary<int, BeatmapSave> beatmapSavesByDifficultyId = new();
public SongStatusSave()
{
}
public SongStatusSave(bool isCompleted, string additionalInfo, List<BeatmapSave> beatmapSaves)
public SongStatusSave(bool isCompleted, string additionalInfo, Dictionary<int, BeatmapSave> beatmapSavesByDifficultyId)
{
this.isCompleted = isCompleted;
this.additionalInfo = additionalInfo;
this.beatmapSaves = beatmapSaves;
this.beatmapSavesByDifficultyId = beatmapSavesByDifficultyId;
}
/// <summary>只读查询当前定义的难度成绩;不会因 UI 展示而创建新的存档记录。</summary>
public bool TryGetBeatmapSave(int saveDifficultyId, out BeatmapSave beatmapSave)
{
beatmapSave = null;
return saveDifficultyId >= 0 && beatmapSavesByDifficultyId != null &&
beatmapSavesByDifficultyId.TryGetValue(saveDifficultyId, out beatmapSave) && beatmapSave != null;
}
/// <summary>
/// 获取或创建指定稳定难度 ID 的成绩容器。
/// 仅存档写入/内容对账可调用此方法;选曲 UI 应使用 <see cref="TryGetBeatmapSave"/>。
/// </summary>
public BeatmapSave GetOrCreateBeatmapSave(int saveDifficultyId)
{
if (saveDifficultyId < 0)
{
return null;
}
beatmapSavesByDifficultyId ??= new Dictionary<int, BeatmapSave>();
if (!beatmapSavesByDifficultyId.TryGetValue(saveDifficultyId, out BeatmapSave beatmapSave) || beatmapSave == null)
{
beatmapSave = new BeatmapSave(0f, false, false);
beatmapSavesByDifficultyId[saveDifficultyId] = beatmapSave;
}
return beatmapSave;
}
}
}
}

View File

@@ -0,0 +1,388 @@
using System;
using System.Collections.Generic;
using Ichni.Menu;
using Sirenix.OdinInspector;
using UnityEngine;
namespace Ichni
{
/// <summary>
/// Offline 内容解锁的独立持久化模块。
/// <para>本模块只保存“玩家已经获得了哪些解锁 Key”不保存歌曲成绩、剧情树或 Yarn 变量。</para>
/// <para>章节、歌曲、教程及未来其它内容都通过 <see cref="UnlockRequirement"/> 查询本模块,
/// 因而新增内容类型时不需要再建立一份平行的 Key 存档。</para>
/// <para>首发为纯 Offline本地 Key 可被玩家修改,不把它作为反作弊或付费凭证使用。</para>
/// </summary>
public sealed class UnlockSaveModule
{
// 保留既有 ES3 Key 与文件名,使此前仅保存 HashSet 的预发布 UnlockKeys.json 可以无损补写 Schema v1。
private const string UnlockKeysKey = "UnlockKeys";
private const string UnlockSaveSchemaVersionKey = "UnlockSaveSchemaVersion";
private const int CurrentUnlockSaveSchemaVersion = 1;
/// <summary>
/// 当前玩家已获得的所有稳定解锁 Key。只能通过 <see cref="GrantKey"/> / <see cref="ClearAllKeys"/>
/// 修改,避免调用方漏存盘或绕过格式校验。
/// </summary>
private HashSet<string> grantedKeys = new HashSet<string>();
// 同一配置错误可能被 UI 每帧查询;仅记录一次,避免 Console 被重复警告淹没。
private readonly HashSet<string> _reportedInvalidRules = new HashSet<string>();
private string UnlockSavePath => Application.persistentDataPath + "/GameSaves/UnlockKeys.json";
/// <summary>
/// 解锁状态变化事件。当前菜单重新打开时会重建列表;未来若需要在同一页面即时刷新,
/// UI 可订阅此事件后调用自己的刷新方法。
/// </summary>
public event Action UnlockStateChanged;
/// <summary>
/// 读取解锁 Key。旧预发布文件若只有 <c>UnlockKeys</c> 而没有 Schema因数据布局相同会直接补写 v1
/// 其它不兼容 Schema 仍按当前预发布策略重置。
/// </summary>
public void LoadUnlockKeys()
{
grantedKeys = new HashSet<string>();
if (!ES3.FileExists(UnlockSavePath) || !ES3.KeyExists(UnlockKeysKey, UnlockSavePath))
{
SaveUnlockKeys();
return;
}
int loadedSchemaVersion = ES3.KeyExists(UnlockSaveSchemaVersionKey, UnlockSavePath)
? ES3.Load<int>(UnlockSaveSchemaVersionKey, UnlockSavePath)
: 0;
// v0 是本项目此前的 HashSet<string> 直接存档,字段布局与 v1 一致,因此可以原样保留。
if (loadedSchemaVersion != 0 && loadedSchemaVersion != CurrentUnlockSaveSchemaVersion)
{
Debug.LogWarning($"Resetting pre-release unlock save schema v{loadedSchemaVersion} to v{CurrentUnlockSaveSchemaVersion}.");
ES3.DeleteFile(UnlockSavePath);
SaveUnlockKeys();
return;
}
grantedKeys = ES3.Load<HashSet<string>>(UnlockKeysKey, UnlockSavePath) ?? new HashSet<string>();
RemoveInvalidKeysLoadedFromDisk();
// 包括从 v0 补写 v1、以及剔除无效 Key 后的回存。
SaveUnlockKeys();
}
/// <summary>
/// 立即写入全部已授予的 Key 与当前 Schema Version。
/// 调用方不应单独操作 ES3以保证 Key 集合与 Schema 始终成对更新。
/// </summary>
public void SaveUnlockKeys()
{
ES3.Save(UnlockKeysKey, grantedKeys, UnlockSavePath);
ES3.Save(UnlockSaveSchemaVersionKey, CurrentUnlockSaveSchemaVersion, UnlockSavePath);
}
/// <summary>
/// 判断玩家是否已持有一个有效 Key。空 Key 永远不代表已解锁;
/// 无条件开放应通过 <see cref="UnlockRequirement.root"/> 为 null 表示。
/// </summary>
public bool HasKey(string key)
{
return IsValidKey(key) && grantedKeys.Contains(key);
}
/// <summary>
/// 授予一个内容解锁 Key并在首次授予时立即保存。
/// 返回 true 表示本次确实新增了 Key已存在或格式非法时返回 false。
/// </summary>
public bool GrantKey(string key)
{
if (!IsValidKey(key))
{
Debug.LogWarning($"Cannot grant invalid unlock key '{key}'. {KeyNamingRule}");
return false;
}
if (!grantedKeys.Add(key))
{
return false;
}
SaveUnlockKeys();
UnlockStateChanged?.Invoke();
return true;
}
/// <summary>
/// 仅供开发调试或“清空剧情进度”流程调用。它会删除所有由剧情授予的内容访问权,
/// 但不会影响歌曲成绩、Chart Revision 或 Yarn 变量。
/// </summary>
public void ClearAllKeys()
{
if (grantedKeys.Count == 0)
{
return;
}
grantedKeys.Clear();
SaveUnlockKeys();
UnlockStateChanged?.Invoke();
}
/// <summary>
/// 统一判断章节是否可访问。章节入口必须调用本方法,不能只依赖 Button.interactable 的视觉状态。
/// </summary>
public bool CanAccessChapter(ChapterSelectionUnit chapter)
{
if (chapter == null)
{
Debug.LogWarning("Cannot evaluate a null chapter unlock requirement.");
return false;
}
return IsRequirementSatisfied(chapter.unlockRequirement, $"chapter '{chapter.chapterIndex}'");
}
/// <summary>
/// 统一判断歌曲本身是否可访问。它不包含章节判断;需要真正开始游玩时请使用 <see cref="CanEnterSong"/>。
/// </summary>
public bool CanAccessSong(SongItemData song)
{
if (song == null)
{
Debug.LogWarning("Cannot evaluate a null song unlock requirement.");
return false;
}
return IsRequirementSatisfied(song.unlockRequirement, $"song '{song.songName}'");
}
/// <summary>
/// 所有进入歌曲的最终授权入口:章节与歌曲本身都必须开放。
/// 标准 Play、快速点击以及未来 Story Song Block 都应调用此方法,避免入口之间出现绕过。
/// </summary>
public bool CanEnterSong(ChapterSelectionUnit chapter, SongItemData song)
{
return CanAccessChapter(chapter) && CanAccessSong(song);
}
/// <summary>
/// 求值一个内容配置的解锁规则。null 规则或根节点为空均表示无条件开放;
/// 已配置但不完整的条件节点会失败关闭(保持锁定)并只记录一次警告。
/// </summary>
public bool IsRequirementSatisfied(UnlockRequirement requirement, string ownerDescription)
{
return requirement == null || requirement.IsSatisfied(this, ownerDescription);
}
/// <summary>
/// Key 命名规则:只能使用小写英文字母、数字和下划线;必须以字母开头,长度 196。
/// 推荐按“来源_章节_事件_状态”命名例如 <c>story_ch0_prologue_completed</c>。
/// 不得使用空格、点号、连字符、显示名称、翻译文本或会频繁改名的 Yarn 标题。
/// </summary>
public const string KeyNamingRule =
"Unlock Key must use lowercase letters, digits and underscores, start with a letter, and be 1-96 characters.";
/// <summary>
/// 验证 Key 是否符合项目稳定命名约定。此方法不记录日志,便于 UI 的高频查询安全调用。
/// </summary>
public static bool IsValidKey(string key)
{
if (string.IsNullOrEmpty(key) || key.Length > 96 || key[0] < 'a' || key[0] > 'z')
{
return false;
}
for (int index = 1; index < key.Length; index++)
{
char character = key[index];
bool isLowercaseLetter = character >= 'a' && character <= 'z';
bool isDigit = character >= '0' && character <= '9';
if (!isLowercaseLetter && !isDigit && character != '_')
{
return false;
}
}
return true;
}
/// <summary>
/// 由规则节点调用,确保无效配置只输出一次可定位的警告。
/// </summary>
internal void ReportInvalidRuleOnce(string ownerDescription, string detail)
{
string reportKey = ownerDescription + ":" + detail;
if (_reportedInvalidRules.Add(reportKey))
{
Debug.LogWarning($"Invalid unlock requirement on {ownerDescription}: {detail}. {KeyNamingRule}");
}
}
private void RemoveInvalidKeysLoadedFromDisk()
{
List<string> invalidKeys = null;
foreach (string key in grantedKeys)
{
if (!IsValidKey(key))
{
invalidKeys ??= new List<string>();
invalidKeys.Add(key);
}
}
if (invalidKeys == null)
{
return;
}
foreach (string key in invalidKeys)
{
grantedKeys.Remove(key);
}
Debug.LogWarning($"Removed {invalidKeys.Count} invalid unlock key(s) while loading the pre-release save. {KeyNamingRule}");
}
}
/// <summary>
/// 可配置在章节或歌曲上的解锁规则。根节点为空表示无条件开放;
/// 若要锁定内容,请在 Inspector 中选择 Key / All Of / Any Of 节点并填写稳定 Key。
/// </summary>
[InlineProperty]
[Serializable]
public sealed class UnlockRequirement
{
[HideLabel]
[SerializeReference]
[InfoBox("留空表示无条件开放。Key 使用小写字母、数字和下划线,例如 story_ch0_prologue_completed。可组合 All Of(AND) / Any Of(OR)。")]
public UnlockConditionNode root;
/// <summary>
/// 使用当前玩家的 <see cref="UnlockSaveModule"/> 对规则树求值。
/// </summary>
internal bool IsSatisfied(UnlockSaveModule unlockSaveModule, string ownerDescription)
{
return root == null || root.Evaluate(unlockSaveModule, ownerDescription);
}
}
/// <summary>
/// 内容解锁条件树的基类。它只依赖已授予的 Key不要把歌曲成绩、Yarn 变量或付费状态直接写入节点。
/// 如需未来扩展条件来源,应在本树增加新的叶子节点,而不是让 UI 自行拼写判断。
/// </summary>
[Serializable]
public abstract class UnlockConditionNode
{
/// <summary>
/// 对当前节点求值。返回 false 即表示内容保持锁定。
/// </summary>
internal abstract bool Evaluate(UnlockSaveModule unlockSaveModule, string ownerDescription);
}
/// <summary>
/// 叶子条件:玩家持有指定 Key 时满足。
/// </summary>
[Serializable]
[LabelText("Key")]
[HideReferenceObjectPicker]
public sealed class UnlockKeyCondition : UnlockConditionNode
{
[HideLabel]
[InfoBox("只能使用小写字母、数字和下划线;例如 story_ch0_prologue_completed。Key 为空会使该内容保持锁定。")]
public string key;
internal override bool Evaluate(UnlockSaveModule unlockSaveModule, string ownerDescription)
{
if (!UnlockSaveModule.IsValidKey(key))
{
unlockSaveModule?.ReportInvalidRuleOnce(ownerDescription, $"key '{key}' is empty or malformed");
return false;
}
return unlockSaveModule != null && unlockSaveModule.HasKey(key);
}
}
/// <summary>
/// 组合条件AND所有子条件都满足时开放。空列表或包含空节点视为配置错误并失败关闭。
/// </summary>
[Serializable]
[LabelText("All Of (AND)")]
[HideReferenceObjectPicker]
public sealed class UnlockAllOfCondition : UnlockConditionNode
{
[HideLabel]
[SerializeReference]
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = true)]
public List<UnlockConditionNode> conditions = new List<UnlockConditionNode>();
internal override bool Evaluate(UnlockSaveModule unlockSaveModule, string ownerDescription)
{
if (conditions == null || conditions.Count == 0)
{
unlockSaveModule?.ReportInvalidRuleOnce(ownerDescription, "All Of has no child conditions");
return false;
}
foreach (UnlockConditionNode condition in conditions)
{
if (condition == null || !condition.Evaluate(unlockSaveModule, ownerDescription))
{
if (condition == null)
{
unlockSaveModule?.ReportInvalidRuleOnce(ownerDescription, "All Of contains a null child condition");
}
return false;
}
}
return true;
}
}
/// <summary>
/// 组合条件OR任意子条件满足时开放。空列表或只包含空节点视为配置错误并失败关闭。
/// </summary>
[Serializable]
[LabelText("Any Of (OR)")]
[HideReferenceObjectPicker]
public sealed class UnlockAnyOfCondition : UnlockConditionNode
{
[HideLabel]
[SerializeReference]
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = true)]
public List<UnlockConditionNode> conditions = new List<UnlockConditionNode>();
internal override bool Evaluate(UnlockSaveModule unlockSaveModule, string ownerDescription)
{
if (conditions == null || conditions.Count == 0)
{
unlockSaveModule?.ReportInvalidRuleOnce(ownerDescription, "Any Of has no child conditions");
return false;
}
bool hasValidChild = false;
foreach (UnlockConditionNode condition in conditions)
{
if (condition == null)
{
continue;
}
hasValidChild = true;
if (condition.Evaluate(unlockSaveModule, ownerDescription))
{
return true;
}
}
if (!hasValidChild)
{
unlockSaveModule?.ReportInvalidRuleOnce(ownerDescription, "Any Of contains no valid child condition");
}
return false;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 35e319c0c2854f0fb9c59af946ee8318
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: