fix(engine): auto-complete delay + right-click shake on no legal move

Closes #80: add AUTO_COMPLETE_INITIAL_DELAY (0.75 s) before the first
auto-complete move fires. Previously cooldown was 0.0, causing the
sequence to hijack the board the same frame the condition was met.

Closes #81: fire MoveRejectedEvent in radial_open_on_right_click when
the right-clicked card has no legal destinations, so the shake
animation and invalid-move sound play consistently on desktop/web.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funman300
2026-05-29 18:01:34 -07:00
parent 862f7e4b48
commit 6309d3325f
2 changed files with 26 additions and 5 deletions
+13 -1
View File
@@ -26,6 +26,12 @@ const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5;
/// Seconds between consecutive auto-complete moves. /// Seconds between consecutive auto-complete moves.
const STEP_INTERVAL: f32 = 0.12; 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. /// Tracks whether auto-complete is active and when the next move fires.
#[derive(Resource, Default, Debug)] #[derive(Resource, Default, Debug)]
pub struct AutoCompleteState { pub struct AutoCompleteState {
@@ -74,7 +80,7 @@ fn detect_auto_complete(
} }
if game.0.is_auto_completable && !state.active { if game.0.is_auto_completable && !state.active {
state.active = true; 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. // Intentionally no `else if !is_auto_completable` branch here.
// Deactivating on every frame where `is_auto_completable` is false // Deactivating on every frame where `is_auto_completable` is false
@@ -210,6 +216,12 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state(); app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
app.world_mut().write_message(StateChangedEvent); app.world_mut().write_message(StateChangedEvent);
app.update(); // detect runs, sets active 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::<AutoCompleteState>()
.cooldown = 0.0;
app.update(); // drive fires the move app.update(); // drive fires the move
let events = app.world().resource::<Messages<MoveRequestEvent>>(); let events = app.world().resource::<Messages<MoveRequestEvent>>();
+13 -4
View File
@@ -52,7 +52,7 @@ use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC; 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::layout::{Layout, LayoutResource, TABLEAU_FAN_FRAC};
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource}; use crate::resources::{DragState, GameStateResource};
@@ -396,9 +396,10 @@ fn cursor_world(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// On `MouseButton::Right` `just_pressed`, attempts to open the radial /// 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 /// menu over the card the cursor is on. When the cursor is on a face-up
/// in progress, when the game is paused, or when the clicked card has no /// card but no legal destinations exist, fires `MoveRejectedEvent` so the
/// legal destinations. /// 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)] #[allow(clippy::too_many_arguments)]
fn radial_open_on_right_click( fn radial_open_on_right_click(
buttons: Option<Res<ButtonInput<MouseButton>>>, buttons: Option<Res<ButtonInput<MouseButton>>>,
@@ -410,6 +411,7 @@ fn radial_open_on_right_click(
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
mut state: ResMut<RightClickRadialState>, mut state: ResMut<RightClickRadialState>,
mut rejected: MessageWriter<MoveRejectedEvent>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
@@ -438,6 +440,12 @@ fn radial_open_on_right_click(
// cards and the highlight tint shows the same set the radial offers. // cards and the highlight tint shows the same set the radial offers.
let dests = legal_destinations_for_card(&card, &source_pile, &game.0); let dests = legal_destinations_for_card(&card, &source_pile, &game.0);
if dests.is_empty() { 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; return;
} }
let legal_destinations = build_radial_destinations(world, dests); let legal_destinations = build_radial_destinations(world, dests);
@@ -745,6 +753,7 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins); app.add_plugins(MinimalPlugins);
app.add_message::<MoveRequestEvent>(); app.add_message::<MoveRequestEvent>();
app.add_message::<MoveRejectedEvent>();
app.init_resource::<DragState>(); app.init_resource::<DragState>();
app.init_resource::<ButtonInput<MouseButton>>(); app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();