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