7 - Incremental delegation
Jun 24, 2021
tdd
rust
srl
|
Prev
|
Next
|
Code
In this post, I would like to showcase a technique I call “incremental delegation”.
I decided to move the handling of walls, enemies and the player to a struct called Dungeon
.
Instead of doing this all at once, I switched between adding new code and integrating it:
- Repeat until done:
- Write a unit test for a piece of new
Dungeon
functionality - Write the code that makes that test pass
- Delegate to the new code in
Game
, keeping the feature tests passing (refactoring)
- Write a unit test for a piece of new
In the interest of brevity, I will not show the Dungeon
code here. It is not super interesting, anyway. For the curious, these are the relevant revisions:
Instead, I will focus on how the incremental development of Dungeon
affects the Game
struct.
Step 1: Walls and enemies
Step one is to extract the handling of walls and enemies to Dungeon
.
Using the new API incurs the following changes in Game
:
pub struct Game {
player: (u32, u32),
dungeon: Dungeon
}
impl Game {
pub fn new(_config: GameConfig) -> Self {
Self {
dungeon: Dungeon::new(),
player: (0, 0),
}
}
pub fn add_enemies(&mut self, positions: &Vec<(u32, u32)>) {
self.dungeon.add_enemies(positions);
}
pub fn add_walls(&mut self, positions: &Vec<(u32, u32)>) {
self.dungeon.add_walls(positions);
}
pub fn set_player_position(&mut self, x: u32, y: u32) {
// Unchanged
}
pub fn tick<T: Input>(&mut self, input: &T) {
// Unchanged
}
pub fn render<T: Renderer>(&self, renderer: &mut T) {
renderer.clear();
for (x, y) in self.dungeon.get_enemies() {
renderer.render_at(*x, *y, Enemy);
}
for (x, y) in self.dungeon.get_walls() {
renderer.render_at(*x, *y, Wall);
}
renderer.render_at(self.player.0, self.player.1, Player);
}
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.dungeon.get_walls().contains(&new_pos) &&
!self.dungeon.get_enemies().contains(&new_pos)
}
}
Work is split up now. Walls and enemies are handled by the new Dungeon
instance. Player position handling and collision are still done in Game
. All tests pass.
Step 2: Player position
Next we delegate storing and retrieving the player position:
pub struct Game {
dungeon: Dungeon
}
impl Game {
pub fn new(_config: GameConfig) -> Self {
Self {
dungeon: Dungeon::new(),
}
}
pub fn set_player_position(&mut self, x: u32, y: u32) {
self.dungeon.set_player_position(x, y);
}
pub fn tick<T: Input>(&mut self, input: &T) {
let (mut player_x, mut player_y) = self.dungeon.get_player_position();
if input.move_left() && self.can_move_to(-1, 0) {
player_x -= 1;
}
// other directions go here
self.dungeon.set_player_position(player_x, player_y);
}
pub fn render<T: Renderer>(&self, renderer: &mut T) {
// wall/enemies rendering unchangesd
let (player_x, player_y) = self.dungeon.get_player_position();
renderer.render_at(player_x, player_y, Player);
}
fn can_move_to(&self, x_offset: i32, y_offset: i32) -> bool {
let (player_x, player_y) = self.dungeon.get_player_position();
let new_pos = ((player_x as i32 + x_offset) as u32,
(player_y as i32 + y_offset) as u32);
!self.dungeon.get_walls().contains(&new_pos) &&
!self.dungeon.get_enemies().contains(&new_pos)
}
}
This time, Game::tick
is changed as well, using the Dungeon
instance to get and set the player position. With feature tests green, we can move on to the next step.
Step 3: Player movement and collision
Finally, let’s extract player movement and collision as well. Only tick
is affected now:
pub fn tick<T: Input>(&mut self, input: &T) {
if input.move_left() { self.dungeon.move_player_left() }
if input.move_right() { self.dungeon.move_player_right() }
if input.move_up() { self.dungeon.move_player_up() }
if input.move_down() { self.dungeon.move_player_down() }
}
This time, collision detection and position modification is moved in one fell swoop.
And that’s it. Except for querying input, all decisions about moving the player are now delegated.
Step 4: A small refactoring
After extraction of Dungeon
, there was the decision to collapse walls and enemies into a single vector. A minor detail that requires only a small change in Game::render
:
pub fn render<T: Renderer>(&self, renderer: &mut T) {
renderer.clear();
for ((x, y), object_type) in self.dungeon.get_objects() {
renderer.render_at(*x, *y, *object_type);
}
let (player_x, player_y) = self.dungeon.get_player_position();
renderer.render_at(player_x, player_y, Player);
}
And that’s that. Some refactoring inside Dungeon
followed, but the camera pans to the ceiling. This no longer concerns any Game
code, which is what we want.
Final musings
Incremental delegation (or extraction) is a heuristic I use very often.
If the increment is small enough, it’s cheap enough to revert to a working state. When things get hairy, it is often more beneficial to revert a few minutes worth of changes, instead of fiddling with unexpected complications.
There are no hard and fast rules to this, of course. If you feel comfortable with a bigger change, there is no reason not to attempt it. On the other hand, if it seems hard to define the next increment, this might call for some design thinking. The resulting design will have the sole purpose of making the next change easy.
Enough with the fancy talk! Next time, we will deal with randomness for the first time in this project.