First levels v0.1.0
This commit is contained in:
352
src/tui.rs
352
src/tui.rs
@@ -5,14 +5,40 @@ use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::{Frame, Terminal};
|
||||
use std::io::{stdout, Stdout};
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
use crate::levels::{self, Difficulty, Level};
|
||||
use crate::progress::{self, Progress};
|
||||
use crate::similarity;
|
||||
|
||||
enum Screen {
|
||||
Welcome,
|
||||
TierSelect {
|
||||
cursor: u8,
|
||||
},
|
||||
Level,
|
||||
Submit {
|
||||
buf: String,
|
||||
},
|
||||
Result {
|
||||
score: f64,
|
||||
passed: bool,
|
||||
level_name: String,
|
||||
},
|
||||
InvalidYaml {
|
||||
error: String,
|
||||
},
|
||||
ResetConfirm,
|
||||
Completed,
|
||||
}
|
||||
|
||||
pub fn run(prog: &mut Progress) -> Result<()> {
|
||||
install_panic_hook();
|
||||
let mut terminal = enter()?;
|
||||
let result = main_loop(&mut terminal);
|
||||
let result = main_loop(&mut terminal, prog);
|
||||
leave()?;
|
||||
result
|
||||
}
|
||||
@@ -37,23 +63,309 @@ fn leave() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main_loop(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
|
||||
loop {
|
||||
terminal.draw(|frame| {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" YAMLabyrinth ");
|
||||
let body = Paragraph::new(
|
||||
"Welcome to the YAML labyrinth.\n\nPress [q] to flee.",
|
||||
)
|
||||
.block(block);
|
||||
frame.render_widget(body, frame.size());
|
||||
})?;
|
||||
fn initial_screen(prog: &Progress, registry_len: usize) -> Screen {
|
||||
match (prog.tier, prog.current_level) {
|
||||
(None, 0) => Screen::Welcome,
|
||||
(None, _) => Screen::TierSelect { cursor: 0 },
|
||||
(Some(_), 0) => Screen::TierSelect { cursor: 0 },
|
||||
(Some(_), n) if (n as usize) > registry_len => Screen::Completed,
|
||||
(Some(_), _) => Screen::Level,
|
||||
}
|
||||
}
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.code == KeyCode::Char('q') {
|
||||
return Ok(());
|
||||
}
|
||||
fn main_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
prog: &mut Progress,
|
||||
) -> Result<()> {
|
||||
let registry = levels::registry();
|
||||
let mut screen = initial_screen(prog, registry.len());
|
||||
|
||||
loop {
|
||||
terminal.draw(|frame| render(frame, &screen, prog, ®istry))?;
|
||||
let Event::Key(key) = event::read()? else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Swap `screen` out so we can match by value, then assign the new
|
||||
// screen back. `Screen::Welcome` is just a placeholder during the
|
||||
// swap; `step` never observes it.
|
||||
let current = std::mem::replace(&mut screen, Screen::Welcome);
|
||||
match step(current, key.code, prog, ®istry)? {
|
||||
Transition::To(next) => screen = next,
|
||||
Transition::Quit => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Transition {
|
||||
To(Screen),
|
||||
Quit,
|
||||
}
|
||||
|
||||
fn step(
|
||||
screen: Screen,
|
||||
key: KeyCode,
|
||||
prog: &mut Progress,
|
||||
registry: &[Box<dyn Level>],
|
||||
) -> Result<Transition> {
|
||||
use Transition::*;
|
||||
|
||||
Ok(match (screen, key) {
|
||||
// Welcome ------------------------------------------------------------
|
||||
(Screen::Welcome, KeyCode::Enter) => To(Screen::TierSelect { cursor: 0 }),
|
||||
(Screen::Welcome, KeyCode::Char('q')) => Quit,
|
||||
|
||||
// TierSelect ---------------------------------------------------------
|
||||
(Screen::TierSelect { cursor }, KeyCode::Up) => To(Screen::TierSelect {
|
||||
cursor: cursor.saturating_sub(1),
|
||||
}),
|
||||
(Screen::TierSelect { cursor }, KeyCode::Down) => To(Screen::TierSelect {
|
||||
cursor: (cursor + 1).min(2),
|
||||
}),
|
||||
(Screen::TierSelect { cursor }, KeyCode::Enter) => {
|
||||
let tier = match cursor {
|
||||
0 => Difficulty::Easy,
|
||||
1 => Difficulty::Medium,
|
||||
_ => Difficulty::Hard,
|
||||
};
|
||||
prog.tier = Some(tier);
|
||||
if prog.current_level == 0 {
|
||||
prog.current_level = 1;
|
||||
prog.current_seed = rand::random();
|
||||
}
|
||||
progress::save(prog)?;
|
||||
To(Screen::Level)
|
||||
}
|
||||
(Screen::TierSelect { .. }, KeyCode::Char('q')) => Quit,
|
||||
|
||||
// Level --------------------------------------------------------------
|
||||
(Screen::Level, KeyCode::Char('s')) => To(Screen::Submit { buf: String::new() }),
|
||||
(Screen::Level, KeyCode::Char('r')) => To(Screen::ResetConfirm),
|
||||
(Screen::Level, KeyCode::Char('q')) => Quit,
|
||||
|
||||
// Submit -------------------------------------------------------------
|
||||
(Screen::Submit { mut buf }, KeyCode::Char(c)) => {
|
||||
buf.push(c);
|
||||
To(Screen::Submit { buf })
|
||||
}
|
||||
(Screen::Submit { mut buf }, KeyCode::Backspace) => {
|
||||
buf.pop();
|
||||
To(Screen::Submit { buf })
|
||||
}
|
||||
(Screen::Submit { .. }, KeyCode::Esc) => To(Screen::Level),
|
||||
(Screen::Submit { buf }, KeyCode::Enter) => To(grade(buf.trim(), prog, registry)?),
|
||||
|
||||
// Result -------------------------------------------------------------
|
||||
(Screen::Result { passed: true, .. }, _) => {
|
||||
if (prog.current_level as usize) > registry.len() {
|
||||
To(Screen::Completed)
|
||||
} else {
|
||||
To(Screen::Level)
|
||||
}
|
||||
}
|
||||
(Screen::Result { passed: false, .. }, _) => To(Screen::Level),
|
||||
|
||||
// InvalidYaml --------------------------------------------------------
|
||||
(Screen::InvalidYaml { .. }, _) => To(Screen::Submit { buf: String::new() }),
|
||||
|
||||
// ResetConfirm -------------------------------------------------------
|
||||
(Screen::ResetConfirm, KeyCode::Char('y')) => {
|
||||
let _ = std::fs::remove_file(progress::save_path());
|
||||
*prog = Progress::default();
|
||||
To(Screen::Welcome)
|
||||
}
|
||||
(Screen::ResetConfirm, _) => To(Screen::Level),
|
||||
|
||||
// Completed ----------------------------------------------------------
|
||||
(Screen::Completed, KeyCode::Char('q')) => Quit,
|
||||
(Screen::Completed, KeyCode::Char('r')) => To(Screen::ResetConfirm),
|
||||
|
||||
// Anything else: stay where we are.
|
||||
(other, _) => To(other),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the player's file, parse it, score it, advance progress on a pass,
|
||||
/// and choose the next screen. Returns `Result` (`InvalidYaml` or `Result`).
|
||||
fn grade(path: &str, prog: &mut Progress, registry: &[Box<dyn Level>]) -> Result<Screen> {
|
||||
let candidate = match std::fs::read_to_string(path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return Ok(Screen::InvalidYaml {
|
||||
error: format!("could not read file: {e}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Parse-first guard: invalid YAML is a wrong *format*, not a wrong
|
||||
// *answer* — no scoring, no attempts bump.
|
||||
if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&candidate) {
|
||||
return Ok(Screen::InvalidYaml { error: e.to_string() });
|
||||
}
|
||||
|
||||
let idx = (prog.current_level - 1) as usize;
|
||||
let level = ®istry[idx];
|
||||
let g = level.generate(prog.current_seed);
|
||||
let score = similarity::semantic_or_textual(&g.target_yaml, &candidate);
|
||||
let threshold = prog
|
||||
.tier
|
||||
.expect("tier set before reaching the Level screen")
|
||||
.threshold();
|
||||
let passed = score >= threshold;
|
||||
let level_name = level.name().to_string();
|
||||
let level_id = level.id();
|
||||
|
||||
if passed {
|
||||
prog.completed.push(level_id);
|
||||
prog.current_level += 1;
|
||||
prog.current_seed = rand::random();
|
||||
} else {
|
||||
prog.attempts += 1;
|
||||
}
|
||||
progress::save(prog)?;
|
||||
|
||||
Ok(Screen::Result {
|
||||
score,
|
||||
passed,
|
||||
level_name,
|
||||
})
|
||||
}
|
||||
|
||||
// -- rendering --------------------------------------------------------------
|
||||
|
||||
fn render(
|
||||
frame: &mut Frame,
|
||||
screen: &Screen,
|
||||
prog: &Progress,
|
||||
registry: &[Box<dyn Level>],
|
||||
) {
|
||||
let area = frame.size();
|
||||
match screen {
|
||||
Screen::Welcome => render_welcome(frame, area),
|
||||
Screen::TierSelect { cursor } => render_tier_select(frame, area, *cursor),
|
||||
Screen::Level => render_level(frame, area, prog, registry),
|
||||
Screen::Submit { buf } => render_submit(frame, area, buf),
|
||||
Screen::Result {
|
||||
score,
|
||||
passed,
|
||||
level_name,
|
||||
} => render_result(frame, area, *score, *passed, level_name, prog),
|
||||
Screen::InvalidYaml { error } => render_invalid_yaml(frame, area, error),
|
||||
Screen::ResetConfirm => render_reset_confirm(frame, area),
|
||||
Screen::Completed => render_completed(frame, area),
|
||||
}
|
||||
}
|
||||
|
||||
fn screen_widget<'a>(title: String, body: String) -> Paragraph<'a> {
|
||||
Paragraph::new(body)
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.wrap(Wrap { trim: false })
|
||||
}
|
||||
|
||||
fn render_welcome(frame: &mut Frame, area: Rect) {
|
||||
let body = "\n\
|
||||
You stand at the entrance to the YAML labyrinth.\n\
|
||||
Within, broken syntax festers and dictionaries\n\
|
||||
sprawl like vines. Reach the heart of it.\n\
|
||||
\n\
|
||||
[Enter] begin · [q] flee"
|
||||
.to_string();
|
||||
frame.render_widget(screen_widget(" YAMLabyrinth ".to_string(), body), area);
|
||||
}
|
||||
|
||||
fn render_tier_select(frame: &mut Frame, area: Rect, cursor: u8) {
|
||||
let rows = [
|
||||
("Easy", "70 %", "forgive small slips"),
|
||||
("Medium", "80 %", "most details must match"),
|
||||
("Hard", "95 %", "only near-perfect passes"),
|
||||
];
|
||||
let mut body = String::from("\n");
|
||||
for (i, (name, pct, hint)) in rows.iter().enumerate() {
|
||||
let marker = if i == cursor as usize { ">" } else { " " };
|
||||
body.push_str(&format!(" {marker} {name:<7} ({pct}) {hint}\n"));
|
||||
}
|
||||
body.push_str("\n↑/↓ choose · [Enter] confirm · [q] quit");
|
||||
frame.render_widget(screen_widget(" Choose your tier ".to_string(), body), area);
|
||||
}
|
||||
|
||||
fn render_level(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
prog: &Progress,
|
||||
registry: &[Box<dyn Level>],
|
||||
) {
|
||||
let idx = (prog.current_level - 1) as usize;
|
||||
let level = ®istry[idx];
|
||||
let g = level.generate(prog.current_seed);
|
||||
let tier_label = prog.tier.map(|t| t.label()).unwrap_or("?");
|
||||
let title = format!(
|
||||
" Level {}: {} · Tier: {} ",
|
||||
level.id(),
|
||||
level.name(),
|
||||
tier_label
|
||||
);
|
||||
let body = format!(
|
||||
"\n{}\n\n{}\nWrite your YAML answer in a file, then\n[s] submit · [r] reset · [q] flee",
|
||||
g.flavor, g.description
|
||||
);
|
||||
frame.render_widget(screen_widget(title, body), area);
|
||||
}
|
||||
|
||||
fn render_submit(frame: &mut Frame, area: Rect, buf: &str) {
|
||||
let body = format!(
|
||||
"\nPath to your answer YAML:\n\n> {buf}_\n\n[Enter] grade · [Esc] cancel"
|
||||
);
|
||||
frame.render_widget(screen_widget(" Submit your answer ".to_string(), body), area);
|
||||
}
|
||||
|
||||
fn render_result(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
score: f64,
|
||||
passed: bool,
|
||||
level_name: &str,
|
||||
prog: &Progress,
|
||||
) {
|
||||
let threshold = prog
|
||||
.tier
|
||||
.map(|t| t.threshold())
|
||||
.unwrap_or(0.0);
|
||||
let (title, narration) = if passed {
|
||||
(
|
||||
format!(" {level_name} cleared "),
|
||||
"The door creaks open. You step deeper into the labyrinth.",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
" Not yet ".to_string(),
|
||||
"The labyrinth resists. Refine your YAML and try again.",
|
||||
)
|
||||
};
|
||||
let body = format!(
|
||||
"\nScore {:.0} % Threshold {:.0} %\n\n{}\n\n[Enter] continue",
|
||||
score * 100.0,
|
||||
threshold * 100.0,
|
||||
narration,
|
||||
);
|
||||
frame.render_widget(screen_widget(title, body), area);
|
||||
}
|
||||
|
||||
fn render_invalid_yaml(frame: &mut Frame, area: Rect, error: &str) {
|
||||
let body = format!(
|
||||
"\n{error}\n\nFix the syntax and try again.\n\n[Enter] back to submit"
|
||||
);
|
||||
frame.render_widget(
|
||||
screen_widget(" Couldn't parse your YAML ".to_string(), body),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_reset_confirm(frame: &mut Frame, area: Rect) {
|
||||
let body = "\nWipe progress and start over?\n\n[y] yes, wipe · any other key cancels".to_string();
|
||||
frame.render_widget(screen_widget(" Reset? ".to_string(), body), area);
|
||||
}
|
||||
|
||||
fn render_completed(frame: &mut Frame, area: Rect) {
|
||||
let body = "\nYou cleared the YAML labyrinth.\n\n[r] reset and replay · [q] quit".to_string();
|
||||
frame.render_widget(screen_widget(" Labyrinth complete ".to_string(), body), area);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user