The tutorial · Step 4

Pushing

Play this step Its whole project

This is the step where it becomes a game. You'll push a box, and win:

The box is on the target and has turned green, and the help line says Solved.

Here's the pay-off from step 3. All of the game's new behaviour goes in the rules. The screen barely changes, because it was already sending commands and showing whatever the state says.

FileWhat changes
crates/game/src/rules.rsPushing, and winning
crates/game/src/board.rsOne line: the help line after a win
crates/game/files/en.ronThe words for that
crates/game/tests/board.rsTests of pushing and winning

New words for what can happen

A box can now move, or be stuck, and a level can be over. So the rules need new words. In rules.rs, Event gains Pushed:

/// What happened.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Event {
    /// The player walked to this tile.
    Walked(Pos),
    /// A box was pushed from one tile to another.
    Pushed {
        /// Where it was.
        from: Pos,
        /// Where it is now.
        to: Pos,
    },
}

Rust: an enum's possibility can have named fields, like a struct. Pushed { from, to } says plainly which is which, where Pushed(Pos, Pos) would leave you to remember.

and Refusal swaps Box for two new reasons:

/// Why a command was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
    /// A wall is that way.
    Wall,
    /// The box that way has a wall or another box behind it.
    Stuck,
    /// Every box is already on a target.
    Over,
}

The Display code below it needs the same two reasons. Here the compiler helps you: as soon as you remove Box, the match in fmt stops compiling, and tells you what to fix. This happens all the time in Rust. Change a type, and follow the errors. They lead you to every place that needs to change.

Pushing

A box can move if the tile behind it is free. Picture the player, the box and the tile past the box, in a line:

    fn check(&self, command: &Command) -> Result<(), Refusal> {
        if self.outcome().is_some() {
            return Err(Refusal::Over);
        }
        let Command::Step(dir) = *command;
        let to = self.player.step(dir);
        if self.is_wall(to) {
            return Err(Refusal::Wall);
        }
        let past = to.step(dir);
        if self.boxes.contains(&to) && (self.is_wall(past) || self.boxes.contains(&past)) {
            return Err(Refusal::Stuck);
        }
        Ok(())
    }

The new part is the middle: to is where the player would step, and past is one further. If there's a box at to, and past holds a wall or another box, the push is refused. && is "and", || is "or", and the brackets group them as in arithmetic.

And apply moves the box first, then the player:

    fn apply(&mut self, command: &Command) -> Result<Vec<Event>, Refusal> {
        // All or nothing: a refused command changes nothing.
        self.check(command)?;
        let Command::Step(dir) = *command;
        let to = self.player.step(dir);
        let mut events = Vec::new();
        if self.boxes.remove(&to) {
            let past = to.step(dir);
            self.boxes.insert(past);
            events.push(Event::Pushed { from: to, to: past });
        }
        self.player = to;
        events.push(Event::Walked(to));
        Ok(events)
    }

self.boxes.remove(&to) removes a box from the set at to if there is one, and answers whether it did. So that if reads: "if there was a box here, it's now one further along". Notice that apply doesn't re-check anything. check has already said this is allowed, which is why it's the first thing apply calls.

Now run it, and push the box around. Here it is after one step right:

The player has pushed the box one tile right, and it is one tile from the target.

Winning

Game idea: the game's outcome is a question you ask of the state, not something you have to remember to record. No command has to remember to say "you won". There can't be a bug where one forgets.

Add a type for how a level ends. Won holds no data: it's a struct with no fields, as the title was:

/// How a level ends: only one way, every box on a target.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct Won;

In TurnRules, change type Outcome = (); to type Outcome = Won;, and answer the question:

    /// Won once every box is on a target.
    fn outcome(&self) -> Option<Won> {
        (self.boxes == self.targets()).then_some(Won)
    }

That's the entire win condition: the set of boxes is the same as the set of targets. A set was a good choice here, since two sets are equal however they were filled. .then_some(Won) turns true into Some(Won), and false into None.

The start of check also refuses every move once the level is won, which is the Refusal::Over you added.

The box turning green is already done. In step 2, scene worked out each box's home, and Pieces picks the box_home color for it. Nobody has to tell the screen about winning, because it only ever draws the state.

Saying so

The board picks its help line according to the outcome. In view, replace the let help line:

        let help = if self.puzzle.outcome().is_some() {
            "board.help.won"
        } else {
            "board.help"
        };

and add the words to files/en.ron:

    // The board.
    "board.help": "{Cursor} walk · {Cancel} back",
    "board.help.won": "Solved! {Cancel} back",

Once again, words and colors are in their own files, so they're quick to change:

In crates/game/files/en.ron:

- "board.help.won": "Solved! {Cancel} back",
+ "board.help.won": "You did it! {Cancel} back",
The help line starts with Solved.
Before
The help line starts with You did it.
After

In crates/game/files/palette.ron:

- "box_home": "#a0e07a",
+ "box_home": "#5a9bff",
A box on its target is green.
Before
A box on its target is blue.
After

The tests

Add these to tests/board.rs, with use shuyi::core::TurnRules; at the top so that outcome() can be called:

#[test]
fn pushing_the_box_onto_the_target_wins() {
    let mut h = on_board();
    h.keys("Right");
    assert!(board(&h).puzzle.boxes().contains(&Pos::new(4, 2)));
    assert!(board(&h).puzzle.outcome().is_none());
    h.keys("Right");
    assert!(board(&h).puzzle.boxes().contains(&Pos::new(5, 2)));
    assert!(board(&h).puzzle.outcome().is_some());
}
#[test]
fn a_box_against_a_wall_stays_and_so_does_the_player() {
    let mut h = on_board();
    // Round the box, and push it up against the top wall.
    h.keys("Down Right Up");
    assert_eq!(board(&h).puzzle.player(), Pos::new(3, 2));
    assert!(board(&h).puzzle.boxes().contains(&Pos::new(3, 1)));
    h.keys("Up");
    assert_eq!(board(&h).puzzle.player(), Pos::new(3, 2));
}

Each of these plays the actual game, by pressing keys. Reading them, you can follow what's happening on the board in your head: that's a good sign in a test. The second walks round the box, pushes it up against the wall, and tries once more.

Level 1 is won in two moves. Try it.

What you learned

Next: a puzzle needs an undo.