Compare commits

..

3 Commits

Author SHA1 Message Date
funman300 eedddb979e feat(engine): add curve-based card animation module
Introduces solitaire_engine::card_animation — a drop-in upgrade over the
existing linear CardAnim. Supports MotionCurve easing, parabolic z-lift,
scale interpolation, delay, retargeting mid-flight, and per-card timing
variation. Coexists with the legacy AnimationPlugin during migration.

Also adds .claude/ to .gitignore so Claude Code local tooling is never
committed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 18:06:58 +00:00
funman300 59a023ed5e chore(workspace): fix all clippy warnings in test code
Resolves 15 violations found by `cargo clippy --workspace --tests -D warnings`:
- Remove unused imports (Card, Rank) in cursor_plugin tests
- Replace absurd i32::MAX comparison with a meaningful >= 0 check
- Use range .contains() instead of manual >= && <= (manual_range_contains)
- Move impl FromRequestParts before test module in middleware.rs (items_after_test_module)
- Move _VEC3_REFERENCED const before test module in input_plugin.rs
- Convert runtime assert on constant to const { assert!(...) }
- Use .contains() instead of .iter().any() for slice membership
- Replace .get(...).is_none() with !.contains_key(...) in HashMap checks
- Collapse Default::default() + field assignment into struct literal initializers
  across solitaire_sync, solitaire_data, and solitaire_engine test helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 18:02:27 +00:00
