Complete Roguelike Tutorial, using python+libtcod, part 2
This is part of a series of tutorials; the main page can be found here.
This part will introduce the map, FOV, and finally a neat dungeon generator! No roguelike is complete without those. We'll start with the map, a two-dimensional array of tiles where all your dungeon adventuring will happen. We'll start by defining its size at the top of the file. It's not quite the same size as the screen, to leave some space for a panel to show up later (where you can show stats and all). We'll try to make this as configurable as possible, this should suffice for now!
MAP_WIDTH = 80 MAP_HEIGHT = 45
Next, the tile colors. For now there are two tile types -- wall and ground. These will be their "dark" colors, which you'll see when they're not in FOV; their "lit" counterparts are not needed right now. Notice that their values are between 0 and 255, if you found colors on the web in hexadecimal format you'll have to convert them with a calculator. Finding RGB values by educated trial-and-error works at first but with time you'll have a set of colors that don't mix together very well (contrast and tone as perceived by the human eye, and all that stuff), so it's usually better to look at a chart of colors; just search for "html colors" and use one you like.
color_dark_wall = libtcod.Color(0, 0, 100) color_dark_ground = libtcod.Color(50, 50, 150)
What sort of info will each tile hold? We'll start simple, with two values that say whether a tile is passable or not, and whether it blocks sight. In this case, it's better to seperate them early, so later you can have see-through but unpassable tiles such as chasms, or passable tiles that block sight for secret passages. They'll be defined in a Tile class, that we'll add to as we go. Believe me, this class will quickly grow to have about a dozen different values for each tile!
class Tile: #a tile of the map and its properties def __init__(self, blocked, block_sight = None): self.blocked = blocked #by default, if a tile is blocked, it also blocks sight if block_sight is None: block_sight = blocked self.block_sight = block_sight
As promised, the map is a two-dimensional array of tiles. The easiest way to do that is to have a list of rows, each row itself being a list of tiles, since there are no native multi-dimensional arrays in Python. We'll build it using a neat trick, list comprehensions. See, the usual way to build lists (from C++ land) is to create an empty list, then iterate with a for and add elements gradually. But in Python, the syntax [element for index in range], where index and range are the same as what you'd use in a for, will return a list of elements. Just take a second to understand that sentence if you never worked with that before. With two of those, one for rows and another for tiles in each row, we create the map in one fell swoop! The linked page has a ton of examples on that, and also an example of nested list comprehensions like we're using for the map. Well, that's an awful lot of words for such a tiny piece of code!
def make_map(): global map #fill map with "unblocked" tiles map = [[ Tile(False) for y in range(MAP_HEIGHT) ] for x in range(MAP_WIDTH) ]
Accessing the tiles is as easy as map[x][y]. Here we add two pillars (blocked tiles) to demonstrate that, and provide a simple test.
map.blocked = True map.block_sight = True map.blocked = True map.block_sight = True
Don't worry, we're already close to a playable version! Since we need to draw both the objects and the map, it now makes sense to put them all under a new function instead of directly in the main loop. Take the object rendering code to a new render_all function, and in its place (in the main loop) call render_all().
def render_all(): #draw all objects in the list for object in objects: object.draw()
Still in the same function, we can now go through all the tiles and draw them to the screen, with the background color of a console character representing the corresponding tile. This will render the map.
for y in range(MAP_HEIGHT): for x in range(MAP_WIDTH): wall = map[x][y].block_sight if wall: libtcod.console_set_back(0, x, y, color_dark_wall, libtcod.BKGND_SET ) else: libtcod.console_set_back(0, x, y, color_dark_ground, libtcod.BKGND_SET )
Ok! Don't forget to call make_map() before the main loop, to set it up before the game begins. You should be able to see the two pillars and walk around the map now!
But wait, there's something wrong. The pillars show up, but the player can walk over them. That's easy to fix though, add this check to the beginning of the Object 's move method:
if not map[self.x + dx][self.y + dy].blocked:
Here's the code so far.
Field of View (FOV)
The next step towards a complete roguelike is FOV. This adds a tactical element, and lets the player wonder what's on the other side of every door and every corner! The FOV works like a light source where the player stands, casting light in every direction but not getting past any walls. Regions in shadow are invisible. You could code it yourself by casting rays outward from the player, but it's much easier than that; libtcod has a whole module dedicated to it! It includes different methods with varying levels of precision, speed and other interesting properties. There's an excellent study here if you want to know more about them, including tables and images comparing the different algorithms.
We'll define the chosen algorithm along with some other constants so they can be changed later. For now it's 0, the default algorithm. There's also an option to light walls or not, this is a matter of preference. Another important constant is the maximum radius for FOV calculations, how far the player can see in the dungeon. (Whether this is due to the player's sight range or the light from the player's torch depends on how you choose to explain this to the player.)
FOV_ALGO = 0 #default FOV algorithm FOV_LIGHT_WALLS = True TORCH_RADIUS = 10
Also, we'll need more colors for lit tiles! The color definitions will now be:
color_dark_wall = libtcod.Color(0, 0, 100) color_light_wall = libtcod.Color(130, 110, 50) color_dark_ground = libtcod.Color(50, 50, 150) color_light_ground = libtcod.Color(200, 180, 50)
These are taken straight away from the libtcod sample that comes with the library, and you may want to change them to give your game a more unique feel (see the earlier notes about colors).
The libtcod FOV module needs to know which tiles block sight. So, we create a map that libtcod can understand (fov_map), and fill it with the appropriate values from the tiles' own block_sight and blocked properties. Well, actually, only block_sight will be used; the blocked value is completely irrelevant for FOV! It will be useful only for the pathfinding module, but it doesn't hurt to provide that value anyway. Also, libtcod asks for values that are the opposite of what we defined, so we toggle them with the not operator.
fov_map = libtcod.map_new(MAP_WIDTH, MAP_HEIGHT) for y in range(MAP_HEIGHT): for x in range(MAP_WIDTH): libtcod.map_set_properties(fov_map, x, y, not map[x][y].blocked, not map[x][y].block_sight)
FOV will only need to be recomputed if the player moves, or a tile changes. To model that we'll define a global variable fov_recompute = True before the main loop. Then, in the handle_keys function, whenever the player moves we set it to True again, like in the following code.
#movement keys elif libtcod.console_is_key_pressed(libtcod.KEY_UP): player.move(0, -1) fov_recompute = True elif libtcod.console_is_key_pressed(libtcod.KEY_DOWN): player.move(0, 1) fov_recompute = True elif libtcod.console_is_key_pressed(libtcod.KEY_LEFT): player.move(-1, 0) fov_recompute = True elif libtcod.console_is_key_pressed(libtcod.KEY_RIGHT): player.move(1, 0) fov_recompute = True
Now we need to change the rendering code to actually recompute FOV, and display the result! It's a major overhaul of the render_all function. We only need to recompute FOV and render the map if recompute_fov is True (and then we reset it to False), done by the following code.
if fov_recompute: #recompute FOV if needed (the player moved or something) fov_recompute = False libtcod.map_compute_fov(fov_map, player.x, player.y, TORCH_RADIUS, FOV_LIGHT_WALLS, FOV_ALGO)
As you can see we're using all the constants we defined earlier. After that comes the code that iterates over all tiles and displays them in the console. We'll add an extra condition for each tile:
visible = libtcod.map_is_in_fov(fov_map, x, y)
Depending on the value of visible, the tile may be drawn in different colors (lit or dark). We'll show all of the modified map display code to make this a bit more clear.
#go through all tiles, and set their background color according to the FOV for y in range(MAP_HEIGHT): for x in range(MAP_WIDTH): visible = libtcod.map_is_in_fov(fov_map, x, y) wall = map[x][y].block_sight if not visible: #it's out of the player's FOV if wall: libtcod.console_set_back(0, x, y, color_dark_wall, libtcod.BKGND_SET) else: libtcod.console_set_back(0, x, y, color_dark_ground, libtcod.BKGND_SET) else: #it's visible if wall: libtcod.console_set_back(0, x, y, color_light_wall, libtcod.BKGND_SET ) else: libtcod.console_set_back(0, x, y, color_light_ground, libtcod.BKGND_SET )
There! It's done, you can move around the pillars and see their shadows being cast as you walk.
The last detail is to make sure objects only show if they're in the player's FOV. In the Object 's draw method, add a FOV check before drawing:
if libtcod.map_is_in_fov(fov_map, self.x, self.y):
Apart from defining the newly used global values in render_all and handle_keys, that's all there is to it. This is actually one aspect that can take a long time to get right in a roguelike, fortunately we were able to do it with a modest amount of work!
The whole code for this section is here.
Dungeon building blocks
Alright then, it's about time our dungeon takes a recognizable shape! I never cared much for pillars anyway. What we're gonna do now is create functions to carve rooms and tunnels in the underground rock. First of all, a little helper class that will be very handy when dealing with rectangular rooms:
class Rect: #a rectangle on the map. used to characterize a room. def __init__(self, x, y, w, h): self.x1 = x self.y1 = y self.x2 = x + w self.y2 = y + h This will take top-left coordinates for a rectangle (in tiles, of course), and its size, to define it in terms of two points: top-left (x1, y1) and bottom-right (x2, y2). Why not store directly its top-left coordinates and size? Well, it's much easier to loop through the room's tiles this way, since Python's ''range'' function takes arguments in this form. It will also be apparent later that the code for intersecting rectangles (for example, to be sure rooms don't overlap) is easier to define this way. Python's ''range'' function, however, excludes the last element in the loop. For example, ''for x in range(x1, x2)'' will only loop until ''x2 - 1''. So the code to fill a room with unblocked tiles could be: <pre>def create_room(room): global map #go through the tiles in the rectangle and make them passable for x in range(room.x1, room.x2 + 1): for y in range(room.y1, room.y2 + 1): map[x][y].blocked = False map[x][y].block_sight = False
But we want to leave some walls at the border of the room, so we'll leave out one tile in all directions.
def create_room(room): global map #go through the tiles in the rectangle and make them passable for x in range(room.x1 + 1, room.x2): for y in range(room.y1 + 1, room.y2): map[x][y].blocked = False map[x][y].block_sight = False
Subtle, but important! This way, even if two rooms are right next to each other (but not overlapping), there's always one wall separating them. Now we're going to modify the make_map function to start out with all tiles blocked, and carve two rooms in the map with our new function.
def make_map(): global map #fill map with "blocked" tiles map = [[ Tile(True) for y in range(MAP_HEIGHT) ] for x in range(MAP_WIDTH) ] #create two rooms room1 = Rect(20, 15, 10, 15) room2 = Rect(50, 15, 10, 15) create_room(room1) create_room(room2)
Before testing out, make the player appear in the center of the first room:
player.x = 25 player.y = 23
You can walk around the first room, but not reach the second. We'll define a function to carve a horizontal tunnel: <def>create_h_tunnel(x1, x2, y):
global map for x in range(min(x1, x2), max(x1, x2) + 1): map[x][y].blocked = False
map[x][y].block_sight = False
There's some creative use of the min and max functions there. They'll return the minimum or maximum of both arguments, but why are they needed? Well, if x1 < x2, x1 will be the minimum of both, and x2 the maximum. So that line will be the same as:
for x in range(x1, x2 + 1):
If they're reversed, the same line wouldn't work -- as it is, for only loops from a small number to a bigger number. But returning to the original line, then x2 will be the minimum, and x1 maximum. You guessed it -- it will be the same as:
for x in range(x2, x1 + 1):
It could be solved with other logic: swapping their values, changing the for 's step to negative, having an if to choose between the two lines... The functions min and max tend to give the shortest code, though it may be harder to understand if you're not used to them much.
The last detail before moving on to dungeon generation is exploration, a.k.a Fog of War.
The code includes a simple algorithm, it's just a sequence of rooms, each one connected to the next through a tunnel. The overlaps make it look more complex than may be apparent at first though.