feat(replay): add quarter-mark notches to scrub bar

Five 1px vertical ticks at 0/25/50/75/100% give the player visual
anchor points for "where am I, relative to the quarter-marks of the
replay" without needing to mentally bisect the bar.

Pure helper `scrub_notch_positions()` returns the fixed array; the
spawn loop sits next to the WIN MOVE marker spawn so the two
overlays share their lifecycle with the rest of the overlay tree.
Notches paint in BORDER_SUBTLE (same as the unfilled track) and
extend vertically past the 1px track (5px tall, anchored 2px above
the track top) — same visibility trick the WIN MOVE marker uses.

Spawned after the WIN MOVE marker so a notch and the marker landing
on the same percentage paint the marker on top.

Mirrors the notch ladder in the screen-takeover mockup at
docs/ui-mockups/replay-overlay-mobile.html. First finite step toward
B-2's screen-takeover layout reflow; labels under each notch land in
a follow-up commit when the banner height grows to accommodate them.

4 new tests: pure-helper guard pinning the [0,25,50,75,100] array,
spawn-cardinality matching helper.len(), lifecycle parity with the
overlay tree, independence from win_move_index.

Tests: 1228 → 1232 (+4). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 15:42:37 -07:00
parent c33b39cf11
commit fe68861e10
+152
View File
@@ -173,6 +173,22 @@ pub struct ReplayOverlayScrubFill;
#[derive(Component, Debug)]
pub struct ReplayOverlayWinMoveMarker;
/// Marker for the fixed-position notches on the scrub bar — five 1px
/// vertical ticks at 0 % / 25 % / 50 % / 75 % / 100 % that give the
/// player visual anchor points for "where am I, relative to the
/// quarter-marks of the replay." Mirrors the notch ladder in the
/// screen-takeover mockup at
/// `docs/ui-mockups/replay-overlay-mobile.html`.
///
/// Static — positions are set at spawn time and never change. The
/// notches paint in [`BORDER_SUBTLE`] which is the same colour as the
/// unfilled track, so visibility comes from extending the notch
/// **vertically past** the 1px track (5px tall, anchored 2px above
/// the track top) rather than from colour contrast. Same trick the
/// WIN MOVE marker uses.
#[derive(Component, Debug)]
pub struct ReplayOverlayScrubNotch;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
@@ -487,6 +503,31 @@ fn spawn_overlay(
BackgroundColor(STATE_SUCCESS),
));
}
// Fixed quarter-mark notches: five 1px vertical
// ticks at 0 / 25 / 50 / 75 / 100 % that give the
// player visual anchor points without needing to
// mentally bisect the bar. Painted in
// BORDER_SUBTLE — same colour as the unfilled
// track — so visibility comes from extending past
// the 1px track height (5px tall, anchored 2px
// above the track top) rather than colour
// contrast. Spawned *after* the WIN MOVE marker
// so a notch and the marker landing on the same
// percentage paint the marker on top.
for pct in scrub_notch_positions() {
track.spawn((
ReplayOverlayScrubNotch,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(pct),
top: Val::Px(-2.0),
width: Val::Px(1.0),
height: Val::Px(5.0),
..default()
},
BackgroundColor(BORDER_SUBTLE),
));
}
});
});
@@ -531,6 +572,16 @@ fn scrub_pct(state: &ReplayPlaybackState) -> f32 {
}
}
/// Pure helper — returns the fixed scrub-bar notch positions as
/// percentages along the track. Five evenly-spaced notches at the
/// quarter-marks: `[0, 25, 50, 75, 100]`. Function (rather than
/// const) so the unit-test surface is obvious and a future
/// regression — e.g. someone simplifying to three notches — fails
/// at the helper test rather than at visual review.
fn scrub_notch_positions() -> [f32; 5] {
[0.0, 25.0, 50.0, 75.0, 100.0]
}
/// Pure helper — returns the WIN MOVE marker's left-edge position as
/// a percentage of the scrub track, or `None` when no marker should
/// be drawn.
@@ -1432,6 +1483,107 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// scrub_notch_positions + ReplayOverlayScrubNotch spawn behaviour
// -----------------------------------------------------------------------
fn scrub_notch_count(app: &mut App) -> usize {
app.world_mut()
.query::<&ReplayOverlayScrubNotch>()
.iter(app.world())
.count()
}
/// Pure-helper guard. Locks in the five-notch ladder at the
/// quarter-marks. A future simplification to fewer notches (or a
/// shift to non-quarter spacing) must touch this test, surfacing
/// the visual change at review time.
#[test]
fn scrub_notch_positions_are_quarter_marks() {
assert_eq!(
scrub_notch_positions(),
[0.0, 25.0, 50.0, 75.0, 100.0],
"scrub notches must sit at the five quarter-mark percentages",
);
}
/// Five notch entities spawn alongside the rest of the overlay
/// tree on `Inactive → Playing`. Cardinality matches
/// `scrub_notch_positions().len()`.
#[test]
fn scrub_notches_spawn_with_overlay() {
let mut app = headless_app();
app.update();
assert_eq!(scrub_notch_count(&mut app), 0);
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
assert_eq!(
scrub_notch_count(&mut app),
scrub_notch_positions().len(),
"exactly one notch entity per quarter-mark must spawn",
);
}
/// Notches share the overlay tree's lifecycle — they despawn on
/// `Playing → Inactive` along with the banner root.
#[test]
fn scrub_notches_despawn_with_overlay() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
assert_eq!(scrub_notch_count(&mut app), 5);
set_state(&mut app, ReplayPlaybackState::Inactive);
app.update();
assert_eq!(
scrub_notch_count(&mut app),
0,
"notches must despawn with the rest of the overlay tree",
);
}
/// Notches are independent of `win_move_index` — a replay with no
/// win marker still gets the full five-notch ladder (notches give
/// quarter-mark anchor points; the win marker is an additional
/// overlay on top of them, not a replacement).
#[test]
fn scrub_notches_spawn_even_without_win_marker() {
let mut app = headless_app();
// Default constructor → win_move_index: None.
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
assert_eq!(
scrub_notch_count(&mut app),
5,
"notches and win marker are independent — no marker doesn't drop the notches",
);
}
// -----------------------------------------------------------------------
// pause_button_label + pause / step click handlers + keyboard accelerator
// -----------------------------------------------------------------------