Designing the crafting GUI

In this lesson, we’ll design the crafting system’s base interface.

The player will use it to turn items that are in their inventory into tools.

We have three tasks to do to make that system a reality: having access to recipes, a GUI to craft with, and making the crafting system interact with the inventory.

Writing a class for recipes

Our crafting system will use predetermined recipes:

That’s the sort of information you need for crafting and machine automation. To represent recipes in code, we create a static class that lists recipes inside dictionaries.

In the FileSystem, create a new script, Recipes.gd.

We will not instantiate it, so make it extend Reference. This makes it explicit at a glance that it will not end up in the scene tree, as it’s not a node.

Give it a class name of Recipes, and from there, we can create dictionaries for crafting purposes.

Providing a class name gives us access to this class and its constants from any other script using its name.

class_name Recipes
extends Reference


## We store crafting recipes in this constant. We can refer to this from the
## crafting GUI to determine what the player can craft with what they have and
## then craft it.
## 
## Each recipe is keyed to the kind of entity it creates, with the value being a
## dictionary with `inputs`, a dictionary of items and their required amount.
## The `amount` key tells us how many items the recipe produces.
const Crafting := {
    StirlingEngine = {inputs = {"Ingot": 8, "Wire": 3}, amount = 1},
    Wire = {inputs = {"Ingot": 2}, amount = 5},
    Battery = {inputs = {"Ingot": 12, "Wire": 5}, amount = 1}
}

Whenever you create a new entity or blueprint and want the player to craft it from their inventory, you can add its recipe using the same format.

Speaking of, we introduced a new item inside those recipes: the metal ingot. We should create it, so we have something to test once we finish the crafting system.

Creating the metal ingot blueprint

Create a new scene with a Node2D named IngotBlueprint as its root, and add a Sprite as its child. Assign the BlueprintEntity.gd script to IngotBlueprint.

Assign the Sprite the blueprints.svg texture, enable texture regions, and select the shiny metal ingot inside the TextureRegion bottom panel.

The ingot sprite is the one outlined in white below.

This blueprint entity should not be placeable, like the branches and rocks, and should be stackable. Update its properties in the Inspector accordingly.

Save the scene as IngotBlueprint.tscn inside the Entities/Blueprints/ directory.

Like other blueprints, the Library autoload will automatically find it, so long as you named it with the Blueprint suffix. The autoload will also register it under the name of “Ingot.”

Head back to the Simulation scene, select the GUI node, and add five ingots when starting the game using its Debug Items property.

If you test the game, you should see them in the quick bar, with the item ordered by name.

Designing the crafting GUI

Let’s create some interface to list crafting recipes next to the inventory. Here’s the result towards which we’ll work.

If we look at the desired output, we can analyze the layout to create a GUI scene around it.

The recipe list floats to the side of the inventory and has a background panel. It has a margin from the edge, the contents are inside a scroll list, and each recipe is inside its own panel with an icon and some.

When the mouse hovers over one of them, the panel lights up, and the required ingredients display on the tooltip.

That gives us enough information to begin working. We can start with the recipe items themselves before we create the window they’ll live in.

Create a new scene with a PanelContainer as its root node named CraftingRecipeItem and save it in a new GUI/Crafting/ directory.

Note that the properties we’ll set in this scene are for preview purposes. When we start scripting, we’ll use the Inventory Size property we defined in the Project Settings window in the previous chapter.

We want to display the icon of the crafted object and its name next to each other.

To prevent them from butting up against the edge, we add a MarginContainer, with an HBoxContainer child to line up the text and texture.

We set the MarginContainer’s Custom Constants to several pixels to define our margins.

We can use a Label for the text, but what node type could we use to display the icon?

TextureRect displays images, but it does not support texture regions, to’t have it use a specific part of the sprite sheet.

Sprite does, but it does not have the screen space rectangle math that Godot’s GUI system expects. If we add a Sprite, we need to set the size manually.

Here’s a wild idea: we’ll create a custom UI element, our own GUISprite node.

GUISprite node

Create a new script in the FileSystem, GUISprite.gd, that extends Control. You can save it in the GUI/ folder.

We’ll reproduce the functionality of Sprite by exporting four properties: texture, region_enabled, region_rect and scale variables.

We’ll use setter functions to automatically resize the Control to then draw the texture on the screen using the built-in _draw() callback.

To visualize everything in the editor, we can make it a tool script. Tool scripts run both in the editor and in game, so we get a live preview of everything we’re doing.

## The tool keyword makes Godot run the script in the editor.
tool
## Giving the class a name lets us instantiate this node in the scene tree.
class_name GUISprite
## Extending the Control class will make the sprite play nice with GUI
## containers without needing manual control from us.
extends Control


## The four properties below simulate those of `Sprite` nodes.
## Each uses a setter to update the node's bounds and draw the sprite correctly.
export var texture: Texture setget _set_texture
export var region_enabled: bool = false setget _set_region_enabled
export var region_rect: Rect2 = Rect2() setget _set_region_rect
export var scale := Vector2.ONE setget _set_scale


