2026-06-03 20:10:02 -05:00
|
|
|
//! `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
|
2026-06-20 15:27:09 -05:00
|
|
|
//! as characters (see [`kiln_core::cp437`]) and drawn with each cell's RGB colors.
|
2026-06-03 20:10:02 -05:00
|
|
|
|
2026-06-15 09:15:30 -05:00
|
|
|
mod animation;
|
2026-06-15 22:02:15 -05:00
|
|
|
mod editor;
|
2026-06-17 18:25:09 -05:00
|
|
|
mod input;
|
2026-06-11 21:55:53 -05:00
|
|
|
mod log;
|
2026-06-15 10:42:21 -05:00
|
|
|
mod menu;
|
2026-06-15 22:02:15 -05:00
|
|
|
mod mode;
|
2026-06-15 10:42:21 -05:00
|
|
|
mod overlay;
|
2026-06-03 20:10:02 -05:00
|
|
|
mod render;
|
2026-06-11 21:31:37 -05:00
|
|
|
mod scroll_overlay;
|
2026-06-17 18:25:09 -05:00
|
|
|
mod speech;
|
2026-06-03 20:10:02 -05:00
|
|
|
mod term;
|
2026-06-13 18:43:29 -05:00
|
|
|
mod transition;
|
2026-06-11 21:45:50 -05:00
|
|
|
mod ui;
|
2026-06-17 18:25:09 -05:00
|
|
|
mod utils;
|
2026-06-20 11:53:02 -05:00
|
|
|
mod editor_menu;
|
2026-06-03 20:10:02 -05:00
|
|
|
|
2026-06-17 18:25:09 -05:00
|
|
|
use crate::animation::{AnimWidget, AnimationLayer};
|
2026-06-20 15:27:09 -05:00
|
|
|
use crate::editor::{
|
|
|
|
|
EditorState, draw_editor, handle_code_editor_input, handle_dialog_input,
|
|
|
|
|
handle_glyph_dialog_input,
|
|
|
|
|
};
|
2026-06-17 18:25:09 -05:00
|
|
|
use crate::input::{
|
|
|
|
|
InputMode, current_input_mode, handle_board_input, handle_editor_input, handle_menu_input,
|
|
|
|
|
handle_scroll_input,
|
|
|
|
|
};
|
|
|
|
|
use crate::log::{LogWidget, log_preview_line};
|
|
|
|
|
use crate::menu::MenuWidget;
|
|
|
|
|
use crate::mode::{Mode, PendingMode};
|
|
|
|
|
use crate::scroll_overlay::ScrollOverlayWidget;
|
|
|
|
|
use crate::speech::SpeechBubblesWidget;
|
|
|
|
|
use crate::ui::Ui;
|
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;
|
2026-06-20 15:53:15 -05:00
|
|
|
use kiln_ui::render_overlay;
|
2026-06-03 20:10:02 -05:00
|
|
|
use ratatui::Frame;
|
2026-06-03 22:46:54 -05:00
|
|
|
use ratatui::crossterm::event::{
|
2026-06-18 11:40:06 -05:00
|
|
|
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, 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;
|
2026-06-03 20:10:02 -05:00
|
|
|
use std::process::ExitCode;
|
2026-06-03 23:21:05 -05:00
|
|
|
use std::time::{Duration, Instant};
|
2026-06-03 20:10:02 -05:00
|
|
|
use term::TerminalCaps;
|
2026-06-10 01:42:33 -05:00
|
|
|
|
2026-06-03 20:10:02 -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),
|
2026-06-03 20:10:02 -05:00
|
|
|
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);
|
|
|
|
|
|
2026-06-18 11:40:06 -05:00
|
|
|
// Enable bracketed paste so the code editor receives terminal pastes as one event.
|
|
|
|
|
let _ = execute!(io::stdout(), EnableBracketedPaste);
|
|
|
|
|
|
2026-06-03 20:10:02 -05:00
|
|
|
// 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-15 22:02:15 -05:00
|
|
|
// Wrap the live game in the top-level Mode; editing later swaps this whole value.
|
|
|
|
|
let mut mode = Mode::Play(game);
|
|
|
|
|
|
|
|
|
|
// Remember the world path so the editor (and play-reload) can reload from it.
|
2026-06-17 18:25:09 -05:00
|
|
|
let mut ui = Ui {
|
|
|
|
|
world_path: path.clone(),
|
|
|
|
|
..Ui::default()
|
|
|
|
|
};
|
2026-06-15 22:02:15 -05:00
|
|
|
let result = run(&mut terminal, &mut mode, &mut ui);
|
2026-06-03 20:10:02 -05:00
|
|
|
|
|
|
|
|
if caps.keyboard_enhancement {
|
|
|
|
|
let _ = term::pop_kitty_flags();
|
|
|
|
|
}
|
2026-06-18 11:40:06 -05:00
|
|
|
let _ = execute!(io::stdout(), DisableBracketedPaste);
|
2026-06-03 22:46:54 -05:00
|
|
|
let _ = execute!(io::stdout(), DisableMouseCapture);
|
2026-06-03 20:10:02 -05:00
|
|
|
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.
|
2026-06-17 18:25:09 -05:00
|
|
|
fn run(terminal: &mut ratatui::DefaultTerminal, mode: &mut Mode, ui: &mut Ui) -> io::Result<()> {
|
2026-06-03 23:21:05 -05:00
|
|
|
let mut last_tick = Instant::now();
|
2026-06-03 20:10:02 -05:00
|
|
|
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.
|
2026-06-15 22:02:15 -05:00
|
|
|
terminal.draw(|frame| draw(frame, mode, ui))?;
|
2026-06-03 22:46:54 -05:00
|
|
|
|
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;
|
|
|
|
|
}
|
2026-06-15 22:02:15 -05:00
|
|
|
let size = terminal.size()?;
|
2026-06-15 23:35:18 -05:00
|
|
|
// Each Mode only produces a subset of input modes (Editor only in Edit;
|
|
|
|
|
// Board/Scroll/Frozen only in Play; Menu/Ignore in either). Match on Mode
|
|
|
|
|
// first so each handler gets the right `game`/`ed` without re-checking,
|
|
|
|
|
// and assert the impossible combinations away with `unreachable!`.
|
|
|
|
|
let input_mode = current_input_mode(mode, ui);
|
|
|
|
|
if matches!(input_mode, InputMode::Menu) {
|
|
|
|
|
handle_menu_input(event, ui)
|
|
|
|
|
} else if !matches!(input_mode, InputMode::Ignore) {
|
|
|
|
|
// If input_mode is ignore, an animation is running and claiming no input mode — discard all events.
|
|
|
|
|
match mode {
|
|
|
|
|
Mode::Play(game) => match input_mode {
|
|
|
|
|
InputMode::Board | InputMode::Frozen => {
|
|
|
|
|
handle_board_input(event, ui.log.open, size.height, game, ui);
|
|
|
|
|
}
|
|
|
|
|
InputMode::Scroll => handle_scroll_input(event, game, ui),
|
2026-06-17 18:25:09 -05:00
|
|
|
_ => unreachable!(
|
|
|
|
|
"Menu and Ignore handled above; Editor input mode only occurs in Edit mode"
|
|
|
|
|
),
|
2026-06-15 23:35:18 -05:00
|
|
|
},
|
|
|
|
|
Mode::Edit(ed) => match input_mode {
|
|
|
|
|
InputMode::Editor => handle_editor_input(event, size.width, ed, ui),
|
2026-06-17 18:25:09 -05:00
|
|
|
InputMode::Dialog => handle_dialog_input(event, ed),
|
2026-06-20 15:27:09 -05:00
|
|
|
InputMode::GlyphDialog => handle_glyph_dialog_input(event, ed),
|
2026-06-18 11:40:06 -05:00
|
|
|
InputMode::CodeEditor => handle_code_editor_input(event, ed),
|
2026-06-17 18:25:09 -05:00
|
|
|
_ => unreachable!(
|
|
|
|
|
"Menu and Ignore handled above; Board/Scroll/Frozen input modes only occur in Play mode"
|
|
|
|
|
),
|
2026-06-15 23:35:18 -05:00
|
|
|
},
|
2026-06-15 22:02:15 -05:00
|
|
|
}
|
2026-06-03 23:21:05 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 22:02:15 -05:00
|
|
|
// Advance the active mode 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`.
|
2026-06-03 23:21:05 -05:00
|
|
|
if last_tick.elapsed() >= FRAME {
|
|
|
|
|
let dt = last_tick.elapsed();
|
|
|
|
|
last_tick = Instant::now();
|
2026-06-15 22:02:15 -05:00
|
|
|
ui.tick(dt, mode);
|
2026-06-03 20:10:02 -05:00
|
|
|
}
|
2026-06-15 10:42:21 -05:00
|
|
|
|
2026-06-15 22:02:15 -05:00
|
|
|
// Apply any Play↔Edit switch a menu action requested this frame.
|
|
|
|
|
apply_pending_mode(mode, ui);
|
|
|
|
|
|
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 20:10:02 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 22:02:15 -05:00
|
|
|
/// Applies a pending [`Mode`] switch, reloading the world from `ui.world_path`.
|
|
|
|
|
///
|
|
|
|
|
/// Entering the editor drops the running game; returning to play rebuilds a fresh
|
|
|
|
|
/// [`GameState`] (so it reflects any edits) and re-runs object `init()` hooks. A
|
|
|
|
|
/// load failure is logged to the play game (if any) and leaves the mode unchanged.
|
|
|
|
|
fn apply_pending_mode(mode: &mut Mode, ui: &mut Ui) {
|
2026-06-17 18:25:09 -05:00
|
|
|
let Some(pending) = ui.pending_mode.take() else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
2026-06-15 22:02:15 -05:00
|
|
|
match world::load(&ui.world_path) {
|
|
|
|
|
Ok(world) => match pending {
|
|
|
|
|
PendingMode::EnterEditor => *mode = Mode::Edit(EditorState::new(world)),
|
|
|
|
|
PendingMode::PlayWorld => {
|
|
|
|
|
let mut game = GameState::from_world(world);
|
|
|
|
|
game.run_init();
|
|
|
|
|
*mode = Mode::Play(game);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Err(e) => {
|
|
|
|
|
// Best-effort: surface the failure in the play log if we're still playing.
|
|
|
|
|
if let Mode::Play(game) = mode {
|
|
|
|
|
game.log(LogLine::error(format!("reload failed: {e}")));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Render a single frame for the active [`Mode`], then the overlay layer on top.
|
|
|
|
|
///
|
|
|
|
|
/// The body of the frame is either the play view ([`draw_play`]) or the editor
|
|
|
|
|
/// view ([`draw_editor`]); overlays (animation, menu, and — play-only — the scroll
|
|
|
|
|
/// window) are drawn above whichever was rendered.
|
|
|
|
|
fn draw(frame: &mut Frame, mode: &mut Mode, ui: &mut Ui) {
|
|
|
|
|
match mode {
|
|
|
|
|
Mode::Play(game) => draw_play(frame, game, ui),
|
|
|
|
|
Mode::Edit(ed) => draw_editor(frame, ed, ui),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Overlay layer: animation > menu > scroll (mutually exclusive at runtime).
|
2026-06-17 18:25:09 -05:00
|
|
|
let is_overlay_anim = ui
|
|
|
|
|
.active_animation
|
|
|
|
|
.as_ref()
|
2026-06-15 22:02:15 -05:00
|
|
|
.map(|a| matches!(a.layer(), AnimationLayer::Overlay))
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
|
|
|
|
|
let area = frame.area();
|
|
|
|
|
if is_overlay_anim {
|
2026-06-17 18:25:09 -05:00
|
|
|
frame.render_widget(
|
|
|
|
|
AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()),
|
|
|
|
|
area,
|
|
|
|
|
);
|
2026-06-15 22:02:15 -05:00
|
|
|
} else if let Some(menu) = ui.menu.as_mut() {
|
|
|
|
|
frame.render_stateful_widget(MenuWidget, area, menu);
|
2026-06-17 18:25:09 -05:00
|
|
|
} else {
|
|
|
|
|
match mode {
|
|
|
|
|
// The scroll window only exists while playing.
|
|
|
|
|
Mode::Play(game) => {
|
|
|
|
|
if let Some(scroll) = &game.active_scroll {
|
|
|
|
|
frame.render_stateful_widget(
|
|
|
|
|
ScrollOverlayWidget::new(&scroll.lines),
|
|
|
|
|
area,
|
|
|
|
|
&mut ui.overlay,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-20 15:27:09 -05:00
|
|
|
// The dialog / glyph-picker overlays only exist while editing.
|
2026-06-17 18:25:09 -05:00
|
|
|
Mode::Edit(ed) => {
|
2026-06-20 15:27:09 -05:00
|
|
|
if let Some(g) = &ed.glyph_dialog {
|
2026-06-20 15:53:15 -05:00
|
|
|
render_overlay(frame, g);
|
2026-06-20 15:27:09 -05:00
|
|
|
} else if let Some(d) = &ed.dialog {
|
2026-06-20 15:53:15 -05:00
|
|
|
render_overlay(frame, d);
|
2026-06-17 18:25:09 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-15 22:02:15 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Renders the play view: the board in a bordered block, and — when open — a log
|
2026-06-03 22:46:54 -05:00
|
|
|
/// 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 22:02:15 -05:00
|
|
|
fn draw_play(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-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-17 18:25:09 -05:00
|
|
|
let is_board_anim = ui
|
|
|
|
|
.active_animation
|
|
|
|
|
.as_ref()
|
2026-06-15 09:15:30 -05:00
|
|
|
.map(|a| matches!(a.layer(), AnimationLayer::Board))
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
|
|
|
|
|
if is_board_anim {
|
|
|
|
|
// Board-layer animation (wipe): replaces the board entirely.
|
2026-06-17 18:25:09 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|