Yet Another Roguelike Tutorial, Part 2

Publish date: Jul 11, 2023
Tags: godot4 tutorial roguelike

Part 2: Generic Entity and the Map

Continuing from the last tutorial ( https://selinadev.github.io/05-rogueliketutorial-01/ ), we will now move to creating a more generic entity, and then start on the structure of our map.

So let’s start with creating out entity class. I want to point out that my approach differs a bit from the original tutorial. In that tutorial all properties of an entity are set in its constructor. A later part eventually uses a factory pattern to create a few prototype entities in code and then spawn copies of them. However, if we have a range of properties that we want to slot into entities the common approach in Godot is to use resources. So what we’ll do is we’ll create an EntityDefinition resource and set all the properties there. We only have the player to define at the moment, so right now this might feel like unnecessary extra work, but the payoff should become apparent in Part 5. For now, let’s create a new script under res://src/Entities/entity_definition.gd. It will have the following contents:

class_name EntityDefinition
extends Resource

@export_category("Visuals")
@export var texture: AtlasTexture
@export_color_no_alpha var color: Color = Color.WHITE

We give it the name “EntityDefinition”, so we have a type and can easily find it, and we also have it extend Resource. The @export_category("Visuals") might be unfamiliar if you come from Godot 3. This is a new annotation that lets us group exported properties in the editor, making things more organized. We only have a Visuals category for now, but in later parts we will expand this class with properties fitting in other categories. The properties we export are a texture of type AtlasTexture and a color (without alpha, to prevent us from accidentally creating transparent entities). I’ve used Atlas Texture in this case as we have a single sprite sheet as source for all our graphics. If you use a different graphics pack with individual image files I’d recommend changing this to just Texture. Using Atlas Texture simply saves us the effort of going through the list of options every time and selecting the Atlas Texture option.

Let’s move on to the actual entity class. Create a file next to the entity definition at res://src/Entities/entity.gd. Here’s the top of the script:

class_name Entity
extends Sprite2D

var grid_position: Vector2i:
	set(value):
		grid_position = value
		position = Grid.grid_to_world(grid_position)

It will extend Sprite2D, just like the player we already have (and will soon replace with an instance of this class). As explained in the previous part we have a discrepancy between screen/world coordinates and coordinates on the grid. To solve this issue we create a grid_position property with a setter that takes care of updating the world position whenever we change the position on the grid. Just by using this simple setter everything is taken care of. Now the second part of the script:

func _init(start_position: Vector2i, entity_definition: EntityDefinition) -> void:
	centered = false
	grid_position = start_position
	texture = entity_definition.texture
	modulate = entity_definition.color


func move(move_offset: Vector2i) -> void:
	grid_position += move_offset

First, we have a constructor that takes a start position (in grid coordinates) and an entity definition. The start position will be different from entity to entity, even if they are the same entity type. Within the _init() function we first set centered to false. That way the position describes the position of the top left corner of the sprite, rather than its center. It should not make too much difference in our case, but I feel this is good form, as it would make adding multi-cell sprites a bit easier (by just assigning them the position of their top left cell). Next we set the grid position to the start position, spawning the sprite wherever we define. Then we take the texture and color from the entity definition and assign it to the texture and modulate of the sprite. Note that in order for coloring via the modulate property to work properly we need to have white on black or white on transparent sprites. We do, so this works out nicely, but keep this in mind if you want to use different assets. If you have colored assets you probably won’t need to touch the modulate at all.

Lastly, we give the entity the ability to move by an offset. The move() function just takes such a move_offset and adds it to the grid_position.

Let’s now see how we can use this entity class to spawn a player and an NPC. For this we first need to create a new resource of our custom EntityDefinition type. Create one as res://assets/definitions/entities/actors/entity_definition_player.tres. In the inspector click on the texture slot and create a new AtlasTexture. Fill its Atlas property with our sprite sheet and use the Edit Region interface to select the icon that you want for our player. Select a color for the player. I’ve left it at our default white, so the icon stands out against the black background, but you can choose whatever you like. Make sure that resource is saved, and we’ll move on to modify the game.dg script of our main Game node. The _process() function we have so far won’t match the new code, so you have to remove it for now. We’ll turn back to this towards the end of this part. Now, also modify the top of the script to read as follows:

class_name Game
extends Node2D

const player_definition: EntityDefinition = preload("res://assets/definitions/entities/actors/entity_definition_player.tres")

@onready var player: Entity
@onready var event_handler: EventHandler = $EventHandler
@onready var entities: Node2D = $Entities


func _ready() -> void:
	var player_start_pos: Vector2i = Grid.world_to_grid(get_viewport_rect().size.floor() / 2)
	player = Entity.new(player_start_pos, player_definition)
	entities.add_child(player)
	var npc := Entity.new(player_start_pos + Vector2i.RIGHT, player_definition)
	npc.modulate = Color.ORANGE_RED
	entities.add_child(npc)

The first important change is a new constant holding the player definition. We preload it so we can easily use it later in the _ready() function. The player entity will keep track of it’s own position, so we can get rid of the player_grid_pos variable we previously had. Speaking of the player entity, we modify the player variable to now be of the type Entity rather than Sprite2D. Also, we get rid of the reference to $Player. While we’re at it, delete the Player node from the scene tree as well, we’ll be spawning our own player in code in a moment. To logically group them we will spawn our entities into a node, which we will call Entities. In the code you can see us obtaining a reference to that node, so now is also the time to create a new Node2D as a child of Game and call it Entities.

In the _ready() function we first calculate a (temporary) starting position for our player. We calculate the grid position corresponding to the center of our game window for that. If you paid attention you’d have noticed that the player variable currently does not hold anything. So now we create a new entity at the player_start_pos using the player_definition, and store it in player. Then we add the player as a child of Entities. Next we spawn another Entity, one cell to the right of the player, using the same definition. We then change its color to orange to differentiate the (temporary) npc from the player visually. And then we also add the npc to Entities.

You should now be able to run the project and see two characters at the center of the screen. You won’t be able to move the player, as we haven’t linked our actions and entities just yet. We’ll get to that, but first let’s take care of laying the foundations for our dungeon by creating a map class.

Here I want to talk about a difference in approaches again. The original python tutorial is a bit more oriented toward keeping the map as data, and having a render function that creates a visual representation from that data every turn. However, in Godot, similar to how we did with the player, we choose objects that can draw themselves, allowing us to just once tell them how they should look, and occasionally update where they should be, with the engine handling the actual drawing. I could very well create a game map that’s a node in the scene tree, and have it have functions to access it’s data. However, the map data needs to be accessed and passed around a lot over the course of this tutorial. For that reason I chose to separate concerns by having a map class that just holds the data, and a map node that hold an instance of that map data and use it to create the visual representation, i.e., act as a place to hold the tiles.

Speaking of tiles, I will in fact deviate from the classic Godot approach a bit by not using a TileMap node. The main reason for that is the lighting system we’ll be adding in part 4. Each tile will have a lit and an unlit version, which will be differentiated by a different color. With just one sprite sheet asset and using our modulate trick this would be surprisingly difficult using the builtin TileMap. Considering our simple tile set on the other hand we won’t need any of the specialized functionality of that node, like autotiling. Therefore, I believe that my approach using Sprites and placing them in code is justified.

Now, after some considerations and explanations let’s move on to the map. Or rather its building blocks, i.e., tiles. In this tutorial we will have two different types of tiles. Each tile type will have the same characteristics. This sounds quite a bit like what I explained above with how we’ll handle different types if entities, which is why again we will start with a tile definition resource. For that create a new script at res://src/Map/tile_definition.gd. Here’s the contents:

class_name TileDefinition
extends Resource

@export_category("Visuals")
@export var texture: AtlasTexture
@export_color_no_alpha var color_lit: Color = Color.WHITE
@export_color_no_alpha var color_dark: Color = Color.WHITE

@export_category("Mechanics")
@export var is_walkable: bool = true
@export var is_transparent: bool = true

Here you can see the export categories in action, with three exported variables being grouped under the visuals category and two under mechanics. We have a texture property, which will define the icon we use for the tile. Skipping a bit ahead from the original tutorial which only defines the dark color we have both a color_dark for when the tile is unlit, and a color_lit for when it’s in the field of view. We won’t be using the lit color until part 4 though, but it doesn’t hurt to define it now. Under the mechanics we have two boolean variables. is_walkable tells us whether entities can walk onto this tile. And is_transparent will come in play for the field of view calculations. In our case we only have tiles that match, i.e., either walkable and transparent (floor) or impassible and opaque (walls). However, by separating them you can easily have tiles like windows that allow you to see through but not walk through.

Let’s go right ahead and create our two definitions. Create a TileDefinition resource at res://assets/definitions/tiles/tile_definition_floor.tres. Select a texture from the sprite sheet the same way we did for the player. I used #ff7f00 (orange) for the lit color and #7f3f00 (darker orange/brown) for the dark color. Both is_walkable and is_transparent should be ticked. Next create another TileDefinition at res://assets/definitions/tiles/tile_definition_wall.tres. Select a nice wall texture. For the wall I’m using #ffffff (white) for the lit color and #7f7f7f (mid gray) as the dark color. More importantly make sure to uncheck is_walkable as well as is_transparent.

Now for the tile itself. Create a new script at res://src/Map/tile.gd. Here’s what we’ll write into that:

class_name Tile
extends Sprite2D

var _definition: TileDefinition


func _init(grid_position: Vector2i, tile_definition: TileDefinition) -> void:
	centered = false
	position = Grid.grid_to_world(grid_position)
	set_tile_type(tile_definition)


func set_tile_type(tile_definition: TileDefinition) -> void:
	_definition = tile_definition
	texture = _definition.texture
	modulate = _definition.color_dark


func is_walkable() -> bool:
	return _definition.is_walkable


func is_transparent() -> bool:
	return _definition.is_transparent

As you can see we will extent Sprite2D. They hold the definition of what tile type they are, so they can access that data. The is_walkable() and is_transparent() functions only forward the data from the definition. That way the tile only needs to hold a reference to the definition it shares with all the other tiles, without needing to copy a lot of variables. In the _init() function we set the tiles position. We don’t need fancy setters like with the Entity, because tiles shouldn’t change their position once it’s set. You also see that we uncenter the sprite, which I already explained for the entity above. Then we have a set_tile_type() function. While the position will be set in stone for the tiles, their type won’t be, as you’ll see in the next part, when we get to map generation. So this little function does all the things we need to to when the type changes. It set’s the _definition variable, so that is_walkable() and is_transparent() access the correct data. It also sets the texture and initializes the modulate to the dark color. We will do more fancy things with the tiles once we get to the field of view. But for now it is usable and we can move on to the map.

As I explained before we will start with the map data. Most of the equivalent code for the map from the original tutorial will go here. Yet another deviation I should mention is that I will not use a two-dimensional array (array of arrays), but rather use a flat array with an indexing function. On the one hand I ultimately find that easier to handle, on the other hand does this approach work better with Godot’s typing system. So let us create a new script at res://src/Map/map_data.gd. Let us go through the script step by step. Here’s the top of the script:

class_name MapData
extends RefCounted

const tile_types = {
	"floor": preload("res://assets/definitions/tiles/tile_definition_floor.tres"),
	"wall": preload("res://assets/definitions/tiles/tile_definition_wall.tres"),
}

var width: int
var height: int
var tiles: Array[Tile]

We give it a class name and have it extend RefCounted. Whenever I have something that isn’t a node or a resource I like to have it inherit from RefCounted. The difference to Object is that you have to delete classes extending Object yourself, while classes extending RefCounted will keep track of how often they are referenced. Once a RefCounted object is no longer stored as a variable anywhere, i.e., there are zero references to it, it will delete itself (which is the behavior we usually want for objects like this).

We then have a constant storing our tile types, so we can easily access the definitions we have created in code. For variables we have a width and a height, that will store the extents of the map, unsurprisingly. And an array of Tiles, that we simply call tiles. Now if you think about it, it might seem odd that we go to the lengths of having a non-node MapData class only to have that store a bunch of nodes. However, this should make sense once we get to the map node, or at the latest in the next part, when we get to map generation. For now, let me show you the _init() function:

func _init(map_width: int, map_height: int) -> void:
	width = map_width
	height = map_height
	_setup_tiles()

Not very surprising, we give it a width and a height and store these in the variables we just defined. Then we initialize the tiles in a _setup_tiles() function, which looks as follows:

func _setup_tiles() -> void:
	tiles = []
	for y in height:
		for x in width:
			var tile_position := Vector2i(x, y)
			var tile := Tile.new(tile_position, tile_types.floor)
			tiles.append(tile)
	for x in range(30, 34):
		var tile: Tile = get_tile(Vector2i(x, 22))
		tile.set_tile_type(tile_types.wall)

First we make sure tiles is an empty array. Then we start nested for loops. We want to fill the tiles row by row. That is why the outer loop increments through all the y indices, i.e., rows, from 0 to height - 1 and the inner loop goes through each x in that row, from 0 to width - 1. For each tile we first create a vector for its position, then create a floor tile at that position. For now we just initialize the whole dungeon with floor. To play around with just a bit of wall we then iterate over the tiles (30, 22), (31, 22), (32, 22), and (33, 22). We get these tiles with the get_tile() function which we’ll implement in a moment. Once we have retrieved those tiles we set it’s type to wall. Now for that get_tile():

func get_tile(grid_position: Vector2i) -> Tile:
	var tile_index: int = grid_to_index(grid_position)
	if tile_index == -1:
		return null
	return tiles[tile_index]

As I mentioned, we are not using a 2D array. The original tutorial does that, and it would allow us to access tiles like tiles[x][y]. However, for type safety and simplicity I have decided to go with a flat array instead. To retrieve the correct tile from that we need to get its index (with a function I’ll explain shortly). If the tile doesn’t exist, that function returns -1, so in that case we will return null. If it does, we simply use that index to return the correct tile. So how do we get that index?

func grid_to_index(grid_position: Vector2i) -> int:
	if not is_in_bounds(grid_position):
		return -1
	return grid_position.y * width + grid_position.x

grid_to_index(), needs to check if the requested grid_position lies within the bounds of the map (with another function we’ll look at below). If it lies outside, and the corresponding tile does not exist, we return -1 as described above. If it does exist we can calculate an index. Remember how we made sure to fill the tiles array row by row. We also know that each row has width tiles. So grid_position.y * width jumps the index ahead to the chunk of tiles that represent the row corresponding to the y coordinate of our position. From there we only need to cound the number of columns corresponding to the x coordinate, i.e., add grid_position.x. That way we can calculate any index in our flat array.

func is_in_bounds(coordinate: Vector2i) -> bool:
	return (
		0 <= coordinate.x
		and coordinate.x < width
		and 0 <= coordinate.y
		and coordinate.y < height
	)

As promised, the is_in_bounds() function. It is just a bunch of checks to make sure the x coordinate is not lower than 0 or higher than (or equal to) the width, and the same with the y coordinate and height correspondingly. So if the coordinate is in the rectangle described by our map, for which we have valid tiles, we return ture, otherwise we return false.

That’s it for the MapData for now, so let’s move on to the map node. Create a new Node2D as child of the Game node and call it Map. This node will hold our tiles. Here and also in later parts we will make use of Godot’s draw order. Unless you use z-index magic (which we will do at some point), or use y-sorting, Godot will draw nodes in the tree from the top to the bottom of the tree. That means if we want something to appear in front of something else, usually we just need to make sure it’s further down in the scene tree. Of course, our entities should be rendered in front of the map, so make sure to arrange the scene tree so that Entities appears below Map. Now, add a script to it and save it at res://src/Map/map.gd. This script is relatively simple:

class_name Map
extends Node2D

@export var map_width: int = 80
@export var map_height: int = 45

var map_data: MapData


func _ready() -> void:
	map_data = MapData.new(map_width, map_height)
	_place_tiles()


func _place_tiles() -> void:
	for tile in map_data.tiles:
		add_child(tile)

We give it a name and we export the desired map_width and map_height. This is an advantage of Godot, that we can have these configuration parameters right at the nodes where they matter, while the original tutorial defines these parameters more centralized.

You see that we hold a reference to a MapData object. In the _ready() function we create that map_data and then call place_tiles(). That function simply loops through all the tiles. The tiles are Sprite2D nodes, but so far they are not part of the scene tree, so here we add each one, meaning they will now be drawn by the engine.

And that’s the map. Now we need to connect it to the other parts of our game. First in game.gd. We need a reference to the map, so expand @onready block:

@onready var player: Entity
@onready var event_handler: EventHandler = $EventHandler
@onready var entities: Node2D = $Entities
@onready var map: Map = $Map

We won’t do that much with the map node, but we will often need the map data it holds, so for our convenience add the following function to the bottom of the script:

func get_map_data() -> MapData:
	return map.map_data

Now we can get the map_data from the Game class. The game still isn’t playable, and we could do an intermediate step here, but I want to go right ahead to making our actions more useful. For that we first expand action.gd:

class_name Action
extends RefCounted


func perform(game: Game, entity: Entity) -> void:
	pass

Each subclass of Action will now have the perform() function, which takes in a reference to both our game (allowing it to access all the useful data) and to the entity performing the action. For the base action this perform() does nothing, because we will never instantiate the base class (the original tutorial even has this function create an error, and we could too here, if we wanted). Now let’s implement some perform() functions that actually do something. Modify escape_action.gd as follows:

class_name EscapeAction
extends Action


func perform(game: Game, entity: Entity) -> void:
	game.get_tree().quit()

For the escape action we get the scene tree from the game and call quit() on it. This essentially moves the code we previously had in game.gd into the action itself. Next the movement_action.gd:

class_name MovementAction
extends Action

var offset: Vector2i


func _init(dx: int, dy: int) -> void:
	offset = Vector2i(dx, dy)


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
	entity.move(offset)

In the new perform() function we first calculate the destination coordinate. Then we get the map_data from game (I told you that would be useful) and use that to retrieve the destination_tile. The if statement first checks whether or not we even have a tile. If the destination tile lies outside the bounds of the map area it would be null. The other reason we would not want to move is if the tile is not walkable. In both these cases we return from the function. If the code gets to the last statement we know we are allowed to move the entity, so we do so.

To make use of this new action functionality we delete the existing _process() function in game.dg and replace it with the following _physics_process():

func _physics_process(_delta: float) -> void:
	var action: Action = event_handler.get_action()
	if action:
		action.perform(self, player)

The switch from _process() to _physics_process() seems arbitrary now. The reason to use _physics_process() is that in the next part we will introduce a camera that will move with the player, and using _process() introduced some unwanted flickering. Other then that this function still serves our purpose of obtaining an action from the event_handler and then executing it. But now, rather than checking what type of action it is and reacting appropriately here, we can now just call perform() on the action and have it execute itself. If it’s an EscapeAction that action will quit the game, if it’s a MovementAction it will move the player if possible.

You can now run the project. You should see the player and an npc surrounded by floor and a short range of wall. The player should move when pressing the arrow keys, and while you will notice that you won’t be able to move into the wall, you’ll also notice that the player can occupy the same space as the npc and will be drawn behind it. We will remedy that in a short while, once we introduce the actual enemies. For now, I hope you could follow along. Remember that you can find the complete code in the accompanying GitHub repository: https://github.com/SelinaDev/Godot-Roguelike-Tutorial

You can find part 3 here: https://selinadev.github.io/07-rogueliketutorial-03/