using System; using System.Collections.Generic; using UnityEngine; namespace BlockOutReplica.Gameplay { public enum BoosterType { None, Freeze, Rocket, Ufo } public enum GameModal { None, Pause, Restart, Quit, OutOfTime, GiveUp, Failed, PreLevel, BoosterPurchase, Lives, Settings, Info } [Serializable] public sealed class BlockOutEconomy { public int refillPrice = 900, PlayOnPrice = 900, SecondPlayOnPrice = 1900, ThirdSecondPlayOnPrice = 2300; public int refillDurationAsMinutes = 30, maxLifeAmount = 5, initialCoin = 1000; public int InitialUfoAmount = 3, InitialRocketAmount = 3, InitialTimeFreezeAmount = 3; public int UfoPrice = 1200, RocketPrice = 600, TimeFreezePrice = 300; public int TimeFreezeDurationAsSeconds = 10, PlayOnExtraTimeSeconds = 30, RegularLevelWinCoinReward = 20; public static BlockOutEconomy Load() { var asset = Resources.Load("OriginalFlow/GameEconomyModel"); return asset != null ? JsonUtility.FromJson(asset.text) : new BlockOutEconomy(); } public int Price(BoosterType type) => type == BoosterType.Freeze ? TimeFreezePrice : type == BoosterType.Rocket ? RocketPrice : UfoPrice; public int ContinuePrice(int used) => used == 0 ? PlayOnPrice : used == 1 ? SecondPlayOnPrice : ThirdSecondPlayOnPrice; } // One local save for wallet, inventory and settings. Tests can pass an in-memory // JSON writer and clock; they never need to modify the user's PlayerPrefs. public sealed class BlockOutPlayerState { private const string Key = "BlockOutReplica.PlayerState.v1"; [Serializable] private sealed class Data { public int coins, lives, freeze, rocket, ufo; public bool sounds = true, music = true, haptics = true; public long refillUtcTicks, infiniteLivesUntilUtcTicks; public List paidJourneyLevels = new List(); } private readonly BlockOutEconomy config; private readonly Func now; private readonly Action writer; private Data data; public int Coins => data.coins; public int Lives { get { RefreshLives(); return data.lives; } } public bool Sounds => data.sounds; public bool Music => data.music; public bool Haptics => data.haptics; public int MaxLives => config.maxLifeAmount; public double InfiniteLivesSeconds => Math.Max(0, (data.infiniteLivesUntilUtcTicks - now()) / (double)TimeSpan.TicksPerSecond); public bool HasInfiniteLives => InfiniteLivesSeconds > 0; public double SecondsUntilLife => data.lives >= MaxLives ? 0 : Math.Max(0, (data.refillUtcTicks + TimeSpan.FromMinutes(config.refillDurationAsMinutes).Ticks - now()) / (double)TimeSpan.TicksPerSecond); public BlockOutPlayerState(BlockOutEconomy config, string json = null, Action save = null, Func utcNow = null) { this.config = config; now = utcNow ?? (() => DateTime.UtcNow.Ticks); writer = save ?? (value => { PlayerPrefs.SetString(Key, value); PlayerPrefs.Save(); }); if (json == null && save == null) json = PlayerPrefs.GetString(Key, ""); try { if (!string.IsNullOrEmpty(json)) data = JsonUtility.FromJson(json); } catch (ArgumentException) { data = null; } if (data == null) data = new Data {coins = config.initialCoin, lives = MaxLives, freeze = config.InitialTimeFreezeAmount, rocket = config.InitialRocketAmount, ufo = config.InitialUfoAmount, refillUtcTicks = now()}; data.coins = Math.Max(0, data.coins); data.lives = Mathf.Clamp(data.lives, 0, MaxLives); data.freeze = Math.Max(0, data.freeze); data.rocket = Math.Max(0, data.rocket); data.ufo = Math.Max(0, data.ufo); if (data.refillUtcTicks <= 0 || data.refillUtcTicks > now()) data.refillUtcTicks = now(); if (data.paidJourneyLevels == null) data.paidJourneyLevels = new List(); RefreshLives(); } public string ToJson() => JsonUtility.ToJson(data); private void Save() => writer(ToJson()); public int Amount(BoosterType type) => type == BoosterType.Freeze ? data.freeze : type == BoosterType.Rocket ? data.rocket : type == BoosterType.Ufo ? data.ufo : 0; public bool SpendBooster(BoosterType type) { if (Amount(type) <= 0) return false; if (type == BoosterType.Freeze) data.freeze--; else if (type == BoosterType.Rocket) data.rocket--; else data.ufo--; Save(); return true; } public bool CanUseBooster(BoosterType type) => type>=BoosterType.Freeze&&type<=BoosterType.Ufo&& (Amount(type)>0||data.coins>=config.Price(type)); // Native SpendBooster spends inventory first; at zero inventory it pays // for this use directly. No temporary inventory grant or second save. public bool SpendBoosterUse(BoosterType type) { if(!CanUseBooster(type))return false; return Amount(type)>0?SpendBooster(type):SpendCoins(config.Price(type)); } public bool BuyBooster(BoosterType type) { if (type == BoosterType.None || data.coins < config.Price(type)) return false; data.coins -= config.Price(type); if (type == BoosterType.Freeze) data.freeze++; else if (type == BoosterType.Rocket) data.rocket++; else data.ufo++; Save(); return true; } public bool SpendCoins(int amount) { if (amount < 0 || data.coins < amount) return false; data.coins -= amount; Save(); return true; } public void AwardCoins(int amount) { if (amount > 0) { data.coins = (int)Math.Min(int.MaxValue, (long)data.coins + amount); Save(); } } public bool LoseLife() { RefreshLives(); if (HasInfiniteLives) return true; if (data.lives <= 0) return false; if (data.lives == MaxLives) data.refillUtcTicks = now(); data.lives--; Save(); return true; } public bool RefillLives() { RefreshLives(); if (data.lives == MaxLives || data.coins < config.refillPrice) return false; data.coins -= config.refillPrice; data.lives = MaxLives; data.refillUtcTicks = now(); Save(); return true; } public void RefreshLives() { long interval = TimeSpan.FromMinutes(Math.Max(1, config.refillDurationAsMinutes)).Ticks; if (data.lives >= MaxLives) return; long passed = Math.Max(0, now() - data.refillUtcTicks) / interval; if (passed <= 0) return; data.lives = (int)Math.Min(MaxLives, data.lives + passed); data.refillUtcTicks = data.lives >= MaxLives ? now() : data.refillUtcTicks + passed * interval; Save(); } public void ToggleSounds() { data.sounds = !data.sounds; Save(); } public void ToggleMusic() { data.music = !data.music; Save(); } public void ToggleHaptics() { data.haptics = !data.haptics; Save(); } // Wallet receipt and currency are persisted together. If the separate // Journey ledger save fails, retry can repair it without paying twice. public bool GrantJourneyOnce(int level, BlockOutJourneyReward[] rewards) { if (data.paidJourneyLevels.Contains(level)) return true; if (rewards == null || Array.Exists(rewards, r => r==null || !r.IsSupported)) return false; string before = ToJson(); foreach (var reward in rewards) { if (reward.IsCoins) data.coins = (int)Math.Min(int.MaxValue, (long)data.coins + Math.Max(0,reward.Amount)); else if(reward.IsTimedLives) data.infiniteLivesUntilUtcTicks = Math.Max(now(), data.infiniteLivesUntilUtcTicks) + TimeSpan.FromSeconds(reward.DurationSeconds).Ticks; else if(reward.Booster==BoosterType.Freeze)data.freeze=(int)Math.Min(int.MaxValue,(long)data.freeze+Math.Max(0,reward.Amount)); else if(reward.Booster==BoosterType.Rocket)data.rocket=(int)Math.Min(int.MaxValue,(long)data.rocket+Math.Max(0,reward.Amount)); else if(reward.Booster==BoosterType.Ufo)data.ufo=(int)Math.Min(int.MaxValue,(long)data.ufo+Math.Max(0,reward.Amount)); } data.paidJourneyLevels.Add(level); try { Save(); } catch { data = JsonUtility.FromJson(before); throw; } return true; } } }