Files
Ferrous-Solitaire/solitaire_engine/src/card_animation/animation.rs
T
funman300 ffc79447d4 fix+refactor+docs: P0–P3 todo list items
P0 fixes:
- Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs
  (all three were exported but never wired — features silently did nothing)
- game_state::draw(): increment move_count on waste→stock recycle, not just
  on normal draws; add move_count_increments_on_recycle regression test

P1 fixes:
- solitaire_server/Cargo.toml: remove duplicate dev-dependencies
  (solitaire_sync, uuid, chrono, jsonwebtoken were in both sections)

P2 — input_plugin refactor:
- Split 198-line handle_keyboard() into three focused systems under 110 lines each:
  handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G)
- Introduce KeyboardConfirmState resource to share countdown timers across systems
- Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck,
  new_game_confirm_window_is_positive

P2 — achievement predicate tests (solitaire_core):
- Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer,
  on_a_roll, comeback predicates (previously only covered via check_achievements())
- 141 core tests now passing

P2 — server tests:
- solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required)
- solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order

P3 — documentation:
- Add struct-level ///  to 12 Plugin structs (ChallengePlugin, CursorPlugin,
  AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin,
  HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin)
- Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef
- Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win

card_animation module (new files from previous session):
- chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs
- Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants
- Add handle_touch_stock_tap so touch users can draw from the stock pile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:02:52 +00:00

397 lines
13 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.
//!
//! # 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 stationary) and starts a fresh
/// [`CardAnimation`] from that position. Duration is recalculated from the
/// remaining distance so short paths stay quick.
///
/// # Velocity continuity
///
/// When a card is mid-flight, the new animation starts with a small positive
/// `elapsed` offset (`carry`) derived from how far through the current animation
/// the card is. This preserves a sense of forward momentum: the new curve does
/// not restart from zero velocity, avoiding a visible "lurch" when the target
/// changes rapidly.
///
/// The carry is deliberately small (≤ 10 % of the new duration) so that it
/// never causes a visible position jump — the card's start position is still
/// read from the current transform.
///
/// # 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, momentum_carry) = match current_anim {
Some(anim) if anim.duration > 0.0 => {
// Estimate how far into the current animation we are and carry
// a small fraction of that progress into the new animation.
// This avoids restarting from zero velocity and makes the motion
// feel continuous when the target changes mid-flight.
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
// Cap at 10 % of the new animation so there's no visible jump.
let carry = (t * 0.12).min(0.10);
(anim.current_xy(), transform.translation.z, carry)
}
_ => (transform.translation.truncate(), transform.translation.z, 0.0),
};
let distance = current_xy.distance(new_end);
let duration = compute_duration(distance);
commands.entity(entity).insert(CardAnimation {
start: current_xy,
end: new_end,
// Start slightly into the new animation to carry forward momentum.
elapsed: momentum_carry * duration,
duration,
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:?}");
}
}
}