Files
Ferrous-Solitaire/solitaire_engine/src/assets/card_face_svg.rs
T
funman300 dd970215cc fix(engine): drop card-face border to remove gray-corner artifact
Player feedback after the 2-colour revert: "I do not like the
grey corners on the cards." The visible artifact was anti-
aliasing physics — the 1 px suit-coloured stroke (red for
hearts/diamonds, near-white for clubs/spades) faded through
gray pixels into the dark play surface at each rounded corner,
producing a visible "gray sliver" at the four arcs of every
card.

Fix: drop the stroke entirely. The card body fill defines the
shape against the play surface; the 5-unit brightness gap
between `#1a1a1a` body and `#151515` surface is enough to read
as a card edge without an explicit stroke. Anti-aliasing on a
fill-only rounded rect blends `#1a1a1a → #151515` over a few
pixels — barely perceptible compared to the
`stroke → transparent` gradient that produced the artifact.

### Changes

- `card_face_svg.rs`: removed `stroke="{colour}" stroke-width="2"`
  from the card body rect. Reverted the 1 px stroke inset back
  to `(x=0, y=0, width=256, height=384)` since there's no
  longer a stroke to keep inside the pixmap. Module-level
  comment updated to document the reasoning.
- `design-system.md` § Game Cards line 225 updated: "Border:
  1px solid in suit color" → "Border: none." with the
  artifact rationale recorded as audit trail.
- `card_face_svg_pin.rs` rebaselined: all 52 face hashes drift
  (every card's perimeter pixels changed); 5 back hashes
  unchanged.

Workspace clippy + cargo test --workspace clean. 1191 passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:41:54 -07:00

305 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 (`<suit>_<rank>.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 `<text>` 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 `<text>`)
// 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##"<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="0" y="0" width="256" height="384" rx="16" ry="16"
fill="{BG_FACE}"/>
<!-- Top-left rank in JetBrains-Mono-styled FiraMono (rank digits
and letters render correctly in FiraMono; only the suit glyphs
needed to escape to paths). -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="{colour}">{rank_text}</text>
<!-- Top-left small suit glyph at (14, 50), 20 × 20.
`suit_path_d` is authored in a 32-unit box, so scale 0.625
lands the visible glyph at 20 px. -->
<g transform="translate(14 50) scale(0.625)">
<path d="{path_d}" {small_glyph_attrs}/>
</g>
<!-- Bottom-right large suit glyph at (178, 286), 64 × 64.
Visible bottom-right at (242, 350), visible top-left at
(178, 286). Same upright orientation as the top-left small
glyph — no 180° rotation applied. -->
<g transform="translate(178 286) scale(2)">
<path d="{path_d}" {large_glyph_attrs}/>
</g>
</svg>"##
)
}
/// 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##"<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<defs>
<pattern id="scanlines" x="0" y="0" width="2" height="4" patternUnits="userSpaceOnUse">
<rect x="0" y="0" width="2" height="2" fill="{BACK_SCANLINE}"/>
</pattern>
</defs>
<!-- Background fill, then scanlines on top (the scanlines stay
darker than BACK_BG so the "off" rows show through). -->
<rect x="1" y="1" width="254" height="382" rx="16" ry="16"
fill="{BACK_BG}" stroke="{BACK_BORDER}" stroke-width="2"/>
<rect x="1" y="1" width="254" height="382" rx="16" ry="16"
fill="url(#scanlines)"/>
<!-- Top-left accent badge (the only theme-varying element). -->
<rect x="12" y="12" width="24" height="32" fill="{accent}"/>
<!-- Bottom-right "▌RS" monogram in JetBrains-Mono-styled FiraMono. -->
<text x="244" y="368" font-family="Fira Mono" font-size="24"
fill="{BACK_MONOGRAM}" text-anchor="end">&#x258C;RS</text>
</svg>"##
)
}