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::color::Color;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use bevy::sprite::Anchor;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
use solitaire_core::pile::PileType;
|
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.
|
/// Font size as a fraction of card width.
|
||||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
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).
|
/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
|
||||||
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
|
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
|
||||||
/// Suit colour for hearts + diamonds — saturated red `#e35353`.
|
/// Suit colour for hearts + diamonds — saturated red `#e35353`.
|
||||||
@@ -163,6 +171,16 @@ pub struct CardEntity {
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct CardLabel;
|
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.
|
/// Marker component indicating the card is currently highlighted as a hint.
|
||||||
/// `remaining` counts down in real seconds; the highlight is removed when it
|
/// `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.
|
/// 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),
|
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.
|
// When PNG faces are loaded the rank/suit are baked into the image.
|
||||||
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
// 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() {
|
if card_images.is_none() {
|
||||||
entity.with_children(|b| {
|
entity.with_children(|b| {
|
||||||
b.spawn((
|
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)]
|
#[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,
|
// 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
|
// 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).despawn_related::<Children>();
|
||||||
commands.entity(entity).with_children(|b| {
|
commands.entity(entity).with_children(|b| {
|
||||||
add_card_shadow_child(b, layout.card_size);
|
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 {
|
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
|
// 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
|
/// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum
|
||||||
/// face-up column depth. Runs after every `StateChangedEvent` so the fan
|
/// face-up column depth. Runs after every `StateChangedEvent` so the fan
|
||||||
/// expands as the player reveals cards while staying within the window.
|
/// expands as the player reveals cards while staying within the window.
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0);
|
|||||||
/// which rendered the cards ~3.6 % squashed vertically.
|
/// which rendered the cards ~3.6 % squashed vertically.
|
||||||
const CARD_ASPECT: f32 = 1.4523;
|
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
|
/// Fraction of card height used as vertical padding between the top row and
|
||||||
/// the tableau row.
|
/// the tableau row.
|
||||||
const VERTICAL_GAP_FRAC: f32 = 0.2;
|
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 window = window.max(MIN_WINDOW);
|
||||||
let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 };
|
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.
|
// Width-based candidate: 7 cards + 8 h_gaps where h_gap = card_width/H_GAP_DIVISOR.
|
||||||
let card_width_width_based = window.x / 9.0;
|
// 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
|
// 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.
|
// 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_height = card_width * CARD_ASPECT;
|
||||||
let card_size = Vec2::new(card_width, card_height);
|
let card_size = Vec2::new(card_width, card_height);
|
||||||
|
|
||||||
let h_gap = card_width / 4.0;
|
let h_gap = card_width / H_GAP_DIVISOR;
|
||||||
// Total occupied width = 7*card_width + 8*h_gap = 9*card_width. When card
|
// Total occupied width = 7*card_width + 8*h_gap = card_width_divisor*card_width.
|
||||||
// sizing is height-limited (tall/narrow windows), this is smaller than
|
// When card sizing is height-limited (tall/narrow windows) this is smaller than
|
||||||
// window.x, so the grid is centred horizontally; otherwise side_margin
|
// window.x and the grid is centred horizontally; otherwise side_margin collapses
|
||||||
// collapses to h_gap and the geometry matches the original width-based
|
// to h_gap and the geometry fills the window exactly.
|
||||||
// layout exactly.
|
let total_grid_width = card_width_divisor * card_width;
|
||||||
let total_grid_width = 9.0 * card_width;
|
|
||||||
let side_margin = (window.x - total_grid_width) / 2.0 + h_gap;
|
let side_margin = (window.x - total_grid_width) / 2.0 + h_gap;
|
||||||
let left_edge = -window.x / 2.0;
|
let left_edge = -window.x / 2.0;
|
||||||
let col_x = |col: usize| -> f32 {
|
let col_x = |col: usize| -> f32 {
|
||||||
@@ -401,11 +420,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tall_narrow_window_keeps_width_based_sizing() {
|
fn tall_narrow_window_keeps_width_based_sizing() {
|
||||||
// Tall narrow window: there's plenty of vertical budget, so width is
|
// Tall narrow window: there's plenty of vertical budget, so width is
|
||||||
// the bottleneck and card_width matches the legacy window.x / 9
|
// the bottleneck and card_width matches window.x / (7 + 8/H_GAP_DIVISOR).
|
||||||
// derivation exactly.
|
|
||||||
let window = Vec2::new(900.0, 1600.0);
|
let window = Vec2::new(900.0, 1600.0);
|
||||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
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!(
|
assert!(
|
||||||
(layout.card_size.x - width_based).abs() < 1e-3,
|
(layout.card_size.x - width_based).abs() < 1e-3,
|
||||||
"expected width-based sizing (card_width {} should equal {})",
|
"expected width-based sizing (card_width {} should equal {})",
|
||||||
|
|||||||
Reference in New Issue
Block a user