Scripting crafting recipes

We have the necessary scenes to craft objects, but we have to tie it all together to make it work.

We’ll start with scripting the recipe items first. We need to have them display the right name and icon, respond to mouse input, and have a way of signaling the rest of the game that the user made a selection.

Displaying the recipe’s name

As we build the recipe list, we’ll get each recipe’s name in PascalCase, as registered in the Library.

But to display it on the interface, we have to tweak it. It would not look correct for the player.

Multi-word names should have spaces separating them, for example. We can add a script to the Label to node do that for us.

Head on back to CraftingRecipeItem.tscn and attach a new script, RecipeName.gd, to the Label node.

extends Label

# The recipe's name comes from the Library object and will be in PascalCase. 
# We use a setter to automatically make the text readable while storing the
# original name for crafting purposes.
var recipe_name := "" setget _set_recipe_name


func _set_recipe_name(value: String) -> void:
    recipe_name = value
    # `String.capitalize()` separates words in PascalCase when it encounters capital
    # letters.
    # For example, it would turn "StirlingEngine" into "Stirling Engine."
    text = recipe_name.capitalize()

Populating the list with recipes

To trigger crafting, we add a script to the root node, CraftingRecipeItem.

We need to highlight the panel as the mouse hovers over it to give the user feedback from their mouse movements.

We also need a way to set the icon and text of the recipe, and we need to react when the user clicks on it, so the GUI knows to craft an object.

We’ll also add a new global event in Events.gd to display a tooltip and tell the player the resources the recipe would consume.

Let’s start with populating the CraftingGUI with recipes so we have something to interact with. That’s a good place to start.

Head to CraftingGUI.tscn and attach a script to the root node, CraftingGUI.

We need a reference to the recipe item recipe to instantiate and modify it, and we need access to the GUI to access the inventory.

extends MarginContainer

## We preload the CraftingRecipeItem scene to instantiate it.
## We load it using its relative path. As it's in the same directory as this
## script, we only need the file's name.
const CraftingItem := preload("CraftingRecipeItem.tscn")

var gui: Control

## We'll instantiate recipe panels as children of the VBoxContainer.
onready var items := $PanelContainer/MarginContainer/ScrollContainer/VBoxContainer


## We use this function to get a reference to the main GUI node, which, in turn,
## will give us functions to access the inventory.
func setup(_gui: Control) -> void:
    gui = _gui

Then, when we open the inventory, we need a way to trigger the recipes to display what we have available to craft.

We define an update function for that purpose.

extends MarginContainer

## We preload the CraftingRecipeItem scene to instantiate it.
## We load it using its relative path. As it's in the same directory as this
## script, we only need the file's name.
const CraftingItem := preload("CraftingRecipeItem.tscn")

var gui: Control

## We'll instantiate recipe panels as children of the VBoxContainer.
onready var items := $PanelContainer/MarginContainer/ScrollContainer/VBoxContainer


## We use this function to get a reference to the main GUI node, which, in turn,
## will give us functions to access the inventory.
func setup(_gui: Control) -> void:
    gui = _gui


## The main function that forces an update of all recipes based on what items
## are available in the player's inventory.
func update_recipes() -> void:
    # We free all existing recipes to start from a clean state.
    for child in items.get_children():
        child.queue_free()

    # We loop over every available recipe name.
    for output in Recipes.Crafting.keys():
        var recipe: Dictionary = Recipes.Crafting[output]

        # We default to true, and then iterate over each item. If at any point
        # it turns out false, then we can skip the item.
        var can_craft := true

        # For each required material in the recipe, we ensure the player has enough of it.
        # If not, they can't craft the item and we move to the next recipe.
        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

        # We temporarily instance the blueprint to acess its sprite and data.
        var temp: BlueprintEntity = Library.blueprints[output].instance()

        # We then instantiate the recipe item and add it to the scene tree.
        var item := CraftingItem.instance()
        items.add_child(item)

        # We grab the blueprint's sprite.
        var sprite: Sprite = temp.get_node("Sprite")

        # And we use the sprite to set up the recipe item with the name,
        # texture, and sprite region information.
        item.setup(
            Library.get_entity_name_from(temp),
            sprite.texture,
            sprite.region_enabled,
            sprite.region_rect
        )
        # And finally, we free the temporary blueprint as we don't need it
        # anymore.
        temp.free()

