Roguelike Tutorial 2020: Part 8 - Items and Inventory

In this part we’ll introduce items, and add an inventory menu.

By the end of this part you’ll be able to pick up, use, and drop items.

item-menu

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

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

In this post:

Placing Health Potions

In a previous section we added a layer to the spatial grid for corpses. Generalize this into what we’ll call “objects”, which consist of corpses and items, which will be introduced in this part. The implication of this is that corpses and items cannot exist in the same game cell, which will simplify some gameplay logic.

// world.rs
...
spatial_table::declare_layers_module! {
    layers {
        floor: Floor,
        character: Character,
        object: Object,
        feature: Feature,
    }
}
...
impl World {
    ...
    fn character_die(&mut self, entity: Entity) {
        if let Some(occpied_by_entity) = self
            .spatial_table
            .update_layer(entity, Layer::Object)
            .err()
            .map(|e| e.unwrap_occupied_by())
        {
            // If a character dies on a cell which contains an object, remove the existing object
            // from existence and replace it with the character's corpse.
            self.remove_entity(occpied_by_entity);
            self.spatial_table
                .update_layer(entity, Layer::Object)
                .unwrap();
        }
        ...
    }
    ...
}
// app.rs
...
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 depth = match entity_to_render.location.layer {
                None => -1,
                Some(Layer::Floor) => 0,
                Some(Layer::Feature) => 1,
                Some(Layer::Object) => 2,
                Some(Layer::Character) => 3,
            };
            ...
        }
    }
}

Now make it possible to represent items, using health potions as our first item.

// world.rs
...
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ItemType {
    HealthPotion,
}

impl ItemType {
    pub fn name(self) -> &'static str {
        match self {
            Self::HealthPotion => "health potion",
        }
    }
}
...
#[derive(Clone, Copy, Debug)]
pub enum Tile {
    ...
    Item(ItemType),
}
...
entity_table::declare_entity_module! {
    components {
        item: ItemType,
    }
}
...
impl World {
    ...
    fn spawn_item(&mut self, coord: Coord, item_type: ItemType) {
        let entity = self.entity_allocator.alloc();
        self.spatial_table
            .update(
                entity,
                Location {
                    coord,
                    layer: Some(Layer::Object),
                },
            )
            .unwrap();
        self.components.tile.insert(entity, Tile::Item(item_type));
        self.components.item.insert(entity, item_type);
    }
    ...
}
...

Place health potions during terrain generation.

// terrain.rs
use crate::world::{ItemType, NpcType};
...
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TerrainTile {
    ...
    Item(ItemType),
}
...
impl Room {
    ...
    // Place `n` health potions random positions within the room
    fn place_health_potions<R: Rng>(
        &self,
        n: usize,
        grid: &mut Grid<Option<TerrainTile>>,
        rng: &mut R,
    ) {
        for coord in self
            .coords()
            .filter(|&coord| grid.get_checked(coord).unwrap() == TerrainTile::Floor)
            .choose_multiple(rng, n)
        {
            *grid.get_checked_mut(coord) = Some(TerrainTile::Item(ItemType::HealthPotion));
        }
    }
}
...
pub fn generate_dungeon<R: Rng>(size: Size, rng: &mut R) -> Grid<TerrainTile> {
    ...
    const HEALTH_POTIONS_PER_ROOM_DISTRIBUTION: &[usize] = &[0, 0, 1, 1, 1, 1, 1, 2, 2];
    ...
    for _ in 0..NUM_ATTEMPTS {
        ...
        // Add health potions to the room
        let &num_health_potions = HEALTH_POTIONS_PER_ROOM_DISTRIBUTION.choose(rng).unwrap();
        room.place_health_potions(num_health_potions, &mut grid, rng);
    }
}
// world.rs
...
impl World {
    ...
    pub fn populate<R: Rng>(&mut self, rng: &mut R) -> Populate {
        ...
        for (coord, &terrain_tile) in terrain.enumerate() {
            match terrain_tile {
                ...
                TerrainTile::Item(item_type) => {
                    self.spawn_item(coord, item_type);
                    self.spawn_floor(coord);
                }
            }
        }
        ...
    }
    ...
}

Add rendering logic for health potions:

// app.rs
...
pub mod colours {
    ...
    pub const HEALTH_POTION: Rgb24 = Rgb24::new(255, 0, 255);
    ...
}

fn currently_visible_view_cell_of_tile(tile: Tile) -> ViewCell {
    match tile {
        ...
        Tile::Item(ItemType::HealthPotion) => ViewCell::new()
            .with_character('!')
            .with_foreground(colours::HEALTH_POTION),
    }
}
...

Run the game. There should be health potions on the ground, but you won’t be able to pick them up or use them yet.

health-potions

Reference implementation branch: part-8.0

Adding Items to Inventory

Define a data structure to represent the player’s inventory. The inventory has a finite number of slots, and each slot may contain an item. We’ll just store the entity (remember, just a numeric identifier) of items in inventory slots.

// world.rs
...
#[derive(Clone, Debug)]
pub struct Inventory {
    slots: Vec<Option<Entity>>,
}

pub struct InventoryIsFull;

#[derive(Debug)]
pub struct InventorySlotIsEmpty;

#[derive(Clone, Copy)]
pub enum ItemUsage {
    Immediate,
    Aim,
}

impl Inventory {
    pub fn new(capacity: usize) -> Self {
        let slots = vec![None; capacity];
        Self { slots }
    }
    pub fn slots(&self) -> &[Option<Entity>] {
        &self.slots
    }
    pub fn insert(&mut self, item: Entity) -> Result<(), InventoryIsFull> {
        if let Some(slot) = self.slots.iter_mut().find(|s| s.is_none()) {
            *slot = Some(item);
            Ok(())
        } else {
            Err(InventoryIsFull)
        }
    }
    pub fn remove(&mut self, index: usize) -> Result<Entity, InventorySlotIsEmpty> {
        if let Some(slot) = self.slots.get_mut(index) {
            slot.take().ok_or(InventorySlotIsEmpty)
        } else {
            Err(InventorySlotIsEmpty)
        }
    }
    pub fn get(&self, index: usize) -> Result<Entity, InventorySlotIsEmpty> {
        self.slots
            .get(index)
            .cloned()
            .flatten()
            .ok_or(InventorySlotIsEmpty)
    }
}
...

