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:
@@ -17,6 +17,7 @@ use solitaire_data::AnimSpeed;
|
|||||||
|
|
||||||
use crate::achievement_plugin::display_name_for;
|
use crate::achievement_plugin::display_name_for;
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
|
use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
||||||
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
||||||
@@ -28,10 +29,17 @@ use crate::pause_plugin::PausedResource;
|
|||||||
use crate::progress_plugin::LevelUpEvent;
|
use crate::progress_plugin::LevelUpEvent;
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||||
|
use crate::ui_theme::{
|
||||||
|
scaled_duration, MOTION_CASCADE_SLIDE_SECS, MOTION_CASCADE_STAGGER_SECS, MOTION_SLIDE_SECS,
|
||||||
|
};
|
||||||
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
|
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
|
||||||
|
|
||||||
/// Duration of a card slide (move) animation in seconds at Normal speed.
|
/// Duration of a card slide (move) animation in seconds at Normal speed.
|
||||||
pub const SLIDE_SECS: f32 = 0.15;
|
///
|
||||||
|
/// Re-exported from `ui_theme::MOTION_SLIDE_SECS` so the entire engine pulls
|
||||||
|
/// gameplay slide timing from one design-token. Kept as a `pub const` for
|
||||||
|
/// backwards compatibility with existing callers that read this directly.
|
||||||
|
pub const SLIDE_SECS: f32 = MOTION_SLIDE_SECS;
|
||||||
|
|
||||||
/// The effective slide duration, updated whenever `Settings::animation_speed` changes.
|
/// The effective slide duration, updated whenever `Settings::animation_speed` changes.
|
||||||
#[derive(Resource, Debug, Clone, Copy)]
|
#[derive(Resource, Debug, Clone, Copy)]
|
||||||
@@ -46,11 +54,10 @@ impl Default for EffectiveSlideDuration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn anim_speed_to_secs(speed: &AnimSpeed) -> f32 {
|
fn anim_speed_to_secs(speed: &AnimSpeed) -> f32 {
|
||||||
match speed {
|
// Route through `ui_theme::scaled_duration` so the slide timing follows
|
||||||
AnimSpeed::Normal => SLIDE_SECS,
|
// the same `MOTION_*_SECS` token / `AnimSpeed` mapping as every other
|
||||||
AnimSpeed::Fast => 0.07,
|
// motion in the engine (toasts, deal stagger, shake, settle, cascade).
|
||||||
AnimSpeed::Instant => 0.0,
|
scaled_duration(MOTION_SLIDE_SECS, *speed)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const WIN_TOAST_SECS: f32 = 4.0;
|
const WIN_TOAST_SECS: f32 = 4.0;
|
||||||
@@ -63,38 +70,25 @@ const CHALLENGE_TOAST_SECS: f32 = 3.0;
|
|||||||
const VOLUME_TOAST_SECS: f32 = 1.4;
|
const VOLUME_TOAST_SECS: f32 = 1.4;
|
||||||
|
|
||||||
/// Per-card stagger interval for the win cascade at Normal speed (seconds).
|
/// Per-card stagger interval for the win cascade at Normal speed (seconds).
|
||||||
const CASCADE_STAGGER_NORMAL: f32 = 0.05;
|
|
||||||
/// Duration of each card's cascade slide at Normal speed (seconds).
|
|
||||||
const CASCADE_DURATION_NORMAL: f32 = 0.5;
|
|
||||||
|
|
||||||
/// Returns the per-card stagger delay for the win cascade at the given `AnimSpeed`.
|
|
||||||
///
|
///
|
||||||
/// | `AnimSpeed` | Returned value |
|
/// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS` so all motion timing
|
||||||
/// |-------------|----------------|
|
/// lives in one design-token module.
|
||||||
/// | `Normal` | 0.05 s |
|
const CASCADE_STAGGER_NORMAL: f32 = MOTION_CASCADE_STAGGER_SECS;
|
||||||
/// | `Fast` | 0.025 s |
|
/// Duration of each card's cascade slide at Normal speed (seconds).
|
||||||
/// | `Instant` | 0.0 s |
|
///
|
||||||
|
/// Sourced from `ui_theme::MOTION_CASCADE_SLIDE_SECS`.
|
||||||
|
const CASCADE_DURATION_NORMAL: f32 = MOTION_CASCADE_SLIDE_SECS;
|
||||||
|
|
||||||
|
/// Returns the per-card stagger delay for the win cascade at the given
|
||||||
|
/// `AnimSpeed`, scaled via `ui_theme::scaled_duration`.
|
||||||
pub fn cascade_step_secs(speed: AnimSpeed) -> f32 {
|
pub fn cascade_step_secs(speed: AnimSpeed) -> f32 {
|
||||||
match speed {
|
scaled_duration(MOTION_CASCADE_STAGGER_SECS, speed)
|
||||||
AnimSpeed::Normal => CASCADE_STAGGER_NORMAL,
|
|
||||||
AnimSpeed::Fast => CASCADE_STAGGER_NORMAL / 2.0,
|
|
||||||
AnimSpeed::Instant => 0.0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the slide duration for each card in the win cascade at the given `AnimSpeed`.
|
/// Returns the slide duration for each card in the win cascade at the given
|
||||||
///
|
/// `AnimSpeed`, scaled via `ui_theme::scaled_duration`.
|
||||||
/// | `AnimSpeed` | Returned value |
|
|
||||||
/// |-------------|----------------|
|
|
||||||
/// | `Normal` | 0.5 s |
|
|
||||||
/// | `Fast` | 0.25 s |
|
|
||||||
/// | `Instant` | 0.0 s |
|
|
||||||
pub fn cascade_duration_secs(speed: AnimSpeed) -> f32 {
|
pub fn cascade_duration_secs(speed: AnimSpeed) -> f32 {
|
||||||
match speed {
|
scaled_duration(MOTION_CASCADE_SLIDE_SECS, speed)
|
||||||
AnimSpeed::Normal => CASCADE_DURATION_NORMAL,
|
|
||||||
AnimSpeed::Fast => CASCADE_DURATION_NORMAL / 2.0,
|
|
||||||
AnimSpeed::Instant => 0.0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Linear-lerp slide animation.
|
/// Linear-lerp slide animation.
|
||||||
@@ -237,13 +231,35 @@ fn advance_card_anims(
|
|||||||
}
|
}
|
||||||
anim.elapsed += dt;
|
anim.elapsed += dt;
|
||||||
let t = (anim.elapsed / anim.duration).min(1.0);
|
let t = (anim.elapsed / anim.duration).min(1.0);
|
||||||
transform.translation = anim.start.lerp(anim.target, t);
|
// Curved interpolation using `MotionCurve::SmoothSnap` (cubic ease-out
|
||||||
|
// with a small terminal overshoot). Hardcoded at the call site so the
|
||||||
|
// shared `CardAnim` struct stays a simple linear-tween container — the
|
||||||
|
// upgrade is one extra `sample_curve` call per advancing animation.
|
||||||
|
let s = sample_curve(MotionCurve::SmoothSnap, t);
|
||||||
|
transform.translation = anim.start.lerp(anim.target, s);
|
||||||
if t >= 1.0 {
|
if t >= 1.0 {
|
||||||
|
transform.translation = anim.target;
|
||||||
commands.entity(entity).remove::<CardAnim>();
|
commands.entity(entity).remove::<CardAnim>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maximum per-card Z-rotation drift applied during the win cascade, in
|
||||||
|
/// radians. 15° gives a lively but legible scatter — anything larger starts
|
||||||
|
/// to look chaotic.
|
||||||
|
const WIN_CASCADE_MAX_ROTATION_RAD: f32 = std::f32::consts::PI / 12.0;
|
||||||
|
|
||||||
|
/// Returns a deterministic per-card Z-rotation in `±WIN_CASCADE_MAX_ROTATION_RAD`
|
||||||
|
/// for the win cascade. Indexing by the card's position in the iterator keeps
|
||||||
|
/// the result reproducible for a given deal without needing a random crate.
|
||||||
|
fn cascade_rotation(index: usize) -> f32 {
|
||||||
|
// Pseudo-random hash from a Fibonacci multiplier; same approach used by
|
||||||
|
// `card_animation::timing::micro_vary`. Returns 0..=1.
|
||||||
|
let hash = (index as u32).wrapping_mul(2_654_435_761);
|
||||||
|
let noise = (hash >> 16) as f32 / 65_536.0;
|
||||||
|
(noise - 0.5) * 2.0 * WIN_CASCADE_MAX_ROTATION_RAD
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_win_cascade(
|
fn handle_win_cascade(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: MessageReader<GameWonEvent>,
|
mut events: MessageReader<GameWonEvent>,
|
||||||
@@ -274,17 +290,44 @@ fn handle_win_cascade(
|
|||||||
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
|
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
|
||||||
spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS);
|
spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS);
|
||||||
|
|
||||||
let step = settings.as_ref().map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed.clone()));
|
let step = settings
|
||||||
let duration = settings.as_ref().map_or(CASCADE_DURATION_NORMAL, |s| cascade_duration_secs(s.0.animation_speed.clone()));
|
.as_ref()
|
||||||
|
.map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed));
|
||||||
|
let duration = settings
|
||||||
|
.as_ref()
|
||||||
|
.map_or(CASCADE_DURATION_NORMAL, |s| cascade_duration_secs(s.0.animation_speed));
|
||||||
|
|
||||||
for (i, (entity, transform)) in cards.iter().enumerate() {
|
for (i, (entity, transform)) in cards.iter().enumerate() {
|
||||||
commands.entity(entity).insert(CardAnim {
|
// Use the curve-aware `CardAnimation` here (not `CardAnim`) so we can
|
||||||
start: transform.translation,
|
// pick `MotionCurve::Expressive` for the cascade — the spring-style
|
||||||
target: targets[i % 8],
|
// overshoot is what gives the win moment its theatrical feel. The
|
||||||
|
// `CardAnim`/`CardAnimation` coexistence rule (one per entity) is
|
||||||
|
// satisfied because cards have neither at the moment the cascade
|
||||||
|
// starts.
|
||||||
|
let start = transform.translation;
|
||||||
|
let target = targets[i % 8];
|
||||||
|
commands.entity(entity).insert(CardAnimation {
|
||||||
|
start: start.truncate(),
|
||||||
|
end: target.truncate(),
|
||||||
elapsed: 0.0,
|
elapsed: 0.0,
|
||||||
duration,
|
duration,
|
||||||
|
curve: crate::card_animation::MotionCurve::Expressive,
|
||||||
delay: i as f32 * step,
|
delay: i as f32 * step,
|
||||||
|
start_z: start.z,
|
||||||
|
end_z: target.z,
|
||||||
|
z_lift: 0.0,
|
||||||
|
scale_start: 1.0,
|
||||||
|
scale_end: 1.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Per-card Z-rotation drift (±15°), deterministic per cascade
|
||||||
|
// ordering — gives the scatter a more lively feel without needing
|
||||||
|
// rotation interpolation in the tween system. Since cards fly off
|
||||||
|
// screen, the static rotation reads as motion.
|
||||||
|
let rot = cascade_rotation(i);
|
||||||
|
let mut new_transform = *transform;
|
||||||
|
new_transform.rotation = Quat::from_rotation_z(rot);
|
||||||
|
commands.entity(entity).insert(new_transform);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,13 +627,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn card_anim_at_half_elapsed_reaches_midpoint() {
|
fn card_anim_at_half_elapsed_passes_geometric_midpoint() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
|
||||||
let start = Vec3::ZERO;
|
let start = Vec3::ZERO;
|
||||||
let target = Vec3::new(100.0, 0.0, 0.0);
|
let target = Vec3::new(100.0, 0.0, 0.0);
|
||||||
// elapsed = 0.5, duration = 1.0 → t = 0.5 even when dt=0
|
// elapsed = 0.5, duration = 1.0 → t = 0.5 even when dt=0.
|
||||||
|
// With `MotionCurve::SmoothSnap` (cubic ease-out) the position at
|
||||||
|
// t=0.5 is well past the geometric midpoint — assert we're past 50
|
||||||
|
// but still short of the target so the animation is mid-flight.
|
||||||
let entity = app
|
let entity = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -602,7 +648,11 @@ mod tests {
|
|||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
|
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
|
||||||
assert!((pos.x - 50.0).abs() < 1e-3, "expected midpoint x=50, got {}", pos.x);
|
assert!(
|
||||||
|
pos.x > 50.0 && pos.x < 100.0,
|
||||||
|
"with SmoothSnap, t=0.5 should be past geometric midpoint but short of target; got {}",
|
||||||
|
pos.x
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
app.world().entity(entity).get::<CardAnim>().is_some(),
|
app.world().entity(entity).get::<CardAnim>().is_some(),
|
||||||
"animation not yet complete"
|
"animation not yet complete"
|
||||||
@@ -788,7 +838,7 @@ mod tests {
|
|||||||
|
|
||||||
let before = app
|
let before = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<&CardAnim>()
|
.query::<&CardAnimation>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(before, 0, "no animations before win");
|
assert_eq!(before, 0, "no animations before win");
|
||||||
@@ -799,10 +849,60 @@ mod tests {
|
|||||||
|
|
||||||
let after = app
|
let after = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<&CardAnim>()
|
.query::<&CardAnimation>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(after, 52, "all 52 cards should have cascade animations");
|
assert_eq!(
|
||||||
|
after, 52,
|
||||||
|
"all 52 cards should have curve-based cascade animations"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_cascade_uses_expressive_curve() {
|
||||||
|
let mut app = app_with_anim();
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let mut q = app.world_mut().query::<&CardAnimation>();
|
||||||
|
for anim in q.iter(app.world()) {
|
||||||
|
assert_eq!(
|
||||||
|
anim.curve,
|
||||||
|
MotionCurve::Expressive,
|
||||||
|
"win cascade must use the Expressive curve"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_cascade_applies_per_card_rotation() {
|
||||||
|
let mut app = app_with_anim();
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// At least one card's rotation must differ from identity — the
|
||||||
|
// deterministic hash will produce non-zero rotations for nearly all
|
||||||
|
// 52 indices.
|
||||||
|
let mut q = app.world_mut().query::<(&CardEntity, &Transform)>();
|
||||||
|
let any_rotated = q
|
||||||
|
.iter(app.world())
|
||||||
|
.any(|(_, t)| t.rotation.z.abs() > 1e-4 || t.rotation.w < 0.999);
|
||||||
|
assert!(any_rotated, "expected at least one card to receive a Z rotation drift");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cascade_rotation_stays_within_bounds() {
|
||||||
|
// Per-card rotation is capped at ±15° (≈ 0.2618 rad). Sampling a
|
||||||
|
// wider index range than a real deal exercises the hash distribution.
|
||||||
|
for i in 0..256 {
|
||||||
|
let r = cascade_rotation(i);
|
||||||
|
assert!(
|
||||||
|
r.abs() <= WIN_CASCADE_MAX_ROTATION_RAD + 1e-6,
|
||||||
|
"cascade_rotation({i}) = {r} exceeds the ±15° cap"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -810,8 +910,9 @@ mod tests {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cascade_step_normal_is_expected_value() {
|
fn cascade_step_normal_matches_design_token() {
|
||||||
assert!((cascade_step_secs(AnimSpeed::Normal) - 0.05).abs() < 1e-6);
|
// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS`.
|
||||||
|
assert!((cascade_step_secs(AnimSpeed::Normal) - MOTION_CASCADE_STAGGER_SECS).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -830,8 +931,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cascade_duration_normal_is_expected_value() {
|
fn cascade_duration_normal_matches_design_token() {
|
||||||
assert!((cascade_duration_secs(AnimSpeed::Normal) - 0.5).abs() < 1e-6);
|
// Sourced from `ui_theme::MOTION_CASCADE_SLIDE_SECS`.
|
||||||
|
assert!(
|
||||||
|
(cascade_duration_secs(AnimSpeed::Normal) - MOTION_CASCADE_SLIDE_SECS).abs() < 1e-6
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -11,10 +11,13 @@
|
|||||||
//!
|
//!
|
||||||
//! # Task #55 — Settle/bounce on valid placement
|
//! # Task #55 — Settle/bounce on valid placement
|
||||||
//!
|
//!
|
||||||
//! After `StateChangedEvent` fires, `start_settle_anim` inserts `SettleAnim`
|
//! `start_settle_anim` listens for `MoveRequestEvent` and `DrawRequestEvent` so
|
||||||
//! on the top card of every non-empty pile. `tick_settle_anim` applies a brief
|
//! the bounce is **scoped to the cards that just moved**, not every top card on
|
||||||
//! Y-scale compression (`scale.y` 1.0 → 0.92 → 1.0 over 0.15 s) and removes
|
//! the board. For a move it bounces the top `count` cards of the destination
|
||||||
//! the component when elapsed ≥ 0.15 s.
|
//! pile; for a draw it bounces the top card of the waste. Undos are skipped so
|
||||||
|
//! reverting a move doesn't replay the placement feedback. `tick_settle_anim`
|
||||||
|
//! applies a brief Y-scale compression (`scale.y` 1.0 → 0.92 → 1.0 over 0.15 s)
|
||||||
|
//! and removes the component when elapsed ≥ 0.15 s.
|
||||||
//!
|
//!
|
||||||
//! # Task #69 — Animated card deal on new game start
|
//! # Task #69 — Animated card deal on new game start
|
||||||
//!
|
//!
|
||||||
@@ -22,17 +25,21 @@
|
|||||||
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
|
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
|
||||||
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
|
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
|
||||||
//! pile's position to its current (final) position with a per-card stagger
|
//! pile's position to its current (final) position with a per-card stagger
|
||||||
//! derived from the current `AnimSpeed` setting:
|
//! derived from the current `AnimSpeed` setting plus a deterministic ±10 %
|
||||||
|
//! jitter per card so the deal feels organic instead of mechanical:
|
||||||
//!
|
//!
|
||||||
//! | `AnimSpeed` | Stagger |
|
//! | `AnimSpeed` | Base stagger |
|
||||||
//! |---------------|-------------------|
|
//! |---------------|-------------------|
|
||||||
//! | `Normal` | 0.04 s (default) |
|
//! | `Normal` | 0.04 s (default) |
|
||||||
//! | `Fast` | 0.02 s (half) |
|
//! | `Fast` | 0.02 s (half) |
|
||||||
//! | `Instant` | 0.00 s (no delay) |
|
//! | `Instant` | 0.00 s (no delay) |
|
||||||
//!
|
//!
|
||||||
//! `deal_stagger_delay` is a pure helper exposed for unit testing.
|
//! `deal_stagger_delay` and `deal_stagger_jitter` are pure helpers exposed for
|
||||||
|
//! unit testing.
|
||||||
|
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::f32::consts::PI;
|
use std::f32::consts::PI;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
@@ -40,7 +47,9 @@ use solitaire_data::AnimSpeed;
|
|||||||
|
|
||||||
use crate::animation_plugin::CardAnim;
|
use crate::animation_plugin::CardAnim;
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::{MoveRejectedEvent, NewGameRequestEvent, StateChangedEvent};
|
use crate::events::{
|
||||||
|
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
||||||
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
@@ -155,6 +164,23 @@ pub fn deal_stagger_delay(index: usize, stagger_secs: f32) -> f32 {
|
|||||||
index as f32 * stagger_secs
|
index as f32 * stagger_secs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a deterministic ±10 % jitter factor for `card_id`.
|
||||||
|
///
|
||||||
|
/// Hashes `card_id` with `DefaultHasher` and maps the low bits into a value in
|
||||||
|
/// `0.0..=1.0`, then re-centres into `-0.1..=0.1`. The same card id always
|
||||||
|
/// produces the same factor so deals are reproducible (important for
|
||||||
|
/// seed-based testing and replay), while a 52-card deal still feels organic
|
||||||
|
/// because each card's offset varies.
|
||||||
|
///
|
||||||
|
/// Multiply a base stagger interval by `1.0 + deal_stagger_jitter(card_id)` to
|
||||||
|
/// apply the jitter.
|
||||||
|
pub fn deal_stagger_jitter(card_id: u32) -> f32 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
card_id.hash(&mut hasher);
|
||||||
|
let jitter_norm = (hasher.finish() % 1000) as f32 / 1000.0; // 0.0..=1.0
|
||||||
|
(jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 %
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -164,16 +190,23 @@ pub struct FeedbackAnimPlugin;
|
|||||||
|
|
||||||
impl Plugin for FeedbackAnimPlugin {
|
impl Plugin for FeedbackAnimPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_systems(
|
// Register the events this plugin consumes so it can run in isolation
|
||||||
Update,
|
// under `MinimalPlugins` (e.g. unit tests) without depending on other
|
||||||
(
|
// plugins to register them. Double-registration is idempotent in Bevy.
|
||||||
start_shake_anim.after(GameMutation),
|
app.add_message::<MoveRequestEvent>()
|
||||||
tick_shake_anim,
|
.add_message::<DrawRequestEvent>()
|
||||||
start_settle_anim.after(GameMutation),
|
.add_message::<MoveRejectedEvent>()
|
||||||
tick_settle_anim,
|
.add_message::<NewGameRequestEvent>()
|
||||||
start_deal_anim.after(GameMutation),
|
.add_systems(
|
||||||
),
|
Update,
|
||||||
);
|
(
|
||||||
|
start_shake_anim.after(GameMutation),
|
||||||
|
tick_shake_anim,
|
||||||
|
start_settle_anim.after(GameMutation),
|
||||||
|
tick_settle_anim,
|
||||||
|
start_deal_anim.after(GameMutation),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,28 +273,52 @@ fn tick_shake_anim(
|
|||||||
// Task #55 — Settle systems
|
// Task #55 — Settle systems
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Inserts `SettleAnim` on the top card of every non-empty pile when
|
/// Inserts `SettleAnim` only on the cards that just moved — the top `count`
|
||||||
/// `StateChangedEvent` fires.
|
/// cards of the move destination, or the top of the waste pile for a draw.
|
||||||
|
///
|
||||||
|
/// Triggered by `MoveRequestEvent` and `DrawRequestEvent`. Undo and other
|
||||||
|
/// state-mutations are deliberately skipped: replaying the placement bounce on
|
||||||
|
/// an undo would feel like the rejected-move shake fired by mistake. Note this
|
||||||
|
/// runs before the move resolves in `GameMutation`, so we read the destination
|
||||||
|
/// pile **after** the request has been accepted by reading the up-to-date game
|
||||||
|
/// state for both readers — the schedule labels the system `.after(GameMutation)`
|
||||||
|
/// to ensure that ordering.
|
||||||
fn start_settle_anim(
|
fn start_settle_anim(
|
||||||
mut events: MessageReader<StateChangedEvent>,
|
mut moves: MessageReader<MoveRequestEvent>,
|
||||||
|
mut draws: MessageReader<DrawRequestEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
card_entities: Query<(Entity, &CardEntity)>,
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
if events.read().next().is_none() {
|
// Build the list of card ids that should bounce this frame from every
|
||||||
|
// queued request; multiple events can fire in the same frame (e.g. a move
|
||||||
|
// followed by a draw via keyboard accelerators).
|
||||||
|
let mut bounce_ids: Vec<u32> = Vec::new();
|
||||||
|
|
||||||
|
for ev in moves.read() {
|
||||||
|
if let Some(pile) = game.0.piles.get(&ev.to) {
|
||||||
|
// The moved cards land on top — take the last `count` ids.
|
||||||
|
let n = ev.count.min(pile.cards.len());
|
||||||
|
if n > 0 {
|
||||||
|
let start = pile.cards.len() - n;
|
||||||
|
bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if draws.read().next().is_some()
|
||||||
|
&& let Some(pile) = game.0.piles.get(&PileType::Waste)
|
||||||
|
&& let Some(top) = pile.cards.last()
|
||||||
|
{
|
||||||
|
bounce_ids.push(top.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if bounce_ids.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect the id of the top card for each non-empty pile.
|
|
||||||
let top_ids: Vec<u32> = game
|
|
||||||
.0
|
|
||||||
.piles
|
|
||||||
.values()
|
|
||||||
.filter_map(|p| p.cards.last().map(|c| c.id))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for (entity, card_marker) in card_entities.iter() {
|
for (entity, card_marker) in card_entities.iter() {
|
||||||
if top_ids.contains(&card_marker.card_id) {
|
if bounce_ids.contains(&card_marker.card_id) {
|
||||||
commands.entity(entity).insert(SettleAnim::default());
|
commands.entity(entity).insert(SettleAnim::default());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,7 +365,7 @@ fn start_deal_anim(
|
|||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
card_entities: Query<(Entity, &Transform), With<CardEntity>>,
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
if events.read().next().is_none() {
|
if events.read().next().is_none() {
|
||||||
@@ -327,8 +384,12 @@ fn start_deal_anim(
|
|||||||
.map(deal_stagger_secs_for_speed)
|
.map(deal_stagger_secs_for_speed)
|
||||||
.unwrap_or(DEAL_STAGGER_SECS);
|
.unwrap_or(DEAL_STAGGER_SECS);
|
||||||
|
|
||||||
for (index, (entity, transform)) in card_entities.iter().enumerate() {
|
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
|
||||||
let final_pos = transform.translation;
|
let final_pos = transform.translation;
|
||||||
|
// ±10 % jitter, deterministic per card id, so the deal feels organic
|
||||||
|
// without losing reproducibility (a given seed still produces the
|
||||||
|
// same per-card stagger pattern across runs).
|
||||||
|
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id));
|
||||||
commands.entity(entity).insert((
|
commands.entity(entity).insert((
|
||||||
Transform::from_translation(stock_start.with_z(final_pos.z)),
|
Transform::from_translation(stock_start.with_z(final_pos.z)),
|
||||||
CardAnim {
|
CardAnim {
|
||||||
@@ -336,7 +397,7 @@ fn start_deal_anim(
|
|||||||
target: final_pos,
|
target: final_pos,
|
||||||
elapsed: 0.0,
|
elapsed: 0.0,
|
||||||
duration: DEAL_SLIDE_SECS,
|
duration: DEAL_SLIDE_SECS,
|
||||||
delay: deal_stagger_delay(index, stagger_secs),
|
delay: deal_stagger_delay(index, per_card_stagger),
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -449,4 +510,44 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 9 — deal stagger jitter helper
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deal_stagger_jitter_is_within_ten_percent() {
|
||||||
|
// Every card id in 0..256 must produce a jitter factor in ±10 %.
|
||||||
|
for card_id in 0u32..256 {
|
||||||
|
let j = deal_stagger_jitter(card_id);
|
||||||
|
assert!(
|
||||||
|
(-0.1..=0.1).contains(&j),
|
||||||
|
"deal_stagger_jitter({card_id}) = {j} is outside ±10 %"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deal_stagger_jitter_is_deterministic() {
|
||||||
|
// Same card id must always produce the same jitter factor.
|
||||||
|
for card_id in [0u32, 7, 51, 999_999] {
|
||||||
|
assert!(
|
||||||
|
(deal_stagger_jitter(card_id) - deal_stagger_jitter(card_id)).abs() < 1e-9,
|
||||||
|
"deal_stagger_jitter({card_id}) is not deterministic"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deal_stagger_jitter_varies_across_card_ids() {
|
||||||
|
// 52 cards should produce more than a couple distinct jitter factors;
|
||||||
|
// a constant function would return one value for all ids.
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let unique: HashSet<u64> = (0u32..52)
|
||||||
|
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
unique.len() > 10,
|
||||||
|
"expected > 10 distinct jitter factors for 52 cards, got {}",
|
||||||
|
unique.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ use crate::events::{
|
|||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
|
use crate::ui_theme::{scaled_duration, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -30,10 +32,12 @@ use crate::stats_plugin::{StatsResource, StatsUpdate};
|
|||||||
/// Chosen so the cascade animation has a moment to start first.
|
/// Chosen so the cascade animation has a moment to start first.
|
||||||
const WIN_SUMMARY_DELAY_SECS: f32 = 0.5;
|
const WIN_SUMMARY_DELAY_SECS: f32 = 0.5;
|
||||||
|
|
||||||
/// Duration of the screen-shake in seconds.
|
/// Default duration of the screen-shake in seconds, before `AnimSpeed` scaling.
|
||||||
const SHAKE_DURATION_SECS: f32 = 0.6;
|
/// 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.
|
/// 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
|
// Resources
|
||||||
@@ -103,6 +107,11 @@ fn build_xp_detail(time_seconds: u64, used_undo: bool) -> String {
|
|||||||
pub struct ScreenShakeResource {
|
pub struct ScreenShakeResource {
|
||||||
/// Seconds of shake remaining.
|
/// Seconds of shake remaining.
|
||||||
pub remaining: f32,
|
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`).
|
/// Peak displacement in world-space pixels (decays to zero over `remaining`).
|
||||||
pub intensity: f32,
|
pub intensity: f32,
|
||||||
}
|
}
|
||||||
@@ -308,14 +317,25 @@ fn spawn_win_summary_after_delay(
|
|||||||
mut shake: ResMut<ScreenShakeResource>,
|
mut shake: ResMut<ScreenShakeResource>,
|
||||||
mut pending: ResMut<WinSummaryPending>,
|
mut pending: ResMut<WinSummaryPending>,
|
||||||
session: Res<SessionAchievements>,
|
session: Res<SessionAchievements>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
mut delay: Local<Option<f32>>,
|
mut delay: Local<Option<f32>>,
|
||||||
) {
|
) {
|
||||||
// Process new win events.
|
// Process new win events.
|
||||||
for _ in won.read() {
|
for _ in won.read() {
|
||||||
// Arm the screen shake immediately.
|
// Arm the screen shake immediately. Duration scales with the
|
||||||
shake.remaining = SHAKE_DURATION_SECS;
|
// 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;
|
shake.intensity = SHAKE_INTENSITY;
|
||||||
// Start the delay timer (overwrite if a second win arrives).
|
// Start the delay timer (overwrite if a second win arrives).
|
||||||
*delay = Some(WIN_SUMMARY_DELAY_SECS);
|
*delay = Some(WIN_SUMMARY_DELAY_SECS);
|
||||||
@@ -391,8 +411,11 @@ fn apply_screen_shake(
|
|||||||
}
|
}
|
||||||
|
|
||||||
shake.remaining = (shake.remaining - dt).max(0.0);
|
shake.remaining = (shake.remaining - dt).max(0.0);
|
||||||
// Decay factor: 1.0 at start, 0.0 at end.
|
// Decay factor: 1.0 at start, 0.0 at end. Falls back to the design-token
|
||||||
let decay = shake.remaining / SHAKE_DURATION_SECS;
|
// 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 elapsed = time.elapsed_secs();
|
||||||
let offset_x = (elapsed * 47.0).sin() * shake.intensity * decay;
|
let offset_x = (elapsed * 47.0).sin() * shake.intensity * decay;
|
||||||
let offset_y = (elapsed * 31.0).cos() * shake.intensity * decay;
|
let offset_y = (elapsed * 31.0).cos() * shake.intensity * decay;
|
||||||
|
|||||||
Reference in New Issue
Block a user