Inventory and Shop System

This project is a showcase of a dynamic and adaptable Inventory and Shop System.

As a side feature, I added a saving system, which saves the inventories of the:
- Player
- Chests
- Shops


The Inventory system is inspired by Minecraft, it lets you, pick up, place, and split item slots.

The shop system is similar to shops in Skyrim, but can be customized to fit any style.
- Choose what items to be sold and the quantity of availabe items.
- Set how much gold shop keepers have.
- Set if items have a markup.


Both systems are made to be very flexible and easily adapted into a different style.

                    
[Serializable]
public class InventorySystem
{
    [SerializeField] private List<InventorySlot> _inventorySlots;
    private int _gold;

    
    #region Properties

    public List<InventorySlot> InventorySlots => _inventorySlots;

    public int InventorySize => _inventorySlots.Count;

    public int Gold => _gold;

    #endregion

    // event for changes in inventory slot
    public UnityAction<InventorySlot> OnInventorySlotChanged;


    // Constuctor
    public InventorySystem(int inventorySize)
    {
        _gold = 0;
        CreateInventory(inventorySize);
    }

    public InventorySystem(int inventorySize, int gold)
    {
        _gold = gold;
        CreateInventory(inventorySize);
    }

    private void CreateInventory(int inventorySize)
    {
        _inventorySlots = new List<InventorySlot>(inventorySize);

        for (int i = 0; i < inventorySize; i++)
        {
            _inventorySlots.Add(new InventorySlot());
        }
    }



    public bool AddItemToInventory(InventoryItemData inventoryItemData, int amount)
    {
        // item exist in inventory - add amount to item stack
        if (ContainsItem(inventoryItemData, out List<InventorySlot> inventorySlots))
        {
            // check all inventory slots with item - add to first available slot
            foreach (InventorySlot inventorySlot in inventorySlots)
            {
                if (inventorySlot.HasAvailableAmountInStack(amount))
                {
                    inventorySlot.AddToStack(amount);
                    OnInventorySlotChanged?.Invoke(inventorySlot);
                    return true;
                }
            }
        }
        
        // item doesn't exist in inventory - has available slot exist in inventory - add item(s) to first available inventory slot
        if (HasAvailableInventorySlot(out InventorySlot availableInventorySlot))
        {
            if (availableInventorySlot.HasAvailableAmountInStack(amount)) // available inventory slot has available amount
            {
                availableInventorySlot.UpdateInventorySlot(inventoryItemData, amount);
                OnInventorySlotChanged?.Invoke(availableInventorySlot);
                return true;
            }
        }

        return false;
    }

    // inventory has item - return all inventory slots with items
    public bool ContainsItem(InventoryItemData inventoryItemData, out List<InventorySlot> inventorySlots)
    {
        inventorySlots = _inventorySlots.Where(slot => slot.InventoryItemData == inventoryItemData).ToList();

        if (inventorySlots.Count > 1) return true;
        
        return false;
    }

    public bool HasAvailableInventorySlot(out InventorySlot availableInventorySlot)
    {
        // get first available inventory slot
        availableInventorySlot = _inventorySlots.FirstOrDefault(slot => slot.InventoryItemData == null);
        
        if (availableInventorySlot != null) return true;

        return false;
    }


    public bool HasAvailableInventorySlots(Dictionary<InventoryItemData, int> shoppingCart)
    {
        var tempInventorySystem = new InventorySystem(InventorySize);

        // clone current inventory
        for (int i = 0; i < InventorySize; i++)
        {
            tempInventorySystem.InventorySlots[i].AssignItemData(InventorySlots[i].InventoryItemData, InventorySlots[i].StackSize);
        }

        foreach (var kvp in shoppingCart)
        {
            for (int i = 0; i < kvp.Value; i++)
            {
                if (!tempInventorySystem.AddItemToInventory(kvp.Key, 1)) return false;
            }
        }

        return true;
    }

    public void UseGold(int priceTotal)
    {
        _gold -= priceTotal;
    }
    public void GetGold(int finalPrice)
    {
        _gold += finalPrice;
    }

