using System; using System.Collections.Generic; using UnityEngine; #if UNITY_EDITOR using UnityEditor; #endif public sealed class StoreOwnershipLedger : MonoBehaviour { private const string RuntimeStoreItemResourcesPath = "so/storeSO"; public static StoreOwnershipLedger Instance { get; private set; } public event Action OnOwnershipChanged; private readonly Dictionary entriesByItemId = new Dictionary(); private readonly List cachedStoreItems = new List(); private bool initialized; private bool loadedFromSave; private bool hasAnyRecoverableLocalState; private int lastSaveFrame = -1; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void Bootstrap() { EnsureInstance(); } public static StoreOwnershipLedger EnsureInstance() { if (Instance != null) { return Instance; } var host = new GameObject("__runtime_ownership_bridge"); host.hideFlags = HideFlags.HideInHierarchy | HideFlags.HideInInspector; DontDestroyOnLoad(host); Instance = host.AddComponent(); return Instance; } private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); InitializeIfNeeded(); } private void OnApplicationPause(bool pauseStatus) { if (pauseStatus) { SaveNow(); } } private void OnApplicationQuit() { SaveNow(); } public void InitializeIfNeeded() { if (initialized) { return; } initialized = true; StoreOwnershipPayload payload; loadedFromSave = StoreOwnershipStorage.TryLoad(out payload); hasAnyRecoverableLocalState = loadedFromSave || StoreOwnershipStorage.HasAnyRecoverableState(); RebuildFromPayload(payload); LoadStoreItems(); bool recoveredFromMirrors = false; if (!loadedFromSave && !hasAnyRecoverableLocalState) { recoveredFromMirrors = SeedFromCurrentMirrorFlags(); } if (loadedFromSave || recoveredFromMirrors) { SyncAllMirrorFlags(); SaveNow(); } } public void ForceSyncMirrorFlags() { InitializeIfNeeded(); LoadStoreItems(); SyncAllMirrorFlags(); } public bool IsOwned(storeItemSO itemSO) { InitializeIfNeeded(); if (itemSO == null) { return false; } StoreOwnershipEntry entry; entriesByItemId.TryGetValue(itemSO.itemID, out entry); switch (itemSO.itemType) { case storeItemSO.ItemType.character: return (entry != null && entry.owned) || (itemSO.associatedAllyHero != null && itemSO.associatedAllyHero.isUnlocked); case storeItemSO.ItemType.song: return (entry != null && entry.owned) || (itemSO.associatedSong != null && itemSO.associatedSong.isUnlocked); case storeItemSO.ItemType.storyPassage: return IsStoryGrantOwned(itemSO, entry); default: return false; } } public bool TryGrantOwnership(storeItemSO itemSO, out string failureMessage) { InitializeIfNeeded(); failureMessage = string.Empty; if (!ValidateGrantTarget(itemSO, out failureMessage)) { return false; } var entry = GetOrCreateEntry(itemSO.itemID); switch (itemSO.itemType) { case storeItemSO.ItemType.character: case storeItemSO.ItemType.song: entry.owned = true; break; case storeItemSO.ItemType.storyPassage: GrantStoryOwnership(itemSO, entry); break; default: failureMessage = LocalizationService.Get("store.error.grant_logic_missing", "当前未配置发放逻辑"); return false; } ApplyEntryToItem(itemSO, entry); GlobalAchievementService.EnsureInstance().RefreshDerivedMetrics(); SaveNow(); NotifyOwnershipChanged(itemSO.itemID); return true; } public bool TryRevokeOwnership(storeItemSO itemSO, out string failureMessage) { InitializeIfNeeded(); failureMessage = string.Empty; if (itemSO == null) { failureMessage = LocalizationService.Get("store.error.item_data_missing", "商品数据丢失"); return false; } StoreOwnershipEntry entry; entriesByItemId.TryGetValue(itemSO.itemID, out entry); if (entry == null && !IsOwned(itemSO)) { return true; } if (entry != null) { entry.owned = false; if (entry.unlockedStorySonIds != null) { entry.unlockedStorySonIds.Clear(); } if (!entry.owned && (entry.unlockedStorySonIds == null || entry.unlockedStorySonIds.Count == 0)) { entriesByItemId.Remove(itemSO.itemID); } } SyncAllMirrorFlags(); GlobalAchievementService.EnsureInstance().RefreshDerivedMetrics(); SaveNow(); NotifyOwnershipChanged(itemSO.itemID); return true; } public bool TryGrantOwnershipPreview(storeItemSO itemSO, out string failureMessage) { InitializeIfNeeded(); return ValidateGrantTarget(itemSO, out failureMessage); } public void SaveNow() { if (!initialized) { return; } if (!loadedFromSave && hasAnyRecoverableLocalState) { Debug.LogWarning("[StoreOwnershipLedger] Recoverable save state exists but could not be loaded; skip saving to avoid overwriting older store ownership data."); return; } if (Application.isPlaying && lastSaveFrame == Time.frameCount) { return; } lastSaveFrame = Application.isPlaying ? Time.frameCount : -1; StoreOwnershipStorage.TrySave(CreatePayload()); } public void ClearPersistentState() { InitializeIfNeeded(); entriesByItemId.Clear(); SyncAllMirrorFlags(); StoreOwnershipStorage.TrySave(CreatePayload()); } private void RebuildFromPayload(StoreOwnershipPayload payload) { entriesByItemId.Clear(); if (payload == null || payload.entries == null) { return; } for (int i = 0; i < payload.entries.Count; i++) { var entry = payload.entries[i]; if (entry == null) { continue; } if (entry.unlockedStorySonIds == null) { entry.unlockedStorySonIds = new List(); } entriesByItemId[entry.storeItemId] = entry; } } private StoreOwnershipPayload CreatePayload() { var payload = StoreOwnershipStorage.CreateDefaultPayload(); foreach (var pair in entriesByItemId) { payload.entries.Add(CloneEntry(pair.Value)); } return payload; } private void LoadStoreItems() { cachedStoreItems.Clear(); var loadedItems = RuntimeResourcesCache.LoadAllStoreItems(); var seen = new HashSet(); for (int i = 0; i < loadedItems.Length; i++) { var item = loadedItems[i]; if (item == null || !seen.Add(item.itemID)) { continue; } cachedStoreItems.Add(item); } } private bool SeedFromCurrentMirrorFlags() { bool changed = false; for (int i = 0; i < cachedStoreItems.Count; i++) { var item = cachedStoreItems[i]; if (item == null || entriesByItemId.ContainsKey(item.itemID)) { continue; } StoreOwnershipEntry seededEntry = TrySeedEntry(item); if (seededEntry == null) { continue; } entriesByItemId[item.itemID] = seededEntry; changed = true; } return changed; } private StoreOwnershipEntry TrySeedEntry(storeItemSO itemSO) { if (itemSO == null) { return null; } switch (itemSO.itemType) { case storeItemSO.ItemType.character: if (itemSO.associatedAllyHero != null && itemSO.associatedAllyHero.isUnlocked) { return new StoreOwnershipEntry { storeItemId = itemSO.itemID, owned = true }; } break; case storeItemSO.ItemType.song: if (itemSO.associatedSong != null && itemSO.associatedSong.isUnlocked) { return new StoreOwnershipEntry { storeItemId = itemSO.itemID, owned = true }; } break; case storeItemSO.ItemType.storyPassage: return TrySeedStoryEntry(itemSO); } return null; } private StoreOwnershipEntry TrySeedStoryEntry(storeItemSO itemSO) { var story = itemSO != null ? itemSO.associatedStoryPassage : null; if (story == null) { return null; } var entry = new StoreOwnershipEntry { storeItemId = itemSO.itemID }; switch (itemSO.storyPassageGrantMode) { case storeItemSO.StoryPassageGrantMode.fatherOnly: entry.owned = story.isUnlocked; break; case storeItemSO.StoryPassageGrantMode.specificSonOnly: if (HasStorySonUnlocked(story, itemSO.associatedStorySonId)) { entry.unlockedStorySonIds.Add(itemSO.associatedStorySonId); } break; case storeItemSO.StoryPassageGrantMode.fatherAndSpecificSon: if (story.isUnlocked || HasStorySonUnlocked(story, itemSO.associatedStorySonId)) { entry.owned = true; if (itemSO.associatedStorySonId >= 0) { entry.unlockedStorySonIds.Add(itemSO.associatedStorySonId); } } break; case storeItemSO.StoryPassageGrantMode.allSons: if (story.isUnlocked || AreAllStorySonsUnlocked(story)) { entry.owned = true; AddAllStorySonIds(story, entry.unlockedStorySonIds); } break; } return entry.owned || entry.unlockedStorySonIds.Count > 0 ? entry : null; } private void SyncAllMirrorFlags() { ResetAllMirrorFlags(); for (int i = 0; i < cachedStoreItems.Count; i++) { var item = cachedStoreItems[i]; if (item == null) { continue; } StoreOwnershipEntry entry; if (!entriesByItemId.TryGetValue(item.itemID, out entry)) { continue; } ApplyEntryToItem(item, entry); } } private void ResetAllMirrorFlags() { var seenHeroIds = new HashSet(); var seenSongIds = new HashSet(); var seenStoryIds = new HashSet(); for (int i = 0; i < cachedStoreItems.Count; i++) { var item = cachedStoreItems[i]; if (item == null) { continue; } if (item.itemType == storeItemSO.ItemType.character && item.associatedAllyHero != null && seenHeroIds.Add(item.associatedAllyHero.ally_heroID)) { if (item.associatedAllyHero.isUnlocked) { item.associatedAllyHero.isUnlocked = false; MarkDirty(item.associatedAllyHero); } } else if (item.itemType == storeItemSO.ItemType.song && item.associatedSong != null && seenSongIds.Add(item.associatedSong.songID)) { if (item.associatedSong.isUnlocked) { item.associatedSong.isUnlocked = false; MarkDirty(item.associatedSong); } } else if (item.itemType == storeItemSO.ItemType.storyPassage && item.associatedStoryPassage != null && seenStoryIds.Add(item.associatedStoryPassage.class_id)) { var story = item.associatedStoryPassage; bool changed = false; if (story.isUnlocked) { story.isUnlocked = false; changed = true; } if (story.sonList != null) { for (int sonIndex = 0; sonIndex < story.sonList.Count; sonIndex++) { var son = story.sonList[sonIndex]; if (son != null && son.son_isUnlocked) { son.son_isUnlocked = false; changed = true; } } } if (changed) { MarkDirty(story); } } } } private bool ValidateGrantTarget(storeItemSO itemSO, out string failureMessage) { failureMessage = string.Empty; if (itemSO == null) { failureMessage = LocalizationService.Get("store.error.item_data_missing", "商品数据丢失"); return false; } if (IsOwned(itemSO)) { failureMessage = LocalizationService.Get("store.error.item_already_unlocked", "物品已解锁"); return false; } switch (itemSO.itemType) { case storeItemSO.ItemType.character: if (itemSO.associatedAllyHero == null) { failureMessage = LocalizationService.Get("store.error.character_data_missing", "角色数据未配置"); return false; } return true; case storeItemSO.ItemType.song: if (itemSO.associatedSong == null) { failureMessage = LocalizationService.Get("store.error.song_data_missing", "歌曲数据未配置"); return false; } return true; case storeItemSO.ItemType.storyPassage: if (itemSO.associatedStoryPassage == null) { failureMessage = LocalizationService.Get("store.error.story_data_missing", "剧情数据未配置"); return false; } if (RequiresSpecificStorySon(itemSO.storyPassageGrantMode)) { if (itemSO.associatedStorySonId < 0 || FindStorySonIndex(itemSO.associatedStoryPassage, itemSO.associatedStorySonId) < 0) { failureMessage = LocalizationService.Get("store.error.story_chapter_missing", "剧情子章节未配置"); return false; } } return true; default: failureMessage = LocalizationService.Get("store.error.grant_logic_missing", "当前未配置发放逻辑"); return false; } } private void GrantStoryOwnership(storeItemSO itemSO, StoreOwnershipEntry entry) { if (entry.unlockedStorySonIds == null) { entry.unlockedStorySonIds = new List(); } switch (itemSO.storyPassageGrantMode) { case storeItemSO.StoryPassageGrantMode.fatherOnly: entry.owned = true; break; case storeItemSO.StoryPassageGrantMode.specificSonOnly: AddStorySonId(entry.unlockedStorySonIds, itemSO.associatedStorySonId); break; case storeItemSO.StoryPassageGrantMode.fatherAndSpecificSon: entry.owned = true; AddStorySonId(entry.unlockedStorySonIds, itemSO.associatedStorySonId); break; case storeItemSO.StoryPassageGrantMode.allSons: entry.owned = true; AddAllStorySonIds(itemSO.associatedStoryPassage, entry.unlockedStorySonIds); break; } } private void ApplyEntryToItem(storeItemSO itemSO, StoreOwnershipEntry entry) { if (itemSO == null || entry == null) { return; } switch (itemSO.itemType) { case storeItemSO.ItemType.character: if (entry.owned && itemSO.associatedAllyHero != null) { itemSO.associatedAllyHero.isUnlocked = true; itemSO.associatedAllyHero.SetJoinDateIfMissing(); MarkDirty(itemSO.associatedAllyHero); } break; case storeItemSO.ItemType.song: if (entry.owned && itemSO.associatedSong != null) { itemSO.associatedSong.isUnlocked = true; MarkDirty(itemSO.associatedSong); } break; case storeItemSO.ItemType.storyPassage: ApplyStoryEntry(itemSO.associatedStoryPassage, entry); break; } } private void ApplyStoryEntry(notebook_faterType story, StoreOwnershipEntry entry) { if (story == null || entry == null) { return; } if (entry.owned || (entry.unlockedStorySonIds != null && entry.unlockedStorySonIds.Count > 0)) { story.isUnlocked = true; MarkDirty(story); } if (entry.unlockedStorySonIds == null || story.sonList == null) { return; } for (int i = 0; i < entry.unlockedStorySonIds.Count; i++) { int targetSonId = entry.unlockedStorySonIds[i]; int sonIndex = FindStorySonIndex(story, targetSonId); if (sonIndex < 0) { continue; } story.sonList[sonIndex].son_isUnlocked = true; MarkDirty(story); } } private bool IsStoryGrantOwned(storeItemSO itemSO, StoreOwnershipEntry entry) { if (itemSO == null || itemSO.associatedStoryPassage == null) { return false; } switch (itemSO.storyPassageGrantMode) { case storeItemSO.StoryPassageGrantMode.fatherOnly: return entry != null && entry.owned; case storeItemSO.StoryPassageGrantMode.specificSonOnly: return HasEntryStorySon(entry, itemSO.associatedStorySonId) || HasStorySonUnlocked(itemSO.associatedStoryPassage, itemSO.associatedStorySonId); case storeItemSO.StoryPassageGrantMode.fatherAndSpecificSon: return (entry != null && entry.owned) || HasEntryStorySon(entry, itemSO.associatedStorySonId) || HasStorySonUnlocked(itemSO.associatedStoryPassage, itemSO.associatedStorySonId); case storeItemSO.StoryPassageGrantMode.allSons: return (entry != null && entry.owned) || AreAllStorySonsUnlocked(itemSO.associatedStoryPassage); default: return false; } } private static bool HasEntryStorySon(StoreOwnershipEntry entry, int sonId) { if (entry == null || entry.unlockedStorySonIds == null || sonId < 0) { return false; } for (int i = 0; i < entry.unlockedStorySonIds.Count; i++) { if (entry.unlockedStorySonIds[i] == sonId) { return true; } } return false; } private static bool HasStorySonUnlocked(notebook_faterType story, int sonId) { int index = FindStorySonIndex(story, sonId); return index >= 0 && story.sonList[index].son_isUnlocked; } private static bool AreAllStorySonsUnlocked(notebook_faterType story) { if (story == null || story.sonList == null || story.sonList.Count == 0) { return false; } for (int i = 0; i < story.sonList.Count; i++) { var son = story.sonList[i]; if (son == null || !son.son_isUnlocked) { return false; } } return true; } private static int FindStorySonIndex(notebook_faterType story, int sonId) { if (story == null || story.sonList == null || sonId < 0) { return -1; } for (int i = 0; i < story.sonList.Count; i++) { var son = story.sonList[i]; if (son != null && son.son_id == sonId) { return i; } } return -1; } private static bool RequiresSpecificStorySon(storeItemSO.StoryPassageGrantMode mode) { return mode == storeItemSO.StoryPassageGrantMode.specificSonOnly || mode == storeItemSO.StoryPassageGrantMode.fatherAndSpecificSon; } private static void AddStorySonId(List sonIds, int sonId) { if (sonIds == null || sonId < 0) { return; } for (int i = 0; i < sonIds.Count; i++) { if (sonIds[i] == sonId) { return; } } sonIds.Add(sonId); } private static void AddAllStorySonIds(notebook_faterType story, List sonIds) { if (story == null || story.sonList == null || sonIds == null) { return; } for (int i = 0; i < story.sonList.Count; i++) { var son = story.sonList[i]; if (son == null) { continue; } AddStorySonId(sonIds, son.son_id); } } private StoreOwnershipEntry GetOrCreateEntry(int storeItemId) { StoreOwnershipEntry entry; if (entriesByItemId.TryGetValue(storeItemId, out entry)) { return entry; } entry = new StoreOwnershipEntry { storeItemId = storeItemId, owned = false, unlockedStorySonIds = new List() }; entriesByItemId[storeItemId] = entry; return entry; } private static StoreOwnershipEntry CloneEntry(StoreOwnershipEntry source) { return new StoreOwnershipEntry { storeItemId = source.storeItemId, owned = source.owned, unlockedStorySonIds = source.unlockedStorySonIds != null ? new List(source.unlockedStorySonIds) : new List() }; } private static void MarkDirty(UnityEngine.Object target) { #if UNITY_EDITOR if (!Application.isPlaying && target != null) { EditorUtility.SetDirty(target); } #endif } private void NotifyOwnershipChanged(int itemId) { if (itemId <= 0 || OnOwnershipChanged == null) { return; } OnOwnershipChanged(itemId); } }