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 bool hasAnyRecoverableLocalState; 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; } hasAnyRecoverableLocalState = SecureSaveVault.HasAnyRecoverableState("player_experience", "runtime", GetLegacySavePath()) || PlayerProgressBackupService.HasPlayerExperienceBackup(); loadedFromSave = SecureSaveVault.TryLoadJson("player_experience", "runtime", out payload, GetLegacySavePath()); if (payload == null) { int backupExperience; if (PlayerProgressBackupService.TryRestorePlayerExperience(out backupExperience)) { payload = CreateDefaultPayload(); payload.playerExp = Mathf.Max(0, backupExperience); loadedFromSave = true; } else { payload = CreateDefaultPayload(); } } hasAnyRecoverableLocalState = hasAnyRecoverableLocalState || loadedFromSave; initialized = true; } public void AttachPlayerData(Player_SO playerData) { if (playerData == null) { return; } InitializeIfNeeded(); boundPlayerData = playerData; if (!loadedFromSave && !hasAnyRecoverableLocalState) { // 首次启动且无任何可恢复存档:用硬编码默认值(0)建立初始存档, // 不读取 Player_SO 的烘焙经验值,避免打包测试数据污染玩家进度。 payload = CreateDefaultPayload(); SyncToPlayerData(); SaveNow(); loadedFromSave = true; hasAnyRecoverableLocalState = true; NotifyExperienceChanged(); return; } SyncToPlayerData(); NotifyExperienceChanged(); } public int GetExperience() { InitializeIfNeeded(); return payload.playerExp; } public void SetExperience(int amount) { InitializeIfNeeded(); payload.playerExp = Mathf.Max(0, amount); SaveNow(); } 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(); } if (!loadedFromSave && hasAnyRecoverableLocalState) { Debug.LogWarning("[PlayerExperienceLedger] Recoverable save state exists but could not be loaded; skip saving to avoid overwriting older player experience data."); return; } payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks; SecureSaveVault.SaveJson("player_experience", "runtime", payload, GetLegacySavePath()); PlayerProgressBackupService.SavePlayerExperience(payload.playerExp); SyncToPlayerData(); NotifyExperienceChanged(); } public void ClearPersistentState() { InitializeIfNeeded(); payload = CreateDefaultPayload(); SecureSaveVault.Delete("player_experience", "runtime", GetLegacySavePath()); PlayerProgressBackupService.ClearPlayerExperienceBackup(); 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"); } }