//! Keyboard + mouse input for the game board. //! //! All systems exit immediately when `PausedResource(true)` — no moves, //! draws, undos, or drags are processed while the pause overlay is showing. //! //! Keyboard: //! - `U` → `UndoRequestEvent` //! - `N` → `NewGameRequestEvent { seed: None }` (cancels Time Attack if active) //! - `D` / `Space` → `DrawRequestEvent` //! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag) //! //! Mouse: //! - Left-click on the stock pile (face-down top) → `DrawRequestEvent` //! - Left-press-drag-release on a face-up card → `MoveRequestEvent` between //! the origin pile and whatever pile the cursor is over at release. //! On rejection, the drag cards snap back to their origin via a //! `StateChangedEvent` re-sync. use std::collections::HashMap; use bevy::ecs::system::SystemParam; use bevy::input::touch::{TouchInput, TouchPhase, Touches}; use bevy::input::ButtonInput; use bevy::math::{Vec2, Vec3}; use bevy::prelude::*; use bevy::window::{MonitorSelection, PrimaryWindow, WindowMode}; use solitaire_core::card::{Card, Suit}; use solitaire_core::game_state::GameState; use solitaire_core::pile::PileType; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use crate::card_animation::tuning::AnimationTuning; use crate::card_animation::{CardAnimation, MotionCurve}; use crate::card_plugin::{ CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC, TABLEAU_FAN_FRAC, }; use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_WARNING}; use solitaire_core::game_state::DrawMode; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::events::{ DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, StartZenRequestEvent, StateChangedEvent, UndoRequestEvent, }; use crate::game_plugin::{ConfirmNewGameScreen, GameMutation, RestorePromptScreen}; use crate::pause_plugin::PausedResource; use crate::progress_plugin::ProgressResource; use crate::layout::{Layout, LayoutResource}; use crate::resources::{DragState, GameStateResource, HintCycleIndex}; use crate::selection_plugin::SelectionState; use crate::time_attack_plugin::TimeAttackResource; /// Z-depth used for cards while being dragged — above all resting cards. const DRAG_Z: f32 = 500.0; /// Solver budgets used by the H-key hint system. /// /// Wraps `solitaire_core::solver::SolverConfig` as a Bevy resource so /// tests can inject tighter budgets to exercise the heuristic-fallback /// path. Production initialises this to `SolverConfig::default()` (100k /// move / 200k state budgets, the same numbers the new-game retry loop /// uses). #[derive(Resource, Debug, Clone, Default)] pub struct HintSolverConfig(pub solitaire_core::solver::SolverConfig); /// Registers keyboard, mouse, and touch input systems. /// /// Mouse drag pipeline (ordered, left-to-right): /// `start_drag` → `follow_drag` → `end_drag` /// /// Touch drag pipeline (ordered, interleaved with mouse): /// `touch_start_drag` → `touch_follow_drag` → `touch_end_drag` /// /// Both pipelines share [`DragState`]. Only one can be active at a time — /// the second checks `drag.is_idle()` before proceeding, and mouse drags /// check `drag.active_touch_id.is_none()`. /// /// All drag systems run before [`GameMutation`] so move events are consumed /// in the same frame they are emitted. pub struct InputPlugin; impl Plugin for InputPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() .init_resource::() .add_message::() .add_message::() .add_message::() .add_message::() .add_systems( Update, ( handle_keyboard_core, handle_keyboard_hint, handle_keyboard_forfeit, handle_stock_click, handle_touch_stock_tap, handle_double_click, // Mouse drag pipeline. start_drag, follow_drag, end_drag.before(GameMutation), // Touch drag pipeline (parallel path through DragState). touch_start_drag, touch_follow_drag, touch_end_drag.before(GameMutation), ) .chain(), ) .add_systems(Update, handle_fullscreen) .add_systems(Update, reset_hint_cycle_on_state_change) // Async hint pipeline: state-change drop runs before the // poll system so a move applied this frame cancels any // in-flight task before its result can be surfaced. .add_systems( Update, ( crate::pending_hint::drop_pending_hint_on_state_change, crate::pending_hint::poll_pending_hint_task, ) .chain(), ); } } /// Bundles the event writers needed by the core keyboard handler. /// /// Keeping these in a [`SystemParam`] avoids hitting Bevy's 16-parameter limit. #[derive(SystemParam)] struct CoreKeyboardMessages<'w> { undo: MessageWriter<'w, UndoRequestEvent>, new_game: MessageWriter<'w, NewGameRequestEvent>, info_toast: MessageWriter<'w, InfoToastEvent>, draw: MessageWriter<'w, DrawRequestEvent>, } /// Handles the core keyboard shortcuts: U (undo), N (new game), Z (zen mode), /// D / Space (draw). /// /// `N` fires `NewGameRequestEvent` straight through; the existing /// `handle_new_game` flow shows the `ConfirmNewGameScreen` modal when /// the current game is in progress, so a single press surfaces a real /// Confirm / Cancel UI instead of a "press N again" toast. `Shift+N` /// keeps the keyboard power-user bypass by setting `confirmed: true`. /// /// While the confirm modal or the restore prompt is already open, the /// system skips the N branch so those modals' own input handlers can /// process N (cancel / start-new-game) without us re-firing a request /// the same frame. #[allow(clippy::too_many_arguments)] fn handle_keyboard_core( keys: Res>, paused: Option>, progress: Option>, mut ev: CoreKeyboardMessages<'_>, mut time_attack: Option>, selection: Option>, mut zen_requests: MessageReader, confirm_screens: Query<(), With>, restore_prompts: Query<(), With>, ) { if paused.is_some_and(|p| p.0) { return; } if keys.just_pressed(KeyCode::KeyU) { ev.undo.write(UndoRequestEvent); } if keys.just_pressed(KeyCode::KeyN) { // If a Time Attack session is running, cancel it and start a Classic game. if let Some(ref mut session) = time_attack && session.active { session.active = false; session.remaining_secs = 0.0; ev.info_toast.write(InfoToastEvent("Time Attack ended".to_string())); ev.new_game.write(NewGameRequestEvent { seed: None, mode: Some(solitaire_core::game_state::GameMode::Classic), confirmed: false, }); return; } // The confirm modal and restore prompt own N while they're up — // they cancel / accept respectively. Skipping here prevents us // from firing a fresh request the same frame those modals close. if !confirm_screens.is_empty() || !restore_prompts.is_empty() { // intentional: defer to those modals' input handlers. } else { let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight); ev.new_game.write(NewGameRequestEvent { seed: None, mode: None, // Shift+N skips the confirm modal for keyboard power-users; // bare N falls through `handle_new_game`'s active-game check // and shows the modal when a game is in progress. confirmed: shift_held, }); } } let zen_clicked = zen_requests.read().count() > 0; if keys.just_pressed(KeyCode::KeyZ) || zen_clicked { // Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL. // X is gated separately by ChallengePlugin. Either Z or the HUD // Modes-popover "Zen" row reaches this branch. let level = progress.as_ref().map_or(0, |p| p.0.level); if level >= CHALLENGE_UNLOCK_LEVEL { ev.new_game.write(NewGameRequestEvent { seed: None, mode: Some(solitaire_core::game_state::GameMode::Zen), confirmed: false, }); } else { ev.info_toast.write(InfoToastEvent(format!( "Zen mode unlocks at level {CHALLENGE_UNLOCK_LEVEL}" ))); } } // Space draws only when no card is keyboard-selected; when a card IS selected, // SelectionPlugin handles Space to execute the move. let space_draws = keys.just_pressed(KeyCode::Space) && selection.as_ref().is_none_or(|s| s.selected_pile.is_none()); if keys.just_pressed(KeyCode::KeyD) || space_draws { ev.draw.write(DrawRequestEvent); } // Esc is handled by `PausePlugin` (overlay toggle + paused flag). } /// Handles the H key: spawn an async solver task on /// `AsyncComputeTaskPool` whose result `pending_hint::poll_pending_hint_task` /// turns into hint visuals one frame later. /// /// Median solve time is ~2 ms but pathological positions can hit the /// `SolverConfig::default()` cap at ~120 ms; running synchronously /// (the v0.17.0 behaviour) blocked the main thread on the same frame /// the player pressed H. Cancel-on-replace lives in /// `PendingHintTask::spawn` — a fresh H press while a previous task /// is in flight drops the previous task's handle. /// /// Special-cases: when the game is already won, surface a "Game won!" /// toast instead of asking the solver. The poll system handles the /// "no legal moves" toast on the heuristic fallback path so the /// handler here only needs to dispatch. fn handle_keyboard_hint( keys: Res>, paused: Option>, game: Option>, layout: Option>, solver_config: Res, mut pending_hint: ResMut, mut info_toast: MessageWriter, ) { if paused.is_some_and(|p| p.0) { return; } if !keys.just_pressed(KeyCode::KeyH) { return; } let Some(ref g) = game else { return }; if g.0.is_won { info_toast.write(InfoToastEvent("Game won! Press N for a new game".to_string())); return; } let Some(_layout_res) = layout else { return }; pending_hint.spawn(g.0.clone(), solver_config.0); } /// Heuristic hint helper used by `pending_hint::poll_pending_hint_task` /// when the solver returns `Inconclusive` or `Unwinnable`. /// /// Picks the hint at `HintCycleIndex % hints.len()` (wrapping) and /// advances the index so successive H presses on a stuck position /// cycle through every legal move. Returns `None` when no legal move /// exists at all — the caller surfaces a "No hints available" toast. pub fn find_heuristic_hint( game: &GameState, hint_cycle: &mut HintCycleIndex, ) -> Option<(PileType, PileType)> { let hints = all_hints(game); if hints.is_empty() { return None; } let idx = hint_cycle.0 % hints.len(); hint_cycle.0 = hint_cycle.0.wrapping_add(1); let (from, to, _count) = hints[idx].clone(); Some((from, to)) } /// Apply the visual + toast effects for a single chosen hint move. /// /// Shared between the solver-driven and heuristic-driven hint paths so /// both produce identical player-facing feedback. Called from /// `pending_hint::poll_pending_hint_task` once the async solver task /// resolves. pub fn emit_hint_visuals( game: &GameState, from: &PileType, to: &PileType, commands: &mut Commands, mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>, info_toast: &mut MessageWriter, hint_visual: &mut MessageWriter, ) { // When the hint points at the stock (draw suggestion) there is no // face-up card to highlight — show a toast instead. // If the stock is empty, pressing D will recycle the waste rather // than draw a card, so the toast text must reflect that. if *from == PileType::Stock { let stock_empty = game.piles .get(&PileType::Stock) .is_some_and(|p| p.cards.is_empty()); let msg = if stock_empty { "Hint: recycle waste (D)".to_string() } else { "Hint: draw from stock (D)".to_string() }; info_toast.write(InfoToastEvent(msg)); return; } // Find the top face-up card in the source pile and highlight it. let top_card_id = game.piles.get(from) .and_then(|p| p.cards.last().filter(|c| c.face_up)) .map(|c| c.id); if let Some(card_id) = top_card_id { for (entity, card_entity, mut sprite) in card_entities.iter_mut() { if card_entity.card_id == card_id { // Tint the card gold without replacing the Sprite (which would // discard the image handle set by CardImageSet). Uses the // design-system `STATE_WARNING` token so the source-card // tint matches the destination pile highlight, both of // which signal "look here" for the hint. sprite.color = STATE_WARNING; commands.entity(entity) .insert(HintHighlight { remaining: 2.0 }) .insert(HintHighlightTimer(2.0)); break; } } // Emit HintVisualEvent so the destination pile marker is also // tinted gold for 2 s. hint_visual.write(HintVisualEvent { source_card_id: card_id, dest_pile: to.clone(), }); } // Fire an informational toast describing where the hinted card should // move so the player always sees the suggestion in text. When the // destination foundation already claims a suit, surface that suit so the // player keeps thinking in suit terms; otherwise fall back to "foundation". let msg = match to { PileType::Foundation(_) => { let claimed = game.piles.get(to).and_then(|p| p.claimed_suit()); if let Some(suit) = claimed { let suit_name = match suit { Suit::Clubs => "Clubs", Suit::Diamonds => "Diamonds", Suit::Hearts => "Hearts", Suit::Spades => "Spades", }; format!("Hint: move to {suit_name} foundation") } else { "Hint: move to foundation".to_string() } } PileType::Tableau(col) => format!("Hint: move to tableau (col {})", col + 1), _ => "Hint: move card".to_string(), }; info_toast.write(InfoToastEvent(msg)); } /// Handles the G key: fires `ForfeitRequestEvent` so `PausePlugin` /// can spawn the `ForfeitConfirmScreen` modal. /// /// Replaces a prior double-press toast countdown with a real /// Cancel / Yes-forfeit modal — the same code path the Pause modal's /// Forfeit button takes. The "no game to forfeit" check (won state, /// missing resource) lives in `handle_forfeit_request` so it can /// surface a toast; here we only gate on whether the player is paused /// (in which case the pause modal's Forfeit button is the entry /// point). fn handle_keyboard_forfeit( keys: Res>, paused: Option>, mut requests: MessageWriter, ) { if paused.is_some_and(|p| p.0) { return; } if !keys.just_pressed(KeyCode::KeyG) { return; } requests.write(ForfeitRequestEvent); } /// Resets [`HintCycleIndex`] to `0` whenever the game state changes or a new /// game is requested so the next H press always starts cycling from the first /// hint of the new position. /// /// Listening to both events ensures the reset happens immediately on /// `NewGameRequestEvent`, one frame before the `StateChangedEvent` that the /// game plugin fires after dealing — preventing a stale hint from the previous /// game being shown when H is pressed in that gap frame. fn reset_hint_cycle_on_state_change( mut state_events: MessageReader, mut new_game_events: MessageReader, mut hint_cycle: ResMut, ) { if state_events.read().next().is_some() || new_game_events.read().next().is_some() { hint_cycle.0 = 0; } } /// `F11` toggles between borderless-fullscreen and windowed mode. /// Not gated by the pause flag — the player can always resize the window. fn handle_fullscreen( keys: Res>, mut windows: Query<&mut Window, With>, mut toast: MessageWriter, ) { if !keys.just_pressed(KeyCode::F11) { return; } let Ok(mut window) = windows.single_mut() else { return }; let new_mode = match window.mode { WindowMode::Windowed => WindowMode::BorderlessFullscreen(MonitorSelection::Current), _ => WindowMode::Windowed, }; window.mode = new_mode; let label = match window.mode { WindowMode::Windowed => "Fullscreen: off", _ => "Fullscreen: on", }; toast.write(InfoToastEvent(label.to_string())); } fn handle_stock_click( buttons: Res>, drag: Res, paused: Option>, windows: Query<&Window, With>, cameras: Query<(&Camera, &GlobalTransform)>, layout: Option>, mut draw: MessageWriter, ) { if paused.is_some_and(|p| p.0) { return; } if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() { return; } let Some(layout) = layout else { return; }; let Some(world) = cursor_world(&windows, &cameras) else { return; }; let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else { return; }; if point_in_rect(world, stock_pos, layout.0.card_size) { draw.write(DrawRequestEvent); } } /// Fires [`DrawRequestEvent`] when the player taps the stock pile on a touch screen. /// /// Uses `TouchPhase::Started` (the finger-down moment) for instant responsiveness /// — since the stock cannot be dragged, there is no ambiguity between a tap and /// the start of a drag on this pile. Does nothing while a drag is in progress. fn handle_touch_stock_tap( mut touch_events: MessageReader, paused: Option>, cameras: Query<(&Camera, &GlobalTransform)>, layout: Option>, drag: Res, mut draw: MessageWriter, ) { if paused.is_some_and(|p| p.0) { return; } if !drag.is_idle() { return; } let Some(layout) = layout else { return }; for event in touch_events.read() { if event.phase != TouchPhase::Started { continue; } let Some(world) = touch_to_world(&cameras, event.position) else { continue; }; let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else { continue; }; if point_in_rect(world, stock_pos, layout.0.card_size) { draw.write(DrawRequestEvent); break; // one draw per tap frame } } } /// Begins a mouse drag: records the press position and the cards that would be /// dragged. Cards are **not** elevated yet — that happens in [`follow_drag`] /// once the drag threshold is crossed. fn start_drag( buttons: Res>, paused: Option>, windows: Query<&Window, With>, cameras: Query<(&Camera, &GlobalTransform)>, layout: Option>, game: Res, mut drag: ResMut, ) { if paused.is_some_and(|p| p.0) { return; } // Only start a new drag when idle (no touch drag running either). if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() { return; } let Some(layout) = layout else { return }; let Some(world) = cursor_world(&windows, &cameras) else { return }; // Don't pick up the stock — that is handled by handle_stock_click. let Some((pile, stack_index, card_ids)) = find_draggable_at(world, &game.0, &layout.0) else { return; }; let bottom_pos = card_position(&game.0, &layout.0, &pile, stack_index); // Store as a pending drag. We do NOT elevate the cards yet — the visual // lift happens in follow_drag once the threshold is crossed. drag.cards = card_ids; drag.origin_pile = Some(pile); drag.cursor_offset = bottom_pos - world; drag.origin_z = DRAG_Z; drag.press_pos = world; drag.committed = false; drag.active_touch_id = None; } /// Moves dragged cards with the mouse cursor each frame. /// /// If the drag has not yet been committed (threshold not crossed), checks /// whether the cursor has moved far enough from the press position to commit. /// On commit, cards are elevated to `DRAG_Z` and dimmed. Does nothing for /// touch-driven drags (`drag.active_touch_id.is_some()`). #[allow(clippy::too_many_arguments)] fn follow_drag( windows: Query<&Window, With>, cameras: Query<(&Camera, &GlobalTransform)>, mut drag: ResMut, layout: Option>, tuning: Res, mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>, ) { // Skip if idle or if a touch drag is running. if drag.is_idle() || drag.active_touch_id.is_some() { return; } let Some(layout) = layout else { return }; let Some(world) = cursor_world(&windows, &cameras) else { // Cursor left the window mid-drag. Cancel a pending drag; let a // committed drag freeze at the last known position. if !drag.committed { drag.clear(); } return; }; // Check drag threshold on the first frames after press. if !drag.committed { // Use screen-space distance (world ≈ screen for 2-D games with no // camera zoom, which is our case). let moved = world.distance(drag.press_pos); if moved < tuning.drag_threshold_px { return; // Still within tap zone — don't start visual drag yet. } // Threshold crossed → commit. drag.committed = true; // Elevate cards: push to DRAG_Z and dim slightly so the board // beneath stays readable. for (i, &id) in drag.cards.iter().enumerate() { if let Some((_, mut transform, mut sprite)) = card_transforms.iter_mut().find(|(ce, _, _)| ce.card_id == id) { transform.translation.z = DRAG_Z + i as f32 * 0.01; sprite.color.set_alpha(0.85); } } } // Move cards to the cursor. let bottom_pos = world + drag.cursor_offset; let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC; for (i, &id) in drag.cards.iter().enumerate() { if let Some((_, mut transform, _)) = card_transforms.iter_mut().find(|(ce, _, _)| ce.card_id == id) { transform.translation.x = bottom_pos.x; transform.translation.y = bottom_pos.y + fan * i as f32; } } } #[allow(clippy::too_many_arguments)] fn end_drag( buttons: Res>, paused: Option>, windows: Query<&Window, With>, cameras: Query<(&Camera, &GlobalTransform)>, layout: Option>, game: Res, mut drag: ResMut, mut moves: MessageWriter, mut rejected: MessageWriter, mut changed: MessageWriter, mut commands: Commands, card_entities: Query<(Entity, &CardEntity, &Transform)>, ) { if paused.is_some_and(|p| p.0) { drag.clear(); return; } // Only handle mouse releases; touch releases are handled by touch_end_drag. if !buttons.just_released(MouseButton::Left) || drag.is_idle() { return; } if drag.active_touch_id.is_some() { return; // Touch-driven drag — not ours to handle. } // If the drag was never committed (user tapped without moving far enough), // treat it as a click: cancel the pending drag and exit. We deliberately // do NOT fire `StateChangedEvent` here — `start_drag` only mutates the // `DragState` resource on press, never card transforms, so an uncommitted // drag has no visual side effect to undo. // // Firing one would race a CardAnim that's already in flight on the same // card. Specifically: on a successful double-click, `handle_double_click` // fires `MoveRequestEvent`, `start_drag` picks the card up the same // frame (uncommitted), and `handle_move` queues a `StateChangedEvent` → // `sync_cards_on_change` starts a slide animation. When the player // releases the button mid-slide, `end_drag` would fire a second // `StateChangedEvent`, `sync_cards_on_change` would see the card mid- // animation (`cur != target`), and replace the in-flight CardAnim with // a fresh one — restarting the slide and reading on screen as the move // animation playing twice. if !drag.committed { drag.clear(); return; } let Some(layout) = layout else { return; }; let Some(origin) = drag.origin_pile.clone() else { drag.clear(); return; }; let count = drag.cards.len(); let world = cursor_world(&windows, &cameras); let target = world.and_then(|w| find_drop_target(w, &game.0, &layout.0, &origin)); // Whether we fire a MoveRequestEvent or not, always trigger a resync so // the dragged cards snap back to their resting positions if the move is // rejected (or never fired). When the cursor was over a real pile but // the placement is illegal, fire MoveRejectedEvent so AudioPlugin can // play card_invalid.wav. let mut fired = false; if let Some(target) = target && target != origin { let bottom_card_id = drag.cards[0]; if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) { let ok = match &target { PileType::Foundation(_) => { count == 1 && can_place_on_foundation( &bottom_card, &game.0.piles[&target], ) } PileType::Tableau(_) => { can_place_on_tableau(&bottom_card, &game.0.piles[&target]) } _ => false, }; if ok { moves.write(MoveRequestEvent { from: origin.clone(), to: target.clone(), count, }); fired = true; } else { rejected.write(MoveRejectedEvent { from: origin.clone(), to: target.clone(), count, }); // Smoothly glide each dragged card from its drop-time // transform back to its resting slot in the origin pile. // The audio cue (card_invalid.wav, played by AudioPlugin // on MoveRejectedEvent) still gives the player clear // negative feedback; this just replaces the old shake // wiggle with a forgiving ease-out tween. // // `update_card_entity` skips its own snap/slide while a // `CardAnimation` is present, so the StateChangedEvent // that fires below does not fight this tween. if let Some(origin_pile) = game.0.piles.get(&origin) { for &card_id in &drag.cards { let Some(stack_index) = origin_pile.cards.iter().position(|c| c.id == card_id) else { continue; }; let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); if let Some((entity, _, transform)) = card_entities .iter() .find(|(_, ce, _)| ce.card_id == card_id) { let drag_pos = transform.translation.truncate(); let drag_z = transform.translation.z; let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC; commands.entity(entity).insert( CardAnimation::slide( drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive, ) .with_duration(MOTION_DRAG_REJECT_SECS), ); } } } } } } drag.clear(); // Either the move succeeded (GamePlugin will also fire StateChangedEvent) // or it didn't — in both cases we emit one so cards resync to the current // game state. Duplicate events are harmless. changed.write(StateChangedEvent); let _ = fired; } // --------------------------------------------------------------------------- // Touch drag pipeline // --------------------------------------------------------------------------- /// Begins a touch drag when a finger first touches a face-up card. /// /// Mirrors [`start_drag`] but uses [`TouchInput`] events instead of mouse /// buttons. Records the touch ID in [`DragState`] so only this finger drives /// the drag — other fingers are ignored. fn touch_start_drag( mut touch_events: MessageReader, paused: Option>, cameras: Query<(&Camera, &GlobalTransform)>, layout: Option>, game: Res, mut drag: ResMut, ) { if paused.is_some_and(|p| p.0) { return; } // Only one drag at a time. if !drag.is_idle() { return; } let Some(layout) = layout else { return }; for event in touch_events.read() { if event.phase != TouchPhase::Started { continue; } let Some(world) = touch_to_world(&cameras, event.position) else { continue; }; let Some((pile, stack_index, card_ids)) = find_draggable_at(world, &game.0, &layout.0) else { continue; }; let bottom_pos = card_position(&game.0, &layout.0, &pile, stack_index); drag.cards = card_ids; drag.origin_pile = Some(pile); drag.cursor_offset = bottom_pos - world; drag.origin_z = DRAG_Z; drag.press_pos = event.position; // screen-space for threshold comparison drag.committed = false; drag.active_touch_id = Some(event.id); // Process only the first touch that landed on a card. break; } } /// Moves touch-dragged cards with the active finger each frame. /// /// Checks the drag threshold on the first frames after the touch began and /// commits (elevates cards) once exceeded. Does nothing for mouse drags. #[allow(clippy::too_many_arguments)] fn touch_follow_drag( touches: Option>, cameras: Query<(&Camera, &GlobalTransform)>, mut drag: ResMut, layout: Option>, tuning: Res, mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>, ) { let Some(active_id) = drag.active_touch_id else { return; // Mouse drag or idle. }; let Some(touches) = touches else { return }; let Some(layout) = layout else { return }; // Look up the driving touch. let Some(touch) = touches.iter().find(|t| t.id() == active_id) else { // Touch no longer active — will be cleaned up by touch_end_drag. return; }; let Some(world) = touch_to_world(&cameras, touch.position()) else { return; }; if !drag.committed { // Compare screen-space distance from the original press position. let moved = touch.position().distance(drag.press_pos); if moved < tuning.drag_threshold_px { return; } drag.committed = true; for (i, &id) in drag.cards.iter().enumerate() { if let Some((_, mut transform, mut sprite)) = card_transforms.iter_mut().find(|(ce, _, _)| ce.card_id == id) { transform.translation.z = DRAG_Z + i as f32 * 0.01; sprite.color.set_alpha(0.85); } } } let bottom_pos = world + drag.cursor_offset; let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC; for (i, &id) in drag.cards.iter().enumerate() { if let Some((_, mut transform, _)) = card_transforms.iter_mut().find(|(ce, _, _)| ce.card_id == id) { transform.translation.x = bottom_pos.x; transform.translation.y = bottom_pos.y + fan * i as f32; } } } /// Resolves a touch drag when the finger lifts or is cancelled. /// /// Mirrors [`end_drag`] but reads [`TouchInput`] events instead of mouse /// buttons. Uncommitted drags (tap gestures) are cancelled cleanly. #[allow(clippy::too_many_arguments)] fn touch_end_drag( mut touch_events: MessageReader, paused: Option>, cameras: Query<(&Camera, &GlobalTransform)>, layout: Option>, game: Res, mut drag: ResMut, mut moves: MessageWriter, mut rejected: MessageWriter, mut changed: MessageWriter, mut commands: Commands, card_entities: Query<(Entity, &CardEntity, &Transform)>, ) { let Some(active_id) = drag.active_touch_id else { return; // Mouse drag or idle. }; if paused.is_some_and(|p| p.0) { drag.clear(); return; } for event in touch_events.read() { if event.id != active_id { continue; } if !matches!(event.phase, TouchPhase::Ended | TouchPhase::Canceled) { continue; } // Uncommitted tap — cancel cleanly. if !drag.committed { drag.clear(); changed.write(StateChangedEvent); return; } let Some(origin) = drag.origin_pile.clone() else { drag.clear(); return; }; let count = drag.cards.len(); // Find the drop target using the finger's lift position. let world = touch_to_world(&cameras, event.position); let Some(layout) = layout.as_ref() else { drag.clear(); changed.write(StateChangedEvent); return; }; let target = world.and_then(|w| find_drop_target(w, &game.0, &layout.0, &origin)); let mut fired = false; if let Some(target) = target && target != origin { let bottom_card_id = drag.cards[0]; if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) { let ok = match &target { PileType::Foundation(_) => { count == 1 && can_place_on_foundation(&bottom_card, &game.0.piles[&target]) } PileType::Tableau(_) => { can_place_on_tableau(&bottom_card, &game.0.piles[&target]) } _ => false, }; if ok { moves.write(MoveRequestEvent { from: origin.clone(), to: target, count }); fired = true; } else { rejected.write(MoveRejectedEvent { from: origin.clone(), to: target, count }); // Smoothly glide each dragged card from its drop-time // transform back to its resting slot. See `end_drag` // (mouse path) for the full rationale; the touch path // mirrors it exactly so finger and mouse rejection // feel identical. if let Some(origin_pile) = game.0.piles.get(&origin) { for &card_id in &drag.cards { let Some(stack_index) = origin_pile.cards.iter().position(|c| c.id == card_id) else { continue; }; let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); if let Some((entity, _, transform)) = card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id) { let drag_pos = transform.translation.truncate(); let drag_z = transform.translation.z; let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC; commands.entity(entity).insert( CardAnimation::slide( drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive, ) .with_duration(MOTION_DRAG_REJECT_SECS), ); } } } } } } drag.clear(); changed.write(StateChangedEvent); let _ = fired; return; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn cursor_world( windows: &Query<&Window, With>, cameras: &Query<(&Camera, &GlobalTransform)>, ) -> Option { let window = windows.single().ok()?; let cursor = window.cursor_position()?; let (camera, camera_transform) = cameras.single().ok()?; camera.viewport_to_world_2d(camera_transform, cursor).ok() } /// Converts a touch screen position (logical pixels, top-left origin) to /// world-space 2-D coordinates using the primary camera. /// /// Returns `None` if no camera is present or the projection fails. fn touch_to_world( cameras: &Query<(&Camera, &GlobalTransform)>, screen_pos: Vec2, ) -> Option { let (camera, camera_transform) = cameras.single().ok()?; camera.viewport_to_world_2d(camera_transform, screen_pos).ok() } /// Axis-aligned rectangle hit-test with a center and full size. fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool { let half = size / 2.0; point.x >= center.x - half.x && point.x <= center.x + half.x && point.y >= center.y - half.y && point.y <= center.y + half.y } /// Where a card at `stack_index` in pile `pile` would be rendered. /// /// For tableau columns the per-card fan step depends on the face-up state of /// every preceding card — face-down cards step by `TABLEAU_FACEDOWN_FAN_FRAC`, /// face-up cards by `TABLEAU_FAN_FRAC`. Mirrors `card_plugin::card_positions` /// exactly; any drift creates an offset between the visible card face and /// where clicks land. fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 { let base = layout.pile_positions[pile]; if matches!(pile, PileType::Tableau(_)) { let mut y_offset = 0.0_f32; if let Some(pile_cards) = game.piles.get(pile) { for card in pile_cards.cards.iter().take(stack_index) { let step = if card.face_up { TABLEAU_FAN_FRAC } else { TABLEAU_FACEDOWN_FAN_FRAC }; y_offset -= layout.card_size.y * step; } } Vec2::new(base.x, base.y + y_offset) } else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree { // In Draw-Three mode the top 3 waste cards are fanned in X to match // card_plugin::card_positions(). Hit-testing must use the same offsets // so clicking the visually rightmost (top) card actually registers. let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len()); let visible_start = pile_len.saturating_sub(3); let slot = stack_index.saturating_sub(visible_start) as f32; Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y) } else { base } } fn card_by_id(game: &GameState, id: u32) -> Option { for pile in game.piles.values() { if let Some(card) = pile.cards.iter().find(|c| c.id == id) { return Some(card.clone()); } } None } /// Given a world-space cursor, find the topmost draggable card. Returns /// `(pile, bottom_stack_index, card_ids_bottom_to_top)`. fn find_draggable_at( cursor: Vec2, game: &GameState, layout: &Layout, ) -> Option<(PileType, usize, Vec)> { // Search order: waste, foundations, tableau. Stock is skipped (click-to-draw). // Within a pile, we consider cards top-down because the visual top card is drawn last. let piles = [ PileType::Waste, PileType::Foundation(0), PileType::Foundation(1), PileType::Foundation(2), PileType::Foundation(3), PileType::Tableau(0), PileType::Tableau(1), PileType::Tableau(2), PileType::Tableau(3), PileType::Tableau(4), PileType::Tableau(5), PileType::Tableau(6), ]; for pile in piles { let Some(pile_cards) = game.piles.get(&pile) else { continue; }; if pile_cards.cards.is_empty() { continue; } let is_tableau = matches!(pile, PileType::Tableau(_)); // Iterate from topmost to bottommost so the first hit is the one // visually on top. for i in (0..pile_cards.cards.len()).rev() { let card = &pile_cards.cards[i]; if !card.face_up { continue; } let pos = card_position(game, layout, &pile, i); if !point_in_rect(cursor, pos, layout.card_size) { continue; } // Picked a face-up card. Determine drag range: // - Tableau: cards [i..len), must all be face-up (guaranteed // because tableau never has face-down above face-up). // - Waste / Foundation: only the top card is draggable. let (start, end) = if is_tableau { (i, pile_cards.cards.len()) } else { if i != pile_cards.cards.len() - 1 { return None; } (i, i + 1) }; let ids: Vec = pile_cards.cards[start..end].iter().map(|c| c.id).collect(); return Some((pile, start, ids)); } } None } /// Pick the drop-target pile whose extended rectangle contains `cursor`. /// Returns `None` if the cursor is outside every pile's rectangle. fn find_drop_target( cursor: Vec2, game: &GameState, layout: &Layout, origin: &PileType, ) -> Option { let piles = [ PileType::Foundation(0), PileType::Foundation(1), PileType::Foundation(2), PileType::Foundation(3), PileType::Tableau(0), PileType::Tableau(1), PileType::Tableau(2), PileType::Tableau(3), PileType::Tableau(4), PileType::Tableau(5), PileType::Tableau(6), ]; for pile in piles { let (center, size) = pile_drop_rect(&pile, layout, game); if point_in_rect(cursor, center, size) { // Skip origin — dropping onto the source is a no-op. if pile == *origin { continue; } return Some(pile); } } None } /// Bounding rect used for drop detection. For tableaus this extends /// downward to cover the entire visible fan of cards. fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2, Vec2) { let center = layout.pile_positions[pile]; 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 bottom_card_center_y = center.y + fan * (card_count - 1) as f32; let top_edge = center.y + layout.card_size.y / 2.0; let bottom_edge = bottom_card_center_y - layout.card_size.y / 2.0; let span_height = top_edge - bottom_edge; let new_center_y = (top_edge + bottom_edge) / 2.0; return ( Vec2::new(center.x, new_center_y), Vec2::new(layout.card_size.x, span_height), ); } } (center, layout.card_size) } // --------------------------------------------------------------------------- // Task #27 — Double-click to auto-move // --------------------------------------------------------------------------- /// Maximum seconds between two clicks to count as a double-click. const DOUBLE_CLICK_WINDOW: f32 = 0.35; /// Find the best legal destination for `card` — Foundation first, then Tableau. /// /// Returns `None` if no legal move exists from the card's current location. pub fn best_destination(card: &Card, game: &GameState) -> Option { // Try all four foundation slots first. for slot in 0..4_u8 { let dest = PileType::Foundation(slot); if let Some(pile) = game.piles.get(&dest) && can_place_on_foundation(card, pile) { return Some(dest); } } // Then try all seven tableau piles. for i in 0..7_usize { let dest = PileType::Tableau(i); if let Some(pile) = game.piles.get(&dest) && can_place_on_tableau(card, pile) { return Some(dest); } } None } /// Find the best tableau column onto which the stack rooted at `bottom_card` /// can be legally placed, excluding the stack's own source pile. /// /// Returns `(destination, stack_count)` if a legal target exists, or `None` /// if the stack cannot move anywhere. Only tableau destinations are considered /// because multi-card stacks cannot go to foundations. pub fn best_tableau_destination_for_stack( bottom_card: &Card, from: &PileType, game: &GameState, stack_count: usize, ) -> Option<(PileType, usize)> { for i in 0..7_usize { let dest = PileType::Tableau(i); if dest == *from { continue; } if let Some(pile) = game.piles.get(&dest) && can_place_on_tableau(bottom_card, pile) { return Some((dest, stack_count)); } } None } /// System that detects double-clicks on face-up cards and fires `MoveRequestEvent` /// to the best legal destination. /// /// Move priority: /// 1. Move the single **top** card to its best foundation (or tableau) destination. /// 2. If no single-card move exists and the clicked card is the base of a /// multi-card face-up stack, move the whole stack to the best tableau column. /// /// When a multi-card stack double-click finds no legal destination (Priority 2 /// returns `None`), fires `MoveRejectedEvent` with `from == to == pile` so the /// invalid-move sound plays and the source pile cards shake as feedback. #[allow(clippy::too_many_arguments)] fn handle_double_click( buttons: Res>, paused: Option>, time: Res