This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!
If you enjoy this and would like me to keep writing, please consider supporting my Patreon.
Tooltips
Whenever I play a pure-ASCII roguelike, I need a "look" function. It's all very well to turn a corner and find yourself facing a see of g
characters, but that triggers my brain into trying to remember what g
stands for! It could be a goblin, a gnu, a ghost, a gargoyle or all manner of other uses of the letter g
. I'd much rather be able to glance (ugh, another "g"!) at it and know what I'm up against!
Some games - I'm looking at you, Nethack
- take this a little too far in my opinion. You can use a look at command to identify things, read the manual, or the in-game help. It's great that the options are all there - but looking at Medusa can be really bad for your health. That's a good joke, but terrible design: the first time you encounter Medusa, you may not remember what the glyph means. Instantly turning the player to stone because they didn't memorize the (huge) list of possible symbols is funny the first time. It's downright annoying the second, especially if you don't have players with eidetic memory (and limiting your player-base to those with perfect memories is just mean!).
So, I wanted tooltips. Mouse over a glyph and see a description. It also gives me a chance to do some writing (you may have noticed by now that I love writing).
Escaping from the Modal
The first thing I wanted to do was to let the player get out of the modal we created in the last section. I was certain that I'd need some game logic (it wouldn't be much of a game without it), so I created a game
module:
- Create a new directory,
src/game
. - Create a new file
src/game/mod.rs
. - Try to keep
mod.rs
as a directory of other content.
I wanted to start adding player logic, so I added the following to game/mod.rs
:
#![allow(unused)] fn main() { pub mod player; pub use player::player_turn; }
Then I created another new file: src/game/player.rs
and added some really simple player code to it:
#![allow(unused)] fn main() { use crate::{components::*, render::tooltips::render_tooltips}; use crate::{ map::{Map, HEIGHT, WIDTH}, NewState, }; use bracket_lib::prelude::*; use legion::*; pub fn player_turn(ctx: &mut BTerm, ecs: &mut World, map: &mut Map) -> NewState { render_tooltips(ctx, ecs, map); NewState::Wait } }
I hadn't made NewState
yet, but the idea is that game functions can return an enumeration indicating where the game should go next. Open up main.rs
and add:
#![allow(unused)] fn main() { mod game; }
This adds the new game module to the program. Add a new enum for NewState
:
#![allow(unused)] fn main() { pub enum NewState { NoChange, Wait, Player, Enemy, } }
Finally, replace the tick
function as follows:
#![allow(unused)] fn main() { impl GameState for State { fn tick(&mut self, ctx: &mut BTerm) { ctx.cls(); render::render_ui_skeleton(ctx); self.map.render(ctx); render::render_glyphs(ctx, &self.ecs, &self.map); let new_state = match &self.turn { TurnState::Modal { title, body } => render::modal(ctx, title, body), TurnState::WaitingForInput => game::player_turn(ctx, &mut self.ecs, &mut self.map), _ => NewState::NoChange, }; match new_state { NewState::NoChange => {} NewState::Wait => self.turn = TurnState::WaitingForInput, NewState::Player => self.turn = TurnState::PlayerTurn, NewState::Enemy => self.turn = TurnState::EnemyTurn, } } } }
We now have a solid pattern for game state progression: it renders dependent upon the turn state, and calls game logic. The game logic can indicate that a new mode is necessary (or return NoChange
to keep spinning) and trigger the new mode.
The game won't quite compile. The new_state
matcher is expecting every arm to return a NewState
. The modal
renderer doesn't do that yet. So open up render/mod.rs
and adjust the modal rendering code:
#![allow(unused)] fn main() { use crate::NewState; pub fn modal(ctx: &mut BTerm, title: &String, body: &String) -> NewState { let mut draw_batch = DrawBatch::new(); draw_batch.draw_double_box(Rect::with_size(19, 14, 71, 12), ColorPair::new(CYAN, BLACK)); let mut buf = TextBuilder::empty(); buf.ln() .fg(YELLOW) .bg(BLACK) .centered(title) .fg(CYAN) .bg(BLACK) .ln() .ln() .line_wrap(body) .ln() .ln() .fg(YELLOW) .bg(BLACK) .centered("PRESS ENTER TO CONTINUE") .reset(); let mut block = TextBlock::new(21, 15, 69, 11); block.print(&buf).expect("Overflow occurred"); block.render_to_draw_batch(&mut draw_batch); draw_batch.submit(0).expect("Batch error"); render_draw_buffer(ctx).expect("Render error"); if let Some(key) = ctx.key { match key { VirtualKeyCode::Return => NewState::Wait, VirtualKeyCode::Space => NewState::Wait, _ => NewState::NoChange, } } else { NewState::NoChange } } }
The new code is all at the bottom. If checks to see if a key is pressed, and if its Return
or Space
returns NewState::Wait
- indicating that it should move the game state to WaitingForInput
. Otherwise, it returns NoChange
and keeps spinning.
If you run the game now (you'd need to comment out render_tooltips
in player.rs
), you can see the modal popup from before - but pressing enter dismisses it (and the game then does nothing of much at all).
Adding tooltips
Create a new file, src/render/tooltips.rs
:
#![allow(unused)] fn main() { use bracket_lib::prelude::*; use legion::*; use crate::{components::{Description, Position}, map::{HEIGHT, Map, WIDTH}}; pub fn render_tooltips(ctx: &mut BTerm, ecs: &World, map: &Map) { let (mx, my) = ctx.mouse_pos(); let map_x = mx -1; let map_y = my - 1; if map_x >= 0 && map_x < WIDTH as i32 && map_y >= 0 && map_y < HEIGHT as i32 { let mut lines = Vec::new(); let mut query = <(&Position, &Description)>::query(); query.for_each(ecs, |(pos, desc)| { if pos.layer == map.current_layer as u32 && pos.pt.x == map_x && pos.pt.y == map_y { lines.push(desc.0.clone()); } }); if !lines.is_empty() { let height = lines.len() + 1; let width = lines.iter().map(|s| s.len()).max().unwrap() + 2; let tip_x = if map_x < WIDTH as i32/2 { mx+1 } else { mx - (width as i32 +1) }; let tip_y = if map_y > HEIGHT as i32/2 { my - height as i32 } else { my }; ctx.draw_box(tip_x, tip_y, width, height, WHITE, BLACK); let mut y = tip_y + 1; lines.iter().for_each(|s| { ctx.print_color(tip_x+1, y, WHITE, BLACK, s); y += 1; }); } } } }
This is a messy function, but quite straightforward:
- It obtains the mouse position as
(mx, my)
withmouse_pos()
from the context. - It sets
map_x
andmap_y
tomx-1
andmy-1
respectively. This offsets the mouse position into the map's coordinates - we have a 1 tile border around the map. - It checks that the map coordinates are within the map boundaries. With hindsight,
in_bounds
would have done this with less typing. - It runs a Legion ECS query for all entities with a
Position
andDescription
component. If they are on the current layer, and at the currentmap_x/may_y
coordinates it adds their descriptions to alines
vector. - If lines isn't empty:
- Calculate the total length of the tooltip in lines. Add 2 to support the box around the tip.
- Calculate the width by looking the longest string. Add 2 to support the box around the tip.
- If the mouse is on the left half of the screen, set
tip_x
to be just to the right of the cursor. Otherwise, set it to be (length+1) tiles left of the cursor. - Draw a box around the total tooltip.
- Iterate the lines vector, and draw each line.
Still messy (and replaced later), but it works. :-)
Using the Tooltips
In render/mod.rs
add the following line:
#![allow(unused)] fn main() { pub mod tooltips; }
You can run the game now and see a tooltip for the player:
You can find the source code for
hello_tooltip
here.
Onwards!
Next, we'll let SecBot's @
walk around the map.