Adding special GUIs to machines

We’ve reached the part of the chapter where we begin diving into automated crafting.

We have all that electricity coming out of a perpetual Stirling engine, and all we can do is store it in the battery. We need to put that energy to good use.

By the end of the chapter, we’ll have ore turning into ingots and lumber into charcoal.

The first step towards this illustrious result is to give entities and machines the ability to hold an inventory of their own.

They’ll have three item slots: two for the inputs, namely fuel and raw material, and the third will hold the machine’s output, like a metal ingot.

In this lesson, we’ll code the UI system’s foundations so that entities can hold an inventory.

In the next lesson, we’ll create a chest entity that can hold some of your extra resources and old tools to test the result.

The machine GUI class and component

To give machines a dedicated interface and detect it, we’ll create a new component for them.

This is a special node that will live as a child of machines that have a dedicated interface. It will hold a GUI scene instance in memory.

Here’s how we’ll make it work:

  1. When we want to open the machine’s GUI, we’ll grab that instanced scene and make it a child of the inventory window so that we can transfer items to and from our inventory.
  2. When the player closes the inventory, we’ll remove the machine’s GUI from the scene tree, but it will continue to live as an un-attached node in the component’s variables.

In our implementation, items live inside inventory panels, so we need to keep the interface in memory at all times.

Let’s get coding the system’s foundations.

To identify the machines’ GUI and give them some unique functionality, we’ll make every machine GUI extend a dedicated class.

Create a new script, BaseMachineGUI.gd, that extends MarginContainer. You can save it in the GUI/ directory.

class_name BaseMachineGUI
extends MarginContainer

## Emitted when a change happens, like an item being put into an inventory slot.
signal gui_status_changed

## We emit two signals to indicate when the interface opens and closes.
signal gui_opened
signal gui_closed


func _enter_tree() -> void:
    # Whenever the node appears in the scene tree, we emit "gui_opened", but we
    # emit it after one frame late using `call_deferred()`.
    # This gives us the chance to call the `setup()` function below and
    # initialize the interface first.
    call_deferred("emit_signal", "gui_opened")


func _exit_tree() -> void:
    call_deferred("emit_signal", "gui_closed")


## Sets up anything the interface needs before use. For example, we'll use the
## `InventoryBar` class, which needs a reference to the `GUI` class.
## We'll override this function in extended classes.
func setup(_gui: Control) -> void:
    pass

We’ll now code a component GUI class to detect machines with an inventory window.

Create a new script, GUIComponent.gd, in the Systems/ directory.

Like with the previous components, this class extends Node.

Its job is to instance its GUI scene and prepare it for the GUI class to add to the scene tree whenever needed.

class_name GUIComponent
extends Node

## We'll bubble up signals from `MachineGUI` to make it easier to connect to
## them.
signal gui_status_changed
signal gui_opened
signal gui_closed

## This will store the instanced interfact for the machine.
var gui: BaseMachineGUI

## This is the scene we'll instance and save in the `gui` variable.
export var GuiWindow: PackedScene


func _ready() -> void:
    # We must assign a scene to the `GuiWindow` for this code to work.
    assert(GuiWindow, "You must assign a scene to the the Gui Window property.")
    gui = GuiWindow.instance()

    # We connect to the new instance's signals, but instead of using a custom
    # function, we call `emit_signal()` while passing the signal name as a
    # parameter.
    # Doing this forwards signals automatically.
    gui.connect("gui_status_changed", self, "emit_signal", ["gui_status_changed"])
    gui.connect("gui_opened", self, "emit_signal", ["gui_opened"])
    gui.connect("gui_closed", self, "emit_signal", ["gui_closed"])


func _exit_tree() -> void:
    if gui:
        # Because the gui instance is not likely to be in the scene tree when we
        # quit the game or go back to the main menu, it will not be
        # automatically be cleaned up in memory.
        #
        # Freeing it when this component exits the scene tree saves us from
        # having a memory leak.
        gui.queue_free()

Having the GUI open the machine GUI

