Table of Contents
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..
You can watch the video covering this topic, or go at your own pace following this post.
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
bullet.gd. We will also move our
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
wall.tscn into the
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
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.
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
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
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.
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.
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.
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
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()
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.
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
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.
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()
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.
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.