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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-17 20:15:15 -07:00
parent 3bb3ddb6f8
commit f8f1f26d64
5 changed files with 25 additions and 9 deletions
+3 -3
View File
@@ -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());
+4 -1
View File
@@ -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<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
diff_expanded: Res<DifficultyExpanded>,
) {
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,
+2 -2
View File
@@ -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;
}
+9 -2
View File
@@ -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<ModalScrim>, Without<PauseScreen>, Without<ForfeitConfirmScreen>);
fn auto_resume_on_overlay(
mut commands: Commands,
pause_screens: Query<Entity, With<PauseScreen>>,
other_modal_scrims: Query<Entity, (With<ModalScrim>, Without<PauseScreen>)>,
other_modal_scrims: Query<Entity, NonPauseFamilyScrim>,
mut paused: ResMut<PausedResource>,
) {
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 —
+7 -1
View File
@@ -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::<SelectionState>
.or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<crate::GameStateResource>),
),
),
);
}