Part 4 - Field of view

Currently the dungeon is fully visible at all times. To add a sense of exploration we will begin tracking which tiles are explored or in view at all times.

First is to add two new arrays to GameMap. visible is a boolean array of all currently visible tiles. explored is a boolean array of all tiles that have ever been seen. Add these changes to /game/game_map.py:

         self.entities: Set[game.entity.Entity] = set()
         self.enter_xy = (width // 2, height // 2)  # Entrance coordinates.
 
+        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
+
     def in_bounds(self, x: int, y: int) -> bool:

An alternative way to create the above arrays is with np.zeros((width, height), dtype=bool, order="F").

Python-tcod ports Libtcod’s FOV algorithms with the tcod.map.compute_fov function. This function takes a 2D array where the zero values are opaque walls, and all non-zero tiles are transparent. Because we’ve already used 0 for walls and 1 for floors the tiles of GameMap.tiles can be passed directory to this function. The function returns which tiles are visible as a boolean array.

We now add a method to Engine to handle updating the FOV. Make the following changes to /game/engine.py:

 import random
 
+import tcod
+
 import game.entity
 ...
     player: game.entity.Entity
     rng: random.Random
+
+    def update_fov(self) -> None:
+        """Recompute the visible area based on the players point of view."""
+        self.game_map.visible[:] = tcod.map.compute_fov(
+            self.game_map.tiles,
+            (self.player.x, self.player.y),
+            radius=8,
+            algorithm=tcod.FOV_SYMMETRIC_SHADOWCAST,
+        )
+        # If a tile is currently "visible" it will also be marked as "explored".
+        self.game_map.explored |= self.game_map.visible

We must call this function after every action is performed in /game/input_handlers.py:

     def handle_action(self, action: game.actions.Action) -> EventHandler:
         """Handle actions returned from event methods."""
         action.perform()
+        self.engine.update_fov()
         return self

The above will only update the visible area after the player moves. Because of that we also need to update it before the first turn, so another call gets added to /main.py after the map and player is setup:

     engine.player = game.entity.Entity(engine.game_map, *engine.game_map.enter_xy, "@", (255, 255, 255))
+    engine.update_fov()

     event_handler = game.input_handlers.EventHandler(engine)

Now it is time to modify the rendering function. We need a default graphic for the unexplored area so a NumpPy scalar called SHROUD (a thing that obscures) is made, which is a blank black tile. Tiles in the current view will have the graphics stay the same as before. We use tile_graphics[gamemap.tiles] as before to generate the entire array but will render only the visible area by the end, this will be called light. A copy of light is made called dark and this array modified to be darker with the foreground at half brightness and the background at 1/8th brightness.

Now np.select is used to determine which If a value on gamemap.visible is True then the value at light will be shown, then gamemap.explored is checked and if that is True then dark is shown, otherwise SHROUD is used. Because SHROUD is a scalar it is broadcast along the entire array. The default parameter could have been another 2D array, or the arrays in condlist could have been scalars like SHROUD, the important part is that these fit the shape of console.rgb[0 : gamemap.width, 0 : gamemap.height] acorind to the broadcasting rules.

The final step is the entities, any entity that is not on a visible tile is not printed to the screen.

With this in mind we can edit /game/rendering.py:

+# SHROUD represents unexplored, unseen tiles
+SHROUD = np.array((ord(" "), (255, 255, 255), (0, 0, 0)), dtype=tcod.console.rgb_graphic)
+

 def render_map(console: tcod.Console, gamemap: game.game_map.GameMap) -> None:
-    console.rgb[0 : gamemap.width, 0 : gamemap.height] = tile_graphics[gamemap.tiles]
+    # The default graphics are of tiles that are visible.
+    light = tile_graphics[gamemap.tiles]
+
+    # Apply effects to create a darkened map of tile graphics.
+    dark = light.copy()
+    dark["fg"] //= 2
+    dark["bg"] //= 8
+
+    # 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 graphic is "SHROUD".
+    console.rgb[0 : gamemap.width, 0 : gamemap.height] = np.select(
+        condlist=[gamemap.visible, gamemap.explored],
+        choicelist=[light, dark],
+        default=SHROUD,
+    )

     for entity in gamemap.entities:
+        if not gamemap.visible[entity.x, entity.y]:
+            continue  # Skip entities that are not in the FOV.
         console.print(entity.x, entity.y, entity.char, fg=entity.color)

You can see the current progress of this code in its entirety here.

Continue to part 5.

Return to the hub.


Project maintained by HexDecimal Hosted on GitHub Pages — Theme by mattgraham