diff --git a/solitaire_engine/examples/card_face_generator.rs b/solitaire_engine/examples/card_face_generator.rs new file mode 100644 index 0000000..5d22ccb --- /dev/null +++ b/solitaire_engine/examples/card_face_generator.rs @@ -0,0 +1,276 @@ +//! Card-face generator — writes 52 Terminal-aesthetic face PNGs + +//! 5 back PNGs into `assets/cards/`. +//! +//! Run with: +//! +//! ```sh +//! cargo run --example card_face_generator --release +//! ``` +//! +//! This is **step 2** of the lockstep migration outlined in +//! `docs/ui-mockups/card-face-migration.md`. Running it overwrites +//! the legacy PNG artwork in-place; the resulting bytes are what +//! step 4 commits alongside the `card_plugin` constant migration. +//! +//! Output paths (matching the filenames `card_plugin::load_card_images` +//! already loads): +//! +//! - Faces: `assets/cards/faces/.png` +//! where `RANK` ∈ `{A, 2..10, J, Q, K}` and `SUIT` ∈ `{C, D, H, S}`. +//! - Backs: `assets/cards/backs/back_{0..4}.png`. +//! +//! All output is 256 × 384 RGBA8 sRGB — half the default +//! `SvgLoaderSettings` resolution, sufficient for ~250 px-wide +//! desktop card sprites and ≈⅓ the disk weight of the legacy 512 × +//! 768 art (per the migration plan's "Output format" rationale). + +use bevy::math::UVec2; +use solitaire_core::card::{Rank, Suit}; +use solitaire_engine::assets::rasterize_svg; +use std::path::PathBuf; +use tiny_skia::{IntSize, Pixmap}; + +// 2× the design-system logical sizes — the spec describes a 128 × +// 192 logical card, this generator targets 256 × 384. +const TARGET: UVec2 = UVec2::new(256, 384); + +// Palette literals — base16-eighties, mirroring `design-system.md`. +const BG_FACE: &str = "#1a1a1a"; // BG_ELEVATED +const SUIT_RED: &str = "#fb9fb1"; // hearts + diamonds +const SUIT_DARK: &str = "#d0d0d0"; // spades + clubs (also TEXT_PRIMARY) + +// Card-back palette. +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; the other four cycle through the +// remaining base16-eighties accents so all 5 slots stay visually +// distinct without leaving the palette. +const BACK_ACCENTS: [&str; 5] = [ + "#6fc2ef", // 0 — cyan (Terminal canonical) + "#acc267", // 1 — lime + "#e1a3ee", // 2 — lavender + "#fb9fb1", // 3 — pink + "#ddb26f", // 4 — gold +]; + +fn main() { + let cards_dir = workspace_assets_dir().join("cards"); + let faces_dir = cards_dir.join("faces"); + let backs_dir = cards_dir.join("backs"); + std::fs::create_dir_all(&faces_dir).expect("create faces dir"); + std::fs::create_dir_all(&backs_dir).expect("create backs dir"); + + let mut written = 0usize; + + for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { + for rank in ALL_RANKS { + let svg = face_svg(rank, suit); + let pixmap = rasterize_to_pixmap(&svg); + let path = faces_dir.join(format!("{}{}.png", rank_str(rank), suit_char(suit))); + pixmap + .save_png(&path) + .unwrap_or_else(|e| panic!("write {}: {e}", path.display())); + written += 1; + } + } + + for (idx, accent) in BACK_ACCENTS.iter().enumerate() { + let svg = back_svg(accent); + let pixmap = rasterize_to_pixmap(&svg); + let path = backs_dir.join(format!("back_{idx}.png")); + pixmap + .save_png(&path) + .unwrap_or_else(|e| panic!("write {}: {e}", path.display())); + written += 1; + } + + println!( + "Wrote {written} PNGs ({}×{} RGBA8) to {}", + TARGET.x, + TARGET.y, + cards_dir.display(), + ); +} + +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, +]; + +fn rank_str(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", + } +} + +fn suit_char(suit: Suit) -> &'static str { + match suit { + Suit::Clubs => "C", + Suit::Diamonds => "D", + Suit::Hearts => "H", + Suit::Spades => "S", + } +} + +/// Returns the suit colour and the `` paint attributes for the +/// glyph (filled vs outlined). Hearts + spades are filled; diamonds + +/// clubs are outlined — the "always-on" color-blind glyph +/// differentiation from the design system. +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), + } +} + +#[derive(Copy, Clone)] +enum GlyphPaint { + Filled, + /// 1.5 px stroke at logical scale → 3 px at 2× output. + 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""#) + } + } +} + +fn suit_glyph(suit: Suit) -> &'static str { + match suit { + Suit::Clubs => "♣", + Suit::Diamonds => "♦", + Suit::Hearts => "♥", + Suit::Spades => "♠", + } +} + +/// Builds the face-card SVG. Sizes are doubled from the design-system +/// logical pixels (the spec describes a 128 × 192 card; we emit at +/// 256 × 384). +fn face_svg(rank: Rank, suit: Suit) -> String { + let (colour, paint) = suit_paint(suit); + let glyph = suit_glyph(suit); + let rank_text = rank_str(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×: + // border: 1 px → 2 px stroke-width + // corner radius: 8 px → 16 px rx/ry + // rank font: 18 px → 36 px + // small glyph: 10 px → 20 px + // large glyph: 32 px → 64 px + // + // Inset the border by 1 px (`x="1" y="1" width="254" height="382"`) + // so the 2 px stroke renders fully inside the 256 × 384 pixmap + // rather than getting clipped at the edge. + format!( + r##" + + + + {rank_text} + {glyph} + + + {glyph} +"## + ) +} + +/// Builds a card-back SVG with the canonical Terminal scanline +/// pattern. `accent` swaps only the top-left badge — every other +/// element stays palette-locked so all 5 backs read as members of +/// the same family. +fn back_svg(accent: &str) -> String { + // Pattern 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 +"## + ) +} + +fn rasterize_to_pixmap(svg: &str) -> Pixmap { + let image = rasterize_svg(svg.as_bytes(), TARGET).expect("rasterise card SVG"); + let bytes = image.data.expect("rasterised image carries pixel data"); + debug_assert_eq!( + bytes.len(), + (TARGET.x * TARGET.y * 4) as usize, + "rasterised buffer must match width × height × 4 RGBA bytes", + ); + let size = IntSize::from_wh(TARGET.x, TARGET.y).expect("non-zero target size"); + Pixmap::from_vec(bytes, size).expect("RGBA buffer forms a valid Pixmap") +} + +/// Resolves the workspace-root `assets/` directory relative to the +/// running example crate (`solitaire_engine/`). `CARGO_MANIFEST_DIR` +/// is the engine crate; its parent is the workspace root. +fn workspace_assets_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("solitaire_engine crate has a workspace-root parent") + .join("assets") +}