The snippet above introduces two functions we have yet to code: the is_in_inventory() function for the GUI.gd script, and setup() for the crafting recipe item scene.

We’ll start with the latter.

Open up CraftingRecipeItem.tscn and attach a script, CraftingRecipeItem.gd, to the root node.

extends PanelContainer


onready var recipe_name := $MarginContainer/HBoxContainer/Label
onready var sprite := $MarginContainer/HBoxContainer/GUISprite


## Sets up the sprite and label with the provided recipe data.
func setup(name: String, texture: Texture, uses_region_rect: bool, region_rect: Rect2) -> void:
    recipe_name.recipe_name = name
    sprite.texture = texture
    sprite.region_enabled = uses_region_rect
    sprite.region_rect = region_rect

And for the is_in_inventory() function, open up GUI.gd to add this helper function after find_panels_with().

## Checks the player's inventory and compares the total count of items with
## a given `item_id`.
## Returns `true` if it's equal or greater than the specified `amount`.
func is_in_inventory(item_id: String, amount: int) -> bool:
    # Get all panels that have the given item by name.
    var existing_stacks := find_panels_with(item_id)
    if existing_stacks.empty():
        return false

    # If we have them, iterate over each one and total them up.
    var total := 0
    for stack in existing_stacks:
        total += stack.held_item.stack_count
    return total >= amount

Adding the crafting UI to the GUI scene

We are still lacking code to open the crafting interface when opening the inventory.

To do so, we need an instance of CraftingGUI.tscn in GUI.tscn. Add it as a child of the HBoxContainer, before the InventoryWindow, so the recipe list appears on the left.

Also, ensure it’s hidden by default, like the InventoryWindow

Back in GUI.gd, we also update _open_inventories() to display and populate the crafting window, and _close_inventories() to hide it.

Add the following lines of code to the functions.

onready var crafting_window := $HBoxContainer/CraftingGUI

func _ready() -> void:
    #...
    crafting_window.setup(self)
    #...


func _open_inventories() -> void:
    #...
    crafting_window.visible = true
    crafting_window.update_recipes()


func _close_inventories() -> void:
    #...
    crafting_window.visible = false

That should give you just enough to work with and make your first recipes appear in-game, so long as you have the required materials.

Updating the information label

To wrap up the lesson, we can use the tooltip to show ingredients and the intended output of any given recipe when hovered over with the mouse.

To do that, we can use our global events to send a notification to the information label.

Open up Events.gd to add the new signal.

## Emitted when the mouse hovers over a recipe item.
signal hovered_over_recipe(output, recipe)

To emit that signal, head back to CraftingRecipeItem.tscn.

We need to respond to the mouse hovering in and out of the panel to highlight or remove highlighting, and we can use Control’s signals for that.

In the Inspector, select the root CraftingRecipe node and in the Node tab, under Signals, connect to the mouse_entered() and mouse_exited() signals.

In CraftingRecipeItem.gd, we emit our hovered_over_recipe signal in the callback functions.

func _on_CraftingRecipeItem_mouse_exited() -> void:
    # We use the existing `hovered_over_entity` signal as a simple way to clear
    # the tooltip.
    Events.emit_signal("hovered_over_entity", null)


# Here, we retrieve the recipe's ID from the label and emit it along with its 
# data.
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])

That way, we pass the name of the highlighted object and the recipe itself.

Head back to the information label script, InfoGUI.gd. We can connect to this new signal and react to it.

func _ready() -> void:
    #...
    Events.connect("hovered_over_recipe", self, "_on_hovered_over_recipe")
    #...


