Files
Ferrous-Solitaire/solitaire_engine/src/animation_plugin.rs
T
funman300 3388169329
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m29s
fix(engine): platform-correct onboarding copy; Home waits for first-run onboarding (Phase H)
- The how-to-play slide told touch players to left/right-click; Android
  now gets tap/double-tap copy pointing at the bottom-bar Hint button.
- On a fresh profile the Home auto-show spawned underneath the
  onboarding modal, stacking two scrims (v0.44.0 emulator smoke
  finding). spawn_home_on_launch now waits until first_run_complete
  and the onboarding modal is gone, so the launch beat is onboarding,
  then Home, then the table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:52:16 -07:00

1440 lines
50 KiB
Rust

//! 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 bevy::window::RequestRedraw;
use solitaire_data::{AnimSpeed, Settings};
use crate::achievement_plugin::display_name_for;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::{CardAnimation, MotionCurve, sample_curve};
use crate::card_plugin::CardEntity;
use crate::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
use crate::events::{
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, WarningToastEvent,
XpAwardedEvent,
};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource;
use crate::platform::USE_TOUCH_UI_LAYOUT;
use crate::progress_plugin::LevelUpEvent;
use crate::safe_area::SafeAreaAnchoredBottom;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::ui_theme::{
ACCENT_SECONDARY, BG_ELEVATED, MOTION_CASCADE_SLIDE_SECS, MOTION_CASCADE_STAGGER_SECS,
MOTION_SLIDE_SECS, RADIUS_MD, STATE_DANGER, STATE_INFO, STATE_WARNING, TEXT_PRIMARY,
TYPE_BODY_LG, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_TOAST, scaled_duration,
};
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
/// Duration of a card slide (move) animation in seconds at Normal speed.
///
/// Re-exported from `ui_theme::MOTION_SLIDE_SECS` so the entire engine pulls
/// gameplay slide timing from one design-token. Kept as a `pub const` for
/// backwards compatibility with existing callers that read this directly.
pub const SLIDE_SECS: f32 = MOTION_SLIDE_SECS;
/// 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 {
// Route through `ui_theme::scaled_duration` so the slide timing follows
// the same `MOTION_*_SECS` token / `AnimSpeed` mapping as every other
// motion in the engine (toasts, deal stagger, shake, settle, cascade).
scaled_duration(MOTION_SLIDE_SECS, *speed)
}
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;
/// Z added to a card's render depth while its `CardAnim` is in-flight.
///
/// Foundation and tableau cards share x,y during the slide (destination equals
/// a slot that already holds a card). Without this lift the incoming card's
/// bottom-right corner overlaps the stationary card's top-left, which the
/// player perceives as a single card with mismatched rank/suit indices.
///
/// 50.0 sits comfortably above the highest pile depth (~1.04) and well below
/// `DRAG_Z` (500), so a dragged card always renders above an animated one.
pub const CARD_ANIM_Z_LIFT: f32 = 50.0;
/// Per-card stagger interval for the win cascade at Normal speed (seconds).
///
/// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS` so all motion timing
/// lives in one design-token module.
const CASCADE_STAGGER_NORMAL: f32 = MOTION_CASCADE_STAGGER_SECS;
/// Duration of each card's cascade slide at Normal speed (seconds).
///
/// Sourced from `ui_theme::MOTION_CASCADE_SLIDE_SECS`.
const CASCADE_DURATION_NORMAL: f32 = MOTION_CASCADE_SLIDE_SECS;
/// Returns the per-card stagger delay for the win cascade at the given
/// `AnimSpeed`, scaled via `ui_theme::scaled_duration`.
pub fn cascade_step_secs(speed: AnimSpeed) -> f32 {
scaled_duration(MOTION_CASCADE_STAGGER_SECS, speed)
}
/// Returns the slide duration for each card in the win cascade at the given
/// `AnimSpeed`, scaled via `ui_theme::scaled_duration`.
pub fn cascade_duration_secs(speed: AnimSpeed) -> f32 {
scaled_duration(MOTION_CASCADE_SLIDE_SECS, speed)
}
/// 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<String>);
/// 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<Entity>,
/// 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;
/// Marker on the persistent bottom-anchored flex column every toast
/// spawns into (Phase H). Stacking through one container gives queued
/// and immediate toasts a single shared anchor — simultaneous toasts
/// stack upward instead of relying on the old staggered-percentage
/// anchors to dodge each other.
#[derive(Component, Debug)]
pub struct ToastStackRoot;
/// Marker on every toast card node (both paths). Freshly spawned toasts
/// start `Visibility::Hidden` and unparented; [`adopt_toasts_into_stack`]
/// re-parents them under [`ToastStackRoot`] and reveals them — one frame
/// of latency, imperceptible at toast timescales, in exchange for the 14
/// toast handlers keeping their `Commands`-only signatures.
#[derive(Component, Debug)]
pub struct ToastNode;
/// Logical-pixel gap between the screen bottom and the toast stack,
/// before safe-area insets. Clears the Phase F bottom action bar on
/// touch (compact 44px buttons + primary 64px trio + bar padding);
/// desktop's shorter bar needs less.
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT { 112.0 } else { 72.0 };
/// Spawns the persistent [`ToastStackRoot`] container at startup.
fn spawn_toast_stack_root(mut commands: Commands) {
commands.spawn((
ToastStackRoot,
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(TOAST_STACK_BASE_BOTTOM_PX),
left: Val::Px(0.0),
width: Val::Percent(100.0),
// Newest toast sits nearest the bottom edge; older ones
// push upward.
flex_direction: FlexDirection::ColumnReverse,
align_items: AlignItems::Center,
row_gap: VAL_SPACE_2,
..default()
},
SafeAreaAnchoredBottom {
base_bottom: TOAST_STACK_BASE_BOTTOM_PX,
},
ZIndex(Z_TOAST),
));
}
/// Re-parents freshly spawned [`ToastNode`]s under the stack root and
/// reveals them. No-op when every toast is already adopted.
fn adopt_toasts_into_stack(
mut commands: Commands,
orphans: Query<Entity, (With<ToastNode>, Without<ChildOf>)>,
root: Query<Entity, With<ToastStackRoot>>,
) {
let Ok(root) = root.single() else {
return;
};
for toast in &orphans {
commands
.entity(toast)
.insert((ChildOf(root), Visibility::Inherited));
}
}
/// 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::<GameWonEvent>()
.add_message::<AchievementUnlockedEvent>()
.add_message::<LevelUpEvent>()
.add_message::<DailyChallengeCompletedEvent>()
.add_message::<DailyGoalAnnouncementEvent>()
.add_message::<WeeklyGoalCompletedEvent>()
.add_message::<TimeAttackEndedEvent>()
.add_message::<ChallengeAdvancedEvent>()
.add_message::<SettingsChangedEvent>()
.add_message::<InfoToastEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>()
.add_message::<RequestRedraw>()
.init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>()
.init_resource::<ActiveToast>()
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
.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_xp_awarded_toast,
handle_move_rejected_toast,
handle_warning_toast,
tick_toasts,
(enqueue_toasts, drive_toast_display).chain(),
adopt_toasts_into_stack,
)
.after(GameMutation),
);
}
}
fn init_slide_duration(
settings: Option<Res<SettingsResource>>,
mut dur: ResMut<EffectiveSlideDuration>,
) {
if let Some(s) = settings {
dur.slide_secs = effective_slide_secs(&s.0);
}
}
fn sync_slide_duration(
mut events: MessageReader<SettingsChangedEvent>,
mut dur: ResMut<EffectiveSlideDuration>,
) {
for ev in events.read() {
dur.slide_secs = effective_slide_secs(&ev.0);
}
}
/// Resolves the player's effective per-slide animation duration —
/// pre-Settings the same as `anim_speed_to_secs(&settings.animation_speed)`,
/// but reduce-motion mode forces it to `0.0` regardless of the
/// `AnimSpeed` selection so cards snap instantly to their target
/// position. Spec at `design-system.md` §Accessibility (#3).
fn effective_slide_secs(settings: &Settings) -> f32 {
if settings.reduce_motion_mode {
0.0
} else {
anim_speed_to_secs(&settings.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<Time>,
paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Keep the winit loop awake at full frame rate while slides (including
// staggered deals still in their delay phase) are in flight — required
// for Android's reactive_low_power focused_mode.
if !anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims {
if anim.delay > 0.0 {
anim.delay = (anim.delay - dt).max(0.0);
continue;
}
if anim.duration <= 0.0 {
transform.translation = anim.target;
commands.entity(entity).remove::<CardAnim>();
continue;
}
anim.elapsed += dt;
let t = (anim.elapsed / anim.duration).min(1.0);
// Curved interpolation using `MotionCurve::SmoothSnap` (cubic ease-out
// with a small terminal overshoot). Hardcoded at the call site so the
// shared `CardAnim` struct stays a simple linear-tween container — the
// upgrade is one extra `sample_curve` call per advancing animation.
let s = sample_curve(MotionCurve::SmoothSnap, t);
let mut pos = anim.start.lerp(anim.target, s);
// Elevate z during transit so the moving card always renders in front
// of any card already resting at the destination position.
pos.z = anim.target.z + CARD_ANIM_Z_LIFT;
transform.translation = pos;
if t >= 1.0 {
transform.translation = anim.target;
commands.entity(entity).remove::<CardAnim>();
}
}
}
/// Maximum per-card Z-rotation drift applied during the win cascade, in
/// radians. 15° gives a lively but legible scatter — anything larger starts
/// to look chaotic.
const WIN_CASCADE_MAX_ROTATION_RAD: f32 = std::f32::consts::PI / 12.0;
/// Returns a deterministic per-card Z-rotation in `±WIN_CASCADE_MAX_ROTATION_RAD`
/// for the win cascade. Indexing by the card's position in the iterator keeps
/// the result reproducible for a given deal without needing a random crate.
fn cascade_rotation(index: usize) -> f32 {
// Pseudo-random hash from a Fibonacci multiplier; same approach used by
// `card_animation::timing::micro_vary`. Returns 0..=1.
let hash = (index as u32).wrapping_mul(2_654_435_761);
let noise = (hash >> 16) as f32 / 65_536.0;
(noise - 0.5) * 2.0 * WIN_CASCADE_MAX_ROTATION_RAD
}
fn handle_win_cascade(
mut commands: Commands,
mut events: MessageReader<GameWonEvent>,
cards: Query<(Entity, &Transform), With<CardEntity>>,
layout: Option<Res<LayoutResource>>,
settings: Option<Res<SettingsResource>>,
) {
// Drain the event reader; the cascade visual is the only thing
// this system contributes — the post-win "You Won!" modal
// (`win_summary_plugin`) consumes the same `GameWonEvent` and
// carries score / time / achievements / XP itself, so a duplicate
// toast saying "You Win! Score X Time Y" rendered behind the modal
// in earlier builds. Removed.
if events.read().next().is_none() {
return;
}
let margin = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
// Eight off-screen destinations spread around the window edges.
let targets: [Vec3; 8] = [
Vec3::new(margin, margin, 300.0),
Vec3::new(-margin, margin, 300.0),
Vec3::new(margin, -margin, 300.0),
Vec3::new(-margin, -margin, 300.0),
Vec3::new(0.0, margin, 300.0),
Vec3::new(0.0, -margin, 300.0),
Vec3::new(margin, 0.0, 300.0),
Vec3::new(-margin, 0.0, 300.0),
];
let step = settings.as_ref().map_or(CASCADE_STAGGER_NORMAL, |s| {
cascade_step_secs(s.0.animation_speed)
});
let duration = settings.as_ref().map_or(CASCADE_DURATION_NORMAL, |s| {
cascade_duration_secs(s.0.animation_speed)
});
for (i, (entity, transform)) in cards.iter().enumerate() {
// Use the curve-aware `CardAnimation` here (not `CardAnim`) so we can
// pick `MotionCurve::Expressive` for the cascade — the spring-style
// overshoot is what gives the win moment its theatrical feel. The
// `CardAnim`/`CardAnimation` coexistence rule (one per entity) is
// satisfied because cards have neither at the moment the cascade
// starts.
let start = transform.translation;
let target = targets[i % 8];
commands.entity(entity).insert(CardAnimation {
start: start.truncate(),
end: target.truncate(),
elapsed: 0.0,
duration,
curve: MotionCurve::Expressive,
delay: i as f32 * step,
start_z: start.z,
end_z: target.z,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
});
// Per-card Z-rotation drift (±15°), deterministic per cascade
// ordering — gives the scatter a more lively feel without needing
// rotation interpolation in the tween system. Since cards fly off
// screen, the static rotation reads as motion.
let rot = cascade_rotation(i);
let mut new_transform = *transform;
new_transform.rotation = Quat::from_rotation_z(rot);
commands.entity(entity).insert(new_transform);
}
}
fn handle_achievement_toast(
mut commands: Commands,
mut events: MessageReader<AchievementUnlockedEvent>,
) {
for ev in events.read() {
spawn_toast(
&mut commands,
format!("Achievement: {}", display_name_for(&ev.0.id)),
ACHIEVEMENT_TOAST_SECS,
ToastVariant::Celebration,
);
}
}
fn handle_levelup_toast(mut commands: Commands, mut events: MessageReader<LevelUpEvent>) {
for ev in events.read() {
spawn_toast(
&mut commands,
format!("Level Up! → {}", ev.new_level),
LEVELUP_TOAST_SECS,
ToastVariant::Celebration,
);
}
}
fn handle_daily_goal_announcement_toast(
mut commands: Commands,
mut events: MessageReader<DailyGoalAnnouncementEvent>,
) {
for ev in events.read() {
spawn_toast(
&mut commands,
format!("Goal: {}", ev.0),
DAILY_TOAST_SECS,
ToastVariant::Info,
);
}
}
fn handle_daily_toast(
mut commands: Commands,
mut events: MessageReader<DailyChallengeCompletedEvent>,
) {
for ev in events.read() {
spawn_toast(
&mut commands,
format!("Daily Challenge Complete! (Streak: {})", ev.streak),
DAILY_TOAST_SECS,
ToastVariant::Celebration,
);
}
}
fn handle_weekly_toast(
mut commands: Commands,
mut events: MessageReader<WeeklyGoalCompletedEvent>,
) {
for ev in events.read() {
spawn_toast(
&mut commands,
format!("Weekly Goal: {}", ev.description),
WEEKLY_TOAST_SECS,
ToastVariant::Celebration,
);
}
}
fn handle_time_attack_toast(
mut commands: Commands,
mut events: MessageReader<TimeAttackEndedEvent>,
) {
for ev in events.read() {
spawn_toast(
&mut commands,
format!(
"Time Attack: {} win{}",
ev.wins,
if ev.wins == 1 { "" } else { "s" }
),
TIME_ATTACK_TOAST_SECS,
ToastVariant::Info,
);
}
}
fn handle_challenge_toast(
mut commands: Commands,
mut events: MessageReader<ChallengeAdvancedEvent>,
) {
for ev in events.read() {
spawn_toast(
&mut commands,
format!("Challenge {} cleared!", ev.previous_index.saturating_add(1)),
CHALLENGE_TOAST_SECS,
ToastVariant::Celebration,
);
}
}
fn handle_settings_toast(
mut commands: Commands,
mut events: MessageReader<SettingsChangedEvent>,
mut last_sfx: Local<Option<f32>>,
mut last_music: Local<Option<f32>>,
) {
for ev in events.read() {
let sfx = ev.0.sfx_volume;
let music = ev.0.music_volume;
let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > 0.001);
let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > 0.001);
*last_sfx = Some(sfx);
*last_music = Some(music);
if sfx_changed {
let pct = (sfx * 100.0).round() as i32;
spawn_toast(
&mut commands,
format!("SFX: {pct}%"),
VOLUME_TOAST_SECS,
ToastVariant::Info,
);
}
if music_changed {
let pct = (music * 100.0).round() as i32;
spawn_toast(
&mut commands,
format!("Music: {pct}%"),
VOLUME_TOAST_SECS,
ToastVariant::Info,
);
}
}
}
/// Shows a one-time "Auto-completing..." toast when auto-complete activates.
fn handle_auto_complete_toast(
mut commands: Commands,
state: Option<Res<AutoCompleteState>>,
mut shown: Local<bool>,
) {
let Some(s) = state else { return };
if s.is_changed() {
if s.active {
if !*shown {
*shown = true;
spawn_toast(
&mut commands,
"Auto-completing…".to_string(),
2.0,
ToastVariant::Info,
);
}
} else {
*shown = false;
}
}
}
/// 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: MessageReader<InfoToastEvent>, mut queue: ResMut<ToastQueue>) {
for ev in events.read() {
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.
/// Pops and displays queued toasts one at a time, despawning each after
/// `QUEUED_TOAST_SECS`.
///
/// Skipped while the game is paused so the active toast timer freezes and no
/// new messages are dequeued.
fn drive_toast_display(
mut commands: Commands,
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut queue: ResMut<ToastQueue>,
mut active: ResMut<ActiveToast>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Keep the loop ticking while a toast is displayed or queued so the
// countdown advances and the despawn frame isn't held hostage by
// Android's reactive_low_power wake ceiling.
if active.entity.is_some() || !queue.0.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
// Tick down the active toast timer.
if let Some(entity) = active.entity {
active.timer -= dt;
if active.timer <= 0.0 {
// Despawn the toast entity and clear the active slot.
commands.entity(entity).despawn();
active.entity = None;
active.timer = 0.0;
}
}
// If no active toast and the queue has messages, show the next one.
if active.entity.is_none()
&& let Some(message) = queue.0.pop_front()
{
let entity = spawn_queued_toast(&mut commands, message);
active.entity = Some(entity);
active.timer = QUEUED_TOAST_SECS;
}
}
/// Visual variant of a toast — drives the 1px border accent per the
/// design-system toast spec
/// (`docs/ui-mockups/design-system.md` → "Toasts").
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastVariant {
/// Neutral system message — teal border. Default for `InfoToastEvent`,
/// settings volume notifications, and the auto-complete announcement.
Info,
/// Caution / penalty — gold border. Used by [`handle_warning_toast`]
/// for `WarningToastEvent` messages (daily-challenge expiry, sync,
/// theme-store, and leaderboard warnings).
Warning,
/// Failure / rejected action — pink border. Used by
/// [`handle_move_rejected_toast`] for illegal-placement
/// feedback; the third leg of the rejection-feedback stool
/// alongside `card_invalid.wav` (audio) and the destination-
/// pile shake (visual).
Error,
/// Reward / milestone — lavender border. Used for XP awards,
/// achievement unlocks, level-ups, daily/weekly/challenge completions.
Celebration,
}
impl ToastVariant {
/// Returns the 1px border accent for this variant per the design
/// system. Single source of truth — `spawn_toast` and
/// `spawn_queued_toast` both consume it so a future palette swap
/// only has to touch the token, never every call site.
fn border_color(self) -> Color {
match self {
ToastVariant::Info => STATE_INFO,
ToastVariant::Warning => STATE_WARNING,
ToastVariant::Error => STATE_DANGER,
ToastVariant::Celebration => ACCENT_SECONDARY,
}
}
}
/// Spawns a `ToastEntity` for the queued toast system.
///
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
/// by [`InfoToastEvent`] which is by definition neutral system info.
/// Variants other than `Info` belong on the immediate-fire path
/// ([`spawn_toast`]) where the call site knows the semantic intent.
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info)
}
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
for ev in events.read() {
spawn_toast(
&mut commands,
format!("+{} XP", ev.amount),
3.0,
ToastVariant::Celebration,
);
}
}
/// Spawns a 2-second pink-bordered Error toast when the player tries an
/// illegal placement (`MoveRejectedEvent`). Adds a third leg to the
/// existing rejection feedback stool — `card_invalid.wav` already plays
/// (audio cue) and `feedback_anim_plugin::queue_shake_for_rejected_move`
/// fires the destination-pile shake (visual cue). The toast is the
/// accessibility-focused leg: persistent ~2 s text that's readable for
/// deaf players and impossible to miss for players who blink during the
/// shake. First in-engine consumer of `ToastVariant::Error` — exercises
/// the variant's pink border accent and the design-system "rejected
/// action" semantic.
fn handle_move_rejected_toast(
mut commands: Commands,
mut events: MessageReader<MoveRejectedEvent>,
) {
for _ev in events.read() {
spawn_toast(
&mut commands,
"Invalid move".to_string(),
2.0,
ToastVariant::Error,
);
}
}
/// Spawns a 4-second amber-bordered Warning toast for every incoming
/// [`WarningToastEvent`]. First in-engine consumer of
/// [`ToastVariant::Warning`] — exercises the variant's amber accent and
/// the design-system "act soon" semantic.
///
/// Mirrors [`handle_move_rejected_toast`] but reads a generic carrier
/// event (not a domain-specific one) because Warning has multiple
/// candidate drivers and the call-site knows the message wording.
fn handle_warning_toast(mut commands: Commands, mut events: MessageReader<WarningToastEvent>) {
for ev in events.read() {
spawn_toast(&mut commands, ev.0.clone(), 4.0, ToastVariant::Warning);
}
}
/// Ticks down `ToastTimer` on each toast and despawns it when the timer expires.
///
/// Skipped while the game is paused so toast countdowns freeze along with the
/// rest of the animation systems.
fn tick_toasts(
mut commands: Commands,
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut toasts: Query<(Entity, &mut ToastTimer)>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
for (entity, mut timer) in &mut toasts {
timer.0 -= dt;
if timer.0 <= 0.0 {
commands.entity(entity).despawn();
}
}
}
/// Spawns a bottom-anchored fire-and-forget toast that despawns after
/// `duration_secs`. The `variant` selects the 1px accent border color
/// per the design-system toast spec.
fn spawn_toast(
commands: &mut Commands,
message: String,
duration_secs: f32,
variant: ToastVariant,
) {
spawn_toast_node(
commands,
(ToastOverlay, ToastTimer(duration_secs)),
message,
variant,
);
}
/// Common toast-spawn primitive used by both the queued and the
/// fire-and-forget paths. Centralizes the design-system contract so a
/// future spec change (e.g. a different border thickness) is a
/// one-line edit.
///
/// The Terminal toast spec from `design-system.md`:
/// - Opaque [`BG_ELEVATED`] fill (no translucent dim).
/// - 1px border in the variant's accent color.
/// - [`TYPE_BODY_LG`] (18px) `TEXT_PRIMARY` caption — the spec calls
/// for 16px, but the engine type scale only carries 14/18/26/40/...
/// rungs; 18 is the closest rung that preserves the scale invariants
/// tested in `ui_theme::tests`.
/// - [`RADIUS_MD`] corners.
///
/// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns
/// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it
/// into the shared bottom-anchored column — one anchor for every toast,
/// simultaneous toasts stack instead of overlapping.
fn spawn_toast_node<B: Bundle>(
commands: &mut Commands,
bundle: B,
message: String,
variant: ToastVariant,
) -> Entity {
commands
.spawn((
bundle,
ToastNode,
Visibility::Hidden,
Node {
max_width: Val::Percent(70.0),
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(BG_ELEVATED),
BorderColor::all(variant.border_color()),
))
.with_children(|b| {
b.spawn((
Text::new(message),
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(TEXT_PRIMARY),
));
})
.id()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::card_plugin::CardPlugin;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
fn app_with_anim() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
.add_plugins(AnimationPlugin);
app.update(); // PostStartup: spawns cards
app
}
#[test]
fn effective_slide_secs_zeros_out_under_reduce_motion() {
// Reduce-motion forces slide_secs to 0.0 regardless of the
// AnimSpeed selection — cards snap instantly. Spec at
// `design-system.md` §Accessibility (#3).
let s = Settings {
animation_speed: AnimSpeed::Normal,
reduce_motion_mode: true,
..Settings::default()
};
assert_eq!(effective_slide_secs(&s), 0.0);
let s = Settings {
animation_speed: AnimSpeed::Fast,
reduce_motion_mode: true,
..Settings::default()
};
assert_eq!(
effective_slide_secs(&s),
0.0,
"Fast + reduce-motion still 0.0"
);
}
#[test]
fn effective_slide_secs_falls_through_to_anim_speed_when_motion_unchecked() {
// With reduce-motion off, the resolver is just a pass-through
// to anim_speed_to_secs — preserves the existing AnimSpeed
// ladder for players who aren't using the accessibility flag.
for speed in [AnimSpeed::Normal, AnimSpeed::Fast, AnimSpeed::Instant] {
let s = Settings {
animation_speed: speed,
reduce_motion_mode: false,
..Settings::default()
};
assert_eq!(
effective_slide_secs(&s),
anim_speed_to_secs(&speed),
"without reduce-motion the effective duration must equal the AnimSpeed mapping",
);
}
}
#[test]
fn card_anim_at_half_elapsed_passes_geometric_midpoint() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
let start = Vec3::ZERO;
let target = Vec3::new(100.0, 0.0, 0.0);
// elapsed = 0.5, duration = 1.0 → t = 0.5 even when dt=0.
// With `MotionCurve::SmoothSnap` (cubic ease-out) the position at
// t=0.5 is well past the geometric midpoint — assert we're past 50
// but still short of the target so the animation is mid-flight.
let entity = app
.world_mut()
.spawn((
Transform::from_translation(start),
CardAnim {
start,
target,
elapsed: 0.5,
duration: 1.0,
delay: 0.0,
},
))
.id();
app.update();
let pos = app
.world()
.entity(entity)
.get::<Transform>()
.unwrap()
.translation;
assert!(
pos.x > 50.0 && pos.x < 100.0,
"with SmoothSnap, t=0.5 should be past geometric midpoint but short of target; got {}",
pos.x
);
assert!(
app.world().entity(entity).get::<CardAnim>().is_some(),
"animation not yet complete"
);
}
#[test]
fn card_anim_removed_and_at_target_when_elapsed_equals_duration() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
let target = Vec3::new(10.0, 0.0, 0.0);
let entity = app
.world_mut()
.spawn((
Transform::from_translation(Vec3::ZERO),
CardAnim {
start: Vec3::ZERO,
target,
elapsed: 1.0,
duration: 1.0,
delay: 0.0,
},
))
.id();
app.update();
assert!(
app.world().entity(entity).get::<CardAnim>().is_none(),
"CardAnim should be removed when done"
);
let pos = app
.world()
.entity(entity)
.get::<Transform>()
.unwrap()
.translation;
assert!((pos.x - 10.0).abs() < 1e-3);
}
#[test]
fn card_anim_does_not_move_during_delay() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
let entity = app
.world_mut()
.spawn((
Transform::from_translation(Vec3::ZERO),
CardAnim {
start: Vec3::ZERO,
target: Vec3::new(100.0, 0.0, 0.0),
elapsed: 0.0,
duration: 0.15,
delay: 100.0, // large delay — card must not move
},
))
.id();
app.update();
let pos = app
.world()
.entity(entity)
.get::<Transform>()
.unwrap()
.translation;
assert!(pos.x.abs() < 1e-3, "card must not move during delay period");
}
#[test]
fn anim_speed_fast_is_less_than_normal() {
assert!(anim_speed_to_secs(&AnimSpeed::Fast) < anim_speed_to_secs(&AnimSpeed::Normal));
}
/// Pin every `ToastVariant` to its design-system border colour.
/// A future palette swap that touches `STATE_INFO`, `STATE_WARNING`,
/// `STATE_DANGER`, or `ACCENT_SECONDARY` flows through this mapping
/// automatically; this test guards against accidental remappings.
#[test]
fn toast_variant_border_colors_match_design_tokens() {
assert_eq!(ToastVariant::Info.border_color(), STATE_INFO);
assert_eq!(ToastVariant::Warning.border_color(), STATE_WARNING);
assert_eq!(ToastVariant::Error.border_color(), STATE_DANGER);
assert_eq!(ToastVariant::Celebration.border_color(), ACCENT_SECONDARY);
}
/// Every `ToastVariant` resolves to a unique border colour so a
/// careless rebinding (e.g. accidentally setting `Warning` to the
/// same hue as `Info`) fails loudly. Pure check — does not run a
/// Bevy app.
#[test]
fn toast_variant_border_colors_are_distinct() {
let colors = [
ToastVariant::Info.border_color(),
ToastVariant::Warning.border_color(),
ToastVariant::Error.border_color(),
ToastVariant::Celebration.border_color(),
];
for i in 0..colors.len() {
for j in (i + 1)..colors.len() {
assert_ne!(
format!("{:?}", colors[i]),
format!("{:?}", colors[j]),
"variants {i} and {j} resolved to the same border colour",
);
}
}
}
#[test]
fn anim_speed_instant_is_zero() {
assert_eq!(anim_speed_to_secs(&AnimSpeed::Instant), 0.0);
}
#[test]
fn toast_dismissed_after_timer_reaches_zero() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
// Manually spawn a toast with a timer that's already expired.
app.world_mut().spawn((ToastOverlay, ToastTimer(-0.001)));
app.update();
// The toast entity must have been despawned.
let remaining = app
.world_mut()
.query::<&ToastTimer>()
.iter(app.world())
.count();
assert_eq!(remaining, 0, "expired toast must be despawned");
}
#[test]
fn toast_not_dismissed_before_timer_reaches_zero() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
// Large positive timer — should survive one update.
app.world_mut().spawn((ToastOverlay, ToastTimer(100.0)));
app.update();
let remaining = app
.world_mut()
.query::<&ToastTimer>()
.iter(app.world())
.count();
assert_eq!(remaining, 1, "unexpired toast must not be despawned");
}
#[test]
fn info_toast_event_spawns_toast_overlay() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.world_mut()
.write_message(InfoToastEvent("hello".to_string()));
app.update();
let count = app
.world_mut()
.query::<&ToastOverlay>()
.iter(app.world())
.count();
// Existing non-queued toasts (achievement, win, etc.) still spawn
// a ToastOverlay immediately, so the assertion is >= 0 here.
// The queue-based path spawns a ToastEntity instead.
let _ = count;
}
#[test]
fn move_rejected_event_spawns_error_toast() {
// The first in-engine consumer of `ToastVariant::Error`. Firing
// a `MoveRejectedEvent` (illegal placement) must spawn exactly
// one `ToastOverlay` carrying the rejection-feedback message.
// Pairs the existing audio (`card_invalid.wav`) and visual
// (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback
// with an accessibility-focused readable text cue.
use solitaire_core::{KlondikePile, Tableau};
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
// Baseline: no toast overlays exist before the event.
let before = app
.world_mut()
.query::<&ToastOverlay>()
.iter(app.world())
.count();
app.world_mut().write_message(MoveRejectedEvent {
from: KlondikePile::Tableau(Tableau::Tableau1),
to: KlondikePile::Tableau(Tableau::Tableau2),
count: 1,
});
app.update();
let after = app
.world_mut()
.query::<&ToastOverlay>()
.iter(app.world())
.count();
assert_eq!(
after,
before + 1,
"MoveRejectedEvent must spawn exactly one error toast",
);
}
// -----------------------------------------------------------------------
// Task #67 — Toast queue pure-function tests
// -----------------------------------------------------------------------
fn queue_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.update();
app
}
#[test]
fn toast_queue_empty_initially() {
let app = queue_app();
let queue = app.world().resource::<ToastQueue>();
assert!(queue.0.is_empty(), "ToastQueue must start empty");
}
#[test]
fn toast_queue_enqueues_on_event() {
let mut app = queue_app();
app.world_mut()
.write_message(InfoToastEvent("test message".to_string()));
app.update();
// After one update the message should have been consumed (shown) or is
// still in the queue — either way we verify the system processed it by
// checking the ActiveToast resource holds an entity.
let active = app.world().resource::<ActiveToast>();
assert!(
active.entity.is_some(),
"an InfoToastEvent must activate a toast within one update"
);
}
#[test]
fn toast_queue_dequeues_in_order() {
// Push two messages directly into the queue and verify FIFO order.
let mut queue = ToastQueue::default();
queue.0.push_back("first".to_string());
queue.0.push_back("second".to_string());
assert_eq!(queue.0.pop_front().as_deref(), Some("first"));
assert_eq!(queue.0.pop_front().as_deref(), Some("second"));
assert!(queue.0.is_empty());
}
#[test]
fn settings_changed_event_updates_slide_duration() {
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
let fast_settings = Settings {
animation_speed: AnimSpeed::Fast,
..Default::default()
};
app.world_mut()
.write_message(SettingsChangedEvent(fast_settings));
app.update();
let dur = app.world().resource::<EffectiveSlideDuration>().slide_secs;
assert!((dur - anim_speed_to_secs(&AnimSpeed::Fast)).abs() < 1e-6);
}
#[test]
fn win_cascade_adds_anim_to_all_52_cards() {
let mut app = app_with_anim();
let before = app
.world_mut()
.query::<&CardAnimation>()
.iter(app.world())
.count();
assert_eq!(before, 0, "no animations before win");
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
app.update();
let after = app
.world_mut()
.query::<&CardAnimation>()
.iter(app.world())
.count();
assert_eq!(
after, 52,
"all 52 cards should have curve-based cascade animations"
);
}
#[test]
fn win_cascade_uses_expressive_curve() {
let mut app = app_with_anim();
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
let mut q = app.world_mut().query::<&CardAnimation>();
for anim in q.iter(app.world()) {
assert_eq!(
anim.curve,
MotionCurve::Expressive,
"win cascade must use the Expressive curve"
);
}
}
#[test]
fn win_cascade_applies_per_card_rotation() {
let mut app = app_with_anim();
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
// At least one card's rotation must differ from identity — the
// deterministic hash will produce non-zero rotations for nearly all
// 52 indices.
let mut q = app.world_mut().query::<(&CardEntity, &Transform)>();
let any_rotated = q
.iter(app.world())
.any(|(_, t)| t.rotation.z.abs() > 1e-4 || t.rotation.w < 0.999);
assert!(
any_rotated,
"expected at least one card to receive a Z rotation drift"
);
}
#[test]
fn cascade_rotation_stays_within_bounds() {
// Per-card rotation is capped at ±15° (≈ 0.2618 rad). Sampling a
// wider index range than a real deal exercises the hash distribution.
for i in 0..256 {
let r = cascade_rotation(i);
assert!(
r.abs() <= WIN_CASCADE_MAX_ROTATION_RAD + 1e-6,
"cascade_rotation({i}) = {r} exceeds the ±15° cap"
);
}
}
// -----------------------------------------------------------------------
// Task #52 — cascade timing helper tests
// -----------------------------------------------------------------------
#[test]
fn cascade_step_normal_matches_design_token() {
// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS`.
assert!((cascade_step_secs(AnimSpeed::Normal) - MOTION_CASCADE_STAGGER_SECS).abs() < 1e-6);
}
#[test]
fn cascade_step_fast_is_half_normal() {
let normal = cascade_step_secs(AnimSpeed::Normal);
let fast = cascade_step_secs(AnimSpeed::Fast);
assert!(
(fast - normal / 2.0).abs() < 1e-6,
"Fast cascade step must be half of Normal; normal={normal} fast={fast}"
);
}
#[test]
fn cascade_step_instant_is_zero() {
assert_eq!(cascade_step_secs(AnimSpeed::Instant), 0.0);
}
#[test]
fn cascade_duration_normal_matches_design_token() {
// Sourced from `ui_theme::MOTION_CASCADE_SLIDE_SECS`.
assert!(
(cascade_duration_secs(AnimSpeed::Normal) - MOTION_CASCADE_SLIDE_SECS).abs() < 1e-6
);
}
#[test]
fn cascade_duration_fast_is_half_normal() {
let normal = cascade_duration_secs(AnimSpeed::Normal);
let fast = cascade_duration_secs(AnimSpeed::Fast);
assert!(
(fast - normal / 2.0).abs() < 1e-6,
"Fast cascade duration must be half of Normal; normal={normal} fast={fast}"
);
}
#[test]
fn cascade_duration_instant_is_zero() {
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
}
// -----------------------------------------------------------------------
// Phase H: unified toast stack
// -----------------------------------------------------------------------
/// Both toast paths must end up as visible children of the single
/// [`ToastStackRoot`] — the Phase H "one anchor" contract.
#[test]
fn queued_and_immediate_toasts_stack_under_one_root() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.update(); // Startup: spawns the stack root.
// One immediate (error) toast + one queued (info) toast in the
// same frame — the exact collision case the old staggered
// anchors existed to dodge.
use solitaire_core::{KlondikePile, Tableau};
app.world_mut().write_message(MoveRejectedEvent {
from: KlondikePile::Tableau(Tableau::Tableau1),
to: KlondikePile::Tableau(Tableau::Tableau2),
count: 1,
});
app.world_mut()
.write_message(InfoToastEvent("stacked info".to_string()));
app.update(); // handlers spawn both toasts (hidden, unparented)
app.update(); // adopt_toasts_into_stack re-parents + reveals
let root = app
.world_mut()
.query_filtered::<Entity, With<ToastStackRoot>>()
.single(app.world())
.expect("exactly one ToastStackRoot must exist");
let toasts: Vec<(Entity, &ChildOf, &Visibility)> = app
.world_mut()
.query_filtered::<(Entity, &ChildOf, &Visibility), With<ToastNode>>()
.iter(app.world())
.collect();
assert_eq!(
toasts.len(),
2,
"both the immediate and the queued toast must be adopted"
);
for (entity, child_of, visibility) in toasts {
assert_eq!(
child_of.parent(),
root,
"toast {entity} must be a child of the shared stack root"
);
assert_eq!(
*visibility,
Visibility::Inherited,
"adopted toast {entity} must be revealed"
);
}
}
/// Toast nodes must not carry their own absolute positioning — the
/// stack root owns layout (regression guard against reintroducing
/// per-path anchors).
#[test]
fn toast_nodes_have_no_absolute_position() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.update();
app.world_mut()
.write_message(WarningToastEvent("layout check".to_string()));
app.update();
let node = app
.world_mut()
.query_filtered::<&Node, With<ToastNode>>()
.single(app.world())
.expect("warning toast must spawn a ToastNode");
assert_eq!(
node.position_type,
PositionType::Relative,
"toast nodes are flex children of the stack, not absolute overlays"
);
}
}