6e407a3ea7
Build and Deploy / build-and-push (push) Successful in 3m54s
- #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>
159 lines
4.9 KiB
Rust
159 lines
4.9 KiB
Rust
//! 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}"
|
||
);
|
||
}
|
||
}
|
||
}
|