Simple Inventory System Using Scriptable Objects

Hey all! I just wanted to show off the inventory system that I’ve been working on for the past week now. I would really like it if you could give me any feedback, or suggest any feature that would make it even better. But anyways, here’s how it works.

First, I created a Scriptable Item Item:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

[CreateAssetMenu]
public class Item : ScriptableObject
{
    public string ItemName;
    public enum ItemRarity {Common, Epic, Legendary, GodTier};
    public ItemRarity itemRarity;
    public float Damage;
    public float Range;
    public float NumberOfBulletsAbleToBeHeld;
    public Sprite ItemIcon;
    public GameObject gameObject;
   
}

Then I created another script referencing this. (To put on other game objects)

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class PhysicalItem : MonoBehaviour
{
    public Item item;

}

I attached an image of my Canvas hierarchy(which is important because the code for organizing the inventory kind of relies on knowing where the children of each slot reside and their order on the hierarchy).

I also created a prefab Slot, which consists of an image and text box (which will be for displaying ammo when I get around to implementing it). This prefab will be instantiated on the panel element that the player has selected and the image will change based on the item the player picks up. I also attached the PhysicalItem script to the slot prefab so we can always know which gun belongs to which slot.

Anyways, the code for the inventory is below:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
using UnityEngine.UI;
public class Inventory : MonoBehaviour
{
    public float numberOfSlotSelected = 1;
    public Transform selectedPanel;
    Transform availablePanel;
    public GameObject slot;
    public List<Item> inventory;
    public Image select;
    public TMP_Text selectedGunText;
    public List<Transform> availableSlots;
    public List<Transform> possibleSlots;
    public List<Transform> closestAvailablePanel;
   
   
    // Start is called before the first frame update
    void Start()
    {

        numberOfSlotSelected = 1;
        inventory = new List<Item>();
        availableSlots = new List<Transform>();
        closestAvailablePanel = new List<Transform>();

       int i = transform.childCount;
      
        while(i > 0)
        {
            if(transform.GetChild(i - 1).childCount == 0)
            {
                possibleSlots.Add(transform.GetChild(i - 1)); //becuase child index starts at 0
            }
            i--;
          
        }
    }

    // Update is called once per frame
    void Update()
    {
      
     

        int numberOfSlotSelected_Int = (int)numberOfSlotSelected;
        numberOfSlotSelected += Input.mouseScrollDelta.y;
       
       
        selectedPanel = transform.GetChild(numberOfSlotSelected_Int - 1); //We use -1 because unity starts egtting children at 0. So the first child would be child 0 not child 1
       

        if (numberOfSlotSelected > 5)
        {
            numberOfSlotSelected = 1;
        }
      
        if (numberOfSlotSelected < 1)
        {
            numberOfSlotSelected = 5;
        }

//=======================================================================================================================================================================================//
        //DETERMINE WHICH PANEL IS OPEN


        closestAvailablePanel.Clear();
            foreach (Transform available in availableSlots)
            {

                if (Vector3.Distance(selectedPanel.position, available.position) <= 100 && available.childCount == 0)
                {
                    closestAvailablePanel.Add(available);

                }

            }

            if (selectedPanel.childCount == 0)
            {
                availablePanel = selectedPanel;
            }

            if (selectedPanel.childCount >= 1)
            {
                if (closestAvailablePanel.Count >= 1)
                {
                    availablePanel = closestAvailablePanel[0];

                }
               
                //THIS IS BASICALLY FOR IF YOU KEEP YOUR CURSOR ON THE FIRST SLOT THE WHOLE TIME WHILE PICKING UP ITEMS
                if(availableSlots.Count > 0 && selectedPanel != possibleSlots[0])
                {
                    if (transform.GetChild(numberOfSlotSelected_Int).childCount >= 1)
                    {
                        closestAvailablePanel.Clear();
                        foreach (Transform available in availableSlots)
                        {
                            availablePanel = availableSlots[availableSlots.Count - 1];
                        }
                    }
                }
                //THIS IS BASICALLY FOR IF YOU KEEP YOUR CURSOR ON THE LAST SLOT THE WHOLE TIME WHILE PICKING UP ITEMS
                if(availableSlots.Count > 0 && selectedPanel == possibleSlots[0])
                {
                    if (transform.GetChild(numberOfSlotSelected_Int - 2).childCount >= 1)
                    {
                        closestAvailablePanel.Clear();
                        foreach (Transform available in availableSlots)
                        {
                            availablePanel = availableSlots[availableSlots.Count - 1];
                        }
                    }
                }


            }

            //THIS IS FOR WHEN THERE IS ONLY ONE SLOT LEFT
            if (availableSlots.Count == 1)
            {
                availablePanel = availableSlots[0];
            }

//=======================================================================================================================================================================================//      
       
        select.transform.position = selectedPanel.position;
       

        //SHOW SELECTED ITEM'S NAME
        if (selectedPanel.childCount >= 1)
        {
            selectedGunText.text = selectedPanel.GetComponentInChildren<PhysicalItem>().item.ItemName;
        }
        else if(selectedPanel.childCount == 0)
        {
            selectedGunText.text = "";
        }
       
       
    }

   
    public void Add(Item pickedUpItem)
    {
       
        GameObject instantiatedSlot = Instantiate(slot, availablePanel.position, Quaternion.identity);

        instantiatedSlot.transform.SetParent(availablePanel);
        instantiatedSlot.transform.GetChild(0).GetComponent<TMP_Text>();
        instantiatedSlot.transform.GetChild(1).GetComponent<Image>().sprite = pickedUpItem.ItemIcon;
        instantiatedSlot.GetComponent<PhysicalItem>().item = pickedUpItem;


        availableSlots.Clear();
        foreach (Transform available in possibleSlots)
        {
            if (available.childCount == 0)
            {
                availableSlots.Add(available);

            }
        }
       

    }

}

