feat(engine): playability improvements — rounds 7–9 (#40–#64)
Round 7 — Input & feedback
- H key cycles hints; F1 opens help (conflict resolved)
- N key cancels active Time Attack session
- Hint text distinguishes "draw from stock" vs "recycle waste"
- Forfeit (G) clears AutoCompleteState so chime does not bleed into new deal
- Zen mode timer clears immediately on Z press
- HUD shows recycle count in both draw modes
- Settings scroll position persisted across open/close
Round 8 — Polish & clarity
- Undo unavailable fires "Nothing to undo" toast
- Streak-break toast on forfeit/abandon when streak > 1
- F11 fullscreen toggle with toast; documented in help and home screens
- H-after-win, new-game countdown expiry, Tab-no-cards toasts
- Win cascade duration/stagger scales with animation speed setting
- Draw-Three cycle counter HUD ("Cycle: N/3")
- Forfeit requires G×2 confirmation within 3 s (mirrors N key)
Round 9 — Game feel & information
- Escape dismisses game-over/stuck overlay (PausePlugin skips Escape when overlay visible)
- Shake animation on rejected drag before snap-back
- Forfeit countdown cancels when any other key is pressed (U/H/D/Z/Space)
- Tab wrap-around fires "Back to first card" toast
- HUD selection indicator shows active Tab-selected pile in yellow
- Challenge time-limit HUD turns orange < 60s, red < 30s
- Win summary shows XP breakdown (+50 base, +25 no-undo, +N speed)
- Game-over overlay: "No more moves available" with clear N/Escape/G instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,15 @@
|
||||
//!
|
||||
//! 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.
|
||||
//! available destination using the following priority order, then clears the
|
||||
//! selection:
|
||||
//!
|
||||
//! 1. Move the top card to its best foundation (count = 1).
|
||||
//! 2. Move the full face-up run from the selected tableau pile to the best
|
||||
//! tableau destination (count = run length). Single-card stacks from
|
||||
//! non-tableau piles fall back to [`best_destination`] for tableau targets.
|
||||
//!
|
||||
//! 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
|
||||
@@ -15,9 +22,9 @@ use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::MoveRequestEvent;
|
||||
use crate::events::{InfoToastEvent, MoveRequestEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::input_plugin::best_destination;
|
||||
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
@@ -115,17 +122,48 @@ pub fn cycle_next_pile(
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns `true` when cycling from `current` to `next` wraps around the
|
||||
/// available list — i.e., `next` appears at or before `current` in the global
|
||||
/// cycle order defined by [`cycled_piles`].
|
||||
///
|
||||
/// Both `current` and `next` must be `Some`; if either is `None` this returns
|
||||
/// `false`.
|
||||
fn did_wrap(
|
||||
available: &[PileType],
|
||||
current: Option<&PileType>,
|
||||
next: Option<&PileType>,
|
||||
) -> bool {
|
||||
let (Some(cur), Some(nxt)) = (current, next) else {
|
||||
return false;
|
||||
};
|
||||
let order = cycled_piles();
|
||||
// Position of each pile within the *available* subset, ordered by the
|
||||
// global cycle order.
|
||||
let pos_in_available = |target: &PileType| -> Option<usize> {
|
||||
order
|
||||
.iter()
|
||||
.filter(|p| available.contains(p))
|
||||
.position(|p| p == target)
|
||||
};
|
||||
match (pos_in_available(cur), pos_in_available(nxt)) {
|
||||
(Some(cur_pos), Some(nxt_pos)) => nxt_pos <= cur_pos,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handles Tab / Enter / Space / Escape for keyboard card selection.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_selection_keys(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
game: Res<GameStateResource>,
|
||||
mut selection: ResMut<SelectionState>,
|
||||
mut moves: EventWriter<MoveRequestEvent>,
|
||||
mut info_toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
@@ -160,8 +198,15 @@ fn handle_selection_keys(
|
||||
|
||||
// Tab — cycle selection.
|
||||
if keys.just_pressed(KeyCode::Tab) {
|
||||
selection.selected_pile =
|
||||
cycle_next_pile(&available, selection.selected_pile.as_ref());
|
||||
let next = cycle_next_pile(&available, selection.selected_pile.as_ref());
|
||||
if next.is_none() {
|
||||
info_toast.send(InfoToastEvent("No cards to select".to_string()));
|
||||
} else if selection.selected_pile.is_some()
|
||||
&& did_wrap(&available, selection.selected_pile.as_ref(), next.as_ref())
|
||||
{
|
||||
info_toast.send(InfoToastEvent("Back to first card".to_string()));
|
||||
}
|
||||
selection.selected_pile = next;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,7 +216,12 @@ fn handle_selection_keys(
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter / Space — execute move for the selected pile's top card.
|
||||
// Enter / Space — execute move for the selected pile's top card (or full
|
||||
// face-up run when the source is a tableau column).
|
||||
//
|
||||
// Priority:
|
||||
// 1. Foundation move — always count = 1.
|
||||
// 2. Tableau stack move — count = full face-up run length from the source.
|
||||
let activate =
|
||||
keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::Space);
|
||||
if activate {
|
||||
@@ -183,6 +233,46 @@ fn handle_selection_keys(
|
||||
.and_then(|p| p.cards.last())
|
||||
.filter(|c| c.face_up)
|
||||
{
|
||||
// --- Priority 1: foundation move (single card) ---
|
||||
let foundation_dest = try_foundation_dest(card, &game.0);
|
||||
if let Some(dest) = foundation_dest {
|
||||
moves.send(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
to: dest,
|
||||
count: 1,
|
||||
});
|
||||
selection.selected_pile = None;
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Priority 2: tableau stack move ---
|
||||
// Count the full contiguous face-up run in the source pile.
|
||||
let run_len = face_up_run_len(game.0.piles.get(pile).map(|p| p.cards.as_slice()).unwrap_or(&[]));
|
||||
let bottom_card = game
|
||||
.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.and_then(|p| {
|
||||
let start = p.cards.len().saturating_sub(run_len);
|
||||
p.cards.get(start)
|
||||
});
|
||||
if let Some(bottom) = bottom_card {
|
||||
if let Some((dest, count)) =
|
||||
best_tableau_destination_for_stack(bottom, pile, &game.0, run_len)
|
||||
{
|
||||
moves.send(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
to: dest,
|
||||
count,
|
||||
});
|
||||
selection.selected_pile = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fallback: single-card move to any destination ---
|
||||
// Covers non-tableau sources (Waste, Foundation) that have no
|
||||
// stack-move logic.
|
||||
if let Some(dest) = best_destination(card, &game.0) {
|
||||
moves.send(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
@@ -196,6 +286,49 @@ fn handle_selection_keys(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Count the contiguous face-up cards at the top of `cards`.
|
||||
///
|
||||
/// Walks backwards from the last element and stops at the first face-down card
|
||||
/// (or when the slice is exhausted). Returns at least `1` when the top card is
|
||||
/// face-up; returns `0` for an empty slice or when the top card is face-down.
|
||||
fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize {
|
||||
let mut count = 0;
|
||||
for card in cards.iter().rev() {
|
||||
if card.face_up {
|
||||
count += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Find the best foundation destination for `card` — returns the first
|
||||
/// foundation pile that legally accepts the card, or `None`.
|
||||
///
|
||||
/// This is intentionally separated from [`best_destination`] so the Enter
|
||||
/// handler can attempt a foundation move first and fall through to a
|
||||
/// multi-card stack move rather than accepting a single-card tableau move.
|
||||
fn try_foundation_dest(
|
||||
card: &solitaire_core::card::Card,
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
) -> Option<PileType> {
|
||||
use solitaire_core::rules::can_place_on_foundation;
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let dest = PileType::Foundation(suit);
|
||||
if let Some(pile) = game.piles.get(&dest) {
|
||||
if can_place_on_foundation(card, pile, suit) {
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Maintains the `SelectionHighlight` outline sprite.
|
||||
///
|
||||
/// When a pile is selected, a cyan sprite is placed at the selected card's
|
||||
@@ -310,10 +443,97 @@ mod tests {
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #59 — wrap detection: 3 piles, Tab ×3 fires wrap on third press
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Simulate three Tab presses over [Waste, Tableau(0), Tableau(1)].
|
||||
///
|
||||
/// Press 1: None → Waste — no wrap (started from nothing)
|
||||
/// Press 2: Waste → Tableau(0) — no wrap (advancing forward)
|
||||
/// Press 3: T(0) → Tableau(1) — no wrap (still advancing forward)
|
||||
/// (A fourth press would wrap T(1) → Waste.)
|
||||
#[test]
|
||||
fn wrap_detected_on_third_tab_with_three_piles() {
|
||||
let available = piles_from(&["Waste", "T0", "T1"]);
|
||||
|
||||
// Press 1: no current selection → first pile, no wrap.
|
||||
let sel1 = cycle_next_pile(&available, None);
|
||||
assert_eq!(sel1, Some(PileType::Waste));
|
||||
assert!(!did_wrap(&available, None, sel1.as_ref()), "first Tab should not wrap");
|
||||
|
||||
// Press 2: Waste → Tableau(0), no wrap.
|
||||
let sel2 = cycle_next_pile(&available, sel1.as_ref());
|
||||
assert_eq!(sel2, Some(PileType::Tableau(0)));
|
||||
assert!(!did_wrap(&available, sel1.as_ref(), sel2.as_ref()), "second Tab should not wrap");
|
||||
|
||||
// Press 3: Tableau(0) → Tableau(1), still no wrap.
|
||||
let sel3 = cycle_next_pile(&available, sel2.as_ref());
|
||||
assert_eq!(sel3, Some(PileType::Tableau(1)));
|
||||
assert!(!did_wrap(&available, sel2.as_ref(), sel3.as_ref()), "third Tab (T0→T1) should not wrap");
|
||||
|
||||
// Press 4: Tableau(1) → Waste, this IS the wrap.
|
||||
let sel4 = cycle_next_pile(&available, sel3.as_ref());
|
||||
assert_eq!(sel4, Some(PileType::Waste));
|
||||
assert!(did_wrap(&available, sel3.as_ref(), sel4.as_ref()), "fourth Tab should wrap back to Waste");
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #8 — face_up_run_len pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_empty_slice_is_zero() {
|
||||
assert_eq!(face_up_run_len(&[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_all_face_up() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: true },
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
|
||||
Card { id: 2, suit: Suit::Spades, rank: Rank::Jack, face_up: true },
|
||||
];
|
||||
assert_eq!(face_up_run_len(&cards), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_mixed_stops_at_face_down() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: false },
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: false },
|
||||
Card { id: 2, suit: Suit::Spades, rank: Rank::Jack, face_up: true },
|
||||
Card { id: 3, suit: Suit::Diamonds, rank: Rank::Ten, face_up: true },
|
||||
];
|
||||
// Only the top two cards are face-up.
|
||||
assert_eq!(face_up_run_len(&cards), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_top_card_face_down_is_zero() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: true },
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: false },
|
||||
];
|
||||
assert_eq!(face_up_run_len(&cards), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_single_face_up_card() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true },
|
||||
];
|
||||
assert_eq!(face_up_run_len(&cards), 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user