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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user