Crafting the Furnace GUI

Let’s design and code the furnace’s interface.

To do so, we’ll reuse the fuel slot we created for the Stirling engine, then design a slightly more elaborate UI around it.

Our furnace smelts ore into ingots using some fuel. So we need one slot for the ore, one for the fuel, and a third one for the crafted item.

The arrow above serves as a progress bar.

Create a new scene, FurnaceGUI.tscn, with a MarginContainer named FurnaceGUI as its root node.

To create a bit of padding space, set its Custom Constants’ margins to 5 pixels, and set its Size Flags -> Horizontal to Shrink Center so it centers horizontally in the inventory window.

If we analyze the image above, we can see that the GUI is made up of three columns, with the first made of two rows and the other two centered. To arrange the nodes horizontally, we add an HBoxContainer as a child.

Again, for padding, set its Custom Constants -> Separation to 5 pixels.

To create the input and fuel bars, add a child VBoxContainer and once again, set its Custom Constants -> Separation to 5.

Instance an InventoryBar.tscn scene as a child of that VBoxContainer. Set its Slot Count to 1 and write in Ore for its Item Filters.

For the fuel bar, you can save a bit of time with Godot’s scene merge feature.

Right-click on the VBoxContainer and select Merge From Scene. Find and open the StirlingEngineGUI.tscn file.

Then, double-click on the HBoxContainer in the list. Godot will copy its children alongside it.

It saves us a fair bit of time as we don’t need to configure the nodes again.

To add the progress bar arrow, add a new GUISprite node as a child of the first HBoxContainer.

There is a texture, Symbols.svg, which contains an arrow. Assign it to the GUISprite’s texture.

Turn on Region Enabled and set the Region Rect to wrap around the arrow graphic. You can see the settings below since we don’t have access to the TextureRegion bottom panel.

Finally, instance a new InventoryBar.tscn as a sibling of the GUISprite to create the third column.

Set its Slot Count to 1. This time, write NIL in its Item Filters. Not because we are going to introduce a NIL item, but because it will ensure the player cannot manually put items in that slot - but, through code, we still can. It will only ever be an output.

If you Play the scene, you can see the instanced panels and test out the GUI, and it doesn’t look right.

The input panel is not lined up with the fuel panel. The output panel and the arrow are both stretched out. Let’s fix them left to right.

Select the topmost InventoryBar and change its Size Flags -> Horizontal from Fill to Shrink End. This will force it to move to the right of its column, lining up with the fuel slot.

Select the GUISprite arrow and the output bar and change their Size Flags -> Vertical from Fill to Shrink Center.

The default value of Fill makes the node expand to fill the available space, if possible. By turning this off, the node size gets determined by the texture’s size and centers vertically in the parent HBoxContainer.

Now, do the same for the last InventoryBar node, so it centers vertically in the parent container. We want its Size Flags -> Vertical to be only Shrink Center.

If you rerun the scene, it should look correct.

To animate the arrow progress bar, we’ll give it a shader like the one on the fuel bar.

The difference is that we’re not dealing with a regular node, as we manually draw the texture via code. As a result, we can’t use the MODULATE shader variable to set its color.

Also, shaders tend to be highly specific programs. Our previous one was only for vertical bars filling from bottom to top. This one has to fill from left to right.

Add a new ShaderMaterial resource to the GUISprite’s Material property, and assign a new Shader resource to the material’s Shader property.

Open the Shader to edit its code.

shader_type canvas_item;

uniform float fill_amount : hint_range(0, 1.0) = 0.0;
// The position in pixels in the texture where the region begins.
uniform vec2 region_position;
// The number of pixels in the texture region.
uniform vec2 region_size;

void fragment() {
    // We first sample our node's texture.
  vec4 color = texture(TEXTURE, UV);
    
    // We request the texture's size. We can use this multiplied by the `UV` to 
  // figure out where in the texture we are currently at, in pixels.
  vec2 texture_size = vec2(textureSize(TEXTURE, 0));

    // We calculate the bottom-right end of the region to use to compare it to.
  vec2 region_end = region_position + region_size;

    // We use the `step()` function to calculate how much of the sprite's UV
  // map should be filled.
  //
  // If the pixel we are at in the texture is greater than the pixel value of the
  // end of the region, then it returns 1. Otherwise, it returns 0.
  float pixel_fill = step(UV.x * texture_size.x, fill_amount * region_end.x);

    COLOR = clamp(color + (color * pixel_fill), 0, 1);
}

