Player/PlayerInventory.cs

PlayerInventory component for a player, extends the engine BaseInventoryComponent. Manages weapons collection, pickups, drops, switching, autoswitch logic, events for pickup/drop/move/remove, and routes RPCs for client-host interactions.

NetworkingExternal Download
using Sandbox.Citizen;

public sealed class PlayerInventory : BaseInventoryComponent, Local.IPlayerEvents
{
	// MaxSlots, ActiveItem, the active-item enable/disable + equip/holster and the add/remove/drop/
	// move-slot flows come from the engine BaseInventoryComponent. This adds the sandbox's events, ammo
	// merging, notices and undo handling through the engine's hooks.

	[RequireComponent] public Player Player { get; set; }

	/// <summary>
	/// All weapons currently in the inventory, ordered by slot. Narrowing shim over engine Items.
	/// </summary>
	public IEnumerable<BaseSandboxWeapon> Weapons => Items.OfType<BaseSandboxWeapon>();

	/// <summary>
	/// The currently active weapon. Narrowing shim over the engine's <see cref="BaseInventoryComponent.ActiveItem"/>.
	/// </summary>
	public BaseSandboxWeapon ActiveWeapon => ActiveItem as BaseSandboxWeapon;

	/// <summary>
	/// Returns the weapon in the given slot, or null if the slot is empty.
	/// </summary>
	public new BaseSandboxWeapon GetSlot( int slot ) => base.GetSlot( slot ) as BaseSandboxWeapon;

	/// <summary>
	/// A weapon of the same class we already carry, if any. Duplicate handling itself is the
	/// engine's (a duplicate donates its magazine to the reserve, see BaseCombatWeapon.OnAdding) - this
	/// just finds the weapon the donation lands on, for the pickup notices.
	/// </summary>
	private BaseSandboxWeapon FindExistingWeapon( BaseSandboxWeapon like )
		=> like.IsValid() ? Weapons.FirstOrDefault( x => x.GetType() == like.GetType() ) : null;

	// FindEmptySlot is inherited from the engine BaseInventoryComponent.

	// The default weapons come from the engine's Loadout feature, configured on the player prefab -
	// PlayerLoadout calls GiveLoadout() when there's no saved hotbar to restore.

	/// <summary>
	/// Activates the named tool mode, giving and equipping the toolgun first if the player doesn't have one.
	/// </summary>
	public void SetToolMode( string name )
	{
		if ( !Networking.IsHost )
		{
			HostSetToolMode( name );
			return;
		}

		if ( !HasWeapon<Toolgun>() )
		{
			Pickup( "weapons/toolgun/toolgun.prefab", false );
		}

		var toolGun = GetWeapon<Toolgun>();
		if ( !toolGun.IsValid() ) 
			return;

		SwitchWeapon( toolGun );
		toolGun.SetToolMode( name );
	}

	[Rpc.Host]
	private void HostSetToolMode( string toolModeName )
	{
		SetToolMode( toolModeName );
	}

	public bool Pickup( string prefabName, bool notice = true ) => Pickup( prefabName, -1, notice );

	public bool HasWeapon( GameObject prefab )
	{
		var baseCarry = prefab.GetComponent<BaseSandboxWeapon>( true );
		if ( !baseCarry.IsValid() )
			return false;

		return Weapons.Where( x => x.GetType() == baseCarry.GetType() )
			.FirstOrDefault()
			.IsValid();
	}

	public bool HasWeapon<T>() where T : BaseSandboxWeapon
	{
		return GetWeapon<T>().IsValid();
	}

	public T GetWeapon<T>() where T : BaseSandboxWeapon
	{
		return Weapons.OfType<T>().FirstOrDefault();
	}

	public bool Pickup( GameObject prefab, bool notice = true ) => Pickup( prefab, -1, notice );

	public bool Pickup( string prefabName, int targetSlot, bool notice = true )
	{
		if ( !Networking.IsHost )
			return false;

		var prefab = GameObject.GetPrefab( prefabName );
		if ( prefab is null )
		{
			Log.Warning( $"Prefab not found: {prefabName}" );
			return false;
		}

		return Pickup( prefab, targetSlot, notice );
	}

