//! PNG-based card rendering. //! //! Card entities are synced with [`GameStateResource`] on every //! [`StateChangedEvent`]: missing cards are spawned, present cards are //! repositioned/updated in place, and stale cards are despawned. //! //! When [`CardImageSet`] is available, each face-up card renders its own //! 120×168 px `Handle` chosen from the 52 per-card PNGs loaded from //! `assets/cards/faces/{rank}_{suit}.png`. A solid-colour `Sprite` with a //! `Text2d` rank+suit overlay is used as a fallback when `CardImageSet` is //! absent (e.g. in tests running under `MinimalPlugins`). use std::collections::{HashMap, HashSet}; use bevy::color::Color; use bevy::prelude::*; use bevy::window::WindowResized; use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::pile::PileType; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use crate::animation_plugin::{CardAnim, EffectiveSlideDuration}; use crate::card_animation::CardAnimation; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent}; use crate::game_plugin::GameMutation; use crate::layout::{Layout, LayoutResource, LayoutSystem}; use crate::pause_plugin::PausedResource; use crate::resources::{DragState, GameStateResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::table_plugin::PileMarker; use crate::font_plugin::FontResource; 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, STOCK_BADGE_BG, STOCK_BADGE_FG, TYPE_CAPTION, Z_STOCK_BADGE, }; /// Fraction of card height used as vertical offset between face-up tableau cards. pub const TABLEAU_FAN_FRAC: f32 = 0.25; /// Tighter fan for face-down cards in the tableau — just enough to show the stack. /// Per-card vertical step for face-down tableau cards, as a fraction of /// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards /// don't need their full body shown — only the back-pattern strip is /// visible. Public so `input_plugin` can mirror the exact sprite layout /// when hit-testing tableau columns; any drift between this and the /// renderer creates a visible offset between the card face and where /// clicks land. pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12; /// Fraction of card height used as a tiny offset between stacked cards in /// non-tableau piles, so stacking is visible. Public so other plugins /// (e.g. input_plugin's drag-rejection tween) can compute the resting /// `Transform.translation.z` for a card at a given stack index without /// drifting from the value used by [`card_positions`]. pub const STACK_FAN_FRAC: f32 = 0.003; /// Font size as a fraction of card width. const FONT_SIZE_FRAC: f32 = 0.28; pub const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95); pub const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15); pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08); /// Pre-loaded [`Handle`]s for card face and back PNG textures. /// /// Loaded once at startup by [`load_card_images`]. When this resource is /// present, card sprites use the PNG artwork; otherwise they fall back to /// solid-colour sprites (used in tests with `MinimalPlugins`). #[derive(Resource)] pub struct CardImageSet { /// Per-card face images indexed by `[suit][rank]`. /// /// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3. /// Rank order: Ace=0, Two=1 … King=12. pub faces: [[Handle; 13]; 4], /// One handle per unlockable card-back design (indices 0–4). These /// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed /// by `Settings::selected_card_back`. Used as a fallback when the active /// theme does not provide its own back (see [`Self::theme_back`]). pub backs: [Handle; 5], /// Back image supplied by the currently-active card theme, if any. /// /// Populated by `theme::plugin::apply_theme_to_card_image_set` whenever /// a `CardTheme` finishes loading. The face-down render path in /// [`card_sprite`] prefers this handle over the legacy `backs[]` array, /// so a theme switch swaps both faces *and* the back without the player /// needing to touch the legacy `selected_card_back` picker. `None` means /// the active theme did not declare a back asset (or no theme has loaded /// yet); in that case [`card_sprite`] falls back to the legacy array. pub theme_back: Option>, } /// Alternative face tint for red-suit cards in color-blind mode — a subtle /// blue wash that distinguishes them from black-suit cards without colour alone. const CARD_FACE_COLOUR_RED_CBM: Color = Color::srgba(0.85, 0.92, 1.0, 1.0); /// Returns the card back color for the given unlocked card-back index. /// Index 0 = default blue; 1–4 are unlockable alternate designs. fn card_back_colour(selected_card_back: usize) -> Color { match selected_card_back { 0 => Color::srgb(0.15, 0.30, 0.55), // default blue 1 => Color::srgb(0.55, 0.10, 0.10), // deep red 2 => Color::srgb(0.05, 0.40, 0.10), // forest green 3 => Color::srgb(0.35, 0.08, 0.52), // purple _ => Color::srgb(0.05, 0.40, 0.42), // teal (4+) } } /// Marker component linking a Bevy entity to a `solitaire_core::Card::id`. #[derive(Component, Debug, Clone, Copy)] pub struct CardEntity { pub card_id: u32, } /// Marker for the text child inside a card entity. #[derive(Component, Debug)] pub struct CardLabel; /// Marker component indicating the card is currently highlighted as a hint. /// `remaining` counts down in real seconds; the highlight is removed when it /// reaches zero and the card sprite colour is restored to its normal value. #[derive(Component, Debug, Clone)] pub struct HintHighlight { /// Seconds remaining before the highlight is cleared. pub remaining: f32, } /// Countdown (seconds) until the `HintHighlight` on a card entity is removed. /// /// Inserted alongside `HintHighlight` by the hint-visual system. When the timer /// reaches zero both `HintHighlight` and `HintHighlightTimer` are removed from /// the entity and the sprite colour is restored. #[derive(Component, Debug, Clone)] pub struct HintHighlightTimer(pub f32); /// Marker on a `PileMarker` entity that is highlighted because the right-clicked /// card can legally be placed there. #[derive(Component, Debug)] pub struct RightClickHighlight; /// Countdown (seconds) until this right-click destination highlight despawns. /// /// Inserted alongside `RightClickHighlight` so that highlights auto-clear after /// 1.5 s even if the player does not make a move or click again. The existing /// clear-on-state-change and clear-on-pause logic still fires early when /// appropriate. #[derive(Component, Debug, Clone)] pub struct RightClickHighlightTimer(pub f32); /// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile /// marker when the stock pile is empty. #[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 *top-level* world entity (not parented to the /// stock [`PileMarker`]) and its `Transform` is recomputed each frame from /// `LayoutResource` so it tracks the stock pile through window resizes. /// The chip sits in the top-right corner of the stock pile and is hidden /// while the stock is empty — the existing `↺` overlay /// ([`StockEmptyLabel`]) covers the recycle hint instead, so the two /// indicators never render simultaneously. #[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 // --------------------------------------------------------------------------- /// Phase of the two-stage flip animation. #[derive(Debug, Clone, PartialEq)] pub enum FlipPhase { /// Scale X from 1.0 → 0.0 (hiding the back face). ScalingDown, /// Scale X from 0.0 → 1.0 (revealing the front face). ScalingUp, } /// Drives a 2-phase "card flip" animation on `CardEntity` entities. /// /// The animation squashes X to 0, swaps the sprite to the face-up colour, /// then expands X back to 1. Total duration is `2 × FLIP_HALF_SECS`. #[derive(Component, Debug, Clone)] pub struct CardFlipAnim { /// Seconds elapsed in the current phase. pub timer: f32, /// Which half of the flip we are in. pub phase: FlipPhase, } /// Duration of each half of the flip animation (scale-down or scale-up). const FLIP_HALF_SECS: f32 = 0.08; // --------------------------------------------------------------------------- // Task #38 — Drag-elevation shadow // --------------------------------------------------------------------------- /// Marker component for the semi-transparent shadow sprite shown while dragging. #[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 /// produce dozens of events per frame. Re-running the per-card snap logic /// (52 cards × sprite/transform/font_size touches) for every event is the /// dominant cost of resize lag. We coalesce pending work and apply it at most /// once per [`RESIZE_THROTTLE_SECS`] (~20 Hz). The user still sees updates /// during a sustained drag, and the layout always catches up to the final /// size when the drag stops because the pending size is held until applied. const RESIZE_THROTTLE_SECS: f32 = 0.05; /// Holds the latest pending window size from `WindowResized` events plus a /// timestamp for the last applied snap, so the resize-snap work can be /// rate-limited to ~20 Hz during sustained drags. #[derive(Resource, Debug, Default)] pub struct ResizeThrottle { /// Latest unapplied window size from `WindowResized`. `None` when there is /// nothing to apply. pub pending: Option, /// `Time::elapsed_secs()` value at the moment of the most recent applied /// snap. `0.0` until the first apply. pub last_applied_secs: f32, } /// Pure helper used by the throttled resize-snap system: returns `true` when /// a pending resize should be flushed given the current `now_secs` and the /// last-applied timestamp. Throttle interval is [`RESIZE_THROTTLE_SECS`]. /// /// Extracted so the rate-limit logic can be unit-tested without spinning up /// a full Bevy app. fn should_apply_resize(now_secs: f32, last_applied_secs: f32) -> bool { (now_secs - last_applied_secs) >= RESIZE_THROTTLE_SECS } /// Renders cards by reading `GameStateResource` on `StateChangedEvent`. pub struct CardPlugin; impl Plugin for CardPlugin { fn build(&self, app: &mut App) { // PostStartup ensures TablePlugin's Startup system has inserted // LayoutResource before we try to read it. // // `handle_right_click` reads `ButtonInput`. Under // `MinimalPlugins` (tests) this resource is absent by default, so we // ensure it exists here. Under `DefaultPlugins` the call is a no-op. app.init_resource::>() .init_resource::() .add_message::() .add_message::() .add_message::() .add_systems(Startup, load_card_images) .add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup)) .add_systems( Update, ( sync_cards_on_change.after(GameMutation), resync_cards_on_settings_change.before(sync_cards_on_change), 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, clear_right_click_highlights_on_state_change.after(GameMutation), clear_right_click_highlights_on_pause, update_stock_empty_indicator.after(GameMutation), update_stock_count_badge.after(GameMutation), collect_resize_events.after(LayoutSystem::UpdateOnResize), snap_cards_on_window_resize.after(collect_resize_events), ), ); } } /// Loads card face and back PNGs at startup via [`AssetServer`] and inserts /// [`CardImageSet`]. /// /// Faces: `assets/cards/faces/{RANK}{SUIT}.png` (e.g. `AC.png`, `10H.png`) /// Backs: `assets/cards/backs/back_{0..4}.png` /// /// Under `MinimalPlugins` (tests) `AssetServer` is absent, so the system /// returns without inserting `CardImageSet` and the plugin falls back to /// solid-colour sprites. fn load_card_images(asset_server: Option>, mut commands: Commands) { let Some(asset_server) = asset_server else { return; }; // Suit index: Clubs=0, Diamonds=1, Hearts=2, Spades=3 const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"]; // Rank index: Ace=0 … King=12 const RANK_STRS: [&str; 13] = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]; let faces: [[Handle; 13]; 4] = std::array::from_fn(|suit| { std::array::from_fn(|rank| { asset_server.load(format!( "cards/faces/{}{}.png", RANK_STRS[rank], SUIT_CHARS[suit] )) }) }); let backs = std::array::from_fn(|i| { asset_server.load(format!("cards/backs/back_{i}.png")) }); commands.insert_resource(CardImageSet { faces, backs, // Populated by the theme plugin once a `CardTheme` finishes loading. // Until then the legacy back fallback (`backs[selected_card_back]`) // is used. theme_back: None, }); } /// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is /// available and falling back to a solid-colour sprite in tests. fn card_sprite( card: &Card, card_size: Vec2, back_colour: Color, color_blind: bool, card_images: Option<&CardImageSet>, selected_back: usize, ) -> Sprite { if let Some(set) = card_images { let image = if card.face_up { let suit_idx = match card.suit { Suit::Clubs => 0, Suit::Diamonds => 1, Suit::Hearts => 2, Suit::Spades => 3, }; let rank_idx = match card.rank { Rank::Ace => 0, Rank::Two => 1, Rank::Three => 2, Rank::Four => 3, Rank::Five => 4, Rank::Six => 5, Rank::Seven => 6, Rank::Eight => 7, Rank::Nine => 8, Rank::Ten => 9, Rank::Jack => 10, Rank::Queen => 11, Rank::King => 12, }; set.faces[suit_idx][rank_idx].clone() } else if let Some(theme_back) = &set.theme_back { // Active theme provides its own back — always wins over the // legacy `selected_card_back` picker, so a theme switch swaps // faces *and* the back. The picker is treated as informational // only while a theme back is active (see settings_plugin). theme_back.clone() } else { let idx = selected_back.min(set.backs.len() - 1); set.backs[idx].clone() }; Sprite { image, color: Color::WHITE, custom_size: Some(card_size), ..default() } } else { let body_colour = if card.face_up { face_colour(card, color_blind) } else { back_colour }; Sprite { color: body_colour, custom_size: Some(card_size), ..default() } } } /// When card-back selection changes in Settings, re-render all cards so the /// new back colour is applied immediately (without waiting for a state change). fn resync_cards_on_settings_change( mut setting_events: MessageReader, mut state_events: MessageWriter, ) { if setting_events.read().next().is_some() { state_events.write(StateChangedEvent); } } /// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems /// (including `TablePlugin::setup_table` which inserts `LayoutResource`) /// have already completed. fn sync_cards_startup( commands: Commands, game: Res, layout: Option>, slide_dur: Option>, settings: Option>, entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>, card_images: Option>, ) { if let Some(layout) = layout { let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs); let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back); let back_colour = card_back_colour(selected_back); let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode); sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities, card_images.as_deref(), selected_back); } } #[allow(clippy::too_many_arguments)] fn sync_cards_on_change( mut events: MessageReader, commands: Commands, game: Res, layout: Option>, slide_dur: Option>, settings: Option>, entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>, card_images: Option>, ) { if events.read().next().is_none() { return; } if let Some(layout) = layout { let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs); let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back); let back_colour = card_back_colour(selected_back); let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode); sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities, card_images.as_deref(), selected_back); } } #[allow(clippy::too_many_arguments)] fn sync_cards( mut commands: Commands, game: &GameState, layout: &Layout, slide_secs: f32, back_colour: Color, color_blind: bool, entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>, card_images: Option<&CardImageSet>, selected_back: usize, ) { let positions = card_positions(game, layout); // Map card_id -> (Entity, current_translation, has_card_animation) for // in-place updates. The `has_card_animation` flag lets `update_card_entity` // skip the snap/slide path on cards that are already being driven by a // curve-based `CardAnimation` tween (e.g. the drag-rejection return tween // — see `input_plugin::end_drag`). Otherwise the StateChangedEvent that // accompanies a rejection would race the tween and the card would jump. let mut existing: HashMap = HashMap::new(); for (entity, marker, transform, anim) in entities.iter() { existing.insert(marker.card_id, (entity, transform.translation, anim.is_some())); } let live_ids: HashSet = positions.iter().map(|(c, _, _)| c.id).collect(); // Despawn any entity whose card is no longer tracked. for (card_id, (entity, _, _)) in &existing { if !live_ids.contains(card_id) { commands.entity(*entity).despawn(); } } // For each card in the current state: spawn or update its entity. for (card, position, z) in positions { match existing.get(&card.id) { Some(&(entity, cur, has_anim)) => { update_card_entity( &mut commands, entity, card, position, z, layout, slide_secs, back_colour, color_blind, cur, has_anim, card_images, selected_back, ) } None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back), } } } /// Returns an ordered vec of (card, position, z) for every card in the game. fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Vec2, f32)> { let mut out: Vec<(&'a Card, Vec2, f32)> = Vec::with_capacity(52); let piles = [ PileType::Stock, PileType::Waste, PileType::Foundation(0), PileType::Foundation(1), PileType::Foundation(2), PileType::Foundation(3), PileType::Tableau(0), PileType::Tableau(1), PileType::Tableau(2), PileType::Tableau(3), PileType::Tableau(4), PileType::Tableau(5), PileType::Tableau(6), ]; for pile_type in piles { let Some(base) = layout.pile_positions.get(&pile_type) else { continue; }; let Some(pile) = game.piles.get(&pile_type) else { continue; }; let is_tableau = matches!(pile_type, PileType::Tableau(_)); let is_waste = matches!(pile_type, PileType::Waste); // Tableau uses a two-speed fan: face-down cards are packed tighter // than face-up cards so the visible (playable) portion stands out. // Non-tableau piles stack with a negligible offset. // // Waste pile: only the top N cards are rendered to prevent bleed-through // while new cards animate in from the stock. Draw-One shows 1; Draw-Three // shows up to 3 fanned in X (matching the standard Klondike presentation). let cards = &pile.cards; let render_start = if is_waste { let visible = match game.draw_mode { DrawMode::DrawOne => 1_usize, DrawMode::DrawThree => 3_usize, }; cards.len().saturating_sub(visible) } else { 0 }; let mut y_offset = 0.0_f32; for (slot, card) in cards[render_start..].iter().enumerate() { let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) { // Fan left→right; top card (last slot) is rightmost and playable. slot as f32 * layout.card_size.x * 0.28 } else { 0.0 }; let pos = Vec2::new(base.x + x_offset, base.y + y_offset); let z = 1.0 + (slot as f32) * STACK_FAN_FRAC; out.push((card, pos, z)); if is_tableau { let step = if card.face_up { TABLEAU_FAN_FRAC } else { TABLEAU_FACEDOWN_FAN_FRAC }; y_offset -= layout.card_size.y * step; } } } out } /// Returns the appropriate face-up body colour for a card. /// /// In color-blind mode, red-suit cards receive a subtle blue tint /// (`CARD_FACE_COLOUR_RED_CBM`) so they are distinguishable from black-suit /// cards without relying on the text colour alone. fn face_colour(card: &Card, color_blind: bool) -> Color { if color_blind && card.suit.is_red() { CARD_FACE_COLOUR_RED_CBM } else { CARD_FACE_COLOUR } } #[allow(clippy::too_many_arguments)] fn spawn_card_entity( commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color, color_blind: bool, card_images: Option<&CardImageSet>, selected_back: usize, ) { let sprite = card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back); let mut entity = commands.spawn(( CardEntity { card_id: card.id }, sprite, 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() { entity.with_children(|b| { b.spawn(( CardLabel, Text2d::new(label_for(card)), TextFont { font_size: layout.card_size.x * FONT_SIZE_FRAC, ..default() }, TextColor(text_colour(card)), Transform::from_xyz(0.0, 0.0, 0.01), label_visibility(card), )); }); } } #[allow(clippy::too_many_arguments)] fn update_card_entity( commands: &mut Commands, entity: Entity, card: &Card, pos: Vec2, z: f32, layout: &Layout, slide_secs: f32, back_colour: Color, color_blind: bool, cur: Vec3, has_card_animation: bool, card_images: Option<&CardImageSet>, selected_back: usize, ) { let target = Vec3::new(pos.x, pos.y, z); // Always refresh the visual appearance. commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back)); // Skip the snap/slide path entirely when a curve-based `CardAnimation` // is driving this card (e.g. the drag-rejection return tween). Writing // `Transform` here would race that animation each frame and cause a // visible jump. The animation system snaps the final position itself // when it completes. if !has_card_animation { // Slide to the new position when it differs meaningfully; snap otherwise. if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 { let start = Vec3::new(cur.x, cur.y, z); // update Z immediately commands .entity(entity) .insert(Transform::from_translation(start)) .insert(CardAnim { start, target, elapsed: 0.0, duration: slide_secs, delay: 0.0, }); } else { commands .entity(entity) .remove::() .insert(Transform::from_xyz(pos.x, pos.y, z)); } } // 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(( CardLabel, Text2d::new(label_for(card)), TextFont { font_size: layout.card_size.x * FONT_SIZE_FRAC, ..default() }, TextColor(text_colour(card)), Transform::from_xyz(0.0, 0.0, 0.01), label_visibility(card), )); }); } } fn label_for(card: &Card) -> String { let rank = match card.rank { Rank::Ace => "A", Rank::Two => "2", Rank::Three => "3", Rank::Four => "4", Rank::Five => "5", Rank::Six => "6", Rank::Seven => "7", Rank::Eight => "8", Rank::Nine => "9", Rank::Ten => "10", Rank::Jack => "J", Rank::Queen => "Q", Rank::King => "K", }; let suit = match card.suit { Suit::Clubs => "C", Suit::Diamonds => "D", Suit::Hearts => "H", Suit::Spades => "S", }; format!("{rank}{suit}") } fn text_colour(card: &Card) -> Color { if card.suit.is_red() { RED_SUIT_COLOUR } else { BLACK_SUIT_COLOUR } } fn label_visibility(card: &Card) -> Visibility { if card.face_up { Visibility::Inherited } else { Visibility::Hidden } } // --------------------------------------------------------------------------- // Task #34 — Card-flip animation systems // --------------------------------------------------------------------------- /// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity. /// /// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed). fn start_flip_anim( mut events: MessageReader, slide_dur: Option>, mut commands: Commands, card_entities: Query<(Entity, &CardEntity)>, ) { if slide_dur.is_some_and(|d| d.slide_secs == 0.0) { // Instant animation speed — skip the flip effect entirely. events.clear(); return; } for CardFlippedEvent(card_id) in events.read() { for (entity, marker) in &card_entities { if marker.card_id == *card_id { commands.entity(entity).insert(CardFlipAnim { timer: 0.0, phase: FlipPhase::ScalingDown, }); break; } } } } /// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`. /// /// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`. /// - At the midpoint the phase switches to `ScalingUp`, scale.x resets to 0, /// and a `CardFaceRevealedEvent` is fired so audio plays in sync with the reveal. /// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`. /// - When complete the component is removed and scale.x is restored to 1.0. fn tick_flip_anim( mut commands: Commands, time: Res