And the code for picking up the game objects is in my player controller script:

    void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Item"))
        {
            if(GameObject.Find("Inventory").GetComponent<Inventory>().inventory.Count < 5)
            {
                 GameObject.Find("Inventory").GetComponent<Inventory>().inventory.Add(other.gameObject.GetComponent<PhysicalItem>().item);
                 GameObject.Find("Inventory").GetComponent<Inventory>().Add(other.gameObject.GetComponent<PhysicalItem>().item);
           
                 Destroy(other.gameObject);
            }


        }
    }

Again, I just made this in a week, and currently, I have no way to drop items, but I’m still developing it and wil post updates here if anyone is interested.

Again, I would really appreciate any feedback you might have.

Thanks!

Your item description is moving in the right way, but:

  • You don’t have any sort of “description”.
  • If you plan to localize the game at some point, things might get interesting.
  • You do not denote item type anywhere. Ammo, weapons and armor will be processed quite differently in any game.
  • Rarity probably can be just denoted by a number instead of enum. So when you decide to add one more tier later you wouldn’t need to go through all your code.
  • Probably the best to store item counts as ints. Same likely applies to damage as well.

Regarding your inventory class…

I’m not feeling it.

Inventory stored on an object should be a simple array, list or grid with no visual representation. And you’ll only need grid if you use grid-based system. You start spawning visible “slots” in UI only when you DISPLAY the inventory. In your case, however, an object stores slots as transforms and inventory as a monobehavior. Is it necessary? Naked C# classes would suffice here.

Consider this. A dead enemy, a barrel, a chest, or a storage container will all have their inventories. Do you plan to attach gameobject slots to all of them? What if there’s a hundred containers nearby?

Also read about Model View Controller architecture.

1 Like

I mean looking at it, it’s a simple inventory system. Does it fulfil your requirements? Or does your game require more?

Mind you, I would still definitely have a stronger separation between the data and visuals here. The “Inventory” part of an inventory should just be the part that handles the storage of items; no involvement with visuals or anything else.

Then you just need to provide some hooks that allows any visuals to respond to changed (basically delegates). It becomes a lot easier to modify things without worrying about how other parts of it work.

