Solution: redesigning the battler’s HUD

Here’s how I implemented the text-based health and energy counters.

First, as both the health and energy were going to look about the same, I decided to create a single scene that I would instantiate twice. I called it UIValueCounter.

To preserve the animations from the life bar, I duplicated UILifeBar.tscn as a starting point.

I changed the root node from a TextureProgress to a Label, removed it script, and created a new script named UIValueCounter.gd.

Here’s the code.

# Animated counter for health and mana.
extends Label

# To differentiate the health and energy, I decided to use an enum. Its purpose
# is to preserve animations that are specific to the life counter, like when the
# health is low, and the label should blink red.
enum Type {HEALTH, ENERGY}

# I used the enum members as keys to assign a specific prefix to each type of
# counter.
const LABELS := {
    Type.HEALTH: "HP",
    Type.ENERGY: "EP"
}

# Rate of the animation relative to `max_value`. A value of 1.0 means the
# animation takes numbers from zero to the max_value in 1 second.
export var fill_rate := 1.0
# I exported the type variable so we can easily set it on each instance. Using
# the `Type` enum as an export hint in parentheses results in a drop-down menu
# in the Inspector.
export(Type) var type := Type.HEALTH

# As we extend the `Label` class, we don't have access to the `value` and
# `max_value` properties anymore, so we must define variables for them.
# We use a setter function for the `value` to update the label's `text` accordingly.
# We will also use the tween node to animate the text through this function.
var value := 0.0 setget set_value
var max_value := 0.0

# When this value changes, the counter smoothly animates towards that value
# using a tween.
var target_value := 0.0 setget set_target_value

onready var _tween: Tween = $Tween
onready var _anim_player: AnimationPlayer = $AnimationPlayer


# The setup function initialises the `max_value`, the `target_value`, and
# triggers an animation from `0` to `target_value`.
func setup(_value: float, _max_value: float) -> void:
    max_value = _max_value
    value = 0
    _tween.connect("tween_completed", self, "_on_Tween_tween_completed")
    self.target_value = _value


func set_value(new_value: float) -> void:
    value = new_value
    # For the text, we use a string template. Each "%s" gets replaced by the
    # corresponding value in the following array.
    text = "%s: %s/%s" % [LABELS[type], round(value), round(max_value)]


# I only had to do minor changes to this function, highlighted by the comments
# below.
func set_target_value(amount: float) -> void:
    target_value = amount

    # As we now have two types of counters in one, we need to check for the type
    # before applying the damage animation.
    if target_value > amount and type == Type.HEALTH:
        _anim_player.play("damage")

    if _tween.is_active():
        _tween.stop_all()

    var duration: float = abs(target_value - value) / max_value / fill_rate
    # An alternative to interpolating a value is to call
    # `Tween.interpolate_method()`. It calls the method every frame with a new
    # value. You can use it whenever a single tween should trigger multiple
    # changes at once.
    _tween.interpolate_method(self, "set_value", value, target_value, duration, Tween.TRANS_QUAD)
    _tween.start()


func _on_Tween_tween_completed(object: Object, key: NodePath) -> void:
    # There again, when the tween completes, we need to check for the counter's
    # type.
    if value < 0.2 * max_value and type == Type.HEALTH:
        _anim_player.play("danger")

Then, I removed both the life and the energy bar from the UIBattlerHUD scene. And to replace them, I instantiated two counters, which I respectively named HealthCounter and EnergyCounter.

And I modified the energy counter’s type in the Inspector.

Next, I had to update the HUD’s code to use the new counters. I also removed all the energy-specific events.

# Displays a party member's name, health, and energy.
class_name UIBattlerHUD
extends TextureRect

onready var _health_counter := $HealthCounter
onready var _energy_counter := $EnergyCounter
onready var _label := $Label
onready var _anim_player: AnimationPlayer = $AnimationPlayer


# Initializes the health and energy bars using the battler's stats.
func setup(battler: Battler) -> void:
    battler.connect("selection_toggled", self, "_on_Battler_selection_toggled")

    _label.text = battler.ui_data.display_name

    var stats: BattlerStats = battler.stats
    # I had to change the order of the parameters here to match
    # `UIValueCounter.setup()`.
    _health_counter.setup(stats.health, stats.max_health)
    _energy_counter.setup(stats.energy, stats.max_energy)

    stats.connect("health_changed", self, "_on_BattlerStats_health_changed")
    stats.connect("energy_changed", self, "_on_BattlerStats_energy_changed")


func _on_BattlerStats_health_changed(_old_value: float, new_value: float) -> void:
    _health_counter.target_value = new_value


# Here, also, instead of setting the `value` property, to trigger the animation,
# I used the `target_value` instead.
func _on_BattlerStats_energy_changed(_old_value: float, new_value: float) -> void:
    _energy_counter.target_value = new_value


func _on_Battler_selection_toggled(value: bool) -> void:
    if value:
        _anim_player.play("select")
    else:
        _anim_player.play("deselect")

And those changes lead to the expected result, with the battlers’ life and energy showing as text labels, as seen in games like Final Fantasy.