From 355e8754a883c896591b809dad392341b6148d8f Mon Sep 17 00:00:00 2001 From: Valentin Heiserer Date: Mon, 3 Nov 2025 20:37:03 +0100 Subject: [PATCH] implement basic card rendering --- .github/workflows/deploy-pages.yml | 8 +- schafkopf-logic/assets/symbole.png | Bin 1368 -> 2739 bytes schafkopf-logic/src/main.rs | 727 +++++++++++++++++++++++++---- 3 files changed, 654 insertions(+), 81 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 8923fe4..2cd7a79 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -17,19 +17,23 @@ concurrency: jobs: build: - name: Build (trunk -> dist) + name: Build WebAssembly runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust (stable + wasm32) + - name: Install Rust uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown - name: Cache cargo uses: Swatinem/rust-cache@v2 + with: + workspaces: schafkopf-logic + cache-directories: | + ~/.cache/trunk - name: Install trunk run: cargo install trunk --locked diff --git a/schafkopf-logic/assets/symbole.png b/schafkopf-logic/assets/symbole.png index dc90e812e07dba1fa7347b47af83f2e7cfc5d23a..b4012d7e737c3d4d633db2ee7945b46d370a8690 100644 GIT binary patch literal 2739 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, ) -> Self { let texture: Handle = asset_server.load("symbole.png"); - let layout = TextureAtlasLayout::from_grid(UVec2::splat(32), 2, 2, None, None); + 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 } @@ -69,12 +75,6 @@ struct PlayerHandResource { cards: Vec, } -#[derive(Component)] -struct PlayerCardVisual { - card: Card, - index: usize, -} - // Marker for the base (non-atlas) sprite child under a card parent #[derive(Component)] struct BaseCardSprite; @@ -89,7 +89,8 @@ fn main() { } ).set(ImagePlugin::default_nearest())) .add_systems(Startup, (setup_game, spawn_click_text)) - .add_systems(PostStartup, spawn_player_hand) + // Spawn the player hand once the atlas image is fully loaded + .add_systems(Update, spawn_player_hand) .add_systems(Update, update_click_text) .run(); } @@ -126,9 +127,25 @@ fn setup_game( 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) { @@ -154,13 +171,23 @@ fn spawn_player_hand( mut images: ResMut>, atlas: 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, card); + let base_handle = create_card_texture(&mut images, &atlas, card); let parent = commands .spawn(Transform::from_xyz(start_x + i as f32 * spacing, y, 0.0)) @@ -180,28 +207,6 @@ fn spawn_player_hand( BaseCardSprite, )) .observe(on_click_select(*card)); - - c.spawn(( - Sprite::from_atlas_image( - atlas.texture.clone(), - TextureAtlas { - layout: atlas.layout.clone(), - index: atlas.index_for(card.suit), - }, - ), - Transform::from_xyz(ICON_OFFSET_TL.x, ICON_OFFSET_TL.y, 0.1), // on top - )); - - c.spawn(( - Sprite::from_atlas_image( - atlas.texture.clone(), - TextureAtlas { - layout: atlas.layout.clone(), - index: atlas.index_for(card.suit), - }, - ), - Transform::from_xyz(ICON_OFFSET_BR.x, ICON_OFFSET_BR.y, 0.1), - )); }); } } @@ -210,34 +215,88 @@ fn sort_cards(cards: &mut Vec) { } -fn create_card_texture(images: &mut Assets, card: &Card) -> Handle { +fn create_card_texture(images: &mut Assets, atlas: &SuitAtlas, 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; - let background = suit_background(card.suit); + // Initialize with transparent background + let transparent = [0, 0, 0, 0]; for chunk in pixels.chunks_exact_mut(4) { - chunk.copy_from_slice(&background); + chunk.copy_from_slice(&transparent); } - draw_border(&mut pixels, [45, 45, 45, 255]); + 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, + ); + } + + // 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(); - let rank_text_width = if rank_text_len == 0 { - 0 - } else { - (rank_text_len - 1) * GLYPH_STRIDE + GLYPH_WIDTH - }; - let top_label_x = LABEL_MARGIN_X; - let top_label_y = LABEL_MARGIN_Y + SUIT_ICON_PX + LABEL_TEXT_GAP; - let bottom_label_x = CARD_TEXTURE_WIDTH.saturating_sub(LABEL_MARGIN_X + rank_text_width); - let bottom_label_y = CARD_TEXTURE_HEIGHT - .saturating_sub(LABEL_MARGIN_Y + SUIT_ICON_PX + LABEL_TEXT_GAP + GLYPH_HEIGHT); + // 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); - draw_text(&mut pixels, top_label_x, top_label_y, rank_text, ink); - draw_text(&mut pixels, bottom_label_x, bottom_label_y, rank_text, ink); + // 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, @@ -256,15 +315,487 @@ fn create_card_texture(images: &mut Assets, card: &Card) -> Handle images.add(image) } -fn draw_border(pixels: &mut [u8], color: [u8; 4]) { - for x in 0..CARD_TEXTURE_WIDTH { - set_pixel(pixels, x, 0, color); - set_pixel(pixels, x, CARD_TEXTURE_HEIGHT - 1, color); +// 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 + } + } + } + } +} + +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); } - for y in 0..CARD_TEXTURE_HEIGHT { - set_pixel(pixels, 0, y, color); - set_pixel(pixels, CARD_TEXTURE_WIDTH - 1, y, 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); + } + } + } } } @@ -278,11 +809,41 @@ fn draw_text(pixels: &mut [u8], start_x: usize, start_y: usize, text: &str, colo } } +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); } } } @@ -296,9 +857,35 @@ fn set_pixel(pixels: &mut [u8], x: usize, y: usize, color: [u8; 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, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110]), + '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]), @@ -324,24 +911,6 @@ fn rank_label(rank: Rank) -> &'static str { } } -fn suit_background(suit: Suit) -> [u8; 4] { - match suit { - Suit::Eichel => [245, 235, 220, 255], - Suit::Gras => [225, 245, 225, 255], - Suit::Herz => [245, 225, 225, 255], - Suit::Schell => [245, 240, 210, 255], - } -} - -fn suit_color(suit: Suit) -> [u8; 4] { - match suit { - Suit::Eichel => [131, 100, 56, 255], - Suit::Gras => [62, 120, 54, 255], - Suit::Herz => [170, 40, 60, 255], - Suit::Schell => [204, 142, 30, 255], - } -} - fn on_hover( ) -> impl Fn( On>,