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!
Here’s the complete code so far.
Continue to the next part.