The menu’s touch input

We can play a track but not change the selected one.

In this lesson, we will implement touch and drag detection and leverage Godot’s Area2D node to select a track for us automatically.

We need to do three things:

  1. Detect a dragging motion on the upper part of the screen.
  2. Move and update the track tiles as the player clicks and drags.
  3. Update the selected track as the player changes it.

Detecting a dragging motion

Let’s start by creating a node to detect a dragging motion over the tracklist.

This node’s role is exclusively to detect the player dragging on a specific part of the screen. Once again, we use Area2D to do so.

It’s a convenient little script you can reuse whenever you need to detect dragging the mouse or a finger in a specific area.

Create a new Area2D node named DragDetector as a child of TrackCarousel and give it a wide rectangular collision shape that spans the viewport’s upper area.

This is where the player will be able to click and drag to move the tiles.

Be sure to turn off its Monitorable property so it can’t be detected by other areas.

We attach a script to the node and, Before adding any lines of code to it, connect the input_event signal to the node itself. I connected it to a function named _on_input_event().

This input_event signal is available to physics objects and allows you to detect an input that happened within its collision shapes, like a click within the rectangle we defined.

With that, we can detect dragging with only several lines of code.

class_name DragDetector
extends Area2D

## We emit the signal any time we detected a drag motion, allowing any other node to react to it.
signal dragged(amount)

# We multiply the number of pixels the player dragged by this value.
# You could scale it when the player drags faster, as seen on mobile devices, using the power function instead of a multiplication.
export var strength := 2.0


func _on_input_event(_viewport, event, _shape_idx) -> void:
    # The object representing mouse motion events has all the information we need to detect a drag motion.
    # It relative property tells us how many pixels the cursor or finger moved since the previous frame.
    # Notice how we use the input singleton here to ensure that either a mouse button is clicked or that the player's finger is touching the screen.
    if event is InputEventMouseMotion and Input.is_action_pressed("touch"):
        emit_signal("dragged", event.relative * strength)

Moving the tiles horizontally

We now get to the larger code change.

We will animate the tiles moving horizontally as the player drags them.

We want the node to:

  1. Scroll horizontally when the player drags over the DragDetector.
  2. Limit the range of motion so the player can’t push the tiles outside the screen.
  3. When the player releases that touch, smoothly center the tile closest to the viewport center.

To do so, we’ll need two new nodes as children of TrackTiles. Add a Timer named AlignTimer and a Tween node.

We will use the AlignTimer to slightly delay re-centering tiles when the player releases a touch, something we’ll add last.

Select the node and turn on its One Shot property so it doesn’t cycle.

Also, set its Wait Time to a short duration, around 0.1 seconds.

To move all the tiles horizontally, we will animate the position of the TrackTiles node, causing all its children to scroll alongside it.

We first connect the DragDetector’s dragged signal to TrackTiles. We’ll call a new scroll() function through its callback.

To limit the scrolling, we introduce a new property, _min_x_position, and update _generate_tiles() to calculate it when we first display the menu.

## The bounds for the node's maximum range of motion. We use it to prevent the
## player from pushing all the tiles outside the screen.
var _min_x_position := 0.0


func scroll(amount: Vector2) -> void:
    position.x = clamp(position.x + amount.x, _min_x_position, 0)


func _generate_tiles() -> void:
    #...
    # The bound position is negative because the player will drag the node to 
    # the left with their finger.
    # Be careful to place this outside the `for` loop.
    _min_x_position = -(separation * (_track_tiles.size() - 1))


func _on_DragDetector_dragged(amount: Vector2) -> void:
    scroll(amount)

Right now, if you try to move the tiles, nothing happens.

That’s because of our UITrackSelector’s mouse filter: it’s interrupting all mouse input. Select the node and set its Mouse Filter to Ignore.

You can now play and move the tiles.

The thing is, upon releasing the mouse click, the tiles stay in place, even if none’s centered.

Instead, we want to center the selected track, and that’s where the timer and tween nodes come in.

Back in TrackTiles.gd, we add the following code.

onready var _align_timer := $AlignTimer
onready var _tween := $Tween


func scroll(amount: Vector2) -> void:
    #...
    _tween.stop_all()


## This function takes a `TrackTile` instance and starts a tween animation to
## center it on the screen.
func _snap_to_track(track_tile: TrackTile) -> void:
    # Here, we calculate the offset vector from the `track_tile` to the center
    # of the carousel.
    # To do so, we get the parent, the `TrackCarousel` node, which we centered
    # horizontally in the viewport.
    var offset_to_center := Vector2(get_parent().global_position.x - track_tile.global_position.x, 0)

    # We then animate the node from its current position to the offset position.
    _tween.interpolate_property(
        self,
        "position",
        position,
        position + offset_to_center,
        0.5,
        # This transition makes the animation snappy, making the motion feel
        # like that of a spring.
        Tween.TRANS_EXPO,
        Tween.EASE_OUT
    )
    _tween.start()

We also need to code the selection system to make it work, which we’ll do next.

Detecting the selected track

To detect the selected track, we leverage the Area2D node once more.

We create a new Area2D named SelectArea as a child of TrackCarousel, with a narrow rectangle as its collision shape.

