//! 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::KlondikePile; use solitaire_core::Card; 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 cards to move (bottom-to-top). pub selected: Option<(KlondikePile, 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<(KlondikePile, Vec)> { self.selected.take() } /// Sets the current selection. pub fn set(&mut self, pile: KlondikePile, 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 by [`update_touch_selection_highlight`] whenever /// [`TouchSelectionState`] changes. The system is gated on `is_changed()` so it /// is a no-op every frame that the selection is stable. #[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. /// /// Rebuilds the highlight set only when [`TouchSelectionState`] or the layout /// actually changes — not every frame. Existing highlights are despawned first, /// then a fresh highlight is spawned on every card in the selected stack. pub(crate) fn update_touch_selection_highlight( mut commands: Commands, selection: Res, card_entities: Query<(Entity, &CardEntity)>, highlights: Query>, layout: Option>, ) { // Skip when neither the selection nor the layout changed this frame. let layout_changed = layout.as_ref().map(|l| l.is_changed()).unwrap_or(false); if !selection.is_changed() && !layout_changed { return; } // Despawn stale highlights first. for entity in &highlights { commands.entity(entity).despawn(); } let Some((_, ref cards)) = 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 in cards { spawn_touch_highlight(&mut commands, &card_entities, card, 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: &Card, card_size: Vec2, ) { for (entity, card_entity) in card_entities { if card_entity.card == *card { 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::*; use solitaire_core::Tableau; use solitaire_core::{Card, Deck, Rank, Suit}; /// Three distinct test cards, used in place of the old `vec![1, 2, 3]` /// numeric ids. Identity is now the `Card` value. fn test_cards() -> [Card; 3] { [ Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace), Card::new(Deck::Deck1, Suit::Hearts, Rank::Two), Card::new(Deck::Deck1, Suit::Spades, Rank::Three), ] } #[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(); let cards = test_cards().to_vec(); state.set(KlondikePile::Tableau(Tableau::Tableau1), cards.clone()); assert!(state.has_selection()); let taken = state.take(); assert!(taken.is_some()); let (pile, taken_cards) = taken.unwrap(); assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1)); assert_eq!(taken_cards, cards); assert!(!state.has_selection()); } #[test] fn clear_removes_selection() { let mut state = TouchSelectionState::default(); state.set( KlondikePile::Stock, vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::King)], ); 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( KlondikePile::Tableau(Tableau::Tableau1), vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], ); let second = vec![ Card::new(Deck::Deck1, Suit::Hearts, Rank::Seven), Card::new(Deck::Deck1, Suit::Spades, Rank::Eight), ]; state.set(KlondikePile::Tableau(Tableau::Tableau4), second.clone()); let (pile, cards) = state.take().unwrap(); assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau4)); assert_eq!(cards, second); } }