113a933170
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95) wraps signatures and call sites differently, so every touched file was picking up unrelated formatting hunks. One mechanical pass, and a 'cargo fmt --check' step in the test workflow (same pinned 1.95.0 toolchain) so drift can't accumulate again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
265 lines
9.3 KiB
Rust
265 lines
9.3 KiB
Rust
//! 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::Card;
|
|
use solitaire_core::KlondikePile;
|
|
|
|
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<Card>)>,
|
|
}
|
|
|
|
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<Card>)> {
|
|
self.selected.take()
|
|
}
|
|
|
|
/// Sets the current selection.
|
|
pub fn set(&mut self, pile: KlondikePile, cards: Vec<Card>) {
|
|
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::<TouchSelectionState>().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<TouchSelectionState>,
|
|
mut state_events: MessageReader<StateChangedEvent>,
|
|
) {
|
|
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<TouchSelectionState>,
|
|
card_entities: Query<(Entity, &CardEntity)>,
|
|
highlights: Query<Entity, With<TouchSelectionHighlight>>,
|
|
layout: Option<Res<LayoutResource>>,
|
|
) {
|
|
// 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);
|
|
}
|
|
}
|