animations
This commit is contained in:
@@ -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),
|
||||
|
||||
+27
-16
@@ -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<ScrollLine>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<SpeechBubble>,
|
||||
/// 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<Scroll>,
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
-25
@@ -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<String> {
|
||||
|
||||
/// Handles an input event in [`InputMode::Scroll`].
|
||||
pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut Ui) {
|
||||
let lines: Vec<ScrollLine> = 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),
|
||||
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(&scroll.lines, c) {
|
||||
ui.begin_close(Some(ch));
|
||||
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),
|
||||
|
||||
+28
-20
@@ -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);
|
||||
|
||||
+117
-101
@@ -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<ScrollAnimState>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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,14 +50,94 @@ 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;
|
||||
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<ScrollLine>,
|
||||
/// 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<ScrollLine>) -> 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<ScrollLine>,
|
||||
/// 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<ScrollLine>) -> 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;
|
||||
}
|
||||
|
||||
@@ -127,23 +152,23 @@ impl StatefulWidget for ScrollOverlayWidget<'_> {
|
||||
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);
|
||||
// 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 centered; others are left-aligned.
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
// Lines whose source starts with whitespace are stripped and centered; others are left-aligned.
|
||||
let mut ratatui_lines: Vec<Line> = Vec::new();
|
||||
let mut choice_letter = b'a';
|
||||
for item in &self.scroll.lines {
|
||||
for item in 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()) {
|
||||
ratatui_lines.push(if s.starts_with(|c: char| c.is_ascii_whitespace()) {
|
||||
line.centered()
|
||||
} else {
|
||||
line
|
||||
@@ -151,7 +176,7 @@ impl StatefulWidget for ScrollOverlayWidget<'_> {
|
||||
}
|
||||
ScrollLine::Choice { display, .. } => {
|
||||
let key = choice_letter as char;
|
||||
lines.push(Line::from(vec![
|
||||
ratatui_lines.push(Line::from(vec![
|
||||
Span::styled(format!("[{key}] "), key_style),
|
||||
Span::styled(display.clone(), choice_style),
|
||||
]));
|
||||
@@ -160,8 +185,7 @@ impl StatefulWidget for ScrollOverlayWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
// Dim the whole background, then use Fill to paint the overlay area with solid
|
||||
// background-colored spaces so board glyphs can't show through.
|
||||
// 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() {
|
||||
@@ -172,7 +196,6 @@ impl StatefulWidget for ScrollOverlayWidget<'_> {
|
||||
}
|
||||
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);
|
||||
@@ -182,12 +205,12 @@ impl StatefulWidget for ScrollOverlayWidget<'_> {
|
||||
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 });
|
||||
// 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 = state.offset.min(max_scroll);
|
||||
let clamped = offset.min(max_scroll);
|
||||
let para = para.scroll((clamped, 0));
|
||||
|
||||
// Center content vertically when it's shorter than the window.
|
||||
@@ -199,12 +222,6 @@ impl StatefulWidget for ScrollOverlayWidget<'_> {
|
||||
]).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);
|
||||
@@ -212,6 +229,5 @@ impl StatefulWidget for ScrollOverlayWidget<'_> {
|
||||
.render(scrollbar_area, buf, &mut sb_state);
|
||||
}
|
||||
|
||||
state.max_scroll = max_scroll;
|
||||
}
|
||||
if let Some(s) = state { s.max_scroll = max_scroll; }
|
||||
}
|
||||
|
||||
+18
-15
@@ -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<u32> {
|
||||
/// 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 }
|
||||
}
|
||||
|
||||
+48
-45
@@ -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<RefCell<Board>>, Rc<RefCell<Board>>)>;
|
||||
|
||||
/// 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<TransitionAnimation>,
|
||||
/// 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<Box<dyn Animation>>,
|
||||
}
|
||||
|
||||
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() {
|
||||
// Normal path: tick the game, then check if a scroll appeared.
|
||||
game.tick(dt);
|
||||
if game.active_scroll.is_some() && self.overlay.anim.is_none() {
|
||||
self.overlay.anim = Some(ScrollAnimState::Opening(0.0));
|
||||
}
|
||||
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<String>) {
|
||||
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<String>, 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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user