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>
@@ -7514,6 +7514,7 @@ dependencies = [
|
||||
name = "solitaire_assetgen"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"png 0.17.16",
|
||||
]
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 213 B |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
@@ -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"
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 0–4).
|
||||
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 {
|
||||
|
||||