From 4b3b1ce5a0974b542c0b11f48a4bb70832a80efc Mon Sep 17 00:00:00 2001 From: Simonas Kareiva Date: Thu, 21 May 2026 18:52:19 +0300 Subject: [PATCH] Add typewriter effect --- game.ini | 10 ++++ src/config.rs | 130 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/tui.rs | 155 +++++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 274 insertions(+), 22 deletions(-) create mode 100644 game.ini create mode 100644 src/config.rs diff --git a/game.ini b/game.ini new file mode 100644 index 0000000..3be32fe --- /dev/null +++ b/game.ini @@ -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 diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e2c1433 --- /dev/null +++ b/src/config.rs @@ -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::() { + cfg.typewriter_enabled = b; + } + } + "typewriter_speed" | "typewriter_speed_cpm" => { + if let Ok(n) = value.parse::() { + 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); + } +} diff --git a/src/lib.rs b/src/lib.rs index 56e560e..10adc92 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod describe; pub mod levels; pub mod progress; diff --git a/src/tui.rs b/src/tui.rs index 9c3832a..b2cb439 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -9,7 +9,9 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::{Frame, Terminal}; use std::io::{stdout, Stdout}; +use std::time::{Duration, Instant}; +use crate::config; use crate::levels::{self, Difficulty, Level}; use crate::progress::{self, Progress}; 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). struct Editor { buffer: Vec, @@ -279,35 +320,82 @@ fn main_loop( prog: &mut Progress, ) -> Result<()> { let registry = levels::registry(); + let cfg = config::load(); let (mut screen, mut log) = initial_state(prog, ®istry); let mut focus = default_focus(&screen); 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 { 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( - &mut screen, - &mut focus, - &mut editor, - &mut log, - key, - prog, - ®istry, - )? { - return Ok(()); + let poll_timeout = typewriter + .as_ref() + .filter(|tw| !tw.is_done()) + .map(|tw| tw.time_to_next_tick()) + .unwrap_or_else(|| Duration::from_secs(3600)); + + if event::poll(poll_timeout)? { + let Event::Key(key) = event::read()? else { + continue; + }; + if !matches!(key.kind, KeyEventKind::Press) { + continue; + } + if step( + &mut screen, + &mut focus, + &mut editor, + &mut log, + key, + prog, + ®istry, + )? { + 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 { + 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. fn step( screen: &mut Screen, @@ -515,6 +603,7 @@ fn render( focus: Focus, editor: &Editor, log: &HistoryLog, + typewriter: Option<&Typewriter>, ) { let area = frame.size(); let chunks = Layout::default() @@ -524,11 +613,17 @@ fn render( let (left, right) = (chunks[0], chunks[1]); match screen { - Screen::Welcome => render_log(frame, left, log, Some(&welcome_prompt_lines())), - Screen::TierSelect { cursor } => { - render_log(frame, left, log, Some(&tier_picker_lines(*cursor))) + Screen::Welcome => { + render_log(frame, left, log, Some(&welcome_prompt_lines()), typewriter) } - 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), } @@ -579,6 +674,7 @@ fn render_log( area: Rect, log: &HistoryLog, trailing_prompt: Option<&[String]>, + typewriter: Option<&Typewriter>, ) { // Flatten log entries into a single line stream, blank line between each. let mut all_lines: Vec = Vec::new(); @@ -586,7 +682,13 @@ fn render_log( if i > 0 { 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. // 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); } +fn truncate_entry_to_chars(entry: &LogEntry, max_chars: usize) -> Vec { + 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 { match entry { LogEntry::Intro => vec![