Generating the tracklist

In the previous lesson, we set up our track menu. In this one, we’ll populate our TrackTiles with multiple tracks to pick from.

And to do so, we need a list of track and corresponding metadata, like which audio file corresponds to a given track, its BPM, icon, and so on.

Listing the tracks

We’ll create a custom resource type, TrackData, and store instances of it in an array on the TrackTiles node.

In the RhythmGame/Tracks directory, right-click to create a new script and name it TrackData.gd.

There, we define several exported properties, which we’ll edit in the Inspector. We will create a TrackData resource for each track and store it in the TrackTiles node.

We also define a function to convert the resource to a dictionary as this is the data format our rhythm game expects.

class_name TrackData
extends Resource

## The track's title.
export var label := "Track Name"
## The audio file for this soundtrack.
export (String, FILE, "*.ogg") var stream
## The track's Beats Per Minute.
export var bpm := 1
## A texture to display in the track selection menu.
export var icon: Texture
## The track's author.
export var artist := "Artist"


## Converts the properties to a dictionary, to pass the track's data to the rhythm game.
func as_dict() -> Dictionary:
    return {"name": label, "stream": stream, "bpm": bpm, "icon": icon, "artist": artist}

With the script ready, we create a resource for each of our available tracks.

In the Cephalopod/ directory, create a new resource of type TrackData. Double-click the file to open it in the Inspector and fill all five fields.

To copy the Stream’s file path, you can right-click Cephalopod.ogg and select Copy Path.

You can repeat the operation for any other soundtrack you might have worked on.

Displaying available tracks in the menu

Next, we want to display the available tracks to the player. To do so, we will generate a row of track tile instances.

First, you want to delete the existing TrackTile instance we had as a child of the TrackTiles node.

Your scene tree should now look like this.

Then, attach a new script to the TackTiles node with the following code.

extends Node2D

# We will add an array of `TrackData` resources. Each resource will lead to
# displaying one tile on the screen.
export (Array, Resource) var tracks: Array
# The `TrackTile` scene, which we will instantiate once for each element in the
# `tracks` array.
export var track_tile_scene: PackedScene

# The horizontal separation between the icons in pixels.
export var separation := 450

# We use the following property to keep track of `TrackTile` instances.
# We'll need it in the next lesson to implement motion
var _track_tiles := []


# When the node's ready, we generate tiles.
# In the next lesson, we'll also scale them and tweak their opacity.
func _ready() -> void:
    _generate_tiles()


# This function generates track tile instances and places them in a row.
func _generate_tiles() -> void:
    # We offset each tile's position by this value.
    var offset := 0.0

    for i in tracks.size():
        # Our `tracks` array contains a list of `TrackData` resources, which we
        # individually store in the instanced `TrackTile` below.
        var track_data: TrackData = tracks[i]
        # We create and track tile instance for each soundtrack.
        var track_tile: TrackTile = track_tile_scene.instance()

        # We store the `TrackData` in the tile, as, moving forward, we will need
        # to retrieve it from the UITrackSelector's script to play the game. You
        # will see how in the next lesson.
        track_tile.track_data = track_data
        _track_tiles.append(track_tile)

        # For each instance, we increment the offset to separate the various
        # track icons.
        track_tile.position = Vector2(offset, 0)
        offset += separation

        add_child(track_tile)

Above, we use a currently undefined TrackTile type. We need to add a script to the TrackTile node next to define the track_data property.

Open the scene TrackTile.tscn and attach a new script to the root node.

class_name TrackTile
extends Area2D

var track_data: TrackData setget set_track_data


# When updating the `track_data` property, we assign its icon to the sprite.
func set_track_data(value: TrackData) -> void:
    track_data = value
    $Sprite.texture = track_data.icon

With that, you can head back to the UITrackSelector scene, select TrackTiles, and drag the TrackTile.tscn file onto its Track Tile Scene property.

In case the exported properties don’t appear and you see an error in the Output bottom panel, try restarting the editor.

We also want to expand the Tracks array and drag-and-drop the TrackData resources we created onto it, like cephalopod.tres.

In case you only have one, you can add it to the array multiple times to see the code in action.

You can now run the scene to see your tiles automatically generated.

We’ll improve the tiles’ look in the next lesson.

In this series, we register and load the available tracks manually.

If you’re going to commercialize such a game, you might want to write code that automatically detects and loads available songs, like games like Osu! and Stepmania do.

Here, we focused interested in showing you how to implement time-sensitive gameplay and the touch interface.

Adding audio playback

We still lack audio playback and the ability to start the game. Let’s add a script to UITrackSelector to do so.

extends Control

# We store the currently selected track's data to have it handy when the
# player presses the go button.
var _current_track_data: TrackData

onready var _track_name := $TrackName
onready var _stream := $AudioStreamPlayer
onready var _animation_player := $AnimationPlayer


# This function updates the track name displayed on screen and plays it
# soundtrack, using our fade animations for smooth playback.
func update_track_info(track_tile: TrackTile) -> void:
    _current_track_data = track_tile.track_data
    _track_name.text = _current_track_data.label

    # Here is where we play the animations. We first play `fade_out_track`, wait
    # for it to finish using `yield`, then load the new track, play it starting
    # at 30 seconds, and fade it in.
    _animation_player.play("fade_out_track")
    yield(_animation_player, "animation_finished")
    _stream.stream = load(_current_track_data.stream)
    _stream.play(30.0)
    _animation_player.play("fade_in_track")

