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