So pretty much what neginfinity said above. With this kind of stuff, you’ll need to get very used to operating in the pure C# work.

I would denote rarity with a scriptable object, myself. That way, any information about said rarity (text colour, etc), can all be baked into said scriptable object. And adding more rarities is trivial.

What do you mean when you say stronger separation between data and visuals? Wouldn’t an inventory mostly be visual? Then again this is my first successful attempt ever making an inventory system.

A game can run with no visuals and no player controllers, just models and AI controllers in a black box. That’s how some AI trains, by just playing the game against itself over and over. Separating the modeled state of your game from the controllers that stimulate those models and from the view through which the state of the game is rendered can help to keep each of those aspects modular.

No, the visual part of an inventory should only represent the underlying data. An item’s definition is data. An collection of items is just data. But the UI that displays these items and their information is the visuals. Have a clear distinction between the two and it makes things a lot more modular.

Right now your ‘Inventory’ component smashes both data and visuals together, and will become harder to work with as you add in more features.

Thank you! What in your opinion would be the best place to start in making the system less focused on the visuals? What do you suggest I focus on?

A general aproach is to have something like a StateManager. A “single source of truth”. That means all abstracted information about the current inventory is stored by members of that unique instance.
The visualisation is then built separately and only queries from that StateManager. Its members can only be accessed via ReadOnlyList and similar. To modify the state, explicit methods on the StateManager need to called by the UI.

This aproach makes serialization of the inventory very easy. The tricky part is to synchronize this state with the UI at all times.

One aproach that helps with the later issue and one Unity uses internally for its canvas system is to give the content a variable like “dirty”. Content in your case is the StateManager.
Whenever the state is changed via the mentioned methods, it is marked as dirty.

Then there’s an update loop which checks if the state was marked as dirty and then rebuilds the whole inventory before setting dirty to false again.
Only having to do rebuilds instead of shuffling around existing visual elements can make that step far easier to implement and secure against edgecases. However it’s also more performance intense (hence why the Unity canvas system sometimes is occasionally said to be a performance problem; yet careless usage (e.g. fast updating elements on same canvas as everything else) is often an avoidable cause).
For most games you may not need to worry about that and if it becomes an issue you can find a hybrid aproach via object pooling…

It’s not about ‘less focus on the visuals’, it’s about separation. About having a distinction between the data side and visuals side. Both sides are still important, of course.

An inventory object, for example, should just be a kind of ‘collection’ object; ergo, it’s an object that represents a collection of items. It can have methods for adding/removing items (or perhaps, methods to attempt to do so), delegates for when the inventory changes, and properties and methods to find out information about it and about the items inside of it. Though nothing inside the object has anything to do with UI or other in-game visuals.

Then separate to that, is the actual UI to display what’s in an inventory. It can take an inventory object, and use it’s API to display its contents. Any modification of the inventory is handled through the UI, which effectively just calls the inventory object’s API.

In ultra basic code terms:

// item asset that outlines information about an item
public class ItemObject : ScriptableObject { }

// pure C# object to represent a collection of items
[System.Serializable]
public class ItemCollection : IEnumerable<ItemObject>
{
    #region Inspector Fields
   
    [SerializeField]
    private List<ItemObject> _inventoryObjects = new();
   
    #endregion
   
    #region Properties
   
    public int Count => _inventoryObjects.Count;   
   
    public Action<ItemObject> OnItemAdded;
   
    public Action<ItemObject> OnItemRemoved;
   
    #endregion
   
    #region Inventory Methods
   
    public ItemObject GetItem(int index)
    {
        return _inventoryObjects[index];
    }
   
    public bool ContainsItem(ItemObject item)
    {
        return _inventoryObjects.Contains(item);
    }
   
    public bool TryAddItem(ItemObject item)
    {
        bool hasItem = this.ContainsItem(item);
       
        if (hasItem == false)
        {
            _inventoryObjects.Add(item);
            OnItemAdded?.Invoke(item);
        }
       
        return !hasItem;
    }
   