In the material’s Shader Param, make sure to set the Region position and Region Size to the same region as the arrow symbol.

To animate the bar, we’ll use a Tween node, so add one as a child of the FurnaceGUI.

To distinguish inventory bars, we can also rename the first InventoryBar to InputInventoryBar, the second to FuelInventoryBar, and the third to OutputInventoryBar.

Coding the GUI

Now we can code the GUI’s functionality. Create a new script, FurnaceGUI.gd, attached to the FurnaceGUI node. It’ll extend BaseMachineGUI, like the engine and chest.

Before we start coding, let’s connect our signals using the inspector so we have those functions to work with.

We want to connect the ore and fuel InventoryBar’s inventory_changed signal to two new callback functions.

You can then connect them both to the FurnaceGUI node, creating two new _on_InputInventoryBar_inventory_changed() and _on_FuelInventoryBar_inventory_changed(). With those functions in, we can code the rest.

The GUI’s job is to animate the arrow and fuel bar and give the entity a place to access its inventory, which means exposed variables for fuel, input, and output.

extends BaseMachineGUI

# Reference to the ore item in the input inventory bar.
var input: BlueprintEntity
# Reference to the fuel item in the fuel inventory bar.
var fuel: BlueprintEntity

# We grab the output panel to work with its inventory directly.
# This is because the inventory bar would normally check the item filters, which we
# want to bypass without having to code a workaround.
var output_panel: Panel

# Those are references to all the nodes we'll need to access in the script.
onready var input_container := $HBoxContainer/VBoxContainer/InputInventoryBar
onready var fuel_container := $HBoxContainer/VBoxContainer/HBoxContainer/FuelInventoryBar
onready var output_panel_container := $HBoxContainer/OutputInventoryBar
onready var tween := $Tween
onready var arrow := $HBoxContainer/GUISprite
onready var fuel_bar := $HBoxContainer/VBoxContainer/HBoxContainer/ColorRect


func _ready() -> void:
    # The inventory panels take care of updating their size, but the arrow
    # should be sized to match the gui's scale in project settings.
    var scale: float = (
        ProjectSettings.get_setting("game_gui/inventory_size") / arrow.region_rect.size.x
    )
    arrow.scale = Vector2(scale, scale)


## We'll call this function to start the crafting animation (arrow filling up).
## The function initiates a tween animation lasting `time` seconds.
func work(time: float) -> void:
    # Tween does not work if it is not in the scene tree, so we need to check if
    # the GUI is open or not before we start animating.
    if not is_inside_tree():
        return

    # Since we're using a shader to animate using `set_shader_param()`, we can't
    # just use interpolate property.
    # Instead, we call a function that'll update the shader.
    tween.interpolate_method(self, "_advance_work_time", 0, 1, time)
    tween.start()


## Stops the tween animation and resets the arrow fill amount.
func abort() -> void:
    tween.stop_all()
    tween.remove_all()
    arrow.material.set_shader_param("fill_amount", 0)


## Updates the *fuel* bar's `fill_amount` shader parameter.
func set_fuel(amount: float) -> void:
    fuel_bar.material.set_shader_param("fill_amount", amount)


## If the tween is already animating, seek to the current amount of time.
## We use this when we start animating, close the inventory, and then open it
## again later. This makes sure the tween is updated to the amount of time
## left crafting.
func seek(time: float) -> void:
    if tween.is_active():
        tween.seek(time)


## Sets up all inventory slots.
func setup(gui: Control) -> void:
    input_container.setup(gui)
    fuel_container.setup(gui)
    output_panel_container.setup(gui)
    # We manually access the item panel in the output inventory bar to manually
    # insert crafted ingots in it.
    output_panel = output_panel_container.panels[0]


## Sets a newly crafted item as the output panel's item, or adds it to the
## existing crafted item stack.
func grab_output(item: BlueprintEntity) -> void:
    # If there's no item in the output slot, we assign it.
    if not output_panel.held_item:
        output_panel.held_item = item
    # If there's already an item, we increase its stack count.
    else:
        var held_item_id := Library.get_entity_name_from(output_panel.held_item)
        var item_id := Library.get_entity_name_from(item)
        if held_item_id == item_id:
            output_panel.held_item.stack_count += item.stack_count

        item.queue_free()
    output_panel_container.update_labels()


