Part 6 - Doing (and taking) some damage


Check your TCOD installation

Before proceeding any further, you’ll want to upgrade to TCOD version 11.15, if you don’t already have it. This version of TCOD was released during the tutorial event, so if you’re following along on a weekly basis, you probably don’t have this version installed!

Refactoring previous code

After parts 1-5 for this tutorial were written, we decided to change a few things around, to hopefully make the codebase a bit cleaner and easier to extend in the future. Unfortunately, this means that code written in previous parts now has to be modified.

I would go back and edit the tutorial text and Github branches to reflect these changes, except for two things:

  1. I don’t have time at the moment. Writing the sections that get published every week is taking all of my time as it is.
  2. It wouldn’t be fair to those who are following this tutorial on a weekly basis.

Someday, when the event is over, the previous parts will be rewritten, and all will be well. But until then, there’s several changes that need to be made before proceeding with Part 6.

I won’t explain all of the changes (again, time is a limiting factor), but here’s the basic ideas:

  • Event handlers will have the handle_events method instead of Engine.
  • The game map will have a reference to Engine, and entities will have a reference to the map.
  • Actions will be initialized with the entity doing the action
  • Because of the above points, Actions will have a reference to the Engine, through Entity->GameMap->Engine

Make the changes to each file, and when you’re finished, verify the project works as it did before.

input_handlers.py

+from __future__ import annotations

-from typing import Optional
+from typing import Optional, TYPE_CHECKING

import tcod.event

from actions import Action, BumpAction, EscapeAction

+if TYPE_CHECKING:
+   from engine import Engine


class EventHandler(tcod.event.EventDispatch[Action]):
+   def __init__(self, engine: Engine):
+       self.engine = engine

+   def handle_events(self) -> None:
+       for event in tcod.event.wait():
+           action = self.dispatch(event)

+           if action is None:
+               continue

+           action.perform()

+           self.engine.handle_enemy_turns()
+           self.engine.update_fov()  # Update the FOV before the players next action.


    def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
        ...
    
    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
        action: Optional[Action] = None

        key = event.sym

+       player = self.engine.player

        if key == tcod.event.K_UP:
-           action = BumpAction(dx=0, dy=-1)
+           action = BumpAction(player, dx=0, dy=-1)
        elif key == tcod.event.K_DOWN:
-           action = BumpAction(dx=0, dy=1)
+           action = BumpAction(player, dx=0, dy=1)
        elif key == tcod.event.K_LEFT:
-           action = BumpAction(dx=-1, dy=0)
+           action = BumpAction(player, dx=-1, dy=0)
        elif key == tcod.event.K_RIGHT:
-           action = BumpAction(dx=1, dy=0)
+           action = BumpAction(player, dx=1, dy=0)

        elif key == tcod.event.K_ESCAPE:
-           action = EscapeAction()
+           action = EscapeAction(player)
from __future__ import annotations

from typing import Optional
from typing import Optional, TYPE_CHECKING

import tcod.event

from actions import Action, BumpAction, EscapeAction

if TYPE_CHECKING:
    from engine import Engine


class EventHandler(tcod.event.EventDispatch[Action]):
    def __init__(self, engine: Engine):
        self.engine = engine

    def handle_events(self) -> None:
        for event in tcod.event.wait():
            action = self.dispatch(event)

            if action is None:
                continue

            action.perform()

            self.engine.handle_enemy_turns()
            self.engine.update_fov()  # Update the FOV before the players next action.


    def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
        ...
    
    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
        action: Optional[Action] = None

        key = event.sym

        player = self.engine.player

        if key == tcod.event.K_UP:
            action = BumpAction(dx=0, dy=-1)
            action = BumpAction(player, dx=0, dy=-1)
        elif key == tcod.event.K_DOWN:
            action = BumpAction(dx=0, dy=1)
            action = BumpAction(player, dx=0, dy=1)
        elif key == tcod.event.K_LEFT:
            action = BumpAction(dx=-1, dy=0)
            action = BumpAction(player, dx=-1, dy=0)
        elif key == tcod.event.K_RIGHT:
            action = BumpAction(dx=1, dy=0)
            action = BumpAction(player, dx=1, dy=0)

        elif key == tcod.event.K_ESCAPE:
            action = EscapeAction()
            action = EscapeAction(player)

actions.py

from __future__ import annotations

+from typing import Optional, Tuple, TYPE_CHECKING
-from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from engine import Engine
    from entity import Entity


class Action:
+   def __init__(self, entity: Entity) -> None:
+       super().__init__()
+       self.entity = entity

+   @property
+   def engine(self) -> Engine:
+       """Return the engine this action belongs to."""
+       return self.entity.gamemap.engine

+   def perform(self) -> None:
-   def perform(self, engine: Engine, entity: Entity) -> None:
        """Perform this action with the objects needed to determine its scope.

+       `self.engine` is the scope this action is being performed in.
-       `engine` is the scope this action is being performed in.

+       `self.entity` is the object performing the action.
-       `entity` is the object performing the action.

        This method must be overridden by Action subclasses.
        """
        raise NotImplementedError()


class EscapeAction(Action):
+   def perform(self) -> None:
-   def perform(self, engine: Engine, entity: Entity) -> None:
        raise SystemExit()



class ActionWithDirection(Action):
+   def __init__(self, entity: Entity, dx: int, dy: int):
+       super().__init__(entity)
-   def __init__(self, dx: int, dy: int):
-       super().__init__()

        self.dx = dx
        self.dy = dy

+   @property
+   def dest_xy(self) -> Tuple[int, int]:
+       """Returns this actions destination."""
+       return self.entity.x + self.dx, self.entity.y + self.dy

+   @property
+   def blocking_entity(self) -> Optional[Entity]:
+       """Return the blocking entity at this actions destination.."""
+       return self.engine.game_map.get_blocking_entity_at_location(*self.dest_xy)

+   def perform(self) -> None:
-   def perform(self, engine: Engine, entity: Entity) -> None:
        raise NotImplementedError()


class MeleeAction(ActionWithDirection):
+   def perform(self) -> None:
+       target = self.blocking_entity
-   def perform(self, engine: Engine, entity: Entity) -> None:
-       dest_x = entity.x + self.dx
-       dest_y = entity.y + self.dy
-       target = engine.game_map.get_blocking_entity_at_location(dest_x, dest_y)
        if not target:
            return  # No entity to attack.

        print(f"You kick the {target.name}, much to its annoyance!")


class MovementAction(ActionWithDirection):
+   def perform(self) -> None:
+       dest_x, dest_y = self.dest_xy
-   def perform(self, engine: Engine, entity: Entity) -> None:
-       dest_x = entity.x + self.dx
-       dest_y = entity.y + self.dy
 
+       if not self.engine.game_map.in_bounds(dest_x, dest_y):
-       if not engine.game_map.in_bounds(dest_x, dest_y):
            return  # Destination is out of bounds.
+       if not self.engine.game_map.tiles["walkable"][dest_x, dest_y]:
-       if not engine.game_map.tiles["walkable"][dest_x, dest_y]:
            return  # Destination is blocked by a tile.
+       if self.engine.game_map.get_blocking_entity_at_location(dest_x, dest_y):
-       if engine.game_map.get_blocking_entity_at_location(dest_x, dest_y):
            return  # Destination is blocked by an entity.
 
+       self.entity.move(self.dx, self.dy)
-       entity.move(self.dx, self.dy)


class BumpAction(ActionWithDirection):
+   def perform(self) -> None:
+       if self.blocking_entity:
+           return MeleeAction(self.entity, self.dx, self.dy).perform()
-   def perform(self, engine: Engine, entity: Entity) -> None:
-       dest_x = entity.x + self.dx
-       dest_y = entity.y + self.dy

-       if engine.game_map.get_blocking_entity_at_location(dest_x, dest_y):
-           return MeleeAction(self.dx, self.dy).perform(engine, entity)
 
        else:
+           return MovementAction(self.entity, self.dx, self.dy).perform()
-           return MovementAction(self.dx, self.dy).perform(engine, entity)
from __future__ import annotations

from typing import Optional, Tuple, TYPE_CHECKING
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from engine import Engine
    from entity import Entity


class Action:
    def __init__(self, entity: Entity) -> None:
        super().__init__()
        self.entity = entity

    @property
    def engine(self) -> Engine:
        """Return the engine this action belongs to."""
        return self.entity.gamemap.engine

    def perform(self) -> None:
    def perform(self, engine: Engine, entity: Entity) -> None:
        """Perform this action with the objects needed to determine its scope.

        `self.engine` is the scope this action is being performed in.
        `engine` is the scope this action is being performed in.

        `self.entity` is the object performing the action.
        `entity` is the object performing the action.

        This method must be overridden by Action subclasses.
        """
        raise NotImplementedError()


