Roguelike Tutorial 2020: Part 7 - User Interface

In this part we’ll add a heads-up display consisting of a health bar and message log.

By the end of this part, the game will look like this:

screenshot-end

This part is loosely based on this part of the python tcod tutorial.

Reference implementation branch for starting point: part-6-end

In this post:

Basic Health Bar

Right now all our drawing takes place in AppView’s implementation of the View trait. One of the goals of the chargrid library is to allow drawing logic to be split up into independent modules which can be composed into complex UI’s. Thus far the only thing we’ve needed to draw has been the game, but in this part we’ll add a UI as well, so start by moving the game-drawing logic into a new type:

// app.rs
...
#[derive(Default)]
struct GameView {}

impl<'a> View<&'a GameState> for GameView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        game_state: &'a GameState,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        for entity_to_render in game_state.entities_to_render() {
            let view_cell = match entity_to_render.visibility {
                CellVisibility::Currently => {
                    currently_visible_view_cell_of_tile(entity_to_render.tile)
                }
                CellVisibility::Previously => {
                    previously_visible_view_cell_of_tile(entity_to_render.tile)
                }
                CellVisibility::Never => ViewCell::new(),
            };
            let depth = match entity_to_render.location.layer {
                None => -1,
                Some(Layer::Floor) => 0,
                Some(Layer::Feature) => 1,
                Some(Layer::Corpse) => 2,
                Some(Layer::Character) => 3,
            };
            frame.set_cell_relative(entity_to_render.location.coord, depth, view_cell, context);
        }
    }
}
...
struct AppView {
    game_view: GameView,
}

impl AppView {
    fn new(screen_size: Size) -> Self {
        Self {
            game_view: GameView::default(),
        }
    }
}

impl<'a> View<&'a AppData> for AppView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        data: &'a AppData,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        self.game_view.view(&data.game_state, context, frame);
    }
}

In a new file ui.rs, implement a simple UI consisting of just the player’s health bar:

// ui.rs

use crate::world::HitPoints;
use chargrid::{
    render::{ColModify, Frame, Style, View, ViewContext},
    text::StringViewSingleLine,
};
use rgb24::Rgb24;

pub struct UiData {
    pub player_hit_points: HitPoints,
}

#[derive(Default)]
pub struct UiView {
    buf: String,
}

impl View<UiData> for UiView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        data: UiData,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        use std::fmt::Write;
        self.buf.clear();
        write!(
            &mut self.buf,
            "{}/{}",
            data.player_hit_points.current, data.player_hit_points.max
        )
        .unwrap();
        StringViewSingleLine::new(Style::new().with_foreground(Rgb24::new_grey(255)))
            .view(&self.buf, context, frame);
    }
}

Similarly to GameView and AppView, the new UiView type implements chargrid::View for a specific type of data, in this case UiData which currently consists of just a HitPoints. The buf: String field in UiView is to prevent needing to allocate a String each time UiView::view gets called. This method writes a string into this buffer, and then uses StringViewSingleLine::view to draw the string.

StringViewSingleLine comes from the chargrid::text module, which contains several implementations of chargrid::View for rendering text.

Update main.rs to include the ui module:

// main.rs
...
mod ui;
...

Update app.rs to draw the UI. The UI_NUM_ROWS constant configures how much of the screen to take up with the UI. This reduces the size of the game area.

// app.rs
...
use crate::ui::{UiData, UiView};
use coord_2d::{Coord, Size};
...
const UI_NUM_ROWS: u32 = 2;
...
impl AppData {
    fn new(screen_size: Size, rng_seed: u64, visibility_algorithm: VisibilityAlgorithm) -> Self {
        let game_area_size = screen_size.set_height(screen_size.height() - UI_NUM_ROWS);
        Self {
            game_state: GameState::new(game_area_size, rng_seed, visibility_algorithm),
            visibility_algorithm,
        }
    }
    ...
}

struct AppView {
    ui_y_offset: i32,
    game_view: GameView,
    ui_view: UiView,
}

