Roguelike Tutorial, using python3+tdl, part 7

From RogueBasin
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


The GUI


Status bars

Lots of stuff happens under the hood of a game that players don't really appreciate, like the combat mechanics detailed in the last couple of sections. We'll now work on something much more flashy -- the Graphical User Interface! Using the full power of libtcod's true-color consoles, and a bit of creativity, you can make some truly amazing graphics. You may argue that the limitations of a console actually make it easier to create a polished game, rather than if you had the freedom to position per-pixel graphics like most other games.

We'll start by creating a GUI panel at the bottom of the screen. Of course, you're welcome to change this to suit your taste. For now, it will hold the player's health bar and a colored message log.

It's easier to manage GUI windows and panels with an off-screen console for each one, created before the main loop:


panel = tdl.Console(SCREEN_WIDTH, PANEL_HEIGHT)


The constant PANEL_HEIGHT is defined later, along with others. Let's jump right to the "status bar" rendering code! This is fully generic and can be used for experience bars, mana bars, recharge times, dungeon level, you name it.

The bar has two parts, one rectangle that changes size according to the proportion between the value and the maximum value, and a background rectangle. It just takes a simple formula to calculate that size, and a few calls to tdl's Console.draw_rect function for the rectangles.


def render_bar(x, y, total_width, name, value, maximum, bar_color, back_color):
    #render a bar (HP, experience, etc). first calculate the width of the bar
    bar_width = int(float(value) / maximum * total_width)
    
    #render the background first
    panel.draw_rect(x, y, total_width, 1, None, bg=back_color)
    
    #now render the bar on top
    if bar_width > 0:
        panel.draw_rect(x, y, bar_width, 1, None, bg=bar_color)


For extra clarity, the actual value and maximum are displayed as text over the bar, along with a caption ('Health', 'Mana', etc).


    #finally, some centered text with the values
    text = name + ': ' + str(value) + '/' + str(maximum)
    x_centered = x + (total_width-len(text))//2
    panel.draw_str(x_centered, y, text, fg=colors.white, bg=None)


Now we'll modify the main rendering function to use this. First, define a few constants: the height of the panel, its position on the screen (it's a bottom panel so only the Y is needed) and the size of the health bar.


#sizes and coordinates relevant for the GUI
BAR_WIDTH = 20
PANEL_HEIGHT = 7
PANEL_Y = SCREEN_HEIGHT - PANEL_HEIGHT


I also changed MAP_HEIGHT to 43 to give the panel more room. At the end of render_all, replace the code that shows the player's stats as text with the following code. It re-initializes the panel to black, calls our render_bar function to display the player's health, then shows the panel on the root console.


    #prepare to render the GUI panel
    panel.clear(fg=colors.white, bg=colors.black)
    
    #show the player's stats
    render_bar(1, 1, BAR_WIDTH, 'HP', player.fighter.hp, player.fighter.max_hp,
        colors.light_red, colors.darker_red)
    
    #blit the contents of "panel" to the root console
    root.blit(panel, 0, PANEL_Y, SCREEN_WIDTH, PANEL_HEIGHT, 0, 0)


Time to test it -- that health bar looks pretty sweet! And you can easily make more like it with different colors and all.

A small detail, the console where the map is rendered (con) should be the size of the map, not the size of the screen. This is more noticeable now that the panel takes up quite a bit of space. Change SCREEN_WIDTH and SCREEN_HEIGHT to MAP_WIDTH and MAP_HEIGHT when creating this console and blitting it. It's the con = tdl.Console(...) line before the main loop, and the first root.blit in render_all.


The message log

Until now the combat messages were dumped in the standard console -- not very user-friendly. We'll make a nice scrolling message log embedded in the GUI panel, and use colored messages so the player can know what happened with a single glance. It will also feature word-wrap!

The constants that define the message bar's position and size are:


MSG_X = BAR_WIDTH + 2
MSG_WIDTH = SCREEN_WIDTH - BAR_WIDTH - 2
MSG_HEIGHT = PANEL_HEIGHT - 1


This is so it appears to the right of the health bar, and fills up the rest of the space. The messages will be stored in a list so they can be easily manipulated. Each message is a tuple with 2 fields: the message string, and its color.


#create the list of game messages and their colors, starts empty
game_msgs = []


A simple-to-use function will handle adding messages to the list. It will use Python's textwrap module to split a message into several lines if it's too long! For that, put import textwrap at the top of the file, and create the function:


def message(new_msg, color = colors.white):
    #split the message if necessary, among multiple lines
    new_msg_lines = textwrap.wrap(new_msg, MSG_WIDTH)
    
    for line in new_msg_lines:
        #if the buffer is full, remove the first line to make room for the new one
        if len(game_msgs) == MSG_HEIGHT:
            del game_msgs[0]
        
        #add the new line as a tuple, with the text and the color
        game_msgs.append((line, color))


After obtaining the broken up message as a list of strings, it adds them one at a time to the actual message log. This is so that, when the log gets full, the first line is removed to make space for the new line. Dealing with one line at a time makes it easy to ensure that the message log never has more than a maximum height.

