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.
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)
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:
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.
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)