From f712b89fe483bb423dbc9a897a0db93e72ef339f Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 2 May 2026 00:21:28 +0000 Subject: [PATCH] feat(engine): drop shadows on cards with lifted state during drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cards previously read as flat stickers on the felt — no separation cue, no sense the play surface had any depth. Each CardEntity now spawns a CardShadow child sprite: neutral black at 25 % alpha, sized to card_size + 4 px halo, offset (2, -3) and rendered at local z -0.05 so it sits behind its card. Cards in the active drag set switch to a lifted shadow: alpha 40 %, offset (4, -6), padding (8, 8). update_card_shadows_on_drag runs every Update and snaps each shadow to the right state based on DragState membership — no lerp, no animation cost. The pure card_shadow_params(is_dragged) helper is unit-tested for the four parameter values. resize_cards_in_place gains a third query for shadows so the in-place resize keeps shadows cheap (no Sprite regeneration); the shadow's current alpha is read to preserve idle vs lifted padding across a resize. update_card_entity's despawn_related call is followed by a fresh add_card_shadow_child so the shadow re-attaches when the card is repainted (face flip, settings change, theme swap). The pre-existing bulk drag-shadow under the whole lifted stack is untouched — per-card shadows complement it. All shadow values flow through eight new ui_theme tokens (CARD_SHADOW_COLOR, alphas, offsets, paddings, local z) so the visual is tunable in one place. Color is neutral black so the shadows don't conflict with color-blind mode's red/blue suit tints. Four new tests pin the contract: shadow params for idle and drag states, every CardEntity spawns with exactly one CardShadow child, and dragging shifts only the dragged shadow's offset while leaving unrelated shadows on the idle offset. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_engine/src/card_plugin.rs | 360 +++++++++++++++++++++++++++- solitaire_engine/src/ui_theme.rs | 77 ++++++ 2 files changed, 430 insertions(+), 7 deletions(-) diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 96f8ea7..ad36fd5 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -29,6 +29,11 @@ use crate::pause_plugin::PausedResource; use crate::resources::{DragState, GameStateResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::table_plugin::PileMarker; +use crate::ui_theme::{ + CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z, + CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG, + CARD_SHADOW_PADDING_IDLE, +}; /// Fraction of card height used as vertical offset between face-up tableau cards. pub const TABLEAU_FAN_FRAC: f32 = 0.25; @@ -132,6 +137,24 @@ pub struct RightClickHighlightTimer(pub f32); #[derive(Component, Debug)] pub struct StockEmptyLabel; +/// Marker on the chip-background sprite of the stock-pile remaining-count +/// badge. +/// +/// The badge is spawned as a child of the stock [`PileMarker`] entity so its +/// transform tracks the stock pile through resizes. The chip sits in the +/// top-right corner of the stock pile and is hidden while the stock is empty +/// (the existing `↺` overlay covers the recycle hint instead). +#[derive(Component, Debug)] +pub struct StockCountBadge; + +/// Marker on the `Text2d` child of [`StockCountBadge`] showing the numeric +/// count of cards remaining in the stock pile. +/// +/// Update systems query this component to write the new count in place rather +/// than despawning and respawning the text entity each tick. +#[derive(Component, Debug)] +pub struct StockCountBadgeText; + // --------------------------------------------------------------------------- // Task #34 — Card-flip animation // --------------------------------------------------------------------------- @@ -168,6 +191,72 @@ const FLIP_HALF_SECS: f32 = 0.08; #[derive(Component, Debug)] pub struct ShadowEntity; +/// Marker component for the per-card drop-shadow child sprite. +/// +/// Every `CardEntity` owns exactly one `CardShadow` child whose `Sprite` is a +/// neutral-black halo painted slightly down-and-right of the card. Idle state +/// uses [`CARD_SHADOW_OFFSET_IDLE`] / [`CARD_SHADOW_ALPHA_IDLE`]; while the +/// parent card is being dragged the shadow is pushed to the deeper +/// [`CARD_SHADOW_OFFSET_DRAG`] / [`CARD_SHADOW_ALPHA_DRAG`] values so the +/// stack reads as "lifted" off the felt. +#[derive(Component, Debug)] +pub struct CardShadow; + +/// Returns the `(offset, padding, alpha)` triple used to paint a per-card +/// shadow given whether its parent card is currently part of the dragged +/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested +/// without spinning up a Bevy app. +/// +/// `is_dragged = false` → resting `(IDLE, IDLE, IDLE)` +/// `is_dragged = true` → lifted `(DRAG, DRAG, DRAG)` +pub fn card_shadow_params(is_dragged: bool) -> (Vec2, Vec2, f32) { + if is_dragged { + ( + CARD_SHADOW_OFFSET_DRAG, + CARD_SHADOW_PADDING_DRAG, + CARD_SHADOW_ALPHA_DRAG, + ) + } else { + ( + CARD_SHADOW_OFFSET_IDLE, + CARD_SHADOW_PADDING_IDLE, + CARD_SHADOW_ALPHA_IDLE, + ) + } +} + +/// Builds the `Sprite` used for a per-card shadow at the resting state. The +/// alpha and size both use the idle tokens; `update_card_shadows_on_drag` +/// retunes them at runtime when the parent card joins / leaves the dragged +/// stack. +fn card_shadow_sprite(card_size: Vec2) -> Sprite { + let (_offset, padding, alpha) = card_shadow_params(false); + Sprite { + color: CARD_SHADOW_COLOR.with_alpha(alpha), + custom_size: Some(card_size + padding), + ..default() + } +} + +/// Builds the `Transform` used for a per-card shadow at the resting state. +/// Local — it is parented to the card entity, so positions are relative. +fn card_shadow_transform() -> Transform { + let (offset, _padding, _alpha) = card_shadow_params(false); + Transform::from_xyz(offset.x, offset.y, CARD_SHADOW_LOCAL_Z) +} + +/// Spawns a single `CardShadow` child under the given card entity builder. +/// Extracted so `spawn_card_entity` and `update_card_entity` can share the +/// exact same shadow recipe — we never want one path to drift from the other. +fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) { + parent.spawn(( + CardShadow, + card_shadow_sprite(card_size), + card_shadow_transform(), + Visibility::default(), + )); +} + /// Throttle interval for resize-driven card snap work, in seconds. /// /// `WindowResized` fires once per pixel of drag, so a fast corner-drag can @@ -228,6 +317,7 @@ impl Plugin for CardPlugin { start_flip_anim.after(GameMutation), tick_flip_anim, update_drag_shadow, + update_card_shadows_on_drag.after(sync_cards_on_change), tick_hint_highlight, handle_right_click, tick_right_click_highlights, @@ -534,6 +624,13 @@ fn spawn_card_entity( Transform::from_xyz(pos.x, pos.y, z), Visibility::default(), )); + // Every card gets a subtle drop-shadow child so the play surface reads + // as physical instead of flat. Spawned in idle state; the drag-tracking + // system retunes its offset / alpha when this card joins the dragged + // stack. + entity.with_children(|b| { + add_card_shadow_child(b, layout.card_size); + }); // When PNG faces are loaded the rank/suit are baked into the image. // Only spawn the Text2d overlay in the solid-colour fallback (tests). if card_images.is_none() { @@ -593,10 +690,13 @@ fn update_card_entity( .insert(Transform::from_xyz(pos.x, pos.y, z)); } - // Despawn any stale children and re-add the label overlay only when - // operating in solid-colour mode (no PNG faces). In image mode the - // rank/suit are baked into the PNG, so no Text2d overlay is needed. + // Despawn any stale children and re-add the per-card drop shadow plus, + // in solid-colour fallback mode, the label overlay. In image mode the + // rank/suit are baked into the PNG, so no `Text2d` overlay is needed. commands.entity(entity).despawn_related::(); + commands.entity(entity).with_children(|b| { + add_card_shadow_child(b, layout.card_size); + }); if card_images.is_none() { commands.entity(entity).with_children(|b| { b.spawn(( @@ -795,6 +895,43 @@ fn update_drag_shadow( } } +/// 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. +fn update_card_shadows_on_drag( + drag: Res, + cards: Query<(&CardEntity, &Sprite, &Children), Without>, + mut shadows: Query<(&mut Sprite, &mut Transform), With>, +) { + let dragged: HashSet = drag.cards.iter().copied().collect(); + + for (card_entity, card_sprite, children) in cards.iter() { + let is_dragged = dragged.contains(&card_entity.card_id); + 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 // --------------------------------------------------------------------------- @@ -1204,7 +1341,7 @@ fn collect_resize_events( /// Scheduled after [`collect_resize_events`] (which itself runs after /// `LayoutSystem::UpdateOnResize`) so `LayoutResource` reflects the latest /// window size before we read it. -#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments, clippy::type_complexity)] fn snap_cards_on_window_resize( mut commands: Commands, time: Res