feat(engine): add InputPlugin with keyboard and stock-click

Keyboard: U=undo, N=new game, D=draw, Escape=pause placeholder (logged
only until the pause screen lands). Mouse: left-click on the stock pile
fires DrawRequestEvent. Cursor coordinates are converted via the active
Camera2d's viewport_to_world_2d so the hit-test works under arbitrary
camera setups.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-23 16:26:40 -07:00
parent 0a87f0f8f2
commit 900de7f376
3 changed files with 127 additions and 1 deletions
+2 -1
View File
@@ -1,5 +1,5 @@
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_engine::{CardPlugin, GamePlugin, TablePlugin}; use solitaire_engine::{CardPlugin, GamePlugin, InputPlugin, TablePlugin};
fn main() { fn main() {
App::new() App::new()
@@ -16,5 +16,6 @@ fn main() {
.add_plugins(GamePlugin) .add_plugins(GamePlugin)
.add_plugins(TablePlugin) .add_plugins(TablePlugin)
.add_plugins(CardPlugin) .add_plugins(CardPlugin)
.add_plugins(InputPlugin)
.run(); .run();
} }
+123
View File
@@ -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<ButtonInput<KeyCode>>,
mut undo: EventWriter<UndoRequestEvent>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut draw: EventWriter<DrawRequestEvent>,
) {
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<ButtonInput<MouseButton>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
mut draw: EventWriter<DrawRequestEvent>,
) {
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));
}
}
+2
View File
@@ -7,6 +7,7 @@
pub mod card_plugin; pub mod card_plugin;
pub mod events; pub mod events;
pub mod game_plugin; pub mod game_plugin;
pub mod input_plugin;
pub mod layout; pub mod layout;
pub mod resources; pub mod resources;
pub mod table_plugin; pub mod table_plugin;
@@ -17,6 +18,7 @@ pub use events::{
StateChangedEvent, UndoRequestEvent, StateChangedEvent, UndoRequestEvent,
}; };
pub use game_plugin::{GameMutation, GamePlugin}; pub use game_plugin::{GameMutation, GamePlugin};
pub use input_plugin::InputPlugin;
pub use layout::{compute_layout, Layout, LayoutResource}; pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource}; pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
pub use table_plugin::{PileMarker, TableBackground, TablePlugin}; pub use table_plugin::{PileMarker, TableBackground, TablePlugin};