UI and Score

The last main piece of our game is the user interface (UI). We need a way to show the player the score and other information. To do this, we’ll use a variety of Control nodes - the nodes Godot provides for building UIs.

UI scene

Start the scene with a MarginContainer and name it UI.

Containers are Control nodes that are designed to control the size and position of their children. Using them makes it easier to position and move Control nodes without having to do it manually. The MarginContainer makes sure its children don’t get too close to the edge.

In the Inspector under Theme Overrides/Constants set all four Margin values to 10. Then, in the menu bar at the top of the viewport, set the anchors to the Top Wide preset.

alt alt

Next, we’ll add an HBoxContainer. This type of container organizes its children horizontally. Under that, add a TextureProgressBar, which will represent our ship’s shield level. Name it ShieldBar.

Unfortunately, there’s not a good image in the art pack to use for a progress bar (there is one, but it isn’t formatted in an easy way to work with). Instead, we’ll use the two images below. One is a green bar and the other is a white outline. Save them in your project folder.

alt alt alt alt

In the Texture section, drag the foreground image to the Progress and the background image to the Under texture. The first thing you’ll notice is that it’s very small. Let’s first under Layout set Custom Minimum Size to (80, 16). You’ll notice that the orange selection rectangle got bigger, but the image didn’t. Well, we don’t want the image to just stretch, or it would look bad. Instead we’ll check the Nine Patch Stretch box, and then set the four Stretch Margin values to 3.

You should now see a long, unfilled bar. To see what it looks like when filled, change the Value property in the Range section to anything between 0 and 100.

alt alt

On the right side, we’d like to show the score. Now, we could just use a Label node and add a font, but that’s not very fun. The art pack includes a lovely pixel set of digits that we could use instead. We’ll just need to do a little coding to chop it up and show the corect digit(s).

Score counter

Start a new scene and add an HBoxContainer. Name it ScoreCounter then set it to Top Wide and set the Alignment to “End”. Also, set the Theme Overrides/Constants/Separation to 0 (you need to check the box next to the property).

In this container, we’ll have a string of TextureRect nodes showing each digit. We’ll start by adding one and then duplicating it.

Name the TextureRect Digit0. Under Texture, select “New AtlasTexture”, then click the box to open it. Drag Number_font (8 x 8).png into the Atlas property, then set the Region to (32, 8, 8, 8). Set Stretch Mode to “Keep Aspect Centered”.

Select the Digit0 node and press Ctrl-D 7 times to create duplicates of the node. The picture below shows what you should see after this step:

alt alt

We now have an issue, though. Even though we’ve duplicated the TextureRect to create 8 unique copies, they are all using the same AtlasTexture in the Texture property. This means that when we change the Region to show a different digit, it will change on all the digits.

This is because Resource objects (such as Texture) are loaded into memory and then shared - there’s really only one texture. While this is very efficient, because you don’t waste memory loading the same image multiple times, it means that when we do want things to be unique, we have to specify it.

On each of the nodes, click the down arrow next to the AtlasTexture and select “Make Unique”.

alt alt

Now we’ll add a script to ScoreCounter that will choose the correct Region values for whichever digit it needs to display.

extends HBoxContainer

var digit_coords = {
    1: Vector2(0, 0),
    2: Vector2(8, 0),
    3: Vector2(16, 0),
    4: Vector2(24, 0),
    5: Vector2(32, 0),
    6: Vector2(0, 8),
    7: Vector2(8, 8),
    8: Vector2(16, 8),
    9: Vector2(24, 8),
    0: Vector2(32, 8)
}

func display_digits(n):
    var s = "%08d" % n
    for i in 8:
        get_child(i).texture.region = Rect2(digit_coords[int(s[i])],
                Vector2(8, 8))

We start by making a list of the coordinates in the image where each digit is found. Then, display_digits() will format the number to an 8 digit number (for example, 258 would become "00000258"). Then, for each digit, we can apply the correct coordinates from the array.

Scripting the UI

Go back to the UI scene and add the ScoreCounter to the HBoxContainer, then add a script to UI.

extends MarginContainer

@onready var shield_bar = $HBoxContainer/ShieldBar
@onready var score_counter = $HBoxContainer/ScoreCounter

func update_score(value):
    score_counter.display_digits(value)


func update_shield(max_value, value):
    shield_bar.max_value = max_value
    shield_bar.value = value

We’ll call these functions from Main whenever we need to update the score or the shield.

Adding the UI to main

Now in the Main scene add a CanvasLayer node, and instance the UI as its child. The CanvasLayer node creates another drawing layer, so our UI will be drawn on top of the rest of the game.

Change this function in main.gd:

func _on_enemy_died(value):
    score += value
    $CanvasLayer/UI.update_score(score)

Run the game and see that your score goes up when shooting enemies.

Player shield

We can also add the shield to the player’s script. Add these new lines at the top of player.gd:

signal died
signal shield_changed

@export var max_shield = 10
var shield = max_shield:
    set = set_shield

This set = syntax tells Godot that we want to call the set_shield() function whenever the shield variable has its value set.

func set_shield(value):
    shield = min(max_shield, value)
    shield_changed.emit(max_shield, shield)
    if shield <= 0:
        hide()
        died.emit()

We can also connect the ship’s area_entered signal so that we can detect when an enemy hits the ship:

func _on_area_entered(area):
    if area.is_in_group("enemies"):
        area.explode()
        shield -= max_shield / 2

And in the enemy bullet, add some damage to the shield when it hits:

func _on_area_entered(area):
    if area.name == "Player":
        queue_free()
        area.shield -= 1

Finally, we need to connect the player’s shield_changed signal to the function in the UI that updates the shield bar. You can do this in the Inspector by selecting the Player node in the Main scene. Under the Node tab, double-click the shield_changed signal to open the “Connect a Signal” window. In this window, select the UI node and type update_shield in the Receiver Method box.

alt alt

Run the game again and check that your shield depletes when you get hit by a bullet or an enemy.

Next steps

We’re almost done with the basic functionality. We just need a way to start and end the game.

PrevNext