The HUD will show each battler’s name, health bar, and energy level. In this part, we’re going to design the interface and set up the bars. In the next lesson, we’ll add an animation and code related to the selection of battlers.
Once again, we’ll create a HUD scene and another to instantiate it multiple times.
Create a new scene with a TextureRect as its root named UIBattlerHUD. Assign battler_hud_background.png
to its Texture. As children appear in front of their parent, we can use the root node as the background.
Instantiate both the UILifeBar and the UIEnergyBar, and add a Label as the UIBattlerHUD’s children. Your scene tree should look like this.
The HUD itself isn’t meant to scale so you can position the bars manually in the viewport. First, though, I invite you to select the Label and give it some placeholder name. We’ll write the associated battler’s name there. I went with “Robi”.
To help preview the energy bar, you can also instantiate some UIEnergyPoint nodes as its children.
Here’s how the scene looks at this point.
To help me position the energy bar quickly, I’ve applied the Layout -> Center Bottom command to it. Then, I moved it up and left.
Another option is to anchor everything to the center and move the nodes around until the layout feels balanced.
The text is too small. We can apply our default font to the scene by assigning combat_ui_theme.tres
to the UIBattlerHUD’s Theme property.
You may have to reposition the Label after doing that.
The text’s white color makes it pop a little too much. We’ll want to animate the UI to help emphasize which battler is currently selected, and we can tint the text for that.
Select the Label and darken its Self Modulate color. I’ve used the value bfd2e6
, a light blue.
Don’t forget to delete the placeholder UIEnergyPoint nodes before saving the scene.
Our energy bar can animate and show selected points. The problem is the only time we have access to an action hovered by the player is when the actions menu is open.
So we’d like to connect the action buttons to the HUD and update the energy bar when one gets focus. To do this with plain signals, we’d have to:
These steps are not only tedious, they also make the code structure harder to track and maintain.
We can use a trick to cut two steps, though, by introducing an autoloaded node. This feature in Godot allows you to automatically load a node or scene at the start of the game and make it globally accessible from scripts. It’s Godot’s version of the singleton, a common pattern that consists of creating global objects that can only have one instance. The Input
is a good example of a singleton built into the engine.
Global objects like singletons are double-edged swords: they’re convenient and often help to implement features quickly in the short term. But use them too much, and your codebase will become hell. The reason is that they’re globally accessible, so whenever a bug happens with a singleton, its root cause could be anywhere. A rule of thumb to keep trouble away is to avoid public properties on global objects as they’re a common source of bugs.
There are cases where global objects, in an object-oriented codebase, help to keep code flexible. Again, the built-in Input
class is a good example: imagine if, instead, you had to instantiate a node every time you wanted to listen to user input!
The secret to writing a good global object is giving it a strict interface and limiting mutable state to the absolute minimum. For example, Godot’s Input
class exclusively provides methods and only necessary ones. It doesn’t let you mess with its internal data directly so it can validate every change internally. Also, most of its methods return a value instead of changing data.
We’re going to create a global object to connect distant nodes. We will use it exclusively when wiring up the nodes directly would make the code harder to follow and maintain.
Create a new script in the FileSystem dock and name it Events.gd
. To do so, you can right-click anywhere and select New Script….
The script will only define signals. Let’s define the first one, combat_action_hovered
, with one argument, the energy_cost
of the hovered action.
# Event bus for distant nodes to communicate using signals. # This is intended for cases where connecting the nodes directly creates more coupling # or substantially increases code complexity. extends Node # Emitted when the player hovers an action button, to preview the corresponding action's energy cost. # We will use the display name to identify the HUD that corresponds to a given battler. signal combat_action_hovered(display_name, energy_cost) # Emitted during a player's turn, when they chose an action and validated their target. signal player_target_selection_done
In Project -> Project Settings, head to the Autoload tab. You want to insert the path to Events.gd
in the Path field.
The editor should name the global object accordingly. Click Add or press Enter to register the autoload.
We need to emit the combat_action_hovered
signal when using the action menu. Open UIActionList.gd
. A few lessons ago, we set up a signal callback, _on_UIActionButton_focus_entered()
, with extra unused arguments. We’re going to use them with our Events
singleton.
func _on_UIActionButton_focus_entered(button: TextureButton, battler_display_name: String, energy_cost: int) -> void: #... # Emitting an event that any node in the game can connect to is that simple, thanks to Godot's signals. Events.emit_signal("combat_action_hovered", battler_display_name, energy_cost)
The energy cost preview should clear up when the player selects a target. To that end, we can emit the Events.player_target_selection_done
signal from the ActiveTurnQueue
. Open ActiveTurnQueue.gd
and update the _play_turn()
function like so.
# There's only one line of code to add, but I'm including other lines to help you find it func _play_turn(battler: Battler) -> void: #... if battler.is_player_controlled(): #... while not is_selection_complete: #... else: targets = yield( _player_select_targets_async(action_data, potential_targets), "completed" ) # Here is the line of code to add. We want to clear the energy cost preview every # time the player finished using the selection arrow. Events.emit_signal("player_target_selection_done")
We can now code the HUD. Assign a new script to the UIBattlerHUD.
# Displays a party member's name, health, and energy. class_name UIBattlerHUD extends TextureRect onready var _life_bar: TextureProgress = $UILifeBar onready var _energy_bar := $UIEnergyBar onready var _label := $Label func _ready() -> void: # We use our event bus to connect the action menu to the HUD. They're difficult to connect or make communicate otherwise. # When working with autoloads, the singleton is accessible anywhere in your project by name. # You get full autocompletion for it as you type. Events.connect("combat_action_hovered", self, "_on_Events_combat_action_hovered") # We also connect to our `player_target_selection_done` event. Events.connect("player_target_selection_done", self, "_on_Events_player_target_selection_done") # Initializes the health and energy bars using the battler's stats. func setup(battler: Battler) -> void: # We display the battler's name using the Label node. _label.text = battler.ui_data.display_name # We extract the health and energy from the stats. var stats: BattlerStats = battler.stats _life_bar.setup(stats.health, stats.max_health) _energy_bar.setup(stats.max_energy, stats.energy) stats.connect("health_changed", self, "_on_BattlerStats_health_changed") stats.connect("energy_changed", self, "_on_BattlerStats_energy_changed") # We control the health in the life bar from this node. All we have to do is update its value. func _on_BattlerStats_health_changed(_old_value: float, new_value: float) -> void: _life_bar.target_value = new_value # Same for the energy. func _on_BattlerStats_energy_changed(_old_value: float, new_value: float) -> void: _energy_bar.value = new_value func _on_Events_combat_action_hovered(battler_name: String, energy_cost: int) -> void: # When hovering an action, we let each HUD check if they correspond to the battler. We set the # label's text to the battler's name in the setup() method so we can use it to find a match. if _label.text == battler_name: # And we update the selected energy to reflect the action. _energy_bar.selected_count = energy_cost # When the selection is done, we reset the energy bar's `selected_count` func _on_Events_player_target_selection_done() -> void: _energy_bar.selected_count = 0
To list the HUDs in a column, we’re going to use another scene. Create a new scene with a VBoxContainer as its root and name the node UIBattlerHUDList. We’re going to reuse the fade animations we designed for the turn bar.
Right-click on the node and select Merge From Scene. Navigate to the file UITurnBar.tscn
, open it, and double-click on the AnimationPlayer to create a copy of it. The animations will automatically target the UIBattlerHUDList node; there’s nothing else to do.
In the final game, the HUDs should be on the right side of the screen. Apply Layout -> Right Wide to the UIBattlerHUDList and resize it vertically to have some top and bottom margin. To do so, you can click and drag on the top handle with the Alt key down.
Attach a script to the UIBattlerHUDList node.
# Displays a list of UIBattlerHUDs, one for each battler in the party. extends VBoxContainer # We see the same pattern once again: preloading a UI widget and instancing it from the "list" component. const UIBattlerHUD: PackedScene = preload("UIBattlerHUD.tscn") onready var _anim_player: AnimationPlayer = $AnimationPlayer # Creates a battler HUD for each battler in the party. func setup(battlers: Array) -> void: for battler in battlers: var battler_hud: UIBattlerHUD = UIBattlerHUD.instance() add_child(battler_hud) battler_hud.setup(battler) # The two functions below respectively play the fade in and fade out animations. func fade_in() -> void: _anim_player.play("fade_in") func fade_out() -> void: _anim_player.play("fade_out")
With that, we can finally integrate the bars and HUDs to the game. Open the main game scene and instantiate the UIBattlerList.tscn
.
Open the CombatDemo’s script and add the following code to initialize the HUDs’ list. Below, I’ve commented out existing lines.
#... onready var ui_battler_hud_list := $UI/UIBattlerHUDList func _ready() -> void: #var battlers: Array = active_turn_queue.battlers # We need a HUD only for party members. So we filter them here. var in_party := [] for battler in battlers: if battler.is_party_member: in_party.append(battler) #ui_turn_bar.setup(active_turn_queue.battlers) ui_battler_hud_list.setup(in_party)
And with that, you should see HUDs magically appear for each battler in the party. Not only that, the energy bar should display a preview of the cost of the hovered action.