Introduce an inventory component and give the player a 10-slot inventory.

// world.rs
...
entity_table::declare_entity_module! {
    components {
        ...
        inventory: Inventory,
    }
}
...

impl World {
    ...
    fn spawn_player(&mut self, coord: Coord) -> Entity {
        ...
        self.components.inventory.insert(entity, Inventory::new(10));
        ...
    }
    ...
}

Make it possible for the player to get the item they are standing on. Print inventory-related messages to the game’s message log.

// world.rs
...
impl World {
    ...
    pub fn maybe_get_item(
        &mut self,
        character: Entity,
        message_log: &mut Vec<LogMessage>,
    ) -> Result<(), ()> {
        let coord = self
            .spatial_table
            .coord_of(character)
            .expect("character has no coord");
        if let Some(object_entity) = self.spatial_table.layers_at_checked(coord).object {
            if let Some(&item_type) = self.components.item.get(object_entity) {
                // this assumes that the only character that can get items is the player
                let inventory = self
                    .components
                    .inventory
                    .get_mut(character)
                    .expect("character has no inventory");
                if inventory.insert(object_entity).is_ok() {
                    self.spatial_table.remove(object_entity);
                    message_log.push(LogMessage::PlayerGets(item_type));
                    return Ok(());
                } else {
                    message_log.push(LogMessage::PlayerInventoryIsFull);
                    return Err(());
                }
            }
        }
        message_log.push(LogMessage::NoItemUnderPlayer);
        Err(())
    }
    ...
}
// game.rs
...
use crate::world::{HitPoints, ItemType, Location, NpcType, Populate, Tile, World};
...
pub enum LogMessage {
    ...
    PlayerGets(ItemType),
    PlayerInventoryIsFull,
    NoItemUnderPlayer,
}
...
impl GameState {
    ...
    pub fn maybe_player_get_item(&mut self) {
        if self
            .world
            .maybe_get_item(self.player_entity, &mut self.message_log)
            .is_ok()
        {
            self.ai_turn();
        }
    }
    ...
}
// ui.rs
...
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) {
            ...
            match message {
                ...
                PlayerGets(item_type) => {
                    write!(&mut buf[0].text, "You get the ").unwrap();
                    write!(&mut buf[1].text, "{}", item_type.name()).unwrap();
                    buf[1].style.foreground = Some(colours::item_colour(item_type));
                    write!(&mut buf[2].text, ".").unwrap();
                }
                PlayerInventoryIsFull => {
                    write!(&mut buf[0].text, "Inventory is full!").unwrap();
                }
                NoItemUnderPlayer => {
                    write!(&mut buf[0].text, "Nothing to get!").unwrap();
                }
            }
        }
        ...
    }
}
...
// app.rs
...
pub mod colours {
    ...
    pub fn item_colour(item_type: ItemType) -> Rgb24 {
        match item_type {
            ItemType::HealthPotion => HEALTH_POTION,
        }
    }
}
...

Finally set up the controls such that pressing ‘g’ picks up the item under the player.

// app.rs
...
impl AppData {
    ...
    fn handle_input(&mut self, input: Input) {
        ...
        match input {
            Input::Keyboard(key) => match key {
                ...
                KeyboardInput::Char('g') => self.game_state.maybe_player_get_item(),
                ...
            },
            _ => (),
        }
        ...
    }
}
...

Now you can pick items but you can’t use them, drop them, or view your inventory.

get-health-potion

Reference implementation branch: part-8.1

Using and Dropping Items

Start by defining what it means to use and drop an item. In the interface for using and dropping items, items will be specified by inventory slot index rather than (say) by their Entity. This will simplify plugging this logic into the game’s ui later. While reading this, keep in mind that using or dropping an item can fail for a number of ways, and if an attempt to use or drop an item fails, we don’t want NPCs to take their turn afterwards.

Also expose some getters and helper functions which will come in handy shortly.

// world.rs
...
impl World {
    ...
    pub fn maybe_use_item(
        &mut self,
        character: Entity,
        inventory_index: usize,
        message_log: &mut Vec<LogMessage>,
    ) -> Result<(), ()> {
        let inventory = self
            .components
            .inventory
            .get_mut(character)
            .expect("character has no inventory");
        let item = match inventory.remove(inventory_index) {
            Ok(item) => item,
            Err(InventorySlotIsEmpty) => {
                message_log.push(LogMessage::NoItemInInventorySlot);
                return Err(());
            }
        };
        let &item_type = self
            .components
            .item
            .get(item)
            .expect("non-item in inventory");
        match item_type {
            ItemType::HealthPotion => {
                let mut hit_points = self
                    .components
                    .hit_points
                    .get_mut(character)
                    .expect("character has no hit points");
                const HEALTH_TO_HEAL: u32 = 5;
                hit_points.current = hit_points.max.min(hit_points.current + HEALTH_TO_HEAL);
                message_log.push(LogMessage::PlayerHeals);
            }
        }
        Ok(())
    }

    pub fn maybe_drop_item(
        &mut self,
        character: Entity,
        inventory_index: usize,
        message_log: &mut Vec<LogMessage>,
    ) -> Result<(), ()> {
        let coord = self
            .spatial_table
            .coord_of(character)
            .expect("character has no coord");
        if self.spatial_table.layers_at_checked(coord).object.is_some() {
            message_log.push(LogMessage::NoSpaceToDropItem);
            return Err(());
        }
        let inventory = self
            .components
            .inventory
            .get_mut(character)
            .expect("character has no inventory");
        let item = match inventory.remove(inventory_index) {
            Ok(item) => item,
            Err(InventorySlotIsEmpty) => {
                message_log.push(LogMessage::NoItemInInventorySlot);
                return Err(());
            }
        };
        self.spatial_table
            .update(
                item,
                Location {
                    coord,
                    layer: Some(Layer::Object),
                },
            )
            .unwrap();
        let &item_type = self
            .components
            .item
            .get(item)
            .expect("non-item in inventory");
        message_log.push(LogMessage::PlayerDrops(item_type));
        Ok(())
    }