class EscapeAction(Action):
    def perform(self) -> None:
    def perform(self, engine: Engine, entity: Entity) -> None:
        raise SystemExit()



class ActionWithDirection(Action):
    def __init__(self, entity: Entity, dx: int, dy: int):
        super().__init__(entity)
    def __init__(self, dx: int, dy: int):
        super().__init__()

        self.dx = dx
        self.dy = dy

    @property
    def dest_xy(self) -> Tuple[int, int]:
        """Returns this actions destination."""
        return self.entity.x + self.dx, self.entity.y + self.dy

    @property
    def blocking_entity(self) -> Optional[Entity]:
        """Return the blocking entity at this actions destination.."""
        return self.engine.game_map.get_blocking_entity_at_location(*self.dest_xy)

    def perform(self) -> None:
    def perform(self, engine: Engine, entity: Entity) -> None:
        raise NotImplementedError()


class MeleeAction(ActionWithDirection):
    def perform(self) -> None:
        target = self.blocking_entity
    def perform(self, engine: Engine, entity: Entity) -> None:
        dest_x = entity.x + self.dx
        dest_y = entity.y + self.dy
        target = engine.game_map.get_blocking_entity_at_location(dest_x, dest_y)
        if not target:
            return  # No entity to attack.

        print(f"You kick the {target.name}, much to its annoyance!")


class MovementAction(ActionWithDirection):
    def perform(self) -> None:
        dest_x, dest_y = self.dest_xy
    def perform(self, engine: Engine, entity: Entity) -> None:
        dest_x = entity.x + self.dx
        dest_y = entity.y + self.dy
 
        if not self.engine.game_map.in_bounds(dest_x, dest_y):
        if not engine.game_map.in_bounds(dest_x, dest_y):
            return  # Destination is out of bounds.
        if not self.engine.game_map.tiles["walkable"][dest_x, dest_y]:
        if not engine.game_map.tiles["walkable"][dest_x, dest_y]:
            return  # Destination is blocked by a tile.
        if self.engine.game_map.get_blocking_entity_at_location(dest_x, dest_y):
        if engine.game_map.get_blocking_entity_at_location(dest_x, dest_y):
            return  # Destination is blocked by an entity.
 
        self.entity.move(self.dx, self.dy)
        entity.move(self.dx, self.dy)


class BumpAction(ActionWithDirection):
    def perform(self) -> None:
        if self.blocking_entity:
            return MeleeAction(self.entity, self.dx, self.dy).perform()
    def perform(self, engine: Engine, entity: Entity) -> None:
        dest_x = entity.x + self.dx
        dest_y = entity.y + self.dy

        if engine.game_map.get_blocking_entity_at_location(dest_x, dest_y):
            return MeleeAction(self.dx, self.dy).perform(engine, entity)
 
        else:
            return MovementAction(self.entity, self.dx, self.dy).perform()
            return MovementAction(self.dx, self.dy).perform(engine, entity)

game_map.py

from __future__ import annotations

from typing import Iterable, Optional, TYPE_CHECKING
 
import numpy as np  # type: ignore
from tcod.console import Console
 
import tile_types
 
if TYPE_CHECKING:
+   from engine import Engine
    from entity import Entity


class GameMap:
-   def __init__(self, width: int, height: int, entities: Iterable[Entity] = ()):
+   def __init__(
+       self, engine: Engine, width: int, height: int, entities: Iterable[Entity] = ()
+   ):
+       self.engine = engine
        self.width, self.height = width, height
        self.entities = set(entities)
        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.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
+       self.explored = np.full(
+           (width, height), fill_value=False, order="F"
+       )  # Tiles the player has seen before
 
-   def get_blocking_entity_at_location(self, location_x: int, location_y: int) -> Optional[Entity]:
+   def get_blocking_entity_at_location(
+       self, location_x: int, location_y: int,
+   ) -> Optional[Entity]:
        for entity in self.entities:
-           if entity.blocks_movement and entity.x == location_x and entity.y == location_y:
+           if (
+               entity.blocks_movement
+               and entity.x == location_x
+               and entity.y == location_y
+           ):
                return entity
 
        return None

    def in_bounds(self, x: int, y: int) -> bool:
        """Return True if x and y are inside of the bounds of this map."""
        return 0 <= x < self.width and 0 <= y < self.height

    def render(self, console: Console) -> None:
        """
        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(
+       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
+           default=tile_types.SHROUD,
        )
from __future__ import annotations

from typing import Iterable, Optional, TYPE_CHECKING
 
import numpy as np  # type: ignore
from tcod.console import Console
 
import tile_types
 
if TYPE_CHECKING:
    from engine import Engine
    from entity import Entity


class GameMap:
    def __init__(self, width: int, height: int, entities: Iterable[Entity] = ()):
    def __init__(
        self, engine: Engine, width: int, height: int, entities: Iterable[Entity] = ()
    ):
        self.engine = engine
        self.width, self.height = width, height
        self.entities = set(entities)
        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.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
        self.explored = np.full(
            (width, height), fill_value=False, order="F"
        )  # Tiles the player has seen before
 
    def get_blocking_entity_at_location(self, location_x: int, location_y: int) -> Optional[Entity]:
    def get_blocking_entity_at_location(
        self, location_x: int, location_y: int,
    ) -> Optional[Entity]:
        for entity in self.entities:
            if entity.blocks_movement and entity.x == location_x and entity.y == location_y:
            if (
                entity.blocks_movement
                and entity.x == location_x
                and entity.y == location_y
            ):
                return entity
 
        return None

    def in_bounds(self, x: int, y: int) -> bool:
        """Return True if x and y are inside of the bounds of this map."""
        return 0 <= x < self.width and 0 <= y < self.height

    def render(self, console: Console) -> None:
        """
        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(
        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
            default=tile_types.SHROUD,
        )

main.py

#!/usr/bin/env python3
import copy

import tcod
 
from engine import Engine
import entity_factories
-from input_handlers import EventHandler
from procgen import generate_dungeon

    ...
+   player = copy.deepcopy(entity_factories.player)
-   event_handler = EventHandler()
 
+   engine = Engine(player=player)
-   player = copy.deepcopy(entity_factories.player)
 
