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 describe;
|
||||||
pub mod levels;
|
pub mod levels;
|
||||||
pub mod progress;
|
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::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,21 +320,37 @@ 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, ®istry);
|
let (mut screen, mut log) = initial_state(prog, ®istry);
|
||||||
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 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 {
|
let Event::Key(key) = event::read()? else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !matches!(key.kind, KeyEventKind::Press) {
|
if !matches!(key.kind, KeyEventKind::Press) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if step(
|
if step(
|
||||||
&mut screen,
|
&mut screen,
|
||||||
&mut focus,
|
&mut focus,
|
||||||
@@ -305,8 +362,39 @@ fn main_loop(
|
|||||||
)? {
|
)? {
|
||||||
return Ok(());
|
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(
|
||||||
@@ -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![
|
||||||
|
|||||||
Reference in New Issue
Block a user