fix(android): adaptive tableau fan fraction fills portrait viewport
On a 360 dp portrait phone the card width is set by the 9-column
horizontal packing (360/9 = 40 dp); the fixed 0.25 fan fraction then
places the worst-case 13-card column in the top ~44 % of the screen,
leaving the bottom 56 % empty black.
`compute_layout` now solves for the fan fraction that exactly uses the
available vertical space below the tableau row:
ideal = avail / (12 * card_height)
On height-limited (desktop) windows ideal ≈ 0.25 and the clamp to the
minimum keeps existing behaviour. On width-limited (portrait phone)
windows the fan expands — ≈ 0.84 at 360 × 800 dp — stretching the
tableau to fill the screen.
Both `tableau_fan_frac` and `tableau_facedown_fan_frac` (scaled
proportionally) are stored on the `Layout` struct. `card_plugin` and
`input_plugin` read from the struct so rendering and hit-testing stay
in sync at every viewport size.
Three new regression tests:
- portrait phone expands fan_frac beyond desktop minimum
- expanded fan fits inside phone viewport (no overflow)
- desktop fan_frac stays at minimum 0.25
Closes P1 "Portrait-first card spacing" in PLAYABILITY_TODO.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -84,8 +84,17 @@ rewrites required.
|
|||||||
Material's guideline applies to all input modes. Cards, pile
|
Material's guideline applies to all input modes. Cards, pile
|
||||||
markers, modal close buttons not yet audited — track as P3 if
|
markers, modal close buttons not yet audited — track as P3 if
|
||||||
they fall below threshold on hardware.
|
they fall below threshold on hardware.
|
||||||
- [ ] **Portrait-first card spacing.** Stretch tableau piles vertically
|
- [x] **Portrait-first card spacing.** *Closed 2026-05-11.*
|
||||||
to fill height; reduce inter-pile gaps so 7 columns fit in 360 dp.
|
`compute_layout` now derives an adaptive `tableau_fan_frac` from the
|
||||||
|
available vertical space below the tableau row. On height-limited
|
||||||
|
(desktop) windows the formula returns ≈ 0.25 and the clamp keeps the
|
||||||
|
existing behaviour. On width-limited (portrait phone) windows — where
|
||||||
|
card size is constrained by the 9-column horizontal packing — the fan
|
||||||
|
fraction expands to fill the viewport (≈ 0.84 at 360 × 800 dp).
|
||||||
|
`tableau_facedown_fan_frac` scales proportionally. Both values live in
|
||||||
|
the `Layout` struct; `card_plugin::card_positions` and
|
||||||
|
`input_plugin::card_position` / `pile_drop_rect` read from the struct
|
||||||
|
so rendering and hit-testing stay in sync across viewport sizes.
|
||||||
- [ ] **Double-tap auto-move visible feedback.** `handle_double_tap`
|
- [ ] **Double-tap auto-move visible feedback.** `handle_double_tap`
|
||||||
exists since `395a322` — verify it triggers on hardware and add a
|
exists since `395a322` — verify it triggers on hardware and add a
|
||||||
brief source-card flash / highlight to confirm to the user.
|
brief source-card flash / highlight to confirm to the user.
|
||||||
|
|||||||
@@ -667,9 +667,9 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
|||||||
out.push((card, pos, z));
|
out.push((card, pos, z));
|
||||||
if is_tableau {
|
if is_tableau {
|
||||||
let step = if card.face_up {
|
let step = if card.face_up {
|
||||||
TABLEAU_FAN_FRAC
|
layout.tableau_fan_frac
|
||||||
} else {
|
} else {
|
||||||
TABLEAU_FACEDOWN_FAN_FRAC
|
layout.tableau_facedown_fan_frac
|
||||||
};
|
};
|
||||||
y_offset -= layout.card_size.y * step;
|
y_offset -= layout.card_size.y * step;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
|||||||
|
|
||||||
use crate::card_animation::tuning::AnimationTuning;
|
use crate::card_animation::tuning::AnimationTuning;
|
||||||
use crate::card_animation::{CardAnimation, MotionCurve};
|
use crate::card_animation::{CardAnimation, MotionCurve};
|
||||||
use crate::card_plugin::{
|
use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC};
|
||||||
CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC,
|
|
||||||
TABLEAU_FAN_FRAC,
|
|
||||||
};
|
|
||||||
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_WARNING};
|
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_WARNING};
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
@@ -614,7 +611,7 @@ fn follow_drag(
|
|||||||
|
|
||||||
// Move cards to the cursor.
|
// Move cards to the cursor.
|
||||||
let bottom_pos = world + drag.cursor_offset;
|
let bottom_pos = world + drag.cursor_offset;
|
||||||
let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC;
|
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
|
||||||
|
|
||||||
for (i, &id) in drag.cards.iter().enumerate() {
|
for (i, &id) in drag.cards.iter().enumerate() {
|
||||||
if let Some((_, mut transform, _)) =
|
if let Some((_, mut transform, _)) =
|
||||||
@@ -875,7 +872,7 @@ fn touch_follow_drag(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let bottom_pos = world + drag.cursor_offset;
|
let bottom_pos = world + drag.cursor_offset;
|
||||||
let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC;
|
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
|
||||||
|
|
||||||
for (i, &id) in drag.cards.iter().enumerate() {
|
for (i, &id) in drag.cards.iter().enumerate() {
|
||||||
if let Some((_, mut transform, _)) =
|
if let Some((_, mut transform, _)) =
|
||||||
@@ -1047,8 +1044,8 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
|||||||
/// Where a card at `stack_index` in pile `pile` would be rendered.
|
/// Where a card at `stack_index` in pile `pile` would be rendered.
|
||||||
///
|
///
|
||||||
/// For tableau columns the per-card fan step depends on the face-up state of
|
/// For tableau columns the per-card fan step depends on the face-up state of
|
||||||
/// every preceding card — face-down cards step by `TABLEAU_FACEDOWN_FAN_FRAC`,
|
/// every preceding card — face-down cards step by `layout.tableau_facedown_fan_frac`,
|
||||||
/// face-up cards by `TABLEAU_FAN_FRAC`. Mirrors `card_plugin::card_positions`
|
/// face-up cards by `layout.tableau_fan_frac`. Mirrors `card_plugin::card_positions`
|
||||||
/// exactly; any drift creates an offset between the visible card face and
|
/// exactly; any drift creates an offset between the visible card face and
|
||||||
/// where clicks land.
|
/// where clicks land.
|
||||||
fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
|
fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
|
||||||
@@ -1058,9 +1055,9 @@ fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index
|
|||||||
if let Some(pile_cards) = game.piles.get(pile) {
|
if let Some(pile_cards) = game.piles.get(pile) {
|
||||||
for card in pile_cards.cards.iter().take(stack_index) {
|
for card in pile_cards.cards.iter().take(stack_index) {
|
||||||
let step = if card.face_up {
|
let step = if card.face_up {
|
||||||
TABLEAU_FAN_FRAC
|
layout.tableau_fan_frac
|
||||||
} else {
|
} else {
|
||||||
TABLEAU_FACEDOWN_FAN_FRAC
|
layout.tableau_facedown_fan_frac
|
||||||
};
|
};
|
||||||
y_offset -= layout.card_size.y * step;
|
y_offset -= layout.card_size.y * step;
|
||||||
}
|
}
|
||||||
@@ -1195,7 +1192,7 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
|
|||||||
if matches!(pile, PileType::Tableau(_)) {
|
if matches!(pile, PileType::Tableau(_)) {
|
||||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||||
if card_count > 1 {
|
if card_count > 1 {
|
||||||
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
let fan = -layout.card_size.y * layout.tableau_fan_frac;
|
||||||
let bottom_card_center_y = center.y + fan * (card_count - 1) as f32;
|
let bottom_card_center_y = center.y + fan * (card_count - 1) as f32;
|
||||||
let top_edge = center.y + layout.card_size.y / 2.0;
|
let top_edge = center.y + layout.card_size.y / 2.0;
|
||||||
let bottom_edge = bottom_card_center_y - layout.card_size.y / 2.0;
|
let bottom_edge = bottom_card_center_y - layout.card_size.y / 2.0;
|
||||||
|
|||||||
@@ -52,11 +52,17 @@ const CARD_ASPECT: f32 = 1.4523;
|
|||||||
/// the tableau row.
|
/// the tableau row.
|
||||||
const VERTICAL_GAP_FRAC: f32 = 0.2;
|
const VERTICAL_GAP_FRAC: f32 = 0.2;
|
||||||
|
|
||||||
/// Fraction of card height contributed by each additional face-up tableau card
|
/// Minimum fraction of card height used as vertical offset between face-up
|
||||||
/// when fanned. Mirrors `card_plugin::TABLEAU_FAN_FRAC` so layout sizing can
|
/// tableau cards. Used for the height-based sizing candidate (worst-case
|
||||||
/// solve for a worst-case column without depending on `card_plugin`.
|
/// column must fit at this fraction). On desktop (height-limited) windows the
|
||||||
|
/// adaptive computation returns this value exactly; on portrait phones it
|
||||||
|
/// expands to fill available vertical space.
|
||||||
const TABLEAU_FAN_FRAC: f32 = 0.25;
|
const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||||
|
|
||||||
|
/// Minimum fraction for face-down tableau cards. Scales proportionally with
|
||||||
|
/// the adaptive face-up fraction so hit-testing and rendering stay in sync.
|
||||||
|
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
|
||||||
|
|
||||||
/// Largest possible face-up tableau column in Klondike: a King down to an Ace
|
/// Largest possible face-up tableau column in Klondike: a King down to an Ace
|
||||||
/// after every face-down card has flipped on column 7. Layout sizing must keep
|
/// after every face-down card has flipped on column 7. Layout sizing must keep
|
||||||
/// this column inside the visible window.
|
/// this column inside the visible window.
|
||||||
@@ -88,6 +94,18 @@ pub struct Layout {
|
|||||||
/// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
|
/// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
|
||||||
/// entry. The map always contains exactly 13 entries after `compute_layout`.
|
/// entry. The map always contains exactly 13 entries after `compute_layout`.
|
||||||
pub pile_positions: HashMap<PileType, Vec2>,
|
pub pile_positions: HashMap<PileType, Vec2>,
|
||||||
|
/// Per-step vertical offset fraction for face-up tableau cards, as a
|
||||||
|
/// fraction of `card_size.y`. On height-limited (desktop) windows this
|
||||||
|
/// equals `TABLEAU_FAN_FRAC` (0.25); on width-limited (portrait phone)
|
||||||
|
/// windows it expands to fill the available vertical space so the tableau
|
||||||
|
/// stretches to the bottom of the screen. Card rendering (`card_plugin`)
|
||||||
|
/// and hit testing (`input_plugin`) both read from this field so they
|
||||||
|
/// stay in sync.
|
||||||
|
pub tableau_fan_frac: f32,
|
||||||
|
/// Per-step vertical offset fraction for face-down tableau cards, as a
|
||||||
|
/// 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the board layout from a window size.
|
/// Compute the board layout from a window size.
|
||||||
@@ -169,9 +187,35 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
|||||||
pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y));
|
pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
|
||||||
|
// height-based sizing already ensures a worst-case 13-card column fits at
|
||||||
|
// TABLEAU_FAN_FRAC (0.25), so the formula returns ≈0.25 and the clamp
|
||||||
|
// keeps it there — no change from prior behaviour. On width-limited
|
||||||
|
// (portrait phone) windows card_size is small and lots of vertical space
|
||||||
|
// is unused; we solve for the fraction that exactly fills the available
|
||||||
|
// space to the bottom margin.
|
||||||
|
//
|
||||||
|
// 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 ideal_fan_frac = if card_height > 0.0 {
|
||||||
|
avail / ((MAX_TABLEAU_CARDS - 1.0) * card_height)
|
||||||
|
} else {
|
||||||
|
TABLEAU_FAN_FRAC
|
||||||
|
};
|
||||||
|
// Never go below the desktop minimum — avoids shrinking the fan on
|
||||||
|
// degenerate near-square windows where the formula might undershoot.
|
||||||
|
let tableau_fan_frac = ideal_fan_frac.max(TABLEAU_FAN_FRAC);
|
||||||
|
// Scale the face-down fraction proportionally so rendering and hit-testing
|
||||||
|
// stay in sync (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC = 0.48 ratio).
|
||||||
|
let facedown_scale = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
|
||||||
|
let tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale;
|
||||||
|
|
||||||
Layout {
|
Layout {
|
||||||
card_size,
|
card_size,
|
||||||
pile_positions,
|
pile_positions,
|
||||||
|
tableau_fan_frac,
|
||||||
|
tableau_facedown_fan_frac,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +426,50 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Portrait phone (width-limited) should expand the fan fraction beyond
|
||||||
|
/// 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));
|
||||||
|
let phone = compute_layout(Vec2::new(360.0, 800.0));
|
||||||
|
assert!(
|
||||||
|
phone.tableau_fan_frac > desktop.tableau_fan_frac,
|
||||||
|
"portrait phone fan_frac ({:.3}) should exceed desktop ({:.3})",
|
||||||
|
phone.tableau_fan_frac,
|
||||||
|
desktop.tableau_fan_frac,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The expanded fan on a portrait phone must not overflow the visible
|
||||||
|
/// window — the worst-case 13-card column must stay above the bottom margin.
|
||||||
|
#[test]
|
||||||
|
fn expanded_fan_fits_phone_viewport() {
|
||||||
|
let window = Vec2::new(360.0, 800.0);
|
||||||
|
let layout = compute_layout(window);
|
||||||
|
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;
|
||||||
|
// Bottom of the 13th (worst-case) fanned face-up card.
|
||||||
|
let bottom = tableau_y - 12.0 * layout.tableau_fan_frac * card_h - card_h / 2.0;
|
||||||
|
let margin = -window.y / 2.0 + h_gap;
|
||||||
|
assert!(
|
||||||
|
bottom >= margin - 1e-3,
|
||||||
|
"worst-case fan overflows phone viewport: bottom={bottom:.1} < margin={margin:.1}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Desktop (height-limited) must keep the minimum fan fraction so the
|
||||||
|
/// 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));
|
||||||
|
assert!(
|
||||||
|
(layout.tableau_fan_frac - TABLEAU_FAN_FRAC).abs() < 1e-3,
|
||||||
|
"desktop fan_frac should stay at minimum {TABLEAU_FAN_FRAC}, got {:.4}",
|
||||||
|
layout.tableau_fan_frac,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_piles_fit_inside_window_horizontally() {
|
fn all_piles_fit_inside_window_horizontally() {
|
||||||
for window in [
|
for window in [
|
||||||
|
|||||||
Reference in New Issue
Block a user