initial editor mode
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
//! World editor mode: state, rendering, and the editor pause menu.
|
||||
//!
|
||||
//! The editor is a separate top-level [`Mode`](crate::mode::Mode): it holds its
|
||||
//! own freshly reloaded [`World`] and never ticks the game. The board is shown
|
||||
//! with a resizable right-hand sidebar (just a "coming soon" placeholder for now)
|
||||
//! and a blinking cursor moved with the arrow keys or a right-click.
|
||||
|
||||
use std::cell::Ref;
|
||||
use kiln_core::Board;
|
||||
use kiln_core::log::LogLine;
|
||||
use kiln_core::world::World;
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Layout, Rect, Spacing};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::symbols::merge::MergeStrategy;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use crate::log::{log_preview_line, LogWidget};
|
||||
use crate::menu::{MenuItem, MenuKey};
|
||||
use crate::mode::PendingMode;
|
||||
use crate::render::{board_to_screen, screen_to_board, BoardWidget};
|
||||
use crate::ui::Ui;
|
||||
|
||||
/// How long each half of the cursor blink lasts, in seconds.
|
||||
const BLINK_SECS: f32 = 0.5;
|
||||
/// Default sidebar width in terminal columns (borders included).
|
||||
const DEFAULT_SIDEBAR_WIDTH: u16 = 24;
|
||||
/// Smallest width either the sidebar or the board may shrink to when resizing.
|
||||
const MIN_PANEL_WIDTH: u16 = 8;
|
||||
/// The cursor glyph: a solid full block (CP437 219) drawn in light gray.
|
||||
const CURSOR_CHAR: char = '█';
|
||||
|
||||
/// All state for an active editing session.
|
||||
///
|
||||
/// Built by reloading the world file, so the running game has no bearing on what
|
||||
/// the editor shows. Holds no [`GameState`](kiln_core::game::GameState) — editing
|
||||
/// never ticks the game.
|
||||
pub(crate) struct EditorState {
|
||||
/// The freshly reloaded world being edited.
|
||||
world: World,
|
||||
/// Key (in [`World::boards`]) of the board currently being edited.
|
||||
board_name: String,
|
||||
/// Cursor position in board cells, clamped to the board bounds.
|
||||
cursor: (i32, i32),
|
||||
/// Width of the right-hand sidebar in terminal columns (mouse-resizable).
|
||||
sidebar_width: u16,
|
||||
/// Whether the blinking cursor is currently in its visible phase.
|
||||
blink_on: bool,
|
||||
/// Time accumulated toward the next blink toggle.
|
||||
blink_timer: f32,
|
||||
/// Inner board rect from the last [`draw_editor`] call, used to convert a
|
||||
/// right-click's screen position back to a board cell (written during render,
|
||||
/// read during input — the same pattern as `ScrollOverlayState::max_scroll`).
|
||||
last_board_area: Rect,
|
||||
/// Editor-side log lines (the editor has no game log to draw from).
|
||||
log: Vec<LogLine>,
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// Builds an editor session for `world`, starting on its designated start board.
|
||||
pub(crate) fn new(world: World) -> Self {
|
||||
let board_name = world.start.clone();
|
||||
Self {
|
||||
world,
|
||||
board_name,
|
||||
cursor: (0, 0),
|
||||
sidebar_width: DEFAULT_SIDEBAR_WIDTH,
|
||||
blink_on: true,
|
||||
blink_timer: 0.0,
|
||||
last_board_area: Rect::default(),
|
||||
log: vec![LogLine::raw("editing — press [esc] for the menu")],
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrows the board currently being edited.
|
||||
pub(crate) fn board(&self) -> Ref<'_, Board> {
|
||||
self.world.boards[&self.board_name].borrow()
|
||||
}
|
||||
|
||||
/// Advances the cursor blink by `dt` seconds (the editor's only animation).
|
||||
pub(crate) fn tick(&mut self, dt: f32) {
|
||||
self.blink_timer += dt;
|
||||
if self.blink_timer >= BLINK_SECS {
|
||||
self.blink_timer -= BLINK_SECS;
|
||||
self.blink_on = !self.blink_on;
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the cursor by `(dx, dy)`, clamped to the board bounds.
|
||||
pub(crate) fn move_cursor(&mut self, dx: i32, dy: i32) {
|
||||
let (w, h) = { let b = self.board(); (b.width as i32, b.height as i32) };
|
||||
self.cursor.0 = (self.cursor.0 + dx).clamp(0, w - 1);
|
||||
self.cursor.1 = (self.cursor.1 + dy).clamp(0, h - 1);
|
||||
}
|
||||
|
||||
/// Moves the cursor to the cell under a screen position, if it lands on the
|
||||
/// board (right-click handling). No-op when the click is off the board.
|
||||
pub(crate) fn set_cursor_at_screen(&mut self, sx: u16, sy: u16) {
|
||||
let cell = screen_to_board(self.last_board_area, &self.board(), sx, sy);
|
||||
if let Some((bx, by)) = cell {
|
||||
self.cursor = (bx as i32, by as i32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resizes the sidebar to `target` columns, clamped so neither the sidebar nor
|
||||
/// the board can shrink below [`MIN_PANEL_WIDTH`] (mirrors [`LogState::resize`]).
|
||||
pub(crate) fn resize_sidebar(&mut self, target: u16, total_w: u16) {
|
||||
let max = total_w.saturating_sub(MIN_PANEL_WIDTH).max(MIN_PANEL_WIDTH);
|
||||
self.sidebar_width = target.clamp(MIN_PANEL_WIDTH, max);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the items for the editor's pause menu (opened with `Esc` while editing).
|
||||
///
|
||||
/// `[p]` reloads the world and returns to play; `[q]` quits; `Esc` resumes editing.
|
||||
/// Like all menu actions these take only `&mut Ui` — the mode swap is deferred to
|
||||
/// the run loop via [`Ui::pending_mode`](crate::ui::Ui::pending_mode).
|
||||
pub(crate) fn editor_menu_items() -> Vec<MenuItem> {
|
||||
vec![
|
||||
MenuItem::new(MenuKey::Char('p'), "Play world", |ui| {
|
||||
ui.pending_mode = Some(PendingMode::PlayWorld);
|
||||
ui.begin_close_menu();
|
||||
}),
|
||||
MenuItem::new(MenuKey::Char('q'), "Quit game", |ui| {
|
||||
ui.should_quit = true;
|
||||
ui.begin_close_menu();
|
||||
}),
|
||||
MenuItem::new(MenuKey::Esc, "Resume editing", |ui| {
|
||||
ui.begin_close_menu();
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// Renders the editor: the board (with a blinking cursor and coordinate readout),
|
||||
/// the right-hand sidebar, and — when open — the bottom log panel spanning the
|
||||
/// full width (board and sidebar sit side-by-side above it).
|
||||
pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui) {
|
||||
let (board_area, sidebar_area, log_area) = editor_layout(frame.area(), ui, ed.sidebar_width);
|
||||
|
||||
// ── Board, cursor, coordinate readout ────────────────────────────────────
|
||||
let inner = {
|
||||
let board = ed.board();
|
||||
// Title carries the board name (top-left) and live cursor coords (top-right).
|
||||
let mut block = Block::bordered()
|
||||
.title(format!(" {} ", board.name))
|
||||
.title_top(Line::from(format!(" ({}, {}) ", ed.cursor.0, ed.cursor.1)).right_aligned())
|
||||
.merge_borders(MergeStrategy::Exact);
|
||||
// When the log is closed, preview the latest line in the bottom border.
|
||||
if log_area.is_none() {
|
||||
block = block.title_bottom(log_preview_line(&ed.log).left_aligned());
|
||||
}
|
||||
let inner = block.inner(board_area);
|
||||
frame.render_widget(block, board_area);
|
||||
frame.render_widget(BoardWidget::new(&board), inner);
|
||||
|
||||
// Overlay the cursor during its visible phase (a light-gray solid block).
|
||||
if ed.blink_on {
|
||||
let (cx, cy) = (ed.cursor.0 as usize, ed.cursor.1 as usize);
|
||||
if let Some((sx, sy)) = board_to_screen(inner, &board, cx, cy)
|
||||
&& let Some(cell) = frame.buffer_mut().cell_mut((sx, sy))
|
||||
{
|
||||
cell.set_char(CURSOR_CHAR)
|
||||
.set_fg(Color::Rgb(180, 180, 180))
|
||||
.set_bg(Color::Black);
|
||||
}
|
||||
}
|
||||
inner
|
||||
};
|
||||
// Record the board rect for right-click hit-testing (board borrow now dropped).
|
||||
ed.last_board_area = inner;
|
||||
|
||||
// ── Sidebar: a placeholder "coming soon" label centered in the panel ──────
|
||||
let sb_block = Block::bordered().title(" Editor ").merge_borders(MergeStrategy::Exact);
|
||||
let sb_inner = sb_block.inner(sidebar_area);
|
||||
frame.render_widget(sb_block, sidebar_area);
|
||||
// Center one line vertically, then horizontally via Paragraph::centered.
|
||||
let [_, mid, _] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
]).areas(sb_inner);
|
||||
frame.render_widget(
|
||||
Paragraph::new("coming soon").style(Style::default().fg(Color::DarkGray)).centered(),
|
||||
mid,
|
||||
);
|
||||
|
||||
// ── Log panel (full width, below board + sidebar) ─────────────────────────
|
||||
if let Some(log_area) = log_area {
|
||||
frame.render_stateful_widget(LogWidget::new(&ed.log), log_area, &mut ui.log);
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the board / sidebar / optional-log rects for the editor.
|
||||
///
|
||||
/// When the log is open the screen splits vertically first (so the log spans the
|
||||
/// full width), then the top region splits horizontally into board + sidebar.
|
||||
/// When closed, only the horizontal board/sidebar split applies. `Overlap(1)`
|
||||
/// makes adjacent panels share their dividing border.
|
||||
fn editor_layout(area: Rect, ui: &Ui, sidebar_width: u16) -> (Rect, Rect, Option<Rect>) {
|
||||
let split_h = |top: Rect| {
|
||||
Layout::horizontal([Constraint::Min(3), Constraint::Length(sidebar_width)])
|
||||
.spacing(Spacing::Overlap(1))
|
||||
.areas::<2>(top)
|
||||
};
|
||||
if ui.log.open {
|
||||
let [top, log_area] =
|
||||
Layout::vertical([Constraint::Min(3), Constraint::Length(ui.log.height)])
|
||||
.spacing(Spacing::Overlap(1))
|
||||
.areas(area);
|
||||
let [board_area, sidebar_area] = split_h(top);
|
||||
(board_area, sidebar_area, Some(log_area))
|
||||
} else {
|
||||
let [board_area, sidebar_area] = split_h(area);
|
||||
(board_area, sidebar_area, None)
|
||||
}
|
||||
}
|
||||
+48
-6
@@ -1,7 +1,9 @@
|
||||
use ratatui::crossterm::event::{Event, KeyCode, MouseEventKind};
|
||||
use ratatui::crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind};
|
||||
use kiln_core::Direction;
|
||||
use kiln_core::game::{GameState, ScrollLine};
|
||||
use crate::editor::{editor_menu_items, EditorState};
|
||||
use crate::menu::{MenuItem, MenuKey, pause_menu_items, ActionRc as MenuActionRc};
|
||||
use crate::mode::Mode;
|
||||
use crate::ui::Ui;
|
||||
|
||||
/// Moves the player and, if the move crosses a portal, captures both board Rcs
|
||||
@@ -25,6 +27,8 @@ pub enum InputMode {
|
||||
Scroll,
|
||||
/// A menu overlay is active; all input is captured for menu navigation.
|
||||
Menu,
|
||||
/// Editing a world: arrow keys move the cursor, the sidebar is shown.
|
||||
Editor,
|
||||
/// An animation is running and has not claimed any input mode; all input is discarded.
|
||||
Ignore,
|
||||
/// Reserved for a post-transition freeze; nothing currently produces this mode.
|
||||
@@ -32,15 +36,20 @@ pub enum InputMode {
|
||||
Frozen,
|
||||
}
|
||||
|
||||
/// Determines the current input mode from game and UI state.
|
||||
pub(crate) fn current_input_mode(game: &GameState, ui: &Ui) -> InputMode {
|
||||
/// Determines the current input mode from the active [`Mode`] and UI state.
|
||||
pub(crate) fn current_input_mode(mode: &Mode, ui: &Ui) -> InputMode {
|
||||
// An active animation declares its own input mode; pending transitions ignore input.
|
||||
if let Some(anim) = &ui.active_animation {
|
||||
return anim.input_mode_during();
|
||||
}
|
||||
if ui.pending_transition.is_some() { return InputMode::Ignore; }
|
||||
if ui.menu.is_some() { return InputMode::Menu; }
|
||||
if game.active_scroll.is_some() { InputMode::Scroll } else { InputMode::Board }
|
||||
match mode {
|
||||
Mode::Edit(_) => InputMode::Editor,
|
||||
Mode::Play(game) => {
|
||||
if game.active_scroll.is_some() { InputMode::Scroll } else { InputMode::Board }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles an input event in [`InputMode::Board`]. Returns `true` if the player quit.
|
||||
@@ -72,6 +81,39 @@ pub(crate) fn handle_board_input(event: Event, log_open: bool, terminal_h: u16,
|
||||
false
|
||||
}
|
||||
|
||||
/// Handles an input event in [`InputMode::Editor`].
|
||||
///
|
||||
/// Arrow keys move the cursor; `l` toggles the log; `Esc` opens the editor menu.
|
||||
/// A right-click jumps the cursor to the clicked board cell, and dragging the
|
||||
/// board/sidebar divider resizes the sidebar (the divider sits at `term_w -
|
||||
/// sidebar_width`, mirroring the log's bottom divider).
|
||||
pub(crate) fn handle_editor_input(
|
||||
event: Event,
|
||||
term_w: u16,
|
||||
ed: &mut EditorState,
|
||||
ui: &mut Ui,
|
||||
) {
|
||||
match event {
|
||||
Event::Key(key) => match key.code {
|
||||
KeyCode::Esc => ui.open_menu(editor_menu_items()),
|
||||
KeyCode::Up => ed.move_cursor(0, -1),
|
||||
KeyCode::Down => ed.move_cursor(0, 1),
|
||||
KeyCode::Left => ed.move_cursor(-1, 0),
|
||||
KeyCode::Right => ed.move_cursor(1, 0),
|
||||
KeyCode::Char('l') => ui.log.toggle(),
|
||||
_ => {}
|
||||
},
|
||||
Event::Mouse(m) => match m.kind {
|
||||
// Right-click moves the cursor to the clicked cell (if on the board).
|
||||
MouseEventKind::Down(MouseButton::Right) => ed.set_cursor_at_screen(m.column, m.row),
|
||||
// Dragging the divider resizes the sidebar.
|
||||
MouseEventKind::Drag(_) => ed.resize_sidebar(term_w.saturating_sub(m.column), term_w),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the choice string for the given key character, scanning the scroll lines.
|
||||
fn resolve_choice(lines: &[ScrollLine], c: char) -> Option<String> {
|
||||
let mut letter = b'a';
|
||||
@@ -117,7 +159,7 @@ pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut U
|
||||
///
|
||||
/// 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) {
|
||||
pub(crate) fn handle_menu_input(event: Event, ui: &mut Ui) {
|
||||
match event {
|
||||
Event::Key(key) => {
|
||||
let pressed = match key.code {
|
||||
@@ -133,7 +175,7 @@ pub(crate) fn handle_menu_input(event: Event, game: &mut GameState, ui: &mut Ui)
|
||||
.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);
|
||||
MenuItem::call_action_rc(action, ui);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
-31
@@ -9,8 +9,10 @@
|
||||
|
||||
mod animation;
|
||||
mod cp437;
|
||||
mod editor;
|
||||
mod log;
|
||||
mod menu;
|
||||
mod mode;
|
||||
mod overlay;
|
||||
mod render;
|
||||
mod scroll_overlay;
|
||||
@@ -38,9 +40,14 @@ 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_menu_input, handle_scroll_input, InputMode};
|
||||
use crate::editor::{draw_editor, EditorState};
|
||||
use crate::input::{
|
||||
current_input_mode, handle_board_input, handle_editor_input, handle_menu_input,
|
||||
handle_scroll_input, InputMode,
|
||||
};
|
||||
use crate::log::{log_preview_line, LogWidget};
|
||||
use crate::menu::MenuWidget;
|
||||
use crate::mode::{Mode, PendingMode};
|
||||
use crate::scroll_overlay::ScrollOverlayWidget;
|
||||
use crate::speech::SpeechBubblesWidget;
|
||||
use crate::ui::Ui;
|
||||
@@ -85,8 +92,12 @@ fn main() -> ExitCode {
|
||||
// scripted object's `init()` hook.
|
||||
game.run_init();
|
||||
|
||||
let mut ui = Ui::default();
|
||||
let result = run(&mut terminal, &mut game, &mut ui);
|
||||
// Wrap the live game in the top-level Mode; editing later swaps this whole value.
|
||||
let mut mode = Mode::Play(game);
|
||||
|
||||
// Remember the world path so the editor (and play-reload) can reload from it.
|
||||
let mut ui = Ui { world_path: path.clone(), ..Ui::default() };
|
||||
let result = run(&mut terminal, &mut mode, &mut ui);
|
||||
|
||||
if caps.keyboard_enhancement {
|
||||
let _ = term::pop_kitty_flags();
|
||||
@@ -112,14 +123,14 @@ const FRAME: Duration = Duration::from_nanos(1_000_000_000 / 30);
|
||||
/// arrives, so input stays responsive while the game keeps ticking on its own.
|
||||
fn run(
|
||||
terminal: &mut ratatui::DefaultTerminal,
|
||||
game: &mut GameState,
|
||||
mode: &mut Mode,
|
||||
ui: &mut Ui,
|
||||
) -> io::Result<()> {
|
||||
let mut last_tick = Instant::now();
|
||||
loop {
|
||||
// Redraw every iteration; ratatui diffs against the previous frame so
|
||||
// only changed cells are actually written to the terminal.
|
||||
terminal.draw(|frame| draw(frame, game, ui))?;
|
||||
terminal.draw(|frame| draw(frame, mode, ui))?;
|
||||
|
||||
// Wait for input only up to the next frame deadline so the loop wakes to
|
||||
// tick even with no keypresses.
|
||||
@@ -131,27 +142,37 @@ fn run(
|
||||
if matches!(&event, Event::Key(k) if k.kind == KeyEventKind::Release) {
|
||||
continue;
|
||||
}
|
||||
let terminal_h = terminal.size()?.height;
|
||||
match current_input_mode(game, ui) {
|
||||
let size = terminal.size()?;
|
||||
match current_input_mode(mode, ui) {
|
||||
InputMode::Board | InputMode::Frozen => {
|
||||
handle_board_input(event, ui.log.open, terminal_h, game, ui);
|
||||
if let Mode::Play(game) = mode {
|
||||
handle_board_input(event, ui.log.open, size.height, game, ui);
|
||||
}
|
||||
}
|
||||
InputMode::Menu => handle_menu_input(event, ui),
|
||||
InputMode::Scroll => {
|
||||
if let Mode::Play(game) = mode { handle_scroll_input(event, game, ui); }
|
||||
}
|
||||
InputMode::Editor => {
|
||||
if let Mode::Edit(ed) = mode { handle_editor_input(event, size.width, ed, ui); }
|
||||
}
|
||||
InputMode::Menu => handle_menu_input(event, game, ui),
|
||||
InputMode::Scroll => handle_scroll_input(event, game, ui),
|
||||
// Animation running and claiming no input mode — discard all events.
|
||||
InputMode::Ignore => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the game by the real time elapsed since the last tick. Only
|
||||
// resetting `last_tick` when we actually tick means time spent waiting on
|
||||
// input (or skipped Release events) is preserved in the next `dt`.
|
||||
// Advance the active mode by the real time elapsed since the last tick.
|
||||
// Only resetting `last_tick` when we actually tick means time spent waiting
|
||||
// on input (or skipped Release events) is preserved in the next `dt`.
|
||||
if last_tick.elapsed() >= FRAME {
|
||||
let dt = last_tick.elapsed();
|
||||
last_tick = Instant::now();
|
||||
ui.tick(dt, game);
|
||||
ui.tick(dt, mode);
|
||||
}
|
||||
|
||||
// Apply any Play↔Edit switch a menu action requested this frame.
|
||||
apply_pending_mode(mode, ui);
|
||||
|
||||
// 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(());
|
||||
@@ -159,11 +180,64 @@ fn run(
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single frame: the board in a bordered block, and — when open — a log
|
||||
/// Applies a pending [`Mode`] switch, reloading the world from `ui.world_path`.
|
||||
///
|
||||
/// Entering the editor drops the running game; returning to play rebuilds a fresh
|
||||
/// [`GameState`] (so it reflects any edits) and re-runs object `init()` hooks. A
|
||||
/// load failure is logged to the play game (if any) and leaves the mode unchanged.
|
||||
fn apply_pending_mode(mode: &mut Mode, ui: &mut Ui) {
|
||||
let Some(pending) = ui.pending_mode.take() else { return };
|
||||
match world::load(&ui.world_path) {
|
||||
Ok(world) => match pending {
|
||||
PendingMode::EnterEditor => *mode = Mode::Edit(EditorState::new(world)),
|
||||
PendingMode::PlayWorld => {
|
||||
let mut game = GameState::from_world(world);
|
||||
game.run_init();
|
||||
*mode = Mode::Play(game);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
// Best-effort: surface the failure in the play log if we're still playing.
|
||||
if let Mode::Play(game) = mode {
|
||||
game.log(LogLine::error(format!("reload failed: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single frame for the active [`Mode`], then the overlay layer on top.
|
||||
///
|
||||
/// The body of the frame is either the play view ([`draw_play`]) or the editor
|
||||
/// view ([`draw_editor`]); overlays (animation, menu, and — play-only — the scroll
|
||||
/// window) are drawn above whichever was rendered.
|
||||
fn draw(frame: &mut Frame, mode: &mut Mode, ui: &mut Ui) {
|
||||
match mode {
|
||||
Mode::Play(game) => draw_play(frame, game, ui),
|
||||
Mode::Edit(ed) => draw_editor(frame, ed, ui),
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 Mode::Play(game) = mode {
|
||||
// The scroll window only exists while playing.
|
||||
if let Some(scroll) = &game.active_scroll {
|
||||
frame.render_stateful_widget(ScrollOverlayWidget::new(&scroll.lines), area, &mut ui.overlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the play view: 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.
|
||||
/// Overlays (menu, scroll, animations) are drawn on top of everything.
|
||||
fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
|
||||
fn draw_play(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();
|
||||
|
||||
@@ -189,20 +263,6 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
|
||||
frame.render_widget(block, frame.area());
|
||||
draw_board_area(frame, inner, game, ui);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
frame.render_stateful_widget(ScrollOverlayWidget::new(&scroll.lines), area, &mut ui.overlay);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the inner board area: either the active board-layer animation or the live board.
|
||||
|
||||
+20
-10
@@ -7,7 +7,6 @@
|
||||
//! 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};
|
||||
@@ -17,11 +16,17 @@ use ratatui::widgets::{
|
||||
};
|
||||
use crate::animation::{Animation, AnimationLayer};
|
||||
use crate::input::InputMode;
|
||||
use crate::mode::PendingMode;
|
||||
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)>;
|
||||
///
|
||||
/// Actions receive only `&mut Ui`: they signal intent (close the menu, quit, or
|
||||
/// request a [`PendingMode`] switch) and the run loop applies the effect. None of
|
||||
/// them need the game state, which is why entering/leaving the editor — a full
|
||||
/// mode swap — fits the same closure shape.
|
||||
pub(crate) type ActionRc = Rc<dyn Fn(&mut Ui)>;
|
||||
|
||||
/// Duration of the menu open/close animations, in seconds.
|
||||
const ANIM_SECS: f32 = 0.25;
|
||||
@@ -63,7 +68,7 @@ impl MenuItem {
|
||||
pub fn new(
|
||||
key: MenuKey,
|
||||
label: impl Into<String>,
|
||||
action: impl Fn(&mut GameState, &mut Ui) + 'static,
|
||||
action: impl Fn(&mut Ui) + 'static,
|
||||
) -> Self {
|
||||
Self { key, label: label.into(), action: Rc::new(action) }
|
||||
}
|
||||
@@ -76,8 +81,8 @@ impl MenuItem {
|
||||
|
||||
/// 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);
|
||||
pub fn call_action_rc(action: ActionRc, ui: &mut Ui) {
|
||||
action(ui);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,17 +247,22 @@ fn render_menu_at(
|
||||
if let Some(m) = out_max_scroll { *m = max_scroll; }
|
||||
}
|
||||
|
||||
/// Returns the items for the standard pause menu.
|
||||
/// Returns the items for the standard (play-mode) pause menu.
|
||||
///
|
||||
/// Pressing `q` marks [`Ui::should_quit`] and starts the close animation.
|
||||
/// Pressing `Esc` closes the menu without quitting.
|
||||
/// Pressing `e` requests entering the editor; `q` marks [`Ui::should_quit`];
|
||||
/// `Esc` closes the menu. The editor switch is deferred to the run loop via
|
||||
/// [`Ui::pending_mode`], so the action only touches [`Ui`].
|
||||
pub fn pause_menu_items() -> Vec<MenuItem> {
|
||||
vec![
|
||||
MenuItem::new(MenuKey::Char('q'), "Quit game", |_g, ui| {
|
||||
MenuItem::new(MenuKey::Char('e'), "Edit world", |ui| {
|
||||
ui.pending_mode = Some(PendingMode::EnterEditor);
|
||||
ui.begin_close_menu();
|
||||
}),
|
||||
MenuItem::new(MenuKey::Char('q'), "Quit game", |ui| {
|
||||
ui.should_quit = true;
|
||||
ui.begin_close_menu();
|
||||
}),
|
||||
MenuItem::new(MenuKey::Esc, "Close menu", |_g, ui| {
|
||||
MenuItem::new(MenuKey::Esc, "Close menu", |ui| {
|
||||
ui.begin_close_menu();
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! The application's top-level mode: either playing a world or editing one.
|
||||
//!
|
||||
//! Play and Edit are mutually exclusive — exactly one of [`GameState`] or
|
||||
//! [`EditorState`] exists at a time — so they are modeled as an enum rather than
|
||||
//! two `Option`s. This matches the design rule that there is *no* game state
|
||||
//! while editing: entering the editor drops the running game, and returning to
|
||||
//! play rebuilds it fresh from the world file.
|
||||
|
||||
use kiln_core::game::GameState;
|
||||
use crate::editor::EditorState;
|
||||
|
||||
/// The top-level application state: exactly one variant is live at any moment.
|
||||
///
|
||||
/// `Play` is much larger than `Edit`, but only a single `Mode` value ever exists
|
||||
/// (it lives on the stack in `run`), so the size disparity costs nothing and
|
||||
/// boxing would only add a deref to the common play path.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub(crate) enum Mode {
|
||||
/// Playing a world: the live, ticking game.
|
||||
Play(GameState),
|
||||
/// Editing a world: a freshly reloaded, non-ticking copy of the world.
|
||||
Edit(EditorState),
|
||||
}
|
||||
|
||||
/// A deferred Play↔Edit switch requested by a menu action.
|
||||
///
|
||||
/// Menu closures only receive `&mut Ui`, so they cannot reach `world_path` or
|
||||
/// run [`world::load`](kiln_core::world::load). Instead they set
|
||||
/// [`Ui::pending_mode`](crate::ui::Ui::pending_mode) and the run loop performs
|
||||
/// the actual reload-and-swap (mirroring the [`should_quit`](crate::ui::Ui::should_quit)
|
||||
/// pattern).
|
||||
pub(crate) enum PendingMode {
|
||||
/// Leave play, reload the world, and enter the editor.
|
||||
EnterEditor,
|
||||
/// Leave the editor, reload the world, and start playing it fresh.
|
||||
PlayWorld,
|
||||
}
|
||||
@@ -50,6 +50,68 @@ impl<'a> BoardWidget<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a board cell `(bx, by)` to its screen position within `area`, using the
|
||||
/// same centering/scrolling layout as [`BoardWidget`]. Returns `None` when the
|
||||
/// cell is scrolled out of view.
|
||||
///
|
||||
/// The inverse of [`screen_to_board`]; both reuse [`BoardWidget::axis`] so they
|
||||
/// stay consistent with how the board is actually drawn. (Unlike
|
||||
/// `speech::board_screen_pos`, which clamps off-screen cells to the edge, this
|
||||
/// reports off-screen cells as `None` — needed for exact cursor placement.)
|
||||
pub fn board_to_screen(area: Rect, board: &Board, bx: usize, by: usize) -> Option<(u16, u16)> {
|
||||
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x);
|
||||
let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, board.player.y);
|
||||
// Cell must fall within the visible window on both axes.
|
||||
if bx < off_x || bx >= off_x + cols || by < off_y || by >= off_y + rows {
|
||||
return None;
|
||||
}
|
||||
let sx = area.x + pad_x + (bx - off_x) as u16;
|
||||
let sy = area.y + pad_y + (by - off_y) as u16;
|
||||
Some((sx, sy))
|
||||
}
|
||||
|
||||
/// Maps a screen position `(sx, sy)` to the board cell drawn there within `area`,
|
||||
/// the inverse of [`board_to_screen`]. Returns `None` when the point lies outside
|
||||
/// the drawn board (in the centering pad or past the visible cells).
|
||||
pub fn screen_to_board(area: Rect, board: &Board, sx: u16, sy: u16) -> Option<(usize, usize)> {
|
||||
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x);
|
||||
let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, board.player.y);
|
||||
// Screen-space offset from the first drawn cell; reject anything before it.
|
||||
let col = (sx as i32) - (area.x as i32 + pad_x as i32);
|
||||
let row = (sy as i32) - (area.y as i32 + pad_y as i32);
|
||||
if col < 0 || row < 0 || col as usize >= cols || row as usize >= rows {
|
||||
return None;
|
||||
}
|
||||
Some((off_x + col as usize, off_y + row as usize))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::BoardWidget;
|
||||
|
||||
#[test]
|
||||
fn axis_centers_a_small_board() {
|
||||
// 10-cell board in a 20-cell view: no scroll, 5 cells of pad each side.
|
||||
let (off, pad, count) = BoardWidget::axis(10, 20, 3);
|
||||
assert_eq!((off, pad, count), (0, 5, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_scrolls_a_large_board_to_follow_the_player() {
|
||||
// 100-cell board in a 20-cell view, player at 50: centered, no pad.
|
||||
let (off, pad, count) = BoardWidget::axis(100, 20, 50);
|
||||
assert_eq!((off, pad, count), (40, 0, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_clamps_scroll_at_the_edges() {
|
||||
// Player near the left edge can't scroll past 0.
|
||||
assert_eq!(BoardWidget::axis(100, 20, 2), (0, 0, 20));
|
||||
// Player near the right edge clamps to board_len - view.
|
||||
assert_eq!(BoardWidget::axis(100, 20, 99), (80, 0, 20));
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for BoardWidget<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let board = self.board;
|
||||
|
||||
+33
-16
@@ -6,6 +6,7 @@ use kiln_core::game::GameState;
|
||||
use crate::animation::Animation;
|
||||
use crate::log::LogState;
|
||||
use crate::menu::{MenuItem, MenuCloseAnimation, MenuOpenAnimation, MenuState};
|
||||
use crate::mode::{Mode, PendingMode};
|
||||
use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState};
|
||||
use crate::transition::TransitionAnimation;
|
||||
|
||||
@@ -29,6 +30,11 @@ pub(crate) struct Ui {
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl Default for Ui {
|
||||
@@ -40,6 +46,8 @@ impl Default for Ui {
|
||||
active_animation: None,
|
||||
menu: None,
|
||||
should_quit: false,
|
||||
world_path: String::new(),
|
||||
pending_mode: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,35 +80,44 @@ impl Ui {
|
||||
self.active_animation = Some(Box::new(MenuCloseAnimation::new(items)));
|
||||
}
|
||||
|
||||
/// Advances all time-driven UI state by `dt` and drives the game tick.
|
||||
/// Advances all time-driven UI state by `dt` and drives the active mode.
|
||||
///
|
||||
/// While any animation is running, `game.tick` is suppressed. When an
|
||||
/// 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) 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) {
|
||||
/// 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 mode = anim.input_mode_after();
|
||||
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 game tick.
|
||||
if matches!(mode, crate::input::InputMode::Board) {
|
||||
game.handle_scroll();
|
||||
// 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 game tick during any animation (even when just ended)
|
||||
return; // no mode tick during any animation (even when just ended)
|
||||
}
|
||||
|
||||
// Normal path: tick the game, then check if a scroll appeared.
|
||||
game.tick(dt);
|
||||
if let Some(scroll) = &game.active_scroll {
|
||||
let lines = scroll.lines.clone();
|
||||
self.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines)));
|
||||
match mode {
|
||||
// Play: tick the game, then check whether a scroll overlay appeared.
|
||||
Mode::Play(game) => {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user