We can add a temporary call to update_track_info() in the _ready() function to test sound playback. We will call update_track_info() through a signal in the next lesson.

# Be sure to remove this temporary code after testing
# Here, we manually grab the first `TrackTile` scene instance.
func _ready() -> void:
    update_track_info($TrackCarousel/TrackTiles.get_child(0))

To wrap this up, we connect the GoButton’s pressed signal to the root node.

In the callback function, we emit a new Events.track_selected signal, which will cause the game to start.

Then, we delete the interface to prevent any more interaction with it.

func _on_GoButton_pressed() -> void:
    Events.emit_signal("track_selected", _current_track_data.as_dict())
    queue_free()

Finally, we add the signal to Events.gd.

signal track_selected(msg)

Playing the selected track

We now want to instantiate our UITrackSelected interface in the RhythmGameDemo scene as part of the UI canvas layer.

Open the RhythmGameDemo scene and add an instance of the UITrackSelector scene as a child of UI.

Make it visible, and hide UITrackPlaying. This way, the game will start with the track selection menu visible.

Currently, the Synchronizer script starts playing a soundtrack automatically. We have to update its code to wait for the player to press the “Go” button.

Open Synchronizer.gd and replace the _ready() function’s code to listen to the Events.track_selected signal. We also add a new _load_track() function that sets up the BPM and other values received with the signal, before calling play_audio().

func _ready() -> void:
    Events.connect("track_selected", self, "_load_track")
    
    
func _load_track(msg: Dictionary) -> void:
    _stream.stream = load(msg.stream)
    bpm = msg.bpm
    _bps = 60.0 / bpm
    _hbps = _bps * 0.5
    play_audio()

You can now play the selected track by playing the game and clicking on “Go”.

Of course, we cannot change the selected track by clicking and dragging or using our finger on a touchscreen.

We will code this feature in the next lesson. See you there!

Code reference

Here are all the new code files added in this lesson.

TrackData.gd

class_name TrackData
extends Resource

export var label := "Track Name"
export (String, FILE, "*.ogg") var stream
export var bpm := 1
export var icon: Texture
export var artist := "Artist"


func as_dict() -> Dictionary:
    return {"name": label, "stream": stream, "bpm": bpm, "icon": icon, "artist": artist}

TrackTiles.gd

extends Node2D

export (Array, Resource) var tracks: Array
export var track_tile_scene: PackedScene

export var separation := 450

var _track_tiles := []


func _ready() -> void:
    _generate_tiles()


func _generate_tiles() -> void:
    var offset := 0.0

    for i in tracks.size():
        var track_data: TrackData = tracks[i]
        var track_tile: TrackTile = track_tile_scene.instance()

        track_tile.track_data = track_data
        _track_tiles.append(track_tile)

        track_tile.position = Vector2(offset, 0)
        offset += separation

        add_child(track_tile)

TrackTile.gd

class_name TrackTile
extends Area2D

var track_data: TrackData setget set_track_data


func set_track_data(value: TrackData) -> void:
    track_data = value
    $Sprite.texture = track_data.icon

UITrackSelector.gd

extends Control

var _current_track_data: TrackData

onready var _track_name := $TrackName
onready var _stream := $AudioStreamPlayer
onready var _animation_player := $AnimationPlayer


func update_track_info(track_tile: TrackTile) -> void:
    _current_track_data = track_tile.track_data
    _track_name.text = _current_track_data.label

    _animation_player.play("fade_out_track")
    yield(_animation_player, "animation_finished")
    _stream.stream = load(_current_track_data.stream)
    _stream.play(30.0)
    _animation_player.play("fade_in_track")


func _on_GoButton_pressed() -> void:
    Events.emit_signal("track_selected", _current_track_data.as_dict())
    queue_free()

Events.gd

extends Node

signal beat_incremented(msg)
signal scored(msg)

signal track_finished
signal track_selected(msg)

Synchronizer.gd

extends Node

export var bpm := 124

var _bps := 60.0 / bpm
var _hbps := _bps * 0.5
var _last_half_beat := 0

onready var _stream := $AudioStreamPlayer


func _ready() -> void:
    Events.connect("track_selected", self, "_load_track")


func play_audio() -> void:
    var time_delay := AudioServer.get_time_to_next_mix() + AudioServer.get_output_latency()
    yield(get_tree().create_timer(time_delay), "timeout")

    _stream.play()


func _process(_delta: float) -> void:
    var time: float = (
        _stream.get_playback_position()
        + AudioServer.get_time_since_last_mix()
        - AudioServer.get_output_latency()
    )

    var half_beat := int(time / _hbps)

    if half_beat > _last_half_beat:
        _last_half_beat = half_beat
        Events.emit_signal("beat_incremented", {"half_beat" : half_beat, "bps": _bps})


func _load_track(msg: Dictionary) -> void:
    _stream.stream = load(msg.stream)
    bpm = msg.bpm
    _bps = 60.0 / bpm
    _hbps = _bps * 0.5
    play_audio()