b26200f948
Test / test (pull_request) Failing after 18s
Three items the multi-agent sweep flagged, now removed with user approval (§8 for the solitaire_sync changes): - WinCascadePlugin: never registered; handle_win_cascade in AnimationPlugin is the live win cascade and builds its own targets. Its now-orphaned helpers (win_scatter_targets, cascade_delay, WIN_CASCADE_INTERVAL_SECS) had no callers outside their own tests and go with it. - SyncCompleteEvent: written by the pull-completion system, zero readers — UI reads SyncStatusResource instead. - solitaire_sync::ApiError: unused by client and server; the merge_at crate-root re-export goes too (merge::merge_at stays for the merge module's own use). ARCHITECTURE.md updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
367 lines
13 KiB
Rust
367 lines
13 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
|
|
//! ));
|
|
//! ```
|
|
//!
|
|
//! # 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;
|
|
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, compute_duration, micro_vary,
|
|
};
|
|
pub use tuning::{AnimationTuning, InputPlatform};
|
|
|
|
use bevy::prelude::*;
|
|
use bevy::window::RequestRedraw;
|
|
|
|
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
|
use crate::game_plugin::GameMutation;
|
|
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),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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());
|
|
}
|
|
}
|