Inventory system on Godot. Crutch first

Preface

A crutch is a stick with a crossbar placed under the arm, serving as a support for the lame when walking. This is how it is written on Wikipedia and quite well, the functionality is described, the appearance and method of use are described. This information is enough for me to assemble my own crutch!

What about game development? A merciless wasteland where you are a traveler, in search of tablets, bits of information, in the hope of collecting a complete scroll. You wander through the vast expanses of the Internet, and in some cases you listen to the tales of your colleagues in the shop and, with trembling hands, write down all their words in a notebook.

I would like to share such a tablet, using the example of a simple inventory system.

The implementation will be on version Godot 4.xx. It will be easier if you have basic knowledge of working with Nodes, Scenes and Singletons.

Repository with sources here here

Items

  1. Importing item data from JSON

  2. Inventory for storing player items

  3. Building the inventory UI

  4. Capture player input

  5. Screwing Pick and Drop using the mouse

  6. Stack and Split Items

  7. Displaying a tooltip on an item

Importing data from JSON

To store data about items, I will use the JSON format; it is easier to store, modify and transfer. There are many options where we can store information about objects, it could be a dictionary or a regular two-dimensional array.

{
  "orange": {
    "name": "Апельсин",
    "description": "Сочный вкусный желтый апельсин!",
    "icon": "orange_icon.png",
    "stackable": true,
    "quantity": 1,
  },
  "potion": {
    "name": "Зелье здоровья",
    "description": "Прибавляет +5 к здоровью",
    "icon": "health_icon.png",
    "stackable": true,
    "quantity": 1,
  },
  "sword": {
    "name": "Меч",
    "description": "Старый ржавый меч",
    "icon": "sword_icon.png",
    "stackable": false,
  }
}

Let's create a small list of items and call it items.json, in my case it is “orange”, “potion” and “sword”. The main thing here is:

  • Stackable – True/False if the item is stackable

  • Quantity – The number of items and uses available to us

Now we have a file listing the items available to us; we need to transfer them for use and storage. First, let's create a script Global.gd and folder scripts where we will store our scripts.

extends Node

var items

func _ready():
  items = read_from_JSON("res://data/items.json")
  for key in items.keys():
    items[key]["key"] = key

func read_from_JSON(path: StringName):
  var file
  var data
  if (FileAccess.file_exists(path)):
    file = FileAccess.open(path, FileAccess.READ)
    data = JSON.parse_string(file.get_as_text())
    file.close()
    return data
  else:
    printerr("File doesn't exist")

Create a function read_from_JSON with parameter path, where we define the path to our JSON file. Using the built-in method FileAccess for read access. If the processing was completed without errors, we return our parsed data; if the file is processed incorrectly, we return an error.

In function _ready call read_from_JSON and pass the path to our list of items res://data/items.jsonstore the values ​​in a variable items. Inside the loop, we store the key as a property of the element, since each item is identified by a unique key, in order to determine in the future whether two elements are of the same type.

Next, we need to add our script Global.gd into startup as a singleton. To do this, go to Project -> Project Settingsselect the item Autoload and add our script Global.gd. Let's call him Global and put a checkbox Global variable on turn on. Now we have access to the class Global globally.

There is a very good description of game programming patterns in the book “Game Programming Patterns” by Robert Nystrom.

It should turn out like this

It should turn out like this

Let's add another useful function for obtaining an item using a key. Add the code to the end of the file Global.gd.

func get_item_by_key(key):
	if items && items.has(key):
		return items[key].duplicate(true)
	else:
		printerr("Item doesn't exist")

This function takes an item key as a parameter and returns a duplicate of it.

For testing, create a scene Node2Drename to Main. Let's create a folder scenes to store our scenes. Save the scene as main.tscn and launch the project (F5), select our scene as the main one.

Now let's add the output of our function get_item_by_key In the end _ready file Global.gd to test our parser.

print(get_item_by_key("orange"))

Run the project again (F5) and look at the output.

We get this conclusion

We get this conclusion

Inventory for storing player items

In this section we will make an inventory for storing player items. Let's create a new script Inventory.gdadd the following code:

extends Node

signal items_changed(indexes)

var cols: int = 6
var rows: int = 6
var slots: int = cols * rows
var items: Array = []

func _ready():
  for i in range(slots):
    items.append({})

func set_item(index, item):
  var previos_item = items[index]
  items[index] = item
  items_changed.emit([index])
  return previos_item

