Files
Ferrous-Solitaire/solitaire_engine/src/card_animation/mod.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

452 lines
16 KiB
Rust

//! `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
//! ));
//! ```
//!
//! # 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 chain;
pub mod curves;
pub mod diagnostics;
pub mod interaction;
pub mod timing;
pub mod tuning;
pub use animation::{CardAnimation, win_scatter_targets};
pub use chain::AnimationChain;
pub use curves::{MotionCurve, sample_curve};
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
pub use interaction::{BufferedInput, HoverState, InputBuffer};
pub use timing::{
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
cascade_delay, compute_duration, micro_vary,
};
pub use tuning::{AnimationTuning, InputPlatform};
use bevy::prelude::*;
use bevy::window::RequestRedraw;
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 chain::advance_animation_chains;
use diagnostics::update_frame_time_diagnostics;
use interaction::{apply_drag_visual, apply_hover_scale, detect_hover, drain_input_buffer};
use tuning::update_input_platform;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers all systems, resources, and components for curve-based card
/// animation, hover visuals, drag lift, input buffering, platform-adaptive
/// tuning, animation chaining, and frame-time diagnostics.
///
/// 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 idempotently — double-registration is
// safe in Bevy.
app.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<GameWonEvent>()
.add_message::<RequestRedraw>()
.init_resource::<DragState>()
.init_resource::<HoverState>()
.init_resource::<InputBuffer>()
// Platform-adaptive tuning (desktop by default, switches on touch).
.init_resource::<AnimationTuning>()
// Rolling frame-time statistics.
.init_resource::<FrameTimeDiagnostics>()
.add_systems(
Update,
(
// Detect input platform and update tuning — runs first so
// all downstream systems in this frame see the fresh value.
update_input_platform,
// Frame-time diagnostics — cheap, runs unconditionally.
update_frame_time_diagnostics,
// Advance active animations.
advance_card_animations,
// Flush deferred commands so `CardAnimation` removals from
// `advance_card_animations` are visible before the chain
// system runs. Without this, the chain sees the component
// still present in the same frame it was removed (deferred
// commands aren't applied until the next ApplyDeferred
// point), causing a 1-frame gap between every chain step.
ApplyDeferred,
// After each animation finishes, pop the next chain segment.
advance_animation_chains,
// Interaction visuals (run after animation for 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: MessageReader<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
);
}
/// Regression test for the v0.40.0 Android animation-lag bug: commit
/// 38e4c03 switched Android to `reactive_low_power` focused_mode on the
/// premise that animation systems write `RequestRedraw` while active,
/// but the writers were never added — card slides rendered at the 100 ms
/// wake ceiling (~10 fps). Active animations MUST emit `RequestRedraw`
/// every frame; an idle board must not.
#[test]
fn active_card_animation_requests_redraw() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(CardAnimationPlugin);
// Idle board: no redraw requests.
app.update();
assert!(
app.world().resource::<Messages<RequestRedraw>>().is_empty(),
"no RequestRedraw expected while no animation is active"
);
app.world_mut().spawn((
Transform::from_translation(Vec3::ZERO),
CardAnimation {
start: Vec2::ZERO,
end: Vec2::new(100.0, 0.0),
elapsed: 0.0,
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,
},
));
app.update();
assert!(
!app.world().resource::<Messages<RequestRedraw>>().is_empty(),
"an active CardAnimation must write RequestRedraw each frame to \
sustain the reactive render loop"
);
}
#[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"
);
}
}
}
}