16 - Winning the game

Aug 7, 2021 tdd rust srl | Prev | Code

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

Up until now, all the player can look forward to is die and a rude message at the end of the game.
But how can the player win? Well, for starters, let’s say they have to kill all the enemies.

From boolean to GameState

To make the easy change, first let’s make the change easy.

Recall that the last time we saw the main loop it looked like this:

while !input.quit_game() && !game.player_died() {
    // game game game
}
if game.player_died() {
    println!("\nYou died. Thanks for playing!");
}

If we want to check for more than the fate of the player, a binary decision is no longer sufficient. We will need an enum. The feature test changes:

#[test]
fn game_over_after_player_death() {
    let mut game = TestableGame::from_strings(vec![". @ E ."]);
    enemy_hits_for(TestableGame::default_enemy_hp() / 2 + 1, &mut game);
    game.input.simulate_move(Right);
    game.tick();
    assert_eq!(Running, game.game.game_state());
    game.tick();
    assert_eq!(PlayerDied, game.game.game_state());
}
(The shiny new TestableGame::default_enemy_hp encodes the previous assumption of player hp being a certain value. Here we make it clear that the player goes down after two hits.)

To make the test compile, we stub out what we need:

pub enum GameState {
    Running,
    PlayerDied,
}

impl Game {
    // ...
    pub fn game_state(&self) -> GameState {
        self.game_state // initialized to 'Running'
    }
}

This compiles and fails. To make it pass, Game::tick yet again receives attention:

pub fn tick<T: Input>(&mut self, input: &T) {
    // ... for attacked enemy at 'coords':
    self.combat_resolver.handle_combat_with(coords, *direction);
    if self.dungeon_ref.borrow().get_player_hp() <= 0 {
        self.game_state = PlayerDied;
    }
}

So, CombatResolver::handle_combat_with no longer has a return value, and decisions based on the state of the player are made by the caller. So, if the player has zero or less hitpoints, the game state changes to PlayerDied, which is all we need to do to make the test pass.

The main loop changes slightly:

while !input.quit_game() && game.game_state() == Running {
    /// run the game
}
if game.game_state() == PlayerDied {
    println!("\nYou died. Thanks for playing!");
}

Finally, winning

In order to make decisions based on enemy bodycount, we need a new GameState enum member.
Let’s have us a feature test:

#[test]
fn game_state_changes_after_all_enemies_are_dead() {
    let mut game = TestableGame::from_strings(vec![" . @ E E"]);
    game.configure_combat(|combat_engine| {
        combat_engine.say_is_hit((1, 0), (2, 0));
        combat_engine.say_is_hit((2, 0), (3, 0));
        combat_engine.say_damage((1, 0), TestableGame::default_enemy_hp()*10);
        combat_engine.say_damage((2, 0), TestableGame::default_enemy_hp()*10);
    });
    game.input.simulate_move(Right);
    game.verify_next_tiles(vec![" . . @ E"]);
    assert_eq!(Running, game.game.game_state());
    game.verify_next_tiles(vec![" . . . @"]);
    assert_eq!(AllEnemiesDied, game.game.game_state());
}

Bit of an eyeful! But basically it sets up a tiny game with two enemies. The player will hit both enemies with a force powerful enough to kill them with one hit. After two ticks, then, all enemies are gone and the game state should be AllEnemiesDead.

To make this pass, the storied Game::tick gets another addition:

pub fn tick<T: Input>(&mut self, input: &T) {
    // After combat:
    if self.dungeon_ref.borrow().get_player_hp() <= 0 {
        self.game_state = PlayerDied;
    }
    if self.dungeon_ref.borrow_mut().get_num_enemies() == 0 {
        self.game_state = AllEnemiesDied;
    }
}

And that, well, is it. The main loop gets an additional if statement. It’s starting to look a little ripe:

while !input.quit_game() && game.game_state() == Running {
    // run the game
}
if game.game_state() == PlayerDied {
    println!("\nYou died. Thanks for playing!");
}
if game.game_state() == AllEnemiesDead {
    println!("\nYou win. Thanks for playing!");
}

And when I say ripe, I mean it starts to stink, with more and more untested branches. Maybe we ought to look into that.

That’s nice, but …

This is is very silly of course. In real life, the player can never win, because there are too many enemies. We can’t even configure the player OR enemy hitpoints, without changing the code. And where is the challenge for the player? Where is the game? Oh my! I screwed up! Or did I? Tune in next time!