Coding the inventory slots

In this lesson, we’ll code the interactive inventory slots, with the ability to click an item to grab it and move it around.

We can repeat what we did with DragPreview with these inventory slots and give them a property that references the current item and make sure it’s a child node of the panel. The code should look familiar. But on top of that, we can put the user interaction code there to collect, split and merge item stacks.

By the end of the lesson, we’ll have something that looks like the following, with the inventory slots working, but their look a bit off.

We’ll address the visuals in the next lesson.

The inventory panel

I’ve called the individual inventory slots InventoryPanel in this game, as they rely on the Panel node.

For this demo, I decided to design the slots in Factorio’s style, a game I love. The controls work like so:

That’s what we’ll code in this section.

Open InventoryPanel.tscn and attach a script to the root node, InventoryPanel.gd. We’ll start by defining some values, mainly the held_item, the BlueprintEntity held by this node, and the gui, a reference to the parent GUI node, which will give us access to the mouse’s inventory.

Whenever you code a drag-and-drop system, you need to pass references around nodes. Here, we’ve chosen to have the mouse’s inventory in the GUI node because it’s our gateway between the user interface and the game world. And we’ll need the ability to drag objects from the inventory to the world.

Note that the Control type has built-in drag-and-drop support, but it only works between control nodes, not to and from the game world. It also only supports holding down the mouse button and dragging. We want to toggle holding an item. That’s why we’re coding a custom system here.

## Represents a slot that can hold an item. 
## We keep track of the held item and its stack size by adding it as a child of the inventory slot.
class_name InventoryPanel
extends Panel

## We emit this signal whenever the item on this panel changes. We'll bubble it up
## to the GUI node so it can make other systems react to inventory changes.
## The `panel` argument below is this node.
signal held_item_changed(panel, item)

## A reference to the entity currently held by the panel. It can be `null` if there is none.
var held_item: BlueprintEntity setget _set_held_item

## We'll store a reference to the main GUI node to access the mouse's inventory.
var gui: Control

## We'll keep track of the stack size using the label.
onready var count_label := $Label

We’ll need to set the gui variable from another node. To do so, we define a setup() method we’ll call from another script.

Below, we also define our held_item variable’s setter, which triggers an update to the Label node.

## Store a reference to the GUI so we can access the mouse's inventory.
func setup(_gui: Control) -> void:
    gui = _gui


## Sets the panel's currently held item, notifies anyone connected of it, and
## updates the stack counter.
func _set_held_item(value: BlueprintEntity) -> void:
    # If we already have an item, remove it. Holding a reference to the old object
    # is the responsibility of whoever is changing it.
    # 
    # In other places in the GUI we've used `if blueprint` or `if held_item`,
    # and it worked fine. But entities may free the held item on the inventory panel.
    # As of Godot 3.2.4, freed objects are not necessarily false, so we use
    # `is_instance_valid()` for the sake of safety and prevent crashes.
    if is_instance_valid(held_item) and held_item.get_parent() == self:
        remove_child(held_item)

    held_item = value

    # If the `held_item` is not `null`, add it as a child and make sure that it appears 
    # behind the label.
    if is_instance_valid(held_item):
        add_child(held_item)
        move_child(held_item, 0)

    # We update the stack count label.
    _update_label()
    # And we notify any subscribers that we've changed what item is in this panel.
    emit_signal("held_item_changed", self, held_item)


## Updates the label with the stack's current amount. If it's only 1 or there is
## no item, we hide the label.
func _update_label() -> void:
    var can_be_stacked := is_instance_valid(held_item) and held_item.stack_count > 1

    if can_be_stacked:
        count_label.text = str(held_item.stack_count)
        count_label.show()
    else:
        count_label.text = str(1)
        count_label.hide()

The biggest function in our script will be the Control._gui_input() callback, which we use to handle all possible cases when clicking a slot. This callback gets called after _input() and only receives events that affect this control node, like clicks within the bounding box of this InventoryPanel.

Note that it relies on some functions we have yet to define. We’ll add them after it.

