diff --git a/CLAUDE.md b/CLAUDE.md index 7a2ea12..d5debc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,6 +135,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc **`kiln-core/src/world.rs`** — world type and world-file loading: - `World` — the runtime representation of a `.toml` world file: `name: String`, `start: String` (key of the starting board), `scripts: HashMap` (named Rhai source, shared across all boards), `boards: HashMap>>` (all boards, always present). Every board is wrapped in `Rc>` so multiple shared refs can coexist — `GameState` clones the active board's `Rc` for its `ScriptHost`, and all boards remain in `world.boards` at all times (no board is ever "removed" when active). - `pub fn load(path: &str) -> Result>` — reads a world `.toml` file, converts each `[boards.NAME.*]` subtable via `TryFrom for Board`, wraps each in `Rc::new(RefCell::new(...))`, and validates that `world.start` matches a board key. +- `pub fn deep_clone(&self) -> World` — a **fully independent** copy: rebuilds every board into a *fresh* `Rc>` (`Board`/`Layer`/`ObjectDef` derive `Clone`) rather than sharing the `Rc`s. An explicit method, not a `Clone` impl, since shallow `Rc`-sharing would be a footgun. Used by the editor's playtest to run a game against an isolated world so play mutations never touch the boards being edited. ### kiln-ui modules @@ -179,27 +180,28 @@ The terminal **player + world editor**, depending on both `kiln-core` and `kiln- **`kiln-tui/src/main.rs`** — entry point, real-time loop, and frame dispatch: - Parses a single positional arg (the world file path); prints usage and exits non-zero if missing. Loads the world via `kiln_core::world::load` *before* touching the terminal so load errors print to a normal screen, then `GameState::from_world(world)`, wrapped in `Mode::Play`. The world path is kept on `Ui::world_path` so the editor / play-reload can reload it. - `ratatui::init()` → `EnableMouseCapture` → detect `TerminalCaps` → push Kitty flags when supported → `run` loop → pop Kitty flags → `DisableMouseCapture` → `ratatui::restore()`. `game.run_init()` runs object `init()` hooks before the loop. -- `run` is a ~30 FPS real-time loop: `event::poll`s only until the next frame deadline (so input stays responsive) and otherwise wakes to advance by the real elapsed `dt` via `Ui::tick(dt, mode)`. Acts on `Press`/`Repeat` and ignores `Release` (the Kitty protocol emits these; acting on them double-fires moves). Input is routed by `current_input_mode`: `Menu`→`handle_menu_input`; otherwise (unless `Ignore`) it matches on `Mode` — `Play` → `handle_board_input`/`handle_scroll_input`, `Edit` → `handle_editor_input`/`handle_dialog_input`. After ticking it calls `apply_pending_mode` and exits once `should_quit` is set and no close animation is running. +- `run` is a ~30 FPS real-time loop: `event::poll`s only until the next frame deadline (so input stays responsive) and otherwise wakes to advance by the real elapsed `dt` via `Ui::tick(dt, mode)`. Acts on `Press`/`Repeat` and ignores `Release` (the Kitty protocol emits these; acting on them double-fires moves). Input is routed by `current_input_mode`: `Menu`→`handle_menu_input`; otherwise (unless `Ignore`) it matches on `Mode` — `Play` → `handle_board_input`/`handle_scroll_input`, `Edit` → `handle_editor_input`/`handle_dialog_input`. After ticking it calls `apply_pending_mode` then `apply_playtest_transition`, and exits once `should_quit` is set and no close animation is running. - `apply_pending_mode(mode, ui)` — applies a `PendingMode` a menu action requested: reloads the world from `world_path` and swaps `*mode` to `Mode::Edit(EditorState::new(world))` (enter editor) or a fresh `Mode::Play` (play world, re-running `init()`); a reload failure is logged to the play game and leaves the mode unchanged. +- `apply_playtest_transition(mode, ui)` — enters/leaves a **playtest** (the editor's `[p] Play board`). Enter: takes the `GameState` the editor parked in `EditorState::pending_playtest` (built from a `World::deep_clone`, so isolated) and `std::mem::replace`s `*mode` with `Mode::Play(game)`, stashing the old `Mode::Edit` into `Ui::suspended_editor`. Exit: when `Ui::exit_playtest` is set (the playtest pressed `Esc`), drops the playtest game and restores the suspended editor verbatim. Reuses `Mode::Play`, so playtest draw/tick/input are the normal play paths; `suspended_editor.is_some()` is the "is playtest" flag (drives the `(playtest)` title cue and the `Esc` branch in `handle_board_input`). - `draw(frame, mode, ui)` — renders the active mode (`draw_play` or `editor::draw_editor`), then the overlay layer in precedence **animation > menu > (Play: scroll / Edit: dialog)**: an `Overlay`-layer animation via `AnimWidget`, else `MenuWidget`, else the play scroll overlay (`ScrollOverlayWidget`) or — via `kiln_ui::render_overlay` — the editor's glyph picker (when open) or dialog (both `CursorOverlay`s). `draw_play` wraps the board in a bordered `Block` (board-name title; latest log previewed in the bottom border when the panel is closed). `draw_board_area` initializes a pending portal transition (now that the inner area is known) and renders either the board-layer wipe animation or the live board + speech bubbles. **`kiln-tui/src/mode.rs`** — top-level application mode: -- `Mode { Play(GameState), Edit(EditorState) }` — exactly one is live at a time (play and edit are mutually exclusive: entering the editor drops the running game; returning to play rebuilds it fresh from the world file). `PendingMode { EnterEditor, PlayWorld }` — a deferred Play↔Edit switch requested by a menu closure (which only has `&mut Ui`) and applied by the run loop, mirroring the `should_quit` pattern. +- `Mode { Play(GameState), Edit(EditorState) }` — exactly one is live at a time (play and edit are mutually exclusive: entering the editor drops the running game; returning to play rebuilds it fresh from the world file). `PendingMode { EnterEditor, PlayWorld }` — a deferred Play↔Edit switch requested by a menu closure (which only has `&mut Ui`) and applied by the run loop, mirroring the `should_quit` pattern. A **playtest** also uses `Mode::Play` but is launched from the editor's sidebar (not a `PendingMode`): the parked editor lives in `Ui::suspended_editor` so `Esc` restores it (see `apply_playtest_transition`). **`kiln-tui/src/editor.rs`** — world editor mode: -- `EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option>`, `code_editor: Option` (the open script editor; replaces the board view), `glyph_dialog: Option>` (the open glyph picker overlay), a blinking `cursor`, the **drawing tool** (`current_archetype` + `current_glyph` — the thing stamped on `space`, defaulting to a wall — and `draw_mode`, the toggle that re-stamps on every cursor move), `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), and editor-side `log`. -- `MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`) — a sidebar menu level; `title()` (breadcrumb segment) and `entries() -> Vec` (the level's lines). A `MenuEntry` is a key-activated `Item` (`[k] Label` + a boxed `FnOnce(&mut EditorState)` action), a `CurrentValue` (a boxed `Fn(&EditorState) -> String` shown indented beneath an item, e.g. the world name), or a `Blank`/`Separator` spacer. An item's action **opens a dialog** (`World`/scripts/boards), **pushes a child level** (`Main` → `World`/`Floor`/`Terrain`/`Machines`; `Machines` → `Pushers`), or **sets the drawing tool** (`Terrain`/`Pushers` → `set_current(arch)`). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor. -- Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → the matched `entries()` item's action via `MenuLevel::perform_key`), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs). +- `EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option>`, `code_editor: Option` (the open script editor; replaces the board view), `glyph_dialog: Option>` (the open glyph picker overlay), a blinking `cursor`, the **drawing tool** (`current_archetype` + `current_glyph` — the thing stamped on `space`, defaulting to a wall — and `draw_mode`, the toggle that re-stamps on every cursor move), `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), editor-side `log`, and `pending_playtest: Option` (a playtest game parked for the run loop to swap into `Mode::Play`). +- `MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`) — a sidebar menu level; `title()` (breadcrumb segment) and `entries() -> Vec` (the level's lines). A `MenuEntry` is a key-activated `Item` (`[k] Label` + a boxed `FnOnce(&mut EditorState)` action), a `CurrentValue` (a boxed `Fn(&EditorState) -> String` shown indented beneath an item, e.g. the world name), or a `Blank`/`Separator` spacer. An item's action **opens a dialog** (`World`/scripts/boards), **pushes a child level** (`Main` → `World`/`Floor`/`Terrain`/`Machines`; `Machines` → `Pushers`), **sets the drawing tool** (`Terrain`/`Pushers` → `set_current(arch)`), or **launches a playtest** (`Main` → `[p] Play board` → `playtest()`). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor. +- Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → the matched `entries()` item's action via `MenuLevel::perform_key`), `playtest()` (deep-clones the world, sets its start to the edited board, builds a `GameState` + runs `init()`, and parks it in `pending_playtest` for the run loop), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs). - Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`. - `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel. **`kiln-tui/src/ui.rs`** — front-end presentation state (not on `GameState`): -- `Ui { log: LogState, overlay: ScrollOverlayState, pending_transition, active_animation: Option>, menu: Option, should_quit, world_path, pending_mode }`. While `active_animation` is `Some` all input is suppressed. `tick(dt, mode)` advances the active animation (and, on completion, clears scroll/menu state when returning to `Board`), then ticks the active mode — the game only in `Play` + `Board` input mode (so an open scroll/menu pauses it), the editor's cursor blink in `Edit`; a scroll that appears from a tick starts an open animation. +- `Ui { log: LogState, overlay: ScrollOverlayState, pending_transition, active_animation: Option>, menu: Option, should_quit, world_path, pending_mode, suspended_editor: Option, exit_playtest: bool }` (the last two drive the editor playtest: the parked editor and its `Esc`-to-exit signal). While `active_animation` is `Some` all input is suppressed. `tick(dt, mode)` advances the active animation (and, on completion, clears scroll/menu state when returning to `Board`), then ticks the active mode — the game only in `Play` + `Board` input mode (so an open scroll/menu pauses it), the editor's cursor blink in `Edit`; a scroll that appears from a tick starts an open animation. - `open_menu(items)` / `begin_close_menu()` — start the menu open/close animations (snapshotting `MenuState`). `begin_close(choice, game)` — set the player's scroll choice and start the scroll close animation. `start_transition(old, new, area)` — build the board-wipe once the inner area is known. `scroll_lines(delta)` / `scroll_menu(delta)` — clamped overlay/menu scrolling. **`kiln-tui/src/input.rs`** — input mode + dispatch: - `InputMode { Board, Scroll, Menu, Editor, Dialog, CodeEditor, GlyphDialog, Ignore, Frozen }`. `current_input_mode(mode, ui)` — derives the mode: an active animation declares its own (`input_mode_during`); a pending transition → `Ignore`; an open `ui.menu` → `Menu`; else from `Mode` (`Edit` → `GlyphDialog` when the glyph picker is open, else `Dialog` when a dialog is open, else `CodeEditor` when a script editor is open, else `Editor`; `Play` → `Scroll` when a scroll is active else `Board`). -- `handle_board_input` (arrows move the player via `try_move_with_transition`, `l` toggles log, `Esc` opens the `pause_menu_items()` overlay, PageUp/Down + mouse scroll/resize the log), `handle_editor_input` (arrows move the cursor, `l` toggles log, `g` opens the glyph picker, `Esc` → `menu_escape`, other letters → `menu_key`, right-click moves the cursor, drag resizes the sidebar), `handle_scroll_input` (scroll nav + choice letters via `resolve_choice`), `handle_menu_input` (clone the matched item's action `Rc` to drop the `ui.menu` borrow, then call it). `try_move_with_transition` captures both board `Rc`s into `ui.pending_transition` when a move crosses a portal. +- `handle_board_input` (arrows move the player via `try_move_with_transition`, `l` toggles log, `Esc` opens the `pause_menu_items()` overlay — or, during a playtest, sets `ui.exit_playtest` to return to the editor, PageUp/Down + mouse scroll/resize the log), `handle_editor_input` (arrows move the cursor, `l` toggles log, `g` opens the glyph picker, `Esc` → `menu_escape`, other letters → `menu_key`, right-click moves the cursor, drag resizes the sidebar), `handle_scroll_input` (scroll nav + choice letters via `resolve_choice`), `handle_menu_input` (clone the matched item's action `Rc` to drop the `ui.menu` borrow, then call it). `try_move_with_transition` captures both board `Rc`s into `ui.pending_transition` when a move crosses a portal. **`kiln-tui/src/animation.rs`** — the animation abstraction: - `Animation` trait — `update(dt) -> bool` (done?), `draw(area, buf)`, `layer()`, `input_mode_during()` (default `Ignore`), `input_mode_after()`. `AnimationLayer { Board, Overlay }` (board-area replacement vs. full-screen overlay). `AnimWidget<'a>(&'a dyn Animation)` — adapts an animation to a ratatui `Widget` for `frame.render_widget`. diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 9639969..fff24b9 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -24,6 +24,12 @@ use std::collections::{BTreeMap, HashMap}; /// separate element palette or index indirection. Access cells with /// [`Board::get`] and [`Board::get_mut`] using `(x, y)` coordinates. /// Use [`Board::is_passable`] for collision checks. +/// +/// `Board` derives [`Clone`] to support deep-copying a whole [`World`](crate::world::World) +/// (see [`World::deep_clone`](crate::world::World::deep_clone)) — e.g. the editor's +/// playtest runs a game against an isolated copy so play mutations never touch the +/// boards being edited. +#[derive(Clone)] pub struct Board { /// Human-readable name for this board, loaded from the map file and round-tripped on save. pub name: String, diff --git a/kiln-core/src/layer.rs b/kiln-core/src/layer.rs index 4c89af2..f62aae5 100644 --- a/kiln-core/src/layer.rs +++ b/kiln-core/src/layer.rs @@ -29,6 +29,7 @@ use tinyrand::StdRand; /// it draws nothing and lets the layer beneath show through. Solidity comes from /// the archetype and is independent of transparency. The grid is `width * height` /// long; index it via the owning [`Board`](crate::board::Board)'s dimensions. +#[derive(Clone)] pub(crate) struct Layer { /// Row-major `(Glyph, Archetype)` cells for this layer. pub(crate) cells: Vec<(Glyph, Archetype)>, diff --git a/kiln-core/src/object_def.rs b/kiln-core/src/object_def.rs index 0548d53..4891c1a 100644 --- a/kiln-core/src/object_def.rs +++ b/kiln-core/src/object_def.rs @@ -22,6 +22,7 @@ use std::collections::HashSet; /// Scripts are executed by [`crate::script::ScriptHost`]: an object's optional /// `init()` and `tick(dt)` functions are called via [`GameState::run_init`] and /// [`GameState::tick`]. Other event hooks (touch, shoot, …) are future work. +#[derive(Clone)] pub struct ObjectDef { /// Stable identity assigned by [`crate::board::Board::add_object`]. /// `0` is the sentinel meaning "not yet inserted into a board". diff --git a/kiln-core/src/world.rs b/kiln-core/src/world.rs index 1262bd2..ab97854 100644 --- a/kiln-core/src/world.rs +++ b/kiln-core/src/world.rs @@ -36,6 +36,29 @@ pub struct World { pub boards: HashMap>>, } +impl World { + /// Returns a fully independent copy of this world. + /// + /// Unlike a shallow clone (which would copy each board's `Rc` and so keep + /// *sharing* the underlying [`Board`]s), this rebuilds every board into a brand + /// new `Rc>`, so mutating the copy never affects the original. + /// The editor uses it to playtest a board against an isolated game state, leaving + /// the boards being edited untouched. (An explicit method rather than a `Clone` + /// impl, precisely because the shallow, `Rc`-sharing behavior would be surprising.) + pub fn deep_clone(&self) -> World { + World { + name: self.name.clone(), + start: self.start.clone(), + scripts: self.scripts.clone(), + boards: self + .boards + .iter() + .map(|(key, board)| (key.clone(), Rc::new(RefCell::new(board.borrow().clone())))) + .collect(), + } + } +} + /// Serde target for the top-level `.toml` world file. #[derive(Deserialize)] struct WorldFile { @@ -91,3 +114,46 @@ pub fn load(path: &str) -> Result> { boards, }) } + +#[cfg(test)] +mod tests { + use super::World; + use crate::archetype::Archetype; + use crate::board::tests::open_board; + use std::cell::RefCell; + use std::collections::HashMap; + use std::rc::Rc; + + /// A `deep_clone`d world owns independent boards: mutating the copy must leave the + /// original board untouched (the property the editor's playtest relies on). + #[test] + fn deep_clone_isolates_boards() { + let board = open_board(3, 1, (0, 0), vec![]); + let mut boards = HashMap::new(); + boards.insert("start".to_string(), Rc::new(RefCell::new(board))); + let world = World { + name: "w".into(), + start: "start".into(), + scripts: HashMap::new(), + boards, + }; + + let copy = world.deep_clone(); + // Stamp a wall into the copy's board. + copy.boards["start"].borrow_mut().place_archetype( + 1, + 0, + Archetype::Wall, + Archetype::Wall.default_glyph(), + ); + + // The copy changed; the original is still empty at that cell. + assert_eq!(copy.boards["start"].borrow().get(0, 1, 0).1, Archetype::Wall); + assert_eq!( + world.boards["start"].borrow().get(0, 1, 0).1, + Archetype::Empty + ); + // And they are genuinely different allocations. + assert!(!Rc::ptr_eq(&world.boards["start"], ©.boards["start"])); + } +} diff --git a/kiln-tui/src/editor.rs b/kiln-tui/src/editor.rs index 56e28cb..89d3c42 100644 --- a/kiln-tui/src/editor.rs +++ b/kiln-tui/src/editor.rs @@ -15,6 +15,7 @@ use crate::render::{BoardWidget, board_to_screen, screen_to_board}; use crate::ui::Ui; use crate::utils::rgba8_to_color; use kiln_core::cp437::tile_to_char; +use kiln_core::game::GameState; use kiln_core::glyph::Glyph; use kiln_core::log::LogLine; use kiln_core::world::World; @@ -89,6 +90,10 @@ pub(crate) struct EditorState { last_board_area: Rect, /// Editor-side log lines (the editor has no game log to draw from). log: Vec, + /// A playtest game built by [`playtest`](EditorState::playtest), waiting for the + /// run loop to swap into [`Mode::Play`](crate::mode::Mode::Play). `Some` for only + /// the single frame between the menu action and the swap. + pub(crate) pending_playtest: Option, } impl EditorState { @@ -111,6 +116,7 @@ impl EditorState { blink_timer: 0.0, last_board_area: Rect::default(), log: vec![LogLine::raw("editing — press [esc] for the menu")], + pending_playtest: None, } } @@ -307,6 +313,24 @@ impl EditorState { let max = total_w.saturating_sub(MIN_PANEL_WIDTH).max(MIN_PANEL_WIDTH); self.sidebar_width = target.clamp(MIN_PANEL_WIDTH, max); } + + /// Playtest the board currently being edited, using its in-memory state. + /// + /// Builds a [`GameState`] against a **deep copy** of the world ([`World::deep_clone`]) + /// so nothing the playtest does (pushing crates, running scripts, …) touches the + /// boards being edited. The copy's start board is set to the edited board so play + /// begins here, not at the world's entry board. The game is parked in + /// [`pending_playtest`](EditorState::pending_playtest); the run loop swaps it into + /// [`Mode::Play`](crate::mode::Mode::Play) and stashes this editor for `Esc` to restore. + pub(crate) fn playtest(&mut self) { + let mut world = self.world.deep_clone(); + // Start the playtest on the board open in the editor, not world.start. + world.start = self.board_name.clone(); + let mut game = GameState::from_world(world); + game.log(LogLine::raw("[esc] to return to the editor")); + game.run_init(); + self.pending_playtest = Some(game); + } } /// Handles an input event while a dialog is open ([`InputMode::Dialog`](crate::input::InputMode::Dialog)). diff --git a/kiln-tui/src/editor_menu.rs b/kiln-tui/src/editor_menu.rs index ab7af98..86217f8 100644 --- a/kiln-tui/src/editor_menu.rs +++ b/kiln-tui/src/editor_menu.rs @@ -45,6 +45,8 @@ impl MenuLevel { pub(crate) fn entries(self) -> Vec { match self { MenuLevel::Main => vec![ + MenuEntry::item('p', "Play board", |ed| ed.playtest()), + MenuEntry::Separator, MenuEntry::item('w', "World...", |ed| ed.menu.push(MenuLevel::World)), MenuEntry::item('s', "Scripts...", |ed| ed.open_scripts_dialog()), MenuEntry::item('b', "Boards...", |ed| ed.open_boards_dialog()), diff --git a/kiln-tui/src/input.rs b/kiln-tui/src/input.rs index ac5e69f..46c6816 100644 --- a/kiln-tui/src/input.rs +++ b/kiln-tui/src/input.rs @@ -86,6 +86,8 @@ pub(crate) fn handle_board_input( ) -> bool { match event { Event::Key(key) => match key.code { + // During a playtest, Esc returns to the editor instead of opening the menu. + KeyCode::Esc if ui.suspended_editor.is_some() => ui.exit_playtest = 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), diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index 13f2c29..7cc82e9 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -195,6 +195,9 @@ fn run(terminal: &mut ratatui::DefaultTerminal, mode: &mut Mode, ui: &mut Ui) -> // Apply any Play↔Edit switch a menu action requested this frame. apply_pending_mode(mode, ui); + // Enter/leave a playtest if the editor or a playtest Esc requested it. + apply_playtest_transition(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(()); @@ -229,6 +232,40 @@ fn apply_pending_mode(mode: &mut Mode, ui: &mut Ui) { } } +/// Applies a pending **playtest** enter or exit, swapping the top-level [`Mode`]. +/// +/// Entering: the editor parked a built [`GameState`] in +/// [`EditorState::pending_playtest`]; swap to [`Mode::Play`] and stash the editor in +/// [`Ui::suspended_editor`] so `Esc` can restore it. Taking the option first drops the +/// `&mut EditorState` borrow before [`std::mem::replace`], whose return value hands us +/// the old `Mode::Edit` (so the editor is preserved with no placeholder variant). +/// +/// Exiting (`Esc` during a playtest set [`Ui::exit_playtest`]): drop the playtest game +/// and restore the suspended editor verbatim. The deep-copied playtest world is simply +/// dropped, so the editor's own boards were never touched. +fn apply_playtest_transition(mode: &mut Mode, ui: &mut Ui) { + // Enter: take the parked game (this ends the editor borrow before the swap). + let new_game = if let Mode::Edit(ed) = mode { + ed.pending_playtest.take() + } else { + None + }; + if let Some(game) = new_game { + let old = std::mem::replace(mode, Mode::Play(game)); + if let Mode::Edit(editor) = old { + ui.suspended_editor = Some(editor); + } + } + + // Exit: restore the parked editor, dropping the playtest game. + if ui.exit_playtest { + ui.exit_playtest = false; + if let Some(editor) = ui.suspended_editor.take() { + *mode = Mode::Edit(editor); + } + } +} + /// 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 @@ -286,6 +323,13 @@ 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(); + // A playtest (launched from the editor) marks its title so it's distinct from play. + let title = if ui.suspended_editor.is_some() { + format!(" {} (playtest) ", board.name) + } else { + format!(" {} ", board.name) + }; + if ui.log.open { // Split the screen: board on top, log panel of `height` at the bottom. let [board_area, log_area] = @@ -294,7 +338,7 @@ fn draw_play(frame: &mut Frame, game: &GameState, ui: &mut Ui) { .areas(frame.area()); let block = Block::bordered() - .title(format!(" {} ", board.name)) + .title(title) .merge_borders(MergeStrategy::Exact); let inner = block.inner(board_area); frame.render_widget(block, board_area); @@ -302,7 +346,7 @@ fn draw_play(frame: &mut Frame, game: &GameState, ui: &mut Ui) { frame.render_stateful_widget(LogWidget::new(&game.log), log_area, &mut ui.log); } else { // Panel closed: board fills the screen, latest message in the border. - let mut block = Block::bordered().title(format!(" {} ", board.name)); + let mut block = Block::bordered().title(title); block = block.title_bottom(log_preview_line(&game.log).left_aligned()); let inner = block.inner(frame.area()); frame.render_widget(block, frame.area()); diff --git a/kiln-tui/src/ui.rs b/kiln-tui/src/ui.rs index 8f830f7..3b79c96 100644 --- a/kiln-tui/src/ui.rs +++ b/kiln-tui/src/ui.rs @@ -4,6 +4,7 @@ use std::time::Duration; use kiln_core::Board; use kiln_core::game::GameState; use crate::animation::Animation; +use crate::editor::EditorState; use crate::log::LogState; use crate::menu::{MenuItem, MenuCloseAnimation, MenuOpenAnimation, MenuState}; use crate::mode::{Mode, PendingMode}; @@ -35,6 +36,14 @@ pub(crate) struct Ui { /// 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, + /// 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, + /// 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 { @@ -48,6 +57,8 @@ impl Default for Ui { should_quit: false, world_path: String::new(), pending_mode: None, + suspended_editor: None, + exit_playtest: false, } } }