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.

1. Visible
2. Not visible
3. 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),
+       )
+       # 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),
)
# 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),
)
# 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 passing `self.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.

Click here to move on to the next part of this tutorial.