	public bool Pickup( GameObject prefab, int targetSlot, bool notice = true )
	{
		if ( !Networking.IsHost )
			return false;

		// The engine consumes a duplicate as an ammo donation (see BaseCombatWeapon.OnAdding) - watch the
		// pool so the ammo notice can fire.
		var existing = FindExistingWeapon( prefab.GetComponent<BaseSandboxWeapon>( true ) );
		var ammoBefore = existing.IsValid() ? existing.Ammo1 : 0;

		// Engine pickup: clone, network spawn, parent, slot, ownership. The cancellable pickup event
		// fires from OnAdding; the engine destroys the clone if it's refused.
		if ( base.Pickup( prefab, targetSlot ) is BaseSandboxWeapon weapon )
		{
			if ( notice )
				OnClientPickup( weapon );

			return true;
		}

		// Refused as a duplicate - donated or already topped up, either way it counts as taken.
		if ( existing.IsValid() )
		{
			if ( notice && existing.Ammo1 > ammoBefore )
				OnClientPickup( existing, true );

			return true;
		}

		return false;
	}

	public bool Take( BaseSandboxWeapon item, bool includeNotices )
	{
		if ( !item.IsValid() )
			return false;

		var existing = FindExistingWeapon( item );
		var ammoBefore = existing.IsValid() ? existing.Ammo1 : 0;

		// Engine add: parent, slot, ownership, disable. The cancellable pickup event fires from
		// OnAdding, and a duplicate donates its magazine to the reserve and is consumed there.
		if ( Add( item ) )
		{
			// Remove from undo stacks so the weapon can't be undone out of our hands
			UndoSystem.Current.Remove( item.GameObject );

			if ( includeNotices )
				OnClientPickup( item );

			return true;
		}

		// Consumed by the donation - that's a take too. A refused item stays in the world.
		if ( item.GameObject.IsDestroyed )
		{
			if ( includeNotices && existing.IsValid() && existing.Ammo1 > ammoBefore )
				OnClientPickup( existing, true );

			return true;
		}

		return false;
	}

	/// <summary>
	/// Engine Touch pickup lands here (see <see cref="BaseInventoryComponent.PickupMode"/>). Routes into
	/// <see cref="Take"/>, so duplicates donate their ammo and the pickup notices fire. Contraption-
	/// wired weapons refuse themselves (see <see cref="BaseSandboxWeapon"/>'s OnCanPickup).
	/// </summary>
	public override void PickupWorldItem( Sandbox.BaseInventoryItem item )
	{
		if ( !Networking.IsHost )
		{
			base.PickupWorldItem( item );
			return;
		}

		if ( item is not BaseSandboxWeapon weapon )
			return;

		if ( !CanPickupWorldItem( weapon ) )
			return;

		Take( weapon, true );
	}

	/// <summary>
	/// Fires the cancellable pickup events before the engine adds an item.
	/// </summary>
	protected override bool OnAdding( Sandbox.BaseInventoryItem item, int slot )
	{
		if ( item is not BaseSandboxWeapon weapon )
			return true;

		var pickupEvent = new PlayerPickupEvent { Player = Player, Weapon = weapon, Slot = slot };
		Local.IPlayerEvents.PostToGameObject( Player.GameObject, e => e.OnPickup( pickupEvent ) );
		Global.IPlayerEvents.Post( e => e.OnPlayerPickup( pickupEvent ) );

		return !pickupEvent.Cancelled;
	}

	/// <summary>
	/// Drops the given weapon from the inventory. The engine holsters it, the weapon throws itself
	/// into the world (see <see cref="BaseSandboxWeapon.OnDrop"/>) and we switch to the best remaining
	/// weapon. The cancellable drop event fires from <see cref="OnDropping"/>.
	/// </summary>
	public void Drop( BaseSandboxWeapon weapon )
	{
		if ( weapon.IsValid() && weapon.Owner != Player )
			return;

		base.Drop( weapon );
	}

	protected override bool OnDropping( Sandbox.BaseInventoryItem item )
	{
		if ( item is not BaseSandboxWeapon weapon )
			return true;

		var dropEvent = new PlayerDropEvent { Player = Player, Weapon = weapon };
		Local.IPlayerEvents.PostToGameObject( Player.GameObject, e => e.OnDrop( dropEvent ) );
		Global.IPlayerEvents.Post( e => e.OnPlayerDrop( dropEvent ) );

		return !dropEvent.Cancelled;
	}

	private static SoundEvent AmmoPickupSound = ResourceLibrary.Get<SoundEvent>( "sounds/weapons/ammo_pickup.sound" );
	private static SoundEvent GunPickupSound = ResourceLibrary.Get<SoundEvent>( "sounds/weapons/gun_pickup.sound" );