    pub fn inventory(&self, entity: Entity) -> Option<&Inventory> {
        self.components.inventory.get(entity)
    }

    pub fn item_type(&self, entity: Entity) -> Option<ItemType> {
        self.components.item.get(entity).cloned()
    }
    ...
}
// game.rs
...
pub enum LogMessage {
    ...
    NoItemInInventorySlot,
    PlayerHeals,
    PlayerDrops(ItemType),
    NoSpaceToDropItem,
}
...
impl GameState {
    ...
    pub fn maybe_player_use_item(&mut self, inventory_index: usize) -> Result<(), ()> {
        let result =
            self.world
                .maybe_use_item(self.player_entity, inventory_index, &mut self.message_log);
        if result.is_ok() {
            self.ai_turn();
        }
        result
    }

    pub fn maybe_player_drop_item(&mut self, inventory_index: usize) -> Result<(), ()> {
        let result =
            self.world
                .maybe_drop_item(self.player_entity, inventory_index, &mut self.message_log);
        if result.is_ok() {
            self.ai_turn();
        }
        result
    }

    pub fn player_inventory(&self) -> &Inventory {
        self.world
            .inventory(self.player_entity)
            .expect("player has no inventory")
    }

    pub fn item_type(&self, entity: Entity) -> Option<ItemType> {
        self.world.item_type(entity)
    }

    pub fn size(&self) -> Size {
        self.world.size()
    }
}
...
// ui.rs
...
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) {
            ...
            match message {
                ...
                NoItemInInventorySlot => {
                    write!(&mut buf[0].text, "No item in inventory slot!").unwrap();
                }
                PlayerHeals => {
                    write!(&mut buf[0].text, "You feel slightly better.").unwrap();
                    buf[0].style.foreground = Some(Rgb24::new(0, 187, 0));
                }
                PlayerDrops(item_type) => {
                    write!(&mut buf[0].text, "You drop the ").unwrap();
                    write!(&mut buf[1].text, "{}", item_type.name()).unwrap();
                    buf[1].style.foreground = Some(colours::item_colour(item_type));
                    write!(&mut buf[2].text, ".").unwrap();
                }
                NoSpaceToDropItem => {
                    write!(&mut buf[0].text, "No space to drop item!").unwrap();
                }
            }
        }
        ...
    }
}
...

Unlike all the actions players can currently take, using and dropping items won’t be done with a single key press. Instead, pressing a key will display a menu, from which the player can select the item they’d like to use or drop.

Here’s how it will look when it’s finished.

item-menu

The chargrid library comes with some tools for working with menus, and rendering UI elements in general. We’ve already seen some of this in the health bar and message log.

Update the AppData type to include a menu:

// app.rs
...
use chargrid::{
    app::{App as ChargridApp, ControlFlow},
    input::{keys, Input, KeyboardInput},
    menu::{MenuInstanceBuilder, MenuInstanceChooseOrEscape},
    render::{ColModify, Frame, ViewCell, ViewContext},
};
use std::collections::HashMap;
...
#[derive(Clone, Copy, Debug)]
struct InventorySlotMenuEntry {
    index: usize, // the index of the inventory slot
    key: char,    // a character corresponding to the slot so players can select with a key
}
...
struct AppData {
    ...
    inventory_slot_menu: MenuInstanceChooseOrEscape<InventorySlotMenuEntry>,
}

impl AppData {
    fn new(screen_size: Size, rng_seed: u64, visibility_algorithm: VisibilityAlgorithm) -> Self {
        ...
        let inventory_slot_menu = {
            let items = (0..player_inventory.slots().len())
                .zip('a'..)
                .map(|(index, key)| InventorySlotMenuEntry { index, key })
                .collect::<Vec<_>>();
            let hotkeys = items
                .iter()
                .map(|&entry| (entry.key, entry))
                .collect::<HashMap<_, _>>();
            MenuInstanceBuilder {
                items,
                hotkeys: Some(hotkeys),
                selected_index: 0,
            }
            .build()
            .unwrap()
            .into_choose_or_escape()
        };
        Self {
            ...
            inventory_slot_menu,
        }
    }
    ...
}

The menu types defined in chargrid can be ticked - fed input events, which updates their internal state, and possibly resolves them to a selected value or an explicit cancellation. Menus can be controlled by the arrow keys, the mouse, and by an optional list of hotkeys, as is done here so players can use letter keys to make selections from the menu (a tradition in roguelike games!).

A MenuInstanceChooseOrEscape<T> is a menu which can be used to select a value of type T that is cancelled when the escape key is pressed. At the moment pressing the escape key quits the entire game, but if the inventory is open we’d like for it to close the inventory instead. Update impl ChargridApp for App to pass escape keys into AppData::handle_input and let that function decide whether to quit the game.

// app.rs
...
impl ChargridApp for App {
    fn on_input(&mut self, input: Input) -> Option<ControlFlow> {
        match input {
            Input::Keyboard(keys::ETX) => Some(ControlFlow::Exit),
            other => self.data.handle_input(other, &self.view),
        }
    }
    ...
}

Note that this change requires AppData::handle_input to return a Option<ControlFlow>. For now it can just return None to get the code to compile.

The meaning of key presses is different depending on whether a menu is open, and we need a way of determining whether an input event should go to the game state or a menu.

Add a new field to AppData for tracking the state of the application.

// app.rs
...
#[derive(Clone, Copy, Debug)]
enum AppStateMenu {
    UseItem,
    DropItem,
}

#[derive(Clone, Copy, Debug)]
enum AppState {
    Game,
    Menu(AppStateMenu),
}

struct AppData {
    ...
    app_state: AppState,
}

impl AppData {
    fn new(screen_size: Size, rng_seed: u64, visibility_algorithm: VisibilityAlgorithm) -> Self {
        ...
        Self {
            ...
            app_state: AppState::Game,
        }
    }
}
...

