diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 7d45c15..e9ccd82 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -41,7 +41,6 @@ use crate::ui_theme::{ /// Fraction of card height used as vertical offset between face-up tableau cards. pub const TABLEAU_FAN_FRAC: f32 = 0.25; -/// Tighter fan for face-down cards in the tableau — just enough to show the stack. /// Per-card vertical step for face-down tableau cards, as a fraction of /// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards /// don't need their full body shown — only the back-pattern strip is @@ -49,7 +48,12 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25; /// when hit-testing tableau columns; any drift between this and the /// renderer creates a visible offset between the card face and where /// clicks land. -pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12; +/// +/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.20). Both constants must +/// stay in sync; the layout constant drives the adaptive LayoutResource value +/// used at runtime, while this one is the minimum floor used by +/// `update_tableau_fan_frac` when computing proportional updates. +pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20; /// Fraction of card height used as a tiny offset between stacked cards in /// non-tableau piles, so stacking is visible. Public so other plugins @@ -1834,9 +1838,13 @@ fn resize_cards_in_place( /// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum /// face-up column depth. Runs after every `StateChangedEvent` so the fan -/// grows as the player reveals cards — preventing over-spread early-game -/// (fresh deal: max depth = 1, fan_frac = TABLEAU_FAN_FRAC = 0.25) while -/// allowing the full window-sized fan late-game (up to 13 face-up cards). +/// expands as the player reveals cards while staying within the window. +/// +/// On fresh deal (max face-up depth = 1) the function returns early, leaving +/// both fracs at the window-size-adaptive values that `compute_layout` already +/// computed for the current viewport. Previously it overwrote the adaptive +/// value with the desktop minimum (0.25) — the wrong behaviour on portrait +/// phones where the adaptive value is much larger. fn update_tableau_fan_frac( mut events: MessageReader, game: Option>, @@ -1857,22 +1865,25 @@ fn update_tableau_fan_frac( let card_h = layout.0.card_size.y; let avail = layout.0.available_tableau_height; - let new_frac = if max_depth <= 1 || card_h <= 0.0 { - TABLEAU_FAN_FRAC - } else { - let ideal = avail / ((max_depth - 1) as f32 * card_h); - let max_frac = if card_h > 0.0 { avail / (12.0 * card_h) } else { TABLEAU_FAN_FRAC }; - ideal.clamp(TABLEAU_FAN_FRAC, max_frac.max(TABLEAU_FAN_FRAC)) - }; + // With ≤ 1 face-up card per column (fresh deal, or completely face-down + // piles) the face-up fan fraction has no visible effect. Leave both fracs + // at the adaptive values set by compute_layout rather than snapping them + // to the desktop minimum. + if max_depth <= 1 || card_h <= 0.0 { + return; + } + + let ideal = avail / ((max_depth - 1) as f32 * card_h); + let max_frac = if card_h > 0.0 { avail / (12.0 * card_h) } else { TABLEAU_FAN_FRAC }; + let new_frac = ideal.clamp(TABLEAU_FAN_FRAC, max_frac.max(TABLEAU_FAN_FRAC)); let new_facedown_frac = new_frac * (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC); - // Only update the face-up fan. The face-down fan is left at the - // window-size-adaptive value from compute_layout so stacked face-down - // cards remain visible regardless of how many face-up cards are out. - let _ = new_facedown_frac; // computed but unused — leave facedown alone if (layout.0.tableau_fan_frac - new_frac).abs() > 1e-4 { layout.0.tableau_fan_frac = new_frac; } + if (layout.0.tableau_facedown_fan_frac - new_facedown_frac).abs() > 1e-4 { + layout.0.tableau_facedown_fan_frac = new_facedown_frac; + } } #[cfg(test)] diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index ec51e3a..454bf43 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -628,15 +628,42 @@ fn spawn_action_buttons( let top_inset = insets.as_deref().copied().unwrap_or_default().top; let font = TextFont { font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), - // TYPE_BODY (14.0) — was a hardcoded `16.0` until the - // top-bar-overlap fix. Aligns with the rest of `hud_plugin`'s - // text (which already routes through the `TYPE_*` tokens) and - // reclaims horizontal space so the action button row doesn't - // collide with the left-anchored HUD column at narrow window - // widths. font_size: TYPE_BODY, ..default() }; + + // On Android, 7 text-labelled buttons at 48 dp each wrap to two rows on + // a 411 dp phone. Use compact Unicode symbols and tighter gaps so all 7 + // fit in a single row (7×44 + 6×4 = 332 dp, well within a 90%-wide band + // of 370 dp). On desktop, keep the descriptive text labels. + #[cfg(target_os = "android")] + let (max_width, col_gap, row_gap_val) = + (Val::Percent(90.0), Val::Px(4.0), Val::Px(4.0)); + #[cfg(not(target_os = "android"))] + let (max_width, col_gap, row_gap_val) = + (Val::Percent(65.0), VAL_SPACE_2, VAL_SPACE_2); + + #[cfg(target_os = "android")] + let labels = ( + /* menu */ "\u{2261}", // ≡ hamburger + /* undo */ "\u{21A9}", // ↩ undo arrow + /* pause */ "\u{23F8}", // ⏸ pause symbol + /* help */ "?", + /* hint */ "\u{2605}", // ★ star + /* modes */ "\u{2699}\u{25BE}", // ⚙▾ gear+chevron + /* new */ "+", + ); + #[cfg(not(target_os = "android"))] + let labels = ( + "Menu \u{25BE}", + "Undo", + "Pause", + "Help", + "Hint", + "Modes \u{25BE}", + "New Game", + ); + commands .spawn(( Node { @@ -644,19 +671,11 @@ fn spawn_action_buttons( right: VAL_SPACE_3, top: Val::Px(SPACE_2 + top_inset), flex_direction: FlexDirection::Row, - // 6 buttons total ~510 px wide; on a desktop window - // (typically >= 1280 px) `max_width: 65%` is >= 832 px - // and the row stays a single line. On a 411 dp phone - // 65% is 267 px; the 6 buttons wrap to 2 lines instead - // of 3, reclaiming one row of vertical HUD space. - max_width: Val::Percent(65.0), + max_width, flex_wrap: FlexWrap::Wrap, - // When the row wraps, buttons pack to the *end* of each - // line so the row stays visually right-aligned (matches - // the `right: VAL_SPACE_3` anchor). justify_content: JustifyContent::FlexEnd, - column_gap: VAL_SPACE_2, - row_gap: VAL_SPACE_2, + column_gap: col_gap, + row_gap: row_gap_val, align_items: AlignItems::Center, ..default() }, @@ -664,77 +683,15 @@ fn spawn_action_buttons( SafeAreaAnchoredTop { base_top: SPACE_2 }, )) .with_children(|row| { - // Menu and Modes don't have a single hotkey accelerator - // (each row inside their popover has its own); their button - // labels carry the dropdown chevron in lieu of a key chip. - // - // The trailing `order` argument is the per-button index in - // visual reading order (left → right). It feeds - // `Focusable { group: Hud, order }` so Tab cycles the action - // bar in the same order the eye scans it. - spawn_action_button( - row, - MenuButton, - "Menu \u{25BE}", - None, - "Open Stats, Achievements, Profile, Settings, or Leaderboard.", - &font, - 0, - ); - spawn_action_button( - row, - UndoButton, - "Undo", - Some("U"), - "Take back your last move. Costs points and blocks No Undo.", - &font, - 1, - ); - spawn_action_button( - row, - PauseButton, - "Pause", - Some("Esc"), - "Pause the game and freeze the timer.", - &font, - 2, - ); - spawn_action_button( - row, - HelpButton, - "Help", - Some("F1"), - "Show controls, rules, and keyboard shortcuts.", - &font, - 3, - ); - spawn_action_button( - row, - HintButton, - "Hint", - Some("H"), - "Highlight a suggested move. Cycles through alternatives on repeat taps.", - &font, - 4, - ); - spawn_action_button( - row, - ModesButton, - "Modes \u{25BE}", - None, - "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", - &font, - 5, - ); - spawn_action_button( - row, - NewGameButton, - "New Game", - Some("N"), - "Start a fresh deal. Confirms first if a game is in progress.", - &font, - 6, - ); + // The trailing `order` argument feeds `Focusable { group: Hud, order }` + // so Tab cycles the action bar in visual reading order. + spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0); + spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1); + spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2); + spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3); + spawn_action_button(row, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4); + spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5); + spawn_action_button(row, NewGameButton,labels.6, Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6); }); } @@ -773,35 +730,29 @@ fn spawn_action_button( font_size: TYPE_CAPTION, ..default() }; + // On Android, use tighter padding and a slightly smaller min-size so all + // 7 icon-label buttons fit in one row on a ~411 dp phone. 44 dp ≥ + // Apple's minimum touch target; padding of 4 dp each side keeps the icon + // centred with room to breathe. On desktop, keep the comfortable 48 dp + // floor and 8 dp side padding. + #[cfg(target_os = "android")] + let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(44.0), Val::Px(44.0)); + #[cfg(not(target_os = "android"))] + let (pad, min_w, min_h) = (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0)); + row.spawn(( marker, ActionButton, Button, Tooltip::new(tooltip), - // Joins the `Hud` focus group at the supplied order so Tab - // cycles HUD buttons left-to-right under Phase 2. The HUD focus - // ring still only engages when a HUD button is hovered (or in - // future phases, when the player explicitly switches groups); - // the marker just declares membership. Focusable { group: FocusGroup::Hud, order, }, Node { - // Horizontal padding stepped down from VAL_SPACE_3 to - // VAL_SPACE_2 to reclaim ~96px across the 6-button row at - // narrow window widths (see top-bar-overlap fix in the - // companion commit). Vertical padding stays at VAL_SPACE_2 - // so button height tracks the rest of the chrome band. - padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), - // 48 px floors meet Material's recommended thumb-target - // size on touch and are a no-op on desktop for buttons - // whose content already exceeds 48 px in either axis - // (Menu, Modes, New Game, etc.). Without these, "Undo" - // ends up ~46 × 33 px — comfortably tappable with a mouse - // but right at the threshold for a finger. - min_width: Val::Px(48.0), - min_height: Val::Px(48.0), + padding: pad, + min_width: min_w, + min_height: min_h, justify_content: JustifyContent::Center, align_items: AlignItems::Center, border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), diff --git a/solitaire_engine/src/layout.rs b/solitaire_engine/src/layout.rs index 99a3cd9..8044131 100644 --- a/solitaire_engine/src/layout.rs +++ b/solitaire_engine/src/layout.rs @@ -61,7 +61,12 @@ 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; +/// +/// Raised from 0.12 to 0.20 so face-down stacks on portrait phones show +/// enough of each card back to read as a meaningful stack rather than a +/// thin sliver. The ratio to TABLEAU_FAN_FRAC (0.80) is preserved by +/// the adaptive scaling in `compute_layout`. +const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20; /// 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 @@ -72,10 +77,15 @@ const MAX_TABLEAU_CARDS: f32 = 13.0; /// (action buttons, Score / Moves / Timer readouts). The card grid starts /// below this band so the HUD doesn't bleed into the play surface. /// -/// 64 px comfortably fits the action button bar (~32 px tall) plus the -/// Score/Moves text line plus padding, with a few pixels of breathing room. -/// The matching translucent background is painted by `hud_plugin::spawn_hud_band`. +/// Desktop: 64 px fits the single-row action bar plus the Score/Moves line. +/// Android: 128 px accommodates the two-row button wrap on narrow phones +/// (7 buttons × ~52 dp each, with a 65% max-width constraint, wraps to two +/// ~48 dp rows plus row-gap). Without this larger reserve the bottom row of +/// buttons overlaps the top card row. +#[cfg(not(target_os = "android"))] pub const HUD_BAND_HEIGHT: f32 = 64.0; +#[cfg(target_os = "android")] +pub const HUD_BAND_HEIGHT: f32 = 128.0; /// Table background colour (dark green felt). pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196]; diff --git a/solitaire_engine/src/table_plugin.rs b/solitaire_engine/src/table_plugin.rs index 8ccbf6e..eb21aa0 100644 --- a/solitaire_engine/src/table_plugin.rs +++ b/solitaire_engine/src/table_plugin.rs @@ -151,9 +151,22 @@ fn setup_table( safe_area: Option>, ) { // Only spawn a camera if one does not already exist (e.g. a parent app - // may have added one in tests). + // may have added one in tests). Use the felt-green clear colour so the + // background reads as green even before the background PNG finishes + // loading (which is asynchronous and can lag by several frames on + // Android). if existing_camera.is_empty() { - commands.spawn(Camera2d); + commands.spawn(( + Camera2d, + Camera { + clear_color: ClearColorConfig::Custom(Color::srgb( + crate::layout::TABLE_COLOUR[0], + crate::layout::TABLE_COLOUR[1], + crate::layout::TABLE_COLOUR[2], + )), + ..default() + }, + )); } let (window_size, scale) = windows.iter().next().map_or( @@ -267,20 +280,31 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) { PileMarker(pile.clone()), )); - // Foundation slots no longer carry a suit letter — any Ace can claim - // any empty slot, so a fixed C/D/H/S badge would be misleading. Empty - // foundation markers render as plain translucent rectangles. - - // Task #43 — King indicator on empty tableau placeholders. - if let PileType::Tableau(_) = &pile { - entity.with_children(|b| { - b.spawn(( - Text2d::new("K"), - TextFont { font_size, ..default() }, - TextColor(TEXT_PRIMARY.with_alpha(0.35)), - Transform::from_xyz(0.0, 0.0, 0.1), - )); - }); + // Tableau markers show "K" (only a King may start an empty column). + // Foundation markers show "A" (only an Ace may claim an empty slot). + // Neither label carries a suit because any suit may start any slot. + match &pile { + PileType::Tableau(_) => { + entity.with_children(|b| { + b.spawn(( + Text2d::new("K"), + TextFont { font_size, ..default() }, + TextColor(TEXT_PRIMARY.with_alpha(0.35)), + Transform::from_xyz(0.0, 0.0, 0.1), + )); + }); + } + PileType::Foundation(_) => { + entity.with_children(|b| { + b.spawn(( + Text2d::new("A"), + TextFont { font_size, ..default() }, + TextColor(TEXT_PRIMARY.with_alpha(0.35)), + Transform::from_xyz(0.0, 0.0, 0.1), + )); + }); + } + _ => {} } } }