funman300 8cd28cfb29 feat(engine): right-click highlight timer and visual hint glow (#5, #6)
Task #5: Add RightClickHighlightTimer(1.5 s) so destination highlights
auto-despawn after 1.5 s. Existing clear-on-state-change and
clear-on-pause logic still fires early when a move is made or the game
is paused. Three unit tests cover timer countdown behaviour.

Task #6: Add HintVisualEvent emitted on H key. Source card gets
HintHighlight + HintHighlightTimer(2 s) for a yellow glow. Destination
PileMarker gets HintPileHighlight with a gold tint (Color::srgb(1.0,
0.85, 0.1)) that restores the original colour when the 2 s timer
expires. Five unit tests cover timer expiry and colour invariants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 17:36:23 +00:00
24 changed files with 1788 additions and 96 deletions
+1
View File
@@ -5,3 +5,4 @@
.env .env
*.tmp *.tmp
data/ data/
.claude/
Generated
+30 -30
View File
@@ -1538,9 +1538,9 @@ dependencies = [
[[package]] [[package]]
name = "blake3" name = "blake3"
version = "1.8.4" version = "1.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
dependencies = [ dependencies = [
"arrayref", "arrayref",
"arrayvec", "arrayvec",
@@ -1684,9 +1684,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.60" version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -1998,9 +1998,9 @@ dependencies = [
[[package]] [[package]]
name = "crc-catalog" name = "crc-catalog"
version = "2.4.0" version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
@@ -2108,9 +2108,9 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.10.0" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]] [[package]]
name = "der" name = "der"
@@ -3318,9 +3318,9 @@ dependencies = [
[[package]] [[package]]
name = "idna_adapter" name = "idna_adapter"
version = "1.2.1" version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [ dependencies = [
"icu_normalizer", "icu_normalizer",
"icu_properties", "icu_properties",
@@ -3533,9 +3533,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.95" version = "0.3.97"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@@ -3642,9 +3642,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.185" version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]] [[package]]
name = "libloading" name = "libloading"
@@ -5287,9 +5287,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.39" version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring", "ring",
@@ -5301,9 +5301,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.14.0" version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [ dependencies = [
"web-time", "web-time",
"zeroize", "zeroize",
@@ -6884,9 +6884,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.118" version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -6897,9 +6897,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.68" version = "0.4.70"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -6907,9 +6907,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.118" version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -6917,9 +6917,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.118" version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -6930,9 +6930,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.118" version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -6973,9 +6973,9 @@ dependencies = [
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.95" version = "0.3.97"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
+1 -1
View File
@@ -91,6 +91,6 @@ mod tests {
fn time_bonus_is_capped_at_i32_max_for_huge_values() { fn time_bonus_is_capped_at_i32_max_for_huge_values() {
// Very short elapsed time would overflow without the .min() guard. // Very short elapsed time would overflow without the .min() guard.
let bonus = compute_time_bonus(1); let bonus = compute_time_bonus(1);
assert!(bonus <= i32::MAX, "time bonus must fit in i32"); assert!(bonus >= 0, "time bonus must be non-negative after u64→i32 cast");
} }
} }
+1 -2
View File
@@ -148,8 +148,7 @@ mod tests {
#[test] #[test]
fn add_xp_saturates_on_overflow() { fn add_xp_saturates_on_overflow() {
let mut p = PlayerProgress::default(); let mut p = PlayerProgress { total_xp: u64::MAX - 5, ..Default::default() };
p.total_xp = u64::MAX - 5;
p.add_xp(100); p.add_xp(100);
assert_eq!(p.total_xp, u64::MAX); assert_eq!(p.total_xp, u64::MAX);
} }
+4 -10
View File
@@ -207,8 +207,7 @@ mod tests {
#[test] #[test]
fn adjust_sfx_volume_clamps() { fn adjust_sfx_volume_clamps() {
let mut s = Settings::default(); let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
s.sfx_volume = 0.5;
assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6); assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6);
assert!((s.adjust_sfx_volume(0.5) - 1.0).abs() < 1e-6); assert!((s.adjust_sfx_volume(0.5) - 1.0).abs() < 1e-6);
assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6); assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6);
@@ -217,8 +216,7 @@ mod tests {
#[test] #[test]
fn adjust_music_volume_clamps() { fn adjust_music_volume_clamps() {
let mut s = Settings::default(); let mut s = Settings { music_volume: 0.5, ..Default::default() };
s.music_volume = 0.5;
assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6); assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6);
assert!((s.adjust_music_volume(0.5) - 1.0).abs() < 1e-6); assert!((s.adjust_music_volume(0.5) - 1.0).abs() < 1e-6);
assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6); assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6);
@@ -241,14 +239,10 @@ mod tests {
#[test] #[test]
fn sanitized_clamps_music_volume() { fn sanitized_clamps_music_volume() {
let mut s = Settings::default(); let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized();
s.music_volume = 2.0;
let s = s.sanitized();
assert_eq!(s.music_volume, 1.0); assert_eq!(s.music_volume, 1.0);
let mut s2 = Settings::default(); let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized();
s2.music_volume = -0.5;
let s2 = s2.sanitized();
assert_eq!(s2.music_volume, 0.0); assert_eq!(s2.music_volume, 0.0);
} }
+1 -2
View File
@@ -173,8 +173,7 @@ mod tests {
#[test] #[test]
fn lifetime_score_saturates_at_u64_max() { fn lifetime_score_saturates_at_u64_max() {
let mut s = StatsSnapshot::default(); let mut s = StatsSnapshot { lifetime_score: u64::MAX - 100, ..Default::default() };
s.lifetime_score = u64::MAX - 100;
s.update_on_win(200, 60, &DrawMode::DrawOne); s.update_on_win(200, 60, &DrawMode::DrawOne);
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow"); assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
} }
+1 -2
View File
@@ -775,8 +775,7 @@ mod tests {
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 mut fast_settings = Settings::default(); let fast_settings = Settings { animation_speed: AnimSpeed::Fast, ..Default::default() };
fast_settings.animation_speed = AnimSpeed::Fast;
app.world_mut().send_event(SettingsChangedEvent(fast_settings)); app.world_mut().send_event(SettingsChangedEvent(fast_settings));
app.update(); app.update();
@@ -0,0 +1,372 @@
//! `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 the card is stationary) and starts a fresh
/// `CardAnimation` from that position. Duration is recalculated from the remaining
/// distance so short remaining paths feel appropriately quick.
///
/// # 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) = match current_anim {
Some(anim) => (anim.current_xy(), transform.translation.z),
None => (transform.translation.truncate(), transform.translation.z),
};
let distance = current_xy.distance(new_end);
commands.entity(entity).insert(CardAnimation {
start: current_xy,
end: new_end,
elapsed: 0.0,
duration: compute_duration(distance),
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:?}");
}
}
}
@@ -0,0 +1,196 @@
//! Motion curve definitions for card animations.
//!
//! All curves map `t ∈ [0, 1]` to a position ratio. Curves with overshoot
//! (`SmoothSnap`, `SoftBounce`, `Expressive`) may return values slightly
//! outside `[0, 1]` near the destination — callers should not clamp the output
//! before applying it to a lerp, as the overshoot is intentional.
//!
//! # Curve selection guide
//!
//! | Interaction | Recommended curve |
//! |----------------------|-------------------|
//! | Standard card move | `SmoothSnap` |
//! | Foundation placement | `SoftBounce` |
//! | Invalid snap-back | `Responsive` |
//! | Win cascade | `Expressive` |
use std::f32::consts::PI;
/// Motion curve variant controlling animation easing behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MotionCurve {
/// Cubic ease-out with a 1.5 % terminal overshoot.
///
/// Overshoot is a sine arch in the final 25 % of the animation that peaks
/// ~1.5 % beyond the target, settling cleanly to 1.0 at `t = 1`. Gives a
/// lively, slightly "alive" feel without feeling heavy.
#[default]
SmoothSnap,
/// Underdamped spring (ζ = 0.65, ω = 20 rad/s).
///
/// One visible overshoot of ~8 % followed by fast decay. Good for
/// satisfying "thud" feedback when placing cards on foundations or tableau.
SoftBounce,
/// Quintic ease-out — aggressive deceleration, zero overshoot.
///
/// Starts extremely fast and decelerates hard. Best for snap-back on
/// invalid drops: the card returns instantly without any bounce.
Responsive,
/// Underdamped spring (ζ = 0.45, ω = 18 rad/s).
///
/// Two visible bounces before settling. High visual energy — reserved for
/// win cascade animations where expressivity matters more than subtlety.
Expressive,
}
/// Samples `curve` at normalised time `t ∈ [0, 1]`.
///
/// The return value is the interpolation factor to pass to `Vec2::lerp` /
/// `Vec3::lerp`. Values may slightly exceed 1.0 for curves with overshoot.
#[inline]
pub fn sample_curve(curve: MotionCurve, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
match curve {
MotionCurve::SmoothSnap => smooth_snap(t),
MotionCurve::SoftBounce => soft_bounce(t),
MotionCurve::Responsive => responsive(t),
MotionCurve::Expressive => expressive(t),
}
}
/// Cubic ease-out with a sine-arch overshoot in the final 25 % of `t`.
///
/// The overshoot term is `sin(tail * π) * 0.015` where `tail` is `t` linearly
/// rescaled from `[0.75, 1.0]` to `[0, 1]`. At `t = 0.875` the card is ~1.5 %
/// past its target; at `t = 1` the card is exactly on target.
#[inline]
fn smooth_snap(t: f32) -> f32 {
let base = 1.0 - (1.0 - t).powi(3);
let tail = ((t - 0.75) / 0.25).clamp(0.0, 1.0);
let overshoot = (tail * PI).sin() * 0.015;
base + overshoot
}
/// Underdamped spring response (ζ = 0.65, ω₀ = 20 rad/s).
///
/// Derived from the exact closed-form solution:
/// `x(t) = 1 e^{−ζω₀t}[cos(ωd·t) + (ζω₀/ωd)·sin(ωd·t)]`
/// where `ωd = ω₀·√(1 ζ²)`.
#[inline]
fn soft_bounce(t: f32) -> f32 {
const OMEGA: f32 = 20.0;
const ZETA: f32 = 0.65;
let omega_d = OMEGA * (1.0 - ZETA * ZETA).sqrt();
let decay = (-ZETA * OMEGA * t).exp();
1.0 - decay * ((omega_d * t).cos() + (ZETA * OMEGA / omega_d) * (omega_d * t).sin())
}
/// Quintic ease-out: `f(t) = 1 (1 t)^5`.
///
/// Reaches ~97 % of the target by `t = 0.5`. No overshoot.
#[inline]
fn responsive(t: f32) -> f32 {
1.0 - (1.0 - t).powi(5)
}
/// Underdamped spring response (ζ = 0.45, ω₀ = 18 rad/s) — two visible bounces.
///
/// Uses the same closed-form spring formula as `soft_bounce` but with lower
/// damping, producing higher overshoot (~18 %) and two discernible oscillations
/// before settling.
#[inline]
fn expressive(t: f32) -> f32 {
const OMEGA: f32 = 18.0;
const ZETA: f32 = 0.45;
let omega_d = OMEGA * (1.0 - ZETA * ZETA).sqrt();
let decay = (-ZETA * OMEGA * t).exp();
1.0 - decay * ((omega_d * t).cos() + (ZETA * OMEGA / omega_d) * (omega_d * t).sin())
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_near(a: f32, b: f32, eps: f32, msg: &str) {
assert!((a - b).abs() < eps, "{msg}: expected ~{b}, got {a}");
}
#[test]
fn all_curves_start_at_zero() {
for curve in [
MotionCurve::SmoothSnap,
MotionCurve::SoftBounce,
MotionCurve::Responsive,
MotionCurve::Expressive,
] {
assert_near(sample_curve(curve, 0.0), 0.0, 1e-5, &format!("{curve:?} at t=0"));
}
}
#[test]
fn all_curves_end_at_one() {
for curve in [
MotionCurve::SmoothSnap,
MotionCurve::SoftBounce,
MotionCurve::Responsive,
] {
assert_near(sample_curve(curve, 1.0), 1.0, 1e-4, &format!("{curve:?} at t=1"));
}
// Spring-based curves have residual oscillation at finite t=1; allow 2 e-3.
assert_near(
sample_curve(MotionCurve::Expressive, 1.0),
1.0,
2e-3,
"Expressive at t=1",
);
}
#[test]
fn responsive_reaches_half_before_midpoint() {
// Quintic ease-out accelerates fast — >50 % by t=0.5.
let v = sample_curve(MotionCurve::Responsive, 0.5);
assert!(v > 0.96, "Responsive should be >96 % at t=0.5, got {v}");
}
#[test]
fn smooth_snap_overshoots_slightly_near_end() {
// Peak overshoot is around t = 0.875.
let peak = sample_curve(MotionCurve::SmoothSnap, 0.875);
assert!(peak > 1.0, "SmoothSnap should overshoot at t=0.875, got {peak}");
assert!(peak < 1.03, "SmoothSnap overshoot should be small (<3 %), got {peak}");
}
#[test]
fn soft_bounce_overshoots_and_returns() {
let v = sample_curve(MotionCurve::SoftBounce, 1.0);
assert_near(v, 1.0, 1e-3, "SoftBounce must settle at 1.0");
}
#[test]
fn expressive_has_more_overshoot_than_soft_bounce() {
// Compare max value in [0,1] range.
let max_soft: f32 = (0..=100)
.map(|i| sample_curve(MotionCurve::SoftBounce, i as f32 / 100.0))
.fold(f32::NEG_INFINITY, f32::max);
let max_expr: f32 = (0..=100)
.map(|i| sample_curve(MotionCurve::Expressive, i as f32 / 100.0))
.fold(f32::NEG_INFINITY, f32::max);
assert!(
max_expr > max_soft,
"Expressive should overshoot more than SoftBounce: {max_expr} vs {max_soft}"
);
}
#[test]
fn sample_curve_clamps_t_below_zero() {
assert_near(sample_curve(MotionCurve::SmoothSnap, -1.0), 0.0, 1e-5, "t<0 clamped");
}
#[test]
fn sample_curve_clamps_t_above_one() {
assert_near(sample_curve(MotionCurve::Responsive, 2.0), 1.0, 1e-5, "t>1 clamped");
}
}
@@ -0,0 +1,321 @@
//! Card interaction visuals: hover scale, drag lift, and input buffering.
//!
//! # Hover
//!
//! [`HoverState`] tracks the entity currently under the cursor. A system
//! smoothly lerps `Transform.scale` toward `HOVER_SCALE` on the hovered card
//! and back to 1.0 when the cursor leaves. Scale is only written when no
//! [`CardAnimation`] is active on the entity (the animation takes priority).
//!
//! # Drag visual
//!
//! While [`DragState`] is non-idle, the dragged card entities receive a subtle
//! scale boost (`DRAG_LIFT_SCALE`) and their z-order is pushed up. The exact
//! translation is still controlled by the existing [`crate::input_plugin`] —
//! this system only applies the _visual_ enhancement without touching XY.
//!
//! # Input buffer
//!
//! [`InputBuffer`] stores move/draw/undo actions that arrived while cards are
//! still animating. Call [`InputBuffer::push`] from any system that wants
//! buffering. The drain system fires the oldest buffered action as soon as all
//! [`CardAnimation`] components have cleared, giving a responsive feel on
//! fast repeated clicks.
//!
//! # Visual priority
//!
//! Dragged cards always have the highest z. The existing [`crate::input_plugin`]
//! sets drag z; this module applies scale on top. The ordering constraint
//! `.after(crate::game_plugin::GameMutation)` ensures all game-state changes
//! settle before visual updates run.
use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use super::animation::CardAnimation;
use crate::card_plugin::CardEntity;
use crate::events::{DrawRequestEvent, MoveRequestEvent, UndoRequestEvent};
use crate::layout::LayoutResource;
use crate::resources::DragState;
/// Type alias to reduce complexity in hover/drag query signatures.
type CardTransformQuery<'w, 's> =
Query<'w, 's, (Entity, &'static mut Transform), (With<CardEntity>, Without<CardAnimation>)>;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Scale applied to the card currently under the cursor (1.0 = no change).
const HOVER_SCALE: f32 = 1.04;
/// Additional scale applied to dragged cards while in flight.
const DRAG_LIFT_SCALE: f32 = 1.08;
/// Lerp speed for hover scale interpolation (higher = snappier).
const HOVER_LERP_SPEED: f32 = 14.0;
/// Lerp speed for drag scale interpolation.
const DRAG_LERP_SPEED: f32 = 20.0;
/// Maximum number of buffered inputs retained.
const INPUT_BUFFER_CAPACITY: usize = 4;
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
/// Tracks the entity currently under the cursor and the interpolated hover scale.
#[derive(Resource, Debug, Default)]
pub struct HoverState {
/// Entity currently hovered (`None` when cursor is off all cards or dragging).
pub entity: Option<Entity>,
/// Current interpolated scale applied to the hovered card.
pub scale: f32,
}
/// Describes a user action that arrived while cards were still animating.
#[derive(Debug, Clone)]
pub enum BufferedInput {
Move { from: crate::events::MoveRequestEvent },
Draw,
Undo,
}
/// FIFO queue of inputs deferred until ongoing animations complete.
///
/// Populate via [`InputBuffer::push`] and consume via the drain system.
/// Capped at [`INPUT_BUFFER_CAPACITY`] — further pushes when full are silently
/// dropped to prevent stale action pileup.
#[derive(Resource, Debug, Default)]
pub struct InputBuffer {
pub(crate) queue: VecDeque<BufferedInput>,
}
impl InputBuffer {
/// Enqueues an input if the buffer is not full.
pub fn push(&mut self, input: BufferedInput) {
if self.queue.len() < INPUT_BUFFER_CAPACITY {
self.queue.push_back(input);
}
}
/// Returns `true` when no inputs are pending.
pub fn is_empty(&self) -> bool {
self.queue.is_empty()
}
/// Returns how many inputs are queued.
pub fn len(&self) -> usize {
self.queue.len()
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Detects which card is under the cursor and updates [`HoverState`].
///
/// Clears hover when [`DragState`] is active (dragging takes visual priority).
/// Picks the topmost card (highest `translation.z`) when multiple cards overlap.
pub(crate) fn detect_hover(
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
drag: Option<Res<DragState>>,
layout: Option<Res<LayoutResource>>,
cards: Query<(Entity, &Transform), With<CardEntity>>,
mut hover: ResMut<HoverState>,
) {
let is_dragging = drag.as_ref().is_some_and(|d| !d.is_idle());
if is_dragging {
hover.entity = None;
return;
}
let Some(layout) = layout else { return };
let Some(cursor_world) = cursor_world(&windows, &cameras) else {
hover.entity = None;
return;
};
let half_w = layout.0.card_size.x * 0.5;
let half_h = layout.0.card_size.y * 0.5;
let mut best: Option<(Entity, f32)> = None;
for (entity, transform) in &cards {
let pos = transform.translation.truncate();
if (cursor_world.x - pos.x).abs() < half_w
&& (cursor_world.y - pos.y).abs() < half_h
{
let z = transform.translation.z;
if best.is_none_or(|(_, bz)| z > bz) {
best = Some((entity, z));
}
}
}
hover.entity = best.map(|(e, _)| e);
}
/// Applies the hover scale to the currently hovered card via smooth lerp.
///
/// Only runs on cards that have **no active [`CardAnimation`]** — animated
/// cards control their own scale. When hover changes entities, the previous
/// entity's scale is snapped back to 1.0 to avoid leaving a permanently
/// enlarged card.
pub(crate) fn apply_hover_scale(
time: Res<Time>,
mut hover_state: ResMut<HoverState>,
mut cards: CardTransformQuery,
) {
let dt = time.delta_secs();
let target_entity = hover_state.entity;
for (entity, mut transform) in &mut cards {
let target_scale = if Some(entity) == target_entity {
HOVER_SCALE
} else {
1.0
};
let current = transform.scale.x;
let new_scale = current + (target_scale - current) * (HOVER_LERP_SPEED * dt).min(1.0);
transform.scale = Vec3::splat(new_scale);
}
// Update the tracked scale for external inspection.
hover_state.scale = if let Some(entity) = target_entity {
cards
.get(entity)
.map(|(_, t)| t.scale.x)
.unwrap_or(HOVER_SCALE)
} else {
1.0
};
}
/// Applies a scale boost and z-lift to dragged card entities.
///
/// Reads [`DragState`] for the list of card IDs being dragged. Does **not**
/// modify `translation.xy` — the existing `InputPlugin` owns drag translation.
/// Only writes `scale` and `translation.z` so the two systems are disjoint.
pub(crate) fn apply_drag_visual(
time: Res<Time>,
drag: Option<Res<DragState>>,
mut cards: Query<(Entity, &CardEntity, &mut Transform), (Without<CardAnimation>,)>,
) {
let dt = time.delta_secs();
let empty: Vec<u32> = Vec::new();
let dragged_ids: &[u32] = drag.as_ref().map_or(empty.as_slice(), |d| &d.cards);
for (_, card, mut transform) in &mut cards {
let is_dragged = dragged_ids.contains(&card.card_id);
let target_scale = if is_dragged { DRAG_LIFT_SCALE } else { 1.0 };
let current = transform.scale.x;
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
transform.scale = Vec3::splat(new_scale);
}
}
/// Fires the oldest buffered input when no [`CardAnimation`] components remain.
///
/// Call this system late in the `Update` schedule so freshly-removed animations
/// are already gone before the drain runs.
pub(crate) fn drain_input_buffer(
mut buffer: ResMut<InputBuffer>,
anims: Query<&CardAnimation>,
mut move_events: EventWriter<MoveRequestEvent>,
mut draw_events: EventWriter<DrawRequestEvent>,
mut undo_events: EventWriter<UndoRequestEvent>,
) {
if !anims.is_empty() {
return;
}
match buffer.queue.pop_front() {
Some(BufferedInput::Move { from }) => {
move_events.send(from);
}
Some(BufferedInput::Draw) => {
draw_events.send(DrawRequestEvent);
}
Some(BufferedInput::Undo) => {
undo_events.send(UndoRequestEvent);
}
None => {}
}
}
// ---------------------------------------------------------------------------
// Cursor helper (mirrors the pattern used by input_plugin)
// ---------------------------------------------------------------------------
/// Converts the cursor screen position to 2-D world coordinates.
///
/// Returns `None` when the cursor is outside the window or no camera is found.
fn cursor_world(
windows: &Query<&Window, With<PrimaryWindow>>,
cameras: &Query<(&Camera, &GlobalTransform)>,
) -> Option<Vec2> {
let window = windows.get_single().ok()?;
let cursor = window.cursor_position()?;
let (camera, camera_transform) = cameras.get_single().ok()?;
camera.viewport_to_world_2d(camera_transform, cursor).ok()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_buffer_capacity_is_respected() {
let mut buf = InputBuffer::default();
for _ in 0..INPUT_BUFFER_CAPACITY + 5 {
buf.push(BufferedInput::Draw);
}
assert_eq!(
buf.len(),
INPUT_BUFFER_CAPACITY,
"buffer must not exceed capacity"
);
}
#[test]
fn input_buffer_is_fifo() {
let mut buf = InputBuffer::default();
buf.push(BufferedInput::Draw);
buf.push(BufferedInput::Undo);
matches!(buf.queue.pop_front().unwrap(), BufferedInput::Draw);
matches!(buf.queue.pop_front().unwrap(), BufferedInput::Undo);
}
#[test]
fn input_buffer_empty_initially() {
let buf = InputBuffer::default();
assert!(buf.is_empty());
assert_eq!(buf.len(), 0);
}
#[test]
fn input_buffer_len_increments() {
let mut buf = InputBuffer::default();
buf.push(BufferedInput::Draw);
assert_eq!(buf.len(), 1);
buf.push(BufferedInput::Undo);
assert_eq!(buf.len(), 2);
}
#[test]
fn hover_state_default_has_no_entity() {
let state = HoverState::default();
assert!(state.entity.is_none());
assert_eq!(state.scale, 0.0);
}
}
+390
View File
@@ -0,0 +1,390 @@
//! `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
//! ));
//! ```
//!
//! Retarget a card mid-flight:
//!
//! ```ignore
//! use solitaire_engine::card_animation::retarget_animation;
//!
//! fn handle_drop(
//! mut commands: Commands,
//! q: Query<(Entity, &Transform, Option<&CardAnimation>), With<CardEntity>>,
//! ) {
//! let (entity, transform, anim) = q.get(card_entity).unwrap();
//! retarget_animation(
//! &mut commands,
//! entity,
//! anim,
//! transform,
//! new_target_xy,
//! new_target_z,
//! MotionCurve::SmoothSnap,
//! );
//! }
//! ```
//!
//! # 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 curves;
pub mod interaction;
pub mod timing;
pub use animation::{retarget_animation, win_scatter_targets, CardAnimation};
pub use curves::{sample_curve, MotionCurve};
pub use interaction::{BufferedInput, HoverState, InputBuffer};
pub use timing::{
cascade_delay, compute_duration, micro_vary, DEAL_INTERVAL_SECS, MAX_DURATION_SECS,
MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
};
use bevy::prelude::*;
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 interaction::{apply_drag_visual, apply_hover_scale, detect_hover, drain_input_buffer};
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers all systems, resources, and components for curve-based card
/// animation, hover visuals, drag lift, and input buffering.
///
/// 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 that interaction systems depend on,
// idempotently — double-registration is safe in Bevy.
app.add_event::<MoveRequestEvent>()
.add_event::<DrawRequestEvent>()
.add_event::<UndoRequestEvent>()
.add_event::<GameWonEvent>()
.init_resource::<DragState>()
.init_resource::<HoverState>()
.init_resource::<InputBuffer>()
.add_systems(
Update,
(
// Advance active animations (highest priority — runs first).
advance_card_animations,
// Interaction visuals (run after animation to read 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: EventReader<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
);
}
#[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"
);
}
}
}
}
@@ -0,0 +1,152 @@
//! Distance-based duration calculation and stagger utilities.
//!
//! All functions are pure (no Bevy dependency) and can be tested in isolation.
/// Minimum animation duration — applied to very short or zero-distance moves.
pub const MIN_DURATION_SECS: f32 = 0.12;
/// Hard cap on animation duration regardless of distance.
pub const MAX_DURATION_SECS: f32 = 0.35;
/// Sqrt scale factor calibrated so a 600-pixel move hits `MAX_DURATION_SECS`:
/// `MIN + √600 × SCALE ≈ 0.35 s`.
const SQRT_SCALE: f32 = 0.0094;
/// Micro-variation amplitude: ±0.4 % of the computed duration.
///
/// Small enough to be imperceptible in isolation but enough to break the
/// "robotic" uniformity when many cards animate simultaneously.
const MICRO_VARY_AMPLITUDE: f32 = 0.004;
/// Computes animation duration from a pixel distance using square-root scaling.
///
/// Square-root growth keeps short moves feeling instant while preventing long
/// moves from feeling excessively slow.
///
/// | Distance | Duration |
/// |----------|-----------|
/// | 25 px | ~0.17 s |
/// | 100 px | ~0.21 s |
/// | 300 px | ~0.28 s |
/// | 600 px | ~0.35 s |
/// | 1200 px | ~0.35 s ← capped |
#[inline]
pub fn compute_duration(distance: f32) -> f32 {
(MIN_DURATION_SECS + distance.abs().sqrt() * SQRT_SCALE).min(MAX_DURATION_SECS)
}
/// Applies a deterministic ±0.4 % micro-variation to `duration`.
///
/// `entity_index` should be a stable per-entity value (e.g. `Entity::index()`).
/// The same index always produces the same variation so animations don't
/// change between frames.
#[inline]
pub fn micro_vary(duration: f32, entity_index: u32) -> f32 {
// Multiplicative Fibonacci hash — cheap, decent distribution.
let hash = entity_index.wrapping_mul(2_654_435_761);
let noise = (hash >> 16) as f32 / 65_536.0; // 0.0 ..= 1.0
let variation = (noise - 0.5) * 2.0 * MICRO_VARY_AMPLITUDE;
duration * (1.0 + variation)
}
/// Returns the pre-animation delay for card at `index` in a staggered cascade.
///
/// `delay = index × interval_secs`.
#[inline]
pub fn cascade_delay(index: usize, interval_secs: f32) -> f32 {
index as f32 * interval_secs
}
/// Recommended per-card interval for the win cascade (Normal speed).
pub const WIN_CASCADE_INTERVAL_SECS: f32 = 0.018;
/// Recommended per-card interval for deal animations (Normal speed).
pub const DEAL_INTERVAL_SECS: f32 = 0.022;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_distance_gives_minimum_duration() {
assert!(
(compute_duration(0.0) - MIN_DURATION_SECS).abs() < 1e-5,
"zero distance must yield MIN_DURATION_SECS"
);
}
#[test]
fn large_distance_is_capped() {
assert!(
(compute_duration(10_000.0) - MAX_DURATION_SECS).abs() < 1e-5,
"very large distance must be capped at MAX_DURATION_SECS"
);
}
#[test]
fn duration_increases_monotonically() {
let mut prev = 0.0f32;
for d in [10, 50, 100, 200, 400, 600] {
let dur = compute_duration(d as f32);
assert!(dur >= prev, "duration must be monotone: d={d} dur={dur} prev={prev}");
prev = dur;
}
}
#[test]
fn duration_is_within_bounds() {
for d in [0, 1, 25, 100, 300, 600, 1200] {
let dur = compute_duration(d as f32);
assert!(
(MIN_DURATION_SECS..=MAX_DURATION_SECS).contains(&dur),
"duration out of bounds for d={d}: {dur}"
);
}
}
#[test]
fn micro_vary_stays_within_tolerance() {
for i in 0..=1000u32 {
let base = 0.25;
let varied = micro_vary(base, i);
let ratio = (varied - base).abs() / base;
assert!(
ratio <= MICRO_VARY_AMPLITUDE + 1e-6,
"variation for index {i} exceeds amplitude: ratio={ratio}"
);
}
}
#[test]
fn micro_vary_is_deterministic() {
let a = micro_vary(0.2, 42);
let b = micro_vary(0.2, 42);
assert!((a - b).abs() < 1e-9, "micro_vary must be deterministic");
}
#[test]
fn micro_vary_differs_for_different_indices() {
let a = micro_vary(0.2, 1);
let b = micro_vary(0.2, 2);
// Very unlikely to be equal (would require hash collision mod 65536).
assert!((a - b).abs() > 1e-9, "micro_vary should differ for different indices");
}
#[test]
fn cascade_delay_zero_index_is_zero() {
assert_eq!(cascade_delay(0, 0.018), 0.0);
}
#[test]
fn cascade_delay_scales_linearly() {
let interval = 0.018;
for i in 0..52usize {
let expected = i as f32 * interval;
let actual = cascade_delay(i, interval);
assert!(
(actual - expected).abs() < 1e-6,
"cascade_delay({i}) = {actual}, expected {expected}"
);
}
}
}
+102 -3
View File
@@ -82,11 +82,28 @@ pub struct HintHighlight {
pub remaining: f32, pub remaining: f32,
} }
/// Countdown (seconds) until the `HintHighlight` on a card entity is removed.
///
/// Inserted alongside `HintHighlight` by the hint-visual system. When the timer
/// reaches zero both `HintHighlight` and `HintHighlightTimer` are removed from
/// the entity and the sprite colour is restored.
#[derive(Component, Debug, Clone)]
pub struct HintHighlightTimer(pub f32);
/// Marker on a `PileMarker` entity that is highlighted because the right-clicked /// Marker on a `PileMarker` entity that is highlighted because the right-clicked
/// card can legally be placed there. /// card can legally be placed there.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct RightClickHighlight; pub struct RightClickHighlight;
/// Countdown (seconds) until this right-click destination highlight despawns.
///
/// Inserted alongside `RightClickHighlight` so that highlights auto-clear after
/// 1.5 s even if the player does not make a move or click again. The existing
/// clear-on-state-change and clear-on-pause logic still fires early when
/// appropriate.
#[derive(Component, Debug, Clone)]
pub struct RightClickHighlightTimer(pub f32);
/// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile /// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile
/// marker when the stock pile is empty. /// marker when the stock pile is empty.
#[derive(Component, Debug)] #[derive(Component, Debug)]
@@ -154,6 +171,7 @@ impl Plugin for CardPlugin {
update_drag_shadow, update_drag_shadow,
tick_hint_highlight, tick_hint_highlight,
handle_right_click, handle_right_click,
tick_right_click_highlights,
clear_right_click_highlights_on_state_change.after(GameMutation), clear_right_click_highlights_on_state_change.after(GameMutation),
clear_right_click_highlights_on_pause, clear_right_click_highlights_on_pause,
update_stock_empty_indicator.after(GameMutation), update_stock_empty_indicator.after(GameMutation),
@@ -627,7 +645,8 @@ fn update_drag_shadow(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Counts down `HintHighlight::remaining` each frame. When it reaches zero, /// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
/// removes the component and resets the card sprite to its normal face-up colour. /// removes both `HintHighlight` and `HintHighlightTimer` (if present) and
/// resets the card sprite to its normal face-up colour.
fn tick_hint_highlight( fn tick_hint_highlight(
time: Res<Time>, time: Res<Time>,
mut commands: Commands, mut commands: Commands,
@@ -649,7 +668,10 @@ fn tick_hint_highlight(
} else { } else {
card_back_colour(back_idx) card_back_colour(back_idx)
}; };
commands.entity(entity).remove::<HintHighlight>(); commands
.entity(entity)
.remove::<HintHighlight>()
.remove::<HintHighlightTimer>();
} }
} }
} }
@@ -664,6 +686,37 @@ const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.2, 0.8, 0.2, 0.6);
/// Restored color for `PileMarker` sprites when the highlight is cleared. /// Restored color for `PileMarker` sprites when the highlight is cleared.
const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08); const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Counts down `RightClickHighlightTimer` each frame and clears the highlight
/// when the timer expires.
///
/// This is a fallback expiry: highlights also clear immediately on
/// `StateChangedEvent` (move made) or when the game is paused, whichever comes
/// first. The 1.5 s timer ensures highlights always disappear even if the
/// player takes no further action.
fn tick_right_click_highlights(
mut commands: Commands,
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut highlights: Query<(Entity, &mut RightClickHighlightTimer, &mut Sprite), With<RightClickHighlight>>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
for (entity, mut timer, mut sprite) in &mut highlights {
timer.0 -= dt;
if timer.0 <= 0.0 {
// Restore the pile marker to its default colour before removing
// the highlight marker component.
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
commands
.entity(entity)
.remove::<RightClickHighlight>()
.remove::<RightClickHighlightTimer>();
}
}
}
/// Removes the `RightClickHighlight` marker from every highlighted pile and /// Removes the `RightClickHighlight` marker from every highlighted pile and
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`. /// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
/// ///
@@ -781,7 +834,10 @@ fn handle_right_click(
}; };
if legal { if legal {
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR; sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
commands.entity(entity).insert(RightClickHighlight); commands
.entity(entity)
.insert(RightClickHighlight)
.insert(RightClickHighlightTimer(1.5));
} }
} }
} }
@@ -1223,6 +1279,49 @@ mod tests {
assert_ne!(FlipPhase::ScalingDown, FlipPhase::ScalingUp); assert_ne!(FlipPhase::ScalingDown, FlipPhase::ScalingUp);
} }
// -----------------------------------------------------------------------
// Task #5 — RightClickHighlightTimer pure-function tests
// -----------------------------------------------------------------------
/// Verify that a freshly-created timer with 1.5 s has a positive countdown
/// and has not yet expired.
#[test]
fn right_click_highlight_timer_starts_positive() {
let timer = RightClickHighlightTimer(1.5);
assert!(
timer.0 > 0.0,
"timer must start with a positive countdown, got {}",
timer.0
);
}
/// Simulate ticking the timer by a delta that exceeds its initial value and
/// verify the resulting value is ≤ 0 (expiry condition).
#[test]
fn right_click_highlight_timer_expires_after_sufficient_ticks() {
let mut remaining = 1.5_f32;
// Tick by more than the initial value to ensure expiry.
remaining -= 2.0;
assert!(
remaining <= 0.0,
"timer must be expired (≤ 0) after 2.0 s tick on a 1.5 s timer, got {}",
remaining
);
}
/// Simulate ticking by less than the initial value and verify the timer is
/// still positive (not yet expired).
#[test]
fn right_click_highlight_timer_not_expired_before_duration() {
let mut remaining = 1.5_f32;
remaining -= 0.5; // only 0.5 s elapsed
assert!(
remaining > 0.0,
"timer must still be positive after only 0.5 s on a 1.5 s timer, got {}",
remaining
);
}
#[test] #[test]
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() { fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
-1
View File
@@ -214,7 +214,6 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use solitaire_core::card::{Card, Rank};
#[test] #[test]
fn point_in_rect_center_is_inside() { fn point_in_rect_center_is_inside() {
+13
View File
@@ -102,3 +102,16 @@ pub struct XpAwardedEvent {
/// persists stats, and starts a fresh deal. /// persists stats, and starts a fresh deal.
#[derive(Event, Debug, Clone, Copy, Default)] #[derive(Event, Debug, Clone, Copy, Default)]
pub struct ForfeitEvent; pub struct ForfeitEvent;
/// Fired when the player requests a hint (H key). Carries the source card ID
/// and destination pile for visual highlighting.
///
/// Consumed by `CardPlugin` (to apply `HintHighlight` on the card entity) and
/// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s).
#[derive(Event, Debug, Clone)]
pub struct HintVisualEvent {
/// The `Card::id` of the source card to be highlighted.
pub source_card_id: u32,
/// The destination pile whose `PileMarker` should be tinted gold.
pub dest_pile: solitaire_core::pile::PileType,
}
+19 -9
View File
@@ -28,13 +28,13 @@ use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::{CardEntity, HintHighlight, TABLEAU_FAN_FRAC}; use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, TABLEAU_FAN_FRAC};
use crate::feedback_anim_plugin::ShakeAnim; use crate::feedback_anim_plugin::ShakeAnim;
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::DrawMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{ use crate::events::{
DrawRequestEvent, ForfeitEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, DrawRequestEvent, ForfeitEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
}; };
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
@@ -61,6 +61,7 @@ impl Plugin for InputPlugin {
.add_event::<NewGameConfirmEvent>() .add_event::<NewGameConfirmEvent>()
.add_event::<InfoToastEvent>() .add_event::<InfoToastEvent>()
.add_event::<ForfeitEvent>() .add_event::<ForfeitEvent>()
.add_event::<HintVisualEvent>()
.add_systems( .add_systems(
Update, Update,
( (
@@ -94,6 +95,7 @@ struct KeyboardEvents<'w> {
info_toast: EventWriter<'w, InfoToastEvent>, info_toast: EventWriter<'w, InfoToastEvent>,
draw: EventWriter<'w, DrawRequestEvent>, draw: EventWriter<'w, DrawRequestEvent>,
forfeit: EventWriter<'w, ForfeitEvent>, forfeit: EventWriter<'w, ForfeitEvent>,
hint_visual: EventWriter<'w, HintVisualEvent>,
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -237,7 +239,8 @@ fn handle_keyboard(
for (entity, card_entity, _sprite) in card_entities.iter() { for (entity, card_entity, _sprite) in card_entities.iter() {
if card_entity.card_id == card_id { if card_entity.card_id == card_id {
commands.entity(entity) commands.entity(entity)
.insert(HintHighlight { remaining: 1.5 }) .insert(HintHighlight { remaining: 2.0 })
.insert(HintHighlightTimer(2.0))
.insert(Sprite { .insert(Sprite {
color: Color::srgba(1.0, 1.0, 0.4, 1.0), color: Color::srgba(1.0, 1.0, 0.4, 1.0),
custom_size: Some(layout_res.0.card_size), custom_size: Some(layout_res.0.card_size),
@@ -246,6 +249,12 @@ fn handle_keyboard(
break; break;
} }
} }
// Emit HintVisualEvent so the destination pile
// marker is also tinted gold for 2 s.
ev.hint_visual.send(HintVisualEvent {
source_card_id: card_id,
dest_pile: to.clone(),
});
} }
// Fire an informational toast describing where the hinted card // Fire an informational toast describing where the hinted card
// should move so the player always sees the suggestion in text. // should move so the player always sees the suggestion in text.
@@ -979,6 +988,11 @@ pub fn find_hint(game: &GameState) -> Option<(PileType, PileType, usize)> {
all_hints(game).into_iter().next() all_hints(game).into_iter().next()
} }
// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced
// when the compiler can't see it used.
#[allow(dead_code)]
const _VEC3_REFERENCED: Option<Vec3> = None;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1419,7 +1433,7 @@ mod tests {
/// window actually opens on the first G press. /// window actually opens on the first G press.
#[test] #[test]
fn forfeit_confirm_window_is_positive() { fn forfeit_confirm_window_is_positive() {
assert!(FORFEIT_CONFIRM_WINDOW > 0.0, "FORFEIT_CONFIRM_WINDOW must be > 0"); const { assert!(FORFEIT_CONFIRM_WINDOW > 0.0, "FORFEIT_CONFIRM_WINDOW must be > 0"); }
} }
/// Simulate the first G press: countdown was 0, so it should become /// Simulate the first G press: countdown was 0, so it should become
@@ -1607,7 +1621,3 @@ mod tests {
} }
} }
// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced
// when the compiler can't see it used.
#[allow(dead_code)]
const _VEC3_REFERENCED: Option<Vec3> = None;
+14 -3
View File
@@ -1,5 +1,6 @@
//! Bevy integration layer for Solitaire Quest. //! Bevy integration layer for Solitaire Quest.
pub mod card_animation;
pub mod achievement_plugin; pub mod achievement_plugin;
pub mod animation_plugin; pub mod animation_plugin;
pub mod auto_complete_plugin; pub mod auto_complete_plugin;
@@ -41,17 +42,27 @@ pub use daily_challenge_plugin::{
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate}; pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin}; pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue}; pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
pub use card_animation::{
CardAnimation, CardAnimationPlugin, MotionCurve, WinCascadePlugin,
retarget_animation, sample_curve, compute_duration, cascade_delay, micro_vary,
HoverState, InputBuffer, BufferedInput,
win_scatter_targets, WIN_CASCADE_INTERVAL_SECS, DEAL_INTERVAL_SECS,
MIN_DURATION_SECS, MAX_DURATION_SECS,
};
pub use feedback_anim_plugin::{ pub use feedback_anim_plugin::{
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale, deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
FeedbackAnimPlugin, SettleAnim, ShakeAnim, FeedbackAnimPlugin, SettleAnim, ShakeAnim,
}; };
pub use auto_complete_plugin::AutoCompletePlugin; pub use auto_complete_plugin::AutoCompletePlugin;
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary}; pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use card_plugin::{CardEntity, CardLabel, CardPlugin, HintHighlight, RightClickHighlight}; pub use card_plugin::{
CardEntity, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer, RightClickHighlight,
RightClickHighlightTimer,
};
pub use cursor_plugin::CursorPlugin; pub use cursor_plugin::CursorPlugin;
pub use events::{ pub use events::{
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, ForfeitEvent, GameWonEvent, AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, ForfeitEvent, GameWonEvent,
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent, NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
}; };
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath}; pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
@@ -71,7 +82,7 @@ pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScroll
pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState}; pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate}; pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
pub use sync_plugin::{SyncPlugin, SyncProviderResource}; pub use sync_plugin::{SyncPlugin, SyncProviderResource};
pub use table_plugin::{PileMarker, TableBackground, TablePlugin}; pub use table_plugin::{HintPileHighlight, PileMarker, TableBackground, TablePlugin};
pub use time_attack_plugin::{ pub use time_attack_plugin::{
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
}; };
+1 -1
View File
@@ -792,7 +792,7 @@ mod tests {
.collect(); .collect();
assert!( assert!(
messages.iter().any(|m| *m == "Streak of 3 broken!"), messages.contains(&"Streak of 3 broken!"),
"expected 'Streak of 3 broken!' in toasts, got: {messages:?}" "expected 'Streak of 3 broken!' in toasts, got: {messages:?}"
); );
} }
+1 -2
View File
@@ -403,8 +403,7 @@ mod tests {
#[test] #[test]
fn build_payload_clones_stats() { fn build_payload_clones_stats() {
let mut stats = StatsSnapshot::default(); let stats = StatsSnapshot { games_played: 42, ..Default::default() };
stats.games_played = 42;
let payload = build_payload(&stats, &[], &PlayerProgress::default()); let payload = build_payload(&stats, &[], &PlayerProgress::default());
assert_eq!(payload.stats.games_played, 42); assert_eq!(payload.stats.games_played, 42);
} }
+145 -1
View File
@@ -10,6 +10,7 @@ use solitaire_core::card::Suit;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_data::settings::Theme; use solitaire_data::settings::Theme;
use crate::events::HintVisualEvent;
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR}; use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
@@ -27,6 +28,17 @@ pub struct TableBackground;
#[derive(Component, Debug, Clone)] #[derive(Component, Debug, Clone)]
pub struct PileMarker(pub PileType); pub struct PileMarker(pub PileType);
/// Attached to a `PileMarker` entity when it has been temporarily tinted gold
/// as a hint destination. Stores the remaining countdown and the original sprite
/// colour so it can be restored when the timer expires.
#[derive(Component, Debug, Clone)]
pub struct HintPileHighlight {
/// Seconds remaining before the pile marker colour is restored.
pub timer: f32,
/// The sprite colour the marker had before the hint tint was applied.
pub original_color: Color,
}
/// Registers the table background and pile-marker rendering. /// Registers the table background and pile-marker rendering.
pub struct TablePlugin; pub struct TablePlugin;
@@ -37,8 +49,17 @@ impl Plugin for TablePlugin {
// and this call is a no-op. // and this call is a no-op.
app.add_event::<WindowResized>() app.add_event::<WindowResized>()
.add_event::<SettingsChangedEvent>() .add_event::<SettingsChangedEvent>()
.add_event::<HintVisualEvent>()
.add_systems(Startup, setup_table) .add_systems(Startup, setup_table)
.add_systems(Update, (on_window_resized, apply_theme_on_settings_change)); .add_systems(
Update,
(
on_window_resized,
apply_theme_on_settings_change,
apply_hint_pile_highlight,
tick_hint_pile_highlights,
),
);
} }
} }
@@ -225,6 +246,59 @@ fn on_window_resized(
} }
} }
// ---------------------------------------------------------------------------
// Task #6 — Hint pile-marker highlight
// ---------------------------------------------------------------------------
/// Gold tint applied to a `PileMarker` sprite when it is the current hint
/// destination.
const HINT_PILE_HIGHLIGHT_COLOUR: Color = Color::srgb(1.0, 0.85, 0.1);
/// Listens for `HintVisualEvent` and tints the matching `PileMarker` entity
/// gold for 2 s, storing the original colour in `HintPileHighlight` so it can
/// be restored when the timer expires.
///
/// If the pile marker already has a `HintPileHighlight` from a previous hint
/// press, the timer is reset to 2 s without changing `original_color`.
fn apply_hint_pile_highlight(
mut events: EventReader<HintVisualEvent>,
mut commands: Commands,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite, Option<&HintPileHighlight>)>,
) {
for ev in events.read() {
for (entity, pile_marker, mut sprite, existing) in pile_markers.iter_mut() {
if pile_marker.0 != ev.dest_pile {
continue;
}
let original_color = existing
.map(|h| h.original_color)
.unwrap_or(sprite.color);
sprite.color = HINT_PILE_HIGHLIGHT_COLOUR;
commands.entity(entity).insert(HintPileHighlight {
timer: 2.0,
original_color,
});
}
}
}
/// Counts down `HintPileHighlight::timer` each frame and restores the original
/// pile marker colour when the timer expires.
fn tick_hint_pile_highlights(
mut commands: Commands,
time: Res<Time>,
mut pile_markers: Query<(Entity, &mut Sprite, &mut HintPileHighlight)>,
) {
let dt = time.delta_secs();
for (entity, mut sprite, mut highlight) in pile_markers.iter_mut() {
highlight.timer -= dt;
if highlight.timer <= 0.0 {
sprite.color = highlight.original_color;
commands.entity(entity).remove::<HintPileHighlight>();
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -342,6 +416,76 @@ mod tests {
assert_eq!(suit_symbol(&Suit::Clubs), "C"); assert_eq!(suit_symbol(&Suit::Clubs), "C");
} }
// -----------------------------------------------------------------------
// Task #6 — HintPileHighlight timer and colour pure-function tests
// -----------------------------------------------------------------------
/// The HINT_PILE_HIGHLIGHT_COLOUR constant must be visibly distinct from the
/// default pile marker colour so the player can see which pile is highlighted.
#[test]
fn hint_pile_highlight_colour_is_distinct_from_default() {
let default = Color::srgba(1.0, 1.0, 1.0, 0.08); // PILE_MARKER_DEFAULT_COLOUR
assert_ne!(
HINT_PILE_HIGHLIGHT_COLOUR, default,
"HINT_PILE_HIGHLIGHT_COLOUR must differ from the default pile marker colour"
);
}
/// A freshly-created HintPileHighlight has a positive timer countdown.
#[test]
fn hint_pile_highlight_timer_starts_positive() {
let h = HintPileHighlight {
timer: 2.0,
original_color: Color::srgba(1.0, 1.0, 1.0, 0.08),
};
assert!(
h.timer > 0.0,
"HintPileHighlight timer must start positive, got {}",
h.timer
);
}
/// Ticking the timer past its initial value results in a non-positive (expired)
/// countdown.
#[test]
fn hint_pile_highlight_timer_expires_after_full_duration() {
let mut remaining = 2.0_f32;
remaining -= 2.5; // 2.5 s elapsed on a 2.0 s timer
assert!(
remaining <= 0.0,
"timer must be expired after ticking past its initial value, got {}",
remaining
);
}
/// `original_color` is preserved through the highlight lifecycle so colour
/// can be correctly restored on expiry.
#[test]
fn hint_pile_highlight_preserves_original_color() {
let original = Color::srgb(0.1, 0.3, 0.5);
let h = HintPileHighlight {
timer: 2.0,
original_color: original,
};
assert_eq!(
h.original_color, original,
"original_color must be stored without modification"
);
}
/// The gold hint colour must have a strong yellow component (r ≥ 0.9, g ≥ 0.8,
/// b ≤ 0.3) to be clearly visible as a "destination" indicator.
#[test]
fn hint_pile_highlight_colour_is_gold() {
// Extract linear components. srgb(1.0, 0.85, 0.1) is the expected gold.
// We test the channel values rather than exact equality so future tweaks
// to the shade do not break the test, as long as the colour remains golden.
let Srgba { red, green, blue, .. } = HINT_PILE_HIGHLIGHT_COLOUR.to_srgba();
assert!(red >= 0.9, "gold hint colour must have red ≥ 0.9, got {red}");
assert!(green >= 0.7, "gold hint colour must have green ≥ 0.7, got {green}");
assert!(blue <= 0.3, "gold hint colour must have blue ≤ 0.3, got {blue}");
}
#[test] #[test]
fn suit_symbol_all_four_are_distinct() { fn suit_symbol_all_four_are_distinct() {
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs] let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
+2 -2
View File
@@ -158,7 +158,7 @@ mod tests {
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&1)); assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
// No-undo + slow win → no_undo goal also ticked, fast goal NOT ticked. // No-undo + slow win → no_undo goal also ticked, fast goal NOT ticked.
assert_eq!(p.weekly_goal_progress.get("weekly_3_no_undo"), Some(&1)); assert_eq!(p.weekly_goal_progress.get("weekly_3_no_undo"), Some(&1));
assert!(p.weekly_goal_progress.get("weekly_3_fast").is_none()); assert!(!p.weekly_goal_progress.contains_key("weekly_3_fast"));
} }
#[test] #[test]
@@ -188,7 +188,7 @@ mod tests {
app.update(); app.update();
let p = &app.world().resource::<ProgressResource>().0; let p = &app.world().resource::<ProgressResource>().0;
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&1)); assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
assert!(p.weekly_goal_progress.get("weekly_3_no_undo").is_none()); assert!(!p.weekly_goal_progress.contains_key("weekly_3_no_undo"));
} }
#[test] #[test]
+16 -16
View File
@@ -100,6 +100,22 @@ pub fn validate_refresh_token(token: &str, secret: &str) -> Result<Claims, AppEr
// Axum extractor — allows handlers to receive AuthenticatedUser directly // Axum extractor — allows handlers to receive AuthenticatedUser directly
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<AuthenticatedUser>()
.cloned()
.ok_or(AppError::Unauthorized)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -221,19 +237,3 @@ mod tests {
assert!(result.is_err(), "expired refresh token must be rejected"); assert!(result.is_err(), "expired refresh token must be rejected");
} }
} }
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<AuthenticatedUser>()
.cloned()
.ok_or(AppError::Unauthorized)
}
}
+3 -6
View File
@@ -201,8 +201,7 @@ mod tests {
#[test] #[test]
fn add_xp_saturates_on_overflow() { fn add_xp_saturates_on_overflow() {
let mut p = PlayerProgress::default(); let mut p = PlayerProgress { total_xp: u64::MAX, ..Default::default() };
p.total_xp = u64::MAX;
p.add_xp(1); p.add_xp(1);
assert_eq!(p.total_xp, u64::MAX); assert_eq!(p.total_xp, u64::MAX);
} }
@@ -230,8 +229,7 @@ mod tests {
#[test] #[test]
fn roll_weekly_goals_clears_progress_for_new_week() { fn roll_weekly_goals_clears_progress_for_new_week() {
let mut p = PlayerProgress::default(); let mut p = PlayerProgress { weekly_goal_week_iso: Some("2026-W16".to_string()), ..Default::default() };
p.weekly_goal_week_iso = Some("2026-W16".to_string());
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 3); p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 3);
let rolled = p.roll_weekly_goals_if_new_week("2026-W17"); let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
@@ -242,8 +240,7 @@ mod tests {
#[test] #[test]
fn roll_weekly_goals_is_noop_for_same_week() { fn roll_weekly_goals_is_noop_for_same_week() {
let mut p = PlayerProgress::default(); let mut p = PlayerProgress { weekly_goal_week_iso: Some("2026-W17".to_string()), ..Default::default() };
p.weekly_goal_week_iso = Some("2026-W17".to_string());
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 2); p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 2);
let rolled = p.roll_weekly_goals_if_new_week("2026-W17"); let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
+2 -5
View File
@@ -135,17 +135,14 @@ mod tests {
#[test] #[test]
fn record_abandoned_resets_win_streak() { fn record_abandoned_resets_win_streak() {
let mut s = StatsSnapshot::default(); let mut s = StatsSnapshot { win_streak_current: 5, ..Default::default() };
s.win_streak_current = 5;
s.record_abandoned(); s.record_abandoned();
assert_eq!(s.win_streak_current, 0, "abandoned game must break the win streak"); assert_eq!(s.win_streak_current, 0, "abandoned game must break the win streak");
} }
#[test] #[test]
fn record_abandoned_preserves_best_streak() { fn record_abandoned_preserves_best_streak() {
let mut s = StatsSnapshot::default(); let mut s = StatsSnapshot { win_streak_best: 7, win_streak_current: 7, ..Default::default() };
s.win_streak_best = 7;
s.win_streak_current = 7;
s.record_abandoned(); s.record_abandoned();
assert_eq!(s.win_streak_best, 7, "best streak must not be reduced on abandon"); assert_eq!(s.win_streak_best, 7, "best streak must not be reduced on abandon");
assert_eq!(s.win_streak_current, 0); assert_eq!(s.win_streak_current, 0);