13 - Doing damage

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

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

The combat log is nice and all, but what about doing actual damage to enemies? What does that look like from the player’s perspective?

One hit wonder

Let’s hit an enemy really hard then! Here is a feature test to that effect:

#[test]
fn enemy_is_removed_when_dead() {
    let mut game = TestableGame::from_strings(vec![". @ E ."]);
    let mut combat_engine = ControlledCombatEngine::new();
    combat_engine.say_is_hit((1, 0), (2, 0));
    combat_engine.say_damage((1, 0), 1000);

    game.game.override_combat_engine(combat_engine);
    game.input.simulate_move(Right);
    game.verify_next_tiles(vec![". . @ ."]);
}

In this tiny version of the game, there is an enemy to the right of the player. We say that the player does a lot of damage to the enemy. As the player attacks the enemy, we expect the enemy to die and the player to take its place - by way of TestableGame::verify_next_tiles, formerly known as verify_next_frame.

The test fails, of course:

thread '_tests::feature::melee_combat_test::enemy_is_removed_when_dead' panicked (...)
  left: `".@E."`,
 right: `"..@."`', src/_tests/_helpers/rendering_spy.rs:23:9

… because, so far, enemies can withstand all damage.

How can we make this work? A small patch in Game::record:combat should do the trick:

 1impl Game {
 2    // ...
 3    fn record_combat_with(&mut self, coords: (u32, u32), direction: MoveDirection) {
 4        let player_pos = self.dungeon.get_player_position();
 5        if self.combat_engine.is_hit(player_pos, coords) {
 6            let player_damage = self.combat_engine.roll_damage(player_pos);
 7            if player_damage >= self.combat_engine.get_hp(coords) {
 8                self.dungeon.remove_enemy(coords.0, coords.1);
 9                self.dungeon.move_player(direction);
10            }
11        // Unchanged ...
12    }
13}

Of course this requires two new functions:

CombatEngine::get_hp - which we can fake for now:

pub trait CombatEngine {
    fn is_hit(&self, attacker: DungeonCoords, victim: DungeonCoords) -> bool;
    fn roll_damage(&self, attacker: DungeonCoords) -> u32;
    fn get_hp(&self, _coords: DungeonCoords) -> u32 { 100 }
}

Dungeon::remove_enemy - for this, it is time to split off enemies into their own list. We introduce a struct Creature and use it to store enemies:

struct Creature {
    position: DungeonCoords,
}

pub struct Dungeon {
    walls: Vec<DungeonCoords>,
    enemies: Vec<Creature>,
    player_position: DungeonCoords,
}

We want Dungeon::get_objects to keep its API, so it needs to do a bit more work:

impl Dungeon {
  pub fn get_objects(&self) -> Vec<DungeonObjectTuple> {
        let mut result: Vec<DungeonObjectTuple> = self.walls.iter()
            .map(|coord| { (*coord, Wall) })
            .collect();

        for enemy in self.enemies.iter() {
            result.push((enemy.position, Enemy));
        }
        result
    }
}

And finally, the hopefully short lived version of Dungeon::remove_enemy, taken straight from the “how to remove an element from a vector in Rust” google search:

impl Dungeon {
    pub fn remove_enemy(&mut self, x: u32, y: u32) {
        let mut i = 0;
        while i < self.enemies.len() {
            if self.enemies[i].position == (x, y) { self.enemies.remove(i) }
            else { i += 1 }
        }
    }
}

And the test passes. Yay!

What if we don’t hit as hard?

So we got away with not tracking any actual hitpoints. A hard-coded value fromCombatEngine::get_hp was enough. But what if the enemy withstands the first attack? Let’s see:

#[test]
fn enemy_is_removed_after_two_hits() {
    let mut game = TestableGame::from_strings(vec![". @ E ."]);
    let mut combat_engine = ControlledCombatEngine::new();
    combat_engine.say_is_hit((1, 0), (2, 0));
    combat_engine.say_damage((1, 0), 60);

    game.game.override_combat_engine(combat_engine);
    game.input.simulate_move(Right);
    game.verify_next_tiles(vec![". @ E ."]);
    game.verify_next_tiles(vec![". . @ ."]);
}

If we assume that an enemy has 100 hit points, we want it to still stand after the first attack deals 60 damage. The second attack, also dealing 60 damage, should kill it (remember that verify_next_tiles ticks and renders the game before asserting).

This fails, as expected:

thread '_tests::feature::melee_combat_test::enemy_is_removed_after_two_hits' panicked (...)
  left: `".@E."`,
 right: `"..@."`', src/_tests/_helpers/rendering_spy.rs:23:9

A little more work is required. In Game, we would like to do something like this:

 1impl Game {
 2    fn record_combat_with(&mut self, coords: (u32, u32), direction: MoveDirection) {
 3        let player_pos = self.dungeon.get_player_position();
 4        if self.combat_engine.is_hit(player_pos, coords) {
 5            let player_damage = self.combat_engine.roll_damage(player_pos);
 6            let remaining_hp = self.dungeon.apply_damage(coords, player_damage);
 7            if remaining_hp <= 0 {
 8                self.dungeon.remove_enemy(coords);
 9                self.dungeon.move_player(direction);
10            }
11        // ..
12    }
13}

The main thing is the new function Dungeon::apply_damage - it should “apply” the given damage to whatever is at the given coordinates, and return the hitpoints that remain.
For this to work, we need to keep track of the actual health of the enemy. Again, Dungeon receives the brunt of the rework:

const DEFAULT_ENEMY_HP: i32 = 100;

struct Creature {
    hp: i32,
}

pub struct Dungeon {
    enemies: HashMap<DungeonCoords, Creature>,
    object_types: HashMap<DungeonCoords, ObjectType>,
    player_position: DungeonCoords,
}

impl Dungeon {
    // ...
    pub fn add_enemies(&mut self, enemies: &Vec<DungeonCoords>) {
        for pos in enemies {
            self.enemies.insert(*pos, Creature { hp: DEFAULT_ENEMY_HP });
            self.object_types.insert(*pos, Enemy);
        }
    }

    pub fn apply_damage(&mut self, coords: DungeonCoords, damage: u32) -> i32 {
        let enemy = self.enemies.get_mut(&coords);
        debug_assert!(enemy.is_some(), "No enemy at {:?}", coords);
        let enemy = enemy.unwrap();
        enemy.hp -= damage as i32;
        enemy.hp
    }

     pub fn remove_enemy(&mut self, coords: DungeonCoords) {
        self.enemies.remove(&coords);
        self.object_types.remove(&coords);
    }
}

Enemies are now stored in a HashMap, which just makes more sense. We also keep track of all object types and their position separately, since Dungeon::get_objects is still needed for collision detection and rendering. CombatEngine::get_hp is no longer needed. The test passes.

Logging the enemy death

Lastly, we want the combat log to reflect the enemy death:

#[test]
fn combat_log_reflects_enemy_death() {
    let mut game = TestableGame::from_strings(vec![". @ E ."]);
    // Setup player hitting enemy for 1000
    game.tick();
    game.render();
    game.renderer.assert_combat_log(vec![
        "Player hits Enemy for 1000 damage!",
        "Enemy dies!"
    ])
}

Seems like we just have to do add a line to Game::record_combat_at:

if remaining_hp <= 0 {
    // kill enemy
    self.combat_events.push(CombatEvent::death(Enemy));
}

But oh no:

thread '_tests::feature::melee_combat_enemy_death_test::combat_log_reflects_enemy_death' panicked (...)
  left: `"Player hits Enemy for 1000 damage!\nEnemy dies!\nEnemy misses Player!"`,
 right: `"Player hits Enemy for 1000 damage!\nEnemy dies!"`', src/_tests/_helpers/rendering_spy.rs:29:9

The dead enemy is trying to hit back!

The quickest way to fix this is to simply bail out:

if remaining_hp <= 0 {
    // kill enemy
    self.combat_events.push(CombatEvent::death(Enemy));
    return;
}

Obviously an early return statement hidden inside layers of if-statements is not ideal, but it’s good enough for now.

So that is enemy damage. Sadly, our time together has come to an end again. There are many questions open: Will enemies always start with 100 hitpoints? What about player damage? What if the player dies?
Hopefully, some or all of these questions will be answered next time. Bye!