The animated ring

In this lesson, we will create an animated ring that shrinks towards each of the buttons.

The point of this is to indicate the perfect timing to tap.

To draw the ring, we will use a shader material and generate it procedurally instead of using a sprite.

The point of this shader is to have a ring with a consistent thickness. If we were to scale a sprite with a predefined ring, the ring’s thickness would shrink as we lower the scale.

Create a new scene with a ColorRect as the root named TargetCircle.

This is all we need as far as nodes are concerned, as our shader will do the heavy lifting. So you can save the scene inside the Hits/ directory.

In the Hits/ folder, create a new shader resource named target_circle.shader.

This shader will draw a shape named a torus, which we can use to draw a crisp ring.

Explaining shader creation is beyond the scope of Godot Secrets 2D. I’ll just mention that the term fragment below refers to pixels or part of the pixel that the graphics card will draw on the screen.

If you’re interested in learning how to code shaders from scratch, we have a course dedicated to the topic: Godot Shader Secrets.

Put the following code in the Shader bottom panel.

The shader below draws a greyscale mask representing a torus, a smooth ring, in 2 dimensions.

This algorithm can draw smooth shapes with a gradient that suggests some roundness. However, we can use it to draw a flat ring by selecting specific default values, which works better with this game’s visuals.

shader_type canvas_item;

// The ring's thickness in UV space.
// The value should be between 0 to 1, but small values will work best.
uniform float torus_thickness : hint_range(0.001, 1.0) = 0.015;
// How soft a gradient we draw inside the ring. A torus is like a 3D ring, so
// this algorithm allows you to draw more than just a disk with a hole in the
// middle.
// The default negative value makes the shape fully opaque and sharp.
uniform float torus_hardness = -2.0;
// The shape's radius in UV space, that is, relative to the node's bounding box.
// The default value of 0.5 means the shape will take as much space as it can
// inside the bounding box.
uniform float torus_radius = 0.5;

void fragment() {
    // Distance of each fragment to the node's center, in UV space.
  float torus_distance = length((UV - vec2(0.5)));
    // The radius of the ring we'll draw in UV space.
  float radius_distance = torus_thickness / 2.0;
    // The radius of the empty inner circle, which will appear transparent.
  float inner_radius = torus_radius - radius_distance;

    // We calculate a base value for each fragment or pixel in the ring.
  // The fragments on the outer edges of the ring will have a value close to 0 and the fragments in the middle a value of 1.0.
  float circle_value = clamp(abs(torus_distance - inner_radius) / torus_thickness, 0.0, 1.0);
    // We use the power function to make the values that are above zero really close to 1, making our ring flat and sharp.
  float circle_alpha = pow(circle_value, pow(torus_hardness, 2.0));

    // The way we calculate our mask, we have to reverse the values to draw the ring instead of everything but the ring.
  float mask = abs(1.0 - circle_alpha);

    // Finally, we output the calculated values to the screen.
  // The type vec4 below represents a color with the four RGBA channels.
  COLOR = vec4(mask);
}

I experimented with a simplified version of this shader that would be designed specifically to draw a flat ring, using the smoothstep() function, which smoothly remaps values from one range to another, allowing you to compress gradients.

But the values to draw a crisp shape were a bit finicky, and you ended up having to change them based on your ring’s resolution.

So, in the end, I stuck with this shader.

We can now attach the shader resource to our target circle node.

Select the TargetCircle node and in the Inspector, navigate down to Material -> Material and create a new ShaderMaterial resource.

Expand the newly created resource and drag-and-drop the target_circle.shader file onto it.

You should already see the ring appear (you can resize the node to make the drawing larger).

I set the node to be centered on the origin and of a size of 150x150.

To do so, I set:

The properties above are only for preview, as we’ll use code to resize the rectangle.

As the material is a resource, every instance of the scene will share it by default, causing every instance to draw the same ring.

That’s because it’s the Shader Param on the ShaderMaterial that control drawing.

However, we want to draw shrinking rings on every button on the screen. To do so, turn on the Resource -> Local To Scene property of the material.

If you want to make the shape thicker, expand the Shader Param and increase the Torus Thickness.

By default, the ring will be completely white. We can lower the Modulate color’s alpha channel to make it a bit transparent.

Animating the ring via code

We can now use code to control the shader and animate the shape’s radius.

In the script, we calculate the radius in pixels and convert it in UV space when assigning the value to the shader material.

Attach a new script to the node with the following code.

extends ColorRect

# Corresponds to the `torus_thickness` property in the shader.
const THICKNESS := 0.015

# The speed at which the `_radius` property below goes down, in pixels per
# second.
var shrink_speed := 0.0

# These 3 values allow us to update and animate the ring's radius every frame.
var _radius := 0.0
var _end_radius := 0.0
var _start_radius := 0.0


# Initialises the node's properties to properly draw the ring and calculate its
# radius.
func setup(radius_start: float, radius_end: float, bps: float, beat_delay: float) -> void:
    _radius = radius_start
    _end_radius = radius_end
    _start_radius = _radius
    # The calculation below allows us to make the animation faster for faster
    # songs, and faster if the starting radius is bigger.
    shrink_speed = 1.0 / bps / beat_delay * (_radius - _end_radius)

    # We set the margins and position via code, based on the `_radius`.
    # This way, the node will be the exact size we need to encompass the ring.
    margin_left = -_radius
    margin_right = _radius
    margin_top = -_radius
    margin_bottom = _radius

    rect_size = Vector2.ONE * _radius * 2


