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
+27 -8
View File
@@ -14,6 +14,7 @@ use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use solitaire_data::settings::SyncBackend;
use solitaire_sync::LeaderboardEntry;
use crate::events::InfoToastEvent;
use crate::settings_plugin::SettingsResource;
use crate::sync_plugin::SyncProviderResource;
@@ -214,13 +215,22 @@ fn handle_opt_in_button(
}
}
/// Polls the opt-in task; logs on error, clears on completion.
fn poll_opt_in_task(mut task_res: ResMut<OptInTask>) {
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure.
fn poll_opt_in_task(
mut task_res: ResMut<OptInTask>,
mut toast: EventWriter<InfoToastEvent>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
task_res.0 = None;
if let Err(e) = result {
warn!("leaderboard opt-in failed: {e}");
match result {
Ok(()) => {
toast.send(InfoToastEvent("Opted in to leaderboard".to_string()));
}
Err(e) => {
warn!("leaderboard opt-in failed: {e}");
toast.send(InfoToastEvent("Leaderboard update failed".to_string()));
}
}
}
@@ -245,13 +255,22 @@ fn handle_opt_out_button(
}
}
/// Polls the opt-out task; logs on error, clears on completion.
fn poll_opt_out_task(mut task_res: ResMut<OptOutTask>) {
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure.
fn poll_opt_out_task(
mut task_res: ResMut<OptOutTask>,
mut toast: EventWriter<InfoToastEvent>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
task_res.0 = None;
if let Err(e) = result {
warn!("leaderboard opt-out failed: {e}");
match result {
Ok(()) => {
toast.send(InfoToastEvent("Opted out of leaderboard".to_string()));
}
Err(e) => {
warn!("leaderboard opt-out failed: {e}");
toast.send(InfoToastEvent("Leaderboard update failed".to_string()));
}
}
}