6 - Player collision

Jun 19, 2021 tdd rust srl | Prev | Next | Code

This post is part of the series Making a Roguelike - A test-driven approach.

The todo list calls for player collision:

- [ ] Player collision with walls
- [ ] Player collision with enemies (no combat)
- [ ] Randomly generated walls

This means the player can’t just walk over walls and enemies, and instead will be stopped. For now, nothing happens when the player runs into a foe.

We start by considering collision with walls:

#[test]
fn player_collides_with_north_wall() {
    let mut game = TestableGame::from_strings(vec![
        "# # #",
        ". @ .",
    ]);

    game.input.simulate_move_up();
    game.verify_next_frame(vec![
        "# # #",
        ". @ .",
    ]);
}
Recall that the # character denotes a wall, and the @ character the player. So we are saying that the first row in the dungeon is a wall, from position (0, 0) to (2, 0). The player is right below, at (1, 1).
The test says: Given walls to the “north” of the player, if the player tries to move up, they will stay put, meaning the output does not change.

This is the first time we see TestableGame in action:

It also has a convience function called verify_next_frame:

pub fn verify_next_frame(&mut self, expected: Vec<&str>) {
    self.tick();
    self.render();
    self.renderer.assert_frame(expected);
}

This is saying “produce the next frame and then assert that it is what we expect”.
(I use the verify-prefix for test helper functions that exercise the production code and then make an assertion.)

Passing the test

Remember last time, the talk about the feature/unit test loop, with a diagram and everything?
Yeah, didn’t happen this time. It was too easy to make the test pass, and defer the design thinking. Sometimes you have to let things stew for a bit.

Speaking of easy, this change in Game::tick makes the test pass:

pub fn tick<T: Input>(&mut self, input: &T) {
    let (mut player_x, mut player_y) = self.player;

    if input.move_left() { player_x -= 1 }
    if input.move_right() { player_x += 1 }

    if input.move_up() {
        if !self.walls.contains(&(player_x, player_y - 1)) {
            player_y -= 1
        }
    }

    if input.move_down() { player_y += 1 }

    self.set_player_position(player_x, player_y);
}

You get the idea. Since the walls property contains tuples of coordinates, we only go up if it does not contain the coordinate “above the player”.

Bumping into enemies

And while we are at it, how hard can it be to collide with enemies? Not hard. The test looks almost the same:

#[test]
fn player_collides_with_enemy_above() {
    let mut game = TestableGame::from_strings(vec![
        ". E . .",
        ". @ . .",
        ". . . ."
    ]);

    game.input.simulate_move_up();
    game.verify_next_frame(vec![
        ". E . .",
        ". @ . .",
        ". . . ."
    ]);

To make this work, we have to expand the conditional:

pub fn tick<T: Input>(&mut self, input: &T) {
    let (mut player_x, mut player_y) = self.player;

    if input.move_left() { player_x -= 1 }
    if input.move_right() { player_x += 1 }

    if input.move_up() {
        if !self.walls.contains(&(player_x, player_y - 1)) && 
            !self.enemies.contains(&(player_x, player_y - 1)) {
            player_y -= 1
        }
    }

    if input.move_down() { player_y += 1 }

    self.set_player_position(player_x, player_y);
}

Repeating the process (write a test, add a bit of code) for all directions yields the new Game::tick:

pub struct Game {
    // ...
    pub fn tick<T: Input>(&mut self, input: &T) {
        if input.move_left() && self.can_move_to(-1, 0) {
            self.player.0 -= 1;
        }

        if input.move_right() && self.can_move_to(1, 0) {
            self.player.0 += 1
        }

        if input.move_up() && self.can_move_to(0, -1) {
            self.player.1 -= 1
        }

        if input.move_down() && self.can_move_to(0, 1) {
            self.player.1 += 1
        }
    }
    // ...
    fn can_move_to(&self, x_offset: i32, y_offset: i32) -> bool {
        let new_pos = ((self.player.0 as i32 + x_offset) as u32, 
                       (self.player.1 as i32 + y_offset) as u32);

        !self.walls.contains(&new_pos) && !self.enemies.contains(&new_pos)
    }
}

To help with readability, a function can_move_to has been extracted, incurring the wrath of the Rust type checker.

And that’s it. Nothing needs to happen yet when the player bumps into an enemy, so we are done.
For reference, Here are the relevant files:

We have Concerns

Here is what the voices in my head are saying, with answers from a presumably wiser voice:

But hey, two items checked off.

- [X] Player collision with walls
- [X] Player collision with enemies (no combat)
- [ ] Randomly generated walls

Stay tuned for next time! I see a bit of refactoring coming up.