feat(engine): add AnimationPlugin with slide, cascade, and toast (Phase 3F)

- CardAnim component lerps cards from old to new position on every move
- card_plugin now adds CardAnim instead of teleporting cards on state change
- Snap-back on invalid drag reuses the same mechanism (StateChangedEvent)
- Win cascade flies all 52 cards off-screen with staggered delay on GameWonEvent
- Achievement toast scaffold wired to AchievementUnlockedEvent (Phase 5 content)
- Fix input_plugin test: click Queen's visible strip, not geometric centre

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Solitaire Quest
2026-04-23 21:32:42 -07:00
parent a8a323c6c3
commit 4d2379c426
7 changed files with 353 additions and 26 deletions
+293
View File
@@ -0,0 +1,293 @@
//! 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.
use bevy::prelude::*;
use crate::card_plugin::CardEntity;
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
/// Duration of a card slide (move) animation in seconds.
pub const SLIDE_SECS: f32 = 0.15;
const WIN_TOAST_SECS: f32 = 4.0;
const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
const CASCADE_STAGGER: f32 = 0.05;
const CASCADE_DURATION: f32 = 0.5;
/// 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);
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_event::<GameWonEvent>()
.add_event::<AchievementUnlockedEvent>()
.add_systems(
Update,
(
advance_card_anims,
handle_win_cascade,
handle_achievement_toast,
tick_toasts,
)
.after(GameMutation),
);
}
}
fn advance_card_anims(
mut commands: Commands,
time: Res<Time>,
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
) {
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;
}
anim.elapsed += dt;
let t = (anim.elapsed / anim.duration).min(1.0);
transform.translation = anim.start.lerp(anim.target, t);
if t >= 1.0 {
commands.entity(entity).remove::<CardAnim>();
}
}
}
fn handle_win_cascade(
mut commands: Commands,
mut events: EventReader<GameWonEvent>,
cards: Query<(Entity, &Transform), With<CardEntity>>,
layout: Option<Res<LayoutResource>>,
) {
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),
];
spawn_toast(&mut commands, "You Win!".to_string(), WIN_TOAST_SECS);
for (i, (entity, transform)) in cards.iter().enumerate() {
commands.entity(entity).insert(CardAnim {
start: transform.translation,
target: targets[i % 8],
elapsed: 0.0,
duration: CASCADE_DURATION,
delay: i as f32 * CASCADE_STAGGER,
});
}
}
fn handle_achievement_toast(
mut commands: Commands,
mut events: EventReader<AchievementUnlockedEvent>,
) {
for ev in events.read() {
spawn_toast(
&mut commands,
format!("Achievement: {}", ev.0),
ACHIEVEMENT_TOAST_SECS,
);
}
}
fn tick_toasts(
mut commands: Commands,
time: Res<Time>,
mut toasts: Query<(Entity, &mut ToastTimer)>,
) {
let dt = time.delta_secs();
for (entity, mut timer) in &mut toasts {
timer.0 -= dt;
if timer.0 <= 0.0 {
commands.entity(entity).despawn_recursive();
}
}
}
fn spawn_toast(commands: &mut Commands, message: String, duration_secs: f32) {
commands
.spawn((
ToastOverlay,
ToastTimer(duration_secs),
Node {
position_type: PositionType::Absolute,
left: Val::Percent(25.0),
top: Val::Percent(42.0),
width: Val::Percent(50.0),
padding: UiRect::axes(Val::Px(16.0), Val::Px(10.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.72)),
))
.with_children(|b| {
b.spawn((
Text::new(message),
TextFont {
font_size: 32.0,
..default()
},
TextColor(Color::srgb(1.0, 0.87, 0.0)),
));
});
}
#[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 card_anim_at_half_elapsed_reaches_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
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).abs() < 1e-3, "expected midpoint x=50, 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 win_cascade_adds_anim_to_all_52_cards() {
let mut app = app_with_anim();
let before = app
.world_mut()
.query::<&CardAnim>()
.iter(app.world())
.count();
assert_eq!(before, 0, "no animations before win");
app.world_mut()
.send_event(GameWonEvent { score: 500, time_seconds: 60 });
app.update();
let after = app
.world_mut()
.query::<&CardAnim>()
.iter(app.world())
.count();
assert_eq!(after, 52, "all 52 cards should have cascade animations");
}
}
+41 -17
View File
@@ -19,6 +19,7 @@ use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use crate::animation_plugin::{CardAnim, SLIDE_SECS};
use crate::events::StateChangedEvent;
use crate::game_plugin::GameMutation;
use crate::layout::{Layout, LayoutResource};
@@ -68,7 +69,7 @@ fn sync_cards_startup(
commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
entities: Query<(Entity, &CardEntity)>,
entities: Query<(Entity, &CardEntity, &Transform)>,
) {
if let Some(layout) = layout {
sync_cards(commands, &game.0, &layout.0, &entities);
@@ -80,7 +81,7 @@ fn sync_cards_on_change(
commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
entities: Query<(Entity, &CardEntity)>,
entities: Query<(Entity, &CardEntity, &Transform)>,
) {
if events.read().next().is_none() {
return;
@@ -94,20 +95,20 @@ fn sync_cards(
mut commands: Commands,
game: &GameState,
layout: &Layout,
entities: &Query<(Entity, &CardEntity)>,
entities: &Query<(Entity, &CardEntity, &Transform)>,
) {
let positions = card_positions(game, layout);
// Map card_id -> Entity for in-place updates.
let mut existing: HashMap<u32, Entity> = HashMap::new();
for (entity, marker) in entities.iter() {
existing.insert(marker.card_id, entity);
// Map card_id -> (Entity, current_translation) for in-place updates.
let mut existing: HashMap<u32, (Entity, Vec3)> = HashMap::new();
for (entity, marker, transform) in entities.iter() {
existing.insert(marker.card_id, (entity, transform.translation));
}
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
// Despawn any entity whose card is no longer tracked.
for (card_id, entity) in &existing {
for (card_id, (entity, _)) in &existing {
if !live_ids.contains(card_id) {
commands.entity(*entity).despawn_recursive();
}
@@ -116,7 +117,9 @@ fn sync_cards(
// For each card in the current state: spawn or update its entity.
for (card, position, z) in positions {
match existing.get(&card.id) {
Some(&entity) => update_card_entity(&mut commands, entity, &card, position, z, layout),
Some(&(entity, cur)) => {
update_card_entity(&mut commands, entity, &card, position, z, layout, cur)
}
None => spawn_card_entity(&mut commands, &card, position, z, layout),
}
}
@@ -206,6 +209,7 @@ fn update_card_entity(
pos: Vec2,
z: f32,
layout: &Layout,
cur: Vec3,
) {
let body_colour = if card.face_up {
CARD_FACE_COLOUR
@@ -213,14 +217,34 @@ fn update_card_entity(
CARD_BACK_COLOUR
};
commands.entity(entity).insert((
Sprite {
color: body_colour,
custom_size: Some(layout.card_size),
..default()
},
Transform::from_xyz(pos.x, pos.y, z),
));
let target = Vec3::new(pos.x, pos.y, z);
// Always refresh the visual appearance.
commands.entity(entity).insert(Sprite {
color: body_colour,
custom_size: Some(layout.card_size),
..default()
});
// Slide to the new position when it differs meaningfully; snap otherwise.
if (cur.truncate() - target.truncate()).length() > 1.0 {
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
commands
.entity(entity)
.insert(Transform::from_translation(start))
.insert(CardAnim {
start,
target,
elapsed: 0.0,
duration: SLIDE_SECS,
delay: 0.0,
});
} else {
commands
.entity(entity)
.remove::<CardAnim>()
.insert(Transform::from_xyz(pos.x, pos.y, z));
}
// Despawn the old label child and respawn a fresh one, so rank/suit/
// colour/visibility all stay in sync with the card's current state.
+6
View File
@@ -41,3 +41,9 @@ pub struct GameWonEvent {
/// Fired when a card's face-up state changes during gameplay.
#[derive(Event, Debug, Clone, Copy)]
pub struct CardFlippedEvent(pub u32);
/// Achievement unlocked notification — name of the achievement.
///
/// Uses `String` as a placeholder; replaced with `AchievementRecord` in Phase 5.
#[derive(Event, Debug, Clone)]
pub struct AchievementUnlockedEvent(pub String);
+1
View File
@@ -37,6 +37,7 @@ impl Plugin for GamePlugin {
.add_event::<StateChangedEvent>()
.add_event::<GameWonEvent>()
.add_event::<crate::events::CardFlippedEvent>()
.add_event::<crate::events::AchievementUnlockedEvent>()
.add_systems(
Update,
(
+6 -2
View File
@@ -511,8 +511,12 @@ mod tests {
});
let layout = compute_layout(Vec2::new(1280.0, 800.0));
// Click the middle card (Queen at stack index 1).
let pos = card_position(&game, &layout, PileType::Tableau(0), 1);
// The Queen's geometric center (index 1) is inside the Jack's bounding box
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
// Queen we click in her visible strip: the 0.25h band above the Jack's top
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
let queen_center = card_position(&game, &layout, PileType::Tableau(0), 1);
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
assert_eq!(pile, PileType::Tableau(0));
assert_eq!(start, 1);
+4 -6
View File
@@ -1,9 +1,6 @@
//! Bevy integration layer for Solitaire Quest.
//!
//! Currently exposes `GamePlugin` plus the resources and events it owns.
//! Additional plugins (`TablePlugin`, `CardPlugin`, `InputPlugin`,
//! `AnimationPlugin`, etc.) land in later sub-phases of Phase 3.
pub mod animation_plugin;
pub mod card_plugin;
pub mod events;
pub mod game_plugin;
@@ -12,10 +9,11 @@ pub mod layout;
pub mod resources;
pub mod table_plugin;
pub use animation_plugin::{AnimationPlugin, CardAnim};
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
pub use events::{
CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent,
StateChangedEvent, UndoRequestEvent,
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent,
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
};
pub use game_plugin::{GameMutation, GamePlugin};
pub use input_plugin::InputPlugin;