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:
@@ -44,6 +44,7 @@ fn advance_on_challenge_win(
|
||||
mut progress: ResMut<ProgressResource>,
|
||||
path: Res<ProgressStoragePath>,
|
||||
mut advanced: EventWriter<ChallengeAdvancedEvent>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
for _ in wins.read() {
|
||||
if game.0.mode != GameMode::Challenge {
|
||||
@@ -56,6 +57,9 @@ fn advance_on_challenge_win(
|
||||
warn!("failed to save progress after challenge advance: {e}");
|
||||
}
|
||||
}
|
||||
// Human-readable level is 1-based (index 0 → "Challenge 1").
|
||||
let level_number = prev.saturating_add(1);
|
||||
toast.send(InfoToastEvent(format!("Challenge {level_number} complete!")));
|
||||
advanced.send(ChallengeAdvancedEvent {
|
||||
previous_index: prev,
|
||||
new_index: progress.0.challenge_index,
|
||||
@@ -199,6 +203,48 @@ mod tests {
|
||||
assert_eq!(challenge_progress_label(2), format!("3 / {total}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_win_fires_complete_toast_with_level_number() {
|
||||
let mut app = headless_app();
|
||||
// Set challenge_index to 2 so the completed level is "Challenge 3".
|
||||
app.world_mut().resource_mut::<ProgressResource>().0.challenge_index = 2;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 100,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).collect();
|
||||
assert_eq!(fired.len(), 1, "exactly one toast must fire on challenge win");
|
||||
assert!(
|
||||
fired[0].0.contains("Challenge 3"),
|
||||
"toast must name the 1-based level that was just completed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_win_does_not_fire_challenge_complete_toast() {
|
||||
let mut app = headless_app();
|
||||
// Default mode is Classic.
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 100,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert!(
|
||||
cursor.read(events).next().is_none(),
|
||||
"no challenge toast should fire for non-Challenge wins"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_x_below_unlock_level_fires_info_toast() {
|
||||
let mut app = headless_app();
|
||||
|
||||
Reference in New Issue
Block a user