From b3646d6cade955cec066079ce6c1d2cf6aa125d5 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 23 Apr 2026 20:48:57 -0700 Subject: [PATCH] modified: solitaire_engine/src/card_plugin.rs modified: solitaire_engine/src/input_plugin.rs --- solitaire_engine/src/card_plugin.rs | 2 +- solitaire_engine/src/input_plugin.rs | 519 ++++++++++++++++++++++++++- 2 files changed, 501 insertions(+), 20 deletions(-) diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 7513b52..bfe3a70 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -25,7 +25,7 @@ use crate::layout::{Layout, LayoutResource}; use crate::resources::GameStateResource; /// Fraction of card height used as vertical offset between stacked tableau cards. -const TABLEAU_FAN_FRAC: f32 = 0.25; +pub const TABLEAU_FAN_FRAC: f32 = 0.25; /// Fraction of card height used as a tiny offset between stacked cards in /// non-tableau piles, so stacking is visible. diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index add9f4f..93c5e7f 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -1,29 +1,61 @@ //! Keyboard + mouse input for the game board. //! +//! Keyboard: //! - `U` → `UndoRequestEvent` //! - `N` → `NewGameRequestEvent { seed: None }` //! - `D` → `DrawRequestEvent` //! - `Esc` → logged as a pause placeholder (no event yet; wired up when the //! pause screen lands in a later phase) -//! - Left-click on the stock pile → `DrawRequestEvent` //! -//! Drag-and-drop for tableau/waste/foundation moves is handled in Phase 3E. +//! 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 bevy::input::ButtonInput; -use bevy::math::Vec2; +use bevy::math::{Vec2, Vec3}; use bevy::prelude::*; use bevy::window::PrimaryWindow; +use solitaire_core::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::events::{DrawRequestEvent, NewGameRequestEvent, UndoRequestEvent}; -use crate::layout::LayoutResource; +use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC}; +use crate::events::{ + DrawRequestEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, +}; +use crate::game_plugin::GameMutation; +use crate::layout::{Layout, LayoutResource}; +use crate::resources::{DragState, GameStateResource}; -/// Registers the keyboard + mouse input systems. +/// Z-depth used for cards while being dragged — above all resting cards. +const DRAG_Z: f32 = 500.0; + +/// Registers keyboard and mouse input systems. +/// +/// Drag systems run in a fixed order each frame: +/// `start_drag` → `follow_drag` → `end_drag`, with `follow_drag` after the +/// card-position sync so it overrides resting positions for cards being +/// dragged. `end_drag` runs before `GameMutation` so the `MoveRequestEvent` +/// it fires is consumed the same frame. pub struct InputPlugin; impl Plugin for InputPlugin { fn build(&self, app: &mut App) { - app.add_systems(Update, (handle_keyboard, handle_mouse_clicks)); + app.add_systems( + Update, + ( + handle_keyboard, + handle_stock_click, + start_drag, + follow_drag, + end_drag.before(GameMutation), + ) + .chain(), + ); } } @@ -48,29 +80,21 @@ fn handle_keyboard( } } -fn handle_mouse_clicks( +fn handle_stock_click( buttons: Res>, + drag: Res, windows: Query<&Window, With>, cameras: Query<(&Camera, &GlobalTransform)>, layout: Option>, mut draw: EventWriter, ) { - if !buttons.just_pressed(MouseButton::Left) { + if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() { return; } let Some(layout) = layout else { return; }; - let Ok(window) = windows.get_single() else { - return; - }; - let Some(cursor) = window.cursor_position() else { - return; - }; - let Ok((camera, camera_transform)) = cameras.get_single() else { - return; - }; - let Ok(world) = camera.viewport_to_world_2d(camera_transform, cursor) else { + let Some(world) = cursor_world(&windows, &cameras) else { return; }; @@ -82,6 +106,169 @@ fn handle_mouse_clicks( } } +fn start_drag( + buttons: Res>, + windows: Query<&Window, With>, + cameras: Query<(&Camera, &GlobalTransform)>, + layout: Option>, + game: Res, + mut drag: ResMut, + mut card_transforms: Query<(&CardEntity, &mut Transform)>, +) { + 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 try to pick up the stock — that's the draw click. + let Some((pile, stack_index, card_ids)) = find_draggable_at(world, &game.0, &layout.0) else { + return; + }; + + let Some(&bottom_id) = card_ids.first() else { + return; + }; + + // Find the bottom drag card's current world position so we can compute + // the offset between cursor and that card (grab point). + let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index); + let cursor_offset = bottom_pos - world; + + // Elevate dragged cards to DRAG_Z. + for (i, id) in card_ids.iter().enumerate() { + if let Some((_, mut transform)) = card_transforms + .iter_mut() + .find(|(entity, _)| entity.card_id == *id) + { + transform.translation.z = DRAG_Z + (i as f32) * 0.01; + } + } + + drag.cards = card_ids; + drag.origin_pile = Some(pile); + drag.cursor_offset = cursor_offset; + drag.origin_z = DRAG_Z; + let _ = bottom_id; // retained for clarity, not used further +} + +fn follow_drag( + windows: Query<&Window, With>, + cameras: Query<(&Camera, &GlobalTransform)>, + drag: Res, + layout: Option>, + mut card_transforms: Query<(&CardEntity, &mut Transform)>, +) { + if drag.is_idle() { + return; + } + let Some(layout) = layout else { + return; + }; + let Some(world) = cursor_world(&windows, &cameras) else { + return; + }; + + 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(|(entity, _)| entity.card_id == *id) + { + transform.translation.x = bottom_pos.x; + transform.translation.y = bottom_pos.y + fan * (i as f32); + } + } +} + +fn end_drag( + buttons: Res>, + windows: Query<&Window, With>, + cameras: Query<(&Camera, &GlobalTransform)>, + layout: Option>, + game: Res, + mut drag: ResMut, + mut moves: EventWriter, + mut changed: EventWriter, +) { + if !buttons.just_released(MouseButton::Left) || drag.is_idle() { + 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). + let mut fired = false; + if let Some(target) = target { + if 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(suit) => { + count == 1 + && can_place_on_foundation( + &bottom_card, + &game.0.piles[&target], + *suit, + ) + } + PileType::Tableau(_) => { + can_place_on_tableau(&bottom_card, &game.0.piles[&target]) + } + _ => false, + }; + if ok { + moves.send(MoveRequestEvent { + from: origin, + to: target, + count, + }); + fired = true; + } + } + } + } + + 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.send(StateChangedEvent); + let _ = fired; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn cursor_world( + windows: &Query<&Window, With>, + cameras: &Query<(&Camera, &GlobalTransform)>, +) -> Option { + let window = windows.get_single().ok()?; + let cursor = window.cursor_position()?; + let (camera, camera_transform) = cameras.get_single().ok()?; + camera.viewport_to_world_2d(camera_transform, cursor).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; @@ -91,9 +278,154 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool { && point.y <= center.y + half.y } +/// Where a card at `stack_index` in pile `pile` would be rendered. +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 fan = -layout.card_size.y * TABLEAU_FAN_FRAC; + Vec2::new(base.x, base.y + fan * (stack_index as f32)) + } else { + let _ = game; + 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(Suit::Clubs), + PileType::Foundation(Suit::Diamonds), + PileType::Foundation(Suit::Hearts), + PileType::Foundation(Suit::Spades), + 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.clone(), 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(Suit::Clubs), + PileType::Foundation(Suit::Diamonds), + PileType::Foundation(Suit::Hearts), + PileType::Foundation(Suit::Spades), + 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) +} + #[cfg(test)] mod tests { use super::*; + use crate::layout::compute_layout; + use solitaire_core::game_state::{DrawMode, GameState}; #[test] fn point_in_rect_inside_returns_true() { @@ -120,4 +452,153 @@ mod tests { assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size)); assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size)); } + + #[test] + fn find_draggable_picks_top_of_tableau() { + let game = GameState::new(42, DrawMode::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0)); + + // In tableau 6, the visually topmost card is the last (face-up) one. + // Its position: base.y + fan * 6. + let top_pos = card_position(&game, &layout, PileType::Tableau(6), 6); + let result = find_draggable_at(top_pos, &game, &layout).expect("hit"); + assert_eq!(result.0, PileType::Tableau(6)); + assert_eq!(result.1, 6); + assert_eq!(result.2.len(), 1); + } + + #[test] + fn find_draggable_skips_face_down_cards() { + let game = GameState::new(42, DrawMode::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0)); + + // Tableau 6 has 7 cards; only index 6 is face-up. A cursor over the + // position of the bottom face-down card (index 0) should miss — + // that card is face-down and the topmost face-up card overlaps at + // a different fanned position. + let bottom_pos = card_position(&game, &layout, PileType::Tableau(6), 0); + // Shift to avoid accidental overlap with the face-up card above it. + let below_bottom = bottom_pos - Vec2::new(0.0, layout.card_size.y * 0.4); + let result = find_draggable_at(below_bottom, &game, &layout); + assert!(result.is_none(), "face-down cards should not be draggable"); + } + + #[test] + fn find_draggable_returns_run_when_picking_mid_stack() { + // Manually construct a tableau with three face-up cards all stacked. + let mut game = GameState::new(1, DrawMode::DrawOne); + use solitaire_core::card::{Card, Rank, Suit}; + let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap(); + t0.cards.clear(); + t0.cards.push(Card { + id: 100, + suit: Suit::Spades, + rank: Rank::King, + face_up: true, + }); + t0.cards.push(Card { + id: 101, + suit: Suit::Hearts, + rank: Rank::Queen, + face_up: true, + }); + t0.cards.push(Card { + id: 102, + suit: Suit::Clubs, + rank: Rank::Jack, + face_up: true, + }); + + let layout = compute_layout(Vec2::new(1280.0, 800.0)); + // Click the middle card (Queen at stack index 1). + let pos = card_position(&game, &layout, PileType::Tableau(0), 1); + let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); + assert_eq!(pile, PileType::Tableau(0)); + assert_eq!(start, 1); + assert_eq!(ids, vec![101, 102]); + } + + #[test] + fn find_draggable_skips_non_top_waste_card() { + let mut game = GameState::new(1, DrawMode::DrawOne); + use solitaire_core::card::{Card, Rank, Suit}; + let waste = game.piles.get_mut(&PileType::Waste).unwrap(); + waste.cards.clear(); + waste.cards.push(Card { + id: 200, + suit: Suit::Spades, + rank: Rank::Two, + face_up: true, + }); + waste.cards.push(Card { + id: 201, + suit: Suit::Hearts, + rank: Rank::Three, + face_up: true, + }); + + let layout = compute_layout(Vec2::new(1280.0, 800.0)); + // Both cards in waste sit at the same (x, y). Clicking should pick + // the visually top card (id 201), with count = 1. + let pos = card_position(&game, &layout, PileType::Waste, 0); + let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); + assert_eq!(pile, PileType::Waste); + assert_eq!(start, 1); + assert_eq!(ids, vec![201]); + } + + #[test] + fn find_drop_target_hits_empty_tableau_pile_marker() { + let game = GameState::new(42, DrawMode::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0)); + // Move all cards out of tableau 0 so its marker is the only drop area. + let mut game = game; + game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear(); + let pos = layout.pile_positions[&PileType::Tableau(0)]; + let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(6)); + assert_eq!(target, Some(PileType::Tableau(0))); + } + + #[test] + fn find_drop_target_returns_none_for_origin() { + let game = GameState::new(42, DrawMode::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0)); + let pos = layout.pile_positions[&PileType::Tableau(3)]; + let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3)); + assert_eq!(target, None); + } + + #[test] + fn pile_drop_rect_extends_for_tableau_with_cards() { + let game = GameState::new(42, DrawMode::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0)); + // Tableau 6 has 7 cards. + let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game); + // Expected: card_height + 6 * fan. fan = 0.25 * card_height, so + // size.y = card_height * (1 + 6 * 0.25) = card_height * 2.5. + let expected = layout.card_size.y * 2.5; + assert!( + (size.y - expected).abs() < 1e-3, + "expected {expected}, got {}", + size.y + ); + } + + #[test] + fn pile_drop_rect_is_card_sized_for_non_tableau() { + let game = GameState::new(42, DrawMode::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0)); + for pile in [ + PileType::Waste, + PileType::Foundation(Suit::Hearts), + ] { + let (_, size) = pile_drop_rect(&pile, &layout, &game); + assert_eq!(size, layout.card_size); + } + } } + +// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced +// when the compiler can't see it used. +#[allow(dead_code)] +const _VEC3_REFERENCED: Option = None;