From f817c7b93ee2e94bad9adb4b96d3f87a79f36112 Mon Sep 17 00:00:00 2001 From: Simonas Kareiva Date: Thu, 21 May 2026 21:21:32 +0300 Subject: [PATCH] Various pre-release cosmetic fixes --- Cargo.lock | 1 + Cargo.toml | 1 + game.ini | 4 +- src/levels/l03.md | 5 +- src/levels/l03_dict.rs | 148 ++++++++++++++++++++++++++ src/levels/mod.rs | 2 + src/lib.rs | 18 +++- src/tui.rs | 232 +++++++++++++++++++++++++++++++++++------ 8 files changed, 373 insertions(+), 38 deletions(-) create mode 100644 src/levels/l03_dict.rs diff --git a/Cargo.lock b/Cargo.lock index 02e5f03..5ec0a47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,6 +1496,7 @@ dependencies = [ "serde_yaml", "similar", "tera", + "unicode-width", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a64753c..9f27451 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ dirs = "5" anyhow = "1" ratatui = "0.26" crossterm = "0.27" +unicode-width = "0.1" diff --git a/game.ini b/game.ini index 3be32fe..9c0d444 100644 --- a/game.ini +++ b/game.ini @@ -6,5 +6,5 @@ typewriter_effect = true # Typing speed in characters per minute. Higher = faster. -# Defaults: 1000 cpm (~60 ms per character). -typewriter_speed_cpm = 1000 +# Defaults: 2000 cpm (~30 ms per character). +typewriter_speed_cpm = 2000 diff --git a/src/levels/l03.md b/src/levels/l03.md index 3f55392..17e3d70 100644 --- a/src/levels/l03.md +++ b/src/levels/l03.md @@ -8,4 +8,7 @@ Third level is dictionaries. Each direction now leads to a feature with its own depth: 10 straight: type: wall - depth: \ No newline at end of file + depth: + +The rendered description ends with a hint reminding the player that each +feature's name goes under a `type:` key (the property keeps its own name). \ No newline at end of file diff --git a/src/levels/l03_dict.rs b/src/levels/l03_dict.rs new file mode 100644 index 0000000..866ea8c --- /dev/null +++ b/src/levels/l03_dict.rs @@ -0,0 +1,148 @@ +//! Level 3 β€” dictionaries. Each direction leads to a feature with its +//! own type + one characteristic property. +//! +//! Paired design note: `l03.md`. + +use rand::seq::SliceRandom; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; +use serde::Serialize; +use serde_yaml::{Mapping, Value}; + +use crate::describe::Describer; + +use super::{Generated, Level}; + +pub struct Dict; + +const DIRECTIONS: &[&str] = &["left", "right", "straight", "back", "up", "down"]; + +enum Feature { + Door, + Tunnel, + Pit, + Stairs, + Wall, + Altar, +} + +impl Feature { + fn name(&self) -> &'static str { + match self { + Feature::Door => "door", + Feature::Tunnel => "tunnel", + Feature::Pit => "pit", + Feature::Stairs => "stairs", + Feature::Wall => "wall", + Feature::Altar => "altar", + } + } + + fn random(rng: &mut ChaCha8Rng) -> Self { + match rng.gen_range(0..6) { + 0 => Feature::Door, + 1 => Feature::Tunnel, + 2 => Feature::Pit, + 3 => Feature::Stairs, + 4 => Feature::Wall, + _ => Feature::Altar, + } + } + + /// One characteristic property: (key, value). + fn property(&self, rng: &mut ChaCha8Rng) -> (&'static str, Value) { + match self { + Feature::Door => ("locked", Value::Bool(rng.gen_bool(0.5))), + Feature::Tunnel => ("depth", Value::from(rng.gen_range(5..=30i64))), + Feature::Pit => ("depth", Value::from(rng.gen_range(5..=30i64))), + Feature::Stairs => { + let going = if rng.gen_bool(0.5) { "up" } else { "down" }; + ("going", Value::String(going.to_string())) + } + Feature::Wall => ("cracked", Value::Bool(rng.gen_bool(0.5))), + Feature::Altar => ("blessed", Value::Bool(rng.gen_bool(0.5))), + } + } +} + +#[derive(Serialize)] +struct DescCtx { + entries: Vec, +} + +#[derive(Serialize)] +struct DescEntry { + direction: String, + feature: String, + prop_name: String, + prop_value: String, +} + +impl Level for Dict { + fn id(&self) -> u8 { + 3 + } + + fn name(&self) -> &'static str { + "Dictionaries" + } + + fn generate(&self, seed: u64) -> Generated { + // Per-level constant so the same `current_seed` produces different + // content per level. + let mut rng = ChaCha8Rng::seed_from_u64(seed ^ 0x0000_0000_0000_0003); + let n = rng.gen_range(2..=3); + let directions: Vec<&'static str> = + DIRECTIONS.choose_multiple(&mut rng, n).copied().collect(); + + let mut top = Mapping::new(); + let mut entries = Vec::with_capacity(directions.len()); + for d in &directions { + let feature = Feature::random(&mut rng); + let (prop_name, prop_value) = feature.property(&mut rng); + + let mut inner = Mapping::new(); + inner.insert( + Value::String("type".to_string()), + Value::String(feature.name().to_string()), + ); + inner.insert( + Value::String(prop_name.to_string()), + prop_value.clone(), + ); + top.insert(Value::String((*d).to_string()), Value::Mapping(inner)); + + let prop_value_str = match &prop_value { + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::String(s) => s.clone(), + _ => String::new(), + }; + entries.push(DescEntry { + direction: (*d).to_string(), + feature: feature.name().to_string(), + prop_name: prop_name.to_string(), + prop_value: prop_value_str, + }); + } + + let target_yaml = + serde_yaml::to_string(&Value::Mapping(top)).expect("serialise mapping"); + + let mut d = Describer::new(); + d.register( + "l03", + "{% for e in entries %}- {{ e.direction }} β†’ {{ e.feature }} ({{ e.prop_name }}: {{ e.prop_value }})\n{% endfor %}\nπŸ’‘ Each feature is a dictionary β€” give it a `type:` key plus its property.", + ) + .expect("register template"); + let description = d + .render("l03", &DescCtx { entries }) + .expect("render template"); + + Generated { + target_yaml, + description, + flavor: "🧭 You stand at a junction. Each path reveals its own detail.".to_string(), + } + } +} diff --git a/src/levels/mod.rs b/src/levels/mod.rs index 33973a3..f3c78cc 100644 --- a/src/levels/mod.rs +++ b/src/levels/mod.rs @@ -12,6 +12,7 @@ pub mod l01_minimum; pub mod l02_kv; +pub mod l03_dict; use serde::{Deserialize, Serialize}; @@ -81,5 +82,6 @@ pub fn registry() -> Vec> { vec![ Box::new(l01_minimum::Minimum), Box::new(l02_kv::KeyValue), + Box::new(l03_dict::Dict), ] } diff --git a/src/lib.rs b/src/lib.rs index b89f53b..60cd2d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,7 +84,7 @@ mod smoke { #[test] fn levels_generate_canonical_yaml() { let registry = levels::registry(); - assert_eq!(registry.len(), 2); + assert_eq!(registry.len(), 3); // Level 1: any null-equivalent passes via the semantic short-circuit. let g1 = registry[0].generate(0); @@ -110,5 +110,21 @@ mod smoke { g2.target_yaml, g2_again.target_yaml, "same seed should produce the same target" ); + + // Level 3: deterministic per seed; produces a mapping of mappings, + // each inner mapping has a `type` key. + let g3 = registry[2].generate(123); + let v3: serde_yaml::Value = serde_yaml::from_str(&g3.target_yaml).unwrap(); + let m3 = v3.as_mapping().expect("level 3 produces a mapping"); + assert!(!m3.is_empty()); + for (_dir, feature) in m3 { + let inner = feature.as_mapping().expect("level 3 inner is a mapping"); + assert!( + inner.get(serde_yaml::Value::String("type".into())).is_some(), + "each direction must carry a `type` key" + ); + } + let g3_again = registry[2].generate(123); + assert_eq!(g3.target_yaml, g3_again.target_yaml); } } diff --git a/src/tui.rs b/src/tui.rs index 31bafc5..44f3ed8 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -7,8 +7,10 @@ use crossterm::terminal::{ use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use ratatui::{Frame, Terminal}; +use unicode_width::UnicodeWidthChar; use std::io::{stdout, Stdout}; use std::time::{Duration, Instant}; @@ -34,8 +36,11 @@ enum Focus { } enum LogEntry { - /// Game rules + the full controls listing. Seeded at startup. + /// Game rules + the controls hint. Seeded at startup; appears instantly. Intro, + /// "🏰 You stand at the entrance…" Lives as a separate entry so only + /// the greeting (not the whole intro) gets the typewriter effect. + Greeting, LevelPrompt { level_id: u8, level_name: String, @@ -257,10 +262,18 @@ fn leave() -> Result<()> { fn initial_state(prog: &Progress, registry: &[Box]) -> (Screen, HistoryLog) { let mut log = HistoryLog::new(); - // The Intro entry is always at the bottom of the log on startup so - // Welcome can show it inline; the player can scroll back to it any - // time during play. - log.push(LogEntry::Intro); + + // The welcome content (Intro rules + Greeting) is seeded only for a + // fresh game β€” `current_level == 0` means never started or just reset. + // A resumed run drops straight into its current level prompt; the + // Greeting is re-seeded on reset (see the ResetConfirm handler). + if prog.current_level == 0 { + // Intro (rules + Ctrl-H hint) appears instantly. Greeting follows as + // the dramatic last line β€” it's the entry that gets the typewriter + // on a fresh start. + log.push(LogEntry::Intro); + log.push(LogEntry::Greeting); + } match prog.current_level { 0 => (Screen::Welcome, log), @@ -373,9 +386,12 @@ fn main_loop( )? { return Ok(()); } - if cfg.typewriter_enabled && log.entries.len() > prev_log_len { + 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 { + // Growth: only animate genuinely new entries. + // Shrink (reset wiped the log): always animate the new + // bottom entry (Greeting). + if log.entries.len() < prev_log_len || tw.entry_idx >= prev_log_len { typewriter = Some(tw); } } @@ -390,11 +406,11 @@ fn main_loop( } } -/// Build a `Typewriter` for the most recent `LevelPrompt` entry in the log, -/// if any. +/// Build a `Typewriter` for the most recent animated entry in the log +/// (either a `LevelPrompt` or the startup/post-reset `Greeting`). 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 { .. }) { + if matches!(e, LogEntry::LevelPrompt { .. } | LogEntry::Greeting) { Some(i) } else { None @@ -518,6 +534,7 @@ fn step( editor.clear(); *log = HistoryLog::new(); log.push(LogEntry::Intro); + log.push(LogEntry::Greeting); *screen = Screen::Welcome; *focus = default_focus(screen); } @@ -773,14 +790,25 @@ fn render_log( all_lines.extend_from_slice(prompt); } - // Pick a visible slice anchored to the bottom, offset by `log.scroll`. + // Wrap every logical line to the column's inner width *before* the + // bottom-anchor math. Otherwise ratatui's `Wrap` reflows long lines into + // extra visual rows the slice/pad math never accounts for, pushing the + // newest entries off the bottom edge. Wrapping here keeps the math in the + // visual-row units the terminal actually draws. + let inner_w = (area.width as usize).saturating_sub(2); // borders let visible_h = (area.height as usize).saturating_sub(2); // borders - let total = all_lines.len(); + let mut rows: Vec = Vec::new(); + for line in &all_lines { + rows.extend(wrap_line(line, inner_w)); + } + + // Pick a visible slice anchored to the bottom, offset by `log.scroll`. + let total = rows.len(); let max_scroll = total.saturating_sub(visible_h); let scroll = log.scroll.min(max_scroll); let end = total.saturating_sub(scroll); let start = end.saturating_sub(visible_h); - let slice = &all_lines[start..end]; + let slice = &rows[start..end]; // Pad the top so the slice sits flush with the bottom of the column. let pad = visible_h.saturating_sub(slice.len()); @@ -793,7 +821,66 @@ fn render_log( } else { " πŸ“œ Labyrinth log ".to_string() }; - frame.render_widget(screen_widget(title, body), area); + // Rows are pre-wrapped to `inner_w`, so render *without* `Wrap`: our + // wrapping is the single source of truth and can't drift from ratatui's. + let block = Block::default().borders(Borders::ALL).title(title); + frame.render_widget(Paragraph::new(body).block(block), area); +} + +/// Word-wrap one logical line to `width` display columns, returning the visual +/// rows it occupies β€” always at least one, even for an empty line. Display +/// width is measured with `unicode-width` so wide glyphs (emoji) count as the +/// two cells they draw as. Leading indentation is kept; a word longer than the +/// whole column is hard-split. +fn wrap_line(line: &str, width: usize) -> Vec { + if width == 0 || line.is_empty() { + return vec![String::new()]; + } + let char_w = |c: char| UnicodeWidthChar::width(c).unwrap_or(0); + + // Tokenize into alternating runs of spaces and non-spaces so breaks land + // between words while (most) whitespace is preserved. + let mut tokens: Vec = Vec::new(); + for c in line.chars() { + let is_space = c == ' '; + match tokens.last_mut() { + Some(tok) if tok.starts_with(' ') == is_space => tok.push(c), + _ => tokens.push(c.to_string()), + } + } + + let mut rows: Vec = Vec::new(); + let mut row = String::new(); + let mut row_w = 0usize; + for token in tokens { + let token_w: usize = token.chars().map(char_w).sum(); + if row_w + token_w <= width { + row.push_str(&token); + row_w += token_w; + } else if token.starts_with(' ') { + // Whitespace that would overflow: swallow it at the break. + rows.push(std::mem::take(&mut row)); + row_w = 0; + } else if token_w <= width { + // Word fits on a row of its own β€” start a fresh row with it. + rows.push(std::mem::take(&mut row)); + row = token; + row_w = token_w; + } else { + // Word wider than the whole column: hard-split it across rows. + for c in token.chars() { + let w = char_w(c); + if row_w + w > width { + rows.push(std::mem::take(&mut row)); + row_w = 0; + } + row.push(c); + row_w += w; + } + } + } + rows.push(row); + rows } fn truncate_entry_to_chars(entry: &LogEntry, max_chars: usize) -> Vec { @@ -808,8 +895,6 @@ fn truncate_entry_to_chars(entry: &LogEntry, max_chars: usize) -> Vec { fn entry_lines(entry: &LogEntry) -> Vec { match entry { LogEntry::Intro => vec![ - "🏰 You stand at the entrance, brave explorer.".to_string(), - String::new(), "πŸŒ€ YAMLabyrinth β€” learn YAML by writing your way through.".to_string(), String::new(), " Each chamber reveals a target. Match it with YAML in the editor".to_string(), @@ -820,6 +905,9 @@ fn entry_lines(entry: &LogEntry) -> Vec { String::new(), " Press [Ctrl-H] any time for the full controls.".to_string(), ], + LogEntry::Greeting => { + vec!["🏰 You stand at the entrance, brave explorer. Press [Enter] to ...well, enter the dungeon.".to_string()] + } LogEntry::LevelPrompt { level_id, level_name, @@ -878,26 +966,62 @@ fn render_editor(frame: &mut Frame, area: Rect, editor: &Editor, focused: bool) } else { " πŸ“ Your YAML ".to_string() }; - let body = if focused { + let block = Block::default().borders(Borders::ALL).title(title); + + if focused { + // Highlight the character under the cursor with explicit bg + fg + // colours (not `REVERSED`) so the cursor cell is filled even when + // the underlying character is whitespace β€” some terminals skip + // background fill on reverse-video spaces. let (cr, cc) = editor.cursor; - let mut lines: Vec = Vec::with_capacity(editor.buffer.len()); - for (r, line) in editor.buffer.iter().enumerate() { - if r == cr { - let col = cc.min(line.len()); - let mut with_cursor = String::with_capacity(line.len() + 1); - with_cursor.push_str(&line[..col]); - with_cursor.push('_'); - with_cursor.push_str(&line[col..]); - lines.push(with_cursor); - } else { - lines.push(line.clone()); - } - } - lines.join("\n") + let cursor_style = Style::default().bg(Color::White).fg(Color::Black); + + let lines: Vec = editor + .buffer + .iter() + .enumerate() + .map(|(r, line)| { + if r == cr { + editor_cursor_line(line, cc, cursor_style) + } else { + Line::from(line.clone()) + } + }) + .collect(); + + let p = Paragraph::new(lines).block(block).wrap(Wrap { trim: false }); + frame.render_widget(p, area); } else { - editor.text() - }; - frame.render_widget(screen_widget(title, body), area); + let p = Paragraph::new(editor.text()) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(p, area); + } +} + +/// Build the cursor-bearing line as three spans: text before, the +/// highlighted character at the cursor, text after. If the cursor sits +/// past the end of the line, the highlight falls on a trailing space. +fn editor_cursor_line(line: &str, col: usize, cursor_style: Style) -> Line<'static> { + let col = col.min(line.len()); + let before = line[..col].to_string(); + + if col < line.len() { + let after = &line[col..]; + let mut chars = after.char_indices(); + let (_, c) = chars.next().expect("non-empty: col < line.len()"); + let next_boundary = chars.next().map(|(i, _)| i).unwrap_or(after.len()); + Line::from(vec![ + Span::raw(before), + Span::styled(c.to_string(), cursor_style), + Span::raw(after[next_boundary..].to_string()), + ]) + } else { + Line::from(vec![ + Span::raw(before), + Span::styled(" ".to_string(), cursor_style), + ]) + } } fn render_editor_inactive(frame: &mut Frame, area: Rect) { @@ -907,3 +1031,43 @@ fn render_editor_inactive(frame: &mut Frame, area: Rect) { area, ); } + +#[cfg(test)] +mod tests { + use super::wrap_line; + + #[test] + fn empty_line_still_occupies_one_row() { + assert_eq!(wrap_line("", 10), vec![String::new()]); + } + + #[test] + fn short_line_is_left_intact() { + assert_eq!(wrap_line("hello", 10), vec!["hello".to_string()]); + } + + #[test] + fn breaks_between_words() { + assert_eq!( + wrap_line("alpha beta gamma", 10), + vec!["alpha beta".to_string(), "gamma".to_string()], + ); + } + + #[test] + fn hard_splits_a_word_longer_than_the_column() { + assert_eq!( + wrap_line("abcdefghij", 4), + vec!["abcd".to_string(), "efgh".to_string(), "ij".to_string()], + ); + } + + #[test] + fn wide_emoji_counts_as_two_cells() { + // "ab " is 3 cells, "πŸ₯‡" adds 2 β†’ fills width 5 exactly; " cd" wraps. + assert_eq!( + wrap_line("ab πŸ₯‡ cd", 5), + vec!["ab πŸ₯‡".to_string(), "cd".to_string()], + ); + } +}