From d387ee68d77582ed14d2a1055b2ddb89ddbb10f8 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 27 Apr 2026 20:07:49 +0000 Subject: [PATCH] feat(engine): stats improvements, toast queue, keyboard selection (#65, #66, #67, #68) Co-Authored-By: Claude Sonnet 4.6 --- solitaire_engine/src/selection_plugin.rs | 319 +++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 solitaire_engine/src/selection_plugin.rs diff --git a/solitaire_engine/src/selection_plugin.rs b/solitaire_engine/src/selection_plugin.rs new file mode 100644 index 0000000..fb09e07 --- /dev/null +++ b/solitaire_engine/src/selection_plugin.rs @@ -0,0 +1,319 @@ +//! Keyboard-driven card selection (Task #68). +//! +//! Pressing `Tab` cycles through piles that have a face-up draggable top card. +//! Pressing `Enter` or `Space` fires a [`MoveRequestEvent`] to the best +//! available destination (foundation first, then tableau), then clears the +//! selection. Pressing `Escape` clears the selection without moving. +//! +//! The selected card is highlighted by a cyan [`SelectionHighlight`] outline +//! sprite parented to the selected card entity. The highlight is despawned when +//! the selection is cleared. + +use bevy::input::ButtonInput; +use bevy::prelude::*; +use solitaire_core::card::Suit; +use solitaire_core::pile::PileType; + +use crate::card_plugin::CardEntity; +use crate::events::MoveRequestEvent; +use crate::game_plugin::GameMutation; +use crate::input_plugin::best_destination; +use crate::layout::LayoutResource; +use crate::pause_plugin::PausedResource; +use crate::resources::GameStateResource; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// Tracks which pile currently has keyboard focus. +/// +/// `None` means no pile is selected. +#[derive(Resource, Debug, Default)] +pub struct SelectionState { + /// The pile whose top face-up card is currently selected, or `None`. + pub selected_pile: Option, +} + +/// Marker component placed on the outline sprite used as the keyboard-selection +/// highlight. +/// +/// Exactly one entity with this marker should exist at any time. It is +/// despawned when the selection is cleared. +#[derive(Component, Debug)] +pub struct SelectionHighlight; + +/// Registers the keyboard selection resources and systems. +pub struct SelectionPlugin; + +impl Plugin for SelectionPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems( + Update, + ( + handle_selection_keys.before(GameMutation), + update_selection_highlight.after(GameMutation), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Pile cycle order +// --------------------------------------------------------------------------- + +/// The ordered list of piles that are considered for keyboard cycling. +/// +/// Order: Waste → Foundation×4 → Tableau 0–6. +fn cycled_piles() -> Vec { + let mut piles = vec![ + PileType::Waste, + PileType::Foundation(Suit::Clubs), + PileType::Foundation(Suit::Diamonds), + PileType::Foundation(Suit::Hearts), + PileType::Foundation(Suit::Spades), + ]; + for i in 0..7_usize { + piles.push(PileType::Tableau(i)); + } + piles +} + +/// Given a list of *available* piles and the currently selected pile, return +/// the next pile in cycling order, wrapping around. +/// +/// If `current` is `None` the first available pile is returned. +/// If `available` is empty, `None` is returned. +pub fn cycle_next_pile( + available: &[PileType], + current: Option<&PileType>, +) -> Option { + if available.is_empty() { + return None; + } + let order = cycled_piles(); + + let Some(cur) = current else { + // No current selection: return the first available pile in cycle order. + return order.iter().find(|p| available.contains(p)).cloned(); + }; + + // Find the position of `cur` inside the ordered list, then scan forward + // for the next available pile (wrapping). + let cur_pos = order.iter().position(|p| p == cur); + let start = cur_pos.map_or(0, |pos| pos + 1); + + // Search from `start` forward, wrapping around, skipping `cur`. + let n = order.len(); + for offset in 0..n { + let candidate = &order[(start + offset) % n]; + if available.contains(candidate) { + return Some(candidate.clone()); + } + } + None +} + +// --------------------------------------------------------------------------- +// Systems +// --------------------------------------------------------------------------- + +/// Handles Tab / Enter / Space / Escape for keyboard card selection. +fn handle_selection_keys( + keys: Res>, + paused: Option>, + game: Res, + mut selection: ResMut, + mut moves: EventWriter, +) { + if paused.is_some_and(|p| p.0) { + return; + } + + // Build the list of piles that currently have a face-up draggable top card. + let available: Vec = { + let all = [ + 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), + ]; + all.into_iter() + .filter(|p| { + game.0 + .piles + .get(p) + .and_then(|pile| pile.cards.last()) + .is_some_and(|c| c.face_up) + }) + .collect() + }; + + // Tab — cycle selection. + if keys.just_pressed(KeyCode::Tab) { + selection.selected_pile = + cycle_next_pile(&available, selection.selected_pile.as_ref()); + return; + } + + // Escape — clear selection. + if keys.just_pressed(KeyCode::Escape) { + selection.selected_pile = None; + return; + } + + // Enter / Space — execute move for the selected pile's top card. + let activate = + keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::Space); + if activate { + if let Some(ref pile) = selection.selected_pile.clone() { + if let Some(card) = game + .0 + .piles + .get(pile) + .and_then(|p| p.cards.last()) + .filter(|c| c.face_up) + { + if let Some(dest) = best_destination(card, &game.0) { + moves.send(MoveRequestEvent { + from: pile.clone(), + to: dest, + count: 1, + }); + selection.selected_pile = None; + } + } + } + } +} + +/// Maintains the `SelectionHighlight` outline sprite. +/// +/// When a pile is selected, a cyan sprite is placed at the selected card's +/// position. When the selection is cleared the highlight entity is despawned. +fn update_selection_highlight( + mut commands: Commands, + selection: Res, + game: Res, + layout: Option>, + card_entities: Query<(Entity, &CardEntity)>, + highlights: Query>, +) { + // Always despawn any existing highlight first. + for entity in &highlights { + commands.entity(entity).despawn_recursive(); + } + + let Some(ref pile) = selection.selected_pile else { + return; + }; + let Some(layout) = layout else { + return; + }; + let Some(card) = game + .0 + .piles + .get(pile) + .and_then(|p| p.cards.last()) + .filter(|c| c.face_up) + else { + return; + }; + + let card_id = card.id; + let card_size = layout.0.card_size; + + // Find the entity for the selected card so we can read its position. + for (entity, card_entity) in &card_entities { + if card_entity.card_id == card_id { + // Spawn the highlight as a child of the card entity so it moves + // with it automatically. + commands.entity(entity).with_children(|b| { + b.spawn(( + SelectionHighlight, + Sprite { + color: Color::srgba(0.0, 1.0, 1.0, 0.5), + custom_size: Some(card_size + Vec2::splat(4.0)), + ..default() + }, + // Slightly behind the card face so text labels are still visible. + Transform::from_xyz(0.0, 0.0, -0.01), + Visibility::default(), + )); + }); + break; + } + } +} + +// --------------------------------------------------------------------------- +// Unit tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn piles_from(names: &[&str]) -> Vec { + names + .iter() + .map(|&n| match n { + "Waste" => PileType::Waste, + "T0" => PileType::Tableau(0), + "T1" => PileType::Tableau(1), + "T2" => PileType::Tableau(2), + _ => PileType::Waste, + }) + .collect() + } + + // ----------------------------------------------------------------------- + // Task #68 — cycle_next_pile pure-function tests + // ----------------------------------------------------------------------- + + #[test] + fn cycle_next_pile_from_none() { + // With [Waste, Tableau(0), Tableau(1)] available, starting from None → Waste. + let available = piles_from(&["Waste", "T0", "T1"]); + let result = cycle_next_pile(&available, None); + assert_eq!(result, Some(PileType::Waste)); + } + + #[test] + fn cycle_next_pile_from_waste() { + // Starting from Waste → Tableau(0). + let available = piles_from(&["Waste", "T0", "T1"]); + let result = cycle_next_pile(&available, Some(&PileType::Waste)); + assert_eq!(result, Some(PileType::Tableau(0))); + } + + #[test] + fn cycle_next_pile_wraps() { + // Starting from Tableau(1) → Waste (wraps back to start). + let available = piles_from(&["Waste", "T0", "T1"]); + let result = cycle_next_pile(&available, Some(&PileType::Tableau(1))); + assert_eq!(result, Some(PileType::Waste)); + } + + #[test] + fn cycle_next_pile_empty_returns_none() { + let result = cycle_next_pile(&[], None); + assert!(result.is_none()); + } + + #[test] + fn cycle_next_pile_single_element_wraps_to_itself() { + let available = vec![PileType::Waste]; + let result = cycle_next_pile(&available, Some(&PileType::Waste)); + assert_eq!(result, Some(PileType::Waste)); + } +}