playtest mode
This commit is contained in:
@@ -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)).
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user