Files
Ferrous-Solitaire/solitaire_engine/src/card_animation/animation.rs
T
funman300 6e407a3ea7
Build and Deploy / build-and-push (push) Successful in 3m54s
fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 13:07:22 -07:00

407 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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::{MotionCurve, sample_curve};
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 stationary) and starts a fresh
/// [`CardAnimation`] from that position. Duration is recalculated from the
/// remaining distance so short paths stay quick.
///
/// # Velocity continuity
///
/// When a card is mid-flight, the new animation starts with a small positive
/// `elapsed` offset (`carry`) derived from how far through the current animation
/// the card is. This preserves a sense of forward momentum: the new curve does
/// not restart from zero velocity, avoiding a visible "lurch" when the target
/// changes rapidly.
///
/// The carry is deliberately small (≤ 10 % of the new duration) so that it
/// never causes a visible position jump — the card's start position is still
/// read from the current transform.
///
/// # 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, momentum_carry) = match current_anim {
Some(anim) if anim.duration > 0.0 => {
// Estimate how far into the current animation we are and carry
// a small fraction of that progress into the new animation.
// This avoids restarting from zero velocity and makes the motion
// feel continuous when the target changes mid-flight.
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
// Cap at 10 % of the new animation so there's no visible jump.
let carry = (t * 0.12).min(0.10);
(anim.current_xy(), transform.translation.z, carry)
}
_ => (
transform.translation.truncate(),
transform.translation.z,
0.0,
),
};
let distance = current_xy.distance(new_end);
let duration = compute_duration(distance);
commands.entity(entity).insert(CardAnimation {
start: current_xy,
end: new_end,
// Start slightly into the new animation to carry forward momentum.
elapsed: momentum_carry * duration,
duration,
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:?}"
);
}
}
}