impl AppView {
    fn new(screen_size: Size) -> Self {
        const UI_Y_PADDING: u32 = 1;
        let ui_y_offset = (screen_size.height() - UI_NUM_ROWS + UI_Y_PADDING) as i32;
        Self {
            ui_y_offset,
            game_view: GameView::default(),
            ui_view: UiView::default(),
        }
    }
}
...
impl<'a> View<&'a AppData> for AppView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        data: &'a AppData,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        self.game_view.view(&data.game_state, context, frame);
        let player_hit_points = data.game_state.player_hit_points();
        self.ui_view.view(
            UiData { player_hit_points },
            context.add_offset(Coord::new(0, self.ui_y_offset)),
            frame,
        );
    }
}
...
impl App {
    pub fn new(
        screen_size: Size,
        rng_seed: u64,
        visibility_algorithm: VisibilityAlgorithm,
    ) -> Self {
        Self {
            ...
            view: AppView::new(screen_size),
        }
    }
}
...

Update game.rs to expose a player_hit_points method:

// game.rs
...
use crate::world::{HitPoints, Location, Populate, Tile, World};
...
impl Game {
    ...
    pub fn player_hit_points(&self) -> HitPoints {
        self.world
            .hit_points(self.player_entity)
            .expect("player has no hit points")
    }
}

health

Reference implementation branch: part-7.0

Pretty Health Bar

Now we’ll colour the background of the health bar so it’s filled based on the amount of health the player has.

// ui.rs
...
use chargrid::{
    decorator::{AlignView, Alignment, BoundView},
    render::{ColModify, Frame, Style, View, ViewCell, ViewContext},
    text::StringViewSingleLine,
};
use coord_2d::{Coord, Size};
use rgb24::Rgb24;

const HEALTH_WIDTH: u32 = 10;
const HEALTH_FILL_COLOUR: Rgb24 = Rgb24::new(200, 0, 0);
const HEALTH_EMPTY_COLOUR: Rgb24 = Rgb24::new(100, 0, 0);

...

impl View<UiData> for UiView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        data: UiData,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        use std::fmt::Write;
        self.buf.clear();
        write!(
            &mut self.buf,
            "{}/{}",
            data.player_hit_points.current, data.player_hit_points.max
        )
        .unwrap();
        let mut hit_points_text_view = BoundView {
            size: Size::new(HEALTH_WIDTH, 1),
            view: AlignView {
                alignment: Alignment::centre(),
                view: StringViewSingleLine::new(Style::new().with_foreground(Rgb24::new_grey(255))),
            },
        };
        hit_points_text_view.view(&self.buf, context.add_depth(1), frame);
        let mut health_fill_width =
            (data.player_hit_points.current * HEALTH_WIDTH) / data.player_hit_points.max;
        if data.player_hit_points.current > 0 {
            health_fill_width = health_fill_width.max(1);
        }
        for i in 0..health_fill_width {
            frame.set_cell_relative(
                Coord::new(i as i32, 0),
                0,
                ViewCell::new().with_background(HEALTH_FILL_COLOUR),
                context,
            );
        }
        for i in health_fill_width..HEALTH_WIDTH {
            frame.set_cell_relative(
                Coord::new(i as i32, 0),
                0,
                ViewCell::new().with_background(HEALTH_EMPTY_COLOUR),
                context,
            );
        }
    }
}

This shows an example of decorators. The chargrid::decorator module contains a collection of implementations chargrid::View which wrap other implementations of chargrid::View and change the way they are rendered.

BoundView constrains the size of its contents. AlignView centers its contents within its parent. Together these are used to centre the text in the health bar.

health-pretty

Reference implementation branch: part-7.1

Message Log

Add a type representing the different types of messages that can appear in the log:

// game.rs
...
use crate::world::{HitPoints, Location, NpcType, Populate, Tile, World};
...

#[derive(Clone, Copy, Debug)]
pub enum LogMessage {
    PlayerAttacksNpc(NpcType),
    NpcAttacksPlayer(NpcType),
    PlayerKillsNpc(NpcType),
    NpcKillsPlayer(NpcType),
}

Add a message log to GameState, add an accessor method, and pass the message log to maybe_move_character. At the moment all events worthy of the message log are triggered by maybe_move_character.

..
pub struct GameState {
    ...
    message_log: Vec<LogMessage>,
}

