Roguelike Tutorial, using python3+tdl, part 8

From RogueBasin
Revision as of 23:52, 16 October 2017 by Pappet (talk | contribs)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

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

The tutorial uses tdl version 3.1.0 and Python 3.5

Items and inventory

Placing items

Now that our GUI is all spiffed up, let's put in some more core Roguelike functionality: the inventory! This has been a staple of Roguelikes and RPGs for literally decades. It's a way of gating the player's access to some abilities, and presents an incentive for exploration. Also, why else would you explore a dungeon if not to haul out as many precious items as you can?

We can place some items in each room in pretty much the same way we place monsters, at the end of the place_objects function:

    #choose random number of items
    num_items = randint(0, MAX_ROOM_ITEMS)
    for i in range(num_items):
        #choose random spot for this item
        x = randint(room.x1+1, room.x2-1)
        y = randint(room.y1+1, room.y2-1)
        #only place it if the tile is not blocked
        if not is_blocked(x, y):
            #create a healing potion
            item = GameObject(x, y, '!', 'healing potion', colors.violet)
            item.send_to_back()  #items appear below other objects

For this to work, we must define the new constant MAX_ROOM_ITEMS = 2 at the top. Later we'll expand this with a few magic scrolls in addition to the healing potions; this is the spot to add any items you want in your game. The healing potions don't have any special components for now; we'll get to that in a second.

The limits of the random position of the items (passed to random_get_int) are a bit different than for the monsters. In fact, the monsters' coordinates have been slightly off the whole time! They should be changed from this:

        #choose random spot for this monster
        x = randint(room.x1, room.x2)
        y = randint(room.y1, room.y2)

To this:

        #choose random spot for this monster
        x = randint(room.x1+1, room.x2-1)
        y = randint(room.y1+1, room.y2-1)

Which is one tile tighter than the room's walls in every direction. That's because the room's rectangle, as we defined it earlier, includes its walls too (oops!). Anyway, when the old code decided to place a monster on the walls, it wouldn't get created due to the is_blocked check, so there were less monsters on average -- now the game got a little harder! The healing potions should balance this effect, but of course you can always tweak the values to your liking.

After that embarrassing revelation, let's define the inventory! This goes before the main loop:

inventory = []

Simple enough: the inventory is a list of items, and it starts empty. Now the Item component -- it will hold all data and functionality that makes an object behave like an item. For it to make its way into the player's inventory, we'll start by giving it a pick_up method.

