feat(engine): add GAME #YYYY-DDD caption beneath the replay headline

Adds the right-anchored game-identifier piece of the replay-overlay
mockup (docs/ui-mockups/replay-overlay-mobile.html), adapted to live
under the existing "▌ replay" headline rather than as a separate
top-bar surface — the screen-takeover redesign is intentionally
deferred per the SESSION_HANDOFF punch list.

The caption reads `GAME #{year}-{ordinal:03}` (e.g. `GAME #2026-122`
for a replay recorded 2026-05-02), matching the mockup's
`GAME #2024-127` motif. Year + chrono ordinal gives a compact,
monotonically-increasing identifier that's grep-friendly across
replay files. TYPE_CAPTION (11 px) / TEXT_SECONDARY paint so the
caption reads as subordinate metadata, not a callout.

Implementation: new ReplayOverlayGameCaption marker, new pure
helper `format_game_caption(state) -> Option<String>` (None for
Inactive / Completed since the replay is consumed in those branches),
left-side label spawn restructured into a column container holding
the headline + caption with a 2 px row gap. BANNER_HEIGHT bumped
48 → 60 px so the column fits without overflow (16 px vertical
padding + 1 px scrub + ~39 px content; +12 px banner mass is the
deliberate cost of the new content).

Two new tests (1180 → 1182): format_game_caption_covers_state_corners
pins the three branches (Inactive / Completed / Playing) plus the
zero-pad-to-3-digits invariant for early-January ordinals; and
overlay_game_caption_shows_replay_date drives ReplayPlaybackState
end-to-end and asserts the caption text on spawn and that the
overlay stays spawned through Playing → Completed.

