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
+32 -5
View File
@@ -32,8 +32,8 @@ use kira::tween::Tween;
use kira::Volume;
use crate::events::{
CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameRequestEvent, UndoRequestEvent,
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
};
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
@@ -136,6 +136,7 @@ impl Plugin for AudioPlugin {
.add_event::<NewGameRequestEvent>()
.add_event::<GameWonEvent>()
.add_event::<CardFlippedEvent>()
.add_event::<CardFaceRevealedEvent>()
.add_event::<UndoRequestEvent>()
.add_event::<SettingsChangedEvent>()
.add_systems(Startup, apply_initial_volume)
@@ -147,7 +148,7 @@ impl Plugin for AudioPlugin {
play_on_rejected,
play_on_new_game,
play_on_win,
play_on_card_flip,
play_on_face_revealed,
play_on_undo,
apply_volume_on_change,
handle_mute_keys,
@@ -226,6 +227,27 @@ fn play(audio: &mut AudioState, sound: &StaticSoundData) {
}
}
impl AudioState {
/// Plays `sound` through the SFX sub-track at `volume` amplitude (0.01.0+).
///
/// Behaves identically to the crate-private `play()` function but accepts an
/// explicit volume override so callers can play sounds at a fraction of their
/// normal level. Silently does nothing when audio is unavailable.
pub fn play_sfx_at_volume(&mut self, sound: &StaticSoundData, volume: f64) {
let Some(manager) = self.manager.as_mut() else {
return;
};
let mut data = sound.clone();
data.settings.volume = Volume::Amplitude(volume).into();
if let Some(track) = &self.sfx_track {
data.settings.output_destination = track.id().into();
}
if let Err(e) = manager.play(data) {
warn!("failed to play SFX at volume {volume}: {e}");
}
}
}
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
if let Some(track) = audio.sfx_track.as_mut() {
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
@@ -390,8 +412,13 @@ fn play_on_win(
}
}
fn play_on_card_flip(
mut events: EventReader<CardFlippedEvent>,
/// Plays the card-flip sound at the animation midpoint — the instant the face
/// is visually revealed — keeping audio and visuals in sync.
///
/// Driven by `CardFaceRevealedEvent`, which is fired by `tick_flip_anim` at
/// the phase transition (scale.x crosses 0), not by the move event itself.
fn play_on_face_revealed(
mut events: EventReader<CardFaceRevealedEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {