From 32aef1ea08d8b3f7b85d61327cbe85d88660fd62 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Mon, 15 Jun 2026 09:15:30 -0500 Subject: [PATCH] animations --- kiln-core/src/action.rs | 1 + kiln-core/src/game.rs | 43 ++-- kiln-core/src/tests/scripting.rs | 17 +- kiln-tui/src/animation.rs | 44 ++++ kiln-tui/src/input.rs | 52 ++--- kiln-tui/src/main.rs | 48 ++-- kiln-tui/src/scroll_overlay.rs | 374 ++++++++++++++++--------------- kiln-tui/src/transition.rs | 33 +-- kiln-tui/src/ui.rs | 95 ++++---- 9 files changed, 394 insertions(+), 313 deletions(-) create mode 100644 kiln-tui/src/animation.rs diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index e90778e..c9c6caf 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -18,6 +18,7 @@ pub(crate) const MOVE_COST: f64 = 0.25; /// Each element of the array passed to the `scroll()` Rhai function becomes a /// `ScrollLine`: a plain string becomes [`Text`](ScrollLine::Text); a two-element /// array `[choice, display]` becomes a [`Choice`](ScrollLine::Choice). +#[derive(Clone)] pub enum ScrollLine { /// Plain text, word-wrapped to the scroll window width. Text(String), diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 4a5e87f..9362d0c 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -20,13 +20,18 @@ pub use crate::action::ScrollLine; /// An active scroll overlay opened by a scripted object via `scroll()`. /// -/// While a `Scroll` is present on [`GameState`], the front-end should pause ticks -/// and display the overlay. Closed via [`GameState::close_scroll`]. +/// While a `Scroll` is present on [`GameState`], the front-end should display +/// the overlay and pause ticks. When the player selects a choice (or dismisses), +/// the front-end sets [`choice`](Scroll::choice); [`GameState::tick`] calls +/// [`GameState::handle_scroll`] at the start of each tick to dispatch and clear it. pub struct Scroll { /// The object whose `scroll()` call opened this overlay. pub source: ObjectId, /// The lines of content to display. pub lines: Vec, + /// The choice key the player selected, or `None` if dismissed without a choice. + /// Set by the front-end; consumed by [`GameState::handle_scroll`]. + pub choice: Option, } /// An active speech bubble emitted by a scripted object via `say()`. @@ -59,7 +64,7 @@ pub struct GameState { /// Active speech bubbles from `say()` calls, displayed by the front-end over the board. pub speech_bubbles: Vec, /// An active scroll overlay opened by `scroll()`. `Some` while the overlay is - /// visible; the front-end pauses ticks and closes it via [`close_scroll`](GameState::close_scroll). + /// visible; the front-end pauses ticks and sets [`Scroll::choice`] before resuming. pub active_scroll: Option, /// Remaining seconds for the board-entry transition animation, set to `1.0` /// by [`enter_board`](GameState::enter_board). Front-ends tick this down and @@ -160,10 +165,14 @@ impl GameState { } /// Advances real-time game state by `dt` (the elapsed time since the last tick). - /// Called once per frame by the front-end's game loop. Counts down object - /// cooldowns, drives every scripted object's `tick(dt)` hook (pumping each queue), - /// then resolves the actions that were promoted onto the board queue. + /// Called once per frame by the front-end's game loop. First processes any active + /// scroll (dispatching the player's choice if set), then drives object tick hooks + /// and resolves queued actions. pub fn tick(&mut self, dt: Duration) { + // Process any pending scroll choice before running scripts; ticks are + // suppressed by the front-end while a scroll overlay is visible, so + // this runs exactly once per player interaction with a scroll. + self.handle_scroll(); let secs = dt.as_secs_f64(); self.scripts.run_tick(secs); // Expire speech bubbles before resolving new actions so a fresh say() @@ -268,7 +277,7 @@ impl GameState { } // Later scrolls overwrite earlier ones from the same tick. Action::Scroll(lines) => { - new_scroll = Some(Scroll { source: ba.source, lines }); + new_scroll = Some(Scroll { source: ba.source, lines, choice: None }); } Action::Teleport { x, y } => { if !board.in_bounds((x, y)) { @@ -311,16 +320,18 @@ impl GameState { self.drain_errors(); } - /// Closes the active scroll overlay, optionally dispatching a named choice - /// back to the object that opened it via `send`. + /// Consumes the active scroll, dispatching the player's choice (if any) back + /// to the source object via `send`. /// - /// Call from the front-end when the player dismisses the scroll or selects a - /// choice. If `choice` is `Some`, the source object receives a `send` call with - /// that string as the function name (e.g. `"eat"` triggers `fn eat()`). - pub fn close_scroll(&mut self, choice: Option<&str>) { - let source = self.active_scroll.take().map(|s| s.source); - if let (Some(src), Some(ch)) = (source, choice) { - self.scripts.run_send(src, ch, None); + /// Called automatically by [`tick`](GameState::tick) as its first step. The + /// front-end sets [`Scroll::choice`] before resuming ticks; if no choice was + /// made (player dismissed), `choice` stays `None` and the scroll is cleared + /// without dispatching. + pub fn handle_scroll(&mut self) { + if let Some(scroll) = self.active_scroll.take() + && let Some(choice) = scroll.choice + { + self.scripts.run_send(scroll.source, &choice, None); self.drain_errors(); } } diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index 3911f13..950d367 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -264,7 +264,7 @@ fn scroll_opens_on_player_bump() { } #[test] -fn close_scroll_without_choice_clears_it() { +fn handle_scroll_without_choice_clears_it() { let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)])); game.run_init(); @@ -272,14 +272,15 @@ fn close_scroll_without_choice_clears_it() { game.tick(Duration::from_millis(16)); assert!(game.active_scroll.is_some()); - game.close_scroll(None); + // No choice set — next tick clears the scroll without dispatching. + game.tick(Duration::from_millis(16)); assert!(game.active_scroll.is_none()); } #[test] -fn close_scroll_with_choice_dispatches_send_to_source() { - // Closing with a choice string fires send() on the object that opened the - // scroll; fn eat() logging "eaten" confirms the dispatch arrived. +fn handle_scroll_with_choice_dispatches_send_to_source() { + // Setting choice on the scroll before a tick fires send() on the source object; + // fn eat() logging "eaten" confirms the dispatch arrived. let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#" fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); } @@ -290,9 +291,9 @@ fn close_scroll_with_choice_dispatches_send_to_source() { game.tick(Duration::from_millis(16)); assert!(game.active_scroll.is_some()); - game.close_scroll(Some("eat")); - assert!(game.active_scroll.is_none()); - // eat() queues Action::Log onto the board queue; a tick resolves it. + // Set the choice, then tick — handle_scroll dispatches "eat" and resolve picks up the log. + game.active_scroll.as_mut().unwrap().choice = Some("eat".to_string()); game.tick(Duration::from_millis(16)); + assert!(game.active_scroll.is_none()); assert!(log_texts(&game).iter().any(|t| t == "eaten"), "eat() should have logged"); } diff --git a/kiln-tui/src/animation.rs b/kiln-tui/src/animation.rs new file mode 100644 index 0000000..a15130a --- /dev/null +++ b/kiln-tui/src/animation.rs @@ -0,0 +1,44 @@ +//! The [`Animation`] trait and supporting types. +//! +//! An animation encapsulates a time-bounded visual effect: it advances via +//! [`update`](Animation::update), renders via [`draw`](Animation::draw), and +//! reports its layer and which [`InputMode`] to enter when it finishes. +//! +//! **While any animation is running, all input is ignored.** The front-end checks +//! [`Ui::active_animation`](crate::ui::Ui::active_animation) before dispatching events. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::Widget; +use crate::input::InputMode; + +/// Which region of the terminal an animation occupies. +pub enum AnimationLayer { + /// Renders in the inner board area, replacing the board (e.g. the wipe transition). + Board, + /// Renders over the full terminal area on top of the board (e.g. scroll open/close). + Overlay, +} + +/// A time-bounded visual effect managed by [`Ui`](crate::ui::Ui). +pub trait Animation { + /// Advance the animation by `dt` seconds. Returns `true` when the animation is done. + fn update(&mut self, dt: f32) -> bool; + /// Draw the current animation frame into `area` on `buf`. + fn draw(&self, area: Rect, buf: &mut Buffer); + /// Which region this animation draws to (board area vs. full overlay). + fn layer(&self) -> AnimationLayer; + /// Which [`InputMode`] to enter when the animation finishes. + fn input_mode_after(&self) -> InputMode; +} + +/// Wraps a `&dyn Animation` as a ratatui [`Widget`] so it can be passed to +/// `frame.render_widget`. The animation's [`draw`](Animation::draw) is called +/// with the area provided by the layout. +pub struct AnimWidget<'a>(pub &'a dyn Animation); + +impl Widget for AnimWidget<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + self.0.draw(area, buf); + } +} diff --git a/kiln-tui/src/input.rs b/kiln-tui/src/input.rs index 23a67c8..353e377 100644 --- a/kiln-tui/src/input.rs +++ b/kiln-tui/src/input.rs @@ -1,7 +1,6 @@ use ratatui::crossterm::event::{Event, KeyCode, MouseEventKind}; use kiln_core::Direction; use kiln_core::game::{GameState, ScrollLine}; -use crate::scroll_overlay::ScrollAnimState; use crate::ui::Ui; /// Moves the player and, if the move crosses a portal, captures both board Rcs @@ -20,29 +19,23 @@ fn try_move_with_transition(game: &mut GameState, ui: &mut Ui, dir: Direction) { /// Which input mode the front-end is currently in. pub enum InputMode { /// Normal gameplay: arrow keys move the player, `l` toggles the log. - /// The `bool` is whether the log panel is open (affects PageUp/Down and mouse). Board, /// A scroll overlay is active; all input is captured for scroll navigation. Scroll, - /// A board-wipe animation is running; all events are silently discarded. - Transition, - /// The wipe just finished; game is frozen until the player presses any key. + /// An animation is running; all input is discarded. + Animating, + /// Reserved for a post-transition freeze; nothing currently produces this mode. + #[allow(dead_code)] Frozen, } /// Determines the current input mode from game and UI state. pub(crate) fn current_input_mode(game: &GameState, ui: &Ui) -> InputMode { - // Transition takes highest priority (pending or animating). - if ui.transition.is_some() || ui.pending_transition.is_some() { - return InputMode::Transition; + // Any active animation (or a pending transition waiting for its area) blocks input. + if ui.active_animation.is_some() || ui.pending_transition.is_some() { + return InputMode::Animating; } - // Post-wipe freeze: show the new board but don't tick or move until a key is pressed. - if ui.post_transition_wait { - return InputMode::Frozen; - } - let scroll_active = game.active_scroll.is_some() - && !matches!(ui.overlay.anim, Some(ScrollAnimState::Closing(_) | ScrollAnimState::Done)); - if scroll_active { InputMode::Scroll } else { InputMode::Board } + if game.active_scroll.is_some() { InputMode::Scroll } else { InputMode::Board } } /// Handles an input event in [`InputMode::Board`]. Returns `true` if the player quit. @@ -88,23 +81,24 @@ fn resolve_choice(lines: &[ScrollLine], c: char) -> Option { /// Handles an input event in [`InputMode::Scroll`]. pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut Ui) { + let lines: Vec = game.active_scroll.as_ref() + .map(|s| s.lines.clone()) + .unwrap_or_default(); + let has_choices = lines.iter().any(|l| matches!(l, ScrollLine::Choice { .. })); + match event { - Event::Key(key) => { - 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 only when there are no choices; with choices the player must select. - KeyCode::Esc if !has_choices => ui.begin_close(None), - KeyCode::Up => ui.scroll_lines(-1), - KeyCode::Down => ui.scroll_lines(1), - KeyCode::Char(c) => { - if let Some(ch) = resolve_choice(&scroll.lines, c) { - ui.begin_close(Some(ch)); - } + Event::Key(key) => match key.code { + // Esc dismisses only when there are no choices. + KeyCode::Esc if !has_choices => ui.begin_close(None, game), + KeyCode::Up => ui.scroll_lines(-1), + KeyCode::Down => ui.scroll_lines(1), + KeyCode::Char(c) => { + if let Some(ch) = resolve_choice(&lines, c) { + ui.begin_close(Some(ch), game); } - _ => {} } - } + _ => {} + }, Event::Mouse(m) => match m.kind { MouseEventKind::ScrollUp => ui.scroll_lines(-1), MouseEventKind::ScrollDown => ui.scroll_lines(1), diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index a3a4c9f..c24ddb4 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -7,6 +7,7 @@ //! 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 animation; mod cp437; mod log; mod render; @@ -34,11 +35,11 @@ use std::io; use std::process::ExitCode; use std::time::{Duration, Instant}; use term::TerminalCaps; +use crate::animation::{AnimationLayer, AnimWidget}; use crate::input::{current_input_mode, handle_board_input, handle_scroll_input, InputMode}; use crate::log::{log_preview_line, LogWidget}; use crate::scroll_overlay::ScrollOverlayWidget; use crate::speech::SpeechBubblesWidget; -use crate::transition::TransitionAnimation; use crate::ui::Ui; /// Entry point: parse the map path, load the board, then run the play loop with @@ -136,11 +137,10 @@ fn run( if handle_board_input(event, ui.log.open, terminal_h, game, ui) { return Ok(()); } - ui.post_transition_wait = false; } InputMode::Scroll => handle_scroll_input(event, game, ui), - // Wipe is running — discard all events. - InputMode::Transition => {} + // Animation running — discard all events. + InputMode::Animating => {} } } @@ -158,7 +158,7 @@ fn run( /// 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. +/// A scroll overlay or overlay animation is drawn on top of everything when active. fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) { // Borrow the board for the duration of the frame (no script runs during draw). let board = &game.board(); @@ -186,32 +186,40 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) { draw_board_area(frame, inner, game, ui); } - // Scroll overlay: drawn last so it sits above all other content. - // Suppressed during a wipe — enter_board() already cleared active_scroll. - if ui.transition.is_none() && let Some(scroll) = &game.active_scroll { - // Capture area before the mutable buffer borrow to satisfy the borrow checker. + // Draw overlay animations (scroll open/close) or the fully-open scroll overlay. + // Suppressed during a board-layer animation (transition wipe); enter_board() + // already cleared active_scroll so there's nothing to show anyway. + let is_overlay_anim = ui.active_animation.as_ref() + .map(|a| matches!(a.layer(), AnimationLayer::Overlay)) + .unwrap_or(false); + + if is_overlay_anim { let area = frame.area(); - frame.render_stateful_widget(ScrollOverlayWidget::new(scroll), area, &mut ui.overlay); + frame.render_widget(AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()), area); + } else if let Some(scroll) = &game.active_scroll { + let area = frame.area(); + frame.render_stateful_widget(ScrollOverlayWidget::new(&scroll.lines), area, &mut ui.overlay); } } -/// Renders the inner board area: either the active wipe animation or the live board. +/// Renders the inner board area: either the active board-layer animation or the live board. /// /// If `ui.pending_transition` is set, pre-renders both boards into a new -/// [`TransitionAnimation`] (now that we know the exact `inner` area) and activates it. +/// [`TransitionAnimation`](transition::TransitionAnimation) (now that we know the +/// exact `inner` area) and activates it via [`Ui::start_transition`]. 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() { - ui.transition = Some(TransitionAnimation::new( - &old_rc.borrow(), - &new_rc.borrow(), - inner, - )); + ui.start_transition(&old_rc.borrow(), &new_rc.borrow(), inner); } - if let Some(ref tr) = ui.transition { - // Wipe in progress: blend old and new board buffers; suppress speech bubbles. - frame.render_widget(tr, inner); + 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); } else { let board = &game.board(); frame.render_widget(BoardWidget::new(board), inner); diff --git a/kiln-tui/src/scroll_overlay.rs b/kiln-tui/src/scroll_overlay.rs index 805db3d..a0736a7 100644 --- a/kiln-tui/src/scroll_overlay.rs +++ b/kiln-tui/src/scroll_overlay.rs @@ -1,11 +1,11 @@ -//! The scroll overlay widget, shown when a script calls `scroll()`. +//! The scroll overlay widget and its open/close animations. //! -//! Uses [`StatefulWidget`] rather than [`Widget`] because rendering computes -//! `max_scroll` (the highest valid scroll offset) as a side-effect that the -//! caller needs to clamp future scroll input. [`ScrollOverlayState`] carries -//! `offset` in and `max_scroll` out across the render call. +//! [`ScrollOverlayWidget`] renders a fully-open overlay for [`InputMode::Scroll`]. +//! [`ScrollOpenAnimation`] and [`ScrollCloseAnimation`] implement [`Animation`] +//! and handle the cosmetic open/close transitions; while either plays, all input +//! is suppressed. -use kiln_core::game::{Scroll, ScrollLine}; +use kiln_core::game::ScrollLine; use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Style}; @@ -14,25 +14,12 @@ use ratatui::widgets::{ Block, Fill, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget, Wrap, }; +use crate::animation::{Animation, AnimationLayer}; +use crate::input::InputMode; /// Duration of the open and close animations, in seconds. const ANIM_SECS: f32 = 0.25; -/// Animation state for the scroll overlay window. -#[derive(Default)] -pub enum ScrollAnimState { - /// Window is opening: progress goes from 0.0 → 1.0 over 0.5 s. - Opening(f32), - /// Window is fully open. - #[default] - Open, - /// Window is closing: progress from 1.0 → 0.0 over 0.5 s. - Closing(f32), - /// Close animation finished; the caller should dispatch - /// [`ScrollOverlayState::pending_choice`] and reset the state. - Done, -} - /// State threaded through a [`ScrollOverlayWidget`] render call. #[derive(Default)] pub struct ScrollOverlayState { @@ -40,64 +27,22 @@ pub struct ScrollOverlayState { pub offset: u16, /// The maximum valid scroll offset (output — written during render). pub max_scroll: u16, - /// Open/close animation state (input — read during render). - pub anim: Option, - /// The player's choice to dispatch when [`close_finished`](Self::close_finished) - /// is true. Set by the caller before starting a close animation. - pub pending_choice: Option, } -impl ScrollOverlayState { - /// Advances the open/close animation by `dt` seconds. - /// - /// When a closing animation completes, transitions `anim` to - /// [`ScrollAnimState::Done`]. Check [`close_finished`](Self::close_finished) - /// after calling this, then use [`take_choice`](Self::take_choice) to - /// retrieve the pending choice before resetting the state. - pub fn advance_anim(&mut self, dt: f32) { - match &mut self.anim { - Some(ScrollAnimState::Opening(p)) => { - *p += dt / ANIM_SECS; - if *p >= 1.0 { self.anim = Some(ScrollAnimState::Open); } - } - Some(ScrollAnimState::Closing(p)) => { - *p -= dt / ANIM_SECS; - if *p <= 0.0 { self.anim = Some(ScrollAnimState::Done); } - } - _ => {} - } - } - - /// Returns `true` when a closing animation has just finished and - /// [`pending_choice`](Self::pending_choice) is ready to be dispatched. - pub fn close_finished(&self) -> bool { - matches!(self.anim, Some(ScrollAnimState::Done)) - } - - /// Removes and returns the pending choice. Returns `None` if no choice - /// was queued (e.g. the player dismissed without selecting). - pub fn take_choice(&mut self) -> Option { - self.pending_choice.take() - } -} - -/// A ratatui widget that draws the full-screen scroll overlay. +/// A ratatui widget that draws the fully-open scroll overlay. /// -/// The overlay dims the background, then renders a `Block`-bordered window at -/// half screen width × 3/4 screen height. Height animates via `state.anim` -/// (0.0 → 1.0 = opening, 1.0 → 0.0 = closing), always staying even so each -/// step grows the window by exactly 2 rows from the center. Text lines whose -/// source starts with whitespace are stripped and centered; all others are -/// left-aligned. Selectable choices show `[a]`/`[b]` prefixes. +/// Used in [`InputMode::Scroll`] when no open/close animation is active. +/// Uses [`StatefulWidget`] because rendering computes `max_scroll` as a +/// side-effect that the caller needs to clamp future scroll input. pub struct ScrollOverlayWidget<'a> { /// The scroll content to display. - pub scroll: &'a Scroll, + pub lines: &'a [ScrollLine], } impl<'a> ScrollOverlayWidget<'a> { - /// Creates a widget for the given scroll content. - pub fn new(scroll: &'a Scroll) -> Self { - Self { scroll } + /// Creates a widget for the given scroll lines. + pub fn new(lines: &'a [ScrollLine]) -> Self { + Self { lines } } } @@ -105,113 +50,184 @@ impl StatefulWidget for ScrollOverlayWidget<'_> { type State = ScrollOverlayState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut ScrollOverlayState) { - let anim = match &state.anim { - Some(ScrollAnimState::Opening(p)) => p.clamp(0.0, 1.0), - Some(ScrollAnimState::Open) => 1.0, - Some(ScrollAnimState::Closing(p)) => p.clamp(0.0, 1.0), - Some(ScrollAnimState::Done) | None => return, - }; - if anim <= 0.0 { - state.max_scroll = 0; - return; - } - - let bg = Color::Rgb(30, 25, 10); - let text_style = Style::default().fg(Color::White).bg(bg); - let key_style = Style::default().fg(Color::Yellow).bg(bg); - let choice_style = Style::default().fg(Color::Rgb(200, 200, 100)).bg(bg); - let border_style = Style::default().fg(Color::Rgb(180, 160, 100)).bg(bg); - - // Fixed proportional sizing. Height is forced even so each animation step - // expands the window by exactly 2 rows (one on each side from the center). - let outer_w = area.width / 2; - let outer_h = (area.height * 3 / 4) & !1; - - // Animate height: keep it even at every intermediate frame too. - let actual_h = ((outer_h as f32 * anim).round() as u16 & !1).max(2); - - let left = area.x + area.width.saturating_sub(outer_w) / 2; - let top = area.y + area.height.saturating_sub(actual_h) / 2; - let overlay_rect = Rect { x: left, y: top, width: outer_w, height: actual_h }; - - // Build Lines without pre-wrapping — Paragraph handles wrapping at render time. - // Lines whose source starts with whitespace are centered; others are left-aligned. - let mut lines: Vec = Vec::new(); - let mut choice_letter = b'a'; - for item in &self.scroll.lines { - match item { - ScrollLine::Text(s) => { - let text = s.trim(); - let line = Line::styled(text.to_string(), text_style); - lines.push(if s.starts_with(|c: char| c.is_ascii_whitespace()) { - line.centered() - } else { - line - }); - } - ScrollLine::Choice { display, .. } => { - let key = choice_letter as char; - lines.push(Line::from(vec![ - Span::styled(format!("[{key}] "), key_style), - Span::styled(display.clone(), choice_style), - ])); - choice_letter += 1; - } - } - } - - // Dim the whole background, then use Fill to paint the overlay area with solid - // background-colored spaces so board glyphs can't show through. - let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black); - for y in area.top()..area.bottom() { - for x in area.left()..area.right() { - if let Some(cell) = buf.cell_mut((x, y)) { - cell.set_style(dim_style); - } - } - } - Fill::new(" ").style(Style::default().bg(bg)).render(overlay_rect, buf); - - // Render the bordered box, then split inner: text on the left, scrollbar on the right. - let block = Block::bordered().border_style(border_style).style(Style::default().bg(bg)); - let inner = block.inner(overlay_rect); - block.render(overlay_rect, buf); - - let [content_area, scrollbar_area] = Layout::horizontal([ - Constraint::Min(0), - Constraint::Length(1), - ]).areas(inner); - - // Measure wrapped content height using the narrower content_area width, clamp - // scroll so content can't be pushed out of view. - let para = Paragraph::new(lines).wrap(Wrap { trim: false }); - let content_lines = para.line_count(content_area.width) as u16; - let max_scroll = content_lines.saturating_sub(content_area.height); - let clamped = state.offset.min(max_scroll); - let para = para.scroll((clamped, 0)); - - // Center content vertically when it's shorter than the window. - let pad = content_area.height.saturating_sub(content_lines) / 2; - let [_, content_rect, _] = Layout::vertical([ - Constraint::Length(pad), - Constraint::Length(content_lines.min(content_area.height)), - Constraint::Fill(1), - ]).areas(content_area); - para.render(content_rect, buf); - - // Scrollbar — only shown when content exceeds the visible height. - // content_length is set to max_scroll + 1 so that position 0 maps to the top - // of the track and position max_scroll maps exactly to the bottom. Using the - // full content_lines as content_length would leave the thumb short of the - // bottom at max scroll because ratatui's thumb formula is based on - // (content_length - 1) as the maximum position. - if content_lines > content_area.height { - let mut sb_state = ScrollbarState::new((max_scroll + 1) as usize) - .position(clamped as usize); - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .render(scrollbar_area, buf, &mut sb_state); - } - - state.max_scroll = max_scroll; + render_scroll_at(self.lines, 1.0, area, buf, Some(state)); } } + +/// Opens a scroll overlay with an animated reveal. +/// +/// Progresses from 0.0 (invisible) to 1.0 (fully open). When complete, +/// [`input_mode_after`](Animation::input_mode_after) returns [`InputMode::Scroll`] +/// so the player can interact with the now-visible overlay. +pub struct ScrollOpenAnimation { + /// Snapshot of the scroll lines (for rendering during the animation). + lines: Vec, + /// Animation progress: 0.0 = hidden, 1.0 = fully open. + progress: f32, +} + +impl ScrollOpenAnimation { + /// Creates a new open animation from a snapshot of the scroll's lines. + pub fn new(lines: Vec) -> Self { + Self { lines, progress: 0.0 } + } +} + +impl Animation for ScrollOpenAnimation { + fn update(&mut self, dt: f32) -> bool { + self.progress += dt / ANIM_SECS; + self.progress >= 1.0 + } + + fn draw(&self, area: Rect, buf: &mut Buffer) { + render_scroll_at(&self.lines, self.progress.clamp(0.0, 1.0), area, buf, None); + } + + fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay } + + fn input_mode_after(&self) -> InputMode { InputMode::Scroll } +} + +/// Closes a scroll overlay with an animated collapse. +/// +/// Progresses from 1.0 (fully open) to 0.0 (invisible). When complete, +/// [`input_mode_after`](Animation::input_mode_after) returns [`InputMode::Board`] +/// and the engine's [`handle_scroll`](kiln_core::game::GameState::handle_scroll) +/// will dispatch any pending choice on the next tick. +pub struct ScrollCloseAnimation { + /// Snapshot of the scroll lines (for rendering during the animation). + lines: Vec, + /// Animation progress: 1.0 = fully open, 0.0 = hidden. + progress: f32, +} + +impl ScrollCloseAnimation { + /// Creates a new close animation from a snapshot of the scroll's lines. + pub fn new(lines: Vec) -> Self { + Self { lines, progress: 1.0 } + } +} + +impl Animation for ScrollCloseAnimation { + fn update(&mut self, dt: f32) -> bool { + self.progress -= dt / ANIM_SECS; + self.progress <= 0.0 + } + + fn draw(&self, area: Rect, buf: &mut Buffer) { + render_scroll_at(&self.lines, self.progress.clamp(0.0, 1.0), area, buf, None); + } + + fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay } + + fn input_mode_after(&self) -> InputMode { InputMode::Board } +} + +/// Shared rendering for the scroll overlay at a given animation progress. +/// +/// `progress` is 0.0 (invisible) → 1.0 (fully open); the window height is +/// animated proportionally, always kept even so it expands symmetrically. +/// When `state` is `Some`, `max_scroll` is written out for scroll-input clamping +/// (only needed when fully open and interactive). +fn render_scroll_at( + lines: &[ScrollLine], + progress: f32, + area: Rect, + buf: &mut Buffer, + state: Option<&mut ScrollOverlayState>, +) { + if progress <= 0.0 { + if let Some(s) = state { s.max_scroll = 0; } + return; + } + + let bg = Color::Rgb(30, 25, 10); + let text_style = Style::default().fg(Color::White).bg(bg); + let key_style = Style::default().fg(Color::Yellow).bg(bg); + let choice_style = Style::default().fg(Color::Rgb(200, 200, 100)).bg(bg); + let border_style = Style::default().fg(Color::Rgb(180, 160, 100)).bg(bg); + + // Fixed proportional sizing. Height is forced even so each animation step + // expands the window by exactly 2 rows (one on each side from the center). + let outer_w = area.width / 2; + let outer_h = (area.height * 3 / 4) & !1; + + // Animate height: keep it even at every intermediate frame. + let actual_h = ((outer_h as f32 * progress).round() as u16 & !1).max(2); + + let left = area.x + area.width.saturating_sub(outer_w) / 2; + let top = area.y + area.height.saturating_sub(actual_h) / 2; + let overlay_rect = Rect { x: left, y: top, width: outer_w, height: actual_h }; + + // Build Lines without pre-wrapping — Paragraph handles wrapping at render time. + // Lines whose source starts with whitespace are stripped and centered; others are left-aligned. + let mut ratatui_lines: Vec = Vec::new(); + let mut choice_letter = b'a'; + for item in lines { + match item { + ScrollLine::Text(s) => { + let text = s.trim(); + let line = Line::styled(text.to_string(), text_style); + ratatui_lines.push(if s.starts_with(|c: char| c.is_ascii_whitespace()) { + line.centered() + } else { + line + }); + } + ScrollLine::Choice { display, .. } => { + let key = choice_letter as char; + ratatui_lines.push(Line::from(vec![ + Span::styled(format!("[{key}] "), key_style), + Span::styled(display.clone(), choice_style), + ])); + choice_letter += 1; + } + } + } + + // Dim the whole background so board glyphs don't show through. + let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_style(dim_style); + } + } + } + Fill::new(" ").style(Style::default().bg(bg)).render(overlay_rect, buf); + + let block = Block::bordered().border_style(border_style).style(Style::default().bg(bg)); + let inner = block.inner(overlay_rect); + block.render(overlay_rect, buf); + + let [content_area, scrollbar_area] = Layout::horizontal([ + Constraint::Min(0), + Constraint::Length(1), + ]).areas(inner); + + // Measure wrapped content height, clamp scroll offset. + let offset = state.as_ref().map(|s| s.offset).unwrap_or(0); + let para = Paragraph::new(ratatui_lines).wrap(Wrap { trim: false }); + let content_lines = para.line_count(content_area.width) as u16; + let max_scroll = content_lines.saturating_sub(content_area.height); + let clamped = offset.min(max_scroll); + let para = para.scroll((clamped, 0)); + + // Center content vertically when it's shorter than the window. + let pad = content_area.height.saturating_sub(content_lines) / 2; + let [_, content_rect, _] = Layout::vertical([ + Constraint::Length(pad), + Constraint::Length(content_lines.min(content_area.height)), + Constraint::Fill(1), + ]).areas(content_area); + para.render(content_rect, buf); + + if content_lines > content_area.height { + let mut sb_state = ScrollbarState::new((max_scroll + 1) as usize) + .position(clamped as usize); + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .render(scrollbar_area, buf, &mut sb_state); + } + + if let Some(s) = state { s.max_scroll = max_scroll; } +} diff --git a/kiln-tui/src/transition.rs b/kiln-tui/src/transition.rs index d1679bf..37c4fd4 100644 --- a/kiln-tui/src/transition.rs +++ b/kiln-tui/src/transition.rs @@ -1,9 +1,10 @@ //! Board-change wipe animation. //! //! When the player steps through a portal both the old and new boards are rendered -//! into off-screen ratatui [`Buffer`]s at init time. Each frame [`TransitionAnimation::update`] -//! advances the LFSR-driven reveal bitmap, and the [`Widget`] impl blends the two -//! buffers — old cells for unrevealed positions, new cells for revealed ones. +//! into off-screen ratatui [`Buffer`]s at init time. Each call to +//! [`Animation::update`] advances the LFSR-driven reveal bitmap, and +//! [`Animation::draw`] blends the two buffers — old cells for unrevealed positions, +//! new cells for revealed ones. use kiln_core::Board; use ratatui::{ @@ -11,6 +12,8 @@ use ratatui::{ layout::Rect, widgets::Widget, }; +use crate::animation::{Animation, AnimationLayer}; +use crate::input::InputMode; use crate::render::BoardWidget; /// How long the wipe lasts in seconds. @@ -49,9 +52,8 @@ fn lfsr_sequence(total: usize) -> Vec { /// A board-to-board wipe animation driven by an LFSR. /// /// Both boards are rendered into off-screen [`Buffer`]s once at construction. -/// Each call to [`update`](TransitionAnimation::update) marks more screen cells -/// as "revealed" (using the pre-computed LFSR order); the [`Widget`] impl blends -/// old and new buffers cell by cell. +/// Implements [`Animation`]: [`update`](Animation::update) marks newly-due cells +/// as revealed each frame; [`draw`](Animation::draw) blends old and new buffers. pub struct TransitionAnimation { /// Pre-rendered old board. old_buf: Buffer, @@ -64,13 +66,13 @@ pub struct TransitionAnimation { /// How many entries of `reveal_order` are currently revealed. revealed_count: usize, /// Elapsed seconds since the animation started. - pub elapsed: f32, + elapsed: f32, } impl TransitionAnimation { /// Render both boards into off-screen buffers and build the LFSR sequence. /// - /// `area` must match the area that will be passed to the widget's `render` call. + /// `area` must match the area that will be passed to [`Animation::draw`]. pub fn new(old: &Board, new: &Board, area: Rect) -> Self { let total = area.width as usize * area.height as usize; @@ -91,11 +93,10 @@ impl TransitionAnimation { elapsed: 0.0, } } +} - /// Advance the animation by `dt` seconds, marking newly-due cells as revealed. - /// - /// Returns `true` when the animation is complete (all cells revealed). - pub fn update(&mut self, dt: f32) -> bool { +impl Animation for TransitionAnimation { + fn update(&mut self, dt: f32) -> bool { self.elapsed += dt; let total = self.revealed.len(); let target = @@ -110,10 +111,8 @@ impl TransitionAnimation { self.elapsed >= TRANSITION_DURATION } -} -impl Widget for &TransitionAnimation { - fn render(self, area: Rect, buf: &mut Buffer) { + fn draw(&self, area: Rect, buf: &mut Buffer) { let w = area.width as usize; for row in 0..area.height { for col in 0..area.width { @@ -131,4 +130,8 @@ impl Widget for &TransitionAnimation { } } } + + fn layer(&self) -> AnimationLayer { AnimationLayer::Board } + + fn input_mode_after(&self) -> InputMode { InputMode::Board } } diff --git a/kiln-tui/src/ui.rs b/kiln-tui/src/ui.rs index abba80b..f0d3348 100644 --- a/kiln-tui/src/ui.rs +++ b/kiln-tui/src/ui.rs @@ -3,28 +3,26 @@ use std::rc::Rc; use std::time::Duration; use kiln_core::Board; use kiln_core::game::GameState; +use crate::animation::Animation; use crate::log::LogState; -use crate::scroll_overlay::{ScrollAnimState, ScrollOverlayState}; +use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState}; use crate::transition::TransitionAnimation; /// A pair of board `Rc`s (old board, new board) queued for transition pre-rendering. type PendingTransition = Option<(Rc>, Rc>)>; -/// View-only UI state for the log panel, scroll overlay, and board transitions. +/// View-only UI state for the log panel, scroll overlay, and board animations. /// Presentation-only state; lives in the front-end, not on [`GameState`]. pub(crate) struct Ui { /// State for the log panel (open/closed, height, scroll position). pub(crate) log: LogState, - /// Scroll position, bounds, and animation state for an active scroll overlay. + /// Scroll offset and bounds for the fully-open scroll overlay. pub(crate) overlay: ScrollOverlayState, /// Old and new board Rcs waiting to be pre-rendered on the next `draw()` call. /// Populated when a portal transition is detected; consumed in `draw()`. pub(crate) pending_transition: PendingTransition, - /// Active board-wipe animation. While `Some`, ticks and input are suppressed. - pub(crate) transition: Option, - /// Set when the wipe completes; puts the game in [`InputMode::Frozen`] until the - /// player presses any key. - pub(crate) post_transition_wait: bool, + /// The currently running animation, if any. All input is suppressed while `Some`. + pub(crate) active_animation: Option>, } impl Default for Ui { @@ -33,8 +31,7 @@ impl Default for Ui { log: LogState::new(), overlay: ScrollOverlayState::default(), pending_transition: None, - transition: None, - post_transition_wait: false, + active_animation: None, } } } @@ -48,48 +45,54 @@ impl Ui { /// Advances all time-driven UI state by `dt` and drives the game tick. /// - /// Board-wipe transition and post-transition freeze take priority: while either - /// is active the game does not tick and no scroll overlay is processed. Once the - /// wipe completes, `post_transition_wait` is set so input stays frozen until the - /// player presses any key. Normal scroll-overlay handling resumes after that. + /// While any animation is running, `game.tick` is suppressed. When an + /// animation finishes, [`input_mode_after`](Animation::input_mode_after) is + /// consulted: returning to [`Board`](crate::input::InputMode::Board) mode + /// immediately calls [`handle_scroll`](GameState::handle_scroll) so the + /// closed overlay is cleared before the next draw rather than flickering open + /// for one extra frame. pub(crate) fn tick(&mut self, dt: Duration, game: &mut GameState) { - // Advance the wipe animation; transition to frozen state when done. - if let Some(ref mut tr) = self.transition { - if tr.update(dt.as_secs_f32()) { - self.transition = None; - self.post_transition_wait = true; + // Advance the active animation; on completion, dispatch any mode-change effect. + if let Some(ref mut anim) = self.active_animation { + if anim.update(dt.as_secs_f32()) { + let mode = anim.input_mode_after(); + self.active_animation = None; + // Returning to Board mode: eagerly close any scroll so it isn't + // rendered as open on the next frame before the game tick runs. + if matches!(mode, crate::input::InputMode::Board) { + game.handle_scroll(); + } } - return; // no game tick during the wipe + return; // no game tick during any animation (even when animation just ended) } - // Game stays frozen until the player presses any key. - if self.post_transition_wait { - return; - } - - // Normal path: advance the scroll overlay and tick the game. - self.overlay.advance_anim(dt.as_secs_f32()); - if self.overlay.close_finished() { - game.close_scroll(self.overlay.take_choice().as_deref()); - self.overlay = ScrollOverlayState::default(); - } - - if game.active_scroll.is_none() { - game.tick(dt); - if game.active_scroll.is_some() && self.overlay.anim.is_none() { - self.overlay.anim = Some(ScrollAnimState::Opening(0.0)); - } + // Normal path: tick the game, then check if a scroll appeared. + game.tick(dt); + if let Some(scroll) = &game.active_scroll { + let lines = scroll.lines.clone(); + self.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines))); } } - /// Starts the scroll-close animation with the optional player choice. - /// Uses the current opening progress if the overlay was still animating in. - pub(crate) fn begin_close(&mut self, choice: Option) { - let progress = match self.overlay.anim { - Some(ScrollAnimState::Opening(p)) => p.min(1.0), - _ => 1.0, - }; - self.overlay.pending_choice = choice; - self.overlay.anim = Some(ScrollAnimState::Closing(progress)); + /// Sets the player's scroll choice and starts the close animation. + /// + /// `choice` is `None` when the player dismisses without selecting; it is + /// stored on the active scroll and dispatched by the engine on the next tick. + pub(crate) fn begin_close(&mut self, choice: Option, game: &mut GameState) { + if let Some(scroll) = game.active_scroll.as_mut() { + scroll.choice = choice; + } + let lines = game.active_scroll.as_ref() + .map(|s| s.lines.clone()) + .unwrap_or_default(); + self.active_animation = Some(Box::new(ScrollCloseAnimation::new(lines))); + } + + /// Initialises the board-wipe transition animation once the draw area is known. + /// + /// Called from `draw_board_area` when [`pending_transition`](Ui::pending_transition) + /// is set, so the [`TransitionAnimation`] is sized to the exact inner board area. + pub(crate) fn start_transition(&mut self, old: &Board, new: &Board, area: ratatui::layout::Rect) { + self.active_animation = Some(Box::new(TransitionAnimation::new(old, new, area))); } }