When the user clicks on an entity, like a chest, we must get its GUIComponent, grab that component’s gui property, and add it to the inventory window.

When we close the inventory, we need to remove the machine’s interface from the scene tree.

While EntityPlacer is the one who will keep track of when we click on an entity, GUI is the one that will do most of the work.

Let’s start there: open up GUI.gd to add some functionality.

We need to keep track of the machine’s GUI to remove it later. We also want to make it so the crafting window does not open when we open a machine’s GUI.

#...
## The currently opened entity GUI.
var _open_gui: Control


## Returns a `GUIComponent` from the scene tree of an entity.
## Returns `null` if none is found.
func get_gui_component_from(entity: Node) -> GUIComponent:
    for child in entity.get_children():
        if child is GUIComponent:
            return child

    return null


## Adds the entity's GUI to the inventory window and displays the inventory
## window.
func open_entity_gui(entity: Entity) -> void:
    var component := get_gui_component_from(entity)
    if not component:
        return

    # If the inventory window is already open, we close it first. This ensures
    # we close any currently opened gui from another entity first.
    if is_open:
        _close_inventories()

    _open_gui = component.gui

    # If the gui is not already in the inventory window, we add it. We also
    # raise the child to the highest tree position so it appears above the
    # inventory, instead of below it.
    # That's necessary because the inventory uses a VBoxContainer.
    if not _open_gui.get_parent() == player_inventory.inventory_path:
        player_inventory.inventory_path.add_child(_open_gui)
        player_inventory.inventory_path.move_child(_open_gui, 0)

    # We make sure to call `BaseMachineGUI.setup()`, then call
    # `open_inventories()`, but without the crafting window.
    _open_gui.setup(self)
    # See below as we add a new argument to `_open_inventories()`.
    _open_inventories(false)


# We add a new `open_crafting` argument to the function to optionally hide the
# crafting recipe list.
# Making `open_crafting` an optional variable that defaults to `true` means we
# don't have to go back to other functions and change them.
func _open_inventories(open_crafting := true) -> void:
    #...
    #player_inventory.claim_quickbar(quickbar)

    # If we should open the crafting window, then make it visible and update its
    # recipes. Otherwise, we leave it hidden.
    if open_crafting:
        # These two lines were already there, we just move them into the
        # conditional branch.
        crafting_window.visible = true
        crafting_window.update_recipes()


func _close_inventories() -> void:
    #...
    #_claim_quickbar()

    # If we have an open gui, then we remove it from the scene tree and clear
    # the `_open_gui` property.
    # We don't free it: that's the component's job.
    if _open_gui:
        player_inventory.inventory_path.remove_child(_open_gui)
        _open_gui = null

Like we did with power components, we can use groups to identify machines with a GUI component.

Head to Types.gd to add a new constant for entities with GUI.

const GUI_ENTITIES := "gui_entities"

Finally, we need to trigger that open_entity_gui() function from somewhere, and that will be in EntityPlacer.gd.

So open that script up and head to _unhandled_input() to modify the “left_click” action.

func _unhandled_input(event: InputEvent) -> void:
    #...
    #if event.is_action_pressed("left_click"):
        #if has_placeable_blueprint:
            #...

        # If we are not holding onto a blueprint and we click on an occupied
        # cell in range, and the entity is part of the GUI_ENTITIES, then call
        # GUI's `open_entity_gui()`.
        elif cell_is_occupied and is_close_to_player:
            var entity := _tracker.get_entity_at(cellv)
            if entity and entity.is_in_group(Types.GUI_ENTITIES):
                _gui.open_entity_gui(entity)
                _clear_hover_entity(cellv)
    #...

We have the system laid out. All that’s left is to create the chest entity and its GUI.

This lesson is getting a little long, so we’ll do that in the next one.

Code reference

BaseMachineGUI.gd

class_name BaseMachineGUI
extends MarginContainer

signal gui_status_changed

signal gui_opened
signal gui_closed


func _enter_tree() -> void:
    call_deferred("emit_signal", "gui_opened")


func _exit_tree() -> void:
    call_deferred("emit_signal", "gui_closed")