func _gui_input(event: InputEvent) -> void:
    # We need to check for left and right click actions below.
    # To help with code readability, I create two variables with short descriptive names.
    var left_click := event.is_action_pressed("left_click")
    var right_click := event.is_action_pressed("right_click")

    if not (left_click or right_click):
        return

    # We have three main cases to handle below:
    #
    # 1. The mouse is holding an item and the inventory slot has an item.
    # 2. The mouse is holding an item and the inventory slot is empty.
    # 3. The mouse is not holding an item and the inventory has one.

    # If the player clicked on the panel and the mouse is holding an item.
    if gui.blueprint:
        # We first get the held item's name to compare it to the slot's item.
        # We'll use it to stack items if they're of the same type.
        var blueprint_name := Library.get_entity_name_from(gui.blueprint)

        # The `held_item` variable tells us if this inventory panel contains an item.
        if is_instance_valid(held_item):
            # If so, we get its name and compare it to the mouse's `blueprint_name`.
            var held_item_name := Library.get_entity_name_from(held_item)
            var item_is_same_type: bool = held_item_name == blueprint_name

            # We then check if this panel's held item stack isn't full.
            var stack_has_space: bool = held_item.stack_count < held_item.stack_size
            if item_is_same_type and stack_has_space:
                # If the player left-clicked, we merge the mouse's entire stack with this one.
                if left_click:
                    _stack_items()
                # With a right-click, we merge half of the mouse's stack with this one.
                elif right_click:
                    _stack_items(true)
            # If the items are not the same name or there is no space, we swap the two items,
            # putting the panel's in the mouse and the mouse's in the panel.
            else:
                if left_click:
                    _swap_items()
        # If this inventory slot is empty,
        else:
            # if the player left-clicks on the slot, we put the item in the slot (the 
            # inventory panel grabs the item from the mouse's inventory).
            if left_click:
                _grab_item()

            # if the player right-clicks, we either put half the mouse's stack in the slot
            # or put the mouse's item in the slot if it can't stack.
            elif right_click:
                if gui.blueprint.stack_count > 1:
                    _grab_split_items()
                else:
                    _grab_item()
    # If the mouse isn't holding any item but there is an item in the slot.
    elif is_instance_valid(held_item):
        # On left-click, we put the slot's item in the mouse's inventory.
        if left_click:
            _release_item()
        # On right-click, we either put the item in the mouse's inventory or split the stack.
        elif right_click:
            if held_item.stack_count == 1:
                _release_item()
            else:
                _split_items()

We then define the methods triggered by our inputs. Note we’ve named them from this slot’s standpoint. For example, _grab_item() grabs an item from the mouse’s inventory, and _release_item() releases the item held in this slot.

## Gets an item from the mouse and stores it in the `held_item` variable.
func _stack_items(split := false) -> void:
    # We first calculate the smaller number between half the mouse's stack and the amount of space 
    # left on the current stack. We don't want to go over or grab more than the mouse has,
    # which is why we pick the smaller number.
    var count := int(
        min(
            gui.blueprint.stack_count / (2 if split else 1),
            held_item.stack_size - held_item.stack_count
        )
    )

    # If we are splitting the mouse's stack, we reduce its `stack_count` by `count` and update
    # its label.
    if split:
        gui.blueprint.stack_count -= count
        gui.update_label()
    else:
        # If we are grabbing as much of the stack as possible, we reduce it by `count`
        # in case we don't have enough space for all of it.
        if count < gui.blueprint.stack_count:
            gui.blueprint.stack_count -= count
            gui.update_label()
        else:
            # Or if the stack is reduced to zero, we destroy it and remove it from the mouse.
            gui.destroy_blueprint()

    # Finally, we increase the held item's stack count by the calculated `count` and update
    # the label.
    held_item.stack_count += count
    _update_label()


## Takes the current held item's stack and swaps it with the mouse's.
func _swap_items() -> void:
    var item: BlueprintEntity = gui.blueprint
    # We set the mouse's blueprint to null here. This calls its setter and ensures
    # that the blueprint is removed from the scene tree, making it available 
    # for the panel to add as a child.
    gui.blueprint = null

    # We store the current item temporarily in a variable. We're about to change
    # what `held_item` points to, but to complete our swap, we need to give this `current_item` 
    # to the mouse's inventory.
    var current_item := held_item
    
    # We then swap the two items, first adding the mouse's item to this inventory slot. 
    # Note the use of `self`. This ensures we call the setter as calling the property 
    # directly from the instance does not call the setter.
    self.held_item = item
    gui.blueprint = current_item


## Grabs the item from the mouse and puts it into the panel's inventory.
func _grab_item() -> void:
    var item: BlueprintEntity = gui.blueprint
    
    # We make sure the blueprint has been released from the mouse so we can grab it.
    gui.blueprint = null
    self.held_item = item


## Releases the item from the panel and puts it into the mouse's inventory.
func _release_item() -> void:
    var item := held_item
    
    # We make sure the blueprint has been released from the panel so the mouse
    # can grab it.
    self.held_item = null
    gui.blueprint = item


