Character base stats

In the previous two parts, we accessed battlers’ stats without defining them. Let’s do so in this lesson.

We’re going to code the complete stats system here, with base stats, bonuses, and penalties. We need a reliable and secure way to read and update stats. That’s where the class’s complexity lies.

As we saw two lessons ago when coding the Battler, our stats will extend the Resource class. This is so we can save them as a file separately from our characters and monsters, share them, and edit them in the Inspector without having to open a scene. Using a resource will also group the stats into a foldable area of in the Inspector.

In a complete RPG game, using a resource would also allow you to attach it to the party member in the game world and the corresponding battler in the combat arena. As resources are shared in memory, losing health during combat would persist back on the map. And that, without having to explicitly pass a reference from the map’s character to the battler: attaching the resource to both in the editor would be enough.

Health and energy

Let’s code the stats class now, starting with health and energy.

Create a new GDScript file named BattlerStats.gd.

# Stores and manages the battler's base stats like health, energy, and base
# damage.
extends Resource
class_name BattlerStats

We need variables to control the maximum and current value for both stats. We will also use three signals to update the life and energy bars and know when a character fell in combat.

Health and energy use similar logic. The main difference is that health should start at the maximum, while the energy starts at 0.

Below, we define signals for when the health and the energy change. This will allow us to limit coupling between the bars in the user interface and the stats themselves. Without signals, we would have to store a reference to the stats inside, say, the life bar, which would tightly couple the two objects and make code maintenance harder.

# Emitted when a character has no `health` left.
signal health_depleted
# Emitted every time the value of `health` changes.
# We will use it to animate the life bar.
signal health_changed(old_value, new_value)
# Same as above, but for the `energy`.
signal energy_changed(old_value, new_value)


# The battler's maximum health.
export var max_health := 100.0
export var max_energy := 6

# Note that due to how Resources work, in Godot 3.2, health will not have a
# value of `max_health`. This is why we have the function `reinitialize()`
# below. Each battler should call it when the encounter starts.
# This happens because a resource's initialization happens when you create it 
# in the editor serialize it, not when you load it in the game.
var health := max_health setget set_health
var energy := 0 setget set_energy


func reinitialize() -> void:
    set_health(max_health)


func set_health(value: float) -> void:
    var health_previous := health
    # We use `clamp()` to ensure the value is always in the [0.0, max_health]
    # interval.
    health = clamp(value, 0.0, max_health)
    emit_signal("health_changed", health_previous, health)
    # As we are working with decimal values, using the `==` operator for
    # comparisons isn't safe. Instead, we need to call `is_equal_approx()`.
    if is_equal_approx(health, 0.0):
        emit_signal("health_depleted")


func set_energy(value: int) -> void:
    var energy_previous := energy
    # Energy works with whole numbers in this demo but the `clamp()` function
    # returns a floating-point value. You can let the compiler cast the value to
    # an integer, which will trigger a warning. I prefer to always do it
    # explicitly to remind myself that I'm working with integers here.
    energy = int(clamp(value, 0.0, max_energy))
    emit_signal("energy_changed", energy_previous, energy)

Base and final stats

Other stats all follow the same pattern. We have an exported variable to control a starting or base value for the character in the Inspector. Another property stores the final stat we’ll use in combat, with bonuses and penalties from status effects, items, or equipment applied.

export var base_attack := 10.0 setget set_base_attack
export var base_defense := 10.0 setget set_base_defense
export var base_speed := 70.0 setget set_base_speed
export var base_hit_chance := 100.0 setget set_base_hit_chance
export var base_evasion := 0.0 setget set_base_evasion

# The values below are meant to be read-only.
var attack := base_attack
var defense := base_defense
var speed := base_speed
var hit_chance := base_hit_chance
var evasion := base_evasion

I’ve chosen five stats for the game:

  1. Attack and defense influence the damage a battler deals or takes.
  2. A high speed allows a battler to take turns often.
  3. Hit chance and evasion influence a battler’s ability to act successfully.

We will discuss the damage and hit formulas in another lesson.

I mentioned above properties like attack and defense are intended to be read-only. There is no way to enforce that in this class, even by using a setter function that doesn’t assign values. To prevent teammates from inadvertently modifying final stats, an option is to make the properties like attack pseudo-private and access them using a getter function. For example:

var _attack := base_attack setget , get_attack
# ...

func get_attack() -> float:
    return attack

But I’ve chosen to stick to public properties as in the team, we have a pretty strong and consistent code style.

When we modify a base stat, we have to recalculate the corresponding final stat. This could happen, for example, when a character levels up. To achieve this, we can have every base_* property go through a setter that triggers recalculating the associated final value.

export var base_attack := 10.0 setget set_base_attack
export var base_defense := 10.0 setget set_base_defense
# ...

func set_base_attack(value: float) -> void:
    base_attack = value
    _recalculate_and_update("attack")


func set_base_defense(value: float) -> void:
    base_defense = value
    _recalculate_and_update("defense")


func set_base_speed(value: float) -> void:
    base_speed = value
    _recalculate_and_update("speed")


func set_base_hit_chance(value: float) -> void:
    base_hit_chance = value
    _recalculate_and_update("hit_chance")


func set_base_evasion(value: float) -> void:
    base_evasion = value
    _recalculate_and_update("evasion")

We have yet to define _recalculate_and_update(), which we’re going to do in the next lesson. But note that it’s this setup that allows for a flexible upgrade system.

There are ways to avoid writing these repetitive setter functions or defining separate base and final stats. You’ll find an example in the course’s appendix at the end of this series. That’s a different stats implementation from our 2D space game that uses Object.get_property_list() to find stats from the object’s properties procedurally.

However, considering we only have several stats and they’re unlikely to change during development, it’s not worth spending an hour to find a “smart” solution to save us 30 seconds of copy and paste.

Deactivating the battler when the health depletes

We can use the stats’ health_depleted signal to deactivate the battler. Open Battler.gd and add the following code.

# We connect to the stats' `health_depleted` signal to react to the health reaching `0`.
func _ready() -> void:
    #...
    #stats.reinitialize()
    stats.connect("health_depleted", self, "_on_BattlerStats_health_depleted")


func _on_BattlerStats_health_depleted() -> void:
    # When the health depletes, we turn off processing for this battler.
    set_is_active(false)
    # Then, if it's an opponent, we mark it as unselectable. For party members,
    # you still want to be able to select them to revive them.
    if not is_party_member:
        set_is_selectable(false)