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, buttons: Query<&Interaction, (With, Changed)>, ) { 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, buttons: Query<&Interaction, (With, Changed)>, ) { 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, game: Option>, mut moves_writer: MessageWriter, mut draws_writer: MessageWriter, buttons: Query<&Interaction, (With, Changed)>, ) { 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, buttons: Query<&Children, With>, 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>>, mut state: ResMut, ) { 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>>, time: Res