Yet Another Roguelike Tutorial, Part 6
Publish date: Jul 28, 2023Tags: godot4 tutorial roguelike
Table of contents
Part 6: Doing (and taking) some damage
Welcome back to the roguelike tutorial series. This tutorial will continue from where the last one left off. You can find the previous tutorial here: https://selinadev.github.io/09-rogueliketutorial-05/
Note on Godot Version
Please make sure to use (at least) Godot 4.1. The previous tutorials should also work with Godot 4.0, but this part utilizes the AStarGrid2D
, which has changed slightly from 4.0 to 4.1, meaning that this part (and all further parts, as they build on this one) will not work in 4.0.
Refactoring
At this point the original tutorial refactors some code, and we will do so as well. Some things will turn out cleaner this way (and I will have to keep less differences in mind when converting the code).
First, let’s start with the action system. We won’t really change how we use it now, but in principle the Command Pattern allows delaying a command (or action as we call it here). We only delay it very little, creating an action in one function and executing it once it’s returned. That’s why we can easily keep track of the entity associated with that action, and therefore can pass it once we call perform()
. But a better way would be to encode the entity right in the action, so once we have the action we only need to call perform()
, without any arguments, and whatever action we encoded before just happens.
Now, we passed a Game
argument into actions before. That was easy, as the Game node was the one executing the actions. However, getting that at the time of construction would be a bit wonky. Sure, we could pass a reference to the game to the EventHandler node. However, we only really needed the game to get access to the map_data
. So the simplest way forward is to give entities a reference to the map data. We need to know the entity anyway for the action, so that’s a good place to put that information. So let’s adapt entity.gd a bit:
var _definition: EntityDefinition
var map_data: MapData
func _init(map_data: MapData, start_position: Vector2i, entity_definition: EntityDefinition) -> void:
centered = false
grid_position = start_position
self.map_data = map_data
set_entity_type(entity_definition)
In our variables we simply create a new variable to hold the map_data
, which is filled in the constructor. The first entity created is the player, and we need to handle them a bit separately. We change how the player is created in game.gd in the _ready()
function:
func _ready() -> void:
player = Entity.new(null, Vector2i.ZERO, player_definition)
remove_child(camera)
player.add_child(camera)
map.generate(player)
map.update_fov(player.grid_position)
The player is a bit of an odd case, as we create it before we create the map. We therefore pass null
here, and set the map_data
manually later. However, the map_data
also needs to know about the player. We can modify the _init()
function in map_data.gd:
func _init(map_width: int, map_height: int, player: Entity) -> void:
width = map_width
height = map_height
self.player = player
entities = []
_setup_tiles()
That means we have to modify the call to this constructor in dungeon_generator.gd. Modify the very top of the generate_dungeon()
function:
func generate_dungeon(player:Entity) -> MapData:
var dungeon := MapData.new(map_width, map_height, player)
Now the map data knows about the player, but the player still doesn’t know about map data. We can change that also in dungeon_generator.gd, when we later place the player in generate_dungeon()
:
if rooms.is_empty():
player.grid_position = new_room.get_center()
player.map_data = dungeon
else:
_tunnel_between(dungeon, rooms.back().get_center(), new_room.get_center())
Here we simply add a line where we set the player’s map_data
to dungeon
, which is the MapData
object the game will use. In the same script we also modify _place_entities()
which is responsible for instantiating new enemies:
if can_place:
var new_entity: Entity
if _rng.randf() < 0.8:
new_entity = Entity.new(dungeon, new_entity_position, entity_types.orc)
else:
new_entity = Entity.new(dungeon, new_entity_position, entity_types.troll)
dungeon.entities.append(new_entity)
Here we just modified the calls to Entity.new()
to also pass dungeon
. With that out of the way we can get to the actual actions we wanted to modify. Go into action.gd and replace it with the following:
class_name Action
extends RefCounted
var entity: Entity
func _init(entity: Entity) -> void:
self.entity = entity
func perform() -> void:
pass
func get_map_data() -> MapData:
return entity.map_data
We add a new variable to hold the entity
performing the action. We pass this entity during the creation of the action, in the new _init()
function. perform()
, however, now loses all its arguments. Lastly, we create a convenient way to get the map_data
from the entity
, by creating the get_map_data()
function. Next we have to go over all the actions we have and modify them to this new interface. Here’s escape_action.gd:
class_name EscapeAction
extends Action
func perform() -> void:
entity.get_tree().quit()
As mentioned, we remove the arguments and simply call get_tree().quit()
from the entity
. Here is the new action_with_direction.gd:
class_name ActionWithDirection
extends Action
var offset: Vector2i
func _init(entity: Entity, dx: int, dy: int) -> void:
super._init(entity)
offset = Vector2i(dx, dy)
func get_destination() -> Vector2i:
return entity.grid_position + offset
func get_blocking_entity_at_destination() -> Entity:
return get_map_data().get_blocking_entity_at_location(get_destination())
Here, we modify the _init()
function to also take in the entity
as an argument. As we overload the Action
constructor here, we must explicitly call super._init(entity)
. Beyond that we create two more convenience functions, one to calculate the destination coordinates, and one to get a blocking entity from that destination. Next, melee_action.gd:
class_name MeleeAction
extends ActionWithDirection
func perform() -> void:
var target: Entity = get_blocking_entity_at_destination()
if not target:
return
print("You kick the %s, much to it's annoyance!" % target.get_entity_name())
Here we get to use get_blocking_entity_at_location()
to write our code a bit more concisely. We do a similar thing in movement_action.gd:
class_name MovementAction
extends ActionWithDirection
func perform() -> void:
var map_data: MapData = get_map_data()
var destination_tile: Tile = map_data.get_tile(get_destination())
if not destination_tile or not destination_tile.is_walkable():
return
if get_blocking_entity_at_destination():
return
entity.move(offset)
A bit different, but ultimately nothing special here. Lastly, bump_action.gd:
class_name BumpAction
extends ActionWithDirection
func perform() -> void:
var destination := Vector2i(entity.grid_position + offset)
if get_map_data().get_blocking_entity_at_location(destination):
MeleeAction.new(entity, offset.x, offset.y).perform()
else:
MovementAction.new(entity, offset.x, offset.y).perform()
With our actions modified, let’s now modify EventHandler.gd where the actions are generated. The first thing we will do is rename it. It took me until now to notice that the EventHandler
class name is overloading an internal class of Godot. Also, I messed up when creating the file, and used the wrong capitalization. So first let’s rename the file to input_handler.gd. Then, modify that script as follows:
class_name InputHandler
extends Node
func get_action(player: Entity) -> Action:
var action: Action = null
if Input.is_action_just_pressed("ui_up"):
action = BumpAction.new(player, 0, -1)
elif Input.is_action_just_pressed("ui_down"):
action = BumpAction.new(player, 0, 1)
elif Input.is_action_just_pressed("ui_left"):
action = BumpAction.new(player, -1, 0)
elif Input.is_action_just_pressed("ui_right"):
action = BumpAction.new(player, 1, 0)
if Input.is_action_just_pressed("ui_cancel"):
action = EscapeAction.new(player)
return action
Now that we changed the class_name
we no longer have that name collision. Now, in our our main scene also change the name of the EventHandler node to InputHandler. Also, we now pass the player
into get_action()
and use it to create our actions. The last piece to this puzzle is to modify game.gd. First, we need to reflect the changed class name where we type the variables:
@onready var player: Entity
@onready var input_handler: InputHandler = $InputHandler
@onready var map: Map = $Map
@onready var camera: Camera2D = $Camera2D
Here we change all the event handler naming to input handler. Now we need to modify _physics_process()
:
func _physics_process(_delta: float) -> void:
var action: Action = input_handler.get_action(player)
if action:
var previous_player_position: Vector2i = player.grid_position
action.perform()
_handle_enemy_turns()
map.update_fov(player.grid_position)
Here we change action.perform(self, player)
to action.perform()
, as the action already knows everything it needs to perform itself. Now that we have the refactoring out of the way we can move on to …
Part 6
In this tutorial we will create our combat system. This will include things like health and dealing damage. This also includes a (very simple) enemy AI. We will do this by composition. Entities will be able to hold several components that extend their functionality. A Fighter component will hold health and similar. And an AI component will handle the actions the enemies will perform.
One deviation from the original tutorial will be that I will omit the Actor
class. I wasn’t sure which approach I preferred, because the other option is to extend our Entity
class by some functions that don’t really make sense for non-actors. However, strict inheritance is also limiting. A fighter component allows an entity to have a health stat, allows it to be attacked by other entities, and allows it to be destroyed. If we wanted to have, say, an obstacle like a barrel, or a door, then that’s just what we would want of this. If we were to go the inheritance route we would need to make that barrel an actor, even though it won’t act. We won’t create things like barrels or doors in this tutorial series, but extensibility is still something I want to consider. Therefore I prefer to just use composition, to give every entity the option to have any component. This means we have to give things like the is_alive()
function to all entities, but technically we can ask if, for example a barrel, is alive. You notice though that I still use the actor terminology in the directory structure though, just to organize things a bit better.
So let’s start with components. We will start with a simple base component. Create a new script extending Node
at res://src/Entities/Actors/Components/base_component.gd. It’ll look like this:
class_name Component
extends Node
@onready var entity: Entity = get_parent() as Entity
func get_map_data() -> MapData:
return entity.map_data
We use an @onready
variable to get the entity the component will be attached to. The components will be attached directly to entities, so we can just get the parent. We will also very often need access to the map_data
, so we again create our familiar convenience function. Now let’s create an actually useful component. Create a new script extending Component
at res://src/Entity/Actors/Components/fighter_component.gd. Here’s the code for that component:
class_name FighterComponent
extends Component
var max_hp: int
var hp: int:
set(value):
hp = clampi(value, 0, max_hp)
var defense: int
var power: int
func _init(definition: FighterComponentDefinition) -> void:
max_hp = definition.max_hp
hp = definition.max_hp
defense = definition.defense
power = definition.power
A fighter component holds the maximum health points, current health points, defense, and power of the entity. The hp
variable representing the current health points has a setter that ensures that health point values stay between 0 and max_hp
. You also see that all these values are initialized from a FighterComponentDefinition
, an approach we already used for entities, as well as tiles. You will soon see why using this resource-driven approach is especially useful, but first we need to create that resource type. Create a new script extending Resource
at res://src/Entities/Actors/Components/ComponentDefinitions/fighter_component_definition.gd. It will hold the initial values for a FighterComponent
:
class_name FighterComponentDefinition
extends Resource
@export_category("Stats")
@export var max_hp: int
@export var power: int
@export var defense: int
Before we attach things to our entities, let’s handle the AI. However, there are a few prerequisites for that as well. The AI will use pathfinding, so we want a system for that. Pathfinding will be the responsibility of the MapData
class. So let’s modify map_data.gd a bit:
const entity_pathfinding_weight = 10.0
var width: int
var height: int
var tiles: Array[Tile]
var entities: Array[Entity]
var player: Entity
var pathfinder: AStarGrid2D
We add two things at the start of the script. We create a new constant called entity_pathfinding_weight
. This is the weight we use for blocking entities. These entities block actual movement, and we want other entities to try pathfinding around them wherever possible, but we don’t actually want to block pathfinding through them. Suppose a player flees into a corridor, followed by two orcs. If the orc closer to the player would block the other orc’s pathfinding, that other orc would seemingly run away from the action while he tries to find a way to the players position through any other corridor and room. By blocking entities just increasing the weight for the pathfinding, the orc will go around the other orc when it can, but queue up nicely behind the other orc if other paths would be too long. Apart from the pathfinding weight we also add a pathfinder
variable of type AStarGrid2D
. This version of the A* algorithm makes pathfinding on a grid pretty easy. Now let’s add some functions to manage the pathfinder:
func register_blocking_entity(entity: Entity) -> void:
pathfinder.set_point_weight_scale(entity.grid_position, entity_pathfinding_weight)
func unregister_blocking_entity(entity: Entity) -> void:
pathfinder.set_point_weight_scale(entity.grid_position, 0)
We have two functions to register and unregister blocking entities on the pathfinder. The register function tells the pathfinder that the entity is there by setting the pathfinding weight on the entities position to entity_pathfinding_weight
, just as we described above. To tell the pathfinder the entity isn’t there anymore, we use the unregister function, which sets the weight back to 0. However, we still need to setup the pathfinder properly, so it knows which tiles are walkable and which are not.
func setup_pathfinding() -> void:
pathfinder = AStarGrid2D.new()
pathfinder.region = Rect2i(0, 0, width, height)
pathfinder.update()
for y in height:
for x in width:
var grid_position := Vector2i(x, y)
var tile: Tile = get_tile(grid_position)
pathfinder.set_point_solid(grid_position, not tile.is_walkable())
for entity in entities:
if entity.is_blocking_movement():
register_blocking_entity(entity)
Here we first create a new AStarGrid2D
instance and set it to the dimensions of the map (which requires us to call update()
). We then go through the whole map. Unfortunately we can’t just loop through the tiles
, which is why we have nested loops for y and x. We then have a position, we retrieve a tile, and subsequently set the set that point in the pathfinder to solid if the tile is walkable. After that we go through all the entities and register them if they are blocking entities. You see that this kind of setup requires a finished dungeon. That’s why we need to call it in dungeon_generator.gd, at the end just before the return
statement of generate_dungeon()
:
if rooms.is_empty():
player.grid_position = new_room.get_center()
player.map_data = dungeon
else:
_tunnel_between(dungeon, rooms.back().get_center(), new_room.get_center())
_place_entities(dungeon, new_room)
rooms.append(new_room)
dungeon.setup_pathfinding()
return dungeon
The last thing we need to do is some management of reflecting moving entities in the pathfinder. We can do this in entity.gd:
func move(move_offset: Vector2i) -> void:
map_data.unregister_blocking_entity(self)
grid_position += move_offset
map_data.register_blocking_entity(self)
Now we unregister the entity from its current position before it moves, then we actually move it, followed by registering it at the new position. Now the pathfinder will properly be updated to the current state of the game. With all that in place, we can now move to the AI.
We start with a base AI by creating a new script extending Component
at res://src/Entities/Actors/Components/base_ai_component.gd. It will simply define the functions all AIs will have:
class_name BaseAIComponent
extends Component
func perform() -> void:
pass
func get_point_path_to(destination: Vector2i) -> PackedVector2Array:
return get_map_data().pathfinder.get_point_path(entity.grid_position, destination)
We will have a perform()
function that will work similar to actions (the original tutorial actually uses multiple inheritance to have the AI also extend the Action
class, but this will work just as well). We also have a get_point_path_to()
function which gives us a path from one point on the map to another, using our new pathfinding functionality. With all the work we already did this is thankfully just a one-liner. This isn’t an exciting AI yet, so let’s make an actually useful one. Create a new script extending BaseAIComponent
at res://src/Entities/Actors/Components/hostile_enemy_ai_component.gd. The AI will look as follows:
class_name HostileEnemyAIComponent
extends BaseAIComponent
var path: Array = []
func perform() -> void:
var target: Entity = get_map_data().player
var target_grid_position: Vector2i = target.grid_position
var offset: Vector2i = target_grid_position - entity.grid_position
var distance: int = max(abs(offset.x), abs(offset.y))
if get_map_data().get_tile(entity.grid_position).is_in_view:
if distance <= 1:
return MeleeAction.new(entity, offset.x, offset.y).perform()
path = get_point_path_to(target_grid_position)
path.pop_front()
if not path.is_empty():
var destination := Vector2i(path[0])
if get_map_data().get_blocking_entity_at_location(destination):
return WaitAction.new(entity).perform()
path.pop_front()
var move_offset: Vector2i = destination - entity.grid_position
return MovementAction.new(entity, move_offset.x, move_offset.y).perform()
return WaitAction.new(entity).perform()
We first need to store a path. This is kind of the memory of the entity. If the player is in view it will update the path
variable, but if it looses sight it will follow its current path to the position it last saw the player. Let’s look at perform in detail:
var target: Entity = get_map_data().player
var target_grid_position: Vector2i = target.grid_position
var offset: Vector2i = target_grid_position - entity.grid_position
var distance: int = max(abs(offset.x), abs(offset.y))
Our target will always be the player. We retrieve it and get its position. We then calculate the offset, i.e. the relative position from the entity to the player, and the distance between them (here as Chebyeshev distance, i.e., moving horizontally, vertically, or diagonally all takes one step).
if get_map_data().get_tile(entity.grid_position).is_in_view:
if distance <= 1:
return MeleeAction.new(entity, offset.x, offset.y).perform()
path = get_point_path_to(target_grid_position)
path.pop_front()
We then check if the entity is in view of the player. If the player can see the enemy, the enemy also can see the player, so in that case we want to act accordingly. If the distance is 1 or less, meaning the enemy is standing right next to the player, it will hit the player by creating a new MeleeAction
and perform it. If we are further away than that, we request a new path from the pathfinder. We then need to remove the first element of that path, because that is the tile the enemy already stands on, and we only want the next tiles we need to go to.
if not path.is_empty():
var destination := Vector2i(path[0])
if get_map_data().get_blocking_entity_at_location(destination):
return WaitAction.new(entity).perform()
path.pop_front()
var move_offset: Vector2i = destination - entity.grid_position
return MovementAction.new(entity, move_offset.x, move_offset.y).perform()
return WaitAction.new(entity).perform()
Then, regardless of whether or not the enemy sees the player, it will try moving along the path. If the path isn’t empty, we get the next tile to move to from it. Of course we need to check whether the entity will be able to move there, otherwise we don’t want to consume a step in the path. If a horde of enemies is chasing the player, then they might block each other. If the entity would be blocked, we make it wait a turn. Otherwise we can use up the next path seep and calculate how we need to move, i.e., the move_offset
, and then create and perform the corresponding MovementAction
. Lastly, if we have not returned yet by either attacking or moving we create and perform a WaitAction
. We haven’t created that one yet, so let’s do so now. Create a new script extending Action
at res://src/Entities/Actors/Actions/wait_action.gd, and fill it with this very simple code:
class_name WaitAction
extends Action
func perform() -> void:
pass
Unsurprisingly the WaitAction
causes the entity to do nothing. Let’s now turn our attention to the Entity
so it can hold and manage those components. For that we start by expanding entity_definition.gd a bit:
@export_category("Components")
@export var fighter_definition: FighterComponentDefinition
@export var ai_type: Entity.AIType
We create a new category for components. The fighter_definition
will be a FighterComponentDefinition
. The neat thing I mentioned earlier is that this now can be a sub-resource. Whenever we create an entity definition we can fill this variable with a new resource we can edit right in that same inspector window. And if we don’t want the entity to have such a component we leave that field empty. This makes this very flexible. We also have an ai_type
variable. This variable refers to an enum we will create shortly. The reason we don’t have a AiComponentDefinition
is that we will only a small number of fixed AIs, so we just need a way to know which of these to instantiate. Now to entity.gd:
class_name Entity
extends Sprite2D
enum AIType {NONE, HOSTILE}
Create a new enum at the start. We will have two types of AI for now. Either None or the hostile AI. We also need references to the components:
var fighter_component: FighterComponent
var ai_component: BaseAIComponent
We then use set_entity_type()
to extract the component information from the entity_definition
:
func set_entity_type(entity_definition: EntityDefinition) -> void:
_definition = entity_definition
texture = entity_definition.texture
modulate = entity_definition.color
match entity_definition.ai_type:
AIType.HOSTILE:
ai_component = HostileEnemyAIComponent.new()
add_child(ai_component)
if entity_definition.fighter_definition:
fighter_component = FighterComponent.new(entity_definition.fighter_definition)
add_child(fighter_component)
The match
block is new. We check which type of AI it should be. If it should be the hostile ai we create a new HostileEnemyAIComponent
and add it as a child. We don’t handle the none type, because in that case nothing will happen with the ai_component
anyway. Then we check if the entity_definition
has a fighter_definition
sub-resource. If so, we take that info and create a new FighterComponent
from it, and add that as a child. Last thing for now is one more function in this script:
func is_alive() -> bool:
return ai_component != null
We have the aforementioned is_alive()
function, which simply checks if we have anything in the ai_component
variable or not. By removing the AI component when an entity dies we can both stop it from doing any more actions and we can mark it as a dead entity.
We now can have entities with components, but to actually do so we need to add these options to our existing entity definition resources. So go into entity_definition_orc.tres and you will see our new Components category. The easy thing first, set Ai Type to Hostile. Now click into the slot for the Fighter Definition and create a new FighterComponentDefinition. You can now click on the sub resource in that slot to expand and collapse it. Set the Max Hp to 10, the Power to 3, and the Defense to 0. Next, open entity_definition_troll.tres, and set the Ai Type to Hostile as well. Create another sub resource in Fighter Definition and fill it with a Max Hp of 16, Power of 4, and Defense of 1.
Lastly, we do the same with the entity_definition_player.tres. Create a Fighter Definition and set the Max Hp to 30, the Power to 5, and the Defense to 2. You might expect that we leave Ai Type as None, because the player is not controlled by an ai. But remember our is_alive()
function. If the player had no ai_component
, the game would treat it as dead. However, we will handle the player separately anyway, so we set Ai Type to Hostile. This will give the player a HostileEnemyAiComponent
, but that AI simply won’t do anything. Let me show you what I mean. In game.gd we change the _handle_enemy_turns()
function as follows:
func _handle_enemy_turns() -> void:
for entity in get_map_data().get_actors():
if entity.is_alive() and entity != player:
entity.ai_component.perform()
We loop through all actors, and we check for two conditions. First, the entity has to be alive, i.e., have an ai_component
, and second the entity mustn’t be the player. That way we know we have an alive enemy, and we call it’s ai_component.perform()
function to have it choose and perform an action. We don’t have the function to get the array of actors yet. While I did explain that I do not want to make that distinction here in terms of class hierarchy, we can create something similar by just returning an array of all alive entities. If then seems redundant to check is_alive()
here, and in our current game it is. But imagine if we extended the game in a way that would allow an enemy further back in the list to die during the earlier enemy turns. It would still be in that array, but when we get to it it won’t be alive anymore. So just to make sure, we check again here. So let’s now create that get_actors()
function in map_data.gd:
func get_actors() -> Array[Entity]:
var actors: Array[Entity] = []
for entity in entities:
if entity.is_alive():
actors.append(entity)
return actors
func get_actor_at_location(location: Vector2i) -> Entity:
for actor in get_actors():
if actor.grid_position == location:
return actor
return null
In get_actors()
we just create an array and filter for all the alive entities. We also create a function to get_actor_at_location()
which works very similarly to get_blocking entity_at_location()
, just for actors.
Now, the way we set up our AI pathfinding allows enemies to move diagonally. The player can’t at the moment, as there’s no input that creates a diagonal BumpAction
. So let us now expand our input system a bit. We’ll have three ways to move the player. The first is the arrow keys, a before, but we’ll extend those by some special keys we use for diagonals. Not the most intuitive, so we’ll also use my favorite method, the numpad. Not everyone has a numpad on their keyboard though, so we’ll also implement the classic vim-style direction keys on the right side of the home row.
To do all this, go to Project > Settings in the menu bar, and there select the tab reading Input Map. Type move_up in the field reading Add New Action then click the Add button to the right of this. You should now see the move_up action in the list, and a plus sign all the way to the right. Click that, and assign the keys you want for moving up. You can add multiple keys after one another. Keep it at physical keycode, that way the layout will stay the same, even for people using another keyboard layout. Here’s the actions and corresponding keys we’ll use (use the ones that make sens in your keyboard layout):
Action | Arrow Keys | Numpad | Vim keys |
---|---|---|---|
move_up | Up arrow key | NumPad 8 | K |
move_down | Down arrow key | NumPad 2 | J |
move_left | Left arrow key | NumPad 4 | H |
move_right | Right arrow key | NumPad 6 | L |
move_up_left | Home | NumPad 7 | Y |
move_up_right | End | NumPad 9 | U |
move_down_left | Page Up | NumPad 1 | B |
_move_down_right | Page Down | NumPad 3 | N |
wait | Delete | NumPad 5 | Period |
quit | ESC | (also uses ESC) | (also uses ESC) |
With our actions defined, let’s rewrite input_handler.gd. Replace the code with the following:
class_name InputHandler
const directions = {
"move_up": Vector2i.UP,
"move_down": Vector2i.DOWN,
"move_left": Vector2i.LEFT,
"move_right": Vector2i.RIGHT,
"move_up_left": Vector2i.UP + Vector2i.LEFT,
"move_up_right": Vector2i.UP + Vector2i.RIGHT,
"move_down_left": Vector2i.DOWN + Vector2i.LEFT,
"move_down_right": Vector2i.DOWN + Vector2i.RIGHT,
}
func get_action(player: Entity) -> Action:
var action: Action = null
for direction in directions:
if Input.is_action_just_pressed(direction):
var offset: Vector2i = directions[direction]
action = BumpAction.new(player, offset.x, offset.y)
if Input.is_action_just_pressed("wait"):
action = WaitAction.new(player)
if Input.is_action_just_pressed("quit"):
action = EscapeAction.new(player)
return action
Here we create an array that associates the names of the action strings with the desired move direction. That way we don’t need four more if checks, we can simply loop through the keys of our dictionary of directions, and if that action is pressed, we create a BumpAction
with the corresponding offset. We also added a WaitAction
for the player, and changed the code for the EscapeAction
to use our new actions.
Now that both the player and the enemies can move around freely, let’s have them do some actual damage. First, we only want to hit living things (in this tutorial anyway), so to make that easier we add the following function to action_with_direction.gd:
func get_target_actor() -> Entity:
return get_map_data().get_actor_at_location(get_destination())
Let’s use that function right away in bump_action.gd:
func perform() -> void:
if get_target_actor():
MeleeAction.new(entity, offset.x, offset.y).perform()
else:
MovementAction.new(entity, offset.x, offset.y).perform()
Next we rewrite the MeleeAction
. So far that action has only printed a little statement. We will still print a statement so we know what happened, but now we will also do damage. Change the perform()
function in melee_action.gd as follows:
func perform() -> void:
var target: Entity = get_target_actor()
if not target:
return
var damage: int = entity.fighter_component.power - target.fighter_component.defense
var attack_description: String = "%s attacks %s" % [entity.get_entity_name(), target.get_entity_name()]
if damage > 0:
attack_description += " for %d hit points." % damage
target.fighter_component.hp -= damage
else:
attack_description += " but does no damage."
print(attack_description)
The top of the function is the same as before, with the exception that we now use the get_target_actor()
function instead of get_blocking_entity()
. Then we calculate the damage. A more exciting game would have damage ranges, maybe some simulated dice throws, but here we will just take the attacker’s power and subtract the defender’s defense from it, and that’s our damage. Next, we cobble together an attack description. The description starts with who is hitting who. Then it either tells us how much damage it did, or that it did not do any damage. In case we do damage, we also subtract that damage from the target’s health points.
Now that entities can take damage, they should also have a way to die. Here we come to a bit of an issue. Where I previously though it would be a nice trick to have values like is_blocking_movement
just refer to the entity type definition, we now will want to change that value for individual entities when they die. Therefore we need to go back and modify entity.gd a bit:
var _definition: EntityDefinition
var entity_name: String
var blocks_movement: bool
var map_data: MapData
We now have a variable for the name and for blocking movement. I would have preferred if I could have followed the convention to name it is_blocking_movement
, but that would have collided with the function of that name. We initialize these values in set_entity_type()
:
func set_entity_type(entity_definition: EntityDefinition) -> void:
_definition = entity_definition
blocks_movement = _definition.is_blocking_movement
entity_name = _definition.name
texture = entity_definition.texture
modulate = entity_definition.color
match entity_definition.ai_type:
AIType.HOSTILE:
ai_component = HostileEnemyAIComponent.new()
add_child(ai_component)
if entity_definition.fighter_definition:
fighter_component = FighterComponent.new(entity_definition.fighter_definition)
add_child(fighter_component)
All our code so far has referred to the functions we wrote to retrieve these values from the definition, so in order to keep all that other code without the need for any further changes we modify these function to now give us the values directly:
func is_blocking_movement() -> bool:
return blocks_movement
func get_entity_name() -> String:
return entity_name
That’s it for the entity refactor. Next, we need a visual representation for a corpse. For that create a new resource of type AtlasTexture
at res://assets/resources/default_death_texture.tres. Fill it with our sprite sheet as the atlas and select an appropriate icon, such as the bone. We will now use this to add the following to fighter_component_definition.gd:
@export_category("Visuals")
@export var death_texture: AtlasTexture = preload("res://assets/resources/default_death_texture.tres")
@export var death_color: Color = Color.DARK_RED
This will allow us to customize what the remains of monsters will look like, while also having a good default. With that, everything is in place to let our entities die. Let’s write a function for that in fighter_component.gd:
func die() -> void:
var death_message: String
if get_map_data().player == entity:
death_message = "You died!"
else:
death_message = "%s is dead!" % entity.get_entity_name()
print(death_message)
entity.texture = death_texture
entity.modulate = death_color
entity.ai_component.queue_free()
entity.ai_component = null
entity.entity_name = "Remains of %s" % entity.entity_name
entity.blocks_movement = false
get_map_data().unregister_blocking_entity(entity)
Here we start piecing a death message together again. This is all leading up to the next part, where we will create a message log. For now we print to the console. We differentiate here whether the player or an enemy died, and create an appropriate message. After printing that message comes the housekeeping. We set the entity texture to the death texture, and it’s modulate to the death color. Both these we’ll have to add to the fighter component in a moment. We delete the AI component and remove the reference to it. We change the name of the entity to reflect that it’s dead. And lastly, we set it to not block movement, which also implies unregistering it from the pathfinder. Now, modify the top of the script:
var max_hp: int
var hp: int:
set(value):
hp = clampi(value, 0, max_hp)
if hp <= 0:
die()
var defense: int
var power: int
var death_texture: Texture
var death_color: Color
func _init(definition: FighterComponentDefinition) -> void:
max_hp = definition.max_hp
hp = definition.max_hp
defense = definition.defense
power = definition.power
death_texture = definition.death_texture
death_color = definition.death_color
Firstly, we add a line to the hp setter that calls die()
when the hp are changed to 0. Secondly, we add the aforementioned death texture and color. And thirdly, we set those in the _init()
function.
And just like that, things can die. If you run the project now, however, you would notice two problems. The first is that alive entities might be drawn behind alive entities, which we don’t want. The second is that when the player dies, nothing much changes apart from their appearance and name. “Remains of Player” can still happily run around through the dungeon and hit the other entities. Let’s start with the first problem, as that’s a bit easier to solve.
To do that, we will a bit of code to entity.gd:
enum EntityType {CORPSE, ITEM, ACTOR}
var type: EntityType:
set(value):
type = value
z_index = type
We add both an enum EntityType
and a variable type. In the setter we set the z_index
to the type. As the enum is actually an integer value, entity types further to the end of the list will be drawn above those at the beginning of the list. So if an entity is set to the type ACTOR
it will be drawn above one that is set to CORPSE
. To make use of this edit set_entity_type()
:
func set_entity_type(entity_definition: EntityDefinition) -> void:
_definition = entity_definition
type = _definition.type
...
You see we need to get that from the definition. We add a line for that to entity_definition.gd:
@export_category("Mechanics")
@export var is_blocking_movement: bool = true
@export var type: Entity.EntityType = Entity.EntityType.ACTOR
The default value is ACTOR
here, as all the entities we have so far that use an entity definition are actors, meaning we don’t have to update them manually. Lastly, at the end of the die()
function in fighter_component.gd:
print(death_message)
entity.texture = death_texture
entity.modulate = death_color
entity.ai_component.queue_free()
entity.ai_component = null
entity.entity_name = "Remains of %s" % entity.entity_name
entity.blocks_movement = false
get_map_data().unregister_blocking_entity(entity)
entity.type = Entity.EntityType.CORPSE
We simply add one line at the end, to set the entity type to CORPSE
. Now the player or living enemies should never be drawn behind a dead enemy.
Now for the last problem, taking control away from the player when their character dies. To do that we will expand our system for input handling a bit. We will create a state machine that defers the call to an action to the currently active child. Create a new script extending Node
at res://src/Game/EventHandlers/base_input_handler.gd:
class_name BaseInputHandler
extends Node
func get_action(player: Entity) -> Action:
return null
It only has a get_action()
method. Now rename the file input_handler.gd to main_game_input_handler.gd, and change the first line to extends BaseInputHandler
, and remove the line with class_name
. Now in the main game scene remove the script from the InputHandler node. Create a new script at res://src/Game/EventHandlers/input_handler.gd. This process might seem to be a bit confusing, but what we do here is to change how input handling works while preserving how it’s called. The Game node will now call the new input handler and just request an action from the new input handler, which will in turn request it from the MainGameInputHandler, which has all the old code. But let’s first write the new top-level input_handler.gd:
class_name InputHandler
extends Node
enum InputHandlers {MAIN_GAME, GAME_OVER}
@export var start_input_handler: InputHandlers
@onready var input_handler_nodes := {
InputHandlers.MAIN_GAME: $MainGameInputHandler,
InputHandlers.GAME_OVER: $GameOverInputHandler,
}
var current_input_handler: BaseInputHandler
func _ready() -> void:
transition_to(start_input_handler)
func get_action(player: Entity) -> Action:
return current_input_handler.get_action(player)
func transition_to(input_handler: InputHandlers) -> void:
current_input_handler = input_handler_nodes[input_handler]
Here we first define a range of enums that represent the different input handlers we will have. We also have an exported variable that lets us select with which one we should start. We create an @onready
dictionary associating the enum values with the available child nodes, which we’ll create in a minute. The variable current_input_handler
remembers which child input handler we’re currently using.
In the _ready()
function we call transition_to()
with the starting handler. Transition to simply retrieves the appropriate node and stores it in current_input_handler
. This then allows us to define get_action()
as simply in turn calling get_action()
on the current input handler.
To make this actually work, let’s add two new Node
s as children of InputHandler. Call the first one MainGameInputHendler and the second one GameOverInputHandler. Now attach main_game_input_handler.gd to MainGameInputHanlder. Let’s also create a new script on GameOverInputHandler, have it extend BaseInputHandler
and save it at res://src/Game/EventHandlers/game_over_input_handler.gd. Here’s its contents:
extends BaseInputHandler
func get_action(player: Entity) -> Action:
var action: Action
if Input.is_action_just_pressed("quit"):
action = EscapeAction.new(player)
return action
Once the player is dead, the only thing we allow it for now is quitting the game. Now that that’s in place we need a way to trigger the transition from one input handler to the other. For that we’ll introduce a signal bus. That way, if the player dies we can emit a signal that automatically triggers that change. Create a new script extending Node
at res://src/Utils/signal_bus.gd. For now it only defines a single signal:
extends Node
signal player_died
Now we need to make the signal bus an Autoload. Go to Project > Settings again, this time into the Autoload tab. Click on the folder icon next to Path and navigate to signal_bus.gd. Set the Node_Name to SignalBus and click add. Now you should see the signal bus entered into the list below.
We will emit this new signal in fighter_component.gd, in the die()
function, when we determine that it was the player that died:
if get_map_data().player == entity:
death_message = "You died!"
SignalBus.player_died.emit()
else:
death_message = "%s is dead!" % entity.get_entity_name()
To catch that signal we add one line to the _ready()
function of input_handler.gd:
func _ready() -> void:
transition_to(start_input_handler)
SignalBus.player_died.connect(transition_to.bind(InputHandlers.GAME_OVER))
This connects the player_died
signal in the SignalBus
directly to transition_to
, and will bind the value InputHandlers.GAME_OVER
, meaning that value will be used as an argument. That way, when the player dies and that signal is emitted, we automatically transition to the game over input handler, which will prevent the player from doing anything further than quit the game.
And with that, I declare this part of the tutorial complete. The original tutorial also adds a little display for the player’s hp, but in our setup that’s slightly more involved, so I’ll save this for the next part, when we will tackle the interface anyway.
This part has been very long with a lot of changes, and I hope you could follow. If there is anything that’s not working for you with this code, remember that you can find the working code on this tutorial’s GitHub repository: https://github.com/SelinaDev/Godot-Roguelike-Tutorial. Once everything is working, you can move on to the next part: https://selinadev.github.io/11-rogueliketutorial-07/