Yet Another Roguelike Tutorial, Part 5
Publish date: Jul 18, 2023Tags: godot4 tutorial roguelike
Part 5: Placing Enemies and Kicking Them (harmlessly)
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/08-rogueliketutorial-04/
In this tutorial we will start spawning more entities. But first, we need a way to keep track of them, and coordinate all this with the tiles, that are also in the map. We already had an Entities node as child of Game. However, if you think about it, entities will be located within the game world, i.e., on the map. They will relate to the map in other ways. For example, pathfinding will happen on the map, but will include information about the entities that exist there. Therefore we will have the map handle entities, and keep track of them. That way MapData
should hold all the information we need to access for movement, fighting, etc. So, let’s move the Entities node into the Map node. Create another Node2D
as child of Map, and name it Tiles. Make sure the Tiles node appears above the Entities node in the scene tree, otherwise tiles would be drawn above entities. Game no longer has an Entities child, so remove @onready ver entities: Node2D = $Entities
from game.gd. This and some other things will go into map.gd, so let’s modify that:
@onready var tiles: Node2D = $Tiles
@onready var entities: Node2D = $Entities
@onready var dungeon_generator: DungeonGenerator = $DungeonGenerator
@onready var field_of_view: FieldOfView = $FieldOfView
func generate(player: Entity) -> void:
map_data = dungeon_generator.generate_dungeon(player)
_place_tiles()
_place_entities()
The variable section now includes the reference to the Tiles and Entities nodes, and we call a new function called _place_entities()
in generate
. This function looks as follows:
func _place_entities() -> void:
for entity in map_data.entities:
entities.add_child(entity)
With tiles now in their own node, we also need to modifiy _place_tiles()
:
func _place_tiles() -> void:
for tile in map_data.tiles:
tiles.add_child(tile)
The dungeon generation algorithm will populate an array of entities in map_data
for us. To actually place the entities we simply go through that array and add every member to the Entities node. The player will be part of that array, and thus will be added to the entities array in this function. That means we need to modify game.gd to no longer do that:
func _ready() -> void:
player = Entity.new(Vector2i.ZERO, player_definition)
remove_child(camera)
player.add_child(camera)
map.generate(player)
map.update_fov(player.grid_position)
You see we no longer add the player to the Entities node. We need to modify one more place before moving to the dungeon generation. Modify map_data.gd as follows:
var width: int
var height: int
var tiles: Array[Tile]
var entities: Array[Entity]
func _init(map_width: int, map_height: int) -> void:
width = map_width
height = map_height
entities = []
_setup_tiles()
Here we added an array for entities
and in the _init()
function initialized that to an empty array. With that out of the way, let’s now move to the dungeon generation. First thing to do is have the dungeon generator handle adding the player to the map data. So in dungeon_generator.gd add a line for that at the top of generate_dungeon()
:
func generate_dungeon(player:Entity) -> MapData:
var dungeon := MapData.new(map_width, map_height)
dungeon.entities.append(player)
Now we can extend extend the dungeon generation with some enemies. Add the following near the top of the script, next to our existing exported variables:
@export_category("Monsters RNG")
@export var max_monsters_per_room = 2
We now have another configuration variable, which is the maximum amount of monsters the generator may place in each room. Inside generate_dungeon()
add the following:
if rooms.is_empty():
player.grid_position = new_room.get_center()
else:
_tunnel_between(dungeon, rooms.back().get_center(), new_room.get_center())
_place_entities(dungeon, new_room)
rooms.append(new_room)
This is the section near the end of the loop, and we added a call to _place_entities()
. Let’s now write that function:
func _place_entities(dungeon: MapData, room: Rect2i) -> void:
var number_of_monsters: int = _rng.randi_range(0, max_monsters_per_room)
for _i in number_of_monsters:
var x: int = _rng.randi_range(room.position.x + 1, room.end.x - 1)
var y: int = _rng.randi_range(room.position.y + 1, room.end.y - 1)
var new_entity_position := Vector2i(x, y)
var can_place = true
for entity in dungeon.entities:
if entity.grid_position == new_entity_position:
can_place = false
break
if can_place:
var new_entity: Entity
if _rng.randf() < 0.8:
new_entity = Entity.new(new_entity_position, entity_types.orc)
else:
new_entity = Entity.new(new_entity_position, entity_types.troll)
dungeon.entities.append(new_entity)
It’s a bit much, so let’s look at the parts. First, as with the other functions in the dungeon generator we take in dungeon
as an argument. We also get the room
in which we will spawn the monsters. Then, as the first thing we decide how many monsters we actually want to (try to) place in this room, by taking a random number between 0 and the maximum we set earlier. Then we loop that many times through the code placing monsters.
var x: int = _rng.randi_range(room.position.x + 1, room.end.x - 1)
var y: int = _rng.randi_range(room.position.y + 1, room.end.y - 1)
var new_entity_position := Vector2i(x, y)
Each loop starts with a section that determines a random position in the room. We take random coordinates between the bounds of the room. Remember how we used Rect2i.grow(-1)
to get the inner part of the room when carving our the room. To account for that we now have to add 1 to the start and subtract 1 from the end of the room, so we don’t accidentally place an entity in the wall. We then create a vector representation of those random coordinates which we call new_entity_position
.
var can_place = true
for entity in dungeon.entities:
if entity.grid_position == new_entity_position:
can_place = false
break
Next we need to check if we can place an entity at that position. We already made sure that we don’t place it in a wall, but what if there’s already another entity at that position. To do that we use the same procedure we used in the previous part to check if rooms are intersecting. We first set the boolean can_place
to the desired outcome, then loop over all the potential obstacles and if we find one, i.e. if one of the existing entities already occupies that space, we invert that variable and break the loop.
if can_place:
var new_entity: Entity
if _rng.randf() < 0.8:
# TODO: place an orc
else:
# TODO: place a troll
dungeon.entities.append(new_entity)
If a this point in the code can_place
is true
we know we made it through all existing entities without encountering one that’s already at that spot, and we know we indeed can place the entity. So we create a variable new_entity
to store our new entity. We then generate a random number between 0 and 1 and check if it’s below 0.8. If so (i.e., in 80% of cases) we will later fill new_entity
with an orc, otherwise (i.e., the remaining 20%) we fill it with a troll. Lastly, we add it to the entity array of our dungeon.
Now we need to take care of our entities. We already have a nice system in place with the EntityDefinition
we created a few parts back. Let’s expand upon that. Open entity_definition.gd and update it as follows:
class_name EntityDefinition
extends Resource
@export_category("Visuals")
@export var name: String = "Unnamed Entity"
@export var texture: AtlasTexture
@export_color_no_alpha var color: Color = Color.WHITE
@export_category("Mechanics")
@export var is_blocking_movement: bool = true
What we added here is the name, which was previously empty, and the Mechanics category including an is_blocking_movement
variable. The player as well as all the monsters will block movement, but we will later have items that can occupy the same space as another entity. We need a way to access these properties, so we need to modify entity.gd a bit. First, add a new variable near the top:
var _definition: EntityDefinition
As we did with the Tile
we store the entity definition, so we can access properties shared by entities of the same type via this variable. We need to also set this variable when we set the entity type:
func set_entity_type(entity_definition: EntityDefinition) -> void:
_definition = entity_definition
texture = entity_definition.texture
modulate = entity_definition.color
This needs to be called within the _init()
function:
func _init(start_position: Vector2i, entity_definition: EntityDefinition) -> void:
centered = false
grid_position = start_position
set_entity_type(entity_definition)
Lastly, we need functions to access the properties in _definition
:
func is_blocking_movement() -> bool:
return _definition.is_blocking_movement
func get_entity_name() -> String:
return _definition.name
The entity now can properly store and give us access to these new properties. Now let’s reopen res://assets/definitions/entities/actors/entity_definition_player.tres and fill the name field with “Player”. The is_blocking is already checked per default.
Now that the player is up to date let’s create the orc and the troll. Create a new EntityDefinition
resource at res://assets/definitions/entities/actors/entity_definition_orc.tres. Set the name to “Orc” and choose a fitting texture for it, as we have done previously with the player and the tiles. I chose a darker green (#3f7f3f) as color for it. Then create another EntityDefinition
at res://assets/definitions/entities/actors/entity_definition_troll.tres. Call it “Troll”, choose a fitting sprite and give it a nice green but ideally slightly different color than the orc (I chose 007f00).
Now back to dungeon_generator.gd. Near the top we add the following constant:
const entity_types = {
"orc": preload("res://assets/definitions/entities/actors/entity_definition_orc.tres"),
"troll": preload("res://assets/definitions/entities/actors/entity_definition_troll.tres"),
}
This gives us access to the entity definitions we just defined. Then go back to where we left the “todo” comments, at the end of _place_entities()
:
if can_place:
var new_entity: Entity
if _rng.randf() < 0.8:
new_entity = Entity.new(new_entity_position, entity_types.orc)
else:
new_entity = Entity.new(new_entity_position, entity_types.troll)
dungeon.entities.append(new_entity)
We now create new entities, at the positions we randomly picked, and with either the orc definition or the troll definition. They should now be placed properly, and you should be able to run the project at this point. You should also be able to run through any enemy you find on the map. Speaking of finding them on the map, we also need to make sure enemies are only visible when they are inside the field of view. Let’s start with that last one. Go to map.gd and edit update_fov()
:
func update_fov(player_position: Vector2i) -> void:
field_of_view.update_fov(map_data, player_position, 8)
for entity in map_data.entities:
entity.visible = map_data.get_tile(entity.grid_position).is_in_view
Now, every time we update the field of view we then go through all the entities and update their visibility according to whether the tile they are on is in view or not. That takes care of that problem. Next let’s make them solid. To do that we need a convenient way to find out if a tile contains an entity that blocks movement. This is the responsibility of the MapData
, so add the following function to map_data.gd:
func get_blocking_entity_at_location(grid_position: Vector2i) -> Entity:
for entity in entities:
if entity.is_blocking_movement() and entity.grid_position == grid_position:
return entity
return null
We receive a position on the grid and then go through all the entities. If an entity blocks movement and is at the given position, we return that entity. Otherwise, if we don’t find a matching entity, we return null
. We could now just add a check into our movement system to prevent movement into a movement-blocking entity. However, we will do what most roguelikes do and differentiate depending on whether we navigate onto a free space or into a blocking entity. So if the player presses left and there’s an empty space, they will move there. If they press left and an enemy is left of them, they will attack that enemy. Both of these actions have directionality, so let’s make a new super-type of action. Create a new script at res://src/Entities/Actors/Actions/action_with_direction.gd, extending Action
. Fill it with the following code:
class_name ActionWithDirection
extends Action
var offset: Vector2i
func _init(dx: int, dy: int) -> void:
offset = Vector2i(dx, dy)
func perform(game: Game, entity: Entity) -> void:
pass
As you can see this looks pretty similar to how the MovementAction
looked initially, as it has an offset
property that defines the direction of the action. As this already has an offset
now, we will modify movement_action.gd to extend that script:
class_name MovementAction
extends ActionWithDirection
func perform(game: Game, entity: Entity) -> void:
var destination: Vector2i = entity.grid_position + offset
var map_data: MapData = game.get_map_data()
var destination_tile: Tile = map_data.get_tile(destination)
if not destination_tile or not destination_tile.is_walkable():
return
if game.get_map_data().get_blocking_entity_at_location(destination):
return
entity.move(offset)
It now extends ActionWithDirection
, and hence we removed all the code relating to handling the offset
, i.e., the offset
variable and the _init()
function. We get those for free from the superclass an can just use the offset in the perform()
function as we did before. Still, we add one additional check in there. If there is a blocking entity at our destination, we won’t move. However, it usually shouldn’t come to that, as we will soon implement a system that fires another action in such cases. To put this check here is not a bad idea though, as in the future we might want to extend the game by entities that we don’t want to auto-attack. Speaking of attacking, let’s create an action to do so. Create a new script at res://src/Entities/Actors/Actions/melee_action.gd and have it extend ActionWithDirection
. Fill it with the following code:
class_name MeleeAction
extends ActionWithDirection
func perform(game: Game, entity: Entity) -> void:
var destination := Vector2i(entity.grid_position + offset)
var target: Entity = game.get_map_data().get_blocking_entity_at_location(destination)
if not target:
return
print("You kick the %s, much to it's annoyance!" % target.get_entity_name())
Similar to the MovementAction
we first determine the destination. Then we lookup the entity at that place. If everything went right with the other code we’ll still have to write then we should find one there, but just in case we don’t for some reason we simply return without doing anything. Then, as we lack a health and damage system, as well as an on-screen event log, we log this attack to the Godot console.
I mentioned that we will put a system into place that decides what will be the appropriate action. Let’s create this bump action now. Create another script at res://src/Entities/Actors/Actions/bump_action.gd. Fill it with the following code:
class_name BumpAction
extends ActionWithDirection
func perform(game: Game, entity: Entity) -> void:
var destination := Vector2i(entity.grid_position + offset)
if game.get_map_data().get_blocking_entity_at_location(destination):
MeleeAction.new(offset.x, offset.y).perform(game, entity)
else:
MovementAction.new(offset.x, offset.y).perform(game, entity)
As the other directional actions did it determines a destination. Then it checks if there’s a blocking entity. If so, it creates a new MeleeAction
with the same offset as itself, and immediately performs it. Otherwise it similarly creates a MovementAction
with the same offset and performs that. So the bump action should take care of all that for us, and we can use it rather similarly to how we use the movement action. So let’s go into the EventHandler.gd and replace MovementAction
with BumpAction
.
func get_action() -> Action:
var action: Action = null
if Input.is_action_just_pressed("ui_up"):
action = BumpAction.new(0, -1)
elif Input.is_action_just_pressed("ui_down"):
action = BumpAction.new(0, 1)
elif Input.is_action_just_pressed("ui_left"):
action = BumpAction.new(-1, 0)
elif Input.is_action_just_pressed("ui_right"):
action = BumpAction.new(1, 0)
if Input.is_action_just_pressed("ui_cancel"):
action = EscapeAction.new()
return action
This should already allow us to run around and kick all the enemies. But in later parts we will also give the enemies a turn, so let’s set that up now. In game.gd modify _physics_process()
as follows:
func _physics_process(_delta: float) -> void:
var action: Action = event_handler.get_action()
if action:
var previous_player_position: Vector2i = player.grid_position
action.perform(self, player)
_handle_enemy_turns()
map.update_fov(player.grid_position)
Here we added a call to a new function called _handle_enemy_turns()
at the end. It is always executed immediately after the player actually takes an action. We’ll have to make sure to update the field of view afterwards, because eventually enemy movement might cause enemies to walk into the field of view, which we need to account for in the update. So let’s code the function handling enemy turns now:
func _handle_enemy_turns() -> void:
for entity in get_map_data().entities:
if entity == player:
continue
print("The %s wonders when it will get to take a real turn." % entity.get_entity_name())
Here we go through all the entities in the map. We check if it’s the player, and if so, we skip it. The player already got a turn, so they don’t get another. Then we print to the console again, to verity that that entity is indeed taking a turn, as we haven’t coded an AI yet that could take an actual turn for the entity. And that’s it. Run the project and keep a look at the console in the editor. After each step you should see a bunch of lines indicating the enemy turns.
Now that we have placed enemies, can detect them and then act accordingly, we have everything in place to start on a combat system in the next part, which you can find here: https://selinadev.github.io/10-rogueliketutorial-06/