This commit is contained in:
2026-06-15 10:42:21 -05:00
parent 32aef1ea08
commit 439fc44106
6 changed files with 445 additions and 74 deletions
+38 -1
View File
@@ -1,6 +1,7 @@
use ratatui::crossterm::event::{Event, KeyCode, MouseEventKind};
use kiln_core::Direction;
use kiln_core::game::{GameState, ScrollLine};
use crate::menu::{MenuItem, MenuKey, pause_menu_items, ActionRc as MenuActionRc};
use crate::ui::Ui;
/// Moves the player and, if the move crosses a portal, captures both board Rcs
@@ -22,6 +23,8 @@ pub enum InputMode {
Board,
/// A scroll overlay is active; all input is captured for scroll navigation.
Scroll,
/// A menu overlay is active; all input is captured for menu navigation.
Menu,
/// An animation is running; all input is discarded.
Animating,
/// Reserved for a post-transition freeze; nothing currently produces this mode.
@@ -35,6 +38,7 @@ pub(crate) fn current_input_mode(game: &GameState, ui: &Ui) -> InputMode {
if ui.active_animation.is_some() || ui.pending_transition.is_some() {
return InputMode::Animating;
}
if ui.menu.is_some() { return InputMode::Menu; }
if game.active_scroll.is_some() { InputMode::Scroll } else { InputMode::Board }
}
@@ -42,7 +46,7 @@ pub(crate) fn current_input_mode(game: &GameState, ui: &Ui) -> InputMode {
pub(crate) fn handle_board_input(event: Event, log_open: bool, terminal_h: u16, game: &mut GameState, ui: &mut Ui) -> bool {
match event {
Event::Key(key) => match key.code {
KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Esc => ui.open_menu(pause_menu_items()),
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),
@@ -107,3 +111,36 @@ pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut U
_ => {}
}
}
/// Handles an input event in [`InputMode::Menu`].
///
/// Uses the clone-then-dispatch pattern: the action `Rc` is cloned to release
/// the immutable borrow on `ui.menu`, then the action is called with full `&mut Ui`.
pub(crate) fn handle_menu_input(event: Event, game: &mut GameState, ui: &mut Ui) {
match event {
Event::Key(key) => {
let pressed = match key.code {
KeyCode::Esc => Some(MenuKey::Esc),
KeyCode::Char(c) => Some(MenuKey::Char(c)),
KeyCode::Up => { ui.scroll_menu(-1); None }
KeyCode::Down => { ui.scroll_menu(1); None }
_ => None,
};
if let Some(mk) = pressed {
// Clone the Rc to release the borrow on ui.menu before calling the action.
let action: Option<MenuActionRc> = ui.menu.as_ref()
.and_then(|m| m.items.iter().find(|i| i.key == mk))
.map(|i| i.action_rc());
if let Some(action) = action {
MenuItem::call_action_rc(action, game, ui);
}
}
}
Event::Mouse(m) => match m.kind {
MouseEventKind::ScrollUp => ui.scroll_menu(-1),
MouseEventKind::ScrollDown => ui.scroll_menu(1),
_ => {}
},
_ => {}
}
}
+17 -14
View File
@@ -10,6 +10,8 @@
mod animation;
mod cp437;
mod log;
mod menu;
mod overlay;
mod render;
mod scroll_overlay;
mod term;
@@ -36,8 +38,9 @@ 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::input::{current_input_mode, handle_board_input, handle_menu_input, handle_scroll_input, InputMode};
use crate::log::{log_preview_line, LogWidget};
use crate::menu::MenuWidget;
use crate::scroll_overlay::ScrollOverlayWidget;
use crate::speech::SpeechBubblesWidget;
use crate::ui::Ui;
@@ -75,10 +78,7 @@ fn main() -> ExitCode {
let _ = term::push_kitty_flags();
}
// Seed the log: the terminal-capabilities badge plus a couple of test
// messages (temporary, just to exercise the panel).
// Note these will appear in reverse order, latest on top
game.log(LogLine::raw("Press 'q' to exit."));
game.log(LogLine::raw("[esc] for menu"));
game.log(LogLine::raw("Welcome to kiln - ").append(caps.status_logline()));
// Now that the board is fully loaded and the terminal is ready, run each
@@ -134,10 +134,9 @@ fn run(
let terminal_h = terminal.size()?.height;
match current_input_mode(game, ui) {
InputMode::Board | InputMode::Frozen => {
if handle_board_input(event, ui.log.open, terminal_h, game, ui) {
return Ok(());
}
handle_board_input(event, ui.log.open, terminal_h, game, ui);
}
InputMode::Menu => handle_menu_input(event, game, ui),
InputMode::Scroll => handle_scroll_input(event, game, ui),
// Animation running — discard all events.
InputMode::Animating => {}
@@ -152,13 +151,18 @@ fn run(
last_tick = Instant::now();
ui.tick(dt, game);
}
// A menu action may have set should_quit; exit once any close animation finishes.
if ui.should_quit && ui.active_animation.is_none() {
return Ok(());
}
}
}
/// 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 or overlay animation is drawn on top of everything when active.
/// Overlays (menu, scroll, animations) are drawn on top of everything.
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,18 +190,17 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
draw_board_area(frame, inner, game, ui);
}
// 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.
// Overlay layer: animation > menu > scroll (mutually exclusive at runtime).
let is_overlay_anim = ui.active_animation.as_ref()
.map(|a| matches!(a.layer(), AnimationLayer::Overlay))
.unwrap_or(false);
let area = frame.area();
if is_overlay_anim {
let area = frame.area();
frame.render_widget(AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()), area);
} else if let Some(menu) = ui.menu.as_mut() {
frame.render_stateful_widget(MenuWidget, area, menu);
} else if let Some(scroll) = &game.active_scroll {
let area = frame.area();
frame.render_stateful_widget(ScrollOverlayWidget::new(&scroll.lines), area, &mut ui.overlay);
}
}
+256
View File
@@ -0,0 +1,256 @@
//! In-game menu overlay: [`MenuItem`], [`MenuState`], animations, and the pause menu definition.
//!
//! Menus share the same [`Animation`](crate::animation::Animation) pipeline and overlay-frame
//! rendering as scroll overlays, but use a blue color scheme and a shorter fixed height.
//! Item actions are stored as `Rc<dyn Fn>` so they can be cheaply cloned without duplicating
//! the closure body — this lets [`handle_menu_input`](crate::input::handle_menu_input) clone
//! the action reference before releasing the borrow on [`Ui`](crate::ui::Ui).
use std::rc::Rc;
use kiln_core::game::GameState;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget,
};
use crate::animation::{Animation, AnimationLayer};
use crate::input::InputMode;
use crate::overlay::{OverlayStyle, render_overlay_frame};
use crate::ui::Ui;
/// Shared-ownership reference to a menu item action closure.
pub(crate) type ActionRc = Rc<dyn Fn(&mut GameState, &mut Ui)>;
/// Duration of the menu open/close animations, in seconds.
const ANIM_SECS: f32 = 0.25;
/// Visual style for menus: cool blue on dark navy.
const MENU_STYLE: OverlayStyle = OverlayStyle {
bg: Color::Rgb(10, 15, 35),
border_fg: Color::Rgb(80, 130, 210),
width_div: 2,
height_fraction: 1.0 / 3.0,
};
/// A key binding that activates a [`MenuItem`].
#[derive(Clone, PartialEq, Debug)]
pub enum MenuKey {
/// A single character key.
Char(char),
/// The Escape key.
Esc,
}
/// A single entry in a menu overlay.
///
/// The `action` closure is stored behind `Rc` so cloning a `MenuItem` shares the
/// closure rather than copying it, keeping [`MenuState`] cheap to snapshot for
/// the close animation.
pub struct MenuItem {
/// The key the player presses to activate this item.
pub key: MenuKey,
/// Display label shown in the menu.
pub label: String,
/// Called when the player presses `key`. Typically calls
/// [`Ui::begin_close_menu`] and/or sets [`Ui::should_quit`].
action: ActionRc,
}
impl MenuItem {
/// Creates a menu item with the given key, label, and action closure.
pub fn new(
key: MenuKey,
label: impl Into<String>,
action: impl Fn(&mut GameState, &mut Ui) + 'static,
) -> Self {
Self { key, label: label.into(), action: Rc::new(action) }
}
/// Returns a cloned `Rc` to the action closure so the caller can release any
/// immutable borrow on the item collection before invoking the action.
pub fn action_rc(&self) -> ActionRc {
Rc::clone(&self.action)
}
/// Invokes an already-cloned action `Rc`. The caller must have released any
/// borrow of [`Ui`] before calling this.
pub fn call_action_rc(action: ActionRc, game: &mut GameState, ui: &mut Ui) {
action(game, ui);
}
}
impl Clone for MenuItem {
fn clone(&self) -> Self {
Self {
key: self.key.clone(),
label: self.label.clone(),
action: Rc::clone(&self.action),
}
}
}
/// Persistent state for an active menu overlay.
#[derive(Default)]
pub struct MenuState {
/// The items shown in the menu.
pub items: Vec<MenuItem>,
/// Current scroll offset (input during render).
pub offset: u16,
/// Maximum valid scroll offset (output from render).
pub max_scroll: u16,
}
/// A ratatui [`StatefulWidget`] that draws the fully-open menu overlay.
///
/// Used in [`InputMode::Menu`](crate::input::InputMode::Menu) when no
/// open/close animation is active. All data is read from [`MenuState`].
pub struct MenuWidget;
impl StatefulWidget for MenuWidget {
type State = MenuState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut MenuState) {
render_menu_at(&state.items, 1.0, state.offset, area, buf, Some(&mut state.max_scroll));
}
}
/// Opens a menu overlay with an animated reveal.
///
/// When complete, [`input_mode_after`](Animation::input_mode_after) returns
/// [`InputMode::Menu`] so the player can interact with the overlay.
pub struct MenuOpenAnimation {
items: Vec<MenuItem>,
progress: f32,
}
impl MenuOpenAnimation {
/// Creates a new open animation from a snapshot of the menu items.
pub fn new(items: Vec<MenuItem>) -> Self {
Self { items, progress: 0.0 }
}
}
impl Animation for MenuOpenAnimation {
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_menu_at(&self.items, self.progress.clamp(0.0, 1.0), 0, area, buf, None);
}
fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay }
fn input_mode_after(&self) -> InputMode { InputMode::Menu }
}
/// Closes a menu overlay with an animated collapse.
///
/// When complete, [`input_mode_after`](Animation::input_mode_after) returns
/// [`InputMode::Board`] and [`MenuState`] is cleared by [`Ui::tick`].
pub struct MenuCloseAnimation {
items: Vec<MenuItem>,
progress: f32,
}
impl MenuCloseAnimation {
/// Creates a new close animation from a snapshot of the menu items.
pub fn new(items: Vec<MenuItem>) -> Self {
Self { items, progress: 1.0 }
}
}
impl Animation for MenuCloseAnimation {
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_menu_at(&self.items, self.progress.clamp(0.0, 1.0), 0, area, buf, None);
}
fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay }
fn input_mode_after(&self) -> InputMode { InputMode::Board }
}
/// Renders the menu overlay at a given animation progress.
///
/// Uses [`render_overlay_frame`] for the window/dimming/border, then fills
/// the content area with one line per item (`[key] label`). When `out_max_scroll`
/// is `Some`, writes the maximum valid scroll offset for input clamping.
fn render_menu_at(
items: &[MenuItem],
progress: f32,
offset: u16,
area: Rect,
buf: &mut Buffer,
out_max_scroll: Option<&mut u16>,
) {
let Some((content_area, scrollbar_area)) =
render_overlay_frame(&MENU_STYLE, progress, area, buf)
else {
if let Some(m) = out_max_scroll { *m = 0; }
return;
};
let key_style = Style::default().fg(Color::Rgb(150, 200, 255)).bg(MENU_STYLE.bg);
let label_style = Style::default().fg(Color::White).bg(MENU_STYLE.bg);
let lines: Vec<Line> = items.iter().map(|item| {
let key_text = match &item.key {
MenuKey::Char(c) => format!("[{c}] "),
MenuKey::Esc => "[esc] ".to_string(),
};
Line::from(vec![
Span::styled(key_text, key_style),
Span::styled(item.label.clone(), label_style),
])
}).collect();
let content_lines = lines.len() as u16;
let max_scroll = content_lines.saturating_sub(content_area.height);
let clamped = offset.min(max_scroll);
// Each menu item is exactly one line — no word-wrap needed.
let para = Paragraph::new(lines).scroll((clamped, 0));
// Center items vertically when there are fewer items than the window height.
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);
use ratatui::widgets::Widget;
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(m) = out_max_scroll { *m = max_scroll; }
}
/// Returns the items for the standard pause menu.
///
/// Pressing `q` marks [`Ui::should_quit`] and starts the close animation.
/// Pressing `Esc` closes the menu without quitting.
pub fn pause_menu_items() -> Vec<MenuItem> {
vec![
MenuItem::new(MenuKey::Char('q'), "Quit game", |_g, ui| {
ui.should_quit = true;
ui.begin_close_menu();
}),
MenuItem::new(MenuKey::Esc, "Close menu", |_g, ui| {
ui.begin_close_menu();
}),
]
}
+73
View File
@@ -0,0 +1,73 @@
//! Shared window-frame rendering for full-screen overlays (scrolls, menus).
//!
//! [`render_overlay_frame`] handles background dimming, window sizing and
//! centering, animated height, and the bordered block. Callers fill the
//! returned content area with their own content.
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Fill, Widget};
/// Visual parameters for an animated overlay window.
///
/// All fields are `Copy` types so this can be a `const`.
pub(crate) struct OverlayStyle {
/// Solid background color filling the window interior.
pub bg: Color,
/// Foreground (border line) color.
pub border_fg: Color,
/// `outer_w = screen_width / width_div`. Use `2` for half-screen.
pub width_div: u16,
/// `outer_h = (screen_height * height_fraction) as u16`, forced even.
pub height_fraction: f32,
}
/// Dims the background, sizes and positions the animated window, fills it,
/// renders the bordered block, and returns `(content_rect, scrollbar_rect)`.
///
/// `progress` is 0.0 (invisible) → 1.0 (fully open). Height is animated
/// proportionally, always kept even so it expands by exactly 2 rows at a time
/// from the center. Returns `None` when `progress ≤ 0`.
pub(crate) fn render_overlay_frame(
style: &OverlayStyle,
progress: f32,
area: Rect,
buf: &mut Buffer,
) -> Option<(Rect, Rect)> {
if progress <= 0.0 { return None; }
let outer_w = area.width / style.width_div;
let outer_h = (area.height as f32 * style.height_fraction) as u16 & !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 };
// Dim all background cells so board/game content doesn'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(style.bg)).render(overlay_rect, buf);
let block = Block::bordered()
.border_style(Style::default().fg(style.border_fg).bg(style.bg))
.style(Style::default().bg(style.bg));
let inner = block.inner(overlay_rect);
block.render(overlay_rect, buf);
// Split inner into content (left) and scrollbar column (right).
let [content_rect, scrollbar_rect] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(1),
]).areas(inner);
Some((content_rect, scrollbar_rect))
}
+22 -49
View File
@@ -11,15 +11,23 @@ use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Fill, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget,
Widget, Wrap,
Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget, Wrap,
};
use crate::animation::{Animation, AnimationLayer};
use crate::input::InputMode;
use crate::overlay::{OverlayStyle, render_overlay_frame};
/// Duration of the open and close animations, in seconds.
const ANIM_SECS: f32 = 0.25;
/// Visual style for scroll overlays: warm amber on dark background.
const SCROLL_STYLE: OverlayStyle = OverlayStyle {
bg: Color::Rgb(30, 25, 10),
border_fg: Color::Rgb(180, 160, 100),
width_div: 2,
height_fraction: 0.75,
};
/// State threaded through a [`ScrollOverlayWidget`] render call.
#[derive(Default)]
pub struct ScrollOverlayState {
@@ -123,12 +131,11 @@ impl Animation for ScrollCloseAnimation {
fn input_mode_after(&self) -> InputMode { InputMode::Board }
}
/// Shared rendering for the scroll overlay at a given animation progress.
/// Renders the scroll overlay at a given animation progress using the shared
/// overlay frame, then fills the content area with the scroll lines.
///
/// `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).
/// When `state` is `Some`, `max_scroll` is written out so the caller can clamp
/// future scroll-input (only needed when fully open and interactive).
fn render_scroll_at(
lines: &[ScrollLine],
progress: f32,
@@ -136,31 +143,18 @@ fn render_scroll_at(
buf: &mut Buffer,
state: Option<&mut ScrollOverlayState>,
) {
if progress <= 0.0 {
let Some((content_area, scrollbar_area)) =
render_overlay_frame(&SCROLL_STYLE, progress, area, buf)
else {
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);
let text_style = Style::default().fg(Color::White).bg(SCROLL_STYLE.bg);
let key_style = Style::default().fg(Color::Yellow).bg(SCROLL_STYLE.bg);
let choice_style = Style::default().fg(Color::Rgb(200, 200, 100)).bg(SCROLL_STYLE.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.
// Build Lines — whitespace-leading lines are centered, others left-aligned.
let mut ratatui_lines: Vec<Line> = Vec::new();
let mut choice_letter = b'a';
for item in lines {
@@ -185,27 +179,6 @@ fn render_scroll_at(
}
}
// 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;
+39 -10
View File
@@ -5,13 +5,14 @@ use kiln_core::Board;
use kiln_core::game::GameState;
use crate::animation::Animation;
use crate::log::LogState;
use crate::menu::{MenuItem, MenuCloseAnimation, MenuOpenAnimation, MenuState};
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 animations.
/// View-only UI state for the log panel, scroll overlay, menu, 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).
@@ -23,6 +24,11 @@ pub(crate) struct Ui {
pub(crate) pending_transition: PendingTransition,
/// The currently running animation, if any. All input is suppressed while `Some`.
pub(crate) active_animation: Option<Box<dyn Animation>>,
/// State for the active menu overlay. `Some` while the menu is open or closing.
pub(crate) menu: Option<MenuState>,
/// Set by a menu action to signal that the game loop should exit after the
/// close animation finishes.
pub(crate) should_quit: bool,
}
impl Default for Ui {
@@ -32,38 +38,62 @@ impl Default for Ui {
overlay: ScrollOverlayState::default(),
pending_transition: None,
active_animation: None,
menu: None,
should_quit: false,
}
}
}
impl Ui {
/// Scrolls the overlay by `delta` lines, clamped to the valid range.
/// Scrolls the scroll overlay by `delta` lines, clamped to the valid range.
pub(crate) fn scroll_lines(&mut self, delta: i32) {
self.overlay.offset = (self.overlay.offset as i32 + delta)
.clamp(0, self.overlay.max_scroll as i32) as u16;
}
/// Scrolls the menu overlay by `delta` lines, clamped to the valid range.
pub(crate) fn scroll_menu(&mut self, delta: i32) {
if let Some(m) = self.menu.as_mut() {
m.offset = (m.offset as i32 + delta).clamp(0, m.max_scroll as i32) as u16;
}
}
/// Opens a menu: stores the items as [`MenuState`] and starts the open animation.
pub(crate) fn open_menu(&mut self, items: Vec<MenuItem>) {
self.active_animation = Some(Box::new(MenuOpenAnimation::new(items.clone())));
self.menu = Some(MenuState { items, ..Default::default() });
}
/// Starts the close animation using a snapshot of the current menu items.
///
/// Called from within a menu item's action closure (e.g. `ui.begin_close_menu()`).
pub(crate) fn begin_close_menu(&mut self) {
let items = self.menu.as_ref().map(|m| m.items.clone()).unwrap_or_default();
self.active_animation = Some(Box::new(MenuCloseAnimation::new(items)));
}
/// Advances all time-driven UI state by `dt` and drives the game tick.
///
/// 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.
/// eagerly calls [`handle_scroll`](GameState::handle_scroll) to clear any
/// pending scroll, and clears [`menu`](Ui::menu) so the closed overlay is not
/// rendered on the next frame.
pub(crate) fn tick(&mut self, dt: Duration, game: &mut GameState) {
// 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.
// Returning to Board mode: eagerly clear scroll and menu state so
// neither renders as open for one extra frame before the game tick.
if matches!(mode, crate::input::InputMode::Board) {
game.handle_scroll();
self.menu = None;
}
}
return; // no game tick during any animation (even when animation just ended)
return; // no game tick during any animation (even when just ended)
}
// Normal path: tick the game, then check if a scroll appeared.
@@ -76,8 +106,7 @@ impl Ui {
/// 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.
/// `choice` is `None` when the player dismisses without selecting.
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;