Add the name
and blocks_movement
attributes to the Entity
class in /game/entity.py
:
y: int,
char: str,
color: Tuple[int, int, int],
+ name: str,
+ blocks_movement: bool = True,
):
...
self.char = char
self.color = color
+ self.name = name
+ self.blocks_movement = blocks_movement
Since the added name
parameter is not optional the current instantiations of Entity
will break.
Mypy can detect all places where a created Entity
must have a name
parameter added.
Add a name
to the player entity in /main.py
:
engine=engine,
)
- engine.player = game.entity.Entity(engine.game_map, *engine.game_map.enter_xy, "@", (255, 255, 255))
+ engine.player = game.entity.Entity(
+ engine.game_map, *engine.game_map.enter_xy, char="@", color=(255, 255, 255), name="Player"
+ )
engine.update_fov()
It will be common to search for if an entity is blocking a specific position.
We add a method to do this to GameMap
in /game/game_map.py
:
from __future__ import annotations
-from typing import Set
+from typing import Optional, Set
import numpy as np
...
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 get_blocking_entity_at(self, x: int, y: int) -> Optional[game.entity.Entity]:
+ """Returns an entity that blocks the position at x,y if one exists, otherwise returns None."""
+ for entity in self.entities:
+ if entity.blocks_movement and entity.x == x and entity.y == y:
+ return entity
+
+ return None
+
def in_bounds(self, x: int, y: int) -> bool:
This is a basic implementation to check if a position is occupied by a blocking entity. A Spatial Partition would scale better, but for now the tutorial will not be using an excessive amount of entities.
Next a function is added to /game/procgen.py
to handle the placement of entities.
def intersects(self, other: RectangularRoom) -> bool:
return self.x1 <= other.x2 and self.x2 >= other.x1 and self.y1 <= other.y2 and self.y2 >= other.y1
+def place_entities(room: RectangularRoom, dungeon: game.game_map.GameMap, maximum_monsters: int) -> None:
+ rng = dungeon.engine.rng
+ number_of_monsters = rng.randint(0, maximum_monsters)
+
+ for _ in range(number_of_monsters):
+ x = rng.randint(room.x1 + 1, room.x2 - 1)
+ y = rng.randint(room.y1 + 1, room.y2 - 1)
+
+ if dungeon.get_blocking_entity_at(x, y):
+ continue
+ if (x, y) == dungeon.enter_xy:
+ continue
+
+ if rng.random() < 0.8:
+ game.entity.Entity(dungeon, x, y, char="o", color=(63, 127, 63), name="Orc")
+ else:
+ game.entity.Entity(dungeon, x, y, char="T", color=(0, 127, 0), name="Troll")
+
+
def tunnel_between(
This attempts to place a random monster but will give up if the location would overlap with the dungeon entrance or another monster.
place_entities
must be called from the generate_dungeon
function in /game/procgen.py
:
room_max_size: int,
map_width: int,
map_height: int,
+ max_monsters_per_room: int,
engine: game.engine.Engine,
) -> game.game_map.GameMap:
...
for x, y in tunnel_between(engine, rooms[-1].center, new_room.center):
dungeon.tiles[x, y] = FLOOR
+ place_entities(new_room, dungeon, max_monsters_per_room)
+
# Finally, append the new room to the list.
rooms.append(new_room)
We then add a max_monsters_per_room
config value in /main.py
:
room_max_size = 10
room_min_size = 6
max_rooms = 30
+ max_monsters_per_room = 2
tileset = tcod.tileset.load_tilesheet("data/dejavu16x16_gs_tc.png", 32, 8, tcod.tileset.CHARMAP_TCOD)
...
room_max_size=room_max_size,
map_width=map_width,
map_height=map_height,
+ max_monsters_per_room=max_monsters_per_room,
engine=engine,
)
Finally we can have entities block movement in the Move
action at /game/actions.py
by adding a condition:
if not self.engine.game_map.tiles[dest_x, dest_y]:
return # Destination is blocked by a tile.
+ if self.engine.game_map.get_blocking_entity_at(dest_x, dest_y):
+ return # Destination is blocked by an entity.
self.entity.x, self.entity.y = dest_x, dest_y
At this point enemies should spawn in the dungeon which can block the players movement.
To make it easier to add more actions an ActionWithDirection
class is added.
This takes the __init__
method from Move
so that all subclasses of ActionWithDirection
can have a direction like Move
has.
Move
has its __init__
removed and becomes a subclass of ActionWithDirection
.
Move
keeps its perform
method.
Add these changes to /game/actions.py
:
-class Move(Action):
+class ActionWithDirection(Action):
def __init__(self, entity: game.entity.Entity, dx: int, dy: int):
super().__init__(entity)
self.dx = dx
self.dy = dy
+ def perform(self) -> None:
+ raise NotImplementedError()
+
+
+class Move(ActionWithDirection):
def perform(self) -> None:
We now use the ActionWithDirection
class to add two actions to the end of /game/actions.py
:
... # /game/actions.py (continued)
class Melee(ActionWithDirection):
def perform(self) -> None:
dest_x = self.entity.x + self.dx
dest_y = self.entity.y + self.dy
target = self.engine.game_map.get_blocking_entity_at(dest_x, dest_y)
if not target:
return # No entity to attack.
print(f"You kick the {target.name}, much to its annoyance!")
class Bump(ActionWithDirection):
def perform(self) -> None:
dest_x = self.entity.x + self.dx
dest_y = self.entity.y + self.dy
if self.engine.game_map.get_blocking_entity_at(dest_x, dest_y):
return Melee(self.entity, self.dx, self.dy).perform()
else:
return Move(self.entity, self.dx, self.dy).perform()
In /game/input_handlers.py
the Move
action is replaced with Bump
:
if key in MOVE_KEYS:
dx, dy = MOVE_KEYS[key]
- return game.actions.Move(self.engine.player, dx=dx, dy=dy)
+ return game.actions.Bump(self.engine.player, dx=dx, dy=dy)
elif key == tcod.event.K_ESCAPE:
raise SystemExit(0)
Now you can interact with enemies in a simple extendable way.
Instead of using print
everywhere the standard logging module will be used for output that is not in-game.
Enable logging at the debug level in /main.py
:
#!/usr/bin/env python3
+import logging
import random
...
if __name__ == "__main__":
+ if __debug__:
+ logging.basicConfig(level=logging.DEBUG)
main()
Then add a logger to /game/engine.py
.
We also add a handle_enemy_turns
function to Engine
and log the results.
from __future__ import annotations
+import logging
import random
import tcod
...
import game.entity
import game.game_map
+logger = logging.getLogger(__name__)
+
class Engine:
game_map: game.game_map.GameMap
player: game.entity.Entity
rng: random.Random
+ def handle_enemy_turns(self) -> None:
+ logger.info("Enemy turn.")
+ for entity in self.game_map.entities - {self.player}:
+ logger.info(f"The {entity.name} wonders when it will get to take a real turn.")
+
def update_fov(self) -> None:
You add logger = logging.getLogger(__name__)
to every module which you plan on logging.
self.game_map.entities - {self.player}
makes a copy of the entities
set with the player removed.
It is important that a copy is iterated over, since later steps will modify the set during the loop.
Add a call to handle_enemy_turns
in /game/input_handlers.py
.
def handle_action(self, action: game.actions.Action) -> EventHandler:
"""Handle actions returned from event methods."""
action.perform()
+ self.engine.handle_enemy_turns()
self.engine.update_fov()
return self
The order of these functions is important.
action.perform
always has to be first, and self.engine.update_fov
has to be right before we return control over to the player.
You can see the current progress of this code in its entirety here.
Part 6 is still in development.