## Splits the current panel's inventory's stack and gives half to the mouse's inventory.
func _split_items() -> void:
    # We calculate half of the current stack.
    var count := int(held_item.stack_count / 2.0)

    # We then create a brand new `BlueprintEntity` and set its stack size to what we've calculated.
    # We also reduce the current one by that amount.
    var new_stack := held_item.duplicate()
    new_stack.stack_count = count
    held_item.stack_count -= count

    # Finally, we give the mouse the new stack and update the label.
    gui.blueprint = new_stack
    _update_label()


## Splits the mouse's inventory stack and takes half of it into this item slot.
## The logic is similar to `_split_items()` above, but reversed as we take the
## item from the mouse this time.
func _grab_split_items() -> void:
    var count := int(gui.blueprint.stack_count / 2.0)

    var new_stack: BlueprintEntity = gui.blueprint.duplicate()
    new_stack.stack_count = count
    
    gui.blueprint.stack_count -= count
    gui.update_label()
    
    self.held_item = new_stack

The most important aspect of the methods we defined is being careful with the numbers. We don’t want to introduce bugs where we either take too many items or increase the total count when splitting stacks. The code above takes care of some edge cases, like how we calculate the count in _stack_items().

Notice how we tightly couple the gui node with this one. With such a drag-and-drop system, the objects are interdependent, so I decided to embrace that dependency. Using signals to coordinate passing an item from one slot to the mouse would make the code much harder to follow, mostly due to the stack system and the calculations we have to do to make it work.

Connecting everything

We still need to forward the GUI node to the inventory panels, so open InventoryWindow.tscn and attach a new InventoryWindow.gd script to the root node.

extends MarginContainer

## We use this signal to notify the GUI system that the inventory has changed.
signal inventory_changed(panel, held_item)

## The main GUI node so we can use it to transmit messages and function calls.
var gui: Control

## Get the control that holds the inventory bars. We get this node because we can
## assign the quick-bar to it later.
onready var inventory_path := $PanelContainer/MarginContainer/Inventories

## We get each of the inventory bars in an array so we can iterate over them.
onready var inventories := inventory_path.get_children()


## Here, we define the missing `setup()` function we called from `GUI.gd` in the previous lesson.
## It forwards the setup call to the inventory bars, so they can setup their panels.
func setup(_gui: Control) -> void:
    gui = _gui
    for bar in inventories:
        bar.setup(gui)


## Whenever we receive the `inventory_changed` signal, bubble up the signal from the inventory bars.
func _on_InventoryBar_inventory_changed(panel, held_item) -> void:
    emit_signal("inventory_changed", panel, held_item)

As you can see, it calls for a setup() inside of InventoryBar.gd as well. Open InventoryBar.gd and add the following code.

# Emitted whenever a panel's item changed.
signal inventory_changed(panel, held_item)

## Sets up each of the inventory panels and connects to their `held_item_changed` signal.
func setup(gui: Control) -> void:
    # For each panel we've created in `_ready()`, we forward the reference to the GUI node 
    # and connect to their signal.
    for panel in panels:
        panel.setup(gui)
        panel.connect("held_item_changed", self, "_on_Panel_held_item_changed")


## Bubbles up the signal from the inventory bar up to the inventory window.
func _on_Panel_held_item_changed(panel: Control, held_item: BlueprintEntity) -> void:
    emit_signal("inventory_changed", panel, held_item)

In InventoryWindow.tscn, use the Node tab to connect the Inventory1, Inventory2, and Inventory3 instances of InventoryBar.tscn to InventoryWindow’s _on_InventoryBar_inventory_changed() function.

When using the signal connection popup, be careful to set the Receiver Method to _on_InventoryBar_inventory_changed.

This is going to be preliminary work towards crafting and detecting what recipes are available to the player.

Your node tree should look like this.

Testing the inventory system

Now, we can write some test code to make sure everything works as expected.

We don’t yet have any way to pick up items, but we can use some code to assign items.

Open InventoryWindow.gd, and at the end of setup(), you can add a few lines of code to create a new stack of engines and batteries and assign them. We can remove this code later, once we implemented item pickup.

func setup(_gui: Control) -> void:
    #...
        
    var engine: BlueprintEntity = Library.blueprints.StirlingEngine.instance()
    engine.stack_count = 4
    var battery: BlueprintEntity = Library.blueprints.Battery.instance()
    battery.stack_count = 4
    inventories[0].panels[0].held_item = engine
    inventories[0].panels[1].held_item = battery

Now, if you run the game, you should get the following.

It doesn’t look right, but it is working! You can click on items to pick them up, split them with right-click, re-stack them, move them from item to item, swap them, so on and so forth, and the numbers stay up to date.

We’re getting closer. We need to fix that size and position issue and then connect them to the EntityPlacer.

Code reference

