//! Card asset loading and entity lifecycle: the sync systems that //! spawn, update, and position card entities from `GameStateResource`. use super::*; use std::collections::{HashMap, HashSet}; use bevy::color::Color; use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{Foundation, KlondikePile, Tableau}; use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration}; use crate::card_animation::CardAnimation; use crate::events::StateChangedEvent; use crate::font_plugin::FontResource; use crate::layout::{Layout, LayoutResource}; use crate::platform::USE_TOUCH_UI_LAYOUT; use crate::resources::GameStateResource; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; /// Rebuild the [`CardEntityIndex`] from the live `CardEntity` set. /// /// Runs in `PostUpdate` so that all spawn/despawn `Commands` issued by /// [`sync_cards_on_change`] and [`snap_cards_on_window_resize`] in `Update` /// have been flushed at the `Update -> PostUpdate` apply-deferred boundary. /// Rebuilding from scratch (rather than incrementally patching at every /// spawn/despawn site — waste cards churn on every draw) keeps a single writer /// and makes a stale entry structurally impossible. /// /// Gated to changed frames only: `Changed` fires the frame a card /// is spawned, `RemovedComponents` the frame one is despawned. The /// `card` field is write-once (never mutated in place), so card-reposition /// frames don't trip `Changed` and correctly skip the O(52) rebuild. pub(super) fn rebuild_card_entity_index( mut index: ResMut, cards: Query<(Entity, &CardEntity)>, changed: Query<(), Changed>, removed: RemovedComponents, ) { if changed.is_empty() && removed.is_empty() { return; } let map = &mut index.0; map.clear(); for (entity, ce) in &cards { map.insert(ce.card.clone(), entity); } } /// Returns the relative asset path for a card face PNG. /// /// The path format is `cards/faces/classic/{RANK}{SUIT}.png`, e.g. `QS.png` /// for the Queen of Spades. Both `load_card_images` and the unit tests use /// this function so the filename formula is tested in isolation from the /// asset-loading machinery. /// /// Note: this function verifies only the **code-side mapping**. If the PNG /// file at the returned path contains wrong artwork (e.g. `QS.png` has a /// diamond watermark baked in), that is an **asset content bug** and must be /// fixed by replacing the file — no code change can correct it. pub(super) fn card_face_asset_path(rank: Rank, suit: Suit) -> String { const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"]; const RANK_STRS: [&str; 13] = [ "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", ]; let suit_idx = match suit { Suit::Clubs => 0, Suit::Diamonds => 1, Suit::Hearts => 2, Suit::Spades => 3, }; let rank_idx = match 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, }; format!( "cards/faces/classic/{}{}.png", RANK_STRS[rank_idx], SUIT_CHARS[suit_idx] ) } /// 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. pub(super) fn load_card_images(asset_server: Option>, mut commands: Commands) { let Some(asset_server) = asset_server else { return; }; // faces[suit_index(s)][rank_index(r)] — see the canonical helpers in // card_plugin::mod; building from SUITS/RANKS order matches them. let faces: [[Handle; 13]; 4] = std::array::from_fn(|si| { std::array::from_fn(|ri| { asset_server.load(card_face_asset_path(Rank::RANKS[ri], Suit::SUITS[si])) }) }); let backs = std::array::from_fn(|i| asset_server.load(format!("cards/backs/classic/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. pub(super) fn card_sprite( card: &Card, face_up: bool, card_size: Vec2, back_colour: Color, card_images: Option<&CardImageSet>, selected_back: usize, ) -> Sprite { if let Some(set) = card_images { let image = if face_up { let suit_idx = suit_index(card.suit()); let rank_idx = rank_index(card.rank()); 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 { // Terminal aesthetic: face background is uniformly CARD_FACE_COLOUR // regardless of colour-blind mode (CBM differentiation now lives in // the suit glyph colour, applied by `text_colour`, not the face // background). Pre-Terminal this branch dispatched through a // separate `face_colour(card, color_blind)` helper. let body_colour = if face_up { CARD_FACE_COLOUR } 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). pub(super) 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. #[allow(clippy::too_many_arguments)] pub(super) fn sync_cards_startup( commands: Commands, game: Res, layout: Option>, slide_dur: Option>, settings: Option>, entities: Query, card_images: Option>, font_res: 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); let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode); let font_handle = font_res.as_ref().map(|r| &r.0); sync_cards( commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle, ); } } #[allow(clippy::too_many_arguments)] pub(super) fn sync_cards_on_change( mut events: MessageReader, commands: Commands, game: Res, layout: Option>, slide_dur: Option>, settings: Option>, entities: Query, card_images: Option>, font_res: 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); let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode); let font_handle = font_res.as_ref().map(|r| &r.0); sync_cards( commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle, ); } } #[allow(clippy::too_many_arguments)] pub(super) fn sync_cards( mut commands: Commands, game: &GameState, layout: &Layout, slide_secs: f32, back_colour: Color, color_blind: bool, high_contrast: bool, entities: &Query, card_images: Option<&CardImageSet>, selected_back: usize, font_handle: Option<&Handle>, ) { let positions = card_positions(game, layout); // The waste buffer card exists only to keep its entity alive while the new // top card's slide animation plays — it must never be visible to the player. // Without this, the buffer sits at waste_base uncovered during the animation // and its rank/suit peek behind the incoming card. let waste_buffer_id: Option = { let visible = match game.draw_mode() { DrawStockConfig::DrawOne => 1_usize, DrawStockConfig::DrawThree => 3_usize, }; let waste_cards = game.waste_cards(); (waste_cards.len() > visible) .then_some(waste_cards) .and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned()) .map(|(c, _face_up)| c) }; // Map Card -> (Entity, current_translation, anim_end) for in-place // updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation` // is currently driving the card (e.g. a drag-rejection return tween). // // In the position loop below we compare `anim_end` against the new game- // state target position to decide whether to honour or cancel the tween: // • end ≈ target → animation is still heading to the right place; let // it finish (skip the snap/slide path). // • end ≠ target → the game state has changed (e.g. a new game started // while the win-cascade was mid-flight); cancel the // stale `CardAnimation` and apply the new position. let mut existing: HashMap, Option)> = HashMap::new(); for (entity, marker, transform, anim, children_key) in entities.iter() { existing.insert( marker.card.clone(), ( entity, transform.translation, anim.map(|a| a.end), children_key.copied(), ), ); } let live_ids: HashSet = positions.iter().map(|(c, _, _)| c.0.clone()).collect(); // Despawn any entity whose card is no longer tracked. for (card, (entity, _, _, _)) in &existing { if !live_ids.contains(card) { commands.entity(*entity).despawn(); } } // For each card in the current state: spawn or update its entity, then // apply visibility. The waste buffer card is hidden so it cannot peek // behind the incoming top card during the draw slide animation. for ((card, face_up), position, z) in positions { let entity = match existing.get(&card) { Some(&(entity, cur, anim_end, children_key)) => { // If a CardAnimation is in flight, check whether its destination // still matches the game-state target. If the game moved the card // elsewhere (e.g. new game started during a win-cascade scatter), // cancel the stale tween so the card snaps/slides to its new home. let has_anim = match anim_end { Some(end_xy) if (end_xy - position).length() > 2.0 => { commands.entity(entity).remove::(); false } Some(_) => true, None => false, }; update_card_entity( &mut commands, entity, &card, face_up, position, z, layout, slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, children_key, card_images, selected_back, font_handle, ); entity } None => spawn_card_entity( &mut commands, &card, face_up, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back, font_handle, ), }; let visibility = if waste_buffer_id.as_ref() == Some(&card) { Visibility::Hidden } else { Visibility::Inherited }; commands.entity(entity).insert(visibility); } } /// Returns an ordered vec of ((card, face_up), position, z) for every card in the game. pub(super) fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, f32)> { let mut out: Vec<((Card, bool), Vec2, f32)> = Vec::with_capacity(52); let piles = [ (KlondikePile::Stock, true), (KlondikePile::Stock, false), (KlondikePile::Foundation(Foundation::Foundation1), false), (KlondikePile::Foundation(Foundation::Foundation2), false), (KlondikePile::Foundation(Foundation::Foundation3), false), (KlondikePile::Foundation(Foundation::Foundation4), false), (KlondikePile::Tableau(Tableau::Tableau1), false), (KlondikePile::Tableau(Tableau::Tableau2), false), (KlondikePile::Tableau(Tableau::Tableau3), false), (KlondikePile::Tableau(Tableau::Tableau4), false), (KlondikePile::Tableau(Tableau::Tableau5), false), (KlondikePile::Tableau(Tableau::Tableau6), false), (KlondikePile::Tableau(Tableau::Tableau7), false), ]; // Draw-Three waste fan step, proportional to the column spacing so it scales // with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via // `waste_fan_step` so the two never drift (a drift puts the top fanned card's // click target on the card beneath it). let waste_fan_step = waste_fan_step(layout); for (pile_type, is_stock_area) in piles { let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else { continue; }; if matches!(pile_type, KlondikePile::Stock) && is_stock_area { base.x -= tableau_col_step(layout); } let is_tableau = matches!(pile_type, KlondikePile::Tableau(_)); let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area; let cards = if matches!(pile_type, KlondikePile::Stock) { if is_stock_area { game.stock_cards() } else { game.waste_cards() } } else { game.pile(pile_type) }; // 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 render_start = if is_waste { let visible = match game.draw_mode() { DrawStockConfig::DrawOne => 1_usize, DrawStockConfig::DrawThree => 3_usize, }; // Render one extra card so that the card sliding off the waste // during a draw animation is still present in the world at z=0 // (hidden under the stack) rather than vanishing mid-tween. cards.len().saturating_sub(visible + 1) } else { 0 }; let mut y_offset = 0.0_f32; let rendered_len = cards[render_start..].len(); for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() { let x_offset = if is_waste && matches!(game.draw_mode(), DrawStockConfig::DrawThree) { // When len > visible, slot 0 is a hidden buffer card kept at // x=0 to prevent a flash during the draw tween. When len ≤ // visible (small pile), every card is visible and should fan // normally — no card is hidden, so the shift is 0. let visible = 3_usize; let hidden = rendered_len.saturating_sub(visible); slot.saturating_sub(hidden) as f32 * waste_fan_step } 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.clone(), *face_up), pos, z)); if is_tableau { let step = if *face_up { layout.tableau_fan_frac } else { layout.tableau_facedown_fan_frac }; y_offset -= layout.card_size.y * step; } } } out } pub(super) fn all_cards(game: &GameState) -> Vec<(Card, bool)> { let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52); cards.extend(game.stock_cards()); cards.extend(game.waste_cards()); for foundation in solitaire_core::FOUNDATIONS { cards.extend(game.pile(KlondikePile::Foundation(foundation))); } for tableau in solitaire_core::TABLEAUS { cards.extend(game.pile(KlondikePile::Tableau(tableau))); } cards } #[allow(clippy::too_many_arguments)] pub(super) fn spawn_card_entity( commands: &mut Commands, card: &Card, face_up: bool, pos: Vec2, z: f32, layout: &Layout, back_colour: Color, color_blind: bool, high_contrast: bool, card_images: Option<&CardImageSet>, selected_back: usize, font_handle: Option<&Handle>, ) -> Entity { let sprite = card_sprite( card, face_up, layout.card_size, back_colour, card_images, selected_back, ); let mut entity = commands.spawn(( CardEntity { card: card.clone() }, sprite, Transform::from_xyz(pos.x, pos.y, z), Visibility::default(), )); let entity_id = entity.id(); // 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); }); // Every card gets a thin border frame so it reads as a distinct // rectangle against the dark felt, regardless of face state. entity.with_children(|b| { add_card_back_frame_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). // On Android we additionally spawn a large-print corner label even in // image mode so the rank/suit are legible at phone scale. 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, color_blind, high_contrast)), Transform::from_xyz(0.0, 0.0, 0.01), label_visibility(face_up), )); }); } if USE_TOUCH_UI_LAYOUT && card_images.is_some() { entity.with_children(|b| { add_android_corner_label( b, card, face_up, layout.card_size, color_blind, high_contrast, font_handle, ); }); } // Record the appearance signature so subsequent `update_card_entity` calls // can skip rebuilding these children until one of the inputs changes. entity.insert(CardChildrenKey { face_up, card_size: layout.card_size, color_blind, high_contrast, }); entity_id } #[allow(clippy::too_many_arguments)] pub(super) fn update_card_entity( commands: &mut Commands, entity: Entity, card: &Card, face_up: bool, pos: Vec2, z: f32, layout: &Layout, slide_secs: f32, back_colour: Color, color_blind: bool, high_contrast: bool, cur: Vec3, has_card_animation: bool, existing_children_key: Option, card_images: Option<&CardImageSet>, selected_back: usize, font_handle: Option<&Handle>, ) { let target = Vec3::new(pos.x, pos.y, z); // Always refresh the visual appearance. commands.entity(entity).insert(card_sprite( card, face_up, layout.card_size, back_colour, 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 { // Lift the card immediately on the first frame of the animation so // it never appears behind a card that is already resting at the // destination slot. `advance_card_anims` will maintain this lift // throughout the tween and snap to `target` (without lift) on // completion. let start = Vec3::new(cur.x, cur.y, z + CARD_ANIM_Z_LIFT); 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)); } } // Rebuild the card's child visuals (drop-shadow, border frame, and the // rank/suit label / large-print corner overlay) only when an input that // affects them actually changed. The child set depends solely on // `CardChildrenKey`; the face/back image is carried by the always-refreshed // `Sprite` above, so theme/card-back swaps need no child rebuild. Skipping // this on a position-only move avoids despawning and respawning the child // entities (incl. a `Text2d` glyph re-layout) for all 52 cards on every // `StateChangedEvent` — the spike that stuttered the slide animation on // high-resolution devices. let new_children_key = CardChildrenKey { face_up, card_size: layout.card_size, color_blind, high_contrast, }; if existing_children_key != Some(new_children_key) { commands.entity(entity).despawn_related::(); commands.entity(entity).with_children(|b| { add_card_shadow_child(b, layout.card_size); }); commands.entity(entity).with_children(|b| { add_card_back_frame_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, color_blind, high_contrast)), Transform::from_xyz(0.0, 0.0, 0.01), label_visibility(face_up), )); }); } if USE_TOUCH_UI_LAYOUT && card_images.is_some() { commands.entity(entity).with_children(|b| { add_android_corner_label( b, card, face_up, layout.card_size, color_blind, high_contrast, font_handle, ); }); } commands.entity(entity).insert(new_children_key); } }