feat(engine): Android UX sweep — tap-to-move, safe area, HUD polish
Single-tap auto-move (input_plugin): - Remove 0.5 s double-tap window; any uncommitted TouchPhase::Ended on a face-up card now fires MoveRequestEvent immediately. Bottom safe-area inset (layout, table_plugin): - compute_layout gains safe_area_bottom param; height budget and bottom margin both respect the navigation bar reservation. Card back contrast (card_plugin): - CardBackFrame child sprite (gray, card_size + 3 px, local z=-0.01) spawned behind every face-down card so the dark back_0.png reads as a distinct rectangle against the dark felt. HUD action bar compactness (hud_plugin): - max_width 50% → 65% on the action button row; 6 buttons now wrap to 2 rows instead of 3 on a 360 dp phone. Dynamic tableau fan fraction (layout, card_plugin): - Layout gains available_tableau_height field. - update_tableau_fan_frac system (after GameMutation, before sync_cards_on_change) grows face-up fan from 0.25 to the window max as revealed column depth increases. Face-down fan is left at the window-adaptive value so stacks stay visible. ModesPopover + MenuPopover light-dismiss (hud_plugin): - Fullscreen transparent Button backdrop spawned at Z_HUD+4 behind each popover; tapping outside the panel despawns both panel and backdrop. Stock badge legibility (card_plugin): - Badge font TYPE_CAPTION (11 pt) → TYPE_BODY (14 pt); background sprite 28×16 → 34×20 world units. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,7 +35,7 @@ use crate::ui_theme::{
|
||||
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
||||
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
|
||||
CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TEXT_PRIMARY_HC,
|
||||
TYPE_CAPTION, Z_STOCK_BADGE,
|
||||
TYPE_BODY, Z_STOCK_BADGE,
|
||||
};
|
||||
|
||||
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
||||
@@ -263,6 +263,23 @@ pub struct ShadowEntity;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardShadow;
|
||||
|
||||
/// Marker on the thin contrasting border sprite spawned behind face-down cards.
|
||||
///
|
||||
/// Face-down cards use `back_0.png` which is near-black (`#1a1a1a`). On the
|
||||
/// dark-green felt the edges are nearly invisible. This child sprite — slightly
|
||||
/// larger than the card, rendered at local z=-0.01 so it peeks out as a thin
|
||||
/// frame — gives every face-down card a visible perimeter.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardBackFrame;
|
||||
|
||||
/// Fill colour for the face-down card border frame. Medium gray so it reads as
|
||||
/// a neutral "edge" without competing with the suit colours on face-up cards.
|
||||
const CARD_BACK_FRAME_COLOR: Color = Color::srgb(0.38, 0.38, 0.38);
|
||||
|
||||
/// Extra width/height (in world units) added to each side of the card to form
|
||||
/// the visible border. 3 world units ≈ 3 dp on a 1× screen.
|
||||
const CARD_BACK_FRAME_PADDING: f32 = 3.0;
|
||||
|
||||
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
|
||||
/// shadow given whether its parent card is currently part of the dragged
|
||||
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
|
||||
@@ -318,6 +335,21 @@ fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||
));
|
||||
}
|
||||
|
||||
/// Spawns a `CardBackFrame` child behind a face-down card entity so the dark
|
||||
/// back PNG has a visible perimeter against the dark felt.
|
||||
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||
parent.spawn((
|
||||
CardBackFrame,
|
||||
Sprite {
|
||||
color: CARD_BACK_FRAME_COLOR,
|
||||
custom_size: Some(card_size + Vec2::splat(CARD_BACK_FRAME_PADDING)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, 0.0, -0.01),
|
||||
Visibility::default(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Throttle interval for resize-driven card snap work, in seconds.
|
||||
///
|
||||
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
|
||||
@@ -373,6 +405,9 @@ impl Plugin for CardPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
update_tableau_fan_frac
|
||||
.after(GameMutation)
|
||||
.before(sync_cards_on_change),
|
||||
sync_cards_on_change.after(GameMutation),
|
||||
resync_cards_on_settings_change.before(sync_cards_on_change),
|
||||
start_flip_anim.after(GameMutation),
|
||||
@@ -706,6 +741,13 @@ fn spawn_card_entity(
|
||||
entity.with_children(|b| {
|
||||
add_card_shadow_child(b, layout.card_size);
|
||||
});
|
||||
// Face-down cards get a thin contrasting border frame so the dark back
|
||||
// PNG reads as a distinct rectangle against the dark felt.
|
||||
if !card.face_up {
|
||||
entity.with_children(|b| {
|
||||
add_card_back_frame_child(b, layout.card_size);
|
||||
});
|
||||
}
|
||||
// When PNG faces are loaded the rank/suit are baked into the image.
|
||||
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
||||
if card_images.is_none() {
|
||||
@@ -781,6 +823,11 @@ fn update_card_entity(
|
||||
commands.entity(entity).with_children(|b| {
|
||||
add_card_shadow_child(b, layout.card_size);
|
||||
});
|
||||
if !card.face_up {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
add_card_back_frame_child(b, layout.card_size);
|
||||
});
|
||||
}
|
||||
if card_images.is_none() {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
@@ -1438,8 +1485,8 @@ fn update_stock_empty_indicator(
|
||||
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
|
||||
|
||||
/// Width / height of the badge background sprite, in world pixels. Sized so
|
||||
/// a 2-digit count (max "24") fits comfortably with `TYPE_CAPTION` text.
|
||||
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(28.0, 16.0);
|
||||
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
|
||||
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0);
|
||||
|
||||
/// Returns the count of cards currently in the stock pile.
|
||||
///
|
||||
@@ -1484,7 +1531,7 @@ fn spawn_stock_count_badge(
|
||||
};
|
||||
let text_font = TextFont {
|
||||
font: font.cloned().unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
|
||||
@@ -1629,13 +1676,20 @@ fn snap_cards_on_window_resize(
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
entities: Query<
|
||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||
(Without<CardLabel>, Without<CardShadow>),
|
||||
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
|
||||
>,
|
||||
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||
shadow_query: Query<&mut Sprite, (With<CardShadow>, Without<CardEntity>, Without<PileMarker>)>,
|
||||
shadow_query: Query<
|
||||
&mut Sprite,
|
||||
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>, Without<CardBackFrame>),
|
||||
>,
|
||||
frame_query: Query<
|
||||
&mut Sprite,
|
||||
(With<CardBackFrame>, Without<CardEntity>, Without<CardShadow>, Without<PileMarker>),
|
||||
>,
|
||||
mut pile_markers: Query<
|
||||
(Entity, &PileMarker, &mut Sprite),
|
||||
(Without<CardEntity>, Without<CardShadow>),
|
||||
(Without<CardEntity>, Without<CardShadow>, Without<CardBackFrame>),
|
||||
>,
|
||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||
) {
|
||||
@@ -1665,6 +1719,7 @@ fn snap_cards_on_window_resize(
|
||||
entities,
|
||||
label_query,
|
||||
shadow_query,
|
||||
frame_query,
|
||||
);
|
||||
|
||||
apply_stock_empty_indicator(
|
||||
@@ -1691,7 +1746,7 @@ fn snap_cards_on_window_resize(
|
||||
///
|
||||
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
|
||||
/// retargeted relative to the previous card-size's position.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn resize_cards_in_place(
|
||||
commands: &mut Commands,
|
||||
game: &GameState,
|
||||
@@ -1699,12 +1754,16 @@ fn resize_cards_in_place(
|
||||
card_images: Option<&CardImageSet>,
|
||||
mut entities: Query<
|
||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||
(Without<CardLabel>, Without<CardShadow>),
|
||||
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
|
||||
>,
|
||||
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||
mut shadow_query: Query<
|
||||
&mut Sprite,
|
||||
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>),
|
||||
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>, Without<CardBackFrame>),
|
||||
>,
|
||||
mut frame_query: Query<
|
||||
&mut Sprite,
|
||||
(With<CardBackFrame>, Without<CardEntity>, Without<CardShadow>, Without<PileMarker>),
|
||||
>,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
@@ -1756,6 +1815,55 @@ fn resize_cards_in_place(
|
||||
font.font_size = new_font_size;
|
||||
}
|
||||
}
|
||||
|
||||
// Resize every face-down border frame to match the new card size.
|
||||
let frame_size = layout.card_size + Vec2::splat(CARD_BACK_FRAME_PADDING);
|
||||
for mut frame_sprite in frame_query.iter_mut() {
|
||||
frame_sprite.custom_size = Some(frame_size);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
fn update_tableau_fan_frac(
|
||||
mut events: MessageReader<StateChangedEvent>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut layout: Option<ResMut<LayoutResource>>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(game) = game else { return; };
|
||||
let Some(layout) = layout.as_mut() else { return; };
|
||||
|
||||
let max_depth = (0..7_usize)
|
||||
.filter_map(|i| game.0.piles.get(&solitaire_core::pile::PileType::Tableau(i)))
|
||||
.map(|pile| pile.cards.iter().filter(|c| c.face_up).count())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
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))
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1862,7 +1970,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);
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
let positions = card_positions(&g, &layout);
|
||||
assert_eq!(positions.len(), 52);
|
||||
}
|
||||
@@ -1882,7 +1990,7 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(waste_ids.len(), 3);
|
||||
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0);
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
// Filter rendered positions to only waste cards (by card ID).
|
||||
@@ -1911,7 +2019,7 @@ mod tests {
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
waste_pile.iter().map(|c| c.id).collect();
|
||||
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0);
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let mut waste_rendered: Vec<_> = positions
|
||||
@@ -1936,7 +2044,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);
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
// Collect positions for Tableau(6) (should have 7 cards).
|
||||
@@ -2248,7 +2356,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);
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
// Tableau(6) has 7 cards: 6 face-down + 1 face-up on top.
|
||||
@@ -2409,7 +2517,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);
|
||||
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0);
|
||||
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
|
||||
assert!(
|
||||
(after - expected).abs() < 1e-3,
|
||||
|
||||
@@ -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);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
// 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)))
|
||||
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0)))
|
||||
.insert_resource(DragState::default())
|
||||
.add_systems(Update, update_drop_target_overlays);
|
||||
app
|
||||
|
||||
@@ -276,6 +276,16 @@ pub struct MenuButton;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct MenuPopover;
|
||||
|
||||
/// Fullscreen transparent backdrop spawned behind the [`MenuPopover`].
|
||||
/// Pressing it (tap anywhere outside the popover) light-dismisses the menu.
|
||||
#[derive(Component, Debug)]
|
||||
struct MenuPopoverBackdrop;
|
||||
|
||||
/// Fullscreen transparent backdrop spawned behind the [`ModesPopover`].
|
||||
/// Pressing it (tap anywhere outside the popover) light-dismisses it.
|
||||
#[derive(Component, Debug)]
|
||||
struct ModesPopoverBackdrop;
|
||||
|
||||
/// One row inside the [`MenuPopover`]. The variant selects which
|
||||
/// `Toggle*RequestEvent` the click handler fires.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
@@ -359,8 +369,10 @@ impl Plugin for HudPlugin {
|
||||
handle_help_button,
|
||||
handle_modes_button,
|
||||
handle_mode_option_click,
|
||||
handle_modes_backdrop_click,
|
||||
handle_menu_button,
|
||||
handle_menu_option_click,
|
||||
handle_menu_backdrop_click,
|
||||
paint_action_buttons,
|
||||
),
|
||||
)
|
||||
@@ -627,13 +639,11 @@ fn spawn_action_buttons(
|
||||
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: 50%` is >= 640 px
|
||||
// and the row stays a single line. On a 360 dp phone
|
||||
// 50% is 180 px and the row wraps to two-three lines —
|
||||
// which keeps the buttons out of the left HUD column's
|
||||
// horizontal range and prevents the off-screen-left
|
||||
// clipping seen in the v0.22.3 hardware screenshot.
|
||||
max_width: Val::Percent(50.0),
|
||||
// (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),
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
// When the row wraps, buttons pack to the *end* of each
|
||||
// line so the row stays visually right-aligned (matches
|
||||
@@ -853,6 +863,7 @@ fn handle_help_button(
|
||||
fn handle_modes_button(
|
||||
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
|
||||
popovers: Query<Entity, With<ModesPopover>>,
|
||||
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
@@ -866,6 +877,9 @@ fn handle_modes_button(
|
||||
}
|
||||
if let Ok(entity) = popovers.single() {
|
||||
commands.entity(entity).despawn();
|
||||
for e in &backdrops {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
} else {
|
||||
spawn_modes_popover(
|
||||
&mut commands,
|
||||
@@ -966,6 +980,23 @@ fn spawn_modes_popover(
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fullscreen transparent backdrop at Z_HUD+4 (below the popover at
|
||||
// Z_HUD+5) so tapping outside the panel light-dismisses it.
|
||||
commands.spawn((
|
||||
ModesPopoverBackdrop,
|
||||
Button,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::NONE),
|
||||
ZIndex(Z_HUD + 4),
|
||||
));
|
||||
}
|
||||
|
||||
/// Dispatches the click on a popover row to the matching request event,
|
||||
@@ -979,6 +1010,7 @@ fn spawn_modes_popover(
|
||||
fn handle_mode_option_click(
|
||||
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
|
||||
popovers: Query<Entity, With<ModesPopover>>,
|
||||
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
mut zen: MessageWriter<StartZenRequestEvent>,
|
||||
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||
@@ -1011,8 +1043,12 @@ fn handle_mode_option_click(
|
||||
}
|
||||
}
|
||||
if clicked_any
|
||||
&& let Ok(entity) = popovers.single() {
|
||||
&& let Ok(entity) = popovers.single()
|
||||
{
|
||||
commands.entity(entity).despawn();
|
||||
for e in &backdrops {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1022,6 +1058,7 @@ fn handle_mode_option_click(
|
||||
fn handle_menu_button(
|
||||
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
|
||||
popovers: Query<Entity, With<MenuPopover>>,
|
||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
@@ -1033,6 +1070,9 @@ fn handle_menu_button(
|
||||
}
|
||||
if let Ok(entity) = popovers.single() {
|
||||
commands.entity(entity).despawn();
|
||||
for e in &backdrops {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
} else {
|
||||
spawn_menu_popover(&mut commands, font_res.as_deref());
|
||||
}
|
||||
@@ -1120,6 +1160,23 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Transparent fullscreen backdrop behind the popover — tapping anywhere
|
||||
// outside the panel light-dismisses it via handle_menu_backdrop_click.
|
||||
commands.spawn((
|
||||
MenuPopoverBackdrop,
|
||||
Button,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::NONE),
|
||||
ZIndex(Z_HUD + 4),
|
||||
));
|
||||
}
|
||||
|
||||
/// Dispatches the click on a menu row to the matching toggle event,
|
||||
@@ -1128,6 +1185,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
fn handle_menu_option_click(
|
||||
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
|
||||
popovers: Query<Entity, With<MenuPopover>>,
|
||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||
mut stats: MessageWriter<ToggleStatsRequestEvent>,
|
||||
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
|
||||
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||
@@ -1162,6 +1220,43 @@ fn handle_menu_option_click(
|
||||
if clicked_any
|
||||
&& let Ok(entity) = popovers.single() {
|
||||
commands.entity(entity).despawn();
|
||||
for e in &backdrops {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Despawns the [`ModesPopover`] and its backdrop when the player taps
|
||||
/// anywhere outside the panel.
|
||||
fn handle_modes_backdrop_click(
|
||||
interaction_query: Query<&Interaction, (With<ModesPopoverBackdrop>, Changed<Interaction>)>,
|
||||
popovers: Query<Entity, With<ModesPopover>>,
|
||||
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||
if !pressed {
|
||||
return;
|
||||
}
|
||||
for e in popovers.iter().chain(backdrops.iter()) {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Despawns the [`MenuPopover`] and its backdrop when the player taps
|
||||
/// anywhere outside the panel (i.e. the transparent backdrop is pressed).
|
||||
fn handle_menu_backdrop_click(
|
||||
interaction_query: Query<&Interaction, (With<MenuPopoverBackdrop>, Changed<Interaction>)>,
|
||||
popovers: Query<Entity, With<MenuPopover>>,
|
||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||
if !pressed {
|
||||
return;
|
||||
}
|
||||
for e in popovers.iter().chain(backdrops.iter()) {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1226,11 +1226,7 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
|
||||
/// Maximum seconds between two clicks to count as a double-click.
|
||||
const DOUBLE_CLICK_WINDOW: f32 = 0.35;
|
||||
|
||||
/// Maximum seconds between two taps to count as a double-tap.
|
||||
/// Slightly wider than the mouse window — touch screens have higher latency.
|
||||
const DOUBLE_TAP_WINDOW: f32 = 0.5;
|
||||
|
||||
/// Duration of the lime flash applied to moved cards when a double-tap
|
||||
/// Duration of the lime flash applied to moved cards when a tap
|
||||
/// auto-move succeeds. Short enough not to linger, long enough to register
|
||||
/// during the card animation (~0.3 s).
|
||||
const DOUBLE_TAP_FLASH_SECS: f32 = 0.35;
|
||||
@@ -1389,36 +1385,28 @@ fn handle_double_click(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #27b — Double-tap to auto-move (touch equivalent of double-click)
|
||||
// Tap-to-move (touch equivalent of mouse auto-move)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// System that detects double-taps on face-up cards and fires `MoveRequestEvent`
|
||||
/// to the best legal destination — the touch equivalent of [`handle_double_click`].
|
||||
/// Fires `MoveRequestEvent` when the player taps a face-up card without
|
||||
/// dragging — the touch equivalent of the mouse auto-move flow.
|
||||
///
|
||||
/// Must run **before** `touch_end_drag` in the system chain. At
|
||||
/// `TouchPhase::Ended` the drag state still holds `active_touch_id`,
|
||||
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
|
||||
/// are cleared and the tap/drag distinction is permanently lost.
|
||||
///
|
||||
/// A pure tap is identified by `drag.active_touch_id.is_some() &&
|
||||
/// !drag.committed`: the touch began (so `touch_start_drag` populated
|
||||
/// `drag`) but the drag threshold was never crossed.
|
||||
///
|
||||
/// Move priority matches [`handle_double_click`]:
|
||||
/// 1. Move the single top card to its best foundation (or tableau).
|
||||
/// 2. If no single-card move exists and the selection spans multiple
|
||||
/// face-up cards, move the whole stack to the best tableau column.
|
||||
/// 3. If both priorities fail, fire `MoveRejectedEvent` for audio + shake
|
||||
/// feedback.
|
||||
/// Move priority:
|
||||
/// 1. Single top card to its best foundation (or tableau).
|
||||
/// 2. Whole face-up run to best tableau column when no single-card move exists.
|
||||
/// 3. `MoveRejectedEvent` for audio + shake feedback when no legal move found.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_double_tap(
|
||||
mut touch_events: MessageReader<TouchInput>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
radial: Option<Res<RightClickRadialState>>,
|
||||
time: Res<Time>,
|
||||
drag: Res<DragState>,
|
||||
game: Res<GameStateResource>,
|
||||
mut last_tap: Local<HashMap<u32, f32>>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
mut rejected: MessageWriter<MoveRejectedEvent>,
|
||||
mut commands: Commands,
|
||||
@@ -1427,33 +1415,21 @@ fn handle_double_tap(
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Long-press opened the radial in this frame — let radial_handle_release_or_cancel
|
||||
// own the finger-lift event instead.
|
||||
// Long-press opened the radial — let radial_handle_release_or_cancel own
|
||||
// the finger-lift event.
|
||||
if radial.is_some_and(|r| r.is_active()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only active when a touch is tracked and hasn't crossed the drag threshold.
|
||||
let Some(active_id) = drag.active_touch_id else { return };
|
||||
if drag.committed {
|
||||
return;
|
||||
}
|
||||
|
||||
for event in touch_events.read() {
|
||||
if event.id != active_id {
|
||||
if event.id != active_id || event.phase != TouchPhase::Ended {
|
||||
continue;
|
||||
}
|
||||
match event.phase {
|
||||
TouchPhase::Canceled => {
|
||||
// Cancelled touch — clear any pending tap state for these cards.
|
||||
for &id in &drag.cards {
|
||||
last_tap.remove(&id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
TouchPhase::Ended => {}
|
||||
_ => continue,
|
||||
}
|
||||
|
||||
// Uncommitted touch ended = pure tap.
|
||||
let Some(&top_card_id) = drag.cards.last() else { return };
|
||||
@@ -1467,15 +1443,8 @@ fn handle_double_tap(
|
||||
return;
|
||||
}
|
||||
|
||||
let now = time.elapsed_secs();
|
||||
let prev = last_tap.get(&top_card_id).copied().unwrap_or(f32::NEG_INFINITY);
|
||||
|
||||
if now - prev <= DOUBLE_TAP_WINDOW {
|
||||
last_tap.remove(&top_card_id);
|
||||
|
||||
// Priority 1: move single top card.
|
||||
if let Some(dest) = best_destination(top_card, &game.0) {
|
||||
// Flash the card lime to confirm the double-tap registered.
|
||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||
if ce.card_id == top_card_id {
|
||||
sprite.color = STATE_SUCCESS;
|
||||
@@ -1502,7 +1471,6 @@ fn handle_double_tap(
|
||||
drag.cards.len(),
|
||||
)
|
||||
{
|
||||
// Flash all cards in the moving run.
|
||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||
if drag.cards.contains(&ce.card_id) {
|
||||
sprite.color = STATE_SUCCESS;
|
||||
@@ -1523,9 +1491,6 @@ fn handle_double_tap(
|
||||
to: pile.clone(),
|
||||
count: drag.cards.len(),
|
||||
});
|
||||
} else {
|
||||
last_tap.insert(top_card_id, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1667,7 +1632,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);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
|
||||
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||
// Its position: base.y + fan * 6.
|
||||
@@ -1681,7 +1646,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);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
|
||||
// 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
|
||||
@@ -1702,7 +1667,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);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
|
||||
// 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
|
||||
@@ -1741,7 +1706,7 @@ mod tests {
|
||||
face_up: true,
|
||||
});
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
// 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
|
||||
@@ -1773,7 +1738,7 @@ mod tests {
|
||||
face_up: true,
|
||||
});
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
// 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);
|
||||
@@ -1786,7 +1751,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);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
// 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();
|
||||
@@ -1798,7 +1763,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);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
let pos = layout.pile_positions[&PileType::Tableau(3)];
|
||||
let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3));
|
||||
assert_eq!(target, None);
|
||||
@@ -1807,7 +1772,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);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
// 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
|
||||
@@ -1832,7 +1797,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);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
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;
|
||||
@@ -1848,7 +1813,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);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
// 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)];
|
||||
@@ -1859,7 +1824,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);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
for pile in [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(2),
|
||||
@@ -2360,7 +2325,7 @@ mod tests {
|
||||
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.insert_resource(crate::layout::LayoutResource(
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0),
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0),
|
||||
));
|
||||
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
|
||||
app.add_systems(Update, handle_keyboard_hint);
|
||||
@@ -2382,13 +2347,5 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Task #27b — double-tap constants
|
||||
#[test]
|
||||
fn double_tap_window_is_wider_than_double_click_window() {
|
||||
// Compile-time check: touch needs a wider window than mouse due to
|
||||
// higher input latency. `const { assert! }` catches regressions at
|
||||
// build time rather than waiting for a test run.
|
||||
const { assert!(DOUBLE_TAP_WINDOW > DOUBLE_CLICK_WINDOW) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,15 +106,21 @@ pub struct Layout {
|
||||
/// 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,
|
||||
/// Vertical pixel budget available for tableau fan steps — the distance
|
||||
/// from the top edge of the first tableau card to the bottom margin, in
|
||||
/// logical pixels. Used by `card_plugin::update_tableau_fan_frac` to
|
||||
/// recompute `tableau_fan_frac` dynamically based on the actual max
|
||||
/// face-up column depth after each game state change.
|
||||
pub available_tableau_height: f32,
|
||||
}
|
||||
|
||||
/// Compute the board layout from a window size and safe-area top inset.
|
||||
/// Compute the board layout from a window size and safe-area insets.
|
||||
///
|
||||
/// `safe_area_top` is the **logical-pixel** height of the OS-reserved region
|
||||
/// at the top of the screen (status bar on Android). Pass `0.0` on desktop or
|
||||
/// when the inset is unknown. Note that Android's `WindowInsets` API returns
|
||||
/// **physical** pixels; callers must divide by `window.scale_factor()` before
|
||||
/// passing the value here.
|
||||
/// `safe_area_top` and `safe_area_bottom` are the **logical-pixel** heights of
|
||||
/// the OS-reserved regions at the top and bottom of the screen (status bar and
|
||||
/// gesture / navigation bar on Android). Pass `0.0` on desktop or when the
|
||||
/// inset is unknown. Android's `WindowInsets` API returns **physical** pixels;
|
||||
/// callers must divide by `window.scale_factor()` before passing values here.
|
||||
///
|
||||
/// # Geometry
|
||||
/// - `card_width` is the smaller of:
|
||||
@@ -130,7 +136,7 @@ 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) -> Layout {
|
||||
pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32) -> Layout {
|
||||
let window = window.max(MIN_WINDOW);
|
||||
|
||||
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
|
||||
@@ -153,7 +159,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32) -> Layout {
|
||||
// (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 - HUD_BAND_HEIGHT).max(0.0) / height_denom;
|
||||
let card_width_height_based = (window.y - safe_area_top - safe_area_bottom - HUD_BAND_HEIGHT).max(0.0) / height_denom;
|
||||
|
||||
let card_width = card_width_width_based.min(card_width_height_based);
|
||||
let card_height = card_width * CARD_ASPECT;
|
||||
@@ -203,7 +209,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32) -> Layout {
|
||||
//
|
||||
// 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 avail = (tableau_y - (-window.y / 2.0 + safe_area_bottom + 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 {
|
||||
@@ -222,6 +228,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32) -> Layout {
|
||||
pile_positions,
|
||||
tableau_fan_frac,
|
||||
tableau_facedown_fan_frac,
|
||||
available_tableau_height: avail,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,15 +260,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn layout_has_all_thirteen_piles() {
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0), 0.0));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0), 0.0));
|
||||
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0));
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_size_scales_with_window_width() {
|
||||
let small = compute_layout(Vec2::new(800.0, 600.0), 0.0);
|
||||
let large = compute_layout(Vec2::new(1920.0, 1080.0), 0.0);
|
||||
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);
|
||||
assert!(large.card_size.x > small.card_size.x);
|
||||
assert!(
|
||||
(large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5,
|
||||
@@ -272,9 +279,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).
|
||||
let below = compute_layout(Vec2::new(200.0, 200.0), 0.0);
|
||||
let at_min = compute_layout(MIN_WINDOW, 0.0);
|
||||
// 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);
|
||||
assert_eq!(below.card_size, at_min.card_size);
|
||||
}
|
||||
|
||||
@@ -285,7 +292,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);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
let half_w = window.x / 2.0;
|
||||
let half_card = layout.card_size.x / 2.0;
|
||||
for (pile, pos) in &layout.pile_positions {
|
||||
@@ -306,7 +313,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tableau_columns_are_sorted_left_to_right() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
for i in 0..6 {
|
||||
let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
|
||||
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
|
||||
@@ -316,7 +323,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn top_row_is_above_tableau_row() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
let stock_y = layout.pile_positions[&PileType::Stock].y;
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
|
||||
assert!(stock_y > tableau_y);
|
||||
@@ -329,7 +336,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);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
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;
|
||||
@@ -341,7 +348,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);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
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;
|
||||
@@ -352,7 +359,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);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
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;
|
||||
@@ -371,7 +378,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);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
let width_based = window.x / 9.0;
|
||||
assert!(
|
||||
layout.card_size.x < width_based,
|
||||
@@ -387,7 +394,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);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
let width_based = window.x / 9.0;
|
||||
assert!(
|
||||
(layout.card_size.x - width_based).abs() < 1e-3,
|
||||
@@ -401,7 +408,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);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
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.
|
||||
@@ -420,7 +427,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);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
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;
|
||||
@@ -436,8 +443,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);
|
||||
let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0);
|
||||
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);
|
||||
assert!(
|
||||
phone.tableau_fan_frac > desktop.tableau_fan_frac,
|
||||
"portrait phone fan_frac ({:.3}) should exceed desktop ({:.3})",
|
||||
@@ -451,7 +458,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);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
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;
|
||||
@@ -468,7 +475,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);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
|
||||
assert!(
|
||||
(layout.tableau_fan_frac - TABLEAU_FAN_FRAC).abs() < 1e-3,
|
||||
"desktop fan_frac should stay at minimum {TABLEAU_FAN_FRAC}, got {:.4}",
|
||||
@@ -483,7 +490,7 @@ mod tests {
|
||||
Vec2::new(1280.0, 800.0),
|
||||
Vec2::new(1920.0, 1080.0),
|
||||
] {
|
||||
let layout = compute_layout(window, 0.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0);
|
||||
let half_w = window.x / 2.0;
|
||||
let half_card = layout.card_size.x / 2.0;
|
||||
for (pile, pos) in &layout.pile_positions {
|
||||
@@ -509,8 +516,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);
|
||||
let with_inset = compute_layout(window, 32.0);
|
||||
let without = compute_layout(window, 0.0, 0.0);
|
||||
let with_inset = compute_layout(window, 32.0, 0.0);
|
||||
let stock_no_inset = without.pile_positions[&PileType::Stock].y;
|
||||
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
|
||||
assert!(
|
||||
@@ -531,8 +538,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);
|
||||
let with_inset = compute_layout(window, 32.0);
|
||||
let without = compute_layout(window, 0.0, 0.0);
|
||||
let with_inset = compute_layout(window, 32.0, 0.0);
|
||||
for pile in [
|
||||
PileType::Stock,
|
||||
PileType::Waste,
|
||||
@@ -545,4 +552,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A bottom safe-area inset must shrink the tableau fan so the worst-case
|
||||
/// column stays above the gesture bar.
|
||||
#[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);
|
||||
assert!(
|
||||
with_inset.tableau_fan_frac <= without.tableau_fan_frac,
|
||||
"safe_area_bottom=48 must not increase tableau_fan_frac: {:.4} → {:.4}",
|
||||
without.tableau_fan_frac,
|
||||
with_inset.tableau_fan_frac,
|
||||
);
|
||||
let card_h = with_inset.card_size.y;
|
||||
let tableau_y = with_inset.pile_positions[&PileType::Tableau(6)].y;
|
||||
let bottom_edge = tableau_y - 12.0 * card_h * with_inset.tableau_fan_frac - card_h / 2.0;
|
||||
let h_gap = with_inset.card_size.x / 4.0;
|
||||
let margin = -window.y / 2.0 + 48.0 + h_gap;
|
||||
assert!(
|
||||
bottom_edge >= margin - 1e-3,
|
||||
"worst-case tableau bottom {bottom_edge:.2} overflows gesture-bar margin {margin:.2}",
|
||||
);
|
||||
}
|
||||
|
||||
/// safe_area_bottom must not affect horizontal positions.
|
||||
#[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);
|
||||
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,
|
||||
"{pile:?} x-position must not change with safe_area_bottom",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
app.insert_resource(LayoutResource(compute_layout(layout_window, 0.0, 0.0)));
|
||||
app.world_mut().resource_mut::<RadialCursorOverride>().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);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0);
|
||||
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);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0);
|
||||
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);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0);
|
||||
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);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0);
|
||||
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);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0);
|
||||
let king_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
|
||||
|
||||
@@ -163,8 +163,10 @@ fn setup_table(
|
||||
// Safe-area insets arrive from JNI asynchronously; they are almost always
|
||||
// 0 here (populated ~frame 2-3). on_safe_area_changed fires when they
|
||||
// arrive and issues a synthetic WindowResized to re-snap all game objects.
|
||||
let safe_area_top = safe_area.as_deref().copied().unwrap_or_default().top / scale;
|
||||
let layout = compute_layout(window_size, safe_area_top);
|
||||
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 selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background);
|
||||
|
||||
@@ -300,8 +302,10 @@ fn on_window_resized(
|
||||
};
|
||||
let window_size = Vec2::new(ev.width, ev.height);
|
||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||
let safe_area_top = safe_area.as_deref().copied().unwrap_or_default().top / scale;
|
||||
let new_layout = compute_layout(window_size, safe_area_top);
|
||||
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);
|
||||
|
||||
if let Some(layout_res) = layout_res.as_deref_mut() {
|
||||
layout_res.0 = new_layout.clone();
|
||||
|
||||
Reference in New Issue
Block a user