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 );
}
}