Make Inventory System in Godot

godot inventory system

So the game I was working on was a risky project, and the best way to keep the risk was to make every component of the game as much independent as possible from other components, to ensure that a component failure doesn’t affect the entire project.

Inventory system I required 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 merchant-system (trading/exchange system), hot-bar & player-equip system from the inventory by extending it. So the inventory had to be extendible and clean.

Overall breakdown

  1. 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.
  2. Make a Slot scene, with necessary UI and a single Item instance as its child.
  3. Make Inventory scene with a list of slots arranged in grid.
  4. 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.
  5. 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):

  1. 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.
  2. deselect_item function: it makes the (previously selected) item the child of slot node, and removes from inventory root (calls reparent() function).
  3. 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.
  4. 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

inventory system setup godot 4

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.

Leave a Reply

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