    public bool TryRemoveItem(ItemObject item)
    {
        bool hasItem = this.ContainsItem(item);
       
        if (hasItem == true)
        {
            _inventoryObjects.Remove(item);
            OnItemRemoved.Invoke(item);
        }
       
        return hasItem;
    }
   
    #endregion
   
    #region Interface Methods
   
    public IEnumerator<ItemObject> GetEnumerator()
    {
        return _inventoryObjects.GetEnumerator();
    }
   
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
   
    #endregion
}

// can wrap the item collection in a scriptable object
// for editing in the inspector
public class ItemCollectionObject : ScriptableObject
{
    [SerializeField]
    private ItemCollection _itemCollection = new();
   
    public ItemCollection ItemCollection => _itemCollection;
}

// then a completey separate monobehaviour
// for handling the user interface
public class InventoryUserInterface : Monobehaviour
{
    public void OpenInventory(ItemCollection itemCollection)
    {
        // UI stuff
    }
}

Not going to go into detail about the actual UI code stuff, but you can see how there’s a proper divide here. This could be abstracted further with interfaces, perhaps, if you require a variety of different kinds of container objects.

The UI stuff will of course require a number of different components the handle various parts of the UI, or visual elements if you’re using UI Toolkit.

All in all this starts to get pretty complicated pretty quickly, depending on your requirements.

2 Likes

Thank you so much!

1 Like

Okay! Update:
Note that this is what is needed for my game, and isn’t for super complex inventories
I created a separate script to manage what is inside the inventory and put it on the Player:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Inventory : MonoBehaviour
{
    public List<Item> inventory;
    public static Inventory instance;


     void Awake()
    {
        instance = this;
    }


    void Start()
    {
        inventory = new List<Item>();
    }

}

