Creating items with crafting recipes

We have recipes, we can detect that we have the required materials, and we can display those recipes to the user.

The next thing to do is to craft the items when the user clicks on a recipe. Doing so should consume the prerequisite materials.

Crafting items

CraftingGUI happens to have a reference to the GUI, which gives it access to the inventory through it. We can emit signals from the CraftingRecipeItem nodes and bubble them back up to GUI when the player clicks on them.

We can detect a mouse click on a user interface using _gui_input().

Head to CraftingRecipeItem.gd to add both a new signal and input code.

signal recipe_activated(recipe, output)


func _gui_input(event: InputEvent) -> void:
    if event.is_action_pressed("left_click"):
        var recipe_filename: String = recipe_name.recipe_name
        var recipe: Dictionary = ReciPes.Crafting[recipe_filename]

        emit_signal("recipe_activated", recipe, recipe_filename)

We can’t use the Inspector to connect this new signal since we instance recipe panels at runtime. So we have to connect via code, when we initialize the CraftingGUI.

Head to CraftingGUI.gd’s update_recipes() function, and right after we call setup(), we connect to the recipe_activated signal.

func update_recipes() -> void:
    #...

    # for output in Recipes.Crafting.keys():
        #...

        # item.setup(
            # Library.get_entity_name_from(temp),
            # sprite.texture,
            # sprite.region_enabled,
            # sprite.region_rect
        # )
        item.connect("recipe_activated", self, "_on_recipe_activated")
        #temp.free()

What should happen when we craft an item?

We need to find the panels that hold the items we want to consume, reduce them by the recipe’s amount, create a new item, and put it in the player’s inventory.

## Crafts the output item by consuming the recipe's inputs from the player
## inventory.
func _on_recipe_activated(recipe: Dictionary, output: String) -> void:
    # We loop over every input and find inventory panels containing this item.
    for input in recipe.inputs.keys():
        var panels: Array = gui.find_panels_with(input)
        var count: int = recipe.inputs[input]

        # We then loop over the panels and update their count.
        for panel in panels:
            # If there is enough in the stack, we reduce it by the required
            # amount.
            if panel.held_item.stack_count >= count:
                panel.held_item.stack_count -= count
                # Since we had enough items, make count 0
                count = 0
            # If there isn't enough, we reduce the required count by how many there
            # are, then set the stack to 0.
            else:
                count -= panel.held_item.stack_count
                panel.held_item.stack_count = 0

            # If the stack is now a size of 0, we delete it.
            if panel.held_item.stack_count == 0:
                panel.held_item.queue_free()
                panel.held_item = null

            # And we update the count label up to date if it hasn't been
            # deleted.
            panel._update_label()
            
            # If count is now 0, then we no longer need to check any other panel
            if count == 0:
                break

    # Now that we've consumed all items, we can use the library to instance a
    # new blueprint for the new item, and add it to the player inventory.
    var item: BlueprintEntity = Library.blueprints[output].instance()
    item.stack_count = recipe.amount

    gui.add_to_inventory(item)

If you run the game now and craft a set of wires, you should see the ingot count go down by two, and a pack of five wires gets added to your inventory.

But right now, there’s a bug: you can keep on crafting wires until you close and re-open the inventory, even if you no longer have enough ingots.

This is because we are not updating the recipe list when the inventory changes.

If we pick up an item or get rid of some items while the inventory is open, we should update it.

Head to GUI.tscn and connect to InventoryWindow’s inventory_changed signal to the GUI node.

Also, connect the QuickBar’s inventory_changed signal to the same _on_InventoryWindow_inventory_changed() function.

That way, all inventories and quick bars will report when they change.

In GUI.gd, the callback function should call CraftingGUI.update_recipes().

func _on_InventoryWindow_inventory_changed(panel, held_item) -> void:
    crafting_window.update_recipes()

Now, once you run out of materials, the recipe should disappear.

In fact, if you start the game with 10 ingots and craft one bundle of wires, you should see the Stirling Engine appear in the recipe list.

There’s one last bug we can squash: when you craft all the wires you can, the tooltip stays visible, even when the recipe disappears.

To fix it, we need to emit the hovered_over_entity signal at the end of CraftingGUI.update_recipes().

func update_recipes() -> void:
        #...
        #item.connect("recipe_activated", self, "_on_recipe_activated")
        #temp.free()

    Events.emit_signal("hovered_over_entity", null)

Hovering feedback

The tooltip tells the player they’re hovering over a recipe, but err on the side of “it’s better than nothing”.

Let’s improve the visual feedback by changing the CraftingRecipeItem panel’s color on hover, like a button.

We can use the custom style properties to change how a GUI item behaves without changing the theme itself. Custom styles only affect this node, unlike the theme resource.

In this project, panel background styles rely on StyleBoxFlat resources.

We can add a couple of those as exported variables and slot them in as needed.

We’ll add two: one for the regular color when the mouse isn’t over the recipe, and another to use on hover.

Head to CraftingRecipeItem.gd and let’s add some custom panels.

export var regular_style: StyleBoxFlat
export var highlight_style: StyleBoxFlat

To apply a custom style from code, we need to use the set() function, because custom styles aren’t accessible as regular variables.

Instead, they’re part of Godot’s theme API.

To update the styles, we need to add code to existing functions.

## We store the property path for the custom panel in a constant for easy
## reference.
const CUSTOM_PANEL_PROPERTY := "custom_styles/panel"