As each of our TrackTile instance is also an area, when one overlaps with our new SelectArea, it becomes the selected track.

We want to add some code to SelectAre to avoid issues in case the user drags the tracklist backward and forward, causing the same tile to enter and leave the selection area twice in a row.

Attach a new script to the SelectArea node.

Before adding code to it, connect the area_entered signal to the node itself. I connected it to a new method named _on_area_entered().

We can now add code to the script.

SelectArea.gd

extends Area2D

## Emitted whenever the user selected a new track.
signal track_selected(track_tile)

# We keep track of the last selected tile to emit our signal selectively.
var _last_tile_selected = null


func _on_area_entered(track_tile: Area2D) -> void:
    if _last_tile_selected != track_tile:
        emit_signal("track_selected", track_tile)

    _last_tile_selected = track_tile

Finally, we connect the SelectArea’s track_selected signal to TrackTiles to keep track of the currently selected track tile.

var _selected_track_tile: TrackTile


func _on_SelectArea_track_selected(track_tile: TrackTile) -> void:
    _selected_track_tile = track_tile

Re-centering the selected track

This was the missing piece to center a tile on the screen upon releasing the mouse or touch input.

Next, we connect the AlignTimer node’s timeout signal to TrackTiles.

In the callback function, we call _snap_to_track(). Also, we add the _unhandled_input() function to start the timer.

func _unhandled_input(event: InputEvent) -> void:
    # When releasing the touch, we start the timer which triggers a call to
    # `_snap_to_track()` on timeout.
    # We'll do this at the end of the lesson as we need to code track selection 
    # first.
    if event.is_action_released("touch"):
        _align_timer.start()


func _on_AlignTimer_timeout() -> void:
    _snap_to_track(_selected_track_tile)

Which gives us fluid mouse and touch controls for our track selection menu.

To update the displayed track name and play it, we connect the SelectArea’s track_selected signal to call the UITrackSelector’s update_track_info() function, which we added in the previous lesson.

And with that, you can select different tracks and play them. The menu works!

We can improve the visuals, though, which we’ll do in the next lesson, a short one.

There, we’ll also take a few moments to talk about performance.

Code reference

Here are the scripts we added or modified in this lesson.

DragDetector.gd

class_name DragDetector
extends Area2D

signal dragged(amount)

export var strength := 2.0


func _on_input_event(_viewport, event, _shape_idx) -> void:
    if event is InputEventMouseMotion and Input.is_action_pressed("touch"):
        emit_signal("dragged", event.relative * strength)

SelectArea.gd

extends Area2D

signal track_selected(track_tile)

var _last_tile_selected = null


func _on_area_entered(track_tile: Area2D) -> void:
    if _last_tile_selected != track_tile:
        emit_signal("track_selected", track_tile)

    _last_tile_selected = track_tile

TrackTiles.gd

extends Node2D

export (Array, Resource) var tracks: Array
export var track_tile_scene: PackedScene

export var separation := 450

var _track_tiles := []
var _min_x_position := 0.0
var _selected_track_tile: TrackTile

onready var _align_timer := $AlignTimer
onready var _tween := $Tween


func _ready() -> void:
    _generate_tiles()


func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_released("touch"):
        _align_timer.start()


func scroll(amount: Vector2) -> void:
    position.x = clamp(position.x + amount.x, _min_x_position, 0)
    _tween.stop_all()


func _generate_tiles() -> void:
    var offset := 0.0

    for i in tracks.size():
        var track_data: TrackData = tracks[i]
        var track_tile: TrackTile = track_tile_scene.instance()

        track_tile.track_data = track_data
        _track_tiles.append(track_tile)

        track_tile.position = Vector2(offset, 0)
        offset += separation

        add_child(track_tile)
    _min_x_position = -(separation * (_track_tiles.size() - 1))


func _snap_to_track(track_tile: TrackTile) -> void:
    var offset_to_center := Vector2(get_parent().global_position.x - track_tile.global_position.x, 0)

    _tween.interpolate_property(
        self,
        "position",
        position,
        position + offset_to_center,
        0.5,
        Tween.TRANS_EXPO,
        Tween.EASE_OUT
    )
    _tween.start()


func _on_DragDetector_dragged(amount: Vector2) -> void:
    scroll(amount)


func _on_SelectArea_track_selected(track_tile: TrackTile) -> void:
    _selected_track_tile = track_tile


func _on_AlignTimer_timeout() -> void:
    _snap_to_track(_selected_track_tile)

UITrackSelector.gd

extends Control

var _current_track_data: TrackData

onready var _track_name := $TrackName
onready var _stream := $AudioStreamPlayer
onready var _animation_player := $AnimationPlayer


func update_track_info(track_tile: TrackTile) -> void:
    _current_track_data = track_tile.track_data
    _track_name.text = _current_track_data.label

    _animation_player.play("fade_out_track")
    yield(_animation_player, "animation_finished")
    _stream.stream = load(_current_track_data.stream)
    _stream.play(30.0)
    _animation_player.play("fade_in_track")


func _on_GoButton_pressed() -> void:
    Events.emit_signal("track_selected", _current_track_data.as_dict())
    queue_free()


func _on_SelectArea_track_selected(track_tile: TrackTile) -> void:
    update_track_info(track_tile)