Project Delve Part 4.2: Unit Health

For this part we’re going to implement a unit’s current health, as well as a way to lower and raise it and display it. This will act as our basic health system going forward for when units fight each other.


Unit Health

First of all, we know we want our units each to have their own health because we want them to be able to take damage and be defeated. We currently have a health variable defined in our UnitData, but that shouldn’t act as the unit’s current health because we’re treating it as an immutable resource (at run-time). Instead we’re going to have to create an instance variable on our Unit class that will keep track of their current health, and use UnitData.health as the “max health” instead.

In our unit.gd script let’s create a new variable health, and initialize it in the _ready() function, and also create a take_damage() function that will reduce the health by the given amount when called, and then let’s also create a heal() function while we’re at it that does the opposite.

var hp: int

func _ready():
    self.hp = unit_data.health

func take_damage(amount: int):
    self.hp -= amount

func heal(amount: int):
    # We don't want to overheal!
    self.hp = [hp + amount, unit_data.health].min()

Easy peasy! Now we just need a way to indicate what a unit’s current health is. The most common way to do that is by using a health bar, so that’s what we’ll do.


Health Bar

Open up the Unit scene and create a new child Node2D and name it “Health.” I’m going to position it at (-10, 6) so that the health bar will appear under the unit, but feel free to position it anywhere you want. The point that you put it at will be the top left corner of the health bar, so keep that in mind.

Now as a child of the Health node create a ColorRect node and set the Rect.size to (20, 3). (You can also set the Control.margin for the same effect.) This is going to be the “background” of the health bar, so I’m going to make it a neutral gray color #414141 (rgb(65, 65, 65)).

Then as a child of the ColorRect node we just created, create another ColorRect and give it the same with as the parent, but one less point vertically, so it will be (20, 2). This ColorRect will be the color of our health bar, so I’m going to set it to a green color for right now.

When you’re all finished you should have something that looks similar to this.

health bar example

Coding the bar

In order to update this health bar when the unit’s current health changes we’ll need to make a simple script to control the bar color and bar width. Let’s create a new script called hp_bar.gd and assign it to the Health node, then open it in your editor of choice.

The first things we’ll want a reference to are the two ColorRects we attached as children to the Health node, so let’s get those references with the onready keyword.

# I named the background bar "HpBar" and the color bar "HpColor"
# for easy reference
onready var _hp_bar = $HpBar
onready var _hp_color = $HpBar/HpColor

Personally I like the health bar to have 3 distinct colors, one for when the unit is near or at full health, one for around 50% health, and another for when the unit is close to that deep dark sleep from whence no traveler returns… So I’m going to create three exported Color variables in my script that will represent these states respectively. (These colors were ones I liked when tweaking the color wheel)

export(Color) var healthy := Color("#20ea40")
export(Color) var wounded := Color("#eae220")
export(Color) var critical := Color("#e74322")

The last thing we’ll need to know is how full we should display the health bar. For that we’ll need the ratio of what the current health value is over what the maximum health value is. Once we calculate that all we need to do is set the X scale of the background bar to that value.

So let’s do that in the code! Make a function in the script and name it something like _update_hp_bar(). This is the function that we’ll run whenever the current_hp or max_hp changes. In order to make the code more readable I’m also going to create two more functions that will handle the change to both color and scale separately: _calculate_color() and _calculate_scale().

Calculating the color

Because we’ve identified three separate colors we want the health bar’s color to swap between, we’re going to need to set cutoff points for where those colors should be activated. As far as the actual color that’s displayed is concerned, we can go about it in two ways:

  1. Lerp (linearly-interpolate) between the colors depending on the current percent health
  2. Just set the color at the corresponding cutoff point

Method #2 is much more straightforward but I like the look of the health bar gradually changing color instead of abruptly changing color, so we’re going to implement the first method.

# The cutoff values can be whatever value you decide between 0 and 1,
# mine are .95, .5, and .2
func _calculate_color():
    var percent := _get_hp_percent()
    var cutoff_diff: float

    if percent >= HEALTHY_CUTOFF:
        _hp_color.color = healthy
    elif percent < CRITICAL_CUTOFF:
        _hp_color.color = critical
    elif percent >= WOUNDED_CUTOFF:
        cutoff_diff = HEALTHY_CUTOFF - WOUNDED_CUTOFF

        # This line means: "depending on the value given, return a color
        # between 'wounded' and 'healthy' where 1.0 == 'healthy' and 0.0 == 'wounded'"
        _hp_color.color = wounded.linear_interpolate(healthy, (percent - WOUNDED_CUTOFF) / cutoff_diff)
    else:
        cutoff_diff = WOUNDED_CUTOFF - CRITICAL_CUTOFF
        _hp_color.color = critical.linear_interpolate(wounded, (percent - CRITICAL_CUTOFF) / cutoff_diff)

