Part 5 - Placing Enemies and kicking them (harmlessly)
What good is a dungeon with no monsters to bash? This chapter will focus on placing the enemies throughout the dungeon, and setting them up to be attacked (the actual attacking part we’ll save for next time).
When we’re building our dungeon, we’ll need to place the enemies in the rooms. In order to do that, we will need to make a change to the way entities
are stored in our game. Currently, they’re saved in the Engine
class. However, for the sake of placing enemies in the dungeon, and when we get to the part where we move between dungeon floors, it will be better to store them in the GameMap
class. That way, the map has access to the entities directly, and we can preserve which entities are on which floors fairly easily.
Start by modifying GameMap
:
+from __future__ import annotations
+from typing import Iterable, TYPE_CHECKING
import numpy as np # type: ignore
from tcod.console import Console
import tile_types
+if TYPE_CHECKING:
+ from entity import Entity
class GameMap:
- def __init__(self, width: int, height: int):
+ def __init__(self, width: int, height: int, entities: Iterable[Entity] = ()):
self.width, self.height = width, height
+ self.entities = set(entities)
self.tiles = np.full((width, height), fill_value=tile_types.wall, order="F")
from __future__ import annotations from typing import Iterable, TYPE_CHECKING import numpy as np # type: ignore from tcod.console import Console import tile_types if TYPE_CHECKING: from entity import Entity class GameMap: def __init__(self, width: int, height: int): def __init__(self, width: int, height: int, entities: Iterable[Entity] = ()): self.width, self.height = width, height self.entities = set(entities) self.tiles = np.full((width, height), fill_value=tile_types.wall, order="F")
Then, let’s modify Engine
to remove the entities
from it:
-from typing import Set, Iterable, Any
+from typing import Iterable, Any
class Engine:
- def __init__(self, entities: Set[Entity], event_handler: EventHandler, game_map: GameMap, player: Entity):
+ def __init__(self, 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()
from typing import Set, Iterable, Any from typing import Iterable, Any class Engine: def __init__(self, entities: Set[Entity], event_handler: EventHandler, game_map: GameMap, player: Entity): def __init__(self, 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()
Because we’ve modified the definition of Engine.__init__
, we need to modify main.py
where we create our game_map
variable. We might as well remove that npc
as well, since we won’t be needing it anymore.
...
player = Entity(int(screen_width / 2), int(screen_height / 2), "@", (255, 255, 255))
- npc = Entity(int(screen_width / 2 - 5), int(screen_height / 2), "@", (255, 255, 0))
- entities = {npc, player}
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,
player=player,
)
- engine = Engine(entities=entities, event_handler=event_handler, game_map=game_map, player=player)
+ engine = Engine(event_handler=event_handler, game_map=game_map, player=player)
with tcod.context.new_terminal(
...
... player = Entity(int(screen_width / 2), int(screen_height / 2), "@", (255, 255, 255)) npc = Entity(int(screen_width / 2 - 5), int(screen_height / 2), "@", (255, 255, 0)) entities = {npc, player} 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, player=player, ) engine = Engine(entities=entities, event_handler=event_handler, game_map=game_map, player=player) engine = Engine(event_handler=event_handler, game_map=game_map, player=player) with tcod.context.new_terminal( ...
We can remove the part in Engine.render
that loops through the entities and renders the ones that are visible. That part will also be handled by the GameMap
from now on.
class Engine:
...
def render(self, console: Console, context: Context) -> None:
self.game_map.render(console)
- for entity in self.entities:
- # 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)
class Engine: ... def render(self, console: Console, context: Context) -> None: self.game_map.render(console) for entity in self.entities: # 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)
We can move this block into GameMap.render
, though take note that the line that checks for visibility has a slight change: it goes from:
if self.game_map.visible[entity.x, entity.y]:
To:
if self.visible[entity.x, entity.y]:
.
class GameMap:
...
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
)
+ for entity in self.entities:
+ # Only print entities that are in the FOV
+ if self.visible[entity.x, entity.y]:
+ console.print(x=entity.x, y=entity.y, string=entity.char, fg=entity.color)
class GameMap:
...
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
)
for entity in self.entities:
# Only print entities that are in the FOV
if self.visible[entity.x, entity.y]:
console.print(x=entity.x, y=entity.y, string=entity.char, fg=entity.color)
Finally, we need to alter the part in generate_dungeon
that creates the instance of GameMap
, so that the player
is passed into the entities
argument.
def generate_dungeon(
max_rooms: int,
room_min_size: int,
room_max_size: int,
map_width: int,
map_height: int,
player: Entity,
) -> GameMap:
"""Generate a new dungeon map."""
- dungeon = GameMap(map_width, map_height)
+ dungeon = GameMap(map_width, map_height, entities=[player])
rooms: List[RectangularRoom] = []
...
def generate_dungeon( max_rooms: int, room_min_size: int, room_max_size: int, map_width: int, map_height: int, player: Entity, ) -> GameMap: """Generate a new dungeon map.""" dungeon = GameMap(map_width, map_height) dungeon = GameMap(map_width, map_height, entities=[player]) rooms: List[RectangularRoom] = [] ...
If you run the project now, things should look the same as before, minus the NPC that we had earlier for testing.
Now, moving on to actually placing monsters in our dungeon. Our logic will be simple enough: For each room that’s created in our dungeon, we’ll place a random number of enemies, between 0 and a maximum (2 for now). We’ll make it so that there’s an 80% chance of spawning an Orc (a weaker enemy) and a 20% chance of it being a Troll (a stronger enemy).
In order to specify the maximum number of monsters that can be spawned into a room, let’s create a new variable, max_monsters_per_room
, and place it in main.py
. We’ll also modify our call to generate_dungeon
to pass this new variable in.
...
max_rooms = 30
+ max_monsters_per_room = 2
tileset = tcod.tileset.load_tilesheet(
"dejavu10x10_gs_tc.png", 32, 8, tcod.tileset.CHARMAP_TCOD
)
event_handler = EventHandler()
player = Entity(int(screen_width / 2), int(screen_height / 2), "@", (255, 255, 255))
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,
player=player
)
engine = Engine(event_handler=event_handler, game_map=game_map, player=player)
...
... max_rooms = 30 max_monsters_per_room = 2 tileset = tcod.tileset.load_tilesheet( "dejavu10x10_gs_tc.png", 32, 8, tcod.tileset.CHARMAP_TCOD ) event_handler = EventHandler() player = Entity(int(screen_width / 2), int(screen_height / 2), "@", (255, 255, 255)) 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, player=player ) engine = Engine(event_handler=event_handler, game_map=game_map, player=player) ...
Pretty straightforward. Now we’ll need to modify the definition of generate_dungeon
to take this new variable, like this:
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,
player: Entity,
) -> GameMap:
"""Generate a new dungeon map."""
dungeon = GameMap(map_width, map_height, entities=[player])
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,
player: Entity,
) -> GameMap:
"""Generate a new dungeon map."""
dungeon = GameMap(map_width, map_height, entities=[player])
Easy enough, but now how do we actually place the enemies?
After we’ve created our room, we’ll want to call a function to put the entities in their places. Let’s call the function place_entities
, and it will take three arguments: The RectangularRoom
that we’ve created, the dungeon
so that it can add the entities to it (remember that dungeon
is an instance of GameMap
, which now holds entities), and the max_monsters_per_room
, so that we know how many monsters to make.
While we haven’t written the function yet, let’s place our call to it in generate_dungeon
:
...
dungeon.tiles[x, y] = tile_types.floor
+ place_entities(new_room, dungeon, max_monsters_per_room)
# Finally, append the new room to the list.
rooms.append(new_room)
return dungeon
...
dungeon.tiles[x, y] = tile_types.floor
place_entities(new_room, dungeon, max_monsters_per_room)
# Finally, append the new room to the list.
rooms.append(new_room)
return dungeon
Now, let’s write the place_entities
function so that this actually works.
Our first version of place_entities
won’t actually place the entities. Why not? Because we’ll need to do a few other things to make spawning the entities here work. However, we can at least fill in most of the function, and skip over the part that actually creates the entities for the moment.
Create the function like this:
class RectangularRoom:
...
+def place_entities(
+ room: RectangularRoom, dungeon: GameMap, maximum_monsters: int,
+) -> None:
+ number_of_monsters = random.randint(0, maximum_monsters)
+ for i in range(number_of_monsters):
+ x = random.randint(room.x1 + 1, room.x2 - 1)
+ y = random.randint(room.y1 + 1, room.y2 - 1)
+ if not any(entity.x == x and entity.y == y for entity in dungeon.entities):
+ if random.random() < 0.8:
+ pass # TODO: Place an Orc here
+ else:
+ pass # TODO: Place a Troll here
def tunnel_between(
...
class RectangularRoom:
...
def place_entities(
room: RectangularRoom, dungeon: GameMap, maximum_monsters: int,
) -> None:
number_of_monsters = random.randint(0, maximum_monsters)
for i in range(number_of_monsters):
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
if not any(entity.x == x and entity.y == y for entity in dungeon.entities):
if random.random() < 0.8:
pass # TODO: Place an Orc here
else:
pass # TODO: Place a Troll here
def tunnel_between(
...
The first line in the function takes a random number between 0 and the provided maximum (2, in this case). From there, it iterates from 0 to the number.
We select a random x
and y
to place the entity, and do a quick check to make sure there’s no other entities in that location before dropping the enemy there. This is to ensure we don’t get stacks of enemies.
As described earlier, there should be an 80% chance of there being an Orc, and 20% chance for a Troll. For now, we’re using pass
to skip over actually putting them down, because that requires a bit more work first.
There’s a few ways we could go about creating the new entities. Assuming that every Orc and Troll we spawn will always have the same attributes as their brethren, we can create initial instances of orc
and troll
, then copy those every time we want to create a new one.
Why not just create the entities right here in the function? We could (the 1st version of this tutorial does, in fact), but that’s a bit of a pain to go back and edit. Imagine if you had 100 enemies in your game at some point in the future. Would you rather search for those entity definitions in one file that only exists to define entities, or try finding it in the file that generates our dungeon? Not to mention, what happens if you want to create a new dungeon generator? Are you going to copy over the entity definitions and have them defined in two places?
Let’s modify Entity
to prepare for this new copying method. Modify entity.py
like this:
+from __future__ import annotations
+import copy
-from typing import Tuple
+from typing import 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.
"""
- def __init__(self, x: int, y: int, char: str, color: Tuple[int, int, int]):
+ def __init__(
+ self,
+ 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
+ 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
+ gamemap.entities.add(clone)
+ return clone
def move(self, dx: int, dy: int) -> None:
...
from __future__ import annotations import copy from typing import Tuple from typing import 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. """ def __init__(self, x: int, y: int, char: str, color: Tuple[int, int, int]): def __init__( self, 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 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 gamemap.entities.add(clone) return clone def move(self, dx: int, dy: int) -> None: ...
We’ve added two new attributes to Entity
: name
and blocks_movement
. name
is straightforward: it’s what the Entity is called. blocks_movement
describes whether or not this Entity
can be moved over or not. Enemies will have blocks_movement
set to True
, while in the future, things like consumable items and equipment will be set to False
.
Notice that we’ve also provided defaults for each of the attributes in the __init__
function as well, whereas we were not before. This is because we’ll soon not need to pass x
and y
during the initialization. More on that in a second.
The more complex section is the spawn
method. It takes the GameMap
instance, along with x
and y
for locations. It then creates a clone
of the instance of Entity
, and assigns the x
and y
variables to it (this is why we don’t need x
and y
in the initializer anymore, they’re set here). It then adds the entity to the gamemap
’s entities, and returns the clone
.
This new spawn
method will probably make a lot more sense by putting it to use. To do that, let’s create a new file, called entity_factories.py
, and fill it with the following contents:
from entity import Entity
player = Entity(char="@", color=(255, 255, 255), name="Player", blocks_movement=True)
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)
This is where we’re defining our entities. player
should look familiar, and orc
and troll
are not all that different, besides their characters and colors.
These are the instances we’ll be cloning to create our new entities. Using these, we can at last fill in our place_entities
function back in procgen.py
.
...
import tcod
+import entity_factories
from game_map import GameMap
...
...
if random.random() < 0.8:
- pass # TODO: Place an Orc here
+ entity_factories.orc.spawn(dungeon, x, y)
else:
- pass # TODO: Place a Troll here
+ entity_factories.troll.spawn(dungeon, x, y)
... import tcod import entity_factories from game_map import GameMap ... ... if random.random() < 0.8: pass # TODO: Place an Orc here entity_factories.orc.spawn(dungeon, x, y) else: pass # TODO: Place a Troll here entity_factories.troll.spawn(dungeon, x, y)
Let’s also modify the way we create the player
:
#!/usr/bin/env python3
+import copy
import tcod
from engine import Engine
-from entity import Entity
+import entity_factories
from input_handlers import EventHandler
from procgen import generate_dungeon
...
...
event_handler = EventHandler()
- player = Entity(int(screen_width / 2), int(screen_height / 2), "@", (255, 255, 255))
+ player = copy.deepcopy(entity_factories.player)
game_map = generate_dungeon(
...
#!/usr/bin/env python3 import copy import tcod from engine import Engine from entity import Entity import entity_factories from input_handlers import EventHandler from procgen import generate_dungeon ... ... event_handler = EventHandler() player = Entity(int(screen_width / 2), int(screen_height / 2), "@", (255, 255, 255)) player = copy.deepcopy(entity_factories.player) game_map = generate_dungeon( ...
Note: We can’t use player.spawn
here, because spawn
requires the GameMap
, which isn’t created until after we create the player.
With that, your dungeon should now be populated with enemies.
They’re… not exactly intimidating, are they? In fact, they don’t really do much of anything right now. But that’s okay, we’ll work on that.
The first step towards making our monsters scarier is making them stand their ground… literally! The player can currently walk over (or under) the enemies by simply moving into the same space. Let’s fix that, and ensure that when the player tries to move towards an enemy, we attack instead.
To begin, we need to determine if the space the player is trying to move into has an Entity in it. Not just any Entity, however: we’ll check if the Entity has “blocks_movement” set to True
. If it does, our player can’t move there, and tries to attack instead.
Add the following to the map:
from __future__ import annotations
-from typing import Iterable, TYPE_CHECKING
+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 entity import Entity
class GameMap:
def __init__(self, width: int, height: int, entities: Iterable[Entity] = ()):
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.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]:
+ for entity in self.entities:
+ 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:
...
from __future__ import annotations from typing import Iterable, TYPE_CHECKING 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 entity import Entity class GameMap: def __init__(self, width: int, height: int, entities: Iterable[Entity] = ()): 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.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]: for entity in self.entities: 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: ...
This new function iterates through all the entities
, and if one is found that both blocks movement and occupies the given location_x
and location_y
coordinates, it returns that Entity. Otherwise, we return None
instead.
Where can we check if a tile is occupied or not? And what do we do if it is?
One way to handle all this is to modify our “actions” a bit. Our current MovementAction
doesn’t take into account what occupies the tile we’re moving into. That’s fine, it doesn’t necessarily need to, but there probably should be an action that does. What if we created an Action
subclass that could tell what was in the tile, and call either MovementAction
if it was empty, or some other “attack” action if it wasn’t?
Let’s do a few things. We’ll start by defining a new class, called ActionWithDirection
, which will actually become the new superclass for MovementAction
. This new class will take the initializer from MovementAction
, but won’t implement its own perform
method. It looks like this:
...
class EscapeAction(Action):
def perform(self, engine: Engine, entity: Entity) -> None:
raise SystemExit()
+class ActionWithDirection(Action):
+ def __init__(self, dx: int, dy: int):
+ super().__init__()
+ self.dx = dx
+ self.dy = dy
+ def perform(self, engine: Engine, entity: Entity) -> None:
+ raise NotImplementedError()
-class MovementAction(Action):
+class MovementAction(ActionWithDirection):
- def __init__(self, dx: int, dy: int):
- super().__init__()
- self.dx = dx
- self.dy = dy
def perform(self, engine: Engine, entity: Entity) -> None:
dest_x = entity.x + self.dx
dest_y = entity.y + self.dy
if not engine.game_map.in_bounds(dest_x, dest_y):
return # Destination is out of bounds.
if not engine.game_map.tiles["walkable"][dest_x, dest_y]:
return # Destination is blocked by a tile.
+ if engine.game_map.get_blocking_entity_at_location(dest_x, dest_y):
+ return # Destination is blocked by an entity.
entity.move(self.dx, self.dy)
... class EscapeAction(Action): def perform(self, engine: Engine, entity: Entity) -> None: raise SystemExit() class ActionWithDirection(Action): def __init__(self, dx: int, dy: int): super().__init__() self.dx = dx self.dy = dy def perform(self, engine: Engine, entity: Entity) -> None: raise NotImplementedError() class MovementAction(Action): class MovementAction(ActionWithDirection): def __init__(self, dx: int, dy: int): super().__init__() self.dx = dx self.dy = dy def perform(self, engine: Engine, entity: Entity) -> None: dest_x = entity.x + self.dx dest_y = entity.y + self.dy if not engine.game_map.in_bounds(dest_x, dest_y): return # Destination is out of bounds. if not engine.game_map.tiles["walkable"][dest_x, dest_y]: return # Destination is blocked by a tile. if engine.game_map.get_blocking_entity_at_location(dest_x, dest_y): return # Destination is blocked by an entity. entity.move(self.dx, self.dy)
Notice that we’ve added an extra check in MovementAction
to ensure we’re not moving into a space with a blocking entity. Theoretically, this bit of code won’t ever trigger, but it’s nice to have it there as a safeguard.
But wait, MovementAction
still doesn’t do anything differently. So what’s the point? Well, now we can use the new ActionWithDirection
class to define two more subclasses, which will do what we want.
The first one will be the action we use to actually attack. It looks like this:
class ActionWithDirection(Action):
def __init__(self, dx: int, dy: int):
super().__init__()
self.dx = dx
self.dy = dy
def perform(self, engine: Engine, entity: Entity) -> None:
raise NotImplementedError()
+class MeleeAction(ActionWithDirection):
+ 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, engine: Engine, entity: Entity) -> None:
dest_x = entity.x + self.dx
dest_y = entity.y + self.dy
if not engine.game_map.in_bounds(dest_x, dest_y):
return # Destination is out of bounds.
if not engine.game_map.tiles["walkable"][dest_x, dest_y]:
return # Destination is blocked by a tile.
if engine.game_map.get_blocking_entity_at_location(dest_x, dest_y)
return # Destination is blocked by an entity.
entity.move(self.dx, self.dy)
class ActionWithDirection(Action):
def __init__(self, dx: int, dy: int):
super().__init__()
self.dx = dx
self.dy = dy
def perform(self, engine: Engine, entity: Entity) -> None:
raise NotImplementedError()
class MeleeAction(ActionWithDirection):
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, engine: Engine, entity: Entity) -> None:
dest_x = entity.x + self.dx
dest_y = entity.y + self.dy
if not engine.game_map.in_bounds(dest_x, dest_y):
return # Destination is out of bounds.
if not engine.game_map.tiles["walkable"][dest_x, dest_y]:
return # Destination is blocked by a tile.
if engine.game_map.get_blocking_entity_at_location(dest_x, dest_y):
return # Destination is blocked by an entity.
entity.move(self.dx, self.dy)
Just like MovementAction
, MeleeAction
inherits from ActionWithDirection
. The perform
method it implements is what we’ll use to attack… eventually. Right now, we’re just printing out a little message. The actual attacking will have to wait until the next part (this one is getting long as it is).
Still, we’re not actually using MeleeAction
anywhere, yet. Let’s add one more class, which is what will make the determination on whether our player is moving or attacking:
class MovementAction(ActionWithDirection):
def perform(self, engine: Engine, entity: Entity) -> None:
dest_x = entity.x + self.dx
dest_y = entity.y + self.dy
if not engine.game_map.in_bounds(dest_x, dest_y):
return # Destination is out of bounds.
if not engine.game_map.tiles["walkable"][dest_x, dest_y]:
return # Destination is blocked by a tile.
if engine.game_map.get_blocking_entity_at_location(dest_x, dest_y):
return # Destination is blocked by an entity.
entity.move(self.dx, self.dy)
+class BumpAction(ActionWithDirection):
+ 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.dx, self.dy).perform(engine, entity)
class MovementAction(ActionWithDirection):
def perform(self, engine: Engine, entity: Entity) -> None:
dest_x = entity.x + self.dx
dest_y = entity.y + self.dy
if not engine.game_map.in_bounds(dest_x, dest_y):
return # Destination is out of bounds.
if not engine.game_map.tiles["walkable"][dest_x, dest_y]:
return # Destination is blocked by a tile.
if engine.game_map.get_blocking_entity_at_location(dest_x, dest_y):
return # Destination is blocked by an entity.
entity.move(self.dx, self.dy)
class BumpAction(ActionWithDirection):
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.dx, self.dy).perform(engine, entity)
This class also inherits from ActionWithDirection
, but its perform
method doesn’t actually perform anything, except deciding which class, between MeleeAction
and MovementAction
to return. Those classes are what are actually doing the work. BumpAction
just determines which one is appropriate to call, based on whether there is a blocking entity at the given destination or not. Notice we’re using the function we defined earlier in our map to decide if there’s a valid target or not.
Now that our new actions are in place, we need to modify our input_handlers.py
file to use BumpAction
instead of MovementAction
. It’s a pretty simple change:
from typing import Optional
import tcod.event
-from actions import Action, EscapeAction, MovementAction
+from actions import Action, BumpAction, EscapeAction
class EventHandler(tcod.event.EventDispatch[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
if key == tcod.event.K_UP:
- action = MovementAction(dx=0, dy=-1)
+ action = BumpAction(dx=0, dy=-1)
elif key == tcod.event.K_DOWN:
- action = MovementAction(dx=0, dy=1)
+ action = BumpAction(dx=0, dy=1)
elif key == tcod.event.K_LEFT:
- action = MovementAction(dx=-1, dy=0)
+ action = BumpAction(dx=-1, dy=0)
elif key == tcod.event.K_RIGHT:
- action = MovementAction(dx=1, dy=0)
+ action = BumpAction(dx=1, dy=0)
elif key == tcod.event.K_ESCAPE:
action = EscapeAction()
# No valid key was pressed
return action
from typing import Optional import tcod.event from actions import Action, EscapeAction, MovementAction from actions import Action, BumpAction, EscapeAction class EventHandler(tcod.event.EventDispatch[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 if key == tcod.event.K_UP: action = MovementAction(dx=0, dy=-1) action = BumpAction(dx=0, dy=-1) elif key == tcod.event.K_DOWN: action = MovementAction(dx=0, dy=1) action = BumpAction(dx=0, dy=1) elif key == tcod.event.K_LEFT: action = MovementAction(dx=-1, dy=0) action = BumpAction(dx=-1, dy=0) elif key == tcod.event.K_RIGHT: action = MovementAction(dx=1, dy=0) action = BumpAction(dx=1, dy=0) elif key == tcod.event.K_ESCAPE: action = EscapeAction() # No valid key was pressed return action
Run the project now. At this point, you shouldn’t be able to move over the enemies, and you should get a message in the terminal, indicating that you’re attacking the enemy (albeit not for any damage).
Before we wrap this part up, let’s set ourselves up to allow for enemy turns as well. They won’t actually be doing anything at the moment, we’ll just get a message in the terminal that indicates something is happening.
Add these small modifications to engine.py
:
class Engine:
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.
class Engine: 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.
The handle_enemy_turns
function loops through each entity (minus the player) and prints out a message for them. In the next part, we’ll replace this with some code that will allow those entities to take real turns.
We call handle_enemy_turns
right after action.perform
, so that the enemies move right after the player. Other roguelike games have more complex timing mechanisms for when entities take their turns, but our tutorial will stick with probably the simplest method of all: the player moves, then all the enemies move.
That’s all for this chapter. Next time, we’ll look at moving the enemies around on their turns, and doing some real damage to both the enemies and the player.
If you want to see the code so far in its entirety, click here.