Various pre-release cosmetic fixes
This commit is contained in:
@@ -8,4 +8,7 @@ Third level is dictionaries. Each direction now leads to a feature with its own
|
||||
depth: 10
|
||||
straight:
|
||||
type: wall
|
||||
depth:
|
||||
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).
|
||||
148
src/levels/l03_dict.rs
Normal file
148
src/levels/l03_dict.rs
Normal file
@@ -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<DescEntry>,
|
||||
}
|
||||
|
||||
#[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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Box<dyn Level>> {
|
||||
vec![
|
||||
Box::new(l01_minimum::Minimum),
|
||||
Box::new(l02_kv::KeyValue),
|
||||
Box::new(l03_dict::Dict),
|
||||
]
|
||||
}
|
||||
|
||||
18
src/lib.rs
18
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);
|
||||
}
|
||||
}
|
||||
|
||||
232
src/tui.rs
232
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<dyn Level>]) -> (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<Typewriter> {
|
||||
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<String> = 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<String> {
|
||||
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<String> = 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<String> = 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<String> {
|
||||
@@ -808,8 +895,6 @@ fn truncate_entry_to_chars(entry: &LogEntry, max_chars: usize) -> Vec<String> {
|
||||
fn entry_lines(entry: &LogEntry) -> Vec<String> {
|
||||
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> {
|
||||
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<String> = 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<Line> = 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()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user