Now update AppData::handle_input to operate as before if the app_state is Game, and otherwise tick the menu. Also add the ‘i’ and ‘d’ controls for opening the use and drop item menus respectively.

The logic for having the escape key quit the game is now in this function as well.

Note the code for ticking the menu includes handlers for what happens when a selection is made which call the maybe_player_use_item and maybe_player_drop_item defined earlier.

// app.rs
...
impl AppData {
    ...
    fn handle_input(&mut self, input: Input, view: &AppView) -> Option<ControlFlow> {
        if !self.game_state.is_player_alive() {
            return None;
        }
        match self.app_state {
            AppState::Game => match input {
                Input::Keyboard(key) => match key {
                    KeyboardInput::Left => {
                        self.game_state.maybe_move_player(CardinalDirection::West)
                    }
                    KeyboardInput::Right => {
                        self.game_state.maybe_move_player(CardinalDirection::East)
                    }
                    KeyboardInput::Up => {
                        self.game_state.maybe_move_player(CardinalDirection::North)
                    }
                    KeyboardInput::Down => {
                        self.game_state.maybe_move_player(CardinalDirection::South)
                    }
                    KeyboardInput::Char(' ') => self.game_state.wait_player(),
                    KeyboardInput::Char('g') => self.game_state.maybe_player_get_item(),
                    KeyboardInput::Char('i') => {
                        self.app_state = AppState::Menu(AppStateMenu::UseItem)
                    }
                    KeyboardInput::Char('d') => {
                        self.app_state = AppState::Menu(AppStateMenu::DropItem)
                    }
                    keys::ESCAPE => return Some(ControlFlow::Exit),
                    _ => (),
                },
                _ => (),
            },
            AppState::Menu(menu) => match self
                .inventory_slot_menu
                .choose(&view.inventory_slot_menu_view, input)
            {
                None => (),
                Some(Err(menu::Escape)) => self.app_state = AppState::Game,
                Some(Ok(entry)) => match menu {
                    AppStateMenu::UseItem => {
                        if self.game_state.maybe_player_use_item(entry.index).is_ok() {
                            self.app_state = AppState::Game;
                        }
                    }
                    AppStateMenu::DropItem => {
                        if self.game_state.maybe_player_drop_item(entry.index).is_ok() {
                            self.app_state = AppState::Game;
                        }
                    }
                },
            },
        }
        self.game_state.update_visibility(self.visibility_algorithm);
        None
    }
}
...

Now we need to define how a menu will be rendered. Chargrid doesn’t prescribe how a menu should be rendered, but does provide some helper functions for defining menu rendering logic. It’s fairly easy to write a basic menu renderer, but the api doesn’t get in your way if you want to write something more complex.

Just for fun, let’s make this menu complex. Entries will be coloured based on the colours::item_colour function defined in a previous section. The selected entry will be bold, of a brighter colour, and be annotated with a “>”.

The logic for rendering the menu is split into 2 parts - the list of menu items, and a decorator which adds a border and a title, and centres the menu on the screen.

Finally, we’ll use the ColModify trait to modify the colour of the game area, dimming it while the menu is visible.

Here’s the code for rendering the menu items:

// app.rs
use chargrid::{
    app::{App as ChargridApp, ControlFlow},
    input::{keys, Input, KeyboardInput},
    menu::{
        self, MenuIndexFromScreenCoord, MenuInstanceBuilder, MenuInstanceChoose,
        MenuInstanceChooseOrEscape, MenuInstanceMouseTracker,
    },
    render::{ColModify, ColModifyMap, Frame, Style, View, ViewCell, ViewContext},
    text::{RichTextPart, RichTextViewSingleLine},
};

...

#[derive(Default)]
struct InventorySlotMenuView {
    mouse_tracker: MenuInstanceMouseTracker,
}

impl MenuIndexFromScreenCoord for InventorySlotMenuView {
    fn menu_index_from_screen_coord(&self, len: usize, coord: Coord) -> Option<usize> {
        self.mouse_tracker.menu_index_from_screen_coord(len, coord)
    }
}

impl<'a> View<&'a AppData> for InventorySlotMenuView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        data: &'a AppData,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        let player_inventory_slots = data.game_state.player_inventory().slots();
        self.mouse_tracker.new_frame(context.offset);
        for ((i, entry, maybe_selected), &slot) in data
            .inventory_slot_menu
            .menu_instance()
            .enumerate()
            .zip(player_inventory_slots.into_iter())
        {
            let (name, name_colour) = if let Some(item_entity) = slot {
                let item_type = data
                    .game_state
                    .item_type(item_entity)
                    .expect("non-item in player inventory");
                (item_type.name(), colours::item_colour(item_type))
            } else {
                ("-", Rgb24::new_grey(187))
            };
            let (selected_prefix, prefix_style, name_style) = if maybe_selected.is_some() {
                (
                    ">",
                    Style::new()
                        .with_foreground(Rgb24::new_grey(255))
                        .with_bold(true),
                    Style::new().with_foreground(name_colour).with_bold(true),
                )
            } else {
                (
                    " ",
                    Style::new().with_foreground(Rgb24::new_grey(187)),
                    Style::new().with_foreground(name_colour.saturating_scalar_mul_div(2, 3)),
                )
            };
            let prefix = format!("{} {}) ", selected_prefix, entry.key);
            let text = &[
                RichTextPart {
                    text: &prefix,
                    style: prefix_style,
                },
                RichTextPart {
                    text: name,
                    style: name_style,
                },
            ];
            let size = RichTextViewSingleLine::new().view_size(
                text.into_iter().cloned(),
                context.add_offset(Coord::new(0, i as i32)),
                frame,
            );
            self.mouse_tracker.on_entry_view_size(size);
        }
    }
}

...

Of note here is the MenuIndexFromScreenCoord trait which is implemented by InventorySlotMenuView. In order to support selecting from the menu with the mouse, the menu needs to know its absolute position on the screen so it can be compared with the mouse position. The MenuInstanceMouseTracker is a helper type which simplifies implementing this trait.

Add an InventorySlotMenuView to AppView.

