From here on the project will be organized into modules. The following files will be added to the project:
/game/__init__.py
/game/actions.py
/game/engine.py
/game/entity.py
/game/game_map.py
/game/rendering.py
/game/__init__.py
will be a blank file as it’s only needed to define a Python package.
# /game/__init__.py
Then we make an Engine
class which is kept in /game/engine.py
:
# /game/engine.py
from __future__ import annotations
import game.entity
import game.game_map
class Engine:
game_map: game.game_map.GameMap
player: game.entity.Entity
from __future__ import annotations
tells Python to do Postponed Evaluation of Annotations, this helps reduce issues from modules referencing each other which can happen often whenever type-hinting is being used.
This will be added to all new modules.
The Engine
class is missing its constructor method __init__
which will normally be defined in all other classes.
It is excluded here because the objects held by Engine
will need Engine
to already exist for they can be created.
We add the annotations for game_map
and player
to the class itself and we will assign these variables manually after creating an instance of Engine
.
game_map
will be a reference to the active map.
player
is a reference to the object currently controlled by the player.
We have a place to put the map in Engine
.
We must now create GameMap
at /game/game_map.py
:
# /game/game_map.py
from __future__ import annotations
from typing import Set
import numpy as np
import game.engine
import game.entity
class GameMap:
def __init__(self, engine: game.engine.Engine, width: int, height: int):
self.engine = engine
self.width, self.height = width, height
self.tiles = np.zeros((width, height), dtype=np.uint8, order="F")
self.entities: Set[game.entity.Entity] = set()
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
GameMap
holds a reference to the Engine
, its own shape, an array of tiles, and a set of entities.
tiles
is an array of integers which defaults to 0, we won’t need large numbers for this so the type is set to np.uint8
.
For now zero will be used for walls and 1 will be used for floors.
entities
is a Python set.
A type-hint must be manually given since the assignment of set()
can not determinate what the set contains.
in_bounds
is added here that we won’t have to copy this code every time we need to check if a coordinate is in the boundary of the map.
Next is /game/entity.py
:
from __future__ import annotations
from typing import Tuple
import game.game_map
class Entity:
"""A generic object to represent players, enemies, items, etc."""
def __init__(
self,
gamemap: game.game_map.GameMap,
x: int,
y: int,
char: str,
color: Tuple[int, int, int],
):
self.gamemap = gamemap
gamemap.entities.add(self)
self.x = x
self.y = y
self.char = char
self.color = color
Entity
’s hold their position and graphics.
They link themselves with a GameMap
, adding themselves to its entities
set and strong the GameMap
in itself.
Now that the map and entity data is setup we can add a way to display their data in /game/rendering.py
:
from __future__ import annotations
import numpy as np
import tcod
import game.game_map
tile_graphics = np.array(
[
(ord("#"), (0x80, 0x80, 0x80), (0x40, 0x40, 0x40)), # wall
(ord("."), (0x40, 0x40, 0x40), (0x18, 0x18, 0x18)), # floor
],
dtype=tcod.console.rgb_graphic,
)
def render_map(console: tcod.Console, gamemap: game.game_map.GameMap) -> None:
console.rgb[0 : gamemap.width, 0 : gamemap.height] = tile_graphics[gamemap.tiles]
for entity in gamemap.entities:
console.print(entity.x, entity.y, entity.char, fg=entity.color)
tile_graphics
holds an array of tile graphics compatible with tcod.Console
.
The first index starts at zero and will be the wall graphic, the 2nd will be the floor graphic.
tcod.console.rgb_graphic is an alias for np.dtype([("ch", np.intc), ("fg", "3B"), ("bg", "3B")])
, and is a structured array type.
The render_map
function draws the map tiles and all entities to a console.
tile_graphics
holds the graphics data and gamemap.tiles
holds a 2D array of indexes into that data.
tile_graphics[gamemap.tiles]
is a form of integer indexing and will result in a 2D array of graphics at the correct positions, which is then assigned to console.rgb
.
The shape of console.rgb
is bigger than gamemap.tiles
so it must be sliced until they both have the same shape.
The next step iterates over all the entities in the map and draws them using Console.print.
The next module is /game/actions.py
which is based loosely on Bob Nystrom’s “Is There More to Game Architecture than ECS?”.
# /game/actions.py
from __future__ import annotations
import game.entity
class Action:
def __init__(self, entity: game.entity.Entity) -> None:
super().__init__()
self.entity = entity # The object performing the action.
self.engine = entity.gamemap.engine
def perform(self) -> None:
"""Perform this action now.
This method must be overridden by Action subclasses.
"""
raise NotImplementedError()
...
Action
is created as an abstract class for all actions.
All actions hold the entity
which they act on, as well as holding onto the engine
for convenience.
The perform
method is called to perform the action, in the base class this raises NotImplementedError
as this is supposed to be overridden buy subclasses.
We continue adding code to /game/actions.py
:
... # /game/actions.py (continued)
class Move(Action):
def __init__(self, entity: game.entity.Entity, dx: int, dy: int):
super().__init__(entity)
self.dx = dx
self.dy = dy
def perform(self) -> None:
dest_x = self.entity.x + self.dx
dest_y = self.entity.y + self.dy
if not self.engine.game_map.in_bounds(dest_x, dest_y):
return # Destination is out of bounds.
if not self.engine.game_map.tiles[dest_x, dest_y]:
return # Destination is blocked by a tile.
self.entity.x, self.entity.y = dest_x, dest_y
The Move
class stores the direction of movement, which can be combined with the current entity
position to get its destination.
Because this is in a module in a package the fully qualified name for Move
is game.actions.Move
, so it won’t be necessary to add the word Action
to every subclass of Action
.
The last new module is /game/input_handlers.py
:
# /game/input_handlers.py
from __future__ import annotations
from typing import Optional, Union
import tcod
import game.actions
import game.engine
import game.rendering
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),
}
ActionOrHandler = Union["game.actions.Action", "EventHandler"]
"""An event handler return value which can trigger an action or switch active handlers.
If a handler is returned then it will become the active handler for future events.
If an action is returned it will be attempted and if it's valid then
MainGameEventHandler will become the active handler.
"""
...
MOVE_KEYS
has been expanded to support diagonal movement and all common layouts.
ActionOrHandler
is a custom Union type which is used by the following class.
Types were put in quotes here since the type they refer to are not available yet.
... # /game/input_handlers.py (continued)
class EventHandler(tcod.event.EventDispatch[ActionOrHandler]):
def __init__(self, engine: game.engine.Engine) -> None:
super().__init__()
self.engine = engine
def handle_events(self, event: tcod.event.Event) -> EventHandler:
"""Handle an event, perform any actions, then return the next active event handler."""
action_or_state = self.dispatch(event)
if isinstance(action_or_state, EventHandler):
return action_or_state
elif isinstance(action_or_state, game.actions.Action):
return self.handle_action(action_or_state)
return self
def handle_action(self, action: game.actions.Action) -> EventHandler:
"""Handle actions returned from event methods."""
action.perform()
return self
def ev_quit(self, event: tcod.event.Quit) -> Optional[ActionOrHandler]:
raise SystemExit(0)
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
key = event.sym
if key in MOVE_KEYS:
dx, dy = MOVE_KEYS[key]
return game.actions.Move(self.engine.player, dx=dx, dy=dy)
elif key == tcod.event.K_ESCAPE:
raise SystemExit(0)
return None
def on_render(self, console: tcod.Console) -> None:
game.rendering.render_map(console, self.engine.game_map)
The EventHandler
class inherits from tcod.event.EventDispatch.
The generic type is filled with ActionOrHandler
which means the event methods (ev_*
) can return a subclass of either the Action
or EventHandler
classes.
To be clear the dispatch
, ev_quit
, and ev_keydown
methods are from EventDispatch
, while the __init__
, handle_events
, handle_action
, and on_render
are unique to the new EventHandler
class.
handle_events
takes an event and calls self.dispatch(event).
What happens next depends on the return value.
If an Action
is returned then handle_action
is called and will perform that action, followed by the handler returning itself as the handler to remain active.
If a EventHandler
was returned then that handier will become active.
None
can also be returned which will make the handier return itself.
Trying to close the window will trigger a call to ev_quit
.
We quit the game in this case.
Key events trigger ev_keydown
, which will return a Move
action if the key is in MOVE_KEYS
or quit the game if escape is pressed.
Unexpected keys will return None
instead.
on_render
is a custom method.
When EventHandler
is the active handler the map will be rendered normally.
Now that the new modules are setup /main.py
has to be modified to use them:
#!/usr/bin/env python3
import tcod
-MOVE_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),
-}
+import game.engine
+import game.entity
+import game.game_map
+import game.input_handlers
def main() -> None:
screen_width = 80
screen_height = 50
- player_x: int = screen_width // 2
- player_y: int = screen_height // 2
+ map_width = 80
+ map_height = 45
tileset = tcod.tileset.load_tilesheet("data/dejavu16x16_gs_tc.png", 32, 8, tcod.tileset.CHARMAP_TCOD)
+ engine = game.engine.Engine()
+ engine.game_map = game.game_map.GameMap(engine, map_width, map_height)
+ engine.game_map.tiles[1:-1, 1:-1] = 1
+ engine.game_map.tiles[30:33, 22] = 0
+ engine.player = game.entity.Entity(engine.game_map, screen_width // 2, screen_height // 2, "@", (255, 255, 255))
+
+ game.entity.Entity(engine.game_map, screen_width // 2 - 5, screen_height // 2, "@", (255, 255, 0)) # NPC
+
+ event_handler = game.input_handlers.EventHandler(engine)
+
with tcod.context.new(
columns=screen_width,
rows=screen_height,
MOVE_KEYS
is no longer needed here and is removed.
The player will become an entity so the player position is no longer needed.
An instance of Engine
must be created since everything else uses it to keep track of the games state.
We marked the class as having variables but these are not filled yet, so we must assign them now before the game loop starts.
A GameMap
is created using the engine and a given size.
The current rendering setup means the map size must be smaller than the window size.
engine.game_map.tiles[1:-1, 1:-1]
selects a view inside of the map that is one tile away from all the sides, this is basic slicing.
Assigning 1 to this view hollows out the map and leaves a 1-tile border of walls.
engine.game_map.tiles[30:33, 22]
selects a small horizontal view which is then filled with walls.
Now the entity for the player is created in the center of the screen.
What makes this the player is assigning to engine.player
, otherwise there is nothing special about this entity.
An NPC is added as a test entity. The game map will keep track of this entity, so it is not assigned to any name.
Now that the engine is setup we create the EventHandler
instance.
Next is the game loop:
root_console = tcod.Console(screen_width, screen_height, order="F")
while True:
root_console.clear()
- root_console.print(x=player_x, y=player_y, string="@")
+ event_handler.on_render(console=root_console)
context.present(root_console)
for event in tcod.event.wait():
- if isinstance(event, tcod.event.Quit):
- raise SystemExit(0)
- if isinstance(event, tcod.event.KeyDown):
- if event.sym in MOVE_KEYS:
- delta_x, delta_y = MOVE_KEYS[event.sym]
- dest_x = player_x + delta_x
- dest_y = player_y + delta_y
- if 0 <= dest_x < screen_width and 0 <= dest_y < screen_height:
- player_x, player_y = dest_x, dest_y
+ event_handler = event_handler.handle_events(event)
The code for drawing the player is replaced with event_handler.on_render
, and the event handling code is replaced with event_handler.handle_events
.
Nothing else is needed, when you run main.py
you will now see a dungeon with a 1-tile border wall, a small wall in the middle, an NPC, and the player.
There will be an empty black area below the dungeon space, this area will be used later for a user interface.
You can see the current progress of this code in its entirety here.