Inventory/InventorySlot.cs
using Sandbox;

public enum InventorySlotIndex
{
	Slot1,
	Slot2,
	Slot3,
	Slot4,
	Slot5
}

public sealed class InventorySlot : Component
{
	/// <summary>Hotbar index for this slot; maps to slot1–slot5 input actions.</summary>
	[Property] public InventorySlotIndex SlotIndex { get; set; }

	/// <summary>Only items of this type can be placed in this slot.</summary>
	[Property] public ItemType SlotType { get; set; }

	/// <summary>World-units radius for VR drop detection and slot highlight.</summary>
	[Property] public float Radius { get; set; } = 8f;

	[Sync] public GameObject StoredItemObject { get; private set; }

	public InventoryItem StoredItem => StoredItemObject?.Components.Get<InventoryItem>();

	public bool IsEmpty => StoredItemObject is null;

	/// <summary>
	/// True if this slot can take the item. An item that already reserved this slot counts,
	/// so a desktop player holstering what they picked up lands back in their own slot.
	/// </summary>
	public bool CanAccept( InventoryItem item )
	{
		if ( item is null || item.Type != SlotType )
			return false;

		return IsEmpty || StoredItemObject == item.GameObject;
	}

	/// <summary>
	/// Puts the item away on the body: parents it to the slot and freezes its physics.
	/// Single stow path for VR, desktop and loadout. Stowing also hides the item's world
	/// model for everyone but a VR carrier, which <see cref="InventoryItem.IsStowed"/> drives.
	/// </summary>
	public bool TryStow( InventoryItem item )
	{
		if ( !CanAccept( item ) )
			return false;

		StoredItemObject = item.GameObject;
		item.AssignSlot( GameObject );
		item.IsPhysicsEnabled = false;
		item.Stow();

		AttachToSlot( item );

		return true;
	}

	/// <summary>
	/// Claims this slot for an item that stays in hand, so the hotbar key keeps working
	/// until the item is holstered with <see cref="TryStow"/>.
	/// </summary>
	public void Reserve( InventoryItem item )
	{
		if ( !CanAccept( item ) )
			return;

		StoredItemObject = item.GameObject;
		item.AssignSlot( GameObject );
	}

	public void ClearStoredItem()
	{
		StoredItemObject = null;
	}

	protected override void OnUpdate()
	{
		if ( IsProxy )
			return;

		UpdateStowedTransform();
		UpdateVrHighlight();
	}

	private void UpdateStowedTransform()
	{
		var item = StoredItem;
		if ( item is null || !item.IsStowed )
			return;

		if ( item.GameObject.Parent != GameObject )
			AttachToSlot( item );
	}

	private void AttachToSlot( InventoryItem item )
	{
		item.GameObject.SetParent( GameObject, false );
		item.GameObject.LocalTransform = global::Transform.Zero;
	}

	private void UpdateVrHighlight()
	{
		if ( !Game.IsRunningInVR )
			return;

		var heldItem = GetHeldItemOfSlotType();
		if ( heldItem is null || !IsEmpty )
			return;

		var color = ShouldHighlight( heldItem ) ? Color.Green : Color.Red;
		Gizmo.Draw.Color = color.WithAlpha( 0.45f );
		Gizmo.Draw.LineSphere( WorldPosition, Radius );
	}

	private InventoryItem GetHeldItemOfSlotType()
	{
		if ( Player.CurrentPlayer is null )
			return null;

		return Player.CurrentPlayer.Inventory.GetHeldItemOfType( SlotType );
	}

	private bool ShouldHighlight( InventoryItem heldItem )
	{
		return WorldPosition.Distance( GetVrStowPosition( heldItem ) ) <= Radius;
	}

	// Holster proximity uses the grabbing hand, not the item root — a long gun's
	// origin can sit well outside the slot while the grip is inside it.
	private Vector3 GetVrStowPosition( InventoryItem item )
	{
		Vector3? closestHand = null;
		var closestDistance = float.MaxValue;

		foreach ( var grabPoint in item.GameObject.Components.GetAll<GrabPoint>( FindMode.InDescendants ) )
		{
			if ( !grabPoint.IsGrabbed )
				continue;

			var handPosition = grabPoint.HandTransform.Position;
			var distance = WorldPosition.Distance( handPosition );
			if ( distance >= closestDistance )
				continue;

			closestDistance = distance;
			closestHand = handPosition;
		}

		return closestHand ?? item.GameObject.WorldPosition;
	}

	protected override void DrawGizmos()
	{
		Gizmo.Draw.Color = Color.Cyan.WithAlpha( 0.35f );
		Gizmo.Draw.LineSphere( Vector3.Zero, Radius );
	}
}