Our game is looking more and more playable by the chapter, but before we move forward with the gameplay, we ought to take a moment to focus on how the project looks. Despite what roguelike traditionalists may tell you, a good UI goes a long way.

Before we get started, we'll update our colors dictionary to include all the colors we'll need for this chapter. It would be annoying to have to come back and add them as needed, so let's just get it out of the way now.

    colors = {
        'dark_wall': (0, 0, 100),
        'dark_ground': (50, 50, 150),
        'light_wall': (130, 110, 50),
        'light_ground': (200, 180, 50),
        'desaturated_green': (63, 127, 63),
        'darker_green': (0, 127, 0),
        'dark_red': (191, 0, 0),
        'white': (255, 255, 255),
        'black': (0, 0, 0),
        'red': (255, 0, 0),
        'orange': (255, 127, 0),
        'light_red': (255, 114, 114),
        'darker_red': (127, 0, 0)
    }

Now let's move on to fixing up our HP section. With not all that much code, we can add a neat little health bar, that will tell the player how much health is remaining before death. We'll start by adding some needed variables in engine.py:

    ...
    screen_height = 50

    bar_width = 20
    panel_height = 7
    panel_y = screen_height - panel_height

    map_width = 80
    map_height = 45
    map_height = 43
    ...
    con = tdl.Console(screen_width, screen_height)
    panel = tdl.Console(screen_width, panel_height)

We're creating a new console, called panel, which will hold our HP bar and message log. We also modified the map height while we were at it, to give the HP bar and (soon to come) message log more room.

Now we'll want a function that draws the health bar, or any other bar we desire. You might want to add a Mana or Stamina bar later on, so it's best that we make this function as reusable as possible. Put the following in render_functions.py, right under the RenderOrder enum, but above render_all.

def render_bar(panel, x, y, total_width, name, value, maximum, bar_color, back_color, string_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)

    # Finally, some centered text with the values
    text = name + ': ' + str(value) + '/' + str(maximum)
    x_centered = x + int((total_width-len(text)) / 2)

    panel.draw_str(x_centered, y, text, fg=string_color, bg=None)

Now let's use this function in render_all. Remove the HP indicator we had put in before, and put the code for the stats panel at the end of the function.

def render_all(con, entities, player, game_map, fov_recompute, root_console, screen_width, screen_height, colors):
def render_all(con, panel, entities, player, game_map, fov_recompute, root_console, screen_width, screen_height,
               bar_width, panel_height, panel_y, colors):
            ...
    con.draw_str(1, screen_height - 2, 'HP: {0:02}/{1:02}'.format(player.fighter.hp, player.fighter.max_hp))

    root_console.blit(con, 0, 0, screen_width, screen_height, 0, 0)

    panel.clear(fg=colors.get('white'), bg=colors.get('black'))

    render_bar(panel, 1, 1, bar_width, 'HP', player.fighter.hp, player.fighter.max_hp,
               colors.get('light_red'), colors.get('darker_red'), colors.get('white'))

    root_console.blit(panel, 0, panel_y, screen_width, panel_height, 0, 0)

Be sure to update the call to render_all in engine.py:

        render_all(con, entities, player, game_map, fov_recompute, root_console, screen_width, screen_height, colors)
        render_all(con, panel, entities, player, game_map, fov_recompute, root_console, screen_width, screen_height,
                   bar_width, panel_height, panel_y, colors)

