Game Dev Artisan Logo
In this post we’re going to continue our series on Godot Fundamentals. We’ll be adding a simple User Interface to our game which will include a Score which will increase when we pickup our collectables. We’ll also add a reload indicator to visualize the weapon’s reload status.
Tutorials

Building a UI in Godot: Godot Fundamentals

In Series: Godot Fundamentals

Author Avatar
2023-06-28
10 min read

Table of Contents

Introduction 

In this post we’re going to continue our series on Godot Fundamentals. We’ll be adding a simple User Interface to our game which will include a Score which will increase when we pickup our collectables. We’ll also add a reload indicator to visualize the weapon’s reload status..

Video 

You can watch the video covering this topic, or go at your own pace following this post.

Project Organization 

Let's begin by addressing our scenes folder as it is starting to get a bit cluttered. We can improve our project organization by adding extra folders to organize the content in our scenes folder. Let's first create an entities folder. This will act as our main folder for any Entity within our game, such as our tank.

For our tank, we have the weapon and bullet scenes and scripts that could also be grouped, so let's create a tank folder inside the entities folder. Within our tank folder let's also create a weapon folder, which will contain our current weapon.tscn, weapon.gd, bullet.tscn, and bullet.gd. We will also move our tank.tscn and tank.gd files into our tank folder now.

We'll also create a dedicated ui folder inside our scenes folder, which will contain our future ui scene and scripts.

For our last folder let's create a world folder, which will contain the current world.tscn but could also include future levels/worlds we create. We'll also move our crate.tscn, crate.gd, pickup.tscn, pickup.gd, and wall.tscn into the scenes/world folder.

Game Scene 

Now that we've reorganized our files a bit better, let's create our main game scene, which will combine our levels/worlds and the tank, as well as any ui elements. This is a great practice to get into for abstracting away behavior, and not requiring each node to be aware of one another directly.

Let's create a game.tscn inside our scenes folder. We'll also want to set this new scene as our main scene. Because this is a our main Game scene, we want it to be of type Node2D.

Next let's instance our World scene as a child of our Game scene, and remove our Tank from the World scene, and instead instance it as a child of our Game scene.

UI Scene 

First we'll create a new scene that has a root node of type CanvasLayer and we'll call it UI, saved as ui.tscn inside our scenes/ui folder.

Next add a Control child node and set it's anchor layout to Full Rect so it scales to fit our screen. We want to also add some margin to pad inside our ui, we can do this by adding a child node inside our Control node of type MarginContainer.

It's important to note that Control nodes have a hiearchy in which the layout is inherited from. All child nodes inherit their position/sizing from the parent in which they belong to. This can make setting up a UI confusing at first, but once you start to understand how the layout impacts things down your SceneTree, it can help with troubleshooting those pesky layout issues.

Open the MarginContainer's Theme Override and modify the Constants for Margins to 32 pixels each.

Next let's add a VboxContainer as a child of our MarginContainer, once we've added this we can see the margin in effect from our parent. Let's align this container to span across the Horizontal alignment and a start on the Vertical Alignment.

Inside our VboxContainer, let's add an HboxContainer child, and then add a child to that HboxContainer of type Label. We'll name this Label Score and right click to set the Access as Unique Name to easily reference it directly in code.

UI Script 

Create a script attached to our UI and call it ui.gd saved inside the scenes/ui folder. This will extend the CanvasLayer and have a class_name of UI.

Add the onready variable for the score_label reference the Unique Name % accessor.

Next add a local var score and set it default to 0 and define a setter function. This will take in the new score, and call the _update_score_label() function everytime the score is updated.

This function will take the score_label reference and update it's text property to the string casted value of the score.

To attach to a signal let's create a function we'll call when a collected signal has been emitted and is connected. We'll call this function _on_collected and it'll take the parameter of collectable. If this is a valid variable and is not undefined, we want to increment our score by 100.

Your script should look like the following:

extends CanvasLayer
class_name UI

@onready var score_label = %Score

var score = 0:
    set(new_score):
        score = new_score
        _update_score_label()
        
        
func _ready():
    _update_score_label()
            
        
func _update_score_label():
    score_label.text = str(score)
    
    
func _on_collected(collectable) -> void:
    if collectable:
        score += 100

Tank Signals 

Open our scenes/tank/tank.gd and we'll want to add a new function to call when our tank collects a pickup. We can call this function collect which will receive our pickup/collectable as a parameter.

We'll also add a signal called collected, when this is emitted, we'll pass the type of collectable that we are collecting.

Inside our new collect function, we'll emit our collected signal and pass the collectable we've just collected.

