It’s time to put the theory into practice and make some patterns! In this lesson, we’ll:
Create the PlacerHitBeat
and PlacerRest
scenes to place them in the editor to design patterns for each track.
Modify the HitSpawner
script so it;
Let’s get to it.
First up, we’ll create the scene to represent HitBeat
placeholders.
Create a new scene with a Node2D
as the root and name it PlacerHitBit. Add a Sprite
and a LabelCustom
instance named OrderNumber. We’ll use it to display the order in which the elements will play in the editor.
Save it as res://RhythmGame/Editor/PlacerHitBeat.tscn
and attach a script to the root node.
We will add custom scene tree icons to our placer scenes to make it easier to see which placer scenes are in the scene tree.
Here’s a screenshot from the final project so you can see how the different placer scene instances will look.
To give a node a custom icon like so, you need to attach a script to it, define its class name with the class_name
keyword, and provide a path to an image.
Let’s dive into the PlacerHitBeat.gd
to add the icon and some properties. Open the root node’s script and give it the following code.
# We use the `tool` mode to run this script's code in the editor. tool extends Node2D # Here's how you give nodes a custom icon. You define the class name, add a # comma, and provide a path to an image file. class_name PlacerHitBeat, "res://RhythmGame/Editor/placer_hit_beat_icon.svg" # This property holds the scene to instance instead of the placeholder at # runtime. For the PlacerHitBeat, this will be the HitBeat scene. # You could make this a constant instead. # I went with an exported variable to avoid hard-coding the path. export (PackedScene) var scene # The note's duration in half-beats. Any number greater than 1 will add rests to # the stack to delay the next instruction. export (int, 1, 4) var duration := 2 setget set_duration # The number to display in the editor and on the instanced HitBeat at runtime. var _order_number := 1 # This virtual function is called by Godot every time the node enters the scene # tree. func _enter_tree() -> void: # There, we set the node's order and update the label's text. # The `Node.get_index()` tells us a node's position in the scene tree # relative to other siblings. # It starts at `0`, which is why we add `1` to the value. _order_number = get_index() + 1 $OrderNumber.text = str(_order_number) # All placer scenes will have this function. The HitSpawner will call it to get # a dictionary to add to the stack. func get_data() -> Dictionary: return { scene = scene, order_number = _order_number, global_position = global_position, duration = duration } # Whenever we set the duration, we also want to update the sprite's frame. func set_duration(amount: int) -> void: duration = amount $Sprite.frame = duration - 1
If the node’s icon doesn’t update in the Scene dock, you may have to restart the editor.
Select the PlacerHitBeat and assign the HitBeat
scene to its Scene property in the Inspector.
For the Sprite node, assign the image icon_beats.png
to its Texture. The image is a sprite sheet with four frames, so we set its Animation > Hframes to 4
.
We’ll update the frame based on the duration of the PlacerHitBeat
.
The sprites are based on musical notation and represent a duration of:
That’s the PlacerHitBeat
done. Next, we’ll create a similar scene to represent rests.
The PlacerRest
scene is very similar to the PlacerHitBeat
.
To save time creating it, find the PlacerHitBeat.tscn
file in the FileSystem dock and duplicate it.
Name the new file PlacerRest
and open it.
Set the Sprite node’s Texture to placer_rest_icon.svg
.
Then, remove the script from the PlacerRest node and attach a new script to it.
Here’s the complete script. The notable differences are:
get_data()
function is shorter.Aside from that, the code’s similar to PlacerHitBeat.gd
, we could even use a parent class for it.
tool extends Node2D class_name PlacerRest, "res://RhythmGame/Editor/placer_rest_icon.svg" export (int, 1, 4) var duration := 2 setget set_duration var _order_number := 1 func _enter_tree() -> void: _order_number = get_index() + 1 $OrderNumber.text = str(_order_number) func get_data() -> Dictionary: return {duration = duration} func set_duration(amount : int) -> void: duration = amount $Sprite.frame = duration - 1
Now we have the pieces we need, let’s get creative and design a pattern for the Cephalopod track.
Create a new scene with a Node2D
as the root. Save it as res://RhythmGame/Tracks/Cephalopod/Cephalopod.tscn
.
We keep anything related to a track in its folder along, including this pattern. This makes modifying and finding things easier in larger projects.
As we talked about in the previous lesson, we want patterns to be made up of chunks of PlacerHitBeat
and PlacerRest
scenes, resulting in a tree structure like the following:
Above, I’m using several Node2D
to group parts of the track and quickly toggle their visibility. We’ll ignore them when reading the scene and only take the PlacerHitBeat
and PlacerRest
instances into account.
Create several Node2D
children. You can name them however you want, or use numbers, like the above.
We’ll use the scene hierarchy to play the beats anyway.
Although not strictly needed, I’m going to use the editor’s snapping functionality to help me position placers uniformly.
You can turn on grid snapping by clicking the icon in the top-left or by pressing Shift G (in Godot 3.3).
I’m only concerned about using a grid, so I’ll disable all the smart snapping features.
Then we can open the Configure Snap… window to set up grid snapping.
Having a grid of 96
pixels gives some padding to the HitBeat
scenes, which have a radius of 75
pixels.
It also allows us to cluster placers as in the following example, which I like to do for shorter duration HitBeat
scenes as the player will need to tap them quickly.
Now we have our snapping set up, we can work on designing chunks.
Under the first Node2D
, which I named Intro, add two PlacerRest
scene instances as children.
The rests’ position doesn’t matter, only the duration and order within the scene tree to generate a pattern stack correctly.
These two scenes will represent the track’s start before we want HitBeat
scenes to appear. We need to give the player some time to listen to the track and get a sense for the rhythm before giving them beats to tap.
Set the duration of the PlacerRest
scenes to 4
and 3
, respectively. This will give us seven half-beats before the next chunk.
The HitSpawner
will add an extra half-beat delay giving us a complete bar.
Note: also remember that our HitBeat
needs a delay of four whole-beats before they should be tapped, to display the animated ring.
Hide the Intro node, and we’ll move on to the remaining chunks.
All other chunks will have a total of 16 worth of half-beats or 8 beats. This represents two bars of music and is enough to work with to create some interesting patterns while also not cluttering up the screen.
Here’s an example of my 0 chunk.
In the editor, you’ll see a blue line representing the 1920 by 1080 screen we defined in the project settings. I’ve highlighted it with yellow to make it more obvious. As long as placer scenes are in this area, they’ll appear in the game.
Looking down the scene tree, we have:
PlacerHitBeat
of duration 4
which will instruct the HitSpawner
to instance a HitBeat
then do nothing for the next three half-beats;PlacerRest
of duration 4
which will instruct the HitSpawner
to do nothing for the next four half-beats;PlacerHitBeat
scenes of duration 2
which will instruct the HitSpawner
to instance a HitBeat
then do nothing on the next half-beat.This setup will result in the following gameplay sequence:
Continue designing as many chunks as you’d like before we move on to loading the patterns.
I also invite you to add patterns to any other tracks by creating a dedicated scene for them.
We have our patterns, but to have them display in-game, now we need to turn the scenes’ content into data for the the HitSpawner
to process.
First, we’ll create a scene to keep all the patterns in one place. Create a new Node2D
scene and name the root node Patterns. Save it as res://RhythmGame/Editor/Patterns.tscn
.
Instance each pattern you created as children, like Cephalopod.tscn
, save the scene, then go to the RhythmGameDemo
scene.
Instance the Patterns
scene as a child of HitSpawner so it can easily access it.
Open up HitSpawner.gd
and let’s make some changes.
# We add a dictionary to store each stack of instructions. # Doing so allows us to pair the stack with the name of the track, which is a # string. var _stacks = {} onready var patterns = $Patterns func _ready() -> void: # ... # We generate all of the stacks at the start of the game. _generate_stacks() _select_stack({name : "Cephalopod"}) # Before, we had one track, but now we select the correct stack from the _stacks # dictionary. In the next lesson we'll create a track selection UI which will # connect to this method which is why it has a dictionary as a parameter. func _select_stack(msg: Dictionary) -> void: _stack_current = _stacks[msg.name].stack
Before we look at the _generate_stacks()
function, let’s think about what we want it to do.
For each pattern we’ve created, we want to look at each chunk. We add data from each PlacerHitBeat
and PlacerRest
to the track’s stack for each chunk.
When the duration of a PlacerHitBeat
or PlacerRest
is greater than 1, we need to add empty instructions to the stack so the next instruction is processed at the correct time.
If we didn’t do this, we’d have HitBeat
scenes instanced every half-beat, no matter what their duration was.
# Reads each child of the Pattern node and gets an array of data from them. func _generate_stacks() -> void: for pattern in patterns.get_children(): # Create a new key pair in the _stacks dictionary. _stacks[pattern.name] = [] for chunk in pattern.get_children(): # We assign a random color to each section of the track. var sprite_frame := round(rand_range(0, 5)) # We get data from each placer instance and append it to the stack. for placer in chunk.get_children(): var hit_beat_data: Dictionary = placer.get_data() hit_beat_data.color = sprite_frame _stacks[pattern.name].append(hit_beat_data) # Add additional rests if needed. for _i in range(hit_beat_data.duration - 1): _stacks[pattern.name].append({}) # Free the patterns scene as it's not needed in-game. patterns.queue_free()
The last thing we’ll do is alter HitBeat.gd
to show the order_number
instead of the half-beat it spawned on.
In HitBeat.setup()
, replace the line self.order_number = data.half_beat
with the following:
func setup(data: Dictionary) -> void: #... self.order_number = data.order_number
If you run the game now, you’ll see your patterns for each track you display in time with the music.
You may find you need to alter the timing of some to get the player to tap when you want them to.
In the next lesson, we’ll add an end screen that will guide the player back to the track-selection menu when the stack has depleted.
PlacerHitBeat.gd
tool extends Node2D class_name PlacerHitBeat, "res://RhythmGame/Editor/placer_hit_beat_icon.svg" export (PackedScene) var scene export (int, 1, 4) var duration := 2 setget set_duration var _order_number := 1 func _enter_tree() -> void: _order_number = get_index() + 1 $OrderNumber.text = str(_order_number) func get_data() -> Dictionary: return { scene = scene, order_number = _order_number, global_position = global_position, duration = duration } func set_duration(amount: int) -> void: duration = amount $Sprite.frame = duration - 1
PlacerRest.gd
tool extends Node2D class_name PlacerRest, "res://RhythmGame/Editor/placer_rest_icon.svg" export (int, 1, 4) var duration := 2 setget set_duration var _order_number := 1 func _enter_tree() -> void: _order_number = get_index() + 1 $OrderNumber.text = str(_order_number) func get_data() -> Dictionary: return {duration = duration} func set_duration(amount : int) -> void: duration = amount $Sprite.frame = duration - 1
HitSpawner.gd
extends Node export var enabled := true export var hit_beat: PackedScene var _stack_current = [] var _stacks = {} onready var patterns = $Patterns func _ready() -> void: Events.connect("beat_incremented", self, "_spawn_beat") _generate_stacks() _select_stack({name = "Cephalopod"}) func _spawn_beat(msg: Dictionary) -> void: if not enabled: return if _stack_current.empty(): enabled = false Events.emit_signal("track_finished") return var hit_beat_data: Dictionary = _stack_current.pop_front() if not hit_beat_data.has("global_position"): return hit_beat_data.bps = msg.bps hit_beat_data.half_beat = msg.half_beat var new_beat: Node = hit_beat.instance() add_child(new_beat) new_beat.setup(hit_beat_data) func _select_stack(msg: Dictionary) -> void: _stack_current = _stacks[msg.name] func _generate_stacks() -> void: for pattern in patterns.get_children(): _stacks[pattern.name] = [] for chunk in pattern.get_children(): var sprite_frame := round(rand_range(0, 5)) for placer in chunk.get_children(): var hit_beat_data: Dictionary = placer.get_data() hit_beat_data.color = sprite_frame _stacks[pattern.name].append(hit_beat_data) for _i in range(hit_beat_data.duration - 1): _stacks[pattern.name].append({}) patterns.queue_free()
HitBeat.gd
extends Node2D var order_number := 0 setget set_order_number var _beat_hit := false var _score_perfect := 10 var _score_great := 5 var _score_ok := 3 var _radius_start := 150.0 var _radius_perfect := 70.0 var _radius := _radius_start var _offset_perfect := 4 var _offset_great := 8 var _offset_ok := 16 var _beat_delay := 4.0 var _speed := 0.0 onready var _animation_player := $AnimationPlayer onready var _sprite := $Sprite onready var _touch_area := $Area2D onready var _label := $LabelCustom onready var _target_circle := $TargetCircle func _ready() -> void: _animation_player.play("show") func setup(data: Dictionary) -> void: self.order_number = data.order_number global_position = data.global_position _sprite.frame = data.color _speed = 1.0 / data.bps / _beat_delay _target_circle.setup(_radius_start, _radius_perfect, data.bps, _beat_delay) func _process(delta: float) -> void: if _beat_hit: return _radius -= delta * (_radius_start - _radius_perfect) * _speed if _radius <= _radius_perfect - _offset_perfect: _touch_area.collision_layer = 0 Events.emit_signal("scored", {"score": 0, "position": global_position}) _animation_player.play("destroy") _beat_hit = true 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") Events.emit_signal("scored", {"score": _get_score(), "position": global_position}) func _get_score() -> int: if abs(_radius_perfect - _radius) < _offset_perfect: return _score_perfect elif abs(_radius_perfect - _radius) < _offset_great: return _score_great elif abs(_radius_perfect - _radius) < _offset_ok: return _score_ok return 0