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
+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,
}
}
}