signal collected(collectable)

...
func collect(collectable):
    collected.emit(collectable)

Next open our Pickup script at scenes/world/pickup.gd and inside our function for the body entering the area, we'll add inside our check that if the body is a Tank, we'll want to call the collect function we've just created, passing in self as the collectable.

func _on_body_entered(body):
    if body is Tank:
        body.collect(self)
        queue_free()

Game Script and Composition 

Let's open our Game scene at scenes/game.tscn and create a script that is attached to our Game, we'll call it game.gd and save it in the scenes folder. This will have the class_name of Game and extends Node2D. We'll add and export variable for our Tank and UI objects. Next in our _ready function we'll check if we've connected our Tank's collected signal to our UI's _on_collected function. If not, we'll create the connection.

extends Node2D
class_name Game

@export var tank: Tank
@export var ui: UI

func _ready():
    if !tank.collected.is_connected(ui._on_collected):
        tank.collected.connect(ui._on_collected)

If we test our game now, we should be able to update our score when destroying a Crate and collecting a Pickup.

score-initial-setup

Reloading Progress Bar 

Back in our UI scene, let's another HboxContainer inside our VboxContainer node. Then create our TextureProgressBar child node, call it ReloadProgress and Access as Unique Name. Then CTRL + Click and Drag the ReloadProgress node into our UI script to set the onready var. This gives us a reference directly to our Progress Bar right within our UI script.

Let's also add functions to monitor our reload's progress and on reload check. First create a _on_reload_progress function that takes in the progress parameter, we'll update our reload_progress's value equal to this progress. We'll also want a _on_reloaded function that will just reset our *reload_progress's value to 1 right away.

class_name UI
...
@onready var reload_progress = %ReloadProgress

...
func _on_reload_progress(progress) -> void:
    reload_progress.value = progress
    
    
func _on_reloaded() -> void:
    reload_progress.value = 1

Either create or download the assets from our project  for the TextureProgressBar

Set the Under and Progress Textures using the correct Texture type, for our project files we use an AtlasTexture that defines the region for the sprite sheet and repeat the step for both fields.

Set the progress bar's fill mode to go Bottom to Top and the Max Value of 1, with a Step of .1, and a default value of 1.

Weapon Reloading Signals 

In order to know when our Weapon is reloaded, we'll need to setup signals for when we are progressing through a reload, and when a reload is complete. Open the Weapon script at scenes/tank/weapon/weapon.gd and add a signal for reloaded and reload_progress with a parameter of progress. Inside our _process function we'll want to check if our reload_timer is not stopped and it not, we'll emit the reload_progress signal passing in the time we've progressed. We can emit our reloaded signal simply within our _on_reload_timer_timeout function after we change state.

class_name Weapon

signal reloaded()
signal reload_progress(progress)
...
func _process(delta):
    if !reload_timer.is_stopped():
        reload_progress.emit(1 - (reload_timer.time_left / reload_timer.wait_time))		


...
func _on_reload_timer_timeout():
    change_state(STATES.READY)
    reloaded.emit()

NOTE

In our video covering this topic, we demonstrate a way you can mirror a signal to a parent node, to adhere to more strict isolation in cases where you need to aggregate or copy a child's signal upstream. Take a look at this example at this timestamp . This method also provides a quick look at Lambda functions to inline the call back out to emit the new signals. In our example we'll take another approach of direclty connecting our UI's progress bar to the weapons's signals.

Inside the Game's script we'll add in our ready function a check if the tank's weapon signals have connections to the functions on the UI.

class_name Game
...
func _ready():
    ...
    if !tank.weapon.reload_progress.is_connected(ui._on_reload_progress):
        tank.weapon.reload_progress.connect(ui._on_reload_progress)
    if !tank.weapon.reloaded.is_connected(ui._on_reloaded):
        tank.weapon.reloaded.connect(ui._on_reloaded)

Next we'll make sure our Weapon Scene's ReloadTimer is set to OneShot true. This will prevent the reload from looping when the timer starts over by default. One shot means the timer will stop when it finishes and await for it to be started again.

If we test our game, we'll see that the reloading indicator updates with the time of the reload timer, and stops once the reload has finished and the signal is emitted.

Recap 

There we have a simple UI that ties in our signals from our Tank's Weapon and Pickups to provide us with visual feedback for something that is happening behind the scenes. Be sure to read more on signals and their capabilities as they are large part of how Godot communicates changes through it's own engine. Be sure to join the discussions on our Discord community server and stay tuned for more content.

Happy Coding!

Want to support our work?Support us via Ko-fi