feat(engine): add CardPlugin with procedural card rendering

Each card is a parent Sprite (white for face-up, blue for face-down) with
a Text2d child showing rank+suit (e.g. "AH", "10C", "KS"). Hearts and
diamonds render red; clubs and spades black. Face-down labels are hidden.
Tableau cards fan downward; other piles stack at the same position with a
small z-offset.

Sync runs in PostStartup for the initial deal and in Update after every
StateChangedEvent. To avoid a one-frame lag, downstream plugins run after
the new GameMutation system set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-23 16:22:49 -07:00
parent d92b4a8648
commit 0a87f0f8f2
4 changed files with 446 additions and 3 deletions
+2 -1
View File
@@ -1,5 +1,5 @@
use bevy::prelude::*;
use solitaire_engine::{GamePlugin, TablePlugin};
use solitaire_engine::{CardPlugin, GamePlugin, TablePlugin};
fn main() {
App::new()
@@ -15,5 +15,6 @@ fn main() {
)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
.run();
}
+433
View File
@@ -0,0 +1,433 @@
//! Procedural card rendering.
//!
//! Each card is a parent entity with a coloured body `Sprite` and a child
//! `Text2d` showing rank+suit. Entities are synced with `GameStateResource`
//! on every `StateChangedEvent`: missing cards are spawned, present cards
//! are repositioned/updated in place, and stale cards are despawned.
//!
//! Phase 3 uses ASCII rank letters ("A", "2"…"10", "J", "Q", "K") and ASCII
//! suit letters ("C", "D", "H", "S") so rendering does not depend on the
//! bundled font carrying Unicode suit glyphs. When real card art lands in a
//! later phase, this plugin is replaced — the `CardEntity` marker and the
//! "sync on StateChangedEvent" contract stay the same.
use std::collections::{HashMap, HashSet};
use bevy::color::Color;
use bevy::prelude::*;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use crate::events::StateChangedEvent;
use crate::game_plugin::GameMutation;
use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource;
/// Fraction of card height used as vertical offset between stacked tableau cards.
const TABLEAU_FAN_FRAC: f32 = 0.25;
/// Fraction of card height used as a tiny offset between stacked cards in
/// non-tableau piles, so stacking is visible.
const STACK_FAN_FRAC: f32 = 0.003;
/// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28;
const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
const CARD_BACK_COLOUR: Color = Color::srgb(0.15, 0.30, 0.55);
const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15);
const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
/// Marker component linking a Bevy entity to a `solitaire_core::Card::id`.
#[derive(Component, Debug, Clone, Copy)]
pub struct CardEntity {
pub card_id: u32,
}
/// Marker for the text child inside a card entity.
#[derive(Component, Debug)]
pub struct CardLabel;
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
pub struct CardPlugin;
impl Plugin for CardPlugin {
fn build(&self, app: &mut App) {
// PostStartup ensures TablePlugin's Startup system has inserted
// LayoutResource before we try to read it.
app.add_systems(PostStartup, sync_cards_startup)
.add_systems(Update, sync_cards_on_change.after(GameMutation));
}
}
/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
/// have already completed.
fn sync_cards_startup(
commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
entities: Query<(Entity, &CardEntity)>,
) {
if let Some(layout) = layout {
sync_cards(commands, &game.0, &layout.0, &entities);
}
}
fn sync_cards_on_change(
mut events: EventReader<StateChangedEvent>,
commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
entities: Query<(Entity, &CardEntity)>,
) {
if events.read().next().is_none() {
return;
}
if let Some(layout) = layout {
sync_cards(commands, &game.0, &layout.0, &entities);
}
}
fn sync_cards(
mut commands: Commands,
game: &GameState,
layout: &Layout,
entities: &Query<(Entity, &CardEntity)>,
) {
let positions = card_positions(game, layout);
// Map card_id -> Entity for in-place updates.
let mut existing: HashMap<u32, Entity> = HashMap::new();
for (entity, marker) in entities.iter() {
existing.insert(marker.card_id, entity);
}
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
// Despawn any entity whose card is no longer tracked.
for (card_id, entity) in &existing {
if !live_ids.contains(card_id) {
commands.entity(*entity).despawn_recursive();
}
}
// For each card in the current state: spawn or update its entity.
for (card, position, z) in positions {
match existing.get(&card.id) {
Some(&entity) => update_card_entity(&mut commands, entity, &card, position, z, layout),
None => spawn_card_entity(&mut commands, &card, position, z, layout),
}
}
}
/// Returns an ordered vec of (card, position, z) for every card in the game.
fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52);
let piles = [
PileType::Stock,
PileType::Waste,
PileType::Foundation(Suit::Clubs),
PileType::Foundation(Suit::Diamonds),
PileType::Foundation(Suit::Hearts),
PileType::Foundation(Suit::Spades),
PileType::Tableau(0),
PileType::Tableau(1),
PileType::Tableau(2),
PileType::Tableau(3),
PileType::Tableau(4),
PileType::Tableau(5),
PileType::Tableau(6),
];
for pile_type in piles {
let Some(base) = layout.pile_positions.get(&pile_type) else {
continue;
};
let Some(pile) = game.piles.get(&pile_type) else {
continue;
};
let is_tableau = matches!(pile_type, PileType::Tableau(_));
let fan_y = if is_tableau {
-layout.card_size.y * TABLEAU_FAN_FRAC
} else {
0.0
};
for (i, card) in pile.cards.iter().enumerate() {
let pos = Vec2::new(base.x, base.y + fan_y * i as f32);
let z = 1.0 + (i as f32) * STACK_FAN_FRAC;
out.push((card.clone(), pos, z));
}
}
out
}
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout) {
let body_colour = if card.face_up {
CARD_FACE_COLOUR
} else {
CARD_BACK_COLOUR
};
commands
.spawn((
CardEntity { card_id: card.id },
Sprite {
color: body_colour,
custom_size: Some(layout.card_size),
..default()
},
Transform::from_xyz(pos.x, pos.y, z),
Visibility::default(),
))
.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)),
// 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),
));
});
}
fn update_card_entity(
commands: &mut Commands,
entity: Entity,
card: &Card,
pos: Vec2,
z: f32,
layout: &Layout,
) {
let body_colour = if card.face_up {
CARD_FACE_COLOUR
} else {
CARD_BACK_COLOUR
};
commands.entity(entity).insert((
Sprite {
color: body_colour,
custom_size: Some(layout.card_size),
..default()
},
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.
commands.entity(entity).despawn_descendants();
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 {
let rank = match card.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",
};
let suit = match card.suit {
Suit::Clubs => "C",
Suit::Diamonds => "D",
Suit::Hearts => "H",
Suit::Spades => "S",
};
format!("{rank}{suit}")
}
fn text_colour(card: &Card) -> Color {
if card.suit.is_red() {
RED_SUIT_COLOUR
} else {
BLACK_SUIT_COLOUR
}
}
fn label_visibility(card: &Card) -> Visibility {
if card.face_up {
Visibility::Inherited
} else {
Visibility::Hidden
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin);
app.update();
app
}
#[test]
fn label_for_ace_of_hearts_is_ah() {
let c = Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
};
assert_eq!(label_for(&c), "AH");
}
#[test]
fn label_for_ten_of_clubs_is_10c() {
let c = Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::Ten,
face_up: true,
};
assert_eq!(label_for(&c), "10C");
}
#[test]
fn text_colour_is_red_for_hearts_and_diamonds() {
let h = Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
};
let d = Card {
id: 0,
suit: Suit::Diamonds,
rank: Rank::Ace,
face_up: true,
};
assert_eq!(text_colour(&h), RED_SUIT_COLOUR);
assert_eq!(text_colour(&d), RED_SUIT_COLOUR);
}
#[test]
fn text_colour_is_black_for_clubs_and_spades() {
let c = Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
};
let s = Card {
id: 0,
suit: Suit::Spades,
rank: Rank::Ace,
face_up: true,
};
assert_eq!(text_colour(&c), BLACK_SUIT_COLOUR);
assert_eq!(text_colour(&s), BLACK_SUIT_COLOUR);
}
#[test]
fn card_plugin_spawns_all_52_cards() {
let mut app = app();
let count = app
.world_mut()
.query::<&CardEntity>()
.iter(app.world())
.count();
assert_eq!(count, 52);
}
#[test]
fn tableau_face_down_cards_start_hidden_label() {
let mut app = app();
// Every tableau column except column 0 has face-down cards. Count
// CardLabels with Visibility::Hidden — should equal 0+1+2+3+4+5+6 = 21
// (every tableau card except the top of each column is face-down).
let hidden_count = app
.world_mut()
.query::<(&CardLabel, &Visibility)>()
.iter(app.world())
.filter(|(_, v)| matches!(v, Visibility::Hidden))
.count();
// 21 tableau face-down + 24 stock face-down = 45.
assert_eq!(hidden_count, 45);
}
#[test]
fn state_changed_event_triggers_resync() {
let mut app = app();
// Trigger a draw, which moves a card from stock to waste and should
// flip it face-up. Count visible labels after.
app.world_mut().send_event(crate::events::DrawRequestEvent);
app.update();
// Now 1 card in waste (face-up), 23 in stock (face-down). So 24
// hidden labels total in stock, plus 21 in tableau = 44.
let hidden_count = app
.world_mut()
.query::<(&CardLabel, &Visibility)>()
.iter(app.world())
.filter(|(_, v)| matches!(v, Visibility::Hidden))
.count();
assert_eq!(hidden_count, 44);
}
#[test]
fn card_positions_includes_all_52_cards() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout =
crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
let positions = card_positions(&g, &layout);
assert_eq!(positions.len(), 52);
}
#[test]
fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout =
crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
let positions = card_positions(&g, &layout);
// Collect positions for Tableau(6) (should have 7 cards).
let tableau_6_base = layout.pile_positions[&PileType::Tableau(6)];
let mut ys: Vec<f32> = positions
.iter()
.filter(|(_, pos, _)| (pos.x - tableau_6_base.x).abs() < 1e-3)
.map(|(_, pos, _)| pos.y)
.collect();
ys.sort_by(|a, b| b.partial_cmp(a).unwrap());
assert_eq!(ys.len(), 7);
// Every subsequent card should be strictly lower.
for w in ys.windows(2) {
assert!(w[0] > w[1]);
}
}
}
+8 -1
View File
@@ -12,6 +12,12 @@ use crate::events::{
};
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
/// System set for `GamePlugin`'s state-mutating systems. Downstream plugins
/// that read the resulting `StateChangedEvent` should schedule themselves
/// `.after(GameMutation)` so updates propagate within a single frame.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct GameMutation;
/// Registers game resources, events, and the systems that route user intent
/// (events) into mutations on `GameState`.
pub struct GamePlugin;
@@ -39,7 +45,8 @@ impl Plugin for GamePlugin {
handle_move,
handle_undo,
)
.chain(),
.chain()
.in_set(GameMutation),
);
}
}
+3 -1
View File
@@ -4,17 +4,19 @@
//! Additional plugins (`TablePlugin`, `CardPlugin`, `InputPlugin`,
//! `AnimationPlugin`, etc.) land in later sub-phases of Phase 3.
pub mod card_plugin;
pub mod events;
pub mod game_plugin;
pub mod layout;
pub mod resources;
pub mod table_plugin;
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
pub use events::{
CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent,
StateChangedEvent, UndoRequestEvent,
};
pub use game_plugin::GamePlugin;
pub use game_plugin::{GameMutation, GamePlugin};
pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
pub use table_plugin::{PileMarker, TableBackground, TablePlugin};