//! Procedural card rendering. //! //! Each card is a parent entity with a coloured body `Sprite` and a child //! `Text2d` showing rank+suit. Entities are synced with `GameStateResource` //! on every `StateChangedEvent`: missing cards are spawned, present cards //! are repositioned/updated in place, and stale cards are despawned. //! //! Phase 3 uses ASCII rank letters ("A", "2"…"10", "J", "Q", "K") and ASCII //! suit letters ("C", "D", "H", "S") so rendering does not depend on the //! bundled font carrying Unicode suit glyphs. When real card art lands in a //! later phase, this plugin is replaced — the `CardEntity` marker and the //! "sync on StateChangedEvent" contract stay the same. use std::collections::{HashMap, HashSet}; use bevy::color::Color; use bevy::prelude::*; 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::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent}; use crate::game_plugin::GameMutation; use crate::layout::{Layout, LayoutResource}; use crate::pause_plugin::PausedResource; use crate::resources::{DragState, GameStateResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::table_plugin::PileMarker; /// 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. 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. 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); /// 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; // --------------------------------------------------------------------------- // 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; /// 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::>() .add_event::() .add_event::() .add_event::() .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, 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), ), ); } } /// 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: EventReader, mut state_events: EventWriter, ) { 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)>, ) { if let Some(layout) = layout { let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs); let back_colour = settings .as_ref() .map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_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); } } fn sync_cards_on_change( mut events: EventReader, commands: Commands, game: Res, layout: Option>, slide_dur: Option>, settings: Option>, entities: Query<(Entity, &CardEntity, &Transform)>, ) { 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 back_colour = settings .as_ref() .map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_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); } } fn sync_cards( mut commands: Commands, game: &GameState, layout: &Layout, slide_secs: f32, back_colour: Color, color_blind: bool, entities: &Query<(Entity, &CardEntity, &Transform)>, ) { let positions = card_positions(game, layout); // Map card_id -> (Entity, current_translation) for in-place updates. let mut existing: HashMap = HashMap::new(); for (entity, marker, transform) in entities.iter() { existing.insert(marker.card_id, (entity, transform.translation)); } 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)) => { update_card_entity( &mut commands, entity, &card, position, z, layout, slide_secs, back_colour, color_blind, cur, ) } None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour, color_blind), } } } /// Returns an ordered vec of (card, position, z) for every card in the game. fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> { let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52); let piles = [ PileType::Stock, PileType::Waste, PileType::Foundation(Suit::Clubs), PileType::Foundation(Suit::Diamonds), PileType::Foundation(Suit::Hearts), PileType::Foundation(Suit::Spades), 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.clone(), 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 } } fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color, color_blind: bool) { let body_colour = if card.face_up { face_colour(card, color_blind) } else { back_colour }; commands .spawn(( CardEntity { card_id: card.id }, Sprite { color: body_colour, custom_size: Some(layout.card_size), ..default() }, Transform::from_xyz(pos.x, pos.y, z), Visibility::default(), )) .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)), // Above the card body on z so it doesn't get occluded by the // parent sprite in back-to-front rendering. 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, ) { let body_colour = if card.face_up { face_colour(card, color_blind) } else { back_colour }; let target = Vec3::new(pos.x, pos.y, z); // Always refresh the visual appearance. commands.entity(entity).insert(Sprite { color: body_colour, custom_size: Some(layout.card_size), ..default() }); // 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 the old label child and respawn a fresh one, so rank/suit/ // colour/visibility all stay in sync with the card's current state. commands.entity(entity).despawn_related::(); 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: EventReader, 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