Part 2 - The generic Entity, the render functions, and the map
Now that we can move our little ‘@’ symbol around, we need to give it something to move around in. But before that, let’s stop for a moment and think about the player object itself.
Right now, we just represent the player with the ‘@’ symbol, and its x and y coordinates. Shouldn’t we tie those things together in an object, along with some other data and functions that pertain to it?
Let’s create a generic class to represent not just the player, but just
about everything in our game world. Enemies, items, and whatever other
foreign entities we can dream of will be part of this class, which we’ll
call Entity
.
Create a new file, and call it entity.py
. In that file, put the
following class:
from typing import Tuple
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]):
self.x = x
self.y = y
self.char = char
self.color = color
def move(self, dx: int, dy: int) -> None:
# Move the entity by a given amount
self.x += dx
self.y += dy
The initializer (__init__
) takes four arguments: x
, y
, char
, and color
.
x
andy
are pretty self explanatory: They represent the Entity’s “x” and “y” coordinates on the map.char
is the character we’ll use to represent the entity. Our player will be an “@” symbol, whereas something like a Troll (coming in a later chapter) can be the letter “T”.color
is the color we’ll use when drawing the Entity. We definecolor
as a Tuple of three integers, representing the entity’s RGB values.
The other method is move
, which takes dx
and dy
as arguments, and uses them to modify the Entity’s position. This should look familiar to what we did in the last chapter.
Let’s put our fancy new class into action! Modify the first part of
main.py
to look like this:
#!/usr/bin/env python3
import tcod
from actions import EscapeAction, MovementAction
+from entity import Entity
from input_handlers import EventHandler
def main() -> None:
screen_width = 80
screen_height = 50
- player_x = int(screen_width / 2)
- player_y = int(screen_height / 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))
+ npc = Entity(int(screen_width / 2 - 5), int(screen_height / 2), "@", (255, 255, 0))
+ entities = {npc, player}
with tcod.context.new_terminal(
...
#!/usr/bin/env python3 import tcod from actions import EscapeAction, MovementAction from entity import Entity from input_handlers import EventHandler def main() -> None: screen_width = 80 screen_height = 50 player_x = int(screen_width / 2) player_y = int(screen_height / 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)) npc = Entity(int(screen_width / 2 - 5), int(screen_height / 2), "@", (255, 255, 0)) entities = {npc, player} with tcod.context.new_terminal( ...
We’re importing the Entity
class into main.py
, and using it to
initialize the player and a new NPC. We store these two in a set, that
will eventually hold all our entities on the map.
Also modify the part where we handle movement so that the Entity class handles the actual movement.
if isinstance(action, MovementAction):
- player_x += action.dx
- player_y += action.dy
+ player.move(dx=action.dx, dy=action.dy)
if isinstance(action, MovementAction): player_x += action.dx player_y += action.dy player.move(dx=action.dx, dy=action.dy)
Lastly, update the drawing functions to use the new player object:
while True:
- root_console.print(x=player_x, y=player_y, string="@")
+ root_console.print(x=player.x, y=player.y, string=player.char, fg=player.color)
context.present(root_console)
while True: root_console.print(x=player_x, y=player_y, string="@") root_console.print(x=player.x, y=player.y, string=player.char, fg=player.color) context.present(root_console)
If you run the project now, only the player gets drawn. We’ll need to modify things to draw both entities, and eventually, draw the map we’re going to create as well.
Before doing that, it’s worth stopping and taking a moment to think about our overall design. Currently, our main.py
file is responsible for:
- Setting up the initial variables, like screen size and the tileset.
- Creating the entities
- Drawing the screen and everything on it.
- Reacting to the player’s input.
Soon, we’re going to need to add a map as well. It’s starting to become a bit much.
One thing we can do is pass of some of these responsibilities to another class, which will be responsible for “running” our game. The main.py
file can still set things up and tell that new class what to do, but this design should help keep the main.py
file from getting too large over time.
Let’s create an Engine
class, which will take the responsibilities of drawing the map and entities, as well as handling the player’s input. Create a new file, and call it engine.py
. In that file, put the following contents:
from typing import Set, Iterable, Any
from tcod.context import Context
from tcod.console import Console
from actions import EscapeAction, MovementAction
from entity import Entity
from input_handlers import EventHandler
class Engine:
def __init__(self, entities: Set[Entity], event_handler: EventHandler, player: Entity):
self.entities = entities
self.event_handler = event_handler
self.player = player
def handle_events(self, events: Iterable[Any]) -> None:
for event in events:
action = self.event_handler.dispatch(event)
if action is None:
continue
if isinstance(action, MovementAction):
self.player.move(dx=action.dx, dy=action.dy)
elif isinstance(action, EscapeAction):
raise SystemExit()
def render(self, console: Console, context: Context) -> None:
for entity in self.entities:
console.print(entity.x, entity.y, entity.char, fg=entity.color)
context.present(console)
console.clear()
Let’s walk through the class a bit, to understand what we’re trying to get at here.
class Engine:
def __init__(self, entities: Set[Entity], event_handler: EventHandler, player: Entity):
self.entities = entities
self.event_handler = event_handler
self.player = player
The __init__
function takes three arguments:
entities
is a set (of entities), which behaves kind of like a list that enforces uniqueness. That is, we can’t add an Entity to the set twice, whereas a list would allow that. In our case, having an entity inentities
twice doesn’t make sense.event_handler
is the sameevent_handler
that we used inmain.py
. It will handle our events.player
is the player Entity. We have a separate reference to it outside ofentities
for ease of access. We’ll need to accessplayer
a lot more than a random entity inentities
.
def handle_events(self, events: Iterable[Any]) -> None:
for event in events:
action = self.event_handler.dispatch(event)
if action is None:
continue
if isinstance(action, MovementAction):
self.player.move(dx=action.dx, dy=action.dy)
elif isinstance(action, EscapeAction):
raise SystemExit()
This should look familiar: It’s almost identical to our event processing in main.py
. We pass the events
to it so it can iterate through them, and it uses self.event_handler
to handle the events.
def render(self, console: Console, context: Context) -> None:
for entity in self.entities:
console.print(entity.x, entity.y, entity.char, fg=entity.color)
context.present(console)
console.clear()
This handles drawing our screen. We iterate through the self.entities
and print them to their proper locations, then present the context, and clear the console, like we did in main.py
.
To make use of our new Engine
class, we’ll need to modify main.py
quite a bit.
#!/usr/bin/env python3
import tcod
-from actions import EscapeAction, MovementAction
+from engine import Engine
from entity import Entity
from input_handlers import EventHandler
def main() -> None:
screen_width = 80
screen_height = 50
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))
npc = Entity(int(screen_width / 2 - 5), int(screen_height / 2), "@", (255, 255, 0))
entities = {npc, player}
+ engine = Engine(entities=entities, event_handler=event_handler, player=player)
with tcod.context.new_terminal(
screen_width,
screen_height,
tileset=tileset,
title="Yet Another Roguelike Tutorial",
vsync=True,
) as context:
root_console = tcod.Console(screen_width, screen_height, order="F")
while True:
- root_console.print(x=player_x, y=player_y, string="@")
+ engine.render(console=root_console, context=context)
- context.present(root_console)
+ events = tcod.event.wait()
+ engine.handle_events(events)
- root_console.clear()
- for event in tcod.event.wait():
- action = event_handler.dispatch(event)
- if action is None:
- continue
- if isinstance(action, MovementAction):
- player_x += action.dx
- player_y += action.dy
- elif isinstance(action, EscapeAction):
- raise SystemExit()
if __name__ == "__main__":
main()
#!/usr/bin/env python3 import tcod from actions import EscapeAction, MovementAction from engine import Engine from entity import Entity from input_handlers import EventHandler def main() -> None: screen_width = 80 screen_height = 50 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)) npc = Entity(int(screen_width / 2 - 5), int(screen_height / 2), "@", (255, 255, 0)) entities = {npc, player} engine = Engine(entities=entities, event_handler=event_handler, player=player) with tcod.context.new_terminal( screen_width, screen_height, tileset=tileset, title="Yet Another Roguelike Tutorial", vsync=True, ) as context: root_console = tcod.Console(screen_width, screen_height, order="F") while True: root_console.print(x=player_x, y=player_y, string="@") engine.render(console=root_console, context=context) context.present(root_console) events = tcod.event.wait() engine.handle_events(events) root_console.clear() for event in tcod.event.wait(): action = event_handler.dispatch(event) if action is None: continue if isinstance(action, MovementAction): player_x += action.dx player_y += action.dy elif isinstance(action, EscapeAction): raise SystemExit() if __name__ == "__main__": main()
Because we’ve moved the rendering and event handling code to the Engine
class, we no longer need it in main.py
. All we need to do is create the Engine
instance, pass the needed variables to it, and use the methods we wrote for it.
Run the project now, and your screen should look like this:
Our main.py
file is looking a lot smaller and simpler, and we’ve rendered both the player and the NPC to the screen. With that, we’ll want to move on to creating a map for our entity to move around in. We won’t do the procedural dungeon generation in this chapter (that’s next), but we’ll at least get our class that will hold that map set up.
We can represent the map with a new class, called GameMap
. The map itself will be made up of tiles, which will contain certain data about if the tile is “walkable” (True if it’s a floor, False if its a wall), “transparency” (again, True for floors, False for walls), and how to render the tile to the screen.
We’ll create the tiles
first. Create a new file called tile_types.py
and fill it with the following contents:
from typing import Tuple
import numpy as np # type: ignore
# Tile graphics structured type compatible with Console.tiles_rgb.
graphic_dt = np.dtype(
[
("ch", np.int32), # Unicode codepoint.
("fg", "3B"), # 3 unsigned bytes, for RGB colors.
("bg", "3B"),
]
)
# Tile struct used for statically defined tile data.
tile_dt = np.dtype(
[
("walkable", np.bool), # True if this tile can be walked over.
("transparent", np.bool), # True if this tile doesn't block FOV.
("dark", graphic_dt), # Graphics for when this tile is not in FOV.
]
)
def new_tile(
*, # Enforce the use of keywords, so that parameter order doesn't matter.
walkable: int,
transparent: int,
dark: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]],
) -> np.ndarray:
"""Helper function for defining individual tile types """
return np.array((walkable, transparent, dark), dtype=tile_dt)
floor = new_tile(
walkable=True, transparent=True, dark=(ord(" "), (255, 255, 255), (50, 50, 150)),
)
wall = new_tile(
walkable=False, transparent=False, dark=(ord(" "), (255, 255, 255), (0, 0, 100)),
)
That’s quite a lot to take in all at once. Let’s go through it.
# Tile graphics structured type compatible with Console.tiles_rgb.
graphic_dt = np.dtype(
[
("ch", np.int32), # Unicode codepoint.
("fg", "3B"), # 3 unsigned bytes, for RGB colors.
("bg", "3B"),
]
)
dtype
creates a data type which Numpy can use, which behaves similarly to a struct
in a language like C. Our data type is made up of three parts:
ch
: The character, represented in integer format. We’ll translate it from the integer into Unicode.fg
: The foreground color. “3B” means 3 unsigned bytes, which can be used for RGB color codes.bg
: The background color. Similar tofg
.
We take this new data type and use it in the next bit:
# Tile struct used for statically defined tile data.
tile_dt = np.dtype(
[
("walkable", np.bool), # True if this tile can be walked over.
("transparent", np.bool), # True if this tile doesn't block FOV.
("dark", graphic_dt), # Graphics for when this tile is not in FOV.
]
)
This is yet another dtype
, which we’ll use in the actual tile itself. It’s also made up of three parts:
walkable
: A boolean that describes if the player can walk across this tile.transparent
: A boolean that describes if this tile does or does not block the field of view. Not used in this chapter, but will be in chapter 4.dark
: This uses our previously defineddtype
, which holds the character to print, the foreground color, and the background color. Why is it calleddark
? Because later on, we’ll want to differentiate between tiles that are and aren’t in the field of view.dark
will represent tiles that are not in the current field of view. Again, we’ll cover that in part 4.
def new_tile(
*, # Enforce the use of keywords, so that parameter order doesn't matter.
walkable: int,
transparent: int,
dark: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]],
) -> np.ndarray:
"""Helper function for defining individual tile types """
return np.array((walkable, transparent, dark), dtype=tile_dt)
This is a helper function, that we’ll use in the next section to define our tile types. It takes the parameters walkable
, transparent
, and dark
, which should look familiar, since they’re the same data points we used in tile_dt
. It creates a Numpy array of just the one tile_dt
element, and returns it.
floor = new_tile(
walkable=True, transparent=True, dark=(ord(" "), (255, 255, 255), (50, 50, 150)),
)
wall = new_tile(
walkable=False, transparent=False, dark=(ord(" "), (255, 255, 255), (0, 0, 100)),
)
Finally, we arrive to our actual tile types. We’ve got two: floor
and wall
.
floor
is both walkable
and transparent
. Its dark
attribute consists of the space character (feel free to change this to something else, a lot of roguelikes use “#”) and defines its foreground color as white (won’t matter since it’s an empty space) and a background color.
wall
is neither walkable
nor transparent
, and its dark
attribute differs from floor
slightly in its background color.
Now let’s use our newly created tiles by creating our map class. Create a file called game_map.py
and fill it with the following:
import numpy as np # type: ignore
from tcod.console import Console
import tile_types
class GameMap:
def __init__(self, width: int, height: int):
self.width, self.height = width, height
self.tiles = np.full((width, height), fill_value=tile_types.floor, order="F")
self.tiles[30:33, 22] = tile_types.wall
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:
console.tiles_rgb[0:self.width, 0:self.height] = self.tiles["dark"]
Let’s break down GameMap
a bit:
def __init__(self, width: int, height: int):
self.width, self.height = width, height
self.tiles = np.full((width, height), fill_value=tile_types.floor, order="F")
self.tiles[30:33, 22] = tile_types.wall
The initializer takes width
and height
integers and assigns them, in one line.
The self.tiles
line might look a little strange if you’re not used to Numpy. Basically, we create a 2D array, filled with the same values, which in this case, is the tile_types.floor
that we created earlier. This will fill self.tiles
with floor tiles.
self.tiles[30:33, 22] = tile_types.wall
creates a small, three tile wide wall at the specified location. We won’t normally hard-code walls like this, the wall is just for demonstration purposes. We’ll remove it in the next part.
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
As the docstring alludes to, this method returns True
if the given x and y values are within the map’s boundaries. We can use this to ensure the player doesn’t move beyond the map, into the void.
def render(self, console: Console) -> None:
console.tiles_rgb[0:self.width, 0:self.height] = self.tiles["dark"]
Using the Console
class’s tiles_rgb
method, we can quickly render the entire map. This method proves much faster than using the console.print
method that we use for the individual entities.
With our GameMap
class ready to go, let’s modify main.py
to make use of it. We’ll also need to modify Engine
to hold the map. Let’s start with main.py
though:
#!/usr/bin/env python3
import tcod
from engine import Engine
from entity import Entity
+from game_map import GameMap
from input_handlers import EventHandler
def main() -> None:
screen_width = 80
screen_height = 50
+ map_width = 80
+ map_height = 45
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))
npc = Entity(int(screen_width / 2 - 5), int(screen_height / 2), "@", (255, 255, 0))
entities = {npc, player}
+ game_map = GameMap(map_width, map_height)
- engine = Engine(entities=entities, event_handler=event_handler, player=player)
+ engine = Engine(entities=entities, event_handler=event_handler, game_map=game_map, player=player)
with tcod.context.new_terminal(
screen_width,
screen_height,
tileset=tileset,
title="Yet Another Roguelike Tutorial",
vsync=True,
) as context:
#!/usr/bin/env python3 import tcod from engine import Engine from entity import Entity from game_map import GameMap from input_handlers import EventHandler def main() -> None: screen_width = 80 screen_height = 50 map_width = 80 map_height = 45 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)) npc = Entity(int(screen_width / 2 - 5), int(screen_height / 2), "@", (255, 255, 0)) entities = {npc, player} game_map = GameMap(map_width, map_height) engine = Engine(entities=entities, event_handler=event_handler, player=player) engine = Engine(entities=entities, event_handler=event_handler, game_map=game_map, player=player) with tcod.context.new_terminal( screen_width, screen_height, tileset=tileset, title="Yet Another Roguelike Tutorial", vsync=True, ) as context:
We’ve added map_width
and map_height
, two integers, which we use in the GameMap
class to describe its width and height. The game_map
variable holds our initialized GameMap
, and we then pass it into engine
. The Engine
class doesn’t yet accept a GameMap
in its __init__
function, so let’s fix that now.
from typing import Set, Iterable, Any
from tcod.context import Context
from tcod.console import Console
from actions import EscapeAction, MovementAction
from entity import Entity
+from game_map import GameMap
from input_handlers import EventHandler
class Engine:
- def __init__(self, entities: Set[Entity], event_handler: EventHandler, player: Entity):
+ def __init__(self, entities: Set[Entity], event_handler: EventHandler, game_map: GameMap, player: Entity):
self.entities = entities
self.event_handler = event_handler
+ self.game_map = game_map
self.player = player
def handle_events(self, events: Iterable[Any]) -> None:
for event in events:
action = self.event_handler.dispatch(event)
if action is None:
continue
if isinstance(action, MovementAction):
- self.player.move(dx=action.dx, dy=action.dy)
+ if self.game_map.tiles["walkable"][self.player.x + action.dx, self.player.y + action.dy]:
+ self.player.move(dx=action.dx, dy=action.dy)
elif isinstance(action, EscapeAction):
raise SystemExit()
def render(self, console: Console, context: Context) -> None:
+ self.game_map.render(console)
for entity in self.entities:
console.print(entity.x, entity.y, entity.char, fg=entity.color)
context.present(console)
console.clear()
from typing import Set, Iterable, Any from tcod.context import Context from tcod.console import Console from actions import EscapeAction, MovementAction from entity import Entity from game_map import GameMap from input_handlers import EventHandler class Engine: def __init__(self, entities: Set[Entity], event_handler: EventHandler, player: Entity): def __init__(self, entities: Set[Entity], event_handler: EventHandler, game_map: GameMap, player: Entity): self.entities = entities self.event_handler = event_handler self.game_map = game_map self.player = player def handle_events(self, events: Iterable[Any]) -> None: for event in events: action = self.event_handler.dispatch(event) if action is None: continue if isinstance(action, MovementAction): self.player.move(dx=action.dx, dy=action.dy) if self.game_map.tiles["walkable"][self.player.x + action.dx, self.player.y + action.dy]: self.player.move(dx=action.dx, dy=action.dy) elif isinstance(action, EscapeAction): raise SystemExit() def render(self, console: Console, context: Context) -> None: self.game_map.render(console) for entity in self.entities: console.print(entity.x, entity.y, entity.char, fg=entity.color) context.present(console) console.clear()
We’ve imported the GameMap
class, and we’re now passing an instance of it in the Engine
class’s initializer. From there, we utilize it in two ways:
- In
handle_events
, we use it to check if the tile is “walkable”, and only then do we move the player. - In
render
, we call theGameMap
’srender
method to draw it to the screen.
If you run the project now, it should look like this:
The darker squares represent the wall, which, if you try to move your character through, should prove to be impenetrable.
Before we finish this up, there’s one last improvement we can make, thanks to our new Engine
class: We can expand our Action
classes to do a bit more of the heavy lifting, rather than leaving it to the Engine
. This is because we can pass the Engine
to the Action
, providing it with the context it needs to do what we want.
Here’s what that looks like:
+from __future__ import annotations
+from typing import TYPE_CHECKING
+if TYPE_CHECKING:
+ from engine import Engine
+ from entity import Entity
class Action:
- pass
+ def perform(self, engine: Engine, entity: Entity) -> None:
+ """Perform this action with the objects needed to determine its scope.
+ `engine` is the scope this action is being performed in.
+ `entity` is the object performing the action.
+ This method must be overridden by Action subclasses.
+ """
+ raise NotImplementedError()
class EscapeAction(Action):
- pass
+ def perform(self, engine: Engine, entity: Entity) -> None:
+ raise SystemExit()
class MovementAction(Action):
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.
+ entity.move(self.dx, self.dy)
from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from engine import Engine from entity import Entity class Action: pass def perform(self, engine: Engine, entity: Entity) -> None: """Perform this action with the objects needed to determine its scope. `engine` is the scope this action is being performed in. `entity` is the object performing the action. This method must be overridden by Action subclasses. """ raise NotImplementedError() class EscapeAction(Action): pass def perform(self, engine: Engine, entity: Entity) -> None: raise SystemExit() class MovementAction(Action): 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. entity.move(self.dx, self.dy)
Now we’re passing in the Engine
and the Entity
performing the action to each Action
subclass. Each subclass needs to implement its own version of the perform
method. In the case of EscapeAction
, we’re just raising SystemExit
. In the case of MovementAction
, we double check that the move is “in bounds” and on a “walkable” tile, and if either is true, we return without doing anything. If neither of those cases prove true, then we move the entity, as before.
So what does this new technique do for us? As it turns out, we can simplify the Engine.handle_events
method like this:
...
-from actions import EscapeAction, MovementAction
from entity import Entity
from game_map import GameMap
from input_handlers import EventHandler
class Engine:
...
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)
- if isinstance(action, MovementAction):
- if self.game_map.tiles["walkable"][self.player.x + action.dx, self.player.y + action.dy]:
- self.player.move(dx=action.dx, dy=action.dy)
- elif isinstance(action, EscapeAction):
- raise SystemExit()
... from actions import EscapeAction, MovementAction from entity import Entity from game_map import GameMap from input_handlers import EventHandler class Engine: ... 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) if isinstance(action, MovementAction): if self.game_map.tiles["walkable"][self.player.x + action.dx, self.player.y + action.dy]: self.player.move(dx=action.dx, dy=action.dy) elif isinstance(action, EscapeAction): raise SystemExit()
Much simpler! Run the project again, and it should function the same as before.
With that, Part 2 is now complete! We’ve managed to lay the groundwork for generating dungeons and moving through them, which, as it happens, is what the next part is all about.
If you want to see the code so far in its entirety, click here.