feat(replay): playback controls — pause / resume / step + Space accelerator

Third commit on the B-2 replay screen-takeover redesign. Adds the
ability to pause an in-flight replay, step through it one move at
a time while paused, and resume — both via on-screen buttons
(UI-first contract per CLAUDE.md §3.3) and the optional `Space`
keyboard accelerator.

State shape: a new `paused: bool` field on
`ReplayPlaybackState::Playing`. The `tick_replay_playback` system
skips the `secs_to_next` decrement entirely while `paused` is set
so cursor and timer freeze together — resuming starts the next
move from a full interval. Stepping fires the next move directly
via a new `step_replay_playback` API that bypasses the tick path
and is hard-gated to `Playing { paused: true }` so it can't race
the running tick loop.

Public API additions:
- `toggle_pause_replay_playback(state)` — flips the flag, returns
  the new value (or None when not Playing).
- `step_replay_playback(state, moves_writer, draws_writer)` —
  advances exactly one move when paused; returns true on dispatch,
  false on any guard miss.

UI:
- Pause / Resume button next to Stop. Label repaints reactively
  via `update_pause_button_label`, which walks `Children` from
  the marked button to its inner `Text` so the spawn path doesn't
  need a second marker.
- Step button next to Pause. Click fires the next move; while
  unpaused the click is a no-op (guarded inside
  `step_replay_playback`).
- `Space` keyboard handler reads `Option<Res<ButtonInput>>` and
  no-ops when missing — keeps test-app compatibility under
  `MinimalPlugins`.

Test coverage: pause-button label truth table, label repaint on
state change, click-toggles-paused, step advances cursor exactly
one with paused flag preserved, step-while-running is no-op,
Space toggles paused flag. 8 new tests (1220 → 1228).

Side-effect: 25 existing `Playing { ... }` construction sites
across `replay_overlay`, `achievement_plugin`, and
`replay_playback` tests gained `paused: false` to satisfy the new
field requirement. Mechanical edit; no behavioral change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 15:20:45 -07:00
parent cd79877933
commit fbe48acef6
3 changed files with 435 additions and 31 deletions
+90 -17
View File
@@ -119,6 +119,15 @@ pub enum ReplayPlaybackState {
cursor: usize,
/// Seconds remaining until the next move is dispatched.
secs_to_next: f32,
/// `true` while playback is paused — `tick_replay_playback`
/// skips the `secs_to_next` decrement entirely while this is
/// set, so the cursor and the timer freeze together. The
/// overlay stays mounted (`is_playing()` still returns
/// `true`) so the player can see the paused state and the
/// Resume / Step controls. Stepping while paused fires the
/// next move directly via [`step_replay_playback`] and
/// leaves the paused flag untouched.
paused: bool,
},
/// The replay finished playing back. The overlay swaps the banner
/// label to "Replay complete" until [`auto_clear_completed_replay`]
@@ -194,6 +203,7 @@ pub fn start_replay_playback(
replay,
cursor: 0,
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
paused: false,
};
}
@@ -219,6 +229,61 @@ pub fn stop_replay_playback(
**state = ReplayPlaybackState::Inactive;
}
/// Toggle the `paused` flag on the active playback. No-op when not
/// `Playing` (i.e. `Inactive` or `Completed`) — pause has no meaning
/// in those states. Returns the new paused value, or `None` if the
/// state wasn't `Playing`.
pub fn toggle_pause_replay_playback(state: &mut ResMut<ReplayPlaybackState>) -> Option<bool> {
if let ReplayPlaybackState::Playing { paused, .. } = state.as_mut() {
*paused = !*paused;
Some(*paused)
} else {
None
}
}
/// Advance playback by exactly one move. Only meaningful while paused
/// — when called on an unpaused playback it would race the
/// `tick_replay_playback` loop. Returns `true` when a move was fired,
/// `false` when no-op (state isn't `Playing { paused: true }` or the
/// cursor is already at the end of the move list).
///
/// Stepping the last move transitions the state to `Completed` on
/// the next `tick_replay_playback` frame — same end-of-list path the
/// normal advance loop takes.
pub fn step_replay_playback(
state: &mut ResMut<ReplayPlaybackState>,
moves_writer: &mut MessageWriter<MoveRequestEvent>,
draws_writer: &mut MessageWriter<DrawRequestEvent>,
) -> bool {
let ReplayPlaybackState::Playing {
replay,
cursor,
paused: true,
..
} = state.as_mut()
else {
return false;
};
if *cursor >= replay.moves.len() {
return false;
}
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
moves_writer.write(MoveRequestEvent {
from: from.clone(),
to: to.clone(),
count: *count,
});
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
}
}
*cursor += 1;
true
}
/// Tick system. Runs every frame; only does work when
/// [`ReplayPlaybackState::is_playing`].
///
@@ -249,28 +314,36 @@ fn tick_replay_playback(
replay,
cursor,
secs_to_next,
paused,
} = state.as_mut()
{
*secs_to_next -= dt;
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
moves_writer.write(MoveRequestEvent {
from: from.clone(),
to: to.clone(),
count: *count,
});
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
// While paused, the cursor and the timer freeze together —
// skip the decrement entirely so resuming starts the next
// move from a full `secs_to_next` window. Stepping (handled
// separately) fires moves directly without touching this
// path.
if !*paused {
*secs_to_next -= dt;
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
moves_writer.write(MoveRequestEvent {
from: from.clone(),
to: to.clone(),
count: *count,
});
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
}
}
*cursor += 1;
*secs_to_next += interval;
}
*cursor += 1;
*secs_to_next += interval;
}
if *cursor >= replay.moves.len() {
transition_to_completed = true;
if *cursor >= replay.moves.len() {
transition_to_completed = true;
}
}
}