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