Files
Ferrous-Solitaire/solitaire_engine/src/card_animation/interaction.rs
T
funman300 1438fd6265
Build and Deploy / build-and-push (push) Failing after 1m2s
Web E2E / web-e2e (push) Failing after 3m19s
refactor(core): complete card_game::Card migration across engine + wasm
Finish the half-applied Card refactor. solitaire_core::card::Card is now an
alias for the opaque card_game::Card: suit()/rank() are methods, there is no
id or face_up field, and it is Clone+Eq+Hash but not Copy. Pile accessors
return Vec<(Card, bool)> where the bool is face-up.

Card identity is now the Card value itself (via Eq/Hash), not a numeric u32:
- CardEntity stores `card: Card` (was `card_id: u32`); lookups compare cards.
- Drag/selection collections and the touch/keyboard selection setters use
  Vec<Card>; CardFlippedEvent/CardFaceRevealedEvent/HintVisualEvent carry Card.
- replay_overlay and feedback/settle/deal animations updated accordingly.

solitaire_wasm: CardSnapshot derives its JSON id from suit+rank (matching the
desktop engine), and consumes the (Card, bool) pile tuples.

test-support: TestPileState tableau overrides now carry a per-card face-up flag
so tests can place face-down tableau cards. set_test_tableau_cards keeps its
Vec<Card> signature (defaulting to face-up); new set_test_tableau_cards_with_face
takes Vec<(Card, bool)>.

cargo test --workspace passes (engine lib 897 ok, 0 failed); cargo clippy
--workspace --all-targets -- -D warnings is clean. Save/serde format unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:45:34 -07:00

326 lines
11 KiB
Rust

//! 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 solitaire_core::card::Card;
use super::animation::CardAnimation;
use super::tuning::AnimationTuning;
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
// ---------------------------------------------------------------------------
/// 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.
///
/// Uses [`AnimationTuning`] to get the platform-appropriate hover scale.
/// On touch (`hover_scale == 1.0`) this becomes a no-op — there is no
/// hover affordance on a touchscreen.
///
/// 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>,
tuning: Res<AnimationTuning>,
mut hover_state: ResMut<HoverState>,
mut cards: CardTransformQuery,
) {
let dt = time.delta_secs();
let target_entity = hover_state.entity;
let hover_target = tuning.hover_scale;
let lerp_speed = tuning.hover_lerp_speed;
for (entity, mut transform) in &mut cards {
let target_scale = if Some(entity) == target_entity {
hover_target
} else {
1.0
};
let current = transform.scale.x;
let new_scale = current + (target_scale - current) * (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_or(hover_target, |(_, t)| t.scale.x)
} else {
1.0
};
}
/// Applies a scale boost to committed dragged card entities.
///
/// Uses [`AnimationTuning`] for the platform-correct drag scale. Only applies
/// to cards whose drag has been *committed* (threshold crossed); cards in the
/// pending-drag state stay at scale 1.0. Does **not** modify `translation.xy`
/// — `InputPlugin` owns drag translation.
pub(crate) fn apply_drag_visual(
time: Res<Time>,
drag: Option<Res<DragState>>,
tuning: Res<AnimationTuning>,
mut cards: Query<(Entity, &CardEntity, &mut Transform), (Without<CardAnimation>,)>,
) {
let dt = time.delta_secs();
let drag_scale = tuning.drag_scale;
// Only lift cards that are in a *committed* drag. Pending drags (below
// threshold) must stay at scale 1.0 to avoid visible premature lift.
let (dragged_cards, committed): (&[Card], bool) = drag
.as_ref()
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
for (_, card, mut transform) in &mut cards {
let is_active_drag = committed && dragged_cards.contains(&card.card);
let target_scale = if is_active_drag { drag_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: MessageWriter<MoveRequestEvent>,
mut draw_events: MessageWriter<DrawRequestEvent>,
mut undo_events: MessageWriter<UndoRequestEvent>,
) {
if !anims.is_empty() {
return;
}
match buffer.queue.pop_front() {
Some(BufferedInput::Move { from }) => {
move_events.write(from);
}
Some(BufferedInput::Draw) => {
draw_events.write(DrawRequestEvent);
}
Some(BufferedInput::Undo) => {
undo_events.write(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.single().ok()?;
let cursor = window.cursor_position()?;
let (camera, camera_transform) = cameras.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);
}
}