Files
Ferrous-Solitaire/solitaire_engine/src/card_animation/animation.rs
T
funman300 ccfb9394e0
Test / test (pull_request) Successful in 37m58s
chore: remove dead code and stale doc claims found in multi-agent sweep
Compiler-verified via RUSTFLAGS=--force-warn dead_code plus workspace-wide
reference greps; three parallel audit agents covered the engine crate, the
other eight crates, and Copilot commit-message-vs-diff drift.

Removed:
- replay_overlay/input.rs: 19 orphaned twins (~950 lines) of items also
  defined in mod.rs — the glob re-export made the mod.rs copies win and
  the file-level #![allow(dead_code)] hid the corpses. The live keyboard/
  button handlers and ReplayScrubKeyHold stay; the allow is retired.
- retarget_animation (never called; doc examples were its only refs)
- ScanThemesRequestEvent (never registered/written/read; its doc claimed
  a handle_scan_themes consumer that does not exist)
- _VEC3_REFERENCED workaround const + now-unneeded Vec3 import
- solitaire_data: load_stats/save_stats/time_attack_session_with_now
  default-path wrappers (the _from/_to variants are the live API) and
  surplus re-export names (settings MIN/MAX bounds, token loaders)
- solitaire_core: Session re-export (no external consumer)
- solitaire_wasm: ReplayPlayer::is_finished (no JS caller)
- solitaire_app: build_app wrapper (real entry is run())

Doc fixes:
- audio_plugin: WAV count 5→7, add FoundationCompletedEvent table row,
  drop bogus 'placeholder' label, bevy_kira_audio→kira
- ToastVariant::Warning: variant is live (5 writer plugins); dropped the
  stale allow(dead_code) and its 'currently unused' comment

Deliberately kept: Spider module (staged forward work), WinCascadePlugin
(documented alternative cascade, pending owner decision), SyncCompleteEvent
and solitaire_sync ApiError/merge_at (§8 change-controlled, flagged to owner).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:48:33 -07:00

325 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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.
//!
//! # 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 bevy::window::RequestRedraw;
use super::curves::{MotionCurve, sample_curve};
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.
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)
}
}
// ---------------------------------------------------------------------------
// 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)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Keep the winit event loop awake while any animation (including one
// still in its delay phase) needs per-frame ticks. Without this,
// Android's reactive_low_power focused_mode only wakes at its 100 ms
// ceiling and card slides render at ~10 fps.
if !q.is_empty() {
redraw.write(RequestRedraw);
}
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:?}"
);
}
}
}