The _calculate_scale() function is much more straightforward — all we have to do is set the scale to the result of _get_hp_percent()!

func _calculate_scale():
    _hp_bar.rect_scale.x = _get_hp_percent()

func _get_hp_percent():
    return float(current_hp) / float(max_hp)

Verifying

In order to test that our script behaves how we want without needing to run the scene, guess what we’re going to do?? That’s right, say it with me now! Make a tool script! Well, we’ll actually be updating our hp_bar.gd script to a tool script.

Once we’ve done that, let’s also add a setter method using setget for all of our exported variables so that we can see updates in real-time when changing these values. Here are what all of those functions will look like:

func _set_max_hp(newVal):
    max_hp = newVal
    if max_hp < current_hp:
        current_hp = max_hp
    _update_hp_bar()

func _set_hp(newVal):
    if newVal > max_hp:
        newVal = max_hp
    current_hp = newVal
    _update_hp_bar()

func _set_healthy_color(newVal):
    healthy = newVal
    _calculate_color()

func _set_wounded_color(newVal):
    wounded = newVal
    _calculate_color()

func _set_critical_color(newVal):
    critical = newVal
    _calculate_color()

Also, because we set our _hp_bar and _hp_color variables as onready variables, we need to check in our _calculate_color() function whether or not it has been set, and if not, to set it.

func _calculate_color():
    # I ran into some weird errors when running this code but this
    # check seemed to fix it
    if not is_inside_tree():
        return
    if not _hp_color:
        _hp_color = $HpBar/HpColor

# ...

func _calculate_scale():
    if not is_inside_tree():
        return
    if not _hp_bar:
        _hp_bar = $HpBar

And that’s it! When you return to the editor you should be able to see your changes happen in real-time!


Syncing With The Unit

Now that we have a working health bar, let’s make it work in tandem with the unit.

The simple way to do this would be to get a reference to the Health node from our unit.gd script and just update the Health.current_hp whenever the unit’s HP changes, but that creates redundancy (having two variables representing the same thing) and coupling (adding an explicit dependency to Unit means the unit.gd script can’t run in isolation without modification). Instead we’re going to follow the Godot pattern of utilizing signals.

First we’ll get rid of the current_hp and max_hp variables inside the hp_bar.gd script. That script will not store any outward-facing state besides what it has direct control over (the colors). Instead we’ll create a listener method in the script called _on_hp_changed(), which we will connect to a signal from the Unit it’s attached to, as well as create a private variable _percent to keep reference of the percent to show.

# hp_bar.gd

var _percent := 1.0

func _on_hp_changed(newHp, maxHp):
    if maxHp == null:
        _percent = 1.0
    else:
        _percent = float(newHp) / float(maxHp)
    _update_hp_bar()

# Also update all references in this script from the old _get_hp_percent()
# to reference _percent instead

Inside the unit.gd script let’s define a custom signal called hp_changed.

# Emits with parameters: newHp, maxHp?
signal hp_changed

And let’s also make a setter function that emits hp_changed whenever the unit’s hp… changes.

var hp: int setget _update_hp

# ...

func _update_hp(newValue):
    if newValue == null or newValue == hp:
        return
    hp = newValue
    if unit_data:
        emit_signal("hp_changed", newValue, unit_data.health)
    else:
        emit_signal("hp_changed", newValue, null)

Connecting the signal

Now that all of the code is set up we can go back to the editor and connect the hp_changed signal to the _on_hp_changed() function on our Health node.

To connect a signal in the editor, click on the node that emits the signal you want to listen for, click on the “Node” tab (to the right of the “Inspector” tab), find the signal you’re looking for in the list of signals displayed there, and double-click on it. This will bring up a dialog that looks like the following image. Select the Health node and change the “Receiver Method” to “_on_hp_changed” and click “Connect.”


Back in the MovementDemo scene we can verify if all of our code is put together correctly. Playing the scene you should see that pressing the “Attack” button and then clicking on a unit will reduce its health by however much you specified in the unit_actions.gd script.

The second unit takes only 1 damage at a time because its defense is 1, whereas the first unit has a defense of 0 and takes 2 damage.

And there we go! Two actions down! I probably won’t be writing a post or two for each separate action option from here on out, but I will give a summary of what I did.

As promised, the code for this post (and the previous two posts) can be found here.

Leave a Reply

Up ↑