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
Importing item data from JSON
Inventory for storing player items
Building the inventory UI
Capture player input
Screwing Pick and Drop using the mouse
Stack and Split Items
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.
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.
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 6rows
– Number of lines in inventory, now 6slots
– Total number of slots in inventory, 6 x 6 = 36items
– 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 anditem
– 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 parameterindex
– 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 andamount
– 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.
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.
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_changed
created 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_slots
to 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:
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:
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:
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:
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:
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 stackable
we 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_amount
reducing 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_amount
and 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:
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:
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)