The tutorial · Step 6

Levels and a menu

Play this step Its whole project

One level isn't a game. This step adds four more, a list of them, and a menu to choose from:

The level menu: Levels at the top, then Level 1 to Level 5, with the first in focus.

The stack of screens gets a third card too, so you'll see how screens talk to one another.

FileWhat changes
crates/game/files/levels/2.map to 5.mapNew: four more levels
crates/game/files/levels.ronNew: the list of levels
crates/game/src/level.rsReads the list, and every level on it
crates/game/src/levels.rsNew: the menu screen
crates/game/src/stages.rsOwn holds every level, and the title starts the menu
crates/game/src/board.rsStarts on a chosen level, and hands back a result
crates/game/src/lib.rspub mod levels;
crates/game/files/en.ronThe menu's words
crates/game/tests/board.rson_board goes through the menu, and new tests

The levels

Add these to crates/game/files/levels/. Designing levels is half of making a puzzle game, and you're welcome to change them once the step works. These four were each checked to be winnable. Step 9 has a test that proves it.

2.map:

// Level 2, in the engine's `.map` format.
(
    name: "Two rows",
    legend: { '.': "floor", '#': "wall", 'o': "target" },
    features: { (2, 2): Start, (3, 2): Box, (3, 4): Box },
)
---
########
#......#
#....o.#
#......#
#....o.#
#......#
########

3.map:

// Level 3, in the engine's `.map` format.
(
    name: "Round the corner",
    legend: { '.': "floor", '#': "wall", 'o': "target" },
    features: { (2, 4): Start, (4, 3): Box },
)
---
########
#o.....#
###.#..#
#......#
#......#
########

4.map:

// Level 4, in the engine's `.map` format.
(
    name: "Side door",
    legend: { '.': "floor", '#': "wall", 'o': "target" },
    features: { (2, 5): Start, (2, 2): Box, (2, 4): Box },
)
---
#########
#...#...#
#...#.o.#
#......o#
#...#...#
#...#...#
#########

5.map:

// Level 5, in the engine's `.map` format.
(
    name: "The yard",
    legend: { '.': "floor", '#': "wall", 'o': "target" },
    features: { (4, 5): Start, (2, 2): Box, (6, 2): Box, (2, 4): Box },
)
---
#########
#o..#..o#
#.......#
##.#.#.##
#.....o.#
#.......#
#########

Here's how the second looks, when you reach it. The camera has more to frame, but no code had to change for that:

Level 2, Two rows: a wider room with two boxes and two targets.

The list

Name them in crates/game/files/levels.ron, in the order they're played:

// The levels, in the order they are played.
[
    "levels/1.map",
    "levels/2.map",
    "levels/3.map",
    "levels/4.map",
    "levels/5.map",
]

A list in a file means the order is data too. Swap the first and last, and level 5 comes first, with no code changed:

In crates/game/files/levels.ron:

-     "levels/1.map",
-     "levels/2.map",
-     "levels/3.map",
-     "levels/4.map",
-     "levels/5.map",
+     "levels/5.map",
+     "levels/2.map",
+     "levels/3.map",
+     "levels/4.map",
+     "levels/1.map",
The first item of the menu opens the level First push.
Before
The first item of the menu opens the level The yard.
After

The list is a kind of asset of the game's own, read as RON. In level.rs:

/// The level list: each level's file, in the order they are played.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(transparent)]
pub struct LevelList(pub Vec<String>);

impl AssetKind for LevelList {
    const NAME: &'static str = "level list";
}

/// Reads every level the list in `levels.ron` names.
pub fn read_all(bundle: &Bundle) -> Result<Vec<Level>, Vec<ContentError>> {
    // check-text: not player text, a file name
    let list = formats().load::<LevelList>(bundle, "levels.ron")?;
    let mut levels = Vec::new();
    let mut errors = Vec::new();
    for name in &list.0 {
        match read(bundle, name) {
            Ok(level) => levels.push(level),
            Err(problems) => errors.extend(problems),
        }
    }
    if errors.is_empty() {
        Ok(levels)
    } else {
        Err(errors)
    }
}

It's the three parts from step 2 again. LevelList is a new kind, a plain type with a name for error messages (that's all AssetKind asks). Ron::<LevelList>::new() is its format, which the engine provides for any type. Add it to the registry, in formats(), below the other:

    let _ = formats.register(Ron::<LevelList>::new());

Rust: Vec<String> is a list of texts, and pub struct LevelList(pub Vec<String>) wraps it in a type of its own. #[serde(transparent)] tells serde to read the file as just the list inside. You get to it as .0, the first field.

read_all shows a good habit: it doesn't stop at the first bad level. It tries them all, gathering every problem into errors, so when you run cargo shuyi check you see everything that's wrong at once. errors.extend(...) adds a whole list to a list.

Now Own holds them all. In stages.rs, the level field becomes levels:

