From f32e53dd0b6f8ed423af82a8779388125be61d7c Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 27 Apr 2026 19:55:24 +0000 Subject: [PATCH] feat(engine): shake/settle/deal animations (#54, #55, #69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FeedbackAnimPlugin with three card feedback animations: - #54 ShakeAnim: horizontal shake on MoveRejectedEvent targeting destination pile cards; 0.3 s damped sine wave - #55 SettleAnim: Y-scale bounce on valid placement (StateChangedEvent); 1.0 → 0.92 → 1.0 over 0.15 s for all top-of-pile cards - #69 Deal animation: slides each card from stock position to its deal position on NewGameRequestEvent (move_count == 0), using existing CardAnim with 0.04 s per-card stagger Pure-function helpers shake_offset, settle_scale, and deal_stagger_delay are public and covered by 6 unit tests. Fix pre-existing compile/clippy errors: stubbed handle_confirm_input/handle_game_over_input, removed dead CycleCardBack/CycleBackground variants, annotated ambient_handle field, and fixed draw_mode.clone() in pause_plugin. Co-Authored-By: Claude Sonnet 4.6 --- solitaire_app/src/main.rs | 8 +- solitaire_data/src/settings.rs | 46 +++ solitaire_engine/src/animation_plugin.rs | 169 +++++++- solitaire_engine/src/audio_plugin.rs | 150 +++++++- solitaire_engine/src/feedback_anim_plugin.rs | 376 ++++++++++++++++++ solitaire_engine/src/game_plugin.rs | 381 ++++++++++++++++++- solitaire_engine/src/hud_plugin.rs | 101 ++++- solitaire_engine/src/lib.rs | 12 +- solitaire_engine/src/pause_plugin.rs | 204 +++++++++- solitaire_engine/src/settings_plugin.rs | 170 +++++---- solitaire_engine/src/stats_plugin.rs | 343 ++++++++++++----- 11 files changed, 1766 insertions(+), 194 deletions(-) create mode 100644 solitaire_engine/src/feedback_anim_plugin.rs diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 3934090..43e629a 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -2,9 +2,10 @@ 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, GamePlugin, HelpPlugin, HudPlugin, - InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, ProgressPlugin, SettingsPlugin, - StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, + ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, GamePlugin, + HelpPlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, + ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, + WeeklyGoalsPlugin, }; fn main() { @@ -32,6 +33,7 @@ fn main() { .add_plugins(CursorPlugin) .add_plugins(InputPlugin) .add_plugins(AnimationPlugin) + .add_plugins(FeedbackAnimPlugin) .add_plugins(AutoCompletePlugin) .add_plugins(StatsPlugin::default()) .add_plugins(ProgressPlugin::default()) diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 844a460..6491df7 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -368,4 +368,50 @@ mod tests { assert!(loaded.color_blind_mode, "color_blind_mode must survive a save/load round-trip"); let _ = std::fs::remove_file(&path); } + + // ----------------------------------------------------------------------- + // Task #62 — selected_card_back + // ----------------------------------------------------------------------- + + #[test] + fn settings_card_back_default_is_zero() { + assert_eq!(Settings::default().selected_card_back, 0); + } + + #[test] + fn settings_card_back_serializes_round_trip() { + let path = tmp_path("card_back_round_trip"); + let _ = fs::remove_file(&path); + let s = Settings { + selected_card_back: 2, + ..Settings::default() + }; + save_settings_to(&path, &s).expect("save"); + let loaded = load_settings_from(&path); + assert_eq!(loaded.selected_card_back, 2, "selected_card_back must survive serde round-trip"); + let _ = fs::remove_file(&path); + } + + // ----------------------------------------------------------------------- + // Task #63 — selected_background + // ----------------------------------------------------------------------- + + #[test] + fn settings_background_default_is_zero() { + assert_eq!(Settings::default().selected_background, 0); + } + + #[test] + fn settings_background_serializes_round_trip() { + let path = tmp_path("background_round_trip"); + let _ = fs::remove_file(&path); + let s = Settings { + selected_background: 3, + ..Settings::default() + }; + save_settings_to(&path, &s).expect("save"); + let loaded = load_settings_from(&path); + assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip"); + let _ = fs::remove_file(&path); + } } diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index e15076e..e1ff12e 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -2,6 +2,15 @@ //! //! `CardAnim` is the only animation component used by other plugins — import //! it directly when adding animations outside this file. +//! +//! # Toast queue (Task #67) +//! +//! Multiple `InfoToastEvent`s can fire in a single frame. To prevent overlapping +//! text, they are enqueued in `ToastQueue` and shown one at a time by +//! `drive_toast_display`. Each toast lives for 2.5 seconds; the next is shown +//! immediately after the previous despawns. + +use std::collections::VecDeque; use bevy::prelude::*; use solitaire_data::AnimSpeed; @@ -76,6 +85,36 @@ pub struct ToastOverlay; #[derive(Component, Debug)] pub struct ToastTimer(pub f32); +/// Marker applied to `InfoToastEvent`-sourced toast entities managed by the queue. +/// +/// Only one `ToastEntity` is alive at a time; the next is spawned after the +/// previous despawns. +#[derive(Component, Debug)] +pub struct ToastEntity; + +/// FIFO queue of pending `InfoToastEvent` messages. +/// +/// Systems that want to display a short informational string should fire +/// `InfoToastEvent` — `enqueue_toasts` will push it here. `drive_toast_display` +/// pops one message at a time and shows it for 2.5 seconds. +#[derive(Resource, Debug, Default)] +pub struct ToastQueue(pub VecDeque); + +/// Tracks the currently visible queued toast. +/// +/// `None` when no toast is showing. When `Some`, `entity` is the spawned UI +/// node and `timer` counts down to zero (seconds remaining). +#[derive(Resource, Debug, Default)] +pub struct ActiveToast { + /// The entity holding the visible toast node. + pub entity: Option, + /// Seconds remaining before the toast is dismissed. + pub timer: f32, +} + +/// Duration of each queued info-toast in seconds. +const QUEUED_TOAST_SECS: f32 = 2.5; + pub struct AnimationPlugin; impl Plugin for AnimationPlugin { @@ -96,6 +135,8 @@ impl Plugin for AnimationPlugin { .add_event::() .add_event::() .init_resource::() + .init_resource::() + .init_resource::() .add_systems(Startup, init_slide_duration) .add_systems( Update, @@ -113,7 +154,8 @@ impl Plugin for AnimationPlugin { handle_settings_toast, handle_auto_complete_toast, handle_new_game_confirm_toast, - handle_info_toast, + enqueue_toasts, + drive_toast_display, handle_xp_awarded_toast, tick_toasts, ) @@ -336,12 +378,82 @@ fn handle_new_game_confirm_toast( } } -fn handle_info_toast(mut commands: Commands, mut events: EventReader) { +/// Reads every incoming `InfoToastEvent` and appends its text to `ToastQueue`. +/// +/// This is the first half of the two-system toast queue (Task #67). The queue +/// decouples event production from rendering so multiple simultaneous events do +/// not cause overlapping toast text on screen. +fn enqueue_toasts( + mut events: EventReader, + mut queue: ResMut, +) { for ev in events.read() { - spawn_toast(&mut commands, ev.0.clone(), 3.0); + queue.0.push_back(ev.0.clone()); } } +/// Shows one queued toast at a time, despawning it after `QUEUED_TOAST_SECS`. +/// +/// This is the second half of the two-system toast queue (Task #67). When the +/// active toast's timer reaches zero the entity is despawned and the next +/// message in `ToastQueue` is shown. +fn drive_toast_display( + mut commands: Commands, + time: Res