Adding user interface during play

Before we add timing and scoring to our HitBeat, we’ll add a score counter and improve the game’s look by adding a lovely background.

During play, the user interface has one of our custom labels at the top of the screen, and a metronome in the bottom left. The score will increase depending on how well the player taps the HitBeat in time to the song’s beat.

The metronome will pulse to the song’s beat to add a bit of energy to the scene.

Adding a background

First off, we’ll frame our play using a lovely background. I included an image for that in the start project, background_shapes.png. It matches the game resolution of 1920x1080 pixels.

Create a new CanvasLayer node in the RhythmGameDemo scene and rename it to Background. The Layer property determines what order CanvasLayers are drawn, so set it to something low such as -20 to guarantee it’s always drawn below everything else.

Add a Sprite node and drag the background_shapes.png image to the Texture property. Uncheck the Offset > Centered property to have it fill the game’s viewport.

Your scene tree should look like this (I named my Sprite node Shapes).

And here are the sprite’s properties.

Adding the play overlay

We’re going to add another CanvasLayer, which will house our user interface scenes.

Having the user interface on a separate drawing layer is generally good practice, especially in games where camera shake is present.

User interfaces should be easy to read, and having them separate from any camera movement helps with readability.

Add another CanvasLayer as a child of RhythmGameDemo and name it UI.

We’ll create two UI widgets as separate scenes, the score and metronome, and then add them to a third scene, UITrackPlaying.

Creating the score scene

The score scene will reuse the LabelCustom scene we created earlier.

To update the text, we’ll need to communicate score changes across the project.

After all, this scene will be in the UI layer, and the score will come from interacting with the HitBeats or HitRollers. We’ll introduce a new signal to the Events autoload to accommodate for this interaction. Open up Events.gd and add the scored signal.

#...
# Emitted when the player made an action that increases the score.
signal scored(msg)

Now let’s create the scene. Create a new inherited scene by going to Scene > New Inherited Scene… and navigating to the LabelCustom scene. Rename the node as UIScore and save this scene in res://RhythmGame/UI/TrackPlaying/UIScore.tscn.

Having an inherited scene gives us more flexibility. For example, we could add a pulsating animation when the score increments by adding an AnimationPlayer.

Attach a new script to the node.

extends Label

# Keeps track of the current score as a number.
var _total_score := 0


func _ready() -> void:
    # We connect to our new signal to know when to increase the score.
    Events.connect("scored", self, "_add_score")


func _add_score(msg: Dictionary) -> void:
    # When the player scores, we increase the score and update the Label to show
    # it. You could play an animation here too.
    _total_score += msg.score
    text = str(_total_score)

Creating the metronome scene

Next, we’ll create the metronome, a sprite that pulses along with the beat.

As we already have a signal that’s emitted every half-beat, we can design and code it right away.

Create a new scene with a Sprite as the root node. Name the node UIMetronome and save the scene as res://RhythmGame/UI/TrackPlaying/UIMetronome.tscn.

Add a Tween node as a child. We’ll use it to animate the image scaling up and down.

Assign the image metronome_sprite.png to the sprite’s Texture property.

Finally, attach a script to the sprite and define some properties.

extends Sprite

# To animate the sprite pulsating, we have it start at a large scale and scale
# up and down.
# The following properties define the animation's range.
var _start_scale := Vector2.ONE * 1.5
var _end_scale := Vector2.ONE

onready var _tween := $Tween


func _ready():
    # We connect to `beat_incremented` to trigger the animation every beat.
    Events.connect("beat_incremented", self, "_pulse")


func _pulse(msg: Dictionary):
    # We want to only pulse every beat (every two half-beats), so we return if
    # we're not on a beat.
    if msg.half_beat % 2 == 1:
        return

    var _beats_per_second: float = msg.bps
    # We animate the scale going down. This way, the metronome pops up every
    # beat and shrinks gradually, giving it some visual impact.
    _tween.interpolate_property(
        self,
        "scale",
        _start_scale,
        _end_scale,
        # We calculate a duration that makes the pulse faster in faster songs
        # songs, typically a fraction of a beat.
        _beats_per_second / 4,
        Tween.TRANS_LINEAR,
        Tween.EASE_OUT
    )
    _tween.start()

Placing both the score and metronome on the screen

Let’s create the user interface to display during gameplay.

Create a new User Interface scene, rename the Control node as UITrackPlaying, and save it as res://RhythmGame/UI/TrackPlaying/UITrackPlaying.tscn.

Set the node’s Mouse > Filter to Ignore. This will prevent it from picking up any input events and getting in the way of tapping out HitBeats.

Add an instance of the UIScore scene.

Then, align the score to the top of the viewport by clicking the Layout button and selecting Top Wide.

Instance the UIMetronome scene as a child of UITrackPlaying and position it where you like. I put it in the bottom left corner of the screen.

Finally, add the UITrackPlaying as a child to UI in RhythmGameDemo.

And there we have it! If you run the game, you’ll see the HitBeats spawn as before, but now with a shiny new metronome and score with them.

In the next lesson, we’ll delve back into the gameplay and add scoring and a timing mechanic to reward player skill when tapping in time to the beat.

Final scripts

UIScore.gd

extends Label

var _total_score := 0


func _ready() -> void:
    Events.connect("scored", self, "_add_score")


func _add_score(msg: Dictionary) -> void:
    _total_score += msg.score
    text = str(_total_score)

UIMetronome.gd

extends Sprite

var _start_scale := Vector2.ONE * 1.5
var _end_scale := Vector2.ONE

onready var _tween := $Tween


func _ready():
    Events.connect("beat_incremented", self, "_pulse")


func _pulse(msg: Dictionary):
    if msg.half_beat % 2 == 1:
        return

    var _beats_per_second: float = msg.bps

    _tween.interpolate_property(
        self,
        "scale",
        _start_scale,
        _end_scale,
        _beats_per_second / 4,
        Tween.TRANS_LINEAR,
        Tween.EASE_OUT
    )
    _tween.start()

Events.gd

extends Node

signal beat_incremented(msg)
signal scored(msg)