using System; using System.Collections.Generic; using UnityEngine; public sealed class GlobalAchievementService : MonoBehaviour { private const string SaveCategory = "global_achievement"; private const string SaveKey = "runtime_state"; public const string DefaultResourcesPath = "so/globalachievments"; public const string DefaultEditorAssetFolder = "Assets/Resources/so/globalachievments"; public static GlobalAchievementService Instance { get; private set; } public event Action OnAchievementUnlocked; private readonly List definitions = new List(); private readonly Dictionary stateById = new Dictionary(); private readonly Dictionary metricByType = new Dictionary(); private GlobalAchievementSaveData saveData; private bool initialized; private bool loginReportedThisSession; private int lastSaveFrame = -1; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void Bootstrap() { EnsureInstance(); } public static GlobalAchievementService EnsureInstance() { if (Instance != null) { return Instance; } var host = new GameObject("__global_achievement_service"); 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; GlobalAchievementSaveData loaded; if (!SecureSaveVault.TryLoadJson(SaveCategory, SaveKey, out loaded)) { loaded = new GlobalAchievementSaveData(); } saveData = loaded ?? new GlobalAchievementSaveData(); RebuildCaches(); LoadDefinitionsFromResources(); ReportLoginSessionStart(); RefreshDerivedMetrics(); SaveNow(); } public void LoadDefinitionsFromResources(string resourcesPath = DefaultResourcesPath) { InitializeIfNeeded(); definitions.Clear(); GlobalAchievementSO[] loadedDefinitions = Resources.LoadAll(resourcesPath); if (loadedDefinitions != null && loadedDefinitions.Length > 0) { definitions.AddRange(loadedDefinitions); definitions.Sort(CompareDefinitions); } SyncMirrorFlagsToDefinitions(); EvaluateAll(); } public IReadOnlyList GetDefinitions() { InitializeIfNeeded(); return definitions; } public List GetSnapshots() { InitializeIfNeeded(); var snapshots = new List(definitions.Count); for (int i = 0; i < definitions.Count; i++) { GlobalAchievementSO definition = definitions[i]; if (definition == null || definition.achievementId <= 0) { continue; } GlobalAchievementStateEntry state = GetOrCreateState(definition.achievementId); float currentValue = GetMetric(definition.metricType); float targetValue = Mathf.Max(1f, definition.targetValue); snapshots.Add(new GlobalAchievementSnapshot { definition = definition, currentValue = currentValue, normalizedProgress = Mathf.Clamp01(currentValue / targetValue), unlocked = state.unlocked, unlockedUtc = state.unlockedUtcTicks > 0 ? new DateTime(state.unlockedUtcTicks, DateTimeKind.Utc) : default(DateTime), unlockedDateTimeText = state.unlockedUtcTicks > 0 ? new DateTime(state.unlockedUtcTicks, DateTimeKind.Utc).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") : string.Empty, }); } return snapshots; } public float GetMetric(GlobalAchievementMetricType metricType) { InitializeIfNeeded(); GlobalAchievementMetricEntry metric; if (metricByType.TryGetValue(metricType, out metric)) { return Mathf.Max(0f, metric.value); } return 0f; } public void AddMetric(GlobalAchievementMetricType metricType, float delta) { if (Mathf.Approximately(delta, 0f)) { return; } InitializeIfNeeded(); GlobalAchievementMetricEntry metric = GetOrCreateMetric(metricType); float nextValue = Mathf.Max(0f, metric.value + delta); if (Mathf.Approximately(metric.value, nextValue)) { return; } metric.value = nextValue; PersistMetricsIfChanged(true); } public void SetMetric(GlobalAchievementMetricType metricType, float value) { InitializeIfNeeded(); PersistMetricsIfChanged(SetMetricValue(metricType, value)); } public void SetMetricAtLeast(GlobalAchievementMetricType metricType, float value) { InitializeIfNeeded(); GlobalAchievementMetricEntry metric = GetOrCreateMetric(metricType); float nextValue = Mathf.Max(0f, value); if (nextValue <= metric.value) { return; } metric.value = nextValue; PersistMetricsIfChanged(true); } public void ReportLoginSessionStart() { InitializeIfNeeded(); if (loginReportedThisSession) { return; } loginReportedThisSession = true; string currentDateStamp = DateTime.Now.ToString("yyyy-MM-dd"); if (!string.IsNullOrEmpty(saveData.trustedDateStamp) && string.CompareOrdinal(currentDateStamp, saveData.trustedDateStamp) < 0) { currentDateStamp = saveData.trustedDateStamp; } else { saveData.trustedDateStamp = currentDateStamp; } if (!string.Equals(saveData.lastLoginDateStamp, currentDateStamp, StringComparison.Ordinal)) { saveData.lastLoginDateStamp = currentDateStamp; AddMetric(GlobalAchievementMetricType.LoginDays, 1f); return; } SaveNow(); } public void ReportCurrentCoins(int currentCoins) { // No current-coin achievement type now, but this remains available as a central probe point // for future expansion without changing economy call sites. } public void ReportCoinsEarned(int amount) { if (amount <= 0) { return; } AddMetric(GlobalAchievementMetricType.TotalCoinsEarned, amount); } public void RefreshOwnedHeroCount() { InitializeIfNeeded(); PersistMetricsIfChanged(RefreshOwnedHeroCountInternal(true)); } public void RefreshOwnedSongCountAndScores() { InitializeIfNeeded(); PersistMetricsIfChanged(RefreshOwnedSongCountAndScoresInternal(true)); } public void RefreshDerivedMetrics() { InitializeIfNeeded(); StoreOwnershipLedger.EnsureInstance().ForceSyncMirrorFlags(); bool changed = false; changed |= RefreshOwnedHeroCountInternal(false); changed |= RefreshOwnedSongCountAndScoresInternal(false); PersistMetricsIfChanged(changed); } public void SaveNow() { if (!initialized || saveData == null) { return; } if (Application.isPlaying && lastSaveFrame == Time.frameCount) { return; } lastSaveFrame = Application.isPlaying ? Time.frameCount : -1; SecureSaveVault.SaveJson(SaveCategory, SaveKey, saveData); } private void EvaluateAll() { for (int i = 0; i < definitions.Count; i++) { GlobalAchievementSO definition = definitions[i]; if (definition == null || definition.achievementId <= 0) { continue; } GlobalAchievementStateEntry state = GetOrCreateState(definition.achievementId); if (state.unlocked) { continue; } float currentValue = GetMetric(definition.metricType); if (currentValue + 0.0001f < Mathf.Max(0f, definition.targetValue)) { continue; } Unlock(definition, state); } SyncMirrorFlagsToDefinitions(); } private bool RefreshOwnedHeroCountInternal(bool syncOwnershipFirst) { if (syncOwnershipFirst) { StoreOwnershipLedger.EnsureInstance().ForceSyncMirrorFlags(); } AllyHero_SO[] heroes = Resources.LoadAll("so/ally"); if (heroes == null || heroes.Length == 0) { return SetMetricValue(GlobalAchievementMetricType.OwnedHeroCount, 0f); } var uniqueIds = new HashSet(); int unlockedCount = 0; for (int i = 0; i < heroes.Length; i++) { AllyHero_SO hero = heroes[i]; if (hero == null || hero.ally_heroID <= 0 || !uniqueIds.Add(hero.ally_heroID)) { continue; } if (hero.isUnlocked) { unlockedCount++; } } return SetMetricValue(GlobalAchievementMetricType.OwnedHeroCount, unlockedCount); } private bool RefreshOwnedSongCountAndScoresInternal(bool syncOwnershipFirst) { if (syncOwnershipFirst) { StoreOwnershipLedger.EnsureInstance().ForceSyncMirrorFlags(); } SongData[] songs = RuntimeResourcesCache.LoadSongsFromPath("song_songIndex"); if (songs == null || songs.Length == 0) { bool emptyChanged = false; emptyChanged |= SetMetricValue(GlobalAchievementMetricType.OwnedSongCount, 0f); emptyChanged |= SetMetricValue(GlobalAchievementMetricType.BestSingleSongTotalScore, 0f); emptyChanged |= SetMetricValue(GlobalAchievementMetricType.TotalSongBestScoreSum, 0f); return emptyChanged; } var uniqueIds = new HashSet(); int ownedSongCount = 0; int bestSingleSongScore = 0; long totalSongBestScore = 0L; for (int i = 0; i < songs.Length; i++) { SongData song = songs[i]; if (song == null || song.songID <= 0 || !uniqueIds.Add(song.songID)) { continue; } song.EnsurePersistentDataLoaded(); if (song.isUnlocked) { ownedSongCount++; } int highestScore; int bestDifficulty; string bestDifficultyName; song.GetAbsoluteHighestScore(out highestScore, out bestDifficulty, out bestDifficultyName); if (highestScore > bestSingleSongScore) { bestSingleSongScore = highestScore; } totalSongBestScore += Mathf.Max(0, highestScore); } bool changed = false; changed |= SetMetricValue(GlobalAchievementMetricType.OwnedSongCount, ownedSongCount); changed |= SetMetricValue(GlobalAchievementMetricType.BestSingleSongTotalScore, bestSingleSongScore); changed |= SetMetricValue(GlobalAchievementMetricType.TotalSongBestScoreSum, totalSongBestScore > float.MaxValue ? float.MaxValue : totalSongBestScore); return changed; } private bool SetMetricValue(GlobalAchievementMetricType metricType, float value) { GlobalAchievementMetricEntry metric = GetOrCreateMetric(metricType); float nextValue = Mathf.Max(0f, value); if (Mathf.Approximately(metric.value, nextValue)) { return false; } metric.value = nextValue; return true; } private void PersistMetricsIfChanged(bool changed) { if (!changed) { return; } EvaluateAll(); SaveNow(); } private void Unlock(GlobalAchievementSO definition, GlobalAchievementStateEntry state) { state.unlocked = true; state.unlockedUtcTicks = DateTime.UtcNow.Ticks; definition.SetUnlockedState(true, state.unlockedUtcTicks); GlobalAchievementSteamBridge.TryUnlock(definition); if (OnAchievementUnlocked != null) { OnAchievementUnlocked(definition); } } private GlobalAchievementMetricEntry GetOrCreateMetric(GlobalAchievementMetricType metricType) { GlobalAchievementMetricEntry metric; if (metricByType.TryGetValue(metricType, out metric)) { return metric; } metric = new GlobalAchievementMetricEntry { metricType = metricType, value = 0f }; metricByType.Add(metricType, metric); saveData.metrics.Add(metric); return metric; } private GlobalAchievementStateEntry GetOrCreateState(int achievementId) { GlobalAchievementStateEntry state; if (stateById.TryGetValue(achievementId, out state)) { return state; } state = new GlobalAchievementStateEntry { achievementId = achievementId, unlocked = false, unlockedUtcTicks = 0L }; stateById.Add(achievementId, state); saveData.states.Add(state); return state; } private void RebuildCaches() { stateById.Clear(); metricByType.Clear(); if (saveData.metrics == null) { saveData.metrics = new List(); } if (saveData.states == null) { saveData.states = new List(); } for (int i = 0; i < saveData.metrics.Count; i++) { GlobalAchievementMetricEntry entry = saveData.metrics[i]; if (entry == null) { continue; } metricByType[entry.metricType] = entry; } for (int i = 0; i < saveData.states.Count; i++) { GlobalAchievementStateEntry entry = saveData.states[i]; if (entry == null || entry.achievementId <= 0) { continue; } stateById[entry.achievementId] = entry; } } private static int CompareDefinitions(GlobalAchievementSO left, GlobalAchievementSO right) { if (left == null && right == null) { return 0; } if (left == null) { return 1; } if (right == null) { return -1; } int seriesCompare = string.CompareOrdinal(left.achievementSeriesKey, right.achievementSeriesKey); if (seriesCompare != 0) { return seriesCompare; } int levelCompare = left.achievementLevel.CompareTo(right.achievementLevel); if (levelCompare != 0) { return levelCompare; } int sortCompare = left.sortOrder.CompareTo(right.sortOrder); if (sortCompare != 0) { return sortCompare; } int metricCompare = left.metricType.CompareTo(right.metricType); if (metricCompare != 0) { return metricCompare; } int targetCompare = left.targetValue.CompareTo(right.targetValue); if (targetCompare != 0) { return targetCompare; } return left.achievementId.CompareTo(right.achievementId); } private void SyncMirrorFlagsToDefinitions() { for (int i = 0; i < definitions.Count; i++) { GlobalAchievementSO definition = definitions[i]; if (definition == null || definition.achievementId <= 0) { continue; } GlobalAchievementStateEntry state; bool unlocked = stateById.TryGetValue(definition.achievementId, out state) && state != null && state.unlocked; definition.SetUnlockedState(unlocked, unlocked && state != null ? state.unlockedUtcTicks : 0L); } } }