func remove_item(index):
  var previos_item = items[index].duplicate()
  items[index].clear()
  items_changed.emit([index])
  return previos_item

func set_item_quantity(index, amount):
  items[index].quantity += amount
  if items[index].quantity <= 0:
    remove_item(index)
  else:
    items_changed.emit([index])

First we define the variables:

  • cols – Number of columns in inventory, now 6

  • rows – Number of lines in inventory, now 6

  • slots – Total number of slots in inventory, 6 x 6 = 36

  • items – An array in which we will store the player’s items

Signal items_changed will be called every time we work with our inventory indexes. We pass an array of indexes along with a signal indicating which inventory slot has changed. We will need this signal to work with the inventory UI in the future.

Initially the inventory will be empty, in the function _ready we fill our array items using an empty dictionary to sort through the number of cells.

Our each element of the array items represents an index into the inventory slot position. We can access the item like this: items[0]

To interact with inventory, we will define several functions:

  • set_item – The function takes two parameters, index – inventory slot index and item – an item from the item list added to the player's item array. The function returns the item previously stored in this cell.

  • remove_item – The function takes a parameter index – inventory slot index, removing an item from the player's item array. It also returns a previously saved item in this cell.

  • set_item_quantity – The function takes two parameters, index – inventory slot index and amount – the amount to be subtracted or added. If the item's quantity is zero or less, we remove the item from the player's item array.

By analogy with the previous point, we need to add our script Inventory.gd V Autoload like a singleton. We get two global classes Global And Inventory.

We get the following output:

We get the following output:

Building the inventory UI

Now we are ready to develop the interface for our inventory. First, let's create a folder interface to store our scenes.

Our interface will be populated via GridContainerwhich will give us greater flexibility in filling depending on the number of rows and columns.

Let's create a scene items_slot.tscnselect the main node ColorRect and let's call it ItemRect. Let's set the anchor preset for our scene to Center. Let's set the property Transform -> Size By X And Y -> 100px and add similar values ​​to the property Custom Minimum Size. Now our parent container is centered, which prevents us from having unexpected surprises in the future.

Let's add a child node of type TextureRect With name ItemIcon. This node will display the icon of our item. Setting properties Transform -> Size By X And Y -> 80pxA Position By X And Y -> 10px. Our icon is now centered inside its parent node. Let's also set the property Expand Mode -> Fit Widthfor correct display of icon proportions.

Let's add another node like Label With name LabelQuantity. In this node we will display the number of items from the property quantity. Set the anchor preset to Bottom Right. We can also change the font size settings in the property Label Settings -> Font -> 18px.

Let's label the nodes ItemIcon And LabelQuantity access by unique name.

interface/items_slot.tscn

interface/items_slot.tscn

Next we need to add ItemRect to the group. To do this, go to Node -> Groups and add a new group items_slot. This will help us later get all the cells of the elements in the array.

Now let's add the script to our scene:

extends ColorRect

@onready var item_icon = %ItemIcon
@onready var item_quantity = %LabelQuantity

func display_item(item):
	if item:
		item_icon.texture = load("res://textures/Items/%s" % item.icon)
		item_quantity.text = str(item.quantity) if item.stackable else ""
	else:
		item_icon.texture = null
		item_quantity.text = ""

We get the reference to the child nodes using the keyword @onready. Calling a function display_item updates the icon and quantity of the item. Path to images of our items res://textures/Items/[item.icon]


Next, add a script inherited from the class GridContainer, which we will use in our inventory menu. Create a new one ContainerSlot.gd script. Add the code:

extends GridContainer
class_name ContainerSlot

var ItemSlot = load("res://scenes/interface/items_slot.tscn")
var slots

func display_item_slot(cols: int, rows: int):
  var item_slot
  columns = cols
  slots = cols * rows
  
  for index in range(slots):
    item_slot = ItemSlot.instantiate()
    add_child(item_slot)
    item_slot.display_item(Inventory.items[index])
  Inventory.items_changed.connect(_on_Inventory_items_changed)

func _on_Inventory_items_changed(indexes):
  var item_slot

  for index in indexes:
    if index < slots:
      item_slot = get_child(index)
      item_slot.display_item(Inventory.items[index])

To begin with, we defined class_name to refer to the class by name in the future ContainerSlot. In variable ItemSlot passing an instance of a previously created scene items_slot.tscn.

