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;
}
}
}