    public Dictionary<InventoryItemData, int> GetAllInventoryItems()
    {
        // collection of specific item
        Dictionary<InventoryItemData, int> distinctItems = new Dictionary<InventoryItemData, int>();

        foreach (var slot in _inventorySlots)
        {
            if (slot.InventoryItemData == null) continue;

            if (!distinctItems.ContainsKey(slot.InventoryItemData))
            {
                distinctItems.Add(slot.InventoryItemData, slot.StackSize);
            }
            else
            {
                distinctItems[slot.InventoryItemData] += slot.StackSize;
            }
        }

        return distinctItems;
    }

    

    public void RemoveItemsFromInventory(InventoryItemData inventoryItemData, int amount)
    {
        if (ContainsItem(inventoryItemData, out List<InventorySlot> inventorySlots))
        {
            foreach(var slot in inventorySlots)
            {
                int stackSize = slot.StackSize;

                if (stackSize > amount)
                {
                    slot.RemoveFromStack(amount);
                }
                else
                {
                    slot.RemoveFromStack(stackSize);
                    amount -= stackSize;
                }

                OnInventorySlotChanged?.Invoke(slot);
            }
        }
    }
}

                    
                

INVENTORY ITEM SLOT

The InventoryItemSlot class is a base class. It is used for individual slots, and handles assigning and stacking items in a slot.

Adding to slot:
- A slot containing the same item will add the new item to the stack.
- A slot with a different item will swap them, adding the new item to the slot.

It also tracks the item type, it's stack size and ID.

                    
[Serializable]
public class InventorySlot : InventoryItemSlot
{
    //Constructors
    public InventorySlot(InventoryItemData inventoryItemData, int amount)
    {
        _inventoryItemData = inventoryItemData;
        _itemID = _inventoryItemData.ID;
        _stackSize = amount;
    }

    public InventorySlot()
    {
        ClearInventorySlot();
    }


    // update inventory slot with new values
    public void UpdateInventorySlot(InventoryItemData inventoryItemData, int amount)
    {
        _inventoryItemData = inventoryItemData;
        _itemID = _inventoryItemData.ID;
        _stackSize = amount;
    }


    // can add amount to stacksize - return amount left in stack
    public bool HasAvailableAmountInStack(int amount, out int availableAmount)
    {
        availableAmount = InventoryItemData.MaxStackSize - _stackSize;
        return HasAvailableAmountInStack(amount);
    }

    // can add amount to stacksize
    public bool HasAvailableAmountInStack(int amount)
    {
        if (_inventoryItemData == null || _inventoryItemData != null && _stackSize + amount <= _inventoryItemData.MaxStackSize) return true;
        return false;
    }


    // splits item stack in 2
    public bool SplitItemStack(out InventorySlot splitInventorySlot)
    {
        if (_stackSize <= 1)
        {
            splitInventorySlot = null;
            return false;
        }

        int halfStackSize = Mathf.RoundToInt(_stackSize / 2);
        RemoveFromStack(halfStackSize);

        splitInventorySlot = new InventorySlot(_inventoryItemData, halfStackSize);
        return true;
    }
}

                    
                

INVENTORY SLOT UI

The InventorySlotUI class handles the visual side of an inventory slot.

Display - Shows the item sprite and stack size in the UI.
Update - When the values of a slot changes the UI side is updated. It can also be refreshed without changing values, ensuring the UI is synchronized.
OnClick - Clicking a slot will trigger callbacks in the InventoryDisplay class.

                    
public abstract class InventoryDisplay : MonoBehaviour
{
    [SerializeField] SelectedItemData _selectedInventoryItem;

    protected InventorySystem _inventorySystem;

    protected Dictionary<InventorySlotUI, InventorySlot> _inventorySlotDictionary;


    #region Properties

    public InventorySystem InventorySystem => _inventorySystem;

    public Dictionary<InventorySlotUI, InventorySlot> InventorySlotDictionary => _inventorySlotDictionary;

    #endregion


    protected virtual void Start()
    {

    }

    public abstract void AssignInventorySlot(InventorySystem inventorySystem, int offset);

