The roller mechanic

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.

What we’ll create

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:

  1. We need to define a path with its shape, length, and duration.
  2. We need to check whether the player is following the path and keep track of the score.
  3. We also need to animate the path growing to guide the player.

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,

  1. The root node, HitRoller, is a Path2D node. It allows us to draw the path in the editor.
  2. GrowingLine2D is a 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.
  3. You can use a 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.
  4. Finally, the Roller node is an Area2D we use to track whether the cursor or finger follows the path.

With this in mind, let’s get started!

Creating the HitRoller scene

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.

Creating the growing line

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!

Code reference

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())