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:

Let’s add another Offscreen console to our Tcod struct:

struct Tcod {
    root: Root,
    con: Offscreen,
    panel: Offscreen,  (1)
    fov: FovMap,
}

...

fn main() {
   ...
   let mut tcod = Tcod {
       root,
       con: Offscreen::new(MAP_WIDTH, MAP_HEIGHT),
       panel: Offscreen::new(SCREEN_WIDTH, PANEL_HEIGHT),  (2)
       fov: FovMap::new(MAP_WIDTH, MAP_HEIGHT),
   };
   ...
}
1 New Tcod field: panel
2 Initialise panel

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 tcod’s rect method for the rectangles.

fn render_bar(
    panel: &mut Offscreen,
    x: i32,
    y: i32,
    total_width: i32,
    name: &str,
    value: i32,
    maximum: i32,
    bar_color: Color,
    back_color: Color,
) {
    // render a bar (HP, experience, etc). First calculate the width of the bar
    let bar_width = (value as f32 / maximum as f32 * total_width as f32) as i32;

    // render the background first
    panel.set_default_background(back_color);
    panel.rect(x, y, total_width, 1, false, BackgroundFlag::Screen);

    // now render the bar on top
    panel.set_default_background(bar_color);
    if bar_width > 0 {
        panel.rect(x, y, bar_width, 1, false, BackgroundFlag::Screen);
    }
}

For extra clarity, the actual value and maximum are displayed as text over the bar, along with a caption ('Health', 'Mana', etc). Put this at the very end of render_bar:

// finally, some centered text with the values
panel.set_default_foreground(WHITE);
panel.print_ex(
    x + total_width / 2,
    y,
    BackgroundFlag::None,
    TextAlignment::Center,
    &format!("{}: {}/{}", name, value, maximum),
);

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
const BAR_WIDTH: i32 = 20;
const PANEL_HEIGHT: i32 = 7;
const PANEL_Y: i32 = SCREEN_HEIGHT - PANEL_HEIGHT;

We also changed MAP_HEIGHT to 43 to give the panel more room:

// size of the map
const MAP_WIDTH: i32 = 80;
const MAP_HEIGHT: i32 = 43;  (1)
1 Changed from 45 to 43

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
tcod.panel.set_default_background(BLACK);
tcod.panel.clear();

// show the player's stats
let hp = objects[PLAYER].fighter.map_or(0, |f| f.hp);
let max_hp = objects[PLAYER].fighter.map_or(0, |f| f.max_hp);
render_bar(
    &mut tcod.panel,
    1,
    1,
    BAR_WIDTH,
    "HP",
    hp,
    max_hp,
    LIGHT_RED,
    DARKER_RED,
);

// blit the contents of `panel` to the root console
blit(
    &tcod.panel,
    (0, 0),
    (SCREEN_WIDTH, PANEL_HEIGHT),
    &mut tcod.root,
    (0, PANEL_Y),
    1.0,
    1.0,
);

And we’ll have to add panel to the render_all arguments and pass it in from main.

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:

const MSG_X: i32 = BAR_WIDTH + 2;
const MSG_WIDTH: i32 = SCREEN_WIDTH - BAR_WIDTH - 2;
const MSG_HEIGHT: usize = PANEL_HEIGHT as usize - 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 vector so they can be easily manipulated. Each message is a tuple with 2 fields: the message string, and its color.

The type of that vector will be Vec<(String, Color)>. We’ll be passing it to a lot of our functions, so let’s make an alias for it:

struct Messages {
    messages: Vec<(String, Color)>,
}

We will use two operations on the struct: adding a new message and iterating over all the existing ones. For convenience, we will also add a new function so we can create it easily.

impl Messages {
    pub fn new() -> Self {
        Self { messages: vec![] }
    }

    /// add the new message as a tuple, with the text and the color
    pub fn add<T: Into<String>>(&mut self, message: T, color: Color) {
        self.messages.push((message.into(), color));
    }

    /// Create a `DoubleEndedIterator` over the messages
    pub fn iter(&self) -> impl DoubleEndedIterator<Item = &(String, Color)> {
        self.messages.iter()
    }
}

The <T: Into<String>> bit makes the add function generic. Instead of accepting a parameter of a specified type, it can work with anything that implements the Into trait for String, i.e. anything that can be converted to String. This lets us pass both &str (and therefore string literals) and String (an output of the format! macro among other things).

As we’re keeping the inner messages field private, we need to provide a way for our users to access the messages. In Rust, this is typically done via iterators. We could try to find the exact type that Vec::iter returns (it is: std::slice::Iter<'a, (String, Color)'), but that’s a bit hairy, not always desirable (you might prefer to treat the exact iterator type as an implementation detail subject to change) and for more complicated scenarios (e.g. returning an iterator that has map or filter called on it) completely impossible. Sometimes a function can return a type that you cannot write down in your own code.