Here’s the code reference for this lesson.

InventoryPanel.gd

class_name InventoryPanel
extends Panel

signal held_item_changed(panel, item)

var held_item: BlueprintEntity setget _set_held_item

var gui: Control

onready var count_label := $Label


func _gui_input(event: InputEvent) -> void:
    var left_click := event.is_action_pressed("left_click")
    var right_click := event.is_action_pressed("right_click")

    if not (left_click or right_click):
        return

    if gui.blueprint:
        var blueprint_name := Library.get_entity_name_from(gui.blueprint)

        if is_instance_valid(held_item):
            var held_item_name := Library.get_entity_name_from(held_item)
            var item_is_same_type: bool = held_item_name == blueprint_name

            var stack_has_space: bool = held_item.stack_count < held_item.stack_size
            if item_is_same_type and stack_has_space:
                if left_click:
                    _stack_items()
                elif right_click:
                    _stack_items(true)
            else:
                if left_click:
                    _swap_items()
        else:
            if left_click:
                _grab_item()

            elif right_click:
                if gui.blueprint.stack_count > 1:
                    _grab_split_items()
                else:
                    _grab_item()
    elif is_instance_valid(held_item):
        if left_click:
            _release_item()
        elif right_click:
            if held_item.stack_count == 1:
                _release_item()
            else:
                _split_items()


func setup(_gui: Control) -> void:
    gui = _gui


func _set_held_item(value: BlueprintEntity) -> void:
    if is_instance_valid(held_item) and held_item.get_parent() == self:
        remove_child(held_item)

    held_item = value

    if is_instance_valid(held_item):
        add_child(held_item)
        move_child(held_item, 0)

    _update_label()
    emit_signal("held_item_changed", self, held_item)


func _update_label() -> void:
    var can_be_stacked := is_instance_valid(held_item) and held_item.stack_count > 1

    if can_be_stacked:
        count_label.text = str(held_item.stack_count)
        count_label.show()
    else:
        count_label.text = str(1)
        count_label.hide()


func _stack_items(split := false) -> void:
    var count := int(
        min(
            gui.blueprint.stack_count / (2 if split else 1),
            held_item.stack_size - held_item.stack_count
        )
    )

    if split:
        gui.blueprint.stack_count -= count
        gui.update_label()
    else:
        if count < gui.blueprint.stack_count:
            gui.blueprint.stack_count -= count
            gui.update_label()
        else:
            gui.destroy_blueprint()

    held_item.stack_count += count
    _update_label()


func _swap_items() -> void:
    var item: BlueprintEntity = gui.blueprint
    gui.blueprint = null

    var current_item := held_item

    self.held_item = item
    gui.blueprint = current_item


func _grab_item() -> void:
    var item: BlueprintEntity = gui.blueprint

    gui.blueprint = null
    self.held_item = item


func _release_item() -> void:
    var item := held_item

    self.held_item = null
    gui.blueprint = item


func _split_items() -> void:
    var count := int(held_item.stack_count / 2.0)

    var new_stack := held_item.duplicate()
    new_stack.stack_count = count
    held_item.stack_count -= count

    gui.blueprint = new_stack
    _update_label()


func _grab_split_items() -> void:
    var count := int(gui.blueprint.stack_count / 2.0)

    var new_stack: BlueprintEntity = gui.blueprint.duplicate()
    new_stack.stack_count = count

    gui.blueprint.stack_count -= count
    gui.update_label()

    self.held_item = new_stack

InventoryWindow.gd

extends MarginContainer

signal inventory_changed(panel, held_item)

var gui: Control

onready var inventory_path := $PanelContainer/MarginContainer/Inventories

onready var inventories := inventory_path.get_children()


func setup(_gui: Control) -> void:
    gui = _gui
    for bar in inventories:
        bar.setup(gui)


func _on_InventoryBar_inventory_changed(panel, held_item) -> void:
    emit_signal("inventory_changed", panel, held_item)

InventoryBar.gd

class_name InventoryBar
extends HBoxContainer

signal inventory_changed(panel, held_item)

export var InventoryPanelScene: PackedScene

export var slot_count := 10

var panels := []


func _ready() -> void:
    _make_panels()


func setup(gui: Control) -> void:
    for panel in panels:
        panel.setup(gui)
        panel.connect("held_item_changed", self, "_on_Panel_held_item_changed")

func _make_panels() -> void:
    for _i in slot_count:
        var panel := InventoryPanelScene.instance()
        add_child(panel)
        panels.append(panel)


func _on_Panel_held_item_changed(panel: Control, held_item: BlueprintEntity) -> void:
    emit_signal("inventory_changed", panel, held_item)