4b9d008be2
Conservative cleanup pass — applied only the high-signal pedantic lints whose fixes either remove genuine waste or read more naturally, skipping anything stylistic that would bloat the diff. - map_unwrap_or: 29 .map(...).unwrap_or(...) sites collapsed to .map_or / .is_some_and / .map_or_else equivalents - uninlined_format_args: 7 production format!/write!/println! sites rewritten to the inline-argument style; assert! sites in test code intentionally untouched - match_same_arms: 2 redundant arms collapsed where the bodies were identical and the merger didn't obscure intent Public API is unchanged. No dependencies added or removed. The pedantic warning count dropped from 840 to 807 (-33). Out-of-scope findings — needless_pass_by_value on Bevy Res params, false-positive explicit_iter_loop on Bevy Query iterators, items_after_statements inside test mods, and the "ask before changing" merge logic in solitaire_sync — were intentionally deferred. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
327 lines
11 KiB
Rust
327 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 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_ids, committed): (&[u32], 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_ids.contains(&card.card_id);
|
|
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);
|
|
}
|
|
}
|