// app.rs
...
struct AppView {
    ...
    inventory_slot_menu_view: InventorySlotMenuView,
}

impl AppView {
    fn new(screen_size: Size) -> Self {
        ...
        Self {
            ...
            inventory_slot_menu_view: InventorySlotMenuView::default(),
        }
    }
}
...

Now update the implementation of View for AppView to use a decorated version of this new view, and dim the game area while the menu is visible. We’ll use a type chargrid::render::ColModifyMap to apply a function to colours selected in the game rendering logic.

// app.rs
...
use chargrid::{
    ...
    decorator::{
        AlignView, Alignment, BorderStyle, BorderView, BoundView, FillBackgroundView, MinSizeView,
    },
    render::{ColModify, ColModifyMap, Frame, Style, View, ViewCell, ViewContext},
};
...
impl<'a> View<&'a AppData> for AppView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        data: &'a AppData,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        fn col_modify_dim(num: u32, denom: u32) -> impl ColModify {
            ColModifyMap(move |col: Rgb24| col.saturating_scalar_mul_div(num, denom))
        }
        let game_col_modify = match data.app_state {
            AppState::Game => col_modify_dim(1, 1),
            AppState::Menu(menu) => {
                let title_text = match menu {
                    AppStateMenu::UseItem => "Use Item",
                    AppStateMenu::DropItem => "Drop Item",
                };
                BoundView {
                    size: data.game_state.size(),
                    view: AlignView {
                        alignment: Alignment::centre(),
                        view: FillBackgroundView {
                            rgb24: Rgb24::new_grey(0),
                            view: BorderView {
                                style: &BorderStyle {
                                    title: Some(title_text.to_string()),
                                    title_style: Style::new().with_foreground(Rgb24::new_grey(255)),
                                    ..Default::default()
                                },
                                view: MinSizeView {
                                    size: Size::new(12, 0),
                                    view: &mut self.inventory_slot_menu_view,
                                },
                            },
                        },
                    },
                }
                .view(data, context.add_depth(10), frame);
                col_modify_dim(1, 2)
            }
        };
        self.game_view.view(
            &data.game_state,
            context.compose_col_modify(game_col_modify),
            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,
        );
    }
}

It’s now possible to view, use, and drop items from your inventory. Here’s what it looks like when you use a health potion.

use-health-potion

Reference implementation branch: part-8.2

Event Routine Intro

In the previous section we introduced an AppState type which keeps track of whether a menu is open. Chargrid apps must be event-driven, so while a menu is displayed, each input must be routed to the correct place Based on the state of the game. Also note that depending on the state of the application, rendering logic must behave differently. For a game of this scale, we can manage the app state reasonably well using our app_state flag, but as the game grow, keeping track of input routing and app state can become painful.

Chargrid contains a module chargrid::event_routine, which is an attempt to simplify the management of state machines where input routing and rendering is dependent on the application state. For a motivating example, consider menus. In the explicitly event-driven code we just wrote, each input must be explicitly handed to the menu, and then we must check whether the menu declares that a selection has been made. It would be nice if we could call a function that blocked until a selection is made, and then the function just returns the selection. Then we could call this function directly from (say) the i key handler, and process the “use item” command then and there. Blocking isn’t possible in this context, but we can do something “kind of like blocking” that gives us some of the convenience while remaining fully event-driven.

At the core of chargrid::event_routine is the EventRoutine trait. Implementations of EventRoutine know render themselves and handle events. While handling an event, an EventRoutine may complete and “return” a value.

Chargrid comes with EventRoutine definitions for menus which tick the menu as events come in, and return the selected value when a selection is made. EventRoutines have several combinators defined which allow them to be composed with other computations or other EventRoutines.

The idea of EventRoutines is very similar to that of Futures in that they are asynchronous, composable values which represent a computation.

This section will show very basic usage of EventRoutine - wrapping up all our event-driven logic inside a giant EventRoutine. The next section will pull apart the existing event-driven explicit state machine, and replace parts of it with smaller EventRoutines which are composed to form the application.

// app.rs
...
use chargrid::{
    app::App as ChargridApp,
    event_routine::{self, common_event::CommonEvent, EventOrPeek, EventRoutine, Handled},
};
...

Stop depending on std::time::Duration and chargrid::app::ControlFlow.

Instead of using ControlFlow::Exit, define our own Exit unit type.

// app.rs
...
struct Exit;
...
impl AppData {
    ...
    fn handle_input(&mut self, input: Input, view: &AppView) -> Option<Exit> {
        ...
        match self.app_state {
            AppState::Game => match input {
                Input::Keyboard(key) => match key {
                    ...
                    keys::ESCAPE => return Some(Exit),
                    ...
                }
                ...
            }
            ...
        }
        ...
    }
}

Remove the App type, and replace it with another unit type AppEventRoutine, which implements EventRoutine. This type will wrap all our existing logic in a giant EventRoutine. We’ll totally replace AppEventRoutine in the next section with the composition of several simpler EventRoutines. It’s a temporary measure to make the transition to EventRoutines more gentle.

// app.rs
...
struct AppEventroutine;

impl EventRoutine for AppEventroutine {
    type Return = ();
    type Data = AppData;
    type View = AppView;
    type Event = CommonEvent;
    fn handle<EP>(
        self,
        data: &mut Self::Data,
        view: &Self::View,
        event_or_peek: EP,
    ) -> Handled<Self::Return, Self>
    where
        EP: EventOrPeek<Event = Self::Event>,
    {
        event_routine::event_or_peek_with_handled(event_or_peek, self, |s, event| match event {
            CommonEvent::Input(input) => match data.handle_input(input, view) {
                None => Handled::Continue(s),
                Some(Exit) => Handled::Return(()),
            },
            CommonEvent::Frame(_) => Handled::Continue(s),
        })
    }
    fn view<F, C>(
        &self,
        data: &Self::Data,
        view: &mut Self::View,
        context: ViewContext<C>,
        frame: &mut F,
    ) where
        F: Frame,
        C: ColModify,
    {
        view.view(data, context, frame);
    }
}

