Managing the roller score

Scoring in the roller scene is very different from the HitBeat because the player’s performance determines the score over time.

If the player manages to keep the cursor in the roller area as it moves along the path, the player scores highly.

We’ll tackle this by polling the player’s cursor position at specific points along the path.

As we want the maximum score to be 10, we’ll check ten times whether the player is in the roller’s bounds throughout the roller’s travel.

In the above image, each yellow dot represents when we check the player has their cursor in the right place. If the player has the cursor in the right place and is clicking down, we award them 1 point.

When the roller reaches the end of its travel, we submit the total score as we did with the HitBeat.

Creating the roller component

We already know we want the roller to move along the path, so add a PathFollow2D as a child of HitRoller and name it RollerFollow.

To this, add an Area2D as a child named Roller. This is the area that’ll move with the RollerFollow and keep track of the player’s score.

To the roller, add a CollisionShape2D, a Sprite, a Timer named ScoreTimer, and a Tween.

Finally, you can attach a script to the roller.

Let’s set up the children.

First, assign the roller_sprites.png to the Sprite’s Texture.

These sprites have the black border removed to distinguish them from beat sprites. There are six different colors available, so we set the Animation > HFrames to 6.

Add a CircleShape to the CollisionShape2D’s Shape property. Rather than fitting the sprite, I chose to set the radius to 90 to give the player a bit of extra room to gain points score.

Especially when using a small mobile display, their finger will cover the roller and make it harder to score with a tight collision shape.

It’s a similar concept to why it’s good to code helpers such as coyote jump in a platformer - it feels unfair to the player to be punished for something they are doing in good conscience but slightly mistimed.

Connect the ScoreTimer’s timeout signal to the Roller node. We can then get working on the code.

extends Area2D

## The points awarded to the player when the roller reaches the end of the path.
## We increment the value as the roller moves along the path.
var _score := 0
# Time interval between score checks, in seconds. We'll calculate this duration
# based on the roller's total duration, the song's speed, and the `_poll_amount`
# below when calling `setup()`
var _poll_time := 0.0
var _poll_amount := 10
var _is_player_in_bounds := false

onready var _score_timer := $ScoreTimer
onready var _tween := $Tween
onready var _sprite := $Sprite

func _ready() -> void:
    # Make sure the sprite of the roller is hidden at the beginning of the
    # animation. You could also set this in the inspector instead.
    _sprite.scale = Vector2.ZERO

Let’s first take a look at the scoring methods. We should already have a blank _on_ScoreTimer_timeout() method from connecting the timer in the previous step.

func _on_ScoreTimer_timeout() -> void:
    if _is_player_in_bounds and Input.is_action_pressed("touch"):
        _score += 1

To set _is_player_in_bounds, we connect the Roller’s mouse_entered and mouse_exited signals to two new _on_mouse_entered() and _on_mouse_exited() methods.

Be sure to connect them to the Roller node itself, not the root HitRoller.

func _on_mouse_entered() -> void:
    _is_player_in_bounds = true


func _on_mouse_exited() -> void:
    _is_player_in_bounds = false

This is a way to track when the cursor (or finger) enters the collision shape. It only takes the cursor’s position into account, which is why we have to check for the input being pressed separately.

Next, we define a setup() method to provide the node with required data, namely the song’s beats per second, the roller’s duration, and the sprite color we’re using.

func setup(beats_per_second: float, duration: float, color: int) -> void:
    # We convert the seconds into beats, divide by 2 to convert to half-beats.
    # This gives the time the roller should travel from start to finish. We
    # split that time by `_poll_amount` which we'll use for the `ScoreTimer`
    _poll_time = beats_per_second * duration / 2.0 / _poll_amount
    _sprite.frame = color

Finally, we add a method to animate the sprite and start the timer so the roller can begin counting the score.

We define a different method because we don’t want to start checking for input as soon as the roller enters the scene tree.

As with the HitBeat, we want to have a four-beat wait before the player needs to interact with the roller. We’ll call this activate() method when the roller starts moving and scale the sprite.

func activate() -> void:
    _tween.interpolate_property(_sprite, "scale",
        Vector2.ZERO,
        Vector2.ONE, 0.2, Tween.TRANS_BACK, Tween.EASE_OUT)
    _tween.start()
    _score_timer.start(_poll_time)

Finally, we define a function to increase the player’s score. We’ll call it when the roller reaches the end of the path.

func submit_score() -> void:
    Events.emit_signal("scored", {"score": _score, "position": global_position})

We can now head back to HitRoller.gd and use our test_data dictionary once again to test everything’s working as intended.

# ...
onready var _roller := $RollerFollow/Roller


func _ready() -> void:
    #...
    # We add `bps` and `duration` for testing.
    var test_data := {
        global_position = global_position, order_number = 1, color = 0, bps = 1, duration = 4
    }
    # ...


func setup(data: Dictionary) -> void:
    # ...
    _roller.setup(data.bps, data.duration, data.color)

You should be able to run the scene without errors, even though nothing happens yet.

