From 3c7a0eb4fb8acaef83b385ab0294389b1e121cd5 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 6 May 2026 04:57:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(engine):=20restore=20prompt=20on=20launch?= =?UTF-8?q?=20=E2=80=94=20Continue=20or=20start=20fresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the engine silently restored any saved in-progress game from `game_state.json` on startup. Players who launched expecting a fresh deal got dropped back into a half-played game with no signal that a save had been picked up; players who wanted to continue had no clear acknowledgement either way. Now: when launching with a saved game that has at least one move and isn't already won, the engine holds the saved state in a new `PendingRestoredGame` resource and seeds `GameStateResource` with a fresh deal. Once the splash overlay finishes, a modal appears: Welcome back You have an in-progress game. Continue where you left off, or start a new one? [New game] [Continue] - Continue (Enter / C / click) — swaps the saved game into `GameStateResource` and fires `StateChangedEvent`. Card sprites resync to the restored layout. - New game (N / click) — drops the saved state, fires `NewGameRequestEvent { confirmed: true }`. The existing `handle_new_game` flow then deletes `game_state.json` and deals. Save files with `move_count == 0` (a fresh deal that was never played) skip the prompt and load directly — there's nothing meaningful to "continue" there. Won games skip too (the existing flow already deletes their save file on win). The spawn system gates on `SplashRoot` being absent so the modal doesn't pop up over the brand splash on first launch. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_engine/src/game_plugin.rs | 168 +++++++++++++++++++++++++++- 1 file changed, 163 insertions(+), 5 deletions(-) diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index ab51c17..7677824 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -73,6 +73,32 @@ pub struct GameStatePath(pub Option); #[derive(Resource, Debug, Clone)] pub struct ReplayPath(pub Option); +/// Holds the saved-on-disk in-progress game between plugin build and +/// the player's answer to the "Continue or start a new game?" prompt. +/// +/// Some(game) at startup means a previously-saved game existed and had +/// real moves on it. The restore-prompt modal swaps it into +/// `GameStateResource` if the player picks Continue, or drops it (and +/// lets `handle_new_game` clean up the disk file) on New Game. None for +/// first-launch installs and for save files that contain a fresh deal +/// with no moves yet — there's nothing meaningful to "continue" there. +#[derive(Resource, Debug, Default)] +pub struct PendingRestoredGame(pub Option); + +/// Marker on the "Welcome back — Continue or start a new game?" modal +/// scrim. Despawning the scrim cascades to the card and children, so a +/// single `commands.entity(scrim).despawn()` tears the modal down. +#[derive(Component, Debug)] +pub struct RestorePromptScreen; + +/// Marker on the modal's primary "Continue" button. +#[derive(Component, Debug)] +pub struct RestoreContinueButton; + +/// Marker on the modal's secondary "New game" button. +#[derive(Component, Debug)] +pub struct RestoreNewGameButton; + /// In-memory accumulator for [`ReplayMove`] entries during the current /// game. Cleared on every new-game start; frozen into a [`Replay`] and /// flushed to disk by [`record_replay_on_win`] when the player wins. @@ -110,11 +136,32 @@ impl GamePlugin { impl Plugin for GamePlugin { fn build(&self, app: &mut App) { let path = game_state_file_path(); - // Restore any saved in-progress game, falling back to a fresh deal. - let initial_state = path - .as_deref() - .and_then(load_game_state_from) - .unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne)); + // Try to load any saved in-progress game. We don't want to + // silently restore a half-played game on launch — the player + // should get to decide between continuing and starting fresh. + // So: if there IS a saved game with progress and it isn't + // already won, hold it in `PendingRestoredGame` and let the + // restore-prompt modal swap it into `GameStateResource` if + // the player picks Continue. Otherwise put it directly into + // `GameStateResource` (existing behaviour for un-played / + // won deals which there's nothing to ask about). + let saved = path.as_deref().and_then(load_game_state_from); + let prompt_worthy = saved + .as_ref() + .is_some_and(|g| g.move_count > 0 && !g.is_won); + let (initial_state, pending_restore) = if prompt_worthy { + ( + GameState::new(seed_from_system_time(), DrawMode::DrawOne), + saved, + ) + } else { + ( + saved.unwrap_or_else(|| { + GameState::new(seed_from_system_time(), DrawMode::DrawOne) + }), + None, + ) + }; // One-shot migration from the legacy single-slot // `latest_replay.json` to the rolling history at `replays.json`. @@ -137,6 +184,7 @@ impl Plugin for GamePlugin { app.insert_resource(GameStateResource(initial_state)) .insert_resource(GameStatePath(path)) .insert_resource(ReplayPath(history_path)) + .insert_resource(PendingRestoredGame(pending_restore)) .init_resource::() .init_resource::() .init_resource::() @@ -173,6 +221,11 @@ impl Plugin for GamePlugin { .add_systems(Update, handle_confirm_button_input.after(GameMutation)) .add_systems(Update, handle_game_over_input.after(GameMutation)) .add_systems(Update, handle_game_over_button_input.after(GameMutation)) + // Restore prompt: spawn the modal once the splash is gone, + // route Continue / New Game intents back into the existing + // GameMutation flow. + .add_systems(Update, spawn_restore_prompt_if_pending) + .add_systems(Update, handle_restore_prompt.before(GameMutation)) .init_resource::() .add_systems(Update, tick_elapsed_time) .add_systems(Update, auto_save_game_state) @@ -462,6 +515,111 @@ pub struct ConfirmNoButton; /// and "No (N)" — those were not real Button entities, so the player /// had no hover / press feedback and the modal felt like a debug panel /// (the user's smoke-test "#2 complaint"). +/// Update-schedule system: once the splash overlay is gone and there's +/// a pending restored game waiting for the player's answer, spawn the +/// "Welcome back — Continue or start a new game?" modal. Idempotent — +/// the existing `RestorePromptScreen` query gates against duplicate +/// spawns if Update fires before the player clicks. +fn spawn_restore_prompt_if_pending( + mut commands: Commands, + pending: Res, + splash: Query<(), With>, + existing: Query<(), With>, + font_res: Option>, +) { + if pending.0.is_none() || !splash.is_empty() || !existing.is_empty() { + return; + } + spawn_modal( + &mut commands, + RestorePromptScreen, + ui_theme::Z_MODAL_PANEL, + |card| { + spawn_modal_header(card, "Welcome back", font_res.as_deref()); + spawn_modal_body_text( + card, + "You have an in-progress game. Continue where you left off, or start a new one?", + ui_theme::TEXT_SECONDARY, + font_res.as_deref(), + ); + spawn_modal_actions(card, |actions| { + spawn_modal_button( + actions, + RestoreNewGameButton, + "New game", + Some("N"), + ButtonVariant::Secondary, + font_res.as_deref(), + ); + spawn_modal_button( + actions, + RestoreContinueButton, + "Continue", + Some("Enter"), + ButtonVariant::Primary, + font_res.as_deref(), + ); + }); + }, + ); +} + +/// Click handlers + keyboard shortcuts for the restore prompt. +/// +/// Continue (Enter / C) — swaps the saved game into `GameStateResource` +/// and writes a `StateChangedEvent` so card sprites resync to the +/// restored layout. +/// New game (N) — drops the saved game and writes +/// `NewGameRequestEvent { confirmed: true }`. The existing +/// `handle_new_game` flow takes over: deletes `game_state.json`, deals +/// a fresh game, fires `StateChangedEvent`. `confirmed: true` skips +/// the abandon-current-game confirm dialog (the player has already +/// confirmed by clicking New game here). +#[allow(clippy::too_many_arguments)] +fn handle_restore_prompt( + mut commands: Commands, + keys: Option>>, + screens: Query>, + continue_buttons: Query<&Interaction, (With, Changed)>, + new_game_buttons: Query<&Interaction, (With, Changed)>, + mut pending: ResMut, + mut game: ResMut, + mut changed: MessageWriter, + mut new_game: MessageWriter, +) { + if screens.is_empty() { + return; + } + let key_continue = keys + .as_ref() + .is_some_and(|k| k.just_pressed(KeyCode::Enter) || k.just_pressed(KeyCode::KeyC)); + let key_new = keys.as_ref().is_some_and(|k| k.just_pressed(KeyCode::KeyN)); + let click_continue = continue_buttons + .iter() + .any(|i| *i == Interaction::Pressed); + let click_new = new_game_buttons.iter().any(|i| *i == Interaction::Pressed); + + if key_continue || click_continue { + if let Some(restored) = pending.0.take() { + game.0 = restored; + changed.write(StateChangedEvent); + } + for entity in &screens { + commands.entity(entity).despawn(); + } + } else if key_new || click_new { + pending.0 = None; + for entity in &screens { + commands.entity(entity).despawn(); + } + new_game.write(NewGameRequestEvent { + seed: None, + mode: None, + confirmed: true, + }); + } +} + fn spawn_confirm_dialog( commands: &mut Commands, original_request: NewGameRequestEvent,