fix(android): lower MIN_WINDOW floor so phone viewports lay out correctly

`compute_layout` runs `window.max(MIN_WINDOW)`, which acts as a
component-wise floor: any window smaller than MIN_WINDOW on either
axis gets clamped up. The previous floor of 800x600 was set with
desktop in mind, but on Android the OS-provided window size is the
device resolution (~360 dp wide on a typical phone) and the clamp
silently re-laid the board for an 800 dp width.

Side effect: total grid width (9 * card_width) became ~800 px on a
360 dp viewport, so the leftmost foundation x-position fell past
-180 and the rightmost tableau pile past +180 — both clipped at
the visible edges, matching the v0.22.3 hardware screenshot.

Lowered MIN_WINDOW to 320x400, below the smallest reasonable phone
(~360x640), so every real device flows through compute_layout
unclamped. The floor is preserved as a sentinel against degenerate
windows (Bevy can briefly report 0-size during startup or after
minimisation on some compositors). Desktop's "minimum supported
playable size" is enforced separately via WindowResizeConstraints
in solitaire_app.

Updates `layout_below_minimum_clamps_to_minimum` to use values
below the new floor, and adds a new regression test
`phone_portrait_layout_fits_horizontally` that asserts all 13
piles fit inside a 360 x 800 dp viewport.

Closes P0 #4 of docs/android/PLAYABILITY_TODO.md. 855 engine tests
pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-10 20:48:56 -07:00
parent 89a21c0587
commit 92a5ebb15e
2 changed files with 57 additions and 8 deletions
+9 -4
View File
@@ -54,10 +54,15 @@ rewrites required.
failed silently. The face-down branch then fell through to the failed silently. The face-down branch then fell through to the
`card_back_colour(0)` solid-red brick fallback. Gated the `card_back_colour(0)` solid-red brick fallback. Gated the
override behind `#[cfg(not(target_os = "android"))]`. override behind `#[cfg(not(target_os = "android"))]`.
- [ ] **Viewport overflow.** Leftmost foundation and rightmost tableau - [x] **Viewport overflow.** *Closed 2026-05-10.* `compute_layout`
pile clipped. `LayoutResource` must recompute on Android using was clamping the input window up to `MIN_WINDOW = 800 × 600`,
actual surface size (post-inset) instead of any desktop default so a 360 dp phone got laid out as if it were 800-wide and the
width assumption. outer piles fell outside the actual viewport. Lowered the floor
to 320 × 400 (below the smallest reasonable phone) so real
Android resolutions flow through without clamping, while keeping
a sentinel to guard against degenerate / startup-zero windows.
New regression test `phone_portrait_layout_fits_horizontally`
asserts all 13 piles fit a 360 × 800 viewport.
## P1 — Touch UX ## P1 — Touch UX
+48 -4
View File
@@ -21,9 +21,25 @@ pub enum LayoutSystem {
UpdateOnResize, UpdateOnResize,
} }
/// Minimum supported window dimensions. Layout is still computed below this /// Minimum window dimensions used as a layout floor.
/// size but cards will be small. ///
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0); /// `compute_layout` runs `window.max(MIN_WINDOW)` so a window smaller than this
/// on either axis is laid out as if it were at least this size. The floor
/// exists to guard against degenerate / divide-by-zero layouts on very small
/// surfaces (Bevy can briefly report 0-size windows during startup or after
/// minimisation on some compositors); it is not a "minimum supported playable
/// size" — desktop builds enforce that via `WindowResizeConstraints` set in
/// `solitaire_app::lib`.
///
/// The previous floor of 800×600 was set with desktop in mind and produced
/// the wrong behaviour on Android: a 360 dp phone got laid out as if it were
/// 800-wide, pushing the leftmost foundation past `-180` and the rightmost
/// tableau pile past `+180`, which clipped both at the visible viewport
/// edges (visible in the v0.22.3 hardware screenshot). 320×400 is below the
/// smallest reasonable phone (≈ 360×640) so every real device flows through
/// without clamping, while still being large enough that the layout math
/// produces non-degenerate card sizes.
pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0);
/// Aspect ratio (height / width) of a standard playing card. /// Aspect ratio (height / width) of a standard playing card.
/// ///
@@ -205,11 +221,39 @@ mod tests {
#[test] #[test]
fn layout_below_minimum_clamps_to_minimum() { fn layout_below_minimum_clamps_to_minimum() {
let below = compute_layout(Vec2::new(400.0, 300.0)); // 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).
let below = compute_layout(Vec2::new(200.0, 200.0));
let at_min = compute_layout(MIN_WINDOW); let at_min = compute_layout(MIN_WINDOW);
assert_eq!(below.card_size, at_min.card_size); assert_eq!(below.card_size, at_min.card_size);
} }
/// Regression for the v0.22.3 Android viewport-overflow bug. A typical
/// portrait-phone viewport (360 dp × 800 dp) must produce a layout
/// where every pile fits horizontally — i.e. card_width is derived
/// from the actual window, not a clamped-up desktop floor.
#[test]
fn phone_portrait_layout_fits_horizontally() {
let window = Vec2::new(360.0, 800.0);
let layout = compute_layout(window);
let half_w = window.x / 2.0;
let half_card = layout.card_size.x / 2.0;
for (pile, pos) in &layout.pile_positions {
assert!(
pos.x - half_card >= -half_w - 1e-3,
"{:?} overflows left at portrait phone window {:?}",
pile,
window
);
assert!(
pos.x + half_card <= half_w + 1e-3,
"{:?} overflows right at portrait phone window {:?}",
pile,
window
);
}
}
#[test] #[test]
fn tableau_columns_are_sorted_left_to_right() { fn tableau_columns_are_sorted_left_to_right() {
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0));