Player/PlayerInventory.cs

PlayerInventory component for a player. Manages the player's carryable items and ammo, spawn loadout, pickups, auto-switch logic, dropping items into a coffin on death, and forwards player event hooks to carried items.

NetworkingFile Access
/// <summary>
/// The player's weapon list. <see cref="BaseInventoryComponent"/> owns the slotting, switching, the
/// reserve ammo pool and the host-authoritative networking - this adds the deathmatch policy on top:
/// spawn loadout, pickup notices, auto-switch preferences and dropping everything into a coffin.
/// </summary>
public sealed class PlayerInventory : BaseInventoryComponent, IPlayerEvent
{
	[RequireComponent]
	public Player Player { get; set; }

	/// <summary>
	/// The deployed carryable, or null. Passive items are never deployed so this is always a weapon.
	/// </summary>
	public Carryable Current => ActiveItem as Carryable;

	/// <summary>
	/// Everything in the inventory that can actually be selected and held.
	/// </summary>
	public List<Carryable> Carryables => Items.OfType<Carryable>().ToList();

	public void GiveDefaultWeapons()
	{
		// Don't run any pickup notices when spawning in
		using var _ = Player.NoNoticeScope();

		Pickup( "weapons/crowbar/crowbar.prefab" );
		//	Pickup( "weapons/hands/hands.prefab" );
		//	Pickup( "weapons/camera/camera.prefab" );
		Pickup( "weapons/glock/glock.prefab" );

		GiveAmmo( ResourceLibrary.Get<BaseAmmoResource>( "weapons/ammo/9mm.ammo" ), 100 );

		if ( GameSettings.CheatMode )
		{
			GiveAll();
		}
	}

	public void GiveAll()
	{
		Pickup( "weapons/mp5/mp5.prefab" );
		Pickup( "weapons/gaussgun/gauss.prefab" );
		Pickup( "weapons/python/python.prefab" );
		Pickup( "weapons/handgrenade/hand_grenade.prefab" );
		Pickup( "weapons/tripmine/tripmine.prefab" );
		Pickup( "weapons/satchelcharge/satchelcharge.prefab" );
		Pickup( "weapons/rpg/rpg.prefab" );
		Pickup( "weapons/crossbow/crossbow.prefab" );
		Pickup( "weapons/shotgun/shotgun.prefab" );
		Pickup( "weapons/ratgun/rat_gun.prefab" );
		Pickup( "weapons/gluongun/gluon_gun.prefab" );
		Pickup( "weapons/hornetgun/hornetgun.prefab" );

		// Only our own ammo types - BaseAmmoResource is shared with the base addon, which ships its
		// own .ammo assets that nothing here fires.
		foreach ( var ammo in ResourceLibrary.GetAll<BaseAmmoResource>( "weapons/ammo" ) )
		{
			GiveAmmo( ammo, 100 );
		}
	}

	public bool HasWeapon<T>() where T : Carryable => Carryables.OfType<T>().Any();

	public T GetWeapon<T>() where T : Carryable => Carryables.OfType<T>().FirstOrDefault();

	public bool HasWeapon( GameObject prefab )
	{
		var carry = prefab.GetComponent<Carryable>( true );
		if ( carry is null )
			return false;

		return Carryables.Any( x => x.GetType() == carry.GetType() );
	}

	/// <summary>
	/// Switch to a carryable. Kept for readability at the call sites - the engine's
	/// <see cref="BaseInventoryComponent.Switch"/> does the work.
	/// </summary>
	public void SwitchWeapon( Carryable weapon ) => Switch( weapon );

	public Carryable GetBestWeapon() => Carryables.OrderByDescending( x => x.Value ).FirstOrDefault();

	public Carryable GetBestWeaponHolstered() => GetBestItem() as Carryable;

	/// <summary>
	/// Give reserve ammo and optionally show the player a pickup notice for it.
	/// </summary>
	public int GiveAmmo( BaseAmmoResource type, int amount, bool notice )
	{
		var gained = GiveAmmo( type, amount );

		if ( notice && gained > 0 )
			Player.ShowNotice( $"{type.Title} x {gained}" );

		return gained;
	}

