10 - Keep to the right

Jul 7, 2021 tdd rust srl | Prev | Next | Code

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

So last time we left a failing feature test:

#[test]
fn combat_on_collision() {
    let combat_engine = ControlledCombatEngine::new();
    combat_engine.say_is_hit((1, 0), (2, 0));
    combat_engine.say_is_hit((2, 0), (1, 0));
    combat_engine.say_damage((1, 0), 6);
    combat_engine.say_damage((2, 0), 2);

    let mut game = TestableGame::from_strings(vec![". @ E ."]);
    game.game.override_combat_engine(combat_engine);

    game.input.simulate_move_right();
    game.tick();
    game.renderer.assert_combat_log(vec![
        "Player hits Enemy for 6 damage!",
        "Enemy hits Player for 2 damage!"
    ])
}

Fake combat

Since no clear design ideas came up, I decided to doodle in the Game struct directly, resulting in the following changes:

struct CombatEvent {
    attacker: ObjectType,
    victim: ObjectType,
    damage: u32,
}

impl CombatEvent {
    pub fn text(&self) -> String {
        let attacker = if self.attacker == Player { "Player" } else { "Enemy" };
        let victim = if self.victim == Player { "Player" } else { "Enemy" };
        return format!("{} hits {} for {} damage!", attacker, victim, self.damage);
    }
}

pub struct Game {
    // ...
    combat_events: Vec<CombatEvent>,
}

impl Game {
    // ...
    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();
            self.combat_events.push(CombatEvent {
                attacker: Player,
                victim: Enemy,
                damage: 6,
            });

            self.combat_events.push(CombatEvent {
                attacker: Enemy,
                victim: Player,
                damage: 2,
            });
        }

        if input.move_up() { self.dungeon.move_player_up(); }

        if input.move_down() { self.dungeon.move_player_down(); }
    }

    pub fn render<T: Renderer>(&self, renderer: &mut T) {
        // ...
        for evt in self.combat_events.iter() {
            renderer.append_combat_log(evt.text().as_str())
        }
    }
}

It feels so deliciously inappropriate. Hard coded damage values, don’t care if there is a collision or not, don’t even use that CombatEngine trait at all. But the test passes. In fact all tests pass (if only because other tests don’t check the combat log).

Hit and miss

If all tests are passing, we need another test to proceed. What should happen if both combatants miss? The combat log should say something else:

#[test]
fn nobody_hits() {
    let combat_engine = ControlledCombatEngine::new();

    let mut game = TestableGame::from_strings(vec![". @ E ."]);
    game.game.override_combat_engine(combat_engine);

    game.input.simulate_move_right();
    game.tick();
    game.render();
    game.renderer.assert_combat_log(vec![
        "Player misses Enemy!",
        "Enemy misses Player!"
    ])
}

Since ControlledCombatEngine ist not told about any hits, everything misses.

The test fails of course:

thread '_tests::feature::melee_combat_test::nobody_hits' panicked at 'assertion failed: `(left == right)`
  left: `"Player hits Enemy for 6 damage!\nEnemy hits Player for 2 damage!"`,
 right: `"Player misses Enemy!\nEnemy misses Player!"`', src/_tests/_helpers/rendering_spy.rs:29:9

Enhanced Collision

To make that work, we need to know a couple of things:

Collision detection has been moved to Dungeon some time ago. Details are yet again not super relevant, but the Dungeon::move_player_* functions will return an optional collision result:

pub type DungeonCoords = (u32, u32);
pub type DungeonObject = (DungeonCoords, ObjectType);

pub type CollisionResult=Option<DungeonObject>;

impl Dungeon {
    // ... 
    pub fn move_player_right(&mut self) -> CollisionResult {
        self.try_move_by(1, 0)
    }

    fn try_move_by(&mut self, x_offset: i32, y_offset: i32) -> CollisionResult {
        // return None if object at offset is Floor, otherwise Some(DungeonObject)

    }
}

(Needless to say this change was made inside a unit testing loop.)

As for determining hits or misses, since we now know what we are colliding with, we can feed the coordinates to the combat engine. This calls for the implementation of ControlledCombatEngine, which is quickly and easily done (test driven of course).

And with that, we can go back to Game and make the test pass. Imagine a montage where our intrepid programmer quickly extracts and cleans up the CombatEvent struct, and we end up with this:

// game.rs
impl Game {
    // ...
    pub fn tick<T: Input>(&mut self, input: &T) {
        if input.move_left() { self.dungeon.move_player_left(); }

        if input.move_right() {
            match self.dungeon.move_player_right() {
                Some((coords, _object_type)) => {
                    let player_pos = self.dungeon.get_player_position();
                    if self.combat_engine.is_hit(player_pos, coords) {
                        let player_damage = self.combat_engine.roll_damage(player_pos);
                        let enemy_damage = self.combat_engine.roll_damage(coords);
                        self.combat_events.push(
                            CombatEvent::hit(Player, Enemy, player_damage));
                        self.combat_events.push(
                            CombatEvent::hit(Enemy, Player, enemy_damage));
                    } else {
                        self.combat_events.push(CombatEvent::miss(Player, Enemy));
                        self.combat_events.push(CombatEvent::miss(Enemy, Player));
                    }
                }
                None => {}
            }
        }

        if input.move_up() { self.dungeon.move_player_up(); }

        if input.move_down() { self.dungeon.move_player_down(); }
    }

    pub fn render<T: Renderer>(&self, renderer: &mut T) {
        // ...
        for evt in self.combat_events.iter() {
            renderer.append_combat_log(evt.log_string().as_str())
        }
    }
}

// combat_event.rs
pub struct CombatEvent {
    attacker: ObjectType,
    victim: ObjectType,
    damage: u32,
}

impl CombatEvent {
    pub fn miss(attacker: ObjectType, victim: ObjectType) -> Self {
        Self::hit(attacker, victim, 0)
    }

    pub fn hit(attacker: ObjectType, victim: ObjectType, damage: u32) -> Self {
        Self { attacker, victim, damage }
    }

    pub fn log_string(&self) -> String {
        if self.damage > 0 {
            format!("{} hits {} for {} damage!", self.attacker, self.victim, self.damage)
        } else {
            format!("{} misses {}!", self.attacker, self.victim)
        }
    }
}

We consult the dungeon for collision information and the combat engine for combat information. The hard-coded fakery has been replaced. That lump of code hanging off to the side there, that is the beginning of our combat system.

But wait, what if the player moves to in another direction? And what if the player hits and the enemy misses, or vice versa? What if we bump into a wall?
Well, this small combinatorial explosion will be addressed in the next installment. Bye for now!