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.