## Godot calls _draw() once when instantiating the node and toggling visibility.
## Otherwise, it displays whatever it drew last.
## To trigger a redraw, you can call the `CanvasItem.update()` method.
## We use Godot's drawing functions to draw the sprite either as a whole or with
## a region selected.
func _draw() -> void:
    if not texture:
        return

    if region_enabled:
        # Draws a texture within the node's bounding box. using the
        # `region_rect` to sample from the texture.
        #
        # Note that the `_draw()` function draws relative to its node's
        # position.
        # That's why we pass in `Vector2.ZERO` for the `Rect2`'s position below:
        # it means 'draw where this node is with no offset.'
        draw_texture_rect_region(texture, Rect2(Vector2.ZERO, rect_size), region_rect)
    else:
        # Draws the entire texture inside the given rectangle on screen.
        draw_texture_rect(texture, Rect2(Vector2.ZERO, rect_size), false)


## Each setter sets the provided value, then calls `_update_region()` to force
## an update to the `Control` values.
func _set_texture(value: Texture) -> void:
    texture = value
    _update_region()


func _set_region_enabled(value: bool) -> void:
    region_enabled = value
    _update_region()


func _set_region_rect(value: Rect2) -> void:
    region_rect = value
    _update_region()


func _set_scale(value: Vector2) -> void:
    scale = value
    _update_region()


# Sets the Control's minimum rectangle size and prompts a texture redraw.
func _update_region() -> void:
    if region_enabled:
        rect_min_size = region_rect.size * scale
    else:
        if texture:
            rect_min_size = texture.get_size() * scale
        else:
            rect_min_size = Vector2.ZERO

    # Asks Godot to call the `_draw()` function on the next drawing update.
    update()

Finishing the crafting GUI

Now that we have a custom GUI element, we can create an instance of GUISprite as a child of the HBoxContainer, along with a Label for the text.

Note if the GUISprite doesn’t appear in the Add Node window, you will need to close and reopen the scene for available node types to refresh.

We can configure the GUISprite with the blueprints.svg texture and set its region in the Inspector.

One downside of this approach is that we don’t have access to the TextureRegion tool. Still, you can open the BatteryBlueprint.tscn scene and use the TextureRegion bottom panel there to get the correct region values.

They are:

We also set the Scale to (0.5, 0.5) to match the rest of the inventory.

If the containers’ size doesn’t refresh when changing some values, toggle the node’s visibility and they will resize.

These values are for testing purposes. We will use a script to override this information with the recipe’s data.

The last thing to do is to ensure the mouse can interact with the recipe.

For that, set the MarginContainer and GUISprite’s Mouse Filter property to Ignore.

This will ensure that the mouse signal reaches the CraftingRecipe panel.

Label’s Mouse Filter should default to Ignore, and HBoxContainer’s to Pass, but double check they are not set to Stop. Otherwise, they will consume mouse input.

You can also set the Label’s Text and assign our builder_theme.tres resource to the root node’s Theme property to get a preview of the final look.

Designing the crafting recipe list

We have the GUI base for the recipe items, and we can now create the actual crafting window.

Create a new scene with a MarginContainer node named CraftingGUI as its root, with all four Custom Constants margins set to 25 or so pixels, to taste.

Save the scene as CraftingGUI.tscn inside the GUI/Crafting/ directory.

For the background, add a PanelContainer node child. To space the contents from the panel, add a second MarginContainer as its child and set its Custom Constants to 20 pixels.

To add the ability to scroll recipes, add a ScrollContainer node as a child, with its Scroll -> Horizontal Enabled property turned off.

This removes the horizontal scroll bar.

To stack the recipe items vertically, finish the cascade of children up with a VBoxContainer node.

You can set its Custom Constants -> Separation property to 5 to have a 5-pixel gap between crafting panels.

Your scene tree should look like the following.

The last thing to do is select all of those nodes and make sure that their Mouse Filter property is not set to Stop, so the game can keep receiving mouse inputs. I set their Mouse Filter to Pass.

It doesn’t look like much right now, but it’s ready to list crafting recipes.

In the next lessons, though, we’ll give it some life with some scripting. We’ll analyze the inventory and populate the list with the recipe items.

Code reference

Here are the two scripts we added in this part.

Recipes.gd

class_name Recipes
extends Reference

const Crafting := {
    StirlingEngine = {inputs = {"Ingot": 8, "Wire": 3}, amount = 1},
    Wire = {inputs = {"Ingot": 2}, amount = 5},
    Battery = {inputs = {"Ingot": 12, "Wire": 5}, amount = 1}
}

GUISprite.gd

tool
class_name GUISprite
extends Control


export var texture: Texture setget _set_texture
export var region_enabled: bool = false setget _set_region_enabled
export var region_rect: Rect2 = Rect2() setget _set_region_rect
export var scale := Vector2.ONE setget _set_scale


func _draw() -> void:
    if not texture:
        return

    if region_enabled:
        draw_texture_rect_region(texture, Rect2(Vector2.ZERO, rect_size), region_rect)
    else:
        draw_texture_rect(texture, Rect2(Vector2.ZERO, rect_size), false)


func _set_texture(value: Texture) -> void:
    texture = value
    _update_region()


func _set_region_enabled(value: bool) -> void:
    region_enabled = value
    _update_region()


func _set_region_rect(value: Rect2) -> void:
    region_rect = value
    _update_region()


func _set_scale(value: Vector2) -> void:
    scale = value
    _update_region()


func _update_region() -> void:
    if region_enabled:
        rect_min_size = region_rect.size * scale
    else:
        if texture:
            rect_min_size = texture.get_size() * scale
        else:
            rect_min_size = Vector2.ZERO

    update()