2 - Start anywhere
May 31, 2021
tdd
rust
srl
|
Prev
|
Next
|
Code
Here is our initial TODO list:
- [ ] Render tiles
- [ ] Move player
- [ ] Run in terminal
Of the three items, only the last one will be a user-facing feature. The first two will be test-facing, so to speak. That’s OK. We have to start somewhere.
As a reminder, this is the TDD sequence in a nutshell:
- Write a failing test
- Write only as much code as is needed to make it pass
- Refactor to remove duplication and improve the design
Should we do design before writing the first test? Yes, we should. I personally like to get away with as little as possible.
Render tiles
A Roguelike typically renders a 2-dimensional matrix of tiles. A tile can have different things on it. For now let’s assume that all we have is walls, enemies, the player and the floor.
The first test will construct a game with a tiny tile matrix:
#[test]
fn renders_walls_enemies_and_player() {
let mut game = Game::new();
game.add_enemies(&vec![(1, 1), (4, 2), (2, 3)]);
game.add_walls(&vec![(1, 0), (2, 0), (3, 1), (1, 2)]);
game.set_player_position(3, 2);
let mut renderer = RenderingSpy::new(5, 4);
game.render(&mut renderer);
renderer.assert_frame(vec![
". # # . .",
". E . # .",
". # . @ E",
". . E . ."
])
}
This test assumes the following:
- There is a
Game
struct - The game is tiny, rendering to a 4x5 matrix
- We can somehow add enemies, walls and a player to the game, specifying x (column) and y (row)
- There is a
RenderingSpy
struct - There is a function
Game::render
that takes an instance of that struct - There is a method
RenderingSpy::assert_frame
, which asserts that the different objects are at their assigned positions. The expected matrix is given as a list of strings, where the characters mean the following:.
is an empty tile (floor)#
is a wall tileE
is a tile with an enemy on it@
is a tile with the player on it
Let’s start with Game::render
. It takes a trait Renderer
:
pub fn render<T: Renderer>(&self, renderer: &mut T) {
// does nothing yet
}
The Renderer
trait looks like this:
pub enum ObjectType {
Wall,
Player,
Enemy,
}
pub trait Renderer {
fn render_at(&mut self, x: u32, y: u32, object_type: ObjectType);
}
So that’s the design out of the way - Game
depends on an abstraction, oblivious of any concrete implementations that will follow.
(Traits are Rust’s sole notion of an interface. There will be an implementation of Renderer
for testing purposes and one “real” implementation for production. And since Game::render
is generic, the trait implementation can be resolved at compile time. So no runtime penalty looking up vtables and such.)
Next, we need the RenderingSpy
. For testing purposes, it will intercept the game code whenever the game wants to render a tile. For that to work, it has to implement the Renderer
trait:
impl Renderer for RenderingSpy {
fn render_at(&mut self, x: u32, y: u32, object_type: ObjectType) {
let ch = match object_type {
Wall => { '#' }
Player => { '@' }
Enemy => { 'E' }
};
self.frame[y as usize][x as usize] = ch;
}
}
This simply translates the different object types into characters and keeps them in a two-dimensional array, for the assert_frame
function to use (via frame_as_string
):
pub fn assert_frame(&self, expected: Vec<&str>) {
assert_eq!(self.frame_as_string(), expected.join("\n").replace(" ", ""));
}
This does two quality-of-life things:
- It removes blanks from the incoming string. This allows us to make the expected value more readable
- It ultimately compares strings, enabling IntelliJ to produce a nice diff in case of failure
(By the way, RenderingSpy
was implemented using unit tests. It’s good to have some confidence in your test helpers.)
Now that we can make a proper assertion, stubbing out an empty Game
will give us the test failure we want:
<Click to see difference>
thread '_tests::feature::rendering_objects_test::renders_walls_enemies_and_player' panicked at 'assertion failed: `(left == right)`
left: `".....\n.....\n.....\n....."`,
right: `".##..\n.E.#.\n.#.@E\n..E.."`', src/_tests/helpers/rendering_spy.rs:31:9
And, after we “click to see the difference”:
This fails because RenderingSpy
has initialized all tiles as empty (verified by a test, of course), and Game::render
does nothing.
We can make the test pass with very little work. Assuming the different objects are stored in vectors of tuples, Game::render
looks like this:
pub fn render<T: Renderer>(&self, renderer: &mut T) {
for (x, y) in &self.enemies {
renderer.render_at(*x, *y, Enemy);
}
for (x, y) in &self.walls {
renderer.render_at(*x, *y, Wall);
}
renderer.render_at(self.player.0, self.player.1, Player);
}
And the test passes. Nothing more to see here.
We tick off the first item in the TODO list:
- [X] Render tiles
- [ ] Move player
- [ ] Run in terminal
And that’s it for this episode. Until next time!