4 - Terminal output

Jun 9, 2021 tdd rust srl | Prev | Next | Code

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

The last item on the list is running the game, such as it is, in the terminal:

- [X] Render tiles
- [X] Move player
- [ ] Run in terminal

Without further ado, here is the main function:

const GAME_WIDTH: usize = 40;
const GAME_HEIGHT: usize = 20;

fn main() -> std::io::Result<()> {
    let mut game = Game::new();
    game.add_enemies(&vec![(1, 1), (4, 2), (2, 3)]);
    game.add_walls(&vec![
        (1, 0), (2, 0), (3, 1), (1, 2),
        (0, GAME_HEIGHT as u32 - 1),
        (GAME_WIDTH as u32 - 1, GAME_HEIGHT as u32 - 1)]);
    game.set_player_position(3, 2);
    let mut renderer = TerminalRenderer::new(GAME_WIDTH, GAME_HEIGHT);
    let mut input = TerminalInput::new();

    let mut term = Term::buffered_stdout();
    while !input.quit_game() {
        game.render(&mut renderer);
        renderer.flush(&mut term);
        input.on_key(term.read_key()?);
        game.tick(&input);
        term.clear_last_lines(GAME_HEIGHT - 1)?;
    }
    term.clear_to_end_of_screen()?;
}

That almost looks like the first test, right? Instantiate the game, add stuff, grab a renderer and an input provider, tick and render, except this time do it in a loop. Granted, there are some minor differences here, mostly owing to peculiarities having to do with the game using the terminal as input and output.
The key pieces to the puzzle are the production implementations of the Renderer and Input traits. Let’s have a look.

Rendering to the terminal

Turns out that TerminalRenderer::render_at is an exact copy of RenderingSpy::render_at

fn render_at(&mut self, x: u32, y: u32, object_type: ObjectType) {
    let ch = match object_type {
        Wall => { '#' }
        Player => { '@' }
        Enemy => { 'E' }
    };

    self.frame[y as usize][x as usize] = ch;
}

Should we be concerned? Not overly. It is not surprising, since we are dealing with strings in both cases. We make a note clean up this duplication.

TerminalRenderer also has a flush function:

pub fn flush<T: Write>(&mut self, write: &mut T) {
    write.write(self.frame_as_string().as_bytes()).unwrap();
    write.flush().unwrap();
}

We write the whole frame as a string to a Write object, which is what Term::buffered_stdout() gives us. And since it is buffered, we call its own flush function as well. The buffering is not strictly necessary, but it prevents intermittent flickering of the output.

Reading input from the terminal

Let’s look at TerminalInput in its entirety:

use console::Key;

use crate::input::Input;

pub struct TerminalInput {
    pressed_key: Key
}

impl TerminalInput {
    pub fn new() -> Self {
        Self {pressed_key: Key::Unknown}
    }

    pub fn on_key(&mut self, key: Key) {
        self.pressed_key = key;
    }
}

impl Input for TerminalInput {
    fn move_left(&self) -> bool {
        self.pressed_key == Key::ArrowLeft
    }

    fn move_right(&self) -> bool {
        self.pressed_key == Key::ArrowRight
    }

    fn move_up(&self) -> bool {
        self.pressed_key == Key::ArrowUp
    }

    fn move_down(&self) -> bool {
        self.pressed_key == Key::ArrowDown
    }

    fn quit_game(&self) -> bool {
        self.pressed_key == Key::Escape
    }
}

Really nothing to write home about. That extra on_key function is used to pass in the key returned from the terminal library’s read_key function (which is blocking until a key is pressed).

Where do we go from here

Wait, not so fast, Wolfgang, I hear you say. Yes yes.

Hi, I am Wolfgang, and I wrote the main function without writing any tests.

True, a case can be made for taking that chunk of main code and writing a big end to end test for it. I am not against that, but end-to-end testing is not the theme of this project. Plus I expect the main loop to remain stable for a good while.

I also did not write tests for the trait implementations. I got too excited. What I usually do in such cases is to write tests when I need to touch that code again.

Anyway, in the coming episodes we will deal with collison, and we will start doing some random dungeon generation. Stay tuned? And in the meantime, look at the ugly duckling go: