Part 11 - Delving into the Dungeon
Our game isn’t much of a “dungeon crawler” if there’s only one floor to our dungeon. In this chapter, we’ll allow the player to go down a level, and we’ll put a very basic leveling up system in place, to make the dive all the more rewarding.
Before diving into the code for this section, let’s add the color we’ll need this chapter, for when the player descends down a level in the dungeon. Open up color.py
and add this line:
...
enemy_atk = (0xFF, 0xC0, 0xC0)
needs_target = (0x3F, 0xFF, 0xFF)
status_effect_applied = (0x3F, 0xFF, 0x3F)
+descend = (0x9F, 0x3F, 0xFF)
player_die = (0xFF, 0x30, 0x30)
enemy_die = (0xFF, 0xA0, 0x30)
...
...
enemy_atk = (0xFF, 0xC0, 0xC0)
needs_target = (0x3F, 0xFF, 0xFF)
status_effect_applied = (0x3F, 0xFF, 0x3F)
descend = (0x9F, 0x3F, 0xFF)
player_die = (0xFF, 0x30, 0x30)
enemy_die = (0xFF, 0xA0, 0x30)
...
We will use this color later on, when adding a message to the message log that the player went down one floor.
We’ll also need a new tile type to represent the downward stairs in the dungeon. Typically, roguelikes represent this with the >
character, and we’ll do the same. Add the following to tile_types.py
:
...
wall = new_tile(
walkable=False,
transparent=False,
dark=(ord(" "), (255, 255, 255), (0, 0, 100)),
light=(ord(" "), (255, 255, 255), (130, 110, 50)),
)
+down_stairs = new_tile(
+ walkable=True,
+ transparent=True,
+ dark=(ord(">"), (0, 0, 100), (50, 50, 150)),
+ light=(ord(">"), (255, 255, 255), (200, 180, 50)),
+)
...
wall = new_tile(
walkable=False,
transparent=False,
dark=(ord(" "), (255, 255, 255), (0, 0, 100)),
light=(ord(" "), (255, 255, 255), (130, 110, 50)),
)
down_stairs = new_tile(
walkable=True,
transparent=True,
dark=(ord(">"), (0, 0, 100), (50, 50, 150)),
light=(ord(">"), (255, 255, 255), (200, 180, 50)),
)
To keep track of where our downwards stairs are located on the map, we can add a new variable in out __init__
function in the GameMap
class. The variable needs some sort of default, so to start, we can set that up to be (0, 0)
by default. Add the following line to game_map.py
:
class GameMap:
def __init__(
self, engine: Engine, width: int, height: int, entities: Iterable[Entity] = ()
):
...
self.explored = np.full(
(width, height), fill_value=False, order="F"
) # Tiles the player has seen before
+ self.downstairs_location = (0, 0)
@property
def gamemap(self) -> GameMap:
...
class GameMap:
def __init__(
self, engine: Engine, width: int, height: int, entities: Iterable[Entity] = ()
):
...
self.explored = np.full(
(width, height), fill_value=False, order="F"
) # Tiles the player has seen before
self.downstairs_location = (0, 0)
@property
def gamemap(self) -> GameMap:
...
Of course, (0, 0)
won’t be the actual location of the stairs. In order to actually place the downwards stairs, we’ll need to edit our procedural dungeon generator to place the stairs at the proper place. We’ll keep things simple and just place the stairs in the last room that our algorithm generates, by keeping track of the center coordinates of the last room we created. Modify generate_dungeon
function in procgen.py
:
...
rooms: List[RectangularRoom] = []
+ center_of_last_room = (0, 0)
for r in range(max_rooms):
...
...
for x, y in tunnel_between(rooms[-1].center, new_room.center):
dungeon.tiles[x, y] = tile_types.floor
+ center_of_last_room = new_room.center
place_entities(new_room, dungeon, max_monsters_per_room, max_items_per_room)
+ dungeon.tiles[center_of_last_room] = tile_types.down_stairs
+ dungeon.downstairs_location = center_of_last_room
# Finally, append the new room to the list.
rooms.append(new_room)
return dungeon
... rooms: List[RectangularRoom] = [] center_of_last_room = (0, 0) for r in range(max_rooms): ... ... for x, y in tunnel_between(rooms[-1].center, new_room.center): dungeon.tiles[x, y] = tile_types.floor center_of_last_room = new_room.center place_entities(new_room, dungeon, max_monsters_per_room, max_items_per_room) dungeon.tiles[center_of_last_room] = tile_types.down_stairs dungeon.downstairs_location = center_of_last_room # Finally, append the new room to the list. rooms.append(new_room) return dungeon
Whichever room is generated last, we take its center and set the downstairs_location
equal to those coordinates. We also replace whatever tile type with the down_stairs
, so the player can clearly see the location.
To hold the information about the maps, including the size, the room variables (size and maximum number), along with the floor that the player is currently on, we can add a class to hold these variables, as well as generate new maps when the time comes. Open up game_map.py
and add the following class:
class GameMap:
...
+class GameWorld:
+ """
+ Holds the settings for the GameMap, and generates new maps when moving down the stairs.
+ """
+ def __init__(
+ self,
+ *,
+ engine: Engine,
+ map_width: int,
+ map_height: int,
+ max_rooms: int,
+ room_min_size: int,
+ room_max_size: int,
+ max_monsters_per_room: int,
+ max_items_per_room: int,
+ current_floor: int = 0
+ ):
+ self.engine = engine
+ self.map_width = map_width
+ self.map_height = map_height
+ self.max_rooms = max_rooms
+ self.room_min_size = room_min_size
+ self.room_max_size = room_max_size
+ self.max_monsters_per_room = max_monsters_per_room
+ self.max_items_per_room = max_items_per_room
+ self.current_floor = current_floor
+ def generate_floor(self) -> None:
+ from procgen import generate_dungeon
+ self.current_floor += 1
+ self.engine.game_map = generate_dungeon(
+ max_rooms=self.max_rooms,
+ room_min_size=self.room_min_size,
+ room_max_size=self.room_max_size,
+ map_width=self.map_width,
+ map_height=self.map_height,
+ max_monsters_per_room=self.max_monsters_per_room,
+ max_items_per_room=self.max_items_per_room,
+ engine=self.engine,
+ )
class GameMap:
...
class GameWorld:
"""
Holds the settings for the GameMap, and generates new maps when moving down the stairs.
"""
def __init__(
self,
*,
engine: Engine,
map_width: int,
map_height: int,
max_rooms: int,
room_min_size: int,
room_max_size: int,
max_monsters_per_room: int,
max_items_per_room: int,
current_floor: int = 0
):
self.engine = engine
self.map_width = map_width
self.map_height = map_height
self.max_rooms = max_rooms
self.room_min_size = room_min_size
self.room_max_size = room_max_size
self.max_monsters_per_room = max_monsters_per_room
self.max_items_per_room = max_items_per_room
self.current_floor = current_floor
def generate_floor(self) -> None:
from procgen import generate_dungeon
self.current_floor += 1
self.engine.game_map = generate_dungeon(
max_rooms=self.max_rooms,
room_min_size=self.room_min_size,
room_max_size=self.room_max_size,
map_width=self.map_width,
map_height=self.map_height,
max_monsters_per_room=self.max_monsters_per_room,
max_items_per_room=self.max_items_per_room,
engine=self.engine,
)
The generate_floor
method will create the new maps each time we go down a floor, using the variables that GameWorld
stores. In this tutorial, we won’t program in the ability to go back up a floor after going down one, but you could perhaps modify GameWorld
to hold the previous maps.
In order to utilize the new GameWorld
class, we’ll need to add it to the Engine
, like this:
...
if TYPE_CHECKING:
from entity import Actor
- from game_map import GameMap
+ from game_map import GameMap, GameWorld
class Engine:
game_map: GameMap
+ game_world: GameWorld
def __init__(self, player: Actor):
...
... if TYPE_CHECKING: from entity import Actor from game_map import GameMap from game_map import GameMap, GameWorld class Engine: game_map: GameMap game_world: GameWorld def __init__(self, player: Actor): ...
Pretty simple. To utilize the new game_world
class attribute, edit setup_game.py
like this:
import tcod
import color
from engine import Engine
import entity_factories
+from game_map import GameWorld
import input_handlers
-from procgen import generate_dungeon
...
...
engine = Engine(player=player)
- engine.game_map = generate_dungeon(
+ engine.game_world = GameWorld(
+ engine=engine,
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,
max_items_per_room=max_items_per_room,
- engine=engine,
)
+ engine.game_world.generate_floor()
engine.update_fov()
...
import tcod import color from engine import Engine import entity_factories from game_map import GameWorld import input_handlers from procgen import generate_dungeon ... ... engine = Engine(player=player) engine.game_map = generate_dungeon( engine.game_world = GameWorld( engine=engine, 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, max_items_per_room=max_items_per_room, engine=engine, ) engine.game_world.generate_floor() engine.update_fov() ...
Now, instead of calling generate_dungeon
directly, we create a new GameWorld
and allow it to call its generate_floor
method. While this doesn’t change anything for the first floor that’s created, it will allow us to more easily create new floors on the fly.
In order to actually take the stairs, we’ll need to add an action and a way for the player to trigger it. Adding the action is pretty simple. Add the following to actions.py
:
class WaitAction(Action):
pass
+class TakeStairsAction(Action):
+ def perform(self) -> None:
+ """
+ Take the stairs, if any exist at the entity's location.
+ """
+ if (self.entity.x, self.entity.y) == self.engine.game_map.downstairs_location:
+ self.engine.game_world.generate_floor()
+ self.engine.message_log.add_message(
+ "You descend the staircase.", color.descend
+ )
+ else:
+ raise exceptions.Impossible("There are no stairs here.")
class ActionWithDirection(Action):
def __init__(self, entity: Actor, dx: int, dy: int):
super().__init__(entity)
class WaitAction(Action):
pass
class TakeStairsAction(Action):
def perform(self) -> None:
"""
Take the stairs, if any exist at the entity's location.
"""
if (self.entity.x, self.entity.y) == self.engine.game_map.downstairs_location:
self.engine.game_world.generate_floor()
self.engine.message_log.add_message(
"You descend the staircase.", color.descend
)
else:
raise exceptions.Impossible("There are no stairs here.")
class ActionWithDirection(Action):
def __init__(self, entity: Actor, dx: int, dy: int):
super().__init__(entity)
To call this action, the player should be able to press the >
key. This can be accomplished by adding this to input_handlers.py
:
class MainGameEventHandler(EventHandler):
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
action: Optional[Action] = None
key = event.sym
+ modifier = event.mod
player = self.engine.player
+ if key == tcod.event.K_PERIOD and modifier & (
+ tcod.event.KMOD_LSHIFT | tcod.event.KMOD_RSHIFT
+ ):
+ return actions.TakeStairsAction(player)
if key in MOVE_KEYS:
dx, dy = MOVE_KEYS[key]
class MainGameEventHandler(EventHandler): def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: action: Optional[Action] = None key = event.sym modifier = event.mod player = self.engine.player if key == tcod.event.K_PERIOD and modifier & ( tcod.event.KMOD_LSHIFT | tcod.event.KMOD_RSHIFT ): return actions.TakeStairsAction(player) if key in MOVE_KEYS: dx, dy = MOVE_KEYS[key]
modifier
tells us if the player is holding a key like control, alt, or shift. In this case, we’re checking if the user is holding shift while pressing the period key, which gives us the “>” key.
With that, the player can now descend the staircase to the next floor of the dungeon!
One little touch we can add before moving on to the next section is adding a way to see which floor the player is on. It’s simple enough: We’ll use the current_floor
in GameWorld
to know which floor we’re on, and we’ll modify our render_functions.py
file to add a method to print this information out to the UI.
Add this function to render_functions.py
:
from __future__ import annotations
-from typing import TYPE_CHECKING
+from typing import Tuple, TYPE_CHECKING
import color
...
...
def render_bar(
console: Console, current_value: int, maximum_value: int, total_width: int
) -> None:
...
+def render_dungeon_level(
+ console: Console, dungeon_level: int, location: Tuple[int, int]
+) -> None:
+ """
+ Render the level the player is currently on, at the given location.
+ """
+ x, y = location
+ console.print(x=x, y=y, string=f"Dungeon level: {dungeon_level}")
def render_names_at_mouse_location(
console: Console, x: int, y: int, engine: Engine
) -> None:
...
from __future__ import annotations from typing import TYPE_CHECKING from typing import Tuple, TYPE_CHECKING import color ... ... def render_bar( console: Console, current_value: int, maximum_value: int, total_width: int ) -> None: ... def render_dungeon_level( console: Console, dungeon_level: int, location: Tuple[int, int] ) -> None: """ Render the level the player is currently on, at the given location. """ x, y = location console.print(x=x, y=y, string=f"Dungeon level: {dungeon_level}") def render_names_at_mouse_location( console: Console, x: int, y: int, engine: Engine ) -> None: ...
The render_dungeon_level
function is fairly straightforward: Given a set of (x, y)
coordinates as a Tuple, it prints to the console which dungeon level was passed to the function.
To call this function, we can edit the Engine
’s render
function, like so:
...
import exceptions
from message_log import MessageLog
-from render_functions import (
- render_bar,
- render_names_at_mouse_location,
-)
+import render_functions
if TYPE_CHECKING:
...
class Engine:
...
def render(self, console: Console) -> None:
self.game_map.render(console)
self.message_log.render(console=console, x=21, y=45, width=40, height=5)
- render_bar(
+ render_functions.render_bar(
console=console,
current_value=self.player.fighter.hp,
maximum_value=self.player.fighter.max_hp,
total_width=20,
)
- render_names_at_mouse_location(console=console, x=21, y=44, engine=self)
+ render_functions.render_dungeon_level(
+ console=console,
+ dungeon_level=self.game_world.current_floor,
+ location=(0, 47),
+ )
+ render_functions.render_names_at_mouse_location(
+ console=console, x=21, y=44, engine=self
+ )
def save_as(self, filename: str) -> None:
...
... import exceptions from message_log import MessageLog from render_functions import ( render_bar, render_names_at_mouse_location, ) import render_functions if TYPE_CHECKING: ... class Engine: ... def render(self, console: Console) -> None: self.game_map.render(console) self.message_log.render(console=console, x=21, y=45, width=40, height=5) render_bar( render_functions.render_bar( console=console, current_value=self.player.fighter.hp, maximum_value=self.player.fighter.max_hp, total_width=20, ) render_names_at_mouse_location(console=console, x=21, y=44, engine=self) render_functions.render_dungeon_level( console=console, dungeon_level=self.game_world.current_floor, location=(0, 47), ) render_functions.render_names_at_mouse_location( console=console, x=21, y=44, engine=self ) def save_as(self, filename: str) -> None: ...
Note that we’re now importing render_functions
instead of importing the functions it contains. After awhile, it makes sense to just import the entire module rather than a few functions here and there. Otherwise, the file can get a bit difficult to read.
The call to render_dungeon_level
shouldn’t be anything too surprising. We use self.game_world.current_floor
as our dungeon_level
, and the location of the printed string is below the health bar (feel free to move this somewhere else, if you like).
Try going down a few levels and make sure everything works as expected. If so, congratulations! Your dungeon now has multiple levels!
Speaking of “levels”, many roguelikes (not all!) feature some sort of level-up system, where your character gains experience and gets stronger by fighting monsters. The rest of this chapter will be spent implementing one such system.
In order to allow the rogue to level up, we need to modify the actors in two ways:
- The player needs to gain experience points, keeping track of the XP gained thus far, and know when it’s time to level up.
- The enemies need to give experience points when they are defeated.
There are several calculations we could use to compute how much XP a player needs to level up (or, theoretically, you could just hard code the values). Ours will be fairly simple: We’ll start with a base number, and add the product of our player’s current level and some other number, which will make it so each level up requires more XP than the last. For this tutorial, the “base” will be 200, and the “factor” will be 150 (so going to level 2 will take 350 XP, level 3 will take 500, and so on).
We can accomplish both of these goals by adding one component: Level
. The Level
component will hold all of the information that we need to accomplish these goals. Create a file called level.py
in the components
directory, and put the following contents in it:
from __future__ import annotations
from typing import TYPE_CHECKING
from components.base_component import BaseComponent
if TYPE_CHECKING:
from entity import Actor
class Level(BaseComponent):
parent: Actor
def __init__(
self,
current_level: int = 1,
current_xp: int = 0,
level_up_base: int = 0,
level_up_factor: int = 150,
xp_given: int = 0,
):
self.current_level = current_level
self.current_xp = current_xp
self.level_up_base = level_up_base
self.level_up_factor = level_up_factor
self.xp_given = xp_given
@property
def experience_to_next_level(self) -> int:
return self.level_up_base + self.current_level * self.level_up_factor
@property
def requires_level_up(self) -> bool:
return self.current_xp > self.experience_to_next_level
def add_xp(self, xp: int) -> None:
if xp == 0 or self.level_up_base == 0:
return
self.current_xp += xp
self.engine.message_log.add_message(f"You gain {xp} experience points.")
if self.requires_level_up:
self.engine.message_log.add_message(
f"You advance to level {self.current_level + 1}!"
)
def increase_level(self) -> None:
self.current_xp -= self.experience_to_next_level
self.current_level += 1
def increase_max_hp(self, amount: int = 20) -> None:
self.parent.fighter.max_hp += amount
self.parent.fighter.hp += amount
self.engine.message_log.add_message("Your health improves!")
self.increase_level()
def increase_power(self, amount: int = 1) -> None:
self.parent.fighter.power += amount
self.engine.message_log.add_message("You feel stronger!")
self.increase_level()
def increase_defense(self, amount: int = 1) -> None:
self.parent.fighter.defense += amount
self.engine.message_log.add_message("Your movements are getting swifter!")
self.increase_level()
Let’s go over what was just added.
class Level(BaseComponent):
parent: Actor
def __init__(
self,
current_level: int = 1,
current_xp: int = 0,
level_up_base: int = 0,
level_up_factor: int = 150,
xp_given: int = 0,
):
self.current_level = current_level
self.current_xp = current_xp
self.level_up_base = level_up_base
self.level_up_factor = level_up_factor
self.xp_given = xp_given
The values in our __init__
function break down like this:
- current_level: The current level of the Entity, defaults to 1.
- current_xp: The Entity’s current experience points.
- level_up_base: The base number we decide for leveling up. We’ll set this to 200 when creating the Player.
- level_up_factor: The number to multiply against the Entity’s current level.
- xp_given: When the Entity dies, this is how much XP the Player will gain.
@property
def experience_to_next_level(self) -> int:
return self.level_up_base + self.current_level * self.level_up_factor
This represents how much experience the player needs until hitting the next level. The formula is explained above. Again, feel free to tweak this formula in any way you see fit.
@property
def requires_level_up(self) -> bool:
return self.current_xp > self.experience_to_next_level
We’ll use this property to determine if the player needs to level up or not. If the current_xp
is higher than the experience_to_next_level
property, then the player levels up. If not, nothing happens.
def add_xp(self, xp: int) -> None:
if xp == 0 or self.level_up_base == 0:
return
self.current_xp += xp
self.engine.message_log.add_message(f"You gain {xp} experience points.")
if self.requires_level_up:
self.engine.message_log.add_message(
f"You advance to level {self.current_level + 1}!"
)
This method adds experience points to the Entity’s XP pool, as the name implies. If the value is 0, we just return, as there’s nothing to do. Notice that we also return if the level_up_base
is set to 0. Why? In this tutorial, the enemies don’t gain XP, so we’ll set their level_up_base
to 0 so that there’s no way they could ever gain experience. Perhaps in your game, monsters will gain XP, and you’ll want to adjust this, but that’s left up to you.
The rest of the method adds the xp, adds a message to the message log, and, if the Entity levels up, posts another message.
def increase_level(self) -> None:
self.current_xp -= self.experience_to_next_level
self.current_level += 1
This method adds +1 to the current_level
, while decreasing the current_xp
by the experience_to_next_level
. We do this because if we didn’t it would always just take the level_up_factor
amount to level up, which isn’t what we want. If you wanted to keep track of the player’s cumulative XP throughout the playthrough, you could skip decrementing the current_xp
and instead adjust the experience_to_next_level
formula accordingly.
Lastly, the functions increase_max_hp
, increase_power
, and increase_defense
all do basically the same thing: they raise one of the Entity’s attributes, add a message to the message log, then call increase_level
.
To use this component, we need to add it to our Actor
class. Make the following changes to the file entity.py
:
if TYPE_CHECKING:
from components.ai import BaseAI
from components.consumable import Consumable
from components.fighter import Fighter
from components.inventory import Inventory
+ from components.level import Level
from game_map import GameMap
T = TypeVar("T", bound="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,
inventory: Inventory,
+ level: Level,
):
super().__init__(
x=x,
y=y,
char=char,
color=color,
name=name,
blocks_movement=True,
render_order=RenderOrder.ACTOR,
)
self.ai: Optional[BaseAI] = ai_cls(self)
self.fighter = fighter
self.fighter.parent = self
self.inventory = inventory
self.inventory.parent = self
+ self.level = level
+ self.level.parent = self
@property
def is_alive(self) -> bool:
...
if TYPE_CHECKING: from components.ai import BaseAI from components.consumable import Consumable from components.fighter import Fighter from components.inventory import Inventory from components.level import Level from game_map import GameMap T = TypeVar("T", bound="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, inventory: Inventory, level: Level, ): super().__init__( x=x, y=y, char=char, color=color, name=name, blocks_movement=True, render_order=RenderOrder.ACTOR, ) self.ai: Optional[BaseAI] = ai_cls(self) self.fighter = fighter self.fighter.parent = self self.inventory = inventory self.inventory.parent = self self.level = level self.level.parent = self @property def is_alive(self) -> bool: ...
Let’s also modify our entities in entity_factories.py
now:
from components.ai import HostileEnemy
from components import consumable
from components.fighter import Fighter
from components.inventory import Inventory
+from components.level import Level
from entity import Actor, Item
player = Actor(
char="@",
color=(255, 255, 255),
name="Player",
ai_cls=HostileEnemy,
fighter=Fighter(hp=30, defense=2, power=5),
inventory=Inventory(capacity=26),
+ level=Level(level_up_base=200),
)
orc = Actor(
char="o",
color=(63, 127, 63),
name="Orc",
ai_cls=HostileEnemy,
fighter=Fighter(hp=10, defense=0, power=3),
inventory=Inventory(capacity=0),
+ level=Level(xp_given=35),
)
troll = Actor(
char="T",
color=(0, 127, 0),
name="Troll",
ai_cls=HostileEnemy,
fighter=Fighter(hp=16, defense=1, power=4),
inventory=Inventory(capacity=0),
+ level=Level(xp_given=100),
)
...
from components.ai import HostileEnemy from components import consumable from components.fighter import Fighter from components.inventory import Inventory from components.level import Level from entity import Actor, Item player = Actor( char="@", color=(255, 255, 255), name="Player", ai_cls=HostileEnemy, fighter=Fighter(hp=30, defense=2, power=5), inventory=Inventory(capacity=26), level=Level(level_up_base=200), ) orc = Actor( char="o", color=(63, 127, 63), name="Orc", ai_cls=HostileEnemy, fighter=Fighter(hp=10, defense=0, power=3), inventory=Inventory(capacity=0), level=Level(xp_given=35), ) troll = Actor( char="T", color=(0, 127, 0), name="Troll", ai_cls=HostileEnemy, fighter=Fighter(hp=16, defense=1, power=4), inventory=Inventory(capacity=0), level=Level(xp_given=100), ) ...
As mentioned, the level_up_base
for the player is set to 200. Orcs give 35 XP, and Trolls give 100, since they’re stronger. These values are completely arbitrary, so feel free to adjust them in any way you see fit.
When an enemy dies, we need to give the player XP. This is as simple as adding one line to the Fighter
component, so open up fighter.py
and add this:
class Fighter(BaseComponent):
def die(self) -> None:
...
self.engine.message_log.add_message(death_message, death_message_color)
+ self.engine.player.level.add_xp(self.parent.level.xp_given)
def heal(self, amount: int) -> int:
...
class Fighter(BaseComponent):
def die(self) -> None:
...
self.engine.message_log.add_message(death_message, death_message_color)
self.engine.player.level.add_xp(self.parent.level.xp_given)
def heal(self, amount: int) -> int:
...
Now the player will gain XP for defeating enemies!
While the player does gain XP now, notice that we haven’t actually called the functions that increase the player’s stats and levels the player up. We’ll need a new interface to do this. The way it will work is that as soon as the player gets enough experience to level up, we’ll display a message to the player, giving the player three choices on what stat to increase. When chosen, the appropriate function will be called, and the message will close.
Let’s create a new event handler, called LevelUpEventHandler
, that will do just that. Create the following class in input_handlers.py
:
class AskUserEventHandler(EventHandler):
...
+class LevelUpEventHandler(AskUserEventHandler):
+ TITLE = "Level Up"
+ def on_render(self, console: tcod.Console) -> None:
+ super().on_render(console)
+ if self.engine.player.x <= 30:
+ x = 40
+ else:
+ x = 0
+ console.draw_frame(
+ x=x,
+ y=0,
+ width=35,
+ height=8,
+ title=self.TITLE,
+ clear=True,
+ fg=(255, 255, 255),
+ bg=(0, 0, 0),
+ )
+ console.print(x=x + 1, y=1, string="Congratulations! You level up!")
+ console.print(x=x + 1, y=2, string="Select an attribute to increase.")
+ console.print(
+ x=x + 1,
+ y=4,
+ string=f"a) Constitution (+20 HP, from {self.engine.player.fighter.max_hp})",
+ )
+ console.print(
+ x=x + 1,
+ y=5,
+ string=f"b) Strength (+1 attack, from {self.engine.player.fighter.power})",
+ )
+ console.print(
+ x=x + 1,
+ y=6,
+ string=f"c) Agility (+1 defense, from {self.engine.player.fighter.defense})",
+ )
+ def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
+ player = self.engine.player
+ key = event.sym
+ index = key - tcod.event.K_a
+ if 0 <= index <= 2:
+ if index == 0:
+ player.level.increase_max_hp()
+ elif index == 1:
+ player.level.increase_power()
+ else:
+ player.level.increase_defense()
+ else:
+ self.engine.message_log.add_message("Invalid entry.", color.invalid)
+ return None
+ return super().ev_keydown(event)
+ def ev_mousebuttondown(
+ self, event: tcod.event.MouseButtonDown
+ ) -> Optional[ActionOrHandler]:
+ """
+ Don't allow the player to click to exit the menu, like normal.
+ """
+ return None
class InventoryEventHandler(AskUserEventHandler):
...
class AskUserEventHandler(EventHandler):
...
class LevelUpEventHandler(AskUserEventHandler):
TITLE = "Level Up"
def on_render(self, console: tcod.Console) -> None:
super().on_render(console)
if self.engine.player.x <= 30:
x = 40
else:
x = 0
console.draw_frame(
x=x,
y=0,
width=35,
height=8,
title=self.TITLE,
clear=True,
fg=(255, 255, 255),
bg=(0, 0, 0),
)
console.print(x=x + 1, y=1, string="Congratulations! You level up!")
console.print(x=x + 1, y=2, string="Select an attribute to increase.")
console.print(
x=x + 1,
y=4,
string=f"a) Constitution (+20 HP, from {self.engine.player.fighter.max_hp})",
)
console.print(
x=x + 1,
y=5,
string=f"b) Strength (+1 attack, from {self.engine.player.fighter.power})",
)
console.print(
x=x + 1,
y=6,
string=f"c) Agility (+1 defense, from {self.engine.player.fighter.defense})",
)
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
player = self.engine.player
key = event.sym
index = key - tcod.event.K_a
if 0 <= index <= 2:
if index == 0:
player.level.increase_max_hp()
elif index == 1:
player.level.increase_power()
else:
player.level.increase_defense()
else:
self.engine.message_log.add_message("Invalid entry.", color.invalid)
return None
return super().ev_keydown(event)
def ev_mousebuttondown(
self, event: tcod.event.MouseButtonDown
) -> Optional[ActionOrHandler]:
"""
Don't allow the player to click to exit the menu, like normal.
"""
return None
class InventoryEventHandler(AskUserEventHandler):
...
The idea here is very similar to InventoryEventHandler
(it inherits from the same AskUserEventHandler
class), but instead of having a variable number of options, it’s set to three, one for each of the primary attributes. Furthermore, there’s no way to exit this menu without selecting something. The user must level up before continuing. (Notice, we had to override ev_mousebutton
to prevent clicks from closing the menu.)
Using LevelUpEventHandler
is actually quite simple: We can check when the player requires a level up at the same time when we check if the player is still alive. Edit the handle_events
method of EventHandler
like this:
if not self.engine.player.is_alive:
# The player was killed sometime during or after the action.
return GameOverEventHandler(self.engine)
+ elif self.engine.player.level.requires_level_up:
+ return LevelUpEventHandler(self.engine)
return MainGameEventHandler(self.engine) # Return to the main handler.
if not self.engine.player.is_alive:
# The player was killed sometime during or after the action.
return GameOverEventHandler(self.engine)
elif self.engine.player.level.requires_level_up:
return LevelUpEventHandler(self.engine)
return MainGameEventHandler(self.engine) # Return to the main handler.
Now, when the player gains the necessary number of experience points, the player will have the chance to level up!
Before finishing this chapter, there’s one last quick thing we can do to improve the user experience: Add a “character information” screen, which displays the player’s stats and current experience. It’s actually quite simple. Add the following class to input_handlers.py
:
class AskUserEventHandler(EventHandler):
...
+class CharacterScreenEventHandler(AskUserEventHandler):
+ TITLE = "Character Information"
+ def on_render(self, console: tcod.Console) -> None:
+ super().on_render(console)
+ if self.engine.player.x <= 30:
+ x = 40
+ else:
+ x = 0
+ y = 0
+ width = len(self.TITLE) + 4
+ console.draw_frame(
+ x=x,
+ y=y,
+ width=width,
+ height=7,
+ title=self.TITLE,
+ clear=True,
+ fg=(255, 255, 255),
+ bg=(0, 0, 0),
+ )
+ console.print(
+ x=x + 1, y=y + 1, string=f"Level: {self.engine.player.level.current_level}"
+ )
+ console.print(
+ x=x + 1, y=y + 2, string=f"XP: {self.engine.player.level.current_xp}"
+ )
+ console.print(
+ x=x + 1,
+ y=y + 3,
+ string=f"XP for next Level: {self.engine.player.level.experience_to_next_level}",
+ )
+ console.print(
+ x=x + 1, y=y + 4, string=f"Attack: {self.engine.player.fighter.power}"
+ )
+ console.print(
+ x=x + 1, y=y + 5, string=f"Defense: {self.engine.player.fighter.defense}"
+ )
class LevelUpEventHandler(AskUserEventHandler):
...
class AskUserEventHandler(EventHandler):
...
class CharacterScreenEventHandler(AskUserEventHandler):
TITLE = "Character Information"
def on_render(self, console: tcod.Console) -> None:
super().on_render(console)
if self.engine.player.x <= 30:
x = 40
else:
x = 0
y = 0
width = len(self.TITLE) + 4
console.draw_frame(
x=x,
y=y,
width=width,
height=7,
title=self.TITLE,
clear=True,
fg=(255, 255, 255),
bg=(0, 0, 0),
)
console.print(
x=x + 1, y=y + 1, string=f"Level: {self.engine.player.level.current_level}"
)
console.print(
x=x + 1, y=y + 2, string=f"XP: {self.engine.player.level.current_xp}"
)
console.print(
x=x + 1,
y=y + 3,
string=f"XP for next Level: {self.engine.player.level.experience_to_next_level}",
)
console.print(
x=x + 1, y=y + 4, string=f"Attack: {self.engine.player.fighter.power}"
)
console.print(
x=x + 1, y=y + 5, string=f"Defense: {self.engine.player.fighter.defense}"
)
class LevelUpEventHandler(AskUserEventHandler):
...
Similar to LevelUpEventHandler
, CharacterScreenEventHandler
shows information in a window, but there’s no real “choices” to be made here. Any input will simply close the screen.
To open the screen, we’ll have the player press the c
key. Add the following to MainGameEventHandler
:
elif key == tcod.event.K_d:
return InventoryDropHandler(self.engine)
+ elif key == tcod.event.K_c:
+ return CharacterScreenEventHandler(self.engine)
elif key == tcod.event.K_SLASH:
return LookHandler(self.engine)
elif key == tcod.event.K_d:
return InventoryDropHandler(self.engine)
elif key == tcod.event.K_c:
return CharacterScreenEventHandler(self.engine)
elif key == tcod.event.K_SLASH:
return LookHandler(self.engine)
That’s it for this chapter. We’ve added the ability to go down floors, and to level up. While the player can now “progress”, the environment itself doesn’t. The items that spawn on each floor are always the same, and the enemies don’t get tougher as we go down floors. The next part will address that.
If you want to see the code so far in its entirety, click here.