//! SVG builders for the Terminal-aesthetic card-face artwork. //! //! Used by the `card_face_generator` example to emit the 52 face PNGs + //! 5 back PNGs into `assets/cards/`, and by the `card_face_svg_pin` //! integration test to pin the rendered output against `usvg`/`resvg` //! drift. //! //! The numbers below are 2× the `design-system.md` § Game Cards //! logical sizes — the spec describes a 128 × 192 logical card and //! this module emits at 256 × 384. //! //! See `docs/ui-mockups/card-face-migration.md` for the full //! migration plan and the rationale behind the output dimensions //! and palette mapping. //! //! # Filled vs outlined glyphs //! //! Hearts (♥) and spades (♠) render as filled glyphs. Diamonds (♦) //! and clubs (♣) render as outlined glyphs (1.5 px stroke at logical //! scale → 3 px at output). This is the design-system's "always-on" //! color-blind glyph differentiation and is independent of the //! red/black colour split. use bevy::math::UVec2; use solitaire_core::card::{Rank, Suit}; /// Target rasterisation size in pixels (2:3 aspect, half the default /// `SvgLoaderSettings` resolution). pub const TARGET: UVec2 = UVec2::new(256, 384); const BG_FACE: &str = "#1a1a1a"; // BG_ELEVATED — face background // 2-colour traditional pairing — hearts + diamonds share a // saturated red, clubs + spades share a near-white. Mirrors the // "Microsoft Solitaire on dark mode" feel of a real card deck. // Brief 4-colour-deck experiment between v0.21.0 and this commit // reverted; the always-on filled-vs-outlined glyph differentiation // (♦ ♣ outlined, ♥ ♠ filled) carries the always-on CBM fallback. const SUIT_RED: &str = "#e35353"; // matches `RED_SUIT_COLOUR` const SUIT_DARK: &str = "#e8e8e8"; // matches `BLACK_SUIT_COLOUR` const BACK_BG: &str = "#151515"; const BACK_SCANLINE: &str = "#1a1a1a"; const BACK_BORDER: &str = "#353535"; const BACK_MONOGRAM: &str = "#505050"; /// Five back-theme accent colours. Slot 0 is the canonical "Terminal" /// back from the design system (matches `ACCENT_PRIMARY`); the other /// four cycle through the remaining base16-eighties accents so all 5 /// slots stay visually distinct without leaving the palette. pub const BACK_ACCENTS: [&str; 5] = [ "#a54242", // 0 — brick red (Terminal canonical, ACCENT_PRIMARY) "#acc267", // 1 — lime "#e1a3ee", // 2 — lavender "#fb9fb1", // 3 — pink "#ddb26f", // 4 — gold ]; /// Every rank in the canonical Ace → King order. Mirrors the order /// `card_plugin::load_card_images` uses to index `CardImageSet.faces`. pub const ALL_RANKS: [Rank; 13] = [ Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King, ]; /// Every suit in `Clubs, Diamonds, Hearts, Spades` order — matches /// `card_plugin::load_card_images` so the suit index used here lines /// up with `CardImageSet.faces[suit]`. pub const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; /// The rank component of the on-disk filename — `A`, `2`..`10`, `J`, /// `Q`, `K`. Matches `card_plugin::load_card_images`'s `RANK_STRS`. pub fn rank_filename(rank: Rank) -> &'static str { match 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", } } /// The suit component of the on-disk filename — `C`, `D`, `H`, `S`. /// Matches `card_plugin::load_card_images`'s `SUIT_CHARS`. pub fn suit_filename(suit: Suit) -> &'static str { match suit { Suit::Clubs => "C", Suit::Diamonds => "D", Suit::Hearts => "H", Suit::Spades => "S", } } /// Lowercase full-word suit token used by the bundled-default theme's /// SVG filenames (`_.svg` — e.g. `spades_ace.svg`). Mirrors /// `solitaire_engine::theme::CardKey::manifest_name`. pub fn theme_suit_token(suit: Suit) -> &'static str { match suit { Suit::Clubs => "clubs", Suit::Diamonds => "diamonds", Suit::Hearts => "hearts", Suit::Spades => "spades", } } /// Lowercase full-word rank token used by the bundled-default theme's /// SVG filenames (`ace`, `2`..`10`, `jack`, `queen`, `king`). Mirrors /// `solitaire_engine::theme::CardKey::manifest_name`. pub fn theme_rank_token(rank: Rank) -> &'static str { match rank { Rank::Ace => "ace", 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 => "jack", Rank::Queen => "queen", Rank::King => "king", } } #[derive(Copy, Clone)] enum GlyphPaint { Filled, /// 1.5 px stroke at logical scale → 3 px at 2× output. Outlined, } fn suit_paint(suit: Suit) -> (&'static str, GlyphPaint) { match suit { Suit::Hearts => (SUIT_RED, GlyphPaint::Filled), Suit::Diamonds => (SUIT_RED, GlyphPaint::Outlined), Suit::Spades => (SUIT_DARK, GlyphPaint::Filled), Suit::Clubs => (SUIT_DARK, GlyphPaint::Outlined), } } fn glyph_paint_attrs(colour: &str, paint: GlyphPaint) -> String { match paint { GlyphPaint::Filled => format!(r#"fill="{colour}""#), GlyphPaint::Outlined => { format!(r#"fill="none" stroke="{colour}" stroke-width="3""#) } } } /// SVG `path` `d` attribute tracing the suit's silhouette inside a /// 32 × 32 logical box (origin top-left, +Y down). All four suits are /// authored as a single closed perimeter so the same path renders /// correctly whether filled (♥ ♠) or outlined (♦ ♣). /// /// Path-based rendering replaces the earlier `` approach because /// the bundled `FiraMono` font doesn't carry the Unicode suit glyphs /// (U+2660-2666) at the requested size — `usvg` was falling back to a /// substitute rendering that produced near-invisible "tofu" marks. /// Paths bypass the font system entirely. fn suit_path_d(suit: Suit) -> &'static str { match suit { Suit::Hearts => { "M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 \ C 12,4 14,6 16,9 C 18,6 20,4 23,4 \ C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" } Suit::Diamonds => "M16,2 L 29,16 L 16,30 L 3,16 Z", Suit::Spades => { "M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 \ C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 \ C 18,26 19,28 23,28 C 27,28 30,25 30,21 \ C 30,14 23,9 16,4 Z" } Suit::Clubs => { "M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 \ C 9,14 4,17 4,21 C 4,24 7,27 10,27 \ C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 \ C 18,26 20,27 22,27 C 25,27 28,24 28,21 \ C 28,17 23,14 20,14 C 21,13 22,12 22,10 \ C 22,7 19,4 16,4 Z" } } } /// Build the SVG markup for a single face card. The output is a /// self-contained, parsable SVG document. pub fn face_svg(rank: Rank, suit: Suit) -> String { let (colour, paint) = suit_paint(suit); let path_d = suit_path_d(suit); let rank_text = rank_filename(rank); let small_glyph_attrs = glyph_paint_attrs(colour, paint); let large_glyph_attrs = glyph_paint_attrs(colour, paint); // Numbers come from `design-system.md` § Game Cards, scaled 2×: // corner radius: 8 px → 16 px rx/ry // rank font: 18 px → 36 px // small glyph: 10 px → 20 px (suit_path_d is authored at 32 → // scale 0.625 to land at 20) // large glyph: 32 px → 64 px (scale 2.0) // // No stroke on the card body. The earlier 1 px suit-coloured // border created anti-aliasing artifacts at the rounded corners // where the colored stroke faded through gray pixels into the // dark play surface beneath — a visible "gray sliver" at each // rounded corner that read as a rendering bug. Without the // stroke, the card shape is defined purely by the body fill // against the play surface; the 5-unit brightness gap between // `#1a1a1a` (card body) and `#151515` (play surface) is small // enough that anti-aliasing is barely perceptible. // // Suit glyphs are rendered as inline SVG paths (not ``) // because the bundled `FiraMono` font doesn't carry usable // U+2660-2666 glyphs at the requested size. See `suit_path_d` // for the rationale. // // Both glyphs render in the same upright orientation. The // traditional playing-card convention rotates the bottom-right // indicator 180° so the card reads correctly when flipped, but // most digital decks have abandoned that — single-orientation // play doesn't benefit from the inverted-corner readback. See // `design-system.md` § Game Cards for the spec deviation. format!( r##" {rank_text} "## ) } /// Build the SVG markup for a card back with the canonical Terminal /// scanline pattern. `accent` swaps only the top-left badge. pub fn back_svg(accent: &str) -> String { // Scanline tile: 1 px line + 1 px gap at logical scale → 2 px + // 2 px at 2× output. `patternUnits="userSpaceOnUse"` so the tile // size is in viewBox pixels rather than fractions of the box. // // Badge: 12 × 16 px logical → 24 × 32 px output, 12 px from corner. // Monogram: "▌RS" in 12 px logical → 24 px output, 12 px inset. format!( r##" ▌RS "## ) }