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:
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user