ccfb9394e0
Test / test (pull_request) Successful in 37m58s
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>
272 lines
10 KiB
Rust
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);
|
|
}
|