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