using System; using System.IO; using UnityEngine; public sealed class PlayerExperienceLedger : MonoBehaviour { [Serializable] private class PlayerExperiencePayload { public int version = 1; public long lastUpdatedUtcTicks; public int playerExp; } public static PlayerExperienceLedger Instance { get; private set; } public event Action OnExperienceChanged; private PlayerExperiencePayload payload; private bool initialized; private bool loadedFromSave; private Player_SO boundPlayerData; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void Bootstrap() { EnsureInstance(); } public static PlayerExperienceLedger EnsureInstance() { if (Instance != null) { return Instance; } var host = new GameObject("__runtime_player_exp_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(); TryAttachDefaultPlayerData(); } private void OnApplicationPause(bool pauseStatus) { if (pauseStatus) { SaveNow(); } } private void OnApplicationQuit() { SaveNow(); } public void InitializeIfNeeded() { if (initialized) { return; } loadedFromSave = SecureSaveVault.TryLoadJson("player_experience", "runtime", out payload, GetLegacySavePath()); if (payload == null) { payload = CreateDefaultPayload(); } initialized = true; } public void AttachPlayerData(Player_SO playerData) { if (playerData == null) { return; } InitializeIfNeeded(); boundPlayerData = playerData; if (!loadedFromSave) { payload.playerExp = Mathf.Max(0, playerData.player_currentEXP); SaveNow(); loadedFromSave = true; return; } SyncToPlayerData(); NotifyExperienceChanged(); } public int GetExperience() { InitializeIfNeeded(); return payload.playerExp; } public void AddExperience(int amount) { if (amount == 0) { return; } InitializeIfNeeded(); long next = (long)payload.playerExp + amount; if (next < 0) { next = 0; } else if (next > int.MaxValue) { next = int.MaxValue; } payload.playerExp = (int)next; SaveNow(); } public bool TryConsumeExperience(int amount) { if (amount <= 0) { return true; } InitializeIfNeeded(); if (payload.playerExp < amount) { return false; } payload.playerExp -= amount; SaveNow(); return true; } public void SaveNow() { if (!initialized) { InitializeIfNeeded(); } if (payload == null) { payload = CreateDefaultPayload(); } payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks; SecureSaveVault.SaveJson("player_experience", "runtime", payload, GetLegacySavePath()); SyncToPlayerData(); NotifyExperienceChanged(); } private void SyncToPlayerData() { if (boundPlayerData == null || payload == null) { return; } boundPlayerData.player_currentEXP = Mathf.Max(0, payload.playerExp); #if UNITY_EDITOR if (!Application.isPlaying) { UnityEditor.EditorUtility.SetDirty(boundPlayerData); } #endif } private void NotifyExperienceChanged() { OnExperienceChanged?.Invoke(GetExperience()); } private void TryAttachDefaultPlayerData() { Player_SO player = RuntimeResourcesCache.LoadDefaultPlayerSo(); if (player != null) { AttachPlayerData(player); } } private static PlayerExperiencePayload CreateDefaultPayload() { return new PlayerExperiencePayload { version = 1, lastUpdatedUtcTicks = DateTime.UtcNow.Ticks, playerExp = 0, }; } private static string GetLegacySavePath() { return Path.Combine(Application.persistentDataPath, "player_experience.json"); } }