In this lesson, we will add the ability to take and inflict damage on the battlers. We will add resources so that each battler has stats and a basic attack action available. Then, we’ll write the code to apply those actions.
I prepared a scene with the following node structure, with our ActiveTurnQueue and two battlers: one for the player and one for the enemy. For each of them, we want to create at least one stat resource. We also need one action data resource to represent a basic attack.
To create a new resource, right-click anywhere in the FileSystem dock and click on New Resource.
As we used the class_name
keyword for our resource classes, Godot adds them to the menu that pops up. You can search for AttackActionData
and BattlerStats
to create resources of the corresponding type.
Create two files that extend BattlerStats
and one that extends AttackActionData
.
To edit a resource, double-click on the .tres
file in the FileSystem dock, and it will open in the Inspector. You can also input the numbers you want for Health, Attack, Defense, and other values. Here are the ones I used.
The PlayerBattler stats.
EnemyBattler stats.
Attack action properties.
At this point, the values are arbitrary. Design-wise, I don’t bother calculating the interactions between the stats and the formulas outside the game at first. You want to make the values feel good to the player and not just try to make them work on paper, which requires gameplay testing.
We then want to assign those resources to the battlers in the scene. To do so, select one of the battler nodes and drag and drop resources on the related properties.
When it comes to the Actions array, you want to expand the field by clicking on it, increase the size to 1
, and click on the pencil icon to create a new value of type Object.
The PlayerBattler node.
The EnemyBattler node.
We’re going to add several methods to our battler and call them from the ActiveTurnQueue
. First, at the start of the encounter, we should make sure that we duplicate the stats resource and call its reinitialize()
function.
Open Battler.gd
and add the _ready()
function:
func _ready() -> void: assert(stats is BattlerStats) stats = stats.duplicate() stats.reinitialize()
Resources are shared in memory so if you have two monsters of the same type on the battlefield, they’re going to reference the same BattlerStats
instance and share health. If we don’t duplicate the stats resources, when either takes damage, it’s the same value that goes down.
Let’s add the ability to take a hit next. To do so, we also need a function to take damage. We also add two signals that we’ll get to use in the next chapter, when adding the user interface.
# Emitted when taking damage. # We'll use this signal in the next chapter to display the number of damage # taken. signal damage_taken(amount) # Emitted when a received hit missed. # We'll use this signal in the next chapter to display a "miss" label on the # screen. signal hit_missed # Applies a hit object to the battler, dealing damage or status effects. func take_hit(hit: Hit) -> void: # We encapsulated the hit chance in the hit. The hit object tells us if # we should take damage. if hit.does_hit(): _take_damage(hit.damage) emit_signal("damage_taken", hit.damage) else: emit_signal("hit_missed") # Applies damage to the battler's stats. # Later, it should also trigger a damage animation. func _take_damage(amount: int) -> void: stats.health -= amount
The next piece of the puzzle is adding a method that allows the battler to act and attack a target.
# Emitted when the battler finished their action and arrived back at their rest # position. signal action_finished # We can't specify the `action`'s type hint here due to cyclic dependency errors # in Godot 3.2. # But it should be of type `Action` or derived, like `AttackAction`. func act(action) -> void: # If the action costs energy, we subtract it. stats.energy -= action.get_energy_cost() # We wait for the action to apply. It's a coroutine, that is to say, an # asynchronous function, so we need to use yield. # The "completed" function signal is built-in, more on that below. yield(action.apply_async(), "completed") # We reset the `_readiness`. The value can be greater than zero, depending # on the action. _set_readiness(action.get_readiness_saved()) # We shouldn't set process back to `true` if the battler isn't active, so its readiness doesn't update. # That can be the case if we code a "stop" or "petrify" status effect, or # during an animation that interrupts the normal flow of battle. if is_active: set_process(true) # We emit our new signal, indicating the end of a turn for a # player-controlled character. emit_signal("action_finished")
Above, we use the yield
keyword to pause execution until the apply_async()
method completes.
When you use yield
in Godot, the engine waits for a signal to be emitted to resume the function’s execution. It can be either a built-in or a custom signal.
In this case, "completed"
is a special signal emitted by functions when they finish executing.
It works like so: when you call a method through yield
, Godot creates a GDScriptFunctionState
object for that function, which can pause and resume a function call.
The "completed"
signal comes from this class.
Now we have a public Battler.act()
method, we need to call it. Let’s open ActiveTurnQueue.gd
and, at the bottom of _play_turn()
, add the following code.
func _play_turn(battler: Battler) -> void: #... # Create a new attack action based on the chosen `action_data` and # `targets`. var action = AttackAction.new(action_data, battler, targets) # And let the battler consume it. battler.act(action) # We wait for the battler's action to finish to complete the function. yield(battler, "action_finished")
We will later add some more code after the yield line.
We now we have everything we need to test our attack and see our battlers take damage and suffer (!).
Let’s test like the pros we are using the almighty print()
function. In the Battler.gd
script, add the following temporary print statement:
func _take_damage(amount: int) -> void: #... print("%s took %s damage. Health is now %s." % [name, amount, stats.health])
You can then run your game by pressing F5, provided you already set your main scene to be the combat arena, and watch the Output console at the bottom of the editor.
The %s
notation is a string placeholder. You can use it to programmatically insert values in text strings using the %
operator. The notation is "%s %s" % [value_1, value_2]
. The compiler converts the values in square brackets to text strings for you.
For more information, read GDScript format strings in the official documentation.
Here are the complete Battler.gd
and ActiveTurnQueue.gd
files so far.
Battler.gd
extends Node2D class_name Battler signal ready_to_act signal readiness_changed(new_value) signal selection_toggled(value) signal damage_taken(amount) signal hit_missed signal action_finished export var stats: Resource export var ai_scene: PackedScene export var actions: Array export var is_party_member := false var time_scale := 1.0 setget set_time_scale var is_active: bool = true setget set_is_active var is_selected: bool = false setget set_is_selected var is_selectable: bool = true setget set_is_selectable var _readiness := 0.0 setget _set_readiness func _ready() -> void: assert(stats is BattlerStats) stats = stats.duplicate() stats.reinitialize() stats.connect("health_depleted", self, "_on_BattlerStats_health_depleted") func _process(delta: float) -> void: _set_readiness(_readiness + stats.speed * delta * time_scale) func is_player_controlled() -> bool: return ai_scene == null func set_time_scale(value) -> void: time_scale = value func set_is_active(value) -> void: is_active = value set_process(is_active) func set_is_selected(value) -> void: if value: assert(is_selectable) is_selected = value emit_signal("selection_toggled", is_selected) func set_is_selectable(value) -> void: is_selectable = value if not is_selectable: set_is_selected(false) func act(action) -> void: stats.energy -= action.get_energy_cost() yield(action.apply_async(), "completed") _set_readiness(action.get_readiness_saved()) if is_active: set_process(true) emit_signal("action_finished") func take_hit(hit: Hit) -> void: if hit.does_hit(): _take_damage(hit.damage) emit_signal("damage_taken", hit.damage) else: emit_signal("hit_missed") func _set_readiness(value: float) -> void: _readiness = value emit_signal("readiness_changed", _readiness) if _readiness >= 100.0: emit_signal("ready_to_act") set_process(false) func _take_damage(amount: int) -> void: stats.health -= amount func _on_BattlerStats_health_depleted() -> void: set_is_active(false) if not is_party_member: set_is_selectable(false)
ActiveTurnQueue.gd
class_name ActiveTurnQueue extends Node var is_active := true setget set_is_active var time_scale := 1.0 setget set_time_scale var _party_members := [] var _opponents := [] onready var battlers := get_children() func _ready() -> void: for battler in battlers: battler.connect("ready_to_act", self, "_on_Battler_ready_to_act", [battler]) if battler.is_player_controlled(): _party_members.append(battler) else: _opponents.append(battler) func set_is_active(value: bool) -> void: is_active = value for battler in battlers: battler.is_active = is_active func set_time_scale(value: float) -> void: time_scale = value for battler in battlers: battler.time_scale = time_scale func _play_turn(battler: Battler) -> void: var action_data: ActionData var targets := [] battler.stats.energy += 1 var potential_targets := [] var opponents := _opponents if battler.is_party_member else _party_members for opponent in opponents: if opponent.is_selectable: potential_targets.append(opponent) if battler.is_player_controlled(): battler.is_selected = true set_time_scale(0.05) var is_selection_complete := false while not is_selection_complete: action_data = yield(_player_select_action_async(battler), "completed") if action_data.is_targeting_self: targets = [battler] else: targets = yield( _player_select_targets_async(action_data, potential_targets), "completed" ) is_selection_complete = action_data != null && targets != [] set_time_scale(1.0) battler.is_selected = false else: action_data = battler.actions[0] targets = [potential_targets[0]] var action = AttackAction.new(action_data, battler, targets) battler.act(action) yield(battler, "action_finished") func _player_select_action_async(battler: Battler) -> ActionData: yield(get_tree(), "idle_frame") return battler.actions[0] func _player_select_targets_async(_action: ActionData, opponents: Array) -> Array: yield(get_tree(), "idle_frame") return [opponents[0]] func _on_Battler_ready_to_act(battler: Battler) -> void: _play_turn(battler)