In the last three lessons, we’ll create the roller mechanic. It’s a gameplay element made of a touch button, a path, and an end point.
In this lesson, we’ll get started by drawing and animating the roller.
When the player touches the starting point, a disk starts following the path. The player has to follow along with their finger until the end.
Instead of testing the player’s timing, the roller also tests their ability to follow a path over some time.
To convey the path’s direction, we’ll not only use the numbers on buttons; we’ll animate the path’s drawing in the direction the player should follow.
As I mentioned, the player should then follow the roller with their cursor or finger.
The longer they keep the cursor in the roller’s bounds, the more points they gain. To keep scoring consistent, we’ll have the best possible outcome be ten points.
So we have to implement the following features:
Let’s take a look at how we can separate these small problems into a coherent scene.
We’ll build a scene with the following structure. I outlined key nodes in yellow.
Here,
Path2D
node. It allows us to draw the path in the editor.Line2D
, which we’ll use to draw the roller’s path and animate drawing. Line2D
can draw both lines and curves and apply a gradient or size curve to them.PathFollow2D
node as a child of a Path2D
to follow along the curve. Here, RollerFollow is a PathFollow2D
. We’ll use it to move an area along the path.Area2D
we use to track whether the cursor or finger follows the path.With this in mind, let’s get started!
Create a new scene with a Path2D
named HitRoller as the root and save the scene in the RhythmGame/Hits/
directory.
We have a lot to do, but the good news is we can cheat a bit and reuse nodes we’ve already created and set up!
Right-click the root node and select Merge From Scene.
Open the HitBeat.tscn
file and select its Sprite, LabelCustom, and AnimationPlayer before clicking OK.
This copies the node to the scene. If you select the AnimationPlayer, you’ll see that we’ve copied over animations too.
From Godot 3.3, you can copy and paste nodes across scenes directly from the scene tree.
Our HitRoller needs two sprites: one at the start of the path and one at the end.
Make the LabelCustom a child of the Sprite and duplicate the two. I renamed the sprites to SpriteStart and SpriteEnd, respectively.
We move the labels as children as we’ll move SpriteEnd to the end of the path. Your scene should now look like this.
I renamed the CustomLabel nodes to Label too.
We can now attach a script to HitRoller and dive into the code. Predictably, we’re going to reuse some of the HitBeat.gd
script too!
Except for this time, the root node type is Path2D
.
extends Path2D var order_number := 0 setget set_order_number # Whole-beats before the roller starts moving. var _beat_delay := 4.0 onready var _sprite_start := $SpriteStart onready var _label_start := $SpriteStart/Label onready var _sprite_end := $SpriteEnd onready var _label_end := $SpriteEnd/Label onready var _animation_player := $AnimationPlayer # When updating the number, we update the scene's labels to emphasize the path's # direction. Unlike in `HitBeat.gd`, we have two nodes to update this time. func set_order_number(number: int) -> void: order_number = number _label_start.text = str(number) _label_end.text = str(number + 1)
When adding an instance of HitRoller
to the tree, we play its “show” animation.
Then, in setup()
, are also similarities in the setup()
function. We’ll alter this function as we add more components that need setting up.
func _ready() -> void: _animation_player.play("show") func setup(data: Dictionary) -> void: # We get the curve's points and place the sprites on the first and last # points. var curve_points := curve.get_baked_points() _sprite_start.position = curve_points[0] _sprite_end.position = curve_points[curve_points.size() - 1] # We update the labels, position the node, and assign the sprites a color. self.order_number = data.order_number global_position = data.global_position _sprite_start.frame = data.color _sprite_end.frame = data.color # We'll call this when the roller reached the end of the path. func destroy() -> void: _animation_player.play("destroy")
To test our scene, we’re going to simulate it being instanced by populating a dictionary with information that’d normally be passed to it via the Beatspawner
.
func _ready() -> void: #... var test_data := {global_position = global_position, order_number = 1, color = 0} setup(test_data)
Before running the scene, we must draw a path, as our script will try to access the curve’s points.
Select the root node, and click the Add Point icon in the toolbar. It’s the one with a green plus sign. Also, you need to be in Select Mode (Q) for the tool to work.
Then, you can click anywhere in the viewport to add points.
Run the scene by pressing F6.
So far, so good! Each sprite is placed correctly.
As you can see, the Path2D
curve isn’t rendered while the game runs, which is why we’ll create our next component: a growing line that will draw the curve.
Add a Line2D
node as a child of HitRoller named GrowingLine2D and attach a script to it. Save the script alongside the HitRoller.tscn
file.
Back to the scene, as children of GrowingLine2D, add a Timer
called GrowthTimer and a Tween
node.
The Line2D
node draws lines between Vector2
points. As luck would have it, the Path2D
curve is also made up of Vector2
points.
We can get the curve’s points and pass them to the Line2D
to draw it. We’ll use the timer to update the line and add points one by one, giving us that line drawing progressively.
Also, we’ll use the Tween
node to increase the line’s width over time.
We could use an AnimationPlayer
as well, but tweens are more flexible if you want to alter values such as duration, width, and so on through code.
In this case, being able to alter properties easily is desirable because we could save this GrowingLine2D as its own scene and reuse it elsewhere.
Before we move on to the script, we’ll set the Default Color of GrowingLine2D to be the same as the target circle for consistency. The hexadecimal value of this is 4affffff
. You can paste it into the color picker.
We also set all three Capping properties to Round.
Rounded capping rounds out the curve on sharp turns and at either end of the curve.
On the left, you can see a line with the default settings. And on the right with the round caps and joint.
Connect the timeout
signal to the GrowingLine2D
script. Doing so will open up the script editor, so let’s get coding!
We’ll start, as always, by defining some properties.
extends Line2D # The GrowthTimer's `wait_time`. Every time it times out, we add a point to the line. export var timer_duration := 0.01 # The line's final width in pixels. export var target_width := 48.0 # The line width animation's duration in seconds. export var expand_duration := 1.0 # We'll store the curve's points here. var _path_points := [] onready var _growth_timer := $GrowthTimer onready var _tween := $Tween
The setup function takes a vector2 array as a parameter and stores it. We clear the line’s points to ensure we have a blank canvas to work with.
func setup(curve_points: PoolVector2Array) -> void: _path_points = curve_points clear_points()
Next, we define a start()
function. When we call it, we start the timer and begin tweening the width.
func start() -> void: _growth_timer.start(timer_duration) _tween.interpolate_property(self, "width", 0, target_width, expand_duration, Tween.TRANS_LINEAR) _tween.start()
Finally, the method the timer is connected to grabs the first point in the cached array and adds it to the points to draw.
If there are no points left, the curve is complete, so we stop the timer.
func _on_GrowthTimer_timeout() -> void: if not _path_points: _growth_timer.stop() return add_point(_path_points.pop_front())
Head over to HitRoller.gd
to set up and start drawing the line.
# ... onready var _growing_line := $GrowingLine2D func setup(data: Dictionary) -> void: #... _growing_line.setup(curve_points) _growing_line.start()
Rerunning the scene, we can see the line grow and follow the path!
There’s overlap going on here, which is visible because we’re using a semi-transparent color and the corners are too harsh.
We can solve this using a shader or by smoothing out the corners. Here, we’ll do the latter.
Select the root node and use the Select Control Points tool.
It’s the second-from-left curve editing icon, with the shape of a curve.
With it, you can click and drag on any existing point to define the curve’s tangents and smooth out corners.
Take note of how to manipulate curves in this way. We’ll be using the same technique to shape the curves for the roller placer scene.
And there we go! Much more pleasing to the eye.
We’ll pause here to reflect on the fruits of our labor before managing the score and getting the roller moving in the next lesson.
See you there!
Here are the two scripts we created in this lesson.
HitRoller.gd
extends Path2D var order_number := 0 setget set_order_number var _beat_delay := 4.0 onready var _sprite_start := $SpriteStart onready var _label_start := $SpriteStart/Label onready var _sprite_end := $SpriteEnd onready var _label_end := $SpriteEnd/Label onready var _animation_player := $AnimationPlayer onready var _growing_line := $GrowingLine2D func _ready() -> void: _animation_player.play("show") var test_data := {global_position = global_position, order_number = 1, color = 0} setup(test_data) func setup(data: Dictionary) -> void: var curve_points := curve.get_baked_points() _sprite_start.position = curve_points[0] _sprite_end.position = curve_points[curve_points.size() - 1] self.order_number = data.order_number global_position = data.global_position _sprite_start.frame = data.color _sprite_end.frame = data.color _growing_line.setup(curve_points) _growing_line.start() func set_order_number(number: int) -> void: order_number = number _label_start.text = str(number) _label_end.text = str(number + 1) func destroy() -> void: _animation_player.play("destroy")
GrowingLine2D.gd
extends Line2D export var timer_duration := 0.01 export var target_width := 48.0 export var expand_duration := 1.0 var _path_points := [] onready var _growth_timer := $GrowthTimer onready var _tween := $Tween func setup(curve_points: PoolVector2Array) -> void: _path_points = curve_points clear_points() func start() -> void: _growth_timer.start(timer_duration) _tween.interpolate_property(self, "width", 0, target_width, expand_duration, Tween.TRANS_LINEAR) _tween.start() func _on_GrowthTimer_timeout() -> void: if not _path_points: _growth_timer.stop() return add_point(_path_points.pop_front())