From f8f1f26d641bc145549eed0ca394cb5942ed13ac Mon Sep 17 00:00:00 2001 From: funman300 Date: Sun, 17 May 2026 20:15:15 -0700 Subject: [PATCH] fix(input): adaptive drop zones, touch event correctness, modal lifecycle guards H-3: cursor_plugin drop_overlay_rect and card_centre_for_index now use layout.tableau_fan_frac instead of the static TABLEAU_FAN_FRAC constant, so drop zones match the actual card fan on portrait Android. Removed now-unused TABLEAU_FAN_FRAC import. H-4: touch_end_drag uncommitted-tap branch no longer writes StateChangedEvent. The mouse path (end_drag) already omits this event for uncommitted drags; the touch path now matches, preventing double-animation on valid taps. H-6: update_selection_highlight is now gated with run_if(resource_changed) on SelectionState | KeyboardDragState | GameStateResource, eliminating the unconditional every-frame despawn+respawn of highlight sprites. H-7: toggle_home_screen (M-key) now checks other_modal_scrims.is_empty() before spawning the home screen, preventing a second concurrent ModalScrim when another overlay is already open. H-8: spawn_mode_card now inserts ModalButton(ButtonVariant::Secondary) so paint_modal_buttons applies hover/press colour feedback on Android. H-10: auto_resume_on_overlay excludes ForfeitConfirmScreen from its "other scrims" query via NonPauseFamilyScrim type alias. Opening the forfeit confirm no longer immediately despawns its parent pause modal. Also guards paused.0 assignment with an if-check to suppress spurious change-detection writes (L-15). Co-Authored-By: Claude Sonnet 4.6 --- solitaire_engine/src/cursor_plugin.rs | 6 +++--- solitaire_engine/src/home_plugin.rs | 5 ++++- solitaire_engine/src/input_plugin.rs | 4 ++-- solitaire_engine/src/pause_plugin.rs | 11 +++++++++-- solitaire_engine/src/selection_plugin.rs | 8 +++++++- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index 06d4bdc..d0e871c 100644 --- a/solitaire_engine/src/cursor_plugin.rs +++ b/solitaire_engine/src/cursor_plugin.rs @@ -38,7 +38,7 @@ use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::pile::PileType; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; -use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC}; +use crate::card_plugin::RightClickHighlight; use crate::layout::{Layout, LayoutResource}; use crate::resources::{DragState, GameStateResource}; use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR}; @@ -387,7 +387,7 @@ fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec if matches!(pile, PileType::Tableau(_)) { let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len()); if card_count > 1 { - let fan = -layout.card_size.y * TABLEAU_FAN_FRAC; + let fan = -layout.card_size.y * layout.tableau_fan_frac; let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32; let top_edge = centre.y + layout.card_size.y / 2.0; let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0; @@ -478,7 +478,7 @@ fn tableau_or_stack_pos( if is_tableau { Vec2::new( base.x, - base.y - layout.card_size.y * TABLEAU_FAN_FRAC * (index as f32), + base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32), ) } else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree { let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len()); diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index ee745a7..1c49dc6 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -35,6 +35,7 @@ use crate::stats_plugin::StatsResource; use crate::ui_focus::{Disabled, FocusGroup, Focusable}; use crate::ui_modal::{ spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, + ModalButton, ScrimDismissible, }; use crate::ui_theme::{ @@ -373,6 +374,7 @@ fn toggle_home_screen( daily: Option>, font_res: Option>, screens: Query>, + other_modal_scrims: Query<(), (With, Without)>, diff_expanded: Res, ) { if !keys.just_pressed(KeyCode::KeyM) { @@ -380,7 +382,7 @@ fn toggle_home_screen( } if let Ok(entity) = screens.single() { commands.entity(entity).despawn(); - } else { + } else if other_modal_scrims.is_empty() { spawn_home_screen( &mut commands, build_home_context( @@ -1348,6 +1350,7 @@ fn spawn_mode_card( // bevy::ui — the click handler queries on `&Interaction` // which Button drives. Button, + ModalButton(ButtonVariant::Secondary), Node { flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_2, diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 177623b..0c82d2d 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -940,10 +940,10 @@ fn touch_end_drag( continue; } - // Uncommitted tap — cancel cleanly. + // Uncommitted tap — cancel cleanly. No StateChangedEvent: nothing + // changed. The mouse path (end_drag) follows the same convention. if !drag.committed { drag.clear(); - changed.write(StateChangedEvent); return; } diff --git a/solitaire_engine/src/pause_plugin.rs b/solitaire_engine/src/pause_plugin.rs index 035b978..3e67ba1 100644 --- a/solitaire_engine/src/pause_plugin.rs +++ b/solitaire_engine/src/pause_plugin.rs @@ -437,10 +437,15 @@ fn close_forfeit_modal( /// The player reaches these overlays via the HUD menu while paused, which /// causes both the pause modal and the overlay to be live simultaneously. /// That is always unintentional — the overlay should own the screen. +/// Query filter for modals that are not part of the pause flow. +/// Excludes both `PauseScreen` (the pause modal itself) and +/// `ForfeitConfirmScreen` (spawned from within the pause flow). +type NonPauseFamilyScrim = (With, Without, Without); + fn auto_resume_on_overlay( mut commands: Commands, pause_screens: Query>, - other_modal_scrims: Query, Without)>, + other_modal_scrims: Query, mut paused: ResMut, ) { if pause_screens.is_empty() || other_modal_scrims.is_empty() { @@ -449,7 +454,9 @@ fn auto_resume_on_overlay( for entity in &pause_screens { commands.entity(entity).despawn(); } - paused.0 = false; + if paused.0 { + paused.0 = false; + } } /// Spawns the pause modal using the standard `ui_modal` scaffold — diff --git a/solitaire_engine/src/selection_plugin.rs b/solitaire_engine/src/selection_plugin.rs index c121ad5..0b2c660 100644 --- a/solitaire_engine/src/selection_plugin.rs +++ b/solitaire_engine/src/selection_plugin.rs @@ -156,7 +156,13 @@ impl Plugin for SelectionPlugin { .in_set(SelectionKeySet) .before(GameMutation), clear_selection_on_state_change.after(GameMutation), - update_selection_highlight.after(GameMutation), + update_selection_highlight + .after(GameMutation) + .run_if( + resource_changed:: + .or(resource_changed::) + .or(resource_changed::), + ), ), ); }