Files
blockout/BlockOutReplica/Assets/Scripts/Gameplay/BlockOutPlayerState.cs
T
2026-10-20 13:21:04 +08:00

157 lines
8.6 KiB
C#

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<TextAsset>("OriginalFlow/GameEconomyModel");
return asset != null ? JsonUtility.FromJson<BlockOutEconomy>(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<int> paidJourneyLevels = new List<int>();
}
private readonly BlockOutEconomy config;
private readonly Func<long> now;
private readonly Action<string> 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<string> save = null, Func<long> 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<Data>(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<int>();
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<Data>(before); throw; }
return true;
}
}
}