In this lesson, we’ll create the HitSpawner
, a node that instances buttons the player can tap. We’ll start building the gameplay.
Before we create a HitSpawner
node, we’ll tweak our project settings.
Defining the target resolution is important to do early, before placing elements on the screen, as the resolution affects where we position objects.
Head to Project > Project Settings… > Display > Window and set the width to 1920
and the height to 1080
.
This is our target resolution and our design space; we want to spawn HitBeats
only in this space.
This is also a common resolution for games and showcases our sprites nicely due to its higher resolution.
You may want to set the Test Width and Test Height properties to have a smaller window when you run the game. I often do this to so a larger game window can fit my monitor’s resolution.
As mentioned before, we want to lock this resolution in. We can have our game always display the desired 1920x1080 resolution by stretching it.
The final thing to point out here is Use Vsync. Vertical synchronization helps smooth out frames and reduce screen tearing, but we disable it here to reduce input latency.
For our game to feel responsive, we need as little input latency as possible; as soon as the player taps a HitBeat
, we need to give the player feedback that it’s been tapped with as little delay as possible.
You’ll be prompted to restart the project after disabling Vsync. Go ahead, and we’ll continue.
We can now start coding the HitSpawner
.
Add a new Node
as a child to RhythmGameDemo and name it HitSpawner. Attach a script and save it in the RhythmGame folder along with Synchronizer.gd
.
We’ll add some complexity to this script later when we add the ability to load multiple track patterns, but for now, we’ll add the _spawn_beat()
function and connect it to the Events
autoload we made in the previous lesson.
extends Node # If `true`, the spawner is actively spawning beats. We use it in `_spawn_beat()` below. export var enabled := true func _ready() -> void: Events.connect("beat_incremented", self, "_spawn_beat") # Spawns a button the player can tap. Expects a dictionary with the form # {half_beat = int, bps = float}. # This gives us all the information we need to selectively spawn buttons at # specific rhythmic moments in the music. func _spawn_beat(msg: Dictionary) -> void: # If the spawner is not enabled, we just return from the function and spawn # nothing. if not enabled: return print(msg.half_beat)
Before we spawn buttons, we’ll head to Synchronizer
to emit the Events.beat_incremented
signal along with the corresponding dictionary.
As we created the Events
autoload in the previous lesson, we can connect the beat_incremented
signal to the _spawn_beat()
function.
In Synchronizer.gd
, emit the beat_incremented
signal at the end of the process function:
func _process(_delta: float) -> void: #... #if half_beat > _last_half_beat: #... Events.emit_signal("beat_incremented", {"half_beat" : half_beat, "bps": _bps})
There, we emit the values our spawner expects.
Check everything is working correctly by running the project. You should see the following in the Output tab:
Each number represents when the Synchronizer
has detected we passed a half-beat in the track.
It’s time! We have our project set up and our resolution set. Let’s spawn some HitBeats
!
Open up HitSpawner.gd
and add the following exported property.
# Allows us to store the preloaded hit_beat scene # to instance it. export var hit_beat: PackedScene
Exporting the hit_beat
variable as a PackedScene
allows us to do a few useful things.
First off, we can set the path to the HitBeat
in the Inspector. Select the HitSpawner
and load up the HitBeat
scene.
This stops us from having to type the scene path, but it also means we don’t have to worry if we later decide to move the HitBeat
scene in the FileSystem because Godot updates the path to it automatically.
You may recall we defined a setup()
function in HitBeat.gd
to pass information to the HitBeat
after it had been instanced.
That function took a Dictionary
as a parameter, and we’ll see how that looks here.
We use a Dictionary
because its ability to store key pairs makes it convenient to bundle multiple parameters together and pass them to functions via signals. We can also extend it later if need be.
Open HitSpawner.gd
and add the following code to the _spawn_beat()
function.
You can also remove the call to print()
, which we used for testing purposes.
func _spawn_beat(msg: Dictionary) -> void: # ... # For now, we'll spawn a HitBeat every whole-beat, or every two half-beats. if msg.half_beat % 2: return # We calculate the values to pass to the HitBeat's `setup()` function. # It expects var beat: Dictionary = {} beat.half_beat = msg.half_beat # We randomly choose the sprite frame. beat.color = int(rand_range(0, 5)) # We calculate a random position on the screen to spawn the HitBeat. beat.global_position = Vector2(rand_range(0, 1920), rand_range(0, 1080)) # And we store the beats per minute in the dictionary. beat.bps = msg.bps # We instance our HitBeat and add it to the scene tree. var new_beat: Node = hit_beat.instance() add_child(new_beat) # We pass the dictionay holding the beat information to the HitBeat via its # `setup()` function. new_beat.setup(beat)
When you run the project, you’ll now see HitBeat
scenes appear randomly around the window and, if you tap them, they’ll disappear. In the next few lessons, we’ll work towards adding timing to the HitBeats
such that the closer you tap them, the better score you get!
Before that, we’ll spruce up the visuals and add a score user interface element to increment it based on our taps.
HitSpawner.gd
extends Node export var enabled := true export var hit_beat: PackedScene func _ready() -> void: Events.connect("beat_incremented", self, "_spawn_beat") func _spawn_beat(msg: Dictionary) -> void: if not enabled: return if msg.half_beat % 2: return var hit_beat_data: Dictionary = {} hit_beat_data.half_beat = msg.half_beat hit_beat_data.color = int(rand_range(0, 5)) hit_beat_data.global_position = Vector2(rand_range(0, 1920), rand_range(0, 1080)) hit_beat_data.bps = msg.bps var new_beat: Node = hit_beat.instance() add_child(new_beat) new_beat.setup(hit_beat_data)
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: play_audio() 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})