//! Win summary modal overlay and screen-shake effect. //! //! # Task #33 — Win summary screen //! On `GameWonEvent`, after a 0.5 s delay (so the cascade animation has //! started), a full-screen modal is spawned showing score, time, XP, and a //! "Play Again" button that fires `NewGameRequestEvent` and closes the modal. //! //! # 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 //! shake duration elapses. use bevy::prelude::*; use solitaire_core::game_state::GameMode; use solitaire_core::scoring::compute_time_bonus; use solitaire_data::AnimSpeed; use crate::achievement_plugin::display_name_for; use crate::events::{ AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, XpAwardedEvent, }; 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::ui_modal::ModalScrim; use crate::ui_theme::{ scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS, MOTION_SCORE_BREAKDOWN_STAGGER_SECS, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS, RADIUS_LG, RADIUS_MD, SCRIM, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_DISPLAY, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, Z_WIN_CASCADE, }; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /// Delay after `GameWonEvent` before the win-summary modal is spawned. /// Chosen so the cascade animation has a moment to start first. const WIN_SUMMARY_DELAY_SECS: f32 = 0.5; /// Default duration of the screen-shake in seconds, before `AnimSpeed` scaling. /// Sourced from `ui_theme::MOTION_WIN_SHAKE_SECS`. const SHAKE_DURATION_SECS: f32 = MOTION_WIN_SHAKE_SECS; /// Maximum camera displacement in world-space pixels at the start of the shake. /// Sourced from `ui_theme::MOTION_WIN_SHAKE_AMPLITUDE`. const SHAKE_INTENSITY: f32 = MOTION_WIN_SHAKE_AMPLITUDE; // --------------------------------------------------------------------------- // Resources // --------------------------------------------------------------------------- /// Accumulates win data while waiting for `XpAwardedEvent` to arrive. /// /// The XP event fires shortly after `GameWonEvent`. We store both pieces of /// data here so the modal can show the complete picture. #[derive(Resource, Debug, Clone, Default)] pub struct WinSummaryPending { /// Score from the most recent `GameWonEvent`. pub score: i32, /// Elapsed game time (seconds) from the most recent `GameWonEvent`. pub time_seconds: u64, /// XP awarded from the most recent `XpAwardedEvent` (0 until that event fires). pub xp: u64, /// Human-readable breakdown of the XP components for the most recent win, /// e.g. `"+50 base +25 no-undo +30 speed"`. Empty until `GameWonEvent` /// populates it. pub xp_detail: String, /// Whether this win beat the player's previous best score or fastest time. /// /// Captured from `StatsResource` **before** `StatsUpdate` mutates it so /// the comparison reflects the old personal-best values. pub new_record: bool, /// When the winning game was a Challenge-mode run, holds the 1-based /// human-readable level number that was just completed (e.g. `Some(3)` /// means "Challenge 3"). `None` for non-Challenge modes. pub challenge_level: Option, /// Number of undos used during the winning game. Captured from /// `GameStateResource` at the moment `GameWonEvent` fires so the /// score-breakdown reveal can decide whether to award the no-undo /// bonus row. pub undo_count: u32, /// Game mode of the winning game. Captured at win time so the /// score-breakdown reveal can format the mode-multiplier row /// (e.g. `Zen ×0.0`, `Classic ×1.0`). pub mode: GameMode, } /// Builds a human-readable XP breakdown string for the win modal. /// /// Mirrors the logic in `solitaire_data::xp_for_win` so the breakdown always /// matches the total shown on the `XpAwardedEvent`. /// /// Examples: /// - slow win, no undo → `"+50 base +25 no-undo"` /// - fast win, undo → `"+50 base +30 speed"` /// - fast win, no undo → `"+50 base +25 no-undo +30 speed"` fn build_xp_detail(time_seconds: u64, used_undo: bool) -> String { let speed_bonus: u64 = if time_seconds >= 120 { 0 } else { let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120); scaled.max(10) }; let no_undo_bonus: u64 = if used_undo { 0 } else { 25 }; let mut parts = vec!["+50 base".to_string()]; if no_undo_bonus > 0 { parts.push("+25 no-undo".to_string()); } if speed_bonus > 0 { parts.push(format!("+{speed_bonus} speed")); } parts.join(" ") } /// Drives the camera shake effect after a win. /// /// While `remaining > 0` a system applies a decaying sinusoidal offset to the /// main camera's `Transform`. The system resets the camera to the origin when /// `remaining` reaches zero. #[derive(Resource, Debug, Clone, Default)] pub struct ScreenShakeResource { /// Seconds of shake remaining. pub remaining: f32, /// Total duration the shake was armed for, used to compute the /// `remaining / total` decay factor. Tracked separately from `remaining` /// because the duration is now scaled by `AnimSpeed`, so a fixed /// divisor would be wrong on Fast. pub total: f32, /// Peak displacement in world-space pixels (decays to zero over `remaining`). pub intensity: f32, } /// Tracks the human-readable names of every achievement unlocked during the /// current game session. /// /// Populated by `collect_session_achievements` from `AchievementUnlockedEvent`s /// and cleared whenever `NewGameRequestEvent` fires so each new game starts /// with a fresh list. This includes all implicit game-context resets triggered /// by mode-switch keys: /// /// | Key | Mode | Event fired | /// |-----|------|-------------| /// | Z | Zen | `NewGameRequestEvent { mode: Some(Zen), .. }` | /// | X | Challenge | `NewGameRequestEvent { mode: Some(Challenge), .. }` | /// | C | Daily Challenge | `NewGameRequestEvent { seed: Some(..), mode: None }` | /// | T | Time Attack | `NewGameRequestEvent { mode: Some(TimeAttack), .. }` | /// /// Because every mode switch routes through `NewGameRequestEvent`, /// `collect_session_achievements` clears this list for all of them. /// The win-summary modal reads this resource to display an /// "Achievements Unlocked" section. #[derive(Resource, Debug, Clone, Default)] pub struct SessionAchievements { /// Display names (not IDs) of achievements unlocked this session, in /// unlock order. pub names: Vec, } // --------------------------------------------------------------------------- // Components // --------------------------------------------------------------------------- /// Marker on the win-summary modal root entity. #[derive(Component, Debug)] pub struct WinSummaryOverlay; /// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal. #[derive(Component, Debug)] enum WinSummaryButton { PlayAgain, WatchReplay, } /// Marker for one row of the win-modal score-breakdown reveal. /// /// Each row carries a stagger delay (seconds until the row should /// become visible) plus a fade-in timer that lerps the row's text /// alpha from `0.0 → 1.0` over [`MOTION_SCORE_BREAKDOWN_FADE_SECS`]. /// Rows are spawned with `Visibility::Hidden`; the reveal system /// flips them to `Visibility::Inherited` once `delay_secs` elapses /// and then drives the per-text alpha lerp until the row reaches /// full opacity. /// /// When `AnimSpeed::Instant` is active the row is spawned with /// `delay_secs = 0.0`, `fade_duration_secs = 0.0`, and visibility /// already set to `Inherited` so the reveal happens on frame 1. #[derive(Component, Debug, Clone, Copy)] pub struct ScoreBreakdownRow { /// Seconds remaining until this row first becomes visible. /// Counts down to 0 in `reveal_score_breakdown`. Zero or negative /// means "show immediately". pub delay_secs: f32, /// Seconds elapsed since this row became visible. Drives the /// alpha lerp on the row's child `Text` nodes. pub fade_elapsed_secs: f32, /// Total fade-in duration. Zero means "no fade — appear at full /// opacity in one frame". pub fade_duration_secs: f32, /// `true` once the row's `Visibility` has been promoted from /// `Hidden` to `Inherited`. Prevents re-running the visibility /// switch every frame after the row first reveals. pub revealed: bool, } // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- /// Registers the win-summary modal and screen-shake systems. pub struct WinSummaryPlugin; impl Plugin for WinSummaryPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() .init_resource::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() // `cache_win_data` must run BEFORE `StatsUpdate` so it can compare // the player's old personal-best values before `StatsPlugin` overwrites them. .add_systems( Update, cache_win_data .after(GameMutation) .before(StatsUpdate), ) .add_systems( Update, ( collect_session_achievements, spawn_win_summary_after_delay, handle_win_summary_buttons, handle_win_summary_keyboard, apply_screen_shake, reveal_score_breakdown, ) .after(GameMutation), ); } } // --------------------------------------------------------------------------- // Pure helpers // --------------------------------------------------------------------------- /// Formats `seconds` as `m:ss`. /// /// ``` /// # use solitaire_engine::win_summary_plugin::format_win_time; /// assert_eq!(format_win_time(0), "0:00"); /// assert_eq!(format_win_time(65), "1:05"); /// assert_eq!(format_win_time(3661), "61:01"); /// ``` pub fn format_win_time(seconds: u64) -> String { let m = seconds / 60; let s = seconds % 60; format!("{m}:{s:02}") } /// Score amount awarded as a "no-undo" bonus in the win modal when the /// player completes the game without using undo. Mirrors the XP-side /// no-undo bonus so the score and XP breakdowns reinforce each other, /// and stays a `pub const` so tests can assert against it without /// re-typing the literal. pub const SCORE_NO_UNDO_BONUS: i32 = 25; /// Decomposed view of the player's final score, displayed in the win /// modal as a sequence of fade-in rows. /// /// The fields mirror the row layout described in the win-modal /// reveal: /// /// ```text /// Base score {base} /// Time bonus ({m:ss}) +{time_bonus} /// No-undo bonus +{no_undo_bonus} /// Mode multiplier ({mode} ×N) ×{multiplier} /// ───────────────────────────────── /// Total {total} /// ``` /// /// Components that do not apply to the current win are zeroed out: /// `time_bonus = 0` when the player took longer than the time-bonus /// curve produces a positive result, `no_undo_bonus = 0` when undo /// was used, and `multiplier = 1.0` outside Zen mode. The renderer /// uses these zero markers to skip rows the player would not benefit /// from seeing. #[derive(Debug, Clone, Copy, PartialEq)] pub struct ScoreBreakdown { /// Running game score before the win-time bonuses are applied. /// Equal to `pending.score`, which is `GameState::score` at the /// moment of `GameWonEvent`. pub base: i32, /// Time-bonus component — `compute_time_bonus(time_seconds)`. /// Zero when `time_seconds == 0` or when the formula yields zero. pub time_bonus: i32, /// Score awarded for completing the win without using undo. /// Zero when `undo_count > 0`. pub no_undo_bonus: i32, /// Multiplier applied to `(base + time_bonus + no_undo_bonus)` to /// produce the final total. `0.0` for Zen mode (which never /// scores), `1.0` otherwise. pub multiplier: f32, /// Game mode the win occurred in. Used by the renderer to format /// the multiplier row label, e.g. `"Mode multiplier (Zen ×0)"`. pub mode: GameMode, /// Elapsed game time in seconds, used to format the time-bonus /// row label as `m:ss`. pub time_seconds: u64, } impl ScoreBreakdown { /// Builds a breakdown for the given win, applying the player's /// **cosmetic** time-bonus multiplier (`Settings::time_bonus_multiplier`) /// to the raw `compute_time_bonus` result before storing it on the /// breakdown. /// /// `base` is the running game score (`pending.score`); `time_seconds`, /// `undo_count`, and `mode` come from the captured `WinSummaryPending`; /// `time_bonus_multiplier` comes from `SettingsResource`. All score /// arithmetic is saturating to keep the breakdown safe even for /// pathologically high scores. /// /// The multiplier is **purely visual** — it changes what the player /// sees in the win modal but does **not** affect achievement /// thresholds, leaderboard submissions, or `StatsSnapshot` totals, /// which all use the raw, unmultiplied scoring values. pub fn compute( base: i32, time_seconds: u64, undo_count: u32, mode: GameMode, time_bonus_multiplier: f32, ) -> Self { let raw_bonus = compute_time_bonus(time_seconds); // Apply the cosmetic multiplier and round back to an integer so // the breakdown total stays a whole-number score. let scaled = (raw_bonus as f32 * time_bonus_multiplier).round(); // Clamp into i32 range defensively — `raw_bonus` is already // bounded by `compute_time_bonus`, but a multiplier of 2.0 on // an i32::MAX-adjacent bonus could still overflow the cast. let time_bonus = if scaled.is_nan() { 0 } else { scaled.clamp(i32::MIN as f32, i32::MAX as f32) as i32 }; let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 }; let multiplier = match mode { GameMode::Zen => 0.0, GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack | GameMode::Difficulty(_) => 1.0, }; Self { base, time_bonus, no_undo_bonus, multiplier, mode, time_seconds, } } /// Final total displayed on the breakdown's bottom row, rounded /// half-to-even (Rust's default `as i32` cast truncates toward /// zero, which is fine for a non-fractional multiplier set). pub fn total(&self) -> i32 { let pre_mult = self .base .saturating_add(self.time_bonus) .saturating_add(self.no_undo_bonus); ((pre_mult as f32) * self.multiplier) as i32 } /// Whether the no-undo bonus row should be rendered. Skipped when /// the player used undo (bonus is zero) so the modal does not /// show a "+0" line that adds nothing. pub fn shows_no_undo_row(&self) -> bool { self.no_undo_bonus > 0 } /// Whether the time-bonus row should be rendered. Skipped when /// the bonus is zero (e.g. `time_seconds == 0`). pub fn shows_time_bonus_row(&self) -> bool { self.time_bonus > 0 } /// Whether the mode-multiplier row should be rendered. Skipped /// for `multiplier == 1.0` so Classic/Challenge/TimeAttack wins /// do not show a redundant "×1.0" line. pub fn shows_multiplier_row(&self) -> bool { (self.multiplier - 1.0).abs() > f32::EPSILON } /// Total number of rows the breakdown will spawn, counting the /// always-present `Base score` and `Total` rows plus the /// separator. Used by tests to assert spawn counts deterministically. pub fn row_count(&self) -> usize { let mut n = 1; // base if self.shows_time_bonus_row() { n += 1; } if self.shows_no_undo_row() { n += 1; } if self.shows_multiplier_row() { n += 1; } n += 1; // separator n += 1; // total n } } /// Human-readable display name for a game mode. Used as the prefix in /// the mode-multiplier row, e.g. `"Mode multiplier (Zen ×0)"`. fn mode_display_name(mode: GameMode) -> &'static str { match mode { GameMode::Classic => "Classic", GameMode::Zen => "Zen", GameMode::Challenge => "Challenge", GameMode::TimeAttack => "Time Attack", GameMode::Difficulty(level) => level.label(), } } // --------------------------------------------------------------------------- // Systems // --------------------------------------------------------------------------- /// Caches score/time from `GameWonEvent` and XP from `XpAwardedEvent` into /// `WinSummaryPending` so they are available when the modal spawns. /// /// Also compares the win result against the player's previous personal bests /// **before** `StatsUpdate` overwrites them, setting `WinSummaryPending::new_record` /// and queuing an `InfoToastEvent` when the player sets a new record. /// /// When the winning game is in `GameMode::Challenge`, the current /// `challenge_index` (before `ChallengePlugin` advances it) is captured as the /// 1-based level number and stored in `WinSummaryPending::challenge_level`. /// /// This system is scheduled `.before(StatsUpdate)` so the comparison always /// sees the old best values. fn cache_win_data( mut won: MessageReader, mut xp: MessageReader, mut pending: ResMut, stats: Res, game: Res, progress: Res, mut toast: MessageWriter, ) { for ev in won.read() { // Compare against old personal bests BEFORE StatsPlugin updates them. // `best_single_score == 0` means no wins yet — any positive score is a record. // `fastest_win_seconds == u64::MAX` is the sentinel for "no wins yet". let beats_score = ev.score > 0 && ev.score as u32 > stats.0.best_single_score; let beats_time = stats.0.fastest_win_seconds == u64::MAX || ev.time_seconds < stats.0.fastest_win_seconds; let is_new_record = beats_score || beats_time; // Capture the challenge level (1-based) before ChallengePlugin advances // the index. Only populated for Challenge-mode wins. let challenge_level = if game.0.mode == GameMode::Challenge { Some(progress.0.challenge_index.saturating_add(1)) } else { None }; let used_undo = game.0.undo_count > 0; pending.score = ev.score; pending.time_seconds = ev.time_seconds; pending.xp = 0; // reset; XP event follows pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo); pending.new_record = is_new_record; pending.challenge_level = challenge_level; pending.undo_count = game.0.undo_count; pending.mode = game.0.mode; if is_new_record { toast.write(InfoToastEvent("New Record!".to_string())); } } for ev in xp.read() { pending.xp = ev.amount; } } /// Accumulates achievement names unlocked this session and resets them on a new game. /// /// Listens for `AchievementUnlockedEvent` and appends the human-readable name /// of each newly unlocked achievement to `SessionAchievements`. Clears the list /// whenever `NewGameRequestEvent` fires so each fresh game starts clean. /// /// All mode-switch keys (Z → Zen, X → Challenge, C → Daily Challenge, /// T → Time Attack) route through `NewGameRequestEvent`, so this single /// reader covers every implicit game-context reset in addition to the /// explicit N / "Play Again" new-game requests. fn collect_session_achievements( mut unlocks: MessageReader, mut new_games: MessageReader, mut session: ResMut, ) { // Reset on any new-game request (including mode switches via Z/X/C/T) so // achievements from the previous session are not carried into the next one. if new_games.read().last().is_some() { session.names.clear(); } for ev in unlocks.read() { session.names.push(display_name_for(&ev.0.id)); } } /// After `GameWonEvent`, arms the screen-shake resource. /// /// This system shares the `GameWonEvent` stream with `cache_win_data` through /// the delay timer stored in `Local` — the shake fires immediately, while the /// modal waits 0.5 s. /// /// Just before the overlay is spawned the system also drains any pending /// `XpAwardedEvent`s and folds their amounts into `pending.xp`. This guards /// against the edge case where `XpAwardedEvent` arrives in the same frame as /// the timer fires but `cache_win_data` runs *after* this system in that /// frame's schedule, which would otherwise leave `pending.xp` at 0 when /// `spawn_overlay` reads it. #[allow(clippy::too_many_arguments)] fn spawn_win_summary_after_delay( mut commands: Commands, mut won: MessageReader, mut xp_events: MessageReader, mut shake: ResMut, mut pending: ResMut, session: Res, settings: Option>, time: Res