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
+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 {