playtest mode

This commit is contained in:
2026-06-20 19:05:46 -05:00
parent e7c3ca1139
commit d32914d99d
10 changed files with 168 additions and 9 deletions
+9 -7
View File
@@ -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<String, String>` (named Rhai source, shared across all boards), `boards: HashMap<String, Rc<RefCell<Board>>>` (all boards, always present). Every board is wrapped in `Rc<RefCell<Board>>` 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<World, Box<dyn std::error::Error>>` — reads a world `.toml` file, converts each `[boards.NAME.*]` subtable via `TryFrom<MapFile> 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<RefCell<Board>>` (`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<MenuLevel>` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option<Dialog<EditorState>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (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<MenuEntry>` (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<MenuLevel>` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option<Dialog<EditorState>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (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<GameState>` (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<MenuEntry>` (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<Box<dyn Animation>>, menu: Option<MenuState>, 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<Box<dyn Animation>>, menu: Option<MenuState>, should_quit, world_path, pending_mode, suspended_editor: Option<EditorState>, 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`.
+6
View File
@@ -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,
+1
View File
@@ -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)>,
+1
View File
@@ -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".
+66
View File
@@ -36,6 +36,29 @@ pub struct World {
pub boards: HashMap<String, Rc<RefCell<Board>>>,
}
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<RefCell<Board>>`, 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<World, Box<dyn std::error::Error>> {
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"], &copy.boards["start"]));
}
}
+24
View File
@@ -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<LogLine>,
/// 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<GameState>,
}
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)).
+2
View File
@@ -45,6 +45,8 @@ impl MenuLevel {
pub(crate) fn entries(self) -> Vec<MenuEntry> {
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()),
+2
View File
@@ -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),
+46 -2
View File
@@ -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());
+11
View File
@@ -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<PendingMode>,
/// The editor parked while a **playtest** runs. `Some` exactly while the current
/// [`Mode::Play`](crate::mode::Mode::Play) is a playtest launched from the editor
/// (see [`EditorState::playtest`]); the run loop restores it on exit. Doubles as
/// the "am I playtesting?" flag for `Esc` handling and the title cue.
pub(crate) suspended_editor: Option<EditorState>,
/// Set when `Esc` is pressed during a playtest, signalling the run loop to drop the
/// playtest game and restore [`suspended_editor`](Ui::suspended_editor).
pub(crate) exit_playtest: bool,
}
impl Default for Ui {
@@ -48,6 +57,8 @@ impl Default for Ui {
should_quit: false,
world_path: String::new(),
pending_mode: None,
suspended_editor: None,
exit_playtest: false,
}
}
}