/// The game's own part of its data.
#[derive(Debug)]
pub struct Own {
    /// Every level, in the order they are played.
    pub levels: Vec<Level>,
    /// The lights a level may be in: none, so the default, which changes
    /// nothing.
    pub lights: Lighting,
}

/// Reads the game's own part from its files.
pub fn own(bundle: &Bundle) -> Result<Own, Vec<ContentError>> {
    Ok(Own {
        levels: level::read_all(bundle)?,
        lights: Lighting::default(),
    })
}

and the board takes which one:

    /// The board on level `level`.
    ///
    /// # Panics
    ///
    /// If that level isn't one, or makes no puzzle, which loading it rules
    /// out for each level of the list.
    pub fn new(ctx: &Ctx<Data>, level: usize) -> Self {
        let file = &ctx.game.own.levels[level];
        let puzzle = Puzzle::new(file).unwrap_or_else(|e| panic!("{e}"));
        Self {
            level,
            name: file.name.clone(),
            history: History::new(puzzle.clone()),
            puzzle,
        }
    }

Rust: ctx.game.own.levels[level] picks one item from a list by its position, counted from 0. A position past the end panics, and the # Panics part of the comment warns of that. usize is the kind of number used for positions.

Also add pub level: usize, to the top of struct Board.

The menu

Make crates/game/src/levels.rs (with an s: level.rs is one level, and this is the screen that lists them).

Shuyi: before you write a screen, look for it in the engine. shuyi::ui::widgets has menus, lists and question boxes. Your screen just wraps one. The Menu here handles moving the focus up and down, wrapping round, and sounds.

use std::any::Any;

use shuyi::prelude::*;
use shuyi::ui::widgets::menu::glyph;
use shuyi::ui::widgets::{Menu, MenuEvent, MenuItem};

use crate::Data;
use crate::action::Action;
use crate::board::Board;

Each item of a menu carries the value it stands for, so the screen doesn't need a list beside it to know what was chosen. Say what those values can be:

/// What an item of the menu stands for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Choice {
    /// Plays this level, counted from 0.
    Level(usize),
}

The screen remembers only which item has the focus. Default in the derive means a LevelMenu can be made with sensible starting values, and focus starts at 0:

/// The level menu.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LevelMenu {
    /// Which item has the focus.
    pub focus: usize,
}

Then an impl LevelMenu { ... } block holds two functions. new makes one, using that default:

    /// The menu, on the first level.
    pub fn new() -> Self {
        Self::default()
    }

and menu makes the menu's items:

    /// The menu's items: a text key and a value each.
    pub fn menu(&self, ctx: &Ctx<Data>) -> Menu<Choice> {
        let levels = ctx.game.own.levels.len();
        let items = (0..levels).map(|n| {
            MenuItem::keyed("levels.level")
                .with_arg("n", n + 1)
                .with_value(Choice::Level(n))
        });
        Menu::new(items.collect()).focused(self.focus)
    }

Rust: 0..levels is a range: the numbers from 0 up to, but not including, levels. And .map(|n| ...) makes something from each one. Here, a chain of builders makes a menu item: its text key, then what to fill {n} with, then its value. n + 1 is there since people count from 1 and programs count from 0.

The text comes from the key levels.level, so it's in en.ron as ever:

    // The level menu.
    "levels.title": "Levels",
    "levels.level": "Level {n}",
    "levels.help": "{Cursor} move · {Confirm} choose · {Cancel} back",

In crates/game/files/en.ron:

- "levels.level": "Level {n}",
+ "levels.level": "Puzzle {n}",
The menu lists Level 1 to Level 5.
Before
The menu lists Puzzle 1 to Puzzle 5.
After

Then the jobs of a screen, inside impl Screen<Data> for LevelMenu. Give it a name of "levels", as you did for the others. Its actions are whichever its menu reads:

    /// Its menu's.
    fn actions(&self) -> Option<Vec<Action>> {
        let actions = Menu::<()>::ACTIONS.iter();
        Some(actions.filter_map(|&ui| Action::for_ui(ui)).collect())
    }

update hands each action to the menu, which answers with what happened, if anything:

    fn update(&mut self, ctx: &mut Ctx<Data>, input: &FrameInput<Action>) -> Transition<Data> {
        let mut menu = self.menu(ctx);
        for &action in &input.actions {
            let event = menu.handle_with_sound(action, &mut ctx.audio);
            self.focus = menu.focus();
            match event {
                Some(MenuEvent::Chosen(Choice::Level(n))) => {
                    return Transition::Push(Box::new(Board::new(ctx, n)));
                }
                Some(MenuEvent::Cancelled) => return Transition::Pop,
                None => {}
            }
        }
        Transition::None
    }

If a level is chosen, it pushes a board. If the player cancels, it pops back to the title. So only the title ever leaves the game, and only where the platform allows it, which means nothing here can leave a browser tab frozen.

