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, tcod makes that easy!
When walking around the dungeon, there will essentially be three “states” a tile can be in, relating to our field of view.
- Visible
- Not visible
- Not visible, but previously seen
What this means is that we should draw the “visible” tiles as well as the “not visible, but previously seen” ones to the screen, but differentiate them somehow. The “not visible” tiles can simply be drawn as an empty tile, with the color black, gray, or whatever you want to use.
In order to differentiate between these tiles, we’ll need two new Numpy arrays: One to keep track of the tiles that are currently visible, and another to keep track of all the tiles that our character has seen before. Add the two arrays to GameMap
like this:
class GameMap:
def __init__(self, width: int, height: int):
self.width, self.height = width, height
self.tiles = np.full((width, height), fill_value=tile_types.wall, order="F")
+ self.visible = np.full((width, height), fill_value=False, order="F") # Tiles the player can currently see
+ self.explored = np.full((width, height), fill_value=False, order="F") # Tiles the player has seen before
class GameMap:
def __init__(self, width: int, height: int):
self.width, self.height = width, height
self.tiles = np.full((width, height), fill_value=tile_types.wall, order="F")
self.visible = np.full((width, height), fill_value=False, order="F") # Tiles the player can currently see
self.explored = np.full((width, height), fill_value=False, order="F") # Tiles the player has seen before
We create two arrays, visible
and explored
, and fill them with the value False
. In a moment, we’ll create a function that will update these arrays based on what’s in the field of view.
Let’s turn our attention back to the tile types. Remember when we specified the “walkable”, “transparent”, and “dark” attributes? We called it “dark” because it’s what the tile will look like when its not in the field of view, but what about when it is?
For that, we’ll want a new graphic_dt
in the tile_dt
type, called light
. We can add that by modifying tile_types.py
like this:
tile_dt = np.dtype(
[
("walkable", np.bool), # True if this tile can be walked over.
("transparent", np.bool), # True if this tile doesn't block FOV.
("dark", graphic_dt), # Graphics for when this tile is not in FOV.
+ ("light", graphic_dt), # Graphics for when the tile is in FOV.
]
)
def new_tile(
*, # Enforce the use of keywords, so that parameter order doesn't matter.
walkable: int,
transparent: int,
dark: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]],
+ light: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]],
) -> np.ndarray:
"""Helper function for defining individual tile types """
- return np.array((walkable, transparent, dark), dtype=tile_dt)
+ return np.array((walkable, transparent, dark, light), dtype=tile_dt)
+# SHROUD represents unexplored, unseen tiles
+SHROUD = np.array((ord(" "), (255, 255, 255), (0, 0, 0)), dtype=graphic_dt)
floor = new_tile(
- walkable=True, transparent=True, dark=(ord(" "), (255, 255, 255), (50, 50, 150)),
+ walkable=True,
+ transparent=True,
+ dark=(ord(" "), (255, 255, 255), (50, 50, 150)),
+ light=(ord(" "), (255, 255, 255), (200, 180, 50)),
)
wall = new_tile(
- walkable=False, transparent=False, dark=(ord(" "), (255, 255, 255), (0, 0, 100)),
+ walkable=False,
+ transparent=False,
+ dark=(ord(" "), (255, 255, 255), (0, 0, 100)),
+ light=(ord(" "), (255, 255, 255), (130, 110, 50)),
)
tile_dt = np.dtype( [ ("walkable", np.bool), # True if this tile can be walked over. ("transparent", np.bool), # True if this tile doesn't block FOV. ("dark", graphic_dt), # Graphics for when this tile is not in FOV. ("light", graphic_dt), # Graphics for when the tile is in FOV. ] ) def new_tile( *, # Enforce the use of keywords, so that parameter order doesn't matter. walkable: int, transparent: int, dark: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]], light: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]], ) -> np.ndarray: """Helper function for defining individual tile types """ return np.array((walkable, transparent, dark), dtype=tile_dt) return np.array((walkable, transparent, dark, light), dtype=tile_dt) # SHROUD represents unexplored, unseen tiles SHROUD = np.array((ord(" "), (255, 255, 255), (0, 0, 0)), dtype=graphic_dt) floor = new_tile( walkable=True, transparent=True, dark=(ord(" "), (255, 255, 255), (50, 50, 150)), walkable=True, transparent=True, dark=(ord(" "), (255, 255, 255), (50, 50, 150)), light=(ord(" "), (255, 255, 255), (200, 180, 50)), ) wall = new_tile( walkable=False, transparent=False, dark=(ord(" "), (255, 255, 255), (0, 0, 100)), walkable=False, transparent=False, dark=(ord(" "), (255, 255, 255), (0, 0, 100)), light=(ord(" "), (255, 255, 255), (130, 110, 50)), )
Let’s go through the new additions.
tile_dt = np.dtype(
[
("walkable", np.bool), # True if this tile can be walked over.
("transparent", np.bool), # True if this tile doesn't block FOV.
("dark", graphic_dt), # Graphics for when this tile is not in FOV.
("light", graphic_dt), # Graphics for when the tile is in FOV.
]
)
We’re adding a new graphic_dt
to the tile_dt
that we use to define our tiles. light
will hold the information about what our tile looks like when it’s in the field of view.
def new_tile(
*, # Enforce the use of keywords, so that parameter order doesn't matter.
walkable: int,
transparent: int,
dark: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]],
light: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]],
) -> np.ndarray:
"""Helper function for defining individual tile types """
return np.array((walkable, transparent, dark, light), dtype=tile_dt)
We’ve modified the new_tile
function to account for the new light
attribute. light
works the same as dark
.
# SHROUD represents unexplored, unseen tiles
SHROUD = np.array((ord(" "), (255, 255, 255), (0, 0, 0)), dtype=graphic_dt)
SHROUD
is what we’ll use for when a tile is neither in view nor has been “explored”. It’s set to just draw a black tile.
floor = new_tile(
walkable=True,
transparent=True,
dark=(ord(" "), (255, 255, 255), (50, 50, 150)),
light=(ord(" "), (255, 255, 255), (200, 180, 50)),
)
wall = new_tile(
walkable=False,
transparent=False,
dark=(ord(" "), (255, 255, 255), (0, 0, 100)),
light=(ord(" "), (255, 255, 255), (130, 110, 50)),
)
Finally, we add light
to both the floor
and wall
tiles. We also modify the functions to fit a bit better on the screen, adding new lines after each argument. This is just for the sake of readability.
light
in both cases is set to a brighter color, so that when we draw the field of view to the screen, the player can easily differentiate between what’s in view and what’s not. As usual, feel free to play with the color schemes to match whatever you might have in mind.
With all that in place, we need to modify the way GameMap
draws itself to the screen.
class GameMap:
...
def render(self, console: Console) -> None:
- console.tiles_rgb[0:self.width, 0:self.height] = self.tiles["dark"]
+ """
+ Renders the map.
+
+ If a tile is in the "visible" array, then draw it with the "light" colors.
+ If it isn't, but it's in the "explored" array, then draw it with the "dark" colors.
+ Otherwise, the default is "SHROUD".
+ """
+ console.tiles_rgb[0:self.width, 0:self.height] = np.select(
+ condlist=[self.visible, self.explored],
+ choicelist=[self.tiles["light"], self.tiles["dark"]],
+ default=tile_types.SHROUD
+ )
class GameMap: ... def render(self, console: Console) -> None: console.tiles_rgb[0:self.width, 0:self.height] = self.tiles["dark"] """ Renders the map. If a tile is in the "visible" array, then draw it with the "light" colors. If it isn't, but it's in the "explored" array, then draw it with the "dark" colors. Otherwise, the default is "SHROUD". """ console.tiles_rgb[0:self.width, 0:self.height] = np.select( condlist=[self.visible, self.explored], choicelist=[self.tiles["light"], self.tiles["dark"]], default=tile_types.SHROUD )
The first part of the statement, console.tiles_rgb[0:self.width, 0:self.height]
, hasn’t changed. But instead of just setting it to self.tiles["dark"]
, we’re using np.select
.
np.select
allows us to conditionally draw the tiles we want, based on what’s specified in condlist
. Since we’re passing [self.visible, self.explored]
, it will check if the tile being drawn is either visible, then explored. If it’s visible, it uses the first value in choicelist
, in this case, self.tiles["light"]
. If it’s not visible, but explored, then we draw self.tiles["dark"]
. If neither is true, we use the default
argument, which is just the SHROUD
we defined earlier.
If you run the project now, none of the tiles will be drawn to the screen. This is because we need a way to actually modify the visible
and explored
tiles. Let’s modify Engine
to do just that:
...
from tcod.context import Context
from tcod.console import Console
+from tcod.map import compute_fov
from entity import Entity
...
class Engine:
def __init__(self, entities: Set[Entity], event_handler: EventHandler, game_map: GameMap, player: Entity):
self.entities = entities
self.event_handler = event_handler
self.game_map = game_map
self.player = player
+ self.update_fov()
def handle_events(self, events: Iterable[Any]) -> None:
for event in events:
action = self.event_handler.dispatch(event)
if action is None:
continue
action.perform(self, self.player)
+ self.update_fov() # Update the FOV before the players next action.
+ def update_fov(self) -> None:
+ """Recompute the visible area based on the players point of view."""
+ self.game_map.visible[:] = compute_fov(
+ self.game_map.tiles["transparent"],
+ (self.player.x, self.player.y),
+ radius=8,
+ )
+ # If a tile is "visible" it should be added to "explored".
+ self.game_map.explored |= self.game_map.visible
def render(self, console: Console, context: Context) -> None:
self.game_map.render(console)
for entity in self.entities:
- console.print(entity.x, entity.y, entity.char, fg=entity.color)
+ # Only print entities that are in the FOV
+ if self.game_map.visible[entity.x, entity.y]:
+ console.print(entity.x, entity.y, entity.char, fg=entity.color)
context.present(console)
console.clear()
... from tcod.context import Context from tcod.console import Console from tcod.map import compute_fov from entity import Entity ... class Engine: def __init__(self, entities: Set[Entity], event_handler: EventHandler, game_map: GameMap, player: Entity): self.entities = entities self.event_handler = event_handler self.game_map = game_map self.player = player self.update_fov() def handle_events(self, events: Iterable[Any]) -> None: for event in events: action = self.event_handler.dispatch(event) if action is None: continue action.perform(self, self.player) self.update_fov() # Update the FOV before the players next action. def update_fov(self) -> None: """Recompute the visible area based on the players point of view.""" self.game_map.visible[:] = compute_fov( self.game_map.tiles["transparent"], (self.player.x, self.player.y), radius=8, ) # If a tile is "visible" it should be added to "explored". self.game_map.explored |= self.game_map.visible def render(self, console: Console, context: Context) -> None: self.game_map.render(console) for entity in self.entities: console.print(entity.x, entity.y, entity.char, fg=entity.color) # Only print entities that are in the FOV if self.game_map.visible[entity.x, entity.y]: console.print(entity.x, entity.y, entity.char, fg=entity.color) context.present(console) console.clear()
The most important part of our additions is the update_fov
function.
def update_fov(self) -> None:
"""Recompute the visible area based on the players point of view."""
self.game_map.visible[:] = compute_fov(
self.game_map.tiles["transparent"],
(self.player.x, self.player.y),
radius=8,
)
# If a tile is "visible" it should be added to "explored".
self.game_map.explored |= self.game_map.visible
We’re setting the game_map
’s visible
tiles to equal the result of the compute_fov
. We’re giving compute_fov
three arguments, which it uses to compute our field of view.
transparency
: This is the first argument, which we’re passingself.game_map.tiles["transparent"]
.transparency
takes a 2D numpy array, and considers any non-zero values to be transparent. This is the array it uses to calculate the field of view.pov
: The origin point for the field of view, which is a 2D index. We use the player’s x and y position here.radius
: How far the FOV extends.
There’s more that this function can do, including not lighting up walls, and using different algorithms to calculate the FOV. If you’re interested, you can find the documentation here.
The line self.game_map.explored |= self.game_map.visible
sets the explored
array to include everything in the visible
array, plus whatever it already had. This means that any tile the player can see, the player has also “explored.”
That’s all we need to do to update our field of view. Notice that we call the function when we initialize the Engine
class, so that the field of view is created before the player can move, and after handling an action, so that whenever the player does move, the field of view will be updated.
Lastly, we modify the part that draws the entities, so that only entities in the field of view are drawn.
Run the project now, and you’ll see something like this:
It’s hard to believe, but that’s all we need to do for a functioning field of view!
This chapter was a shorter one, but we’ve accomplished quite a lot. Our dungeon feels a lot more mysterious, and in coming chapters, it will get a lot more dangerous.
If you want to see the code so far in its entirety, click here.