Files
Ferrous-Solitaire/solitaire_engine/src/card_animation/interaction.rs
T
funman300 648cd44387 chore(deps): migrate to Bevy 0.17
- Event/EventReader/EventWriter renamed to Message/MessageReader/MessageWriter
- add_event → add_message for all 67 call sites
- ScrollPosition changed to tuple struct ScrollPosition(Vec2)
- CursorIcon import moved from bevy::winit::cursor to bevy::window
- WindowResolution::from((f32,f32)) removed — use (u32,u32) tuple
- World::send_event → World::write_message in test code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:04:44 -07:00

322 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 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: 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);
}
}