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.