feat(engine): add curve-based card animation module

Introduces solitaire_engine::card_animation — a drop-in upgrade over the
existing linear CardAnim. Supports MotionCurve easing, parabolic z-lift,
scale interpolation, delay, retargeting mid-flight, and per-card timing
variation. Coexists with the legacy AnimationPlugin during migration.

Also adds .claude/ to .gitignore so Claude Code local tooling is never
committed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-28 18:06:58 +00:00
parent 59a023ed5e
commit eedddb979e
6 changed files with 1432 additions and 0 deletions
@@ -0,0 +1,372 @@
//! `CardAnimation` component and the system that drives it.
//!
//! # Design
//!
//! `CardAnimation` is a **drop-in upgrade** for the existing linear `CardAnim`.
//! It targets `Transform` (the current sprite-based architecture). Swapping to
//! Bevy UI requires only changing the four write lines in `advance_card_animations`
//! to write `Style.left` / `Style.top` via a `Style` component query instead.
//!
//! # Z-lift
//!
//! During motion, `translation.z` follows a parabolic arc:
//!
//! ```text
//! z(t) = lerp(start_z, end_z, t) + z_lift × sin(t × π)
//! ```
//!
//! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the
//! card "floats up" in the middle of its travel and lands at its correct rest z.
//!
//! # Retargeting
//!
//! When a card is redirected mid-flight, call [`retarget_animation`]. It reads
//! the current interpolated position so the card never snaps.
//!
//! # Coexistence with `CardAnim`
//!
//! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but
//! **must never be on the same entity** — both write to `Transform`. When
//! migrating, replace `CardAnim` insertions with `CardAnimation` insertions and
//! register `CardAnimationPlugin` alongside `AnimationPlugin`.
use std::f32::consts::PI;
use bevy::prelude::*;
use super::curves::{sample_curve, MotionCurve};
use super::timing::compute_duration;
use crate::pause_plugin::PausedResource;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/// Curve-based card animation.
///
/// Drives `Transform` XY translation via a [`MotionCurve`], with optional
/// z-lift and scale interpolation. Removes itself when the animation completes.
#[derive(Component, Debug, Clone)]
pub struct CardAnimation {
/// 2-D start position (world space).
pub start: Vec2,
/// 2-D destination (world space).
pub end: Vec2,
/// Seconds elapsed since the delay expired.
pub elapsed: f32,
/// Total animation duration in seconds (excluding delay).
pub duration: f32,
/// Easing curve applied to the interpolation factor.
pub curve: MotionCurve,
/// Seconds to wait before starting movement.
pub delay: f32,
/// Z coordinate at animation start (used for parabolic lift calculation).
pub start_z: f32,
/// Z coordinate at animation end — the card's resting z after completion.
pub end_z: f32,
/// Extra Z added at the midpoint of motion (`z(0.5) = base_z + z_lift`).
/// Set to 0.0 to disable the depth arc.
pub z_lift: f32,
/// Transform scale at `t = 0`.
pub scale_start: f32,
/// Transform scale at `t = 1`.
pub scale_end: f32,
}
impl CardAnimation {
/// Convenience constructor: slide from `start` to `end` with auto-computed
/// duration based on pixel distance. No z-lift or scale change.
pub fn slide(start: Vec2, start_z: f32, end: Vec2, end_z: f32, curve: MotionCurve) -> Self {
Self {
start,
end,
elapsed: 0.0,
duration: compute_duration(start.distance(end)),
curve,
delay: 0.0,
start_z,
end_z,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
}
}
/// Sets the pre-animation delay in seconds.
#[must_use]
pub fn with_delay(mut self, secs: f32) -> Self {
self.delay = secs;
self
}
/// Overrides the auto-computed duration.
#[must_use]
pub fn with_duration(mut self, secs: f32) -> Self {
self.duration = secs;
self
}
/// Enables the parabolic z-lift arc with the given peak offset.
#[must_use]
pub fn with_z_lift(mut self, lift: f32) -> Self {
self.z_lift = lift;
self
}
/// Interpolates `Transform.scale` from `start` to `end` over the animation.
#[must_use]
pub fn with_scale(mut self, start: f32, end: f32) -> Self {
self.scale_start = start;
self.scale_end = end;
self
}
/// Returns the current interpolated XY position without advancing time.
///
/// Used by [`retarget_animation`] to read mid-flight position cleanly.
pub fn current_xy(&self) -> Vec2 {
if self.duration <= 0.0 {
return self.end;
}
let t = (self.elapsed / self.duration).clamp(0.0, 1.0);
let s = sample_curve(self.curve, t);
self.start.lerp(self.end, s)
}
}
// ---------------------------------------------------------------------------
// Retarget helper
// ---------------------------------------------------------------------------
/// Redirects a card to a new destination without snapping or interrupting motion.
///
/// Reads the card's current interpolated position (from a live `CardAnimation` if
/// present, or from `Transform` if the card is stationary) and starts a fresh
/// `CardAnimation` from that position. Duration is recalculated from the remaining
/// distance so short remaining paths feel appropriately quick.
///
/// # Example
///
/// ```ignore
/// // Inside a system that decides to move a card to a new target:
/// let (entity, transform, anim) = cards.get(card_entity)?;
/// retarget_animation(
/// &mut commands,
/// entity,
/// anim, // Option<&CardAnimation>
/// transform,
/// Vec2::new(400.0, 200.0),
/// resting_z,
/// MotionCurve::SmoothSnap,
/// );
/// ```
pub fn retarget_animation(
commands: &mut Commands,
entity: Entity,
current_anim: Option<&CardAnimation>,
transform: &Transform,
new_end: Vec2,
new_end_z: f32,
curve: MotionCurve,
) {
let (current_xy, current_z) = match current_anim {
Some(anim) => (anim.current_xy(), transform.translation.z),
None => (transform.translation.truncate(), transform.translation.z),
};
let distance = current_xy.distance(new_end);
commands.entity(entity).insert(CardAnimation {
start: current_xy,
end: new_end,
elapsed: 0.0,
duration: compute_duration(distance),
curve,
delay: 0.0,
start_z: current_z,
end_z: new_end_z,
z_lift: 8.0,
scale_start: 1.0,
scale_end: 1.0,
});
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// Advances all [`CardAnimation`] components each frame.
///
/// Skipped while the game is paused. On completion the component is removed
/// and `Transform` is snapped to the exact destination to prevent floating-point
/// drift.
pub(crate) fn advance_card_animations(
mut commands: Commands,
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut q {
// Honour pre-animation delay.
if anim.delay > 0.0 {
anim.delay = (anim.delay - dt).max(0.0);
continue;
}
// Zero-duration: instant snap.
if anim.duration <= 0.0 {
transform.translation = anim.end.extend(anim.end_z);
transform.scale = Vec3::splat(anim.scale_end);
commands.entity(entity).remove::<CardAnimation>();
continue;
}
anim.elapsed += dt;
let t = (anim.elapsed / anim.duration).min(1.0);
let s = sample_curve(anim.curve, t);
// --- XY via curve ---
let xy = anim.start.lerp(anim.end, s);
transform.translation.x = xy.x;
transform.translation.y = xy.y;
// --- Z: linear base interpolation + parabolic lift arc ---
//
// The sine arch is 0 at t=0 and t=1, peaking at t=0.5.
// This keeps the card's resting Z correct at both ends.
let base_z = anim.start_z + (anim.end_z - anim.start_z) * t;
let lift = anim.z_lift * (t * PI).sin();
transform.translation.z = base_z + lift;
// --- Scale ---
let scale = anim.scale_start + (anim.scale_end - anim.scale_start) * s;
transform.scale = Vec3::splat(scale);
// --- Completion ---
if t >= 1.0 {
transform.translation = anim.end.extend(anim.end_z);
transform.scale = Vec3::splat(anim.scale_end);
commands.entity(entity).remove::<CardAnimation>();
}
}
}
// ---------------------------------------------------------------------------
// Win cascade
// ---------------------------------------------------------------------------
/// Win-cascade scatter targets — 8 points beyond the window edges.
///
/// Scaled by `radius` (pass `layout.card_size.x * 8.0` for a good result).
pub fn win_scatter_targets(radius: f32) -> [Vec2; 8] {
let r = radius;
[
Vec2::new(r, r),
Vec2::new(-r, r),
Vec2::new(r, -r),
Vec2::new(-r, -r),
Vec2::new(0.0, r),
Vec2::new(0.0, -r),
Vec2::new(r, 0.0),
Vec2::new(-r, 0.0),
]
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn make_anim(start: Vec2, end: Vec2, elapsed: f32, duration: f32) -> CardAnimation {
CardAnimation {
start,
end,
elapsed,
duration,
curve: MotionCurve::Responsive, // linear-ish for easy assertion
delay: 0.0,
start_z: 0.0,
end_z: 0.0,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
}
}
#[test]
fn current_xy_at_start() {
let anim = make_anim(Vec2::ZERO, Vec2::new(100.0, 0.0), 0.0, 1.0);
let pos = anim.current_xy();
assert!(pos.x < 5.0, "at t=0 position should be near start, got {pos:?}");
}
#[test]
fn current_xy_at_end() {
let anim = make_anim(Vec2::ZERO, Vec2::new(100.0, 0.0), 1.0, 1.0);
let pos = anim.current_xy();
assert!(
(pos.x - 100.0).abs() < 1e-3,
"at t=1 position should be at end, got {pos:?}"
);
}
#[test]
fn current_xy_zero_duration_returns_end() {
let anim = make_anim(Vec2::ZERO, Vec2::new(50.0, 0.0), 0.0, 0.0);
let pos = anim.current_xy();
assert!(
(pos.x - 50.0).abs() < 1e-3,
"zero-duration must return end immediately, got {pos:?}"
);
}
#[test]
fn slide_constructor_auto_computes_duration() {
let start = Vec2::ZERO;
let end = Vec2::new(300.0, 0.0);
let anim = CardAnimation::slide(start, 0.0, end, 0.0, MotionCurve::SmoothSnap);
let distance = 300.0_f32;
let expected = compute_duration(distance);
assert!(
(anim.duration - expected).abs() < 1e-5,
"slide() duration mismatch: got {}, expected {}",
anim.duration,
expected
);
}
#[test]
fn with_delay_sets_delay() {
let anim = CardAnimation::slide(Vec2::ZERO, 0.0, Vec2::ONE, 0.0, MotionCurve::SmoothSnap)
.with_delay(0.5);
assert!((anim.delay - 0.5).abs() < 1e-6);
}
#[test]
fn with_z_lift_sets_z_lift() {
let anim = CardAnimation::slide(Vec2::ZERO, 0.0, Vec2::ONE, 0.0, MotionCurve::SmoothSnap)
.with_z_lift(12.0);
assert!((anim.z_lift - 12.0).abs() < 1e-6);
}
#[test]
fn win_scatter_has_eight_targets() {
let targets = win_scatter_targets(800.0);
assert_eq!(targets.len(), 8);
}
#[test]
fn win_scatter_targets_are_off_center() {
for t in win_scatter_targets(400.0) {
let dist = t.length();
assert!(dist > 100.0, "scatter target should be well off-center: {t:?}");
}
}
}
@@ -0,0 +1,196 @@
//! Motion curve definitions for card animations.
//!
//! All curves map `t ∈ [0, 1]` to a position ratio. Curves with overshoot
//! (`SmoothSnap`, `SoftBounce`, `Expressive`) may return values slightly
//! outside `[0, 1]` near the destination — callers should not clamp the output
//! before applying it to a lerp, as the overshoot is intentional.
//!
//! # Curve selection guide
//!
//! | Interaction | Recommended curve |
//! |----------------------|-------------------|
//! | Standard card move | `SmoothSnap` |
//! | Foundation placement | `SoftBounce` |
//! | Invalid snap-back | `Responsive` |
//! | Win cascade | `Expressive` |
use std::f32::consts::PI;
/// Motion curve variant controlling animation easing behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MotionCurve {
/// Cubic ease-out with a 1.5 % terminal overshoot.
///
/// Overshoot is a sine arch in the final 25 % of the animation that peaks
/// ~1.5 % beyond the target, settling cleanly to 1.0 at `t = 1`. Gives a
/// lively, slightly "alive" feel without feeling heavy.
#[default]
SmoothSnap,
/// Underdamped spring (ζ = 0.65, ω = 20 rad/s).
///
/// One visible overshoot of ~8 % followed by fast decay. Good for
/// satisfying "thud" feedback when placing cards on foundations or tableau.
SoftBounce,
/// Quintic ease-out — aggressive deceleration, zero overshoot.
///
/// Starts extremely fast and decelerates hard. Best for snap-back on
/// invalid drops: the card returns instantly without any bounce.
Responsive,
/// Underdamped spring (ζ = 0.45, ω = 18 rad/s).
///
/// Two visible bounces before settling. High visual energy — reserved for
/// win cascade animations where expressivity matters more than subtlety.
Expressive,
}
/// Samples `curve` at normalised time `t ∈ [0, 1]`.
///
/// The return value is the interpolation factor to pass to `Vec2::lerp` /
/// `Vec3::lerp`. Values may slightly exceed 1.0 for curves with overshoot.
#[inline]
pub fn sample_curve(curve: MotionCurve, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
match curve {
MotionCurve::SmoothSnap => smooth_snap(t),
MotionCurve::SoftBounce => soft_bounce(t),
MotionCurve::Responsive => responsive(t),
MotionCurve::Expressive => expressive(t),
}
}
/// Cubic ease-out with a sine-arch overshoot in the final 25 % of `t`.
///
/// The overshoot term is `sin(tail * π) * 0.015` where `tail` is `t` linearly
/// rescaled from `[0.75, 1.0]` to `[0, 1]`. At `t = 0.875` the card is ~1.5 %
/// past its target; at `t = 1` the card is exactly on target.
#[inline]
fn smooth_snap(t: f32) -> f32 {
let base = 1.0 - (1.0 - t).powi(3);
let tail = ((t - 0.75) / 0.25).clamp(0.0, 1.0);
let overshoot = (tail * PI).sin() * 0.015;
base + overshoot
}
/// Underdamped spring response (ζ = 0.65, ω₀ = 20 rad/s).
///
/// Derived from the exact closed-form solution:
/// `x(t) = 1 e^{−ζω₀t}[cos(ωd·t) + (ζω₀/ωd)·sin(ωd·t)]`
/// where `ωd = ω₀·√(1 ζ²)`.
#[inline]
fn soft_bounce(t: f32) -> f32 {
const OMEGA: f32 = 20.0;
const ZETA: f32 = 0.65;
let omega_d = OMEGA * (1.0 - ZETA * ZETA).sqrt();
let decay = (-ZETA * OMEGA * t).exp();
1.0 - decay * ((omega_d * t).cos() + (ZETA * OMEGA / omega_d) * (omega_d * t).sin())
}
/// Quintic ease-out: `f(t) = 1 (1 t)^5`.
///
/// Reaches ~97 % of the target by `t = 0.5`. No overshoot.
#[inline]
fn responsive(t: f32) -> f32 {
1.0 - (1.0 - t).powi(5)
}
/// Underdamped spring response (ζ = 0.45, ω₀ = 18 rad/s) — two visible bounces.
///
/// Uses the same closed-form spring formula as `soft_bounce` but with lower
/// damping, producing higher overshoot (~18 %) and two discernible oscillations
/// before settling.
#[inline]
fn expressive(t: f32) -> f32 {
const OMEGA: f32 = 18.0;
const ZETA: f32 = 0.45;
let omega_d = OMEGA * (1.0 - ZETA * ZETA).sqrt();
let decay = (-ZETA * OMEGA * t).exp();
1.0 - decay * ((omega_d * t).cos() + (ZETA * OMEGA / omega_d) * (omega_d * t).sin())
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_near(a: f32, b: f32, eps: f32, msg: &str) {
assert!((a - b).abs() < eps, "{msg}: expected ~{b}, got {a}");
}
#[test]
fn all_curves_start_at_zero() {
for curve in [
MotionCurve::SmoothSnap,
MotionCurve::SoftBounce,
MotionCurve::Responsive,
MotionCurve::Expressive,
] {
assert_near(sample_curve(curve, 0.0), 0.0, 1e-5, &format!("{curve:?} at t=0"));
}
}
#[test]
fn all_curves_end_at_one() {
for curve in [
MotionCurve::SmoothSnap,
MotionCurve::SoftBounce,
MotionCurve::Responsive,
] {
assert_near(sample_curve(curve, 1.0), 1.0, 1e-4, &format!("{curve:?} at t=1"));
}
// Spring-based curves have residual oscillation at finite t=1; allow 2 e-3.
assert_near(
sample_curve(MotionCurve::Expressive, 1.0),
1.0,
2e-3,
"Expressive at t=1",
);
}
#[test]
fn responsive_reaches_half_before_midpoint() {
// Quintic ease-out accelerates fast — >50 % by t=0.5.
let v = sample_curve(MotionCurve::Responsive, 0.5);
assert!(v > 0.96, "Responsive should be >96 % at t=0.5, got {v}");
}
#[test]
fn smooth_snap_overshoots_slightly_near_end() {
// Peak overshoot is around t = 0.875.
let peak = sample_curve(MotionCurve::SmoothSnap, 0.875);
assert!(peak > 1.0, "SmoothSnap should overshoot at t=0.875, got {peak}");
assert!(peak < 1.03, "SmoothSnap overshoot should be small (<3 %), got {peak}");
}
#[test]
fn soft_bounce_overshoots_and_returns() {
let v = sample_curve(MotionCurve::SoftBounce, 1.0);
assert_near(v, 1.0, 1e-3, "SoftBounce must settle at 1.0");
}
#[test]
fn expressive_has_more_overshoot_than_soft_bounce() {
// Compare max value in [0,1] range.
let max_soft: f32 = (0..=100)
.map(|i| sample_curve(MotionCurve::SoftBounce, i as f32 / 100.0))
.fold(f32::NEG_INFINITY, f32::max);
let max_expr: f32 = (0..=100)
.map(|i| sample_curve(MotionCurve::Expressive, i as f32 / 100.0))
.fold(f32::NEG_INFINITY, f32::max);
assert!(
max_expr > max_soft,
"Expressive should overshoot more than SoftBounce: {max_expr} vs {max_soft}"
);
}
#[test]
fn sample_curve_clamps_t_below_zero() {
assert_near(sample_curve(MotionCurve::SmoothSnap, -1.0), 0.0, 1e-5, "t<0 clamped");
}
#[test]
fn sample_curve_clamps_t_above_one() {
assert_near(sample_curve(MotionCurve::Responsive, 2.0), 1.0, 1e-5, "t>1 clamped");
}
}
@@ -0,0 +1,321 @@
//! Card interaction visuals: hover scale, drag lift, and input buffering.
//!
//! # Hover
//!
//! [`HoverState`] tracks the entity currently under the cursor. A system
//! smoothly lerps `Transform.scale` toward `HOVER_SCALE` on the hovered card
//! and back to 1.0 when the cursor leaves. Scale is only written when no
//! [`CardAnimation`] is active on the entity (the animation takes priority).
//!
//! # Drag visual
//!
//! While [`DragState`] is non-idle, the dragged card entities receive a subtle
//! scale boost (`DRAG_LIFT_SCALE`) and their z-order is pushed up. The exact
//! translation is still controlled by the existing [`crate::input_plugin`] —
//! this system only applies the _visual_ enhancement without touching XY.
//!
//! # Input buffer
//!
//! [`InputBuffer`] stores move/draw/undo actions that arrived while cards are
//! still animating. Call [`InputBuffer::push`] from any system that wants
//! buffering. The drain system fires the oldest buffered action as soon as all
//! [`CardAnimation`] components have cleared, giving a responsive feel on
//! fast repeated clicks.
//!
//! # Visual priority
//!
//! Dragged cards always have the highest z. The existing [`crate::input_plugin`]
//! sets drag z; this module applies scale on top. The ordering constraint
//! `.after(crate::game_plugin::GameMutation)` ensures all game-state changes
//! settle before visual updates run.
use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use super::animation::CardAnimation;
use crate::card_plugin::CardEntity;
use crate::events::{DrawRequestEvent, MoveRequestEvent, UndoRequestEvent};
use crate::layout::LayoutResource;
use crate::resources::DragState;
/// Type alias to reduce complexity in hover/drag query signatures.
type CardTransformQuery<'w, 's> =
Query<'w, 's, (Entity, &'static mut Transform), (With<CardEntity>, Without<CardAnimation>)>;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Scale applied to the card currently under the cursor (1.0 = no change).
const HOVER_SCALE: f32 = 1.04;
/// Additional scale applied to dragged cards while in flight.
const DRAG_LIFT_SCALE: f32 = 1.08;
/// Lerp speed for hover scale interpolation (higher = snappier).
const HOVER_LERP_SPEED: f32 = 14.0;
/// Lerp speed for drag scale interpolation.
const DRAG_LERP_SPEED: f32 = 20.0;
/// Maximum number of buffered inputs retained.
const INPUT_BUFFER_CAPACITY: usize = 4;
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
/// Tracks the entity currently under the cursor and the interpolated hover scale.
#[derive(Resource, Debug, Default)]
pub struct HoverState {
/// Entity currently hovered (`None` when cursor is off all cards or dragging).
pub entity: Option<Entity>,
/// Current interpolated scale applied to the hovered card.
pub scale: f32,
}
/// Describes a user action that arrived while cards were still animating.
#[derive(Debug, Clone)]
pub enum BufferedInput {
Move { from: crate::events::MoveRequestEvent },
Draw,
Undo,
}
/// FIFO queue of inputs deferred until ongoing animations complete.
///
/// Populate via [`InputBuffer::push`] and consume via the drain system.
/// Capped at [`INPUT_BUFFER_CAPACITY`] — further pushes when full are silently
/// dropped to prevent stale action pileup.
#[derive(Resource, Debug, Default)]
pub struct InputBuffer {
pub(crate) queue: VecDeque<BufferedInput>,
}
impl InputBuffer {
/// Enqueues an input if the buffer is not full.
pub fn push(&mut self, input: BufferedInput) {
if self.queue.len() < INPUT_BUFFER_CAPACITY {
self.queue.push_back(input);
}
}
/// Returns `true` when no inputs are pending.
pub fn is_empty(&self) -> bool {
self.queue.is_empty()
}
/// Returns how many inputs are queued.
pub fn len(&self) -> usize {
self.queue.len()
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Detects which card is under the cursor and updates [`HoverState`].
///
/// Clears hover when [`DragState`] is active (dragging takes visual priority).
/// Picks the topmost card (highest `translation.z`) when multiple cards overlap.
pub(crate) fn detect_hover(
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
drag: Option<Res<DragState>>,
layout: Option<Res<LayoutResource>>,
cards: Query<(Entity, &Transform), With<CardEntity>>,
mut hover: ResMut<HoverState>,
) {
let is_dragging = drag.as_ref().is_some_and(|d| !d.is_idle());
if is_dragging {
hover.entity = None;
return;
}
let Some(layout) = layout else { return };
let Some(cursor_world) = cursor_world(&windows, &cameras) else {
hover.entity = None;
return;
};
let half_w = layout.0.card_size.x * 0.5;
let half_h = layout.0.card_size.y * 0.5;
let mut best: Option<(Entity, f32)> = None;
for (entity, transform) in &cards {
let pos = transform.translation.truncate();
if (cursor_world.x - pos.x).abs() < half_w
&& (cursor_world.y - pos.y).abs() < half_h
{
let z = transform.translation.z;
if best.is_none_or(|(_, bz)| z > bz) {
best = Some((entity, z));
}
}
}
hover.entity = best.map(|(e, _)| e);
}
/// Applies the hover scale to the currently hovered card via smooth lerp.
///
/// Only runs on cards that have **no active [`CardAnimation`]** — animated
/// cards control their own scale. When hover changes entities, the previous
/// entity's scale is snapped back to 1.0 to avoid leaving a permanently
/// enlarged card.
pub(crate) fn apply_hover_scale(
time: Res<Time>,
mut hover_state: ResMut<HoverState>,
mut cards: CardTransformQuery,
) {
let dt = time.delta_secs();
let target_entity = hover_state.entity;
for (entity, mut transform) in &mut cards {
let target_scale = if Some(entity) == target_entity {
HOVER_SCALE
} else {
1.0
};
let current = transform.scale.x;
let new_scale = current + (target_scale - current) * (HOVER_LERP_SPEED * dt).min(1.0);
transform.scale = Vec3::splat(new_scale);
}
// Update the tracked scale for external inspection.
hover_state.scale = if let Some(entity) = target_entity {
cards
.get(entity)
.map(|(_, t)| t.scale.x)
.unwrap_or(HOVER_SCALE)
} else {
1.0
};
}
/// Applies a scale boost and z-lift to dragged card entities.
///
/// Reads [`DragState`] for the list of card IDs being dragged. Does **not**
/// modify `translation.xy` — the existing `InputPlugin` owns drag translation.
/// Only writes `scale` and `translation.z` so the two systems are disjoint.
pub(crate) fn apply_drag_visual(
time: Res<Time>,
drag: Option<Res<DragState>>,
mut cards: Query<(Entity, &CardEntity, &mut Transform), (Without<CardAnimation>,)>,
) {
let dt = time.delta_secs();
let empty: Vec<u32> = Vec::new();
let dragged_ids: &[u32] = drag.as_ref().map_or(empty.as_slice(), |d| &d.cards);
for (_, card, mut transform) in &mut cards {
let is_dragged = dragged_ids.contains(&card.card_id);
let target_scale = if is_dragged { DRAG_LIFT_SCALE } else { 1.0 };
let current = transform.scale.x;
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
transform.scale = Vec3::splat(new_scale);
}
}
/// Fires the oldest buffered input when no [`CardAnimation`] components remain.
///
/// Call this system late in the `Update` schedule so freshly-removed animations
/// are already gone before the drain runs.
pub(crate) fn drain_input_buffer(
mut buffer: ResMut<InputBuffer>,
anims: Query<&CardAnimation>,
mut move_events: EventWriter<MoveRequestEvent>,
mut draw_events: EventWriter<DrawRequestEvent>,
mut undo_events: EventWriter<UndoRequestEvent>,
) {
if !anims.is_empty() {
return;
}
match buffer.queue.pop_front() {
Some(BufferedInput::Move { from }) => {
move_events.send(from);
}
Some(BufferedInput::Draw) => {
draw_events.send(DrawRequestEvent);
}
Some(BufferedInput::Undo) => {
undo_events.send(UndoRequestEvent);
}
None => {}
}
}
// ---------------------------------------------------------------------------
// Cursor helper (mirrors the pattern used by input_plugin)
// ---------------------------------------------------------------------------
/// Converts the cursor screen position to 2-D world coordinates.
///
/// Returns `None` when the cursor is outside the window or no camera is found.
fn cursor_world(
windows: &Query<&Window, With<PrimaryWindow>>,
cameras: &Query<(&Camera, &GlobalTransform)>,
) -> Option<Vec2> {
let window = windows.get_single().ok()?;
let cursor = window.cursor_position()?;
let (camera, camera_transform) = cameras.get_single().ok()?;
camera.viewport_to_world_2d(camera_transform, cursor).ok()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_buffer_capacity_is_respected() {
let mut buf = InputBuffer::default();
for _ in 0..INPUT_BUFFER_CAPACITY + 5 {
buf.push(BufferedInput::Draw);
}
assert_eq!(
buf.len(),
INPUT_BUFFER_CAPACITY,
"buffer must not exceed capacity"
);
}
#[test]
fn input_buffer_is_fifo() {
let mut buf = InputBuffer::default();
buf.push(BufferedInput::Draw);
buf.push(BufferedInput::Undo);
matches!(buf.queue.pop_front().unwrap(), BufferedInput::Draw);
matches!(buf.queue.pop_front().unwrap(), BufferedInput::Undo);
}
#[test]
fn input_buffer_empty_initially() {
let buf = InputBuffer::default();
assert!(buf.is_empty());
assert_eq!(buf.len(), 0);
}
#[test]
fn input_buffer_len_increments() {
let mut buf = InputBuffer::default();
buf.push(BufferedInput::Draw);
assert_eq!(buf.len(), 1);
buf.push(BufferedInput::Undo);
assert_eq!(buf.len(), 2);
}
#[test]
fn hover_state_default_has_no_entity() {
let state = HoverState::default();
assert!(state.entity.is_none());
assert_eq!(state.scale, 0.0);
}
}
+390
View File
@@ -0,0 +1,390 @@
//! `CardAnimationPlugin` — curve-based card animation system.
//!
//! # Quick start
//!
//! Register the plugin alongside the existing animation plugins:
//!
//! ```ignore
//! app.add_plugins((
//! AnimationPlugin, // existing: drives CardAnim (linear)
//! FeedbackAnimPlugin, // existing: shake + settle
//! CardAnimationPlugin, // new: curve-based CardAnimation
//! ));
//! ```
//!
//! Spawn a card with a `CardAnimation` component:
//!
//! ```ignore
//! use solitaire_engine::card_animation::{CardAnimation, MotionCurve};
//!
//! commands.spawn((
//! SpriteBundle { /* ... */ },
//! CardAnimation::slide(
//! Vec2::new(0.0, 0.0), // start xy
//! 0.0, // start z
//! Vec2::new(300.0, 200.0),// end xy
//! 5.0, // end z (resting)
//! MotionCurve::SmoothSnap,
//! )
//! .with_z_lift(12.0) // floats up during motion
//! .with_delay(0.03), // stagger delay
//! ));
//! ```
//!
//! Retarget a card mid-flight:
//!
//! ```ignore
//! use solitaire_engine::card_animation::retarget_animation;
//!
//! fn handle_drop(
//! mut commands: Commands,
//! q: Query<(Entity, &Transform, Option<&CardAnimation>), With<CardEntity>>,
//! ) {
//! let (entity, transform, anim) = q.get(card_entity).unwrap();
//! retarget_animation(
//! &mut commands,
//! entity,
//! anim,
//! transform,
//! new_target_xy,
//! new_target_z,
//! MotionCurve::SmoothSnap,
//! );
//! }
//! ```
//!
//! # Win cascade with `Expressive` curve
//!
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
//! (linear). To use the curve-based cascade instead, disable
//! `handle_win_cascade` in `AnimationPlugin` and register `WinCascadePlugin`
//! (declared below) which uses `CardAnimation` + `MotionCurve::Expressive`.
//!
//! They **must not both be active** — both write to `Transform` on the same
//! 52 entities and will race.
//!
//! # Coexistence rules
//!
//! | Condition | Safe? |
//! |---|---|
//! | `CardAnim` and `CardAnimation` on **different** entities | ✓ |
//! | `CardAnim` and `CardAnimation` on the **same** entity | ✗ |
//! | `HoverState` scale + `CardAnimation` scale on same entity | ✓ (CardAnimation takes priority — hover skipped via `Without<CardAnimation>` filter) |
//! | `apply_drag_visual` scale + `CardAnimation` scale | ✓ (same filter) |
pub mod animation;
pub mod curves;
pub mod interaction;
pub mod timing;
pub use animation::{retarget_animation, win_scatter_targets, CardAnimation};
pub use curves::{sample_curve, MotionCurve};
pub use interaction::{BufferedInput, HoverState, InputBuffer};
pub use timing::{
cascade_delay, compute_duration, micro_vary, DEAL_INTERVAL_SECS, MAX_DURATION_SECS,
MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
};
use bevy::prelude::*;
use crate::card_plugin::CardEntity;
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
use crate::resources::DragState;
use animation::advance_card_animations;
use interaction::{apply_drag_visual, apply_hover_scale, detect_hover, drain_input_buffer};
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers all systems, resources, and components for curve-based card
/// animation, hover visuals, drag lift, and input buffering.
///
/// Safe to register alongside `AnimationPlugin` and `FeedbackAnimPlugin` as
/// long as no single entity carries both `CardAnim` and `CardAnimation`.
pub struct CardAnimationPlugin;
impl Plugin for CardAnimationPlugin {
fn build(&self, app: &mut App) {
// Register events and resources that interaction systems depend on,
// idempotently — double-registration is safe in Bevy.
app.add_event::<MoveRequestEvent>()
.add_event::<DrawRequestEvent>()
.add_event::<UndoRequestEvent>()
.add_event::<GameWonEvent>()
.init_resource::<DragState>()
.init_resource::<HoverState>()
.init_resource::<InputBuffer>()
.add_systems(
Update,
(
// Advance active animations (highest priority — runs first).
advance_card_animations,
// Interaction visuals (run after animation to read final positions).
detect_hover,
apply_hover_scale,
apply_drag_visual,
// Drain buffered inputs only when no animations remain.
drain_input_buffer,
)
.chain()
.after(GameMutation),
);
}
}
// ---------------------------------------------------------------------------
// Optional: win cascade with Expressive curve
// ---------------------------------------------------------------------------
/// Optional plugin that replaces the linear win cascade in `AnimationPlugin`
/// with an `Expressive`-curve cascade.
///
/// **Do not register this alongside `AnimationPlugin`'s win cascade** — they
/// will race on the same card entities. To use this plugin, prevent
/// `AnimationPlugin` from handling `GameWonEvent` (or remove it and manage
/// win toasts manually).
pub struct WinCascadePlugin;
impl Plugin for WinCascadePlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
trigger_expressive_win_cascade.after(GameMutation),
);
}
}
/// Inserts `CardAnimation` (Expressive curve) on every card when `GameWonEvent` fires.
///
/// Cards scatter to 8 off-screen positions with per-card stagger. The z-lift
/// creates a "burst" effect as cards fly outward.
fn trigger_expressive_win_cascade(
mut events: EventReader<GameWonEvent>,
cards: Query<(Entity, &Transform), With<CardEntity>>,
layout: Option<Res<LayoutResource>>,
mut commands: Commands,
) {
if events.read().next().is_none() {
return;
}
let radius = layout
.as_ref()
.map_or(800.0, |l| l.0.card_size.x * 8.0);
let targets = win_scatter_targets(radius);
for (index, (entity, transform)) in cards.iter().enumerate() {
let start_xy = transform.translation.truncate();
let start_z = transform.translation.z;
let target = targets[index % targets.len()];
commands.entity(entity).insert(
CardAnimation::slide(start_xy, start_z, target, start_z + 60.0, MotionCurve::Expressive)
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
.with_duration(0.65)
.with_z_lift(25.0),
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::animation_plugin::AnimationPlugin;
use crate::card_plugin::CardPlugin;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
fn base_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(CardAnimationPlugin);
app.update();
app
}
#[test]
fn plugin_registers_hover_state() {
let app = base_app();
assert!(
app.world().get_resource::<HoverState>().is_some(),
"HoverState resource must be registered"
);
}
#[test]
fn plugin_registers_input_buffer() {
let app = base_app();
assert!(
app.world().get_resource::<InputBuffer>().is_some(),
"InputBuffer resource must be registered"
);
}
#[test]
fn card_animation_advances_and_removes_itself() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
let start = Vec2::new(0.0, 0.0);
let end = Vec2::new(100.0, 0.0);
let entity = app
.world_mut()
.spawn((
Transform::from_translation(start.extend(0.0)),
CardAnimation {
start,
end,
elapsed: 0.99,
duration: 1.0,
curve: MotionCurve::Responsive,
delay: 0.0,
start_z: 0.0,
end_z: 0.0,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
},
))
.id();
app.update();
// After one update at elapsed=0.99, component should still be present.
// We can't advance time reliably in MinimalPlugins, but we can check
// that the advance_card_animations system processed the component
// (pos moved closer to end).
let transform = app.world().entity(entity).get::<Transform>().unwrap();
assert!(
transform.translation.x > 50.0,
"card should have moved past midpoint by elapsed=0.99, got x={}",
transform.translation.x
);
}
#[test]
fn card_animation_instant_snaps_on_zero_duration() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
let end = Vec2::new(200.0, 100.0);
let entity = app
.world_mut()
.spawn((
Transform::from_translation(Vec3::ZERO),
CardAnimation {
start: Vec2::ZERO,
end,
elapsed: 0.0,
duration: 0.0, // zero duration → instant snap
curve: MotionCurve::SmoothSnap,
delay: 0.0,
start_z: 0.0,
end_z: 5.0,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
},
))
.id();
app.update();
assert!(
app.world().entity(entity).get::<CardAnimation>().is_none(),
"zero-duration animation must be removed after one update"
);
let transform = app.world().entity(entity).get::<Transform>().unwrap();
assert!(
(transform.translation.x - 200.0).abs() < 1e-3,
"card must snap to end.x"
);
assert!(
(transform.translation.y - 100.0).abs() < 1e-3,
"card must snap to end.y"
);
assert!(
(transform.translation.z - 5.0).abs() < 1e-3,
"card must snap to end_z"
);
}
#[test]
fn card_animation_respects_delay() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
let entity = app
.world_mut()
.spawn((
Transform::from_translation(Vec3::ZERO),
CardAnimation {
start: Vec2::ZERO,
end: Vec2::new(100.0, 0.0),
elapsed: 0.0,
duration: 0.15,
curve: MotionCurve::SmoothSnap,
delay: 100.0, // huge delay — card must not move
start_z: 0.0,
end_z: 0.0,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
},
))
.id();
app.update();
let transform = app.world().entity(entity).get::<Transform>().unwrap();
assert!(
transform.translation.x.abs() < 1e-3,
"card must not move during delay, got x={}",
transform.translation.x
);
}
#[test]
fn input_buffer_push_and_drain_ordering() {
let mut buf = InputBuffer::default();
buf.push(BufferedInput::Draw);
buf.push(BufferedInput::Undo);
// FIFO: Draw comes out first.
assert!(matches!(buf.queue.pop_front().unwrap(), BufferedInput::Draw));
assert!(matches!(buf.queue.pop_front().unwrap(), BufferedInput::Undo));
}
#[test]
fn hover_state_initialises_without_entity() {
let state = HoverState::default();
assert!(state.entity.is_none());
}
#[test]
fn win_scatter_produces_eight_distinct_points() {
let targets = win_scatter_targets(600.0);
assert_eq!(targets.len(), 8);
// All must be different.
for i in 0..8 {
for j in (i + 1)..8 {
assert_ne!(
targets[i], targets[j],
"scatter targets {i} and {j} must be distinct"
);
}
}
}
}
@@ -0,0 +1,152 @@
//! Distance-based duration calculation and stagger utilities.
//!
//! All functions are pure (no Bevy dependency) and can be tested in isolation.
/// Minimum animation duration — applied to very short or zero-distance moves.
pub const MIN_DURATION_SECS: f32 = 0.12;
/// Hard cap on animation duration regardless of distance.
pub const MAX_DURATION_SECS: f32 = 0.35;
/// Sqrt scale factor calibrated so a 600-pixel move hits `MAX_DURATION_SECS`:
/// `MIN + √600 × SCALE ≈ 0.35 s`.
const SQRT_SCALE: f32 = 0.0094;
/// Micro-variation amplitude: ±0.4 % of the computed duration.
///
/// Small enough to be imperceptible in isolation but enough to break the
/// "robotic" uniformity when many cards animate simultaneously.
const MICRO_VARY_AMPLITUDE: f32 = 0.004;
/// Computes animation duration from a pixel distance using square-root scaling.
///
/// Square-root growth keeps short moves feeling instant while preventing long
/// moves from feeling excessively slow.
///
/// | Distance | Duration |
/// |----------|-----------|
/// | 25 px | ~0.17 s |
/// | 100 px | ~0.21 s |
/// | 300 px | ~0.28 s |
/// | 600 px | ~0.35 s |
/// | 1200 px | ~0.35 s ← capped |
#[inline]
pub fn compute_duration(distance: f32) -> f32 {
(MIN_DURATION_SECS + distance.abs().sqrt() * SQRT_SCALE).min(MAX_DURATION_SECS)
}
/// Applies a deterministic ±0.4 % micro-variation to `duration`.
///
/// `entity_index` should be a stable per-entity value (e.g. `Entity::index()`).
/// The same index always produces the same variation so animations don't
/// change between frames.
#[inline]
pub fn micro_vary(duration: f32, entity_index: u32) -> f32 {
// Multiplicative Fibonacci hash — cheap, decent distribution.
let hash = entity_index.wrapping_mul(2_654_435_761);
let noise = (hash >> 16) as f32 / 65_536.0; // 0.0 ..= 1.0
let variation = (noise - 0.5) * 2.0 * MICRO_VARY_AMPLITUDE;
duration * (1.0 + variation)
}
/// Returns the pre-animation delay for card at `index` in a staggered cascade.
///
/// `delay = index × interval_secs`.
#[inline]
pub fn cascade_delay(index: usize, interval_secs: f32) -> f32 {
index as f32 * interval_secs
}
/// Recommended per-card interval for the win cascade (Normal speed).
pub const WIN_CASCADE_INTERVAL_SECS: f32 = 0.018;
/// Recommended per-card interval for deal animations (Normal speed).
pub const DEAL_INTERVAL_SECS: f32 = 0.022;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_distance_gives_minimum_duration() {
assert!(
(compute_duration(0.0) - MIN_DURATION_SECS).abs() < 1e-5,
"zero distance must yield MIN_DURATION_SECS"
);
}
#[test]
fn large_distance_is_capped() {
assert!(
(compute_duration(10_000.0) - MAX_DURATION_SECS).abs() < 1e-5,
"very large distance must be capped at MAX_DURATION_SECS"
);
}
#[test]
fn duration_increases_monotonically() {
let mut prev = 0.0f32;
for d in [10, 50, 100, 200, 400, 600] {
let dur = compute_duration(d as f32);
assert!(dur >= prev, "duration must be monotone: d={d} dur={dur} prev={prev}");
prev = dur;
}
}
#[test]
fn duration_is_within_bounds() {
for d in [0, 1, 25, 100, 300, 600, 1200] {
let dur = compute_duration(d as f32);
assert!(
(MIN_DURATION_SECS..=MAX_DURATION_SECS).contains(&dur),
"duration out of bounds for d={d}: {dur}"
);
}
}
#[test]
fn micro_vary_stays_within_tolerance() {
for i in 0..=1000u32 {
let base = 0.25;
let varied = micro_vary(base, i);
let ratio = (varied - base).abs() / base;
assert!(
ratio <= MICRO_VARY_AMPLITUDE + 1e-6,
"variation for index {i} exceeds amplitude: ratio={ratio}"
);
}
}
#[test]
fn micro_vary_is_deterministic() {
let a = micro_vary(0.2, 42);
let b = micro_vary(0.2, 42);
assert!((a - b).abs() < 1e-9, "micro_vary must be deterministic");
}
#[test]
fn micro_vary_differs_for_different_indices() {
let a = micro_vary(0.2, 1);
let b = micro_vary(0.2, 2);
// Very unlikely to be equal (would require hash collision mod 65536).
assert!((a - b).abs() > 1e-9, "micro_vary should differ for different indices");
}
#[test]
fn cascade_delay_zero_index_is_zero() {
assert_eq!(cascade_delay(0, 0.018), 0.0);
}
#[test]
fn cascade_delay_scales_linearly() {
let interval = 0.018;
for i in 0..52usize {
let expected = i as f32 * interval;
let actual = cascade_delay(i, interval);
assert!(
(actual - expected).abs() < 1e-6,
"cascade_delay({i}) = {actual}, expected {expected}"
);
}
}
}