From f5e34ccece3627c09f4f75efb85720cafc24af7a Mon Sep 17 00:00:00 2001 From: Valentin Heiserer Date: Wed, 19 Nov 2025 02:22:34 +0100 Subject: [PATCH] initial commit --- .gitignore | 5 + Cargo.toml | 11 + Trunk.toml | 7 + assets/schell_sau.png | Bin 0 -> 10186 bytes assets/symbole.png | Bin 0 -> 2739 bytes index.html | 33 ++ src/main.rs | 1146 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1202 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 Trunk.toml create mode 100644 assets/schell_sau.png create mode 100644 assets/symbole.png create mode 100644 index.html create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa47946 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target +Cargo.lock +shell.nix +dist +generated_cards \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7b165e2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "schafkopf-game" +version = "0.1.0" +edition = "2024" + +[dependencies] +schafkopf-logic = "0.1.0" +bevy = { version = "0.17", features = ["png", "default_font"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.3", features = ["wasm_js"] } \ No newline at end of file diff --git a/Trunk.toml b/Trunk.toml new file mode 100644 index 0000000..eaf2280 --- /dev/null +++ b/Trunk.toml @@ -0,0 +1,7 @@ +[build] +dist = "dist" +release = true + +[serve] +open = false +port = 8080 diff --git a/assets/schell_sau.png b/assets/schell_sau.png new file mode 100644 index 0000000000000000000000000000000000000000..b24565b193c66d0a1b6a17907a671420b35a3401 GIT binary patch literal 10186 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 literal 0 HcmV?d00001 diff --git a/assets/symbole.png b/assets/symbole.png new file mode 100644 index 0000000000000000000000000000000000000000..b4012d7e737c3d4d633db2ee7945b46d370a8690 GIT binary patch literal 2739 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 t%0I`xh4l&YCn1gCnm2Qg$K&ZY{|7rTH??N + + + + + Schafkopf Logic + + + + + + + + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b3ea209 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1146 @@ +use bevy::{ + asset::{AssetMetaCheck, AssetPlugin, RenderAssetUsages}, + prelude::*, + render::render_resource::{Extent3d, TextureDimension, TextureFormat}, + window::{WindowPlugin, Window}, +}; +use bevy::ecs::relationship::Relationship; +use schafkopf_logic::{ + deck::{Card, Deck, Rank, Suit}, + gamemode::Gamemode, + player::{HumanPlayer, InternalPlayer}, +}; + + + +const CARD_TEXTURE_WIDTH: usize = 112; +const CARD_TEXTURE_HEIGHT: usize = 190; +const CARD_WORLD_SIZE: Vec2 = Vec2::new(CARD_TEXTURE_WIDTH as f32, CARD_TEXTURE_HEIGHT as f32); +const GLYPH_WIDTH: usize = 5; +const GLYPH_HEIGHT: usize = 7; +const GLYPH_STRIDE: usize = 6; +const SUIT_ICON_PX: usize = 32; +const ATLAS_COLS: usize = 2; +const ATLAS_ROWS: usize = 5; +const LABEL_MARGIN_X: usize = 14; +const LABEL_MARGIN_Y: usize = 8; +const LABEL_TEXT_GAP: usize = 4; + +#[derive(Resource)] +struct CurrentGamemode(Gamemode); + +// Resource to hold the currently clicked card label +#[derive(Resource, Default)] +struct ClickedLabel(pub Option); + +// Marker for the UI text that shows the clicked card name +#[derive(Component)] +struct ClickText; + +#[derive(Resource)] +struct SuitAtlas { + texture: Handle, + layout: Handle, +} + +#[derive(Resource)] +struct SauImage { + texture: Handle, +} + +impl SuitAtlas { + fn load( + asset_server: &AssetServer, + layouts: &mut Assets, + ) -> Self { + let texture: Handle = asset_server.load("symbole.png"); + let layout = TextureAtlasLayout::from_grid( + UVec2::splat(32), + ATLAS_COLS as u32, + ATLAS_ROWS as u32, + None, + None, + ); + let layout_handle = layouts.add(layout); + + Self { texture, layout: layout_handle } + } + + fn index_for(&self, suit: Suit) -> usize { + match suit { + Suit::Eichel => 0, + Suit::Gras => 1, + Suit::Herz => 2, + Suit::Schell => 3, + } + } +} + +#[derive(Resource)] +struct PlayerHandResource { + cards: Vec, +} + +// Marker for the base (non-atlas) sprite child under a card parent +#[derive(Component)] +struct BaseCardSprite; + +// Resource to track if cards have been saved +#[derive(Resource, Default)] +struct CardsSaved(bool); + +fn main() { + App::new() + .add_plugins( + DefaultPlugins + .set(WindowPlugin { + primary_window: Some(Window { + fit_canvas_to_parent: true, + ..default() + }), + ..default() + }) + .set(AssetPlugin { + meta_check: AssetMetaCheck::Never, + ..default() + }) + .set(ImagePlugin::default_nearest()) + ) + .init_resource::() + .add_systems(Startup, (setup_game, spawn_click_text)) + // Spawn the player hand once the atlas image is fully loaded + .add_systems(Update, (spawn_player_hand, save_all_cards)) + .add_systems(Update, update_click_text) + .run(); +} + +fn setup_game( + mut commands: Commands, + asset_server: Res, + mut texture_layouts: ResMut>, +) { + commands.spawn(Camera2d); + + let atlas = SuitAtlas::load(&asset_server, &mut texture_layouts); + commands.insert_resource(atlas); + + let sau_image = SauImage { + texture: asset_server.load("schell_sau.png"), + }; + commands.insert_resource(sau_image); + + let mut deck = Deck::new(); + deck.shuffle(); + let [mut hand1, hand2, hand3, hand4] = + deck.deal_4x8().expect("expected a full deck to deal four hands"); + + sort_cards(&mut hand1); + + let mut p1 = HumanPlayer::new(1, "Alice"); + let mut p2 = HumanPlayer::new(2, "Bob"); + let mut p3 = HumanPlayer::new(3, "Clara"); + let mut p4 = HumanPlayer::new(4, "Max"); + + p1.set_hand(hand1); + p2.set_hand(hand2); + p3.set_hand(hand3); + p4.set_hand(hand4); + + let mode = Gamemode::Wenz(None); + commands.insert_resource(CurrentGamemode(mode)); + + commands.insert_resource(ClickedLabel::default()); + + let mut demo_cards = vec![ + Card { suit: Suit::Eichel, rank: Rank::Sieben }, + Card { suit: Suit::Gras, rank: Rank::Sieben }, + Card { suit: Suit::Eichel, rank: Rank::Acht }, + Card { suit: Suit::Gras, rank: Rank::Acht }, + Card { suit: Suit::Eichel, rank: Rank::Neun }, + Card { suit: Suit::Gras, rank: Rank::Neun }, + Card { suit: Suit::Eichel, rank: Rank::Zehn }, + Card { suit: Suit::Gras, rank: Rank::Zehn }, + Card { suit: Suit::Schell, rank: Rank::Zehn }, + Card { suit: Suit::Herz, rank: Rank::Zehn }, + ]; + sort_cards(&mut demo_cards); + + //commands.insert_resource(PlayerHandResource { cards: demo_cards }); + + commands.insert_resource(PlayerHandResource { + cards: p1.hand().clone(), + }); +} + +fn spawn_click_text(mut commands: Commands, _asset_server: Res) { + commands.spawn(( + Text::new("click a card"), + TextFont { + font_size: 22.0, + ..default() + }, + TextLayout::new_with_justify(Justify::Left), + Node { + position_type: PositionType::Absolute, + top: px(5), + left: px(5), + ..default() + }, + ClickText, + )); +} + +fn save_all_cards( + mut images: ResMut>, + atlas: Option>, + sau_image: Option>, + mut cards_saved: ResMut, +) { + use std::fs; + + // Skip if already saved + if cards_saved.0 { + return; + } + + // Check if atlas resource exists + let Some(atlas) = atlas else { + return; + }; + + // Check if sau_image resource exists + let Some(sau_image) = sau_image else { + return; + }; + + // Wait for atlas to load + if images.get(&atlas.texture).and_then(|img| img.data.as_ref()).is_none() { + return; + } + + // Wait for sau image to load + if images.get(&sau_image.texture).and_then(|img| img.data.as_ref()).is_none() { + return; + } + + // Mark as saved to prevent running again + cards_saved.0 = true; + + // Create output directory + let _ = fs::create_dir_all("generated_cards"); + + // Generate all 32 cards + let suits = [Suit::Eichel, Suit::Gras, Suit::Herz, Suit::Schell]; + let ranks = [ + Rank::Ass, Rank::Zehn, Rank::Koenig, Rank::Ober, + Rank::Unter, Rank::Neun, Rank::Acht, Rank::Sieben, + ]; + + for suit in &suits { + for rank in &ranks { + let card = Card { suit: *suit, rank: *rank }; + let image_handle = create_card_texture(&mut images, &atlas, &sau_image, &card); + + if let Some(image) = images.get(&image_handle) { + let filename = format!("generated_cards/{}_{}.png", + format!("{:?}", suit).to_lowercase(), + format!("{:?}", rank).to_lowercase() + ); + + // Save using Bevy's DynamicImage + if let Ok(dynamic_image) = image.clone().try_into_dynamic() { + if let Err(e) = dynamic_image.save(&filename) { + eprintln!("Failed to save {}: {}", filename, e); + } else { + println!("Saved {}", filename); + } + } + } + } + } + + println!("All cards saved to generated_cards/ directory"); +} + +fn spawn_player_hand( + mut commands: Commands, + mut images: ResMut>, + atlas: Res, + sau_image: Res, + hand: Res, + q_existing: Query<(), With>, // guard to spawn once +) { + // Only proceed once the atlas image is loaded and has CPU-side pixel data + if images.get(&atlas.texture).and_then(|img| img.data.as_ref()).is_none() { + return; + } + + // Prevent spawning multiple times (system runs every frame) + if !q_existing.is_empty() { + return; + } + let spacing = CARD_WORLD_SIZE.x + 5.0; + let start_x = -(spacing * (hand.cards.len() as f32 - 1.0) / 2.0); + let y = -200.0; + + for (i, card) in hand.cards.iter().enumerate() { + let base_handle = create_card_texture(&mut images, &atlas, &sau_image, card); + + let parent = commands + .spawn(Transform::from_xyz(start_x + i as f32 * spacing, y, 0.0)) + .observe(on_hover()) + .observe(on_unhover()) + .id(); + + commands.entity(parent).with_children(|c| { + c.spawn(( + Sprite { + image: base_handle, + custom_size: Some(CARD_WORLD_SIZE), + ..default() + }, + Transform::from_xyz(0.0, 0.0, 0.0), + Pickable::default(), + BaseCardSprite, + )) + .observe(on_click_select(*card)); + }); + } +} +fn sort_cards(cards: &mut Vec) { + cards.sort_by(|a, b| a.suit.cmp(&b.suit).then(a.rank.cmp(&b.rank))); +} + + +fn create_card_texture(images: &mut Assets, atlas: &SuitAtlas, sau_image: &SauImage, card: &Card) -> Handle { + let mut pixels = vec![0u8; CARD_TEXTURE_WIDTH * CARD_TEXTURE_HEIGHT * 4]; + let top_h = CARD_TEXTURE_HEIGHT / 2; + let border_gap = 9; + let card_radius = 10; + + // Initialize with transparent background + let transparent = [0, 0, 0, 0]; + for chunk in pixels.chunks_exact_mut(4) { + chunk.copy_from_slice(&transparent); + } + + let background = [255, 255, 255, 255]; + draw_rounded_rect_filled( + &mut pixels, + 0, + 0, + CARD_TEXTURE_WIDTH, + top_h, + card_radius, + background, + ); + + // Blit a suit/rank pattern (pips) from the atlas into the base texture + if let Some(atlas_img) = images.get(&atlas.texture) { + let dest_x = (CARD_TEXTURE_WIDTH.saturating_sub(SUIT_ICON_PX)) / 2; + let dest_y = top_h; + + // Build the pip layout for this card and blit it in one go + let pattern = pip_layout(card, dest_x as i32, dest_y as i32); + blit_pattern( + &mut pixels, + CARD_TEXTURE_WIDTH, + CARD_TEXTURE_HEIGHT, + atlas_img, + &pattern, + ); + } + + // Blit Sau image at the bottom for Ass cards + if card.rank == Rank::Ass && card.suit == Suit::Schell { + if let Some(sau_img) = images.get(&sau_image.texture) { + let sau_width = 94; + let sau_height = 86; + // Center horizontally, position at bottom of top half + let sau_x = (CARD_TEXTURE_WIDTH - sau_width) / 2; + let sau_y = top_h - sau_height; + blit_image(&mut pixels, CARD_TEXTURE_WIDTH, CARD_TEXTURE_HEIGHT, sau_img, sau_x, sau_y); + } + } + + // Draw a rounded rectangle border in the TOP half with 9px gap from card edge and 6px radius. + // This will be mirrored to the bottom half later. + let border_color = [45, 45, 45, 255]; + + let radius = card_radius - border_gap + 2; + + draw_rounded_rect_border( + &mut pixels, + border_gap, + border_gap, + CARD_TEXTURE_WIDTH - border_gap, + top_h, + radius, + border_color, + ); + + let rank_text = rank_label(card.rank); + + let ink = [15, 15, 15, 255]; + let white_bg = [255, 255, 255, 255]; + let rank_text_len = rank_text.chars().count(); + + // Draw rank labels centered on the vertical border with white background + let text_width = rank_text_len * GLYPH_STRIDE - 1; // -1 because last char has no trailing space + let bg_padding = 1; // padding used in draw_text_with_bg + let total_box_width = text_width + 2 * bg_padding; + let vertical_padding = 5; // padding from the corner vertically + + // Top-left corner label - centered on the left border line + let left_border_x = border_gap; + let label_x_left = left_border_x - (total_box_width / 2) + bg_padding; + let label_y = border_gap + radius + vertical_padding; + draw_text_with_bg(&mut pixels, label_x_left, label_y, rank_text, ink, white_bg); + + // Top-right corner label - centered on the right border line + let right_border_x = CARD_TEXTURE_WIDTH - border_gap - 1; + let label_x_right = right_border_x - (total_box_width / 2) + bg_padding; + draw_text_with_bg(&mut pixels, label_x_right, label_y, rank_text, ink, white_bg); + + // Mirror the entire top half of the card to the bottom half (rotated 180°) + mirror_top_half_to_bottom(&mut pixels, CARD_TEXTURE_WIDTH, CARD_TEXTURE_HEIGHT); + + // No outer border needed - the card has rounded corners with transparency outside + + let extent = Extent3d { + width: CARD_TEXTURE_WIDTH as u32, + height: CARD_TEXTURE_HEIGHT as u32, + depth_or_array_layers: 1, + }; + + let image = Image::new_fill( + extent, + TextureDimension::D2, + &pixels, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::default(), + ); + + images.add(image) +} + +// Describes a single blit operation from the atlas into the card texture. +// x,y are absolute pixel coordinates for the top-left of the 32x32 icon within the card texture. +// rotation: 0=0°, 1=90° CW, 2=180°, 3=270° CW +#[derive(Clone, Copy, Debug)] +struct AtlasBlit { + index: usize, + x: i32, + y: i32, + rotation: u8, // 0, 1, 2, or 3 for 0°, 90°, 180°, 270° +} + +// Apply a list of AtlasBlit operations in order. +fn blit_pattern( + dest_pixels: &mut [u8], + dest_w: usize, + dest_h: usize, + atlas_img: &Image, + pattern: &[AtlasBlit], +) { + for op in pattern { + // Skip negative origins; cast to usize only when non-negative + if op.x < 0 || op.y < 0 { continue; } + blit_atlas_icon_top_left( + dest_pixels, + dest_w, + dest_h, + atlas_img, + op.index, + op.x as usize, + op.y as usize, + op.rotation, + ); + } +} + +fn suit_to_atlas_index(suit: Suit) -> usize { + match suit { + Suit::Eichel => 0, + Suit::Gras => 1, + Suit::Herz => 2, + Suit::Schell => 3, + } +} + +fn pip_layout(card: &Card, dest_x: i32, dest_y: i32) -> Vec { + use Rank::*; + use Suit::*; + + let HORIZONTAL_SPACING = 29; + let CROWN_SPACING = 94; + + match (card.suit, card.rank) { + (Herz, Neun) => vec![ + AtlasBlit { index: 4, x: dest_x, y: dest_y - 46, rotation: 0 }, + AtlasBlit { index: 5, x: dest_x, y: dest_y - 65, rotation: 0 }, + AtlasBlit { index: 4, x: dest_x, y: dest_y - 84, rotation: 0 }, + + AtlasBlit { index: 2, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 34, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 53, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 72, rotation: 0 }, + + AtlasBlit { index: 2, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 34, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 53, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 72, rotation: 0 }, + ], + (Herz, Acht) => vec![ + AtlasBlit { index: 2, x: dest_x - HORIZONTAL_SPACING + 3, y: dest_y - 34, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x - HORIZONTAL_SPACING + 3, y: dest_y - 50, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x - HORIZONTAL_SPACING + 3, y: dest_y - 66, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x - HORIZONTAL_SPACING + 3, y: dest_y - 82, rotation: 0 }, + + AtlasBlit { index: 2, x: dest_x + HORIZONTAL_SPACING - 3, y: dest_y - 34, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x + HORIZONTAL_SPACING - 3, y: dest_y - 50, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x + HORIZONTAL_SPACING - 3, y: dest_y - 66, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x + HORIZONTAL_SPACING - 3, y: dest_y - 82, rotation: 0 }, + ], + (Herz, Sieben) => vec![ + AtlasBlit { index: 2, x: dest_x, y: dest_y - 84, rotation: 0 }, + + AtlasBlit { index: 2, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 34, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 53, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 72, rotation: 0 }, + + AtlasBlit { index: 2, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 34, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 53, rotation: 0 }, + AtlasBlit { index: 2, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 72, rotation: 0 }, + ], + (Schell, Neun) => vec![ + AtlasBlit { index: 7, x: dest_x, y: dest_y - 46, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x, y: dest_y - 46, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x, y: dest_y - 65, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x, y: dest_y - 84, rotation: 0 }, + + AtlasBlit { index: 7, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 36, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 36, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 54, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 70, rotation: 0 }, + + AtlasBlit { index: 7, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 36, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 36, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 54, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 70, rotation: 0 }, + ], + (Schell, Acht) => vec![ + AtlasBlit { index: 7, x: dest_x - HORIZONTAL_SPACING + 3, y: dest_y - 34, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x - HORIZONTAL_SPACING + 3, y: dest_y - 34, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x - HORIZONTAL_SPACING + 3, y: dest_y - 50, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x - HORIZONTAL_SPACING + 3, y: dest_y - 66, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x - HORIZONTAL_SPACING + 3, y: dest_y - 82, rotation: 0 }, + + AtlasBlit { index: 7, x: dest_x + HORIZONTAL_SPACING - 3, y: dest_y - 34, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x + HORIZONTAL_SPACING - 3, y: dest_y - 34, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x + HORIZONTAL_SPACING - 3, y: dest_y - 50, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x + HORIZONTAL_SPACING - 3, y: dest_y - 66, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x + HORIZONTAL_SPACING - 3, y: dest_y - 82, rotation: 0 }, + ], + (Schell, Sieben) => vec![ + AtlasBlit { index: 7, x: dest_x, y: dest_y - 84, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x, y: dest_y - 84, rotation: 0 }, + + AtlasBlit { index: 7, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 36, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 36, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 54, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x - HORIZONTAL_SPACING, y: dest_y - 70, rotation: 0 }, + + AtlasBlit { index: 7, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 36, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 36, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 54, rotation: 0 }, + AtlasBlit { index: 3, x: dest_x + HORIZONTAL_SPACING, y: dest_y - 70, rotation: 0 }, + ], + (Schell, Zehn) | (Herz, Zehn) => { + let suit = card.suit; + let id = suit_to_atlas_index(suit); + let mut blits = Vec::new(); + + let mut spacing = 16; + + // Right column + for i in 0..3 { + blits.push(AtlasBlit { + index: id, + x: dest_x + HORIZONTAL_SPACING, + y: dest_y - 36 - (i * spacing), + rotation: 0, + }); + + // Schell decor + if (i==0 && suit==Schell) { + blits.push(AtlasBlit { + index: 7, + x: dest_x + HORIZONTAL_SPACING, + y: dest_y - 36 - (i * spacing), + rotation: 0, + }); + } + } + + // Left column + for i in 0..3 { + blits.push(AtlasBlit { + index: id, + x: dest_x - HORIZONTAL_SPACING, + y: dest_y - 36 - (i * spacing), + rotation: 0, + }); + + // Schell decor + if (i==0 && suit==Schell) { + blits.push(AtlasBlit { + index: 7, + x: dest_x - HORIZONTAL_SPACING, + y: dest_y - 36 - (i * spacing), + rotation: 0, + }); + } + } + + let center_setoff = 29; + + // Center column + if suit == Schell { + // Schell pattern with decorative overlay on first pip + blits.push(AtlasBlit { index: 7, x: dest_x, y: dest_y - center_setoff, rotation: 0 }); + for i in 0..4 { + blits.push(AtlasBlit { + index: 3, + x: dest_x, + y: dest_y - center_setoff - (i * spacing), + rotation: 0, + }); + } + } else { + // Herz pattern (alternating heart types) + let indices = [5, 4, 5, 4]; + for i in 0..4 { + blits.push(AtlasBlit { + index: indices[i], + x: dest_x, + y: dest_y - 28 - (i as i32 * spacing), + rotation: 0, + }); + } + } + + + // Center bottom crown + blits.push(AtlasBlit { index: 6, x: dest_x, y: dest_y - CROWN_SPACING, rotation: 0 }); + + blits + }, + (Gras, Acht) | (Eichel, Acht) => { + let suit = card.suit; + let id = suit_to_atlas_index(suit); + let mut blits = Vec::new(); + + let spacing = if suit == Eichel { 17 } else { 16 }; + + // Right column + for i in 0..4 { + blits.push(AtlasBlit { + index: id, + x: dest_x + HORIZONTAL_SPACING, + y: dest_y - 32 - (i * spacing), + rotation: 3, + }); + } + + // Left column + for i in 0..4 { + blits.push(AtlasBlit { + index: id, + x: dest_x - HORIZONTAL_SPACING, + y: dest_y - 32 - (i * spacing), + rotation: 1, + }); + } + + blits + }, + (Gras, Zehn) | (Eichel, Zehn) => { + let suit = card.suit; + let id = suit_to_atlas_index(suit); + let mut blits = Vec::new(); + + let mut spacing = 13; + if suit == Eichel { + spacing = 14; + } + + // Right column + for i in 0..5 { + blits.push(AtlasBlit { + index: id, + x: dest_x + HORIZONTAL_SPACING, + y: dest_y - 32 - (i * spacing), + rotation: 3, + }); + } + + // Left column + for i in 0..5 { + blits.push(AtlasBlit { + index: id, + x: dest_x - HORIZONTAL_SPACING, + y: dest_y - 32 - (i * spacing), + rotation: 1, + }); + } + + // Center bottom crown + blits.push(AtlasBlit { index: 6, x: dest_x, y: dest_y - CROWN_SPACING, rotation: 0 }); + + blits + }, + (_, Koenig) | (_, Ober) => { + let id = suit_to_atlas_index(card.suit); + vec![ + AtlasBlit { index: id, x: dest_x - 28, y: dest_y - 82, rotation: 0 }, + ] + }, + (_, Unter) => { + let id = suit_to_atlas_index(card.suit); + vec![ + AtlasBlit { index: id, x: dest_x - 28, y: dest_y - 34, rotation: 0 }, + ] + }, + (Schell, Ass) | (Herz, Ass) => { + let id = if card.suit == Schell { 8 } else { 9 }; + vec![ + AtlasBlit { index: id, x: dest_x + 26, y: dest_y - 82, rotation: 0 }, + AtlasBlit { index: id, x: dest_x - 26, y: dest_y - 82, rotation: 1 }, + ] + }, + (Gras, Ass) | (Eichel, Ass) => { + let id = suit_to_atlas_index(card.suit); + vec![ + AtlasBlit { index: id, x: dest_x + 26, y: dest_y - 82, rotation: 3 }, + AtlasBlit { index: id, x: dest_x - 26, y: dest_y - 82, rotation: 1 }, + ] + }, + (_, _) => { + let id = suit_to_atlas_index(card.suit); + vec![ + AtlasBlit { index: id, x: dest_x, y: dest_y - 32, rotation: 0 }, + ] + }, + } +} + +fn blit_atlas_icon_top_left( + dest_pixels: &mut [u8], + dest_w: usize, + dest_h: usize, + atlas_img: &Image, + index: usize, + dest_x: usize, + dest_y: usize, + rotation: u8, +) { + // Compute source top-left in the atlas based on index and known grid + let col = index % ATLAS_COLS; + let row = index / ATLAS_COLS; + let src_x = col * SUIT_ICON_PX; + let src_y = row * SUIT_ICON_PX; + + let atlas_w = SUIT_ICON_PX * ATLAS_COLS; + let atlas_h = SUIT_ICON_PX * ATLAS_ROWS; + + // Clip to destination bounds + let max_w = SUIT_ICON_PX.min(dest_w.saturating_sub(dest_x)); + let max_h = SUIT_ICON_PX.min(dest_h.saturating_sub(dest_y)); + + if let Some(ref data) = atlas_img.data { + for y in 0..max_h { + for x in 0..max_w { + // Apply rotation transformation to get source coordinates + let (sx_offset, sy_offset) = match rotation { + 0 => (x, y), // 0°: no rotation + 1 => (SUIT_ICON_PX - 1 - y, x), // 90° CW + 2 => (SUIT_ICON_PX - 1 - x, SUIT_ICON_PX - 1 - y), // 180° + 3 => (y, SUIT_ICON_PX - 1 - x), // 270° CW (or 90° CCW) + _ => (x, y), // fallback + }; + + let sx = src_x + sx_offset; + let sy = src_y + sy_offset; + + if sx >= atlas_w || sy >= atlas_h { + continue; + } + + let dx = dest_x + x; + let dy = dest_y + y; + + let s_index = (sy * atlas_w + sx) * 4; + let d_index = (dy * dest_w + dx) * 4; + + if s_index + 4 <= data.len() && d_index + 4 <= dest_pixels.len() { + let a = data[s_index + 3]; + if a == 0 { continue; } + dest_pixels[d_index + 0] = data[s_index + 0]; + dest_pixels[d_index + 1] = data[s_index + 1]; + dest_pixels[d_index + 2] = data[s_index + 2]; + dest_pixels[d_index + 3] = 255; // opaque result + } + } + } + } +} + +// Blit a full image onto the card texture at the specified position +fn blit_image( + dest_pixels: &mut [u8], + dest_w: usize, + dest_h: usize, + src_img: &Image, + dest_x: usize, + dest_y: usize, +) { + if let Some(ref data) = src_img.data { + let src_w = src_img.width() as usize; + let src_h = src_img.height() as usize; + + for y in 0..src_h { + for x in 0..src_w { + let dx = dest_x + x; + let dy = dest_y + y; + + // Skip if out of bounds + if dx >= dest_w || dy >= dest_h { + continue; + } + + let s_index = (y * src_w + x) * 4; + let d_index = (dy * dest_w + dx) * 4; + + if s_index + 4 <= data.len() && d_index + 4 <= dest_pixels.len() { + let a = data[s_index + 3]; + if a == 0 { continue; } // Skip transparent pixels + + dest_pixels[d_index + 0] = data[s_index + 0]; + dest_pixels[d_index + 1] = data[s_index + 1]; + dest_pixels[d_index + 2] = data[s_index + 2]; + dest_pixels[d_index + 3] = 255; // opaque result + } + } + } + } +} + +fn draw_rounded_rect_filled( + pixels: &mut [u8], + x1: usize, + y1: usize, + x2: usize, + y2: usize, + radius: usize, + color: [u8; 4], +) { + let r = radius as i32; + + for y in y1..y2 { + for x in x1..x2 { + let mut inside = false; + + // Check if we're in the main rectangle body (not in TOP corner regions) + if x >= x1 + radius && x < x2.saturating_sub(radius) { + // Middle horizontal section - always inside + inside = true; + } else if y >= y1 + radius { + // Below the top corners - always inside (full width) + inside = true; + } else { + // We're in a TOP corner region - check distance from corner center + let (cx, cy) = if x < x1 + radius { + // Top-left corner + (x1 + radius, y1 + radius) + } else { + // Top-right corner + (x2.saturating_sub(radius + 1), y1 + radius) + }; + + let dx = x as i32 - cx as i32; + let dy = y as i32 - cy as i32; + let dist_sq = dx * dx + dy * dy; + + if dist_sq <= r * r { + inside = true; + } + } + + if inside { + set_pixel(pixels, x, y, color); + } + } + } +} + +fn draw_rounded_rect_border( + pixels: &mut [u8], + x1: usize, + y1: usize, + x2: usize, + y2: usize, + radius: usize, + color: [u8; 4], +) { + // Draw top horizontal line (excluding corners) + for x in (x1 + radius)..(x2.saturating_sub(radius)) { + set_pixel(pixels, x, y1, color); + } + + // NO bottom horizontal line - it will be mirrored from the top + + // Draw left vertical line (excluding top corner, extending to bottom edge) + for y in (y1 + radius)..y2 { + set_pixel(pixels, x1, y, color); + } + + // Draw right vertical line (excluding top corner, extending to bottom edge) + for y in (y1 + radius)..y2 { + set_pixel(pixels, x2.saturating_sub(1), y, color); + } + + // Draw only the two top rounded corners + draw_rounded_corner(pixels, x1 + radius, y1 + radius, radius, color, 0); // top-left + draw_rounded_corner(pixels, x2.saturating_sub(radius + 1), y1 + radius, radius, color, 1); // top-right +} + +fn draw_rounded_corner( + pixels: &mut [u8], + cx: usize, + cy: usize, + radius: usize, + color: [u8; 4], + quadrant: u8, +) { + let r = radius as i32; + for dy in -r..=r { + for dx in -r..=r { + let dist_sq = dx * dx + dy * dy; + let inner_r_sq = (r - 1) * (r - 1); + let outer_r_sq = r * r; + + // Only draw pixels on the circle edge (border) + if dist_sq >= inner_r_sq && dist_sq <= outer_r_sq { + let draw = match quadrant { + 0 => dx <= 0 && dy <= 0, // top-left + 1 => dx >= 0 && dy <= 0, // top-right + 2 => dx <= 0 && dy >= 0, // bottom-left + 3 => dx >= 0 && dy >= 0, // bottom-right + _ => false, + }; + + if draw { + let px = (cx as i32 + dx) as usize; + let py = (cy as i32 + dy) as usize; + set_pixel(pixels, px, py, color); + } + } + } + } +} + +fn draw_text(pixels: &mut [u8], start_x: usize, start_y: usize, text: &str, color: [u8; 4]) { + let mut x = start_x; + for ch in text.chars() { + if let Some(bitmap) = glyph_bitmap(ch) { + draw_glyph(pixels, x, start_y, bitmap, color); + } + x += GLYPH_STRIDE; + } +} + +fn draw_text_with_bg( + pixels: &mut [u8], + start_x: usize, + start_y: usize, + text: &str, + fg_color: [u8; 4], + bg_color: [u8; 4], +) { + let text_len = text.chars().count(); + let text_width = if text_len > 0 { text_len * GLYPH_STRIDE - 1 } else { 0 }; + let padding = 1; + + // Draw white background rectangle with padding + let bg_x = start_x.saturating_sub(padding); + let bg_y = start_y.saturating_sub(padding); + let bg_width = text_width + 2 * padding; + let bg_height = GLYPH_HEIGHT + 2 * padding; + + for y in bg_y..(bg_y + bg_height) { + for x in bg_x..(bg_x + bg_width) { + set_pixel(pixels, x, y, bg_color); + } + } + + // Draw the text on top + draw_text(pixels, start_x, start_y, text, fg_color); +} + +fn draw_glyph(pixels: &mut [u8], start_x: usize, start_y: usize, glyph: [u8; 7], color: [u8; 4]) { + for (row, pattern) in glyph.iter().enumerate() { + for col in 0..5 { + if (pattern >> (4 - col)) & 1 == 1 { + // Draw the pixel and one to the right for bold effect + set_pixel(pixels, start_x + col, start_y + row, color); + set_pixel(pixels, start_x + col + 1, start_y + row, color); + } + } + } +} + +fn set_pixel(pixels: &mut [u8], x: usize, y: usize, color: [u8; 4]) { + if x >= CARD_TEXTURE_WIDTH || y >= CARD_TEXTURE_HEIGHT { + return; + } + let index = (y * CARD_TEXTURE_WIDTH + x) * 4; + pixels[index..index + 4].copy_from_slice(&color); +} + +// Copy the top half of the image to the bottom half with a 180° rotation. +// For each pixel (x, y) in the top half, write it to (W-1-x, H-1-y). +fn mirror_top_half_to_bottom(pixels: &mut [u8], width: usize, height: usize) { + let half_h = height / 2; + for y in 0..half_h { + for x in 0..width { + let src_index = (y * width + x) * 4; + let mx = width - 1 - x; + let my = height - 1 - y; + let dst_index = (my * width + mx) * 4; + + // Read first to avoid overlapping mutable/immutable borrows + let rgba = [ + pixels[src_index], + pixels[src_index + 1], + pixels[src_index + 2], + pixels[src_index + 3], + ]; + pixels[dst_index] = rgba[0]; + pixels[dst_index + 1] = rgba[1]; + pixels[dst_index + 2] = rgba[2]; + pixels[dst_index + 3] = rgba[3]; + } + } +} + +fn glyph_bitmap(ch: char) -> Option<[u8; 7]> { + match ch { + '0' => Some([0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110]), + '1' => Some([0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110]), + '7' => Some([0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000]), + '8' => Some([0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110]), + '9' => Some([0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b11100]), + 'A' => Some([0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001]), + 'K' => Some([0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001]), + 'O' => Some([0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110]), + 'U' => Some([0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110]), + _ => None, + } +} + +fn rank_label(rank: Rank) -> &'static str { + match rank { + Rank::Ass => "A", + Rank::Zehn => "10", + Rank::Koenig => "K", + Rank::Ober => "O", + Rank::Unter => "U", + Rank::Neun => "9", + Rank::Acht => "8", + Rank::Sieben => "7", + } +} + +fn on_hover( +) -> impl Fn( + On>, + Query<&mut Transform>, + Query<&Children>, + Query<(&mut Sprite, Option<&BaseCardSprite>)>, + Query<&ChildOf>, +) +{ + move |ev, mut q_transform, q_children, mut q_sprite, q_parent| { + // Determine the card parent entity from the event target + let mut parent_entity = ev.event_target(); + if let Ok(parent) = q_parent.get(parent_entity) { + parent_entity = parent.get(); + } + + // Scale the parent + if let Ok(mut transform) = q_transform.get_mut(parent_entity) { + transform.scale = Vec3::splat(1.1); + } + + // Tint only the base sprite child (marked with BaseCardSprite) + if let Ok(children) = q_children.get(parent_entity) { + for child in children.iter() { + if let Ok((mut sprite, maybe_base)) = q_sprite.get_mut(child) { + if maybe_base.is_some() { + sprite.color = Color::srgb(0.6, 0.6, 0.6); + } + } + } + } + } +} + +fn on_unhover( +) -> impl Fn( + On>, + Query<&mut Transform>, + Query<&Children>, + Query<(&mut Sprite, Option<&BaseCardSprite>)>, + Query<&ChildOf>, +) +{ + move |ev, mut q_transform, q_children, mut q_sprite, q_parent| { + // Determine the card parent entity from the event target + let mut parent_entity = ev.event_target(); + if let Ok(parent) = q_parent.get(parent_entity) { + parent_entity = parent.get(); + } + + // Reset parent scale + if let Ok(mut transform) = q_transform.get_mut(parent_entity) { + transform.scale = Vec3::ONE; + } + + // Reset tint on the base sprite child + if let Ok(children) = q_children.get(parent_entity) { + for child in children.iter() { + if let Ok((mut sprite, maybe_base)) = q_sprite.get_mut(child) { + if maybe_base.is_some() { + sprite.color = Color::WHITE; + } + } + } + } + } +} + +fn on_click_select(card: Card) -> impl Fn(On>, ResMut) { + move |_, mut clicked| { + println!("Clicked on card: {:?}", card); + clicked.0 = Some(format!("{} {}", card.suit, card.rank)); + } +} + +fn update_click_text(mut q: Query<&mut Text, With>, clicked: Res) { + if let Some(mut text) = q.iter_mut().next() { + if let Some(label) = &clicked.0 { + *text = Text::new(label.clone()); + } else { + *text = Text::new("click a card"); + } + } +} \ No newline at end of file