diff --git a/solitaire_engine/src/auto_complete_plugin.rs b/solitaire_engine/src/auto_complete_plugin.rs index fa74ab1..dc1576a 100644 --- a/solitaire_engine/src/auto_complete_plugin.rs +++ b/solitaire_engine/src/auto_complete_plugin.rs @@ -26,6 +26,12 @@ const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5; /// Seconds between consecutive auto-complete moves. const STEP_INTERVAL: f32 = 0.12; +/// Seconds to wait after detection before firing the first auto-complete move. +/// +/// This pause gives the player a moment to register that the game is +/// transitioning into auto-complete mode before cards start moving. +const AUTO_COMPLETE_INITIAL_DELAY: f32 = 0.75; + /// Tracks whether auto-complete is active and when the next move fires. #[derive(Resource, Default, Debug)] pub struct AutoCompleteState { @@ -74,7 +80,7 @@ fn detect_auto_complete( } if game.0.is_auto_completable && !state.active { state.active = true; - state.cooldown = 0.0; // fire first move immediately + state.cooldown = AUTO_COMPLETE_INITIAL_DELAY; } // Intentionally no `else if !is_auto_completable` branch here. // Deactivating on every frame where `is_auto_completable` is false @@ -210,6 +216,12 @@ mod tests { app.world_mut().resource_mut::().0 = nearly_won_state(); app.world_mut().write_message(StateChangedEvent); app.update(); // detect runs, sets active + + // Zero out the cooldown so drive fires on the next update regardless + // of the initial delay constant. + app.world_mut() + .resource_mut::() + .cooldown = 0.0; app.update(); // drive fires the move let events = app.world().resource::>(); diff --git a/solitaire_engine/src/radial_menu.rs b/solitaire_engine/src/radial_menu.rs index 7edafa6..2847be8 100644 --- a/solitaire_engine/src/radial_menu.rs +++ b/solitaire_engine/src/radial_menu.rs @@ -52,7 +52,7 @@ use solitaire_core::game_state::GameState; use solitaire_core::pile::PileType; use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC; -use crate::events::MoveRequestEvent; +use crate::events::{MoveRejectedEvent, MoveRequestEvent}; use crate::layout::{Layout, LayoutResource, TABLEAU_FAN_FRAC}; use crate::pause_plugin::PausedResource; use crate::resources::{DragState, GameStateResource}; @@ -396,9 +396,10 @@ fn cursor_world( // --------------------------------------------------------------------------- /// On `MouseButton::Right` `just_pressed`, attempts to open the radial -/// menu over the card the cursor is on. Skips when a left-mouse drag is -/// in progress, when the game is paused, or when the clicked card has no -/// legal destinations. +/// menu over the card the cursor is on. When the cursor is on a face-up +/// card but no legal destinations exist, fires `MoveRejectedEvent` so the +/// shake animation and invalid-move sound play. Skips silently when no +/// card is under the cursor, when a drag is in progress, or when paused. #[allow(clippy::too_many_arguments)] fn radial_open_on_right_click( buttons: Option>>, @@ -410,6 +411,7 @@ fn radial_open_on_right_click( layout: Option>, game: Option>, mut state: ResMut, + mut rejected: MessageWriter, ) { if paused.is_some_and(|p| p.0) { return; @@ -438,6 +440,12 @@ fn radial_open_on_right_click( // cards and the highlight tint shows the same set the radial offers. let dests = legal_destinations_for_card(&card, &source_pile, &game.0); if dests.is_empty() { + // No legal destinations — shake the source pile as feedback. + rejected.write(MoveRejectedEvent { + from: source_pile.clone(), + to: source_pile, + count: 1, + }); return; } let legal_destinations = build_radial_destinations(world, dests); @@ -745,6 +753,7 @@ mod tests { let mut app = App::new(); app.add_plugins(MinimalPlugins); app.add_message::(); + app.add_message::(); app.init_resource::(); app.init_resource::>(); app.init_resource::>();