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:
@@ -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,10 +249,20 @@ 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
|
||||||
|
// the headline so the two pieces of "this is a
|
||||||
|
// replay of game X" read as a single unit.
|
||||||
|
row.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
align_items: AlignItems::FlexStart,
|
||||||
|
row_gap: Val::Px(2.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|left| {
|
||||||
|
left.spawn((
|
||||||
ReplayOverlayBannerText,
|
ReplayOverlayBannerText,
|
||||||
Text::new(banner_label),
|
Text::new(banner_label),
|
||||||
TextFont {
|
TextFont {
|
||||||
@@ -247,6 +272,17 @@ fn spawn_overlay(
|
|||||||
},
|
},
|
||||||
TextColor(ACCENT_PRIMARY),
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user