initial editor mode

This commit is contained in:
2026-06-15 22:02:15 -05:00
parent f2dc7861d9
commit d1d0824d37
7 changed files with 507 additions and 63 deletions
+216
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}),
]
+37
View File
@@ -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,
}
+62
View File
@@ -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
View File
@@ -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()),
}
}