In function display_items_slot we pass how many columns and rows will be displayed in our inventory interface. The loop creates an instance of the scene and adds it to GridContainer. Finally we transmit the update signal items_changedcreated earlier in singleton Inventoryto update the item cell index in the inventory.


Let's add one more small script (definitely the last one at this stage) InventoryMenu.gd. It will already serve us as a function for displaying the display of our cells.

extends ContainerSlot

func _ready():
  display_item_slot(Inventory.cols, Inventory.rows)
  position = (get_viewport_rect().size - size) / 2

Inheriting from a class ContainerSlot and call the function display_item_slotsto display cells.


The last step is that we need a scene where we will display our inventory cells. Create a new scene inventory.tscnadd a parent node Control with title Inventory. Set the anchor preset to Full Rectangle. Then let's add a node PanelContainer with title InventoryContainer. This is our container where we will add all other future updates to our inventory. Set the anchor preset to Full Rectangle. The last node to add is GridContainer with title InventoryMenu. Set the horizontal and vertical alignment to the status Push to Center. Adding a script InventoryMenu.gd to the node InventoryMenu. Our scene should look like this:

interface/inventory.tscn

interface/inventory.tscn

With this we have finished setting up the display of our inventory!

Capture player input

Great, we have set up the inventory for storing items and its display, now we need to transfer all this to the main scene. Go to the previously created scene Main.tscn and add a node CanvasLayerrename to UI Next we add our scene Inventory.tscn and activate the ability to edit scene descendants. CanvasLayer allows us to render UI elements on top of the game. Summary of our scene:

interface/main.tscn

interface/main.tscn

Next we need to bind the open and close button for our inventory. Let's go to Project -> Project Settings -> Action List. Adding an action ui_inventory and bind the button Tab to our action. We get the following result:

Let's add a script InventoryHandler.gd to our Inventory stage:

extends Control

func _unhandled_input(event):
  if event.is_action_pressed("ui_inventory"):
    visible = !visible

We use the function _unhandled_input, which will be called upon input action. Seizing the moment of our action ui_inventory and me property visible inventory.

Let's check the work, run the project (F5) and make sure that our inventory opens and closes.

Pick and Drop items

The next feature of our inventory will be the ability to move and select items using the mouse.

We'll make a new scene DragPreview. This scene will be responsible for previewing the dragged items. We will need to update its position every frame to follow the mouse.

Create a new scene drag_preview.tscn with root node Control and name DragPreview. Setting the property Mouse -> Filter on Ignore. Adding a child node Control With name Item. Setting the property transform on X And Y -> 140px.

Next we need to add the output of the icon and quantity of our item. Adding a node TextureRect With name ItemIcon child of node Item. Setting the property transform on X And Y -> 120px, set the anchor preset to Center. Don't forget about the property Expand Mode -> Fit Width. Let's add a node Label With name LabelQuantity. Set it in the property Label Settings -> Font -> Size on 18px And Horizontal Alignment ->Right. Set the preset anchor to Bottom Right. We save the scene and get this result:

interface/drag_preview.tscn

interface/drag_preview.tscn

Now let's add the script DragPreview.gd to our scene:

extends Control

@onready var item_icon = $Item/ItemIcon
@onready var item_quantity = $Item/LabelQuantity

var dragged_item = {} : set = set_dragged_item

func _process(delta):
	if dragged_item:
		position = get_global_mouse_position()

func set_dragged_item(item):
	dragged_item = item
	if dragged_item:
		item_icon.texture = load("res://textures/Items/%s" % dragged_item.icon)
		item_quantity.text = str(dragged_item.quantity) if dragged_item.stackable else ""
	else:
		item_icon.texture = null
		item_quantity.text = ""

Here we are using the variable dragged_item for storing the dragged item. Initially it is empty. We also install a setter on the function set_dragged_item. It will be called every time the dragged item changes, updating the item's icon and quantity.

In function _process we move the node behind the mouse if the dragged item is not empty.

We've finished setting up our draggable item preview scene. Now let's add a scene instance drag_preview.tscn to our inventory scene Inventory.tscn. It should look like this:

interface/inventory.tscn

interface/inventory.tscn

Let's add the code to our script InventoryHandler.gd :

@onready var drag_preview = $InventoryContainer/DragPreview

func _ready():
  for item_slot in get_tree().get_nodes_in_group("items_slot"):
	var index = item_slot.get_index()
	item_slot.connect("gui_input", _on_ItemSlot_gui_input.bind(index))