	[Rpc.Owner]
	private void OnClientPickup( BaseSandboxWeapon weapon, bool justAmmo = false )
	{
		if ( !weapon.IsValid() ) return;

		if ( ShouldAutoswitchTo( weapon ) )
		{
			SwitchWeapon( weapon );
		}

		if ( Player.IsLocalPlayer )
		{
			GameObject.PlaySound( justAmmo ? AmmoPickupSound : GunPickupSound );
			Global.IPlayerEvents.Post( e => e.OnPlayerPickup( new PlayerPickupEvent { Player = Player, Weapon = weapon, Slot = weapon.Slot } ) );
		}
	}

	private bool ShouldAutoswitchTo( BaseSandboxWeapon item )
	{
		Assert.True( item.IsValid(), "item invalid" );

		if ( !ActiveWeapon.IsValid() )
			return true;

		if ( !GamePreferences.AutoSwitch )
			return false;

		if ( ActiveWeapon.IsInUse() )
			return false;

		// Nothing to fire or load - the engine flags spent guns.
		if ( item.ShouldAvoid )
			return false;

		return item.Value > ActiveWeapon.Value;
	}

	// MoveSlot comes from the engine BaseInventoryComponent - the cancellable move event fires from this
	// hook.
	protected override bool OnMovingSlot( int fromSlot, int toSlot )
	{
		var moveEvent = new PlayerMoveSlotEvent { Player = Player, FromSlot = fromSlot, ToSlot = toSlot };
		Local.IPlayerEvents.PostToGameObject( Player.GameObject, e => e.OnMoveSlot( moveEvent ) );
		Global.IPlayerEvents.Post( e => e.OnPlayerMoveSlot( moveEvent ) );

		return !moveEvent.Cancelled;
	}

	/// <summary>
	/// The weapon the inventory would auto-switch to. The engine's <see cref="BaseInventoryComponent.GetBestItem"/>
	/// handles the Value ordering and the avoid-empty-guns fallback.
	/// </summary>
	public BaseSandboxWeapon GetBestWeapon() => GetBestItem() as BaseSandboxWeapon;

	/// <summary>
	/// Switches to the given weapon. Thin wrapper over the engine inventory's <see cref="BaseInventoryComponent.Switch"/>
	/// (which handles host-routing and the holster veto). Switch events had no consumers and were dropped.
	/// </summary>
	public void SwitchWeapon( BaseSandboxWeapon weapon, bool allowHolster = false )
	{
		Switch( weapon, allowHolster );
	}

	public void OnControl()
	{
		if ( Input.Pressed( "drop" ) && ActiveWeapon.IsValid() )
			DropActiveWeapon();
	}

	/// <summary>
	/// Called by the owning client to drop their currently held weapon.
	/// </summary>
	[Rpc.Host]
	private void DropActiveWeapon()
	{
		if ( !ActiveWeapon.IsValid() ) return;
		Drop( ActiveWeapon );
	}

	/// <summary>
	/// Removes a weapon from the inventory and destroys it without dropping it into the world. The
	/// engine holsters it, destroys it and switches to the best remaining weapon. The cancellable
	/// remove event fires from <see cref="OnRemoving"/>.
	/// </summary>
	public void Remove( BaseSandboxWeapon weapon )
	{
		if ( weapon.IsValid() && weapon.Owner != Player )
			return;

		base.Remove( weapon );
	}

	protected override bool OnRemoving( Sandbox.BaseInventoryItem item )
	{
		if ( item is not BaseSandboxWeapon weapon )
			return true;

		var removeEvent = new PlayerRemoveWeaponEvent { Player = Player, Weapon = weapon };
		Local.IPlayerEvents.PostToGameObject( Player.GameObject, e => e.OnRemoveWeapon( removeEvent ) );
		Global.IPlayerEvents.Post( e => e.OnPlayerRemoveWeapon( removeEvent ) );

		return !removeEvent.Cancelled;
	}

	void Local.IPlayerEvents.OnDied( PlayerDiedParams args )
	{
		if ( ActiveWeapon.IsValid() )
		{
			ActiveWeapon.OnPlayerDeath( args );
		}
	}

	void Local.IPlayerEvents.OnCameraMove( ref Angles angles )
	{
		if ( !ActiveWeapon.IsValid() ) return;

		ActiveWeapon.OnCameraMove( Player, ref angles );
	}

	void Local.IPlayerEvents.OnCameraPostSetup( Sandbox.CameraComponent camera )
	{
		if ( !ActiveWeapon.IsValid() ) return;

		ActiveWeapon.OnCameraSetup( Player, camera );
	}
}