feat(engine,assetgen): generate 52 individual card face PNGs

Replace the single shared face.png placeholder with 52 individual card
face images (120×168 px each), generated by the updated gen_art tool:

- solitaire_assetgen: add ab_glyph dep; rewrite gen_art to render each
  card with FiraMono rank characters, programmatic suit symbols (heart,
  spade, diamond, club drawn via circles/triangles), and standard pip
  layout for numbered cards (A–10) plus large face letter for J/Q/K.
- CardImageSet: replace single `face` handle with `faces: [[Handle; 13]; 4]`
  indexed by [suit][rank].
- card_sprite(): select the per-card face image by suit/rank indices.
- spawn/update_card_entity: suppress Text2d overlay when PNG faces are
  loaded (rank/suit baked into image); keep overlay in solid-colour
  fallback for tests.
- gen_sfx.rs: rename `gen` variable to `make` (reserved keyword in 2024).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-29 01:20:31 +00:00
parent 11d53245cf
commit e22fcadb22
58 changed files with 524 additions and 185 deletions
Generated
+1
View File
@@ -7514,6 +7514,7 @@ dependencies = [
name = "solitaire_assetgen"
version = "0.1.0"
dependencies = [
"ab_glyph",
"png 0.17.16",
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

+2 -1
View File
@@ -10,7 +10,8 @@ publish = false
# Not depended on by any other workspace crate.
[dependencies]
png = "0.17"
png = "0.17"
ab_glyph = "0.2"
[[bin]]
name = "gen_sfx"
+389 -150
View File
@@ -1,178 +1,418 @@
//! Generates placeholder PNG assets for card faces, card backs, and table
//! backgrounds. All images are 16×16 pixels — Bevy's Sprite scales them via
//! `custom_size`, so small files keep the repository lightweight.
//! Generates PNG assets for Solitaire Quest.
//!
//! Run with:
//! ```
//! cargo run -p solitaire_assetgen --bin gen_art
//! ```
//! Produces:
//! - 52 card face PNGs (120×168) — one per card, with rank, suit symbol, and
//! pip or face-letter layout baked in.
//! - 5 card back PNGs (16×16 placeholder patterns).
//! - 5 background PNGs (16×16 placeholder patterns).
//!
//! Run with: `cargo run -p solitaire_assetgen --bin gen_art`
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
use std::fs::File;
use std::io::BufWriter;
use std::path::Path;
// ---------------------------------------------------------------------------
// PNG helper
// Card dimensions and palette
// ---------------------------------------------------------------------------
/// Write a 16×16 RGBA image to `path`. `pixels` is a flat `[R,G,B,A, ...]`
/// byte array with exactly 16 * 16 * 4 = 1024 bytes.
fn save_png(path: &Path, pixels: &[u8; 1024]) {
const W: u32 = 120;
const H: u32 = 168;
const BG: [u8; 4] = [0xFE, 0xFE, 0xF2, 0xFF];
const BORDER: [u8; 4] = [0x99, 0x99, 0x99, 0xFF];
const RED: [u8; 4] = [0xCC, 0x11, 0x11, 0xFF];
const DARK: [u8; 4] = [0x11, 0x11, 0x11, 0xFF];
fn suit_color(suit: u8) -> [u8; 4] {
if suit == 1 || suit == 2 { RED } else { DARK }
}
fn rank_str(rank: u8) -> &'static str {
["A","2","3","4","5","6","7","8","9","10","J","Q","K"][rank as usize]
}
// ---------------------------------------------------------------------------
// Pixel canvas (120×168 RGBA)
// ---------------------------------------------------------------------------
struct Canvas {
data: Vec<u8>,
}
impl Canvas {
fn new() -> Self {
let mut data = vec![0u8; (W * H * 4) as usize];
for i in 0..(W * H) as usize {
data[i * 4..i * 4 + 4].copy_from_slice(&BG);
}
Self { data }
}
fn set(&mut self, x: i32, y: i32, c: [u8; 4]) {
if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 { return; }
let i = (y as u32 * W + x as u32) as usize * 4;
let a = c[3] as f32 / 255.0;
if a >= 0.99 {
self.data[i..i + 4].copy_from_slice(&c);
} else if a > 0.01 {
self.data[i] = (self.data[i] as f32 * (1.0 - a) + c[0] as f32 * a) as u8;
self.data[i + 1] = (self.data[i + 1] as f32 * (1.0 - a) + c[1] as f32 * a) as u8;
self.data[i + 2] = (self.data[i + 2] as f32 * (1.0 - a) + c[2] as f32 * a) as u8;
self.data[i + 3] = 255;
}
}
fn circle(&mut self, cx: f32, cy: f32, r: f32, c: [u8; 4]) {
for y in (cy - r - 1.0) as i32..=(cy + r + 1.0) as i32 {
for x in (cx - r - 1.0) as i32..=(cx + r + 1.0) as i32 {
if (x as f32 - cx).powi(2) + (y as f32 - cy).powi(2) <= r * r {
self.set(x, y, c);
}
}
}
}
fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, c: [u8; 4]) {
for ry in y..y + h {
for rx in x..x + w {
self.set(rx, ry, c);
}
}
}
fn triangle(&mut self, pts: [(f32, f32); 3], c: [u8; 4]) {
let min_x = pts.iter().map(|p| p.0).fold(f32::INFINITY, f32::min) as i32;
let max_x = pts.iter().map(|p| p.0).fold(f32::NEG_INFINITY, f32::max) as i32;
let min_y = pts.iter().map(|p| p.1).fold(f32::INFINITY, f32::min) as i32;
let max_y = pts.iter().map(|p| p.1).fold(f32::NEG_INFINITY, f32::max) as i32;
let (ax, ay) = pts[0];
let (bx, by) = pts[1];
let (ex, ey) = pts[2];
for y in min_y..=max_y {
for x in min_x..=max_x {
let px = x as f32 + 0.5;
let py = y as f32 + 0.5;
let d0 = (bx - ax) * (py - ay) - (by - ay) * (px - ax);
let d1 = (ex - bx) * (py - by) - (ey - by) * (px - bx);
let d2 = (ax - ex) * (py - ey) - (ay - ey) * (px - ex);
let neg = d0 < 0.0 || d1 < 0.0 || d2 < 0.0;
let pos = d0 > 0.0 || d1 > 0.0 || d2 > 0.0;
if !(neg && pos) {
self.set(x, y, c);
}
}
}
}
fn diamond(&mut self, cx: f32, cy: f32, rx: f32, ry: f32, c: [u8; 4]) {
for y in (cy - ry - 1.0) as i32..=(cy + ry + 1.0) as i32 {
for x in (cx - rx - 1.0) as i32..=(cx + rx + 1.0) as i32 {
let nx = (x as f32 - cx).abs() / rx;
let ny = (y as f32 - cy).abs() / ry;
if nx + ny <= 1.0 {
self.set(x, y, c);
}
}
}
}
}
// ---------------------------------------------------------------------------
// Suit symbol drawing
// ---------------------------------------------------------------------------
fn draw_suit(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, suit: u8, c: [u8; 4]) {
match suit {
0 => draw_club(cv, cx, cy, sz, c),
1 => draw_diamond_sym(cv, cx, cy, sz, c),
2 => draw_heart(cv, cx, cy, sz, c),
_ => draw_spade(cv, cx, cy, sz, c),
}
}
fn draw_heart(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
let r = sz * 0.33;
let oy = cy - sz * 0.04;
cv.circle(cx - sz * 0.22, oy, r, c);
cv.circle(cx + sz * 0.22, oy, r, c);
cv.triangle([
(cx - sz * 0.52, oy + r * 0.4),
(cx + sz * 0.52, oy + r * 0.4),
(cx, cy + sz * 0.52),
], c);
}
fn draw_spade(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
cv.triangle([
(cx, cy - sz * 0.52),
(cx - sz * 0.52, cy + sz * 0.1),
(cx + sz * 0.52, cy + sz * 0.1),
], c);
cv.circle(cx - sz * 0.22, cy + sz * 0.06, sz * 0.3, c);
cv.circle(cx + sz * 0.22, cy + sz * 0.06, sz * 0.3, c);
// stem + base
cv.triangle([
(cx, cy + sz * 0.12),
(cx - sz * 0.13, cy + sz * 0.5),
(cx + sz * 0.13, cy + sz * 0.5),
], c);
cv.fill_rect(
(cx - sz * 0.26) as i32,
(cy + sz * 0.43) as i32,
(sz * 0.52) as i32,
(sz * 0.1) as i32,
c,
);
}
fn draw_diamond_sym(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
cv.diamond(cx, cy, sz * 0.44, sz * 0.57, c);
}
fn draw_club(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
let r = sz * 0.29;
cv.circle(cx, cy - sz * 0.22, r, c);
cv.circle(cx - sz * 0.28, cy + sz * 0.1, r, c);
cv.circle(cx + sz * 0.28, cy + sz * 0.1, r, c);
cv.fill_rect(
(cx - sz * 0.08) as i32,
(cy + sz * 0.22) as i32,
(sz * 0.16) as i32 + 1,
(sz * 0.27) as i32,
c,
);
cv.fill_rect(
(cx - sz * 0.26) as i32,
(cy + sz * 0.45) as i32,
(sz * 0.52) as i32,
(sz * 0.09) as i32,
c,
);
}
// ---------------------------------------------------------------------------
// Text rendering via ab_glyph
// ---------------------------------------------------------------------------
fn draw_text(cv: &mut Canvas, font: &FontRef<'_>, text: &str, px: f32, left: f32, top: f32, c: [u8; 4]) {
let scale = PxScale::from(px);
let baseline = top + font.as_scaled(scale).ascent();
let mut x = left;
for ch in text.chars() {
let gid = font.glyph_id(ch);
let glyph = gid.with_scale_and_position(scale, ab_glyph::point(x, baseline));
let adv = font.as_scaled(scale).h_advance(gid);
if let Some(outlined) = font.outline_glyph(glyph) {
let bounds = outlined.px_bounds();
outlined.draw(|gx, gy, cov| {
if cov > 0.02 {
let alpha = (cov * c[3] as f32) as u8;
cv.set(
(bounds.min.x + gx as f32) as i32,
(bounds.min.y + gy as f32) as i32,
[c[0], c[1], c[2], alpha],
);
}
});
}
x += adv;
}
}
fn text_w(font: &FontRef<'_>, text: &str, px: f32) -> f32 {
let scale = PxScale::from(px);
let sf = font.as_scaled(scale);
text.chars().map(|c| sf.h_advance(font.glyph_id(c))).sum()
}
fn text_h(font: &FontRef<'_>, px: f32) -> f32 {
let scale = PxScale::from(px);
let sf = font.as_scaled(scale);
sf.ascent() - sf.descent()
}
// ---------------------------------------------------------------------------
// Pip layout (rank 0=Ace … 9=Ten; rank 10-12 are face cards)
// ---------------------------------------------------------------------------
fn pip_positions(rank: u8) -> &'static [(f32, f32)] {
match rank {
0 => &[(0.5, 0.5)],
1 => &[(0.5, 0.2), (0.5, 0.8)],
2 => &[(0.5, 0.12), (0.5, 0.5), (0.5, 0.88)],
3 => &[(0.25, 0.18), (0.75, 0.18), (0.25, 0.82), (0.75, 0.82)],
4 => &[(0.25, 0.18), (0.75, 0.18), (0.5, 0.5), (0.25, 0.82), (0.75, 0.82)],
5 => &[(0.25, 0.12), (0.75, 0.12), (0.25, 0.5), (0.75, 0.5), (0.25, 0.88), (0.75, 0.88)],
6 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.31), (0.25, 0.5), (0.75, 0.5), (0.25, 0.9), (0.75, 0.9)],
7 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.28), (0.25, 0.48), (0.75, 0.48), (0.5, 0.70), (0.25, 0.9), (0.75, 0.9)],
8 => &[(0.25, 0.1), (0.75, 0.1), (0.25, 0.35), (0.75, 0.35), (0.5, 0.5), (0.25, 0.65), (0.75, 0.65), (0.25, 0.9), (0.75, 0.9)],
9 => &[(0.25, 0.09), (0.75, 0.09), (0.5, 0.27), (0.25, 0.44), (0.75, 0.44), (0.25, 0.56), (0.75, 0.56), (0.5, 0.73), (0.25, 0.91), (0.75, 0.91)],
_ => &[],
}
}
// Pip area within the card (avoids the corner labels).
const PIP_X: f32 = 22.0;
const PIP_Y: f32 = 46.0;
const PIP_W: f32 = 76.0;
const PIP_H: f32 = 80.0;
// ---------------------------------------------------------------------------
// Card face generation
// ---------------------------------------------------------------------------
fn make_card_face(font: &FontRef<'_>, rank: u8, suit: u8) -> Canvas {
let mut cv = Canvas::new();
let sc = suit_color(suit);
// Border (2 px)
for x in 0..W as i32 {
cv.set(x, 0, BORDER);
cv.set(x, 1, BORDER);
cv.set(x, H as i32 - 2, BORDER);
cv.set(x, H as i32 - 1, BORDER);
}
for y in 0..H as i32 {
cv.set(0, y, BORDER);
cv.set(1, y, BORDER);
cv.set(W as i32 - 2, y, BORDER);
cv.set(W as i32 - 1, y, BORDER);
}
let rank_s = rank_str(rank);
let rank_px = 18.0f32;
let suit_sz = 11.0f32;
let rh = text_h(font, rank_px);
let rw = text_w(font, rank_s, rank_px);
let corner_h = rh + 2.0 + suit_sz * 1.5;
// Top-left corner
let tl_x = 6.0f32;
let tl_y = 5.0f32;
draw_text(&mut cv, font, rank_s, rank_px, tl_x, tl_y, sc);
draw_suit(&mut cv, tl_x + suit_sz * 0.62, tl_y + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc);
// Bottom-right corner (right-aligned rank, suit above it)
let br_rx = W as f32 - 6.0;
let br_by = H as f32 - 5.0;
let br_ty = br_by - corner_h;
draw_text(&mut cv, font, rank_s, rank_px, br_rx - rw, br_ty, sc);
draw_suit(&mut cv, br_rx - suit_sz * 0.62, br_ty + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc);
// Center content
if rank >= 10 {
// Face cards: large rank letter + suit symbol below
let big_px = 52.0f32;
let big_w = text_w(font, rank_s, big_px);
let big_h = text_h(font, big_px);
let big_x = (W as f32 - big_w) / 2.0;
let big_y = H as f32 * 0.28;
draw_text(&mut cv, font, rank_s, big_px, big_x, big_y, sc);
let sym_sz = 22.0f32;
draw_suit(&mut cv, W as f32 * 0.5, big_y + big_h + sym_sz * 1.0, sym_sz, suit, sc);
} else {
// Pip cards
let pip_sz = if rank == 0 {
24.0f32 // Ace: large single pip
} else if rank <= 5 {
14.0
} else {
12.0
};
for &(nx, ny) in pip_positions(rank) {
let cx = PIP_X + nx * PIP_W;
let cy = PIP_Y + ny * PIP_H;
draw_suit(&mut cv, cx, cy, pip_sz, suit, sc);
}
}
cv
}
// ---------------------------------------------------------------------------
// PNG encoding helpers
// ---------------------------------------------------------------------------
fn save_card_png(path: &Path, cv: &Canvas) {
save_png_wh(path, &cv.data, W, H);
}
fn save_png_wh(path: &Path, data: &[u8], w: u32, h: u32) {
let file = File::create(path)
.unwrap_or_else(|e| panic!("cannot create {}: {e}", path.display()));
let mut w = BufWriter::new(file);
let mut encoder = png::Encoder::new(&mut w, 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder
.write_header()
let mut bw = BufWriter::new(file);
let mut enc = png::Encoder::new(&mut bw, w, h);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut writer = enc.write_header()
.unwrap_or_else(|e| panic!("png header error for {}: {e}", path.display()));
writer
.write_image_data(pixels)
writer.write_image_data(data)
.unwrap_or_else(|e| panic!("png data error for {}: {e}", path.display()));
}
/// Build a flat 16×16 RGBA pixel array using a per-pixel closure.
fn make_image<F: Fn(u32, u32) -> [u8; 4]>(f: F) -> [u8; 1024] {
let mut pixels = [0u8; 1024];
fn save_small_png(path: &Path, pixels: &[u8; 1024]) {
save_png_wh(path, pixels, 16, 16);
}
fn make_small<F: Fn(u32, u32) -> [u8; 4]>(f: F) -> [u8; 1024] {
let mut out = [0u8; 1024];
for y in 0u32..16 {
for x in 0u32..16 {
let rgba = f(x, y);
let c = f(x, y);
let i = ((y * 16 + x) * 4) as usize;
pixels[i..i + 4].copy_from_slice(&rgba);
out[i..i + 4].copy_from_slice(&c);
}
}
pixels
out
}
// ---------------------------------------------------------------------------
// Card face
// Card backs (16×16 placeholder patterns)
// ---------------------------------------------------------------------------
/// Cream/ivory solid fill — represents a blank card face.
fn make_face() -> [u8; 1024] {
make_image(|_, _| [0xF8, 0xF8, 0xF0, 0xFF])
}
// ---------------------------------------------------------------------------
// Card backs (match the colours used in card_plugin.rs `card_back_colour()`)
// ---------------------------------------------------------------------------
/// back_0 — blue base with semi-transparent white horizontal stripes every 4 px.
fn make_back_0() -> [u8; 1024] {
make_image(|_, y| {
if y % 4 < 2 {
[0xFF, 0xFF, 0xFF, 40]
} else {
[0x26, 0x4D, 0x8C, 0xFF]
}
})
make_small(|_, y| if y % 4 < 2 { [0xFF, 0xFF, 0xFF, 40] } else { [0x26, 0x4D, 0x8C, 0xFF] })
}
/// back_1 — red base with semi-transparent white diagonal stripes.
fn make_back_1() -> [u8; 1024] {
make_image(|x, y| {
if (x + y) % 4 < 2 {
[0xFF, 0xFF, 0xFF, 40]
} else {
[0x8C, 0x1A, 0x1A, 0xFF]
}
})
make_small(|x, y| if (x + y) % 4 < 2 { [0xFF, 0xFF, 0xFF, 40] } else { [0x8C, 0x1A, 0x1A, 0xFF] })
}
/// back_2 — green base with white dots at every 4-px grid intersection.
fn make_back_2() -> [u8; 1024] {
make_image(|x, y| {
if x % 4 == 0 && y % 4 == 0 {
[0xFF, 0xFF, 0xFF, 0xFF]
} else {
[0x0D, 0x66, 0x1A, 0xFF]
}
})
make_small(|x, y| if x.is_multiple_of(4) && y.is_multiple_of(4) { [0xFF, 0xFF, 0xFF, 0xFF] } else { [0x0D, 0x66, 0x1A, 0xFF] })
}
/// back_3 — purple base with a white diamond centred at (8, 8).
fn make_back_3() -> [u8; 1024] {
make_image(|x, y| {
make_small(|x, y| {
let dx = (x as i32 - 8).unsigned_abs();
let dy = (y as i32 - 8).unsigned_abs();
if dx + dy <= 4 {
[0xFF, 0xFF, 0xFF, 0xFF]
} else {
[0x59, 0x14, 0x85, 0xFF]
}
if dx + dy <= 4 { [0xFF, 0xFF, 0xFF, 0xFF] } else { [0x59, 0x14, 0x85, 0xFF] }
})
}
/// back_4 — teal base with a 1-px white border.
fn make_back_4() -> [u8; 1024] {
make_image(|x, y| {
if x == 0 || x == 15 || y == 0 || y == 15 {
[0xFF, 0xFF, 0xFF, 0xFF]
} else {
[0x0D, 0x66, 0x6B, 0xFF]
}
})
make_small(|x, y| if x == 0 || x == 15 || y == 0 || y == 15 { [0xFF, 0xFF, 0xFF, 0xFF] } else { [0x0D, 0x66, 0x6B, 0xFF] })
}
// ---------------------------------------------------------------------------
// Backgrounds
// Backgrounds (16×16 placeholder patterns)
// ---------------------------------------------------------------------------
/// bg_0 — dark green felt with very faint lighter grid lines every 8 px.
fn make_bg_0() -> [u8; 1024] {
make_image(|x, y| {
if x % 8 == 0 || y % 8 == 0 {
[0xFF, 0xFF, 0xFF, 30]
} else {
[0x1A, 0x4D, 0x1A, 0xFF]
}
})
make_small(|x, y| if x.is_multiple_of(8) || y.is_multiple_of(8) { [0xFF, 0xFF, 0xFF, 30] } else { [0x1A, 0x4D, 0x1A, 0xFF] })
}
/// bg_1 — dark wood brown with faint horizontal grain lines every 2 px.
fn make_bg_1() -> [u8; 1024] {
make_image(|_, y| {
if y % 2 == 0 {
[0xFF, 0xFF, 0xFF, 20]
} else {
[0x40, 0x2D, 0x1A, 0xFF]
}
})
make_small(|_, y| if y.is_multiple_of(2) { [0xFF, 0xFF, 0xFF, 20] } else { [0x40, 0x2D, 0x1A, 0xFF] })
}
/// bg_2 — navy with faint star/dot pattern (offset rows) every 8 px.
fn make_bg_2() -> [u8; 1024] {
make_image(|x, y| {
let row_offset: u32 = if (y / 4) % 2 == 0 { 0 } else { 4 };
if (x + row_offset) % 8 == 0 && y % 8 == 0 {
[0xFF, 0xFF, 0xFF, 0xFF]
} else {
[0x0D, 0x14, 0x38, 0xFF]
}
make_small(|x, y| {
let off: u32 = if (y / 4).is_multiple_of(2) { 0 } else { 4 };
if (x + off).is_multiple_of(8) && y.is_multiple_of(8) { [0xFF, 0xFF, 0xFF, 0xFF] } else { [0x0D, 0x14, 0x38, 0xFF] }
})
}
/// bg_3 — burgundy with a faint diamond-grid pattern.
fn make_bg_3() -> [u8; 1024] {
make_image(|x, y| {
if (x + y) % 8 == 0 {
[0xFF, 0xFF, 0xFF, 30]
} else {
[0x4D, 0x0D, 0x14, 0xFF]
}
})
make_small(|x, y| if (x + y).is_multiple_of(8) { [0xFF, 0xFF, 0xFF, 30] } else { [0x4D, 0x0D, 0x14, 0xFF] })
}
/// bg_4 — charcoal with faint pixel noise (alternating pixels every 3 columns).
fn make_bg_4() -> [u8; 1024] {
make_image(|x, y| {
if (x + y) % 2 == 0 && x % 3 == 0 {
[0xFF, 0xFF, 0xFF, 20]
} else {
[0x1F, 0x1F, 0x24, 0xFF]
}
})
make_small(|x, y| if (x + y).is_multiple_of(2) && x.is_multiple_of(3) { [0xFF, 0xFF, 0xFF, 20] } else { [0x1F, 0x1F, 0x24, 0xFF] })
}
// ---------------------------------------------------------------------------
@@ -186,44 +426,43 @@ fn workspace_root() -> std::path::PathBuf {
fn main() {
let root = workspace_root();
// Ensure output directories exist.
std::fs::create_dir_all(root.join("assets/cards/faces")).unwrap();
std::fs::create_dir_all(root.join("assets/cards/backs")).unwrap();
std::fs::create_dir_all(root.join("assets/backgrounds")).unwrap();
// Card face.
let path = root.join("assets/cards/faces/face.png");
save_png(&path, &make_face());
println!("wrote {}", path.display());
// Load font from disk (dev tool — runtime load is fine here).
let font_path = root.join("assets/fonts/main.ttf");
let font_bytes = std::fs::read(&font_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", font_path.display()));
let font = FontRef::try_from_slice(&font_bytes)
.expect("failed to parse assets/fonts/main.ttf");
// Card backs.
let backs = [
make_back_0(),
make_back_1(),
make_back_2(),
make_back_3(),
make_back_4(),
];
for (i, pixels) in backs.iter().enumerate() {
// 52 card faces
let suits = ["c", "d", "h", "s"];
let ranks = ["a","2","3","4","5","6","7","8","9","10","j","q","k"];
for suit in 0u8..4 {
for rank in 0u8..13 {
let cv = make_card_face(&font, rank, suit);
let name = format!("{}_{}.png", ranks[rank as usize], suits[suit as usize]);
let path = root.join("assets/cards/faces").join(&name);
save_card_png(&path, &cv);
println!("wrote {}", path.display());
}
}
// Card backs
for (i, pixels) in [make_back_0(), make_back_1(), make_back_2(), make_back_3(), make_back_4()].iter().enumerate() {
let path = root.join(format!("assets/cards/backs/back_{i}.png"));
save_png(&path, pixels);
save_small_png(&path, pixels);
println!("wrote {}", path.display());
}
// Backgrounds.
let bgs = [
make_bg_0(),
make_bg_1(),
make_bg_2(),
make_bg_3(),
make_bg_4(),
];
for (i, pixels) in bgs.iter().enumerate() {
// Backgrounds
for (i, pixels) in [make_bg_0(), make_bg_1(), make_bg_2(), make_bg_3(), make_bg_4()].iter().enumerate() {
let path = root.join(format!("assets/backgrounds/bg_{i}.png"));
save_png(&path, pixels);
save_small_png(&path, pixels);
println!("wrote {}", path.display());
}
println!("gen_art: all placeholder PNG assets generated successfully.");
println!("gen_art: all assets generated successfully.");
}
+2 -2
View File
@@ -25,8 +25,8 @@ fn main() -> io::Result<()> {
("ambient_loop.wav", ambient_loop),
];
for (name, gen) in &effects {
let samples = gen();
for (name, make) in &effects {
let samples = make();
let path = out_dir.join(name);
write_wav_mono_pcm16(&path, SAMPLE_RATE, &samples)?;
println!("wrote {} ({} samples)", path.display(), samples.len());
+130 -32
View File
@@ -54,8 +54,11 @@ pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
/// solid-colour sprites (used in tests with `MinimalPlugins`).
#[derive(Resource)]
pub struct CardImageSet {
/// Shared face image used for all face-up cards.
pub face: Handle<Image>,
/// Per-card face images indexed by `[suit][rank]`.
///
/// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
/// Rank order: Ace=0, Two=1 … King=12.
pub faces: [[Handle<Image>; 13]; 4],
/// One handle per unlockable card-back design (indices 04).
pub backs: [Handle<Image>; 5],
}
@@ -202,8 +205,6 @@ impl Plugin for CardPlugin {
/// so it does nothing and the plugin falls back to solid-colour sprites.
fn load_card_images(images: Option<ResMut<Assets<Image>>>, mut commands: Commands) {
let Some(mut images) = images else {
// Assets<Image> is absent (e.g. MinimalPlugins in tests) — skip so
// tests can still run. The plugin falls back to solid-colour sprites.
return;
};
use bevy::asset::RenderAssetUsages;
@@ -221,7 +222,79 @@ fn load_card_images(images: Option<ResMut<Assets<Image>>>, mut commands: Command
.expect("valid card PNG")
};
let face = images.add(load(include_bytes!("../../assets/cards/faces/face.png")));
// 52 face images: faces[suit][rank]
// Suit: Clubs=0, Diamonds=1, Hearts=2, Spades=3
// Rank: Ace=0 … King=12
const FACE_BYTES: [[&[u8]; 13]; 4] = [
// Clubs
[
include_bytes!("../../assets/cards/faces/a_c.png"),
include_bytes!("../../assets/cards/faces/2_c.png"),
include_bytes!("../../assets/cards/faces/3_c.png"),
include_bytes!("../../assets/cards/faces/4_c.png"),
include_bytes!("../../assets/cards/faces/5_c.png"),
include_bytes!("../../assets/cards/faces/6_c.png"),
include_bytes!("../../assets/cards/faces/7_c.png"),
include_bytes!("../../assets/cards/faces/8_c.png"),
include_bytes!("../../assets/cards/faces/9_c.png"),
include_bytes!("../../assets/cards/faces/10_c.png"),
include_bytes!("../../assets/cards/faces/j_c.png"),
include_bytes!("../../assets/cards/faces/q_c.png"),
include_bytes!("../../assets/cards/faces/k_c.png"),
],
// Diamonds
[
include_bytes!("../../assets/cards/faces/a_d.png"),
include_bytes!("../../assets/cards/faces/2_d.png"),
include_bytes!("../../assets/cards/faces/3_d.png"),
include_bytes!("../../assets/cards/faces/4_d.png"),
include_bytes!("../../assets/cards/faces/5_d.png"),
include_bytes!("../../assets/cards/faces/6_d.png"),
include_bytes!("../../assets/cards/faces/7_d.png"),
include_bytes!("../../assets/cards/faces/8_d.png"),
include_bytes!("../../assets/cards/faces/9_d.png"),
include_bytes!("../../assets/cards/faces/10_d.png"),
include_bytes!("../../assets/cards/faces/j_d.png"),
include_bytes!("../../assets/cards/faces/q_d.png"),
include_bytes!("../../assets/cards/faces/k_d.png"),
],
// Hearts
[
include_bytes!("../../assets/cards/faces/a_h.png"),
include_bytes!("../../assets/cards/faces/2_h.png"),
include_bytes!("../../assets/cards/faces/3_h.png"),
include_bytes!("../../assets/cards/faces/4_h.png"),
include_bytes!("../../assets/cards/faces/5_h.png"),
include_bytes!("../../assets/cards/faces/6_h.png"),
include_bytes!("../../assets/cards/faces/7_h.png"),
include_bytes!("../../assets/cards/faces/8_h.png"),
include_bytes!("../../assets/cards/faces/9_h.png"),
include_bytes!("../../assets/cards/faces/10_h.png"),
include_bytes!("../../assets/cards/faces/j_h.png"),
include_bytes!("../../assets/cards/faces/q_h.png"),
include_bytes!("../../assets/cards/faces/k_h.png"),
],
// Spades
[
include_bytes!("../../assets/cards/faces/a_s.png"),
include_bytes!("../../assets/cards/faces/2_s.png"),
include_bytes!("../../assets/cards/faces/3_s.png"),
include_bytes!("../../assets/cards/faces/4_s.png"),
include_bytes!("../../assets/cards/faces/5_s.png"),
include_bytes!("../../assets/cards/faces/6_s.png"),
include_bytes!("../../assets/cards/faces/7_s.png"),
include_bytes!("../../assets/cards/faces/8_s.png"),
include_bytes!("../../assets/cards/faces/9_s.png"),
include_bytes!("../../assets/cards/faces/10_s.png"),
include_bytes!("../../assets/cards/faces/j_s.png"),
include_bytes!("../../assets/cards/faces/q_s.png"),
include_bytes!("../../assets/cards/faces/k_s.png"),
],
];
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|suit| {
std::array::from_fn(|rank| images.add(load(FACE_BYTES[suit][rank])))
});
let backs = [
images.add(load(include_bytes!("../../assets/cards/backs/back_0.png"))),
images.add(load(include_bytes!("../../assets/cards/backs/back_1.png"))),
@@ -229,7 +302,7 @@ fn load_card_images(images: Option<ResMut<Assets<Image>>>, mut commands: Command
images.add(load(include_bytes!("../../assets/cards/backs/back_3.png"))),
images.add(load(include_bytes!("../../assets/cards/backs/back_4.png"))),
];
commands.insert_resource(CardImageSet { face, backs });
commands.insert_resource(CardImageSet { faces, backs });
}
/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
@@ -244,7 +317,28 @@ fn card_sprite(
) -> Sprite {
if let Some(set) = card_images {
let image = if card.face_up {
set.face.clone()
let suit_idx = match card.suit {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
};
let rank_idx = match card.rank {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
Rank::Four => 3,
Rank::Five => 4,
Rank::Six => 5,
Rank::Seven => 6,
Rank::Eight => 7,
Rank::Nine => 8,
Rank::Ten => 9,
Rank::Jack => 10,
Rank::Queen => 11,
Rank::King => 12,
};
set.faces[suit_idx][rank_idx].clone()
} else {
let idx = selected_back.min(set.backs.len() - 1);
set.backs[idx].clone()
@@ -462,14 +556,16 @@ fn spawn_card_entity(
) {
let sprite = card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back);
commands
.spawn((
CardEntity { card_id: card.id },
sprite,
Transform::from_xyz(pos.x, pos.y, z),
Visibility::default(),
))
.with_children(|b| {
let mut entity = commands.spawn((
CardEntity { card_id: card.id },
sprite,
Transform::from_xyz(pos.x, pos.y, z),
Visibility::default(),
));
// When PNG faces are loaded the rank/suit are baked into the image.
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
if card_images.is_none() {
entity.with_children(|b| {
b.spawn((
CardLabel,
Text2d::new(label_for(card)),
@@ -478,12 +574,11 @@ fn spawn_card_entity(
..default()
},
TextColor(text_colour(card)),
// Above the card body on z so it doesn't get occluded by the
// parent sprite in back-to-front rendering.
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(card),
));
});
}
}
#[allow(clippy::too_many_arguments)]
@@ -526,22 +621,25 @@ fn update_card_entity(
.insert(Transform::from_xyz(pos.x, pos.y, z));
}
// Despawn the old label child and respawn a fresh one, so rank/suit/
// colour/visibility all stay in sync with the card's current state.
// Despawn any stale children and re-add the label overlay only when
// operating in solid-colour mode (no PNG faces). In image mode the
// rank/suit are baked into the PNG, so no Text2d overlay is needed.
commands.entity(entity).despawn_related::<Children>();
commands.entity(entity).with_children(|b| {
b.spawn((
CardLabel,
Text2d::new(label_for(card)),
TextFont {
font_size: layout.card_size.x * FONT_SIZE_FRAC,
..default()
},
TextColor(text_colour(card)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(card),
));
});
if card_images.is_none() {
commands.entity(entity).with_children(|b| {
b.spawn((
CardLabel,
Text2d::new(label_for(card)),
TextFont {
font_size: layout.card_size.x * FONT_SIZE_FRAC,
..default()
},
TextColor(text_colour(card)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(card),
));
});
}
}
fn label_for(card: &Card) -> String {