First, let's connect the signal gui_input to each inventory slot. We can use the method get_tree().get_nodes_in_group()where we pass the name of the desired group items_slot, which was indicated earlier. Inside the loop we connect the signal gui_input to each slot from the searched group, which will call the function on_ItemSlot_gui_input every time the node receives an input event. We transfer it to index our slot and the input event.

Let's define our function _on_ItemSlot_gui_input:

func _on_ItemSlot_gui_input(event, index):
  if event is InputEventMouseButton:
    if event.button_index == MOUSE_BUTTON_LEFT && event.pressed:
      if visible:
    	drag_item(index)

If the player left-clicks on a slot, we call the function drag_item:

func drag_item(index):
  var inventory_item = Inventory.items[index]
  var dragged_item = drag_preview.dragged_item
	
  # Взять предмет
  if inventory_item && !dragged_item:
    drag_preview.dragged_item = Inventory.remove_item(index)
  # Бросить предмет
  if !inventory_item && dragged_item:
    drag_preview.dragged_item = Inventory.set_item(index, dragged_item)
  # Свапнуть предмет
  if inventory_item and dragged_item:
    drag_preview.dragged_item = Inventory.set_item(index, dragged_item)

Lastly, we slightly modify the action of opening and closing the inventory in the function _unhandled_input:

func _unhandled_input(event):
  if event.is_action_pressed("ui_inventory"):
    # Не даем закрыть инвентарь пока взят предмет
    if visible && drag_preview.dragged_item: return
    visible = !visible

For the test, add items to your inventory. Let's go to the script Inventory.gd and add some items to the end of the function _ready:

items[0] = Global.get_item_by_key("orange")
items[1] = Global.get_item_by_key("orange")
items[2] = Global.get_item_by_key("orange")
items[3] = Global.get_item_by_key("potion")
items[4] = Global.get_item_by_key("potion")
items[5] = Global.get_item_by_key("sword")

Let's launch the project (F5) and make sure that our inventory opens and closes, and we can also select an item and transfer it to another slot:

main.tscn

main.tscn

Stack and Split Items

We can now move items into the inventory menu, but there are still two problems remaining. The first is that the same objects do not fit together, and the second is that there is no way to separate them. It's time to decide!

Let's open our script InventoryHandler.gd and add the following code:

func drag_item(index):
  var inventory_item = Inventory.items[index]
  var dragged_item = drag_preview.dragged_item
	
  # Взять предмет
  if inventory_item && !dragged_item:
    drag_preview.dragged_item = Inventory.remove_item(index)
  # Бросить предмет
  if !inventory_item && dragged_item:
    drag_preview.dragged_item = Inventory.set_item(index, dragged_item)

  if inventory_item && dragged_item:
    # Стакнуть предмет
    if inventory_item.key == dragged_item.key && inventory_item.stackable:
      Inventory.set_item_quantity(index, dragged_item.quantity)
      drag_preview.dragged_item = {}
    # Свапнуть предмет
    else:
      drag_preview.dragged_item = Inventory.set_item(index, dragged_item)

We add a new verification condition on line 12, here we check the condition using our unique key key. If the items match and are stackablewe increase their number, otherwise we simply swap.

Now let's add item splitting. To do this, we update the function _on_ItemSlot_gui_input in the same script, adding a new condition:

func _on_ItemSlot_gui_input(event, index):
	if event is InputEventMouseButton:
		if event.button_index == MOUSE_BUTTON_LEFT && event.pressed:
			drag_item(index)
		if event.button_index == MOUSE_BUTTON_RIGHT && event.pressed:
			split_item(index)

On line 5 we added a new condition, if the player presses the right mouse button on the selected item, we call the function split_item.

func split_item(index):
  var inventory_item = Inventory.items[index]
  var dragged_item = drag_preview.dragged_item
  var split_amount
  var item
  
  # Проверяем если предмет стакабл
  if !inventory_item || !inventory_item.stackable: return

  split_amount = ceil(inventory_item.quantity / 2.0)

  if dragged_item && inventory_item.key == dragged_item.key:
	drag_preview.dragged_item.quantity += split_amount
  	Inventory.set_item_quantity(index, -split_amount)
  if !dragged_item:
	item = inventory_item.duplicate()
    item.quantity = split_amount
	drag_preview.dragged_item = item
	Inventory.set_item_quantity(index, -split_amount)