    public virtual void UpdateInventorySlot(InventorySlot updatedInventorySlot)
    {
        foreach(var inventorySlot in _inventorySlotDictionary)
        {
            // if value of inventory slots match - update UI inventory slot with new value
            if (inventorySlot.Value == updatedInventorySlot) 
            {
                inventorySlot.Key.UpdateInventorySlotUI(updatedInventorySlot);
            }
        }
    }

    public void InventorySlotClicked(InventorySlotUI clickedInventorySlotUI)
    {
        // inventory slot has item - player has NO selected item - select item
        if (clickedInventorySlotUI.AssignedInventorySlot.InventoryItemData != null && _selectedInventoryItem.InventorySlot.InventoryItemData == null)
        {
            // player hold shift key - split item stack
            bool isShiftPressed = Keyboard.current.leftShiftKey.isPressed;
            if (isShiftPressed && clickedInventorySlotUI.AssignedInventorySlot.SplitItemStack(out InventorySlot splitInventorySlot))
            {
                _selectedInventoryItem.UpdateSelectedInventorySlot(splitInventorySlot);
                clickedInventorySlotUI.UpdateInventorySlotUI();
                return;
            }
            else
            {
                _selectedInventoryItem.UpdateSelectedInventorySlot(clickedInventorySlotUI.AssignedInventorySlot);
                clickedInventorySlotUI.ClearInventorySlotUI();
                return;
            }
        }


        // inventory slot has NO item - player has selected item - assign item
        if (clickedInventorySlotUI.AssignedInventorySlot.InventoryItemData == null && _selectedInventoryItem.InventorySlot.InventoryItemData != null)
        {
            clickedInventorySlotUI.AssignedInventorySlot.AssignItemData(_selectedInventoryItem.InventorySlot);
            clickedInventorySlotUI.UpdateInventorySlotUI();
            _selectedInventoryItem.ClearInventorySlot();
            return;
        }


        // inventory slot has item - player has selected item - assign / combine / swap
        if (clickedInventorySlotUI.AssignedInventorySlot.InventoryItemData != null && _selectedInventoryItem.InventorySlot.InventoryItemData != null)
        {
            // not the same item - swap with eachother
            if (clickedInventorySlotUI.AssignedInventorySlot.InventoryItemData != _selectedInventoryItem.InventorySlot.InventoryItemData)
            {
                SwapInventorySlots(clickedInventorySlotUI);
                return;
            }

            // same item - combine
            if (clickedInventorySlotUI.AssignedInventorySlot.InventoryItemData == _selectedInventoryItem.InventorySlot.InventoryItemData)
            {
                // has available amount in clicked inventory slot - no overflow
                if (clickedInventorySlotUI.AssignedInventorySlot.HasAvailableAmountInStack(_selectedInventoryItem.InventorySlot.StackSize))
                {
                    clickedInventorySlotUI.AssignedInventorySlot.AssignItemData(_selectedInventoryItem.InventorySlot); // combine inventory slots in to clicked inventory slot
                    clickedInventorySlotUI.UpdateInventorySlotUI(); // update inventory slot UI
                    _selectedInventoryItem.ClearInventorySlot(); // clear inventory slot
                    return;
                }
                // had not enought available amount - overflow
                else if (!clickedInventorySlotUI.AssignedInventorySlot.HasAvailableAmountInStack(_selectedInventoryItem.InventorySlot.StackSize, out int availableAmount))
                {
                    if (availableAmount < 1) // stack size if full - can Not combine - swap items
                    {
                        SwapInventorySlots(clickedInventorySlotUI);
                        return;
                    }
                    // add until max stack size - remove added amount from current selected item
                    else
                    {
                        clickedInventorySlotUI.AssignedInventorySlot.AddToStack(availableAmount); // add available amount to clicked inventory slot
                        clickedInventorySlotUI.UpdateInventorySlotUI(); // update clicked inventory slot

                        int newAmountOnSelectedInventoryItem = _selectedInventoryItem.InventorySlot.StackSize - availableAmount; // create new size for current selected item

                        InventorySlot newSelectedInventoryItem = new InventorySlot(_selectedInventoryItem.InventorySlot.InventoryItemData, newAmountOnSelectedInventoryItem); // create new selected inventory item with new values
                        _selectedInventoryItem.ClearInventorySlot(); // clear selected item
                        _selectedInventoryItem.UpdateSelectedInventorySlot(newSelectedInventoryItem); // update selected item with new selected inventory item
                        return;
                    }
                }
            }

        }


    }

