fix(android): visual polish — green fallback, A-markers, wider fan, compact HUD

- camera clear colour → TABLE_COLOUR green so the background reads as
  felt even before bg_0.png finishes loading (async on Android)
- foundation empty markers now show "A" child text (same pattern as the
  "K" on tableau markers) — no suit letter since any Ace claims any slot
- HUD_BAND_HEIGHT = 128 on Android to accommodate the two-row button
  wrap on narrow phones; card grid reserves this space so buttons no
  longer overlap the top card row
- TABLEAU_FACEDOWN_FAN_FRAC 0.12 → 0.20 (layout.rs + card_plugin.rs):
  face-down stacks show ~67% more back strip per card on fresh deal,
  bringing the deepest column from ~27% to ~40% of available screen height
- update_tableau_fan_frac: return early when max face-up depth ≤ 1
  instead of overwriting the layout-computed adaptive value with the
  desktop minimum (0.25); fixes a regression where the portrait-phone
  adaptive fan_frac was silently snapped to 0.25 on every new deal
- update_tableau_fan_frac: also propagate facedown_fan_frac updates in
  the mid-game path (previously computed but immediately discarded)
- Android HUD buttons: compact Unicode icon labels (≡ ↩ ? ⏸ ⚙▾ +) with
  tighter padding (4 dp) and min-size (44 dp), max-width 90% — all 7
  buttons fit in a single 44 dp row on a 411 dp phone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-11 21:36:07 -07:00
parent 2b01f741b4
commit b1731fe68a
4 changed files with 139 additions and 143 deletions
+27 -16
View File
@@ -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<StateChangedEvent>,
game: Option<Res<GameStateResource>>,
@@ -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)]
+58 -107
View File
@@ -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<M: Component>(
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)),
+14 -4
View File
@@ -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];
+40 -16
View File
@@ -151,9 +151,22 @@ fn setup_table(
safe_area: Option<Res<SafeAreaInsets>>,
) {
// 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),
));
});
}
_ => {}
}
}
}