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
+139 -10
View File
@@ -15,6 +15,7 @@ use solitaire_data::{
WEEKLY_GOALS,
};
use crate::auto_complete_plugin::AutoCompleteState;
use crate::challenge_plugin::challenge_progress_label;
use crate::events::{ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent};
use crate::game_plugin::GameMutation;
@@ -128,17 +129,25 @@ fn update_stats_on_new_game(
game: Res<GameStateResource>,
mut stats: ResMut<StatsResource>,
path: Res<StatsStoragePath>,
mut toast: EventWriter<InfoToastEvent>,
) {
for _ in events.read() {
if game.0.move_count > 0 && !game.0.is_won {
let streak = stats.0.win_streak_current;
stats.0.record_abandoned();
persist(&path, &stats.0, "abandoned game");
if streak > 1 {
toast.send(InfoToastEvent(format!("Streak of {streak} broken!")));
}
}
}
}
/// When the player presses G to forfeit, record the game as abandoned, save
/// stats, fire an informational toast, and start a new game.
///
/// `AutoCompleteState` is reset here so the "AUTO" badge and chime do not bleed
/// into the new deal (task #41).
fn handle_forfeit(
mut events: EventReader<ForfeitEvent>,
game: Res<GameStateResource>,
@@ -146,11 +155,21 @@ fn handle_forfeit(
path: Res<StatsStoragePath>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut toast: EventWriter<InfoToastEvent>,
mut auto_complete: Option<ResMut<AutoCompleteState>>,
) {
for _ in events.read() {
if game.0.move_count > 0 && !game.0.is_won {
let streak = stats.0.win_streak_current;
stats.0.record_abandoned();
persist(&path, &stats.0, "forfeit");
if streak > 1 {
toast.send(InfoToastEvent(format!("Streak of {streak} broken!")));
}
}
// Reset auto-complete so the badge and chime don't carry over to the
// new game that is about to start.
if let Some(ref mut ac) = auto_complete {
**ac = AutoCompleteState::default();
}
toast.send(InfoToastEvent("Game forfeited".to_string()));
new_game.send(NewGameRequestEvent::default());
@@ -186,12 +205,13 @@ fn spawn_stats_screen(
progress: Option<&PlayerProgress>,
time_attack: Option<&TimeAttackResource>,
) {
// --- primary stat cells (tasks #65 and #66) ---
// --- primary stat cells (tasks #65, #66, and #38) ---
let win_rate_str = format_win_rate(stats);
let played_str = format_stat_value(stats.games_played);
let won_str = format_stat_value(stats.games_won);
let lost_str = format_stat_value(stats.games_lost);
let fastest_str = format_fastest_win(stats.fastest_win_seconds);
let avg_time_str = format_avg_time(stats);
let best_score_str = format_optional_u32(stats.best_single_score);
let best_streak_str = format_stat_value(stats.win_streak_best);
@@ -241,6 +261,7 @@ fn spawn_stats_screen(
spawn_stat_cell(grid, &won_str, "Games Won");
spawn_stat_cell(grid, &lost_str, "Games Lost");
spawn_stat_cell(grid, &fastest_str, "Fastest Win");
spawn_stat_cell(grid, &avg_time_str, "Avg Time");
spawn_stat_cell(grid, &best_score_str, "Best Score");
spawn_stat_cell(grid, &best_streak_str, "Best Streak");
});
@@ -380,6 +401,18 @@ pub fn format_fastest_win(fastest_win_seconds: u64) -> String {
}
}
/// Format `avg_time_seconds` for display.
///
/// Returns `"—"` when no games have been won yet (`games_won == 0`), otherwise
/// delegates to [`format_duration`].
pub fn format_avg_time(stats: &StatsSnapshot) -> String {
if stats.games_won == 0 {
"\u{2014}".to_string()
} else {
format_duration(stats.avg_time_seconds)
}
}
/// Format an optional `u32` statistic.
///
/// Returns `"—"` when `value` is `0`, otherwise the decimal representation.
@@ -417,13 +450,13 @@ fn xp_to_next_level_label(total_xp: u64, level: u32) -> String {
format!("{remaining} XP ({pct}%)")
}
/// Format a duration given in whole seconds as `"Mm SSs"`.
/// Format a duration given in whole seconds as `"M:SS"`.
///
/// Example: `90` → `"1m 30s"`.
/// Example: `90` → `"1:30"`.
pub fn format_duration(secs: u64) -> String {
let m = secs / 60;
let s = secs % 60;
format!("{m}m {s:02}s")
format!("{m}:{s:02}")
}
/// Renders a sorted, comma-separated list of unlock indexes for the overlay.
@@ -630,22 +663,22 @@ mod tests {
#[test]
fn format_duration_zero_seconds() {
assert_eq!(format_duration(0), "0m 00s");
assert_eq!(format_duration(0), "0:00");
}
#[test]
fn format_duration_pads_seconds_to_two_digits() {
assert_eq!(format_duration(65), "1m 05s");
assert_eq!(format_duration(65), "1:05");
}
#[test]
fn format_duration_exactly_one_hour() {
assert_eq!(format_duration(3600), "60m 00s");
assert_eq!(format_duration(3600), "60:00");
}
#[test]
fn format_duration_handles_sub_minute() {
assert_eq!(format_duration(59), "0m 59s");
assert_eq!(format_duration(59), "0:59");
}
// -----------------------------------------------------------------------
@@ -687,8 +720,8 @@ mod tests {
#[test]
fn format_fastest_win_90s() {
// 90 seconds → "1m 30s"
assert_eq!(format_fastest_win(90), "1m 30s");
// 90 seconds → "1:30"
assert_eq!(format_fastest_win(90), "1:30");
}
#[test]
@@ -696,4 +729,100 @@ mod tests {
// best_single_score == 0 → "—"
assert_eq!(format_optional_u32(0), "\u{2014}");
}
// -----------------------------------------------------------------------
// Task #38 — avg time pure-function tests
// -----------------------------------------------------------------------
#[test]
fn format_avg_time_no_wins_shows_dash() {
// games_won == 0 → "—"
let s = StatsSnapshot::default();
assert_eq!(format_avg_time(&s), "\u{2014}");
}
#[test]
fn format_avg_time_after_single_win() {
// After one win of 90 s avg should be "1:30"
let s = StatsSnapshot {
games_won: 1,
avg_time_seconds: 90,
..StatsSnapshot::default()
};
assert_eq!(format_avg_time(&s), "1:30");
}
#[test]
fn format_avg_time_after_multiple_wins() {
// avg_time_seconds = 200 s → "3:20"
let s = StatsSnapshot {
games_won: 3,
avg_time_seconds: 200,
..StatsSnapshot::default()
};
assert_eq!(format_avg_time(&s), "3:20");
}
// -----------------------------------------------------------------------
// Task #49 — streak-broken toast on forfeit
// -----------------------------------------------------------------------
#[test]
fn forfeit_with_streak_fires_streak_broken_toast() {
let mut app = headless_app();
// Set up a streak of 3 and at least one move so forfeit counts.
{
let mut stats = app.world_mut().resource_mut::<StatsResource>();
stats.0.win_streak_current = 3;
}
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.0
.move_count = 1;
app.world_mut().send_event(ForfeitEvent);
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let mut reader = events.get_cursor();
let messages: Vec<&str> = reader
.read(events)
.map(|e| e.0.as_str())
.collect();
assert!(
messages.iter().any(|m| *m == "Streak of 3 broken!"),
"expected 'Streak of 3 broken!' in toasts, got: {messages:?}"
);
}
#[test]
fn forfeit_with_streak_of_one_does_not_fire_streak_broken_toast() {
let mut app = headless_app();
{
let mut stats = app.world_mut().resource_mut::<StatsResource>();
stats.0.win_streak_current = 1;
}
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.0
.move_count = 1;
app.world_mut().send_event(ForfeitEvent);
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let mut reader = events.get_cursor();
let messages: Vec<&str> = reader
.read(events)
.map(|e| e.0.as_str())
.collect();
assert!(
!messages.iter().any(|m| m.contains("broken")),
"expected no streak-broken toast for streak of 1, got: {messages:?}"
);
}
}