From ffc79447d428cc7bd3deae0e22ffac47b02be5d7 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 28 Apr 2026 22:02:52 +0000 Subject: [PATCH] =?UTF-8?q?fix+refactor+docs:=20P0=E2=80=93P3=20todo=20lis?= =?UTF-8?q?t=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 fixes: - Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs (all three were exported but never wired — features silently did nothing) - game_state::draw(): increment move_count on waste→stock recycle, not just on normal draws; add move_count_increments_on_recycle regression test P1 fixes: - solitaire_server/Cargo.toml: remove duplicate dev-dependencies (solitaire_sync, uuid, chrono, jsonwebtoken were in both sections) P2 — input_plugin refactor: - Split 198-line handle_keyboard() into three focused systems under 110 lines each: handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G) - Introduce KeyboardConfirmState resource to share countdown timers across systems - Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck, new_game_confirm_window_is_positive P2 — achievement predicate tests (solitaire_core): - Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer, on_a_roll, comeback predicates (previously only covered via check_achievements()) - 141 core tests now passing P2 — server tests: - solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required) - solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order P3 — documentation: - Add struct-level /// to 12 Plugin structs (ChallengePlugin, CursorPlugin, AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin, HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin) - Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef - Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win card_animation module (new files from previous session): - chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs - Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants - Add handle_touch_stock_tap so touch users can draw from the stock pile Co-Authored-By: Claude Sonnet 4.6 --- solitaire_app/src/main.rs | 13 +- solitaire_core/src/achievement.rs | 118 ++- solitaire_core/src/card.rs | 4 + solitaire_core/src/deck.rs | 1 + solitaire_core/src/game_state.rs | 27 + solitaire_core/src/pile.rs | 2 + solitaire_data/src/stats.rs | 2 +- solitaire_data/src/weekly.rs | 6 +- solitaire_engine/src/animation_plugin.rs | 1 + solitaire_engine/src/audio_plugin.rs | 1 + .../src/card_animation/animation.rs | 42 +- solitaire_engine/src/card_animation/chain.rs | 207 +++++ .../src/card_animation/diagnostics.rs | 239 +++++ .../src/card_animation/interaction.rs | 46 +- solitaire_engine/src/card_animation/mod.rs | 31 +- solitaire_engine/src/card_animation/tuning.rs | 230 +++++ solitaire_engine/src/challenge_plugin.rs | 2 + solitaire_engine/src/cursor_plugin.rs | 1 + .../src/daily_challenge_plugin.rs | 2 + solitaire_engine/src/help_plugin.rs | 2 + solitaire_engine/src/hud_plugin.rs | 1 + solitaire_engine/src/input_plugin.rs | 837 ++++++++++++++---- solitaire_engine/src/leaderboard_plugin.rs | 1 + solitaire_engine/src/lib.rs | 3 + solitaire_engine/src/onboarding_plugin.rs | 2 + solitaire_engine/src/pause_plugin.rs | 1 + solitaire_engine/src/resources.rs | 54 +- solitaire_engine/src/time_attack_plugin.rs | 1 + solitaire_engine/src/weekly_goals_plugin.rs | 2 + solitaire_server/Cargo.toml | 6 +- solitaire_server/src/leaderboard.rs | 68 ++ solitaire_server/src/sync.rs | 115 +++ 32 files changed, 1824 insertions(+), 244 deletions(-) create mode 100644 solitaire_engine/src/card_animation/chain.rs create mode 100644 solitaire_engine/src/card_animation/diagnostics.rs create mode 100644 solitaire_engine/src/card_animation/tuning.rs diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index dc63350..450b87b 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -1,11 +1,11 @@ use bevy::prelude::*; use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings}; use solitaire_engine::{ - AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin, - ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, GamePlugin, - HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, - PausePlugin, ProfilePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin, - TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, + AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, + CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, + GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, + OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, + StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, }; fn main() { @@ -32,8 +32,10 @@ fn main() { .add_plugins(CardPlugin) .add_plugins(CursorPlugin) .add_plugins(InputPlugin) + .add_plugins(SelectionPlugin) .add_plugins(AnimationPlugin) .add_plugins(FeedbackAnimPlugin) + .add_plugins(CardAnimationPlugin) .add_plugins(AutoCompletePlugin) .add_plugins(StatsPlugin::default()) .add_plugins(ProgressPlugin::default()) @@ -52,5 +54,6 @@ fn main() { .add_plugins(OnboardingPlugin) .add_plugins(SyncPlugin::new(sync_provider)) .add_plugins(LeaderboardPlugin) + .add_plugins(WinSummaryPlugin) .run(); } diff --git a/solitaire_core/src/achievement.rs b/solitaire_core/src/achievement.rs index 881d77e..e26bf6b 100644 --- a/solitaire_core/src/achievement.rs +++ b/solitaire_core/src/achievement.rs @@ -12,20 +12,25 @@ /// `StatsSnapshot`, the final `GameState`, and wall-clock time. #[derive(Debug, Clone)] pub struct AchievementContext { - // Stats (after this win has been recorded). + /// Total number of games played (after this win has been recorded). pub games_played: u32, + /// Total number of games won (after this win has been recorded). pub games_won: u32, + /// Current consecutive win streak (after this win has been recorded). pub win_streak_current: u32, + /// Highest single-game score ever achieved. pub best_single_score: u32, + /// Cumulative score across all games ever played. pub lifetime_score: u64, + /// Total wins completed in Draw 3 mode. pub draw_three_wins: u32, - // Progression. /// Current daily-challenge completion streak (consecutive days). pub daily_challenge_streak: u32, - // Last-win facts (GameWonEvent + GameState at win time). + /// Score achieved in the just-won game. pub last_win_score: i32, + /// Elapsed seconds for the just-won game. pub last_win_time_seconds: u64, /// `true` if `undo()` was called at least once during the won game. pub last_win_used_undo: bool, @@ -55,13 +60,17 @@ pub enum Reward { /// A single achievement's static metadata + unlock condition. #[derive(Debug, Clone, Copy)] pub struct AchievementDef { + /// Unique string identifier for this achievement (e.g. `"first_win"`). pub id: &'static str, + /// Human-readable display name shown in the achievements screen. pub name: &'static str, + /// Flavour text describing how to unlock the achievement. pub description: &'static str, /// Hidden from the achievements screen until unlocked. pub secret: bool, /// Reward granted on first unlock. `None` for cosmetic-only recognition. pub reward: Option, + /// Predicate evaluated on every `GameWonEvent` and `StateChangedEvent`. Returns true when the achievement should unlock. pub condition: fn(&AchievementContext) -> bool, } @@ -477,6 +486,109 @@ mod tests { assert!(achievement_by_id("nonexistent").is_none()); } + // ----------------------------------------------------------------------- + // Direct predicate tests via ctx_defaults() + // ----------------------------------------------------------------------- + + /// Baseline context representing a single clean one-minute win in Draw-One mode. + fn ctx_defaults() -> AchievementContext { + AchievementContext { + games_played: 1, + games_won: 1, + win_streak_current: 1, + best_single_score: 0, + lifetime_score: 0, + draw_three_wins: 0, + daily_challenge_streak: 0, + last_win_score: 0, + last_win_time_seconds: 600, + last_win_used_undo: false, + wall_clock_hour: Some(12), + last_win_recycle_count: 0, + last_win_is_zen: false, + } + } + + #[test] + fn speed_demon_true_when_under_three_minutes() { + let mut c = ctx_defaults(); + c.last_win_time_seconds = 179; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"speed_demon"), "speed_demon should unlock at 179s"); + } + + #[test] + fn speed_demon_false_when_over_three_minutes() { + let mut c = ctx_defaults(); + c.last_win_time_seconds = 181; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"speed_demon"), "speed_demon must not unlock at 181s"); + } + + #[test] + fn lightning_true_when_under_90_seconds() { + let mut c = ctx_defaults(); + c.last_win_time_seconds = 89; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"lightning"), "lightning should unlock at 89s"); + } + + #[test] + fn lightning_false_at_exactly_90_seconds() { + let mut c = ctx_defaults(); + c.last_win_time_seconds = 90; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"lightning"), "lightning must not unlock at exactly 90s"); + } + + #[test] + fn no_undo_true_when_zero_undos() { + let mut c = ctx_defaults(); + c.last_win_used_undo = false; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"no_undo"), "no_undo should unlock when undo was not used"); + } + + #[test] + fn no_undo_false_when_undo_used() { + let mut c = ctx_defaults(); + c.last_win_used_undo = true; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"no_undo"), "no_undo must not unlock when undo was used"); + } + + #[test] + fn high_scorer_true_when_score_5000_or_more() { + let mut c = ctx_defaults(); + c.best_single_score = 5_000; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"high_scorer"), "high_scorer should unlock at best_single_score=5000"); + } + + #[test] + fn high_scorer_false_when_below_5000() { + let mut c = ctx_defaults(); + c.best_single_score = 4_999; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"high_scorer"), "high_scorer must not unlock at best_single_score=4999"); + } + + #[test] + fn on_a_roll_true_at_streak_3() { + let mut c = ctx_defaults(); + c.win_streak_current = 3; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock at streak=3"); + } + + #[test] + fn comeback_true_when_three_or_more_recycles() { + let mut c = ctx_defaults(); + c.last_win_recycle_count = 3; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"comeback"), "comeback should unlock at last_win_recycle_count=3"); + } + #[test] fn on_a_roll_requires_streak_of_3() { let mut c = ctx(); diff --git a/solitaire_core/src/card.rs b/solitaire_core/src/card.rs index 1fe7c55..f3db2f7 100644 --- a/solitaire_core/src/card.rs +++ b/solitaire_core/src/card.rs @@ -63,9 +63,13 @@ impl Rank { /// A single playing card. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Card { + /// Unique identifier for this card within the deal. Stable across moves and undo. pub id: u32, + /// The card's suit (Clubs, Diamonds, Hearts, Spades). pub suit: Suit, + /// The card's rank (Ace through King). pub rank: Rank, + /// Whether the card is visible to the player. Face-down cards may not be moved. pub face_up: bool, } diff --git a/solitaire_core/src/deck.rs b/solitaire_core/src/deck.rs index ca3debd..84872e7 100644 --- a/solitaire_core/src/deck.rs +++ b/solitaire_core/src/deck.rs @@ -12,6 +12,7 @@ const ALL_RANKS: [Rank; 13] = [ /// A standard 52-card deck. pub struct Deck { + /// All 52 cards in the deck, in deal order. pub cards: Vec, } diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 526a812..c7c89ff 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -64,18 +64,26 @@ struct StateSnapshot { /// Full state of an in-progress Klondike Solitaire game. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GameState { + /// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles. #[serde(with = "pile_map_serde")] pub piles: HashMap, + /// Whether the player draws one or three cards from the stock per turn. pub draw_mode: DrawMode, /// Top-level mode (Classic / Zen). Defaults to Classic for backwards /// compatibility with older save files via `#[serde(default)]`. #[serde(default)] pub mode: GameMode, + /// Current game score. Can be negative (undo penalties subtract from score). pub score: i32, + /// Total moves made this game, including draws and stock recycles. pub move_count: u32, + /// Seconds elapsed since the game started, used for time-bonus scoring. pub elapsed_seconds: u64, + /// RNG seed used to deal this game. Same seed always produces the same layout. pub seed: u64, + /// True once all 52 cards are on the foundations. No further moves are accepted. pub is_won: bool, + /// True when the game can be completed without further input (all remaining cards are face-up and in order). pub is_auto_completable: bool, /// Number of times `undo()` has been successfully invoked this game. /// Used by achievement conditions like `no_undo`. @@ -173,6 +181,7 @@ impl GameState { stock.cards.push(card); } self.recycle_count = self.recycle_count.saturating_add(1); + self.move_count += 1; return Ok(()); } @@ -562,6 +571,24 @@ mod tests { assert_eq!(g.recycle_count, 2); } + #[test] + fn move_count_increments_on_recycle() { + let mut g = new_game(); + // Drain stock to waste, recording how many draws it took. + let mut draws: u32 = 0; + while !g.piles[&PileType::Stock].cards.is_empty() { + g.draw().unwrap(); + draws += 1; + } + let before = g.move_count; + g.draw().unwrap(); // recycle + assert_eq!( + g.move_count, + before + 1, + "recycling waste back to stock must increment move_count (was {before}, draws={draws})" + ); + } + #[test] fn draw_from_empty_stock_and_waste_returns_error() { // The only stop condition for draw() is: both stock AND waste are diff --git a/solitaire_core/src/pile.rs b/solitaire_core/src/pile.rs index de3c51f..8b011b3 100644 --- a/solitaire_core/src/pile.rs +++ b/solitaire_core/src/pile.rs @@ -17,7 +17,9 @@ pub enum PileType { /// A named collection of cards in a specific board position. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Pile { + /// Which pile this is (Stock, Waste, Foundation suit, or Tableau column). pub pile_type: PileType, + /// Cards in the pile, bottom-to-top stacking order. Last element is the top card. pub cards: Vec, } diff --git a/solitaire_data/src/stats.rs b/solitaire_data/src/stats.rs index db40a15..d4ddb51 100644 --- a/solitaire_data/src/stats.rs +++ b/solitaire_data/src/stats.rs @@ -13,7 +13,7 @@ pub use solitaire_sync::StatsSnapshot; /// /// Import this trait alongside `StatsSnapshot` to use `update_on_win`. pub trait StatsExt { - /// Record a completed win. Updates all relevant counters and rolling averages. + /// Updates rolling statistics from a completed game win. Call once per `GameWonEvent`. fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode); } diff --git a/solitaire_data/src/weekly.rs b/solitaire_data/src/weekly.rs index 3ee1f36..96bd52a 100644 --- a/solitaire_data/src/weekly.rs +++ b/solitaire_data/src/weekly.rs @@ -9,7 +9,7 @@ use solitaire_core::game_state::DrawMode; /// XP awarded each time a weekly goal is just completed. pub const WEEKLY_GOAL_XP: u64 = 75; -/// What kind of game outcome counts as progress toward this goal. +/// Discriminant for the type of weekly goal the player is working toward. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WeeklyGoalKind { /// Any win counts. @@ -22,7 +22,7 @@ pub enum WeeklyGoalKind { WinDrawThree, } -/// Static metadata for a single weekly goal. +/// Static definition of a weekly goal — the goal type, target value, and display strings. #[derive(Debug, Clone, Copy)] pub struct WeeklyGoalDef { pub id: &'static str, @@ -31,7 +31,7 @@ pub struct WeeklyGoalDef { pub kind: WeeklyGoalKind, } -/// Per-event facts a goal needs to decide whether it matched. +/// Runtime snapshot of game metrics used to evaluate weekly goal progress. #[derive(Debug, Clone)] pub struct WeeklyGoalContext { pub time_seconds: u64, diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index 02b7cd9..562e32f 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -149,6 +149,7 @@ pub struct ActiveToast { /// Duration of each queued info-toast in seconds. const QUEUED_TOAST_SECS: f32 = 2.5; +/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence. pub struct AnimationPlugin; impl Plugin for AnimationPlugin { diff --git a/solitaire_engine/src/audio_plugin.rs b/solitaire_engine/src/audio_plugin.rs index 3387a57..278cc90 100644 --- a/solitaire_engine/src/audio_plugin.rs +++ b/solitaire_engine/src/audio_plugin.rs @@ -97,6 +97,7 @@ pub struct MuteState { pub music_muted: bool, } +/// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`. pub struct AudioPlugin; impl Plugin for AudioPlugin { diff --git a/solitaire_engine/src/card_animation/animation.rs b/solitaire_engine/src/card_animation/animation.rs index 8b597a5..a01cab5 100644 --- a/solitaire_engine/src/card_animation/animation.rs +++ b/solitaire_engine/src/card_animation/animation.rs @@ -140,10 +140,22 @@ impl CardAnimation { /// Redirects a card to a new destination without snapping or interrupting motion. /// -/// Reads the card's current interpolated position (from a live `CardAnimation` if -/// present, or from `Transform` if the card is stationary) and starts a fresh -/// `CardAnimation` from that position. Duration is recalculated from the remaining -/// distance so short remaining paths feel appropriately quick. +/// Reads the card's current interpolated position (from a live [`CardAnimation`] +/// if present, or from `Transform` if stationary) and starts a fresh +/// [`CardAnimation`] from that position. Duration is recalculated from the +/// remaining distance so short paths stay quick. +/// +/// # Velocity continuity +/// +/// When a card is mid-flight, the new animation starts with a small positive +/// `elapsed` offset (`carry`) derived from how far through the current animation +/// the card is. This preserves a sense of forward momentum: the new curve does +/// not restart from zero velocity, avoiding a visible "lurch" when the target +/// changes rapidly. +/// +/// The carry is deliberately small (≤ 10 % of the new duration) so that it +/// never causes a visible position jump — the card's start position is still +/// read from the current transform. /// /// # Example /// @@ -169,17 +181,29 @@ pub fn retarget_animation( new_end_z: f32, curve: MotionCurve, ) { - let (current_xy, current_z) = match current_anim { - Some(anim) => (anim.current_xy(), transform.translation.z), - None => (transform.translation.truncate(), transform.translation.z), + let (current_xy, current_z, momentum_carry) = match current_anim { + Some(anim) if anim.duration > 0.0 => { + // Estimate how far into the current animation we are and carry + // a small fraction of that progress into the new animation. + // This avoids restarting from zero velocity and makes the motion + // feel continuous when the target changes mid-flight. + let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0); + // Cap at 10 % of the new animation so there's no visible jump. + let carry = (t * 0.12).min(0.10); + (anim.current_xy(), transform.translation.z, carry) + } + _ => (transform.translation.truncate(), transform.translation.z, 0.0), }; let distance = current_xy.distance(new_end); + let duration = compute_duration(distance); + commands.entity(entity).insert(CardAnimation { start: current_xy, end: new_end, - elapsed: 0.0, - duration: compute_duration(distance), + // Start slightly into the new animation to carry forward momentum. + elapsed: momentum_carry * duration, + duration, curve, delay: 0.0, start_z: current_z, diff --git a/solitaire_engine/src/card_animation/chain.rs b/solitaire_engine/src/card_animation/chain.rs new file mode 100644 index 0000000..2ed8a01 --- /dev/null +++ b/solitaire_engine/src/card_animation/chain.rs @@ -0,0 +1,207 @@ +//! Animation chaining — play a sequence of [`CardAnimation`] segments in order. +//! +//! Insert [`AnimationChain`] on a card entity alongside the *first* segment as +//! a [`CardAnimation`] to sequence multi-step motion. When the active +//! [`CardAnimation`] finishes and is removed, [`advance_animation_chains`] +//! pops the next segment and inserts it automatically. +//! +//! # Example — arc then settle +//! +//! ```ignore +//! // Arc up to a midpoint, then settle onto the foundation with a soft bounce. +//! let mid = (start + end) / 2.0 + Vec2::new(0.0, 30.0); +//! +//! let first_leg = CardAnimation::slide(start, z, mid, z + 20.0, MotionCurve::SmoothSnap) +//! .with_z_lift(15.0); +//! let second_leg = CardAnimation::slide(mid, z + 20.0, end, resting_z, MotionCurve::SoftBounce); +//! +//! commands.entity(card_entity).insert(( +//! first_leg, // plays immediately +//! AnimationChain::new().then(second_leg), // queued +//! )); +//! ``` +//! +//! # Invariant +//! +//! The chain holds only the *queued* segments — the segment currently playing +//! lives on the entity as a [`CardAnimation`] component and has already been +//! removed from the queue. When the queue is exhausted the `AnimationChain` +//! component removes itself. + +use std::collections::VecDeque; + +use bevy::prelude::*; + +use super::animation::CardAnimation; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/// A FIFO queue of [`CardAnimation`] segments to be played one after another. +/// +/// The currently playing segment lives on the entity as a [`CardAnimation`] +/// component (already removed from this queue). When that animation completes, +/// [`advance_animation_chains`] pops the next entry and inserts it. +/// +/// Remove this component to cancel the entire chain mid-flight. The in-progress +/// [`CardAnimation`] (if any) will still play to completion unless also removed. +#[derive(Component, Debug, Clone)] +pub struct AnimationChain { + pub(crate) queue: VecDeque, +} + +impl AnimationChain { + /// Creates an empty chain with no queued segments. + #[must_use] + pub fn new() -> Self { + Self { + queue: VecDeque::new(), + } + } + + /// Appends `anim` to the end of the chain. + /// + /// Returns `self` for builder-style chaining. + #[must_use] + pub fn then(mut self, anim: CardAnimation) -> Self { + self.queue.push_back(anim); + self + } + + /// Number of segments waiting in the queue (not including any + /// currently active [`CardAnimation`]). + pub fn remaining(&self) -> usize { + self.queue.len() + } + + /// Returns `true` when no segments remain in the queue. + pub fn is_empty(&self) -> bool { + self.queue.is_empty() + } +} + +impl Default for AnimationChain { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// System +// --------------------------------------------------------------------------- + +/// Pops the next queued segment when the active [`CardAnimation`] has finished. +/// +/// Must run **after** `advance_card_animations` so the completed animation has +/// already been removed before this system inspects the entity. +pub(crate) fn advance_animation_chains( + mut commands: Commands, + mut chains: Query<(Entity, &mut AnimationChain), Without>, +) { + for (entity, mut chain) in &mut chains { + match chain.queue.pop_front() { + Some(next) => { + // Insert the next segment; the chain component stays until empty. + commands.entity(entity).insert(next); + } + None => { + // Queue exhausted — clean up the chain component. + commands.entity(entity).remove::(); + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::card_animation::MotionCurve; + + fn slide(end_x: f32) -> CardAnimation { + CardAnimation::slide( + Vec2::ZERO, + 0.0, + Vec2::new(end_x, 0.0), + 0.0, + MotionCurve::SmoothSnap, + ) + } + + #[test] + fn new_chain_is_empty() { + let c = AnimationChain::new(); + assert_eq!(c.remaining(), 0); + assert!(c.is_empty()); + } + + #[test] + fn then_appends_and_increments_remaining() { + let c = AnimationChain::new().then(slide(1.0)).then(slide(2.0)); + assert_eq!(c.remaining(), 2); + assert!(!c.is_empty()); + } + + #[test] + fn queue_is_fifo() { + let mut c = AnimationChain::new().then(slide(1.0)).then(slide(2.0)); + let first = c.queue.pop_front().expect("must have first segment"); + assert!( + (first.end.x - 1.0).abs() < 1e-6, + "first dequeued must be the first appended (end.x=1), got {}", + first.end.x + ); + let second = c.queue.pop_front().expect("must have second segment"); + assert!( + (second.end.x - 2.0).abs() < 1e-6, + "second dequeued must be the second appended (end.x=2), got {}", + second.end.x + ); + } + + #[test] + fn default_equals_new() { + assert_eq!(AnimationChain::default().remaining(), 0); + } + + #[test] + fn chain_with_three_segments() { + let c = AnimationChain::new() + .then(slide(1.0)) + .then(slide(2.0)) + .then(slide(3.0)); + assert_eq!(c.remaining(), 3); + } + + #[test] + fn advance_system_inserts_next_segment() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(crate::card_animation::CardAnimationPlugin); + + let chain = AnimationChain::new().then(slide(100.0)); + // Spawn an entity with only AnimationChain (no CardAnimation) so the + // system fires immediately on the first update. + let entity = app + .world_mut() + .spawn((Transform::from_translation(Vec3::ZERO), chain)) + .id(); + + app.update(); + + // After one update, the chain system should have popped `slide(100)` and + // inserted it as a `CardAnimation`. + assert!( + app.world().entity(entity).get::().is_some(), + "advance_animation_chains must insert CardAnimation from first queued segment" + ); + // The chain component should still be present (but now empty). + // Actually, since we popped the last item, the chain removes itself too. + // Whether it's present or not depends on system ordering, but the + // CardAnimation must definitely be present. + } +} diff --git a/solitaire_engine/src/card_animation/diagnostics.rs b/solitaire_engine/src/card_animation/diagnostics.rs new file mode 100644 index 0000000..d536b03 --- /dev/null +++ b/solitaire_engine/src/card_animation/diagnostics.rs @@ -0,0 +1,239 @@ +//! Lightweight frame-time diagnostics. +//! +//! [`FrameTimeDiagnostics`] is a Bevy resource that maintains a rolling window +//! of the last [`WINDOW_SIZE`] frame durations. Any system can read it to make +//! performance-aware decisions — for example, disabling settle-bounce animations +//! when the game is running below 30 FPS on a low-end device. +//! +//! # Reading diagnostics +//! +//! ```ignore +//! fn my_system(diag: Res) { +//! if diag.is_low_performance() { +//! // Skip expensive visual effects. +//! return; +//! } +//! println!("avg FPS: {:.1}", diag.fps()); +//! } +//! ``` +//! +//! # Update +//! +//! [`update_frame_time_diagnostics`] runs every frame via [`CardAnimationPlugin`] +//! (or whichever plugin registers it). The window is circular so only the last +//! `WINDOW_SIZE` frames influence the statistics. + +use bevy::prelude::*; + +/// Number of frames kept in the rolling statistics window. +pub const WINDOW_SIZE: usize = 60; + +/// Rolling frame-time statistics over the last [`WINDOW_SIZE`] frames. +/// +/// All times are in seconds. Statistics are updated every frame by +/// [`update_frame_time_diagnostics`]. +#[derive(Resource, Debug)] +pub struct FrameTimeDiagnostics { + samples: [f32; WINDOW_SIZE], + head: usize, + count: usize, + /// Smoothed average frame duration over the window (seconds). + pub avg_secs: f32, + /// Worst-case (slowest) frame duration in the window (seconds). + pub max_secs: f32, + /// Best-case (fastest) frame duration in the window (seconds). + pub min_secs: f32, +} + +impl Default for FrameTimeDiagnostics { + fn default() -> Self { + Self { + samples: [0.0; WINDOW_SIZE], + head: 0, + count: 0, + avg_secs: 0.0, + max_secs: 0.0, + min_secs: 0.0, + } + } +} + +impl FrameTimeDiagnostics { + /// Estimated frames per second based on the rolling average. + /// + /// Returns `0.0` until at least one frame has been recorded. + pub fn fps(&self) -> f32 { + if self.avg_secs > 0.0 { + 1.0 / self.avg_secs + } else { + 0.0 + } + } + + /// Returns `true` when the rolling-average FPS is above `target`. + /// + /// Always returns `false` until the window is fully populated. + pub fn is_above_target(&self, target_fps: f32) -> bool { + self.count >= WINDOW_SIZE && self.fps() > target_fps + } + + /// Returns `true` when the device appears to be running below 30 FPS. + /// + /// Only asserted after the window is fully populated so a single slow + /// startup frame does not permanently suppress visual effects. + pub fn is_low_performance(&self) -> bool { + self.count >= WINDOW_SIZE && self.fps() < 30.0 + } + + /// Appends `dt` to the ring buffer and recomputes statistics. + /// + /// O(WINDOW_SIZE) — acceptable because WINDOW_SIZE is small and constant. + fn push(&mut self, dt: f32) { + self.samples[self.head] = dt; + self.head = (self.head + 1) % WINDOW_SIZE; + if self.count < WINDOW_SIZE { + self.count += 1; + } + + let n = self.count; + let mut sum = 0.0_f32; + let mut max_val = 0.0_f32; + let mut min_val = f32::MAX; + + for &s in &self.samples[..n] { + sum += s; + if s > max_val { + max_val = s; + } + if s < min_val { + min_val = s; + } + } + + self.avg_secs = sum / n as f32; + self.max_secs = max_val; + self.min_secs = if min_val == f32::MAX { 0.0 } else { min_val }; + } +} + +// --------------------------------------------------------------------------- +// System +// --------------------------------------------------------------------------- + +/// Records the current frame's delta time in [`FrameTimeDiagnostics`]. +/// +/// Registered by [`CardAnimationPlugin`]. Runs every frame in `Update`. +pub(crate) fn update_frame_time_diagnostics( + time: Res