draw is the same shape you've written before, and as_any is the same as the board's:

    fn draw(&self, ctx: &Ctx<Data>, buf: &mut GlyphBuffer) {
        let c = |e| ctx.palette.engine(e);
        let bg = c(EngineColor::Background);
        buf.fill_rect(buf.bounds(), Cell::new(' ', bg, bg));
        print_centred(
            buf,
            3,
            ctx.text("levels.title"),
            c(EngineColor::Highlight),
            bg,
        );
        let view = self.menu(ctx).view();
        let (width, _) = glyph::size(ctx, &view);
        let x = (i32::from(buf.width()) - width).max(0) / 2;
        glyph::paint(ctx, &view, buf, x, 6);
        let help = ctx.text_with("levels.help", &[]);
        let last = i32::from(buf.height()) - 1;
        print_centred(buf, last, &help, c(EngineColor::TextDim), bg);
    }

Then, in stages.rs, the title starts the menu rather than the board, and the menu goes on the list of screens:

/// What the title starts: the level menu.
pub fn first(ctx: &mut Ctx<Data>) -> Option<Box<dyn Screen<Data>>> {
    let _ = ctx;
    Some(Box::new(LevelMenu::new()))
}
/// Every screen the game can show past the title, for the check that its
/// controls fit each target.
pub fn screens(ctx: &mut Ctx<Data>) -> Vec<Box<dyn Screen<Data>>> {
    vec![Box::new(LevelMenu::new()), Box::new(Board::new(ctx, 0))]
}

let _ = ctx; only says that this function doesn't need its ctx yet. (It will in step 7.)

A result

Now the stack has three cards: title, menu, board. What should happen when a level is won? The menu should move on to the next level. But the board doesn't know about the menu, and shouldn't: it's only a board. So how does the news get across?

Shuyi: a screen that finishes can hand a value to the one below it. It pops with a result, and the one below hears it in uncovered. Neither has to know what the other is, beyond the type of the value that they agree on.

So agree on a type, in levels.rs:

/// What a won board hands the menu: the level it was on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LevelWon(pub usize);

When the level is won, Confirm pops the board with what happened. In the board's update, above Action::Cancel:

                Action::Confirm if self.puzzle.outcome().is_some() => {
                    return Transition::pop_with(LevelWon(self.level));
                }

The board's help lines in en.ron should now say so, and that Cancel goes back to the levels:

    // The board.
    "board.help": "{Cursor} walk · {Undo} undo · {Cancel} levels",
    "board.help.won": "Solved! {Confirm} next · {Undo} undo · {Cancel} levels",

Rust: Action::Confirm if ... => is a match guard, an arm that counts only if its condition is true too. If it's false, match goes on to try the next arm, which here is the plain Action::Confirm => {}. So a Confirm on a level not yet won does nothing.

The menu hears it when it's uncovered, and moves on:

    /// A won board hands back its level: the menu moves on to the next.
    fn uncovered(&mut self, ctx: &mut Ctx<Data>, result: Option<ScreenResult>) -> Transition<Data> {
        if let Some(LevelWon(level)) = result.and_then(ScreenResult::take::<LevelWon>) {
            let last = ctx.game.own.levels.len().saturating_sub(1);
            self.focus = (level + 1).min(last);
        }
        Transition::None
    }

Rust: result.and_then(ScreenResult::take::<LevelWon>) asks "is there a result, and is it a LevelWon?". The if let Some(LevelWon(level)) = ... then opens it up, two layers at once, and calls the number inside level. Patterns like this can go as deep as you need.

saturating_sub(1) subtracts, but stops at 0 rather than going below. And .min(last) takes the smaller of the two, so that winning the last level doesn't move the focus off the end.

The tests

on_board in tests/board.rs needs another Enter now, and the stack has grown:

/// The game on its first level.
fn on_board() -> Harness<game::Data> {
    let mut h = game::game().harness();
    h.keys("Enter Enter");
    assert_eq!(h.screens(), ["title", "levels", "board"]);
    h
}

Every test that uses it keeps working. That's why it was a helper. Now a test of the whole flow:

#[test]
fn a_won_level_hands_its_result_to_the_menu() {
    let mut h = on_board();
    h.keys("Right Right Enter");
    assert_eq!(h.screens(), ["title", "levels"]);
    let menu = h.screen::<LevelMenu>().unwrap_or_else(|| panic!("no menu"));
    assert_eq!(menu.focus, 1);
    h.keys("Enter");
    assert!(h.snapshot().contains("Two rows"), "{}", h.snapshot());
}

It wins level 1, and checks that the board has gone, the menu's focus moved on, and that Enter opens level 2. h.screen::<LevelMenu>() is why screens need as_any.

What you learned

Chapter 2 explains screens and their results. Next: the game should remember how far you got.