PlayerSave class that stores and persists player progression and shop state to FileSystem.Data as JSON. It tracks fragments, lifetime stats, owned/active modifiers and bombs, consumables, pet XP/level, tutorials, and provides methods to load/save, record runs, buy/toggle items, and update daily fragment counters.
// PlayerSave.cs
using Sandbox;
using System.Text.Json;
using System.Collections.Generic;
/// <summary>
/// Handles persistent player data across runs.
/// Saved to FileSystem.Data as JSON.
/// </summary>
public class PlayerSave
{
// ── Currency ─────────────────────────────────────────────
public int TotalFragments { get; set; } = 0;
// ── Lifetime stats ────────────────────────────────────────
public int BestScore { get; set; } = 0;
public int BestWave { get; set; } = 0;
public int TotalRuns { get; set; } = 0;
public int TotalChestsCleared { get; set; } = 0;
public int TotalChallengesCompleted { get; set; } = 0;
public float TotalMinutesPlayed { get; set; } = 0f;
public bool PassiveIdleGridActivated {get; set; } = false;
// ── Save / Load ───────────────────────────────────────────
const string SavePath = "player_save.json";
// Today Fragments (from pet farm) ──────────────────────────
public int TodayFragments { get; set; } = 0;
public int TodayIdleFragments { get; set; } = 0; // idle farm only
public string LastPlayDate { get; set; } = "";
// Shop stuff ───────────────────────────────────────────────
public List<string> OwnedModifiers {get; set;} = new();
public List<string> ActiveModifiers {get; set;} = new();
public bool OwnsModifier(ModifierId id) => OwnedModifiers.Contains(id.ToString());
public static readonly int MaxActiveModifiers = 3;
// Heart consumable
public int MaxHeartConsumables =>
PetLevel >= 2 ? 1 : 2; // pet lv2 already uses one life slot (used for shop heart consumable limit)
// Trial items ───────────────────────────────────────────────
public int TrialBombsRunsLeft { get; set; } = 3; // counts down each run
public bool IsInTrial => TrialBombsRunsLeft > 0;
// Welcome Gifts ─────────────────────────────────────────────
public bool WelcomeShown { get; set; } = false;
public int WelcomeChestsOpened { get; set; } = 0;
// Player Save ───────────────────────────────────────────────
public bool TutorialCompleted { get; set; } = false;
public bool TutorialChainStepDone { get; set; } = false;
// Game Over / Leaderboard stats ────────────────────────────
public int BestChainLength { get; set; } = 0;
// Pet System ───────────────────────────────────────────────
public string PetCustomName { get; set; } = "";
public bool PetUnlockShown { get; set; } = false;
public bool PendingEvolution { get; set; } = false;
public int PetLevel { get; set; } = 1;
// ── Set PendingEvolution = true in PlayerSave wherever you level up the pet ──
// That way the flag persists across scene loads — even if the menu
// reloads between run end and menu return, the cinematic still triggers.
// ────────── 100 TOP CONTpublic bool TutorialChainStepDone { get; set; } = false;tutorRIBUTOR GLOBAL XP BAR ────────────
public long ForceBaseline { get; set; } = 0L;
public int ForceFillCount { get; set; } = 0;
public int ForceMedalTier { get; set; } = 0;
// Main Menu Tutorial ────────────────────────────────────────
public bool MenuTutorialCompleted { get; set; } = false;
public bool TutorialHeartFragsGranted { get; set; } = false;
public int MenuTutorialStep { get; set; } = 0;
public bool SurvivalTutorialCompleted { get; set; } = false;
public bool ModifierActive(ModifierId id)
{
// Bomb slots auto-apply when owned, no need to be in ActiveModifiers
if(id == ModifierId.BombSlot2 || id == ModifierId.BombSlot3 || id == ModifierId.BombSlot4)
return OwnsModifier(id);
return ActiveModifiers.Contains(id.ToString());
}
public static PlayerSave Load()
{
try
{
if(FileSystem.Data.FileExists(SavePath))
{
string json = FileSystem.Data.ReadAllText(SavePath);
var save = JsonSerializer.Deserialize<PlayerSave>(json) ?? new PlayerSave();
// Bomb slots auto-apply now, don't keep them in ActiveModifiers
save.ActiveModifiers?.RemoveAll(m => m == "BombSlot3" || m == "BombSlot4");
return save;
}
}
catch(System.Exception e)
{
Log.Warning($"PlayerSave.Load failed: {e.Message}");
}
// ── First time player —
return new PlayerSave();
}
public void Save()
{
try
{
string json = JsonSerializer.Serialize( this, new JsonSerializerOptions { WriteIndented = true } );
FileSystem.Data.WriteAllText( SavePath, json );
}
catch ( System.Exception e )
{
Log.Warning( $"PlayerSave.Save failed: {e.Message}" );
}
}
/// <summary>
/// Call at end of each run to update lifetime stats and save.
/// </summary>
public void RecordRun( int score, int wave, int chestsCleared,
int challengesCompleted, int fragmentsEarned, float minutesPlayed, int bestChainLength)
{
TotalRuns++;
// at least need to reach wave 10 to get xp. Get more if you go beyond that.
if (wave >= 10)
{
int xp = wave switch {
< 15 => 1, // wave 10-14 = 1 XP
< 20 => 2, // wave 15-19 = 2 XP
< 30 => 3, // wave 20-29 = 3 XP
< 50 => 4, // wave 30-49 = 4 XP
_ => 5 // wave 50+ = 5 XP — cap here, even big bois grind
};
AddPetXp(xp);
}
TotalChestsCleared += chestsCleared;
TotalChallengesCompleted += challengesCompleted;
TotalMinutesPlayed += minutesPlayed;
BestChainLength = bestChainLength;
AddActiveFragments(fragmentsEarned);
// Level 1 pet bonus — +5 frags per 20 waves survived
if (PetLevel >= 1 && wave >= 20)
{
int petFragBonus = (wave / 20) * 5;
AddActiveFragments(petFragBonus);
}
if ( score > BestScore ) BestScore = score;
if ( wave > BestWave ) BestWave = wave;
// Trial (bombs)
if(TrialBombsRunsLeft > 0)
TrialBombsRunsLeft--;
// persists immediately
Save();
}
// Call this at the start of any fragment-adding operation
void EnsureDayReset()
{
string today = System.DateTime.UtcNow.ToString("yyyy-MM-dd");
if(LastPlayDate != today)
{
LastPlayDate = today;
TodayFragments = 0;
TodayIdleFragments = 0;
// don't Save() here — the caller will save after updating amounts
}
}
// Called by idle farm only
public void AddIdleFragments(int amount)
{
EnsureDayReset();
TodayIdleFragments += amount;
TotalFragments += amount;
Save();
}
// Called by active play (RecordRun, boss kill, secret chest)
public void AddActiveFragments(int amount)
{
EnsureDayReset();
TodayFragments += amount;
TotalFragments += amount;
Save();
}
public int GetTodayIdleFragments()
{
EnsureDayReset();
return TodayIdleFragments;
}
public int GetTodayActiveFragments()
{
EnsureDayReset();
return TodayFragments;
}
// -------------------------------------------------------------------------------- //
// --------------------------------- *Shop stuff* --------------------------------- //
// -------------------------------------------------------------------------------- //
public bool TryBuyModifier(ModifierId id, int cost)
{
if(OwnsModifier(id)) return false;
if(TotalFragments < cost) return false;
TotalFragments -= cost;
OwnedModifiers.Add(id.ToString());
// Bomb slots auto-apply, don't add to ActiveModifiers (don't count toward cap)
bool isBombSlot = id == ModifierId.BombSlot3 || id == ModifierId.BombSlot4;
if(!isBombSlot)
ActiveModifiers.Add(id.ToString());
Save();
return true;
}
public void ToggleModifier(ModifierId id)
{
// Bomb slots can't be toggled — they're permanent once owned
if(id == ModifierId.BombSlot2 || id == ModifierId.BombSlot3 || id == ModifierId.BombSlot4)
return;
string key = id.ToString();
if(ActiveModifiers.Contains(key))
ActiveModifiers.Remove(key);
else
{
if(ActiveModifiers.Count >= MaxActiveModifiers) return;
ActiveModifiers.Add(key);
}
Save();
}
public int MaxIdleFragsPerDay => PetLevel switch
{
1 => 80,
2 => 120,
3 => 180,
4 => 250,
5 => 350,
6 => 500,
_ => 500
};
// BOMB TYPES
public List<string> OwnedBombs { get; set; } = new();
public bool OwnsBomb(GridManager.BombType type) => OwnedBombs.Contains(type.ToString());
public bool TryBuyBomb(GridManager.BombType type, int cost)
{
if(OwnsBomb(type)) return false;
if(TotalFragments < cost) return false;
TotalFragments -= cost;
OwnedBombs.Add(type.ToString());
Save();
return true;
}
// FOR DEBUG PURPOSES
public static void DEBUG_WipeSave()
{
if(FileSystem.Data.FileExists(SavePath))
FileSystem.Data.DeleteFile(SavePath);
}
// ── Consumables ───────────────────────────────────────────────
// Stores count of each consumable owned — key is ConsumableId.ToString()
public Dictionary<string, int> OwnedConsumables { get; set; } = new();
public int GetConsumableCount(ConsumableId id) =>
OwnedConsumables.TryGetValue(id.ToString(), out int count) ? count : 0;
public bool TryBuyConsumable(ConsumableId id, int cost)
{
if (TotalFragments < cost) return false;
TotalFragments -= cost;
string key = id.ToString();
if (!OwnedConsumables.ContainsKey(key))
OwnedConsumables[key] = 0;
OwnedConsumables[key]++;
Save();
return true;
}
public bool TryUseConsumable(ConsumableId id)
{
string key = id.ToString();
if (!OwnedConsumables.ContainsKey(key) || OwnedConsumables[key] <= 0) return false;
OwnedConsumables[key]--;
Save();
return true;
}
// Max 3 consumables carried into a run (any mix)
public int TotalConsumablesOwned => OwnedConsumables.Values.Sum();
// ── PET SYSTEM ───────────────────────────────────────────────
// Pet XP — gained from playing runs
public int PetXp { get; set; } = 0;
public void AddPetXp(int runs)
{
PetXp += runs;
while (PetLevel < 6 && PetXp >= GetXpNeeded(PetLevel))
{
PetXp -= GetXpNeeded(PetLevel);
PetLevel++;
PendingEvolution = true;
}
Save();
}
public static int GetXpNeeded(int level) => level switch
{
1 => 50,
2 => 150,
3 => 400,
4 => 800,
5 => 1000,
_ => 2000 // level 6 = maxed, no further XP needed (or keep growing if you want)
};
public void ConsumeConsumable(ConsumableId id)
{
string key = id.ToString();
if(OwnedConsumables.ContainsKey(key) && OwnedConsumables[key] > 0)
{
OwnedConsumables[key]--;
Save();
}
}
}