feat(replay): add percentage labels under scrub-bar notches
Five `0%` / `25%` / `50%` / `75%` / `100%` labels in a new 16 px row beneath the 1 px scrub track give the player explicit quarter-mark readouts to pair with the notch ticks. Pure helper `scrub_notch_labels()` returns the fixed array, paired index-for-index with `scrub_notch_positions()`. Spawn loop zips both helpers and applies an "endpoints flush, middle three percent-anchored" positioning pattern: leftmost label gets `left: 0` (no clip on `0%`), rightmost gets `right: 0` (no overflow on `100%`), middle three anchor at `left: Val::Percent(p)` since Bevy 0.18 UI lacks a clean CSS-style `translate-x: -50%` centering primitive. The slight right-of-notch offset on the middle three is visually subtle at TYPE_CAPTION; explicit polish target if anyone notices. Banner height grew from 60 → 76 px to make room for the label row (76 = top row 59 flex-grow + scrub track 1 + label row 16). First real layout change in B-2's screen-takeover arc — every prior B-2 commit was additive at fixed banner geometry. Label color is TEXT_SECONDARY rather than mockup's `text-outline` (BORDER_SUBTLE) — the latter would match the notches but is too low-contrast against BG_ELEVATED_HI to read at 12 px. TEXT_SECONDARY keeps the subdued caption hierarchy while staying legible. 4 new tests: pure-helper guard pinning the array + helper-positions pairing invariant, spawn cardinality, set equality between spawned texts and helper output, lifecycle parity with the overlay tree. Tests: 1232 → 1236 (+4). Clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -61,9 +61,20 @@ pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
|
|||||||
/// gameplay surface visible underneath, tall enough to comfortably fit
|
/// gameplay surface visible underneath, tall enough to comfortably fit
|
||||||
/// the headline-sized "▌ replay" label stacked above the
|
/// the headline-sized "▌ replay" label stacked above the
|
||||||
/// `TYPE_CAPTION` "GAME #YYYY-DDD" subtitle (the left column needs
|
/// `TYPE_CAPTION` "GAME #YYYY-DDD" subtitle (the left column needs
|
||||||
/// ~26 + 2 + 11 = 39 px of inner content; banner = scrub (1) +
|
/// ~26 + 2 + 11 = 39 px of inner content; banner = scrub (1) + label
|
||||||
/// vertical padding (16) + content gives 60 with a few px headroom).
|
/// row (16) + vertical padding (16) + content gives 76 with a few px
|
||||||
const BANNER_HEIGHT: f32 = 60.0;
|
/// headroom).
|
||||||
|
///
|
||||||
|
/// Grew from 60 → 76 in the scrub-notch-labels commit to make room
|
||||||
|
/// for the percentage labels (`0%` / `25%` / … / `100%`) under each
|
||||||
|
/// notch on the scrub track.
|
||||||
|
const BANNER_HEIGHT: f32 = 76.0;
|
||||||
|
|
||||||
|
/// Height of the label row that sits below the 1px scrub track and
|
||||||
|
/// carries the `0%` / `25%` / `50%` / `75%` / `100%` notch labels.
|
||||||
|
/// 16 px is enough for `TYPE_CAPTION` text (12 px font + 4 px breathing
|
||||||
|
/// room above the bottom edge).
|
||||||
|
const SCRUB_LABEL_ROW_HEIGHT: f32 = 16.0;
|
||||||
|
|
||||||
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
|
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
|
||||||
/// reads as a clear "this is a UI strip" callout while still letting the
|
/// reads as a clear "this is a UI strip" callout while still letting the
|
||||||
@@ -189,6 +200,23 @@ pub struct ReplayOverlayWinMoveMarker;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct ReplayOverlayScrubNotch;
|
pub struct ReplayOverlayScrubNotch;
|
||||||
|
|
||||||
|
/// Marker for the percentage labels under each scrub-bar notch
|
||||||
|
/// (`0%` / `25%` / `50%` / `75%` / `100%`). One label per notch;
|
||||||
|
/// labels live in a dedicated 16 px row below the 1 px scrub track
|
||||||
|
/// (the row that grew the banner from 60 → 76 px).
|
||||||
|
///
|
||||||
|
/// Positioning follows a "endpoints flush to edges, middle three
|
||||||
|
/// anchored at percentage" pattern: the leftmost label uses
|
||||||
|
/// `left: 0`, the rightmost uses `right: 0`, and the middle three
|
||||||
|
/// (`25%` / `50%` / `75%`) anchor at `left: Val::Percent(p)`. This
|
||||||
|
/// avoids overflow at 100 % without needing CSS-style
|
||||||
|
/// `translate-x: -50%` centering (which Bevy 0.18 UI doesn't have a
|
||||||
|
/// clean equivalent for) — the trade-off is a slight right-of-notch
|
||||||
|
/// offset on the middle three, which is visually subtle at the
|
||||||
|
/// `TYPE_CAPTION` font size.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ReplayOverlayScrubNotchLabel;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -297,6 +325,12 @@ fn spawn_overlay(
|
|||||||
// the original `font_handle`. Cheap — Bevy's `Handle<Font>` is
|
// the original `font_handle`. Cheap — Bevy's `Handle<Font>` is
|
||||||
// `Arc`-backed, the clone bumps a refcount.
|
// `Arc`-backed, the clone bumps a refcount.
|
||||||
let font_handle_for_floating = font_handle.clone();
|
let font_handle_for_floating = font_handle.clone();
|
||||||
|
// Second clone for the scrub-bar label row inside the outer
|
||||||
|
// banner closure. The inner top-row closure consumes the
|
||||||
|
// original `font_handle` for the progress-chip text, so by the
|
||||||
|
// time the outer closure reaches the label-row spawn the
|
||||||
|
// original is gone.
|
||||||
|
let font_handle_for_labels = font_handle.clone();
|
||||||
|
|
||||||
let banner_label = if state.is_completed() {
|
let banner_label = if state.is_completed() {
|
||||||
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
|
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
|
||||||
@@ -529,6 +563,73 @@ fn spawn_overlay(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Third banner row: percentage labels (`0%` / `25%` /
|
||||||
|
// `50%` / `75%` / `100%`) under each scrub-bar notch.
|
||||||
|
// Sibling of (not child of) the 1px track because labels
|
||||||
|
// need their own vertical real estate (TYPE_CAPTION text
|
||||||
|
// doesn't fit inside a 1px container). Position math:
|
||||||
|
// track Node has `Val::Percent(p)` referencing the
|
||||||
|
// banner's full width; this label row also has the
|
||||||
|
// banner's full width, so labels at the same
|
||||||
|
// percentages line up vertically with their notches.
|
||||||
|
let labels = scrub_notch_labels();
|
||||||
|
let positions = scrub_notch_positions();
|
||||||
|
banner
|
||||||
|
.spawn(Node {
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Px(SCRUB_LABEL_ROW_HEIGHT),
|
||||||
|
position_type: PositionType::Relative,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
for (i, (label, pct)) in
|
||||||
|
labels.iter().zip(positions.iter()).enumerate()
|
||||||
|
{
|
||||||
|
// Endpoints flush to the row's edges; middle
|
||||||
|
// three labels anchor at their percentage.
|
||||||
|
// `i == 0` → flush left (`left: 0`), so the
|
||||||
|
// "0%" caption doesn't get clipped at the
|
||||||
|
// left edge. `i == last` → flush right
|
||||||
|
// (`right: 0`) so "100%" doesn't overflow
|
||||||
|
// the banner. Bevy 0.18 UI has no clean
|
||||||
|
// CSS-style `translate-x: -50%` centering,
|
||||||
|
// so the middle three labels sit slightly
|
||||||
|
// right-of-notch — visually subtle at this
|
||||||
|
// font size; explicit polish target if
|
||||||
|
// anyone notices.
|
||||||
|
let mut node = Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
top: Val::Px(2.0),
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
if i == 0 {
|
||||||
|
node.left = Val::Px(0.0);
|
||||||
|
} else if i == labels.len() - 1 {
|
||||||
|
node.right = Val::Px(0.0);
|
||||||
|
} else {
|
||||||
|
node.left = Val::Percent(*pct);
|
||||||
|
}
|
||||||
|
row.spawn((
|
||||||
|
ReplayOverlayScrubNotchLabel,
|
||||||
|
node,
|
||||||
|
Text::new(*label),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle_for_labels.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
// The mockup's `text-outline` (BORDER_SUBTLE)
|
||||||
|
// would match the notches but reads as too
|
||||||
|
// low-contrast against `BG_ELEVATED_HI` for
|
||||||
|
// the labels to actually be legible at 12 px.
|
||||||
|
// TEXT_SECONDARY keeps the subdued visual
|
||||||
|
// hierarchy (caption, not headline) while
|
||||||
|
// staying readable.
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Floating progress chip — a 2D world-space `Text2d` rendered
|
// Floating progress chip — a 2D world-space `Text2d` rendered
|
||||||
@@ -582,6 +683,16 @@ fn scrub_notch_positions() -> [f32; 5] {
|
|||||||
[0.0, 25.0, 50.0, 75.0, 100.0]
|
[0.0, 25.0, 50.0, 75.0, 100.0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure helper — returns the percentage-label text for each notch,
|
||||||
|
/// in left-to-right order. Paired with [`scrub_notch_positions`] so
|
||||||
|
/// `labels[i]` belongs at `positions[i]`. Lifted to a function for
|
||||||
|
/// the same reason as the positions helper: a clean unit-test
|
||||||
|
/// surface that fails at a regression (e.g. someone simplifying
|
||||||
|
/// `100%` → `MAX`) rather than at visual review.
|
||||||
|
fn scrub_notch_labels() -> [&'static str; 5] {
|
||||||
|
["0%", "25%", "50%", "75%", "100%"]
|
||||||
|
}
|
||||||
|
|
||||||
/// Pure helper — returns the WIN MOVE marker's left-edge position as
|
/// Pure helper — returns the WIN MOVE marker's left-edge position as
|
||||||
/// a percentage of the scrub track, or `None` when no marker should
|
/// a percentage of the scrub track, or `None` when no marker should
|
||||||
/// be drawn.
|
/// be drawn.
|
||||||
@@ -1559,6 +1670,120 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scrub_notch_label_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&ReplayOverlayScrubNotchLabel>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the rendered text of every `ReplayOverlayScrubNotchLabel`
|
||||||
|
/// in left-to-right order — the iteration order isn't guaranteed by
|
||||||
|
/// the ECS query, so callers needing a stable order must sort.
|
||||||
|
fn scrub_notch_label_texts(app: &mut App) -> Vec<String> {
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut q = world.query_filtered::<&Text, With<ReplayOverlayScrubNotchLabel>>();
|
||||||
|
q.iter(world).map(|t| t.0.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure-helper guard for the label strings. Pairs with
|
||||||
|
/// `scrub_notch_positions_are_quarter_marks` — same length, same
|
||||||
|
/// order, so `labels[i]` belongs at `positions[i]`.
|
||||||
|
#[test]
|
||||||
|
fn scrub_notch_labels_are_quarter_mark_percents() {
|
||||||
|
assert_eq!(
|
||||||
|
scrub_notch_labels(),
|
||||||
|
["0%", "25%", "50%", "75%", "100%"],
|
||||||
|
"scrub notch labels must read as the five quarter-mark percentages",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scrub_notch_labels().len(),
|
||||||
|
scrub_notch_positions().len(),
|
||||||
|
"labels and positions must remain paired one-to-one",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Five label entities spawn alongside the rest of the overlay.
|
||||||
|
/// Cardinality matches `scrub_notch_labels().len()`.
|
||||||
|
#[test]
|
||||||
|
fn scrub_notch_labels_spawn_with_overlay() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(scrub_notch_label_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_label_count(&mut app),
|
||||||
|
scrub_notch_labels().len(),
|
||||||
|
"exactly one label entity per notch must spawn",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each spawned label carries one of the helper's strings — pins
|
||||||
|
/// the spawn-path against drift between the helper and the actual
|
||||||
|
/// painted text.
|
||||||
|
#[test]
|
||||||
|
fn scrub_notch_labels_carry_helper_strings() {
|
||||||
|
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();
|
||||||
|
|
||||||
|
let mut texts = scrub_notch_label_texts(&mut app);
|
||||||
|
texts.sort();
|
||||||
|
let mut expected: Vec<String> = scrub_notch_labels()
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
expected.sort();
|
||||||
|
assert_eq!(
|
||||||
|
texts, expected,
|
||||||
|
"spawned label texts must equal the helper's strings (set equality, ECS order is not guaranteed)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Labels share the overlay tree's lifecycle — they despawn on
|
||||||
|
/// `Playing → Inactive` along with the banner root.
|
||||||
|
#[test]
|
||||||
|
fn scrub_notch_labels_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_label_count(&mut app), 5);
|
||||||
|
|
||||||
|
set_state(&mut app, ReplayPlaybackState::Inactive);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
scrub_notch_label_count(&mut app),
|
||||||
|
0,
|
||||||
|
"labels must despawn with the rest of the overlay tree",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Notches are independent of `win_move_index` — a replay with no
|
/// Notches are independent of `win_move_index` — a replay with no
|
||||||
/// win marker still gets the full five-notch ladder (notches give
|
/// win marker still gets the full five-notch ladder (notches give
|
||||||
/// quarter-mark anchor points; the win marker is an additional
|
/// quarter-mark anchor points; the win marker is an additional
|
||||||
|
|||||||
Reference in New Issue
Block a user