## Displays the provided item and recipe in the information label
func _on_hovered_over_recipe(output: String, recipe: Dictionary) -> void:
    # We need to get access to the blueprint's `description` property in `_set_info()`,
    # so we must temporarily instance a new version of the blueprint.
    var blueprint: BlueprintEntity = Library.blueprints[output].instance()
    _set_info(blueprint)
    
    # Once we're done with it, we can get rid of it and free the memory we spent.
    blueprint.free()
    
    # Update the name to show how many items will be crafted in one go
    label.text = "%sx %s" % [recipe.amount, label.text]
    
    # For each input item in the recipe, add it on its own line with the amount
    # needed.
    var inputs: Dictionary = recipe.inputs
    for input in inputs.keys():
        # We add a few spaces to make it prettier, and use the `capitalize()`
        # function to make sure any compound names are spaced out
        label.text += "\n    %sx %s" % [inputs[input], input.capitalize()]
    
    # Force-reset the size of the container around the label
    set_deferred("rect_size", Vector2.ZERO)

With these additions, you can hover over a recipe and see its ingredients listed in the tooltip.

In the next lesson, we’ll improve mouse controls and add support for crafting items when clicking the recipe.

We’ll also improve the crafting GUI’s layout a bit, as it’s currently squashed.

Code reference

RecipeName.gd

extends Label

var recipe_name := "" setget _set_recipe_name

func _set_recipe_name(value: String) -> void:
    recipe_name = value
    text = recipe_name.capitalize()

CraftingGUI.gd

extends MarginContainer

const CraftingItem := preload("CraftingRecipeItem.tscn")

var gui: Control

onready var items := $PanelContainer/MarginContainer/ScrollContainer/VBoxContainer


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


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
        )
        temp.free()

CraftingRecipeItem.gd

extends PanelContainer

onready var recipe_name := $MarginContainer/HBoxContainer/Label
onready var sprite := $MarginContainer/HBoxContainer/GUISprite


func setup(name: String, texture: Texture, uses_region_rect: bool, region_rect: Rect2) -> void:
    recipe_name.recipe_name = name
    sprite.texture = texture
    sprite.region_enabled = uses_region_rect
    sprite.region_rect = region_rect


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])


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

For the following classes, I only list modified functions and added properties to avoid lengthening the page.

GUI.gd

onready var crafting_window := $HBoxContainer/CraftingGUI


func _ready() -> void:
    player_inventory.setup(self)
    quickbar.setup(self)
    crafting_window.setup(self)
    Events.connect("entered_pickup_area", self, "_on_Player_entered_pickup_area")
    
    for item in debug_items.keys():
        if not Library.blueprints.has(item):
            continue

        var item_instance: Node = Library.blueprints[item].instance()
        item_instance.stack_count = min(item_instance.stack_size, debug_items[item])
        
        if not add_to_inventory(item_instance):
            item_instance.queue_free()


func is_in_inventory(item_id: String, amount: int) -> bool:
    var existing_stacks := find_panels_with(item_id)
    if existing_stacks.empty():
        return false

    var total := 0
    for stack in existing_stacks:
        total += stack.held_item.stack_count
    return total >= amount


func _open_inventories() -> void:
    is_open = true
    player_inventory.visible = true
    player_inventory.claim_quickbar(quickbar)
    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

Events.gd

signal hovered_over_recipe(output, recipe)

InfoGUI.gd

func _ready() -> void:
    set_as_toplevel(true)

    Events.connect("hovered_over_entity", self, "_on_hovered_over_entity")
    Events.connect("info_updated", self, "_on_info_updated")
    Events.connect("hovered_over_recipe", self, "_on_hovered_over_recipe")
    hide()


func _on_hovered_over_recipe(output: String, recipe: Dictionary) -> void:
    var blueprint: BlueprintEntity = Library.blueprints[output].instance()
    _set_info(blueprint)
    
    blueprint.free()
    
    label.text = "%sx %s" % [recipe.amount, label.text]
    
    var inputs: Dictionary = recipe.inputs
    for input in inputs.keys():
        label.text += "\n    %sx %s" % [inputs[input], input.capitalize()]
    
    set_deferred("rect_size", Vector2.ZERO)