//! Smooth animations: card slide (linear lerp), win cascade, achievement toast. //! //! `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; use crate::achievement_plugin::display_name_for; use crate::auto_complete_plugin::AutoCompleteState; use crate::card_plugin::CardEntity; use crate::challenge_plugin::ChallengeAdvancedEvent; use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent}; use crate::events::{InfoToastEvent, NewGameConfirmEvent, XpAwardedEvent}; use crate::events::{AchievementUnlockedEvent, GameWonEvent}; use crate::game_plugin::GameMutation; use crate::layout::LayoutResource; use crate::pause_plugin::PausedResource; use crate::progress_plugin::LevelUpEvent; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::time_attack_plugin::TimeAttackEndedEvent; use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent; /// Duration of a card slide (move) animation in seconds at Normal speed. pub const SLIDE_SECS: f32 = 0.15; /// The effective slide duration, updated whenever `Settings::animation_speed` changes. #[derive(Resource, Debug, Clone, Copy)] pub struct EffectiveSlideDuration { pub slide_secs: f32, } impl Default for EffectiveSlideDuration { fn default() -> Self { Self { slide_secs: SLIDE_SECS } } } fn anim_speed_to_secs(speed: &AnimSpeed) -> f32 { match speed { AnimSpeed::Normal => SLIDE_SECS, AnimSpeed::Fast => 0.07, AnimSpeed::Instant => 0.0, } } const WIN_TOAST_SECS: f32 = 4.0; const ACHIEVEMENT_TOAST_SECS: f32 = 3.0; const LEVELUP_TOAST_SECS: f32 = 3.0; const DAILY_TOAST_SECS: f32 = 3.0; const WEEKLY_TOAST_SECS: f32 = 3.0; const TIME_ATTACK_TOAST_SECS: f32 = 5.0; const CHALLENGE_TOAST_SECS: f32 = 3.0; const VOLUME_TOAST_SECS: f32 = 1.4; /// Per-card stagger interval for the win cascade at Normal speed (seconds). const CASCADE_STAGGER_NORMAL: f32 = 0.05; /// Duration of each card's cascade slide at Normal speed (seconds). const CASCADE_DURATION_NORMAL: f32 = 0.5; /// Returns the per-card stagger delay for the win cascade at the given `AnimSpeed`. /// /// | `AnimSpeed` | Returned value | /// |-------------|----------------| /// | `Normal` | 0.05 s | /// | `Fast` | 0.025 s | /// | `Instant` | 0.0 s | pub fn cascade_step_secs(speed: AnimSpeed) -> f32 { match speed { AnimSpeed::Normal => CASCADE_STAGGER_NORMAL, AnimSpeed::Fast => CASCADE_STAGGER_NORMAL / 2.0, AnimSpeed::Instant => 0.0, } } /// Returns the slide duration for each card in the win cascade at the given `AnimSpeed`. /// /// | `AnimSpeed` | Returned value | /// |-------------|----------------| /// | `Normal` | 0.5 s | /// | `Fast` | 0.25 s | /// | `Instant` | 0.0 s | pub fn cascade_duration_secs(speed: AnimSpeed) -> f32 { match speed { AnimSpeed::Normal => CASCADE_DURATION_NORMAL, AnimSpeed::Fast => CASCADE_DURATION_NORMAL / 2.0, AnimSpeed::Instant => 0.0, } } /// Linear-lerp slide animation. /// /// After `delay` seconds the card moves from `start` to `target` over /// `duration` seconds. The component removes itself when the slide completes. #[derive(Component, Debug, Clone)] pub struct CardAnim { pub start: Vec3, pub target: Vec3, pub elapsed: f32, pub duration: f32, /// Additional wait before the slide begins. pub delay: f32, } /// Marker on a toast overlay UI node. #[derive(Component, Debug)] pub struct ToastOverlay; /// Auto-dismiss countdown (seconds remaining). Attached to toast entities. #[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; /// 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 { fn build(&self, app: &mut App) { // Register the events this plugin consumes so tests that don't include // GamePlugin can still run AnimationPlugin in isolation. Double-registration // is idempotent in Bevy. app.add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .init_resource::() .init_resource::() .init_resource::() .add_systems(Startup, init_slide_duration) .add_systems( Update, ( advance_card_anims, sync_slide_duration, handle_win_cascade, handle_achievement_toast, handle_levelup_toast, handle_daily_goal_announcement_toast, handle_daily_toast, handle_weekly_toast, handle_time_attack_toast, handle_challenge_toast, handle_settings_toast, handle_auto_complete_toast, handle_new_game_confirm_toast, handle_xp_awarded_toast, tick_toasts, (enqueue_toasts, drive_toast_display).chain(), ) .after(GameMutation), ); } } fn init_slide_duration( settings: Option>, mut dur: ResMut, ) { if let Some(s) = settings { dur.slide_secs = anim_speed_to_secs(&s.0.animation_speed); } } fn sync_slide_duration( mut events: MessageReader, mut dur: ResMut, ) { for ev in events.read() { dur.slide_secs = anim_speed_to_secs(&ev.0.animation_speed); } } /// Advances all in-flight `CardAnim` slide animations. /// /// Skipped while the game is paused so cards do not move while the pause /// overlay is open. fn advance_card_anims( mut commands: Commands, time: Res