diff --git a/solitaire_engine/src/win_summary_plugin.rs b/solitaire_engine/src/win_summary_plugin.rs index 388c5cf..e8041a8 100644 --- a/solitaire_engine/src/win_summary_plugin.rs +++ b/solitaire_engine/src/win_summary_plugin.rs @@ -5,6 +5,13 @@ //! started), a full-screen modal is spawned showing score, time, XP, and a //! "Play Again" button that fires `NewGameRequestEvent` and closes the modal. //! +//! # Phase G (docs/ui-redesign-2026-07.md) — action hierarchy +//! The modal leads with actions: **Play Again** (primary, Enter +//! accelerator), then **Watch Replay** and **Share Replay** (shared +//! `stats_plugin` markers, so the global handlers there act on the +//! just-won replay), with the score/time/XP recap reading quietly +//! below. +//! //! # Task #47 — Win fanfare screen-shake //! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets //! the `Camera2d` `Transform` each frame with a decaying oscillation until the @@ -23,7 +30,7 @@ use crate::game_plugin::GameMutation; use crate::progress_plugin::ProgressResource; use crate::resources::GameStateResource; use crate::settings_plugin::SettingsResource; -use crate::stats_plugin::{StatsResource, StatsUpdate}; +use crate::stats_plugin::{CopyShareLinkButton, StatsResource, StatsUpdate, WatchReplayButton}; use crate::ui_modal::ModalScrim; use crate::ui_theme::{ ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS, @@ -163,12 +170,15 @@ pub struct SessionAchievements { #[derive(Component, Debug)] pub struct WinSummaryOverlay; -/// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal. +/// Marker on the "Play Again" button inside the win-summary modal. +/// +/// Watch Replay and Share Replay carry the shared +/// [`WatchReplayButton`] / [`CopyShareLinkButton`] markers from +/// `stats_plugin`, so the global handlers there drive them (both act +/// on [`crate::stats_plugin::SelectedReplayIndex`], which snaps to the +/// just-won replay on every `GameWonEvent`). #[derive(Component, Debug)] -enum WinSummaryButton { - PlayAgain, - WatchReplay, -} +struct WinSummaryPlayAgainButton; /// Marker for one row of the win-modal score-breakdown reveal. /// @@ -230,6 +240,7 @@ impl Plugin for WinSummaryPlugin { collect_session_achievements, spawn_win_summary_after_delay, handle_win_summary_buttons, + close_overlay_on_watch_replay, handle_win_summary_keyboard, apply_screen_shake, reveal_score_breakdown, @@ -604,50 +615,48 @@ fn spawn_win_summary_after_delay( } } -/// Handles "Play Again" and "Watch Replay" in the win-summary modal. -/// Handles "Play Again" and "Watch Replay" in the win-summary modal. +/// Handles "Play Again" in the win-summary modal: collapses the +/// overlay and requests a fresh deal. `NewGameRequestEvent::default()` +/// reuses the current game's `GameMode`, and `handle_new_game` reads +/// the deal options (draw mode, difficulty) from `Settings` — so the +/// rematch is "same mode + same options" in one tap. fn handle_win_summary_buttons( - interaction_query: Query<(&Interaction, &WinSummaryButton), Changed>, + interaction_query: Query<&Interaction, (Changed, With)>, overlays: Query>, mut commands: Commands, mut new_game: MessageWriter, - mut toast: MessageWriter, - history: Option>, - mut playback: Option>, ) { - // Collect all pressed buttons first to avoid moving `playback` inside the loop. - let pressed: Vec<&WinSummaryButton> = interaction_query - .iter() - .filter(|(i, _)| **i == Interaction::Pressed) - .map(|(_, b)| b) - .collect(); + if !interaction_query.iter().any(|i| *i == Interaction::Pressed) { + return; + } + for entity in &overlays { + commands.entity(entity).despawn(); + } + new_game.write(NewGameRequestEvent::default()); +} - for button in pressed { - match button { - WinSummaryButton::PlayAgain => { - for entity in &overlays { - commands.entity(entity).despawn(); - } - new_game.write(NewGameRequestEvent::default()); - } - WinSummaryButton::WatchReplay => { - let latest = history.as_ref().and_then(|h| h.0.replays.last()).cloned(); - match (latest, playback.as_mut()) { - (Some(replay), Some(pb)) => { - for entity in &overlays { - commands.entity(entity).despawn(); - } - crate::replay_playback::start_replay_playback(&mut commands, pb, replay); - } - (Some(_), None) => { - toast.write(InfoToastEvent("Replay playback not available".to_string())); - } - (None, _) => { - toast.write(InfoToastEvent("No replay saved yet".to_string())); - } - } - } - } +/// Collapses the win-summary overlay when its "Watch Replay" button is +/// pressed, so playback (started by `stats_plugin`'s global +/// [`WatchReplayButton`] handler reacting to the same press) is not +/// hidden behind the celebration scrim. No-op while the overlay is +/// closed — the Replays-tab copy of the button manages its own modal. +/// +/// "Share Replay" ([`CopyShareLinkButton`]) deliberately does NOT +/// close the overlay: the player stays on the celebration while the +/// copy-feedback toast confirms the link. +fn close_overlay_on_watch_replay( + buttons: Query<&Interaction, (Changed, With)>, + overlays: Query>, + mut commands: Commands, +) { + if overlays.is_empty() { + return; + } + if !buttons.iter().any(|i| *i == Interaction::Pressed) { + return; + } + for entity in &overlays { + commands.entity(entity).despawn(); } } @@ -818,18 +827,68 @@ fn spawn_overlay( )); } - // Score breakdown reveal — replaces the previous single - // "Score:" line with a per-component multi-row layout. + // --- Action hierarchy (Phase G) --- + // Play Again is the hero action; the two replay + // actions sit under it; the stats recap reads quietly + // below all three. Rematch is one tap. + + // Play Again (primary, full row) + card.spawn(( + WinSummaryPlayAgainButton, + Button, + Node { + padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3), + justify_content: JustifyContent::Center, + align_self: AlignSelf::Stretch, + border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), + margin: UiRect::top(VAL_SPACE_2), + ..default() + }, + BackgroundColor(ACCENT_PRIMARY), + )) + .with_children(|b| { + b.spawn(( + Text::new("Play Again \u{21B5}"), + TextFont { + font_size: TYPE_BODY_LG, + ..default() + }, + TextColor(BG_BASE), + )); + }); + + // Watch Replay + Share Replay (secondary, side by side). + // Both reuse the global stats_plugin handlers, which + // target the just-won replay (`SelectedReplayIndex` + // snaps to 0 on every win). Each is always rendered — + // with no replay / no share URL the handler explains + // itself in a toast instead of silently doing nothing. + card.spawn(Node { + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::Center, + column_gap: VAL_SPACE_3, + ..default() + }) + .with_children(|row| { + spawn_replay_action(row, WatchReplayButton, "Watch Replay"); + spawn_replay_action(row, CopyShareLinkButton, "Share Replay"); + }); + + // --- Quiet stats recap, below the actions --- + + // Score breakdown reveal — per-component multi-row + // layout with the staggered fade-in. spawn_score_breakdown(card, &breakdown, anim_speed); - // Time + // Time (demoted to body/secondary — part of the quiet + // recap, not the celebration headline) card.spawn(( Text::new(format!("Time: {}", format_win_time(pending.time_seconds))), TextFont { - font_size: TYPE_HEADLINE, + font_size: TYPE_BODY_LG, ..default() }, - TextColor(TEXT_PRIMARY), + TextColor(TEXT_SECONDARY), )); // XP total @@ -859,68 +918,40 @@ fn spawn_overlay( if !session.names.is_empty() { spawn_achievements_section(card, &session.names); } - - // Button row: Watch Replay + Play Again side by side. - card.spawn(Node { - flex_direction: FlexDirection::Row, - justify_content: JustifyContent::Center, - column_gap: VAL_SPACE_3, - margin: UiRect::top(VAL_SPACE_2), - ..default() - }) - .with_children(|row| { - // Watch Replay (secondary style) - row.spawn(( - WinSummaryButton::WatchReplay, - Button, - Node { - padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3), - justify_content: JustifyContent::Center, - border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), - border: UiRect::all(Val::Px(1.0)), - ..default() - }, - BackgroundColor(Color::NONE), - BorderColor::all(ACCENT_PRIMARY), - )) - .with_children(|b| { - b.spawn(( - Text::new("Watch Replay"), - TextFont { - font_size: TYPE_BODY_LG, - ..default() - }, - TextColor(ACCENT_PRIMARY), - )); - }); - - // Play Again (primary style) - row.spawn(( - WinSummaryButton::PlayAgain, - Button, - Node { - padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3), - justify_content: JustifyContent::Center, - border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), - ..default() - }, - BackgroundColor(ACCENT_PRIMARY), - )) - .with_children(|b| { - b.spawn(( - Text::new("Play Again \u{21B5}"), - TextFont { - font_size: TYPE_BODY_LG, - ..default() - }, - TextColor(BG_BASE), - )); - }); - }); }); }); } +/// Spawns one secondary (outline-style) replay action button in the +/// win modal's replay row. `marker` is the shared `stats_plugin` +/// click-target component (`WatchReplayButton` / `CopyShareLinkButton`) +/// whose global handler reacts to the press. +fn spawn_replay_action(row: &mut ChildSpawnerCommands, marker: M, label: &str) { + row.spawn(( + marker, + Button, + Node { + padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3), + justify_content: JustifyContent::Center, + border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(Color::NONE), + BorderColor::all(ACCENT_PRIMARY), + )) + .with_children(|b| { + b.spawn(( + Text::new(label.to_string()), + TextFont { + font_size: TYPE_BODY_LG, + ..default() + }, + TextColor(ACCENT_PRIMARY), + )); + }); +} + /// Maximum number of achievement names shown explicitly in the win modal before /// the overflow "...and N more" line is shown instead. const MAX_ACHIEVEMENTS_SHOWN: usize = 3; @@ -1863,4 +1894,134 @@ mod tests { assert_eq!(stagger, 0.0); assert_eq!(fade, 0.0); } + + // ----------------------------------------------------------------------- + // Phase G — action hierarchy + // ----------------------------------------------------------------------- + + /// Like [`make_app`] but with `TimePlugin` disabled and a manual + /// `Time` resource, so tests can step the win-summary delay timer + /// deterministically via `Time::advance_by` (the real clock's + /// microsecond deltas would never reach the 0.5 s threshold). + fn make_app_manual_clock() -> App { + use bevy::time::TimePlugin; + let mut app = App::new(); + app.add_plugins(MinimalPlugins.build().disable::()) + .add_plugins(WinSummaryPlugin) + .insert_resource(StatsResource(StatsSnapshot::default())) + .insert_resource(GameStateResource(GameState::new( + 0, + solitaire_core::DrawStockConfig::DrawOne, + ))) + .insert_resource(ProgressResource(PlayerProgress::default())); + app.init_resource::