Inventory system I needed was similar to Minecraft. I had tons of items in game and an item can be stackable (one cell of inventory holding many of them). Also, there had to be a mechanism to put item from world to inventory or from inventory back to world. Finally, I was gonna make trading system (exchange system) similar to Minecraft, hot-bar & player-equip system from the inventory by extending it. So the inventory had to be extendible and clean.
Overall breakdown
- Make an Item class, this will be a base class for all items (all items will extend it). Also, all exchange between Inventory and World and within Inventory itself must only be done by passing or retrieving Item instances only. This will keep the code clean as no matter how many items we create, they will eventually be stored in inventory as they extend from Item.
- Make a Slot scene, with necessary UI and a single Item instance as its child.
- Make Inventory scene with a list of slots arranged in grid.
- in inventory, allow capturing of input events and when the event is click, pick an item, and when click again, throw an item on slot. and while item is picked, move it along with mouse cursor.
- Finally, things like tooltip and hints can later be added.
Start with an ‘Item’
Basic things we have to know about each item is its name, icon and is-stackable in inventory. So:
extends Node
class_name Item
@export var item_name: String = ""
@export var icon: Texture2D
@export var is_stackable: bool = false
func _ready():
add_to_group("items")
Scenes of all the items have the same script, allowing you to assign the name, icon and other properties of each item. Here is the scene setup. Notice that the properties of Amulet item set-up on right side and the scene structure on left side.
Making slot for inventory
For the slot, just make a scene with ‘CenterContainer’ as its root and a TextureButton node as its child.
Notice the properties on the right side. These are variables for item, hint item (ignore it we will see it later), and inventory item scene. There properties are added via script so create a script called “InventorySlot.gd”.
Most of the inventory logic such as selecting item, splitting item, placing items and so on are all handled within slot. And inventory itself will act just as a list of slots; and only providing operations such as holding an item temporarily while it is selected (so item can follow mouse position when selected).
Basic slot setting
extends Control
class_name InventorySlot
# So its copy can be instanced while splitting
@export var inventory_item_scene: PackedScene = preload("res://Inventory/InventorySlot/InventoryItem/InventoryItem.tscn")
@export var item: InventoryItem
@export var hint_item: InventoryItem = null
# hint_item SERVE TO RESTRICT A SLOT TO ONLY
# ACCEPT THE TYPE OF ITEM REPRESENTED BY THE hint_item
enum InventorySlotAction {
SELECT, SPLIT, # FOR ITEM SELECTION
}
signal slot_input(which: InventorySlot, action: InventorySlotAction)
signal slot_hovered(which: InventorySlot, is_hovering: bool)
func _ready():
add_to_group("inventory_slots")
Above code is self-explanatory; it adds slot properties (such as an item that slot has) and signals to detect if slot is hovered or input event (mouse click) have taken place.
The reason for InventoryItem scene is because inventory should be able to hold a quantity of item in same slot. An Item class does not provide ‘amount’ attribute, thats why we create a data structure (or call it scene) local to inventory’s inner purpose called inventory-item. Also, when splitting an item (taking half an amount from a slot), it gets easier to create its duplicate and split the amount. While I think it can be done somehow in base Item() class directly, I found it more convenient to do it with InventoryItem so I can later add more functionality and not make inventory too much dependent on something outside of it.
Detecting input clicks or hover
# When slot is pressed
func _on_texture_button_gui_input(event):
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
slot_input.emit(
self, InventorySlotAction.SELECT
)
elif event.button_index == MOUSE_BUTTON_RIGHT:
slot_input.emit(
self, InventorySlotAction.SPLIT
)
func _on_texture_button_mouse_entered():
slot_hovered.emit(self, true)
func _on_texture_button_mouse_exited():
slot_hovered.emit(self, false)
When slot is pressed or when mouse enteres, we emit signals, which will be catched by the inventory node to move inventory items around.
Slot functions
The code is given below but their description is as follows (ignore item_hints for now, I will discuss them later):
- select_item function: it takes the inventory-item and makes it the child of inventory node instead of this slot node. This will free the item from slot and make irt child of inventory root.
- deselect_item function: it makes the (previously selected) item the child of slot node, and removes from inventory root (calls reparent() function).
- split_item function: If amount of inventroy-item is > 1, it takes half of that amount by duplicating InventoryItem and creating a new one with half of original amount. Otherwise, it performs simple selection if amount is 1.
- update_slot is the helper function to update values of inventory slot, thereby reflecting them in UI
# Removes item from slot and returns it.
func select_item() -> InventoryItem:
var inventory = self.get_parent().get_parent() # Inventory
var tmp_item := self.item
if tmp_item:
tmp_item.reparent(inventory)
self.item = null
tmp_item.z_index = 128
# Show it above other items
return tmp_item
# If swap, then returb swapped item, else return null and add new item
func deselect_item(new_item: InventoryItem) -> InventoryItem:
if not is_respecting_hint(new_item):
return new_item # Do nothing
var inventory = self.get_parent().get_parent() # Inventory
if self.is_empty():
new_item.reparent(self)
self.item = new_item
self.item.z_index = 64
return null
else:
if self.has_same_item(new_item): # if both items are same
print("Has same item")
self.item.amount += new_item.amount
new_item.free()
return null
else: # if different type, swap
new_item.reparent(self) # Make new thing our child
self.item.reparent(inventory) # make old thing inventory child
var tmp_item = self.item
self.item = new_item
new_item.z_index = 64 # Reset its z index
tmp_item.z_index = 128 # Update swapped item's z index
return tmp_item
# Split means selecting half amount
func split_item() -> InventoryItem:
if self.is_empty():
return null
var inventory = self.get_parent().get_parent() # Inventory
if self.item.amount > 1:
var new_item: InventoryItem = inventory_item_scene.instantiate()
new_item.set_data(
self.item.item_name, self.item.icon,
self.item.is_stackable, self.item.amount
) # Because .duplicate() is buggy (doesnt make it unique0 thats why duplicating via this way
new_item.amount = self.item.amount / 2
self.item.amount -= new_item.amount
inventory.add_child(new_item)
new_item.z_index = 128
return new_item
elif self.item.amount == 1:
return self.select_item()
else:
return null
func update_slot():
if item:
if not self.get_children().has(item):
add_child(item)
#item.sprite.texture = item.icon
#item.label.text = str(item.amount) + " - " + str(item.name)
# If amount ios 0, make iot semi-transparent
if item.amount < 1:
item.fade()
if hint_item:
if not self.get_children().has(hint_item):
add_child(hint_item)
hint_item.fade() # Visually look faded
Item Hints
In some cases we want to force the user to place only a pre-defined kind of item in an inventory slot. Or to guide user that a certain kind of item must be placed on the given slot. For this, item hints were introduced. They tell how much and of what type of item must go in this slot. And a slot only accepts item if it satisfies the constraints set by the item hint.
This was the key feature that allowed for the merchant system where inventory slots take fixed type of item in exchange for another item.
Item hint, by defination is of same type as Item but it is displayed with less opacity and cannot be selected. Unlike primary item, which is referenced by ‘item: inventoryItem’ variable; hint is referenced by ‘item_hint: InventoryItem’.
Full slot code
extends Control
class_name InventorySlot
# So its copy can be instanced while splitting
@export var inventory_item_scene: PackedScene = preload("res://Inventory/InventorySlot/InventoryItem/InventoryItem.tscn")
@export var item: InventoryItem
@export var hint_item: InventoryItem = null
# hint_item SERVE TO RESTRICT A SLOT TO ONLY
# ACCEPT THE TYPE OF ITEM REPRESENTED BY THE hint_item
enum InventorySlotAction {
SELECT, SPLIT, # FOR ITEM SELECTION
}
signal slot_input(which: InventorySlot, action: InventorySlotAction)
signal slot_hovered(which: InventorySlot, is_hovering: bool)
func _ready():
add_to_group("inventory_slots")
# When slot is pressed
func _on_texture_button_gui_input(event):
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
slot_input.emit(
self, InventorySlotAction.SELECT
)
elif event.button_index == MOUSE_BUTTON_RIGHT:
slot_input.emit(
self, InventorySlotAction.SPLIT
)
func _on_texture_button_mouse_entered():
slot_hovered.emit(self, true)
func _on_texture_button_mouse_exited():
slot_hovered.emit(self, false)
# Is it having same type and amount as indicated by the hint_item
func is_respecting_hint(new_item: InventoryItem, in_amount_as_well: bool = true) -> bool:
if not hint_item:
return true
if in_amount_as_well:
return (
new_item.item_name == self.hint_item.item_name
and new_item.amount >= self.hint_item.amount
)
else:
return new_item.item_name == self.hint_item.item_name
# Sets hint item
func set_item_hint(new_item_hint: InventoryItem):
if self.hint_item:
self.hint_item.free()
self.hint_item = new_item_hint
self.add_child(new_item_hint)
update_slot()
# Deletes hint item
func clear_item_hint():
if self.hint_item:
self.hint_item.free()
self.hint_item = null
update_slot()
# Removes item from slot
func remove_item():
self.remove_child(item)
item.free()
item = null
update_slot()
# Removes item from slot and returns it.
func select_item() -> InventoryItem:
var inventory = self.get_parent().get_parent() # Inventory
var tmp_item := self.item
if tmp_item:
tmp_item.reparent(inventory)
self.item = null
tmp_item.z_index = 128
# Show it above other items
return tmp_item
# If swap, then returb swapped item, else return null and add new item
func deselect_item(new_item: InventoryItem) -> InventoryItem:
if not is_respecting_hint(new_item):
return new_item # Do nothing
var inventory = self.get_parent().get_parent() # Inventory
if self.is_empty():
new_item.reparent(self)
self.item = new_item
self.item.z_index = 64
return null
else:
if self.has_same_item(new_item): # if both items are same
print("Has same item")
self.item.amount += new_item.amount
new_item.free()
return null
else: # if different type, swap
new_item.reparent(self) # Make new thing our child
self.item.reparent(inventory) # make old thing inventory child
var tmp_item = self.item
self.item = new_item
new_item.z_index = 64 # Reset its z index
tmp_item.z_index = 128 # Update swapped item's z index
return tmp_item
# Split means selecting half amount
func split_item() -> InventoryItem:
if self.is_empty():
return null
var inventory = self.get_parent().get_parent() # Inventory
if self.item.amount > 1:
var new_item: InventoryItem = inventory_item_scene.instantiate()
new_item.set_data(
self.item.item_name, self.item.icon,
self.item.is_stackable, self.item.amount
) # Because .duplicate() is buggy (doesnt make it unique0 thats why duplicating via this way
new_item.amount = self.item.amount / 2
self.item.amount -= new_item.amount
inventory.add_child(new_item)
new_item.z_index = 128
return new_item
elif self.item.amount == 1:
return self.select_item()
else:
return null
# Is slot empty (has no item)
func is_empty():
return self.item == null
# Has same kind of item? (same name)
func has_same_item(_item: InventoryItem):
return _item.item_name == self.item.item_name
func update_slot():
if item:
if not self.get_children().has(item):
add_child(item)
#item.sprite.texture = item.icon
#item.label.text = str(item.amount) + " - " + str(item.name)
# If amount ios 0, make iot semi-transparent
if item.amount < 1:
item.fade()
if hint_item:
if not self.get_children().has(hint_item):
add_child(hint_item)
hint_item.fade() # Visually look faded
Inventory root
Above is the scene structure. Inventory is set up as a grid container, the root node is a control. it is where the items go while they are selected. For now, just know that tooltip is the name of item displayed when we hover on it; ignore the tooltip; it will be discussed later.
Inventory set-up
extends Control
class_name Inventory
var inventory_item_scene = preload("res://Inventory/InventorySlot/InventoryItem/InventoryItem.tscn")
@export var rows: int = 3
@export var cols: int = 6
@export var inventory_grid: GridContainer
@export var inventory_slot_scene: PackedScene
var slots: Array[InventorySlot]
@export var tooltip: Tooltip # Must be shared among all instanesself
static var selected_item: Item = null
Rows and cols are used to calculate how many slots will be instanced. The inventory_grid is reference to the grid container where slots reside. This is how inventory is created:
func _ready():
inventory_grid.columns = cols
for i in range(rows * cols):
var slot = inventory_slot_scene.instantiate()
slots.append(slot)
inventory_grid.add_child(slot)
slot.slot_input.connect(self._on_slot_input) # binding not necessary as
slot.slot_hovered.connect(self._on_slot_hovered) # it does while emit() call
tooltip.visible = false
Notice how the signals are being connected for every slot when we instance.
Important thing here is ‘selected_item’; it is the reference to the item which is selected from a slot. This item is made the child of the inventory’s root Control node while selected and its refernce is stored here. When slot signals are triggered, all inventory does is call slot’s functions that we discused earlier (select, split and so on). Like this:
func _on_slot_input(which: InventorySlot, action: InventorySlot.InventorySlotAction):
print(action)
# Select/deselect items
if not selected_item:
# Spliting only occurs if not item selected already
if action == InventorySlot.InventorySlotAction.SELECT:
selected_item = which.select_item()
elif action == InventorySlot.InventorySlotAction.SPLIT:
selected_item = which.split_item() # Split means selecting half amount
else:
selected_item = which.deselect_item(selected_item)
func _on_slot_hovered(which: InventorySlot, is_hovering: bool):
if which.item:
tooltip.set_text(which.item.item_name)
tooltip.visible = is_hovering
elif which.hint_item:
tooltip.set_text(which.hint_item.item_name)
tooltip.visible = is_hovering
Moving item while selected
When item is selected, all we do is this:
func _process(delta):
tooltip.global_position = get_global_mouse_position() + Vector2.ONE * 8
if selected_item:
tooltip.visible = false
selected_item.global_position = get_global_mouse_position()
Tooltip
Tooltip is a simple scene that is instanced and always follows mouse position. When mouse is hovered, it is made visible else invisible. It is updated with hovered slot’s item’s name. It is setup like this:
Scene:
Code:
extends ColorRect
class_name Tooltip
@onready var margin_container: MarginContainer = $MarginContainer
@onready var item_name: Label = $MarginContainer/Label
func set_text(_text: String):
self.item_name.text = _text
margin_container.size = Vector2()
size = margin_container.size
Other helper functions
The rest of the inventory functions are just API made by calling slot functions. They were added at last before using the inventory and are self explanatory.
Full inventory root code
@tool
extends Control
class_name Inventory
var inventory_item_scene = preload("res://Inventory/InventorySlot/InventoryItem/InventoryItem.tscn")
@export var rows: int = 3
@export var cols: int = 6
@export var inventory_grid: GridContainer
@export var inventory_slot_scene: PackedScene
var slots: Array[InventorySlot]
@export var tooltip: Tooltip # Must be shared among all instanesself
static var selected_item: Item = null
func _ready():
inventory_grid.columns = cols
for i in range(rows * cols):
var slot = inventory_slot_scene.instantiate()
slots.append(slot)
inventory_grid.add_child(slot)
slot.slot_input.connect(self._on_slot_input) # binding not necessary as
slot.slot_hovered.connect(self._on_slot_hovered) # it does while emit() call
tooltip.visible = false
func _process(delta):
tooltip.global_position = get_global_mouse_position() + Vector2.ONE * 8
if selected_item:
tooltip.visible = false
selected_item.global_position = get_global_mouse_position()
func _on_slot_input(which: InventorySlot, action: InventorySlot.InventorySlotAction):
print(action)
# Select/deselect items
if not selected_item:
# Spliting only occurs if not item selected already
if action == InventorySlot.InventorySlotAction.SELECT:
selected_item = which.select_item()
elif action == InventorySlot.InventorySlotAction.SPLIT:
selected_item = which.split_item() # Split means selecting half amount
else:
selected_item = which.deselect_item(selected_item)
func _on_slot_hovered(which: InventorySlot, is_hovering: bool):
if which.item:
tooltip.set_text(which.item.item_name)
tooltip.visible = is_hovering
elif which.hint_item:
tooltip.set_text(which.hint_item.item_name)
tooltip.visible = is_hovering
# API::
# !DESTRUCTUVE (removes item itself from world and adds its copy to inventory)
# Calling thius func impies that item is not already in inventory
func add_item(item: Item, amount: int) -> void:
var _item: InventoryItem = inventory_item_scene.instantiate() # Duplicate
_item.set_data(
item.item_name, item.icon, item.is_stackable, amount
)
item.queue_free() # Consume the item by inventory (by the end of frame)
if item.is_stackable:
for slot in slots:
if slot.item and slot.item.item_name == _item.item_name: # if item and is of same type
slot.item.amount += _item.amount
return
for slot in slots:
if slot.item == null and slot.is_respecting_hint(_item):
slot.item = _item
slot.update_slot()
return
# !DESTRUCTUVE (removes from inventory if retrieved)
#A function to remove item from inventory and return if it exists
func retrieve_item(_item_name: String) -> Item:
for slot in slots:
if slot.item and slot.item.item_name == _item_name:
var copy_item := Item.new()
copy_item.item_name = slot.item.item_name
copy_item.name = copy_item.item_name
copy_item.icon = slot.item.icon
copy_item.is_stackable = slot.item.is_stackable
if slot.item.amount > 1:
slot.item.amount -= 1
else:
slot.remove_item()
return copy_item
return null
# !NON-DESTRUCTIVE (read-only function) to get all items in inventory
func all_items() -> Array[Item]:
var items: Array[Item] = []
for slot in slots:
if slot.item:
items.append(slot.item)
return items
# ! NON-DESTRUCTUVE (read-only), returns all items of a particular type
func all(_name: String) -> Array[Item]:
var items: Array[Item] = []
for slot in slots:
if slot.item and slot.item.item_name == _name:
items.append(slot.item)
return items
# !DESTRUCTUVE (removes all items of a particular type)
func remove_all(_name: String) -> void:
for slot in slots:
if slot.item and slot.item.item_name == _name:
slot.remove_item()
# !DESTRUCTUVE (removes all items from inventory)
func clear_inventory() -> void:
for slot in slots:
slot.remove_item()
Now, what is InventoryItem tho?
So far we discussed everything except InventoryItem itself, that I called is the inner data structure of the inventory.
See how the code is focused on just displaying the item in a slot with correct scale, and with its name label.
Code:
extends Item
class_name InventoryItem
# NOTE: IT IS not SLOT AMOUNT, but currently carried amount
@export var amount: int = 0 # Amount that is being carried in inventory
@export var sprite: Sprite2D
@export var label: Label
func set_data(_name: String, _icon: Texture2D, _is_stackable: bool, _amount: int):
self.item_name = _name
self.name = _name
self.icon = _icon
self.is_stackable = _is_stackable
self.amount = _amount
func _process(delta):
self.sprite.texture = self.icon
self.set_sprite_size_to(sprite, Vector2(42, 42))
if is_stackable:
self.label.text = str(self.amount)
else:
label.visible = false
func set_sprite_size_to(sprite: Sprite2D, size: Vector2):
var texture_size = sprite.texture.get_size()
var scale_factor = Vector2(size.x / texture_size.x, size.y / texture_size.y)
sprite.scale = scale_factor
func fade():
self.sprite.modulate = Color(1, 1, 1, 0.4)
self.label.modulate = Color(1, 1, 1, 0.4)
Extending the inventory
The inventory was made to be very extendible in mind. It follows hierarchal structure (that i typically use) very strongly. Look how InventoryItem provides an API to the slot, which in turn provides an API to the inventory root to work. Inventory root script provides API to other game elements. They follow a hierarchal pattern so modules need not know anything other than their direct descendent only.
So, systems such as hot-bar, merchant/trade system, etc can be made by making an even greater scene and extending Inventory; and using all the operations provided by inventory as API.
I will be updating my hot-bar system in a separate post along with merchant system later.
Project Link
The GitHub repo for the project is here, you can take the code and test it.
2 responses to “Make Inventory System in Godot”
This was as super helpful and interesting tutorial to follow along with. I also really like the merchant system you implemented with it. Wondering how easy it would be to use the concepts you did here to create multiple inventories, similar to chests/storage that are not attached to the player. Further, I’m trying to figure out data saving with this to ensure the inventory doesn’t just wipe after you exit and come back into the game.
Wondering if you considered these things when making it and if you’ll be implementing them in future tutorials. Really great work, loved learning from you!
Additionally, curious about why you decided not to use a resource to make data inheritance even easier instead of storing scene items in the inventory you just have to pass the resource data. Unless I missed that in reading the code, I’ll be following along in Godot shortly. This comment could be premature.