6 - Player collision
Jun 19, 2021
tdd
rust
srl
|
Prev
|
Next
|
Code
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![
"# # #",
". @ .",
]);
}
#
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’s a thin wrapper around the
Game
struct - It gives it an
InputSimulator
as input - It gives it a
RenderingSpy
as output - It provides a constructor
from_strings
, deriving enemies, walls and the player from the given strings, just likeRenderingSpy
does.
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:
-
Isn’t that too simplistic? Will it scale with the anticipated rise in complexity?
I certainly hope it will. But if the project is cancelled today, the code will have fulfilled the requirements. -
There is only one example test for each direction. The coverage is laughable! Property testing! QuickCheck!
The code under test is super simple, so my confidence in the existing tests is high. I only write (or generate) tests that are worth writing. -
Can we at least make the tests shorter? Why write the same set of strings twice?
Later, maybe. But if a bit of duplication makes a test more readable, I tend to leave it that way. -
The tests for wall and player collision almost look the same!
See above
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.