	/// <summary>
	/// An empty gun is worth switching to only when there's nothing better - and a weapon mid-use
	/// (a primed grenade, a charging gauss shot) must never be yanked out of the player's hands.
	/// </summary>
	protected override bool ShouldAutoSwitchTo( BaseInventoryItem item )
	{
		if ( !item.IsValid() )
			return false;

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

		if ( !GamePreferences.AutoSwitch )
			return false;

		if ( Current.IsValid() && Current.IsInUse() )
			return false;

		return base.ShouldAutoSwitchTo( item );
	}

	protected override void OnItemAdded( BaseInventoryItem item )
	{
		base.OnItemAdded( item );

		IPlayerEvent.PostToGameObject( Player.GameObject, e => e.OnPickup( item ) );

		// Only PickupWorldItem auto-switches in the engine - a code-driven Add doesn't, so without
		// this a player would spawn into their loadout holding nothing.
		if ( !ActiveItem.IsValid() )
			SwitchToBest();

		NotifyItemPickup( item );
	}

	[Rpc.Owner]
	private void NotifyItemPickup( BaseInventoryItem item )
	{
		if ( !item.IsValid() )
			return;

		// Auto-switch is a client-side preference, so the owning client makes the call and asks the
		// host to apply it - deciding it host-side would use the host's preference for everyone.
		if ( ShouldAutoSwitchTo( item ) )
			Switch( item );

		if ( Player.IsValid() && Player.IsLocalPlayer )
			ILocalPlayerEvent.Post( e => e.OnPickup( item ) );
	}

	public void DropCoffin()
	{
		if ( !Networking.IsHost )
			return;

		var go = GameObject.Clone( "items/coffin/coffin.prefab" );
		go.Name = $"Coffin for {GameObject.Name}";
		go.WorldPosition = Player.EyeTransform.Position;
		go.WorldRotation = Rotation.LookAt( Player.EyeTransform.Forward.WithZ( 0 ), Vector3.Up );

		var coffin = go.GetComponent<Coffin>();
		Assert.True( coffin.IsValid(), "Coffin not on coffin prefab" );

		// The reserve pool is private to the inventory, so hand the coffin a snapshot of what we're
		// actually holding rather than the dictionary itself.
		coffin.AmmoCounts = new();
		foreach ( var ammo in ResourceLibrary.GetAll<BaseAmmoResource>() )
		{
			var count = GetAmmo( ammo );
			if ( count > 0 )
				coffin.AmmoCounts[ammo] = count;
		}

		if ( go.GetComponent<Rigidbody>() is { } rb )
		{
			rb.Velocity = Player.Controller.Velocity + (Player.EyeTransform.Backward * 128);
		}

		go.NetworkSpawn( true, null );

		var coffinInventory = go.GetComponent<BaseInventoryComponent>();

		// Transfer all items to the coffin
		foreach ( var item in Items.ToArray() )
		{
			if ( coffinInventory.IsValid() )
			{
				Transfer( item, coffinInventory );
			}
			else
			{
				Remove( item );
			}
		}
	}

	// IPlayerEvent implementation
	void IPlayerEvent.OnSpawned() => GiveDefaultWeapons();

	void IPlayerEvent.OnDied( IPlayerEvent.DiedParams args )
	{
		foreach ( var item in Items )
		{
			switch ( item )
			{
				case Carryable carryable:
					carryable.OnPlayerDeath( args );
					break;
				case Item passive:
					passive.OnPlayerDeath( args );
					break;
			}
		}
	}

	void IPlayerEvent.OnPickup( BaseInventoryItem item )
	{
		if ( item is BaseWeapon weapon && weapon.IsSelfAmmo && weapon.AmmoResource.IsValid() )
			Player.ShowNotice( $"{weapon.AmmoResource.Title} x {weapon.StartingAmmo}" );
		else
			Player.ShowNotice( item.DisplayName );
	}

	void IPlayerEvent.OnCameraMove( ref Angles angles )
	{
		if ( Current.IsValid() )
			Current.OnCameraMove( Player, ref angles );
	}

	void IPlayerEvent.OnCameraPostSetup( Sandbox.CameraComponent camera )
	{
		if ( Current.IsValid() )
			Current.OnCameraSetup( Player, camera );
	}
}