feat(engine,data): add tap-to-select touch input mode (#70)
- 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>
This commit is contained in:
@@ -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<u32>)>,
|
||||
}
|
||||
|
||||
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<u32>)> {
|
||||
self.selected.take()
|
||||
}
|
||||
|
||||
/// Sets the current selection.
|
||||
pub fn set(&mut self, pile: PileType, cards: Vec<u32>) {
|
||||
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::<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.
|
||||
///
|
||||
/// 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<TouchSelectionState>,
|
||||
card_entities: Query<(Entity, &CardEntity)>,
|
||||
highlights: Query<Entity, With<TouchSelectionHighlight>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
) {
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user