The logic in impl EventRoutine for AppEventroutine is very similar to what used to be in impl ChargridApp for App, just with a little more boilerplate.

Note the 4 associated types of the EventRoutine trait:

Add a function game_loop which returns an AppEventRoutine. Note the return_on_exit combinator which causes the event return to complete when the application is exited (e.g. by closing its window). Its argument is a function that will be called when the application is closed. For now this will do nothing. In the next section, we’ll replace the body of this function with a composition of several EventRoutines.

// app.rs
...
fn game_loop() -> impl EventRoutine<Return = (), Data = AppData, View = AppView, Event = CommonEvent>
{
    AppEventroutine.return_on_exit(|_| ())
}
...

Finally, define a public function app which instantiates AppData and AppView, and calls game_loop.

// app.rs
...
pub fn app(
    screen_size: Size,
    rng_seed: u64,
    visibility_algorithm: VisibilityAlgorithm,
) -> impl ChargridApp {
    let data = AppData::new(screen_size, rng_seed, visibility_algorithm);
    let view = AppView::new(screen_size);
    game_loop().app_one_shot_ignore_return(data, view)
}

Note the app_one_shot_ignore_return method which converts EventRoutines into ChargridApps.

Update main.rs to call the new app function:

// main.rs
use app::app;
...
fn main() {
    ...
    let app = app(screen_size, rng_seed, visibility_algorithm);
    context.run_app(app);
}

Reference implementation branch: part-8.3

State Machine Management with Event Routines

This section is a major refactor of app.rs to use EventRoutines.

Grab some more dependencies from chargrid::event_routine and chargrid::menu.

// app.rs
...
use chargrid::{
    ...
    event_routine::{
        self, common_event::CommonEvent, make_either, DataSelector, Decorate, EventOrPeek,
        EventRoutine, EventRoutineView, Handled, Loop, SideEffect, Value, ViewSelector,
    },
    menu::{
        self, ChooseSelector, MenuIndexFromScreenCoord, MenuInstanceBuilder, MenuInstanceChoose,
        MenuInstanceChooseOrEscape, MenuInstanceMouseTracker, MenuInstanceRoutine,
    },
};

It will turn out convenient to move the ui-rendering logic into a method of AppView:

// app.rs
...
impl AppView {
    ...
    fn render_ui<F: Frame, C: ColModify>(
        &mut self,
        data: &AppData,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        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,
        );
    }
}
...

Define a unit type InventorySlotMenuSelect and implement the traits ChooseSelector, DataSelector and ViewSelector. We’re going to use chargrid::menu’s built-in EventRoutine for menus, and it needs to be told for a given Data and View (EventRoutine’s associated types - in this case AppData and AppView), select a field containing the menu to display (ChooseSelector), the data to use when rendering the menu (DataSelector) and the view to use when rendering the menu (ViewSelector).

// app.rs
...
struct InventorySlotMenuSelect;

impl ChooseSelector for InventorySlotMenuSelect {
    type ChooseOutput = MenuInstanceChooseOrEscape<InventorySlotMenuEntry>;
    fn choose_mut<'a>(&self, input: &'a mut Self::DataInput) -> &'a mut Self::ChooseOutput {
        &mut input.inventory_slot_menu
    }
}

impl DataSelector for InventorySlotMenuSelect {
    type DataInput = AppData;
    type DataOutput = AppData;
    fn data<'a>(&self, input: &'a Self::DataInput) -> &'a Self::DataOutput {
        input
    }
    fn data_mut<'a>(&self, input: &'a mut Self::DataInput) -> &'a mut Self::DataOutput {
        input
    }
}

impl ViewSelector for InventorySlotMenuSelect {
    type ViewInput = AppView;
    type ViewOutput = InventorySlotMenuView;
    fn view<'a>(&self, input: &'a Self::ViewInput) -> &'a Self::ViewOutput {
        &input.inventory_slot_menu_view
    }
    fn view_mut<'a>(&self, input: &'a mut Self::ViewInput) -> &'a mut Self::ViewOutput {
        &mut input.inventory_slot_menu_view
    }
}

Now define a decorator for the menu. Previously it was decorated inside impl<'a> View<&'a AppData> for AppView, but since the menu will now be handled by an EventRoutine we need to implement the chargrid::event_routine::Decorator trait. This code is largely cut’n’pasted from impl<'a> View<&'a AppData> for AppView.

// app.rs
...
struct InventorySlotMenuDecorate<'a> {
    title: &'a str,
}

impl<'a> Decorate for InventorySlotMenuDecorate<'a> {
    type View = AppView;
    type Data = AppData;
    fn view<E, F, C>(
        &self,
        data: &Self::Data,
        mut event_routine_view: EventRoutineView<E>,
        context: ViewContext<C>,
        frame: &mut F,
    ) where
        E: EventRoutine<Data = Self::Data, View = Self::View>,
        F: Frame,
        C: ColModify,
    {
        BoundView {
            size: data.game_state.size(),
            view: AlignView {
                alignment: Alignment::centre(),
                view: FillBackgroundView {
                    rgb24: Rgb24::new_grey(0),
                    view: BorderView {
                        style: &BorderStyle {
                            title: Some(self.title.to_string()),
                            title_style: Style::new().with_foreground(Rgb24::new_grey(255)),
                            ..Default::default()
                        },
                        view: MinSizeView {
                            size: Size::new(12, 0),
                            view: &mut event_routine_view,
                        },
                    },
                },
            },
        }
        .view(data, context.add_depth(10), frame);
        event_routine_view.view.game_view.view(
            &data.game_state,
            context.compose_col_modify(ColModifyMap(|c: Rgb24| c.saturating_scalar_mul_div(1, 2))),
            frame,
        );
        event_routine_view.view.render_ui(&data, context, frame);
    }
}

Note that the title displayed in the border is now in a field of the InventorySlotMenuDecorate type.

The point of the Decorate trait is to take the rendering logic from an EventRoutine (encapsulated as the event_routine_view argument) and decorate it using existing decorator logic from chargrid::decorators, or custom logic that you define yourself. This works because the EventRoutineView type implements chargrid::render::View.

