Yet Another Roguelike Tutorial, Part 4
Publish date: Jul 18, 2023Tags: godot4 tutorial roguelike
Part 4: Field of View
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/07-rogueliketutorial-03/
Last time we created a dungeon to explore, but we also noticed that it’s not that exciting to explore if you know where all the rooms are from the beginning (also those rooms are empty, but we’ll handle that next time, one thing at a time). So this time we are going to implement a field of view. We will do the classic fog of war approach. So we differentiate between areas we haven’t explored yet, which we won’t draw at all, and those areas we already know about, which we draw. In the areas we draw we differentiate between all the tiles we can see right now and those that we “remember” but can’t see. As is common in dungeon crawling we use a color scheme that invokes the image of the character carrying a torch, so the area we can see is “lit”, while the area we can’t see is “dark”. You maybe remember that we already set up our tiles with that terminology.
Speaking of that let’s start with the tiles. The original tutorial uses a big array keep track of what’s explored and what’s visible. But as this impacts mainly how we draw the tiles we will do all this in the Tile
class, so the tiles themselves will keep track of their state and how that state should be visually represented. So go to tile.gd and slightly change the _init()
function:
func _init(grid_position: Vector2i, tile_definition: TileDefinition) -> void:
visible = false
centered = false
position = Grid.grid_to_world(grid_position)
set_tile_type(tile_definition)
Here we just added one line: visible = false
. This means that when a tile is created it won’t be drawn. And that’s what we want, as we haven’t explored most tiles in the beginning. We only want to draw the tile once it is explored. To keep track of that we add the following property:
var is_explored: bool = false:
set(value):
is_explored = value
if is_explored and not visible:
visible = true
is_explored
starts out as false
. Using the setter, whenever we set it to something we check if it is set to true
and if the tile is not visible. If that’s the case, we set visible = true
to show the tile. That way we reveal the tile the first time it is set to be explored. And it will be explored the first time it is in the field of view. That state is handled in the next property.
var is_in_view: bool = false:
set(value):
is_in_view = value
modulate = _definition.color_lit if is_in_view else _definition.color_dark
if is_in_view and not is_explored:
is_explored = true
This is also a small break from the original tutorial, where this property is called “visible”. However, as Godot uses that name as a builtin property already, I can’t use it. These are the things you have to be mindful of when converting between languages/engines. This property also starts out as false
. Whenever we set it, we do two additional things. The first is to change the modulate of the tile to the appropriate state. If the tile is_in_view
we look up color_lit
in _definition
. Otherwise we look up color_dark
. This here is the main reason I did not use a TileMap
node, as each tile can better handle it’s own appearance. Apart from that if the tile is in the field of view but hasn’t been explored yet, we mark it as explored. We thereby will trigger the setter above, so the first time our field of view algorithm determines that a tile is in view this (small) cascade of functions will also reveal it.
Godot handles the rendering from there. We only need a way to determine if a tile is in the field of view or not, and set it appropriately. The original tutorial uses a function built into tcod. However, we have to code that ourself. Field of view will be within the responsibility of the Map. In our game scene add a new Node
as child of the Map node and call it FieldOfView. Attach a script it and save it at res://src/Map/field_of_view.gd. I am not clever enough to come up with some field of view calculations myself, so I went looking online. I found this C++ implementation of shadowcasting: https://www.roguebasin.com/index.php?title=C%2B%2B_shadowcasting_implementation. Thankfully I am clever enough to convert it to GDScript. I hate to admit it, but I can’t fully explain what is going on in the following code. I myself have only a vague idea. I can only encourage you to read up on shadowcasting or other field of view algorithms online if it interests you. But for now let’s just be content to use this code as a tool to achieve our goals.
While most of the game uses vectors to represent positions, this algorithm goes from tile to tile, so it makes more sense to represent positions on the grid as separate x and y coordinates. To facilitate that we create a little wrapper function in map_data.gd:
func get_tile_xy(x: int, y: int) -> Tile:
var grid_position := Vector2i(x, y)
return get_tile(grid_position)
This just wraps our existing tile access function. With that we are ready to add the following to field_of_view.gd:
class_name FieldOfView
extends Node
const multipliers = [
[1, 0, 0, -1, -1, 0, 0, 1],
[0, 1, -1, 0, 0, -1, 1, 0],
[0, 1, 1, 0, 0, -1, -1, 0],
[1, 0, 0, 1, -1, 0, 0, -1]
]
var _fov: Array[Tile] = []
func update_fov(map_data: MapData, origin: Vector2i, radius: int) -> void:
_clear_fov()
var start_tile: Tile = map_data.get_tile(origin)
start_tile.is_in_view = true
_fov = [start_tile]
for i in 8:
_cast_light(map_data, origin.x, origin.y, radius, 1, 1.0, 0.0, multipliers[0][i], multipliers[1][i], multipliers[2][i], multipliers[3][i])
func _clear_fov() -> void:
for tile in _fov:
tile.is_in_view = false
_fov = []
func _cast_light(map_data: MapData, x: int, y: int, radius: int, row: int, start_slope: float, end_slope: float, xx: int, xy: int, yx: int, yy: int) -> void:
if start_slope < end_slope:
return
var next_start_slope: float = start_slope
for i in range(row, radius + 1):
var blocked: bool = false
var dy: int = -i
for dx in range(-i, 1):
var l_slope: float = (dx - 0.5) / (dy + 0.5)
var r_slope: float = (dx + 0.5) / (dy - 0.5)
if start_slope < r_slope:
continue
elif end_slope > l_slope:
break
var sax: int = dx * xx + dy * xy
var say: int = dx * yx + dy * yy
if ((sax < 0 and absi(sax) > x) or (say < 0 and absi(say) > y)):
continue
var ax: int = x + sax
var ay: int = y + say
if ax >= map_data.width or ay >= map_data.height:
continue
var radius2: int = radius * radius
var current_tile: Tile = map_data.get_tile_xy(ax, ay)
if (dx * dx + dy * dy) < radius2:
current_tile.is_in_view = true
_fov.append(current_tile)
if blocked:
if not current_tile.is_transparent():
next_start_slope = r_slope
continue
else:
blocked = false
start_slope = next_start_slope
elif not current_tile.is_transparent():
blocked = true
next_start_slope = r_slope
_cast_light(map_data, x, y, radius, i + 1, start_slope, l_slope, xx, xy, yx, yy)
if blocked:
break
Of note here is a small modification/extension of the original code: the persistent _fov
array. I use it here to simplify calculations a bit. It keeps track of which tiles are in view, so the next time we recalculate the field of view we can just ust the _clear_fov()
function to go through that array and set all the tiles in it as not in view, completely resetting the field of view. Beyond that the update_fov()
function is written in a way that fits the rest of our code, e.g., it has the map_data
as an argument.
Now let’s fit this into the rest of the game. As mentioned, this will be called from the Map, so let’s start there. Open map.gd, and change the variables section as follows:
@export var fov_radius: int = 8
var map_data: MapData
@onready var dungeon_generator: DungeonGenerator = $DungeonGenerator
@onready var field_of_view: FieldOfView = $FieldOfView
We added an exported variable for the field of view radius, and a reference to our FieldOfView node. Now also add the following function:
func update_fov(player_position: Vector2i) -> void:
field_of_view.update_fov(map_data, player_position, fov_radius)
So we expect the position of the player (from where we calculate the view), and relay that to the FieldOfView node, supplying the necessary arguments. This means we will need to call that function from game.gd. In that script, 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)
if player.grid_position != previous_player_position:
map.update_fov(player.grid_position)
After the player’s action, we update the field of view. However, this only works for updates. We still need an initial field of view for the first turn. So we add another update to the end of the _ready()
function:
@onready var camera: Camera2D = $Camera2D
func _ready() -> void:
player = Entity.new(Vector2i.ZERO, player_definition)
remove_child(camera)
player.add_child(camera)
entities.add_child(player)
map.generate(player)
map.update_fov(player.grid_position)
Now we have an initial field of view, and every time an action causes the player to change its position, we update it. On a side note, I also moved the camera variable to a proper @onready
variable. You should now be able to run the project and see that you don’s see everything. You should see your immediate surroundings revealed and illuminated, and once you start moving around you should see how it looks when an area is explored but not in view. Now that we have a dungeon and can explore it, we can move on to placing something in it. So in the next part we will look and placing some enemies in the dungeon. You can find that part here: https://selinadev.github.io/09-rogueliketutorial-05/