impl GameState {
    pub fn new(
        screen_size: Size,
        rng_seed: u64,
        initial_visibility_algorithm: VisibilityAlgorithm,
    ) -> Self {
        ...
        let mut game_state = Self {
            ...
            message_log: Vec::new(),
        };
        ...
    }
    ...
    pub fn maybe_move_player(&mut self, direction: CardinalDirection) {
        self.world
            .maybe_move_character(self.player_entity, direction, &mut self.message_log);
        self.ai_turn();
    }
    ...
    fn ai_turn(&mut self) {
        ...
        for (entity, agent) in self.ai_state.iter_mut() {
            ...
            match npc_action {
                NpcAction::Wait => (),
                NpcAction::Move(direction) => {
                    self.world
                        .maybe_move_character(entity, direction, &mut self.message_log)
                }
            }
        }
    }
    ...
    pub fn message_log(&self) -> &[LogMessage] {
        &self.message_log
    }
}

Update maybe_move_character to add to the message log:

// world.rs
...
use crate::game::LogMessage;
...
struct VictimDies;
...
impl World {
    ...
    fn write_combat_log_messages(
        attacker_is_player: bool,
        victim_dies: bool,
        npc_type: NpcType,
        message_log: &mut Vec<LogMessage>,
    ) {
        if attacker_is_player {
            if victim_dies {
                message_log.push(LogMessage::PlayerKillsNpc(npc_type));
            } else {
                message_log.push(LogMessage::PlayerAttacksNpc(npc_type));
            }
        } else {
            if victim_dies {
                message_log.push(LogMessage::NpcKillsPlayer(npc_type));
            } else {
                message_log.push(LogMessage::NpcAttacksPlayer(npc_type));
            }
        }
    }
    pub fn maybe_move_character(
        &mut self,
        character_entity: Entity,
        direction: CardinalDirection,
        message_log: &mut Vec<LogMessage>,
    ) {
        let character_coord = self
            .spatial_table
            .coord_of(character_entity)
            .expect("character has no coord");
        let new_character_coord = character_coord + direction.coord();
        if new_character_coord.is_valid(self.spatial_table.grid_size()) {
            let dest_layers = self.spatial_table.layers_at_checked(new_character_coord);
            if let Some(dest_character_entity) = dest_layers.character {
                let character_is_npc = self.components.npc_type.get(character_entity).cloned();
                let dest_character_is_npc =
                    self.components.npc_type.get(dest_character_entity).cloned();
                if character_is_npc.is_some() != dest_character_is_npc.is_some() {
                    let victim_dies = self.character_bump_attack(dest_character_entity).is_some();
                    let npc_type = character_is_npc.or(dest_character_is_npc).unwrap();
                    Self::write_combat_log_messages(
                        character_is_npc.is_none(),
                        victim_dies,
                        npc_type,
                        message_log,
                    );
                }
            } else if dest_layers.feature.is_none() {
                self.spatial_table
                    .update_coord(character_entity, new_character_coord)
                    .unwrap();
            }
        }
    }
    fn character_bump_attack(&mut self, victim: Entity) -> Option<VictimDies> {
        const DAMAGE: u32 = 1;
        if let Some(hit_points) = self.components.hit_points.get_mut(victim) {
            hit_points.current = hit_points.current.saturating_sub(DAMAGE);
            if hit_points.current == 0 {
                self.character_die(victim);
                return Some(VictimDies);
            }
        }
        None
    }
    ...
}

Now let’s draw the message log. Start by moving the rendering of the player’s health bar into a new type HealthView:

// ui.rs
...
#[derive(Default)]
struct HealthView {
    buf: String,
}

impl View<HitPoints> for HealthView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        hit_points: HitPoints,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        use std::fmt::Write;
        self.buf.clear();
        write!(&mut self.buf, "{}/{}", hit_points.current, hit_points.max).unwrap();
        ...
    }
}

Add a simple helper function for mapping an NPC to a colour in app.rs, and make the colours mod public:

// app.rs
...
pub mod colours {
    use super::*;
    ...
    pub fn npc_colour(npc_type: NpcType) -> Rgb24 {
        match npc_type {
            NpcType::Orc => ORC,
            NpcType::Troll => TROLL,
        }
    }
}

Now define another type MessageView for rendering the message log:

// ui.rs
use crate::app::colours;
...
use crate::game::LogMessage;
...
use chargrid::{
    decorator::{AlignView, Alignment, BoundView},
    render::{ColModify, Frame, Style, View, ViewCell, ViewContext},
    text::{RichTextPartOwned, RichTextViewSingleLine, StringViewSingleLine},
};

...

struct MessagesView {
    buf: Vec<RichTextPartOwned>,
}

impl Default for MessagesView {
    fn default() -> Self {
        let common = RichTextPartOwned::new(String::new(), Style::new());
        Self {
            buf: vec![common.clone(), common.clone(), common],
        }
    }
}