Now define a function which creates the menu event routine. It will take the title of the inventory menu as an argument, and run until the user makes a choice or explicitly cancels returning a Result of either the user’s choice, or menu::Escape.

// app.rs
...
fn inventory_slot_menu<'a>(
    title: &'a str,
) -> impl 'a
       + EventRoutine<
    Return = Result<InventorySlotMenuEntry, menu::Escape>,
    Data = AppData,
    View = AppView,
    Event = CommonEvent,
> {
    MenuInstanceRoutine::new(InventorySlotMenuSelect)
        .convert_input_to_common_event()
        .decorated(InventorySlotMenuDecorate { title })
}
...

The convert_input_to_common_event converts an EventRoutine which expects only chargrid::input::Input as its events (the menu event routine) into an EventRoutine which expects CommonEvents which can also include animation frames. These animation frame events will be ignored by the resulting EventRoutine, but it’s needed to make the types line up.

The decorate method applies the decorator we defined above, creating a new EventRoutine which behaves the same, but is rendered differently.

Add a unit type GameEventRoutine which represents the game running normally (with no menus open) where the player controls the player character and the game area and ui are rendered. Implement EventRoutine for this type.

Also define a type GameReturn which enumerates all the conditions under which normal gameplay can be interrupted.

A GameEventRoutine will run until normal gameplay is interrupted. Then, depending on the nature of interruption (as indicated by the returned GameReturn), some action will be taken and then GameEventRoutine will be invoked again. This continues in a loop until an Exit or GameOver are returned, in which case the program will terminate. The game’s state is stored in a field of AppData (as before), so it will persist across invocations of GameEventRoutine.

// app.rs
...
struct GameEventRoutine;

enum GameReturn {
    Exit,
    UseItem,
    DropItem,
    GameOver,
}

impl EventRoutine for GameEventRoutine {
    type Return = GameReturn;
    type Data = AppData;
    type View = AppView;
    type Event = CommonEvent;

    fn handle<EP>(
        self,
        data: &mut Self::Data,
        _view: &Self::View,
        event_or_peek: EP,
    ) -> Handled<Self::Return, Self>
    where
        EP: EventOrPeek<Event = Self::Event>,
    {
        event_routine::event_or_peek_with_handled(event_or_peek, self, |s, event| match event {
            CommonEvent::Input(input) => {
                if let Some(game_return) = data.handle_input(input) {
                    Handled::Return(game_return)
                } else {
                    Handled::Continue(s)
                }
            }
            CommonEvent::Frame(_period) => Handled::Continue(s),
        })
    }

    fn view<F, C>(
        &self,
        data: &Self::Data,
        view: &mut Self::View,
        context: ViewContext<C>,
        frame: &mut F,
    ) where
        F: Frame,
        C: ColModify,
    {
        view.game_view.view(&data.game_state, context, frame);
        view.render_ui(&data, context, frame);
    }
}
...

In the above code, input events are forwarded to AppData::handle_input. Previously, this method handled both game inputs and menu inputs. Now that menus will be handled by an EventRoutine, we can simplify AppData::handle_input to only be concerned with game inputs. Remove the AppState type and app_state field of AppData. Instead of setting the app_state field to the appropriate menu, when the ‘i’ or ‘d’ keys are pressed, return a GameReturn indicating which menu to switch to.

// app.rs
...
struct AppData {
    game_state: GameState,
    visibility_algorithm: VisibilityAlgorithm,
    inventory_slot_menu: MenuInstanceChooseOrEscape<InventorySlotMenuEntry>,
}

impl AppData {
    ...
    fn handle_input(&mut self, input: Input) -> Option<GameReturn> {
        if !self.game_state.is_player_alive() {
            return Some(GameReturn::GameOver);
        }
        match input {
            Input::Keyboard(key) => match key {
                KeyboardInput::Left => self.game_state.maybe_move_player(CardinalDirection::West),
                KeyboardInput::Right => self.game_state.maybe_move_player(CardinalDirection::East),
                KeyboardInput::Up => self.game_state.maybe_move_player(CardinalDirection::North),
                KeyboardInput::Down => self.game_state.maybe_move_player(CardinalDirection::South),
                KeyboardInput::Char(' ') => self.game_state.wait_player(),
                KeyboardInput::Char('g') => self.game_state.maybe_player_get_item(),
                KeyboardInput::Char('i') => return Some(GameReturn::UseItem),
                KeyboardInput::Char('d') => return Some(GameReturn::DropItem),
                keys::ESCAPE => return Some(GameReturn::Exit),
                _ => (),
            },
            _ => (),
        }
        self.game_state.update_visibility(self.visibility_algorithm);
        None
    }
}

All rendering logic is now contained within GameEventRoutine and the menu renderer and decorator. Remove impl<'a> View<&'a AppData> for AppView (but keep the AppView type around as a place to store rendering-related state).

Define a pair of functions for running the use_item and drop_item menus.

// app.rs
...
fn use_item() -> impl EventRoutine<Return = (), Data = AppData, View = AppView, Event = CommonEvent>
{
    make_either!(Ei = A | B);
    Loop::new(|| {
        inventory_slot_menu("Use Item").and_then(|result| match result {
            Err(menu::Escape) => Ei::A(Value::new(Some(()))),
            Ok(entry) => Ei::B(SideEffect::new_with_view(
                move |data: &mut AppData, _: &_| {
                    if data.game_state.maybe_player_use_item(entry.index).is_ok() {
                        Some(())
                    } else {
                        None
                    }
                },
            )),
        })
    })
}

fn drop_item() -> impl EventRoutine<Return = (), Data = AppData, View = AppView, Event = CommonEvent>
{
    make_either!(Ei = A | B);
    Loop::new(|| {
        inventory_slot_menu("Drop Item").and_then(|result| match result {
            Err(menu::Escape) => Ei::A(Value::new(Some(()))),
            Ok(entry) => Ei::B(SideEffect::new_with_view(
                move |data: &mut AppData, _: &_| {
                    if data.game_state.maybe_player_drop_item(entry.index).is_ok() {
                        Some(())
                    } else {
                        None
                    }
                },
            )),
        })
    })
}