Let's walk through our function. We get the index of the inventory item and the dragged item. We exit the function if there is no item in the slot or it is not stackable.

Then we get split_amountreducing the number of items by half.

On lines 12-14, if an item is dragged and has the same key as the item's key in the inventory, we increase its quantity by split_amountand subtract from the item in the inventory.

On lines 15-19, if the item being dragged doesn't exist yet, we will add an item of the same type and change its quantity so that it has the same amount as in split_amount. Finally, we subtract this amount from the item in inventory.

Let's run the project and make sure that our separation and addition of objects works correctly.

Tooltip

As a final touch, we'll add a tooltip to our inventory. It will display additional information about our item, such as the name and description of the item.

Create a new scene inventory_tooltip.tscnset the main node ColorRect and rename it to InventoryTooltip. Let's add a little transparency to our node by setting the property Color -> #313131cb. We assign the anchor preset to Top Left. For property Custom Minimum Size set the size according X -> 300pxBy Y -> 100px. We also need to set the horizontal and vertical alignment for the child nodes. In property Container Sizing For horizontal And Vertical set the value to Press to the beginning. Don't forget to set the property for Mouse -> Ignore.

Let's add a child node MarginContainer. Set the anchor preset to Full Rectangle. Property Mouse -> Ignore. Let's set small indents for our container; to do this, go to properties Theme Overrides -> Constants, Margin Left -> 20, Margin Top And Margin Bottom -> 2, Margin Right -> 4.

Create 2 nodes Label With name NameLabel And DescriptionLabelset the vertical alignment for NameLabel on Press to the beginning, for DescriptionLabel Press to the center. The output is this scene:

interface/inventory_tooltip.tscn

interface/inventory_tooltip.tscn

Let's create a new script Tooltip.gd for our stage inventory_tooltip.tscn:

extends ColorRect

@onready var name_label = $MarginContainer/NameLabel
@onready var description_label = $MarginContainer/DescriptionLabel

func _process(delta):
  position = get_global_mouse_position() + Vector2.ONE * 4

func display_info(item):
  name_label.text = item.name
  description_label.text = item.description

We update the position of our tooltip every frame so that it follows the position of our mouse. In function display_info we transfer the item, where we will display information about our item by keys name And description.

Now let's add a scene instance inventory_tooltip.tscn to our inventory scene Inventory.tscn. Since the tooltip should be hidden by default, let's hide it by clicking on the eye icon. Our scene should look like this:

interface/Inventory.tscn

interface/Inventory.tscn

Let's go to our script Inventory.gd and add a scene with a hint:

@onready var tooltip = $InventoryContainer/InventoryTooltip

To show and hide the tooltip, add signals mouse_entered And mouse_exited into a function _ready.

func _ready():
  for item_slot in get_tree().get_nodes_in_group("items_slot"):
    var index = item_slot.get_index()
    item_slot.connect("gui_input", _on_ItemSlot_gui_input.bind(index))
    item_slot.connect("mouse_entered", show_tooltip.bind(index))
	item_slot.connect("mouse_exited", hide_tooltip)

Let's define the functions show_tooltip And hide_tooltip.

func show_tooltip(index):
  var inventory_item = Inventory.items[index]
	
  if inventory_item && !drag_preview.dragged_item:
    tooltip.display_info(inventory_item)
    tooltip.visible = true
  else:
    tooltip.visible = false

func hide_tooltip():
  tooltip.visible = false

We will display a hint only in the case when there is an item in the inventory slot and it is not at the moment of dragging.

Finally, we’ll add hiding the tooltip when switching the inventory display and when dragging an item.

func _on_ItemSlot_gui_input(event, index):
  if event is InputEventMouseButton:
    if event.button_index == MOUSE_BUTTON_LEFT && event.pressed:
    	hide_tooltip()
    	drag_item(index)
    if event.button_index == MOUSE_BUTTON_RIGHT && event.pressed:
    	hide_tooltip()
        split_item(index)

func _unhandled_input(event):
	if event.is_action_pressed("ui_inventory"):
    	# Не даем закрыть инвентарь пока взят предмет
    	if visible && drag_preview.dragged_item: return
        visible = !visible
        hide_tooltip()

Function calls added here hide_tooltip. Let's launch the project and test our super sophisticated inventory!

The final

I hope this tutorial (or notes from a madman) helped you on your way to realizing that same game! Thank you for your time!

PS Inspiration taken from here (made with Godot 3.xx)

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *