Files
Ferrous-Solitaire/solitaire_engine/src/replay_overlay/input.rs
T
funman300 ccfb9394e0
Test / test (pull_request) Successful in 37m58s
chore: remove dead code and stale doc claims found in multi-agent sweep
Compiler-verified via RUSTFLAGS=--force-warn dead_code plus workspace-wide
reference greps; three parallel audit agents covered the engine crate, the
other eight crates, and Copilot commit-message-vs-diff drift.

Removed:
- replay_overlay/input.rs: 19 orphaned twins (~950 lines) of items also
  defined in mod.rs — the glob re-export made the mod.rs copies win and
  the file-level #![allow(dead_code)] hid the corpses. The live keyboard/
  button handlers and ReplayScrubKeyHold stay; the allow is retired.
- retarget_animation (never called; doc examples were its only refs)
- ScanThemesRequestEvent (never registered/written/read; its doc claimed
  a handle_scan_themes consumer that does not exist)
- _VEC3_REFERENCED workaround const + now-unneeded Vec3 import
- solitaire_data: load_stats/save_stats/time_attack_session_with_now
  default-path wrappers (the _from/_to variants are the live API) and
  surplus re-export names (settings MIN/MAX bounds, token loaders)
- solitaire_core: Session re-export (no external consumer)
- solitaire_wasm: ReplayPlayer::is_finished (no JS caller)
- solitaire_app: build_app wrapper (real entry is run())

Doc fixes:
- audio_plugin: WAV count 5→7, add FoundationCompletedEvent table row,
  drop bogus 'placeholder' label, bevy_kira_audio→kira
- ToastVariant::Warning: variant is live (5 writer plugins); dropped the
  stale allow(dead_code) and its 'currently unused' comment

Deliberately kept: Spider module (staged forward work), WinCascadePlugin
(documented alternative cascade, pending owner decision), SyncCompleteEvent
and solitaire_sync ApiError/merge_at (§8 change-controlled, flagged to owner).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:48:33 -07:00

272 lines
10 KiB
Rust