class Item:
    #an item that can be picked up and used.
    def pick_up(self):
        #add to the player's inventory and remove from the map
        if len(inventory) >= 26:
            message('Your inventory is full, cannot pick up ' + + '.',
            message('You picked up a ' + + '!',

The limit of 26 is because later, in the inventory screen, items will be selected by pressing a key from A to Z, and there are 26 letters. You could overcome this restriction by implementing "pages" in the inventory, or a fancier interface with scrollbars. That would be a bit harder, so we'll stick to this for now. You could also assign weights to the items and limit the total weight here, as some games do.

This component must be accepted by the GameObject 's __init__ method, like all other components. Just add another parameter to it item=None, and an initialization similar to the other components:

        self.item = item
        if self.item:  #let the Item component know who owns it
            self.item.owner = self

Now that we have an Item component, you can add it to the healing potion in place_objects:

            item_component = Item()
            item = GameObject(x, y, '!', 'healing potion', colors.violet, item=item_component)

How does the player pick up an item? It's very easy: just test for another key in the handle_keys function. If it's pressed, look for an item under the player and pick it up. The new code goes between the else and the return 'didnt-take-turn' line:

            #test for other keys
            if user_input.text == 'g':
                #pick up an item
                for obj in objects:  #look for an item in the player's tile
                    if obj.x == player.x and obj.y == player.y and obj.item:
            return 'didnt-take-turn'

Note: In the older versions of the tutorial which used tdl version 1.x.x, "user_input.text" was "user_input.char". This old code will no longer work on version 3.x.x, and vice-versa.

You can test it out now! There will be a few potions scattered around, and you'll get a message when you pick them up by pressing G. The inventory is still invisible though.

The inventory screen

We now get to what's probably the trickiest part: showing the inventory screen. Since the functionality is tightly bound to the user interface, it's hard to do it without super-messy code.

To minimize the amount of hacks, we'll define a single function to present a list of options to the player, and reuse the hell out of it! We'll start by defining its parameters so we can decide exactly what it's supposed to do:

def menu(header, options, width):

This function should show a window with a string at the top, the header, which can be the title of the window and/or an explanatory text (say, "Choose an item to use" or "Choose an item to drop"). Following are the options, which are nothing more than a list of strings (for instance, the names of the items). We also need to define the window's width; the height is implicit, since it depends on the header height and number of options.

A letter will be shown next to each option (A, B, ...) so you can select it by pressing that key. Finally, the function returns the index of the selected option (starting with 0), or None if the user pressed some other key. We'll start by just displaying the menu and worry about choosing an option later.

First, check if there are more options than allowed. Since the menu function is supposed to be reused, it's possible that in the future you'll get too carried away and try to give it more options than the letters from A to Z! It's better to get an early error and fix it than let it slide and get harder-to-track errors down the line.

    if len(options) > 26: raise ValueError('Cannot have a menu with more than 26 options.')

Now we calculate the height of the window; as I said, it's implicit. The header will be shown using the draw_rect function. We'll also need to word-wrap long sentences so they fit a given width. We'll need to split the lines manually and calculate the number of lines of wrapped text; the total height is that plus the number of options.

    #calculate total height for the header (after textwrap) and one line per option
    header_wrapped = []
    for header_line in header.splitlines():
        header_wrapped.extend(textwrap.wrap(header_line, width))
    header_height = len(header_wrapped)
    height = len(options) + header_height

Given the window's size, we can create an off-screen console where the window's contents will be drawn first. The header is printed at the top, using the auto-wrap function.

    #create an off-screen console that represents the menu's window
    window = tdl.Console(width, height)
    #print the header, with wrapped text
    window.draw_rect(0, 0, width, height, None, fg=colors.white, bg=None)
    for i, line in enumerate(header_wrapped):
        window.draw_str(0, 0+i, header_wrapped[i])

Now to the actual options, printed in a loop. The Y coordinate of the first option is right below the header; we print the option's text and increment it. We also want to start with the letter A and increment it each time, to show it next to the option's text. The ord function returns the ASCII code of the letter A; we can then increment it to get the codes of the remaining letters. To convert an ASCII code back to a character (string), we use the chr function. These are built-in functions; you can read up on them (and others) in the Python docs.

    y = header_height
    letter_index = ord('a')
    for option_text in options:
        text = '(' + chr(letter_index) + ') ' + option_text
        window.draw_str(0, y, text, bg=None)
        y += 1
        letter_index += 1

Ok, all of the window's contents are stored in the off-screen console! It's a simple matter of calling root.blit to display them on the screen. These little formulae calculate what the position of the top-left corner of the window should be, so that it's centered on the screen.

    #blit the contents of "window" to the root console
    x = SCREEN_WIDTH//2 - width//2
    y = SCREEN_HEIGHT//2 - height//2
    root.blit(window, x, y, width, height, 0, 0)

It's not complete though; this screen will be shown for a single frame and then vanish immediately, replaced by the new frame. We need to stop time until the player makes a choice, and only then can the game carry on. This is easy to do with console_wait_for_keypress. There's also the need to flush the screen to present the changes before waiting for input:

    #present the root console to the player and wait for a key-press
    key = tdl.event.key_wait()
    key_char = key.char
    if key_char == '':
        key_char = ' ' # placeholder

The arrow keys return an empty string as a character, which won't work well later, so we replace the empty string with a space character as a placeholder in the last two lines.

That was one really long function! But if you base most of your interfaces on this function, you won't need to create any more like it. As an example, here's how you show an inventory -- just build a list of the items' names, and call the menu function:

def inventory_menu(header):
    #show a menu with each item of the inventory as an option
    if len(inventory) == 0:
        options = ['Inventory is empty.']
        options = [ for item in inventory]
    index = menu(header, options, INVENTORY_WIDTH)

It also tells the player if the inventory is empty; simply displaying an empty list would be rude! The constant INVENTORY_WIDTH = 50 is defined at the top, as usual. The header text is a parameter because we want to call this both for using and dropping items (and maybe other actions). Speaking of which, we can define the inventory key right now, in handle_keys (after the code to pick up items). The line break \n after the header gives one line of separation between it and the options.

            if user_input.text == 'i':
                #show the inventory
                inventory_menu('Press the key next to an item to use it, or any other to cancel.\n')

Finally, the inventory is visible! You can list the items you pick up by pressing I. Selecting them does nothing though; that is handled in the next section.

Using items

What happens when you use an item? Well, it depends on which item you're talking about. They're all different, so the "use" behavior of each item must be defined as a different function. For the Item component to know which one is it, you need to pass it at initialization, just like the Fighter with its death_function.

    def __init__(self, use_function=None):
        self.use_function = use_function

Then, a generic method can call the Item's use_function:

    def use(self):
        #just call the "use_function" if it is defined
        if self.use_function is None:
            message('The ' + + ' cannot be used.')
            if self.use_function() != 'cancelled':
                inventory.remove(self.owner)  #destroy after use, unless it was cancelled for some reason

If it's undefined, the item can't be used. Otherwise the function is called, and the item is destroyed (since most items are single-use). We'll also allow the function to return a special string in case it found that it can't be used after all; for instance, the player is already at full health, so the potion shouldn't be destroyed.

Now let's make the function for the healing potion! It's quite straightforward, as it simply calls a heal method of the Fighter component (which manages health).

def cast_heal():
    #heal the player
    if player.fighter.hp == player.fighter.max_hp:
        message('You are already at full health.',
        return 'cancelled'
    message('Your wounds start to feel better!', colors.light_violet)

The heal method is very simple too; still, it's handy to keep it since it will probably be used multiple times. The constant HEAL_AMOUNT = 4 is defined at the top.

    def heal(self, amount):
        #heal by the given amount, without going over the maximum
        self.hp += amount
        if self.hp > self.max_hp:
            self.hp = self.max_hp

To make the healing potion object call it on use, pass it as a parameter to the Item component, in place_objects.

            item_component = Item(use_function=cast_heal)

That's it for creating usable items! You can make other items easily by just defining their use_function. This could also work for wielding weapons or wearing armor, zapping wands, rubbing a magic lamp and all that stuff we know and love.

We now need to get back to the menu function, to finish it so it can actually select an item. It's only a few lines of code though; we already have the key from tdl.event.key_wait, and it's just a matter of converting it to the selected option's index. We do that by subtracting the ASCII code of the letter A, so we get key codes from 0 to 25 corresponding to letters A to Z. Anything out of that range means it's not a valid key. Actually, any index over the number of options is invalid too, since the number of options is usually smaller than 26.

    #convert the ASCII code to an index; if it corresponds to an option, return it
    #convert the ASCII code to an index; if it corresponds to an option, return it
    index = ord(key_char) - ord('a')
    if index >= 0 and index < len(options):
        return index
    return None

This returns a valid index, or None if something else was pressed. The inventory_menu function can make use of that index, and return the corresponding item, so this goes at the end:

    #if an item was chosen, return it
    if index is None or len(inventory) == 0: return None
    return inventory[index].item

The inventory_menu function is very handy; it returns the selected item directly, or None if cancelled. We can now change the code in handle_keys to use the selected item:

            if key_char == 'i':
                #show the inventory; if an item is selected, use it
                chosen_item = inventory_menu('Press the key next to an item to use it, or any other to cancel.\n')
                if chosen_item is not None:

There you go, the inventory code is complete! Well, minus dropping items. That's fairly easy with the inventory_menu, but to keep this from getting long we'll leave it to the next part: magic scrolls! That will really make the most of this inventory system.

The whole code is available here.

Go on to the next part.