## Force all panel labels to update.
func update_labels() -> void:
    input_container.update_labels()
    fuel_container.update_labels()
    output_panel_container.update_labels()


## Sets the *arrow*'s `fill_amount` shader param so it fills up. Called by the
## tween node.
func _advance_work_time(amount: float) -> void:
    arrow.material.set_shader_param("fill_amount", amount)


# When the player changes the item in the input inventory slot, we store a
# reference to the item and emit the `gui_status_changed` signal.
func _on_InputInventoryBar_inventory_changed(_panel, held_item) -> void:
    input = held_item
    emit_signal("gui_status_changed")


# We do the same with the fuel item slot.
func _on_FuelInventoryBar_inventory_changed(_panel, held_item) -> void:
    fuel = held_item
    emit_signal("gui_status_changed")

That gives the GUI its functionality: routing signals up for the entity to use and giving it access to inventory elements.

In the next lesson, we’ll finally get to smelting some ingots with ore and fuel.

Code reference

Here’s the GUISprite’s shader code.

shader_type canvas_item;

uniform float fill_amount : hint_range(0, 1.0) = 0.0;
uniform vec2 region_position;
uniform vec2 region_size;

void fragment() {
    vec4 color = texture(TEXTURE, UV);
    vec2 texture_size = vec2(textureSize(TEXTURE, 0));
    vec2 region_end = region_position + region_size;

    float pixel_fill = step(UV.x * texture_size.x, fill_amount * region_end.x);

    COLOR = clamp(color + (color * pixel_fill), 0, 1);
}

And FurnaceGUI.gd.

extends BaseMachineGUI

var input: BlueprintEntity
var fuel: BlueprintEntity

var output_panel: Panel

onready var input_container := $HBoxContainer/VBoxContainer/InputInventoryBar
onready var fuel_container := $HBoxContainer/VBoxContainer/HBoxContainer/FuelInventoryBar
onready var output_panel_container := $HBoxContainer/OutputInventoryBar
onready var tween := $Tween
onready var arrow := $HBoxContainer/GUISprite
onready var fuel_bar := $HBoxContainer/VBoxContainer/HBoxContainer/ColorRect

func _ready() -> void:
    var scale: float = (
        ProjectSettings.get_setting("game_gui/inventory_size") / arrow.region_rect.size.x
    )
    arrow.scale = Vector2(scale, scale)


func work(time: float) -> void:
    if not is_inside_tree():
        return

    tween.interpolate_method(self, "_advance_work_time", 0, 1, time)
    tween.start()


func abort() -> void:
    tween.stop_all()
    tween.remove_all()
    arrow.material.set_shader_param("fill_amount", 0)


func set_fuel(amount: float) -> void:
    fuel_bar.material.set_shader_param("fill_amount", amount)


func seek(time: float) -> void:
    if tween.is_active():
        tween.seek(time)


func setup(gui: Control) -> void:
    input_container.setup(gui)
    fuel_container.setup(gui)
    output_panel_container.setup(gui)
    output_panel = output_panel_container.panels[0]


func grab_output(item: BlueprintEntity) -> void:
    if not output_panel.held_item:
        output_panel.held_item = item
    else:
        var held_item_id := Library.get_entity_name_from(output_panel.held_item)
        var item_id := Library.get_entity_name_from(item)
        if held_item_id == item_id:
            output_panel.held_item.stack_count += item.stack_count

        item.queue_free()
    output_panel_container.update_labels()


func update_labels() -> void:
    input_container.update_labels()
    fuel_container.update_labels()
    output_panel_container.update_labels()


func _advance_work_time(amount: float) -> void:
    arrow.material.set_shader_param("fill_amount", amount)


func _on_InputInventoryBar_inventory_changed(_panel, held_item) -> void:
    input = held_item
    emit_signal("gui_status_changed")


func _on_FuelInventoryBar_inventory_changed(_panel, held_item) -> void:
    fuel = held_item
    emit_signal("gui_status_changed")