    private void SwapInventorySlots(InventorySlotUI clickedInventorySlotUI)
    {
        // new temporary(cloned) inventory slot
       InventorySlot tempInventorySlot = new InventorySlot(_selectedInventoryItem.InventorySlot.InventoryItemData, _selectedInventoryItem.InventorySlot.StackSize);
       
        _selectedInventoryItem.ClearInventorySlot(); // clear inventory slot
        _selectedInventoryItem.UpdateSelectedInventorySlot(clickedInventorySlotUI.AssignedInventorySlot); // update inventory slot with new values
        

        clickedInventorySlotUI.ClearInventorySlotUI(); // clear inventory slot UI
        clickedInventorySlotUI.AssignedInventorySlot.AssignItemData(tempInventorySlot); // assing inventory slot UI with cloned values
        clickedInventorySlotUI.UpdateInventorySlotUI(); // update inventory slot UI
    }

}

                    
                

SHOP SYSTEM

The ShopSystem class handles shop items and slots, as well as transactions.

Slots - Maintains a list of shop slots, inherited from the InventoryItemSlot class. tracks available slots, and places new items in appropriate slots.
Items - Checks if an item exists in stock. Add, remove, or merge items based on the action.
Transactions - Updates gold and inventory accordingly. Supports buying and selling with price markups.

                    
public class ShopKeeperDisplay : MonoBehaviour
{
    [SerializeField] private ShopInventorySlotUI _shopSlotInventoryPrefab;
    [SerializeField] private ShoppingCartItemUI _shoppingCartItemPrefab;


    [SerializeField] private Button _buyTabButton;
    [SerializeField] private Button _sellTabButton;

    [Header("Shopping Cart:")]

    [SerializeField] private TextMeshProUGUI _priceTotalText;
    [SerializeField] private TextMeshProUGUI _playerGoldText;
    [SerializeField] private TextMeshProUGUI _shopGoldText;

    [Space(10)]

    [SerializeField] private Button _buySellButton;
    [SerializeField] private TextMeshProUGUI _buySellButtonText;


    [Header("Item Preview Section:")]

    [SerializeField] private Image _itemPreviewSprite;
    [SerializeField] private TextMeshProUGUI _itemPreviewName;
    [SerializeField] private TextMeshProUGUI _itemPreviewDescription;

    [SerializeField] private GameObject _itemListContentPanel;
    [SerializeField] private GameObject _shoppingCartContentPanel;


    private ShopSystem _shopSystem;
    private PlayerInventory _playerInventory;

    private Dictionary<InventoryItemData, int> _shoppingCart = new Dictionary<InventoryItemData, int>();
    private Dictionary<InventoryItemData, ShoppingCartItemUI> _shoppingCartUI = new Dictionary<InventoryItemData, ShoppingCartItemUI>();

    private int _priceTotal;
    private bool _isSelling;

    public void DisplayShopWindow(ShopSystem shopSystem, PlayerInventory playerInventory)
    {
        _shopSystem = shopSystem;
        _playerInventory = playerInventory;

        RefreshShopDisplay();
    }

    private void RefreshShopDisplay()
    {
        if (_buySellButton != null)
        {
            _buySellButtonText.text = _isSelling ? "Sell" : "Buy";
            _buySellButton.onClick?.RemoveAllListeners();
            
            if (_isSelling)
            {
                _buySellButton.onClick.AddListener(SellItems);
            }
            else
            {
                _buySellButton.onClick.AddListener(BuyItems);
            }
        }

        ClearShopInventorySlots();
        ClearItemPreview();

        _priceTotalText.enabled = false;
        _buySellButton.gameObject.SetActive(false);
        _priceTotal = 0;
        _playerGoldText.text = $"Player Gold: {_playerInventory.PrimaryInventorySystem.Gold}G";
        _shopGoldText.text = $"Shop Gold: {_shopSystem.AvailableGold}G";

        if (_isSelling) DisplayPlayerInventory();
        else DisplayShopInventory();

    }

