Part 4 - Field of View
We have a dungeon now, and we can move about it freely. But are we really exploring the dungeon if we can just see it all from the beginning?
Most roguelikes (not all!) only let you see within a certain range of your character, and ours will be no different. We need to implement a way to calculate the “Field of View” for our adventurer, and fortunately, libtcod makes that easy!
We’ll need to define a few variables before we get started. Add these in the same section as our screen and map variables:
...
max_rooms = 30
+ fov_algorithm = 0
+ fov_light_walls = True
+ fov_radius = 10
colors = {
...
...
max_rooms = 30
fov_algorithm = 0
fov_light_walls = True
fov_radius = 10
colors = {
...
‘0’ is just the default algorithm that libtcod uses; it has more, and I
encourage you to experiment with them later. fov_light_walls
just
tells us whether or not to ’light up’ the walls we see; you can change
it if you don’t like the way it looks. fov_radius
is somewhat obvious,
it tells us how far we can actually see.
We also need to update the colors
dictionary, because now we need two
more colors for the ’light’ versions of both walls and floors. Walls and
floors in our fov will be ’lit’, distinguishing them from the ones
outside what we can see.
colors = {
'dark_wall': libtcod.Color(0, 0, 100),
'dark_ground': libtcod.Color(50, 50, 150),
'light_wall': libtcod.Color(130, 110, 50),
'light_ground': libtcod.Color(200, 180, 50)
}
* Don’t forget to add the comma after the ‘dark_ground’ entry; Python will throw an error without it!
If you don’t like these colors, feel free to change them to your liking.
The thing about field of view is that it doesn’t need to be computed
every turn. In fact, it would be quite a waste to do so! We really only
need change it when the player moves. Attacking, using an item, or just
standing still for a turn doesn’t alter FOV. We can handle this by
having a boolean variable, which we’ll call fov_recompute
, which tells
us if we need to recompute. We can define it somewhere above our game
loop (I put mine right after the map initialization).
...
game_map.make_map(max_rooms, room_min_size, room_max_size, map_width, map_height, player)
+ fov_recompute = True
key = libtcod.Key()
...
...
game_map.make_map(max_rooms, room_min_size, room_max_size, map_width, map_height, player)
fov_recompute = True
key = libtcod.Key()
...
It’s True
by default, because we have to compute it right when the
game starts.
Now let’s initialize our field of view, which we’ll store in a variable
called fov_map
. fov_map
will need to not only be initialized, but
recomputed when the player moves. Let’s keep these functions out of
engine.py
, and instead, put them in a new file, called
fov_functions.py
. In that file, put the following:
import tcod as libtcod
def initialize_fov(game_map):
fov_map = libtcod.map_new(game_map.width, game_map.height)
for y in range(game_map.height):
for x in range(game_map.width):
libtcod.map_set_properties(fov_map, x, y, not game_map.tiles[x][y].block_sight,
not game_map.tiles[x][y].blocked)
return fov_map
Call this function in engine.py
and store the result in fov_map
.
...
fov_recompute = True
+ fov_map = initialize_fov(game_map)
key = libtcod.Key()
...
...
fov_recompute = True
fov_map = initialize_fov(game_map)
key = libtcod.Key()
...
Don’t forget the import for the function.
...
from entity import Entity
+from fov_functions import initialize_fov
from input_handlers import handle_keys
...
...
from entity import Entity
from fov_functions import initialize_fov
from input_handlers import handle_keys
...
While we’re at it, let’s modify the section where we move the player to
set fov_recompute
to True.
...
player.move(dx, dy)
+ fov_recompute = True
...
player.move(dx, dy)
fov_recompute = True
But where does the recompute actually happen? For that, let’s add a
new function to fov_functions.py
to do the recomputing. The recompute
function will modify the fov_map
variable based on where the player
is, what the radius for lighting is, whether or not to light the walls,
and what algorithm we’re using.
That’s a lot of variables, but consider this: in your game, you’ll
probably pick one FOV algorithm and stick with it. Also, whether or not
you light the walls probably won’t change during the course of the game.
So why not create our function with default arguments? That way, we can
pass the light_walls
and algorithm
variables if we want to, but if
not, a default is chosen. That looks like this:
def initialize_fov(game_map):
...
+def recompute_fov(fov_map, x, y, radius, light_walls=True, algorithm=0):
+ libtcod.map_compute_fov(fov_map, x, y, radius, light_walls, algorithm)
def initialize_fov(game_map):
...
def recompute_fov(fov_map, x, y, radius, light_walls=True, algorithm=0):
libtcod.map_compute_fov(fov_map, x, y, radius, light_walls, algorithm)
So when we call the function, we have to give fov_map, x, y, and
radius, but we don’t necessarily have to pass in light_walls or
algorithm. In my engine.py
file, I’ll pass them in anyway, but you
don’t have to if you don’t want to (you can also change the defaults I
gave above to whatever you prefer).
Whatever you decide, put your fov recomputation in engine.py
like so:
...
libtcod.sys_check_for_event(libtcod.EVENT_KEY_PRESS, key, mouse)
+ if fov_recompute:
+ recompute_fov(fov_map, player.x, player.y, fov_radius, fov_light_walls, fov_algorithm)
render_all(con, entities, game_map, screen_width, screen_height, colors)
...
...
libtcod.sys_check_for_event(libtcod.EVENT_KEY_PRESS, key, mouse)
if fov_recompute:
recompute_fov(fov_map, player.x, player.y, fov_radius, fov_light_walls, fov_algorithm)
render_all(con, entities, game_map, screen_width, screen_height, colors)
...
… And, of course, we have to import that function:
...
from entity import Entity
-from fov_functions import initialize_fov
+from fov_functions import initialize_fov, recompute_fov
from input_handlers import handle_keys
...
...
from entity import Entity
from fov_functions import initialize_fov, recompute_fov
from input_handlers import handle_keys
...
Now, after the player successfully moves, the field of view will be set to recalculate, but it won’t if we do something else.
With our field of view calculated, we need to actually display it (if
you run the code now, you won’t notice any visible change). Open up
render_functions.py
and modify the render_all
function like
this:
def render_all(con, entities, game_map, screen_width, screen_height, colors):
+def render_all(con, entities, game_map, fov_map, fov_recompute, screen_width, screen_height, colors):
- for y in range(game_map.height):
+ if fov_recompute:
- for x in range(game_map.width):
+ for y in range(game_map.height):
- wall = game_map.tiles[x][y].block_sight
-
- if wall:
- libtcod.console_set_char_background(con, x, y, colors.get('dark_wall'), libtcod.BKGND_SET)
- else:
- libtcod.console_set_char_background(con, x, y, colors.get('dark_ground'), libtcod.BKGND_SET)
+ for x in range(game_map.width):
+ visible = libtcod.map_is_in_fov(fov_map, x, y)
+ wall = game_map.tiles[x][y].block_sight
+ if visible:
+ if wall:
+ libtcod.console_set_char_background(con, x, y, colors.get('light_wall'), libtcod.BKGND_SET)
+ else:
+ libtcod.console_set_char_background(con, x, y, colors.get('light_ground'), libtcod.BKGND_SET)
+ else:
+ if wall:
+ libtcod.console_set_char_background(con, x, y, colors.get('dark_wall'), libtcod.BKGND_SET)
+ else:
+ libtcod.console_set_char_background(con, x, y, colors.get('dark_ground'), libtcod.BKGND_SET)
# Draw all entities in the list
for entity in entities:
draw_entity(con, entity)
libtcod.console_blit(con, 0, 0, screen_width, screen_height, 0, 0, 0)
def render_all(con, entities, game_map, screen_width, screen_height, colors): def render_all(con, entities, game_map, fov_map, fov_recompute, screen_width, screen_height, colors): if fov_recompute: for y in range(game_map.height): for x in range(game_map.width): visible = libtcod.map_is_in_fov(fov_map, x, y) wall = game_map.tiles[x][y].block_sight if visible: if wall: libtcod.console_set_char_background(con, x, y, colors.get('light_wall'), libtcod.BKGND_SET) else: libtcod.console_set_char_background(con, x, y, colors.get('light_ground'), libtcod.BKGND_SET) else: if wall: libtcod.console_set_char_background(con, x, y, colors.get('dark_wall'), libtcod.BKGND_SET) else: libtcod.console_set_char_background(con, x, y, colors.get('dark_ground'), libtcod.BKGND_SET) # Draw all entities in the list for entity in entities: draw_entity(con, entity) libtcod.console_blit(con, 0, 0, screen_width, screen_height, 0, 0, 0)
* Note: Blue denotes lines that are exactly the same as before, expect
for their indentation. The if statements for fov_recompute
and
visible
force certain lines to be indented farther than they were
before. Remember, this is Python, indentation matters!
Now our render_all
function will display tiles differently, depending
on if they’re in our field of view or not. If a tile falls in the
fov_map
, we draw it with the ’light’ colors, and if not, we draw the
‘dark’ version.
The definition of render_all
has changed, so be sure to update it in
engine.py
. While we’re at it, let’s set fov_recompute
to False
after we call render_all
.
...
- render_all(con, entities, game_map, screen_width, screen_height, colors)
+ render_all(con, entities, game_map, fov_map, fov_recompute, screen_width, screen_height, colors)
+
+ fov_recompute = False
... render_all(con, entities, game_map, screen_width, screen_height, colors) render_all(con, entities, game_map, fov_map, fov_recompute, screen_width, screen_height, colors) fov_recompute = False
Run the project now. The player’s field of view is now visible! But, despite being able to “see” the FOV, it still doesn’t really do anything. We can still see the entire map, along with our NPC. Luckily, the changes we have to make to fix this are fairly minimal.
Let’s start with our NPC. We should just be able to modify our
draw_entity
function to account for the field of view, which would
solve our problem.
def draw_entity(con, entity):
+def draw_entity(con, entity, fov_map):
- libtcod.console_set_default_foreground(con, entity.color)
- libtcod.console_put_char(con, entity.x, entity.y, entity.char, libtcod.BKGND_NONE)
+ if libtcod.map_is_in_fov(fov_map, entity.x, entity.y):
+ libtcod.console_set_default_foreground(con, entity.color)
+ libtcod.console_put_char(con, entity.x, entity.y, entity.char, libtcod.BKGND_NONE)
def draw_entity(con, entity): def draw_entity(con, entity, fov_map): if libtcod.map_is_in_fov(fov_map, entity.x, entity.y): libtcod.console_set_default_foreground(con, entity.color) libtcod.console_put_char(con, entity.x, entity.y, entity.char, libtcod.BKGND_NONE)
* Again, the blue means the line is the same as before, except the indentation has changed.
Also be sure to update the part where we call the function:
for entity in entities:
- draw_entity(con, entity)
+ draw_entity(con, entity, fov_map)
for entity in entities: draw_entity(con, entity) draw_entity(con, entity, fov_map)
Run the project again, and you won’t see the NPC unless it’s in your field of view.
Now for the map. In traditional roguelikes, your character can only see
whats inside its field of view, but it will “remember” areas that were
explored previously. We can accomplish this effect by adding a variable
called explored
to our Tile
class. Modify the __init__
function in
Tile
to include this new variable:
...
self.block_sight = block_sight
+ self.explored = False
...
self.block_sight = block_sight
self.explored = False
This new variable needs to be taken into account in our render_all
function. Let’s do that now. We’ll only draw the tiles outside of our
field of view if we’ve explored them previously. Also, any tiles that
are in our field of view, we’ll mark as ’explored’.
...
visible = libtcod.map_is_in_fov(fov_map, x, y)
wall = game_map.tiles[x][y].block_sight
if visible:
if wall:
libtcod.console_set_char_background(con, x, y, colors.get('light_wall'), libtcod.BKGND_SET)
else:
libtcod.console_set_char_background(con, x, y, colors.get('light_ground'), libtcod.BKGND_SET)
+ game_map.tiles[x][y].explored = True
- else:
+ elif game_map.tiles[x][y].explored:
if wall:
libtcod.console_set_char_background(con, x, y, colors.get('dark_wall'), libtcod.BKGND_SET)
else:
libtcod.console_set_char_background(con, x, y, colors.get('dark_ground'), libtcod.BKGND_SET)
...
... visible = libtcod.map_is_in_fov(fov_map, x, y) wall = game_map.tiles[x][y].block_sight if visible: if wall: libtcod.console_set_char_background(con, x, y, colors.get('light_wall'), libtcod.BKGND_SET) else: libtcod.console_set_char_background(con, x, y, colors.get('light_ground'), libtcod.BKGND_SET) game_map.tiles[x][y].explored = True else: elif game_map.tiles[x][y].explored: if wall: libtcod.console_set_char_background(con, x, y, colors.get('dark_wall'), libtcod.BKGND_SET) else: libtcod.console_set_char_background(con, x, y, colors.get('dark_ground'), libtcod.BKGND_SET) ...
We now have a real, explorable dungeon! True, there may not be much in there right now, but this was a major step to a working game. In the next few parts, we’ll fill the dungeon with some evil(?) monsters to punch.
If you want to see the code so far in its entirety, click here.