feat(engine): add Android corner-label overlay for card readability
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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::<Children>();
|
||||
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<LayoutResource>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
mut query: Query<(&mut TextFont, &mut Transform), With<AndroidCornerLabel>>,
|
||||
) {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user