    private void BuyItems()
    {
        if (_playerInventory.PrimaryInventorySystem.Gold < _priceTotal) return;
        if (!_playerInventory.PrimaryInventorySystem.HasAvailableInventorySlots(_shoppingCart)) return;

        foreach (var kvp in _shoppingCart)
        {
            _shopSystem.BuyItem(kvp.Key, kvp.Value);

            for (int i = 0; i < kvp.Value; i++)
            {
                _playerInventory.PrimaryInventorySystem.AddItemToInventory(kvp.Key, 1);
            }
        }

        _shopSystem.GetGold(_priceTotal);
        _playerInventory.PrimaryInventorySystem.UseGold(_priceTotal);

        RefreshShopDisplay();
    }

    private void SellItems()
    {
        if (_shopSystem.AvailableGold < _priceTotal) return;

        foreach (var kvp in _shoppingCart)
        {
            int finalPrice = GetModifiedPrice(kvp.Key, kvp.Value, _shopSystem.SellMarkUp);

            _shopSystem.SellItem(kvp.Key, kvp.Value, finalPrice);

            _playerInventory.PrimaryInventorySystem.GetGold(finalPrice);
            _playerInventory.PrimaryInventorySystem.RemoveItemsFromInventory(kvp.Key, kvp.Value);
        }

        RefreshShopDisplay();
    }
    

    private void ClearShopInventorySlots()
    {
        _shoppingCart = new Dictionary<InventoryItemData, int>();
        _shoppingCartUI = new Dictionary<InventoryItemData, ShoppingCartItemUI>();

        foreach (var item in _itemListContentPanel.transform.Cast<Transform>())
        {
            Destroy(item.gameObject);
        }

        foreach (var item in _shoppingCartContentPanel.transform.Cast<Transform>())
        {
            Destroy(item.gameObject);
        }
    }

    private void DisplayShopInventory()
    {
        foreach (var slot in _shopSystem.ShopInventory)
        {
            if (slot.InventoryItemData == null)
            {
                continue;
            }

            ShopInventorySlotUI shopInventorySlotUI = Instantiate(_shopSlotInventoryPrefab, _itemListContentPanel.transform);

            shopInventorySlotUI.Initialize(slot, _shopSystem.BuyMarkUp);
        }
    }

    private void DisplayPlayerInventory()
    {
        foreach (var item in _playerInventory.PrimaryInventorySystem.GetAllInventoryItems())
        {
            ShopInventorySlot tempShopInventorySlot = new ShopInventorySlot();

            tempShopInventorySlot.AssignItemData(item.Key, item.Value);

            ShopInventorySlotUI shopInventorySlotUI = Instantiate(_shopSlotInventoryPrefab, _itemListContentPanel.transform);
            shopInventorySlotUI.Initialize(tempShopInventorySlot, _shopSystem.SellMarkUp);
        }
    }

    public void AddShopItemToCart(ShopInventorySlotUI shopInventorySlotUI)
    {
        InventoryItemData inventoryItemData = shopInventorySlotUI.AssignedShopInventorySlot.InventoryItemData;

        UpdateItemPreview(shopInventorySlotUI);

        int finalPrice = GetModifiedPrice(inventoryItemData, 1, shopInventorySlotUI.Markup);

        // item already in cart - increase amount
        if (_shoppingCart.ContainsKey(inventoryItemData))
        {
            _shoppingCart[inventoryItemData]++;
            string previewItemData = $"{inventoryItemData.Name} - {finalPrice}G - x{_shoppingCart[inventoryItemData]}";
            _shoppingCartUI[inventoryItemData].SetItemPreviewText(previewItemData);
        }
        // item not in cart - add item to cart
        else
        {
            _shoppingCart.Add(inventoryItemData, 1);

            ShoppingCartItemUI shoppingCartItemUI = Instantiate(_shoppingCartItemPrefab, _shoppingCartContentPanel.transform);
            string previewItemData = $"{inventoryItemData.Name} - {finalPrice}G - x1";

            shoppingCartItemUI.SetItemPreviewText(previewItemData);
            _shoppingCartUI.Add(inventoryItemData, shoppingCartItemUI);        
        }

        // set total price of all items
        _priceTotal += finalPrice;
        _priceTotalText.text = $"Total: {_priceTotal}G";

        if (_priceTotal > 0 && !_priceTotalText.IsActive())
        {
            _priceTotalText.enabled = true;
            _buySellButton.gameObject.SetActive(true);
        }

        HasAvailableGold();
    }

