Files
kiln/kiln-tui/src/main.rs
T

232 lines
9.2 KiB
Rust
Raw Normal View History

//! `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.
2026-06-15 09:15:30 -05:00
mod animation;
mod cp437;
2026-06-11 21:55:53 -05:00
mod log;
2026-06-15 10:42:21 -05:00
mod menu;
mod overlay;
mod render;
2026-06-11 21:31:37 -05:00
mod scroll_overlay;
mod term;
2026-06-13 18:43:29 -05:00
mod transition;
2026-06-11 21:31:37 -05:00
mod utils;
mod speech;
2026-06-11 21:45:50 -05:00
mod ui;
2026-06-11 22:09:32 -05:00
mod input;
2026-06-11 22:17:46 -05:00
use kiln_core::game::GameState;
2026-06-03 22:46:54 -05:00
use kiln_core::log::LogLine;
2026-06-12 23:35:40 -05:00
use kiln_core::world;
use ratatui::Frame;
2026-06-03 22:46:54 -05:00
use ratatui::crossterm::event::{
2026-06-11 22:10:09 -05:00
self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind,
2026-06-03 22:46:54 -05:00
};
use ratatui::crossterm::execute;
2026-06-13 18:43:29 -05:00
use ratatui::layout::{Constraint, Layout, Rect, Spacing};
2026-06-04 23:52:33 -05:00
use ratatui::symbols::merge::MergeStrategy;
2026-06-11 21:55:53 -05:00
use ratatui::widgets::Block;
use render::BoardWidget;
2026-06-03 22:46:54 -05:00
use std::io;
use std::process::ExitCode;
2026-06-03 23:21:05 -05:00
use std::time::{Duration, Instant};
use term::TerminalCaps;
2026-06-15 09:15:30 -05:00
use crate::animation::{AnimationLayer, AnimWidget};
2026-06-15 10:42:21 -05:00
use crate::input::{current_input_mode, handle_board_input, handle_menu_input, handle_scroll_input, InputMode};
2026-06-11 21:55:53 -05:00
use crate::log::{log_preview_line, LogWidget};
2026-06-15 10:42:21 -05:00
use crate::menu::MenuWidget;
2026-06-11 22:10:09 -05:00
use crate::scroll_overlay::ScrollOverlayWidget;
2026-06-11 21:31:37 -05:00
use crate::speech::SpeechBubblesWidget;
2026-06-11 21:45:50 -05:00
use crate::ui::Ui;
2026-06-10 01:42:33 -05:00
/// 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 <map.toml>");
return ExitCode::from(2);
};
// Load before touching the terminal so load errors print to a normal screen.
2026-06-13 01:25:58 -05:00
let mut game = match world::load(&path) {
Ok(world) => GameState::from_world(world),
Err(e) => {
eprintln!("error loading {path}: {e}");
return ExitCode::FAILURE;
}
};
// `init` enters the alternate screen and raw mode; `restore` always undoes it.
let mut terminal = ratatui::init();
2026-06-03 22:46:54 -05:00
// 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();
}
2026-06-15 10:42:21 -05:00
game.log(LogLine::raw("[esc] for menu"));
2026-06-03 22:46:54 -05:00
game.log(LogLine::raw("Welcome to kiln - ").append(caps.status_logline()));
2026-06-04 20:11:55 -05:00
// Now that the board is fully loaded and the terminal is ready, run each
// scripted object's `init()` hook.
game.run_init();
2026-06-03 22:46:54 -05:00
let mut ui = Ui::default();
let result = run(&mut terminal, &mut game, &mut ui);
if caps.keyboard_enhancement {
let _ = term::pop_kitty_flags();
}
2026-06-03 22:46:54 -05:00
let _ = execute!(io::stdout(), DisableMouseCapture);
ratatui::restore();
if let Err(e) = result {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
2026-06-03 23:21:05 -05:00
/// 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,
2026-06-03 22:46:54 -05:00
ui: &mut Ui,
2026-06-11 21:55:53 -05:00
) -> io::Result<()> {
2026-06-03 23:21:05 -05:00
let mut last_tick = Instant::now();
loop {
2026-06-03 22:46:54 -05:00
// 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))?;
2026-06-03 23:21:05 -05:00
// 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)? {
2026-06-11 22:09:32 -05:00
let event = event::read()?;
// Ignore key-release events (the Kitty protocol emits these; acting on
// them would fire a second move per keypress).
if matches!(&event, Event::Key(k) if k.kind == KeyEventKind::Release) {
continue;
}
let terminal_h = terminal.size()?.height;
match current_input_mode(game, ui) {
2026-06-13 18:43:29 -05:00
InputMode::Board | InputMode::Frozen => {
2026-06-15 10:42:21 -05:00
handle_board_input(event, ui.log.open, terminal_h, game, ui);
2026-06-10 01:42:33 -05:00
}
2026-06-15 10:42:21 -05:00
InputMode::Menu => handle_menu_input(event, game, ui),
2026-06-11 22:09:32 -05:00
InputMode::Scroll => handle_scroll_input(event, game, ui),
2026-06-15 11:44:18 -05:00
// Animation running and claiming no input mode — discard all events.
InputMode::Ignore => {}
2026-06-03 23:21:05 -05:00
}
}
// 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();
2026-06-11 21:45:50 -05:00
ui.tick(dt, game);
}
2026-06-15 10:42:21 -05:00
// A menu action may have set should_quit; exit once any close animation finishes.
if ui.should_quit && ui.active_animation.is_none() {
return Ok(());
}
}
}
2026-06-03 22:46:54 -05:00
/// 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.
2026-06-15 10:42:21 -05:00
/// Overlays (menu, scroll, animations) are drawn on top of everything.
2026-06-11 18:53:52 -05:00
fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
2026-06-04 23:52:33 -05:00
// Borrow the board for the duration of the frame (no script runs during draw).
2026-06-11 22:17:46 -05:00
let board = &game.board();
2026-06-03 22:46:54 -05:00
2026-06-11 21:55:53 -05:00
if ui.log.open {
// Split the screen: board on top, log panel of `height` at the bottom.
2026-06-03 22:46:54 -05:00
let [board_area, log_area] =
2026-06-11 21:55:53 -05:00
Layout::vertical([Constraint::Min(3), Constraint::Length(ui.log.height)])
2026-06-03 22:46:54 -05:00
.spacing(Spacing::Overlap(1))
.areas(frame.area());
2026-06-04 23:52:33 -05:00
let block = Block::bordered()
.title(format!(" {} ", board.name))
.merge_borders(MergeStrategy::Exact);
2026-06-03 22:46:54 -05:00
let inner = block.inner(board_area);
frame.render_widget(block, board_area);
2026-06-13 18:43:29 -05:00
draw_board_area(frame, inner, game, ui);
2026-06-11 21:55:53 -05:00
frame.render_stateful_widget(LogWidget::new(&game.log), log_area, &mut ui.log);
2026-06-03 22:46:54 -05:00
} else {
// Panel closed: board fills the screen, latest message in the border.
2026-06-04 23:52:33 -05:00
let mut block = Block::bordered().title(format!(" {} ", board.name));
2026-06-11 21:55:53 -05:00
block = block.title_bottom(log_preview_line(&game.log).left_aligned());
2026-06-03 22:46:54 -05:00
let inner = block.inner(frame.area());
frame.render_widget(block, frame.area());
2026-06-13 18:43:29 -05:00
draw_board_area(frame, inner, game, ui);
2026-06-03 22:46:54 -05:00
}
2026-06-10 01:42:33 -05:00
2026-06-15 10:42:21 -05:00
// Overlay layer: animation > menu > scroll (mutually exclusive at runtime).
2026-06-15 09:15:30 -05:00
let is_overlay_anim = ui.active_animation.as_ref()
.map(|a| matches!(a.layer(), AnimationLayer::Overlay))
.unwrap_or(false);
2026-06-15 10:42:21 -05:00
let area = frame.area();
2026-06-15 09:15:30 -05:00
if is_overlay_anim {
frame.render_widget(AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()), area);
2026-06-15 10:42:21 -05:00
} else if let Some(menu) = ui.menu.as_mut() {
frame.render_stateful_widget(MenuWidget, area, menu);
2026-06-15 09:15:30 -05:00
} else if let Some(scroll) = &game.active_scroll {
frame.render_stateful_widget(ScrollOverlayWidget::new(&scroll.lines), area, &mut ui.overlay);
2026-06-10 01:42:33 -05:00
}
2026-06-03 22:46:54 -05:00
}
2026-06-13 18:43:29 -05:00
2026-06-15 09:15:30 -05:00
/// Renders the inner board area: either the active board-layer animation or the live board.
2026-06-13 18:43:29 -05:00
///
/// If `ui.pending_transition` is set, pre-renders both boards into a new
2026-06-15 09:15:30 -05:00
/// [`TransitionAnimation`](transition::TransitionAnimation) (now that we know the
/// exact `inner` area) and activates it via [`Ui::start_transition`].
2026-06-13 18:43:29 -05:00
fn draw_board_area(frame: &mut Frame, inner: Rect, game: &GameState, ui: &mut Ui) {
// Initialize any pending transition now that we know the exact area.
if let Some((old_rc, new_rc)) = ui.pending_transition.take() {
2026-06-15 09:15:30 -05:00
ui.start_transition(&old_rc.borrow(), &new_rc.borrow(), inner);
2026-06-13 18:43:29 -05:00
}
2026-06-15 09:15:30 -05:00
let is_board_anim = ui.active_animation.as_ref()
.map(|a| matches!(a.layer(), AnimationLayer::Board))
.unwrap_or(false);
if is_board_anim {
// Board-layer animation (wipe): replaces the board entirely.
frame.render_widget(AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()), inner);
2026-06-13 18:43:29 -05:00
} else {
let board = &game.board();
frame.render_widget(BoardWidget::new(board), inner);
frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner);
}
}