From 102506f7992912c355867c1746703aac70ada4e3 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 15 May 2026 16:49:50 -0700 Subject: [PATCH] feat(engine): add Android corner-label overlay for card readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android, face-up cards now render a large rank+suit overlay in the upper-left corner (FONT_SIZE_FRAC_MOBILE = 0.35 × card_width, using Anchor::TOP_LEFT) so the rank and suit are legible at phone scale. The baked-in SVG art corner text is only ~10–15 px physical; the overlay is ~52 px physical — roughly 3-4× larger. Accompanying changes: - H_GAP_DIVISOR on Android raised 8 → 32, widening cards from 112.5 → 124.1 logical px (135 → 149 physical px on Pixel 7 AVD). - AndroidCornerLabel marker component tracks overlay entities so resize_android_corner_labels can update font-size + transform on orientation change without a full card respawn. - Uses text_colour() for overlay tint so black suits render as near-white (BLACK_SUIT_COLOUR) on the dark Terminal card face, matching the existing fallback overlay behaviour. Co-Authored-By: Claude Sonnet 4.6 --- solitaire_engine/src/card_plugin.rs | 124 +++++++++++++++++++++++++++- solitaire_engine/src/layout.rs | 42 +++++++--- 2 files changed, 153 insertions(+), 13 deletions(-) diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index a1ed7dd..9f3dc77 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -15,6 +15,8 @@ use std::collections::{HashMap, HashSet}; use bevy::color::Color; use bevy::prelude::*; use bevy::window::WindowResized; +#[cfg(target_os = "android")] +use bevy::sprite::Anchor; use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::pile::PileType; @@ -65,6 +67,12 @@ pub const STACK_FAN_FRAC: f32 = 0.003; /// Font size as a fraction of card width. const FONT_SIZE_FRAC: f32 = 0.28; +/// Font-size fraction for the large-print readability overlay on Android. +/// Spawned on top of PNG face cards to make the rank+suit legible at phone +/// scale, where the baked-in PNG corner text is only ~10 px physical. +#[cfg(target_os = "android")] +const FONT_SIZE_FRAC_MOBILE: f32 = 0.35; + /// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED). pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102); /// Suit colour for hearts + diamonds — saturated red `#e35353`. @@ -163,6 +171,16 @@ pub struct CardEntity { #[derive(Component, Debug)] pub struct CardLabel; +/// Marker for the large-print rank+suit corner overlay on Android. +/// +/// Spawned on top of PNG face cards (face-up only) at font size +/// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are +/// readable at phone scale. Only exists when `CardImageSet` is present +/// (the fallback solid-colour path uses a plain `CardLabel` instead). +#[cfg(target_os = "android")] +#[derive(Component, Debug, Clone, Copy)] +struct AndroidCornerLabel; + /// Marker component indicating the card is currently highlighted as a hint. /// `remaining` counts down in real seconds; the highlight is removed when it /// reaches zero and the card sprite colour is restored to its normal value. @@ -429,6 +447,9 @@ impl Plugin for CardPlugin { snap_cards_on_window_resize.after(collect_resize_events), ), ); + + #[cfg(target_os = "android")] + app.add_systems(Update, resize_android_corner_labels); } } @@ -761,6 +782,8 @@ fn spawn_card_entity( }); // When PNG faces are loaded the rank/suit are baked into the image. // Only spawn the Text2d overlay in the solid-colour fallback (tests). + // On Android we additionally spawn a large-print corner label even in + // image mode so the rank/suit are legible at phone scale. if card_images.is_none() { entity.with_children(|b| { b.spawn(( @@ -776,6 +799,12 @@ fn spawn_card_entity( )); }); } + #[cfg(target_os = "android")] + if card_images.is_some() { + entity.with_children(|b| { + add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast); + }); + } } #[allow(clippy::too_many_arguments)] @@ -829,7 +858,8 @@ fn update_card_entity( // Despawn any stale children and re-add the per-card drop shadow plus, // in solid-colour fallback mode, the label overlay. In image mode the - // rank/suit are baked into the PNG, so no `Text2d` overlay is needed. + // rank/suit are baked into the PNG; on Android we also add a large-print + // corner overlay so they are legible at phone scale. commands.entity(entity).despawn_related::(); commands.entity(entity).with_children(|b| { add_card_shadow_child(b, layout.card_size); @@ -852,6 +882,12 @@ fn update_card_entity( )); }); } + #[cfg(target_os = "android")] + if card_images.is_some() { + commands.entity(entity).with_children(|b| { + add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast); + }); + } } fn label_for(card: &Card) -> String { @@ -924,6 +960,67 @@ fn label_visibility(card: &Card) -> Visibility { } } +/// Rank+suit string for the Android readability overlay. +/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono). +#[cfg(target_os = "android")] +fn mobile_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 => "♣", + Suit::Diamonds => "♦", + Suit::Hearts => "♥", + Suit::Spades => "♠", + }; + format!("{rank}{suit}") +} + +/// Spawns the [`AndroidCornerLabel`] overlay child on face-up cards. +/// Uses [`Anchor::TopLeft`] so the transform is the inset top-left corner +/// of the card face; the text block grows down and right from there. +#[cfg(target_os = "android")] +fn add_android_corner_label( + parent: &mut ChildSpawnerCommands, + card: &Card, + card_size: Vec2, + color_blind: bool, + high_contrast: bool, +) { + if !card.face_up { + return; + } + let inset = 4.0_f32; + parent.spawn(( + AndroidCornerLabel, + CardLabel, + Text2d::new(mobile_label_for(card)), + TextFont { + font_size: card_size.x * FONT_SIZE_FRAC_MOBILE, + ..default() + }, + TextColor(text_colour(card, color_blind, high_contrast)), + Anchor::TOP_LEFT, + Transform::from_xyz( + -card_size.x / 2.0 + inset, + card_size.y / 2.0 - inset, + 0.02, + ), + )); +} + // --------------------------------------------------------------------------- // Task #34 — Card-flip animation systems // --------------------------------------------------------------------------- @@ -1832,6 +1929,31 @@ fn resize_cards_in_place( } } +/// Updates font size and top-left anchor transform of every +/// [`AndroidCornerLabel`] entity when `LayoutResource` changes (orientation +/// change or any window resize). The full despawn/respawn path in +/// `update_card_entity` already handles game-state changes; this system +/// covers the resize-only path where children are mutated in place. +#[cfg(target_os = "android")] +fn resize_android_corner_labels( + layout: Res, + card_images: Option>, + mut query: Query<(&mut TextFont, &mut Transform), With>, +) { + if !layout.is_changed() || card_images.is_none() { + return; + } + let new_font_size = layout.0.card_size.x * FONT_SIZE_FRAC_MOBILE; + let inset = 4.0_f32; + let new_x = -layout.0.card_size.x / 2.0 + inset; + let new_y = layout.0.card_size.y / 2.0 - inset; + for (mut font, mut transform) in query.iter_mut() { + font.font_size = new_font_size; + transform.translation.x = new_x; + transform.translation.y = new_y; + } +} + /// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum /// face-up column depth. Runs after every `StateChangedEvent` so the fan /// expands as the player reveals cards while staying within the window. diff --git a/solitaire_engine/src/layout.rs b/solitaire_engine/src/layout.rs index 736449e..e8b8c0a 100644 --- a/solitaire_engine/src/layout.rs +++ b/solitaire_engine/src/layout.rs @@ -48,6 +48,24 @@ pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0); /// which rendered the cards ~3.6 % squashed vertically. const CARD_ASPECT: f32 = 1.4523; +/// Divisor used to derive the horizontal gap between columns from the card +/// width: `h_gap = card_width / H_GAP_DIVISOR`. +/// +/// This constant also drives `card_width_width_based`: +/// total layout width = 7*card_width + 8*h_gap = card_width*(7 + 8/H_GAP_DIVISOR) +/// → card_width = window.x / (7 + 8/H_GAP_DIVISOR) +/// +/// Desktop (H_GAP_DIVISOR = 4): card_width = window.x / 9 — existing behaviour. +/// Android (H_GAP_DIVISOR = 32): card_width = window.x / 7.25 — cards are ~10 % +/// wider than at divisor 8, with very tight gaps (~4 px) that are still visible +/// as a faint seam between columns. The primary readability boost on Android +/// comes from the `AndroidCornerLabel` overlay in `card_plugin`, but maximising +/// the physical card size helps too. +#[cfg(not(target_os = "android"))] +const H_GAP_DIVISOR: f32 = 4.0; +#[cfg(target_os = "android")] +const H_GAP_DIVISOR: f32 = 32.0; + /// Fraction of card height used as vertical padding between the top row and /// the tableau row. const VERTICAL_GAP_FRAC: f32 = 0.2; @@ -149,8 +167,10 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h let window = window.max(MIN_WINDOW); let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 }; - // Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width. - let card_width_width_based = window.x / 9.0; + // Width-based candidate: 7 cards + 8 h_gaps where h_gap = card_width/H_GAP_DIVISOR. + // Total = card_width*(7 + 8/H_GAP_DIVISOR) = window.x → card_width = window.x/card_width_divisor. + let card_width_divisor = 7.0 + 8.0 / H_GAP_DIVISOR; + let card_width_width_based = window.x / card_width_divisor; // Height-based candidate. The vertical budget below the top row must hold // a worst-case fanned tableau column plus a bottom margin equal to h_gap. @@ -175,13 +195,12 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h let card_height = card_width * CARD_ASPECT; let card_size = Vec2::new(card_width, card_height); - let h_gap = card_width / 4.0; - // Total occupied width = 7*card_width + 8*h_gap = 9*card_width. When card - // sizing is height-limited (tall/narrow windows), this is smaller than - // window.x, so the grid is centred horizontally; otherwise side_margin - // collapses to h_gap and the geometry matches the original width-based - // layout exactly. - let total_grid_width = 9.0 * card_width; + let h_gap = card_width / H_GAP_DIVISOR; + // Total occupied width = 7*card_width + 8*h_gap = card_width_divisor*card_width. + // When card sizing is height-limited (tall/narrow windows) this is smaller than + // window.x and the grid is centred horizontally; otherwise side_margin collapses + // to h_gap and the geometry fills the window exactly. + let total_grid_width = card_width_divisor * card_width; let side_margin = (window.x - total_grid_width) / 2.0 + h_gap; let left_edge = -window.x / 2.0; let col_x = |col: usize| -> f32 { @@ -401,11 +420,10 @@ mod tests { #[test] fn tall_narrow_window_keeps_width_based_sizing() { // Tall narrow window: there's plenty of vertical budget, so width is - // the bottleneck and card_width matches the legacy window.x / 9 - // derivation exactly. + // the bottleneck and card_width matches window.x / (7 + 8/H_GAP_DIVISOR). let window = Vec2::new(900.0, 1600.0); let layout = compute_layout(window, 0.0, 0.0, true); - let width_based = window.x / 9.0; + let width_based = window.x / (7.0 + 8.0 / H_GAP_DIVISOR); assert!( (layout.card_size.x - width_based).abs() < 1e-3, "expected width-based sizing (card_width {} should equal {})",