From 4398403418810363dc59e57eaa91f0cfaef4bc03 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 11 May 2026 19:37:46 -0700 Subject: [PATCH] =?UTF-8?q?feat(engine):=20Android=20UX=20sweep=20?= =?UTF-8?q?=E2=80=94=20tap-to-move,=20safe=20area,=20HUD=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- solitaire_engine/src/card_plugin.rs | 140 ++++++++++++++++++++--- solitaire_engine/src/cursor_plugin.rs | 4 +- solitaire_engine/src/hud_plugin.rs | 113 ++++++++++++++++-- solitaire_engine/src/input_plugin.rs | 159 ++++++++++---------------- solitaire_engine/src/layout.rs | 117 +++++++++++++------ solitaire_engine/src/radial_menu.rs | 12 +- solitaire_engine/src/table_plugin.rs | 12 +- 7 files changed, 383 insertions(+), 174 deletions(-) diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index ca8cdf3..c0be2da 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -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>, entities: Query< (Entity, &CardEntity, &mut Sprite, &mut Transform), - (Without, Without), + (Without, Without, Without), >, label_query: Query<&mut TextFont, (With, Without)>, - shadow_query: Query<&mut Sprite, (With, Without, Without)>, + shadow_query: Query< + &mut Sprite, + (With, Without, Without, Without), + >, + frame_query: Query< + &mut Sprite, + (With, Without, Without, Without), + >, mut pile_markers: Query< (Entity, &PileMarker, &mut Sprite), - (Without, Without), + (Without, Without, Without), >, label_children: Query<(Entity, &ChildOf), With>, ) { @@ -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, Without), + (Without, Without, Without), >, mut label_query: Query<&mut TextFont, (With, Without)>, mut shadow_query: Query< &mut Sprite, - (With, Without, Without), + (With, Without, Without, Without), + >, + mut frame_query: Query< + &mut Sprite, + (With, Without, Without, Without), >, ) { 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, + game: Option>, + mut layout: Option>, +) { + 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 = 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, diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index 4bccab3..0ac50c4 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); + 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 diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 1b7e9e9..4fca9c6 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -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, Changed)>, popovers: Query>, + backdrops: Query>, progress: Option>, daily: Option>, font_res: Option>, @@ -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>, popovers: Query>, + backdrops: Query>, mut new_game: MessageWriter, mut zen: MessageWriter, mut challenge: MessageWriter, @@ -1011,9 +1043,13 @@ fn handle_mode_option_click( } } if clicked_any - && let Ok(entity) = popovers.single() { - commands.entity(entity).despawn(); + && let Ok(entity) = popovers.single() + { + commands.entity(entity).despawn(); + for e in &backdrops { + commands.entity(e).despawn(); } + } } /// Toggles the [`MenuPopover`]: spawns it on first click, despawns it on @@ -1022,6 +1058,7 @@ fn handle_mode_option_click( fn handle_menu_button( interaction_query: Query<&Interaction, (With, Changed)>, popovers: Query>, + backdrops: Query>, font_res: Option>, 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>, popovers: Query>, + backdrops: Query>, mut stats: MessageWriter, mut achievements: MessageWriter, mut profile: MessageWriter, @@ -1162,9 +1220,46 @@ 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, Changed)>, + popovers: Query>, + backdrops: Query>, + 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, Changed)>, + popovers: Query>, + backdrops: Query>, + 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(); + } +} + /// Auto-fade state for the action button bar. The bar fades out when /// the cursor is in the play area (below the HUD band) and back in when /// the cursor approaches the top of the window — same UX as a video diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index f54077a..a92e831 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -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, paused: Option>, radial: Option>, - time: Res