impl<'a> View<&'a [LogMessage]> for MessagesView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        messages: &'a [LogMessage],
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        fn format_message(buf: &mut [RichTextPartOwned], message: LogMessage) {
            use std::fmt::Write;
            use LogMessage::*;
            buf[0].text.clear();
            buf[1].text.clear();
            buf[2].text.clear();
            buf[0].style.foreground = Some(Rgb24::new_grey(255));
            buf[1].style.bold = Some(true);
            buf[2].style.foreground = Some(Rgb24::new_grey(255));
            match message {
                PlayerAttacksNpc(npc_type) => {
                    write!(&mut buf[0].text, "You attack the ").unwrap();
                    write!(&mut buf[1].text, "{}", npc_type.name()).unwrap();
                    buf[1].style.foreground = Some(colours::npc_colour(npc_type));
                    write!(&mut buf[2].text, ".").unwrap();
                }
                NpcAttacksPlayer(npc_type) => {
                    write!(&mut buf[0].text, "The ").unwrap();
                    write!(&mut buf[1].text, "{}", npc_type.name()).unwrap();
                    buf[1].style.foreground = Some(colours::npc_colour(npc_type));
                    write!(&mut buf[2].text, " attacks you.").unwrap();
                }
                PlayerKillsNpc(npc_type) => {
                    write!(&mut buf[0].text, "You kill the ").unwrap();
                    write!(&mut buf[1].text, "{}", npc_type.name()).unwrap();
                    buf[1].style.foreground = Some(colours::npc_colour(npc_type));
                    write!(&mut buf[2].text, ".").unwrap();
                }
                NpcKillsPlayer(npc_type) => {
                    write!(&mut buf[0].text, "THE ").unwrap();
                    buf[0].style.foreground = Some(Rgb24::new(255, 0, 0));
                    write!(&mut buf[1].text, "{}", npc_type.name()).unwrap();
                    buf[1].text.make_ascii_uppercase();
                    buf[1].style.foreground = Some(colours::npc_colour(npc_type));
                    write!(&mut buf[2].text, " KILLS YOU!").unwrap();
                    buf[2].style.foreground = Some(Rgb24::new(255, 0, 0));
                }
            }
        }
        const NUM_MESSAGES: usize = 4;
        let start_index = messages.len().saturating_sub(NUM_MESSAGES);
        for (i, &message) in (&messages[start_index..]).iter().enumerate() {
            format_message(&mut self.buf, message);
            let offset = Coord::new(0, i as i32);
            RichTextViewSingleLine.view(
                self.buf.iter().map(|part| part.as_rich_text_part()),
                context.add_offset(offset),
                frame,
            );
        }
    }
}

Similar to the buf field of HealthView, the buf field of MessageView exists to prevent the need to allocate on each frame. The RichTextPartOwned type from chargrid::text is a combination of a String and a Style to apply to it. The RichTextViewSingleLine type, also from chargrid::text, implements chargrid::View for iterators over rich text. It’s used here to render messages about NPCs, where the names of NPCs are colour coded to match their tiles.

The UiView type is now a combination of HealthView and MessageView:

pub struct UiData<'a> {
    pub player_hit_points: HitPoints,
    pub messages: &'a [LogMessage],
}

#[derive(Default)]
pub struct UiView {
    health_view: HealthView,
    messages_view: MessagesView,
}

impl<'a> View<UiData<'a>> for UiView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        data: UiData,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        self.health_view
            .view(data.player_hit_points, context, frame);
        let message_log_offset = Coord::new(HEALTH_WIDTH as i32 + 1, 0);
        self.messages_view
            .view(data.messages, context.add_offset(message_log_offset), frame);
    }
}

Update app.rs to leave room below for the message log. When calling UiView::view inside AppView::view, populate the new messages field.

// app.rs
...
const UI_NUM_ROWS: u32 = 5;
...
impl<'a> View<&'a AppData> for AppView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        data: &'a AppData,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        self.game_view.view(&data.game_state, context, frame);
        let player_hit_points = data.game_state.player_hit_points();
        let messages = data.game_state.message_log();
        self.ui_view.view(
            UiData {
                player_hit_points,
                messages,
            },
            context.add_offset(Coord::new(0, self.ui_y_offset)),
            frame,
        );
    }
}

screenshot-end

Reference implementation branch: part-7.2

Click here for the next part!