The code to show the message log is simpler. Just loop through the lines and print them with the appropriate colors (right before rendering the health bar). Notice how we get the values of the tuple right in the for loop; this sort of feature in Python (called unpacking) allows you to write very concise code.


    #print the game messages, one line at a time
    y = 1
    for (line, color) in game_msgs:
        panel.draw_str(MSG_X, y, line, bg=None, fg=color)
        y += 1


Ready to test! Let's print a friendly message before the main loop to welcome the player to our dungeon of doom:


#a warm welcoming message!
message('Welcome stranger! Prepare to perish in the Tombs of the Ancient Kings.', colors.red)


The long message allows us to test the word-wrap. You can now replace all the calls to the standard print with calls to our own message function (all 4 of them). I made the player death message red (colors.red), and the monster death message orange (colors.orange), others are the default. By the way, here's the list of colors. It's very handy, if you don't mind using a pre-defined palette of colors! As mentioned ealier, don't forget that our colors.py file uses underscores (light_red) rather than camel case (lightRed).

This is just the kind of polish that our game needed, it's much more attractive, even for casual players. Don't let the die-hard roguelike players fool you, everyone likes a little bit of eye candy!

Mouse-look

We'll now work some interactivity into our GUI. Roguelikes have a long tradition of using strict keyboard interfaces, and that's nice; but for a couple of tasks, like selecting a tile, a mouse interface is much easier. So we'll implement something like a "look" command, by automatically showing the name of any object the player hovers the mouse with! You could also use it for selecting targets of spells and ranged combat. Of course this is only a tutorial, showing you what you can do, and you may decide to replace this with a traditional "look" command!

Using tdl it's very easy to know the position of the mouse, and if there were any clicks: the tdl.event.get method returns information on both keyboard and mouse activity. See here for more details about mouse motion events, and here for the mouse button events.

We need to restructure the program a little bit to use this combined mouse and keyboard detection. Just before the main loop, add:


mouse_coord = (0, 0)


As our turn-based game gets more complex, we'll have to remove the option REALTIME = False. In the extras at the end of the tutorial, we'll discuss how to re-implement a real-time game.

For now, lets's take this whole block of code


    if REALTIME:
        keypress = False
        for event in tdl.event.get():
            if event.type == 'KEYDOWN':
               user_input = event
               keypress = True
        if not keypress:
            return

    else: #turn-based
        user_input = tdl.event.key_wait()


and chage it to:


    keypress = False
    for event in tdl.event.get():
        if event.type == 'KEYDOWN':
            user_input = event
            keypress = True
        if event.type == 'MOUSEMOTION':
            mouse_coord = event.cell

    if not keypress:
        return 'didnt-take-turn'


We now have a mouse_coord global variable that lets use know which tile the mouse pointer is on. This will be used by a new function that returns a string with the names of objects under the mouse:


def get_names_under_mouse():
    global visible_tiles

    #return a string with the names of all objects under the mouse
    (x, y) = mouse_coord


Now we need to gather a list of names of objects that satisfy a few conditions: they're under the mouse, and inside the player's FOV. (Otherwise he or she would be able to detect enemies through walls!) This can be done with list comprehensions, using the "if" variant.


    #create a list with the names of all objects at the mouse's coordinates and in FOV
    names = [obj.name for obj in objects
        if obj.x == x and obj.y == y and (obj.x, obj.y) in visible_tiles]


After that mouthful, it's a simple matter of joining the names into a single string, using commas. Python has neat functions for this and capitalizing the first letter:


    names = ', '.join(names)  #join the names, separated by commas
    return names.capitalize()


The function render_all can call this to get the string that depends on the mouse's position, after rendering the health bar:


    #display names of objects under the mouse
    panel.draw_str(1, 0, get_names_under_mouse(), bg=None, fg=colors.light_gray)


But wait! If you recall, in a turn-based game, the rendering is done only once per turn; the rest of the time, the game is blocked on tdl.event.key_wait. During this time (which is most of the time) the code we wrote above would simply not be processed! We switched to real-time rendering by replacing the tdl.event.key_wait call in handle_keys with the tdl.event.get in the main loop.


Won't our game stop being turn-based then? It's funny, but surprisingly it won't! Before you question logic itself, let me tell you that we did some changes earlier that had the side-effect of enabling this.

When the player doesn't take a turn (doesn't press a movement/attack key), handle_keys returns a special string ( 'didnt-take-turn' ). You'll notice that the main loop only allows enemies to take their turns if the value returned from handle_keys is not 'didnt-take-turn' ! The main loop goes on, but the monsters don't move. The only real distinction between a real-time game and a turn-based game is that, in a turn-based game, the monsters wait until the player moves to make their move. Makes sense!

If you hadn't before, you now need to call tdl.setFPS(LIMIT_FPS) before the main loop to limit the game's speed.

That's it! You can move the mouse around to quickly know the names of every object in sight.


The whole code is available here.

Go on to the next part.