diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index d698f62..239b62c 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -1,5 +1,5 @@ use bevy::prelude::*; -use solitaire_engine::{CardPlugin, GamePlugin, TablePlugin}; +use solitaire_engine::{CardPlugin, GamePlugin, InputPlugin, TablePlugin}; fn main() { App::new() @@ -16,5 +16,6 @@ fn main() { .add_plugins(GamePlugin) .add_plugins(TablePlugin) .add_plugins(CardPlugin) + .add_plugins(InputPlugin) .run(); } diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs new file mode 100644 index 0000000..add9f4f --- /dev/null +++ b/solitaire_engine/src/input_plugin.rs @@ -0,0 +1,123 @@ +//! Keyboard + mouse input for the game board. +//! +//! - `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. + +use bevy::input::ButtonInput; +use bevy::math::Vec2; +use bevy::prelude::*; +use bevy::window::PrimaryWindow; +use solitaire_core::pile::PileType; + +use crate::events::{DrawRequestEvent, NewGameRequestEvent, UndoRequestEvent}; +use crate::layout::LayoutResource; + +/// Registers the keyboard + mouse input systems. +pub struct InputPlugin; + +impl Plugin for InputPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Update, (handle_keyboard, handle_mouse_clicks)); + } +} + +fn handle_keyboard( + keys: Res>, + mut undo: EventWriter, + mut new_game: EventWriter, + mut draw: EventWriter, +) { + if keys.just_pressed(KeyCode::KeyU) { + undo.send(UndoRequestEvent); + } + if keys.just_pressed(KeyCode::KeyN) { + new_game.send(NewGameRequestEvent { seed: None }); + } + if keys.just_pressed(KeyCode::KeyD) { + draw.send(DrawRequestEvent); + } + if keys.just_pressed(KeyCode::Escape) { + // Pause placeholder — the pause screen hooks this up in a later phase. + info!("pause requested (not yet wired)"); + } +} + +fn handle_mouse_clicks( + buttons: Res>, + windows: Query<&Window, With>, + cameras: Query<(&Camera, &GlobalTransform)>, + layout: Option>, + mut draw: EventWriter, +) { + if !buttons.just_pressed(MouseButton::Left) { + 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 { + 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.send(DrawRequestEvent); + } +} + +/// 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 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn point_in_rect_inside_returns_true() { + let center = Vec2::new(10.0, 20.0); + let size = Vec2::new(40.0, 60.0); + assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size)); + assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size)); + assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size)); + } + + #[test] + fn point_in_rect_on_edge_returns_true() { + let center = Vec2::ZERO; + let size = Vec2::new(10.0, 10.0); + assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size)); + assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size)); + } + + #[test] + fn point_in_rect_outside_returns_false() { + let center = Vec2::ZERO; + let size = Vec2::new(10.0, 10.0); + assert!(!point_in_rect(Vec2::new(6.0, 0.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)); + } +} diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 2a96a7f..453b39f 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -7,6 +7,7 @@ pub mod card_plugin; pub mod events; pub mod game_plugin; +pub mod input_plugin; pub mod layout; pub mod resources; pub mod table_plugin; @@ -17,6 +18,7 @@ pub use events::{ StateChangedEvent, UndoRequestEvent, }; pub use game_plugin::{GameMutation, GamePlugin}; +pub use input_plugin::InputPlugin; pub use layout::{compute_layout, Layout, LayoutResource}; pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource}; pub use table_plugin::{PileMarker, TableBackground, TablePlugin};