ea9dd848fd
Build and Deploy / build-and-push (push) Successful in 4m2s
Core (solitaire_core): - fix(scoring): apply -15 penalty for Foundation→Tableau moves when take_from_foundation is enabled; update test - fix(solver): is_won() validates full Ace→King suit sequence, not just card count — prevents hint system from emitting invalid paths Engine — animation / layout: - fix(animation): guard CardAnim advance against duration=0 to prevent NaN-poisoned Transform (analogous to CardAnimation's instant-snap path) - fix(card_plugin): align TABLEAU_FAN_FRAC (0.25→0.18) and TABLEAU_FACEDOWN_FAN_FRAC (0.20→0.14) with layout.rs so the initial layout and first dynamic update produce identical fan spacing - fix(layout): update tableau_fan_frac doc comment from 0.25→0.18 Engine — ECS / modal guards: - fix(auto_complete): drive_auto_complete now checks PausedResource so cooldown does not tick while paused (prevents instant-move on unpause) - fix(play_by_seed): handle_open_dialog checks global ModalScrim guard to prevent stacking over an existing modal - fix(win_summary): spawn_win_summary_after_delay checks global ModalScrim guard; collect_session_achievements uses .next() not .last() to avoid draining the new_games stream Engine — message registration: - fix(leaderboard): register InfoToastEvent in LeaderboardPlugin::build so opt-in/opt-out toasts work under MinimalPlugins - fix(replay_playback): register StateChangedEvent in ReplayPlaybackPlugin::build to prevent panic when used standalone Security: - fix(sync_setup): zero password SyncFieldBuffer immediately after spawning auth task — credential must not linger in ECS components Server: - fix(auth): replace MIME contains-chain with exact match for avatar upload; removes illusory starts_with guard and dead ALLOWED_IMAGE_TYPES Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1752 lines
66 KiB
Rust
1752 lines
66 KiB
Rust
//! 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<u32>,
|
||
/// 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<String>,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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::<WinSummaryPending>()
|
||
.init_resource::<ScreenShakeResource>()
|
||
.init_resource::<SessionAchievements>()
|
||
.add_message::<GameWonEvent>()
|
||
.add_message::<XpAwardedEvent>()
|
||
.add_message::<NewGameRequestEvent>()
|
||
.add_message::<InfoToastEvent>()
|
||
.add_message::<AchievementUnlockedEvent>()
|
||
// `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<GameWonEvent>,
|
||
mut xp: MessageReader<XpAwardedEvent>,
|
||
mut pending: ResMut<WinSummaryPending>,
|
||
stats: Res<StatsResource>,
|
||
game: Res<GameStateResource>,
|
||
progress: Res<ProgressResource>,
|
||
mut toast: MessageWriter<InfoToastEvent>,
|
||
) {
|
||
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<AchievementUnlockedEvent>,
|
||
mut new_games: MessageReader<NewGameRequestEvent>,
|
||
mut session: ResMut<SessionAchievements>,
|
||
) {
|
||
// 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().next().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<GameWonEvent>,
|
||
mut xp_events: MessageReader<XpAwardedEvent>,
|
||
mut shake: ResMut<ScreenShakeResource>,
|
||
mut pending: ResMut<WinSummaryPending>,
|
||
session: Res<SessionAchievements>,
|
||
settings: Option<Res<SettingsResource>>,
|
||
time: Res<Time>,
|
||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||
other_scrims: Query<(), (With<ModalScrim>, Without<WinSummaryOverlay>)>,
|
||
mut delay: Local<Option<f32>>,
|
||
) {
|
||
// Process new win events.
|
||
for _ in won.read() {
|
||
// Arm the screen shake immediately. Duration scales with the
|
||
// player's `AnimSpeed` preference via `ui_theme::scaled_duration`;
|
||
// intensity is left at its design-token value because amplitude
|
||
// does not benefit from "fast" / "instant" scaling — at Instant
|
||
// speed the duration is zero anyway, suppressing the shake.
|
||
let speed = settings.as_ref().map_or(
|
||
solitaire_data::AnimSpeed::Normal,
|
||
|s| s.0.animation_speed,
|
||
);
|
||
let scaled = scaled_duration(SHAKE_DURATION_SECS, speed);
|
||
shake.remaining = scaled;
|
||
shake.total = scaled;
|
||
shake.intensity = SHAKE_INTENSITY;
|
||
// Start the delay timer (overwrite if a second win arrives).
|
||
*delay = Some(WIN_SUMMARY_DELAY_SECS);
|
||
// Clear any stale overlay from a previous win.
|
||
for entity in &overlays {
|
||
commands.entity(entity).despawn();
|
||
}
|
||
}
|
||
|
||
// Tick the delay timer.
|
||
if let Some(remaining) = delay.as_mut() {
|
||
*remaining -= time.delta_secs();
|
||
if *remaining <= 0.0 {
|
||
*delay = None;
|
||
// Only spawn if no overlay of any kind is already visible.
|
||
if overlays.is_empty() && other_scrims.is_empty() {
|
||
// Drain any XpAwardedEvents that arrived this frame but were
|
||
// not yet consumed by `cache_win_data` (which may run later in
|
||
// the same schedule). Accumulating here ensures the modal
|
||
// never shows "XP: +0" due to a same-frame ordering race.
|
||
for ev in xp_events.read() {
|
||
pending.xp = pending.xp.saturating_add(ev.amount);
|
||
}
|
||
let challenge_level = pending.challenge_level;
|
||
// Re-derive AnimSpeed here — the `speed` binding above
|
||
// only lives inside the `for _ in won.read()` loop.
|
||
let anim_speed = settings
|
||
.as_ref()
|
||
.map_or(AnimSpeed::Normal, |s| s.0.animation_speed);
|
||
// The cosmetic time-bonus multiplier is also pulled
|
||
// here — defaults to 1.0 (no change) when settings are
|
||
// absent (tests under MinimalPlugins without
|
||
// SettingsPlugin).
|
||
let time_bonus_multiplier = settings
|
||
.as_ref()
|
||
.map_or(1.0_f32, |s| s.0.time_bonus_multiplier);
|
||
spawn_overlay(
|
||
&mut commands,
|
||
&pending,
|
||
&session,
|
||
challenge_level,
|
||
anim_speed,
|
||
time_bonus_multiplier,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
||
fn handle_win_summary_buttons(
|
||
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
|
||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||
mut commands: Commands,
|
||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||
mut toast: MessageWriter<InfoToastEvent>,
|
||
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
||
mut playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
|
||
) {
|
||
// 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();
|
||
|
||
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()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Keyboard accelerator for the win summary's "Play Again" button.
|
||
/// Enter / Return collapses the win modal and starts a fresh deal —
|
||
/// the same path the click handler takes — so a keyboard-only player
|
||
/// can dismiss the post-win celebration without reaching for the mouse.
|
||
fn handle_win_summary_keyboard(
|
||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||
mut commands: Commands,
|
||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||
) {
|
||
if overlays.is_empty() {
|
||
return;
|
||
}
|
||
let Some(keys) = keys else {
|
||
return;
|
||
};
|
||
if !keys.just_pressed(KeyCode::Enter) {
|
||
return;
|
||
}
|
||
for entity in &overlays {
|
||
commands.entity(entity).despawn();
|
||
}
|
||
new_game.write(NewGameRequestEvent::default());
|
||
}
|
||
|
||
/// Applies a decaying sinusoidal offset to the main `Camera2d` each frame
|
||
/// while `ScreenShakeResource::remaining > 0`.
|
||
///
|
||
/// Uses a deterministic oscillation (`sin`/`cos` of total elapsed time) to
|
||
/// avoid a dependency on a random-number crate in this crate.
|
||
fn apply_screen_shake(
|
||
mut shake: ResMut<ScreenShakeResource>,
|
||
time: Res<Time>,
|
||
mut cameras: Query<&mut Transform, With<Camera2d>>,
|
||
) {
|
||
let dt = time.delta_secs();
|
||
if shake.remaining <= 0.0 {
|
||
// Ensure the camera is back at origin whenever shake is idle.
|
||
for mut t in &mut cameras {
|
||
t.translation.x = 0.0;
|
||
t.translation.y = 0.0;
|
||
}
|
||
return;
|
||
}
|
||
|
||
shake.remaining = (shake.remaining - dt).max(0.0);
|
||
// Decay factor: 1.0 at start, 0.0 at end. Falls back to the design-token
|
||
// duration if `total` is zero (older armings or test setups that bypass
|
||
// `spawn_win_summary_after_delay`) so we never divide by zero.
|
||
let total = if shake.total > 0.0 { shake.total } else { SHAKE_DURATION_SECS };
|
||
let decay = shake.remaining / total;
|
||
let elapsed = time.elapsed_secs();
|
||
let offset_x = (elapsed * 47.0).sin() * shake.intensity * decay;
|
||
let offset_y = (elapsed * 31.0).cos() * shake.intensity * decay;
|
||
|
||
for mut t in &mut cameras {
|
||
t.translation.x = offset_x;
|
||
t.translation.y = offset_y;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// UI construction
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Spawns the full-screen win-summary modal.
|
||
///
|
||
/// `challenge_level` is `Some(N)` when the win was a Challenge-mode completion;
|
||
/// a "Challenge N complete!" annotation is added to the modal header in that case.
|
||
///
|
||
/// `anim_speed` controls the score-breakdown reveal: under
|
||
/// `AnimSpeed::Instant`, every breakdown row is spawned visible and at
|
||
/// full opacity (no stagger, no fade); otherwise rows are spawned
|
||
/// hidden and the [`reveal_score_breakdown`] system fades them in over
|
||
/// roughly one second.
|
||
///
|
||
/// `time_bonus_multiplier` is the player's cosmetic
|
||
/// `Settings::time_bonus_multiplier` and is folded into the time-bonus
|
||
/// row of the score breakdown only — it does **not** alter any stored
|
||
/// score or achievement-unlock evaluation.
|
||
fn spawn_overlay(
|
||
commands: &mut Commands,
|
||
pending: &WinSummaryPending,
|
||
session: &SessionAchievements,
|
||
challenge_level: Option<u32>,
|
||
anim_speed: AnimSpeed,
|
||
time_bonus_multiplier: f32,
|
||
) {
|
||
let breakdown = ScoreBreakdown::compute(
|
||
pending.score,
|
||
pending.time_seconds,
|
||
pending.undo_count,
|
||
pending.mode,
|
||
time_bonus_multiplier,
|
||
);
|
||
commands
|
||
.spawn((
|
||
WinSummaryOverlay,
|
||
ModalScrim,
|
||
Node {
|
||
position_type: PositionType::Absolute,
|
||
left: Val::Percent(0.0),
|
||
top: Val::Percent(0.0),
|
||
width: Val::Percent(100.0),
|
||
height: Val::Percent(100.0),
|
||
flex_direction: FlexDirection::Column,
|
||
justify_content: JustifyContent::Center,
|
||
align_items: AlignItems::Center,
|
||
..default()
|
||
},
|
||
BackgroundColor(SCRIM),
|
||
GlobalZIndex(Z_WIN_CASCADE),
|
||
ZIndex(Z_WIN_CASCADE),
|
||
))
|
||
.with_children(|root| {
|
||
root.spawn((
|
||
Node {
|
||
flex_direction: FlexDirection::Column,
|
||
padding: UiRect::all(Val::Px(36.0)),
|
||
row_gap: Val::Px(18.0),
|
||
min_width: Val::Px(320.0),
|
||
align_items: AlignItems::Center,
|
||
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
|
||
..default()
|
||
},
|
||
BackgroundColor(BG_ELEVATED),
|
||
))
|
||
.with_children(|card| {
|
||
// Heading
|
||
card.spawn((
|
||
Text::new("You Won!"),
|
||
TextFont { font_size: TYPE_DISPLAY, ..default() },
|
||
TextColor(ACCENT_PRIMARY),
|
||
));
|
||
|
||
// Challenge-mode annotation — shown only for Challenge wins.
|
||
if let Some(level) = challenge_level {
|
||
card.spawn((
|
||
Text::new(format!("Challenge {level} complete!")),
|
||
TextFont { font_size: TYPE_HEADLINE, ..default() },
|
||
TextColor(STATE_INFO),
|
||
));
|
||
}
|
||
|
||
// New Record badge — shown only when the player beats their
|
||
// previous best score or fastest win time.
|
||
if pending.new_record {
|
||
card.spawn((
|
||
Text::new("New Record!"),
|
||
TextFont { font_size: TYPE_HEADLINE, ..default() },
|
||
TextColor(STATE_WARNING),
|
||
));
|
||
}
|
||
|
||
// Score breakdown reveal — replaces the previous single
|
||
// "Score:" line with a per-component multi-row layout.
|
||
spawn_score_breakdown(card, &breakdown, anim_speed);
|
||
|
||
// Time
|
||
card.spawn((
|
||
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
||
TextFont { font_size: TYPE_HEADLINE, ..default() },
|
||
TextColor(TEXT_PRIMARY),
|
||
));
|
||
|
||
// XP total
|
||
card.spawn((
|
||
Text::new(format!("XP earned: +{}", pending.xp)),
|
||
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||
TextColor(STATE_SUCCESS),
|
||
));
|
||
|
||
// XP breakdown (smaller, dimmer text)
|
||
if !pending.xp_detail.is_empty() {
|
||
card.spawn((
|
||
Text::new(pending.xp_detail.clone()),
|
||
TextFont { font_size: 15.0, ..default() },
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
}
|
||
|
||
// Achievements unlocked this game — at most 3 shown explicitly;
|
||
// excess is summarised with "...and N more".
|
||
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),
|
||
));
|
||
});
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/// 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;
|
||
|
||
/// Spawns the "Achievements Unlocked" sub-section inside the win modal card.
|
||
///
|
||
/// Shows at most [`MAX_ACHIEVEMENTS_SHOWN`] names. When more achievements were
|
||
/// unlocked than the cap, appends a "...and N more" line so the player knows
|
||
/// there are additional unlocks visible on the achievements screen.
|
||
fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String]) {
|
||
card.spawn((
|
||
Text::new("Achievements Unlocked"),
|
||
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||
TextColor(ACCENT_PRIMARY),
|
||
));
|
||
|
||
let shown = names.len().min(MAX_ACHIEVEMENTS_SHOWN);
|
||
for name in &names[..shown] {
|
||
card.spawn((
|
||
Text::new(format!(" {name}")),
|
||
TextFont { font_size: 16.0, ..default() },
|
||
TextColor(TEXT_PRIMARY),
|
||
));
|
||
}
|
||
|
||
let overflow = names.len().saturating_sub(MAX_ACHIEVEMENTS_SHOWN);
|
||
if overflow > 0 {
|
||
card.spawn((
|
||
Text::new(format!(" ...and {overflow} more")),
|
||
TextFont { font_size: 15.0, ..default() },
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
}
|
||
}
|
||
|
||
/// Spawns the score-breakdown rows inside the win-modal card.
|
||
///
|
||
/// Rows are appended in this order — only the first and last two are
|
||
/// always present, the middle three depend on `breakdown`:
|
||
///
|
||
/// 1. `Base score` — value column = `breakdown.base`.
|
||
/// 2. `Time bonus (m:ss)` — only when `breakdown.shows_time_bonus_row()`.
|
||
/// 3. `No-undo bonus` — only when `breakdown.shows_no_undo_row()`.
|
||
/// 4. `Mode multiplier (Mode-name ×N)` — only when
|
||
/// `breakdown.shows_multiplier_row()`.
|
||
/// 5. Separator (em-dashes).
|
||
/// 6. `Total` — value column = `breakdown.total()`.
|
||
///
|
||
/// Every row is spawned with a [`ScoreBreakdownRow`] component carrying
|
||
/// a per-row stagger delay calculated from
|
||
/// [`MOTION_SCORE_BREAKDOWN_STAGGER_SECS`]. Under `AnimSpeed::Instant`,
|
||
/// stagger and fade are both zero so the breakdown appears in one frame.
|
||
fn spawn_score_breakdown(
|
||
card: &mut ChildSpawnerCommands,
|
||
breakdown: &ScoreBreakdown,
|
||
anim_speed: AnimSpeed,
|
||
) {
|
||
let stagger = scaled_duration(MOTION_SCORE_BREAKDOWN_STAGGER_SECS, anim_speed);
|
||
let fade = scaled_duration(MOTION_SCORE_BREAKDOWN_FADE_SECS, anim_speed);
|
||
let mut row_index: u32 = 0;
|
||
|
||
// 1. Base score — always shown.
|
||
spawn_breakdown_row(
|
||
card,
|
||
"Base score",
|
||
format!("{}", breakdown.base),
|
||
ACCENT_PRIMARY,
|
||
anim_speed,
|
||
stagger * row_index as f32,
|
||
fade,
|
||
);
|
||
row_index += 1;
|
||
|
||
// 2. Time bonus.
|
||
if breakdown.shows_time_bonus_row() {
|
||
spawn_breakdown_row(
|
||
card,
|
||
&format!("Time bonus ({})", format_win_time(breakdown.time_seconds)),
|
||
format!("+{}", breakdown.time_bonus),
|
||
STATE_SUCCESS,
|
||
anim_speed,
|
||
stagger * row_index as f32,
|
||
fade,
|
||
);
|
||
row_index += 1;
|
||
}
|
||
|
||
// 3. No-undo bonus.
|
||
if breakdown.shows_no_undo_row() {
|
||
spawn_breakdown_row(
|
||
card,
|
||
"No-undo bonus",
|
||
format!("+{}", breakdown.no_undo_bonus),
|
||
STATE_SUCCESS,
|
||
anim_speed,
|
||
stagger * row_index as f32,
|
||
fade,
|
||
);
|
||
row_index += 1;
|
||
}
|
||
|
||
// 4. Mode multiplier (only when not 1.0).
|
||
if breakdown.shows_multiplier_row() {
|
||
let mode_name = mode_display_name(breakdown.mode);
|
||
spawn_breakdown_row(
|
||
card,
|
||
&format!("Mode multiplier ({mode_name} ×{:.1})", breakdown.multiplier),
|
||
format!("×{:.1}", breakdown.multiplier),
|
||
STATE_INFO,
|
||
anim_speed,
|
||
stagger * row_index as f32,
|
||
fade,
|
||
);
|
||
row_index += 1;
|
||
}
|
||
|
||
// 5. Separator — em-dashes spanning the visual width.
|
||
spawn_breakdown_row(
|
||
card,
|
||
"─────────────────",
|
||
"─────".to_string(),
|
||
TEXT_SECONDARY,
|
||
anim_speed,
|
||
stagger * row_index as f32,
|
||
fade,
|
||
);
|
||
row_index += 1;
|
||
|
||
// 6. Total — emphasised in primary accent.
|
||
spawn_breakdown_row(
|
||
card,
|
||
"Total",
|
||
format!("{}", breakdown.total()),
|
||
ACCENT_PRIMARY,
|
||
anim_speed,
|
||
stagger * row_index as f32,
|
||
fade,
|
||
);
|
||
}
|
||
|
||
/// Spawns one row of the score breakdown — a flex-row `Node` with two
|
||
/// `Text` children (label left, value right). The row is tagged with
|
||
/// [`ScoreBreakdownRow`] and starts hidden when `anim_speed` is anything
|
||
/// other than [`AnimSpeed::Instant`]; the [`reveal_score_breakdown`]
|
||
/// system flips it visible after `delay_secs` and fades in the text
|
||
/// over `fade_duration_secs`.
|
||
fn spawn_breakdown_row(
|
||
card: &mut ChildSpawnerCommands,
|
||
label: &str,
|
||
value: String,
|
||
value_color: Color,
|
||
anim_speed: AnimSpeed,
|
||
delay_secs: f32,
|
||
fade_duration_secs: f32,
|
||
) {
|
||
// Under Instant, every row is visible immediately at full opacity.
|
||
let instant = matches!(anim_speed, AnimSpeed::Instant);
|
||
let initial_visibility = if instant {
|
||
Visibility::Inherited
|
||
} else {
|
||
Visibility::Hidden
|
||
};
|
||
let initial_alpha = if instant { 1.0 } else { 0.0 };
|
||
|
||
let label_color_with_alpha = TEXT_PRIMARY.with_alpha(initial_alpha);
|
||
let value_color_with_alpha = value_color.with_alpha(initial_alpha);
|
||
|
||
card.spawn((
|
||
ScoreBreakdownRow {
|
||
delay_secs,
|
||
fade_elapsed_secs: 0.0,
|
||
fade_duration_secs,
|
||
revealed: instant,
|
||
},
|
||
Node {
|
||
width: Val::Percent(100.0),
|
||
min_width: Val::Px(280.0),
|
||
flex_direction: FlexDirection::Row,
|
||
justify_content: JustifyContent::SpaceBetween,
|
||
align_items: AlignItems::Center,
|
||
..default()
|
||
},
|
||
initial_visibility,
|
||
))
|
||
.with_children(|row| {
|
||
// Label — left-aligned.
|
||
row.spawn((
|
||
Text::new(label.to_string()),
|
||
TextFont { font_size: TYPE_BODY, ..default() },
|
||
TextColor(label_color_with_alpha),
|
||
));
|
||
// Value — right-aligned via the parent's JustifyContent::SpaceBetween.
|
||
row.spawn((
|
||
Text::new(value),
|
||
TextFont { font_size: TYPE_BODY, ..default() },
|
||
TextColor(value_color_with_alpha),
|
||
));
|
||
});
|
||
}
|
||
|
||
/// Reveal system — ticks each [`ScoreBreakdownRow`] down toward zero
|
||
/// and fades its child `Text` alpha from 0 → 1 over the row's
|
||
/// `fade_duration_secs` once `delay_secs` elapses.
|
||
///
|
||
/// The system is non-blocking: the Play Again button is interactable
|
||
/// from the moment the modal spawns; the breakdown reveal just plays
|
||
/// out underneath. Rows that have already reached full opacity are
|
||
/// skipped via the `revealed` flag plus an early
|
||
/// `fade_elapsed >= fade_duration` short-circuit on the alpha lerp.
|
||
pub fn reveal_score_breakdown(
|
||
time: Res<Time>,
|
||
mut rows: Query<(&mut ScoreBreakdownRow, &mut Visibility, Option<&Children>)>,
|
||
mut texts: Query<&mut TextColor>,
|
||
) {
|
||
let dt = time.delta_secs();
|
||
for (mut row, mut visibility, children) in &mut rows {
|
||
if !row.revealed {
|
||
row.delay_secs -= dt;
|
||
if row.delay_secs <= 0.0 {
|
||
*visibility = Visibility::Inherited;
|
||
row.revealed = true;
|
||
} else {
|
||
continue; // still hidden, no fade work yet
|
||
}
|
||
}
|
||
// Row is revealed — drive the fade-in until it's fully opaque.
|
||
let fade_done = row.fade_elapsed_secs >= row.fade_duration_secs;
|
||
if !fade_done {
|
||
row.fade_elapsed_secs += dt;
|
||
}
|
||
let t = if row.fade_duration_secs <= 0.0 {
|
||
1.0
|
||
} else {
|
||
(row.fade_elapsed_secs / row.fade_duration_secs).clamp(0.0, 1.0)
|
||
};
|
||
let target_alpha = if fade_done { 1.0 } else { t };
|
||
if let Some(children) = children {
|
||
for child in children.iter() {
|
||
if let Ok(mut tc) = texts.get_mut(child) {
|
||
let c = tc.0;
|
||
if (c.alpha() - target_alpha).abs() > f32::EPSILON {
|
||
tc.0 = c.with_alpha(target_alpha);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use solitaire_core::game_state::GameState;
|
||
use solitaire_data::{PlayerProgress, StatsSnapshot};
|
||
|
||
/// Build a minimal app with `WinSummaryPlugin` and all resources required
|
||
/// by `cache_win_data`: `StatsResource`, `GameStateResource`, and
|
||
/// `ProgressResource`.
|
||
fn make_app() -> App {
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins)
|
||
.add_plugins(WinSummaryPlugin)
|
||
.insert_resource(StatsResource(StatsSnapshot::default()))
|
||
.insert_resource(GameStateResource(GameState::new(0, solitaire_core::game_state::DrawMode::DrawOne)))
|
||
.insert_resource(ProgressResource(PlayerProgress::default()));
|
||
app.update();
|
||
app
|
||
}
|
||
|
||
#[test]
|
||
fn format_win_time_zero() {
|
||
assert_eq!(format_win_time(0), "0:00");
|
||
}
|
||
|
||
#[test]
|
||
fn format_win_time_one_minute_five_seconds() {
|
||
assert_eq!(format_win_time(65), "1:05");
|
||
}
|
||
|
||
#[test]
|
||
fn format_win_time_exact_minute() {
|
||
assert_eq!(format_win_time(120), "2:00");
|
||
}
|
||
|
||
#[test]
|
||
fn format_win_time_large() {
|
||
// 3661 s = 61 min 1 s
|
||
assert_eq!(format_win_time(3661), "61:01");
|
||
}
|
||
|
||
#[test]
|
||
fn format_win_time_59_seconds() {
|
||
assert_eq!(format_win_time(59), "0:59");
|
||
}
|
||
|
||
#[test]
|
||
fn screen_shake_resource_default_is_idle() {
|
||
let shake = ScreenShakeResource::default();
|
||
assert!(shake.remaining <= 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn win_summary_pending_default_is_zeroed() {
|
||
let p = WinSummaryPending::default();
|
||
assert_eq!(p.score, 0);
|
||
assert_eq!(p.time_seconds, 0);
|
||
assert_eq!(p.xp, 0);
|
||
assert!(p.xp_detail.is_empty());
|
||
assert!(!p.new_record);
|
||
assert!(p.challenge_level.is_none());
|
||
assert_eq!(p.undo_count, 0);
|
||
assert_eq!(p.mode, GameMode::Classic);
|
||
}
|
||
|
||
#[test]
|
||
fn build_xp_detail_slow_win_with_undo() {
|
||
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
||
let detail = build_xp_detail(300, true);
|
||
assert_eq!(detail, "+50 base");
|
||
}
|
||
|
||
#[test]
|
||
fn build_xp_detail_slow_win_no_undo() {
|
||
let detail = build_xp_detail(300, false);
|
||
assert_eq!(detail, "+50 base +25 no-undo");
|
||
}
|
||
|
||
#[test]
|
||
fn build_xp_detail_fast_win_with_undo() {
|
||
// 0s → speed bonus 50.
|
||
let detail = build_xp_detail(0, true);
|
||
assert_eq!(detail, "+50 base +50 speed");
|
||
}
|
||
|
||
#[test]
|
||
fn build_xp_detail_fast_win_no_undo() {
|
||
let detail = build_xp_detail(0, false);
|
||
assert_eq!(detail, "+50 base +25 no-undo +50 speed");
|
||
}
|
||
|
||
#[test]
|
||
fn win_summary_plugin_inserts_resources() {
|
||
let app = make_app();
|
||
assert!(app.world().get_resource::<WinSummaryPending>().is_some());
|
||
assert!(app.world().get_resource::<ScreenShakeResource>().is_some());
|
||
assert!(app.world().get_resource::<SessionAchievements>().is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn session_achievements_accumulates_unlock_events() {
|
||
let mut app = make_app();
|
||
|
||
use solitaire_data::AchievementRecord;
|
||
let record = AchievementRecord::locked("first_win");
|
||
app.world_mut()
|
||
.write_message(AchievementUnlockedEvent(record));
|
||
app.update();
|
||
|
||
let session = app.world().resource::<SessionAchievements>();
|
||
assert_eq!(session.names.len(), 1);
|
||
// display_name_for("first_win") == "First Win"
|
||
assert_eq!(session.names[0], "First Win");
|
||
}
|
||
|
||
#[test]
|
||
fn session_achievements_resets_on_new_game_request() {
|
||
let mut app = make_app();
|
||
|
||
use solitaire_data::AchievementRecord;
|
||
let record = AchievementRecord::locked("first_win");
|
||
app.world_mut()
|
||
.write_message(AchievementUnlockedEvent(record));
|
||
app.update();
|
||
|
||
// Confirm it was recorded.
|
||
assert_eq!(
|
||
app.world().resource::<SessionAchievements>().names.len(),
|
||
1
|
||
);
|
||
|
||
// Fire NewGameRequestEvent — should clear the list.
|
||
app.world_mut().write_message(NewGameRequestEvent::default());
|
||
app.update();
|
||
|
||
assert!(
|
||
app.world().resource::<SessionAchievements>().names.is_empty(),
|
||
"session achievements must be cleared on NewGameRequestEvent"
|
||
);
|
||
}
|
||
|
||
/// Verifies that mode-switch new-game requests (Z/X/C/T keys) also clear
|
||
/// `SessionAchievements`. All mode switches route through
|
||
/// `NewGameRequestEvent` with a non-`None` `mode` or `seed` field, so
|
||
/// this test uses `GameMode::Zen` as a representative case; the same path
|
||
/// is taken for Challenge, Daily Challenge, and Time Attack.
|
||
#[test]
|
||
fn session_achievements_resets_on_mode_switch_new_game_request() {
|
||
let mut app = make_app();
|
||
|
||
use solitaire_core::game_state::GameMode;
|
||
use solitaire_data::AchievementRecord;
|
||
|
||
// Simulate an achievement unlock during the current session.
|
||
let record = AchievementRecord::locked("first_win");
|
||
app.world_mut()
|
||
.write_message(AchievementUnlockedEvent(record));
|
||
app.update();
|
||
|
||
assert_eq!(
|
||
app.world().resource::<SessionAchievements>().names.len(),
|
||
1,
|
||
"achievement should be recorded before the mode switch"
|
||
);
|
||
|
||
// Simulate pressing Z (Zen mode switch) — fires NewGameRequestEvent
|
||
// with mode = Some(Zen). Same event shape used by X (Challenge),
|
||
// C (Daily Challenge), and T (Time Attack).
|
||
app.world_mut().write_message(NewGameRequestEvent {
|
||
seed: None,
|
||
mode: Some(GameMode::Zen),
|
||
confirmed: false,
|
||
});
|
||
app.update();
|
||
|
||
assert!(
|
||
app.world().resource::<SessionAchievements>().names.is_empty(),
|
||
"session achievements must be cleared when a mode-switch NewGameRequestEvent fires"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cache_win_data_sets_score_and_time() {
|
||
let mut app = make_app();
|
||
|
||
app.world_mut()
|
||
.write_message(GameWonEvent { score: 1234, time_seconds: 90 });
|
||
app.update();
|
||
|
||
let pending = app.world().resource::<WinSummaryPending>();
|
||
assert_eq!(pending.score, 1234);
|
||
assert_eq!(pending.time_seconds, 90);
|
||
// 90s < 120s → speed bonus present; default game has undo_count=0 → no-undo bonus present.
|
||
assert!(!pending.xp_detail.is_empty(), "xp_detail must be populated on GameWonEvent");
|
||
assert!(pending.xp_detail.contains("+50 base"));
|
||
}
|
||
|
||
#[test]
|
||
fn cache_win_data_sets_xp_from_xp_awarded_event() {
|
||
let mut app = make_app();
|
||
|
||
app.world_mut().write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||
app.world_mut().write_message(XpAwardedEvent { amount: 75 });
|
||
app.update();
|
||
|
||
let pending = app.world().resource::<WinSummaryPending>();
|
||
assert_eq!(pending.xp, 75);
|
||
}
|
||
|
||
#[test]
|
||
fn game_won_event_arms_screen_shake() {
|
||
let mut app = make_app();
|
||
|
||
app.world_mut()
|
||
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||
app.update();
|
||
|
||
let shake = app.world().resource::<ScreenShakeResource>();
|
||
assert!(shake.remaining > 0.0, "shake must be armed after GameWonEvent");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// New Record detection tests
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn first_win_is_always_a_new_record() {
|
||
// Default stats: best_single_score=0, fastest_win_seconds=u64::MAX.
|
||
// Any positive-score win should be flagged as a new record.
|
||
let mut app = make_app();
|
||
|
||
app.world_mut()
|
||
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
|
||
app.update();
|
||
|
||
let pending = app.world().resource::<WinSummaryPending>();
|
||
assert!(pending.new_record, "first win should always set new_record");
|
||
}
|
||
|
||
#[test]
|
||
fn win_that_beats_best_score_sets_new_record() {
|
||
let mut app = make_app();
|
||
{
|
||
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||
stats.0.best_single_score = 400;
|
||
stats.0.fastest_win_seconds = 200;
|
||
}
|
||
|
||
// Score 500 beats previous best of 400.
|
||
app.world_mut()
|
||
.write_message(GameWonEvent { score: 500, time_seconds: 300 });
|
||
app.update();
|
||
|
||
let pending = app.world().resource::<WinSummaryPending>();
|
||
assert!(pending.new_record, "beating best score should set new_record");
|
||
}
|
||
|
||
#[test]
|
||
fn win_that_beats_fastest_time_sets_new_record() {
|
||
let mut app = make_app();
|
||
{
|
||
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||
stats.0.best_single_score = 800;
|
||
stats.0.fastest_win_seconds = 200;
|
||
}
|
||
|
||
// Score 500 does not beat 800, but time 100 < 200.
|
||
app.world_mut()
|
||
.write_message(GameWonEvent { score: 500, time_seconds: 100 });
|
||
app.update();
|
||
|
||
let pending = app.world().resource::<WinSummaryPending>();
|
||
assert!(pending.new_record, "beating fastest time should set new_record");
|
||
}
|
||
|
||
#[test]
|
||
fn win_below_personal_bests_does_not_set_new_record() {
|
||
let mut app = make_app();
|
||
{
|
||
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||
stats.0.best_single_score = 800;
|
||
stats.0.fastest_win_seconds = 60;
|
||
}
|
||
|
||
// Score 500 < 800 and time 120 > 60 — neither record broken.
|
||
app.world_mut()
|
||
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
|
||
app.update();
|
||
|
||
let pending = app.world().resource::<WinSummaryPending>();
|
||
assert!(
|
||
!pending.new_record,
|
||
"win below both personal bests must not set new_record"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Challenge-level capture tests
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn challenge_win_captures_level_number() {
|
||
let mut app = make_app();
|
||
|
||
// Set challenge_index = 4 so the completed level is 5 (1-based).
|
||
app.world_mut()
|
||
.resource_mut::<ProgressResource>()
|
||
.0
|
||
.challenge_index = 4;
|
||
// Switch game mode to Challenge.
|
||
{
|
||
use solitaire_core::game_state::DrawMode;
|
||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||
}
|
||
|
||
app.world_mut()
|
||
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||
app.update();
|
||
|
||
let pending = app.world().resource::<WinSummaryPending>();
|
||
assert_eq!(
|
||
pending.challenge_level,
|
||
Some(5),
|
||
"challenge_level must be 1-based index of the completed challenge"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classic_win_leaves_challenge_level_none() {
|
||
let mut app = make_app();
|
||
// Default game mode is Classic — challenge_level should stay None.
|
||
app.world_mut()
|
||
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||
app.update();
|
||
|
||
let pending = app.world().resource::<WinSummaryPending>();
|
||
assert!(
|
||
pending.challenge_level.is_none(),
|
||
"challenge_level must be None for non-Challenge wins"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Score-breakdown tests
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// `cache_win_data` captures both `undo_count` and `mode` from the
|
||
/// `GameStateResource` at the moment of `GameWonEvent`. The breakdown
|
||
/// reveal needs both fields to format the no-undo-bonus and
|
||
/// mode-multiplier rows.
|
||
#[test]
|
||
fn cache_win_data_captures_undo_count_and_mode() {
|
||
use solitaire_core::game_state::DrawMode;
|
||
|
||
let mut app = make_app();
|
||
// Set up a Zen-mode game with 2 undos used.
|
||
{
|
||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||
game.0 = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Zen);
|
||
game.0.undo_count = 2;
|
||
}
|
||
|
||
app.world_mut()
|
||
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||
app.update();
|
||
|
||
let pending = app.world().resource::<WinSummaryPending>();
|
||
assert_eq!(pending.undo_count, 2);
|
||
assert_eq!(pending.mode, GameMode::Zen);
|
||
}
|
||
|
||
/// `ScoreBreakdown::compute` produces the expected per-component
|
||
/// values for a non-trivial Classic-mode win. Time-bonus is the
|
||
/// canonical `compute_time_bonus(120) = 5833` (700_000 / 120) and
|
||
/// the no-undo bonus fires because `undo_count == 0`.
|
||
#[test]
|
||
fn score_breakdown_compute_produces_expected_components() {
|
||
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0);
|
||
assert_eq!(bd.base, 3200);
|
||
assert_eq!(bd.time_bonus, 5833); // 700_000 / 120
|
||
assert_eq!(bd.no_undo_bonus, SCORE_NO_UNDO_BONUS);
|
||
assert!((bd.multiplier - 1.0).abs() < f32::EPSILON);
|
||
// Classic ×1.0 → multiplier row is suppressed.
|
||
assert!(!bd.shows_multiplier_row());
|
||
// Total == base + time_bonus + no_undo_bonus.
|
||
assert_eq!(bd.total(), 3200 + 5833 + SCORE_NO_UNDO_BONUS);
|
||
}
|
||
|
||
/// Zen-mode wins produce a zero multiplier — the breakdown shows
|
||
/// the multiplier row and the total collapses to zero regardless
|
||
/// of the other components.
|
||
#[test]
|
||
fn score_breakdown_zen_mode_zeros_total() {
|
||
let bd = ScoreBreakdown::compute(500, 60, 0, GameMode::Zen, 1.0);
|
||
assert!((bd.multiplier - 0.0).abs() < f32::EPSILON);
|
||
assert!(bd.shows_multiplier_row(), "Zen ×0 must display the multiplier row");
|
||
assert_eq!(bd.total(), 0);
|
||
}
|
||
|
||
/// When the player used undo, the `no_undo_bonus` is zero and the
|
||
/// row is suppressed.
|
||
#[test]
|
||
fn score_breakdown_skips_no_undo_row_when_undo_was_used() {
|
||
let bd = ScoreBreakdown::compute(100, 60, 1, GameMode::Classic, 1.0);
|
||
assert_eq!(bd.no_undo_bonus, 0);
|
||
assert!(!bd.shows_no_undo_row());
|
||
}
|
||
|
||
/// At `time_seconds == 0` the time-bonus formula yields 0; the row
|
||
/// is suppressed.
|
||
#[test]
|
||
fn score_breakdown_skips_time_bonus_row_when_zero() {
|
||
let bd = ScoreBreakdown::compute(100, 0, 0, GameMode::Classic, 1.0);
|
||
assert_eq!(bd.time_bonus, 0);
|
||
assert!(!bd.shows_time_bonus_row());
|
||
}
|
||
|
||
/// `row_count()` reports the number of rows the renderer will
|
||
/// spawn. A non-trivial Classic win with both bonuses produces:
|
||
/// base + time + no-undo + separator + total = 5 rows (no
|
||
/// multiplier row, ×1.0 is suppressed).
|
||
#[test]
|
||
fn win_modal_score_breakdown_spawns_one_row_per_component() {
|
||
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0);
|
||
assert_eq!(
|
||
bd.row_count(),
|
||
5,
|
||
"Classic with both bonuses: base + time + no-undo + sep + total"
|
||
);
|
||
|
||
// Zen with both bonuses ALSO shows the multiplier row.
|
||
let zen = ScoreBreakdown::compute(3200, 120, 0, GameMode::Zen, 1.0);
|
||
assert_eq!(
|
||
zen.row_count(),
|
||
6,
|
||
"Zen with both bonuses: base + time + no-undo + multiplier + sep + total"
|
||
);
|
||
}
|
||
|
||
/// When `no_undo_bonus == 0`, the row count drops by one.
|
||
#[test]
|
||
fn win_modal_score_breakdown_skips_zero_bonus_rows() {
|
||
let bd_with = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0);
|
||
let bd_without = ScoreBreakdown::compute(3200, 120, 1, GameMode::Classic, 1.0);
|
||
assert_eq!(
|
||
bd_with.row_count() - 1,
|
||
bd_without.row_count(),
|
||
"removing the no-undo bonus must remove exactly one row"
|
||
);
|
||
}
|
||
|
||
/// Cosmetic time-bonus multiplier from `Settings::time_bonus_multiplier`
|
||
/// scales the displayed `time_bonus` row by the factor, rounded to
|
||
/// the nearest integer. A `0.5` multiplier halves the canonical
|
||
/// `compute_time_bonus(120) = 5833` to `2917` (5833 × 0.5 = 2916.5,
|
||
/// round-half-to-even via `.round()` lands on 2917 in IEEE-754).
|
||
#[test]
|
||
fn score_breakdown_applies_time_bonus_multiplier() {
|
||
let raw = compute_time_bonus(120);
|
||
assert_eq!(raw, 5833, "sanity-check raw bonus before testing the multiplier");
|
||
|
||
let bd = ScoreBreakdown::compute(0, 120, 0, GameMode::Classic, 0.5);
|
||
let expected = ((raw as f32) * 0.5).round() as i32;
|
||
assert_eq!(
|
||
bd.time_bonus, expected,
|
||
"time_bonus row must reflect raw_bonus × multiplier (rounded)"
|
||
);
|
||
// The row is still shown — value is 2917, not zero.
|
||
assert!(bd.shows_time_bonus_row());
|
||
}
|
||
|
||
/// At `multiplier == 0.0` ("Off"), the time-bonus row collapses to
|
||
/// zero and is suppressed by the renderer (same path as a zero
|
||
/// elapsed time).
|
||
#[test]
|
||
fn score_breakdown_off_multiplier_zeros_time_bonus() {
|
||
let bd = ScoreBreakdown::compute(100, 120, 0, GameMode::Classic, 0.0);
|
||
assert_eq!(
|
||
bd.time_bonus, 0,
|
||
"0.0 multiplier must zero out the displayed time bonus"
|
||
);
|
||
assert!(
|
||
!bd.shows_time_bonus_row(),
|
||
"with time_bonus = 0 the row must be suppressed by the renderer"
|
||
);
|
||
}
|
||
|
||
/// A `2.0` multiplier doubles the displayed bonus — exercises the
|
||
/// upper end of the slider range.
|
||
#[test]
|
||
fn score_breakdown_double_multiplier_doubles_time_bonus() {
|
||
let raw = compute_time_bonus(120);
|
||
let bd = ScoreBreakdown::compute(0, 120, 0, GameMode::Classic, 2.0);
|
||
let expected = ((raw as f32) * 2.0).round() as i32;
|
||
assert_eq!(bd.time_bonus, expected);
|
||
}
|
||
|
||
/// Pure helper test: the reveal logic uses delta-time to count
|
||
/// down `delay_secs`; at `t = 0` only the first row is "revealed",
|
||
/// and after one stagger interval the second row reveals as well.
|
||
/// We exercise the system directly on a hand-built world rather
|
||
/// than going through the full modal-spawn path so the test is
|
||
/// independent of `Time` resource quirks.
|
||
#[test]
|
||
fn score_breakdown_reveal_advances_visibility_per_stagger() {
|
||
use bevy::time::TimePlugin;
|
||
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins.build().disable::<TimePlugin>());
|
||
app.init_resource::<Time>();
|
||
app.add_systems(Update, reveal_score_breakdown);
|
||
|
||
// Spawn three rows with delays of 0.0, 0.15, and 0.30 s.
|
||
let stagger = MOTION_SCORE_BREAKDOWN_STAGGER_SECS;
|
||
let fade = MOTION_SCORE_BREAKDOWN_FADE_SECS;
|
||
let row0 = app
|
||
.world_mut()
|
||
.spawn((
|
||
ScoreBreakdownRow {
|
||
delay_secs: 0.0,
|
||
fade_elapsed_secs: 0.0,
|
||
fade_duration_secs: fade,
|
||
revealed: false,
|
||
},
|
||
Visibility::Hidden,
|
||
))
|
||
.id();
|
||
let row1 = app
|
||
.world_mut()
|
||
.spawn((
|
||
ScoreBreakdownRow {
|
||
delay_secs: stagger,
|
||
fade_elapsed_secs: 0.0,
|
||
fade_duration_secs: fade,
|
||
revealed: false,
|
||
},
|
||
Visibility::Hidden,
|
||
))
|
||
.id();
|
||
let row2 = app
|
||
.world_mut()
|
||
.spawn((
|
||
ScoreBreakdownRow {
|
||
delay_secs: stagger * 2.0,
|
||
fade_elapsed_secs: 0.0,
|
||
fade_duration_secs: fade,
|
||
revealed: false,
|
||
},
|
||
Visibility::Hidden,
|
||
))
|
||
.id();
|
||
|
||
// Frame 1: `time.delta` is 0 (first frame), so only row0
|
||
// (delay = 0) should reveal.
|
||
app.update();
|
||
assert!(app.world().entity(row0).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||
assert!(!app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||
|
||
// Advance time by one stagger interval — row1 should reveal.
|
||
{
|
||
let mut time = app.world_mut().resource_mut::<Time>();
|
||
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
|
||
}
|
||
app.update();
|
||
assert!(app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||
|
||
// Advance again — row2 should reveal.
|
||
{
|
||
let mut time = app.world_mut().resource_mut::<Time>();
|
||
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
|
||
}
|
||
app.update();
|
||
assert!(app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||
}
|
||
|
||
/// Under `AnimSpeed::Instant`, breakdown rows must spawn already
|
||
/// revealed and at full opacity — there should be no stagger
|
||
/// reveal animation at all.
|
||
#[test]
|
||
fn score_breakdown_instant_speed_skips_stagger() {
|
||
// Helper: simulate what `spawn_breakdown_row` constructs by
|
||
// checking the `instant` branch behaviour. Specifically: under
|
||
// Instant, scaled_duration → 0.0, so the row's stagger and
|
||
// fade are both zero.
|
||
let stagger = scaled_duration(MOTION_SCORE_BREAKDOWN_STAGGER_SECS, AnimSpeed::Instant);
|
||
let fade = scaled_duration(MOTION_SCORE_BREAKDOWN_FADE_SECS, AnimSpeed::Instant);
|
||
assert_eq!(stagger, 0.0);
|
||
assert_eq!(fade, 0.0);
|
||
}
|
||
}
|