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