Code/InventoryComponent.cs
using Sandbox;
using System;
public sealed class InventoryComponent : Component
{
// ─── Inspector Properties ────────────────────────────────────
[Property, Group( "Grid" )]
public int BagWidth { get; set; } = 6;
[Property, Group( "Grid" )]
public int BagHeight { get; set; } = 5;
[Property, Group( "Hotbar" )]
public bool UseHotbar { get; set; } = true;
[Property, Group( "Equipment" )]
public bool UseEquipment { get; set; } = true;
// ─── Feature Toggles ─────────────────────────────────────────
[Property, Group( "Features" )]
public bool EnableDestroyConfirm { get; set; } = true;
[Property, Group( "Features" )]
public bool EnableWeight { get; set; } = true;
[Property, Group( "Features" )]
public bool EnableDurability { get; set; } = true;
[Property, Group( "Features" )]
public bool EnableEquipmentStats { get; set; } = true;
[Property, Group( "Features" )]
public bool EnableCategories { get; set; } = true;
[Property, Group( "Features" )]
public bool EnableRarity { get; set; } = true;
[Property, Group( "Features" )]
public bool EnableCrafting { get; set; } = true;
[Property, Group( "Features" )]
public bool EnableQuickStack { get; set; } = true;
[Property, Group( "Features" )]
public bool EnableSearch { get; set; } = true;
[Property, Group( "Features" )]
public bool EnableSorting { get; set; } = true;
[Property, Group( "Category Visibility" )]
public List<ItemCategory> VisibleCategories { get; set; } = new()
{
ItemCategory.Misc, ItemCategory.Weapon, ItemCategory.Armor,
ItemCategory.Consumable, ItemCategory.Material, ItemCategory.Tool,
ItemCategory.Key, ItemCategory.Ammo, ItemCategory.Quest
};
// ─── Synced State ────────────────────────────────────────────
[Sync, Change( nameof(OnBagDataChanged) )]
private string _bagData { get; set; } = "";
[Sync, Change( nameof(OnEquipDataChanged) )]
private string _equipData { get; set; } = "";
[Sync] public int HotbarActiveIndex { get; private set; } = 0;
// ─── Public State ────────────────────────────────────────────
/// <summary>Serialized bag string — exposed for BuildHash in InventoryHud. Do not mutate directly.</summary>
public string BagDataHash => _bagData;
/// <summary>Serialized equipment string — exposed for BuildHash in InventoryHud. Do not mutate directly.</summary>
public string EquipDataHash => _equipData;
/// <summary>The player's bag grid container.</summary>
public InventoryContainer Bag { get; private set; }
/// <summary>The player's equipment slots. Null if UseEquipment is false.</summary>
public EquipmentSlots Equipment { get; private set; }
/// <summary>
/// Total grid height including hotbar row if enabled.
/// </summary>
public int TotalHeight => BagHeight + ( UseHotbar ? 1 : 0 );
/// <summary>
/// The first slot index of the hotbar row (last row of the grid).
/// Returns -1 if hotbar is disabled.
/// </summary>
public int HotbarStartIndex => UseHotbar
? BagWidth * BagHeight
: -1;
/// <summary>
/// The currently active hotbar slot, or null if hotbar is disabled.
/// </summary>
public InventorySlot ActiveHotbarSlot
{
get
{
if ( !UseHotbar ) return null;
return Bag.GetSlot( HotbarStartIndex + HotbarActiveIndex );
}
}
// ─── Events ──────────────────────────────────────────────────
/// <summary>
/// Fired on all clients whenever any inventory state changes.
/// Hook into this to update custom UI or game logic.
/// </summary>
public Action OnInventoryChanged;
// ─── Lifecycle ───────────────────────────────────────────────
protected override void OnStart()
{
Bag = new InventoryContainer( BagWidth, TotalHeight );
if ( UseEquipment )
Equipment = new EquipmentSlots();
RebuildFromSyncData();
}
// ─── Sync Callbacks ──────────────────────────────────────────
// ─── Force Sync (called by UI for direct slot manipulation) ──
public void ForcePushBagSync()
{
AssertAuthority();
PushBagSync();
}
public void ForcePushEquipSync()
{
AssertAuthority();
PushEquipSync();
}
private void OnBagDataChanged( string oldValue, string newValue )
{
Bag?.Deserialize( newValue );
OnInventoryChanged?.Invoke();
}
private void OnEquipDataChanged( string oldValue, string newValue )
{
if ( !UseEquipment || Equipment == null ) return;
Equipment.Deserialize( newValue );
OnInventoryChanged?.Invoke();
}
// ─── Bag API ─────────────────────────────────────────────────
/// <summary>
/// Add items to the inventory. Returns leftover amount that didn't fit.
/// Only call on the owning client or server.
/// </summary>
public int GiveItem( InventoryItem item, int amount = 1 )
{
if ( !AssertAuthority() ) return amount;
int toAdd = amount;
if ( EnableWeight && MaxWeight > 0f && item.Weight > 0f )
{
float available = MaxWeight - CurrentWeight;
int maxByWeight = (int)( available / item.Weight );
toAdd = Math.Min( toAdd, Math.Max( 0, maxByWeight ) );
}
if ( toAdd <= 0 ) return amount;
int bagLeftover = Bag.AddItem( item, toAdd );
int actuallyAdded = toAdd - bagLeftover;
PushBagSync();
return amount - actuallyAdded;
}
/// <summary>
/// Remove items from the inventory. Returns how many were actually removed.
/// </summary>
public int TakeItem( InventoryItem item, int amount = 1 )
{
if ( !AssertAuthority() ) return 0;
int removed = Bag.RemoveItem( item, amount );
PushBagSync();
return removed;
}
public bool HasItem( InventoryItem item, int amount = 1 )
=> Bag.HasItem( item, amount );
/// <summary>
/// Add items directly to the bag without triggering a sync push.
/// Call ForcePushBagSync() after a batch of these to sync once.
/// </summary>
internal int GiveItemSilent( InventoryItem item, int amount = 1 )
{
if ( !AssertAuthority() ) return amount;
int toAdd = amount;
if ( EnableWeight && MaxWeight > 0f && item.Weight > 0f )
{
float available = MaxWeight - CurrentWeight;
int maxByWeight = (int)( available / item.Weight );
toAdd = Math.Min( toAdd, Math.Max( 0, maxByWeight ) );
}
if ( toAdd <= 0 ) return amount;
int bagLeftover = Bag.AddItem( item, toAdd );
int actuallyAdded = toAdd - bagLeftover;
return amount - actuallyAdded;
}
// ─── Quick Stack ─────────────────────────────────────────────
/// <summary>
/// For every item in the bag, push it into any nearby StorageContainer that
/// already holds that item type. Only stacks into existing stacks — won't
/// start new stacks in containers that don't already have the item.
/// </summary>
public void QuickStackToNearby()
{
if ( !AssertAuthority() ) return;
if ( !EnableQuickStack ) return;
bool bagChanged = false;
foreach ( var container in Scene.GetAllComponents<StorageContainer>() )
{
if ( container.IsLootContainer ) continue;
float dist = Vector3.DistanceBetween( Transform.Position, container.Transform.Position );
if ( dist > QuickStackRadius ) continue;
bool containerChanged = false;
for ( int i = 0; i < Bag.SlotCount; i++ )
{
var slot = Bag.GetSlot( i );
if ( slot.IsEmpty ) continue;
if ( !container.Container.HasItem( slot.Item ) ) continue;
int before = slot.Quantity;
int leftover = container.Container.AddItem( slot.Item, slot.Quantity );
int moved = before - leftover;
if ( moved > 0 )
{
slot.Remove( moved );
bagChanged = true;
containerChanged = true;
}
}
if ( containerChanged )
container.ForcePush();
}
if ( bagChanged )
PushBagSync(); // single sync after all containers processed
}
// ─── Slot Manipulation ───────────────────────────────────────
/// <summary>
/// Swap two slots in the bag. Works freely across hotbar and inventory rows.
/// This is the primary method called by UI drag and drop.
/// </summary>
public void SwapSlots( int indexA, int indexB )
{
if ( !AssertAuthority() ) return;
if ( indexA < 0 || indexB < 0 || indexA >= Bag.SlotCount || indexB >= Bag.SlotCount )
return;
Bag.SwapSlots( indexA, indexB );
PushBagSync();
}
/// <summary>
/// Split a stack — moves half (rounded down) from one slot into another.
/// Target slot must be empty or contain the same item type.
/// </summary>
public void SplitStack( int fromIndex, int toIndex )
{
if ( !AssertAuthority() ) return;
if ( fromIndex < 0 || toIndex < 0
|| fromIndex >= Bag.SlotCount || toIndex >= Bag.SlotCount )
return;
var from = Bag.GetSlot( fromIndex );
var to = Bag.GetSlot( toIndex );
if ( from.IsEmpty || from.Quantity < 2 ) return;
if ( !to.IsEmpty && to.Item != from.Item ) return;
int splitAmount = from.Quantity / 2;
from.Remove( splitAmount );
to.Add( from.Item, splitAmount );
PushBagSync();
}
/// <summary>
/// Merge as much of fromIndex into toIndex as toIndex's MaxStack allows.
/// Both slots must have the same item type. If fromIndex empties out, the slot is cleared.
/// </summary>
public void MergeStacks( int fromIndex, int toIndex )
{
if ( !AssertAuthority() ) return;
if ( fromIndex < 0 || toIndex < 0
|| fromIndex >= Bag.SlotCount || toIndex >= Bag.SlotCount )
return;
var from = Bag.GetSlot( fromIndex );
var to = Bag.GetSlot( toIndex );
if ( from.IsEmpty || to.IsEmpty || from.Item != to.Item ) return;
int maxStack = from.Item.MaxStack;
int space = maxStack - to.Quantity;
int move = Math.Min( space, from.Quantity );
if ( move <= 0 ) return;
to.Add( from.Item, move );
from.Remove( move );
PushBagSync();
}
// ─── Sort ────────────────────────────────────────────────────
/// <summary>
/// Sort the bag by Category (grouped) then Name (alphabetical) then Rarity (descending).
/// </summary>
public void SortBag()
{
if ( !AssertAuthority() ) return;
Bag.SortBy( ( a, b ) =>
{
int c = a.Item.Category.CompareTo( b.Item.Category );
if ( c != 0 ) return c;
int n = string.Compare( a.Item.ItemName, b.Item.ItemName, StringComparison.OrdinalIgnoreCase );
if ( n != 0 ) return n;
return b.Item.Rarity.CompareTo( a.Item.Rarity );
} );
PushBagSync();
}
// ─── Weight / Encumbrance ─────────────────────────────────────
[Property, Group( "Quick Stack" )]
public float QuickStackRadius { get; set; } = 400f;
[Property, Group( "Weight" )]
public float MaxWeight { get; set; } = 0f; // 0 = unlimited
public float CurrentWeight
{
get
{
float total = 0f;
for ( int i = 0; i < Bag.SlotCount; i++ )
{
var s = Bag.GetSlot( i );
if ( !s.IsEmpty && s.Item.Weight > 0f )
total += s.Item.Weight * s.Quantity;
}
if ( UseEquipment && Equipment != null )
{
foreach ( var kvp in Equipment.AllSlots() )
{
var s = kvp.Value;
if ( !s.IsEmpty && s.Item.Weight > 0f )
total += s.Item.Weight * s.Quantity;
}
}
return total;
}
}
public bool IsOverEncumbered => MaxWeight > 0f && CurrentWeight > MaxWeight;
public Action<float, float> OnEncumbranceChanged; // current, max
// ─── Durability ───────────────────────────────────────────────
public Action<InventoryItem, int> OnItemDamaged; // item, remainingDurability
public Action<InventoryItem> OnItemBroken; // item (durability hit 0)
/// <summary>
/// Damage an item in the bag. If durability reaches 0, the item is destroyed.
/// </summary>
public void DamageItem( int bagIndex, float amount )
{
if ( !AssertAuthority() ) return;
if ( !EnableDurability ) return;
var slot = Bag.GetSlot( bagIndex );
if ( slot.IsEmpty || slot.Durability < 0 ) return;
slot.Durability = Math.Max( 0, slot.Durability - amount );
PushBagSync();
OnItemDamaged?.Invoke( slot.Item, (int)slot.Durability );
if ( slot.Durability <= 0 )
{
OnItemBroken?.Invoke( slot.Item );
slot.Clear();
PushBagSync();
}
}
/// <summary>
/// Damage an equipped item.
/// </summary>
public void DamageEquipItem( EquipSlot slotType, float amount )
{
if ( !AssertAuthority() ) return;
if ( !EnableDurability || !UseEquipment || Equipment == null ) return;
var slot = Equipment.GetSlot( slotType );
if ( slot == null || slot.IsEmpty || slot.Durability < 0 ) return;
slot.Durability = Math.Max( 0, slot.Durability - amount );
PushEquipSync();
OnItemDamaged?.Invoke( slot.Item, (int)slot.Durability );
if ( slot.Durability <= 0 )
{
OnItemBroken?.Invoke( slot.Item );
slot.Clear();
PushEquipSync();
}
}
// ─── Equipment API ───────────────────────────────────────────
/// <summary>
/// Equip the item sitting in the given bag slot.
/// Any item displaced from the equipment slot returns to the bag automatically.
/// Does nothing if UseEquipment is false or the item has no EquipSlot set.
/// </summary>
public void EquipFromBag( int bagSlotIndex )
{
if ( !AssertAuthority() ) return;
if ( !UseEquipment || Equipment == null ) return;
var bagSlot = Bag.GetSlot( bagSlotIndex );
if ( bagSlot.IsEmpty || bagSlot.Item.EquipSlot == EquipSlot.None ) return;
var itemToEquip = bagSlot.Item;
bagSlot.Clear();
var displaced = Equipment.Equip( itemToEquip );
if ( displaced != null )
Bag.AddItem( displaced, 1 );
PushBagSync();
PushEquipSync();
BroadcastStatsChanged();
}
/// <summary>
/// Unequip the item in the given equipment slot and move it back to the bag.
/// </summary>
public void UnequipToBag( EquipSlot slotType )
{
if ( !AssertAuthority() ) return;
if ( !UseEquipment || Equipment == null ) return;
var item = Equipment.Unequip( slotType );
if ( item != null )
Bag.AddItem( item, 1 );
PushBagSync();
PushEquipSync();
BroadcastStatsChanged();
}
// ─── Equipment Stats ─────────────────────────────────────────
/// <summary>
/// Fired whenever equipped items change and stats need to be recalculated.
/// Subscribe to apply totals to your character (health, attack, etc.).
/// </summary>
public Action<Dictionary<StatType, float>> OnStatsChanged;
/// <summary>
/// Returns the summed value of all stat modifiers across every equipped item.
/// Computed on demand — no sync overhead.
/// Returns 0 if EnableEquipmentStats is false or equipment is disabled.
/// </summary>
public float GetTotalStat( StatType type )
{
if ( !EnableEquipmentStats || !UseEquipment || Equipment == null ) return 0f;
float total = 0f;
foreach ( var kvp in Equipment.AllSlots() )
{
var slot = kvp.Value;
if ( slot.IsEmpty || slot.Item.StatModifiers == null ) continue;
foreach ( var mod in slot.Item.StatModifiers )
if ( mod.Type == type ) total += mod.Value;
}
return total;
}
private void BroadcastStatsChanged()
{
if ( !EnableEquipmentStats || OnStatsChanged == null ) return;
var totals = new Dictionary<StatType, float>();
foreach ( StatType t in Enum.GetValues( typeof( StatType ) ) )
{
float v = GetTotalStat( t );
if ( v != 0f ) totals[t] = v;
}
OnStatsChanged.Invoke( totals );
}
// ─── Hotbar API ──────────────────────────────────────────────
/// <summary>
/// Set the active hotbar slot by index (0 to BagWidth - 1).
/// </summary>
public void SetHotbarActive( int index )
{
if ( !AssertAuthority() ) return;
if ( !UseHotbar ) return;
HotbarActiveIndex = Math.Clamp( index, 0, BagWidth - 1 );
}
/// <summary>
/// Scroll the active hotbar slot. Pass -1 or +1. Wraps around.
/// </summary>
public void ScrollHotbar( int delta )
{
if ( !AssertAuthority() ) return;
if ( !UseHotbar ) return;
HotbarActiveIndex = ( HotbarActiveIndex + delta % BagWidth + BagWidth ) % BagWidth;
}
// ─── Use / Drop / Destroy API ─────────────────────────────────
/// <summary>
/// Fired when an item is used (context menu "Use" action).
/// Subscribe to implement healing, ammo, key items, etc.
/// </summary>
public Action<InventoryItem, int> OnItemUsed;
/// <summary>
/// Use an item from the bag.
/// </summary>
public void UseItem( int bagIndex )
{
if ( !AssertAuthority() ) return;
var slot = Bag.GetSlot( bagIndex );
if ( slot.IsEmpty || !slot.Item.IsUsable ) return;
int qty = slot.Quantity;
OnItemUsed?.Invoke( slot.Item, qty );
if ( OnItemUsed == null )
Log.Warning( $"UseItem: {slot.Item.ItemName} has IsUsable=true but no OnItemUsed handler is assigned." );
}
/// <summary>
/// Use an equipped item.
/// </summary>
public void UseEquipItem( EquipSlot slotType )
{
if ( !AssertAuthority() ) return;
if ( !UseEquipment || Equipment == null ) return;
var slot = Equipment.GetSlot( slotType );
if ( slot == null || slot.IsEmpty || !slot.Item.IsUsable ) return;
OnItemUsed?.Invoke( slot.Item, 1 );
if ( OnItemUsed == null )
Log.Warning( $"UseEquipItem: {slot.Item.ItemName} has IsUsable=true but no OnItemUsed handler is assigned." );
}
/// <summary>
/// Fired when an item is dropped into the world. The default behavior
/// spawns a world pickup — assign your own handler to customize this.
/// </summary>
public Action<InventoryItem, int, Vector3> OnItemDropped;
/// <summary>
/// Remove an item from the bag and spawn it in the world.
/// </summary>
public void DropItem( int bagIndex, int quantity = 1 )
{
if ( !AssertAuthority() ) return;
var slot = Bag.GetSlot( bagIndex );
if ( slot.IsEmpty ) return;
var item = slot.Item;
int toDrop = Math.Min( quantity, slot.Quantity );
slot.Remove( toDrop );
PushBagSync();
var dropPos = DropPosition();
OnItemDropped?.Invoke( item, toDrop, dropPos );
if ( OnItemDropped == null )
SpawnDropPickup( item, toDrop, dropPos );
}
/// <summary>
/// Remove an equipped item and spawn it in the world.
/// </summary>
public void DropEquipItem( EquipSlot slotType, int quantity = 1 )
{
if ( !AssertAuthority() ) return;
if ( !UseEquipment || Equipment == null ) return;
var slot = Equipment.GetSlot( slotType );
if ( slot == null || slot.IsEmpty ) return;
var item = slot.Item;
int toDrop = Math.Min( quantity, slot.Quantity );
slot.Remove( toDrop );
PushEquipSync();
var dropPos = DropPosition();
OnItemDropped?.Invoke( item, toDrop, dropPos );
if ( OnItemDropped == null )
SpawnDropPickup( item, toDrop, dropPos );
}
/// <summary>
/// Remove an item from the bag without spawning it in the world.
/// </summary>
public void DestroyItem( int bagIndex, int quantity = 1 )
{
if ( !AssertAuthority() ) return;
var slot = Bag.GetSlot( bagIndex );
if ( slot.IsEmpty ) return;
slot.Remove( Math.Min( quantity, slot.Quantity ) );
PushBagSync();
}
/// <summary>
/// Remove an equipped item without spawning it in the world.
/// </summary>
public void DestroyEquipItem( EquipSlot slotType, int quantity = 1 )
{
if ( !AssertAuthority() ) return;
if ( !UseEquipment || Equipment == null ) return;
var slot = Equipment.GetSlot( slotType );
if ( slot == null || slot.IsEmpty ) return;
slot.Remove( Math.Min( quantity, slot.Quantity ) );
PushEquipSync();
}
private Vector3 DropPosition()
{
var cam = Scene.Camera;
var rot = cam?.Transform.Rotation ?? Transform.Rotation;
return Transform.Position + rot.Forward * 100f + Vector3.Up * 50f;
}
private void SpawnDropPickup( InventoryItem item, int quantity, Vector3 position )
{
var groundPos = DropToGround( position );
if ( item.WorldPrefab != null )
{
var go = item.WorldPrefab.Clone();
go.Transform.Position = groundPos;
go.Name = $"Drop_{item.ItemName}";
var existingPickup = go.Components.Get<ItemPickup>();
if ( existingPickup != null )
{
existingPickup.Item = item;
existingPickup.Quantity = quantity;
}
else
{
var pickup = go.Components.Create<ItemPickup>();
pickup.Item = item;
pickup.Quantity = quantity;
pickup.Mode = PickupMode.Proximity;
pickup.PickupRadius = 64f;
}
var prop = go.Components.Get<Prop>();
if ( prop != null )
{
prop.IsStatic = false;
}
else
{
var rb = go.Components.GetOrCreate<Rigidbody>();
rb.Gravity = true;
rb.MotionEnabled = true;
}
}
else
{
var go = new GameObject( true, $"Drop_{item.ItemName}" );
go.Transform.Position = groundPos;
var prop = go.Components.Create<Prop>();
prop.Model = Model.Load( "models/dev/box.vmdl" );
var pickup = go.Components.Create<ItemPickup>();
pickup.Item = item;
pickup.Quantity = quantity;
pickup.Mode = PickupMode.Proximity;
pickup.PickupRadius = 64f;
}
}
private Vector3 DropToGround( Vector3 position )
{
var tr = Scene.Trace
.Ray( position + Vector3.Up * 50f, position + Vector3.Down * 1000f )
.Run();
if ( tr.Hit )
return tr.EndPosition + Vector3.Up * 5f;
return position;
}
// ─── Crafting API ────────────────────────────────────────────
/// <summary>
/// Returns true if the bag contains all ingredients for the given quantity.
/// Always returns false if EnableCrafting is off.
/// </summary>
public bool CanCraft( CraftingRecipe recipe, int quantity = 1 )
{
if ( !EnableCrafting || recipe == null || recipe.Ingredients == null ) return false;
foreach ( var ing in recipe.Ingredients )
{
if ( ing.Item == null ) continue;
if ( !Bag.HasItem( ing.Item, ing.Amount * quantity ) ) return false;
}
return true;
}
/// <summary>
/// Returns how many times this recipe can be crafted with the current bag contents.
/// Returns 0 if EnableCrafting is off or ingredients are missing.
/// </summary>
public int GetMaxCraftable( CraftingRecipe recipe )
{
if ( !EnableCrafting || recipe == null || recipe.Ingredients == null ) return 0;
int max = int.MaxValue;
foreach ( var ing in recipe.Ingredients )
{
if ( ing.Item == null || ing.Amount <= 0 ) continue;
int canMake = Bag.CountItem( ing.Item ) / ing.Amount;
if ( canMake < max ) max = canMake;
}
return max == int.MaxValue ? 0 : max;
}
/// <summary>
/// Removes one batch of ingredients for the recipe from the bag.
/// Returns false if ingredients are not available.
/// </summary>
public bool ConsumeCraftIngredients( CraftingRecipe recipe )
{
if ( !AssertAuthority() ) return false;
if ( !CanCraft( recipe, 1 ) ) return false;
foreach ( var ing in recipe.Ingredients )
{
if ( ing.Item == null ) continue;
Bag.RemoveItem( ing.Item, ing.Amount );
}
PushBagSync();
return true;
}
/// <summary>
/// Adds the recipe's result to the bag. Call this after the craft timer completes.
/// </summary>
public void GiveCraftResult( CraftingRecipe recipe )
{
if ( !AssertAuthority() ) return;
if ( recipe?.ResultItem == null ) return;
GiveItem( recipe.ResultItem, recipe.ResultQuantity );
}
// ─── Internal ────────────────────────────────────────────────
private void PushBagSync()
{
_bagData = Bag.Serialize();
OnInventoryChanged?.Invoke();
}
private void PushEquipSync()
{
if ( !UseEquipment || Equipment == null ) return;
_equipData = Equipment.Serialize();
OnInventoryChanged?.Invoke();
}
private void RebuildFromSyncData()
{
Bag?.Deserialize( _bagData );
if ( UseEquipment && Equipment != null )
Equipment.Deserialize( _equipData );
}
private bool AssertAuthority()
{
if ( !IsProxy ) return true;
Log.Warning( "InventoryComponent: Cannot modify inventory from a proxy. Use an RPC or run this on the owning client." );
return false;
}
}