389 lines
15 KiB
C#
389 lines
15 KiB
C#
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 命名规则:只能使用小写英文字母、数字和下划线;必须以字母开头,长度 1–96。
|
||
/// 推荐按“来源_章节_事件_状态”命名,例如 <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;
|
||
}
|
||
}
|
||
}
|