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

389 lines
15 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;
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;
}
}
}