From 927598202ec5986d1d59052cd57f097937a659d4 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 28 May 2026 14:04:40 -0700 Subject: [PATCH] feat(engine,data): add tap-to-select touch input mode (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TouchInputMode enum (OneTap | TapToSelect) to solitaire_data settings - Create TouchSelectionPlugin with TouchSelectionState resource and highlight - Branch handle_double_tap: OneTap → existing auto-move, TapToSelect → two-tap flow - Add Settings UI toggle row (Touch Input Mode) with TouchInputModeText marker - Register TouchSelectionPlugin in CoreGamePlugin Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- solitaire_data/src/settings.rs | 23 ++ solitaire_engine/src/core_game_plugin.rs | 4 +- solitaire_engine/src/input_plugin.rs | 52 +++- solitaire_engine/src/lib.rs | 2 + solitaire_engine/src/settings_plugin.rs | 54 ++++ .../src/touch_selection_plugin.rs | 234 ++++++++++++++++++ 6 files changed, 361 insertions(+), 8 deletions(-) create mode 100644 solitaire_engine/src/touch_selection_plugin.rs diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 1909385..a12454e 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -62,6 +62,21 @@ pub enum SyncBackend { }, } +/// Touch input mode — controls what a single tap on a face-up card does. +/// +/// Defaults to `OneTap` so existing behaviour is unchanged on upgrade. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum TouchInputMode { + /// A single tap immediately moves the card to its best destination + /// (foundation-first, then tableau). This is the original behaviour. + #[default] + OneTap, + /// A first tap *selects* the card/stack and highlights it; a second + /// tap on a valid destination pile performs the move. Tapping the + /// selection again, or an empty / invalid target, cancels without moving. + TapToSelect, +} + /// Persisted window size (in logical pixels) and screen position /// (top-left corner, in physical pixels) — restored on next launch. /// @@ -264,6 +279,13 @@ pub struct Settings { /// Defaults to `1` (the first site created in a fresh Matomo install). #[serde(default = "default_matomo_site_id")] pub matomo_site_id: u32, + /// Touch input mode — `OneTap` (default) auto-moves on first tap; + /// `TapToSelect` requires an explicit destination tap. Only affects + /// touch/Android; desktop mouse input is unchanged. Older + /// `settings.json` files deserialize cleanly to `OneTap` via + /// `#[serde(default)]`. + #[serde(default)] + pub touch_input_mode: TouchInputMode, } fn default_draw_mode() -> DrawMode { @@ -397,6 +419,7 @@ impl Default for Settings { analytics_enabled: false, matomo_url: None, matomo_site_id: default_matomo_site_id(), + touch_input_mode: TouchInputMode::OneTap, } } } diff --git a/solitaire_engine/src/core_game_plugin.rs b/solitaire_engine/src/core_game_plugin.rs index 223a93b..053af60 100644 --- a/solitaire_engine/src/core_game_plugin.rs +++ b/solitaire_engine/src/core_game_plugin.rs @@ -21,7 +21,8 @@ use crate::{ RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, SyncProvider, SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, - UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, + TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, + WinSummaryPlugin, }; /// Groups all Ferrous Solitaire gameplay plugins. @@ -83,6 +84,7 @@ impl Plugin for CoreGamePlugin { .add_plugins(InputPlugin) .add_plugins(RadialMenuPlugin) .add_plugins(SelectionPlugin) + .add_plugins(TouchSelectionPlugin) .add_plugins(AnimationPlugin) .add_plugins(FeedbackAnimPlugin) .add_plugins(CardAnimationPlugin) diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index dc2593d..5293535 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -48,7 +48,9 @@ use crate::radial_menu::RightClickRadialState; use crate::replay_playback::ReplayPlaybackState; use crate::resources::{DragState, GameInputConsumedResource, GameStateResource, HintCycleIndex}; use crate::selection_plugin::SelectionState; +use crate::settings_plugin::SettingsResource; use crate::time_attack_plugin::TimeAttackResource; +use crate::touch_selection_plugin::TouchSelectionState; use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING}; use solitaire_core::game_state::DrawMode; @@ -1503,11 +1505,15 @@ fn handle_double_tap( radial: Option>, drag: Res, game: Res, + settings: Option>, + mut touch_selection: Option>, mut moves: MessageWriter, mut rejected: MessageWriter, mut commands: Commands, mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>, ) { + use solitaire_data::settings::TouchInputMode; + if paused.is_some_and(|p| p.0) { return; } @@ -1524,6 +1530,10 @@ fn handle_double_tap( return; } + let tap_to_select = settings + .as_ref() + .is_some_and(|s| s.0.touch_input_mode == TouchInputMode::TapToSelect); + for event in touch_events.read() { if event.id != active_id || event.phase != TouchPhase::Ended { continue; @@ -1533,10 +1543,10 @@ fn handle_double_tap( let Some(&top_card_id) = drag.cards.last() else { return; }; - let Some(ref pile) = drag.origin_pile else { + let Some(ref tapped_pile) = drag.origin_pile else { return; }; - let Some(pile_cards) = game.0.piles.get(pile) else { + let Some(pile_cards) = game.0.piles.get(tapped_pile) else { return; }; @@ -1547,6 +1557,34 @@ fn handle_double_tap( return; } + // --- Tap-to-select mode --- + if tap_to_select { + if let Some(ref mut sel) = touch_selection { + if let Some((ref source_pile, ref source_cards)) = sel.selected.clone() { + // Second tap: this is the destination. + if tapped_pile == source_pile { + // Re-tap on selected source → cancel. + sel.clear(); + return; + } + // Attempt the move. MoveRequestEvent carries validation; + // a rejection will fire MoveRejectedEvent automatically. + moves.write(MoveRequestEvent { + from: source_pile.clone(), + to: tapped_pile.clone(), + count: source_cards.len(), + }); + sel.clear(); + return; + } + // First tap: select the source. + sel.set(tapped_pile.clone(), drag.cards.clone()); + } + return; + } + + // --- One-tap auto-move (original behaviour) --- + // Priority 1: move single top card. if let Some(dest) = best_destination(top_card, &game.0) { for (entity, ce, mut sprite) in card_sprites.iter_mut() { @@ -1559,7 +1597,7 @@ fn handle_double_tap( } } moves.write(MoveRequestEvent { - from: pile.clone(), + from: tapped_pile.clone(), to: dest, count: 1, }); @@ -1571,7 +1609,7 @@ fn handle_double_tap( let stack_index = pile_cards.cards.len() - drag.cards.len(); if let Some(bottom_card) = pile_cards.cards.get(stack_index) && let Some((dest, count)) = - best_tableau_destination_for_stack(bottom_card, pile, &game.0, drag.cards.len()) + best_tableau_destination_for_stack(bottom_card, tapped_pile, &game.0, drag.cards.len()) { for (entity, ce, mut sprite) in card_sprites.iter_mut() { if drag.cards.contains(&ce.card_id) { @@ -1582,7 +1620,7 @@ fn handle_double_tap( } } moves.write(MoveRequestEvent { - from: pile.clone(), + from: tapped_pile.clone(), to: dest, count, }); @@ -1591,8 +1629,8 @@ fn handle_double_tap( } rejected.write(MoveRejectedEvent { - from: pile.clone(), - to: pile.clone(), + from: tapped_pile.clone(), + to: tapped_pile.clone(), count: drag.cards.len(), }); } diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index be95518..8d09a5b 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -48,6 +48,7 @@ pub mod sync_setup_plugin; pub mod table_plugin; pub mod theme; pub mod time_attack_plugin; +pub mod touch_selection_plugin; pub mod ui_focus; pub mod ui_modal; pub mod ui_theme; @@ -142,6 +143,7 @@ pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin}; pub use selection_plugin::{ KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState, }; +pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState}; pub use settings_plugin::{ PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS, diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index d821aa9..d897218 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -141,6 +141,10 @@ struct HighContrastText; #[derive(Component, Debug)] struct ReduceMotionText; +/// Marks the `Text` node showing the current touch input mode state. +#[derive(Component, Debug)] +struct TouchInputModeText; + /// Marks the `Text` node showing the live tooltip-delay value. #[derive(Component, Debug)] struct TooltipDelayText; @@ -230,6 +234,10 @@ enum SettingsButton { /// non-essential motion (card-slide animations become instant /// snaps) per `design-system.md` §Accessibility (#3). ToggleReduceMotion, + /// Toggle [`Settings::touch_input_mode`] between `OneTap` + /// (auto-move on tap, default) and `TapToSelect` (first tap selects + /// a card/stack, second tap on a target pile moves it). + ToggleTouchInputMode, /// Toggle the [`Settings::winnable_deals_only`] flag. When on, new /// random Classic-mode deals are filtered through /// [`solitaire_core::solver::try_solve`] until one is provably @@ -303,6 +311,7 @@ impl SettingsButton { // run before continuing to the picker rows. SettingsButton::ToggleHighContrast => 61, SettingsButton::ToggleReduceMotion => 62, + SettingsButton::ToggleTouchInputMode => 63, // Picker rows — every swatch in a row shares the row's // priority so entity-index tiebreaking yields left → right. SettingsButton::SelectCardBack(_) => 70, @@ -405,11 +414,17 @@ impl Plugin for SettingsPlugin { update_high_contrast_borders.run_if(resource_changed::), update_high_contrast_backgrounds.run_if(resource_changed::), update_reduce_motion_text, + update_touch_input_mode_text, update_tooltip_delay_text, update_time_bonus_multiplier_text, update_replay_move_interval_text, update_winnable_deals_only_text, update_smart_default_size_text, + ), + ); + app.add_systems( + Update, + ( update_analytics_enabled_text, attach_focusable_to_settings_buttons, ), @@ -769,6 +784,18 @@ fn update_reduce_motion_text( } } +fn update_touch_input_mode_text( + settings: Res, + mut text_nodes: Query<&mut Text, With>, +) { + if !settings.is_changed() { + return; + } + for mut text in &mut text_nodes { + **text = touch_input_mode_label(&settings.0.touch_input_mode); + } +} + /// Refreshes the live "Winnable deals only" toggle value in the /// Gameplay section whenever `SettingsResource` changes (button click, /// hand-edited `settings.json` reload, etc.). @@ -1177,6 +1204,16 @@ fn handle_settings_buttons( **t = on_off_label(settings.0.reduce_motion_mode); } } + SettingsButton::ToggleTouchInputMode => { + use solitaire_data::settings::TouchInputMode; + settings.0.touch_input_mode = match settings.0.touch_input_mode { + TouchInputMode::OneTap => TouchInputMode::TapToSelect, + TouchInputMode::TapToSelect => TouchInputMode::OneTap, + }; + persist(&path, &settings.0); + changed.write(SettingsChangedEvent(settings.0.clone())); + // Text refreshed by `update_touch_input_mode_text` next frame. + } SettingsButton::ToggleWinnableDealsOnly => { settings.0.winnable_deals_only = !settings.0.winnable_deals_only; persist(&path, &settings.0); @@ -1311,6 +1348,14 @@ fn winnable_deals_only_label(enabled: bool) -> String { if enabled { "ON".into() } else { "OFF".into() } } +fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInputMode) -> String { + use solitaire_data::settings::TouchInputMode; + match mode { + TouchInputMode::OneTap => "One-tap".into(), + TouchInputMode::TapToSelect => "Tap to select".into(), + } +} + /// Display string for the "Smart window size" toggle. The argument /// is the *enabled* state (i.e. the inverse of the underlying /// `disable_smart_default_size` field) so reading the label gives @@ -1761,6 +1806,15 @@ fn spawn_settings_panel( "Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.", font_res, ); + toggle_row( + body, + "Touch Input Mode", + TouchInputModeText, + touch_input_mode_label(&settings.touch_input_mode), + SettingsButton::ToggleTouchInputMode, + "One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.", + font_res, + ); if theme_overrides_back { // The active theme provides its own back; the legacy // picker has no visible effect, so we replace its diff --git a/solitaire_engine/src/touch_selection_plugin.rs b/solitaire_engine/src/touch_selection_plugin.rs new file mode 100644 index 0000000..2d1858e --- /dev/null +++ b/solitaire_engine/src/touch_selection_plugin.rs @@ -0,0 +1,234 @@ +//! Touch tap-to-select input mode. +//! +//! When [`TouchInputMode::TapToSelect`] is active (set via [`crate::settings_plugin`]), +//! a single tap on a face-up card **selects** it (showing a visual highlight) instead +//! of immediately auto-moving it. A second tap on a valid destination pile performs +//! the move; a second tap on the same pile (or an invalid target) cancels silently. +//! +//! In [`TouchInputMode::OneTap`] mode this plugin is fully passive — all resources +//! default to their empty state and no highlight is ever shown. +//! +//! ## State machine +//! +//! ```text +//! Idle ──(tap face-up card)──> Selected(pile, cards) +//! ↑ │ +//! │ cancel (re-tap or │ second tap on destination +//! └── StateChangedEvent) ◄──────┤ → MoveRequestEvent; back to Idle +//! │ +//! └── rejected / no destination → back to Idle +//! ``` +//! +//! ## Interaction with the existing auto-move flow +//! +//! [`crate::input_plugin::handle_double_tap`] is the entry point: it reads +//! [`TouchSelectionState`] and, in `TapToSelect` mode, populates it on the first +//! tap instead of firing `MoveRequestEvent`. This plugin owns the highlight visual +//! and the state-clear reactions. + +use bevy::ecs::message::MessageReader; +use bevy::prelude::*; +use solitaire_core::pile::PileType; + +use crate::card_plugin::CardEntity; +use crate::events::StateChangedEvent; +use crate::game_plugin::GameMutation; +use crate::layout::LayoutResource; +use crate::ui_theme::ACCENT_PRIMARY; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// State for the tap-to-select touch flow. +/// +/// `selected` is `Some((source_pile, card_ids))` while the player has +/// chosen a source but not yet tapped a destination. `None` is the idle state. +/// +/// `card_ids` mirrors `DragState::cards` — the bottom-to-top ordered list of +/// card ids that will be moved (1 for a single card, multiple for a face-up run). +#[derive(Resource, Debug, Default)] +pub struct TouchSelectionState { + /// Currently selected source pile and the card ids to move (bottom-to-top). + pub selected: Option<(PileType, Vec)>, +} + +impl TouchSelectionState { + /// Returns `true` when a source is selected. + pub fn has_selection(&self) -> bool { + self.selected.is_some() + } + + /// Takes the current selection, leaving `selected` as `None`. + pub fn take(&mut self) -> Option<(PileType, Vec)> { + self.selected.take() + } + + /// Sets the current selection. + pub fn set(&mut self, pile: PileType, cards: Vec) { + self.selected = Some((pile, cards)); + } + + /// Clears the selection without returning it. + pub fn clear(&mut self) { + self.selected = None; + } +} + +/// Marker component placed on the highlight sprite child of a selected source card. +/// +/// Despawned and respawned each frame by [`update_touch_selection_highlight`] so +/// stale highlights never linger after a game-state change. +#[derive(Component)] +pub struct TouchSelectionHighlight; + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +/// Registers all resources and systems for the touch tap-to-select flow. +pub struct TouchSelectionPlugin; + +impl Plugin for TouchSelectionPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems( + Update, + ( + clear_touch_selection_on_state_change, + update_touch_selection_highlight, + ) + .chain() + .after(GameMutation), + ); + } +} + +// --------------------------------------------------------------------------- +// Systems +// --------------------------------------------------------------------------- + +/// Clears [`TouchSelectionState`] whenever the board changes (undo, new game, +/// won, forfeit). This prevents stale selections surviving across game resets. +pub(crate) fn clear_touch_selection_on_state_change( + mut selection: ResMut, + mut state_events: MessageReader, +) { + if state_events.read().next().is_some() { + selection.clear(); + } +} + +/// Maintains the `TouchSelectionHighlight` outline sprite on the selected source card. +/// +/// All existing `TouchSelectionHighlight` entities are despawned each frame and +/// a new one is spawned on the top card of the selected pile (if any). This +/// matches the pattern used by `selection_plugin::update_selection_highlight`. +pub(crate) fn update_touch_selection_highlight( + mut commands: Commands, + selection: Res, + card_entities: Query<(Entity, &CardEntity)>, + highlights: Query>, + layout: Option>, +) { + // Despawn stale highlights first. + for entity in &highlights { + commands.entity(entity).despawn(); + } + + let Some((_, ref card_ids)) = selection.selected else { + return; + }; + let Some(layout) = layout else { + return; + }; + + // Highlight every card in the selected stack (bottom-to-top order). + // The bottom card of the run is the most visually important anchor, + // but highlighting the whole run gives the player clear confirmation + // of how many cards are involved in the move. + let card_size = layout.0.card_size; + for &card_id in card_ids { + spawn_touch_highlight(&mut commands, &card_entities, card_id, card_size); + } +} + +/// Spawns a [`TouchSelectionHighlight`] sprite as a child of the matching card entity. +fn spawn_touch_highlight( + commands: &mut Commands, + card_entities: &Query<(Entity, &CardEntity)>, + card_id: u32, + card_size: Vec2, +) { + for (entity, card_entity) in card_entities { + if card_entity.card_id == card_id { + commands.entity(entity).with_children(|b| { + b.spawn(( + TouchSelectionHighlight, + Sprite { + color: ACCENT_PRIMARY.with_alpha(0.55), + custom_size: Some(card_size + Vec2::splat(6.0)), + ..default() + }, + Transform::from_xyz(0.0, 0.0, -0.01), + Visibility::default(), + )); + }); + return; + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selection_state_default_is_idle() { + let state = TouchSelectionState::default(); + assert!(!state.has_selection()); + assert!(state.selected.is_none()); + } + + #[test] + fn set_and_take_roundtrip() { + let mut state = TouchSelectionState::default(); + state.set(PileType::Tableau(0), vec![1, 2, 3]); + assert!(state.has_selection()); + let taken = state.take(); + assert!(taken.is_some()); + let (pile, cards) = taken.unwrap(); + assert_eq!(pile, PileType::Tableau(0)); + assert_eq!(cards, vec![1, 2, 3]); + assert!(!state.has_selection()); + } + + #[test] + fn clear_removes_selection() { + let mut state = TouchSelectionState::default(); + state.set(PileType::Waste, vec![42]); + state.clear(); + assert!(!state.has_selection()); + } + + #[test] + fn take_on_idle_returns_none() { + let mut state = TouchSelectionState::default(); + assert!(state.take().is_none()); + assert!(!state.has_selection()); + } + + #[test] + fn set_overwrites_previous_selection() { + let mut state = TouchSelectionState::default(); + state.set(PileType::Tableau(0), vec![1]); + state.set(PileType::Tableau(3), vec![7, 8]); + let (pile, cards) = state.take().unwrap(); + assert_eq!(pile, PileType::Tableau(3)); + assert_eq!(cards, vec![7, 8]); + } +}