diff --git a/docs/android/PLAYABILITY_TODO.md b/docs/android/PLAYABILITY_TODO.md index 48cfb14..6e03ab7 100644 --- a/docs/android/PLAYABILITY_TODO.md +++ b/docs/android/PLAYABILITY_TODO.md @@ -84,8 +84,17 @@ rewrites required. Material's guideline applies to all input modes. Cards, pile markers, modal close buttons not yet audited — track as P3 if they fall below threshold on hardware. -- [ ] **Portrait-first card spacing.** Stretch tableau piles vertically - to fill height; reduce inter-pile gaps so 7 columns fit in 360 dp. +- [x] **Portrait-first card spacing.** *Closed 2026-05-11.* + `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` exists since `395a322` — verify it triggers on hardware and add a brief source-card flash / highlight to confirm to the user. diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 0d3bc6f..a4a980e 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -667,9 +667,9 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve out.push((card, pos, z)); if is_tableau { let step = if card.face_up { - TABLEAU_FAN_FRAC + layout.tableau_fan_frac } else { - TABLEAU_FACEDOWN_FAN_FRAC + layout.tableau_facedown_fan_frac }; y_offset -= layout.card_size.y * step; } diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 788b184..3684667 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -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::{CardAnimation, MotionCurve}; -use crate::card_plugin::{ - CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC, - TABLEAU_FAN_FRAC, -}; +use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC}; use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_WARNING}; use solitaire_core::game_state::DrawMode; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; @@ -614,7 +611,7 @@ fn follow_drag( // Move cards to the cursor. 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() { if let Some((_, mut transform, _)) = @@ -875,7 +872,7 @@ fn touch_follow_drag( } 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() { 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. /// /// 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`, -/// face-up cards by `TABLEAU_FAN_FRAC`. Mirrors `card_plugin::card_positions` +/// every preceding card — face-down cards step by `layout.tableau_facedown_fan_frac`, +/// 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 /// where clicks land. 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) { for card in pile_cards.cards.iter().take(stack_index) { let step = if card.face_up { - TABLEAU_FAN_FRAC + layout.tableau_fan_frac } else { - TABLEAU_FACEDOWN_FAN_FRAC + layout.tableau_facedown_fan_frac }; 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(_)) { let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len()); 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 top_edge = center.y + layout.card_size.y / 2.0; let bottom_edge = bottom_card_center_y - layout.card_size.y / 2.0; diff --git a/solitaire_engine/src/layout.rs b/solitaire_engine/src/layout.rs index 4788ddc..6d208c5 100644 --- a/solitaire_engine/src/layout.rs +++ b/solitaire_engine/src/layout.rs @@ -52,11 +52,17 @@ const CARD_ASPECT: f32 = 1.4523; /// the tableau row. const VERTICAL_GAP_FRAC: f32 = 0.2; -/// Fraction of card height contributed by each additional face-up tableau card -/// when fanned. Mirrors `card_plugin::TABLEAU_FAN_FRAC` so layout sizing can -/// solve for a worst-case column without depending on `card_plugin`. +/// Minimum fraction of card height used as vertical offset between face-up +/// tableau cards. Used for the height-based sizing candidate (worst-case +/// 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; +/// 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 /// after every face-down card has flipped on column 7. Layout sizing must keep /// this column inside the visible window. @@ -88,6 +94,18 @@ pub struct Layout { /// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an /// entry. The map always contains exactly 13 entries after `compute_layout`. pub pile_positions: HashMap, + /// 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. @@ -169,9 +187,35 @@ pub fn compute_layout(window: Vec2) -> Layout { 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 { card_size, 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] fn all_piles_fit_inside_window_horizontally() { for window in [