Add typewriter effect
This commit is contained in:
10
game.ini
Normal file
10
game.ini
Normal 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
130
src/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod describe;
|
||||
pub mod levels;
|
||||
pub mod progress;
|
||||
|
||||
125
src/tui.rs
125
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<String>,
|
||||
@@ -279,21 +320,37 @@ 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 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,
|
||||
@@ -305,7 +362,38 @@ fn main_loop(
|
||||
)? {
|
||||
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.
|
||||
@@ -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<String> = 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<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> {
|
||||
match entry {
|
||||
LogEntry::Intro => vec![
|
||||
|
||||
Reference in New Issue
Block a user