With the separation between base and final stats, we still need to register and calculate bonuses and penalties. We’re going to store a list of upgrades for each stat. And to avoid creating many more properties, we’ll use a dictionary to do so.
# A list of all properties that can receive bonuses. const UPGRADABLE_STATS = [ "max_health", "max_energy", "attack", "defense", "speed", "hit_chance", "evasion" ] # The property below stores a list of modifiers for each property listed in # `UPGRADABLE_STATS`. # The value of a modifier can be any floating-point value, positive or negative. var _modifiers := {} # Initializes keys in the modifiers dict, ensuring they all exist. func _init() -> void: for stat in UPGRADABLE_STATS: # For each stat, we create an empty dictionary. # Each upgrade will be a unique key-value pair. _modifiers[stat] = {}
From there, we can write the missing _recalculate_and_update()
method from the previous lesson.
# Calculates the final value of a single stat. That is, its based value # with all modifiers applied. # We reference a stat property name using a string here and update # it with the `set()` method. func _recalculate_and_update(stat: String) -> void: # All our property names follow a pattern: the base stat has the # same identifier as the final stat with the "base_" prefix. var value: float = get("base_" + stat) # We get the array of modifiers corresponding to a stat. var modifiers: Array = _modifiers[stat].values() for modifier in modifiers: value += modifier # This line ensures the final stat cannot be negative. value = max(value, 0.0) # Here's where we assign the value to the stat. For instance, # if the `stat` argument is "attack", this is like writing # attack = value set(stat, value)
I should explain a bit more about how _modifiers
works. Once populated with stats and bonuses, it should look like this:
{ "attack": { 1: 12.0, 2: -3.0 }, "defense": {}, # ... }
In the example above, if we sum the values for “attack”, we get 12 - 3 = 9
bonus attack points. That’s what _recalculate_and_update()
does for us: it adds the bonus points to the base_attack
and assigns the total to attack
.
To safely add and remove modifiers, we should provide some public methods. As we need to identify stats using a string, the assert()
function can tell us if we write an incorrect name.
We will use the functions below when we add status effects.
# Adds a modifier that affects the stat with the given `stat_name` and returns # its unique key. func add_modifier(stat_name: String, value: float) -> int: assert(stat_name in UPGRADABLE_STATS, "Trying to add a modifier to a nonexistent stat.") # We use a function to ensure we generate a unique ID for every stat # modifier. You can find it below. var id := _generate_unique_id(stat_name) # Using the unique ID, we save the modifier's value. _modifiers[stat_name][id] = value # Every time we add or remove a stat modifier, we need to recalculate its # final value. _recalculate_and_update(stat_name) # Returning the id allows the caller to bind it to a signal. For instance # with equpment, to call `remove_modifier()` upon removing the equipment. return id # Removes a modifier associated with the given `stat_name`. func remove_modifier(stat_name: String, id: int) -> void: # As above, during development, we want to know if we try to remove a # modifier that doesn't exist. assert(id in _modifiers[stat_name], "Id %s not found in %s" % [id, _modifiers[stat_name]]) # Here's why we use dictionaries in `_modifiers`: we can arbitrarily erase # keys without affecting others, ensuring our unique IDs always work. _modifiers[stat_name].erase(id) _recalculate_and_update(stat_name) # Find the first unused integer in a stat's modifiers keys. func _generate_unique_id(stat_name: String) -> int: var keys: Array = _modifiers[stat_name].keys() # If there are no keys, we return `0`, which is our first valid unique id. # Without existing keys, calling methods like `Array.back()` will trigger an # error. if keys.empty(): return 0 else: # We always start from the last key, which will always be the highest # number, even if we remove modifiers. return keys.back() + 1
We have yet to create and attach BattlerStats resources to our battlers. But once we do so, we will be able to add and remove modifiers like so.
# This is a short imaginary example of some stat-boosting effect. class_name AttackBooster extends Node # Emitted when the effect expires. signal expired export var attack_bonus := 10 # Duration of the boost in seconds. export var duration := 4.0 var _target: Battler func _init(target: Battler) -> void: _target = target # When adding the node to the tree, we apply the bonus # and start the effect's countdown using a timer. func _ready() -> void: var id: int = target.stats.add_modifier("attack", attack_bonus) # We can use the convenient method `SceneTree.create_timer()` to create a # simple timer object from anywhere. var timer := get_tree().create_timer(duration) # Notice how we bind the `id` to the signal's callback. # Doing so allows us to later remove the stat boost. timer.connect("timeout", self, "_on_Timer_timeout", [id]) # When the timer ends, we remove the modifier. func _on_Timer_timeout(id: int) -> void: var id: int = target.stats.remove_modifier("attack", id) emit_signal("expired") queue_free()
There is more than one way to code stats. Adding character progression to the mix may require you to separate base data from the calculated attack, defense, and other properties.
You will find another example from our open-source 2D space game in the course’s annex.
Here’s the complete BattlerStats.gd
for reference.
# Stores and manages the battler's base stats like health, energy, and base # damage. extends Resource class_name BattlerStats signal health_depleted signal health_changed(old_value, new_value) signal energy_changed(old_value, new_value) const UPGRADABLE_STATS = [ "max_health", "max_energy", "attack", "defense", "speed", "hit_chance", "evasion" ] export var max_health := 100 export var max_energy := 6 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 var health := max_health setget set_health var energy := 0 setget set_energy var attack := base_attack var defense := base_defense var speed := base_speed var hit_chance := base_hit_chance var evasion := base_evasion var _modifiers := {} func _init() -> void: for stat in UPGRADABLE_STATS: _modifiers[stat] = {} func reinitialize() -> void: set_health(max_health) func add_modifier(stat_name: String, value: float) -> int: assert(stat_name in UPGRADABLE_STATS, "Trying to add a modifier to a nonexistent stat.") var id := _generate_unique_id(stat_name) _modifiers[stat_name][id] = value _recalculate_and_update(stat_name) return id func remove_modifier(stat_name: String, id: int) -> void: assert(id in _modifiers[stat_name], "Id %s not found in %s" % [id, _modifiers[stat_name]]) _modifiers[stat_name].erase(id) _recalculate_and_update(stat_name) func set_health(value: float) -> void: var health_previous := health health = clamp(value, 0.0, max_health) emit_signal("health_changed", health_previous, health) if is_equal_approx(health, 0.0): emit_signal("health_depleted") func set_energy(value: int) -> void: var energy_previous := energy energy = int(clamp(value, 0.0, max_energy)) emit_signal("energy_changed", energy_previous, energy) 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") func _recalculate_and_update(stat: String) -> void: var value: float = get("base_" + stat) var modifiers: Array = _modifiers[stat].values() for modifier in modifiers: value += modifier value = max(value, 0.0) set(stat, value) func _generate_unique_id(stat_name: String) -> int: var keys: Array = _modifiers[stat_name].keys() if keys.empty(): return 0 else: return keys.back() + 1