feat(engine): Android UX sweep — tap-to-move, safe area, HUD polish
Single-tap auto-move (input_plugin): - Remove 0.5 s double-tap window; any uncommitted TouchPhase::Ended on a face-up card now fires MoveRequestEvent immediately. Bottom safe-area inset (layout, table_plugin): - compute_layout gains safe_area_bottom param; height budget and bottom margin both respect the navigation bar reservation. Card back contrast (card_plugin): - CardBackFrame child sprite (gray, card_size + 3 px, local z=-0.01) spawned behind every face-down card so the dark back_0.png reads as a distinct rectangle against the dark felt. HUD action bar compactness (hud_plugin): - max_width 50% → 65% on the action button row; 6 buttons now wrap to 2 rows instead of 3 on a 360 dp phone. Dynamic tableau fan fraction (layout, card_plugin): - Layout gains available_tableau_height field. - update_tableau_fan_frac system (after GameMutation, before sync_cards_on_change) grows face-up fan from 0.25 to the window max as revealed column depth increases. Face-down fan is left at the window-adaptive value so stacks stay visible. ModesPopover + MenuPopover light-dismiss (hud_plugin): - Fullscreen transparent Button backdrop spawned at Z_HUD+4 behind each popover; tapping outside the panel despawns both panel and backdrop. Stock badge legibility (card_plugin): - Badge font TYPE_CAPTION (11 pt) → TYPE_BODY (14 pt); background sprite 28×16 → 34×20 world units. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -106,15 +106,21 @@ pub struct Layout {
|
||||
/// fraction of `card_size.y`. Scales proportionally with `tableau_fan_frac`
|
||||
/// (ratio preserved from `TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC`).
|
||||
pub tableau_facedown_fan_frac: f32,
|
||||
/// Vertical pixel budget available for tableau fan steps — the distance
|
||||
/// from the top edge of the first tableau card to the bottom margin, in
|
||||
/// logical pixels. Used by `card_plugin::update_tableau_fan_frac` to
|
||||
/// recompute `tableau_fan_frac` dynamically based on the actual max
|
||||
/// face-up column depth after each game state change.
|
||||
pub available_tableau_height: f32,
|
||||
}
|
||||
|
||||
/// Compute the board layout from a window size and safe-area top inset.
|
||||
/// Compute the board layout from a window size and safe-area insets.
|
||||
///
|
||||
/// `safe_area_top` is the **logical-pixel** height of the OS-reserved region
|
||||
/// at the top of the screen (status bar on Android). Pass `0.0` on desktop or
|
||||
/// when the inset is unknown. Note that Android's `WindowInsets` API returns
|
||||
/// **physical** pixels; callers must divide by `window.scale_factor()` before
|
||||
/// passing the value here.
|
||||
/// `safe_area_top` and `safe_area_bottom` are the **logical-pixel** heights of
|
||||
/// the OS-reserved regions at the top and bottom of the screen (status bar and
|
||||
/// gesture / navigation bar on Android). Pass `0.0` on desktop or when the
|
||||
/// inset is unknown. Android's `WindowInsets` API returns **physical** pixels;
|
||||
/// callers must divide by `window.scale_factor()` before passing values here.
|
||||
///
|
||||
/// # Geometry
|
||||
/// - `card_width` is the smaller of:
|
||||
@@ -130,7 +136,7 @@ pub struct Layout {
|
||||
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
|
||||
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
|
||||
/// waste/stock cluster from the foundations.
|
||||
pub fn compute_layout(window: Vec2, safe_area_top: f32) -> Layout {
|
||||
pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32) -> Layout {
|
||||
let window = window.max(MIN_WINDOW);
|
||||
|
||||
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
|
||||
@@ -153,7 +159,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32) -> Layout {
|
||||
// (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
|
||||
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
|
||||
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
|
||||
let card_width_height_based = (window.y - safe_area_top - HUD_BAND_HEIGHT).max(0.0) / height_denom;
|
||||
let card_width_height_based = (window.y - safe_area_top - safe_area_bottom - HUD_BAND_HEIGHT).max(0.0) / height_denom;
|
||||
|
||||
let card_width = card_width_width_based.min(card_width_height_based);
|
||||
let card_height = card_width * CARD_ASPECT;
|
||||
@@ -203,7 +209,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32) -> Layout {
|
||||
//
|
||||
// avail = distance from the top of the first tableau card to the bottom
|
||||
// margin — i.e. the space available for 12 fan steps.
|
||||
let avail = (tableau_y - (-window.y / 2.0 + h_gap) - card_height / 2.0).max(0.0);
|
||||
let avail = (tableau_y - (-window.y / 2.0 + safe_area_bottom + h_gap) - card_height / 2.0).max(0.0);
|
||||
let ideal_fan_frac = if card_height > 0.0 {
|
||||
avail / ((MAX_TABLEAU_CARDS - 1.0) * card_height)
|
||||
} else {
|
||||
@@ -222,6 +228,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32) -> Layout {
|
||||
pile_positions,
|
||||
tableau_fan_frac,
|
||||
tableau_facedown_fan_frac,
|
||||
available_tableau_height: avail,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,15 +260,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn layout_has_all_thirteen_piles() {
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0), 0.0));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0), 0.0));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_size_scales_with_window_width() {
|
||||
let small = compute_layout(Vec2::new(800.0, 600.0), 0.0);
|
||||
let large = compute_layout(Vec2::new(1920.0, 1080.0), 0.0);
|
||||
let small = compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0);
|
||||
let large = compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0);
|
||||
assert!(large.card_size.x > small.card_size.x);
|
||||
assert!(
|
||||
(large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5,
|
||||
@@ -272,9 +279,9 @@ mod tests {
|
||||
#[test]
|
||||
fn layout_below_minimum_clamps_to_minimum() {
|
||||
// 200×200 sits below the floor on both axes, so the clamp pulls each
|
||||
// axis up to MIN_WINDOW and the layout matches compute_layout(MIN_WINDOW, 0.0).
|
||||
let below = compute_layout(Vec2::new(200.0, 200.0), 0.0);
|
||||
let at_min = compute_layout(MIN_WINDOW, 0.0);
|
||||
// axis up to MIN_WINDOW and the layout matches compute_layout(MIN_WINDOW, 0.0, 0.0).
|
||||
let below = compute_layout(Vec2::new(200.0, 200.0), 0.0, 0.0);
|
||||
let at_min = compute_layout(MIN_WINDOW, 0.0, 0.0);
|
||||
assert_eq!(below.card_size, at_min.card_size);
|
||||
}
|
||||
|
||||
@@ -285,7 +292,7 @@ mod tests {
|
||||
#[test]
|
||||
fn phone_portrait_layout_fits_horizontally() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let layout = compute_layout(window, 0.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
let half_w = window.x / 2.0;
|
||||
let half_card = layout.card_size.x / 2.0;
|
||||
for (pile, pos) in &layout.pile_positions {
|
||||
@@ -306,7 +313,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tableau_columns_are_sorted_left_to_right() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
for i in 0..6 {
|
||||
let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
|
||||
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
|
||||
@@ -316,7 +323,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn top_row_is_above_tableau_row() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
let stock_y = layout.pile_positions[&PileType::Stock].y;
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
|
||||
assert!(stock_y > tableau_y);
|
||||
@@ -329,7 +336,7 @@ mod tests {
|
||||
#[test]
|
||||
fn top_row_clears_hud_band() {
|
||||
let window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(window, 0.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
let stock_y = layout.pile_positions[&PileType::Stock].y;
|
||||
let card_top = stock_y + layout.card_size.y / 2.0;
|
||||
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
|
||||
@@ -341,7 +348,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
let stock_x = layout.pile_positions[&PileType::Stock].x;
|
||||
let waste_x = layout.pile_positions[&PileType::Waste].x;
|
||||
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
|
||||
@@ -352,7 +359,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn foundations_align_with_tableau_cols_3_to_6() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
for slot in 0..4_u8 {
|
||||
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
|
||||
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
|
||||
@@ -371,7 +378,7 @@ mod tests {
|
||||
// keep a worst-case 13-card column inside the window. (Most desktop
|
||||
// monitors fall into this regime — e.g. 1280x800, 1920x1080.)
|
||||
let window = Vec2::new(2560.0, 1080.0);
|
||||
let layout = compute_layout(window, 0.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
let width_based = window.x / 9.0;
|
||||
assert!(
|
||||
layout.card_size.x < width_based,
|
||||
@@ -387,7 +394,7 @@ mod tests {
|
||||
// the bottleneck and card_width matches the legacy window.x / 9
|
||||
// derivation exactly.
|
||||
let window = Vec2::new(900.0, 1600.0);
|
||||
let layout = compute_layout(window, 0.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
let width_based = window.x / 9.0;
|
||||
assert!(
|
||||
(layout.card_size.x - width_based).abs() < 1e-3,
|
||||
@@ -401,7 +408,7 @@ mod tests {
|
||||
fn worst_case_tableau_fits_vertically_on_default_resolution() {
|
||||
// Default app resolution (see solitaire_app/src/main.rs).
|
||||
let window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(window, 0.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
|
||||
let card_h = layout.card_size.y;
|
||||
// Bottom edge of the 13th fanned face-up card.
|
||||
@@ -420,7 +427,7 @@ mod tests {
|
||||
fn worst_case_tableau_fits_vertically_on_full_hd() {
|
||||
// The bug originally reproduced at 1920x1080. Lock in a regression test.
|
||||
let window = Vec2::new(1920.0, 1080.0);
|
||||
let layout = compute_layout(window, 0.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
|
||||
let card_h = layout.card_size.y;
|
||||
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
|
||||
@@ -436,8 +443,8 @@ mod tests {
|
||||
/// the desktop minimum so the tableau fills the available vertical space.
|
||||
#[test]
|
||||
fn portrait_phone_expands_tableau_fan_frac() {
|
||||
let desktop = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
|
||||
let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0);
|
||||
let desktop = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0, 0.0);
|
||||
assert!(
|
||||
phone.tableau_fan_frac > desktop.tableau_fan_frac,
|
||||
"portrait phone fan_frac ({:.3}) should exceed desktop ({:.3})",
|
||||
@@ -451,7 +458,7 @@ mod tests {
|
||||
#[test]
|
||||
fn expanded_fan_fits_phone_viewport() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let layout = compute_layout(window, 0.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
|
||||
let card_h = layout.card_size.y;
|
||||
let h_gap = layout.card_size.x / 4.0;
|
||||
@@ -468,7 +475,7 @@ mod tests {
|
||||
/// existing worst-case-fits-vertically invariant is preserved.
|
||||
#[test]
|
||||
fn desktop_tableau_fan_frac_is_minimum() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
assert!(
|
||||
(layout.tableau_fan_frac - TABLEAU_FAN_FRAC).abs() < 1e-3,
|
||||
"desktop fan_frac should stay at minimum {TABLEAU_FAN_FRAC}, got {:.4}",
|
||||
@@ -483,7 +490,7 @@ mod tests {
|
||||
Vec2::new(1280.0, 800.0),
|
||||
Vec2::new(1920.0, 1080.0),
|
||||
] {
|
||||
let layout = compute_layout(window, 0.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
let half_w = window.x / 2.0;
|
||||
let half_card = layout.card_size.x / 2.0;
|
||||
for (pile, pos) in &layout.pile_positions {
|
||||
@@ -509,8 +516,8 @@ mod tests {
|
||||
#[test]
|
||||
fn safe_area_top_shifts_top_row_downward() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let without = compute_layout(window, 0.0);
|
||||
let with_inset = compute_layout(window, 32.0);
|
||||
let without = compute_layout(window, 0.0, 0.0);
|
||||
let with_inset = compute_layout(window, 32.0, 0.0);
|
||||
let stock_no_inset = without.pile_positions[&PileType::Stock].y;
|
||||
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
|
||||
assert!(
|
||||
@@ -531,8 +538,8 @@ mod tests {
|
||||
#[test]
|
||||
fn safe_area_top_does_not_affect_horizontal_layout() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let without = compute_layout(window, 0.0);
|
||||
let with_inset = compute_layout(window, 32.0);
|
||||
let without = compute_layout(window, 0.0, 0.0);
|
||||
let with_inset = compute_layout(window, 32.0, 0.0);
|
||||
for pile in [
|
||||
PileType::Stock,
|
||||
PileType::Waste,
|
||||
@@ -545,4 +552,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A bottom safe-area inset must shrink the tableau fan so the worst-case
|
||||
/// column stays above the gesture bar.
|
||||
#[test]
|
||||
fn safe_area_bottom_reduces_tableau_fan() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let without = compute_layout(window, 0.0, 0.0);
|
||||
let with_inset = compute_layout(window, 0.0, 48.0);
|
||||
assert!(
|
||||
with_inset.tableau_fan_frac <= without.tableau_fan_frac,
|
||||
"safe_area_bottom=48 must not increase tableau_fan_frac: {:.4} → {:.4}",
|
||||
without.tableau_fan_frac,
|
||||
with_inset.tableau_fan_frac,
|
||||
);
|
||||
let card_h = with_inset.card_size.y;
|
||||
let tableau_y = with_inset.pile_positions[&PileType::Tableau(6)].y;
|
||||
let bottom_edge = tableau_y - 12.0 * card_h * with_inset.tableau_fan_frac - card_h / 2.0;
|
||||
let h_gap = with_inset.card_size.x / 4.0;
|
||||
let margin = -window.y / 2.0 + 48.0 + h_gap;
|
||||
assert!(
|
||||
bottom_edge >= margin - 1e-3,
|
||||
"worst-case tableau bottom {bottom_edge:.2} overflows gesture-bar margin {margin:.2}",
|
||||
);
|
||||
}
|
||||
|
||||
/// safe_area_bottom must not affect horizontal positions.
|
||||
#[test]
|
||||
fn safe_area_bottom_does_not_affect_horizontal_layout() {
|
||||
let window = Vec2::new(360.0, 800.0);
|
||||
let without = compute_layout(window, 0.0, 0.0);
|
||||
let with_inset = compute_layout(window, 0.0, 48.0);
|
||||
for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] {
|
||||
assert!(
|
||||
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
|
||||
"{pile:?} x-position must not change with safe_area_bottom",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user