//! `kiln-tui` — a terminal "player" for kiln boards. //! //! Pass a `.toml` map file as the single command-line argument; kiln-tui loads //! that board and lets you walk the player around it with the arrow keys //! (or HJKL). There is no editor — this is a play-only front-end. //! //! Rendering is text-based: kiln's bitmap-font tile indices are reinterpreted //! as characters (see [`cp437`]) and drawn with each cell's RGB colors. mod cp437; mod render; mod term; use kiln_core::game::{GameState, ScrollLine}; use kiln_core::log::LogLine; use kiln_core::map_file; use kiln_core::Direction; use ratatui::Frame; use ratatui::crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind, }; use ratatui::crossterm::execute; use ratatui::layout::{Constraint, Layout, Spacing}; use ratatui::style::{Color, Style}; use ratatui::symbols::merge::MergeStrategy; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Wrap}; use render::{BoardWidget, logline_to_line}; use std::io; use std::process::ExitCode; use std::time::{Duration, Instant}; use term::TerminalCaps; /// Animation state for the scroll overlay window. enum ScrollAnimState { /// Window is opening: progress goes from 0.0 → 1.0 over 0.5 s. Opening(f32), /// Window is fully open. Open, /// Window is closing: progress from 1.0 → 0.0 over 0.5 s. /// When it reaches 0 the `choice` (if any) is dispatched and the overlay clears. Closing { progress: f32, choice: Option }, } /// View-only UI state for the log panel and scroll overlay. This is presentation /// state, so it lives in the front-end rather than on the engine's [`GameState`]. struct Ui { /// Whether the bottom log panel is open. log_open: bool, /// Height (in terminal rows, borders included) of the log panel when open. log_height: u16, /// Vertical scroll offset of the log panel, in lines from the top (newest). scroll: u16, /// Vertical scroll offset within an active scroll overlay. scroll_offset: u16, /// Animation state for the scroll overlay; `None` when no overlay is active. scroll_anim: Option, } impl Default for Ui { fn default() -> Self { Self { log_open: false, log_height: 10, scroll: 0, scroll_offset: 0, scroll_anim: None, } } } /// Starts the scroll-close animation with the optional player choice. /// Uses the current opening progress if the overlay was still animating in. fn begin_close(ui: &mut Ui, choice: Option) { let progress = match &ui.scroll_anim { Some(ScrollAnimState::Opening(p)) => p.min(1.0), _ => 1.0, }; ui.scroll_anim = Some(ScrollAnimState::Closing { progress, choice }); } /// Returns the choice string for the given key character, or `None` if it doesn't /// match any choice in the scroll. Choices are assigned letters a, b, c… in order. fn resolve_choice(lines: &[ScrollLine], c: char) -> Option { let mut letter = b'a'; for line in lines { if let ScrollLine::Choice { choice, .. } = line { if c == letter as char { return Some(choice.clone()); } letter += 1; } } None } /// Entry point: parse the map path, load the board, then run the play loop with /// the terminal in raw/alternate-screen mode (restored on every exit path). fn main() -> ExitCode { // The single positional argument is the map file to play. let Some(path) = std::env::args().nth(1) else { eprintln!("usage: kiln-tui "); return ExitCode::from(2); }; // Load before touching the terminal so load errors print to a normal screen. let board = match map_file::load(&path) { Ok(board) => board, Err(e) => { eprintln!("error loading {path}: {e}"); return ExitCode::FAILURE; } }; let mut game = GameState::new(board); // `init` enters the alternate screen and raw mode; `restore` always undoes it. let mut terminal = ratatui::init(); // Capture mouse events so the log panel can be scrolled with the wheel and // resized by dragging its divider. Disabled again before restoring. let _ = execute!(io::stdout(), EnableMouseCapture); // Detect terminal capabilities (now that we're in raw mode) and enable the // Kitty keyboard protocol when it's available, so scripts can rely on the // richer bindings it provides. Disabled again before restoring the terminal. let caps = TerminalCaps::detect(); if caps.keyboard_enhancement { let _ = term::push_kitty_flags(); } // Seed the log: the terminal-capabilities badge plus a couple of test // messages (temporary, just to exercise the panel). // Note these will appear in reverse order, latest on top game.log(LogLine::raw("Press 'q' to exit.")); game.log(LogLine::raw("Welcome to kiln - ").append(caps.status_logline())); // Now that the board is fully loaded and the terminal is ready, run each // scripted object's `init()` hook. game.run_init(); let mut ui = Ui::default(); let result = run(&mut terminal, &mut game, &mut ui); if caps.keyboard_enhancement { let _ = term::pop_kitty_flags(); } let _ = execute!(io::stdout(), DisableMouseCapture); ratatui::restore(); if let Err(e) = result { eprintln!("error: {e}"); return ExitCode::FAILURE; } ExitCode::SUCCESS } /// The target frame interval: 30 frames per second. const FRAME: Duration = Duration::from_nanos(1_000_000_000 / 30); /// Real-time draw / input / update loop at ~30 FPS. Returns once the user quits. /// /// Unlike a turn-based loop, this never blocks indefinitely: it waits for input /// only until the next frame deadline (`event::poll`), then advances the game by /// the real time elapsed since the last tick. `poll` returns the instant a key /// arrives, so input stays responsive while the game keeps ticking on its own. fn run( terminal: &mut ratatui::DefaultTerminal, game: &mut GameState, ui: &mut Ui, ) -> std::io::Result<()> { let mut last_tick = Instant::now(); loop { // Redraw every iteration; ratatui diffs against the previous frame so // only changed cells are actually written to the terminal. terminal.draw(|frame| draw(frame, game, ui))?; // Wait for input only up to the next frame deadline so the loop wakes to // tick even with no keypresses. let timeout = FRAME.saturating_sub(last_tick.elapsed()); if event::poll(timeout)? { match event::read()? { // Act on Press and Repeat so holding a key moves the player // continuously (the Kitty protocol emits Repeat events while a key // is held). Ignore Release, which that protocol also emits and // which would otherwise fire a second, spurious move per keypress. Event::Key(key) if key.kind != KeyEventKind::Release => { // When a scroll overlay is active (and not already animating // closed), intercept all keys for scroll navigation/choices. let scroll_active = game.active_scroll.is_some() && !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. })); if scroll_active { let scroll = game.active_scroll.as_ref().unwrap(); let has_choices = scroll.lines.iter() .any(|l| matches!(l, ScrollLine::Choice { .. })); match key.code { // Esc dismisses the scroll only when there are no choices; // with choices present the player must select one. KeyCode::Esc if !has_choices => begin_close(ui, None), KeyCode::Up => ui.scroll_offset = ui.scroll_offset.saturating_sub(1), KeyCode::Down => ui.scroll_offset = ui.scroll_offset.saturating_add(1), KeyCode::Char(c) => { if let Some(ch) = resolve_choice(&scroll.lines, c) { begin_close(ui, Some(ch)); } } _ => {} } } else { match key.code { KeyCode::Char('q') | KeyCode::Esc => return Ok(()), KeyCode::Up => game.try_move(Direction::North), KeyCode::Down => game.try_move(Direction::South), KeyCode::Left => game.try_move(Direction::West), // Right arrow moves right; 'l' is the log panel. KeyCode::Right => game.try_move(Direction::East), // 'l' toggles the log panel; reset scroll to newest. KeyCode::Char('l') => { ui.log_open = !ui.log_open; ui.scroll = 0; } // PageUp/PageDown scroll the log when it's open. KeyCode::PageUp if ui.log_open => scroll_log(ui, game, -5), KeyCode::PageDown if ui.log_open => scroll_log(ui, game, 5), _ => {} } } } // Mouse: scroll overlay captures wheel events when active; otherwise // wheel scrolls the log and drag resizes it. Event::Mouse(m) => { let scroll_active = game.active_scroll.is_some() && !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. })); if scroll_active { match m.kind { MouseEventKind::ScrollUp => ui.scroll_offset = ui.scroll_offset.saturating_sub(1), MouseEventKind::ScrollDown => ui.scroll_offset = ui.scroll_offset.saturating_add(1), _ => {} } } else if ui.log_open { match m.kind { MouseEventKind::ScrollUp => scroll_log(ui, game, -1), MouseEventKind::ScrollDown => scroll_log(ui, game, 1), MouseEventKind::Drag(_) => { // The divider sits at row `total - log_height`; dragging // it up (smaller row) grows the panel. Keep both usable. let total = terminal.size()?.height; let target = total.saturating_sub(m.row); ui.log_height = target.clamp(3, total.saturating_sub(3).max(3)); } _ => {} } } } _ => {} } } // Advance the game by the real time elapsed since the last tick. Only // resetting `last_tick` when we actually tick means time spent waiting on // input (or skipped Release events) is preserved in the next `dt`. if last_tick.elapsed() >= FRAME { let dt = last_tick.elapsed(); last_tick = Instant::now(); // Advance scroll animation regardless of game pause state. let dt_s = dt.as_secs_f32(); match &mut ui.scroll_anim { Some(ScrollAnimState::Opening(p)) => { *p += dt_s / 0.5; if *p >= 1.0 { ui.scroll_anim = Some(ScrollAnimState::Open); } } Some(ScrollAnimState::Closing { progress, choice }) => { *progress -= dt_s / 0.5; if *progress <= 0.0 { // Animation done — dispatch choice and clear the scroll. let ch = choice.take(); game.close_scroll(ch.as_deref()); ui.scroll_anim = None; ui.scroll_offset = 0; } } _ => {} } // Tick the game only when no scroll overlay is blocking. if game.active_scroll.is_none() { game.tick(dt); // Detect a scroll just opened by the tick; start its open animation. if game.active_scroll.is_some() && ui.scroll_anim.is_none() { ui.scroll_anim = Some(ScrollAnimState::Opening(0.0)); } } } } } /// Adjusts the log scroll offset by `delta` lines, clamped so it can neither go /// above the newest message nor scroll past the oldest. fn scroll_log(ui: &mut Ui, game: &GameState, delta: i32) { let max = game.log.len().saturating_sub(1) as i32; ui.scroll = (ui.scroll as i32 + delta).clamp(0, max) as u16; } /// Render a single frame: the board in a bordered block, and — when open — a log /// panel across the bottom. When the panel is closed the latest log message is /// previewed in the board's bottom-left border after a `[l]og:` label. /// A scroll overlay is drawn on top of everything when active. fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) { // Borrow the board for the duration of the frame (no script runs during draw). let board = game.board(); let board = &*board; if ui.log_open { // Split the screen: board on top, log panel of `log_height` at the bottom. let [board_area, log_area] = Layout::vertical([Constraint::Min(3), Constraint::Length(ui.log_height)]) .spacing(Spacing::Overlap(1)) .areas(frame.area()); let block = Block::bordered() .title(format!(" {} ", board.name)) .merge_borders(MergeStrategy::Exact); let inner = block.inner(board_area); frame.render_widget(block, board_area); frame.render_widget(BoardWidget::new(board), inner); render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles); // Newest message on top: reverse the log into ratatui lines. let log_block = Block::bordered() .title(Span::styled( " [l]og: ", Style::default().fg(Color::DarkGray), )) .merge_borders(MergeStrategy::Exact); let lines: Vec = game.log.iter().rev().map(logline_to_line).collect(); let paragraph = Paragraph::new(lines) .block(log_block) .scroll((ui.scroll, 0)) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, log_area); } else { // Panel closed: board fills the screen, latest message in the border. let mut block = Block::bordered().title(format!(" {} ", board.name)); block = block.title_bottom(log_preview(game).left_aligned()); let inner = block.inner(frame.area()); frame.render_widget(block, frame.area()); frame.render_widget(BoardWidget::new(board), inner); render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles); } // Scroll overlay: drawn last so it sits above all other content. if let Some(scroll) = &game.active_scroll { let anim = match &ui.scroll_anim { Some(ScrollAnimState::Opening(p)) => p.clamp(0.0, 1.0), Some(ScrollAnimState::Open) => 1.0, Some(ScrollAnimState::Closing { progress, .. }) => progress.clamp(0.0, 1.0), // Fallback: overlay exists but animation not set yet — show fully open. None => 1.0, }; // Capture area before the mutable buffer borrow to satisfy the borrow checker. let area = frame.area(); render::draw_scroll_overlay(frame.buffer_mut(), area, scroll, ui.scroll_offset, anim); } } /// Builds the `[l]og: ` preview shown in the board's bottom-left /// border when the log panel is closed. fn log_preview(game: &GameState) -> Line<'static> { let mut spans = vec![Span::styled( " [l]og: ", Style::default().fg(Color::DarkGray), )]; if let Some(latest) = game.log.last() { spans.extend(logline_to_line(latest).spans); } spans.push(Span::raw(" ")); Line::from(spans) }