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:
@@ -5,3 +5,4 @@
|
|||||||
.env
|
.env
|
||||||
*.tmp
|
*.tmp
|
||||||
data/
|
data/
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
//! `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::{sample_curve, MotionCurve};
|
||||||
|
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 the card is stationary) and starts a fresh
|
||||||
|
/// `CardAnimation` from that position. Duration is recalculated from the remaining
|
||||||
|
/// distance so short remaining paths feel appropriately quick.
|
||||||
|
///
|
||||||
|
/// # 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) = match current_anim {
|
||||||
|
Some(anim) => (anim.current_xy(), transform.translation.z),
|
||||||
|
None => (transform.translation.truncate(), transform.translation.z),
|
||||||
|
};
|
||||||
|
|
||||||
|
let distance = current_xy.distance(new_end);
|
||||||
|
commands.entity(entity).insert(CardAnimation {
|
||||||
|
start: current_xy,
|
||||||
|
end: new_end,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: compute_duration(distance),
|
||||||
|
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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
//! Motion curve definitions for card animations.
|
||||||
|
//!
|
||||||
|
//! All curves map `t ∈ [0, 1]` to a position ratio. Curves with overshoot
|
||||||
|
//! (`SmoothSnap`, `SoftBounce`, `Expressive`) may return values slightly
|
||||||
|
//! outside `[0, 1]` near the destination — callers should not clamp the output
|
||||||
|
//! before applying it to a lerp, as the overshoot is intentional.
|
||||||
|
//!
|
||||||
|
//! # Curve selection guide
|
||||||
|
//!
|
||||||
|
//! | Interaction | Recommended curve |
|
||||||
|
//! |----------------------|-------------------|
|
||||||
|
//! | Standard card move | `SmoothSnap` |
|
||||||
|
//! | Foundation placement | `SoftBounce` |
|
||||||
|
//! | Invalid snap-back | `Responsive` |
|
||||||
|
//! | Win cascade | `Expressive` |
|
||||||
|
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
/// Motion curve variant controlling animation easing behaviour.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum MotionCurve {
|
||||||
|
/// Cubic ease-out with a 1.5 % terminal overshoot.
|
||||||
|
///
|
||||||
|
/// Overshoot is a sine arch in the final 25 % of the animation that peaks
|
||||||
|
/// ~1.5 % beyond the target, settling cleanly to 1.0 at `t = 1`. Gives a
|
||||||
|
/// lively, slightly "alive" feel without feeling heavy.
|
||||||
|
#[default]
|
||||||
|
SmoothSnap,
|
||||||
|
|
||||||
|
/// Underdamped spring (ζ = 0.65, ω = 20 rad/s).
|
||||||
|
///
|
||||||
|
/// One visible overshoot of ~8 % followed by fast decay. Good for
|
||||||
|
/// satisfying "thud" feedback when placing cards on foundations or tableau.
|
||||||
|
SoftBounce,
|
||||||
|
|
||||||
|
/// Quintic ease-out — aggressive deceleration, zero overshoot.
|
||||||
|
///
|
||||||
|
/// Starts extremely fast and decelerates hard. Best for snap-back on
|
||||||
|
/// invalid drops: the card returns instantly without any bounce.
|
||||||
|
Responsive,
|
||||||
|
|
||||||
|
/// Underdamped spring (ζ = 0.45, ω = 18 rad/s).
|
||||||
|
///
|
||||||
|
/// Two visible bounces before settling. High visual energy — reserved for
|
||||||
|
/// win cascade animations where expressivity matters more than subtlety.
|
||||||
|
Expressive,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Samples `curve` at normalised time `t ∈ [0, 1]`.
|
||||||
|
///
|
||||||
|
/// The return value is the interpolation factor to pass to `Vec2::lerp` /
|
||||||
|
/// `Vec3::lerp`. Values may slightly exceed 1.0 for curves with overshoot.
|
||||||
|
#[inline]
|
||||||
|
pub fn sample_curve(curve: MotionCurve, t: f32) -> f32 {
|
||||||
|
let t = t.clamp(0.0, 1.0);
|
||||||
|
match curve {
|
||||||
|
MotionCurve::SmoothSnap => smooth_snap(t),
|
||||||
|
MotionCurve::SoftBounce => soft_bounce(t),
|
||||||
|
MotionCurve::Responsive => responsive(t),
|
||||||
|
MotionCurve::Expressive => expressive(t),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cubic ease-out with a sine-arch overshoot in the final 25 % of `t`.
|
||||||
|
///
|
||||||
|
/// The overshoot term is `sin(tail * π) * 0.015` where `tail` is `t` linearly
|
||||||
|
/// rescaled from `[0.75, 1.0]` to `[0, 1]`. At `t = 0.875` the card is ~1.5 %
|
||||||
|
/// past its target; at `t = 1` the card is exactly on target.
|
||||||
|
#[inline]
|
||||||
|
fn smooth_snap(t: f32) -> f32 {
|
||||||
|
let base = 1.0 - (1.0 - t).powi(3);
|
||||||
|
let tail = ((t - 0.75) / 0.25).clamp(0.0, 1.0);
|
||||||
|
let overshoot = (tail * PI).sin() * 0.015;
|
||||||
|
base + overshoot
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Underdamped spring response (ζ = 0.65, ω₀ = 20 rad/s).
|
||||||
|
///
|
||||||
|
/// Derived from the exact closed-form solution:
|
||||||
|
/// `x(t) = 1 − e^{−ζω₀t}[cos(ωd·t) + (ζω₀/ωd)·sin(ωd·t)]`
|
||||||
|
/// where `ωd = ω₀·√(1 − ζ²)`.
|
||||||
|
#[inline]
|
||||||
|
fn soft_bounce(t: f32) -> f32 {
|
||||||
|
const OMEGA: f32 = 20.0;
|
||||||
|
const ZETA: f32 = 0.65;
|
||||||
|
let omega_d = OMEGA * (1.0 - ZETA * ZETA).sqrt();
|
||||||
|
let decay = (-ZETA * OMEGA * t).exp();
|
||||||
|
1.0 - decay * ((omega_d * t).cos() + (ZETA * OMEGA / omega_d) * (omega_d * t).sin())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quintic ease-out: `f(t) = 1 − (1 − t)^5`.
|
||||||
|
///
|
||||||
|
/// Reaches ~97 % of the target by `t = 0.5`. No overshoot.
|
||||||
|
#[inline]
|
||||||
|
fn responsive(t: f32) -> f32 {
|
||||||
|
1.0 - (1.0 - t).powi(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Underdamped spring response (ζ = 0.45, ω₀ = 18 rad/s) — two visible bounces.
|
||||||
|
///
|
||||||
|
/// Uses the same closed-form spring formula as `soft_bounce` but with lower
|
||||||
|
/// damping, producing higher overshoot (~18 %) and two discernible oscillations
|
||||||
|
/// before settling.
|
||||||
|
#[inline]
|
||||||
|
fn expressive(t: f32) -> f32 {
|
||||||
|
const OMEGA: f32 = 18.0;
|
||||||
|
const ZETA: f32 = 0.45;
|
||||||
|
let omega_d = OMEGA * (1.0 - ZETA * ZETA).sqrt();
|
||||||
|
let decay = (-ZETA * OMEGA * t).exp();
|
||||||
|
1.0 - decay * ((omega_d * t).cos() + (ZETA * OMEGA / omega_d) * (omega_d * t).sin())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn assert_near(a: f32, b: f32, eps: f32, msg: &str) {
|
||||||
|
assert!((a - b).abs() < eps, "{msg}: expected ~{b}, got {a}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_curves_start_at_zero() {
|
||||||
|
for curve in [
|
||||||
|
MotionCurve::SmoothSnap,
|
||||||
|
MotionCurve::SoftBounce,
|
||||||
|
MotionCurve::Responsive,
|
||||||
|
MotionCurve::Expressive,
|
||||||
|
] {
|
||||||
|
assert_near(sample_curve(curve, 0.0), 0.0, 1e-5, &format!("{curve:?} at t=0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_curves_end_at_one() {
|
||||||
|
for curve in [
|
||||||
|
MotionCurve::SmoothSnap,
|
||||||
|
MotionCurve::SoftBounce,
|
||||||
|
MotionCurve::Responsive,
|
||||||
|
] {
|
||||||
|
assert_near(sample_curve(curve, 1.0), 1.0, 1e-4, &format!("{curve:?} at t=1"));
|
||||||
|
}
|
||||||
|
// Spring-based curves have residual oscillation at finite t=1; allow 2 e-3.
|
||||||
|
assert_near(
|
||||||
|
sample_curve(MotionCurve::Expressive, 1.0),
|
||||||
|
1.0,
|
||||||
|
2e-3,
|
||||||
|
"Expressive at t=1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responsive_reaches_half_before_midpoint() {
|
||||||
|
// Quintic ease-out accelerates fast — >50 % by t=0.5.
|
||||||
|
let v = sample_curve(MotionCurve::Responsive, 0.5);
|
||||||
|
assert!(v > 0.96, "Responsive should be >96 % at t=0.5, got {v}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smooth_snap_overshoots_slightly_near_end() {
|
||||||
|
// Peak overshoot is around t = 0.875.
|
||||||
|
let peak = sample_curve(MotionCurve::SmoothSnap, 0.875);
|
||||||
|
assert!(peak > 1.0, "SmoothSnap should overshoot at t=0.875, got {peak}");
|
||||||
|
assert!(peak < 1.03, "SmoothSnap overshoot should be small (<3 %), got {peak}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn soft_bounce_overshoots_and_returns() {
|
||||||
|
let v = sample_curve(MotionCurve::SoftBounce, 1.0);
|
||||||
|
assert_near(v, 1.0, 1e-3, "SoftBounce must settle at 1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expressive_has_more_overshoot_than_soft_bounce() {
|
||||||
|
// Compare max value in [0,1] range.
|
||||||
|
let max_soft: f32 = (0..=100)
|
||||||
|
.map(|i| sample_curve(MotionCurve::SoftBounce, i as f32 / 100.0))
|
||||||
|
.fold(f32::NEG_INFINITY, f32::max);
|
||||||
|
let max_expr: f32 = (0..=100)
|
||||||
|
.map(|i| sample_curve(MotionCurve::Expressive, i as f32 / 100.0))
|
||||||
|
.fold(f32::NEG_INFINITY, f32::max);
|
||||||
|
assert!(
|
||||||
|
max_expr > max_soft,
|
||||||
|
"Expressive should overshoot more than SoftBounce: {max_expr} vs {max_soft}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_curve_clamps_t_below_zero() {
|
||||||
|
assert_near(sample_curve(MotionCurve::SmoothSnap, -1.0), 0.0, 1e-5, "t<0 clamped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_curve_clamps_t_above_one() {
|
||||||
|
assert_near(sample_curve(MotionCurve::Responsive, 2.0), 1.0, 1e-5, "t>1 clamped");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
//! Card interaction visuals: hover scale, drag lift, and input buffering.
|
||||||
|
//!
|
||||||
|
//! # Hover
|
||||||
|
//!
|
||||||
|
//! [`HoverState`] tracks the entity currently under the cursor. A system
|
||||||
|
//! smoothly lerps `Transform.scale` toward `HOVER_SCALE` on the hovered card
|
||||||
|
//! and back to 1.0 when the cursor leaves. Scale is only written when no
|
||||||
|
//! [`CardAnimation`] is active on the entity (the animation takes priority).
|
||||||
|
//!
|
||||||
|
//! # Drag visual
|
||||||
|
//!
|
||||||
|
//! While [`DragState`] is non-idle, the dragged card entities receive a subtle
|
||||||
|
//! scale boost (`DRAG_LIFT_SCALE`) and their z-order is pushed up. The exact
|
||||||
|
//! translation is still controlled by the existing [`crate::input_plugin`] —
|
||||||
|
//! this system only applies the _visual_ enhancement without touching XY.
|
||||||
|
//!
|
||||||
|
//! # Input buffer
|
||||||
|
//!
|
||||||
|
//! [`InputBuffer`] stores move/draw/undo actions that arrived while cards are
|
||||||
|
//! still animating. Call [`InputBuffer::push`] from any system that wants
|
||||||
|
//! buffering. The drain system fires the oldest buffered action as soon as all
|
||||||
|
//! [`CardAnimation`] components have cleared, giving a responsive feel on
|
||||||
|
//! fast repeated clicks.
|
||||||
|
//!
|
||||||
|
//! # Visual priority
|
||||||
|
//!
|
||||||
|
//! Dragged cards always have the highest z. The existing [`crate::input_plugin`]
|
||||||
|
//! sets drag z; this module applies scale on top. The ordering constraint
|
||||||
|
//! `.after(crate::game_plugin::GameMutation)` ensures all game-state changes
|
||||||
|
//! settle before visual updates run.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::PrimaryWindow;
|
||||||
|
|
||||||
|
use super::animation::CardAnimation;
|
||||||
|
use crate::card_plugin::CardEntity;
|
||||||
|
use crate::events::{DrawRequestEvent, MoveRequestEvent, UndoRequestEvent};
|
||||||
|
use crate::layout::LayoutResource;
|
||||||
|
use crate::resources::DragState;
|
||||||
|
|
||||||
|
/// Type alias to reduce complexity in hover/drag query signatures.
|
||||||
|
type CardTransformQuery<'w, 's> =
|
||||||
|
Query<'w, 's, (Entity, &'static mut Transform), (With<CardEntity>, Without<CardAnimation>)>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Scale applied to the card currently under the cursor (1.0 = no change).
|
||||||
|
const HOVER_SCALE: f32 = 1.04;
|
||||||
|
|
||||||
|
/// Additional scale applied to dragged cards while in flight.
|
||||||
|
const DRAG_LIFT_SCALE: f32 = 1.08;
|
||||||
|
|
||||||
|
/// Lerp speed for hover scale interpolation (higher = snappier).
|
||||||
|
const HOVER_LERP_SPEED: f32 = 14.0;
|
||||||
|
|
||||||
|
/// Lerp speed for drag scale interpolation.
|
||||||
|
const DRAG_LERP_SPEED: f32 = 20.0;
|
||||||
|
|
||||||
|
/// Maximum number of buffered inputs retained.
|
||||||
|
const INPUT_BUFFER_CAPACITY: usize = 4;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Tracks the entity currently under the cursor and the interpolated hover scale.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct HoverState {
|
||||||
|
/// Entity currently hovered (`None` when cursor is off all cards or dragging).
|
||||||
|
pub entity: Option<Entity>,
|
||||||
|
/// Current interpolated scale applied to the hovered card.
|
||||||
|
pub scale: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Describes a user action that arrived while cards were still animating.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum BufferedInput {
|
||||||
|
Move { from: crate::events::MoveRequestEvent },
|
||||||
|
Draw,
|
||||||
|
Undo,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FIFO queue of inputs deferred until ongoing animations complete.
|
||||||
|
///
|
||||||
|
/// Populate via [`InputBuffer::push`] and consume via the drain system.
|
||||||
|
/// Capped at [`INPUT_BUFFER_CAPACITY`] — further pushes when full are silently
|
||||||
|
/// dropped to prevent stale action pileup.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct InputBuffer {
|
||||||
|
pub(crate) queue: VecDeque<BufferedInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputBuffer {
|
||||||
|
/// Enqueues an input if the buffer is not full.
|
||||||
|
pub fn push(&mut self, input: BufferedInput) {
|
||||||
|
if self.queue.len() < INPUT_BUFFER_CAPACITY {
|
||||||
|
self.queue.push_back(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when no inputs are pending.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.queue.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns how many inputs are queued.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.queue.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Detects which card is under the cursor and updates [`HoverState`].
|
||||||
|
///
|
||||||
|
/// Clears hover when [`DragState`] is active (dragging takes visual priority).
|
||||||
|
/// Picks the topmost card (highest `translation.z`) when multiple cards overlap.
|
||||||
|
pub(crate) fn detect_hover(
|
||||||
|
windows: Query<&Window, With<PrimaryWindow>>,
|
||||||
|
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||||
|
drag: Option<Res<DragState>>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
||||||
|
mut hover: ResMut<HoverState>,
|
||||||
|
) {
|
||||||
|
let is_dragging = drag.as_ref().is_some_and(|d| !d.is_idle());
|
||||||
|
if is_dragging {
|
||||||
|
hover.entity = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
let Some(cursor_world) = cursor_world(&windows, &cameras) else {
|
||||||
|
hover.entity = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let half_w = layout.0.card_size.x * 0.5;
|
||||||
|
let half_h = layout.0.card_size.y * 0.5;
|
||||||
|
|
||||||
|
let mut best: Option<(Entity, f32)> = None;
|
||||||
|
for (entity, transform) in &cards {
|
||||||
|
let pos = transform.translation.truncate();
|
||||||
|
if (cursor_world.x - pos.x).abs() < half_w
|
||||||
|
&& (cursor_world.y - pos.y).abs() < half_h
|
||||||
|
{
|
||||||
|
let z = transform.translation.z;
|
||||||
|
if best.is_none_or(|(_, bz)| z > bz) {
|
||||||
|
best = Some((entity, z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hover.entity = best.map(|(e, _)| e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the hover scale to the currently hovered card via smooth lerp.
|
||||||
|
///
|
||||||
|
/// Only runs on cards that have **no active [`CardAnimation`]** — animated
|
||||||
|
/// cards control their own scale. When hover changes entities, the previous
|
||||||
|
/// entity's scale is snapped back to 1.0 to avoid leaving a permanently
|
||||||
|
/// enlarged card.
|
||||||
|
pub(crate) fn apply_hover_scale(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut hover_state: ResMut<HoverState>,
|
||||||
|
mut cards: CardTransformQuery,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
let target_entity = hover_state.entity;
|
||||||
|
|
||||||
|
for (entity, mut transform) in &mut cards {
|
||||||
|
let target_scale = if Some(entity) == target_entity {
|
||||||
|
HOVER_SCALE
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let current = transform.scale.x;
|
||||||
|
let new_scale = current + (target_scale - current) * (HOVER_LERP_SPEED * dt).min(1.0);
|
||||||
|
transform.scale = Vec3::splat(new_scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the tracked scale for external inspection.
|
||||||
|
hover_state.scale = if let Some(entity) = target_entity {
|
||||||
|
cards
|
||||||
|
.get(entity)
|
||||||
|
.map(|(_, t)| t.scale.x)
|
||||||
|
.unwrap_or(HOVER_SCALE)
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a scale boost and z-lift to dragged card entities.
|
||||||
|
///
|
||||||
|
/// Reads [`DragState`] for the list of card IDs being dragged. Does **not**
|
||||||
|
/// modify `translation.xy` — the existing `InputPlugin` owns drag translation.
|
||||||
|
/// Only writes `scale` and `translation.z` so the two systems are disjoint.
|
||||||
|
pub(crate) fn apply_drag_visual(
|
||||||
|
time: Res<Time>,
|
||||||
|
drag: Option<Res<DragState>>,
|
||||||
|
mut cards: Query<(Entity, &CardEntity, &mut Transform), (Without<CardAnimation>,)>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
let empty: Vec<u32> = Vec::new();
|
||||||
|
let dragged_ids: &[u32] = drag.as_ref().map_or(empty.as_slice(), |d| &d.cards);
|
||||||
|
|
||||||
|
for (_, card, mut transform) in &mut cards {
|
||||||
|
let is_dragged = dragged_ids.contains(&card.card_id);
|
||||||
|
let target_scale = if is_dragged { DRAG_LIFT_SCALE } else { 1.0 };
|
||||||
|
let current = transform.scale.x;
|
||||||
|
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
|
||||||
|
transform.scale = Vec3::splat(new_scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires the oldest buffered input when no [`CardAnimation`] components remain.
|
||||||
|
///
|
||||||
|
/// Call this system late in the `Update` schedule so freshly-removed animations
|
||||||
|
/// are already gone before the drain runs.
|
||||||
|
pub(crate) fn drain_input_buffer(
|
||||||
|
mut buffer: ResMut<InputBuffer>,
|
||||||
|
anims: Query<&CardAnimation>,
|
||||||
|
mut move_events: EventWriter<MoveRequestEvent>,
|
||||||
|
mut draw_events: EventWriter<DrawRequestEvent>,
|
||||||
|
mut undo_events: EventWriter<UndoRequestEvent>,
|
||||||
|
) {
|
||||||
|
if !anims.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match buffer.queue.pop_front() {
|
||||||
|
Some(BufferedInput::Move { from }) => {
|
||||||
|
move_events.send(from);
|
||||||
|
}
|
||||||
|
Some(BufferedInput::Draw) => {
|
||||||
|
draw_events.send(DrawRequestEvent);
|
||||||
|
}
|
||||||
|
Some(BufferedInput::Undo) => {
|
||||||
|
undo_events.send(UndoRequestEvent);
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cursor helper (mirrors the pattern used by input_plugin)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Converts the cursor screen position to 2-D world coordinates.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the cursor is outside the window or no camera is found.
|
||||||
|
fn cursor_world(
|
||||||
|
windows: &Query<&Window, With<PrimaryWindow>>,
|
||||||
|
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||||
|
) -> Option<Vec2> {
|
||||||
|
let window = windows.get_single().ok()?;
|
||||||
|
let cursor = window.cursor_position()?;
|
||||||
|
let (camera, camera_transform) = cameras.get_single().ok()?;
|
||||||
|
camera.viewport_to_world_2d(camera_transform, cursor).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_buffer_capacity_is_respected() {
|
||||||
|
let mut buf = InputBuffer::default();
|
||||||
|
for _ in 0..INPUT_BUFFER_CAPACITY + 5 {
|
||||||
|
buf.push(BufferedInput::Draw);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
buf.len(),
|
||||||
|
INPUT_BUFFER_CAPACITY,
|
||||||
|
"buffer must not exceed capacity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_buffer_is_fifo() {
|
||||||
|
let mut buf = InputBuffer::default();
|
||||||
|
buf.push(BufferedInput::Draw);
|
||||||
|
buf.push(BufferedInput::Undo);
|
||||||
|
|
||||||
|
matches!(buf.queue.pop_front().unwrap(), BufferedInput::Draw);
|
||||||
|
matches!(buf.queue.pop_front().unwrap(), BufferedInput::Undo);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_buffer_empty_initially() {
|
||||||
|
let buf = InputBuffer::default();
|
||||||
|
assert!(buf.is_empty());
|
||||||
|
assert_eq!(buf.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_buffer_len_increments() {
|
||||||
|
let mut buf = InputBuffer::default();
|
||||||
|
buf.push(BufferedInput::Draw);
|
||||||
|
assert_eq!(buf.len(), 1);
|
||||||
|
buf.push(BufferedInput::Undo);
|
||||||
|
assert_eq!(buf.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hover_state_default_has_no_entity() {
|
||||||
|
let state = HoverState::default();
|
||||||
|
assert!(state.entity.is_none());
|
||||||
|
assert_eq!(state.scale, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
//! `CardAnimationPlugin` — curve-based card animation system.
|
||||||
|
//!
|
||||||
|
//! # Quick start
|
||||||
|
//!
|
||||||
|
//! Register the plugin alongside the existing animation plugins:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! app.add_plugins((
|
||||||
|
//! AnimationPlugin, // existing: drives CardAnim (linear)
|
||||||
|
//! FeedbackAnimPlugin, // existing: shake + settle
|
||||||
|
//! CardAnimationPlugin, // new: curve-based CardAnimation
|
||||||
|
//! ));
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Spawn a card with a `CardAnimation` component:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! use solitaire_engine::card_animation::{CardAnimation, MotionCurve};
|
||||||
|
//!
|
||||||
|
//! commands.spawn((
|
||||||
|
//! SpriteBundle { /* ... */ },
|
||||||
|
//! CardAnimation::slide(
|
||||||
|
//! Vec2::new(0.0, 0.0), // start xy
|
||||||
|
//! 0.0, // start z
|
||||||
|
//! Vec2::new(300.0, 200.0),// end xy
|
||||||
|
//! 5.0, // end z (resting)
|
||||||
|
//! MotionCurve::SmoothSnap,
|
||||||
|
//! )
|
||||||
|
//! .with_z_lift(12.0) // floats up during motion
|
||||||
|
//! .with_delay(0.03), // stagger delay
|
||||||
|
//! ));
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Retarget a card mid-flight:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! use solitaire_engine::card_animation::retarget_animation;
|
||||||
|
//!
|
||||||
|
//! fn handle_drop(
|
||||||
|
//! mut commands: Commands,
|
||||||
|
//! q: Query<(Entity, &Transform, Option<&CardAnimation>), With<CardEntity>>,
|
||||||
|
//! ) {
|
||||||
|
//! let (entity, transform, anim) = q.get(card_entity).unwrap();
|
||||||
|
//! retarget_animation(
|
||||||
|
//! &mut commands,
|
||||||
|
//! entity,
|
||||||
|
//! anim,
|
||||||
|
//! transform,
|
||||||
|
//! new_target_xy,
|
||||||
|
//! new_target_z,
|
||||||
|
//! MotionCurve::SmoothSnap,
|
||||||
|
//! );
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Win cascade with `Expressive` curve
|
||||||
|
//!
|
||||||
|
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
|
||||||
|
//! (linear). To use the curve-based cascade instead, disable
|
||||||
|
//! `handle_win_cascade` in `AnimationPlugin` and register `WinCascadePlugin`
|
||||||
|
//! (declared below) which uses `CardAnimation` + `MotionCurve::Expressive`.
|
||||||
|
//!
|
||||||
|
//! They **must not both be active** — both write to `Transform` on the same
|
||||||
|
//! 52 entities and will race.
|
||||||
|
//!
|
||||||
|
//! # Coexistence rules
|
||||||
|
//!
|
||||||
|
//! | Condition | Safe? |
|
||||||
|
//! |---|---|
|
||||||
|
//! | `CardAnim` and `CardAnimation` on **different** entities | ✓ |
|
||||||
|
//! | `CardAnim` and `CardAnimation` on the **same** entity | ✗ |
|
||||||
|
//! | `HoverState` scale + `CardAnimation` scale on same entity | ✓ (CardAnimation takes priority — hover skipped via `Without<CardAnimation>` filter) |
|
||||||
|
//! | `apply_drag_visual` scale + `CardAnimation` scale | ✓ (same filter) |
|
||||||
|
|
||||||
|
pub mod animation;
|
||||||
|
pub mod curves;
|
||||||
|
pub mod interaction;
|
||||||
|
pub mod timing;
|
||||||
|
|
||||||
|
pub use animation::{retarget_animation, win_scatter_targets, CardAnimation};
|
||||||
|
pub use curves::{sample_curve, MotionCurve};
|
||||||
|
pub use interaction::{BufferedInput, HoverState, InputBuffer};
|
||||||
|
pub use timing::{
|
||||||
|
cascade_delay, compute_duration, micro_vary, DEAL_INTERVAL_SECS, MAX_DURATION_SECS,
|
||||||
|
MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
|
||||||
|
};
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::card_plugin::CardEntity;
|
||||||
|
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::layout::LayoutResource;
|
||||||
|
use crate::resources::DragState;
|
||||||
|
|
||||||
|
use animation::advance_card_animations;
|
||||||
|
use interaction::{apply_drag_visual, apply_hover_scale, detect_hover, drain_input_buffer};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Registers all systems, resources, and components for curve-based card
|
||||||
|
/// animation, hover visuals, drag lift, and input buffering.
|
||||||
|
///
|
||||||
|
/// Safe to register alongside `AnimationPlugin` and `FeedbackAnimPlugin` as
|
||||||
|
/// long as no single entity carries both `CardAnim` and `CardAnimation`.
|
||||||
|
pub struct CardAnimationPlugin;
|
||||||
|
|
||||||
|
impl Plugin for CardAnimationPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
// Register events and resources that interaction systems depend on,
|
||||||
|
// idempotently — double-registration is safe in Bevy.
|
||||||
|
app.add_event::<MoveRequestEvent>()
|
||||||
|
.add_event::<DrawRequestEvent>()
|
||||||
|
.add_event::<UndoRequestEvent>()
|
||||||
|
.add_event::<GameWonEvent>()
|
||||||
|
.init_resource::<DragState>()
|
||||||
|
.init_resource::<HoverState>()
|
||||||
|
.init_resource::<InputBuffer>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
// Advance active animations (highest priority — runs first).
|
||||||
|
advance_card_animations,
|
||||||
|
// Interaction visuals (run after animation to read final positions).
|
||||||
|
detect_hover,
|
||||||
|
apply_hover_scale,
|
||||||
|
apply_drag_visual,
|
||||||
|
// Drain buffered inputs only when no animations remain.
|
||||||
|
drain_input_buffer,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Optional: win cascade with Expressive curve
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Optional plugin that replaces the linear win cascade in `AnimationPlugin`
|
||||||
|
/// with an `Expressive`-curve cascade.
|
||||||
|
///
|
||||||
|
/// **Do not register this alongside `AnimationPlugin`'s win cascade** — they
|
||||||
|
/// will race on the same card entities. To use this plugin, prevent
|
||||||
|
/// `AnimationPlugin` from handling `GameWonEvent` (or remove it and manage
|
||||||
|
/// win toasts manually).
|
||||||
|
pub struct WinCascadePlugin;
|
||||||
|
|
||||||
|
impl Plugin for WinCascadePlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
trigger_expressive_win_cascade.after(GameMutation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts `CardAnimation` (Expressive curve) on every card when `GameWonEvent` fires.
|
||||||
|
///
|
||||||
|
/// Cards scatter to 8 off-screen positions with per-card stagger. The z-lift
|
||||||
|
/// creates a "burst" effect as cards fly outward.
|
||||||
|
fn trigger_expressive_win_cascade(
|
||||||
|
mut events: EventReader<GameWonEvent>,
|
||||||
|
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let radius = layout
|
||||||
|
.as_ref()
|
||||||
|
.map_or(800.0, |l| l.0.card_size.x * 8.0);
|
||||||
|
|
||||||
|
let targets = win_scatter_targets(radius);
|
||||||
|
|
||||||
|
for (index, (entity, transform)) in cards.iter().enumerate() {
|
||||||
|
let start_xy = transform.translation.truncate();
|
||||||
|
let start_z = transform.translation.z;
|
||||||
|
let target = targets[index % targets.len()];
|
||||||
|
|
||||||
|
commands.entity(entity).insert(
|
||||||
|
CardAnimation::slide(start_xy, start_z, target, start_z + 60.0, MotionCurve::Expressive)
|
||||||
|
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
|
||||||
|
.with_duration(0.65)
|
||||||
|
.with_z_lift(25.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::animation_plugin::AnimationPlugin;
|
||||||
|
use crate::card_plugin::CardPlugin;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
|
||||||
|
fn base_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(CardPlugin)
|
||||||
|
.add_plugins(AnimationPlugin)
|
||||||
|
.add_plugins(CardAnimationPlugin);
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_registers_hover_state() {
|
||||||
|
let app = base_app();
|
||||||
|
assert!(
|
||||||
|
app.world().get_resource::<HoverState>().is_some(),
|
||||||
|
"HoverState resource must be registered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_registers_input_buffer() {
|
||||||
|
let app = base_app();
|
||||||
|
assert!(
|
||||||
|
app.world().get_resource::<InputBuffer>().is_some(),
|
||||||
|
"InputBuffer resource must be registered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_animation_advances_and_removes_itself() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
|
||||||
|
|
||||||
|
let start = Vec2::new(0.0, 0.0);
|
||||||
|
let end = Vec2::new(100.0, 0.0);
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
Transform::from_translation(start.extend(0.0)),
|
||||||
|
CardAnimation {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
elapsed: 0.99,
|
||||||
|
duration: 1.0,
|
||||||
|
curve: MotionCurve::Responsive,
|
||||||
|
delay: 0.0,
|
||||||
|
start_z: 0.0,
|
||||||
|
end_z: 0.0,
|
||||||
|
z_lift: 0.0,
|
||||||
|
scale_start: 1.0,
|
||||||
|
scale_end: 1.0,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// After one update at elapsed=0.99, component should still be present.
|
||||||
|
// We can't advance time reliably in MinimalPlugins, but we can check
|
||||||
|
// that the advance_card_animations system processed the component
|
||||||
|
// (pos moved closer to end).
|
||||||
|
let transform = app.world().entity(entity).get::<Transform>().unwrap();
|
||||||
|
assert!(
|
||||||
|
transform.translation.x > 50.0,
|
||||||
|
"card should have moved past midpoint by elapsed=0.99, got x={}",
|
||||||
|
transform.translation.x
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_animation_instant_snaps_on_zero_duration() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
|
||||||
|
|
||||||
|
let end = Vec2::new(200.0, 100.0);
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
Transform::from_translation(Vec3::ZERO),
|
||||||
|
CardAnimation {
|
||||||
|
start: Vec2::ZERO,
|
||||||
|
end,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: 0.0, // zero duration → instant snap
|
||||||
|
curve: MotionCurve::SmoothSnap,
|
||||||
|
delay: 0.0,
|
||||||
|
start_z: 0.0,
|
||||||
|
end_z: 5.0,
|
||||||
|
z_lift: 0.0,
|
||||||
|
scale_start: 1.0,
|
||||||
|
scale_end: 1.0,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world().entity(entity).get::<CardAnimation>().is_none(),
|
||||||
|
"zero-duration animation must be removed after one update"
|
||||||
|
);
|
||||||
|
let transform = app.world().entity(entity).get::<Transform>().unwrap();
|
||||||
|
assert!(
|
||||||
|
(transform.translation.x - 200.0).abs() < 1e-3,
|
||||||
|
"card must snap to end.x"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(transform.translation.y - 100.0).abs() < 1e-3,
|
||||||
|
"card must snap to end.y"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(transform.translation.z - 5.0).abs() < 1e-3,
|
||||||
|
"card must snap to end_z"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_animation_respects_delay() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
|
||||||
|
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
Transform::from_translation(Vec3::ZERO),
|
||||||
|
CardAnimation {
|
||||||
|
start: Vec2::ZERO,
|
||||||
|
end: Vec2::new(100.0, 0.0),
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: 0.15,
|
||||||
|
curve: MotionCurve::SmoothSnap,
|
||||||
|
delay: 100.0, // huge delay — card must not move
|
||||||
|
start_z: 0.0,
|
||||||
|
end_z: 0.0,
|
||||||
|
z_lift: 0.0,
|
||||||
|
scale_start: 1.0,
|
||||||
|
scale_end: 1.0,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let transform = app.world().entity(entity).get::<Transform>().unwrap();
|
||||||
|
assert!(
|
||||||
|
transform.translation.x.abs() < 1e-3,
|
||||||
|
"card must not move during delay, got x={}",
|
||||||
|
transform.translation.x
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_buffer_push_and_drain_ordering() {
|
||||||
|
let mut buf = InputBuffer::default();
|
||||||
|
buf.push(BufferedInput::Draw);
|
||||||
|
buf.push(BufferedInput::Undo);
|
||||||
|
// FIFO: Draw comes out first.
|
||||||
|
assert!(matches!(buf.queue.pop_front().unwrap(), BufferedInput::Draw));
|
||||||
|
assert!(matches!(buf.queue.pop_front().unwrap(), BufferedInput::Undo));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hover_state_initialises_without_entity() {
|
||||||
|
let state = HoverState::default();
|
||||||
|
assert!(state.entity.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_scatter_produces_eight_distinct_points() {
|
||||||
|
let targets = win_scatter_targets(600.0);
|
||||||
|
assert_eq!(targets.len(), 8);
|
||||||
|
// All must be different.
|
||||||
|
for i in 0..8 {
|
||||||
|
for j in (i + 1)..8 {
|
||||||
|
assert_ne!(
|
||||||
|
targets[i], targets[j],
|
||||||
|
"scatter targets {i} and {j} must be distinct"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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