Compare commits
3 Commits
v0.9.0
..
eedddb979e
| Author | SHA1 | Date | |
|---|---|---|---|
| eedddb979e | |||
| 59a023ed5e | |||
| 8cd28cfb29 |
@@ -5,3 +5,4 @@
|
||||
.env
|
||||
*.tmp
|
||||
data/
|
||||
.claude/
|
||||
|
||||
Generated
+30
-30
@@ -1538,9 +1538,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.4"
|
||||
version = "1.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
@@ -1684,9 +1684,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.60"
|
||||
version = "1.2.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -1998,9 +1998,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.4.0"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
@@ -2108,9 +2108,9 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
@@ -3318,9 +3318,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
@@ -3533,9 +3533,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.95"
|
||||
version = "0.3.97"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
||||
checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -3642,9 +3642,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.185"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -5287,9 +5287,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.39"
|
||||
version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
@@ -5301,9 +5301,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
@@ -6884,9 +6884,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.118"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
|
||||
checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -6897,9 +6897,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.68"
|
||||
version = "0.4.70"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
|
||||
checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -6907,9 +6907,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.118"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
|
||||
checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -6917,9 +6917,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.118"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
|
||||
checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -6930,9 +6930,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.118"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
|
||||
checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -6973,9 +6973,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.95"
|
||||
version = "0.3.97"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
|
||||
checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
|
||||
@@ -91,6 +91,6 @@ mod tests {
|
||||
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
|
||||
// Very short elapsed time would overflow without the .min() guard.
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,8 +148,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn add_xp_saturates_on_overflow() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.total_xp = u64::MAX - 5;
|
||||
let mut p = PlayerProgress { total_xp: u64::MAX - 5, ..Default::default() };
|
||||
p.add_xp(100);
|
||||
assert_eq!(p.total_xp, u64::MAX);
|
||||
}
|
||||
|
||||
@@ -207,8 +207,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn adjust_sfx_volume_clamps() {
|
||||
let mut s = Settings::default();
|
||||
s.sfx_volume = 0.5;
|
||||
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
|
||||
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(-2.0) - 0.0).abs() < 1e-6);
|
||||
@@ -217,8 +216,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn adjust_music_volume_clamps() {
|
||||
let mut s = Settings::default();
|
||||
s.music_volume = 0.5;
|
||||
let mut s = Settings { music_volume: 0.5, ..Default::default() };
|
||||
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(-2.0) - 0.0).abs() < 1e-6);
|
||||
@@ -241,14 +239,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sanitized_clamps_music_volume() {
|
||||
let mut s = Settings::default();
|
||||
s.music_volume = 2.0;
|
||||
let s = s.sanitized();
|
||||
let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized();
|
||||
assert_eq!(s.music_volume, 1.0);
|
||||
|
||||
let mut s2 = Settings::default();
|
||||
s2.music_volume = -0.5;
|
||||
let s2 = s2.sanitized();
|
||||
let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized();
|
||||
assert_eq!(s2.music_volume, 0.0);
|
||||
}
|
||||
|
||||
|
||||
@@ -173,8 +173,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn lifetime_score_saturates_at_u64_max() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.lifetime_score = u64::MAX - 100;
|
||||
let mut s = StatsSnapshot { lifetime_score: u64::MAX - 100, ..Default::default() };
|
||||
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
||||
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
|
||||
}
|
||||
|
||||
@@ -775,8 +775,7 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||
|
||||
let mut fast_settings = Settings::default();
|
||||
fast_settings.animation_speed = AnimSpeed::Fast;
|
||||
let fast_settings = Settings { animation_speed: AnimSpeed::Fast, ..Default::default() };
|
||||
app.world_mut().send_event(SettingsChangedEvent(fast_settings));
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,11 +82,28 @@ pub struct HintHighlight {
|
||||
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
|
||||
/// card can legally be placed there.
|
||||
#[derive(Component, Debug)]
|
||||
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 when the stock pile is empty.
|
||||
#[derive(Component, Debug)]
|
||||
@@ -154,6 +171,7 @@ impl Plugin for CardPlugin {
|
||||
update_drag_shadow,
|
||||
tick_hint_highlight,
|
||||
handle_right_click,
|
||||
tick_right_click_highlights,
|
||||
clear_right_click_highlights_on_state_change.after(GameMutation),
|
||||
clear_right_click_highlights_on_pause,
|
||||
update_stock_empty_indicator.after(GameMutation),
|
||||
@@ -627,7 +645,8 @@ fn update_drag_shadow(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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(
|
||||
time: Res<Time>,
|
||||
mut commands: Commands,
|
||||
@@ -649,7 +668,10 @@ fn tick_hint_highlight(
|
||||
} else {
|
||||
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.
|
||||
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
|
||||
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
|
||||
///
|
||||
@@ -781,7 +834,10 @@ fn handle_right_click(
|
||||
};
|
||||
if legal {
|
||||
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);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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]
|
||||
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
|
||||
@@ -214,7 +214,6 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solitaire_core::card::{Card, Rank};
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_center_is_inside() {
|
||||
|
||||
@@ -102,3 +102,16 @@ pub struct XpAwardedEvent {
|
||||
/// persists stats, and starts a fresh deal.
|
||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -28,13 +28,13 @@ use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
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 solitaire_core::game_state::DrawMode;
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::events::{
|
||||
DrawRequestEvent, ForfeitEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||
DrawRequestEvent, ForfeitEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
|
||||
MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||
};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
@@ -61,6 +61,7 @@ impl Plugin for InputPlugin {
|
||||
.add_event::<NewGameConfirmEvent>()
|
||||
.add_event::<InfoToastEvent>()
|
||||
.add_event::<ForfeitEvent>()
|
||||
.add_event::<HintVisualEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -94,6 +95,7 @@ struct KeyboardEvents<'w> {
|
||||
info_toast: EventWriter<'w, InfoToastEvent>,
|
||||
draw: EventWriter<'w, DrawRequestEvent>,
|
||||
forfeit: EventWriter<'w, ForfeitEvent>,
|
||||
hint_visual: EventWriter<'w, HintVisualEvent>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -237,7 +239,8 @@ fn handle_keyboard(
|
||||
for (entity, card_entity, _sprite) in card_entities.iter() {
|
||||
if card_entity.card_id == card_id {
|
||||
commands.entity(entity)
|
||||
.insert(HintHighlight { remaining: 1.5 })
|
||||
.insert(HintHighlight { remaining: 2.0 })
|
||||
.insert(HintHighlightTimer(2.0))
|
||||
.insert(Sprite {
|
||||
color: Color::srgba(1.0, 1.0, 0.4, 1.0),
|
||||
custom_size: Some(layout_res.0.card_size),
|
||||
@@ -246,6 +249,12 @@ fn handle_keyboard(
|
||||
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
|
||||
// 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()
|
||||
}
|
||||
|
||||
// `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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1419,7 +1433,7 @@ mod tests {
|
||||
/// window actually opens on the first G press.
|
||||
#[test]
|
||||
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
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Bevy integration layer for Solitaire Quest.
|
||||
|
||||
pub mod card_animation;
|
||||
pub mod achievement_plugin;
|
||||
pub mod animation_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 weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||
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::{
|
||||
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
|
||||
FeedbackAnimPlugin, SettleAnim, ShakeAnim,
|
||||
};
|
||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||
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 events::{
|
||||
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, ForfeitEvent, GameWonEvent,
|
||||
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
|
||||
};
|
||||
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 stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||
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::{
|
||||
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
|
||||
};
|
||||
|
||||
@@ -792,7 +792,7 @@ mod tests {
|
||||
.collect();
|
||||
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -403,8 +403,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_payload_clones_stats() {
|
||||
let mut stats = StatsSnapshot::default();
|
||||
stats.games_played = 42;
|
||||
let stats = StatsSnapshot { games_played: 42, ..Default::default() };
|
||||
let payload = build_payload(&stats, &[], &PlayerProgress::default());
|
||||
assert_eq!(payload.stats.games_played, 42);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_data::settings::Theme;
|
||||
|
||||
use crate::events::HintVisualEvent;
|
||||
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
|
||||
@@ -27,6 +28,17 @@ pub struct TableBackground;
|
||||
#[derive(Component, Debug, Clone)]
|
||||
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.
|
||||
pub struct TablePlugin;
|
||||
|
||||
@@ -37,8 +49,17 @@ impl Plugin for TablePlugin {
|
||||
// and this call is a no-op.
|
||||
app.add_event::<WindowResized>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.add_event::<HintVisualEvent>()
|
||||
.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -342,6 +416,76 @@ mod tests {
|
||||
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]
|
||||
fn suit_symbol_all_four_are_distinct() {
|
||||
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
|
||||
|
||||
@@ -158,7 +158,7 @@ mod tests {
|
||||
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.
|
||||
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]
|
||||
@@ -188,7 +188,7 @@ mod tests {
|
||||
app.update();
|
||||
let p = &app.world().resource::<ProgressResource>().0;
|
||||
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]
|
||||
|
||||
@@ -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::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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -221,19 +237,3 @@ mod tests {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,8 +201,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn add_xp_saturates_on_overflow() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.total_xp = u64::MAX;
|
||||
let mut p = PlayerProgress { total_xp: u64::MAX, ..Default::default() };
|
||||
p.add_xp(1);
|
||||
assert_eq!(p.total_xp, u64::MAX);
|
||||
}
|
||||
@@ -230,8 +229,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn roll_weekly_goals_clears_progress_for_new_week() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.weekly_goal_week_iso = Some("2026-W16".to_string());
|
||||
let mut p = PlayerProgress { weekly_goal_week_iso: Some("2026-W16".to_string()), ..Default::default() };
|
||||
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 3);
|
||||
|
||||
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
|
||||
@@ -242,8 +240,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn roll_weekly_goals_is_noop_for_same_week() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.weekly_goal_week_iso = Some("2026-W17".to_string());
|
||||
let mut p = PlayerProgress { weekly_goal_week_iso: Some("2026-W17".to_string()), ..Default::default() };
|
||||
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 2);
|
||||
|
||||
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
|
||||
|
||||
@@ -135,17 +135,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn record_abandoned_resets_win_streak() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.win_streak_current = 5;
|
||||
let mut s = StatsSnapshot { win_streak_current: 5, ..Default::default() };
|
||||
s.record_abandoned();
|
||||
assert_eq!(s.win_streak_current, 0, "abandoned game must break the win streak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_abandoned_preserves_best_streak() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.win_streak_best = 7;
|
||||
s.win_streak_current = 7;
|
||||
let mut s = StatsSnapshot { win_streak_best: 7, win_streak_current: 7, ..Default::default() };
|
||||
s.record_abandoned();
|
||||
assert_eq!(s.win_streak_best, 7, "best streak must not be reduced on abandon");
|
||||
assert_eq!(s.win_streak_current, 0);
|
||||
|
||||
Reference in New Issue
Block a user