Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3. A map

Chapter 2 clamped @ to the screen edge. This chapter replaces that with an actual level: a walled room built once from a plain string, with movement blocked by the walls in it instead of by the edge of the grid.

Building the level

Grid::from_charmap turns a multi-line string into a Grid, calling a closure once per character to decide that character’s tile. It’s the same helper the 11_sokoban gallery example (a complete small game) builds its level from:

/// The level: a single walled room. `#` is a wall, everything else (just `.` here) is floor.
const LEVEL: &str = "\
#############
#............#
#............#
#............#
#............#
#............#
#############";

/// Builds the level's [`Grid`]: one [`Tile`] per character, walls and floor each with their own
/// style so they read apart from the player drawn on top of them.
fn build_level() -> Grid {
    let wall = Style::default();
    let floor = Style::default();
    Grid::from_charmap(LEVEL, |c| match c {
        '#' => Tile::new('#', wall),
        _ => Tile::new('.', floor),
    })
}

Movement and collision

try_move no longer clamps to the screen; it asks is_wall whether the destination cell is walkable before committing to it. Out-of-bounds counts as a wall too, so a level doesn’t need a border check of its own as long as it’s, well, walled in:

    /// Whether `(x, y)` is a wall, or outside the level entirely (out of bounds is treated the
    /// same as a wall: nothing to walk onto there).
    fn is_wall(&self, x: i32, y: i32) -> bool {
        let (Ok(x), Ok(y)) = (u16::try_from(x), u16::try_from(y)) else {
            return true;
        };
        self.level
            .tile(0, (x, y))
            .is_none_or(|tile| tile.glyph() == '#')
    }

    /// Moves the player by one cell in `(dx, dy)`, a no-op if the destination is a wall.
    fn try_move(&mut self, dx: i32, dy: i32) {
        let (nx, ny) = (i32::from(self.x) + dx, i32::from(self.y) + dy);
        if self.is_wall(nx, ny) {
            return;
        }
        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
        {
            self.x = nx as u16;
            self.y = ny as u16;
        }
    }

    /// Drains pending input: moves on an arrow key, returns `false` if the user asked to quit.
    fn handle_events<B: Backend>(&mut self, term: &mut Terminal<B>) -> bool {
        for event in term.drain_events() {
            match event {
                Event::Key(key) => match key.code {
                    KeyCode::Char('q') | KeyCode::Escape => return false,
                    KeyCode::Left => self.try_move(-1, 0),
                    KeyCode::Right => self.try_move(1, 0),
                    KeyCode::Up => self.try_move(0, -1),
                    KeyCode::Down => self.try_move(0, 1),
                    _ => {}
                },
                Event::Close => return false,
                _ => {}
            }
        }
        true
    }

Drawing composes the two pieces from the last two chapters: Surface::blit stamps the whole level grid onto the screen in one call, then @ is drawn on top of it the same way it always has been.

Running it

cargo run --example 03_a_map --features crossterm
cargo run --example 03_a_map --features software
cargo run --example 03_a_map  # headless fallback, prints a few frames to stdout

Arrow keys move @, blocked by the walls (#). q or Escape quits.

This is the minimum viable tutorial: the how-to section covers what comes next task by task, including drawing a status bar or log with retroglyph-ui widgets (see Draw a panel and Handle a click).