func _process(delta: float) -> void:
    # Every frame, we lower the radius and forward the property to the shader.
    _radius -= delta * shrink_speed
    # The shader expects a radius in UV space, with a value between zero and
    # one, so we divide our value by the node's start radius.
    material.set_shader_param("torus_radius", _radius / _start_radius / 2)

    # When the node reached the and or target radius, we stop the animation.
    if _radius <= _end_radius:
        _radius = _end_radius
        set_process(false)

With that, we can add an instance of the TargetCircle to our HitBeat.

Open the HitBeat scene and instantiate TargetCircle.tscn. You want to place the instance at the top of the node hierarchy, so the ring shrinks and disappears behind the button.

Control nodes like ColorRect don’t have a Z Index property that allows us to control the drawing order, so we must place it behind the sprite for it to draw behind it.

Then, we need to update the HitBeat.gd script to call the setup function. Open the script and add the following code to it.

# ...
onready var _target_circle := $TargetCircle

func setup(data: Dictionary) -> void:
    # ...
    _target_circle.setup(_radius_start, _radius_perfect, data.bps, _beat_delay)

You can now play the game, and every button will have a shrinking ring that automatically appears and animates alongside it.

This is a must-have for this kind of game as the player needs to know when to tap.

Code reference

Here are the code files we added and modified in this lesson.

target_circle.shader

shader_type canvas_item;

uniform float torus_thickness : hint_range(0.001, 1.0) = 0.015;
uniform float torus_hardness = -2.0;
uniform float torus_radius = 0.5;

void fragment() {
    float torus_distance = length((UV - vec2(0.5)));
    float radius_distance = torus_thickness / 2.0;
    float inner_radius = torus_radius - radius_distance;

    float circle_value = clamp(abs(torus_distance - inner_radius) / torus_thickness, 0.0, 1.0);
    float circle_alpha = pow(circle_value, pow(torus_hardness, 2.0));

    float mask = abs(1.0 - circle_alpha);

    COLOR = vec4(mask);
}

TargetCircle.gd

extends ColorRect

const THICKNESS := 0.015

var shrink_speed := 0.0

var _radius := 0.0
var _end_radius := 0.0
var _start_radius := 0.0


func _init() -> void:
    set_process(false)


func setup(radius_start: float, radius_end: float, bps: float, beat_delay: float) -> void:
    _radius = radius_start
    _end_radius = radius_end
    _start_radius = _radius
    shrink_speed = 1.0 / bps / beat_delay * (_radius - _end_radius)

    margin_left = -_radius
    margin_right = _radius
    margin_top = -_radius
    margin_bottom = _radius

    rect_size = Vector2.ONE * _radius * 2

    set_process(true)


func _process(delta: float) -> void:
    _radius -= delta * shrink_speed
    material.set_shader_param("torus_radius", _radius / _start_radius / 2)

    if _radius <= _end_radius:
        _radius = _end_radius
        set_process(false)

HitBeat.gd

extends Node2D

var order_number := 0 setget set_order_number

var _beat_hit := false

var _score_perfect := 10
var _score_great := 5
var _score_ok := 3

var _radius_start := 150.0
var _radius_perfect := 70.0
var _radius := _radius_start

var _offset_perfect := 4
var _offset_great := 8
var _offset_ok := 16

var _beat_delay := 4.0
var _speed := 0.0


onready var _animation_player := $AnimationPlayer
onready var _sprite := $Sprite
onready var _touch_area := $Area2D
onready var _label := $LabelCustom
onready var _target_circle := $TargetCircle


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


func setup(data: Dictionary) -> void:
    self.order_number = data.half_beat
    global_position = data.global_position
    _sprite.frame = data.color
    _speed = 1.0 / data.bps / _beat_delay
    _target_circle.setup(_radius_start, _radius_perfect, data.bps, _beat_delay)


func _process(delta: float) -> void:
    if _beat_hit:
        return

    _radius -= delta * (_radius_start - _radius_perfect) * _speed

    if _radius <= _radius_perfect - _offset_perfect:
        _touch_area.collision_layer = 0

        Events.emit_signal("scored", {"score": 0, "position": global_position})
        _animation_player.play("destroy")
        _beat_hit = true


func set_order_number(number: int) -> void:
    order_number = number
    _label.text = str(order_number)


func _on_Area2D_input_event(_viewport, event, _shape_idx) -> void:
    if event.is_action_pressed("touch"):
        _beat_hit = true
        _touch_area.collision_layer = 0
        _animation_player.play("destroy")
        Events.emit_signal("scored", {"score": _get_score(), "position": global_position})


func _get_score() -> int:
    if abs(_radius_perfect - _radius) < _offset_perfect:
        return _score_perfect
    elif abs(_radius_perfect - _radius) < _offset_great:
        return _score_great
    elif abs(_radius_perfect - _radius) < _offset_ok:
        return _score_ok
    return 0