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, IDamageMitigator
{
// 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; }
// --- Armour -----------------------------------------------------------
//
// Bought at the vendor, worn until it's shot through or you go down.
// Unlike a weapon it has no slot and no WeaponRegistry entry.
//
// It has its own health pool, separate from the player's. Every hit
// wears some of it away (ArmorWearFraction of the hit), and while
// there's any left the damage reduction applies. At zero the vest is
// gone and has to be bought again.
//
// A float rather than an int because the wear per hit is small - 10%
// of an 8 damage shot is 0.8 - and rounding each one to a whole number
// would either make the vest last forever (round down to 0) or wear
// out twice as fast as it should (round up to 1). The HUD rounds it
// for display; the maths keeps the fractions.
//
// [Sync] for the same reason as the rest: other clients read it to
// display state, and the owner is the authority. It's written inside
// TakeDamage, which SWB only runs on the owner (it returns early on
// proxies), so the write always lands on the machine that owns it.
[Sync] public float ArmorHealth { get; set; }
// "Wearing a vest" is now just "the vest has anything left". Kept as a
// property so the vendor and anything else that only cares whether you
// have armour doesn't need to know about the health pool at all.
public bool HasArmor => ArmorHealth > 0f;
// What a freshly bought vest starts at.
[Property]
public float MaxArmorHealth { get; set; } = 50f;
// How much of each hit the vest itself soaks up as wear, as a fraction
// of the FULL incoming damage (before the reduction is applied). 0.1
// on a 50 point vest means it breaks after 500 damage has been thrown
// at you - it takes that much raw fire to wear it out, however much of
// it actually reached your health.
[Property]
public float ArmorWearFraction { get; set; } = 0.1f;
// Fraction of incoming damage armour absorbs. 0.35 means you take 65%
// of what you otherwise would - roughly one extra hit in three.
//
// Applied AFTER SWB's headshot doubling, so it protects against
// headshots in proportion rather than cancelling them out.
[Property]
public float ArmorDamageReduction { get; set; } = 0.35f;
/// <summary>What a vest costs at the vendor. Read by Vendor stock entries.</summary>
[Property]
public int ArmorCost { get; set; } = 800;
// --- Investments ------------------------------------------------------
//
// Money parked at the investment table in the hideout. It pays out a
// share of itself after every job, won or lost.
//
// Deliberately NOT lost on death, unlike weapons and armour. That's the
// whole point of it: gear is the risky place to put your money and this
// is the safe one, so a crew that keeps getting wiped still has an
// income and can climb back out instead of being stuck at zero.
[Sync] public int Invested { get; set; }
// What one deposit costs. Everything about the table works in these
// units, so the payout maths can't produce fractions of a dollar.
[Property]
public int InvestmentIncrement { get; set; } = 100;
// Paid per increment, per job. 20 on a 100 deposit is a 20% return a
// round - it pays for itself after five jobs and is pure profit after,
// which is a horizon a single sitting can actually reach.
[Property]
public int InvestmentReturnPerIncrement { get; set; } = 20;
/// <summary>What the table will pay after the next job.</summary>
public int DividendPerJob => Invested / MathF.Max( InvestmentIncrement, 1 ).FloorToInt()
* InvestmentReturnPerIncrement;
// Owner-side, called by the investment menu.
public bool TryInvest()
{
if ( !CanAfford( InvestmentIncrement ) )
return false;
Cash -= InvestmentIncrement;
Invested += InvestmentIncrement;
Save();
return true;
}
// Routed to the owner for the same reason AddCash is: Cash and Invested
// are [Sync]'d with the owner as the authority, so a write anywhere else
// is local-only and gets overwritten. MissionTimer calls this on the
// host once per player when a job ends.
[Rpc.Owner]
public void PayDividend()
{
var payout = DividendPerJob;
if ( payout <= 0 )
return;
Cash += payout;
Save();
Log.Info( $"{GameObject.Name} earned ${payout} from ${Invested} invested (total ${Cash})." );
}
// IDamageMitigator. Called from PlayerBase.TakeDamage before the health
// subtraction - see IDamageMitigator for why it can't be done later.
public float Mitigate( float damage )
{
if ( !HasArmor )
return damage;
// Clamped so a silly value in the inspector can't make a player
// immortal (1.0) or amplify incoming damage (negative).
var reduction = MathX.Clamp( ArmorDamageReduction, 0f, 0.95f );
// Wear is worked out from the damage as it ARRIVED, not from what
// gets through. The vest is what's being hit; how much it saved
// you doesn't change how hard it was hit.
var wear = damage * MathX.Clamp( ArmorWearFraction, 0f, 1f );
ArmorHealth = MathF.Max( ArmorHealth - wear, 0f );
// The hit that breaks the vest is still reduced - the vest was
// there when it landed. It's the NEXT hit that comes in full.
if ( !HasArmor )
{
// Written to disk straight away: a broken vest is the one armour
// change that has to survive a crash or quit, or you'd get it
// back on the next launch.
Save();
Log.Info( $"{GameObject.Name}'s armour has been shot through." );
}
else
{
// Every other hit only updates the in-memory copy. That's what
// the next scene reads back from, so the value carries from the
// job to the hideout correctly - see SaveToCache for why a disk
// write per bullet isn't worth it.
SaveToCache();
}
return damage * (1f - reduction);
}
// 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; }
// Named Armor rather than HasArmor so the save file reads as a
// list of what you own. Older save files simply lack the field and
// deserialize to false, which is the correct default.
//
// Still written alongside ArmorHealth, and only really read for
// saves made before the vest had health - see Load.
public bool Armor { get; set; }
// How much of the vest is left. Missing from older saves, where it
// deserializes to 0.
public float ArmorHealth { get; set; }
public int Invested { 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.
//
// Same treatment for a player who's out of the run. SWB respawns
// the dead two seconds later no matter what, and the dead->alive
// branch below then hands them their sidearm, drawn - which put
// arms and a gun on a spectator's screen and let them fire from an
// invisible body sitting on a spawn point. Holstering every frame
// covers both, and the 1-9 slot keys can't undo it.
if ( InSafeArea() || IsOutOfTheRun() )
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;
}
// Armour has no slot and no weapon behind it, so it gets its own buy
// path rather than being forced through TryBuy's ClassName lookup.
//
// Buying a second vest does nothing but cost money, so it refuses when
// you already have one - the vendor greys the line out for the same
// reason, this is the guard behind it.
public bool TryBuyArmor( int cost )
{
if ( HasArmor || !CanAfford( cost ) )
return false;
Cash -= cost;
ArmorHealth = MaxArmorHealth;
Save();
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;
// Armour goes with the rest. It's part of what you were carrying,
// so it carries the same risk - buy it, and it's only yours for as
// long as you keep getting out alive.
ArmorHealth = 0f;
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;
}
// Dead or extracted, and spectating - see MissionPlayerState.
bool IsOutOfTheRun()
{
return Components.Get<MissionPlayerState>()?.IsOut ?? false;
}
// 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;
Invested = saved.Invested;
// A save from before armour had health says Armor = true with
// no ArmorHealth at all. Those players paid for a whole vest, so
// they get a whole one rather than having it vanish on them.
ArmorHealth = saved.ArmorHealth > 0f
? saved.ArmorHealth
: saved.Armor ? MaxArmorHealth : 0f;
}
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()
{
SaveToCache();
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}" );
}
}
// Updates the in-memory copy only, without touching disk.
//
// Exists for armour wear, which changes on every bullet that hits you.
// A disk write per hit during a firefight is exactly the wrong moment
// to be doing file I/O, and it buys nothing: the in-memory cache is
// what the next scene loads from, and something else always writes the
// file before the run is over - LoseLoadout if you go down, AddCash if
// you escape, the dividend if you've invested, and Save() itself the
// moment the vest breaks.
//
// The only case this loses is quitting the game mid-job, which puts
// your vest back to where it was when you left the hideout. That's a
// fair trade for not stalling the frame on every hit.
void SaveToCache()
{
Cache()[SaveKey()] = new SavedLoadout
{
Cash = Cash,
Primary = Primary,
Secondary = Secondary,
Armor = HasArmor,
ArmorHealth = ArmorHealth,
Invested = Invested
};
}
// 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();
}
}