Inventory component for the NZombies player. Implements IInventory to hold multiple weapon GameObjects, manage active weapon switching, holstering timing, adding/removing weapons, trimming slots when perks are lost, and tracking weapon source/prefab for persistence and Pack-a-Punch logic.
using Sandbox;
using SWB.Shared;
using System.Linq;
namespace NZombies;
/// <summary>
/// TWO WEAPONS, AND THE SWITCH BETWEEN THEM.
///
/// ⛔ SWB SHIPS THE ITEM HALF AND EXPECTS THE GAME TO SUPPLY THE INVENTORY.
/// `SWB.Base.Weapon` already implements `IInventoryItem` — `Slot`, `OnCarryStart`,
/// `OnCarryStop`, `CanCarryStop`, `Mobility` are all there and correct. Nothing in
/// this project implemented `IInventory`, so the player could only ever hold one
/// gun: `EquipStartingWeapon` guarded on "is there already a weapon" and returned.
/// This is the missing half, not a new system.
///
/// ⚠️ NOT `BaseInventoryComponent`. The engine's slot inventory is a real,
/// replicated, host-authoritative implementation — but it stores
/// `BaseInventoryItem`s, and an SWB weapon is not one. Adapting every weapon to a
/// second item base to reuse it would be a far larger change than the ~150 lines
/// SWB's own interface asks for.
///
/// ⚠️ Carrying is EXPRESSED BY ENABLING. `OnCarryStart` enables the weapon
/// GameObject and resets its deploy timer so the draw animation plays;
/// `OnCarryStop` disables it. The holstered weapon keeps existing — which is what
/// makes its ammo, its attachments and its Pack-a-Punch level survive a switch.
/// </summary>
/// <summary>
/// Remembers which prefab a spawned weapon came from.
///
/// ⛔ THE JOIN KEY FOR EVERYTHING WEAPON-SHAPED. Pack-a-Punch levels, the box's
/// duplicate check and the wall buys all key on the prefab PATH, and a live weapon
/// otherwise has no way to say which one it is — `DisplayName` is a lossy join
/// through a string the weapon is free to change. The spawner knows the exact path,
/// so it stamps it here.
/// </summary>
public sealed class WeaponSource : Component
{
[Property] public string Prefab { get; set; } = "";
/// <summary>
/// The weapon's name BEFORE any Pack-a-Punch suffix.
///
/// ⛔ CAPTURED ONCE, AT SPAWN, BECAUSE DERIVING IT BY STRIPPING FAILED IN PLAY.
/// The first version recomputed the base by removing a trailing " MK<n>"
/// from the current DisplayName — which is correct arithmetic and still shipped
/// "M1911 MK2 MK2", because the value it was stripping had already been written
/// through to shared prefab state and came back pre-suffixed on a fresh spawn.
///
/// A remembered base cannot drift: whatever DisplayName holds now, the name is
/// rebuilt from this, so re-applying is idempotent no matter how many times
/// ApplyStoredUpgrades runs or what it finds.
/// </summary>
[Property] public string BaseName { get; set; } = "";
/// <summary>
/// Apply saved stat overrides once this weapon's identity is known.
///
/// ⛔ IT HAS TO BE HERE, NOT IN `Weapon.OnEnabled`. Overrides are keyed by
/// `Prefab`, and `OnEnabled` fires while the weapon is being built — BEFORE
/// `SpawnWeapon` has stamped this component. `WeaponTuning.KeyFor` then fell back
/// to the GameObject name, looked up a key nothing was stored under, found
/// nothing and returned quietly: tuning saved correctly, reloaded correctly, and
/// silently did not apply. Verified as `Damage = 45` on a weapon saved at 999.
///
/// ⚠️ The weapon keeps the `OnEnabled` call too, for RE-deploys — by then this
/// component exists, and applying twice is idempotent. One hook covers the first
/// spawn, the other covers every switch after it.
/// </summary>
protected override void OnStart()
{
var wep = Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf );
if ( wep.IsValid() )
{
// ⚠️ CLASS RULES FIRST, TUNING SECOND. Tuning is a saved per-weapon OVERRIDE and has to
// win; applying it before the class bonus would let the multiply stamp over a value the
// user set by hand.
WeaponClassRules.Apply( wep );
WeaponTuning.Apply( wep );
}
}
}
public sealed class NZInventory : Component, IInventory
{
/// <summary>
/// How many weapons you can carry.
///
/// ⚠️ TWO, and a property rather than a constant because Mule Kick is a perk
/// that raises it to three. Hardcoding the cap would mean finding this again.
/// </summary>
[Property] public int MaxSlots { get; set; } = 2;
public NetList<GameObject> Items { get; set; } = new();
GameObject _active;
/// <summary>
/// The weapon in hand. Assigning routes through <see cref="SetActive"/> so the
/// carry hooks always run — a raw field assignment would leave the old weapon
/// enabled and both would render at once.
/// </summary>
/// ⛔ `new` BECAUSE `Component.Active` ALREADY EXISTS — and here the shadowing is
/// forced, not careless: `IInventory` names this member and the interface must be
/// satisfied. That makes this the one place in the project where hiding a base
/// member is right, and the keyword says so out loud. (The other four times —
/// `DamageOverlay.Enabled`, `Barricade.Reset`, `PackAPunch.Reset` — were
/// accidents and got renamed.)
public new GameObject Active
{
get => _active;
set => SetActive( value );
}
public IInventoryItem ActiveItem
{
get => _active.IsValid() ? _active.Components.Get<IInventoryItem>( FindMode.EverythingInSelf ) : null;
// ⚠️ Looked up by SEARCHING Items, because `IInventoryItem` is `IValid` and
// carries no GameObject of its own. The component knows its object; the
// interface does not expose it.
set => SetActive( Items.FirstOrDefault( i =>
i.IsValid() && ReferenceEquals( i.Components.Get<IInventoryItem>( FindMode.EverythingInSelf ), value ) ) );
}
/// <summary>Every weapon held, live ones only.</summary>
public IEnumerable<GameObject> Weapons => Items.Where( i => i.IsValid() );
public int Count => Items.Count( i => i.IsValid() );
/// <summary>The cap actually in force — authored `MaxSlots` plus Mule Kick.
///
/// ⛔ DERIVED, NOT WRITTEN ON PURCHASE. Every other perk in this project is a
/// multiplier read off the owned list, for one reason: `MaxSlots` is authored
/// config, and a perk that WROTE 3 into it would have to write 2 back on loss
/// — restoring a value it does not own and cannot know. Deriving it means
/// losing the perk is free and the authored number is never touched.</summary>
public int EffectiveMaxSlots
{
get
{
var nz = Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
return MaxSlots + PerkEffects.BonusWeaponSlots( nz );
}
}
public bool IsFull => Count >= EffectiveMaxSlots;
/// <summary>Drop everything past the current cap, permanently.
///
/// ⛔ CALLED AFTER MULE KICK IS LOST, and the weapon is GONE — not dropped on
/// the floor to be picked back up. That is the deal the perk makes.
///
/// ⚠️ Trims from the END so the third slot is what goes, not whatever happens
/// to be selected. Loops on a snapshot because `Remove` mutates `Items`.</summary>
public int TrimToCap()
{
var cap = EffectiveMaxSlots;
var extra = Weapons.Skip( cap ).ToList();
var nz = Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
foreach ( var w in extra )
{
// ⚠️ CAPTURED BEFORE `Destroy`, obviously — but also before `Remove`, because
// the prefab path is resolved through the weapon component and there is no
// reason to make that depend on inventory membership.
//
// ⚠️ `Rarity.PrefabOf` is the established weapon-to-prefab mapping in this
// project (TechEffects and ArsenalMenu both use it); a name would not round-trip
// through `GiveWeapon`.
var wep = w.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf );
var path = wep.IsValid() ? Rarity.PrefabOf( wep ) : null;
MuleKickAugments.Remember( nz, path );
Log.Info( $"[nz-perk] Mule Kick lost — '{w.Name}' destroyed with slot {cap + 1}" );
Remove( w );
w.Destroy();
}
return extra.Count;
}
public bool Has( GameObject gameObject ) => Items.Contains( gameObject );
/// <summary>
/// Take a weapon into a free slot.
///
/// ⚠️ DOES NOT ENFORCE THE CAP — <see cref="GiveOrReplace"/> does, because the
/// decision about WHICH weapon to drop belongs to the thing handing one over,
/// not to the container.
/// </summary>
public void Add( GameObject gameObject, bool makeActive = false )
{
if ( !gameObject.IsValid() || Has( gameObject ) ) return;
Items.Add( gameObject );
// ⚠️ A new weapon starts HOLSTERED unless asked for. Enabling everything on
// pickup is how you end up holding two guns at once.
var item = gameObject.Components.Get<IInventoryItem>( FindMode.EverythingInSelf );
// ⚠️ force: true — this is ACQUISITION, not a switch. See SetActive.
if ( makeActive || !_active.IsValid() ) SetActive( gameObject, force: true );
else item?.OnCarryStop();
}
public GameObject AddClone( GameObject gamePrefab, bool makeActive = true )
{
if ( !gamePrefab.IsValid() ) return null;
var go = gamePrefab.Clone( new CloneConfig
{
Parent = GameObject,
Transform = global::Transform.Zero,
StartEnabled = true,
} );
Add( go, makeActive );
return go;
}
/// <summary>
/// Put a weapon in hand, holstering whatever was there.
///
/// ⚠️ Respects `CanCarryStop()`, which SWB uses to refuse a holster mid-deploy.
/// Ignoring it lets a fast double-tap strand a weapon half-drawn.
///
/// ⛔ EVERY COMPONENT LOOKUP HERE PASSES `FindMode.EverythingInSelf`, because
/// holstering is EXPRESSED BY DISABLING and `Components.Get<T>()` skips
/// disabled components by default. Without it, switching TO a holstered weapon
/// finds no `IInventoryItem`, never calls `OnCarryStart`, and leaves the player
/// holding nothing at all — the weapon stays disabled and the old one is already
/// put away. Caught by `nz_inv` printing the holstered slot as `?` with its raw
/// object name instead of "M1911".
/// </summary>
public void SetActive( GameObject gameObject ) => SetActive( gameObject, false );
/// <param name="force">
/// Ignore `CanCarryStop()`.
///
/// ⛔ REQUIRED WHEN THE GAME HANDS YOU A WEAPON, and its absence was a shipped
/// bug: `CanCarryStop()` is false during the draw, so buying two wall weapons a
/// second apart hit the guard and SetActive returned early — leaving the new
/// gun ALREADY ENABLED (it is cloned StartEnabled) but never made active. Both
/// weapons rendered at once and neither was properly in hand.
///
/// The guard exists to stop a player switching mid-draw. Acquisition is not a
/// switch: a wall buy, a box roll or a Pack-a-Punch collect must land whatever
/// the animation is doing.
/// </param>
public void SetActive( GameObject gameObject, bool force )
{
if ( _active == gameObject ) return;
if ( gameObject.IsValid() && !Has( gameObject ) ) return;
// ⛔ ACQUISITION NEVER WAITS. `force` means the game HANDED you this weapon —
// a wall buy, a box roll, a Pack-a-Punch collect — and those must land on the
// frame they happen. Running a put-away first would leave the old gun on
// screen while the new one is already in the inventory, which is precisely
// the both-weapons-at-once state that took five separate bugs to clear.
//
// ⚠️ A holster also needs something to holster: no current weapon, or no
// time configured, means switch straight through.
// ⛔ SPEED COLA'S m2 DIVIDES THE HOLSTER AT THE READ SITE, NOT BY WRITING
// `HolsterTime`. That property is a STATIC shared by every weapon and every player —
// the same trap `Slide.SpeedMultiplier` and `Slide.Duration` document — so an
// augment that assigned it would hand the faster swap to everyone and leave it there
// after the perk was lost.
//
// ⚠️ Resolved once and used for both the gate and the timer below, so the two cannot
// disagree about whether there is a holster to play at all.
var swapSpeed = SpeedColaAugments.SwapSpeedMultiplier(
Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) );
var holsterTime = HolsterTime / swapSpeed;
// ⚠️ THE GUN BEING PUT AWAY HAS ITS SAY (2026-10-04): Quickdraw Holster's "instant", the per-class augments' `s.swap`
// (Scout, Quickscope Stock, Sawed-Off). The player's keys and wheel pass `instant` and never reach a put-away at all;
// this is the half of a swap the console's switch, and anything later, still plays.
var putAway = _active.IsValid() ? _active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf ) : null;
if ( putAway.IsValid() )
{
if ( TechStats.Flag( putAway, "f.instantswap" ) ) holsterTime = 0f;
else
{
var swap = TechStats.Mul( putAway, "s.swap" );
if ( swap > 0f ) holsterTime /= swap;
}
}
if ( !force && _active.IsValid() && holsterTime > 0f )
{
// ⚠️ Retarget rather than restart. Scrolling twice quickly should end on
// the second weapon, not replay the put-away and delay the switch again.
if ( _holstering ) { _pending = gameObject; return; }
var outgoing = _active.Components
.Get<IInventoryItem>( FindMode.EverythingInSelf );
// Respected HERE, at the start — checking it at commit time would strand
// a weapon half-put-away with the switch already begun.
if ( outgoing is not null && !outgoing.CanCarryStop() ) return;
(_active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf ))
?.PlayHolster( holsterTime );
_pending = gameObject;
_holstering = true;
_holsterDone = holsterTime;
return;
}
Commit( gameObject, force );
}
/// <summary>How long the put-away takes before the new weapon is drawn.</summary>
public static float HolsterTime { get; set; } = 0.3f;
/// <summary>
/// Tune the switch: `nz_holster_time [seconds]`, 0 for instant.
///
/// ⚠️ THIS IS A FEEL VALUE AND IT FIGHTS ITSELF. Longer reads as weight and
/// matches the authored animation; shorter keeps scrolling responsive, which is
/// what was asked for when the second slot went in. 0.3 is the answer to that
/// tension — the authored clip is 0.46s and gets compressed to fit.
/// </summary>
[ConCmd( "nz_holster_time" )]
public static void SetHolsterTime( float seconds = 0.3f )
{
HolsterTime = MathX.Clamp( seconds, 0f, 3f );
Log.Info( $"[nz] holster {(HolsterTime <= 0f ? "OFF — switches are instant" : $"{HolsterTime:0.##}s")}" );
}
GameObject _pending;
bool _holstering;
TimeUntil _holsterDone;
/// <summary>
/// Finish a deferred switch.
///
/// ⚠️ THE PENDING TARGET IS RE-VALIDATED. Those 0.3s are long enough for the
/// weapon to stop existing — Pack-a-Punch strips the active gun, and a swap can
/// be queued in the same breath. `Commit` already tolerates an invalid target
/// (it means "hold nothing"), but `Has` would refuse a destroyed one and leave
/// the player stuck holding a holstered weapon forever.
/// </summary>
protected override void OnUpdate()
{
if ( !_holstering || !_holsterDone ) return;
_holstering = false;
var target = _pending;
_pending = null;
Commit( target.IsValid() ? target : Weapons.FirstOrDefault(), force: true );
}
/// <summary>The switch itself, once any put-away has finished.</summary>
void Commit( GameObject gameObject, bool force )
{
if ( _active == gameObject ) return;
if ( _active.IsValid() )
{
var old = _active.Components.Get<IInventoryItem>( FindMode.EverythingInSelf );
if ( !force && old is not null && !old.CanCarryStop() ) return;
old?.OnCarryStop();
}
_active = gameObject;
if ( _active.IsValid() )
_active.Components.Get<IInventoryItem>( FindMode.EverythingInSelf )?.OnCarryStart();
TrackActive();
// ⚠️ THE PER-CLASS WEAPON TECH'S DRAW (2026-10-04): Holster Reload, and Lifeline's health following the gun in hand.
ClassTech.OnWeaponDrawn( GameObject.Components.Get<NZPlayer>( FindMode.EverythingInSelf ), _active );
}
/// <summary>
/// Point the player's <c>StartingWeapon</c> at whatever is now in hand.
///
/// ⛔ IN HERE, NOT AT THE CALL SITES. It started life in the input handler and
/// the console command promptly bypassed it — `nz_inv_next` switched the weapon
/// and left StartingWeapon naming the holstered one. Half the project reads that
/// field as "the current weapon" (wall buys, the box's duplicate check,
/// Pack-a-Punch's price AND its level lookup), so a stale value means
/// Pack-a-Punch quotes a price for one gun and upgrades the other.
///
/// Every switch goes through SetActive, so this is the one place it cannot be
/// forgotten.
/// </summary>
void TrackActive()
{
var player = GameObject.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( !player.IsValid() || !_active.IsValid() ) return;
var src = _active.Components.Get<WeaponSource>( FindMode.EverythingInSelf );
if ( src is not null && !string.IsNullOrEmpty( src.Prefab ) )
player.StartingWeapon = src.Prefab;
}
public void SetActive( string name )
{
var go = Items.FirstOrDefault( i => i.IsValid() && i.Name == name );
if ( go.IsValid() ) SetActive( go );
}
/// <summary>Switch by index, ignoring anything out of range.</summary>
/// <param name="instant">
/// Skip the mid-draw refusal.
///
/// ⛔ THE DEFAULT FEELS BROKEN ON A SCROLL WHEEL. `CanCarryStop()` is false for
/// the length of the draw, so a switch requested during it is silently dropped —
/// which on a flick of the wheel reads as the input not working rather than as
/// the game protecting an animation. A player asking to swap has already decided;
/// the half-played draw is theirs to interrupt.
/// </param>
public void SetActiveSlot( int index, bool instant = false )
{
var live = Weapons.ToList();
if ( index < 0 || index >= live.Count ) return;
SetActive( live[index], instant );
}
/// <summary>Which slot is in hand, or -1.</summary>
public int ActiveSlot => Weapons.ToList().IndexOf( _active );
/// <summary>Cycle. `delta` of 1 is next, -1 is previous.</summary>
public void Cycle( int delta, bool instant = false )
{
var live = Weapons.ToList();
if ( live.Count < 2 ) return;
int i = live.IndexOf( _active );
if ( i < 0 ) i = 0;
SetActive( live[(i + delta + live.Count) % live.Count], instant );
}
/// <summary>
/// Hand over a weapon, dropping the ACTIVE one if there is no room.
///
/// ⛔ REPLACES WHAT IS IN YOUR HANDS, not the oldest or the worst. That is the
/// zombies convention and it is the only one the player can predict — you look
/// at what you are holding, and that is what the wall buy or the box takes.
/// </summary>
public void GiveOrReplace( GameObject gameObject )
{
if ( !gameObject.IsValid() ) return;
if ( IsFull && _active.IsValid() )
Remove( _active );
Add( gameObject, makeActive: true );
}
/// <summary>
/// Drop a weapon out of the inventory and destroy it.
///
/// ⚠️ THE SAME disable-unparent-destroy SEQUENCE the buyables all use. A Destroy
/// is deferred to end of frame, so anything that scans for weapons this frame
/// would still find the corpse and act on it.
/// </summary>
public void Remove( GameObject gameObject )
{
if ( !gameObject.IsValid() ) return;
bool wasActive = _active == gameObject;
Items.Remove( gameObject );
if ( wasActive ) _active = null;
// ⛔ TELL IT TO STOP CARRYING FIRST. SWB tears its viewmodel down from
// OnDestroy, but a weapon that is still ENABLED keeps running OnUpdate in
// the frames before a deferred Destroy lands — and that update dereferences
// `Owner`, which throws once the object is on its way out. That was the
// `NullReferenceException at Weapon.OnUpdate` spam.
//
// ⛔ AND DO NOT UNPARENT. `Owner` is resolved ONCE, via
// `Components.GetInAncestors<IPlayerBase>()` — cutting the object loose from
// the player is precisely what makes that lookup meaningless.
gameObject.Components.Get<IInventoryItem>( FindMode.EverythingInSelf )?.OnCarryStop();
gameObject.Enabled = false;
gameObject.Destroy();
// ⛔ FALL BACK TO WHATEVER IS LEFT. Losing the weapon in your hands should
// put the other one there, not leave you empty-handed — most visibly at
// Pack-a-Punch, which takes your gun for 3.5s and stands you next to a
// machine with nothing to shoot while a horde closes in.
//
// ⚠️ Here rather than in Pack-a-Punch, because it is true of EVERY path that
// takes the active weapon — the machine today, a drop or a downed-state
// strip later. Solving it at the machine would mean solving it again.
//
// ⚠️ instant, since there is no weapon left to refuse the holster.
if ( wasActive )
SetActive( Weapons.FirstOrDefault(), force: true );
}
public void Clear()
{
foreach ( var go in Items.ToList() )
Remove( go );
Items.Clear();
_active = null;
}
// ── commands ─────────────────────────────────────────────────────────────
static NZInventory Of( out NZPlayer player )
{
player = NZPlayer.Local;
return player.IsValid() ? player.Inventory : null;
}
/// <summary>
/// What you are carrying: `nz_inv`.
///
/// ⚠️ Prints the PREFAB and the PaP level per slot, not just names. With two
/// slots the interesting failures are a level attached to the wrong gun and a
/// slot holding a weapon the game thinks is elsewhere — both invisible from a
/// list of display names.
/// </summary>
[ConCmd( "nz_inv" )]
public static void List()
{
var inv = Of( out var player );
if ( inv is null ) { Log.Warning( "[nz] no player" ); return; }
Log.Info( $"[nz] {inv.Count}/{inv.EffectiveMaxSlots} slots (base {inv.MaxSlots}), active #{inv.ActiveSlot}"
+ $" (StartingWeapon = '{player.StartingWeapon}')" );
int i = 0;
foreach ( var go in inv.Weapons )
{
var wep = go.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf );
var src = go.Components.Get<WeaponSource>( FindMode.EverythingInSelf )?.Prefab ?? "?";
int lvl = player.PapLevelFor( src );
Log.Info( $"[nz] [{i}]{(go == inv.Active ? " *" : " ")} "
+ $"{wep?.DisplayName ?? go.Name,-14}{(lvl > 0 ? $" MK{lvl}" : " ")}"
+ $" enabled={go.Enabled} {src}" );
i++;
}
}
/// <summary>Switch slots from the console: `nz_inv_slot 1`.</summary>
[ConCmd( "nz_inv_slot" )]
public static void Slot( int index = 0 )
{
var inv = Of( out _ );
if ( inv is null ) { Log.Warning( "[nz] no player" ); return; }
inv.SetActiveSlot( index );
List();
}
/// <summary>Cycle from the console: `nz_inv_next`.</summary>
[ConCmd( "nz_inv_next" )]
public static void Next()
{
var inv = Of( out _ );
if ( inv is null ) { Log.Warning( "[nz] no player" ); return; }
inv.Cycle( 1 );
List();
}
/// <summary>How many weapons you can carry: `nz_inv_max 3` (Mule Kick).</summary>
[ConCmd( "nz_inv_max" )]
public static void Slots( int max = -1 )
{
var inv = Of( out _ );
if ( inv is null ) { Log.Warning( "[nz] no player" ); return; }
if ( max > 0 ) inv.MaxSlots = max;
List();
}
}