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:
funman300
2026-04-28 02:35:15 +00:00
parent d387ee68d7
commit 03227f8c77
26 changed files with 3278 additions and 264 deletions
+227 -7
View File
@@ -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);
}
}