+   engine.game_map = generate_dungeon(
-   game_map = generate_dungeon(
        max_rooms=max_rooms,
        room_min_size=room_min_size,
        room_max_size=room_max_size,
        map_width=map_width,
        map_height=map_height,
        max_monsters_per_room=max_monsters_per_room,
+       engine=engine,
-       player=player,
    )
+   engine.update_fov()

-   engine = Engine(event_handler=event_handler, game_map=game_map, player=player)
 
    with tcod.context.new_terminal(
        ...
        while True:
            engine.render(console=root_console, context=context)
 
+           engine.event_handler.handle_events()
-           events = tcod.event.wait()

-           engine.handle_events(events)


if __name__ == "__main__":
    main()
#!/usr/bin/env python3
import copy

import tcod
 
from engine import Engine
import entity_factories
from input_handlers import EventHandler
from procgen import generate_dungeon

    ...
    player = copy.deepcopy(entity_factories.player)
    event_handler = EventHandler()
 
    engine = Engine(player=player)
    player = copy.deepcopy(entity_factories.player)
 
    engine.game_map = generate_dungeon(
    game_map = generate_dungeon(
        max_rooms=max_rooms,
        room_min_size=room_min_size,
        room_max_size=room_max_size,
        map_width=map_width,
        map_height=map_height,
        max_monsters_per_room=max_monsters_per_room,
        engine=engine,
        player=player,
    )
    engine.update_fov()

    engine = Engine(event_handler=event_handler, game_map=game_map, player=player)
 
    with tcod.context.new_terminal(
        ...
        while True:
            engine.render(console=root_console, context=context)
 
            engine.event_handler.handle_events()
            events = tcod.event.wait()

            engine.handle_events(events)


if __name__ == "__main__":
    main()

entity.py:

from __future__ import annotations

import copy
-from typing import Tuple, TypeVar, TYPE_CHECKING
+from typing import Optional, Tuple, TypeVar, TYPE_CHECKING

if TYPE_CHECKING:
    from game_map import GameMap

T = TypeVar("T", bound="Entity")


class Entity:
    """
    A generic object to represent players, enemies, items, etc.
    """
 
+   gamemap: GameMap

    def __init__(
        self,
+       gamemap: Optional[GameMap] = None,
        x: int = 0,
        y: int = 0,
        char: str = "?",
        color: Tuple[int, int, int] = (255, 255, 255),
        name: str = "<Unnamed>",
        blocks_movement: bool = False,
    ):
        self.x = x
        self.y = y
        self.char = char
        self.color = color
        self.name = name
        self.blocks_movement = blocks_movement
+       if gamemap:
+           # If gamemap isn't provided now then it will be set later.
+           self.gamemap = gamemap
+           gamemap.entities.add(self)
 
    def spawn(self: T, gamemap: GameMap, x: int, y: int) -> T:
        """Spawn a copy of this instance at the given location."""
        clone = copy.deepcopy(self)
        clone.x = x
        clone.y = y
+       clone.gamemap = gamemap
        gamemap.entities.add(clone)
        return clone
    
+   def place(self, x: int, y: int, gamemap: Optional[GameMap] = None) -> None:
+       """Place this entity at a new location.  Handles moving across GameMaps."""
+       self.x = x
+       self.y = y
+       if gamemap:
+           if hasattr(self, "gamemap"):  # Possibly uninitialized.
+               self.gamemap.entities.remove(self)
+           self.gamemap = gamemap
+           gamemap.entities.add(self)
from __future__ import annotations

import copy
from typing import Tuple, TypeVar, TYPE_CHECKING
from typing import Optional, Tuple, TypeVar, TYPE_CHECKING

if TYPE_CHECKING:
    from game_map import GameMap

T = TypeVar("T", bound="Entity")


class Entity:
    """
    A generic object to represent players, enemies, items, etc.
    """
 
    gamemap: GameMap

    def __init__(
        self,
        gamemap: Optional[GameMap] = None,
        x: int = 0,
        y: int = 0,
        char: str = "?",
        color: Tuple[int, int, int] = (255, 255, 255),
        name: str = "<Unnamed>",
        blocks_movement: bool = False,
    ):
        self.x = x
        self.y = y
        self.char = char
        self.color = color
        self.name = name
        self.blocks_movement = blocks_movement
        if gamemap:
            # If gamemap isn't provided now then it will be set later.
            self.gamemap = gamemap
            gamemap.entities.add(self)
 
    def spawn(self: T, gamemap: GameMap, x: int, y: int) -> T:
        """Spawn a copy of this instance at the given location."""
        clone = copy.deepcopy(self)
        clone.x = x
        clone.y = y
        clone.gamemap = gamemap
        gamemap.entities.add(clone)
        return clone
    
    def place(self, x: int, y: int, gamemap: Optional[GameMap] = None) -> None:
        """Place this entity at a new location.  Handles moving across GameMaps."""
        self.x = x
        self.y = y
        if gamemap:
            if hasattr(self, "gamemap"):  # Possibly uninitialized.
                self.gamemap.entities.remove(self)
            self.gamemap = gamemap
            gamemap.entities.add(self)

procgen.py:

import tile_types
 
 
if TYPE_CHECKING:
+   from engine import Engine
-   from entity import Entity

...
def generate_dungeon(
    max_rooms: int,
    room_min_size: int,
    room_max_size: int,
    map_width: int,
    map_height: int,
    max_monsters_per_room: int,
+   engine: Engine,
-   player: Entity,
) -> GameMap:
    """Generate a new dungeon map."""
+   player = engine.player
+   dungeon = GameMap(engine, map_width, map_height, entities=[player])
-   dungeon = GameMap(map_width, map_height, entities=[player])
 
    rooms: List[RectangularRoom] = []
    ...
 
        ...
        if len(rooms) == 0:
            # The first room, where the player starts.
+           player.place(*new_room.center, dungeon)
-           player.x, player.y = new_room.center
        else:  # All rooms after the first.
            ...
import tile_types
 
 
if TYPE_CHECKING:
    from engine import Engine
    from entity import Entity

...
def generate_dungeon(
    max_rooms: int,
    room_min_size: int,
    room_max_size: int,
    map_width: int,
    map_height: int,
    max_monsters_per_room: int,
    engine: Engine,
    player: Entity,
) -> GameMap:
    """Generate a new dungeon map."""
    player = engine.player
    dungeon = GameMap(engine, map_width, map_height, entities=[player])
    dungeon = GameMap(map_width, map_height, entities=[player])
 
    rooms: List[RectangularRoom] = []
    ...
 
        ...
        if len(rooms) == 0:
            # The first room, where the player starts.
            player.place(*new_room.center, dungeon)
            player.x, player.y = new_room.center
        else:  # All rooms after the first.
            ...

engine.py:

+from __future__ import annotations

+from typing import TYPE_CHECKING
-from typing import Iterable, Any
 
from tcod.context import Context
from tcod.console import Console
from tcod.map import compute_fov

-from entity import Entity
-from game_map import GameMap
from input_handlers import EventHandler
 
+if TYPE_CHECKING:
+   from entity import Entity
+   from game_map import GameMap
 
 
class Engine:
+   game_map: GameMap
 
+   def __init__(self, player: Entity):
+       self.event_handler: EventHandler = EventHandler(self)
+       self.player = player
-   def __init__(self, event_handler: EventHandler, game_map: GameMap, player: Entity):
-       self.event_handler = event_handler
-       self.game_map = game_map
-       self.player = player
-       self.update_fov()
 
    def handle_enemy_turns(self) -> None:
        for entity in self.game_map.entities - {self.player}:
            print(f'The {entity.name} wonders when it will get to take a real turn.')

-   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.handle_enemy_turns()
-           self.update_fov()  # Update the FOV before the players next action.
 
    def update_fov(self) -> None:
        ...
from __future__ import annotations

from typing import TYPE_CHECKING
from typing import Iterable, Any
 
from tcod.context import Context
from tcod.console import Console
from tcod.map import compute_fov

from entity import Entity
from game_map import GameMap
from input_handlers import EventHandler
 
if TYPE_CHECKING:
    from entity import Entity
    from game_map import GameMap
 
 
class Engine:
    game_map: GameMap
 
    def __init__(self, player: Entity):
        self.event_handler: EventHandler = EventHandler(self)
        self.player = player
    def __init__(self, event_handler: EventHandler, game_map: GameMap, player: Entity):
        self.event_handler = event_handler
        self.game_map = game_map
        self.player = player
        self.update_fov()
 
    def handle_enemy_turns(self) -> None:
        for entity in self.game_map.entities - {self.player}:
            print(f'The {entity.name} wonders when it will get to take a real turn.')

    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.handle_enemy_turns()
            self.update_fov()  # Update the FOV before the players next action.
 
    def update_fov(self) -> None:
        ...

Onwards to Part 6

The last part of this tutorial set us up for combat, so now it’s time to actually implement it.

In order to make “killable” Entities, rather than attaching hit points to each Entity we create, we’ll create a component, called Fighter, which will hold information related to combat, like HP, max HP, attack, and defense. If an Entity can fight, it will have this component attached to it, and if not, it won’t. This way of doing things is called composition, and it’s an alternative to your typical inheritance-based programming model. (This tutorial uses both composition and inheritance).

Create a new Python package (a folder with an empty __init__.py file), called components. In that new directory, add two new files, one called base_component.py, and another called fighter.py. The Fighter class in fighter.py will inherit from the class we put in base_component.py, so let’s start with that one:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from engine import Engine
    from entity import Entity


class BaseComponent:
    entity: Entity  # Owning entity instance.

    @property
    def engine(self) -> Engine:
        return self.entity.gamemap.engine

With that, let’s now open up fighter.py and put the following into it:

from components.base_component import BaseComponent


class Fighter(BaseComponent):
    def __init__(self, hp: int, defense: int, power: int):
        self.max_hp = hp
        self._hp = hp
        self.defense = defense
        self.power = power

    @property
    def hp(self) -> int:
        return self._hp

    @hp.setter
    def hp(self, value: int) -> None:
        self._hp = max(0, min(value, self.max_hp))

We import and inherit from BaseComponent, which gives us access to the parent entity and the engine, which will be useful later on.

The __init__ function takes a few arguments. hp represents the entity’s hit points. defense is how much taken damage will be reduced. power is the entity’s raw attack power.

What’s with the hp property? We define both a getter and setter, which will allow the class to access hp like a normal variable. The getter (the one with the @property thing above the method) doesn’t do anything special: it just returns the HP. The setter (@hp.setter) is where things get more interesting.

By defining HP this way, we can modify the value as it’s set within the method. This line:

        self._hp = max(0, min(value, self.max_hp))

Means that _hp (which we access through hp) will never be set to less than 0, but also won’t ever go higher than the max_hp attribute.

So that’s our Fighter component. It won’t do us much good at the moment, because the entities in our game still don’t move or do much of anything (besides the player, anyway). To give some life to our entities, we can add another component, which, when attached to our entities, will allow them to take turns and move around.

Create a file in the components directory called ai.py, and put the following contents into it:

from __future__ import annotations

from typing import List, Tuple

import numpy as np  # type: ignore
import tcod

from actions import Action
from components.base_component import BaseComponent


class BaseAI(Action, BaseComponent):
    def perform(self) -> None:
        raise NotImplementedError()

    def get_path_to(self, dest_x: int, dest_y: int) -> List[Tuple[int, int]]:
        """Compute and return a path to the target position.

        If there is no valid path then returns an empty list.
        """
        # Copy the walkable array.
        cost = np.array(self.entity.gamemap.tiles["walkable"], dtype=np.int8)

        for entity in self.entity.gamemap.entities:
            # Check that an enitiy blocks movement and the cost isn't zero (blocking.)
            if entity.blocks_movement and cost[entity.x, entity.y]:
                # Add to the cost of a blocked position.
                # A lower number means more enemies will crowd behind each other in
                # hallways.  A higher number means enemies will take longer paths in
                # order to surround the player.
                cost[entity.x, entity.y] += 10

        # Create a graph from the cost array and pass that graph to a new pathfinder.
        graph = tcod.path.SimpleGraph(cost=cost, cardinal=2, diagonal=3)
        pathfinder = tcod.path.Pathfinder(graph)

        pathfinder.add_root((self.entity.x, self.entity.y))  # Start position.

        # Compute the path to the destination and remove the starting point.
        path: List[List[int]] = pathfinder.path_to((dest_x, dest_y))[1:].tolist()

        # Convert from List[List[int]] to List[Tuple[int, int]].
        return [(index[0], index[1]) for index in path]

BaseAI doesn’t implement a perform method, since the entities which will be using AI to act will have to have an AI class that inherits from this one.

get_path_to uses the “walkable” tiles in our map, along with some TCOD pathfinding tools to get the path from the BaseAI’s parent entity to whatever their target might be. In the case of this tutorial, the target will always be the player, though you could theoretically write a monster that cares more about food or treasure than attacking the player.

The pathfinder first builds an array of cost, which is how “costly” (time consuming) it will take to get to the target. If a piece of terrain takes longer to traverse, its cost will be higher. In the case of our simple game, all parts of the map have the same cost, but what this cost array allows us to do is take other entities into account.

How? Well, if an entity exists at a spot on the map, we increase the cost of moving there to “10”. What this does is encourages the entity to move around the entity that’s blocking them from their target. Higher values will cause the entity to take a longer path around; shorter values will cause groups to gather into crowds, since they don’t want to move around.

More information about TCOD’s pathfinding can be found here.

To make use of our new Fighter and AI components, we could attach them directly onto the Entity class. However, it might be useful to differentiate between entities that can act, and those that can’t. Right now, our game only consists of acting entities, but soon enough, we’ll be adding things like consumable items and, eventually, equipment, which won’t be able to take turns or take damage.

One way to handle this is to create a new subclass of Entity, called Actor, and give it all the same attributes as Entity, plus the ai and fighter components it will need. Modify entity.py like this:

from __future__ import annotations

import copy
-from typing import Tuple, TypeVar, TYPE_CHECKING
+from typing import Optional, Tuple, Type, TypeVar, TYPE_CHECKING


if TYPE_CHECKING:
+   from components.ai import BaseAI
+   from components.fighter import Fighter
    from game_map import GameMap

T = TypeVar("T", bound="Entity")


class Entity:
    ...


+class Actor(Entity):
+   def __init__(
+       self,
+       *,
+       x: int = 0,
+       y: int = 0,
+       char: str = "?",
+       color: Tuple[int, int, int] = (255, 255, 255),
+       name: str = "<Unnamed>",
+       ai_cls: Type[BaseAI],
+       fighter: Fighter
+   ):
+       super().__init__(
+           x=x,
+           y=y,
+           char=char,
+           color=color,
+           name=name,
+           blocks_movement=True,
+       )

+       self.ai: Optional[BaseAI] = ai_cls(self)

+       self.fighter = fighter
+       self.fighter.entity = self

+   @property
+   def is_alive(self) -> bool:
+       """Returns True as long as this actor can perform actions."""
+       return bool(self.ai)
from __future__ import annotations

import copy
from typing import Tuple, TypeVar, TYPE_CHECKING
from typing import Optional, Tuple, Type, TypeVar, TYPE_CHECKING


if TYPE_CHECKING:
    from components.ai import BaseAI
    from components.fighter import Fighter
    from game_map import GameMap

T = TypeVar("T", bound="Entity")


class Entity:
    ...


class Actor(Entity):
    def __init__(
        self,
        *,
        x: int = 0,
        y: int = 0,
        char: str = "?",
        color: Tuple[int, int, int] = (255, 255, 255),
        name: str = "<Unnamed>",
        ai_cls: Type[BaseAI],
        fighter: Fighter
    ):
        super().__init__(
            x=x,
            y=y,
            char=char,
            color=color,
            name=name,
            blocks_movement=True,
        )

        self.ai: Optional[BaseAI] = ai_cls(self)

        self.fighter = fighter
        self.fighter.entity = self

    @property
    def is_alive(self) -> bool:
        """Returns True as long as this actor can perform actions."""
        return bool(self.ai)

The first thing our Actor class does in its __init__() function is call its superclass’s __init__(), which in this case, is the Entity class. We’re passing blocks_movement as True every time, because we can assume that all the “actors” will block movement.

Besides calling the Entity.__init__(), we also set the two components for the Actor class: ai and fighter. The idea is that each actor will need two things to function: the ability to move around and make decisions, and the ability to take (and receive) damage.

This new Actor class isn’t quite enough to get our enemies up and moving around, but we’re getting there. We actually need to revisit ai.py, and add a new class there to handle hostile enemies. Enter the following changes in ai.py:

from __future__ import annotations

-from typing import List, Tuple
+from typing import List, Tuple, TYPE_CHECKING

import numpy as np  # type: ignore
import tcod

-from actions import Action
+from actions import Action, MeleeAction, MovementAction, WaitAction
from components.base_component import BaseComponent

+if TYPE_CHECKING:
+   from entity import Actor


class BaseAI(Action, BaseComponent):
+   entity: Actor

    def perform(self) -> None:
        ...


+class HostileEnemy(BaseAI):
+   def __init__(self, entity: Actor):
+       super().__init__(entity)
+       self.path: List[Tuple[int, int]] = []

+   def perform(self) -> None:
+       target = self.engine.player
+       dx = target.x - self.entity.x
+       dy = target.y - self.entity.y
+       distance = max(abs(dx), abs(dy))  # Chebyshev distance.

+       if self.engine.game_map.visible[self.entity.x, self.entity.y]:
+           if distance <= 1:
+               return MeleeAction(self.entity, dx, dy).perform()

+           self.path = self.get_path_to(target.x, target.y)

+       if self.path:
+           dest_x, dest_y = self.path.pop(0)
+           return MovementAction(
+               self.entity, dest_x - self.entity.x, dest_y - self.entity.y,
+           ).perform()

+       return WaitAction(self.entity).perform()
from __future__ import annotations

from typing import List, Tuple
from typing import List, Tuple, TYPE_CHECKING

import numpy as np  # type: ignore
import tcod

from actions import Action
from actions import Action, MeleeAction, MovementAction, WaitAction
from components.base_component import BaseComponent

if TYPE_CHECKING:
    from entity import Actor


class BaseAI(Action, BaseComponent):
    entity: Actor

    def perform(self) -> None:
        ...


class HostileEnemy(BaseAI):
    def __init__(self, entity: Actor):
        super().__init__(entity)
        self.path: List[Tuple[int, int]] = []

    def perform(self) -> None:
        target = self.engine.player
        dx = target.x - self.entity.x
        dy = target.y - self.entity.y
        distance = max(abs(dx), abs(dy))  # Chebyshev distance.

        if self.engine.game_map.visible[self.entity.x, self.entity.y]:
            if distance <= 1:
                return MeleeAction(self.entity, dx, dy).perform()

            self.path = self.get_path_to(target.x, target.y)

        if self.path:
            dest_x, dest_y = self.path.pop(0)
            return MovementAction(
                self.entity, dest_x - self.entity.x, dest_y - self.entity.y,
            ).perform()

        return WaitAction(self.entity).perform()

HostileEnemy is the AI class we’ll use for our enemies. It defines the perform method, which does the following:

  • If the entity is not in the player’s vision, simply wait.
  • If the player is right next to the entity (distance <= 1), attack the player.
  • If the player can see the entity, but the entity is too far away to attack, then move towards the player.

The last line actually calls an action that we haven’t defined yet: WaitAction. This action will be used when the player or an enemy decides to wait where they are rather than taking a turn.

Implement WaitAction by opening actions.py:

class EscapeAction(Action):
    ...


+class WaitAction(Action):
+   def perform(self) -> None:
+       pass


class ActionWithDirection(Action):
    ...
class EscapeAction(Action):
    ...


class WaitAction(Action):
    def perform(self) -> None:
        pass


class ActionWithDirection(Action):
    ...

As you can see, WaitAction does… well, nothing. And that’s what we want it to do, as it represents an actor saying “I’ll do nothing this turn.”

With all that in place, we’ll need to refactor our entity_factories.py file to make use of the new Actor class, as well as its components. Modify entity_factories.py to look like this:

+from components.ai import HostileEnemy
+from components.fighter import Fighter
+from entity import Actor
-from entity import Entity
 
+player = Actor(
+   char="@",
+   color=(255, 255, 255),
+   name="Player",
+   ai_cls=HostileEnemy,
+   fighter=Fighter(hp=30, defense=2, power=5),
+)
-player = Entity(char="@", color=(255, 255, 255), name="Player", blocks_movement=True)
 
+orc = Actor(
+   char="o",
+   color=(63, 127, 63),
+   name="Orc",
+   ai_cls=HostileEnemy,
+   fighter=Fighter(hp=10, defense=0, power=3),
+)
+troll = Actor(
+   char="T",
+   color=(0, 127, 0),
+   name="Troll",
+   ai_cls=HostileEnemy,
+   fighter=Fighter(hp=16, defense=1, power=4),
+)
-orc = Entity(char="o", color=(63, 127, 63), name="Orc", blocks_movement=True)
-troll = Entity(char="T", color=(0, 127, 0), name="Troll", blocks_movement=True)
from components.ai import HostileEnemy
from components.fighter import Fighter
from entity import Actor
from entity import Entity
 
player = Actor(
    char="@",
    color=(255, 255, 255),
    name="Player",
    ai_cls=HostileEnemy,
    fighter=Fighter(hp=30, defense=2, power=5),
)
player = Entity(char="@", color=(255, 255, 255), name="Player", blocks_movement=True)
 
orc = Actor(
    char="o",
    color=(63, 127, 63),
    name="Orc",
    ai_cls=HostileEnemy,
    fighter=Fighter(hp=10, defense=0, power=3),
)
troll = Actor(
    char="T",
    color=(0, 127, 0),
    name="Troll",
    ai_cls=HostileEnemy,
    fighter=Fighter(hp=16, defense=1, power=4),
)
orc = Entity(char="o", color=(63, 127, 63), name="Orc", blocks_movement=True)
troll = Entity(char="T", color=(0, 127, 0), name="Troll", blocks_movement=True)

We’ve changed each entity to make use of the Actor class, and used the HostileEnemy AI class for the Orc and the Troll types, while using the BaseAI for our player. Also, we defined the Fighter component for each, giving a few different values to make the Trolls stronger than the Orcs. Feel free to modify these values to your liking.

How do enemies actually take their turns, though? It’s actually pretty simple: rather than printing the message we were before, we just check if the entity has an AI, and if it does, we call the perform method from that AI component. Modify engine.py to do this:

    def handle_enemy_turns(self) -> None:
+       for entity in set(self.game_map.actors) - {self.player}:
+           if entity.ai:
+               entity.ai.perform()
-       for entity in self.game_map.entities - {self.player}:
-           print(f'The {entity.name} wonders when it will get to take a real turn.')
    def handle_enemy_turns(self) -> None:
        for entity in set(self.game_map.actors) - {self.player}:
            if entity.ai:
                entity.ai.perform()
        for entity in self.game_map.entities - {self.player}:
            print(f'The {entity.name} wonders when it will get to take a real turn.')

But wait, game_map.actors isn’t defined. What should it do, though? Same thing as game_map.entities, except it should return only the Actor entities.

Let’s add this method to GameMap:

from __future__ import annotations

-from typing import Iterable, Optional, TYPE_CHECKING
+from typing import Iterable, Iterator, Optional, TYPE_CHECKING

import numpy as np  # type: ignore
from tcod.console import Console

+from entity import Actor
import tile_types

if TYPE_CHECKING:
    from engine import Engine
    from entity import Entity

class GameMap:
    def __init__(
        ...
    
+   @property
+   def actors(self) -> Iterator[Actor]:
+       """Iterate over this maps living actors."""
+       yield from (
+           entity
+           for entity in self.entities
+           if isinstance(entity, Actor) and entity.is_alive
+       )
    
    def get_blocking_entity_at_location(
        ...
    
+   def get_actor_at_location(self, x: int, y: int) -> Optional[Actor]:
+       for actor in self.actors:
+           if actor.x == x and actor.y == y:
+               return actor

+       return None
from __future__ import annotations

from typing import Iterable, Optional, TYPE_CHECKING
from typing import Iterable, Iterator, Optional, TYPE_CHECKING

import numpy as np  # type: ignore
from tcod.console import Console

from entity import Actor
import tile_types

if TYPE_CHECKING:
    from engine import Engine
    from entity import Entity

class GameMap:
    def __init__(
        ...
    
    @property
    def actors(self) -> Iterator[Actor]:
        """Iterate over this maps living actors."""
        yield from (
            entity
            for entity in self.entities
            if isinstance(entity, Actor) and entity.is_alive
        )
    
    def get_blocking_entity_at_location(
        ...
    
    def get_actor_at_location(self, x: int, y: int) -> Optional[Actor]:
        for actor in self.actors:
            if actor.x == x and actor.y == y:
                return actor

        return None

Our actors property will return all the Actor entities in the map, but only those that are currently “alive”.

We’ve also went ahead and added a get_actor_at_location, which, as the name implies, acts similarly to get_blocking_entity_at_location, but returns only an Actor. This will come in handy later on.

Run the project now, and the enemies should chase you around! They can’t really attack just yet, but we’re getting there.

Part 6 - The Chase

One thing you might have noticed is that we’re letting our enemies move and attack in diagonal directions, but our player can only move in the four cardinal directions (up, down, left, right). We can fix that by adjusting input_handlers.py. While we’re at it, we might want to define a more flexible way of defining the movement keys rather than the if...elif structure we’ve used so far. While that does work, it gets a bit clunky after more than just a few options. We can fix this by modifying input_handlers.py like this:

from __future__ import annotations

from typing import Optional, TYPE_CHECKING

import tcod.event

-from actions import Action, BumpAction, EscapeAction
+from actions import Action, BumpAction, EscapeAction, WaitAction

if TYPE_CHECKING:
    from engine import Engine


+MOVE_KEYS = {
+   # Arrow keys.
+   tcod.event.K_UP: (0, -1),
+   tcod.event.K_DOWN: (0, 1),
+   tcod.event.K_LEFT: (-1, 0),
+   tcod.event.K_RIGHT: (1, 0),
+   tcod.event.K_HOME: (-1, -1),
+   tcod.event.K_END: (-1, 1),
+   tcod.event.K_PAGEUP: (1, -1),
+   tcod.event.K_PAGEDOWN: (1, 1),
+   # Numpad keys.
+   tcod.event.K_KP_1: (-1, 1),
+   tcod.event.K_KP_2: (0, 1),
+   tcod.event.K_KP_3: (1, 1),
+   tcod.event.K_KP_4: (-1, 0),
+   tcod.event.K_KP_6: (1, 0),
+   tcod.event.K_KP_7: (-1, -1),
+   tcod.event.K_KP_8: (0, -1),
+   tcod.event.K_KP_9: (1, -1),
+   # Vi keys.
+   tcod.event.K_h: (-1, 0),
+   tcod.event.K_j: (0, 1),
+   tcod.event.K_k: (0, -1),
+   tcod.event.K_l: (1, 0),
+   tcod.event.K_y: (-1, -1),
+   tcod.event.K_u: (1, -1),
+   tcod.event.K_b: (-1, 1),
+   tcod.event.K_n: (1, 1),
+}

+WAIT_KEYS = {
+   tcod.event.K_PERIOD,
+   tcod.event.K_KP_5,
+   tcod.event.K_CLEAR,
+}


        ...

-       if key == tcod.event.K_UP:
-           action = BumpAction(dx=0, dy=-1)
-       elif key == tcod.event.K_DOWN:
-           action = BumpAction(dx=0, dy=1)
-       elif key == tcod.event.K_LEFT:
-           action = BumpAction(dx=-1, dy=0)
-       elif key == tcod.event.K_RIGHT:
-           action = BumpAction(dx=1, dy=0)
+       if key in MOVE_KEYS:
+           dx, dy = MOVE_KEYS[key]
+           action = BumpAction(player, dx, dy)
+       elif key in WAIT_KEYS:
+           action = WaitAction(player)

        ...
from __future__ import annotations

from typing import Optional, TYPE_CHECKING

import tcod.event

from actions import Action, BumpAction, EscapeAction
from actions import Action, BumpAction, EscapeAction, WaitAction

if TYPE_CHECKING:
    from engine import Engine


MOVE_KEYS = {
    # Arrow keys.
    tcod.event.K_UP: (0, -1),
    tcod.event.K_DOWN: (0, 1),
    tcod.event.K_LEFT: (-1, 0),
    tcod.event.K_RIGHT: (1, 0),
    tcod.event.K_HOME: (-1, -1),
    tcod.event.K_END: (-1, 1),
    tcod.event.K_PAGEUP: (1, -1),
    tcod.event.K_PAGEDOWN: (1, 1),
    # Numpad keys.
    tcod.event.K_KP_1: (-1, 1),
    tcod.event.K_KP_2: (0, 1),
    tcod.event.K_KP_3: (1, 1),
    tcod.event.K_KP_4: (-1, 0),
    tcod.event.K_KP_6: (1, 0),
    tcod.event.K_KP_7: (-1, -1),
    tcod.event.K_KP_8: (0, -1),
    tcod.event.K_KP_9: (1, -1),
    # Vi keys.
    tcod.event.K_h: (-1, 0),
    tcod.event.K_j: (0, 1),
    tcod.event.K_k: (0, -1),
    tcod.event.K_l: (1, 0),
    tcod.event.K_y: (-1, -1),
    tcod.event.K_u: (1, -1),
    tcod.event.K_b: (-1, 1),
    tcod.event.K_n: (1, 1),
}

WAIT_KEYS = {
    tcod.event.K_PERIOD,
    tcod.event.K_KP_5,
    tcod.event.K_CLEAR,
}


        ...

        if key == tcod.event.K_UP:
            action = BumpAction(player, dx=0, dy=-1)
        elif key == tcod.event.K_DOWN:
            action = BumpAction(player, dx=0, dy=1)
        elif key == tcod.event.K_LEFT:
            action = BumpAction(player, dx=-1, dy=0)
        elif key == tcod.event.K_RIGHT:
            action = BumpAction(player, dx=1, dy=0)
        if key in MOVE_KEYS:
            dx, dy = MOVE_KEYS[key]
            action = BumpAction(player, dx, dy)
        elif key in WAIT_KEYS:
            action = WaitAction(player)

        ...

The MOVE_KEYS dictionary holds various different possibilities for movement. Some roguelikes utilize the numpad for movement, some use “Vi Keys.” Ours will actually use both for the time being. Feel free to change the key scheme if you’re not a fan of it.

Where we used to do if...elif statements for each direction, we can now just check if the key was part of MOVE_KEYS, and if it was, we return the dx and dy values from the dictionary. This is a lot simpler and cleaner than our previous format.

So now that our enemies can chase us down, it’s time to make them do some real damage.

Open up actions.py:

from __future__ import annotations

from typing import Optional, Tuple, TYPE_CHECKING

if TYPE_CHECKING:
    from engine import Engine
-   from entity import Entity
+   from entity import Actor, Entity


class Action:
-   def __init__(self, entity: Entity) -> None:
+   def __init__(self, entity: Actor) -> None:
        super().__init__()
        self.entity = entity

    ...


class ActionWithDirection(Action):
-   def __init__(self, entity: Entity, dx: int, dy: int):
+   def __init__(self, entity: Actor, dx: int, dy: int):
        super().__init__(entity)

        self.dx = dx
        self.dy = dy
    
    @property
    def dest_xy(self) -> Tuple[int, int]:
        """Returns this actions destination."""
        return self.entity.x + self.dx, self.entity.y + self.dy

    @property
    def blocking_entity(self) -> Optional[Entity]:
        """Return the blocking entity at this actions destination.."""
        return self.engine.game_map.get_blocking_entity_at_location(*self.dest_xy)
    
+   @property
+   def target_actor(self) -> Optional[Actor]:
+       """Return the actor at this actions destination."""
+       return self.engine.game_map.get_actor_at_location(*self.dest_xy)

    def perform(self) -> None:
        raise NotImplementedError()


class MeleeAction(ActionWithDirection):
    def perform(self) -> None:
+       target = self.target_actor
-       target = self.blocking_entity
        if not target:
            return  # No entity to attack.
 
+       damage = self.entity.fighter.power - target.fighter.defense

+       attack_desc = f"{self.entity.name.capitalize()} attacks {target.name}"
+       if damage > 0:
+           print(f"{attack_desc} for {damage} hit points.")
+           target.fighter.hp -= damage
+       else:
+           print(f"{attack_desc} but does no damage.")
-       print(f"You kick the {target.name}, much to its annoyance!")


class MovementAction(ActionWithDirection):
    ...


class BumpAction(ActionWithDirection):
    def perform(self) -> None:
-       if self.blocking_entity:
+       if self.target_actor:
            return MeleeAction(self.entity, self.dx, self.dy).perform()

        else:
            return MovementAction(self.entity, self.dx, self.dy).perform()
from __future__ import annotations

from typing import Optional, Tuple, TYPE_CHECKING

if TYPE_CHECKING:
    from engine import Engine
    from entity import Entity
    from entity import Actor, Entity


class Action:
    def __init__(self, entity: Entity) -> None:
    def __init__(self, entity: Actor) -> None:
        super().__init__()
        self.entity = entity

    ...


class ActionWithDirection(Action):
    def __init__(self, entity: Entity, dx: int, dy: int):
    def __init__(self, entity: Actor, dx: int, dy: int):
        super().__init__(entity)

        self.dx = dx
        self.dy = dy
    
    @property
    def dest_xy(self) -> Tuple[int, int]:
        """Returns this actions destination."""
        return self.entity.x + self.dx, self.entity.y + self.dy

    @property
    def blocking_entity(self) -> Optional[Entity]:
        """Return the blocking entity at this actions destination.."""
        return self.engine.game_map.get_blocking_entity_at_location(*self.dest_xy)
    
    @property
    def target_actor(self) -> Optional[Actor]:
        """Return the actor at this actions destination."""
        return self.engine.game_map.get_actor_at_location(*self.dest_xy)

    def perform(self) -> None:
        raise NotImplementedError()


class MeleeAction(ActionWithDirection):
    def perform(self) -> None:
        target = self.target_actor
        target = self.blocking_entity
        if not target:
            return  # No entity to attack.
 
        damage = self.entity.fighter.power - target.fighter.defense

        attack_desc = f"{self.entity.name.capitalize()} attacks {target.name}"
        if damage > 0:
            print(f"{attack_desc} for {damage} hit points.")
            target.fighter.hp -= damage
        else:
            print(f"{attack_desc} but does no damage.")
        print(f"You kick the {target.name}, much to its annoyance!")


class MovementAction(ActionWithDirection):
    ...


class BumpAction(ActionWithDirection):
    def perform(self) -> None:
        if self.blocking_entity:
        if self.target_actor:
            return MeleeAction(self.entity, self.dx, self.dy).perform()

        else:
            return MovementAction(self.entity, self.dx, self.dy).perform()

We’re replacing the type hint for entity in Action and ActionWithDirection with Actor instead of Entity, since only Actors should be taking actions.

We’ve also added the target_actor property to ActionWithDirection, which will give us the Actor at the destination we’re moving to, if there is one. We utilize that property instead of blocking_entity in both BumpAction and MeleeAction.

Lastly, we modify MeleeAction to actually do an attack, instead of just printing a message. We calculate the damage (attacker’s power minus defender’s defense), and assign a description to the attack, based on whether any damage was done or not. If the damage is greater than 0, we subtract it from the defender’s HP.

If you run the project now, you’ll see the print statements indicating that the player and the enemies are doing damage to each other. But since neither side can actually die, combat doesn’t feel all that high stakes just yet.

What do we do when an Entity reaches 0 HP or lower? Well, it should drop dead, obviously! But what should our code do to make this happen? To handle this, we can refer back to our Fighter component.

Remember when we created a setter for hp? It will come in handy right now, as we can utilize it to automatically “kill” the actor when their HP drops to zero. Add the following to fighter.py:

+from __future__ import annotations

+from typing import TYPE_CHECKING

from components.base_component import BaseComponent

+if TYPE_CHECKING:
+   from entity import Actor


class Fighter(BaseComponent):
+   entity: Actor

    def __init__(self, hp: int, defense: int, power: int):
        self.max_hp = hp
        self._hp = hp
        self.defense = defense
        self.power = power

    @property
    def hp(self) -> int:
        return self._hp

    @hp.setter
    def hp(self, value: int) -> None:
        self._hp = max(0, min(value, self.max_hp))
+       if self._hp == 0 and self.entity.ai:
+           self.die()

+   def die(self) -> None:
+       if self.engine.player is self.entity:
+           death_message = "You died!"
+       else:
+           death_message = f"{self.entity.name} is dead!"

+       self.entity.char = "%"
+       self.entity.color = (191, 0, 0)
+       self.entity.blocks_movement = False
+       self.entity.ai = None
+       self.entity.name = f"remains of {self.entity.name}"

+       print(death_message)
from __future__ import annotations

from typing import TYPE_CHECKING

from components.base_component import BaseComponent

if TYPE_CHECKING:
    from entity import Actor


class Fighter(BaseComponent):
    entity: Actor

    def __init__(self, hp: int, defense: int, power: int):
        self.max_hp = hp
        self._hp = hp
        self.defense = defense
        self.power = power

    @property
    def hp(self) -> int:
        return self._hp

    @hp.setter
    def hp(self, value: int) -> None:
        self._hp = max(0, min(value, self.max_hp))
        if self._hp == 0 and self.entity.ai:
            self.die()

    def die(self) -> None:
        if self.engine.player is self.entity:
            death_message = "You died!"
        else:
            death_message = f"{self.entity.name} is dead!"

        self.entity.char = "%"
        self.entity.color = (191, 0, 0)
        self.entity.blocks_movement = False
        self.entity.ai = None
        self.entity.name = f"remains of {self.entity.name}"

        print(death_message)

When the actor dies, we use the die method to do several things:

  • Print out a message, indicating the death of the entity
  • Set the entity’s character to “%” (most roguelikes use this for corpses)
  • Set its color to red (for a bloody, gory mess)
  • Set blocks_movement to False, so that the entities can walk over the corpse
  • Remove the AI from the entity, so it’ll be marked as dead and won’t take any more turns.
  • Change the name to “remains of {entity name}”

Run the project now, and enjoy slaughtering some Orcs and Trolls!

Part 6 - Killing Enemies

As satisfying as it would be to end here, our work is not quite done. If you play the game a bit, you’ll notice two problems.

The first is that, sometimes, corpses actually cover up entities.

Part 6 - Player under a Corpse

The player is currently under the corpse in the screenshot.

This not only makes no sense, since the entities should be walking over the corpses, but it can confuse the player rather easily.

The other issue is much more severe. Try playing the game and letting yourself die on purpose.

Part 6 - Dead Player

The player does indeed turn into a corpse, but… you can still move around, and even attack enemies! This is because the game doesn’t really “end” at the moment when the player dies. The only thing that changes is that the player’s AI component is set to None, but that isn’t actually what controls the player, the EventHandler class does that.

Let’s focus on the first issue first. Solving it is actually pretty easy. What we’ll do is assign a value to each Entity, and this value will represent which order the entities should be rendered in. Lower values will be rendered first, and higher values will be rendered after. Therefore, if we assign a low value to a corpse, it will get drawn before an entity. If two things are on the same tile, whatever gets drawn last will be what the player sees.

To create the render values we’ll need, create a new file, called render_order.py, and put the following class in it:

from enum import auto, Enum


class RenderOrder(Enum):
    CORPSE = auto()
    ITEM = auto()
    ACTOR = auto()

Note: You’ll need Python 3.6 or higher for the auto function to work.

RenderOrder is an Enum. An “Enum” is a set of named values that won’t change, so it’s perfect for things like this. auto assigns incrementing integer values automatically, so we don’t need to retype them if we add more values later on.

To use this new Enum, let’s edit entity.py:

from __future__ import annotations

import copy
from typing import Optional, Tuple, Type, TypeVar, TYPE_CHECKING

+from render_order import RenderOrder

if TYPE_CHECKING:
    from components.ai import BaseAI
    from components.fighter import Fighter
    from game_map import GameMap

T = TypeVar("T", bound="Entity")


class Entity:
    """
    A generic object to represent players, enemies, items, etc.
    """

    gamemap: GameMap

    def __init__(
        self,
        gamemap: Optional[GameMap] = None,
        x: int = 0,
        y: int = 0,
        char: str = "?",
        color: Tuple[int, int, int] = (255, 255, 255),
        name: str = "<Unnamed>",
        blocks_movement: bool = False,
+       render_order: RenderOrder = RenderOrder.CORPSE,
    ):
        self.x = x
        self.y = y
        self.char = char
        self.color = color
        self.name = name
        self.blocks_movement = blocks_movement
+       self.render_order = render_order
        if gamemap:
            # If gamemap isn't provided now then it will be set later.
            self.gamemap = gamemap
            gamemap.entities.add(self)
    ...

class Actor(Entity):
    def __init__(
        self,
        *,
        x: int = 0,
        y: int = 0,
        char: str = "?",
        color: Tuple[int, int, int] = (255, 255, 255),
        name: str = "<Unnamed>",
        ai_cls: Type[BaseAI],
        fighter: Fighter
    ):
        super().__init__(
            x=x,
            y=y,
            char=char,
            color=color,
            name=name,
            blocks_movement=True,
+           render_order=RenderOrder.ACTOR,
        )
from __future__ import annotations

import copy
from typing import Optional, Tuple, Type, TypeVar, TYPE_CHECKING

from render_order import RenderOrder

if TYPE_CHECKING:
    from components.ai import BaseAI
    from components.fighter import Fighter
    from game_map import GameMap

T = TypeVar("T", bound="Entity")


class Entity:
    """
    A generic object to represent players, enemies, items, etc.
    """

    gamemap: GameMap

    def __init__(
        self,
        gamemap: Optional[GameMap] = None,
        x: int = 0,
        y: int = 0,
        char: str = "?",
        color: Tuple[int, int, int] = (255, 255, 255),
        name: str = "<Unnamed>",
        blocks_movement: bool = False,
        render_order: RenderOrder = RenderOrder.CORPSE,
    ):
        self.x = x
        self.y = y
        self.char = char
        self.color = color
        self.name = name
        self.blocks_movement = blocks_movement
        self.render_order = render_order
        if gamemap:
            # If gamemap isn't provided now then it will be set later.
            self.gamemap = gamemap
            gamemap.entities.add(self)
    ...

class Actor(Entity):
    def __init__(
        self,
        *,
        x: int = 0,
        y: int = 0,
        char: str = "?",
        color: Tuple[int, int, int] = (255, 255, 255),
        name: str = "",
        ai_cls: Type[BaseAI],
        fighter: Fighter
    ):
        super().__init__(
            x=x,
            y=y,
            char=char,
            color=color,
            name=name,
            blocks_movement=True,
            render_order=RenderOrder.ACTOR,
        )

We’re now passing the render order to the Entity class, with a default of CORPSE. Notice that we don’t pass it to Actor, and instead, assume that the actor’s default will be the ACTOR value.

In order to actually take advantage of the rendering order, we’ll need to modify the part of GameMap that renders the entities to the screen. Modify the render method in GameMap like this:

    ...
    def render(self, console: Console) -> None:
        """
        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
        )

+       entities_sorted_for_rendering = sorted(
+           self.entities, key=lambda x: x.render_order.value
+       )

-       for entity in self.entities:
+       for entity in entities_sorted_for_rendering:
            if self.visible[entity.x, entity.y]:
-               console.print(x=entity.x, y=entity.y, string=entity.char, fg=entity.color)
+               console.print(
+                   x=entity.x, y=entity.y, string=entity.char, fg=entity.color
+               )
    ...
    def render(self, console: Console) -> None:
        """
        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
        )

        entities_sorted_for_rendering = sorted(
            self.entities, key=lambda x: x.render_order.value
        )

        for entity in self.entities:
        for entity in entities_sorted_for_rendering:
            if self.visible[entity.x, entity.y]:
                console.print(x=entity.x, y=entity.y, string=entity.char, fg=entity.color)
                console.print(
                    x=entity.x, y=entity.y, string=entity.char, fg=entity.color
                )

The sorted function takes two arguments: The collection to sort, and the function used to sort it. By using key in sorted, we’re defining a custom way to sort the self.entities, which in this case, we’re using a lambda function (basically, a function that’s limited to one line that we don’t need to write a formal definition for). The lambda function itself tells sorted to sort by the value of render_order. Since the RenderOrder enum defines its order from 1 (Corpse, lowest) to 3 (Actor, highest), corpses should be sent to the front of the sorted list. That way, when rendering, they’ll get drawn first, so if there’s something else on top of them, they’ll get overwritten, and we’ll just see the Actor instead of the corpse.

Last thing we need to do is rewrite the render_order of an entity when it dies. Go back to the Fighter class and add the following:

from __future__ import annotations

from typing import TYPE_CHECKING

from components.base_component import BaseComponent
+from render_order import RenderOrder

if TYPE_CHECKING:
    from entity import Actor


class Fighter(BaseComponent):
    ...
        ...
        self.entity.ai = None
        self.entity.name = f"remains of {self.entity.name}"
+       self.entity.render_order = RenderOrder.CORPSE

        print(death_message)
from __future__ import annotations

from typing import TYPE_CHECKING

from components.base_component import BaseComponent
from render_order import RenderOrder

if TYPE_CHECKING:
    from entity import Actor


class Fighter(BaseComponent):
    ...
        ...
        self.entity.ai = None
        self.entity.name = f"remains of {self.entity.name}"
        self.entity.render_order = RenderOrder.CORPSE

        print(death_message)

Run the project now, and the corpse ordering issue should be resolved.

Now, onto the more important issue: solving the player’s death.

One thing that would be helpful right now is being able to see the player’s HP. Otherwise, the player will just kinda drop dead after a while, and it’ll be difficult for the player to know how close they are to death’s door.

Add the following line to the render function in the Engine class:

if TYPE_CHECKING:
-   from entity import Entity
+   from entity import Actor
    from game_map import GameMap


class Engine:
    game_map: GameMap

-   def __init__(self, player: Entity):
+   def __init__(self, player: Actor):
        ...

    def render(self, console: Console, context: Context) -> None:
        self.game_map.render(console)
 
+       console.print(
+           x=1,
+           y=47,
+           string=f"HP: {self.player.fighter.hp}/{self.player.fighter.max_hp}",
+       )

        context.present(console)

        console.clear()
if TYPE_CHECKING:
    from entity import Entity
    from entity import Actor
    from game_map import GameMap


class Engine:
    game_map: GameMap

    def __init__(self, player: Entity):
    def __init__(self, player: Actor):
        ...
    
    def render(self, console: Console, context: Context) -> None:
        self.game_map.render(console)
 
        console.print(
            x=1,
            y=47,
            string=f"HP: {self.player.fighter.hp}/{self.player.fighter.max_hp}",
        )

        context.present(console)

        console.clear()

Pretty simple. We’re printing the player’s HP current health over maximum health below the map. It’s not the most attractive looking health display, that’s for sure, but it should suffice for now. A better looking way to show the character’s health is coming shortly anyway, in the next chapter.

Notice that we also updated the type hint for the player argument in the Engine’s __init__ function.

The health indicator is great and all, but our player is still animated after death. There’s a few ways to handle this, but the way we’ll go with is swapping out the EventHandler class. Why? Because what we want to do right now is disallow the player from moving around after dying. An easy way to do that is to stop reacting to the movement keypresses. By switching to a different EventHandler, we can do just that.

What we’ll want to do is actually modify our existing EventHandler to be a base class, and inherit from it in two new classes: MainGameEventHandler, and GameOverEventHandler. MainGameEventHandler will actually do what our current implementation of EventHandler does, and GameOverEventHandler will handle things when the main character meets his or her untimely demise.

Open up input_handlers.py and make the following adjustments:

class EventHandler(tcod.event.EventDispatch[Action]):
    def __init__(self, engine: Engine):
        self.engine = engine
    
+   def handle_events(self) -> None:
+       raise NotImplementedError()

+   def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
+       raise SystemExit()


+class MainGameEventHandler(EventHandler):
    def handle_events(self) -> None:
        for event in tcod.event.wait():
            action = self.dispatch(event)

            if action is None:
                continue

            action.perform()

            self.engine.handle_enemy_turns()
            self.engine.update_fov()  # Update the FOV before the players next action.

-   def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
-       raise SystemExit()

    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
        action: Optional[Action] = None

        key = event.sym

        player = self.engine.player

        if key in MOVE_KEYS:
            dx, dy = MOVE_KEYS[key]
            action = BumpAction(player, dx, dy)
        elif key in WAIT_KEYS:
            action = WaitAction(player)

        elif key == tcod.event.K_ESCAPE:
            action = EscapeAction(player)

        # No valid key was pressed
        return action


+class GameOverEventHandler(EventHandler):
+   def handle_events(self) -> None:
+       for event in tcod.event.wait():
+           action = self.dispatch(event)

+           if action is None:
+               continue

+           action.perform()

+   def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
+       action: Optional[Action] = None

+       key = event.sym

+       if key == tcod.event.K_ESCAPE:
+           action = EscapeAction(self.engine.player)

+       # No valid key was pressed
+       return action
class EventHandler(tcod.event.EventDispatch[Action]):
    def __init__(self, engine: Engine):
        self.engine = engine
    
    def handle_events(self) -> None:
        raise NotImplementedError()

    def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
        raise SystemExit()


class MainGameEventHandler(EventHandler):
    def handle_events(self) -> None:
        for event in tcod.event.wait():
            action = self.dispatch(event)

            if action is None:
                continue

            action.perform()

            self.engine.handle_enemy_turns()
            self.engine.update_fov()  # Update the FOV before the players next action.

    def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
        raise SystemExit()

    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
        action: Optional[Action] = None

        key = event.sym

        player = self.engine.player

        if key in MOVE_KEYS:
            dx, dy = MOVE_KEYS[key]
            action = BumpAction(player, dx, dy)
        elif key in WAIT_KEYS:
            action = WaitAction(player)

        elif key == tcod.event.K_ESCAPE:
            action = EscapeAction(player)

        # No valid key was pressed
        return action


class GameOverEventHandler(EventHandler):
    def handle_events(self) -> None:
        for event in tcod.event.wait():
            action = self.dispatch(event)

            if action is None:
                continue

            action.perform()

    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
        action: Optional[Action] = None

        key = event.sym

        if key == tcod.event.K_ESCAPE:
            action = EscapeAction(self.engine.player)

        # No valid key was pressed
        return action

EventHandler is not the base class for our other two classes.

MainGameEventHandler is almost identical to our original EventHandler class, except that it doesn’t need to implement ev_quit, as EventHandler takes care of that just fine.

GameOverEventHandler is what’s really new here. It doesn’t look terribly different from MainGameEventHandler, except for a few key differences.

  • After performing its actions, it doesn’t call the enemy turns nor update the FOV.
  • It also doesn’t respond to the movement keys, just Esc, so the player can still exit the game.

Because we’re replacing our old implementation of EventHandler with MainGameEventHandler, we’ll need to adjust engine.py to use MainGameEventHandler:

from tcod.map import compute_fov

-from input_handlers import EventHandler
+from input_handlers import MainGameEventHandler

if TYPE_CHECKING:
    from entity import Actor
    from game_map import GameMap
+   from input_handlers import EventHandler


class Engine:
    game_map: GameMap

    def __init__(self, player: Actor):
-       self.event_handler: EventHandler = EventHandler(self)
+       self.event_handler: EventHandler = MainGameEventHandler(self)
        self.player = player
from tcod.map import compute_fov

from input_handlers import EventHandler
from input_handlers import MainGameEventHandler

if TYPE_CHECKING:
    from entity import Actor
    from game_map import GameMap
    from input_handlers import EventHandler


class Engine:
    game_map: GameMap

    def __init__(self, player: Actor):
        self.event_handler: EventHandler = EventHandler(self)
        self.event_handler: EventHandler = MainGameEventHandler(self)
        self.player = player

Lastly, we can use the GameOverEventHandler in fighter.py to ensure the player cannot move after death:

from __future__ import annotations

from typing import TYPE_CHECKING

from components.base_component import BaseComponent
+from input_handlers import GameOverEventHandler
from render_order import RenderOrder

if TYPE_CHECKING:
    from entity import Actor


class Fighter(BaseComponent):
    ...

    def die(self) -> None:
        if self.engine.player is self.entity:
            death_message = "You died!"
+           self.engine.event_handler = GameOverEventHandler(self.engine)
        else:
            death_message = f"{self.entity.name} is dead!"
from __future__ import annotations

from typing import TYPE_CHECKING

from components.base_component import BaseComponent
from input_handlers import GameOverEventHandler
from render_order import RenderOrder

if TYPE_CHECKING:
    from entity import Actor


class Fighter(BaseComponent):
    ...

    def die(self) -> None:
        if self.engine.player is self.entity:
            death_message = "You died!"
            self.engine.event_handler = GameOverEventHandler(self.engine)
        else:
            death_message = f"{self.entity.name} is dead!"

And with that last change, the main character should die, for real this time! You’ll be unable to move or attack, but you can still exit the game as normal.

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.