    public void RemoveShopItemFromCart(ShopInventorySlotUI shopInventorySlotUI)
    {
        InventoryItemData inventoryItemData = shopInventorySlotUI.AssignedShopInventorySlot.InventoryItemData;

        int finalPrice = GetModifiedPrice(inventoryItemData, 1, shopInventorySlotUI.Markup);

        // item already in cart - decrease amount
        if (_shoppingCart.ContainsKey(inventoryItemData))
        {
            _shoppingCart[inventoryItemData]--;
            string previewItemData = $"{inventoryItemData.Name} - {finalPrice}G - x{_shoppingCart[inventoryItemData]}";
            _shoppingCartUI[inventoryItemData].SetItemPreviewText(previewItemData);

            if (_shoppingCart[inventoryItemData] <= 0)
            {
                _shoppingCart.Remove(inventoryItemData);
                GameObject shoppingCartItemUI = _shoppingCartUI[inventoryItemData].gameObject;
                _shoppingCartUI.Remove(inventoryItemData);
                Destroy(shoppingCartItemUI);
            }
        }

        _priceTotal -= finalPrice;
        _priceTotalText.text = $"Total: {_priceTotal}G";

        if (_priceTotal <= 0 &&  _priceTotalText.IsActive())
        {
            _priceTotalText.enabled = false;
            _buySellButton.gameObject.SetActive(false);
            ClearItemPreview();
            return;
        }

        HasAvailableGold();
    }


    private void UpdateItemPreview(ShopInventorySlotUI shopInventorySlotUI)
    {
        InventoryItemData inventoryItemData = shopInventorySlotUI.AssignedShopInventorySlot.InventoryItemData;

        _itemPreviewSprite.sprite = inventoryItemData.Sprite;
        _itemPreviewSprite.color = Color.white;
        _itemPreviewName.text = inventoryItemData.Name;
        _itemPreviewDescription.text = inventoryItemData.Description;
    }

    private void ClearItemPreview()
    {
        _itemPreviewSprite.sprite = null;
        _itemPreviewSprite.color = Color.clear;
        _itemPreviewName.text = "";
        _itemPreviewDescription.text = "";
    }

    public static int GetModifiedPrice(InventoryItemData inventoryItemData, int amount, float markup)
    {
        int basePrice = inventoryItemData.GoldValue * amount;

        return Mathf.FloorToInt(basePrice + basePrice * markup);
    }
    private void HasAvailableGold()
    {
        int availableGold = _isSelling ? _shopSystem.AvailableGold : _playerInventory.PrimaryInventorySystem.Gold;

        _priceTotalText.color = _priceTotal > availableGold ? Color.red : Color.white;

        if (_isSelling || _playerInventory.PrimaryInventorySystem.HasAvailableInventorySlots(_shoppingCart)) return;

        _priceTotalText.text = "Not enough space in Inventory!";
        _priceTotalText.color = Color.red;

    }


    public void OnBuyTabPressed()
    {
        _isSelling = false;
        RefreshShopDisplay();
    }

    public void OnSellTabPressed()
    {
        _isSelling = true;
        RefreshShopDisplay();
    }

}

                    
                

SAVE SYSTEM

The SaveLoad class handles saving, loading, and deleting game data.
The game data is saved in a JSON format.
Loading the data triggeres appropriate callbacks.