Difference between revisions of "Complete Roguelike Tutorial, using python+libtcod, part 2"

From RogueBasin
Jump to navigation Jump to search
m
 
(29 intermediate revisions by 11 users not shown)
Line 1: Line 1:
<center><table border="0" cellpadding="10" cellspacing="0" style="background:#F0E68C"><tr><td><center>
<center><table border="0" cellpadding="10" cellspacing="0" style="background:#F0E68C"><tr><td><center>
This is part of a series of tutorials; the main page can be found [[Complete Roguelike Tutorial, using python+libtcod|here]].
This is part of a series of tutorials; the main page can be found [[Complete Roguelike Tutorial, using python+libtcod|here]].
The tutorial uses libtcod version 1.6.0 and above.
If you choose to use 1.5.1, you can find the old version [http://www.roguebasin.com/index.php?title=Complete_Roguelike_Tutorial,_using_python%2Blibtcod,_part_2&oldid=42384 here].<br/>
If you choose to use 1.5.0, you can find the old version [http://roguebasin.roguelikedevelopment.org/index.php?title=Complete_Roguelike_Tutorial,_using_python%2Blibtcod,_part_2&oldid=29861 here].
If you are looking for a Python 3 version, see [http://www.roguebasin.com/index.php?title=Complete_Roguelike_Tutorial,_using_python3%2Blibtcod,_part_2 here]
</center></td></tr></table></center>
</center></td></tr></table></center>


__TOC__
__TOC__


<center><h1>'''The Dungeon'''</h1></center>
<center><h1>'''The object and the map'''</h1></center>
 
== Off-screen consoles ==
 
There's one small thing we need to get out of the way before we can continue. Notice that the drawing functions we called (''console_set_default_foreground'' and ''console_put_char'') have their first argument set to 0, meaning that they draw on the ''root console''. This is the buffer that is shown directly on screen.
 
It can be useful, however, to have other buffers to store the results of drawing functions without automatically showing them to the player. This is akin to drawing surfaces or buffers in other graphics libraries; but instead of pixels they store characters and colors so you can modify them at will. Some uses of these off-screen consoles include semi-transparency or fading effects and composing GUI panels by blitting them to different portions of the root console. We're going to draw on an off-screen console from now on. The main reason is that not doing so would mean that later on you can't compose different GUI panels as easily, or add certain effects.
 
First, create a new off-screen console, which for now will occupy the whole screen, but this can be changed later. We'll use a simple name like ''con'' because it will be used '''a lot'''! You can put this in the initialization, right after ''console_init_root''.
 
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">con = libtcod.console_new(SCREEN_WIDTH, SCREEN_HEIGHT)</syntaxhighlight></div>
 
 
Now change the first argument of ''console_put_char'' (there are 2 calls to this function) and ''console_set_default_foreground'', from ''0'' to ''con''. They're now drawing on the new console.
 
Finally, just before ''console_flush()'', blit the contents of the new console to the root console, to display them. The parameters may look a bit mysterious at first, but they're just saying that the source rectangle has its top-left corner at coordinates (0, 0) and is the same size as the screen; the destination coordinates are (0, 0) as well. Check the documentation on [http://roguecentral.org/doryen/data/libtcod/doc/1.5.1/html2/console_offscreen.html?c=false&cpp=false&cs=false&py=true&lua=false#6 the console_blit function] for more details.
 
 
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">libtcod.console_blit(con, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 0, 0, 0)</syntaxhighlight></div>
 
 
That was a lot of talk for so little code and no visible change! The next section will surely be much more interesting, as we'll introduce our first dummy NPC among other things. Remember to check back the above documentation page when you get to coding your GUI.


== The Map ==
== Generalizing ==
 
Now that we have the @ walking around, it would be a good idea to step back and think a bit about the design. Having variables for the player's coordinates is easy, but it can quickly get out of control when you're defining things such as HP, bonuses, and inventory. We're going to take the opportunity to generalize a bit.


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!
Now, there ''can'' be such a thing as over-generalization, but we'll try not to fall in that trap. What we're going to do is define the player as a game ''Object'', by creating that class. It will hold all position and display information (character and color). The neat thing is that the player will just be one instance of the ''Object'' class -- it's general enough that you can re-use it to define items on the floor, monsters, doors, stairs; anything representable by a character on the screen. Here's the class, with the initialization, and three common methods ''move'', ''draw'' and ''clear''. The code for drawing and erasing is the same as the one we used for the player earlier.
<pre>MAP_WIDTH = 80
MAP_HEIGHT = 45</pre>


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.
<pre>color_dark_wall = libtcod.Color(0, 0, 100)
color_dark_ground = libtcod.Color(50, 50, 150)</pre>


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!
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">class Object:
<pre>class Tile:
    #this is a generic object: the player, a monster, an item, the stairs...
     #a tile of the map and its properties
    #it's always represented by a character on screen.
     def __init__(self, blocked, block_sight = None):
    def __init__(self, x, y, char, color):
         self.blocked = blocked
        self.x = x
        self.y = y
         #by default, if a tile is blocked, it also blocks sight
        self.char = char
         if block_sight is None: block_sight = blocked
        self.color = color
         self.block_sight = block_sight</pre>
      
     def move(self, dx, dy):
        #move by the given amount
        self.x += dx
         self.y += dy
   
    def draw(self):
         #set the color and then draw the character that represents this object at its position
        libtcod.console_set_default_foreground(con, self.color)
        libtcod.console_put_char(con, self.x, self.y, self.char, libtcod.BKGND_NONE)
   
    def clear(self):
         #erase the character that represents this object
         libtcod.console_put_char(con, self.x, self.y, ' ', libtcod.BKGND_NONE)</syntaxhighlight></div>


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, [http://docs.python.org/tutorial/datastructures.html#list-comprehensions 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 ''element''s. 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 [http://docs.python.org/tutorial/datastructures.html#nested-list-comprehensions 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!
Please note that there are ''two'' underscores on each side of <code>__init__</code>!
<pre>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) ]</pre>


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.
Now, before the main loop, instead of just setting the player's coordinates, we create it as an actual ''Object''. We also add it to a list, that will hold ''all'' objects that are in the game. While we're at it we'll add a yellow @ that represents a non-playing character, like in an RPG, just to test it out!
<pre>    map[30][22].blocked = True
    map[30][22].block_sight = True
    map[50][22].blocked = True
    map[50][22].block_sight = True</pre>


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()''.
<pre>def render_all():
    #draw all objects in the list
    for object in objects:
        object.draw()</pre>


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.
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">player = Object(SCREEN_WIDTH/2, SCREEN_HEIGHT/2, '@', libtcod.white)
<pre>   for y in range(MAP_HEIGHT):
npc = Object(SCREEN_WIDTH/2 - 5, SCREEN_HEIGHT/2, '@', libtcod.yellow)
        for x in range(MAP_WIDTH):
objects = [npc, player]</syntaxhighlight></div>
            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 )</pre>


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:
We'll have to make a couple of changes now. First, in the ''handle_keys'' function, instead of dealing directly with the player's coordinates, we can use the player's ''move'' method with the appropriate displacement. Later this will come in handy as it can automatically check if the player (or another object) is about to hit a wall. Secondly, the main loop will now clear all objects like this:
<pre>if not map[self.x + dx][self.y + dy].blocked:</pre>


[[Complete Roguelike Tutorial, using python+libtcod, part 2 code#The Map|Here]]'s the code so far.


<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">    for object in objects:
        object.clear()</syntaxhighlight></div>




== Field of View (FOV) ==
And draw them like this:


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 [http://roguecentral.org/libtcod/fov/fov.pdf 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.)
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">    for object in objects:
<pre>FOV_ALGO = 0  #default FOV algorithm
        object.draw()</syntaxhighlight></div>
FOV_LIGHT_WALLS = True
TORCH_RADIUS = 10</pre>


Also, we'll need more colors for lit tiles! The color definitions will now be:
<pre>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)</pre>


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).
Ok, that's all! A fully generic object system. Later, this class can be modified to have all the special info that items, monsters and all that will require. But we can add that as we go along!


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.
[[Complete Roguelike Tutorial, using python+libtcod, part 2 code#Generalizing|Here]]'s the code so far.


<pre>fov_map = libtcod.map_new(MAP_WIDTH, MAP_HEIGHT)
== The Map ==
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)</pre>


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.
Just like how you generalized the concept of the player object, you'll now do the same thing with the dungeon map. Your map will be 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!
<pre>    #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</pre>


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.
<pre>    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)</pre>


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:
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">MAP_WIDTH = 80
<pre>visible = libtcod.map_is_in_fov(fov_map, x, y)</pre>
MAP_HEIGHT = 45</syntaxhighlight></div>


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.
<pre>        #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 )</pre>


There! It's done, you can move around the pillars and see their shadows being cast as you walk.
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.


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:
<pre>if libtcod.map_is_in_fov(fov_map, self.x, self.y):</pre>


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!
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">color_dark_wall = libtcod.Color(0, 0, 100)
color_dark_ground = libtcod.Color(50, 50, 150)</syntaxhighlight></div>


The whole code for this section is [[Complete Roguelike Tutorial, using python+libtcod, part 2 code#Field of View (FOV)|here]].


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!


== 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:
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">class Tile:
<pre>class Rect:
     #a tile of the map and its properties
     #a rectangle on the map. used to characterize a room.
     def __init__(self, blocked, block_sight = None):
     def __init__(self, x, y, w, h):
         self.blocked = blocked
         self.x1 = x
         self.y1 = y
         #by default, if a tile is blocked, it also blocks sight
         self.x2 = x + w
         if block_sight is None: block_sight = blocked
         self.y2 = y + h
         self.block_sight = block_sight</syntaxhighlight></div>


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:
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, [http://docs.python.org/tutorial/datastructures.html#list-comprehensions 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 ''element''s. 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 [http://docs.python.org/tutorial/datastructures.html#nested-list-comprehensions 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!
<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</pre>


But we want to leave some walls at the border of the room, so we'll leave out one tile in all directions.
<pre>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</pre>


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.
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">def make_map():
<pre>def make_map():
     global map
     global map
   
     #fill map with "blocked" tiles
     #fill map with "unblocked" tiles
     map = [[ Tile(True)
     map = [[ Tile(False)
         for y in range(MAP_HEIGHT) ]
         for y in range(MAP_HEIGHT) ]
             for x in range(MAP_WIDTH) ]
             for x in range(MAP_WIDTH) ]</syntaxhighlight></div>
      
 
     #create two rooms
 
     room1 = Rect(20, 15, 10, 15)
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.
    room2 = Rect(50, 15, 10, 15)
 
    create_room(room1)
 
     create_room(room2)</pre>
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">    map[30][22].blocked = True
     map[30][22].block_sight = True
     map[50][22].blocked = True
     map[50][22].block_sight = True</syntaxhighlight></div>
 
 
One very important piece of advice: ''in list comprehensions, always call the constructor of the objects you're creating'', like we did with Tile(False). If we had tried to first create an unblocked tile like ''floor = Tile(False)'' and then in the list comprehension just refer to that same ''floor'', we'd get all sorts of weird bugs! This is a common rookie (and veteran!) mistake in Python. That's because all elements in the list would point to the exact same Tile (the one you defined as ''floor''), not copies of it. Changing a property of one element would appear to change it in other elements as well! Calling the constructor for every element ensures that each is a distinct instance.
 
 
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()''.
 
 
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">def render_all():
     #draw all objects in the list
    for object in objects:
        object.draw()</syntaxhighlight></div>
 
 
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.


Before testing out, make the player appear in the center of the first room:
<pre>    player.x = 25
    player.y = 23</pre>


You can walk around the first room, but not reach the second. We'll define a function to carve a horizontal tunnel:
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">   for y in range(MAP_HEIGHT):
<def>create_h_tunnel(x1, x2, y):
        for x in range(MAP_WIDTH):
    global map
            wall = map[x][y].block_sight
    for x in range(min(x1, x2), max(x1, x2) + 1):
            if wall:
        map[x][y].blocked = False
                libtcod.console_set_char_background(con, x, y, color_dark_wall, libtcod.BKGND_SET )
        map[x][y].block_sight = False</pre>
            else:
                libtcod.console_set_char_background(con, x, y, color_dark_ground, libtcod.BKGND_SET )</syntaxhighlight></div>


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:
<pre>    for x in range(x1, x2 + 1):</pre>


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:
Also, move the ''console_blit'' call to the end of ''render_all()'' since it's part of the rendering code, just to keep things tidy.
<pre>    for x in range(x2, x1 + 1):</pre>


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.
A little note on customization: if you want a more old-school look, using characters like ' '''.''' ' and ' '''#''' ' to represent floor and wall tiles, [[Complete Roguelike Tutorial, using Python+libtcod, extras#Old-school wall and floor tiles|check out this Extra]].




== Exploration ==
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!


The last detail before moving on to dungeon generation is exploration, a.k.a Fog of War.
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:




== Dungeon generator ==
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">if not map[self.x + dx][self.y + dy].blocked:</syntaxhighlight></div>


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.


[[Complete Roguelike Tutorial, using python+libtcod, part 2 code#The Map|Here]]'s the code so far.


[[Complete Roguelike Tutorial, using python+libtcod, part 3|Go on to the next part]].
[[Complete Roguelike Tutorial, using python+libtcod, part 3|Go on to the next part]].
[[Category:Developing]]

Latest revision as of 15:16, 14 September 2017

This is part of a series of tutorials; the main page can be found here.

The tutorial uses libtcod version 1.6.0 and above.

If you choose to use 1.5.1, you can find the old version here.
If you choose to use 1.5.0, you can find the old version here.

If you are looking for a Python 3 version, see here


The object and the map

Off-screen consoles

There's one small thing we need to get out of the way before we can continue. Notice that the drawing functions we called (console_set_default_foreground and console_put_char) have their first argument set to 0, meaning that they draw on the root console. This is the buffer that is shown directly on screen.

It can be useful, however, to have other buffers to store the results of drawing functions without automatically showing them to the player. This is akin to drawing surfaces or buffers in other graphics libraries; but instead of pixels they store characters and colors so you can modify them at will. Some uses of these off-screen consoles include semi-transparency or fading effects and composing GUI panels by blitting them to different portions of the root console. We're going to draw on an off-screen console from now on. The main reason is that not doing so would mean that later on you can't compose different GUI panels as easily, or add certain effects.

First, create a new off-screen console, which for now will occupy the whole screen, but this can be changed later. We'll use a simple name like con because it will be used a lot! You can put this in the initialization, right after console_init_root.

con = libtcod.console_new(SCREEN_WIDTH, SCREEN_HEIGHT)


Now change the first argument of console_put_char (there are 2 calls to this function) and console_set_default_foreground, from 0 to con. They're now drawing on the new console.

Finally, just before console_flush(), blit the contents of the new console to the root console, to display them. The parameters may look a bit mysterious at first, but they're just saying that the source rectangle has its top-left corner at coordinates (0, 0) and is the same size as the screen; the destination coordinates are (0, 0) as well. Check the documentation on the console_blit function for more details.


libtcod.console_blit(con, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 0, 0, 0)


That was a lot of talk for so little code and no visible change! The next section will surely be much more interesting, as we'll introduce our first dummy NPC among other things. Remember to check back the above documentation page when you get to coding your GUI.

Generalizing

Now that we have the @ walking around, it would be a good idea to step back and think a bit about the design. Having variables for the player's coordinates is easy, but it can quickly get out of control when you're defining things such as HP, bonuses, and inventory. We're going to take the opportunity to generalize a bit.

Now, there can be such a thing as over-generalization, but we'll try not to fall in that trap. What we're going to do is define the player as a game Object, by creating that class. It will hold all position and display information (character and color). The neat thing is that the player will just be one instance of the Object class -- it's general enough that you can re-use it to define items on the floor, monsters, doors, stairs; anything representable by a character on the screen. Here's the class, with the initialization, and three common methods move, draw and clear. The code for drawing and erasing is the same as the one we used for the player earlier.


class Object:
    #this is a generic object: the player, a monster, an item, the stairs...
    #it's always represented by a character on screen.
    def __init__(self, x, y, char, color):
        self.x = x
        self.y = y
        self.char = char
        self.color = color
    
    def move(self, dx, dy):
        #move by the given amount
        self.x += dx
        self.y += dy
    
    def draw(self):
        #set the color and then draw the character that represents this object at its position
        libtcod.console_set_default_foreground(con, self.color)
        libtcod.console_put_char(con, self.x, self.y, self.char, libtcod.BKGND_NONE)
    
    def clear(self):
        #erase the character that represents this object
        libtcod.console_put_char(con, self.x, self.y, ' ', libtcod.BKGND_NONE)

Please note that there are two underscores on each side of __init__!

Now, before the main loop, instead of just setting the player's coordinates, we create it as an actual Object. We also add it to a list, that will hold all objects that are in the game. While we're at it we'll add a yellow @ that represents a non-playing character, like in an RPG, just to test it out!


player = Object(SCREEN_WIDTH/2, SCREEN_HEIGHT/2, '@', libtcod.white)
npc = Object(SCREEN_WIDTH/2 - 5, SCREEN_HEIGHT/2, '@', libtcod.yellow)
objects = [npc, player]


We'll have to make a couple of changes now. First, in the handle_keys function, instead of dealing directly with the player's coordinates, we can use the player's move method with the appropriate displacement. Later this will come in handy as it can automatically check if the player (or another object) is about to hit a wall. Secondly, the main loop will now clear all objects like this:


    for object in objects:
        object.clear()


And draw them like this:


    for object in objects:
        object.draw()


Ok, that's all! A fully generic object system. Later, this class can be modified to have all the special info that items, monsters and all that will require. But we can add that as we go along!

Here's the code so far.

The Map

Just like how you generalized the concept of the player object, you'll now do the same thing with the dungeon map. Your map will be 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[30][22].blocked = True
    map[30][22].block_sight = True
    map[50][22].blocked = True
    map[50][22].block_sight = True


One very important piece of advice: in list comprehensions, always call the constructor of the objects you're creating, like we did with Tile(False). If we had tried to first create an unblocked tile like floor = Tile(False) and then in the list comprehension just refer to that same floor, we'd get all sorts of weird bugs! This is a common rookie (and veteran!) mistake in Python. That's because all elements in the list would point to the exact same Tile (the one you defined as floor), not copies of it. Changing a property of one element would appear to change it in other elements as well! Calling the constructor for every element ensures that each is a distinct instance.


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_char_background(con, x, y, color_dark_wall, libtcod.BKGND_SET )
            else:
                libtcod.console_set_char_background(con, x, y, color_dark_ground, libtcod.BKGND_SET )


Also, move the console_blit call to the end of render_all() since it's part of the rendering code, just to keep things tidy.

A little note on customization: if you want a more old-school look, using characters like ' . ' and ' # ' to represent floor and wall tiles, check out this Extra.


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.

Go on to the next part.