113a933170
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95) wraps signatures and call sites differently, so every touched file was picking up unrelated formatting hunks. One mechanical pass, and a 'cargo fmt --check' step in the test workflow (same pinned 1.95.0 toolchain) so drift can't accumulate again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
198 lines
7.5 KiB
Rust
198 lines
7.5 KiB
Rust
//! Card flip animation and drag shadows.
|
|
|
|
use super::*;
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use solitaire_core::Card;
|
|
|
|
use crate::animation_plugin::EffectiveSlideDuration;
|
|
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
|
use crate::layout::LayoutResource;
|
|
use crate::resources::DragState;
|
|
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z};
|
|
|
|
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
|
|
///
|
|
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
|
|
pub(super) fn start_flip_anim(
|
|
mut events: MessageReader<CardFlippedEvent>,
|
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
|
mut commands: Commands,
|
|
card_entities: Query<(Entity, &CardEntity)>,
|
|
) {
|
|
if slide_dur.is_some_and(|d| d.slide_secs == 0.0) {
|
|
// Instant animation speed — skip the flip effect entirely.
|
|
events.clear();
|
|
return;
|
|
}
|
|
|
|
for CardFlippedEvent(flipped_card) in events.read() {
|
|
for (entity, marker) in &card_entities {
|
|
if marker.card == *flipped_card {
|
|
commands.entity(entity).insert(CardFlipAnim {
|
|
timer: 0.0,
|
|
phase: FlipPhase::ScalingDown,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
|
|
///
|
|
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
|
|
/// - At the midpoint the phase switches to `ScalingUp`, scale.x resets to 0,
|
|
/// and a `CardFaceRevealedEvent` is fired so audio plays in sync with the reveal.
|
|
/// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`.
|
|
/// - When complete the component is removed and scale.x is restored to 1.0.
|
|
pub(super) fn tick_flip_anim(
|
|
mut commands: Commands,
|
|
time: Res<Time>,
|
|
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
|
|
mut reveal_events: MessageWriter<CardFaceRevealedEvent>,
|
|
) {
|
|
let dt = time.delta_secs();
|
|
for (entity, card_entity, mut transform, mut anim) in &mut anims {
|
|
anim.timer += dt;
|
|
match anim.phase {
|
|
FlipPhase::ScalingDown => {
|
|
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
|
transform.scale.x = 1.0 - t;
|
|
if t >= 1.0 {
|
|
anim.phase = FlipPhase::ScalingUp;
|
|
anim.timer = 0.0;
|
|
transform.scale.x = 0.0;
|
|
// Fire the reveal event exactly once, at the phase transition,
|
|
// so the flip sound is synchronised with the visual face reveal.
|
|
reveal_events.write(CardFaceRevealedEvent(card_entity.card.clone()));
|
|
}
|
|
}
|
|
FlipPhase::ScalingUp => {
|
|
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
|
transform.scale.x = t;
|
|
if t >= 1.0 {
|
|
transform.scale.x = 1.0;
|
|
commands.entity(entity).remove::<CardFlipAnim>();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task #38 — Drag-elevation shadow
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Maintains a single `ShadowEntity` while cards are being dragged.
|
|
///
|
|
/// - If a drag is active, spawns (or repositions) a semi-transparent dark
|
|
/// sprite behind the top dragged card.
|
|
/// - If no drag is active, despawns the shadow entity.
|
|
pub(super) fn update_drag_shadow(
|
|
mut commands: Commands,
|
|
drag: Res<DragState>,
|
|
layout: Option<Res<LayoutResource>>,
|
|
card_entities: Query<(&CardEntity, &Transform)>,
|
|
card_index: Res<CardEntityIndex>,
|
|
mut shadow: Local<Option<Entity>>,
|
|
) {
|
|
if drag.is_idle() {
|
|
// No drag in progress — remove shadow if it exists.
|
|
if let Some(e) = shadow.take() {
|
|
commands.entity(e).despawn();
|
|
}
|
|
return;
|
|
}
|
|
|
|
let Some(layout) = layout else { return };
|
|
let card_w = layout.0.card_size.x;
|
|
let card_h = layout.0.card_size.y;
|
|
|
|
// Find the world position of the first (top) dragged card.
|
|
let top_pos = drag.cards.first().and_then(|first_card| {
|
|
card_index
|
|
.get(first_card)
|
|
.and_then(|entity| card_entities.get(entity).ok())
|
|
.map(|(_, t)| t.translation)
|
|
});
|
|
|
|
let Some(top_pos) = top_pos else { return };
|
|
|
|
// Shadow is slightly larger, offset behind-and-below, at a z slightly
|
|
// below the dragged cards.
|
|
let shadow_pos = top_pos + Vec3::new(-4.0, 4.0, -1.0);
|
|
|
|
match *shadow {
|
|
Some(e) => {
|
|
// Reposition the existing shadow.
|
|
commands
|
|
.entity(e)
|
|
.insert(Transform::from_translation(shadow_pos));
|
|
}
|
|
None => {
|
|
// Spawn a new shadow sprite. Alpha tracks the per-card
|
|
// CARD_SHADOW_ALPHA_DRAG token so the Terminal palette's
|
|
// "no box-shadow" policy disables this stack shadow in
|
|
// lockstep with the per-card shadows. Re-enabling shadows
|
|
// is then a one-line change in `ui_theme`, not a hunt
|
|
// through plugin code.
|
|
let e = commands
|
|
.spawn((
|
|
ShadowEntity,
|
|
Sprite {
|
|
color: CARD_SHADOW_COLOR.with_alpha(CARD_SHADOW_ALPHA_DRAG),
|
|
custom_size: Some(Vec2::new(card_w + 8.0, card_h + 8.0)),
|
|
..default()
|
|
},
|
|
Transform::from_translation(shadow_pos),
|
|
Visibility::default(),
|
|
))
|
|
.id();
|
|
*shadow = Some(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Snaps every per-card [`CardShadow`] between its idle and lifted tunings
|
|
/// based on whether the parent [`CardEntity`] is currently in
|
|
/// [`DragState::cards`]. Runs every frame; the transition is an instant snap
|
|
/// (no lerp) — the existing shake / settle feedback already handles motion
|
|
/// at drag-end, so an additional shadow tween would compete with those cues.
|
|
///
|
|
/// The shadow size is rebuilt from the parent card's current `Sprite`
|
|
/// `custom_size` plus the appropriate padding, so the resize handler does
|
|
/// not need to pre-tune shadow sizes for the drag state — this system fixes
|
|
/// the geometry within one frame.
|
|
pub(super) fn update_card_shadows_on_drag(
|
|
drag: Res<DragState>,
|
|
cards: Query<(&CardEntity, &Sprite, &Children), Without<CardShadow>>,
|
|
mut shadows: Query<(&mut Sprite, &mut Transform), With<CardShadow>>,
|
|
) {
|
|
let dragged: HashSet<&Card> = drag.cards.iter().collect();
|
|
|
|
for (card_entity, card_sprite, children) in cards.iter() {
|
|
let is_dragged = dragged.contains(&card_entity.card);
|
|
let (offset, padding, alpha) = card_shadow_params(is_dragged);
|
|
let Some(card_size) = card_sprite.custom_size else {
|
|
continue;
|
|
};
|
|
|
|
for child in children.iter() {
|
|
let Ok((mut shadow_sprite, mut shadow_transform)) = shadows.get_mut(child) else {
|
|
continue;
|
|
};
|
|
shadow_sprite.color = CARD_SHADOW_COLOR.with_alpha(alpha);
|
|
shadow_sprite.custom_size = Some(card_size + padding);
|
|
shadow_transform.translation.x = offset.x;
|
|
shadow_transform.translation.y = offset.y;
|
|
shadow_transform.translation.z = CARD_SHADOW_LOCAL_Z;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task #28 — Hint highlight tick system
|
|
// ---------------------------------------------------------------------------
|