Now we've got a nice looking HP bar at the bottom of the screen. It'll decrease when the player takes damage, and increase when we heal (that's coming next chapter).

Let's keep things going, and create our message log. Add the following variables to engine.py to start:

    ...
    panel_y = screen_height - panel_height

    message_x = bar_width + 2
    message_width = screen_width - bar_width - 2
    message_height = panel_height - 1

    map_width = 80
    ...

To implement the message log, we'll need two classes: one for the log, and one for the messages inside it. Start by creating a new file, called game_messages.py. Put the following code inside it:

import textwrap


class Message:
    def __init__(self, text, color=(255, 255, 255)):
        self.text = text
        self.color = color


class MessageLog:
    def __init__(self, x, width, height):
        self.messages = []
        self.x = x
        self.width = width
        self.height = height

    def add_message(self, message):
        # Split the message if necessary, among multiple lines
        new_msg_lines = textwrap.wrap(message.text, self.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(self.messages) == self.height:
                del self.messages[0]

            # Add the new line as a Message object, with the text and the color
            self.messages.append(Message(line, message.color))

That's a lot to take in at once, so let's go through it.

Message is pretty simple. We store the message text and the color to draw it with. You can opt to not pass a color, in which case, white is used by default.

The MessageLog is the more interesting class. It holds a list of messages (the Message class), holds its x coordinate (for convenience), and its width and height. Width and height are useful so that we'll know when we need to cut off the top messages (the message log will "scroll" as new messages come up).

In the add_message method, we're splitting up the message text into multiple lines if needed, using the textwrap.wrap function. We then check if the message log is at its capacity, and if so, we delete the top lines. Finally, we append the new message.

Let's putting this new message log into place. Add a new message log to engine.py:

    ...
    fov_recompute = True

    message_log = MessageLog(message_x, message_width, message_height)

    game_state = GameStates.PLAYERS_TURN
    ...

Remember to import the MessageLog at the top as well:

from entity import Entity, get_blocking_entities_at_location
from game_messages import MessageLog
from game_states import GameStates

With our message log in place, let's go through the project and remove all the print statements, replacing them with the message log.

Let's start with the death functions. In death_functions.py:

from game_messages import Message

from game_states import GameStates

from render_functions import RenderOrder


def kill_player(player):
    player.char = '%'
    player.color = colors.get('dark_red')

    return 'You died!', GameStates.PLAYER_DEAD
    return Message('You died!', colors.get('red')), GameStates.PLAYER_DEAD


def kill_monster(monster):
    death_message = '{0} is dead!'.format(monster.name.capitalize())
    death_message = Message('{0} is dead!'.format(monster.name.capitalize()), colors.get('orange'))
    ...

Then, back in engine.py, replace the corresponding print statements like this:

             ...
            (In the player's results loop)
            ...
            if dead_entity:
                if dead_entity == player:
                    message, game_state = kill_player(dead_entity)
                else:
                    message = kill_monster(dead_entity)

                print(message)
                message_log.add_message(message)
            ...
            (In the enemy results loop)
            ...
                        if dead_entity:
                            if dead_entity == player:
                                message, game_state = kill_player(dead_entity)
                            else:
                                message = kill_monster(dead_entity)

                            print(message)
                            message_log.add_message(message)
                            ...

Now for our action messages. In fighter.py:

        ...
        if damage > 0:
            results.append({'message': '{0} attacks {1} for {2} hit points.'.format(
                self.owner.name.capitalize(), target.name, str(damage))})
            results.append({'message': Message('{0} attacks {1} for {2} hit points.'.format(
                self.owner.name.capitalize(), target.name, str(damage)))})
            results.extend(target.fighter.take_damage(damage))
        else:
            results.append({'message': '{0} attacks {1} but does no damage.'.format(
                self.owner.name.capitalize(), target.name)})
            results.append({'message': Message('{0} attacks {1} but does no damage.'.format(
                self.owner.name.capitalize(), target.name))})

        return results

You'll need to import the Message class:

from game_messages import Message


class Fighter:
    ...

And in engine.py:

            ...
            (In the player's results loop)
            ...
            if message:
                print(message)
                message_log.add_message(message)
            ...
            (In the enemy results loop)
            ...
                        if message:
                            print(message)
                            message_log.add_message(message)

Great, now we're adding all the messages to the log. However, nothing shows up yet. Let's modify render_all to display the message log we've created.

def render_all(con, panel, entities, player, game_map, fov_recompute, root_console, screen_width, screen_height,
               bar_width, panel_height, panel_y, colors):
def render_all(con, panel, entities, player, game_map, fov_recompute, root_console, message_log, screen_width,
               screen_height, bar_width, panel_height, panel_y, colors):
    ...
    panel.clear(fg=colors.get('white'), bg=colors.get('black'))

    # Print the game messages, one line at a time
    y = 1
    for message in message_log.messages:
        panel.draw_str(message_log.x, y, message.text, bg=None, fg=message.color)
        y += 1
    ...

Then modify the call to render_all in engine.py to include the message log:

        render_all(con, panel, entities, player, game_map, fov_recompute, root_console, screen_width, screen_height,
                   bar_width, panel_height, panel_y, colors)
        render_all(con, panel, entities, player, game_map, fov_recompute, root_console, message_log, screen_width,
                   screen_height, bar_width, panel_height, panel_y, colors)

Run the project now. All our previous printed statements should now appear in a scrolling message log. From here on out, we won't be doing any more print statements, we'll just add everything to our message log.

What's next? How about a little mouse-driven action? Our game is only orcs and trolls right now, but perhaps someday it will have dozens (hundreds?) of different monster and item types. It would be nice if we could see what they are by moving our mouse over them.

Lucky for us, capturing the mouse movement is easier than you might think. We've already set up our game loop to take input in a non-blocking fashion, so we just need to add some code to get the mouse coordinates, and pass them to a function to draw the names of the entities at that location.

Start by defining a new variable above our game loop to record the mouse's coordinates.

    ...
    message_log = MessageLog(message_x, message_width, message_height)

    mouse_coordinates = (0, 0)

    game_state = GameStates.PLAYERS_TURN
    ...

Let's update our loop that handles tdl events to capture mouse motion events:

        ...
        for event in tdl.event.get():
            if event.type == 'KEYDOWN':
                user_input = event
                break
            elif event.type == 'MOUSEMOTION':
                mouse_coordinates = event.cell
        else:
            user_input = None
        ...

With that, the mouse_coordinates variable will always have the latest x and y coordinates of the mouse. We can use that in relation to the map to figure out which entities are there. To do that, add the following function in render_functions.py, above render_bar:

def get_names_under_mouse(mouse_coordinates, entities, game_map):
    x, y = mouse_coordinates

    names = [entity.name for entity in entities
             if entity.x == x and entity.y == y and game_map.fov[entity.x, entity.y]]
    names = ', '.join(names)

    return names.capitalize()


def render_bar(panel, x, y, total_width, name, value, maximum, bar_color, back_color, string_color):
    ...

Now we'll once again modify our render_all function (that sure has had a lot of changes this chapter, hasn't it?) to account for the mouse and take advantage of our new function.

def render_all(con, panel, entities, player, game_map, fov_recompute, root_console, message_log, screen_width,
               screen_height, bar_width, panel_height, panel_y, colors):
def render_all(con, panel, entities, player, game_map, fov_recompute, root_console, message_log, screen_width,
               screen_height, bar_width, panel_height, panel_y, mouse_coordinates, colors):
    ...
    render_bar(panel, 1, 1, bar_width, 'HP', player.fighter.hp, player.fighter.max_hp,
               colors.get('light_red'), colors.get('darker_red'), colors.get('white'))

    panel.draw_str(1, 0, get_names_under_mouse(mouse_coordinates, entities, game_map))

    root_console.blit(panel, 0, panel_y, screen_width, panel_height, 0, 0)

And, of course, we'll need to modify the call to render_all in engine.py to match our new definition.

        render_all(con, panel, entities, player, game_map, fov_recompute, root_console, message_log, screen_width,
                   screen_height, bar_width, panel_height, panel_y, colors)
        render_all(con, panel, entities, player, game_map, fov_recompute, root_console, message_log, screen_width,
                   screen_height, bar_width, panel_height, panel_y, mouse_coordinates, colors)

Our game is now looking much, much better. If you ever intend for your game to be played by more people than just yourself (it's okay if you don't!) then changes like these will be of paramount importance to your project.

If you want to see the code so far in its entirety, click here.

Click here to move on to the next part of this tutorial.