PlayerLoadout.cs
using Sandbox;
using SWB.Base;
using SWB.Player;
using SWB.Shared;
using System;
using System.Collections.Generic;
using System.Linq;
// Which hand a bought weapon goes in. Vendor stock entries carry one of
// these so the shop knows which column to list them under, and so buying
// a second rifle replaces your rifle rather than stacking up.
public enum LoadoutSlot
{
Primary,
Secondary
}
// Per-player cash and equipped weapons - the risk/reward core. Buy gear at
// the Hideout vendor, keep it if you extract, lose it if you go down.
//
// Cash lives here rather than in its own wallet component because the two
// are always read and written together (a purchase spends cash AND equips
// a gun), and one component is one thing to wire onto the player prefab
// instead of two.
//
// Put this on the player prefab, alongside PlayerBase.
public sealed class PlayerLoadout : Component
{
// The sidearm, always given, never lost - so a broke player who just
// got wiped still has something to shoot with. The string is a weapon
// ClassName as registered in SWB's WeaponRegistry ("swb_colt",
// "swb_scarh", ...), not a prefab path.
//
// It lives in the SECONDARY slot on purpose. As a default primary it
// got replaced by the first rifle anyone bought, which meant players
// were permanently carrying exactly one gun - nothing to switch to,
// and no reason for a weapon-swap key to exist. As a sidearm it stays
// put, and the only thing that displaces it is buying the revolver.
[Property]
public string DefaultSecondary { get; set; } = "swb_colt";
[Property]
public int StartingCash { get; set; } = 2000;
// How much reserve ammo each equipped weapon spawns with. Reserve is
// the pool reloads pull from, separate from what's in the magazine.
[Property]
public int ReserveAmmo { get; set; } = 360;
// [Sync] replicates these to every other client, with the owning
// client as the authority - so other players' HUDs can read them, but
// only the owner's writes actually stick. That's why AddCash below is
// an RPC rather than something any machine can just call.
//
// Primary/Secondary hold weapon ClassNames; null means "nothing
// bought", which ApplyToInventory reads as "fall back to default".
[Sync] public int Cash { get; set; }
[Sync] public string Primary { get; set; }
[Sync] public string Secondary { get; set; }
// Where progress is written. FileSystem.Data is this game's own
// per-machine storage folder, so the file survives quitting.
//
// Not Game.Cookies, which is the other obvious candidate: cookies
// expire a month after they're set, and a save file that silently
// evaporates is worse than no save file.
const string SaveFile = "loadouts.json";
// In-memory mirror of the save file. It has two jobs:
//
// - Loadouts have to survive the Hideout -> mission scene change, and
// GameObjects don't; loading a scene destroys everything in the old
// one, this component included. A static lives on the type, so it
// outlives the swap.
// - It saves re-reading the file on every lookup.
//
// Keyed by SteamId so progress follows the person rather than their
// spawned pawn, and so two accounts sharing a machine don't collide.
static Dictionary<string, SavedLoadout> _cache;
// Plain snapshot of what's worth keeping. Deliberately not the
// component itself - we only want the data, not a reference to a
// GameObject that's about to be destroyed.
//
// Public class with public PROPERTIES because this gets JSON
// serialized: System.Text.Json ignores public fields by default and
// can't reach private types, so a private struct of fields would
// quietly save as {} every time.
public class SavedLoadout
{
public int Cash { get; set; }
public string Primary { get; set; }
public string Secondary { get; set; }
}
PlayerBase _player;
// Last tick's alive state, so OnUpdate can spot the moment it flips
// rather than just knowing the current value. See OnUpdate.
bool _wasAlive = false;
// OnAwake runs once when the component is created, before OnStart and
// before any updates - the right place to cache references to things
// on the same GameObject.
protected override void OnAwake()
{
_player = Components.Get<PlayerBase>();
if ( _player is null )
Log.Warning( $"{GameObject.Name}: PlayerLoadout needs a PlayerBase on the same GameObject." );
}
protected override void OnStart()
{
// IsProxy means "this object belongs to somebody else and I'm just
// displaying their copy". Only the owner restores their own saved
// loadout; everyone else receives it through [Sync].
if ( IsProxy )
return;
Load();
}
protected override void OnUpdate()
{
if ( IsProxy || _player is null )
return;
// SWB's InventoryDisplay equips whatever slot key you press (1-9),
// which would let you draw a weapon in the hideout despite
// ApplyToInventory having deliberately left everything holstered.
// Rather than modify SWB's HUD, just put it back every frame.
if ( InSafeArea() )
HolsterEverything();
// Both branches below watch for a CHANGE in alive state rather
// than acting on the current value, because each thing should
// happen once per transition, not every frame while it's true.
//
// This polling approach is used instead of overriding PlayerBase's
// respawn/death methods, which would mean subclassing the player -
// and swapping a component's type on a prefab loses every wired
// reference in the s&box editor. A sibling component that watches
// state avoids that entirely.
// Dead -> alive. PlayerBase.Respawn() rebuilds the inventory from
// scratch (and SWB's demo player hands out every gun in the game
// while it's at it), so the loadout has to be re-applied
// afterwards. By the time this reads true, Respawn() has already
// finished, so there's no race to lose.
if ( _player.IsAlive && !_wasAlive )
ApplyToInventory();
// Alive -> dead. The gear they paid for is gone, and the automatic
// respawn 2 seconds later will re-apply an empty loadout as
// default kit.
//
// Players who escaped never hit this: EscapeZone disables their
// GameObject, and a disabled object stops updating, so this method
// isn't running to notice anything. That's precisely why escaping
// preserves your loadout.
if ( !_player.IsAlive && _wasAlive )
LoseLoadout();
_wasAlive = _player.IsAlive;
}
public bool CanAfford( int cost ) => Cash >= cost;
// Called by the vendor menu when a line is clicked. Returns false and
// changes nothing if the purchase isn't valid, so the UI can just
// check the result instead of pre-validating everything itself.
public bool TryBuy( string className, int cost, LoadoutSlot slot )
{
if ( string.IsNullOrWhiteSpace( className ) || !CanAfford( cost ) )
return false;
// Verify the gun actually exists before taking any money -
// otherwise a typo'd ClassName in the vendor's stock list would
// charge the player for nothing.
if ( WeaponRegistry.Instance?.Get( className ) is null )
{
Log.Warning( $"PlayerLoadout: '{className}' isn't in the WeaponRegistry." );
return false;
}
Cash -= cost;
// One weapon per slot - buying a new primary replaces the old one
// rather than accumulating. Keeps loadout selection quick, which
// is the design goal; there's deliberately no deep inventory here.
if ( slot == LoadoutSlot.Primary )
Primary = className;
else
Secondary = className;
Save();
// Equip immediately so the player can see what they bought in the
// Hideout, rather than waiting until the next mission spawn.
ApplyToInventory();
return true;
}
// [Rpc.Owner] makes this run on the machine that owns this player,
// no matter who calls it. Needed because Cash is [Sync] - a write on
// any other machine is local-only and gets overwritten by the owner's
// value - and because Save() has to land in the owning player's own
// process for the static store to have it after the scene change.
//
// An EscapeZone trigger can fire on a machine that doesn't own the
// player who walked in, so the payout has to be routed rather than
// applied wherever it happened to be detected.
[Rpc.Owner]
public void AddCash( int amount )
{
Cash += amount;
Save();
Log.Info( $"{GameObject.Name} banked ${amount} (total ${Cash})." );
}
// Called when this player is eliminated: the gear they paid for is
// gone and they drop back to default kit. Cash they've already banked
// is untouched - it's the loadout that carries the risk, so a wipe
// sets you back but never traps you with nothing to spend.
public void LoseLoadout()
{
Primary = null;
Secondary = null;
Save();
}
// Wipes the inventory and re-gives exactly what's equipped, so the
// player ends up holding their loadout and nothing else.
public void ApplyToInventory()
{
if ( IsProxy || _player?.Inventory is null )
return;
// Clear() destroys the existing weapon GameObjects. This is what
// undoes SWB's demo player handing out all six guns on respawn.
_player.Inventory.Clear();
// In the hideout you still carry your gear - you just can't use
// it. Handing the weapons over without making any of them active
// means they show up in the inventory HUD (so you can see what
// you've bought) while Inventory.Add calls OnCarryStop on each,
// which disables the weapon GameObject outright. A disabled weapon
// doesn't run its firing loop, so there's nothing to suppress.
var safe = InSafeArea();
// The primary is purely what they've bought - there's no fallback
// for it. Someone who hasn't bought one, or who just lost theirs,
// goes out on the sidearm alone, which is what makes buying a
// proper gun feel like it's worth the money.
var hasPrimary = !string.IsNullOrWhiteSpace( Primary );
if ( hasPrimary )
Give( Primary, makeActive: !safe );
// The sidearm slot is never empty: the bought secondary if there
// is one, otherwise the default pistol. This is the "never leave
// anyone unarmed" guarantee that used to sit on the primary.
var secondary = string.IsNullOrWhiteSpace( Secondary ) ? DefaultSecondary : Secondary;
// Drawn only when there's no primary to draw instead - you come
// out of the hideout holding your best gun, not your pistol.
Give( secondary, makeActive: !safe && !hasPrimary );
}
// Puts whatever's in hand away. Mirrors what Inventory.SetActive does
// to the outgoing weapon: OnCarryStop disables the weapon GameObject,
// which stops it running its firing loop at all.
void HolsterEverything()
{
var inventory = _player.Inventory;
if ( inventory?.Active is null )
return;
if ( inventory.Active.Components.TryGet<IInventoryItem>( out var item ) )
item.OnCarryStop();
inventory.Active = null;
inventory.ActiveItem = null;
}
// A scene with no MissionTimer isn't a job, so it's somewhere safe -
// currently just the hideout. Keyed off the timer rather than a scene
// name or a flag component so any mission map you add later works
// without extra wiring, and the hideout needs no marker on it.
static bool InSafeArea()
{
return Game.ActiveScene?.GetAllComponents<MissionTimer>().Any() != true;
}
// makeActive decides whether this becomes the weapon in their hands
// right now, versus sitting in the inventory to be switched to.
void Give( string className, bool makeActive )
{
// The registry holds one disabled master copy of each weapon,
// built at scene start from the WeaponPrefabs list.
var weapon = WeaponRegistry.Instance?.Get( className );
if ( weapon is null )
{
Log.Warning( $"PlayerLoadout: '{className}' isn't in the WeaponRegistry." );
return;
}
// AddClone (not Add) because the registry entry is that shared
// master copy - every player needs their own instance of it. This
// also handles network-spawning the clone under this player's
// ownership.
_player.Inventory.AddClone( weapon.GameObject, makeActive );
// Ammo is tracked per ammo TYPE on the player, not per weapon, so
// two guns sharing a caliber share the same reserve pool.
_player.SetAmmo( weapon.Primary.AmmoType, ReserveAmmo );
}
// Restore whatever this player was carrying, whether that's from
// earlier this session or from a previous run of the game. First time
// we've seen them, they get their starting stake instead.
void Load()
{
if ( Cache().TryGetValue( SaveKey(), out var saved ) )
{
Cash = saved.Cash;
Primary = saved.Primary;
Secondary = saved.Secondary;
}
else
{
Cash = StartingCash;
}
}
// Called after every change rather than at some "end of mission"
// moment, because there isn't a reliable one - players can die,
// escape, or disconnect at any point, and a disabled or destroyed
// object won't get the chance to save on its way out.
//
// Writes are small and infrequent (a purchase or a payout), so going
// to disk each time is cheaper than trying to find a safe moment to
// flush and getting it wrong.
void Save()
{
Cache()[SaveKey()] = new SavedLoadout
{
Cash = Cash,
Primary = Primary,
Secondary = Secondary
};
try
{
FileSystem.Data.WriteJson( SaveFile, _cache );
}
catch ( Exception e )
{
// A failed write shouldn't take the run down with it - the
// in-memory cache still has the right values, so play
// continues and only persistence is lost.
Log.Warning( $"PlayerLoadout: couldn't write {SaveFile} - {e.Message}" );
}
}
// Reads the save file once per session, then serves everything from
// memory.
static Dictionary<string, SavedLoadout> Cache()
{
if ( _cache is not null )
return _cache;
// ReadJsonOrDefault rather than ReadJson: a corrupt or
// hand-edited save should start people fresh rather than throw on
// every spawn.
_cache = FileSystem.Data.ReadJsonOrDefault<Dictionary<string, SavedLoadout>>( SaveFile, null )
?? new Dictionary<string, SavedLoadout>();
return _cache;
}
// SteamId is stable across scene loads and reconnects, which a
// GameObject Id isn't. The fallback covers anything without a network
// owner (a local test pawn dropped straight into the scene); it won't
// survive a scene change, but nothing else about that case would
// either.
string SaveKey()
{
return Network.Owner?.SteamId.ToString() ?? GameObject.Id.ToString();
}
}