diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index e7556a3..c0e3578 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -128,6 +128,15 @@ impl GameState { &self.current_board_name } + /// Returns a clone of the `Rc` for the active board. + /// + /// Lets a front-end hold a reference to the current board across a board + /// transition — the old board stays alive in `world.boards`, so the clone + /// keeps it reachable even after `current_board_name` changes. + pub fn board_rc(&self) -> std::rc::Rc> { + self.world.boards[&self.current_board_name].clone() + } + /// Appends a styled message to the log. pub fn log(&mut self, line: LogLine) { self.log.push(line); diff --git a/kiln-tui/src/input.rs b/kiln-tui/src/input.rs index 0ec4390..23a67c8 100644 --- a/kiln-tui/src/input.rs +++ b/kiln-tui/src/input.rs @@ -4,20 +4,45 @@ 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 +/// and stores them in [`Ui::pending_transition`] for the next draw call. +fn try_move_with_transition(game: &mut GameState, ui: &mut Ui, dir: Direction) { + let old_rc = game.board_rc(); + let old_name = game.current_board_name().to_string(); + game.try_move(dir); + if game.current_board_name() != old_name { + // Portal crossed: queue transition; consume the engine-side signal. + ui.pending_transition = Some((old_rc, game.board_rc())); + game.board_transition = None; + } +} + /// 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(bool), + 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. + 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; + } + // 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(ui.log.open) } + if scroll_active { InputMode::Scroll } else { InputMode::Board } } /// Handles an input event in [`InputMode::Board`]. Returns `true` if the player quit. @@ -25,10 +50,10 @@ pub(crate) fn handle_board_input(event: Event, log_open: bool, terminal_h: u16, match event { Event::Key(key) => match key.code { KeyCode::Char('q') | KeyCode::Esc => return true, - KeyCode::Up => game.try_move(Direction::North), - KeyCode::Down => game.try_move(Direction::South), - KeyCode::Left => game.try_move(Direction::West), - KeyCode::Right => game.try_move(Direction::East), + KeyCode::Up => try_move_with_transition(game, ui, Direction::North), + KeyCode::Down => try_move_with_transition(game, ui, Direction::South), + KeyCode::Left => try_move_with_transition(game, ui, Direction::West), + KeyCode::Right => try_move_with_transition(game, ui, Direction::East), KeyCode::Char('l') => ui.log.toggle(), KeyCode::PageUp if log_open => ui.log.scroll_by(-5, game.log.len()), KeyCode::PageDown if log_open => ui.log.scroll_by(5, game.log.len()), diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index eace6c3..a3a4c9f 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -12,6 +12,7 @@ mod log; mod render; mod scroll_overlay; mod term; +mod transition; mod utils; mod speech; mod ui; @@ -25,7 +26,7 @@ use ratatui::crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, }; use ratatui::crossterm::execute; -use ratatui::layout::{Constraint, Layout, Spacing}; +use ratatui::layout::{Constraint, Layout, Rect, Spacing}; use ratatui::symbols::merge::MergeStrategy; use ratatui::widgets::Block; use render::BoardWidget; @@ -37,6 +38,7 @@ use crate::input::{current_input_mode, handle_board_input, handle_scroll_input, 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 @@ -130,12 +132,15 @@ fn run( } let terminal_h = terminal.size()?.height; match current_input_mode(game, ui) { - InputMode::Board(log_open) => { - if handle_board_input(event, log_open, terminal_h, game, ui) { + InputMode::Board | InputMode::Frozen => { + 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 => {} } } @@ -170,8 +175,7 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) { .merge_borders(MergeStrategy::Exact); let inner = block.inner(board_area); frame.render_widget(block, board_area); - frame.render_widget(BoardWidget::new(board), inner); - frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner); + draw_board_area(frame, inner, game, ui); frame.render_stateful_widget(LogWidget::new(&game.log), log_area, &mut ui.log); } else { // Panel closed: board fills the screen, latest message in the border. @@ -179,14 +183,38 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) { block = block.title_bottom(log_preview_line(&game.log).left_aligned()); let inner = block.inner(frame.area()); frame.render_widget(block, frame.area()); - frame.render_widget(BoardWidget::new(board), inner); - frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner); + draw_board_area(frame, inner, game, ui); } // Scroll overlay: drawn last so it sits above all other content. - if let Some(scroll) = &game.active_scroll { + // 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. let area = frame.area(); frame.render_stateful_widget(ScrollOverlayWidget::new(scroll), area, &mut ui.overlay); } } + +/// Renders the inner board area: either the active wipe 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. +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, + )); + } + + if let Some(ref tr) = ui.transition { + // Wipe in progress: blend old and new board buffers; suppress speech bubbles. + frame.render_widget(tr, inner); + } else { + let board = &game.board(); + frame.render_widget(BoardWidget::new(board), inner); + frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner); + } +} diff --git a/kiln-tui/src/transition.rs b/kiln-tui/src/transition.rs new file mode 100644 index 0000000..d1679bf --- /dev/null +++ b/kiln-tui/src/transition.rs @@ -0,0 +1,134 @@ +//! 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. + +use kiln_core::Board; +use ratatui::{ + buffer::Buffer, + layout::Rect, + widgets::Widget, +}; +use crate::render::BoardWidget; + +/// How long the wipe lasts in seconds. +pub const TRANSITION_DURATION: f32 = 0.25; + +/// Advance a 16-bit Galois LFSR one step. +/// +/// Polynomial 0xB400 gives a maximal period of 65 535, covering every realistic +/// terminal viewport (e.g. 256 × 200 = 51 200 cells). +fn lfsr_step(state: u16) -> u16 { + let lsb = state & 1; + let s = state >> 1; + if lsb != 0 { s ^ 0xB400 } else { s } +} + +/// Build a permutation of `[0, total)` using the 16-bit LFSR. +/// +/// The LFSR visits every value in `1..=65535` exactly once per cycle. +/// Mapping `state → state - 1` gives `0..=65534`; values in `[0, total)` are +/// kept in encounter order. `total` must not exceed 65 535. +fn lfsr_sequence(total: usize) -> Vec { + let mut seq = Vec::with_capacity(total); + let mut state: u16 = 1; // non-zero start; LFSR never visits 0 + loop { + let idx = state as u32 - 1; + if (idx as usize) < total { + seq.push(idx); + if seq.len() == total { break; } + } + state = lfsr_step(state); + if state == 1 { break; } // full cycle — shouldn't happen for total <= 65535 + } + seq +} + +/// 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. +pub struct TransitionAnimation { + /// Pre-rendered old board. + old_buf: Buffer, + /// Pre-rendered new board. + new_buf: Buffer, + /// LFSR-ordered permutation of screen-cell indices `[0, width×height)`. + reveal_order: Vec, + /// Which cells have been revealed (show `new_buf`). + revealed: Vec, + /// How many entries of `reveal_order` are currently revealed. + revealed_count: usize, + /// Elapsed seconds since the animation started. + pub 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. + pub fn new(old: &Board, new: &Board, area: Rect) -> Self { + let total = area.width as usize * area.height as usize; + + let mut old_buf = Buffer::empty(area); + BoardWidget::new(old).render(area, &mut old_buf); + + let mut new_buf = Buffer::empty(area); + BoardWidget::new(new).render(area, &mut new_buf); + + let reveal_order = lfsr_sequence(total); + + Self { + old_buf, + new_buf, + reveal_order, + revealed: vec![false; total], + revealed_count: 0, + 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 { + self.elapsed += dt; + let total = self.revealed.len(); + let target = + ((self.elapsed / TRANSITION_DURATION) * total as f32) as usize; + let target = target.min(total); + + // Mark each newly revealed cell in the bitmap. + for &idx in &self.reveal_order[self.revealed_count..target] { + self.revealed[idx as usize] = true; + } + self.revealed_count = target; + + self.elapsed >= TRANSITION_DURATION + } +} + +impl Widget for &TransitionAnimation { + fn render(self, area: Rect, buf: &mut Buffer) { + let w = area.width as usize; + for row in 0..area.height { + for col in 0..area.width { + let idx = row as usize * w + col as usize; + // Out-of-range cells (e.g. after a terminal resize) default to new board. + let src = if self.revealed.get(idx).copied().unwrap_or(true) { + &self.new_buf + } else { + &self.old_buf + }; + let pos = (area.x + col, area.y + row); + if let (Some(s), Some(d)) = (src.cell(pos), buf.cell_mut(pos)) { + *d = s.clone(); + } + } + } + } +} diff --git a/kiln-tui/src/ui.rs b/kiln-tui/src/ui.rs index 3c8eb41..abba80b 100644 --- a/kiln-tui/src/ui.rs +++ b/kiln-tui/src/ui.rs @@ -1,15 +1,30 @@ +use std::cell::RefCell; +use std::rc::Rc; use std::time::Duration; +use kiln_core::Board; use kiln_core::game::GameState; use crate::log::LogState; use crate::scroll_overlay::{ScrollAnimState, ScrollOverlayState}; +use crate::transition::TransitionAnimation; -/// View-only UI state for the log panel and scroll overlay. This is presentation -/// state, so it lives in the front-end rather than on the engine's [`GameState`]. +/// 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. +/// 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. 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, } impl Default for Ui { @@ -17,6 +32,9 @@ impl Default for Ui { Self { log: LogState::new(), overlay: ScrollOverlayState::default(), + pending_transition: None, + transition: None, + post_transition_wait: false, } } } @@ -30,10 +48,26 @@ impl Ui { /// Advances all time-driven UI state by `dt` and drives the game tick. /// - /// Advances the scroll overlay animation, dispatches any finished close - /// animation, ticks the game when no overlay is blocking, and starts the - /// open animation when a new scroll appears. + /// 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. 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; + } + return; // no game tick during the wipe + } + + // 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()); diff --git a/maps/start.toml b/maps/start.toml index fd60060..fe8f1e3 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -24,7 +24,11 @@ fn tick(dt) { mover = """ fn init() { if Registry.get("dancer_x") != () { - teleport(Registry.get("dancer_x"), Registry.get("dancer_y")); + let new_x = Registry.get("dancer_x"); + let new_y = Registry.get("dancer_y"); + if Me.x != new_x || Me.y != new_y { + teleport(new_x, new_y); + } } else { Registry.set("dancer_x", Me.x); Registry.set("dancer_y", Me.y);