//! Card face labels: desktop text labels and Android corner labels. use super::*; use bevy::color::Color; use bevy::sprite::Anchor; use solitaire_core::{Card, Rank, Suit}; use crate::ui_theme::TEXT_PRIMARY_HC; pub(super) 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}") } /// Suit colour for the rank/suit overlay rendered atop the constant /// fallback sprite (only fires under `MinimalPlugins` — production /// renders the suit glyph baked into the PNG). 2-colour traditional /// pairing — hearts + diamonds share the saturated red, clubs + /// spades share the near-white. Two accessibility flags compose: /// /// - `color_blind`: red-suit cards swap to `RED_SUIT_COLOUR_CBM` /// (lime) — the "Settings toggle swaps red→lime" half of the /// design system's colour-blind support. CBM is a hue-replacement /// for red, so HC has no further effect on red when CBM is on /// (the lime is itself a high-luminance colour). /// - `high_contrast`: when CBM is off, red suits boost to /// `RED_SUIT_COLOUR_HC` (`#ff6868`); black suits boost from /// `#e8e8e8` (near-white) to `#f5f5f5` (`TEXT_PRIMARY_HC`). /// /// The other half of CBM support (always-on filled-vs-outlined /// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art /// and has no constant-fallback equivalent. pub(super) fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color { if card.suit().is_red() { if color_blind { // CBM lime wins — the colour-blind swap replaces the // red hue entirely, and the lime is already high- // luminance, so an HC boost on top has nothing to do. RED_SUIT_COLOUR_CBM } else if high_contrast { RED_SUIT_COLOUR_HC } else { RED_SUIT_COLOUR } } else if high_contrast { TEXT_PRIMARY_HC } else { BLACK_SUIT_COLOUR } } pub(super) fn label_visibility(face_up: bool) -> Visibility { if face_up { Visibility::Inherited } else { Visibility::Hidden } } /// Rank+suit string for the readability overlay on touch HUD layouts. /// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono). pub(super) fn mobile_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 => "♣", Suit::Diamonds => "♦", Suit::Hearts => "♥", Suit::Spades => "♠", }; format!("{rank}{suit}") } /// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on /// face-up cards. The background sprite covers the card art's own small /// corner text so only the large overlay is visible. /// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on /// face-up cards using FiraMono (passed via `font_handle`) so that the /// suit Unicode glyphs U+2660–U+2666 render correctly. Without an explicit /// font handle Bevy falls back to its built-in face which does not include /// those glyphs, causing a coloured missing-glyph rectangle to appear in /// the text colour — the root cause of the "red square on face-down cards" /// visual bug (the box bleeds through near the card edge at z=0.02). pub(super) fn add_android_corner_label( parent: &mut ChildSpawnerCommands, card: &Card, face_up: bool, card_size: Vec2, color_blind: bool, high_contrast: bool, font_handle: Option<&Handle>, ) { if !face_up { return; } let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE; let inset = 3.0_f32; // Background covers ~3 monospace chars wide × 1 line tall. // FiraMono char width ≈ 0.6 × font_size; 2.0× gives room for "10♠" // (3 chars = 1.8× font_size) plus a small margin. let bg_w = font_size * 2.0; let bg_h = font_size * 1.25; // Background covers the PNG's baked-in small corner text (top-left). // Classic PNG cards have a white face, so the background must be white too. // (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.) parent.spawn(( AndroidCornerBg, Sprite { color: Color::WHITE, custom_size: Some(Vec2::new(bg_w, bg_h)), ..default() }, Transform::from_xyz( -card_size.x / 2.0 + inset + bg_w / 2.0, card_size.y / 2.0 - inset - bg_h / 2.0, 0.015, ), )); // Cover the matching rotated baked-in text at the bottom-right corner. parent.spawn(( AndroidCornerBg, Sprite { color: Color::WHITE, custom_size: Some(Vec2::new(bg_w, bg_h)), ..default() }, Transform::from_xyz( card_size.x / 2.0 - inset - bg_w / 2.0, -card_size.y / 2.0 + inset + bg_h / 2.0, 0.015, ), )); // Large rank+suit text drawn on top of the background. FiraMono must be // wired here explicitly — the suit glyphs (U+2660–U+2666) are not in // Bevy's built-in font and render as a coloured rectangle without it. // // Classic PNG cards have a white face: red suits stay the same saturated // red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a) // rather than the near-white BLACK_SUIT_COLOUR designed for the dark // Terminal theme background. let text_col = if card.suit().is_red() { if color_blind { RED_SUIT_COLOUR_CBM } else if high_contrast { RED_SUIT_COLOUR_HC } else { RED_SUIT_COLOUR } } else { CARD_FACE_COLOUR }; let label_text = mobile_label_for(card); parent.spawn(( AndroidCornerLabel(label_text.clone()), CardLabel, Text2d::new(label_text), TextFont { font: font_handle.cloned().unwrap_or_default(), font_size, ..default() }, TextColor(text_col), Anchor::TOP_LEFT, Transform::from_xyz(-card_size.x / 2.0 + inset, card_size.y / 2.0 - inset, 0.02), )); } // --------------------------------------------------------------------------- // Task #34 — Card-flip animation systems // ---------------------------------------------------------------------------