Each of these functions calls inventory_slot_menu until either a valid selection or explicit cancellation was made. In the case of a valid selection, the appropriate action for each menu is taken (using or dropping). Note the make_either! macro. These function both include logic which executes one of two possible event routines depending on whether the user made a selection or cancelled the menu.

To pull one out:

match result {
    Err(menu::Escape) => Ei::A(Value::new(Some(()))),
    Ok(entry) => Ei::B(SideEffect::new_with_view(
        move |data: &mut AppData, _: &_| {
            if data.game_state.maybe_player_use_item(entry.index).is_ok() {
                Some(())
            } else {
                None
            }
        },
    )),
})

In the first case here, the use hit the escape key to cancel the menu, in which case we run the event routine:

Value::new(Some(()))

The Value EventRoutine returns immediately with a specified value. Returning Some(()) in this case tells the Loop that we’re inside to stop iterating.

In the second case the user made a selection, so we run the event routine:

SideEffect::new_with_view(
    move |data: &mut AppData, _: &_| {
        if data.game_state.maybe_player_use_item(entry.index).is_ok() {
            Some(())
        } else {
            None
        }
    },
)

SideEffect is an EventRoutine which lets you run arbitrary code on its Data. It’s used here to update the game state by attempting to have the player use an item.

The EventRoutines executed in both cases implement the same trait, but they are not the same type. Rust requires that all branches of a conditional statement have the same type. The make_either!(Ei = A | B | ...) macro creates a type:

enum Ei<AType, BType, ...> {
    A(AType),
    B(BType),
    ...
}

…and implements EventRoutine for the generated type. This macro is necessary whenever you have a conditional statement where each branch is a different EventRoutine.

So far we’ve defined an EventRoutine for running the game and displaying inventory menus. Now we just need something to stitch it all together. Remove the AppEventRoutine type defined in the previous section, and re-implement game_loop as:

// app.rs
...
fn game_loop() -> impl EventRoutine<Return = (), Data = AppData, View = AppView, Event = CommonEvent>
{
    make_either!(Ei = A | B | C | D);
    Loop::new(|| {
        GameEventRoutine.and_then(|game_return| match game_return {
            GameReturn::Exit => Ei::A(Value::new(Some(()))),
            GameReturn::GameOver => Ei::B(Value::new(Some(()))),
            GameReturn::UseItem => Ei::C(use_item().map(|_| None)),
            GameReturn::DropItem => Ei::D(drop_item().map(|_| None)),
        })
    }).return_on_exit(|_| ())
}
...

It repeatedly runs the game, handles any interruptions, and then resumes the game unless it has been quit or the game is over.

Reference implementation branch: part-8.4

Death Screen

To celebrate making it through the previous section, let’s implement a death screen!

At the moment we check whether the player is alive at the begining of AppData::handle_input. Because of this, when the player dies as a result of an NPCs action, the game doesn’t exit until the next input is handled. Let’s change this to use a timeout, where a death screen is briefly displayed before exiting the game.

Move the code which checks whether the player is dead to the end of AppData::handle_input so we can react immediately to the player’s demise:

// app.rs
...
impl AppData {
    fn handle_input(&mut self, input: Input) -> Option<GameReturn> {
        match input {
            ...
        }
        self.game_state.update_visibility(self.visibility_algorithm);
        if !self.game_state.is_player_alive() {
            return Some(GameReturn::GameOver);
        }
        None
    }
}
...

Implement a function returning an EventRoutine that displays a death screen for 2 seconds before completing with (). There’s a Delay EventRoutine already defined in chargrid so let’s just use that, with a custom decorator that replaces Delay’s rendering logic (which is to draw nothing) with rendering logic for our death screen.

// app.rs
...
use chargrid::{
    ...
    event_routine::{
        ...
        common_event::{CommonEvent, Delay},
        ...
    },
    ...
    text::{RichTextPart, RichTextViewSingleLine, StringViewSingleLine},
};
...
fn game_over() -> impl EventRoutine<Return = (), Data = AppData, View = AppView, Event = CommonEvent>
{
    struct GameOverDecorate;
    impl Decorate for GameOverDecorate {
        type View = AppView;
        type Data = AppData;
        fn view<E, F, C>(
            &self,
            data: &Self::Data,
            event_routine_view: EventRoutineView<E>,
            context: ViewContext<C>,
            frame: &mut F,
        ) where
            E: EventRoutine<Data = Self::Data, View = Self::View>,
            F: Frame,
            C: ColModify,
        {
            AlignView {
                alignment: Alignment::centre(),
                view: StringViewSingleLine::new(
                    Style::new()
                        .with_foreground(Rgb24::new(255, 0, 0))
                        .with_bold(true),
                ),
            }
            .view("YOU DIED", context.add_depth(10), frame);
            FillBackgroundView {
                rgb24: Rgb24::new(31, 0, 0),
                view: &mut event_routine_view.view.game_view,
            }
            .view(
                &data.game_state,
                context.compose_col_modify(ColModifyMap(|c: Rgb24| {
                    c.saturating_scalar_mul_div(1, 3)
                        .saturating_add(Rgb24::new(31, 0, 0))
                })),
                frame,
            );
            event_routine_view.view.render_ui(&data, context, frame);
        }
    }
    Delay::new(Duration::from_millis(2000)).decorated(GameOverDecorate)
}
...

Finally update game_loop to call game_over when the game is over:

// app.rs
...
fn game_loop() -> impl EventRoutine<Return = (), Data = AppData, View = AppView, Event = CommonEvent>
{
    make_either!(Ei = A | B | C | D);
    Loop::new(|| {
        GameEventRoutine.and_then(|game_return| match game_return {
            ..
            GameReturn::GameOver => Ei::B(game_over().map(|()| Some(()))),
            ...
        })
    }).return_on_exit(|_| ())
}
...

Switching to EventRoutine was a big change, but imagine how much messing about with state variables and countdown timers it would take to implement a death screen with explicit state machines.

death-screen

Reference implementation branch: part-8.5

Click here for the next part!