Add typewriter effect

This commit is contained in:
2026-05-21 18:52:19 +03:00
parent 740685afc5
commit 4b3b1ce5a0
4 changed files with 274 additions and 22 deletions

10
game.ini Normal file
View File

@@ -0,0 +1,10 @@
# YAMLabyrinth — game configuration.
# Delete this file to use defaults. Keys are case-insensitive; spaces
# and underscores in keys are equivalent.
# Animate level prompts character-by-character in the log.
typewriter_effect = true
# Typing speed in characters per minute. Higher = faster.
# Defaults: 1000 cpm (~60 ms per character).
typewriter_speed_cpm = 1000

130
src/config.rs Normal file
View File

@@ -0,0 +1,130 @@
//! Game configuration, loaded from `./game.ini`.
//!
//! Two settings today:
//! - `typewriter_effect` (bool, default `true`) — animate level prompts
//! character-by-character as they appear in the log.
//! - `typewriter_speed_cpm` (u32, default `1000`) — typing speed in
//! characters per minute (~60 ms per character).
//!
//! Missing keys fall back to defaults. Missing file falls back to all
//! defaults. Comments (`#` or `;`) and `[sections]` are ignored.
use std::path::PathBuf;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GameConfig {
pub typewriter_enabled: bool,
pub typewriter_speed_cpm: u32,
}
impl Default for GameConfig {
fn default() -> Self {
Self {
typewriter_enabled: true,
typewriter_speed_cpm: 1000,
}
}
}
impl GameConfig {
/// How long each character takes to appear during the typewriter
/// animation. Falls back to the default speed if `speed_cpm` is 0.
pub fn char_duration(&self) -> Duration {
let cpm = if self.typewriter_speed_cpm == 0 {
Self::default().typewriter_speed_cpm
} else {
self.typewriter_speed_cpm
};
Duration::from_millis(60_000 / cpm as u64)
}
}
pub fn config_path() -> PathBuf {
PathBuf::from("game.ini")
}
pub fn load() -> GameConfig {
match std::fs::read_to_string(config_path()) {
Ok(content) => parse(&content),
Err(_) => GameConfig::default(),
}
}
pub fn parse(content: &str) -> GameConfig {
let mut cfg = GameConfig::default();
for line in content.lines() {
let line = line.trim();
if line.is_empty()
|| line.starts_with('#')
|| line.starts_with(';')
|| line.starts_with('[')
{
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim().to_lowercase().replace(' ', "_");
let value = value.trim();
match key.as_str() {
"typewriter_effect" | "typewriter_enabled" => {
if let Ok(b) = value.parse::<bool>() {
cfg.typewriter_enabled = b;
}
}
"typewriter_speed" | "typewriter_speed_cpm" => {
if let Ok(n) = value.parse::<u32>() {
cfg.typewriter_speed_cpm = n;
}
}
_ => {}
}
}
cfg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults() {
let cfg = GameConfig::default();
assert!(cfg.typewriter_enabled);
assert_eq!(cfg.typewriter_speed_cpm, 1000);
// 1000 cpm = 60 ms per char
assert_eq!(cfg.char_duration().as_millis(), 60);
}
#[test]
fn parser_reads_both_underscored_and_spaced_keys() {
let ini = "\
# comment\n\
; another comment\n\
[section]\n\
typewriter effect = false\n\
typewriter_speed_cpm = 240\n\
";
let cfg = parse(ini);
assert!(!cfg.typewriter_enabled);
assert_eq!(cfg.typewriter_speed_cpm, 240);
// 240 cpm = 250 ms per char
assert_eq!(cfg.char_duration().as_millis(), 250);
}
#[test]
fn parser_keeps_defaults_on_garbage() {
let cfg = parse("typewriter_effect = maybe\ntypewriter_speed_cpm = fast\n");
assert_eq!(cfg, GameConfig::default());
}
#[test]
fn zero_speed_falls_back_to_default_duration() {
let cfg = GameConfig {
typewriter_enabled: true,
typewriter_speed_cpm: 0,
};
assert_eq!(cfg.char_duration().as_millis(), 60);
}
}

View File

@@ -1,3 +1,4 @@
pub mod config;
pub mod describe; pub mod describe;
pub mod levels; pub mod levels;
pub mod progress; pub mod progress;

View File

@@ -9,7 +9,9 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::{Frame, Terminal}; use ratatui::{Frame, Terminal};
use std::io::{stdout, Stdout}; use std::io::{stdout, Stdout};
use std::time::{Duration, Instant};
use crate::config;
use crate::levels::{self, Difficulty, Level}; use crate::levels::{self, Difficulty, Level};
use crate::progress::{self, Progress}; use crate::progress::{self, Progress};
use crate::similarity; use crate::similarity;
@@ -93,6 +95,45 @@ impl HistoryLog {
} }
} }
/// Drives the character-by-character reveal of the latest `LevelPrompt`.
/// `entry_idx` is the index into `HistoryLog::entries` being animated.
struct Typewriter {
entry_idx: usize,
chars_shown: usize,
total_chars: usize,
char_duration: Duration,
next_tick_at: Instant,
}
impl Typewriter {
fn new(entry_idx: usize, total_chars: usize, char_duration: Duration) -> Self {
Self {
entry_idx,
chars_shown: 0,
total_chars,
char_duration,
next_tick_at: Instant::now() + char_duration,
}
}
fn is_done(&self) -> bool {
self.chars_shown >= self.total_chars
}
fn tick(&mut self) {
let now = Instant::now();
while self.next_tick_at <= now && self.chars_shown < self.total_chars {
self.chars_shown += 1;
self.next_tick_at += self.char_duration;
}
}
fn time_to_next_tick(&self) -> Duration {
let now = Instant::now();
self.next_tick_at.saturating_duration_since(now)
}
}
/// Multi-line YAML editor. Byte-indexed; ASCII-only (see risk R6). /// Multi-line YAML editor. Byte-indexed; ASCII-only (see risk R6).
struct Editor { struct Editor {
buffer: Vec<String>, buffer: Vec<String>,
@@ -279,35 +320,82 @@ fn main_loop(
prog: &mut Progress, prog: &mut Progress,
) -> Result<()> { ) -> Result<()> {
let registry = levels::registry(); let registry = levels::registry();
let cfg = config::load();
let (mut screen, mut log) = initial_state(prog, &registry); let (mut screen, mut log) = initial_state(prog, &registry);
let mut focus = default_focus(&screen); let mut focus = default_focus(&screen);
let mut editor = Editor::new(); let mut editor = Editor::new();
// If resume put a LevelPrompt at the bottom, typewriter it from scratch.
let mut typewriter = if cfg.typewriter_enabled {
start_typewriter(&log, &cfg)
} else {
None
};
let mut prev_log_len = log.entries.len();
loop { loop {
terminal.draw(|frame| { terminal.draw(|frame| {
render(frame, &screen, focus, &editor, &log); render(frame, &screen, focus, &editor, &log, typewriter.as_ref());
})?; })?;
let Event::Key(key) = event::read()? else {
continue;
};
if !matches!(key.kind, KeyEventKind::Press) {
continue;
}
if step( let poll_timeout = typewriter
&mut screen, .as_ref()
&mut focus, .filter(|tw| !tw.is_done())
&mut editor, .map(|tw| tw.time_to_next_tick())
&mut log, .unwrap_or_else(|| Duration::from_secs(3600));
key,
prog, if event::poll(poll_timeout)? {
&registry, let Event::Key(key) = event::read()? else {
)? { continue;
return Ok(()); };
if !matches!(key.kind, KeyEventKind::Press) {
continue;
}
if step(
&mut screen,
&mut focus,
&mut editor,
&mut log,
key,
prog,
&registry,
)? {
return Ok(());
}
if cfg.typewriter_enabled && log.entries.len() > prev_log_len {
if let Some(tw) = start_typewriter(&log, &cfg) {
if tw.entry_idx >= prev_log_len {
typewriter = Some(tw);
}
}
}
prev_log_len = log.entries.len();
} else if let Some(tw) = typewriter.as_mut() {
tw.tick();
if tw.is_done() {
typewriter = None;
}
} }
} }
} }
/// Build a `Typewriter` for the most recent `LevelPrompt` entry in the log,
/// if any.
fn start_typewriter(log: &HistoryLog, cfg: &config::GameConfig) -> Option<Typewriter> {
let idx = log.entries.iter().enumerate().rev().find_map(|(i, e)| {
if matches!(e, LogEntry::LevelPrompt { .. }) {
Some(i)
} else {
None
}
})?;
let total = entry_lines(&log.entries[idx])
.join("\n")
.chars()
.count();
Some(Typewriter::new(idx, total, cfg.char_duration()))
}
/// Returns `true` to quit the loop. /// Returns `true` to quit the loop.
fn step( fn step(
screen: &mut Screen, screen: &mut Screen,
@@ -515,6 +603,7 @@ fn render(
focus: Focus, focus: Focus,
editor: &Editor, editor: &Editor,
log: &HistoryLog, log: &HistoryLog,
typewriter: Option<&Typewriter>,
) { ) {
let area = frame.size(); let area = frame.size();
let chunks = Layout::default() let chunks = Layout::default()
@@ -524,11 +613,17 @@ fn render(
let (left, right) = (chunks[0], chunks[1]); let (left, right) = (chunks[0], chunks[1]);
match screen { match screen {
Screen::Welcome => render_log(frame, left, log, Some(&welcome_prompt_lines())), Screen::Welcome => {
Screen::TierSelect { cursor } => { render_log(frame, left, log, Some(&welcome_prompt_lines()), typewriter)
render_log(frame, left, log, Some(&tier_picker_lines(*cursor)))
} }
Screen::Game | Screen::Completed => render_log(frame, left, log, None), Screen::TierSelect { cursor } => render_log(
frame,
left,
log,
Some(&tier_picker_lines(*cursor)),
typewriter,
),
Screen::Game | Screen::Completed => render_log(frame, left, log, None, typewriter),
Screen::ResetConfirm => render_reset_confirm(frame, left), Screen::ResetConfirm => render_reset_confirm(frame, left),
} }
@@ -579,6 +674,7 @@ fn render_log(
area: Rect, area: Rect,
log: &HistoryLog, log: &HistoryLog,
trailing_prompt: Option<&[String]>, trailing_prompt: Option<&[String]>,
typewriter: Option<&Typewriter>,
) { ) {
// Flatten log entries into a single line stream, blank line between each. // Flatten log entries into a single line stream, blank line between each.
let mut all_lines: Vec<String> = Vec::new(); let mut all_lines: Vec<String> = Vec::new();
@@ -586,7 +682,13 @@ fn render_log(
if i > 0 { if i > 0 {
all_lines.push(String::new()); all_lines.push(String::new());
} }
all_lines.extend(entry_lines(entry)); let entry_view = match typewriter {
Some(tw) if tw.entry_idx == i && !tw.is_done() => {
truncate_entry_to_chars(entry, tw.chars_shown)
}
_ => entry_lines(entry),
};
all_lines.extend(entry_view);
} }
// Append the transient prompt (Welcome / TierSelect) as the bottom block. // Append the transient prompt (Welcome / TierSelect) as the bottom block.
// It's part of the bottom-anchored view but not part of the log itself. // It's part of the bottom-anchored view but not part of the log itself.
@@ -620,6 +722,15 @@ fn render_log(
frame.render_widget(screen_widget(title, body), area); frame.render_widget(screen_widget(title, body), area);
} }
fn truncate_entry_to_chars(entry: &LogEntry, max_chars: usize) -> Vec<String> {
if max_chars == 0 {
return Vec::new();
}
let full = entry_lines(entry).join("\n");
let truncated: String = full.chars().take(max_chars).collect();
truncated.split('\n').map(str::to_string).collect()
}
fn entry_lines(entry: &LogEntry) -> Vec<String> { fn entry_lines(entry: &LogEntry) -> Vec<String> {
match entry { match entry {
LogEntry::Intro => vec![ LogEntry::Intro => vec![