What we can do instead is to say: "This function returns some type implementing this trait" and let the compiler figure it out.

To do that, you have your function return impl Trait and make sure whatever value you actually return does indeed implement that trait.

To show the messages, we go through them one by one, get the height of each (potentially line-wrapped) and draw them onto the panel using the print_rect method.

// print the game messages, one line at a time
let mut y = MSG_HEIGHT as i32;
for &(ref msg, color) in game.messages.iter().rev() {
    let msg_height = tcod.panel.get_height_rect(MSG_X, y, MSG_WIDTH, 0, msg);
    y -= msg_height;
    if y < 0 {
        break;
    }
    tcod.panel.set_default_foreground(color);
    tcod.panel.print_rect(MSG_X, y, MSG_WIDTH, 0, msg);
}

We’re going through the messages backwards (starting with the last message), because we don’t know if we get to print all. So we first calculate the height of the message (in case it gets wrapped), we draw it at the corresponding y position by subtracting the height and then repeat.

When we have y lower than zero, it would mean we’d draw above the panel. Libtcod wouldn’t let us, but since that means we’ve ran out of space, we may as well break out of the loop.

The original Python tutorial uses the textwrap module in Python’s standard library to split the text into multiple lines based on the maximum length. Rust’s standard library doesn’t have such a function, but we can use libtcod’s get_height_rect and print_rect to do the wrapping for us.

We’re going to add the Messages type our Game struct rather than adding another type to every function that will want to print a message:

struct Game {
    map: Map,
    messages: Messages,  (1)
}
1 Added messages

And we’ll initialise it is main:

fn main() {
    ...
let mut game = Game {
    // generate map (at this point it's not drawn to the screen)
    map: make_map(&mut objects),
    messages: Messages::new(),  (1)
};
    ...
}
1 Initialised messages

But now we’re ready to test it! Let’s print a friendly message before the main loop to welcome the player to our dungeon of doom:

// a warm welcoming message!
game.messages.add(
    "Welcome stranger! Prepare to perish in the Tombs of the Ancient Kings.",
    RED,
);

Yay! You can now replace all the println! macro uses with calls to our own message function (all four of them). The player death message is red (colors::RED), monster death is orange (colors::ORANGE) and the rest is colors::WHITE.

Unfortunately, to display messages, we have to pass the messages: &mut Messages vector everywhere we want to print a message (which is pretty much everywhere).

As usual, just replace the println!(…​) calls with message(…​) and let the compiler guide you.

The end result should look something like this:

fn player_death(player: &mut Object, game: &mut Game) {  (1)
    // the game ended!
    game.messages.add("You died!", RED);  (2)
    ...
}

fn monster_death(monster: &mut Object, game: &mut Game) {  (3)
    // transform it into a nasty corpse! it doesn't block, can't be
    // attacked and doesn't move
    game.messages
        .add(format!("{} is dead!", monster.name), ORANGE);  (4)
    ...
}

fn player_move_or_attack(dx: i32, dy: i32, game: &mut Game, objects: &mut [Object]) {  (5)
    ...
    let (player, target) = mut_two(PLAYER, target_id, objects);
    player.attack(target, game);  (6)
}

// handle keys and exit game if needed
previous_player_position = objects[PLAYER].pos();
let player_action = handle_keys(&mut tcod, &mut game, &mut objects);  (7)
if player_action == PlayerAction::Exit {
    break;
}

pub fn take_damage(&mut self, damage: i32, game: &mut Game) {
    ...

    // check for death, call the death function
    if let Some(fighter) = self.fighter {
        if fighter.hp <= 0 {
            self.alive = false;
            fighter.on_death.callback(self, game);
        }
    }
}

fn ai_take_turn(monster_id: usize, tcod: &Tcod, game: &mut Game, objects: &mut [Object]) {  (8)
    ...
    // close enough, attack! (if the player is still alive.)
    let (monster, player) = mut_two(monster_id, PLAYER, objects);
    monster.attack(player, game);  (9)
}

impl DeathCallback {
    fn callback(self, object: &mut Object, game: &mut Game) {  (10)
        use DeathCallback::*;
        let callback = match self {  (11)
            Player => player_death,
            Monster => monster_death,
        };
        callback(object, game);  (12)
    }
}

pub fn attack(&mut self, target: &mut Object, game: &mut Game) {
    // a simple formula for attack damage
    let damage = self.fighter.map_or(0, |f| f.power) - target.fighter.map_or(0, |f| f.defense);
    if damage > 0 {
        // make the target take some damage
        game.messages.add(
            format!(
                "{} attacks {} for {} hit points.",
                self.name, target.name, damage
            ),
            WHITE,
        );
        target.take_damage(damage, game);
    } else {
        game.messages.add(
            format!(
                "{} attacks {} but it has no effect!",
                self.name, target.name
            ),
            WHITE,
        );
    }
}

