//! 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 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::stats_plugin::{StatsResource, StatsUpdate}; // --------------------------------------------------------------------------- // 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; /// Duration of the screen-shake in seconds. const SHAKE_DURATION_SECS: f32 = 0.6; /// Maximum camera displacement in world-space pixels at the start of the shake. const SHAKE_INTENSITY: f32 = 8.0; // --------------------------------------------------------------------------- // 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, } /// 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, /// 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" button inside the win-summary modal. #[derive(Component, Debug)] enum WinSummaryButton { PlayAgain, } // --------------------------------------------------------------------------- // 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, apply_screen_shake, ) .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}") } // --------------------------------------------------------------------------- // 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; 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, time: Res