Spawning damage and miss labels

Welcome to the last part of this JRPG series. We will wrap up with damage and miss labels.

We will first design two types of animated labels to instantiate them at runtime using a builder class. The builder class’s responsibility is to instantiate and initialize different kinds of labels.

As with other user interface components, we will use a setup() function on the builder that receives an array of battlers to connect to their signals.

Creating the label scenes

Let’s start with the label scenes because, as we saw, they are a dependency on the builder.

We will use two approaches for animation: the miss label will use an animation player because it will only move upwards in a predefined way. We will use tweens to throw the damage label randomly in a cone.

Having these different types of animations will give more visual weight to dealing or taking damage.

The miss label

Let’s create the miss label first, as it’s the simplest of the two. Here’s what we’ll make.

Create a scene with a position 2D named UIMissedLabel as its root, a Label, and an AnimationPlayer. Save it as UIMissedLabel.tscn.

The Position2D node serves as a pivot for the Label. I use this setup because control nodes’ position is based on their top-left corner, even if you change the node’s pivot. Also, if we animate the Label’s position, we can still set the parent UIMissedLabel’s and the animation will be at an offset.

Set the Label’s text to “miss”, its Custom Fonts -> Font to text_extralarge.tres.

Then, move the node up so the text is right above the viewport’s origin.

The scene should have only one animation that automatically plays on load and moves and fades the root node as it goes up. Like before, you can use the Modulate property to do so. Also, I’ve set the duration to 0.5 seconds so it disappears quickly.

Remember to animate the Label node’s Rect Position to move it up, not the UIMissedLabel.

We also want to destroy the node at the end of the animation as it’s no longer visible. Add a Call Method track and use a keyframe to call queue_free() at the end of the timeline. This way, we don’t need any script.

Your animation timeline should look like the following.

The damage label

The damage label will animate in a random direction defined by a cone. This is why we need a different approach for this animation. Whenever an animation isn’t predefined, the Tween node is a tool of choice.

You can duplicate the UIMissedLabel.tscn scene you just created as we will use a similar setup. Name the copy UIDamageLabel.tscn, open it, rename its root node, and after ensuring you reset the node’s Position and Modulate, delete the AnimationPlayer.

You can also change the Label’s placeholder text.

Add a Tween node as a child of UIDamageLabel.

Attach a script to the UIDamageLabel node.

# Animated label displaying amounts of damage or healing.
class_name UIDamageLabel
extends Node2D

# We use the Types enum to control the Label's color, using the colors below.
enum Types { HEAL, DAMAGE }

const COLOR_TRANSPARENT := Color(1.0, 1.0, 1.0, 0.0)

# By exporting these two colors, we can edit them in the Inspector using a color picker. You could
# use constants instead.
export var color_damage := Color("#b0305c")
export var color_heal := Color("#3ca370")

# The actual color applied to the Label node's text via its `modulate` property.
var _color: Color setget _set_color
# This is the number displayed on the Label. We store it in a variable so we can call the `setup()`
# function below before adding the node to the tree, which is when we set the Label's text and start
# the animation.
var _amount := 0

onready var _label: Label = $Label
onready var _tween: Tween = $Tween


# Below, `type` should be a member of the `Types` enum.
func setup(type: int, start_global_position: Vector2, amount: int) -> void:
    # We start by updating the node's `global_position` and `_amount`
    global_position = start_global_position
    _amount = amount

    # Then, we assign it a color based on the `type`.
    match type:
        Types.DAMAGE:
            _set_color(color_damage)
        Types.HEAL:
            _set_color(color_heal)


func _ready() -> void:
    _label.text = str(_amount)
    _animate()


func _set_color(value: Color) -> void:
    _color = value
    # Once more, we need a setter for the `_color` property to trigger a change in the Label node.
    if not is_inside_tree():
        yield(self, "ready")
    _label.modulate = _color


# Animates the node flying up in a random direction.
func _animate() -> void:
    # We define a range of 120 degrees for the direction in which the node can fly.
    var angle := rand_range(-PI / 3.0, PI / 3.0)
    # And we calculate an offset vector from that.
    var offset := Vector2.UP.rotated(angle) * 60.0

    # The Tween node takes care of animating the Label's `rect_position` over 0.4 seconds. It's a
    # bit faster than the miss label and uses an ease-out so the animation feels dynamic.
    _tween.interpolate_property(
        _label,
        "rect_position",
        _label.rect_position,
        _label.rect_position + offset,
        0.4,
        Tween.TRANS_QUAD,
        Tween.EASE_OUT
    )
    # The fade-out animation starts after a 0.3 seconds delay and lasts 0.1 seconds. 
    # This makes it so the Label quickly fades out and disappears at the end.
    _tween.interpolate_property(
        self, "modulate", modulate, COLOR_TRANSPARENT, 0.1, Tween.TRANS_LINEAR, Tween.EASE_IN, 0.3
    )
    _tween.start()
    # We finally wait for all tweens to complete before freeing the node.
    yield(_tween, "tween_all_completed")
    queue_free()

Spawning labels with the builder object

We’re only missing the builder node to bring everything together.

Create a new scene with a Node2D named UIDamageLabelBuilder and attach a script to it. It should connect to the battler’s damage_taken and hit_missed signals, and instantiate the corresponding animated labels when those events occur.

# Spawns labels that display damage, healing, or missed hits.
class_name UIDamageLabelBuilder
extends Node2D

# We preload the labels.
export var damage_label_scene: PackedScene = preload("UIDamageLabel.tscn")
export var miss_label_scene: PackedScene = preload("UIMissedLabel.tscn")


# In setup(), we connect to the Battler's `damage_taken` and `hit_missed` signals to instantiate the appropriate labels.
func setup(battlers: Array) -> void:
    for battler in battlers:
        battler.connect("damage_taken", self, "_on_Battler_damage_taken", [battler])
        battler.connect("hit_missed", self, "_on_Battler_hit_missed", [battler])
        

# When a battler takes damage, we instantiate a damage label.
func _on_Battler_damage_taken(amount: int, target: Battler) -> void:
    var label: UIDamageLabel = damage_label_scene.instance()
    # The setup() function takes care of changing the color, setting the text and placing it.
    label.setup(UIDamageLabel.Types.DAMAGE, target.battler_anim.get_top_anchor_global_position(), amount)
    # Adding the label as a child causes the animation to start.
    add_child(label)


func _on_Battler_hit_missed(target: Battler) -> void:
    var label = miss_label_scene.instance()
    add_child(label)
    # The miss label doesn't have a setup() function so we set its position by hand.
    label.global_position = target.battler_anim.get_top_anchor_global_position()

We need to instantiate it in the CombatDemo scene as a child of our UI canvas layer and call its setup() function.

Open CombatDemo’s script and add the following lines to it.

onready var ui_damage_label_builder := $UI/UIDamageLabelBuilder

func _ready() -> void:
    #...
    ui_damage_label_builder.setup(battlers)

With that, upon taking a hit, a label will appear.

And this completes our JRPG combat demo.