feat(engine): upgrade animations — curves, scoped settle, deal jitter, cascade rotation

Slide animations now interpolate through MotionCurve::SmoothSnap via
sample_curve() at the call site (no struct field added). Slide and
cascade durations route through ui_theme::scaled_duration with
MOTION_SLIDE_SECS / MOTION_CASCADE_STAGGER_SECS / MOTION_CASCADE_SLIDE_SECS.

Settle bounce in feedback_anim_plugin scoped to MoveRequestEvent and
DrawRequestEvent receivers — only the top `count` cards of the
destination pile (or top of waste) bounce; undo and other state
changes no longer trigger a global all-tops settle.

Deal stagger gains a deterministic ±10% jitter via DefaultHasher on
card_id (no rand dep). Per-card stagger = base * (1.0 + jitter).

Win cascade switched from CardAnim to CardAnimation with
MotionCurve::Expressive and a deterministic ±15° per-card Z-rotation
via Fibonacci hash. Win screen shake routes through
MOTION_WIN_SHAKE_SECS / MOTION_WIN_SHAKE_AMPLITUDE; ScreenShakeResource
gained a `total` field so decay computes correctly under Fast / Instant.

cargo build / clippy --workspace -- -D warnings / test --workspace
all green (819 passed, 0 failed, 8 ignored).
This commit is contained in:
funman300
2026-04-30 04:38:59 +00:00
parent 79d391724e
commit 3a01318fbd
3 changed files with 318 additions and 90 deletions
+30 -7
View File
@@ -20,7 +20,9 @@ use crate::events::{
use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_theme::{scaled_duration, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS};
// ---------------------------------------------------------------------------
// Constants
@@ -30,10 +32,12 @@ use crate::stats_plugin::{StatsResource, StatsUpdate};
/// Chosen so the cascade animation has a moment to start first.
const WIN_SUMMARY_DELAY_SECS: f32 = 0.5;
/// Duration of the screen-shake in seconds.
const SHAKE_DURATION_SECS: f32 = 0.6;
/// Default duration of the screen-shake in seconds, before `AnimSpeed` scaling.
/// Sourced from `ui_theme::MOTION_WIN_SHAKE_SECS`.
const SHAKE_DURATION_SECS: f32 = MOTION_WIN_SHAKE_SECS;
/// Maximum camera displacement in world-space pixels at the start of the shake.
const SHAKE_INTENSITY: f32 = 8.0;
/// Sourced from `ui_theme::MOTION_WIN_SHAKE_AMPLITUDE`.
const SHAKE_INTENSITY: f32 = MOTION_WIN_SHAKE_AMPLITUDE;
// ---------------------------------------------------------------------------
// Resources
@@ -103,6 +107,11 @@ fn build_xp_detail(time_seconds: u64, used_undo: bool) -> String {
pub struct ScreenShakeResource {
/// Seconds of shake remaining.
pub remaining: f32,
/// Total duration the shake was armed for, used to compute the
/// `remaining / total` decay factor. Tracked separately from `remaining`
/// because the duration is now scaled by `AnimSpeed`, so a fixed
/// divisor would be wrong on Fast.
pub total: f32,
/// Peak displacement in world-space pixels (decays to zero over `remaining`).
pub intensity: f32,
}
@@ -308,14 +317,25 @@ fn spawn_win_summary_after_delay(
mut shake: ResMut<ScreenShakeResource>,
mut pending: ResMut<WinSummaryPending>,
session: Res<SessionAchievements>,
settings: Option<Res<SettingsResource>>,
time: Res<Time>,
overlays: Query<Entity, With<WinSummaryOverlay>>,
mut delay: Local<Option<f32>>,
) {
// Process new win events.
for _ in won.read() {
// Arm the screen shake immediately.
shake.remaining = SHAKE_DURATION_SECS;
// Arm the screen shake immediately. Duration scales with the
// player's `AnimSpeed` preference via `ui_theme::scaled_duration`;
// intensity is left at its design-token value because amplitude
// does not benefit from "fast" / "instant" scaling — at Instant
// speed the duration is zero anyway, suppressing the shake.
let speed = settings.as_ref().map_or(
solitaire_data::AnimSpeed::Normal,
|s| s.0.animation_speed,
);
let scaled = scaled_duration(SHAKE_DURATION_SECS, speed);
shake.remaining = scaled;
shake.total = scaled;
shake.intensity = SHAKE_INTENSITY;
// Start the delay timer (overwrite if a second win arrives).
*delay = Some(WIN_SUMMARY_DELAY_SECS);
@@ -391,8 +411,11 @@ fn apply_screen_shake(
}
shake.remaining = (shake.remaining - dt).max(0.0);
// Decay factor: 1.0 at start, 0.0 at end.
let decay = shake.remaining / SHAKE_DURATION_SECS;
// Decay factor: 1.0 at start, 0.0 at end. Falls back to the design-token
// duration if `total` is zero (older armings or test setups that bypass
// `spawn_win_summary_after_delay`) so we never divide by zero.
let total = if shake.total > 0.0 { shake.total } else { SHAKE_DURATION_SECS };
let decay = shake.remaining / total;
let elapsed = time.elapsed_secs();
let offset_x = (elapsed * 47.0).sin() * shake.intensity * decay;
let offset_y = (elapsed * 31.0).cos() * shake.intensity * decay;