feat(replay): wire ESC accelerator for stop, gate pause modal
ESC during an active replay now stops it (mirrors the existing Stop button click). UI-first contract from CLAUDE.md §3.3 holds for the keyboard accelerator: every keybind the footer surfaces points at a wired action. Cross-plugin coordination: pause_plugin's `toggle_pause` already listens for ESC and would otherwise open the pause modal on the same press. Resolved by adding a fourth defer-if check to the existing modal-stack pattern in `toggle_pause` — `replay_state.is_some_and(|s| s.is_playing())` slots in right after `other_modal_scrims` and before `selection`. Symmetric shape to the existing forfeit / modal-scrim / selection / game-over / drag gates. Footer hint extended from `[SPACE] pause/resume` to `[SPACE] pause/resume · [ESC] stop` in lockstep — the "only-wired-keybinds" discipline holds. 3 new tests: - esc_keyboard_stops_active_replay (positive: Esc → Inactive, overlay despawns next frame) - esc_keyboard_is_noop_when_not_playing (negative: doesn't fire on Inactive state, lets global Esc listeners own those frames) - keybind_footer_hint_lists_space_and_esc (footer text contains both keybinds) Plus updated helper-pin test for the new hint string. Existing pause_plugin tests unaffected (they don't insert a ReplayPlaybackState resource so the new gate is a no-op for them). Tests: 1240 → 1243 (+3). Clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ use crate::events::{
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::selection_plugin::{SelectionKeySet, SelectionState};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||
@@ -154,6 +155,7 @@ fn toggle_pause(
|
||||
mut drag: Option<ResMut<DragState>>,
|
||||
mut changed: MessageWriter<StateChangedEvent>,
|
||||
selection: Option<Res<SelectionState>>,
|
||||
replay_state: Option<Res<ReplayPlaybackState>>,
|
||||
) {
|
||||
let PauseModalQueries {
|
||||
pause_screens: screens,
|
||||
@@ -184,6 +186,15 @@ fn toggle_pause(
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
// If a replay is currently playing, let `replay_overlay::handle_stop_keyboard`
|
||||
// own the Esc press — that handler stops the replay. Without this guard a
|
||||
// single Esc both stops the replay AND opens the pause modal on top of the
|
||||
// (now empty) board, leaving the player on a screen they didn't ask for.
|
||||
// The HUD-button path is gated too; clicking Pause while watching a replay
|
||||
// is almost always an accident.
|
||||
if replay_state.is_some_and(|s| s.is_playing()) {
|
||||
return;
|
||||
}
|
||||
// If a card is currently selected, let SelectionPlugin handle this Escape
|
||||
// (it will clear the selection). Pause must not also open in the same frame.
|
||||
if selection.is_some_and(|s| s.selected_pile.is_some()) {
|
||||
|
||||
Reference in New Issue
Block a user