Progress and progression manager for the game. Defines shop item, quest and achievement data structures and provides persistent player progress handling: stats, quests, achievements, coins, shop ownership, gem upgrades, equip/loadout and save/load logic.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
public enum ShopItemCategory
{
Gun,
Charm,
Gem,
}
public struct ShopItemDef
{
public string Id;
public ShopItemCategory Category;
public string Name;
public int Price;
public string IconPath;
public readonly string PrefabPath => Category switch
{
ShopItemCategory.Gun => $"prefabs/guns/{Id}.prefab",
ShopItemCategory.Charm => $"prefabs/charms/{Id}.prefab",
ShopItemCategory.Gem => $"prefabs/gems/{Id}.prefab",
_ => null
};
public int[] UpgradePrices; // gems only: null/empty = not upgradeable; each entry is the cost per upgrade level
public int RequiredPurchases; // 0 = no gate; N = must own N items in this category first
public int GemSocketCount; // guns only: number of gem sockets (0/3 = three sockets)
public int CharmSlotCount; // guns only: number of charm slots (0/1 = one slot)
public Func<string>? ItemDescription; // guns and charms: rich description (no level)
public Func<int, string>? GemDescription; // gems only: description at the given 1-indexed level (shown in Loadout)
public Func<int, string>? GemUpgradeDescription; // gems only: upgrade progression description at the given 1-indexed level (shown in Shop)
}
public enum ProgressStat
{
EnemyKills,
MinibossKills,
BossKills,
DamageDealt,
TotalRuns,
RunsWon,
Deaths,
PerksCollected,
ChestsOpened,
EvilChestsOpened,
ItemsCollected,
FireDamageDealt,
BarrelsDestroyed,
EnemiesFrozen,
PoisonDamageDealt,
BuzzsawDamageDealt,
Rerolls,
PerksBanished,
DamageTaken,
HpHealed,
SelfDamageTaken,
ArmorDamageBlocked,
BulletBounces,
BulletPierces,
SecondsStandingStill,
EnemiesExecuted,
UniquePerksCollected,
CursesCollected,
MagnetsCollected,
ExplosionKills,
BoomerangKills,
TimesDodged,
XpCoinsCollected,
PerkMaxedOut,
FlyingSkullsDashDestroyed,
ShieldsLost,
EnemiesScared,
TimesDashed,
RadiationDamageDealt,
SecondsAfterBoss,
ZombieEliteKills,
ChargerEliteKills,
SpikerEliteKills,
SpitterEliteKills,
RunnerEliteKills,
ExploderEliteKills,
ExploderMiniKills,
ProjectileHits,
CriticalHits,
ShockKills,
HealthPacksCollected,
SecondsAtOneHp,
Revives,
HpHealedOthers,
MushroomsDisturbed,
}
public enum QuestId
{
// Combat (Tab 0)
KillEnemies,
KillMinibosses,
KillBosses,
DealDamage,
DealFireDamage,
// Exploration (Tab 1)
CompleteRuns,
WinRuns,
OpenChests,
OpenEvilChests,
DestroyBarrels,
// Collection (Tab 2)
CollectPerks,
CollectItems,
RerollPerks,
BanishPerks,
// Combat (continued)
FreezeEnemies,
DealPoisonDamage,
DealBuzzsawDamage,
TakeDamage,
TakeSelfDamage,
BlockDamageWithArmor,
BounceWithBullets,
PierceWithBullets,
// Survival (continued)
HealHp,
StandStill,
// Collection (continued)
GetUniquePerks,
GetCurses,
GetMagnets,
// Combat (continued)
ExecuteEnemies,
KillWithExplosions,
// Survival (Tab 3)
DieDeaths,
// Combat (continued)
KillWithBoomerangs,
DestroyFlyingSkullsDashing,
// Survival (continued)
DodgeTimes,
LoseShield,
// Collection (continued)
CollectXpCoins,
MaxOutPerks,
CollectHealthPacks,
// Combat (continued)
ScareEnemies,
DealRadiationDamage,
LandCriticalHits,
KillWithShock,
KillEliteZombies,
KillEliteChargers,
KillEliteSpikers,
KillEliteSpitters,
KillEliteWerewolves,
KillEliteExploders,
KillExploderMinis,
// Survival (continued)
DashTimes,
SurviveAfterBoss,
SurviveAtOneHp,
ReviveTimes,
HealOtherPlayers,
DisturbMushrooms,
// Combat (continued)
GetHitByProjectiles,
}
public struct QuestDef
{
public QuestId Id;
public string Name; // Use {0} as placeholder for target value
public string Description; // Short description shown below the title
public ProgressStat Stat;
public int[] LevelTargets; // per-level targets; length = max level
public int[] RewardAmounts; // reward per level, same length as LevelTargets
public string RewardIcon; // path to icon image, shared across all levels
public Color Color;
public int Tab; // 0=Combat, 1=Exploration, 2=Collection, 3=Survival
}
public struct AchievementDef
{
public string Name; // unique key used in UnlockAchievement calls
public string DisplayName;
public string Description;
public int RewardAmount;
public string RewardIcon;
public Color Color;
}
public class ProgressData
{
public Dictionary<ProgressStat, float> Stats { get; set; } = new();
public Dictionary<QuestId, int> QuestLevels { get; set; } = new();
public HashSet<string> UnlockedAchievements { get; set; } = new();
public HashSet<string> ClaimedAchievements { get; set; } = new();
public int Coins { get; set; }
public HashSet<string> OwnedShopItems { get; set; } = new();
public HashSet<string> UnlockedLockedPerks { get; set; } = new();
public string SelectedGunId { get; set; }
public string SelectedCharmId { get; set; }
public List<string> SelectedCharmIds { get; set; } = new();
public Dictionary<string, int> GemUpgradeLevels { get; set; } = new(); // 0 = base level (just bought)
public List<string> EquippedGems { get; set; } = new(); // max 3
public float PerkUnlockXp { get; set; } // XP accumulated toward next unlock
}
public static partial class ProgressManager
{
private static ProgressData _data;
private static string FilePath => "progress.json";
private static bool _isDirty = false;
private static RealTimeSince _timeSinceLastSave = 0f;
private const float SaveInterval = 5f;
public static int StateVersion { get; private set; } = 0;
// Perk unlock XP progress
public static float PerkUnlockXp { get { if ( _data == null ) Load(); return _data.PerkUnlockXp; } }
public static int UnlockedLockedPerksCount { get { if ( _data == null ) Load(); return _data.UnlockedLockedPerks.Count; } }
private static float _perkUnlockXpBeforeRun = 0f;
private static float _runXpEarned = 0f;
private static int _runPerkUnlocksEarned = 0;
public static float PerkUnlockXpBeforeRun => _perkUnlockXpBeforeRun;
public static float RunXpEarned => _runXpEarned;
public static int RunPerkUnlocksEarned => _runPerkUnlocksEarned;
public static float GetXpRequiredForNextPerkUnlock( int extraUnlocksSoFar = 0 )
{
if ( _data == null ) Load();
int total = _data.UnlockedLockedPerks.Count + extraUnlocksSoFar;
const float Base = 50f;
const float Step = 5f;
const float Max = 1000f;
return Math.Min( Base + Step * total, Max );
}
public static void ProcessRunXp( float xp )
{
if ( _data == null ) Load();
_perkUnlockXpBeforeRun = _data.PerkUnlockXp;
_runXpEarned = xp;
_runPerkUnlocksEarned = 0;
if ( AreAllLockedPerksUnlocked() ) return;
_data.PerkUnlockXp += xp;
float required = GetXpRequiredForNextPerkUnlock();
if ( _data.PerkUnlockXp >= required )
{
_runPerkUnlocksEarned = 1;
_data.PerkUnlockXp = 0f;
}
_isDirty = true;
StateVersion++;
}
public static bool AreAllLockedPerksUnlocked()
{
if ( _data == null ) Load();
var allLocked = TypeLibrary.GetTypes<Perk>()
.Where( t => t.GetAttribute<PerkAttribute>() is { Locked: true, Disabled: false } )
.ToList();
return allLocked.All( t => _data.UnlockedLockedPerks.Contains( t.FullName ) );
}
public static void ClearPerkUnlockXp()
{
if ( _data == null ) Load();
_data.PerkUnlockXp = 0f;
_runPerkUnlocksEarned = 0;
_isDirty = true;
StateVersion++;
Save();
}
// Quest definitions — defined in code, never serialized.
// CONSTRAINT: Each QuestId must map to a unique ProgressStat.
// CollectQuestReward resets that stat to the remainder; two quests sharing a stat would corrupt each other.
private static QuestDef[] _quests;
private static string[] _questTabNames;
public static QuestDef[] Quests => _quests ??= BuildQuests();
public static string[] QuestTabNames => _questTabNames ??= GetQuestGroups().OrderBy( g => g.Tab ).Select( g => g.Name ).ToArray();
private record struct QuestGroup( int Tab, string Name, QuestDef[] Quests );
private static QuestDef[] BuildQuests()
{
var result = new List<QuestDef>();
foreach ( var group in GetQuestGroups() )
foreach ( var quest in group.Quests )
{
var q = quest;
q.Tab = group.Tab;
result.Add( q );
}
return result.ToArray();
}
public static void Load()
{
_data = FileSystem.Data.ReadJsonSafe<ProgressData>( FilePath, new ProgressData() );
// Migrate single SelectedCharmId to SelectedCharmIds list
if ( (_data.SelectedCharmIds == null || _data.SelectedCharmIds.Count == 0) && !string.IsNullOrEmpty( _data.SelectedCharmId ) )
{
_data.SelectedCharmIds = [_data.SelectedCharmId];
}
_data.SelectedCharmIds ??= [];
_quests = null;
_achievements = null;
}
public static void Save()
{
FileSystem.Data.WriteJson( FilePath, _data );
_isDirty = false;
_timeSinceLastSave = 0f;
}
public static void Tick()
{
if ( _isDirty && _timeSinceLastSave > SaveInterval )
Save();
}
public static void IncrementStat( ProgressStat stat, float amount )
{
if ( _data == null ) Load();
if ( !_data.Stats.ContainsKey( stat ) ) _data.Stats[stat] = 0f;
_data.Stats[stat] += amount;
_isDirty = true;
StateVersion++;
}
public static float GetStat( ProgressStat stat )
{
if ( _data == null ) Load();
return _data.Stats.TryGetValue( stat, out float v ) ? v : 0f;
}
public static int GetQuestLevel( QuestId id )
{
if ( _data == null ) Load();
return _data.QuestLevels.TryGetValue( id, out int v ) ? v : 0;
}
public static bool IsQuestCompleted( QuestId id )
{
var def = GetDef( id );
return GetQuestLevel( id ) >= def.LevelTargets.Length;
}
public static bool IsQuestReadyToCollect( QuestId id )
{
var def = GetDef( id );
int level = GetQuestLevel( id );
if ( level >= def.LevelTargets.Length ) return false;
return GetStat( def.Stat ) >= def.LevelTargets[level];
}
/// <summary>
/// Collects the current level reward for a quest. Advances the quest level and resets the
/// tracking stat to zero — overflow is always discarded. Returns false if the quest is not
/// ready to collect.
/// </summary>
public static bool CollectQuestReward( QuestId id )
{
if ( !IsQuestReadyToCollect( id ) ) return false;
var def = GetDef( id );
int level = GetQuestLevel( id );
int reward = def.RewardAmounts[level];
_data.Stats[def.Stat] = 0f;
_data.QuestLevels[id] = level + 1;
_data.Coins += reward;
StateVersion++;
Save(); // immediate save on reward collection
return true;
}
/// <summary>
/// Returns (current, target) for the active level of the quest. If completed, returns (lastTarget, lastTarget).
/// </summary>
public static (float current, int target) GetQuestProgress( QuestId id )
{
var def = GetDef( id );
int level = GetQuestLevel( id );
if ( level >= def.LevelTargets.Length )
{
int last = def.LevelTargets[^1];
return (last, last);
}
return (GetStat( def.Stat ), def.LevelTargets[level]);
}
public static int GetCurrentRewardAmount( QuestId id )
{
var def = GetDef( id );
int level = GetQuestLevel( id );
if ( level >= def.RewardAmounts.Length ) return def.RewardAmounts[^1];
return def.RewardAmounts[level];
}
// Achievement definitions — defined in code, never serialized.
// Run snapshot — captures stat values at run start so we can diff at run end.
private static Dictionary<ProgressStat, float> _runSnapshot = new();
public static HashSet<string> AchievementsUnlockedThisRun { get; private set; } = new();
public static void TakeRunSnapshot()
{
_runSnapshot.Clear();
AchievementsUnlockedThisRun.Clear();
if ( _data == null ) Load();
foreach ( var kvp in _data.Stats )
_runSnapshot[kvp.Key] = kvp.Value;
}
public static float GetStatGainedThisRun( ProgressStat stat )
{
var current = GetStat( stat );
var snapshot = _runSnapshot.TryGetValue( stat, out float v ) ? v : 0f;
return Math.Max( 0f, current - snapshot );
}
private static AchievementDef[] _achievements;
public static AchievementDef[] Achievements => _achievements ??= BuildAchievements();
// BuildAchievements() is defined in AchievementDefs.cs
/// <summary>
/// Marks an achievement as unlocked and ready to collect. Does nothing if already claimed.
/// Call this from gameplay code when the achievement condition is met.
/// </summary>
public static void UnlockAchievement( string name )
{
if ( _data == null ) Load();
if ( _data.ClaimedAchievements.Contains( name ) ) return;
if ( _data.UnlockedAchievements.Add( name ) )
{
AchievementsUnlockedThisRun.Add( name );
StateVersion++;
Save();
}
}
public static bool IsAchievementUnlocked( string name )
{
if ( _data == null ) Load();
return _data.UnlockedAchievements.Contains( name );
}
public static bool IsAchievementClaimed( string name )
{
if ( _data == null ) Load();
return _data.ClaimedAchievements.Contains( name );
}
/// <summary>
/// Collects the reward for an unlocked achievement. Returns false if not yet unlocked or already claimed.
/// </summary>
public static bool ClaimAchievementReward( string name )
{
if ( _data == null ) Load();
if ( !_data.UnlockedAchievements.Contains( name ) ) return false;
if ( _data.ClaimedAchievements.Contains( name ) ) return false;
var achDef = Achievements.FirstOrDefault( a => a.Name == name );
int reward = achDef.Name != null ? achDef.RewardAmount : 0;
_data.UnlockedAchievements.Remove( name );
_data.ClaimedAchievements.Add( name );
_data.Coins += reward;
StateVersion++;
Save();
return true;
}
public static void DebugClearOwnedByCategory( ShopItemCategory category )
{
if ( _data == null ) Load();
var ids = ShopItems.Where( d => d.Category == category ).Select( d => d.Id ).ToHashSet();
_data.OwnedShopItems.ExceptWith( ids );
if ( category == ShopItemCategory.Gem )
{
_data.GemUpgradeLevels.Clear();
_data.EquippedGems.RemoveAll( id => ids.Contains( id ) );
}
StateVersion++;
Save();
}
public static void DebugResetQuestLevels()
{
if ( _data == null ) Load();
_data.QuestLevels.Clear();
_data.UnlockedAchievements.Clear();
_data.ClaimedAchievements.Clear();
StateVersion++;
Save();
}
public static void DebugResetStats()
{
if ( _data == null ) Load();
_data.Stats.Clear();
StateVersion++;
Save();
}
public static IEnumerable<QuestDef> GetQuestsForTab( int tab )
=> Quests.Where( d => d.Tab == tab );
private static QuestDef GetDef( QuestId id )
=> Quests.First( d => d.Id == id );
// ── Coins ──────────────────────────────────────────────────────────────
public static int GetCoins()
{
if ( _data == null ) Load();
return _data.Coins;
}
public static void AddCoins( int amount )
{
if ( _data == null ) Load();
_data.Coins += amount;
_isDirty = true;
StateVersion++;
}
// ── Shop ────────────────────────────────────────────────────────────────
public static string GetItemRichTextToken( string id ) => $"[item:{id}]";
private static ShopItemDef[] _shopItems;
public static ShopItemDef[] ShopItems => _shopItems ??= BuildShopItems();
// BuildGunItems(), BuildCharmItems(), BuildGemItems() are defined in GunDefs.cs, CharmDefs.cs, GemDefs.cs
private static ShopItemDef[] BuildShopItems()
{
var items = BuildGunItems().Concat( BuildCharmItems() ).Concat( BuildGemItems() ).ToArray();
var duplicates = items.GroupBy( d => d.Id ).Where( g => g.Count() > 1 ).Select( g => g.Key ).ToList();
foreach ( var id in duplicates )
Log.Error( $"ShopItemDef duplicate ID detected: \"{id}\"" );
return items;
}
[ConCmd( "reload_shop_item_defs" )]
public static void ReloadShopItemDefs()
{
_shopItems = null;
StateVersion++;
Log.Info( $"Shop item defs reloaded ({ShopItems.Length} items)" );
}
public static bool IsItemOwned( string id )
{
if ( _data == null ) Load();
return _data.OwnedShopItems.Contains( id );
}
/// <summary>
/// Attempts to purchase a shop item. Deducts coins and marks as owned.
/// Returns false if already owned or can't afford it.
/// </summary>
public static bool BuyItem( string id )
{
if ( _data == null ) Load();
if ( _data.OwnedShopItems.Contains( id ) ) return false;
var def = ShopItems.FirstOrDefault( d => d.Id == id );
if ( def.Id == null ) return false;
if ( _data.Coins < def.Price ) return false;
_data.Coins -= def.Price;
_data.OwnedShopItems.Add( id );
StateVersion++;
Save();
return true;
}
/// <summary>Returns the number of shop items the player owns in the given category.</summary>
public static int GetOwnedCountByCategory( ShopItemCategory category )
{
if ( _data == null ) Load();
return ShopItems.Count( d => d.Category == category && _data.OwnedShopItems.Contains( d.Id ) );
}
/// <summary>
/// Returns true when the player has bought enough items in the item's category
/// to satisfy its RequiredPurchases gate. Always true when RequiredPurchases == 0.
/// </summary>
public static bool IsItemUnlocked( string id )
{
var def = ShopItems.FirstOrDefault( d => d.Id == id );
if ( def.Id == null || def.RequiredPurchases == 0 ) return true;
return GetOwnedCountByCategory( def.Category ) >= def.RequiredPurchases;
}
/// <summary>
/// Returns how many more purchases in the same category are needed before this
/// item unlocks. Returns 0 if already unlocked.
/// </summary>
public static int GetPurchasesNeeded( string id )
{
var def = ShopItems.FirstOrDefault( d => d.Id == id );
if ( def.Id == null || def.RequiredPurchases == 0 ) return 0;
return Math.Max( 0, def.RequiredPurchases - GetOwnedCountByCategory( def.Category ) );
}
// ── Gems ────────────────────────────────────────────────────────────────
/// <summary>Returns 1 = base level (Lv1), 2+ = upgraded. Returns 0 if not owned.</summary>
public static int GetGemDisplayLevel( string id )
{
int raw = GetGemLevel( id );
return raw < 0 ? 0 : raw + 1;
}
/// <summary>Returns 0 = base level, 1+ = upgraded. Returns -1 if not owned.</summary>
public static int GetGemLevel( string id )
{
if ( _data == null ) Load();
if ( !_data.OwnedShopItems.Contains( id ) ) return -1;
return _data.GemUpgradeLevels.TryGetValue( id, out int v ) ? v : 0;
}
/// <summary>Returns the upgrade cost at the gem's current level, or null if not upgradeable or already maxed.</summary>
public static int? GetGemUpgradeCost( string id )
{
var def = ShopItems.FirstOrDefault( d => d.Id == id );
if ( def.Id == null || def.UpgradePrices == null || def.UpgradePrices.Length == 0 ) return null;
int level = GetGemLevel( id );
if ( level < 0 || level >= def.UpgradePrices.Length ) return null;
return def.UpgradePrices[level];
}
public static bool IsGemMaxed( string id )
{
if ( !IsItemOwned( id ) ) return false;
return GetGemUpgradeCost( id ) == null;
}
/// <summary>Upgrades a gem by one level. Deducts coins. Returns false if not upgradeable or can't afford.</summary>
public static bool UpgradeGem( string id )
{
if ( _data == null ) Load();
int? cost = GetGemUpgradeCost( id );
if ( cost == null || _data.Coins < cost.Value ) return false;
_data.Coins -= cost.Value;
_data.GemUpgradeLevels[id] = ( _data.GemUpgradeLevels.TryGetValue( id, out int v ) ? v : 0 ) + 1;
StateVersion++;
Save();
return true;
}
public static List<string> GetEquippedGems()
{
if ( _data == null ) Load();
return _data.EquippedGems ?? ( _data.EquippedGems = new List<string>() );
}
public static List<string> GetEffectiveEquippedGems()
{
return Manager.HideProgressionSystem ? [] : GetEquippedGems();
}
public static int GetSelectedGunSocketCount()
{
var gunId = GetSelectedGunId();
if ( gunId == DefaultGun.Id ) return DefaultGun.GemSocketCount > 0 ? DefaultGun.GemSocketCount : 3;
var count = ShopItems.FirstOrDefault( x => x.Id == gunId && x.Category == ShopItemCategory.Gun ).GemSocketCount;
return count > 0 ? count : 3;
}
public static bool EquipGem( string id, int maxCount = 3 )
{
if ( _data == null ) Load();
if ( !IsItemOwned( id ) ) return false;
var equipped = GetEquippedGems();
if ( equipped.Contains( id ) ) return false;
if ( equipped.Count >= maxCount ) return false;
equipped.Add( id );
StateVersion++;
_isDirty = true;
return true;
}
public static bool UnequipGem( string id )
{
if ( _data == null ) Load();
var equipped = GetEquippedGems();
if ( !equipped.Remove( id ) ) return false;
StateVersion++;
_isDirty = true;
return true;
}
// ── Loadout ─────────────────────────────────────────────────────────────
public static string GetSelectedGunId()
{
if ( _data == null ) Load();
return string.IsNullOrEmpty( _data.SelectedGunId ) ? "gun_default" : _data.SelectedGunId;
}
public static string GetEffectiveSelectedGunId()
{
return Manager.HideProgressionSystem ? DefaultGun.Id : GetSelectedGunId();
}
public static void SetSelectedGunId( string id )
{
if ( _data == null ) Load();
_data.SelectedGunId = id;
StateVersion++;
_isDirty = true;
}
public static string GetSelectedCharmId()
{
var ids = GetSelectedCharmIds();
return ids.Count > 0 ? ids[0] : "";
}
public static void SetSelectedCharmId( string id )
{
SetSelectedCharmIds( string.IsNullOrEmpty( id ) ? [] : [id] );
}
public static List<string> GetSelectedCharmIds()
{
if ( _data == null ) Load();
return _data.SelectedCharmIds ??= [];
}
public static List<string> GetEffectiveSelectedCharmIds()
{
return Manager.HideProgressionSystem ? [] : GetSelectedCharmIds();
}
public static void SetSelectedCharmIds( List<string> ids )
{
if ( _data == null ) Load();
_data.SelectedCharmIds = ids ?? [];
_data.SelectedCharmId = _data.SelectedCharmIds.Count > 0 ? _data.SelectedCharmIds[0] : "";
StateVersion++;
_isDirty = true;
}
public static int GetSelectedGunCharmSlotCount()
{
var gunId = GetSelectedGunId();
ShopItemDef def;
if ( gunId == DefaultGun.Id )
def = DefaultGun;
else
def = ShopItems.FirstOrDefault( x => x.Id == gunId && x.Category == ShopItemCategory.Gun );
return def.CharmSlotCount > 0 ? def.CharmSlotCount : 1;
}
public static bool IsLockedPerkUnlocked( TypeDescription perkType )
{
if ( _data == null ) Load();
return _data.UnlockedLockedPerks.Contains( perkType.FullName );
}
public static void UnlockLockedPerk( TypeDescription perkType )
{
if ( _data == null ) Load();
if ( _data.UnlockedLockedPerks.Add( perkType.FullName ) )
{
StateVersion++;
Save();
}
}
public static void RelockLockedPerk( TypeDescription perkType )
{
if ( _data == null ) Load();
if ( _data.UnlockedLockedPerks.Remove( perkType.FullName ) )
{
StateVersion++;
Save();
}
}
public static void RelockAllLockedPerks()
{
if ( _data == null ) Load();
if ( _data.UnlockedLockedPerks.Count == 0 ) return;
_data.UnlockedLockedPerks.Clear();
StateVersion++;
Save();
}
/// <summary>
/// Returns all items in a category that the player owns.
/// </summary>
public static List<ShopItemDef> GetOwnedItemsByCategory( ShopItemCategory category )
{
return ShopItems
.Where( d => d.Category == category && IsItemOwned( d.Id ) )
.ToList();
}
/// <summary>
/// Looks up the prefab path for a shop item by its id. Returns null if not found.
/// </summary>
public static string GetPrefabPath( string id )
{
if ( string.IsNullOrEmpty( id ) ) return null;
var def = ShopItems.FirstOrDefault( d => d.Id == id );
return def.Id != null ? def.PrefabPath : null;
}
}