diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 1a521c4..08eb6aa 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -1990,7 +1990,7 @@ mod tests { // At game start waste is empty, so all 52 cards are across stock + tableau. let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); let layout = - crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); assert_eq!(positions.len(), 52); } @@ -2010,7 +2010,7 @@ mod tests { .collect(); assert_eq!(waste_ids.len(), 3); - let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); // Filter rendered positions to only waste cards (by card ID). @@ -2041,7 +2041,7 @@ mod tests { let waste_ids: std::collections::HashSet = waste_pile.iter().map(|c| c.id).collect(); - let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); let mut waste_rendered: Vec<_> = positions @@ -2084,7 +2084,7 @@ mod tests { let waste_ids: std::collections::HashSet = waste_pile.iter().map(|c| c.id).collect(); - let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); let mut waste_rendered: Vec<_> = positions @@ -2107,7 +2107,7 @@ mod tests { fn card_positions_tableau_cards_are_fanned_downward() { let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); let layout = - crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); // Collect positions for Tableau(6) (should have 7 cards). @@ -2419,7 +2419,7 @@ mod tests { #[test] fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() { let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); - let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); // Tableau(6) has 7 cards: 6 face-down + 1 face-up on top. @@ -2580,7 +2580,7 @@ mod tests { // Sanity-check: the new font size matches FONT_SIZE_FRAC × the // post-resize card width, so the in-place path is using the // refreshed Layout. - let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0); + let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true); let expected = expected_layout.card_size.x * FONT_SIZE_FRAC; assert!( (after - expected).abs() < 1e-3, diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index 0ac50c4..06d4bdc 100644 --- a/solitaire_engine/src/cursor_plugin.rs +++ b/solitaire_engine/src/cursor_plugin.rs @@ -604,7 +604,7 @@ mod tests { use crate::layout::compute_layout; let game = GameState::new(42, DrawMode::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // A cursor far off-screen should never hit anything. assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout)); } @@ -624,7 +624,7 @@ mod tests { let mut app = App::new(); app.add_plugins(MinimalPlugins) .insert_resource(GameStateResource(game)) - .insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0))) + .insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true))) .insert_resource(DragState::default()) .add_systems(Update, update_drop_target_overlays); app diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 645c83d..5c71826 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -36,7 +36,14 @@ use crate::events::{ }; use crate::font_plugin::FontResource; use crate::game_plugin::GameMutation; +#[cfg(target_os = "android")] +use crate::input_plugin::TouchDragSet; +use crate::layout::LayoutSystem; +#[cfg(target_os = "android")] +use crate::pause_plugin::PausedResource; use crate::resources::GameStateResource; +#[cfg(target_os = "android")] +use crate::resources::DragState; use crate::selection_plugin::SelectionState; use crate::time_attack_plugin::TimeAttackResource; use crate::ui_focus::{FocusGroup, Focusable}; @@ -119,6 +126,37 @@ pub struct HudDrawCycle; #[derive(Component, Debug)] pub struct HudSelection; +/// Marker on the HUD band background node (the translucent band behind buttons). +#[derive(Component, Debug)] +pub struct HudBand; + +/// Marker on the HUD score/info column root node. +#[derive(Component, Debug)] +pub struct HudColumn; + +/// Marker on the action button bar root node. +#[derive(Component, Debug)] +pub struct HudActionBar; + +/// Controls whether the in-game HUD (band, score column, action buttons) is +/// visible. Toggled on Android by tapping empty board space; always `Visible` +/// on desktop. Resets to `Visible` whenever a modal opens. +#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum HudVisibility { + #[default] + Visible, + Hidden, +} + +#[cfg(target_os = "android")] +#[derive(Resource, Debug, Default)] +struct HudTapTracker { + start_pos: Option, +} + +#[cfg(target_os = "android")] +const HUD_TAP_SLOP_PX: f32 = 15.0; + /// Drives the score-readout pulse: scales the [`HudScore`] text from /// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by /// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score @@ -350,6 +388,7 @@ impl Plugin for HudPlugin { .add_message::() .init_resource::() .init_resource::() + .init_resource::() // Escape-close handlers for popovers read this; init defensively // so HudPlugin works under MinimalPlugins in tests. .init_resource::>() @@ -358,6 +397,11 @@ impl Plugin for HudPlugin { .add_message::() .add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons)) .add_systems(Update, update_hud.after(GameMutation)) + .add_systems( + Update, + apply_hud_visibility.before(LayoutSystem::UpdateOnResize), + ) + .add_systems(Update, restore_hud_on_modal) .add_systems(Update, update_won_previously.after(GameMutation)) .add_systems(Update, announce_auto_complete.after(GameMutation)) .add_systems(Update, update_selection_hud) @@ -403,6 +447,17 @@ impl Plugin for HudPlugin { // `paint_action_buttons` would clobber the alpha back to 1.0 // mid-fade and produce a visible blip. .add_systems(Last, (update_action_fade, apply_action_fade).chain()); + #[cfg(target_os = "android")] + { + app.init_resource::() + .add_message::() + .add_systems( + Update, + toggle_hud_on_tap + .after(TouchDragSet::AfterStartDrag) + .in_set(TouchDragSet::BeforeEndDrag), + ); + } } } @@ -434,6 +489,7 @@ fn spawn_hud_band(insets: Option>, mut commands: Commands) { // entities and rendered behind UI regardless). ZIndex(Z_HUD - 1), SafeAreaAnchoredTop { base_top: BASE_TOP }, + HudBand, )); } @@ -516,6 +572,7 @@ fn spawn_hud( }, ZIndex(Z_HUD), SafeAreaAnchoredTop { base_top: SPACE_2 }, + HudColumn, )) .with_children(|hud| { // Tier 1 — primary readouts. Score is the protagonist (HEADLINE); @@ -697,6 +754,7 @@ fn spawn_action_buttons( }, ZIndex(Z_HUD), SafeAreaAnchoredTop { base_top: SPACE_2 }, + HudActionBar, )) .with_children(|row| { // The trailing `order` argument feeds `Focusable { group: Hud, order }` @@ -2223,6 +2281,82 @@ fn update_hud_typography( } } +#[allow(clippy::type_complexity)] +fn apply_hud_visibility( + hud_vis: Res, + mut nodes: Query< + &mut Visibility, + Or<(With, With, With)>, + >, + window_entities: Query<(Entity, &Window)>, + mut resize_events: MessageWriter, +) { + if !hud_vis.is_changed() { + return; + } + let v = if *hud_vis == HudVisibility::Visible { + Visibility::Visible + } else { + Visibility::Hidden + }; + for mut node_vis in &mut nodes { + *node_vis = v; + } + if let Some((entity, window)) = window_entities.iter().next() { + resize_events.write(WindowResized { + window: entity, + width: window.resolution.width(), + height: window.resolution.height(), + }); + } +} + +fn restore_hud_on_modal( + new_scrims: Query<(), (With, Added)>, + mut hud_vis: ResMut, +) { + if !new_scrims.is_empty() { + *hud_vis = HudVisibility::Visible; + } +} + +#[cfg(target_os = "android")] +fn toggle_hud_on_tap( + mut touch_events: MessageReader, + drag: Res, + scrims: Query<(), With>, + paused: Option>, + mut tracker: ResMut, + mut hud_vis: ResMut, +) { + use bevy::input::touch::TouchPhase; + if !scrims.is_empty() || paused.is_some_and(|p| p.0) { + tracker.start_pos = None; + return; + } + for event in touch_events.read() { + match event.phase { + TouchPhase::Started => { + tracker.start_pos = Some(event.position); + } + TouchPhase::Ended if drag.is_idle() => { + if let Some(start) = tracker.start_pos.take() { + if (event.position - start).length() < HUD_TAP_SLOP_PX { + *hud_vis = match *hud_vis { + HudVisibility::Visible => HudVisibility::Hidden, + HudVisibility::Hidden => HudVisibility::Visible, + }; + } + } + } + TouchPhase::Canceled | TouchPhase::Moved => { + tracker.start_pos = None; + } + _ => {} + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index a92e831..51e4b5e 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -51,6 +51,16 @@ use crate::resources::{DragState, GameStateResource, HintCycleIndex}; use crate::selection_plugin::SelectionState; use crate::time_attack_plugin::TimeAttackResource; +/// System-set labels used to anchor external systems relative to the touch +/// drag pipeline without duplicating the internal chain ordering. +#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] +pub enum TouchDragSet { + /// After `touch_start_drag` has run — drag state is populated if a card was touched. + AfterStartDrag, + /// Before `touch_end_drag` runs — drag state has not yet been cleared. + BeforeEndDrag, +} + /// Z-depth used for cards while being dragged — above all resting cards. const DRAG_Z: f32 = 500.0; @@ -103,10 +113,10 @@ impl Plugin for InputPlugin { follow_drag, end_drag.before(GameMutation), // Touch drag pipeline (parallel path through DragState). - touch_start_drag, + touch_start_drag.in_set(TouchDragSet::AfterStartDrag), touch_follow_drag, handle_double_tap, // before touch_end_drag: reads drag state pre-clear - touch_end_drag.before(GameMutation), + touch_end_drag.after(TouchDragSet::BeforeEndDrag).before(GameMutation), ) .chain(), ) @@ -1632,7 +1642,7 @@ mod tests { #[test] fn find_draggable_picks_top_of_tableau() { let game = GameState::new(42, DrawMode::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // In tableau 6, the visually topmost card is the last (face-up) one. // Its position: base.y + fan * 6. @@ -1646,7 +1656,7 @@ mod tests { #[test] fn find_draggable_skips_face_down_cards() { let game = GameState::new(42, DrawMode::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at // the bottom (index 6). Click at the topmost face-down card's @@ -1667,7 +1677,7 @@ mod tests { // face-up bottom card, clicking the visible card face missed the // hit-test box and only the bottom strip of the card responded. let game = GameState::new(42, DrawMode::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Tableau 6 starts with 6 face-down + 1 face-up. The face-up card // sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at @@ -1706,7 +1716,7 @@ mod tests { face_up: true, }); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // The Queen's geometric center (index 1) is inside the Jack's bounding box // (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the // Queen we click in her visible strip: the 0.25h band above the Jack's top @@ -1738,7 +1748,7 @@ mod tests { face_up: true, }); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Both cards in waste sit at the same (x, y). Clicking should pick // the visually top card (id 201), with count = 1. let pos = card_position(&game, &layout, &PileType::Waste, 0); @@ -1751,7 +1761,7 @@ mod tests { #[test] fn find_drop_target_hits_empty_tableau_pile_marker() { let game = GameState::new(42, DrawMode::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Move all cards out of tableau 0 so its marker is the only drop area. let mut game = game; game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear(); @@ -1763,7 +1773,7 @@ mod tests { #[test] fn find_drop_target_returns_none_for_origin() { let game = GameState::new(42, DrawMode::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let pos = layout.pile_positions[&PileType::Tableau(3)]; let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3)); assert_eq!(target, None); @@ -1772,7 +1782,7 @@ mod tests { #[test] fn pile_drop_rect_extends_for_tableau_with_cards() { let game = GameState::new(42, DrawMode::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Tableau 6 has 7 cards. let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game); // Expected: card_height + 6 * fan. fan = 0.25 * card_height, so @@ -1797,7 +1807,7 @@ mod tests { waste.cards.push(Card { id: 201, suit: Suit::Hearts, rank: Rank::Three, face_up: true }); waste.cards.push(Card { id: 202, suit: Suit::Clubs, rank: Rank::Four, face_up: true }); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let waste_base = layout.pile_positions[&PileType::Waste]; // Top card (slot=2) is at base.x + 2 * 0.28 * card_width. let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x; @@ -1813,7 +1823,7 @@ mod tests { #[test] fn find_draggable_returns_none_for_click_on_empty_pile() { let mut game = GameState::new(42, DrawMode::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Clear tableau 0 so it's an empty slot. game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear(); let pos = layout.pile_positions[&PileType::Tableau(0)]; @@ -1824,7 +1834,7 @@ mod tests { #[test] fn pile_drop_rect_is_card_sized_for_non_tableau() { let game = GameState::new(42, DrawMode::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); for pile in [ PileType::Waste, PileType::Foundation(2), @@ -2325,7 +2335,7 @@ mod tests { app.init_resource::(); app.init_resource::>(); app.insert_resource(crate::layout::LayoutResource( - crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0), + crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true), )); app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne))); app.add_systems(Update, handle_keyboard_hint); diff --git a/solitaire_engine/src/layout.rs b/solitaire_engine/src/layout.rs index 8044131..922e1d6 100644 --- a/solitaire_engine/src/layout.rs +++ b/solitaire_engine/src/layout.rs @@ -146,8 +146,9 @@ pub struct Layout { /// - Top row (stock, waste, 4 foundations) aligns with tableau columns /// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the /// waste/stock cluster from the foundations. -pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32) -> Layout { +pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, hud_visible: bool) -> Layout { let window = window.max(MIN_WINDOW); + let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 }; // Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width. let card_width_width_based = window.x / 9.0; @@ -169,7 +170,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32) - // (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT) let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC; let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT; - let card_width_height_based = (window.y - safe_area_top - safe_area_bottom - HUD_BAND_HEIGHT).max(0.0) / height_denom; + let card_width_height_based = (window.y - safe_area_top - safe_area_bottom - band_h).max(0.0) / height_denom; let card_width = card_width_width_based.min(card_width_height_based); let card_height = card_width * CARD_ASPECT; @@ -189,7 +190,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32) - }; let vertical_gap = card_height * VERTICAL_GAP_FRAC; - let top_y = window.y / 2.0 - safe_area_top - HUD_BAND_HEIGHT - h_gap - card_height / 2.0; + let top_y = window.y / 2.0 - safe_area_top - band_h - h_gap - card_height / 2.0; let tableau_y = top_y - card_height - vertical_gap; let mut pile_positions: HashMap = HashMap::with_capacity(13); @@ -270,15 +271,15 @@ mod tests { #[test] fn layout_has_all_thirteen_piles() { - assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0)); - assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0)); - assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0)); + assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true)); + assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true)); + assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0, true)); } #[test] fn card_size_scales_with_window_width() { - let small = compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0); - let large = compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0); + let small = compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true); + let large = compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0, true); assert!(large.card_size.x > small.card_size.x); assert!( (large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5, @@ -289,9 +290,9 @@ mod tests { #[test] fn layout_below_minimum_clamps_to_minimum() { // 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, 0.0, 0.0). - let below = compute_layout(Vec2::new(200.0, 200.0), 0.0, 0.0); - let at_min = compute_layout(MIN_WINDOW, 0.0, 0.0); + // axis up to MIN_WINDOW and the layout matches compute_layout(MIN_WINDOW, 0.0, 0.0, true). + let below = compute_layout(Vec2::new(200.0, 200.0), 0.0, 0.0, true); + let at_min = compute_layout(MIN_WINDOW, 0.0, 0.0, true); assert_eq!(below.card_size, at_min.card_size); } @@ -302,7 +303,7 @@ mod tests { #[test] fn phone_portrait_layout_fits_horizontally() { let window = Vec2::new(360.0, 800.0); - let layout = compute_layout(window, 0.0, 0.0); + let layout = compute_layout(window, 0.0, 0.0, true); let half_w = window.x / 2.0; let half_card = layout.card_size.x / 2.0; for (pile, pos) in &layout.pile_positions { @@ -323,7 +324,7 @@ mod tests { #[test] fn tableau_columns_are_sorted_left_to_right() { - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); for i in 0..6 { let lhs = layout.pile_positions[&PileType::Tableau(i)].x; let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x; @@ -333,7 +334,7 @@ mod tests { #[test] fn top_row_is_above_tableau_row() { - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let stock_y = layout.pile_positions[&PileType::Stock].y; let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y; assert!(stock_y > tableau_y); @@ -346,7 +347,7 @@ mod tests { #[test] fn top_row_clears_hud_band() { let window = Vec2::new(1280.0, 800.0); - let layout = compute_layout(window, 0.0, 0.0); + let layout = compute_layout(window, 0.0, 0.0, true); let stock_y = layout.pile_positions[&PileType::Stock].y; let card_top = stock_y + layout.card_size.y / 2.0; let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT; @@ -358,7 +359,7 @@ mod tests { #[test] fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() { - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let stock_x = layout.pile_positions[&PileType::Stock].x; let waste_x = layout.pile_positions[&PileType::Waste].x; let t0_x = layout.pile_positions[&PileType::Tableau(0)].x; @@ -369,7 +370,7 @@ mod tests { #[test] fn foundations_align_with_tableau_cols_3_to_6() { - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); for slot in 0..4_u8 { let f_x = layout.pile_positions[&PileType::Foundation(slot)].x; let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x; @@ -388,7 +389,7 @@ mod tests { // keep a worst-case 13-card column inside the window. (Most desktop // monitors fall into this regime — e.g. 1280x800, 1920x1080.) let window = Vec2::new(2560.0, 1080.0); - let layout = compute_layout(window, 0.0, 0.0); + let layout = compute_layout(window, 0.0, 0.0, true); let width_based = window.x / 9.0; assert!( layout.card_size.x < width_based, @@ -404,7 +405,7 @@ mod tests { // the bottleneck and card_width matches the legacy window.x / 9 // derivation exactly. let window = Vec2::new(900.0, 1600.0); - let layout = compute_layout(window, 0.0, 0.0); + let layout = compute_layout(window, 0.0, 0.0, true); let width_based = window.x / 9.0; assert!( (layout.card_size.x - width_based).abs() < 1e-3, @@ -418,7 +419,7 @@ mod tests { fn worst_case_tableau_fits_vertically_on_default_resolution() { // Default app resolution (see solitaire_app/src/main.rs). let window = Vec2::new(1280.0, 800.0); - let layout = compute_layout(window, 0.0, 0.0); + let layout = compute_layout(window, 0.0, 0.0, true); let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y; let card_h = layout.card_size.y; // Bottom edge of the 13th fanned face-up card. @@ -437,7 +438,7 @@ mod tests { fn worst_case_tableau_fits_vertically_on_full_hd() { // The bug originally reproduced at 1920x1080. Lock in a regression test. let window = Vec2::new(1920.0, 1080.0); - let layout = compute_layout(window, 0.0, 0.0); + let layout = compute_layout(window, 0.0, 0.0, true); let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y; let card_h = layout.card_size.y; let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0; @@ -453,8 +454,8 @@ mod tests { /// 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), 0.0, 0.0); - let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0, 0.0); + let desktop = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0, 0.0, true); assert!( phone.tableau_fan_frac > desktop.tableau_fan_frac, "portrait phone fan_frac ({:.3}) should exceed desktop ({:.3})", @@ -468,7 +469,7 @@ mod tests { #[test] fn expanded_fan_fits_phone_viewport() { let window = Vec2::new(360.0, 800.0); - let layout = compute_layout(window, 0.0, 0.0); + let layout = compute_layout(window, 0.0, 0.0, true); 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; @@ -485,7 +486,7 @@ mod tests { /// 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), 0.0, 0.0); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); assert!( (layout.tableau_fan_frac - TABLEAU_FAN_FRAC).abs() < 1e-3, "desktop fan_frac should stay at minimum {TABLEAU_FAN_FRAC}, got {:.4}", @@ -500,7 +501,7 @@ mod tests { Vec2::new(1280.0, 800.0), Vec2::new(1920.0, 1080.0), ] { - let layout = compute_layout(window, 0.0, 0.0); + let layout = compute_layout(window, 0.0, 0.0, true); let half_w = window.x / 2.0; let half_card = layout.card_size.x / 2.0; for (pile, pos) in &layout.pile_positions { @@ -526,8 +527,8 @@ mod tests { #[test] fn safe_area_top_shifts_top_row_downward() { let window = Vec2::new(360.0, 800.0); - let without = compute_layout(window, 0.0, 0.0); - let with_inset = compute_layout(window, 32.0, 0.0); + let without = compute_layout(window, 0.0, 0.0, true); + let with_inset = compute_layout(window, 32.0, 0.0, true); let stock_no_inset = without.pile_positions[&PileType::Stock].y; let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y; assert!( @@ -548,8 +549,8 @@ mod tests { #[test] fn safe_area_top_does_not_affect_horizontal_layout() { let window = Vec2::new(360.0, 800.0); - let without = compute_layout(window, 0.0, 0.0); - let with_inset = compute_layout(window, 32.0, 0.0); + let without = compute_layout(window, 0.0, 0.0, true); + let with_inset = compute_layout(window, 32.0, 0.0, true); for pile in [ PileType::Stock, PileType::Waste, @@ -568,8 +569,8 @@ mod tests { #[test] fn safe_area_bottom_reduces_tableau_fan() { let window = Vec2::new(360.0, 800.0); - let without = compute_layout(window, 0.0, 0.0); - let with_inset = compute_layout(window, 0.0, 48.0); + let without = compute_layout(window, 0.0, 0.0, true); + let with_inset = compute_layout(window, 0.0, 48.0, true); assert!( with_inset.tableau_fan_frac <= without.tableau_fan_frac, "safe_area_bottom=48 must not increase tableau_fan_frac: {:.4} → {:.4}", @@ -591,8 +592,8 @@ mod tests { #[test] fn safe_area_bottom_does_not_affect_horizontal_layout() { let window = Vec2::new(360.0, 800.0); - let without = compute_layout(window, 0.0, 0.0); - let with_inset = compute_layout(window, 0.0, 48.0); + let without = compute_layout(window, 0.0, 0.0, true); + let with_inset = compute_layout(window, 0.0, 48.0, true); for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] { assert!( (without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3, diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 073f1c8..5a1bbb8 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -113,9 +113,9 @@ pub use game_plugin::{ pub use help_plugin::{HelpPlugin, HelpScreen}; pub use home_plugin::{HomePlugin, HomeScreen}; pub use hud_plugin::{ - streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton, - MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton, - StreakFlourish, UndoButton, + streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, HudVisibility, + MenuButton, MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton, + PauseButton, StreakFlourish, UndoButton, }; pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen}; pub use input_plugin::InputPlugin; diff --git a/solitaire_engine/src/radial_menu.rs b/solitaire_engine/src/radial_menu.rs index e25020c..d973f09 100644 --- a/solitaire_engine/src/radial_menu.rs +++ b/solitaire_engine/src/radial_menu.rs @@ -801,7 +801,7 @@ mod tests { fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) { app.insert_resource(GameStateResource(state)); - app.insert_resource(LayoutResource(compute_layout(layout_window, 0.0, 0.0))); + app.insert_resource(LayoutResource(compute_layout(layout_window, 0.0, 0.0, true))); app.world_mut().resource_mut::().0 = Some(cursor); } @@ -913,7 +913,7 @@ mod tests { fn right_click_press_on_face_up_card_opens_radial() { let mut app = radial_test_app(); let layout_window = Vec2::new(1280.0, 800.0); - let layout = compute_layout(layout_window, 0.0, 0.0); + let layout = compute_layout(layout_window, 0.0, 0.0, true); let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; install_resources(&mut app, ace_only_state(), layout_window, ace_pos); @@ -950,7 +950,7 @@ mod tests { fn right_click_release_over_destination_fires_move_request() { let mut app = radial_test_app(); let layout_window = Vec2::new(1280.0, 800.0); - let layout = compute_layout(layout_window, 0.0, 0.0); + let layout = compute_layout(layout_window, 0.0, 0.0, true); let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; install_resources(&mut app, ace_only_state(), layout_window, ace_pos); @@ -989,7 +989,7 @@ mod tests { fn right_click_release_outside_any_destination_cancels() { let mut app = radial_test_app(); let layout_window = Vec2::new(1280.0, 800.0); - let layout = compute_layout(layout_window, 0.0, 0.0); + let layout = compute_layout(layout_window, 0.0, 0.0, true); let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; install_resources(&mut app, ace_only_state(), layout_window, ace_pos); @@ -1016,7 +1016,7 @@ mod tests { fn escape_cancels_active_radial() { let mut app = radial_test_app(); let layout_window = Vec2::new(1280.0, 800.0); - let layout = compute_layout(layout_window, 0.0, 0.0); + let layout = compute_layout(layout_window, 0.0, 0.0, true); let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; install_resources(&mut app, ace_only_state(), layout_window, ace_pos); @@ -1039,7 +1039,7 @@ mod tests { fn right_click_on_face_down_card_does_not_open_radial() { let mut app = radial_test_app(); let layout_window = Vec2::new(1280.0, 800.0); - let layout = compute_layout(layout_window, 0.0, 0.0); + let layout = compute_layout(layout_window, 0.0, 0.0, true); let king_pos = layout.pile_positions[&PileType::Tableau(0)]; install_resources(&mut app, face_down_only_state(), layout_window, king_pos); diff --git a/solitaire_engine/src/table_plugin.rs b/solitaire_engine/src/table_plugin.rs index eb21aa0..e0043b8 100644 --- a/solitaire_engine/src/table_plugin.rs +++ b/solitaire_engine/src/table_plugin.rs @@ -10,6 +10,7 @@ use solitaire_core::card::Suit; use solitaire_core::pile::PileType; use crate::events::{HintVisualEvent, StateChangedEvent}; +use crate::hud_plugin::HudVisibility; use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem}; use crate::safe_area::SafeAreaInsets; use crate::resources::GameStateResource; @@ -149,6 +150,7 @@ fn setup_table( settings: Option>, bg_images: Option>, safe_area: Option>, + hud_vis: Option>, ) { // Only spawn a camera if one does not already exist (e.g. a parent app // may have added one in tests). Use the felt-green clear colour so the @@ -179,7 +181,8 @@ fn setup_table( let insets = safe_area.as_deref().copied().unwrap_or_default(); let safe_area_top = insets.top / scale; let safe_area_bottom = insets.bottom / scale; - let layout = compute_layout(window_size, safe_area_top, safe_area_bottom); + let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible; + let layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible); let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background); @@ -314,6 +317,7 @@ fn on_window_resized( mut events: MessageReader, safe_area: Option>, windows: Query<&Window>, + hud_vis: Option>, mut layout_res: Option>, mut backgrounds: Query< (&mut Sprite, &mut Transform), @@ -329,7 +333,8 @@ fn on_window_resized( let insets = safe_area.as_deref().copied().unwrap_or_default(); let safe_area_top = insets.top / scale; let safe_area_bottom = insets.bottom / scale; - let new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom); + let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible; + let new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible); if let Some(layout_res) = layout_res.as_deref_mut() { layout_res.0 = new_layout.clone();