func setup(_gui: Control) -> void:
    pass

GUIComponent.gd

class_name GUIComponent
extends Node

signal gui_status_changed
signal gui_opened
signal gui_closed

var gui: BaseMachineGUI

export var GuiWindow: PackedScene


func _ready() -> void:
    assert(GuiWindow, "You must assign a scene to the the Gui Window property.")
    gui = GuiWindow.instance()

    gui.connect("gui_status_changed", self, "emit_signal", ["gui_status_changed"])
    gui.connect("gui_opened", self, "emit_signal", ["gui_opened"])
    gui.connect("gui_closed", self, "emit_signal", ["gui_closed"])


func _exit_tree() -> void:
    if gui:
        gui.queue_free()

In GUI.gd, we added one property, _open_gui, two functions, get_gui_component_from() and open_entity_gui(), and modified two existing functions, _open_inventories() and _close_inventories().

var _open_gui: Control


func get_gui_component_from(entity: Node) -> GUIComponent:
    for child in entity.get_children():
        if child is GUIComponent:
            return child

    return null


func open_entity_gui(entity: Entity) -> void:
    var component := get_gui_component_from(entity)
    if not component:
        return

    if is_open:
        _close_inventories()

    _open_gui = component.gui

    if not _open_gui.get_parent() == player_inventory.inventory_path:
        player_inventory.inventory_path.add_child(_open_gui)
        player_inventory.inventory_path.move_child(_open_gui, 0)

    _open_gui.setup(self)
    _open_inventories(false)


func _open_inventories(open_crafting := true) -> void:
    is_open = true
    player_inventory.visible = true
    player_inventory.claim_quickbar(quickbar)

    if open_crafting:
        crafting_window.visible = true
        crafting_window.update_recipes()


func _close_inventories() -> void:
    is_open = false
    player_inventory.visible = false
    _claim_quickbar()
    crafting_window.visible = false

    if _open_gui:
        player_inventory.inventory_path.remove_child(_open_gui)
        _open_gui = null

In Types.gd, we added one constant to keep track of a group name.

const GUI_ENTITIES := "gui_entities"

And in EntityPlacer.gd, we updated the _unhandled_input() callback.

func _unhandled_input(event: InputEvent) -> void:
    if event is InputEventMouseButton:
        _abort_deconstruct()

    var global_mouse_position := get_global_mouse_position()
    
    var has_placeable_blueprint: bool = _gui.blueprint and _gui.blueprint.placeable

    var is_close_to_player := (
        global_mouse_position.distance_to(_player.global_position)
        < MAXIMUM_WORK_DISTANCE
    )
    
    var cellv := world_to_map(global_mouse_position)
    var cell_is_occupied := _tracker.is_cell_occupied(cellv)
    var is_on_ground := _ground.get_cellv(cellv) == 0

    if event.is_action_pressed("left_click"):
        if has_placeable_blueprint:
            if not cell_is_occupied and is_close_to_player and is_on_ground:
                _place_entity(cellv)
                _update_neighboring_flat_entities(cellv)

        elif cell_is_occupied and is_close_to_player:
            var entity := _tracker.get_entity_at(cellv)
            if entity and entity.is_in_group(Types.GUI_ENTITIES):
                _gui.open_entity_gui(entity)
                _clear_hover_entity(cellv)

    elif event.is_action_pressed("right_click") and not has_placeable_blueprint:
        if cell_is_occupied and is_close_to_player:
            _deconstruct(global_mouse_position, cellv)

    elif event is InputEventMouseMotion:
        if cellv != _current_deconstruct_location:
            _abort_deconstruct()
        
        if has_placeable_blueprint:
            _move_blueprint_in_world(cellv)
        else:
            _update_hover(cellv)

    elif event.is_action_pressed("drop") and _gui.blueprint:
        if is_on_ground:
            _drop_entity(_gui.blueprint, global_mouse_position)
            _gui.blueprint = null
    
    elif event.is_action_pressed("rotate_blueprint") and _gui.blueprint:
        _gui.blueprint.rotate_blueprint()