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,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}"
);
}
}
}