modified: solitaire_engine/src/card_plugin.rs

modified:   solitaire_engine/src/input_plugin.rs
This commit is contained in:
funman300
2026-04-23 20:48:57 -07:00
parent 900de7f376
commit b3646d6cad
2 changed files with 501 additions and 20 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
/// Fraction of card height used as vertical offset between stacked tableau cards. /// 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 /// Fraction of card height used as a tiny offset between stacked cards in
/// non-tableau piles, so stacking is visible. /// non-tableau piles, so stacking is visible.
+500 -19
View File
@@ -1,29 +1,61 @@
//! Keyboard + mouse input for the game board. //! Keyboard + mouse input for the game board.
//! //!
//! Keyboard:
//! - `U` → `UndoRequestEvent` //! - `U` → `UndoRequestEvent`
//! - `N` → `NewGameRequestEvent { seed: None }` //! - `N` → `NewGameRequestEvent { seed: None }`
//! - `D` → `DrawRequestEvent` //! - `D` → `DrawRequestEvent`
//! - `Esc` → logged as a pause placeholder (no event yet; wired up when the //! - `Esc` → logged as a pause placeholder (no event yet; wired up when the
//! pause screen lands in a later phase) //! 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::input::ButtonInput;
use bevy::math::Vec2; use bevy::math::{Vec2, Vec3};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use solitaire_core::card::Suit;
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType; 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::card_plugin::{CardEntity, TABLEAU_FAN_FRAC};
use crate::layout::LayoutResource; 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; pub struct InputPlugin;
impl Plugin for InputPlugin { impl Plugin for InputPlugin {
fn build(&self, app: &mut App) { 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<ButtonInput<MouseButton>>, buttons: Res<ButtonInput<MouseButton>>,
drag: Res<DragState>,
windows: Query<&Window, With<PrimaryWindow>>, windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>, cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
mut draw: EventWriter<DrawRequestEvent>, mut draw: EventWriter<DrawRequestEvent>,
) { ) {
if !buttons.just_pressed(MouseButton::Left) { if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
return; return;
} }
let Some(layout) = layout else { let Some(layout) = layout else {
return; return;
}; };
let Ok(window) = windows.get_single() else { let Some(world) = cursor_world(&windows, &cameras) 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 {
return; return;
}; };
@@ -82,6 +106,169 @@ fn handle_mouse_clicks(
} }
} }
fn start_drag(
buttons: Res<ButtonInput<MouseButton>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
mut drag: ResMut<DragState>,
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<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
drag: Res<DragState>,
layout: Option<Res<LayoutResource>>,
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<ButtonInput<MouseButton>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
mut drag: ResMut<DragState>,
mut moves: EventWriter<MoveRequestEvent>,
mut changed: EventWriter<StateChangedEvent>,
) {
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<PrimaryWindow>>,
cameras: &Query<(&Camera, &GlobalTransform)>,
) -> Option<Vec2> {
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. /// Axis-aligned rectangle hit-test with a center and full size.
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool { fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
let half = size / 2.0; 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 && 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<solitaire_core::card::Card> {
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<u32>)> {
// 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<u32> = 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<PileType> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::layout::compute_layout;
use solitaire_core::game_state::{DrawMode, GameState};
#[test] #[test]
fn point_in_rect_inside_returns_true() { 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(0.0, 6.0), center, size));
assert!(!point_in_rect(Vec2::new(-100.0, 0.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<Vec3> = None;