Then I created a separate script called InventoryUiController, where I placed most of the previous code:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class InventoryUiController : MonoBehaviour
{
    public float numberOfSlotSelected = 1;

    public Transform selectedPanel;
    Transform availablePanel;

    public GameObject slot;
    public Transform gunSpawnPoint;
    GameObject heldGun;


    public Image select;
    public TMP_Text selectedGunText;

    public Item selectedItem;

    public List<Transform> availableSlots;
    public List<Transform> possibleSlots;
    public List<Transform> closestAvailablePanel;



    // Start is called before the first frame update
    void Start()
    {

     
        numberOfSlotSelected = 1;
        availableSlots = new List<Transform>();
        closestAvailablePanel = new List<Transform>();

        int i = transform.childCount;

        while (i > 0)
        {
            if (transform.GetChild(i - 1).childCount == 0)
            {
                possibleSlots.Add(transform.GetChild(i - 1)); //becuase child index starts at 0
            }
            i--;

        }

    }

    // Update is called once per frame
    void Update()
    {



        int numberOfSlotSelected_Int = (int)numberOfSlotSelected;
        numberOfSlotSelected += Input.mouseScrollDelta.y;


        selectedPanel = transform.GetChild(numberOfSlotSelected_Int - 1); //We use -1 because unity starts egtting children at 0. So the first child would be child 0 not child 1


        if (numberOfSlotSelected > 5)
        {
            numberOfSlotSelected = 1;
        }

        if (numberOfSlotSelected < 1)
        {
            numberOfSlotSelected = 5;
        }

        //=======================================================================================================================================================================================//
        //DETERMINE WHICH PANEL IS OPEN


        closestAvailablePanel.Clear();
        foreach (Transform available in availableSlots)
        {

            if (Vector3.Distance(selectedPanel.position, available.position) <= 100 && available.childCount == 0)
            {
                closestAvailablePanel.Add(available);

            }

        }

        if (selectedPanel.childCount == 0)
        {
            availablePanel = selectedPanel;
        }

        if (selectedPanel.childCount >= 1)
        {
            if (closestAvailablePanel.Count >= 1)
            {
                availablePanel = closestAvailablePanel[0];

            }

            //THIS IS BASICALLY FOR IF YOU KEEP YOUR CURSOR ON THE FIRST SLOT THE WHOLE TIME WHILE PICKING UP ITEMS
            if (availableSlots.Count > 0 && selectedPanel != possibleSlots[0])
            {
                if (transform.GetChild(numberOfSlotSelected_Int).childCount >= 1)
                {
                    closestAvailablePanel.Clear();
                    foreach (Transform available in availableSlots)
                    {
                        availablePanel = availableSlots[availableSlots.Count - 1];
                    }
                }
            }
            //THIS IS BASICALLY FOR IF YOU KEEP YOUR CURSOR ON THE LAST SLOT THE WHOLE TIME WHILE PICKING UP ITEMS
            if (availableSlots.Count > 0 && selectedPanel == possibleSlots[0])
            {
                if (transform.GetChild(numberOfSlotSelected_Int - 2).childCount >= 1)
                {
                    closestAvailablePanel.Clear();
                    foreach (Transform available in availableSlots)
                    {
                        availablePanel = availableSlots[availableSlots.Count - 1];
                    }
                }
            }


        }

        //THIS IS FOR WHEN THERE IS ONLY ONE SLOT LEFT
        if (availableSlots.Count == 1)
        {
            availablePanel = availableSlots[0];
        }

        //=======================================================================================================================================================================================//     

        select.transform.position = selectedPanel.position;


        //SELECTED ITEM
        if (selectedPanel.childCount >= 1)
        {
            selectedItem = selectedPanel.GetComponentInChildren<PhysicalItem>().item;
        }

        //SHOW SELECTED ITEM'S NAME
        if (selectedPanel.childCount >= 1)
        {
            selectedGunText.text = selectedItem.ItemName;
        }
        else if (selectedPanel.childCount == 0)
        {
            selectedGunText.text = "";
        }





        //HOLD GUN   
        if (selectedPanel.childCount >= 1)
        {

            if (gunSpawnPoint.childCount == 0)
            {
                heldGun = Instantiate(selectedItem.itemGameObject, gunSpawnPoint.position, gunSpawnPoint.rotation);
                heldGun.transform.SetParent(gunSpawnPoint);

            }

            if (gunSpawnPoint.childCount >= 1)
            {

                if (heldGun.GetComponent<PhysicalItem>().item.ItemName == selectedItem.name)
                {
                    print("same gun");
                }
                else if (heldGun.GetComponent<PhysicalItem>().item.ItemName != selectedItem.name)
                {
                    print("different gun");
                    Destroy(heldGun);
                    heldGun = Instantiate(selectedItem.itemGameObject, gunSpawnPoint.position, gunSpawnPoint.rotation);
                    heldGun.transform.SetParent(gunSpawnPoint);
                }

            }

        }
        if (selectedPanel.childCount == 0)
        {
            Destroy(heldGun);
        }


    }


    public void Add(Item pickedUpItem)
    {

        GameObject instantiatedSlot = Instantiate(slot, availablePanel.position, Quaternion.identity);

        instantiatedSlot.transform.SetParent(availablePanel);
        instantiatedSlot.transform.GetChild(0).GetComponent<TMP_Text>();
        instantiatedSlot.transform.GetChild(1).GetComponent<Image>().sprite = pickedUpItem.ItemIcon;

        instantiatedSlot.GetComponent<PhysicalItem>().item = pickedUpItem;


        availableSlots.Clear();
        foreach (Transform available in possibleSlots)
        {
            if (available.childCount == 0)
            {
                availableSlots.Add(available);

            }
        }


    }

}

I also updated the method by which you are able to pick up items. Instead of walking straight into a gun to pick it up, you have to look at it and press ‘E’. The code is also pretty much the same. (I placed the portion of code in a separate script meant to handle raycasts and shooting):

    void PickUp()
    {
        if (GetComponent<Inventory>().inventory.Count < 5)
        {
            GetComponent<Inventory>().inventory.Add(selectedObject.GetComponent<PhysicalItem>().item); //ADD TO INVENTORY
            GameObject.Find("Inventory").GetComponent<InventoryUiController>().Add(selectedObject.gameObject.GetComponent<PhysicalItem>().item);

            Destroy(selectedObject);
        }
    }