Moving the roller

The final piece of the puzzle is getting the roller to appear and move. For that, we already created a PathFollow2D node, RollerFollow.

We need to make sure the movement is in sync with the song, and it also has a delay before we start moving.

We’ll use a tween to animate the movement so add a Tween node as a child of RollerFollow.

We’ll add a script to take into account this custom behavior. Attach a script to RollerFollow.

The script only has one method.

extends PathFollow2D

# When the movement is finished, we emit this so the roller knows to submit the
# score, and the root node calls `queue_free`.
signal movement_finished

onready var _roller := $Roller
onready var _tween := $Tween


## Moves the node from the start to the end of the path by animating its `unit_offset` property.
func start_movement(delay: float, duration: float) -> void:
    # First, we delay the movement animation as we'll have that four-beat
    # `TargetCircle` animation.
    # We'll calculate the delay during setup in `HitRoller.gd`. We hold the
    # execution of the method until that amount of time has passed.
    yield(get_tree().create_timer(delay), "timeout")

    # Then, we can have the roller track the player's score.
    _roller.activate()

    # We animate the `unit_offset` from 0 to 1 based on the duration which we
    # also calculate at setup.
    # This causes the roller to move along the path.
    _tween.interpolate_property(
        self, "unit_offset", 0, 1, duration, Tween.TRANS_LINEAR, Tween.EASE_IN
    )
    _tween.start()

    yield(_tween, "tween_all_completed")

    # When the tween completes, we reached the end of the path, so we emit a
    # signal to let other components know.
    emit_signal("movement_finished")

It’s time to set this component up in HitRoller.gd.

# ...
onready var _roller_follow := $RollerFollow


func setup(data: Dictionary) -> void:
    # ...

    # We want to delay the movement by four beats.
    var roller_path_delay = data.bps * _beat_delay
    # Our game uses half-beat as a base for time measurements so we convert the
    # duration to half-beats.
    var roller_path_duration = data.bps * data.duration / 2.0
    _roller_follow.start_movement(roller_path_delay, roller_path_duration)

If you run the scene now, you’ll see the roller move after a while.

But the roller lingers at the end of the path because we haven’t signaled it to destroy itself. It’s time to put that movement_finished signal to use!

Connect the RollerFollow’s movement_finished signal to HitRoller’s destroy() method.

Also, connect it to the Roller’s submit_score().

With that, we have our complete roller scene!

If you play the game now, you’ll see the roller disappears automatically.

Tidying up

As everything’s working as expected, we remove all temporary test code.

In HitRoller.gd, remove the test_data dictionary from the _ready() method, as well as the code to setup().

The function should look like this.

func _ready() -> void:
    _animation_player.play("show")

Also, clear the HitRoller’s Curve in the Inspector. We’ll design the curve in our tracks’ pattern scenes.

In the next and final lesson, we’ll create a scene to design different rollers incorporate the new mechanic into playable tracks.

Code reference

Roller.gd

extends Area2D

var _score := 0
var _poll_time := 0.0
var _poll_amount := 10
var _is_player_in_bounds := false

onready var _score_timer := $ScoreTimer
onready var _tween := $Tween
onready var _sprite := $Sprite


func _ready() -> void:
    _sprite.scale = Vector2.ZERO


func setup(beats_per_second: float, duration: float, color: int) -> void:
    _poll_time = beats_per_second * duration / 2.0 / _poll_amount
    _sprite.frame = color


func activate() -> void:
    _tween.interpolate_property(_sprite, "scale",
        Vector2.ZERO,
        Vector2.ONE, 0.2, Tween.TRANS_BACK, Tween.EASE_OUT)
    _tween.start()
    _score_timer.start(_poll_time)


func submit_score() -> void:
    Events.emit_signal("scored", {"score": _score, "position": global_position})


func _on_ScoreTimer_timeout() -> void:
    if _is_player_in_bounds and Input.is_action_pressed("touch"):
        _score += 1


func _on_mouse_entered() -> void:
    _is_player_in_bounds = true


func _on_mouse_exited() -> void:
    _is_player_in_bounds = false

RollerFollow.gd

extends PathFollow2D

signal movement_finished

onready var _roller := $Roller
onready var _tween := $Tween


func start_movement(delay: float, duration: float) -> void:
    yield(get_tree().create_timer(delay), "timeout")
    _roller.activate()
    _tween.interpolate_property(
        self, "unit_offset", 0, 1, duration, Tween.TRANS_LINEAR, Tween.EASE_IN
    )
    _tween.start()
    yield(_tween, "tween_all_completed")
    emit_signal("movement_finished")

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
onready var _roller := $RollerFollow/Roller
onready var _roller_follow := $RollerFollow


func _ready() -> void:
    _animation_player.play("show")


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

    _roller.setup(data.bps, data.duration, data.color)

    var roller_path_delay = data.bps * _beat_delay
    var roller_path_duration = data.bps * data.duration / 2.0
    _roller_follow.start_movement(roller_path_delay, roller_path_duration)


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