The HitBeat

The HitBeat is one element the player will interact with. It’s a button you touch or click in sync with the beat.

In this lesson, we’ll design a button that disappears if the player clicks or taps it. We’ll deal with scoring, timing, and add an animated ring in a few lessons.

We’ll create the button out of a sprite, an area, and a text label to display the number.

A word about assets

We used a sprite sheet made up of six different colored circles for the demo: res://RhythmGame/Hits/hit_sprites.png. Each circle sprite is 150x150 pixels.

Be mindful of the resolution of the game you’re making. These assets were sized with a base resolution of 1920x1080 in mind.

To write numbers on the button, we’ll use the font Montserrat. You can find it in the res://RhythmGame/UI/Fonts/ directory.

Creating a reusable label

We’re going to use labels quite a bit going forward. We’ll use them for the HitBeat number, the score, displaying track information, and so on. To save time configuring all of these individual labels, we’ll make a custom label scene and instance it when we need a label that fits the game’s theme.

Create a new scene with a Label as the root node and save it as res://RhythmGame/UI/LabelCustom.tscn.

This label will display a number that represents the order a HitBeat occurs.

We need to center the text on the button’s sprite. In the Inspector, set Align and Valign to Center. This aligns the text horizontally and vertically within whatever bounding size we define.

To preview the text, write a number in the Text property.

We can define the shape and position of Control nodes by setting their margin.

Each Margin property is relative to (0, 0). You can think of them as positioning lines to define a rectangle. For example, we create the desired 150x150 rectangle to match the circle sprite by setting the Left and Top margins to -75 pixels and the Right and Bottom margins to 75.

Note how the Rect > Position and Rect > Size properties change as you alter the margins. You can use these properties to define the shape as well.

Let’s improve the default font by completely replacing it.

Create a NewDynamicFont in Custom Fonts > Font. Click it to display its properties. You’ll notice any text you set has disappeared!

This is because the dynamic font doesn’t have any font data; it has no characters to draw.

Drag the Monserrat-Bold.ttf file to the Font Data property to draw the text in the new font.

As the game is a decently high resolution, we need larger text so set the Settings > Size to 90 pixels.

A drop shadow will also help the text pop and add clarity. Fold the font resource by clicking the Custom Fonts section and set Custom Colors > Font Color Shadow to a dark, translucent color by reducing the A (alpha) value.

Set the Custom Constants > Shadow Offset X and Custom Constants > Shadow Offset Y to 2 pixels to give a subtle shadow, but as always, feel free to experiment.

Now we have a custom label scene that we can instance in any other scene, let’s move on to incorporating it into the HitBeat scene.

The HitBeat scene

Create a new scene with Node2D as the root. Rename it as HitBeat and save it as res://RhythmGame/Hits/HitBeat.tscn.

Add a Sprite, Area2D, AnimationPlayer, and an instance of our new LabelCustom as children.

Nodes are drawn from top to bottom of the scene tree. Make sure the LabelCustom is below the Sprite in the scene tree. We’ll set up each node before moving on to the script.

Add a CollisionShape2D as a child to the Area2D.

Drag the hit_sprites.png to the Sprite’s Texture property. You’ll see all of the circles in the editor.

We let Godot know that this sprite sheet should be treated as six separate images by setting Animation > Hframes to 6. Now, you can cycle between the colors by changing the Frame property as you would with an animation.

The Area2D will detect the player’s input, but we need to define the collision shape first.

Click on the CollisionShape2D and add a NewCircleShape2D. Set the Radius to 75 to match the sprite.

As the Area2D doesn’t need to interact with any other physics layers, you can select it and deactivate any Collision > Mask mask layers.

Creating fade animations

The AnimationPlayer node will hold two animations:

We’ll create these animations using two different methods. In the first, we’ll use the key icons that appear next to properties when you select an AnimationPlayer node. In the second, we’ll use the Add Track dialogue to create tracks.

Select the AnimationPlayer and click Animation -> New to create a new animation. Name it “show” and set the animation length to 0.3 seconds. Scrub the timeline to 0 seconds.

In the scene tree, select the HitBeat node to access its properties in the inspector. Navigate to Visibility > Modulate. Notice the key icon to the right of it.

Change the Modulate alpha to 0, then click the key to create a new animation track for the modulate property.

A new track and keyframe with the color appear in the editor. Scrub to 0.3 seconds and add another modulate keyframe with the alpha at maximum.

You can scrub the timeline back and forth to see the animation happen in the editor.

Let’s move on to the “destroy” animation.

Create a new “destroy” animation and set the animation length to 0.5.

