Rust ASCII Terminal "Buddy" Prototype

This project is a virtual pet that runs in the terminal, built using entirely ASCII characters. The character was inspired by Japanese Emoticons (Kaomoji.)

This was an educational exercise to make me more familiar with some Rust libraries and more familiar with Rust coding in general. It also gave me the chance to work with threads, a state machine, and make custom modules for things like the character, its animations, and "emotions".

The prototype is built for the terminal. Theoretically it could also run on an embedded device with an LCD or OLED display.

In this style the character, UI, and environment are all ASCII text characters. A frame looks like:

  ╔════════════════ Buddy ═════════════════╗
  ╠ p 6                              empty ╣
  ╠ ♥ 5                              200 ¤ ╣
  ║                                        ║
  ║                                        ║
  ║                                        ║
  ║                                        ║
  ║                 |\_|\                  ║
  ║                / •.• \                 ║
  ║░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░║
  ║▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒║
  ║▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓║
  ╚════════════════════════════════════════╝
   q: Exit   f: Feed                        

Some Technical Considerations

The library crossterm does the work of updating the terminal's size, title, mode, etc. It also handles moving the cursor around to draw the UI and character.

In the standard library, sync::mpsc is used to make a message queue between the timers/input and the game loop. And the standard library's threads is used to run the three main threads.

There is a main game loop thread that keeps the game running as we .join() on that thread. Inside of there we poll the mpsc::channel queues for messages and act on them. The messages sent into the main thread indicate that either an input occurred, or it's time to change state.

A second thread is used as a timer that triggers switching to the next behavioral state. And a third thread polls events for user commands and adds a message to the queue.

Behaviors

There is a graph of behavioral transitions defined in character/emotion/state.rs.

A thread runs that triggers sending a state_change signal that the game loop sees. When this signal is received, the game calls state_changed() on the pet which changes the pet's emotion to the result of calling next_emotion() on the current emotion.

The next emotion is based on the current one, and is based on random probability:

pub fn next_from_creative() -> Emotions {
    match rand_range(0, 100) {
        0..=33 => Emotions::Happy,
        34..=60 => Emotions::Playful,
        61..=75 => Emotions::Creative,
        _ => Emotions::Curious
    }
}

These transitions could be modified to consider the pet's current vitals and change the probability distribution according to the state.

Animations

The character has animated actions for each of the possible behaviors. Animations are a struct that has some metadata and a vector of frame structs.

pub struct Frame(pub String, pub u64);

impl Frame {
    fn new(text: String, duration: u64) -> Frame {
        Frame(text, duration)
    }
}

pub struct Animation {
    pub frames: Vec<Frame>,
    pub current: usize,
    pub duration: u64,
    pub loops: u64,
}

impl Animation {
    fn new(frames: &[Frame], loops: u64) -> Animation {
        Animation {
            frames: frames.to_vec(),
            current: 0,
            duration: frames.iter().fold(0, |accum, frame| accum + frame.1 ) * loops,
            loops
        }
    }
    pub fn frame(&self) -> &Frame {
        let frame = &self.frames[self.current];
        frame
    }
    pub fn next(&mut self){
        self.current += 1;
        if self.current >= self.frames.len() {
            self.current = 0;
        }
    }
}

A particular animation can then be defined like this:

fn tired(face: &str) -> Animation {
    Animation::new(&[
        Frame::new(format!("( {} )", face),  rand_range(1000,5000)),
        Frame::new(format!("✧( {} ) ", face), 350),
        Frame::new(format!("⁘( {} ) ", face), 350),
        Frame::new(format!("⁛( {} ) ", face), 350),
    ], rand_range(3,6))
}

In a situation like the one above, the face can be changed on the fly to change what eyes/mouth are inside the character for the animation.

In some other animations, that is ignored and a suitable face is hard-coded.

fn play(_face: &str) -> Animation {
    Animation::new(&[
        Frame::new(format!("     (  •_•)     "), rand_range(500,5000)),
        Frame::new(format!("     (  •_•)>⌐■-■"), 450),
        Frame::new(format!("     (  •_•)⌐■-■ "), 300),
        Frame::new(format!("     (  •⌐■)■    "), 300),
        Frame::new(format!("     ( ⌐■_■)     "), 3000),
        Frame::new(format!("     ( ⌐■‿■)     "), 1750),
    ], rand_range(1,2))
}

The Code

The code for this project is all available on Github.

Published: February 20, 2022

Categories: gamedev