use bevy::prelude::*;
use super::*;
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
use crate::replay_playback::{
ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback,
stop_replay_playback, toggle_pause_replay_playback,
};
use crate::resources::GameStateResource;
/// Per-arrow-key time-since-last-fire accumulators that drive the
/// continuous-scrub repeat behaviour for held arrow keys. Each
/// frame the key is held, the corresponding accumulator absorbs
/// `time.delta_secs()`; when it exceeds
/// [`SCRUB_REPEAT_INTERVAL_SECS`] the handler fires another step
/// and resets the accumulator.
///
/// `just_pressed` events bypass the accumulator entirely and fire
/// immediately — only *repeat* fires (while held) are gated by
/// the interval. Releases reset the accumulator to 0 so the next
/// fresh press fires immediately rather than at half-interval.
#[derive(Resource, Default, Debug)]
pub(crate) struct ReplayScrubKeyHold {
pub(crate) left_held_secs: f32,
pub(crate) right_held_secs: f32,
}
// ---------------------------------------------------------------------------
// Playback-control button handlers
// ---------------------------------------------------------------------------
/// Pure helper — returns the label the Pause / Resume button should
/// carry for the given state. "Pause" while running, "Resume" while
/// paused, empty otherwise (the button is despawned with the rest of
/// the overlay tree on transitions to `Inactive` / `Completed`, so
/// the empty branch only fires for one frame around state changes).
pub(crate) fn pause_button_label(state: &ReplayPlaybackState) -> &'static str {
match state {
ReplayPlaybackState::Playing { paused: true, .. } => "Resume",
ReplayPlaybackState::Playing { paused: false, .. } => "Pause",
ReplayPlaybackState::Inactive | ReplayPlaybackState::Completed => "",
}
}
/// Watches the Stop button for `Interaction::Pressed` transitions. On a
/// click, calls [`stop_replay_playback`] which resets the state to
/// `Inactive`; the next frame's `react_to_state_change` then despawns
/// the overlay.
pub(crate) fn handle_stop_button(
mut commands: Commands,
mut state: ResMut<ReplayPlaybackState>,
buttons: Query<&Interaction, (With<ReplayStopButton>, Changed<Interaction>)>,
) {
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
stop_replay_playback(&mut commands, &mut state);
}
/// Watches the Pause / Resume button for `Interaction::Pressed`
/// transitions. On a click, toggles the `paused` flag via
/// [`toggle_pause_replay_playback`]. The label repaint happens in
/// [`update_pause_button_label`] on the same frame the state mutation
/// flushes.
pub(crate) fn handle_pause_button(
mut state: ResMut<ReplayPlaybackState>,
buttons: Query<&Interaction, (With<ReplayPauseButton>, Changed<Interaction>)>,
) {
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
toggle_pause_replay_playback(&mut state);
}
/// Watches the Step button for `Interaction::Pressed` transitions. On
/// a click, advances exactly one move via [`step_replay_playback`].
/// No-op while playback is unpaused (would race the tick loop) — the
/// guard lives inside `step_replay_playback`.
pub(crate) fn handle_step_button(
mut state: ResMut<ReplayPlaybackState>,
game: Option<Res<GameStateResource>>,
mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>,
buttons: Query<&Interaction, (With<ReplayStepButton>, Changed<Interaction>)>,
) {
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
step_replay_playback(
&mut state,
game.as_deref(),
&mut moves_writer,
&mut draws_writer,
);
}
/// Repaints the Pause / Resume button's label whenever
/// [`ReplayPlaybackState`] changes. Walks from the marked button
/// entity to its single child [`Text`] so the spawn path doesn't need
/// a second marker on the inner node.
pub(crate) fn update_pause_button_label(
state: Res<ReplayPlaybackState>,
buttons: Query<&Children, With<ReplayPauseButton>>,
mut texts: Query<&mut Text>,
) {
if !state.is_changed() {
return;
}
let label = pause_button_label(&state);
if label.is_empty() {
// Overlay is mid-teardown; the button entity will despawn
// this frame anyway. Skip the repaint to avoid touching a
// doomed entity.
return;
}
for children in &buttons {
for child in children.iter() {
if let Ok(mut text) = texts.get_mut(child) {
text.0 = label.to_string();
break;
}
}
}
}
/// Watches `Space` for the keyboard pause / resume accelerator.
/// UI-first contract from CLAUDE.md §3.3 is satisfied by the on-
/// screen Pause / Resume button; this is the optional accelerator.
/// No-op when the playback isn't `Playing` (e.g. while a modal is
/// open and the player is using `Space` for something else).
pub(crate) fn handle_pause_keyboard(
keys: Option<Res<ButtonInput<KeyCode>>>,
mut state: ResMut<ReplayPlaybackState>,
) {
let Some(keys) = keys else { return };
if !keys.just_pressed(KeyCode::Space) {
return;
}
toggle_pause_replay_playback(&mut state);
}
/// Watches the arrow keys for the paused step / scrub
/// accelerators. UI-first contract from CLAUDE.md §3.3 is
/// satisfied by the on-screen Step button (forward only); these
/// are the optional accelerators that also surface a backwards
/// step plus continuous scrub.
///
/// Both keys are paused-only — the underlying step helpers
/// hard-gate via destructure on `paused: true`. Pressing → during
/// running playback or ← at cursor 0 are silent no-ops; the
/// player learns "pause first, then arrow."
///
/// **Single press fires once immediately**
/// (`just_pressed`). **Holding** the key triggers continuous
/// scrub at [`SCRUB_REPEAT_INTERVAL_SECS`] cadence (10 steps/sec
/// at 100 ms): the per-key accumulator on
/// [`ReplayScrubKeyHold`] absorbs `time.delta_secs()` each frame
/// the key is held, fires + resets when the threshold is hit, and
/// resets to 0 on key release so the next fresh press fires
/// immediately. This matches the mockup's `[← →] scrub`
/// terminology while keeping single-press = single-step semantics.
#[allow(clippy::too_many_arguments)]
pub(crate) fn handle_arrow_keyboard(
keys: Option<Res<ButtonInput<KeyCode>>>,
time: Res<Time>,
game: Option<Res<GameStateResource>>,
mut hold: ResMut<ReplayScrubKeyHold>,
mut state: ResMut<ReplayPlaybackState>,
mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>,
mut undo_writer: MessageWriter<UndoRequestEvent>,
mut state_changed: MessageReader<StateChangedEvent>,
// `true` while a backward step is in-flight: cursor was decremented and
// `UndoRequestEvent` was written, but `handle_undo` hasn't applied it yet.
// Cleared when `StateChangedEvent` confirms the game state has caught up.
// Prevents rapid ← presses from accumulating multiple cursor decrements
// before any undo is applied (Bug #16).
mut back_pending: Local<bool>,
) {
let Some(keys) = keys else { return };
let dt = time.delta_secs();
// Clear the in-flight flag once the game confirms the undo landed.
if state_changed.read().count() > 0 {
*back_pending = false;
}
// Right (forward step) — initial press fires immediately;
// held repeats fire when the accumulator crosses the interval.
if keys.just_pressed(KeyCode::ArrowRight) {
step_replay_playback(
&mut state,
game.as_deref(),
&mut moves_writer,
&mut draws_writer,
);
hold.right_held_secs = 0.0;
} else if keys.pressed(KeyCode::ArrowRight) {
hold.right_held_secs += dt;
if hold.right_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
step_replay_playback(
&mut state,
game.as_deref(),
&mut moves_writer,
&mut draws_writer,
);
hold.right_held_secs = 0.0;
}
} else {
hold.right_held_secs = 0.0;
}
// Left (backwards step) — gate on `back_pending` so at most one undo
// is in-flight at a time. The cursor is only decremented inside
// `step_backwards_replay_playback`, which also writes `UndoRequestEvent`.
// `back_pending` is set after a successful step and cleared above when
// `StateChangedEvent` confirms the undo was applied.
if keys.just_pressed(KeyCode::ArrowLeft) {
if !*back_pending {
let fired = step_backwards_replay_playback(&mut state, &mut undo_writer);
if fired {
*back_pending = true;
}
}
hold.left_held_secs = 0.0;
} else if keys.pressed(KeyCode::ArrowLeft) {
hold.left_held_secs += dt;
if hold.left_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
if !*back_pending {
let fired = step_backwards_replay_playback(&mut state, &mut undo_writer);
if fired {
*back_pending = true;
}
}
hold.left_held_secs = 0.0;
}
} else {
hold.left_held_secs = 0.0;
}
}
/// Watches `Esc` for the keyboard stop accelerator. UI-first
/// contract from CLAUDE.md §3.3 is satisfied by the on-screen
/// Stop button; this is the optional accelerator.
///
/// Cross-plugin coordination: `pause_plugin::toggle_pause` also
/// listens for `Esc` and would otherwise open the pause modal on
/// the same press. The conflict is resolved by `toggle_pause`
/// gating itself on `ReplayPlaybackState::is_playing()` —
/// symmetrical to the existing `forfeit_screens` /
/// `other_modal_scrims` defer-if pattern in that system. So during
/// an active replay this handler owns the `Esc` press and the
/// pause modal stays closed.
///
/// No-op when the playback isn't `Playing` (the resource may still
/// exist as `Inactive` or `Completed`; only `Playing` means a
/// replay is on screen for the player to stop).
pub(crate) fn handle_stop_keyboard(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
mut state: ResMut<ReplayPlaybackState>,
) {
let Some(keys) = keys else { return };
if !keys.just_pressed(KeyCode::Escape) {
return;
}
if !state.is_playing() {
return;
}
stop_replay_playback(&mut commands, &mut state);
}