You don’t have to use the Inspector to create animation tracks and keys.

This time, click the Add Track button to add a new Property Track. Select the root HitBeat node in the pop-up dialogue, then search for the modulate property.

Now we have our modulate track, right-click on the timeline and select Insert Key to add a new keyframe. The color’s alpha should go from the maximum value to 0. Add keyframes as necessary.

You can click a diamond icon to select a keyframe and edit its value in the Inspector.

Among other things, we can call any function we’ve defined in scripts or that belongs to the node. We’ll take advantage of this to free the HitBeat from memory. Using the Add Track button again, add a new Call Method track.

At the end of the animation, right-click to add a new key. Search for the queue_free() method, which will delete the scene from memory when it’s safe to do so, and the scene is no longer in use.

I designed my “destroy” animation like this. The node fades really fast, in about 0.2 seconds, before getting freed.

Switch back to the “show” animation and set the timeline to 0 seconds to make it transparent by default. We want the HitBeat to start transparent to avoid flickering when instancing the scene.

The HitBeat script

We’re finally ready to add the HitBeat script and pull all of these elements together.

Attach a new script to the HitBeat node and save it in the same directory as the scene.

As always, we define a few variables and cache child nodes so we don’t have to keep looking them up.

extends Node2D

# A number representing the order the HitBeat appears.
# The setter function updates the variable and the number displayed on the label.
var order_number := 0 setget set_order_number

# If `true`, the player already tapped this HitBeat.
var _beat_hit := false

# Here are references to all the nodes we use in this script.
onready var _animation_player := $AnimationPlayer
onready var _sprite := $Sprite
onready var _touch_area := $Area2D
onready var _label := $LabelCustom

# We play the show animation when adding HitBeat instances to the scene tree, so they automatically fade in.
func _ready() -> void:
    _animation_player.play("show")


# This function initializes the HitBeat's properties.
# A separate object will spawn HitBeat instances and call this function, as
# we'll see in the next lesson.
# The function expects a dictionary with three keys: `half_beat`, a number,
# `global_position`, the position to place this instance, and `color`, a number
# representing the sprite's frame.
func setup(data: Dictionary) -> void:
    # We write `self.order_number` to run through the property's setter
    # function.
    self.order_number = data.half_beat
    global_position = data.global_position
    _sprite.frame = data.color


# Finally, here's the setter for the `order_number` property.
func set_order_number(number: int) -> void:
    order_number = number
    _label.text = str(order_number)

We still need to take into account the player’s input. First, let’s create an action that’ll take into account mouse clicks as well as taps on touch devices.

Head to Project > Project Settings… > Input Map.

Create a new action named “touch”. Add a the left mouse button to the action.

Under devices, choose all devices also to capture all touch events on touch screens.

To do anything with player input on the Area2D, we connect the input_event signal to the HitBeat.gd. Navigate to the Node dock and double-click on the input_event signal.

This signal is available on all objects that extend CollisionObject2D and emits whenever mouse or touch input occurs within the node’s collision shapes. It requires its input_pickable property to be set to true, which it is by default.

Upon connecting the signal, the HitBeat.gd script automatically opens. We fill in the function:

# Called whenever there's an input event within the Area2D's
# collision shape. 
# We don't need the viewport or shape information so we mute compilation
# warnings that mention not using the parameters by prefixing them with `_`.
func _on_Area2D_input_event(_viewport, event, _shape_idx) -> void:
    if event.is_action_pressed("touch"):
        _beat_hit = true
        # We disable any further events by removing any physics layers.
        _touch_area.collision_layer = 0
        # Run the destroy animation to hide and free the node.
        _animation_player.play("destroy")

And we’re done!

In the next lesson, we’ll create a new node that will spawn HitBeat instances.

Code reference

Here’s how the HitBeat.gd should look so far.

extends Node2D

var order_number := 0 setget set_order_number

var _beat_hit := false

onready var _animation_player := $AnimationPlayer
onready var _sprite := $Sprite
onready var _touch_area := $Area2D
onready var _label := $LabelCustom


func _ready() -> void:
    _animation_player.play("show")


func setup(data: Dictionary) -> void:
    self.order_number = data.half_beat
    global_position = data.global_position
    _sprite.frame = data.color


func set_order_number(number: int) -> void:
    order_number = number
    _label.text = str(order_number)


func _on_Area2D_input_event(_viewport, event, _shape_idx) -> void:
    if event.is_action_pressed("touch"):
        _beat_hit = true
        _touch_area.collision_layer = 0
        _animation_player.play("destroy")