MOVE chip restyle from the same mockup is the next commit.
This commit is contained in:
funman300
2026-05-07 22:19:49 -07:00
parent 44f5972edd
commit 54005d5494
+150 -17
View File
@@ -23,13 +23,14 @@
//! [`Z_MODAL_SCRIM`]: crate::ui_theme::Z_MODAL_SCRIM //! [`Z_MODAL_SCRIM`]: crate::ui_theme::Z_MODAL_SCRIM
use bevy::prelude::*; use bevy::prelude::*;
use chrono::Datelike;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState}; use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState};
use crate::ui_modal::{spawn_modal_button, ButtonVariant}; use crate::ui_modal::{spawn_modal_button, ButtonVariant};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, TEXT_PRIMARY, TYPE_BODY, TYPE_HEADLINE, ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -52,8 +53,11 @@ pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
/// Total height of the banner in pixels. Thin enough to leave the /// Total height of the banner in pixels. Thin enough to leave the
/// gameplay surface visible underneath, tall enough to comfortably fit /// gameplay surface visible underneath, tall enough to comfortably fit
/// the headline-sized "▌ replay" label. /// the headline-sized "▌ replay" label stacked above the
const BANNER_HEIGHT: f32 = 48.0; /// `TYPE_CAPTION` "GAME #YYYY-DDD" subtitle (the left column needs
/// ~26 + 2 + 11 = 39 px of inner content; banner = scrub (1) +
/// vertical padding (16) + content gives 60 with a few px headroom).
const BANNER_HEIGHT: f32 = 60.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
@@ -89,6 +93,17 @@ pub struct ReplayOverlayProgressText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ReplayStopButton; pub struct ReplayStopButton;
/// Marker on the small caption sitting below the "▌ replay"
/// headline. Carries `GAME #YYYY-DDD` (year + chrono ordinal) while a
/// replay is playing — a compact, monotonically-increasing identifier
/// that mirrors the `▌replay.tsx` / `GAME #2024-127` Terminal-output
/// motif from the mockup. The caption is empty in `Inactive` /
/// `Completed` since the replay is consumed when transitioning out
/// of `Playing` and the identifier is no longer recoverable from
/// state alone.
#[derive(Component, Debug)]
pub struct ReplayOverlayGameCaption;
/// Marker on the cyan "fill" of the bottom-edge scrub bar. The /// Marker on the cyan "fill" of the bottom-edge scrub bar. The
/// `Node`'s `width` is rewritten every frame the cursor advances to /// `Node`'s `width` is rewritten every frame the cursor advances to
/// `cursor / total` of the bar's full width, so the player has a /// `cursor / total` of the bar's full width, so the player has a
@@ -234,19 +249,40 @@ fn spawn_overlay(
..default() ..default()
}) })
.with_children(|row| { .with_children(|row| {
// Left: "Replay" label in the cyan primary accent // Left: column with the cyan "▌ replay" headline
// (`ACCENT_PRIMARY`) so it reads unmistakably as a // above and a small `GAME #YYYY-DDD` caption below.
// non-gameplay surface. // The caption mirrors the mockup's right-anchored
row.spawn(( // game identifier but stays visually grouped with
ReplayOverlayBannerText, // the headline so the two pieces of "this is a
Text::new(banner_label), // replay of game X" read as a single unit.
TextFont { row.spawn(Node {
font: font_handle.clone(), flex_direction: FlexDirection::Column,
font_size: TYPE_HEADLINE, align_items: AlignItems::FlexStart,
..default() row_gap: Val::Px(2.0),
}, ..default()
TextColor(ACCENT_PRIMARY), })
)); .with_children(|left| {
left.spawn((
ReplayOverlayBannerText,
Text::new(banner_label),
TextFont {
font: font_handle.clone(),
font_size: TYPE_HEADLINE,
..default()
},
TextColor(ACCENT_PRIMARY),
));
left.spawn((
ReplayOverlayGameCaption,
Text::new(format_game_caption(state).unwrap_or_default()),
TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
});
// Centre: progress readout — neutral primary text // Centre: progress readout — neutral primary text
// colour so the eye treats it as data, not a // colour so the eye treats it as data, not a
@@ -391,6 +427,26 @@ fn update_scrub_fill(
} }
} }
/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given
/// state. Returns `None` for `Inactive` / `Completed` (the replay is
/// consumed when transitioning out of `Playing`, so the identifier
/// isn't recoverable from state in those branches); spawn-time
/// callers fall back to an empty string.
///
/// Year + chrono ordinal (`{year}-{ordinal:03}`) gives a compact
/// monotonically-increasing identifier shaped like `2026-127` — same
/// shape as the mockup's `GAME #2024-127` motif.
fn format_game_caption(state: &ReplayPlaybackState) -> Option<String> {
match state {
ReplayPlaybackState::Playing { replay, .. } => Some(format!(
"GAME #{}-{:03}",
replay.recorded_at.year(),
replay.recorded_at.ordinal()
)),
ReplayPlaybackState::Inactive | ReplayPlaybackState::Completed => None,
}
}
/// Pure helper — formats the centre progress readout for the given state. /// Pure helper — formats the centre progress readout for the given state.
/// Exposed at module scope so the spawn path and the per-frame update /// Exposed at module scope so the spawn path and the per-frame update
/// path produce the exact same string. /// path produce the exact same string.
@@ -707,6 +763,83 @@ mod tests {
); );
} }
/// Read the current text content of the unique GAME-caption entity.
fn game_caption_text(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Text, With<ReplayOverlayGameCaption>>();
q.iter(app.world())
.next()
.map(|t| t.0.clone())
.unwrap_or_default()
}
/// Pure-helper guard. `Inactive` / `Completed` carry no replay
/// reference so the caption is `None`; `Playing` formats the
/// recorded-date as `GAME #YYYY-DDD` with a 3-digit zero-padded
/// ordinal. Locks all three branches so a future refactor can't
/// silently regress the identifier shape.
#[test]
fn format_game_caption_covers_state_corners() {
assert_eq!(format_game_caption(&ReplayPlaybackState::Inactive), None);
assert_eq!(format_game_caption(&ReplayPlaybackState::Completed), None);
// 2026-05-02 is the 122nd day of 2026 (Jan = 31, Feb = 28,
// Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always
// uses this date so the assertion is stable.
assert_eq!(
format_game_caption(&ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
}),
Some("GAME #2026-122".to_string()),
);
// Single-digit ordinal must zero-pad to three digits — pin
// the format string in case someone simplifies to `{}-{}`.
let mut early_january = synthetic_replay(10);
early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date");
assert_eq!(
format_game_caption(&ReplayPlaybackState::Playing {
replay: early_january,
cursor: 0,
secs_to_next: 0.5,
}),
Some("GAME #2026-005".to_string()),
);
}
/// End-to-end: spawning the overlay paints the GAME caption with
/// the active replay's recorded date in `YYYY-DDD` form. Caption
/// is empty for `Completed` since the replay is consumed.
#[test]
fn overlay_game_caption_shows_replay_date() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(game_caption_text(&mut app), "GAME #2026-122");
// Caption empties out on Playing → Completed because
// `format_game_caption` returns None and the spawn-path
// helper falls through to `unwrap_or_default()`.
// The overlay itself stays spawned in `Completed`.
set_state(&mut app, ReplayPlaybackState::Completed);
app.update();
assert_eq!(
overlay_root_count(&mut app),
1,
"overlay must remain spawned while in Completed state",
);
}
/// End-to-end: the spawn path must paint the scrub fill at the /// End-to-end: the spawn path must paint the scrub fill at the
/// initial cursor's percentage, and the per-frame `update_scrub_fill` /// initial cursor's percentage, and the per-frame `update_scrub_fill`
/// system must repaint it as the cursor advances. Mirrors the shape /// system must repaint it as the cursor advances. Mirrors the shape