transition
This commit is contained in:
+31
-6
@@ -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()),
|
||||
|
||||
+36
-8
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u32> {
|
||||
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<u32>,
|
||||
/// Which cells have been revealed (show `new_buf`).
|
||||
revealed: Vec<bool>,
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-5
@@ -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<RefCell<Board>>, Rc<RefCell<Board>>)>;
|
||||
|
||||
/// 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<TransitionAnimation>,
|
||||
/// 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());
|
||||
|
||||
Reference in New Issue
Block a user