180 lines
8.0 KiB
Rust
180 lines
8.0 KiB
Rust
use crate::animation::Animation;
|
|
use crate::editor::EditorState;
|
|
use crate::log::LogState;
|
|
use crate::menu::{MenuCloseAnimation, MenuItem, MenuOpenAnimation, MenuState};
|
|
use crate::mode::{Mode, PendingMode};
|
|
use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState};
|
|
use crate::transition::TransitionAnimation;
|
|
use kiln_core::Board;
|
|
use kiln_core::game::GameState;
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
use std::time::Duration;
|
|
|
|
/// 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, 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).
|
|
pub(crate) log: LogState,
|
|
/// 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,
|
|
/// 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,
|
|
/// Path to the world file, kept so the editor / play-reload can reload it.
|
|
pub(crate) world_path: String,
|
|
/// Set by a menu action to request a Play↔Edit switch; applied by the run loop
|
|
/// (which owns [`world_path`](Ui::world_path) and can run the world reload).
|
|
pub(crate) pending_mode: Option<PendingMode>,
|
|
/// The editor parked while a **playtest** runs. `Some` exactly while the current
|
|
/// [`Mode::Play`](crate::mode::Mode::Play) is a playtest launched from the editor
|
|
/// (see [`EditorState::playtest`]); the run loop restores it on exit. Doubles as
|
|
/// the "am I playtesting?" flag for `Esc` handling and the title cue.
|
|
pub(crate) suspended_editor: Option<EditorState>,
|
|
/// Set when `Esc` is pressed during a playtest, signalling the run loop to drop the
|
|
/// playtest game and restore [`suspended_editor`](Ui::suspended_editor).
|
|
pub(crate) exit_playtest: bool,
|
|
}
|
|
|
|
impl Default for Ui {
|
|
fn default() -> Self {
|
|
Self {
|
|
log: LogState::new(),
|
|
overlay: ScrollOverlayState::default(),
|
|
pending_transition: None,
|
|
active_animation: None,
|
|
menu: None,
|
|
should_quit: false,
|
|
world_path: String::new(),
|
|
pending_mode: None,
|
|
suspended_editor: None,
|
|
exit_playtest: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Ui {
|
|
/// 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 active mode.
|
|
///
|
|
/// While any animation is running, the mode's own tick is suppressed. When an
|
|
/// animation finishes, [`input_mode_after`](Animation::input_mode_after) is
|
|
/// consulted: returning to [`Board`](crate::input::InputMode::Board) mode
|
|
/// eagerly calls [`handle_scroll`](GameState::handle_scroll) on the play game to
|
|
/// clear any pending scroll, and clears [`menu`](Ui::menu) so the closed overlay
|
|
/// is not rendered for an extra frame.
|
|
///
|
|
/// In [`Mode::Edit`] only the editor's cursor blink advances — the game never
|
|
/// ticks while editing.
|
|
pub(crate) fn tick(&mut self, dt: Duration, mode: &mut Mode) {
|
|
// 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 after = anim.input_mode_after();
|
|
self.active_animation = None;
|
|
// Returning to Board mode: eagerly clear scroll and menu state so
|
|
// neither renders as open for one extra frame before the next tick.
|
|
if matches!(after, crate::input::InputMode::Board) {
|
|
if let Mode::Play(game) = mode {
|
|
game.handle_scroll();
|
|
}
|
|
self.menu = None;
|
|
}
|
|
}
|
|
return; // no mode tick during any animation (even when just ended)
|
|
}
|
|
|
|
// The game only advances during normal play (InputMode::Board). While a
|
|
// scroll or menu overlay is open the input mode is Scroll/Menu, so the game
|
|
// is paused — otherwise `game.tick` would call `handle_scroll`, clearing the
|
|
// scroll the instant its open animation finished.
|
|
let input_mode = crate::input::current_input_mode(mode, self);
|
|
match mode {
|
|
// Play: tick the game (only in Board mode), then check whether a scroll
|
|
// overlay appeared as a result of this tick.
|
|
Mode::Play(game) => {
|
|
if matches!(input_mode, crate::input::InputMode::Board) {
|
|
game.tick(dt);
|
|
if let Some(scroll) = &game.active_scroll {
|
|
let lines = scroll.lines.clone();
|
|
self.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines)));
|
|
}
|
|
}
|
|
}
|
|
// Edit: only the cursor blink advances; the game does not exist here.
|
|
Mode::Edit(editor) => editor.tick(dt.as_secs_f32()),
|
|
}
|
|
}
|
|
|
|
/// Sets the player's scroll choice and starts the close animation.
|
|
///
|
|
/// `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;
|
|
}
|
|
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)));
|
|
}
|
|
}
|