15 - A first hint of UI

Aug 4, 2021 tdd rust srl | Prev | Next | Code

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

As a kind of intermission, I thought it would be fun to add a little bit of user interface, such as it is. Since the player has hitpoints now, why not display them?

Spying on the player health

Let’s imagine, for a moment, that the Renderer trait has a new function:

pub trait Renderer {
    fn clear(&mut self);
    fn render_tile(&mut self, x: u32, y: u32, object_type: ObjectType);
    fn append_combat_log(&mut self, text: &str);
    fn render_player_hp(&mut self, value: i32);
}

When called, we expect render_player_hp to stick the player hitpoints somewhere on the screen.

Here is a simple feature test:

#[test]
fn displays_initial_player_hp_in_status() {
    let mut game = TestableGame::from_strings(vec![". @ . ."]);
    game.render();
    game.renderer.assert_player_hp_rendered(100);
}

This generates a few follow up tasks:

Since RenderingSpy is not concerned about where or how the player health is rendered, the implementation is quite trivial:

impl RenderingSpy {    
    // ...
    pub fn assert_player_hp_rendered(&self, expected: i32) {
        assert_eq!(self.current_player_hp, expected)
    }
}

impl Renderer for RenderingSpy {
    // ...
    fn render_player_hp(&mut self, value: i32) {
        self.current_player_hp = value;
    }
}

So in essence we are just spying on a value that was set by the trait function - good enough!

Initialzing that current_player_hp member to 0 gives us a failing test, which is easily fixed:

impl Game {
    // ...
    pub fn render<T: Renderer>(&self, renderer: &mut T) {
        // ...
        renderer.render_player_hp(100);
    }
}

That’s cute and all, but of course it only ever renders 100, heck, the game itself won’t render anything, since the TerminalRender::render_player_hp implementation does nothing.

So let’s finish the job for the logic and then implement the real renderer.

To verify that the display keeps track of changes, how about this test:

#[test]
fn displays_modified_player_hp_in_status() {
    let mut game = TestableGame::from_strings(vec!["E @ . ."]);
    game.configure_combat(|combat_engine| {
        combat_engine.say_is_hit((0, 0), (1, 0));
        combat_engine.say_damage((0, 0), 25);
    });
    game.input.simulate_move(Left);
    game.tick();
    game.render();
    game.renderer.assert_player_hp_rendered(75);
}

Assuming 100 hitpoints on the player, we say that the enemy hits them for 25. We then assert that only 75 player hitpoints are “displayed”.

To fix the test, after implementing Dungeon::get_player_hp, we can do this:

impl Game {
    // ...
    pub fn render<T: Renderer>(&self, renderer: &mut T) {
        // ...
        renderer.render_player_hp(self.dungeon_ref.borrow().get_player_hp());
    }
}

I know I called it “logic” before - but now we call the render function with the actual player hitpoints, which are being tracked by Dungeon.

Displaying the player health for real

Finally, to make the player hitpoints appear somewhere on the screen, we need to implement TerminalRender::render_player_hp. Here is a unit test for for TerminalRenderer:

#[test]
fn render_player_hp_displays_player_hp_on_top() {
    let mut renderer = TerminalRenderer::new(10, 2);
    renderer.render_player_hp(123);
    verify_flush_writes(&mut renderer, vec![
        "HP: 123",
        "..........",
        ".........."]);
}

We decide to stick the player hitpoints on top with an informative prefix. Since TerminalRenderer::render_player_hp does nothing, the test will fail:


To make it pass, we patch TerminalRenderer::frame_as_string, which builds the buffer that is written to the terminal:

impl TerminalRenderer {
    fn frame_as_string(&self) -> (usize, String) {
        let tile_lines = self.backend.tiles_as_strings();
        let mut result = Vec::with_capacity(tile_lines.len() + 1);
        if self.current_player_hp != -1 {
            result.push(format!("HP: {}", self.current_player_hp));
        }
        // .. tiles and combat log is added here
        return (result.len(), result.join("\n"));
    }
    // ...
}

And we are done.

Turns out that TermnalRenderer::render_player_hp is exactly identical to the RenderingSpy implementation - it just stores the value in a member variable. Something to think about.

Also, that little if statement is a bit of a workaround. It keeps tests passing that don’t want to make any assumptions about player hitpoints. A bit clunky! But it will do for now.

Here is your celebratory gif: