Last time I thought the Wraith was a complicated feature. This time I found myself implementing an even more difficult one: stairs.
If you’re familiar with the finished game, you know it already has stairs. But really it’s more like a “blow up the entire world, build a new one, then say it was from descending a staircase.” I decided I wanted the ability to move back and forth between floors. That sounds simple enough, but it’s not even close.
Hairball Removal
This section is going to be very dry. I really feel the need to talk about this, but I fear it may not be interesting to read. It’s basically about how and why I refactored 200 or so lines of code to do the same thing it did before but in a slightly more organized way.
In previous posts I’ve set up systems for easy creation of spinning off new content. monsters.py
for new enemies. items.py
for new treasures. This week some things dawned on me:
- I’d really like a way to generate different levels instead of using the same algorithm every time
- I’m having a surprisingly, frustratingly difficult time figuring out how to do that
- I sure am tweaking this
GameMap
class every time I do anything with this project
Looking at the tutorial’s history, it’s easy to see how it ended up this way. The original version on the roguebasin wiki was clearly meant as a “here’s the fastest way from Point A to Point B” demonstration. The Python 3 update I’m working from clearly tried to clean things up, and often succeeded, but some parts of it are still giant tangled hairballs and the GameMap
class is one of the hairiest. On GitHub you can find the final version of the GameMap
class.
So, what is a GameMap
object? To start, it’s almost the entire game state, including every single bit of the level data hardcoded. Which means it might make more sense to ask what is the GameMap
object, which already hints at the problem. This class includes:
- Logic for generating the first dungeon floor
- Logic for generating all subsequent dungeon floors and the only exit to them
- Logic for generating all rooms including blocking tiles
- All item/monster drop tables
- All item/monster definitions
The big problem, and something that can be seen in most commits to my version that isn’t a bugfix, is that so much of this stuff is hardcoded with no reasonable way to change or add to it apart from rewriting the class. The place_entities
method alone includes monster definitions, item definitions, drop rates, and drop generation methods so if you want to change any part of that you have to change all of it or start refactoring.
I’ve been slowly chipping away at this class over the course of previous updates, but thinking about floors and creating new ones required me to pay more attention to the game’s entities
and that’s where the real pain started. Some other things I noticed when I took a step back and really started examining this class:
- There is an
entities
list created at the beginning of the game. Nearly everything that touches it both (A) Takesgame_map
as a parameter, and (B) mutatesentities
. In theory this is supposed to prevent usingentities
as a global variable, but in practice this version is more like an obfuscated global variable. - Many of the values it uses are constants, set in a dictionary literally called
constants
. Yet instead of asking for and holding onto those values in the__init__
method,GameMap
repeatedly asks for them as individual parameters in method calls. make_map
has to be manually called in the game’s initialization even though it’s automatically called on subsequent rooms and there’s no reason that couldn’t be done in__init__
as written.initialize_tiles
returns a list of tiles. But it’s only ever called in the formself.tiles = initialize_tiles()
, which means there’s no reason not to just make theinitialize_tiles
method setself.tiles
directly.- Where’s the logic for transitioning from one floor to another? The
engine.py
game loop handles deciding whether the player successfully used an exit or not, theStairs
component contains only an integer (representing how many floors deep the new floor will be), and the actual new floor is handled bynext_floor
in theGameMap
class. - The
constants
in theinitialize_game.py
file include values for things likemax_monsters_per_room
andmax_room_width
. Setting aside the wisdom of hard-coding those values for the entire game in the first place, it turns out that these values aren’t even used because they’re also hardcoded inGameMap
. And the versions inGameMap
differ from theconstants
version.
The good news is that my previous work has really been helping to streamline this class. I’m happy with how items.py
and monsters.py
turned out, and factoring out the drop tables will be trivial once I decide if, how, and why I would want to do that.
I ultimately decided I would need to overhaul GameMap
. The first step was to add an instance variable for entities
and force the rest of the game to only interact with it through this class. I know, I just criticized a class for doing too much and then added stuff to it, but the idea to make it clearer what’s actually happening whether I approve of it or not. entities
is an instance variable no matter how hard it’s pretending not to be. On a similar note, I also made __init__
set all the constants that are currently hardcoded. This at least made it obvious what values were being set in which places.
On that note, the second step was…deleting the GameMap
class. Not literally, of course, but I did remove the class name completely just to make sure I didn’t accidentally keep any code calling it. The GameMap
class is trying to be both the state of the entire game world as well as the current floor and if I’m going to have a concept of different floors then I wanted to separate those things. After a lot of flailing around, I ended up with two classes: World
and DungeonFloor
.
World
is meant to be the primary interface between the game world and the player. DungeonFloor and the other game objects should mostly interact each other, while the engine and renderer should be polling World
about the state of the world.
DungeonFloor
currently does most of what GameMap
did. The key differences, other than that entities
instance variable, is that it now calls methods like make_map
on its own as needed. Now that I’m confident that DungeonFloor
can do its job with no involvement from other code, it will be easier to pull more stuff out as needed.
It also no longer changes floors by deleting all its own data and creating new rooms. A new floor means a new DungeonFloor
, while World
keeps track of what floor we are on. This makes it easy to keep track of the game state; by making our save system save the World
, it also automatically saves:
- The current floor
- The current floor’s entities, including the staircases
- The staircases themselves, and by extension their destination floors
- All of the above for the destination floors themselves
Saving all generated level data is as simple as saving the World
instance.
Can We Make Stairs Yet?
At some point I decided enough was enough and turned my eye to actually making the staircase. I decided that staircases need to know where they lead, which means having them point to their destination floor. This causes problems in our theoretically-infinite dungeon. We start by creating our first floor, which has a staircase to the second floor, which has a staircase to the third floor, and so on until we’ve created an infinite loop before the game even starts.
I ended up changing the Stairs
component to the Exit
component. Then, following my map_objects of monsters.py
and items.py
, I added exits.py
, whose name I already hate but I haven’t come up with an alternative yet. The actual staircases are the easy part (I decided that if >
is downstairs then obviously <
is upstairs):
import tcod as libtcod import components.exit # doesn't alias to exit because of builtin exit function from entity import Entity from render_functions import RenderOrder class DownStairs(Entity): def __init__(self, x, y, destination): super().__init__(x, y, '>', libtcod.white, 'Stairs (Down)', render_order=RenderOrder.STAIRS) components.exit.DownStairsExit(destination).add_to_entity(self) class UpStairs(Entity): def __init__(self, x, y, old_floor): super().__init__(x, y, '<', libtcod.white, 'Stairs (Up)', render_order=RenderOrder.STAIRS) components.exit.Exit(destination=(), new_floor=old_floor).add_to_entity(self)
That’s the easy part (if you’re wondering what a destination is, that’s what I’m about to show you). The harder part:
class Exit(Component): def __init__(self, destination=tuple(), new_floor=None): super().__init__('exit') # destination defaults to the empty tuple because we want a falsy, immutable, ordered container # if a destination isn't given here, the new floor will have to be defined some other way self.destination = destination # Sets the destination now if a new floor was provided self.new_floor = new_floor def take_exit(self, player, message_log): # This is here for subclasses to override if they want to add logic before moving to the next room # e.g. the default game's healing before progressing to a new floor return self.next_floor @property def next_floor(self): if not self.new_floor: # construct the new floor if it doesn't exist yet # currently just letting this throw an exception if no destination was ever defined floor_constructor, args, kwargs = self.destination self.new_floor = floor_constructor(*args, **kwargs) return self.new_floor class DownStairsExit(Exit): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.already_taken = False def take_exit(self, player, message_log): if not self.already_taken: self.already_taken = True player.fighter.heal(player.fighter.max_hp // 2) message_log.add_message(Message('You take a moment to rest, and recover your strength.')) return super().take_exit(player, message_log)
The destination and next_floor parameters are essentially mutually exclusive, but currently I don’t see a way around accommodating both. The problem:
- You can’t assume the next floor exists when the stairs are first created, or you end up with the infinite loop discussed earlier.
- You can’t create an exit that returns to a previous room unless you handle the case where the previous room already exists.
I actually tried creating a function that would turn an existing room into a destination tuple, but that confused shelve
and created the same crashing bug I encountered when implementing my status effects. So instead the component accepts a DungeonFloor
, or a constructor and parameters to create one. If it gets the floor it just uses that, whereas if it gets the constructor it builds the floor the first time the player tries to actually enter it. My current code for calling it looks like this:
# This is deep in the DungeonFloor class if num_rooms == 0: # this is the first room, where the player starts player.x = new_x player.y = new_y if self.previous_floor: self.entities.append(exits.UpStairs(new_x, new_y, self.previous_floor)) # some intervening code intermitted destination=( DungeonFloor, (player, self.width, self.height, self.dungeon_level + 1), {'room_max_size': self.room_max_size, 'room_min_size': self.room_min_size, 'max_rooms': self.max_rooms, 'previous_floor': self}) self.entities.append(exits.DownStairs(center_of_last_room_x, center_of_last_room_y, destination))
Finally, there’s the small matter of placing the player when they reenter a room. As written, this created an amusing bug where the player doesn’t change coordinates after entering a staircase if it’s not a new floor. I forgot to get a screenshot of the bug sticking me in a wall. Anyway, the new World
class made this an easy fix:
def change_room(self, player, entity, message_log): # remember the player's current position in case they come back to this room self.current_floor.last_player_position = (player.x, player.y) self.current_floor = entity.exit.take_exit(player, message_log) # reset the player's position if they've been here before # note: currently this does not work if a player has more than one way to reenter a room where they've already been if self.current_floor.last_player_position: player.x, player.y = self.current_floor.last_player_position
After all that…we have stairs that go up.
Finally, We Have Stairs
The funny thing is that a lot of the things I didn’t like about the code still aren’t fixed. Each floor is still hardcoded to go to the next random floor, and it’s not even possible to have more than one staircase/exit leading into the same room or my player placement code above breaks. If it seemed like I was trashing the previous tutorial authors above, I really wasn’t; I recognize and appreciate the work it took to build as much as they did. And on the bright side, we now have stairs that go up and won’t trap you in walls.
This session did give me a lot of ideas, both for code and for game mechanics. Once I gave the player the ability to retrace their steps to previous floors, it made me wonder why that might be a fun thing to do. I already have some ideas. Hopefully I won’t have to rewrite half the core code this time.
As always, I’ve posted the full code on GitHub.