# If we set styles in the inspector, we should use it as soon as the item is
# instanced.
# We define the `_ready()` function to do that.
func _ready() -> void:
    if regular_style:
        set(CUSTOM_PANEL_PROPERTY, regular_style)


func _on_CraftingRecipe_mouse_entered() -> void:
    #...
    set(CUSTOM_PANEL_PROPERTY, highlight_style)


# And we update the styles by adding code to our existing callback functions.
func _on_CraftingRecipe_mouse_exited() -> void:
    #...
    set(CUSTOM_PANEL_PROPERTY, regular_style)

We now need to add two styleboxes to the CraftingRecipeItem.

Open CraftingRecipeItem.tscn and assign our existing inventory_panel_style.tres and inventory_panel_light.tres styleboxes to the node’s Regular Style and Highlight Style property, respectively.

If you hover the recipes, they should now light up, making it clearer you can interact with them.

Changing the crafting window’s scale and width

If you remember, we added a custom setting in our project settings to change the panels’ size based on inventory items’ size.

But if you tweak the value now, it won’t change the size. We can fix it, though.

While we’re there, we can also force the crafting window’s size to be a minimum width.

Open up CraftingRecipeItem.gd to add that functionality, in the _ready() function.

func _ready() -> void:
    #...
    var gui_size: float = ProjectSettings.get_setting("game_gui/inventory_size")
    # Blueprint sprites are 100 pixels in size. We calcualte the desired scale
    # modifier by dividing the provided size by 100.
    var scale := gui_size / 100.0

    # Then we can scale the sprite's size and the minimum size of the crafting
    # window based on a constant 300 pixels, our desired base width.
    sprite.scale *= Vector2(scale, scale)
    rect_min_size = Vector2(300, 0) * scale

If you’re going to make your GUI larger, you may want to go into Shared/Theme/Font in the FileSystem dock and update the font resource’s Size property to be bigger too.

Conclusion

There’s a lot of back and forth to keep the code clean and sequestered, but the result is a simple crafting system that works with the inventory system we already coded.

We’ll add some more items we can craft in the following lessons: tools to chop trees and mine boulders with.

Code reference

Here are the functions and properties we added or modified in this lesson.

CraftingRecipeItem.gd

signal recipe_activated(recipe, output)

const CUSTOM_PANEL_PROPERTY := "custom_styles/panel"

export var regular_style: StyleBoxFlat
export var highlight_style: StyleBoxFlat


func _ready() -> void:
    if regular_style:
        set(CUSTOM_PANEL_PROPERTY, regular_style)
    var gui_size: float = ProjectSettings.get_setting("game_gui/inventory_size")
    var scale := gui_size / 100.0

    sprite.scale *= Vector2(scale, scale)
    rect_min_size = Vector2(300, 0) * scale


func _gui_input(event: InputEvent) -> void:
    if event.is_action_pressed("left_click"):
        var recipe_filename: String = recipe_name.recipe_name
        var recipe: Dictionary = Recipes.Crafting[recipe_filename]

        emit_signal("recipe_activated", recipe, recipe_filename)


func _on_CraftingRecipeItem_mouse_entered() -> void:
    var recipe_filename: String = recipe_name.recipe_name
    Events.emit_signal("hovered_over_recipe", recipe_filename, Recipes.Crafting[recipe_filename])
    set(CUSTOM_PANEL_PROPERTY, highlight_style)


func _on_CraftingRecipeItem_mouse_exited() -> void:
    Events.emit_signal("hovered_over_entity", null)
    set(CUSTOM_PANEL_PROPERTY, regular_style)

We connected the inventory and quick bar’s inventory_changed signal to a new callback function in GUI.gd.

func _on_InventoryWindow_inventory_changed(panel, held_item) -> void:
    crafting_window.update_recipes()

In CraftingGUI.gd, we modified update_recipes() and added _on_recipe_activated()

func update_recipes() -> void:
    for child in items.get_children():
        child.queue_free()

    for output in Recipes.Crafting.keys():
        var recipe: Dictionary = Recipes.Crafting[output]

        var can_craft := true

        for input in recipe.inputs.keys():
            if not gui.is_in_inventory(input, recipe.inputs[input]):
                can_craft = false
                break

        if not can_craft:
            continue

        var temp: BlueprintEntity = Library.blueprints[output].instance()

        var item := CraftingItem.instance()
        items.add_child(item)

        var sprite: Sprite = temp.get_node("Sprite")

        item.setup(
            Library.get_entity_name_from(temp),
            sprite.texture,
            sprite.region_enabled,
            sprite.region_rect
        )
        item.connect("recipe_activated", self, "_on_recipe_activated")
        temp.free()

    Events.emit_signal("hovered_over_entity", null)


func _on_recipe_activated(recipe: Dictionary, output: String) -> void:
    for input in recipe.inputs.keys():
        var panels: Array = gui.find_panels_with(input)
        var count: int = recipe.inputs[input]

        for panel in panels:
            if panel.held_item.stack_count >= count:
                panel.held_item.stack_count -= count
                count = 0
            else:
                count -= panel.held_item.stack_count
                panel.held_item.stack_count = 0

            if panel.held_item.stack_count == 0:
                panel.held_item.queue_free()
                panel.held_item = null

            panel._update_label()
            
            if count == 0:
                break

    var item: BlueprintEntity = Library.blueprints[output].instance()
    item.stack_count = recipe.amount

    gui.add_to_inventory(item)