fn main() {
   ...
    while !tcod.root.window_closed() {
        ...
        // let monsters take their turn
        if objects[PLAYER].alive && player_action != PlayerAction::DidntTakeTurn {
            for id in 0..objects.len() {
                if objects[id].ai.is_some() {
                    ai_take_turn(id, &tcod, &mut game, &mut objects);
                }
            }
        }
    }
}
1 Added game to the function’s arguments
2 Using Messages instead of println!
3 Added game to the function’s arguments
4 Using Messages instead of println!
5 Made game mutable
6 Passing game to the function
7 Passing a mutable reference to Game to the function call
8 Made game mutable
9 Passing messages to the function call
10 Added messages to the function’s arguments
11 Passing messages to the callback function pointer type
12 Passing messages to the function call
This is quite annoying and you may think about using global variables or the singleton pattern to ease the pain. If you want to go that route, you may want to check out the lazy_static crate. But if you persist a while longer, we’ll collapse all these separate variables into three structs that are much easier to pass around.

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 libtcod it’s very easy to know the position of the mouse, and if there were any clicks: the input::check_for_event function returns information on both keyboard and mouse activity.

First, let’s import new types from the {input}[input module]:

use tcod::input::{self, Event, Key, Mouse};

Next we’ll add both fields to our Tcod struct:

struct Tcod {
    root: Root,
    con: Offscreen,
    panel: Offscreen,
    fov: FovMap,
    key: Key,  (1)
    mouse: Mouse,  (2)
}
1 New field: key
2 New field: mouse

Now in the main loop, populate the two new fields where we initialise the Tcod struct:

let mut tcod = Tcod {
    root,
    con: Offscreen::new(MAP_WIDTH, MAP_HEIGHT),
    panel: Offscreen::new(SCREEN_WIDTH, PANEL_HEIGHT),
    fov: FovMap::new(MAP_WIDTH, MAP_HEIGHT),
    key: Default::default(),
    mouse: Default::default(),
};

The Default::default() value is whatever the type considers a default value. It’s implemented for a lot of primitives and you derive it for your own types.

We use it to initialise our values to known states so we don’t have to wrap them in an Option when nothing happens.

And to fill them up, we use check_for_event at the beginning of the main loop, right before the call to render_all:

match input::check_for_event(input::MOUSE | input::KEY_PRESS) {
    Some((_, Event::Mouse(m))) => tcod.mouse = m,
    Some((_, Event::Key(k))) => tcod.key = k,
    _ => tcod.key = Default::default(),
}

We clear the key back to its default state when we don’t get a keyboard event back because our handle_keys system would treat it as a new keypress otherwise. We don’t have to clear the mouse, because "no mouse event" means it’s right where it was last time.

Now pass the key to handle_keys and remove the call to wait_for_keypress:

fn handle_keys(tcod: &mut Tcod, game: &mut Game, objects: &mut Vec<Object>) -> PlayerAction {  (1)
    ...
    let player_alive = objects[PLAYER].alive;  (2)
    match (tcod.key, tcod.key.text(), player_alive) {
        ...
    }
}
1 Made game mutable
2 Removed root.wait_for_keypress

Next we’ll write a function that lists names of all objects at the current mouse position. We’ll use the cx and cy fields of the Mouse struct, which are the coordinates of the tile (or cell) that the mouse is over.

/// return a string with the names of all objects under the mouse
fn get_names_under_mouse(mouse: Mouse, objects: &[Object], fov_map: &FovMap) -> String {
    let (x, y) = (mouse.cx as i32, mouse.cy as i32);

    // create a list with the names of all objects at the mouse's coordinates and in FOV
    let names = objects
        .iter()
        .filter(|obj| obj.pos() == (x, y) && fov_map.is_in_fov(obj.x, obj.y))
        .map(|obj| obj.name.clone())
        .collect::<Vec<_>>();

    names.join(", ") // join the names, separated by commas
}

We go through objects under the mouse, gather their names into a vector and then use join to put them into a string separated by a coma.

The render_all function 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
tcod.panel.set_default_foreground(LIGHT_GREY);
tcod.panel.print_ex(
    1,
    0,
    BackgroundFlag::None,
    TextAlignment::Left,
    get_names_under_mouse(tcod.mouse, objects, &tcod.fov),
);

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 wait_for_keypress. 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 wait_for_keypress call in handle_keys with the check_for_event 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 specific PlayerAction value(DidntTakeTurn). You’ll notice that the main loop only allows enemies to take their turns if the value returned from handle_keys is not DidntTakeTurn! 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!

Continue to the next part.