diff --git a/solitaire_engine/examples/card_face_generator.rs b/solitaire_engine/examples/card_face_generator.rs index 5d22ccb..5ac4a1b 100644 --- a/solitaire_engine/examples/card_face_generator.rs +++ b/solitaire_engine/examples/card_face_generator.rs @@ -12,51 +12,18 @@ //! 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). +//! The SVG builders live in +//! `solitaire_engine::assets::card_face_svg` so the integration +//! test at `tests/card_face_svg_pin.rs` can pin their output +//! against `usvg`/`resvg` rendering drift. -use bevy::math::UVec2; -use solitaire_core::card::{Rank, Suit}; +use solitaire_engine::assets::card_face_svg::{ + back_svg, face_svg, rank_filename, suit_filename, ALL_RANKS, ALL_SUITS, BACK_ACCENTS, TARGET, +}; 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"); @@ -66,11 +33,15 @@ fn main() { let mut written = 0usize; - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { + for suit in ALL_SUITS { 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))); + let path = faces_dir.join(format!( + "{}{}.png", + rank_filename(rank), + suit_filename(suit) + )); pixmap .save_png(&path) .unwrap_or_else(|e| panic!("write {}: {e}", path.display())); @@ -96,163 +67,6 @@ fn main() { ); } -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"); diff --git a/solitaire_engine/src/assets/card_face_svg.rs b/solitaire_engine/src/assets/card_face_svg.rs new file mode 100644 index 0000000..798f223 --- /dev/null +++ b/solitaire_engine/src/assets/card_face_svg.rs @@ -0,0 +1,210 @@ +//! 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 +const SUIT_RED: &str = "#fb9fb1"; // hearts + diamonds +const SUIT_DARK: &str = "#d0d0d0"; // spades + clubs (also TEXT_PRIMARY) + +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. +pub const BACK_ACCENTS: [&str; 5] = [ + "#6fc2ef", // 0 — cyan (Terminal canonical) + "#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", + } +} + +#[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""#) + } + } +} + +fn suit_glyph(suit: Suit) -> &'static str { + match suit { + Suit::Clubs => "♣", + Suit::Diamonds => "♦", + Suit::Hearts => "♥", + Suit::Spades => "♠", + } +} + +/// 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 glyph = suit_glyph(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×: + // 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 so the 2 px stroke renders fully + // inside the 256 × 384 pixmap rather than getting clipped. + format!( + r##" + + + + {rank_text} + {glyph} + + + {glyph} +"## + ) +} + +/// 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 +"## + ) +} diff --git a/solitaire_engine/src/assets/mod.rs b/solitaire_engine/src/assets/mod.rs index e235edf..078647b 100644 --- a/solitaire_engine/src/assets/mod.rs +++ b/solitaire_engine/src/assets/mod.rs @@ -6,6 +6,7 @@ //! (user-themes directory). Phase 3 will extend it further with custom //! `AssetSource` implementations for `embedded://` and `themes://`. +pub mod card_face_svg; pub mod sources; pub mod svg_loader; pub mod user_dir; diff --git a/solitaire_engine/tests/card_face_svg_pin.rs b/solitaire_engine/tests/card_face_svg_pin.rs new file mode 100644 index 0000000..28579a0 --- /dev/null +++ b/solitaire_engine/tests/card_face_svg_pin.rs @@ -0,0 +1,175 @@ +//! Pinning test for the Terminal card-face SVG builders. +//! +//! Hashes the raw RGBA8 pixel bytes produced by rasterising every +//! `face_svg` × `back_svg` output through `assets::rasterize_svg`, +//! and compares each hash to an embedded constant. Catches silent +//! rendering drift if `usvg`, `resvg`, `tiny_skia`, or the bundled +//! `FiraMono` font ever change in a way that perturbs the rendered +//! pixels. +//! +//! When the SVG builders intentionally change (or a dependency +//! upgrade legitimately changes rendering), update `EXPECTED` by +//! emptying it (`&[]`) and re-running this test once — the test +//! will panic with the new hashes formatted as Rust source ready +//! to paste back in. +//! +//! Hashing is FNV-1a 64-bit on the raw RGBA byte buffer. PNG +//! compression is intentionally not in the loop — we only want +//! the test to fire on actual pixel changes, not zlib-level +//! shifts that don't affect what a player sees. + +use solitaire_engine::assets::card_face_svg::{ + back_svg, face_svg, rank_filename, suit_filename, ALL_RANKS, ALL_SUITS, BACK_ACCENTS, TARGET, +}; +use solitaire_engine::assets::rasterize_svg; + +const EXPECTED: &[(&str, u64)] = &[ + ("face_AC", 0xca11dff5bb9f0eb0), + ("face_2C", 0xc929a25f0f217577), + ("face_3C", 0xdaede8383266b5c3), + ("face_4C", 0xeaa3ea51866f69e5), + ("face_5C", 0xe5a74589cb09cc5c), + ("face_6C", 0xdbbc1036895ee08e), + ("face_7C", 0xb8a28119a85ccf5d), + ("face_8C", 0xab4d19ce4b8d15e7), + ("face_9C", 0x17c95eb07f382059), + ("face_10C", 0x1f1b2c84e42211b1), + ("face_JC", 0xd87c45124df8b03d), + ("face_QC", 0xe23701b6685994b2), + ("face_KC", 0xc628e55b8a15472a), + ("face_AD", 0x49a140d84b0a731b), + ("face_2D", 0x713f755b5ecfb67a), + ("face_3D", 0xe59a72abc47af7d4), + ("face_4D", 0xf75ac828822079d1), + ("face_5D", 0x6db0cc9a5849395f), + ("face_6D", 0x9b034cf6851512de), + ("face_7D", 0x85f96e0326780a6e), + ("face_8D", 0x59ec5533b615ecd4), + ("face_9D", 0x3689911671b30921), + ("face_10D", 0x682684217e3e8b60), + ("face_JD", 0xd999f85e6862c5a7), + ("face_QD", 0x6db493a3b370b211), + ("face_KD", 0x4c2ec19166fdee7b), + ("face_AH", 0x0d41c498281b9a74), + ("face_2H", 0xec6493b71d4576b1), + ("face_3H", 0xd2fb4b5956caf15b), + ("face_4H", 0xfbe8e1eaa2b28c5a), + ("face_5H", 0x649a0964e549f008), + ("face_6H", 0xa10fa42b5549fc85), + ("face_7H", 0x6823107295c149b5), + ("face_8H", 0x474d2de14865e65b), + ("face_9H", 0x1b0de1af8dae108a), + ("face_10H", 0x451fd5855859c9d7), + ("face_JH", 0xd821a7d4c79a37e0), + ("face_QH", 0xde0c6ef7e963861a), + ("face_KH", 0xe29039cb6a115214), + ("face_AS", 0x1697fbcc61b64e0f), + ("face_2S", 0x5ada7ea3e39547d0), + ("face_3S", 0x6d8eed531f2d659c), + ("face_4S", 0x1b1a2d25e080d71e), + ("face_5S", 0x5eb82baa4f9a74bb), + ("face_6S", 0xa00b217892d32ead), + ("face_7S", 0xaf60935ec8d93346), + ("face_8S", 0xffbde852d8699a80), + ("face_9S", 0x8f68afa04b88e1a2), + ("face_10S", 0x96fa4a08f168210a), + ("face_JS", 0x73030a8109b5b5e6), + ("face_QS", 0x303eb6c33e363cc1), + ("face_KS", 0x3ed5b5a9432c91e9), + ("back_0", 0xf698d0e161eae13a), + ("back_1", 0x446fdc0a3c83a03a), + ("back_2", 0xcf188fdec9f5819a), + ("back_3", 0xcaffd02af141743a), + ("back_4", 0xcee8a700bbaaf71a), +]; + +#[test] +fn rasterised_card_bytes_match_pinned_hashes() { + let actual = compute_actual_hashes(); + + if EXPECTED.is_empty() { + panic_with_hashes_to_paste(&actual); + } + + assert_eq!( + actual.len(), + EXPECTED.len(), + "card-output count drifted (actual {} vs expected {})", + actual.len(), + EXPECTED.len(), + ); + + let mut mismatches: Vec = Vec::new(); + for ((actual_name, actual_hash), (expected_name, expected_hash)) in + actual.iter().zip(EXPECTED.iter()) + { + assert_eq!( + actual_name, expected_name, + "card-output naming/order drifted", + ); + if actual_hash != expected_hash { + mismatches.push(format!( + " {actual_name}: actual 0x{actual_hash:016x} expected 0x{expected_hash:016x}", + )); + } + } + + if !mismatches.is_empty() { + let mut msg = String::from( + "rasterised card bytes drifted from EXPECTED — usvg/resvg/tiny_skia/font upgrade?\n", + ); + for m in &mismatches { + msg.push_str(m); + msg.push('\n'); + } + msg.push_str( + "\nIf this drift is intentional, replace EXPECTED with `&[]` and re-run\nthis test to print fresh hashes.\n", + ); + panic!("{msg}"); + } +} + +fn compute_actual_hashes() -> Vec<(String, u64)> { + let mut out = Vec::with_capacity(ALL_RANKS.len() * ALL_SUITS.len() + BACK_ACCENTS.len()); + for suit in ALL_SUITS { + for rank in ALL_RANKS { + let name = format!("face_{}{}", rank_filename(rank), suit_filename(suit)); + out.push((name, hash_rasterised(&face_svg(rank, suit)))); + } + } + for (idx, accent) in BACK_ACCENTS.iter().enumerate() { + out.push((format!("back_{idx}"), hash_rasterised(&back_svg(accent)))); + } + out +} + +fn hash_rasterised(svg: &str) -> u64 { + let image = rasterize_svg(svg.as_bytes(), TARGET).expect("rasterise card SVG"); + let bytes = image.data.expect("rasterised image carries RGBA pixel data"); + fnv1a(&bytes) +} + +/// FNV-1a 64-bit. Inline rather than a dependency — adding `sha2` +/// or `blake3` for ~5 lines of code would burn a CLAUDE.md §8 +/// "ask before adding deps" round-trip for no real benefit. +/// Cryptographic strength isn't load-bearing here — we just need +/// stable byte fingerprints. +fn fnv1a(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +fn panic_with_hashes_to_paste(actual: &[(String, u64)]) -> ! { + let mut out = String::from( + "\nEXPECTED is empty — paste the following into the const literal:\n\nconst EXPECTED: &[(&str, u64)] = &[\n", + ); + for (name, hash) in actual { + out.push_str(&format!(" (\"{name}\", 0x{hash:016x}),\n")); + } + out.push_str("];\n"); + panic!("{out}"); +}