//! On-screen overlay shown while a recorded [`Replay`] plays back. //! //! The overlay is a thin top-of-window banner with three pieces of UI: //! //! - A "Replay" label on the left so the player knows the surface is //! under playback control rather than live input. //! - A "Move N of M" progress indicator in the centre, recomputed every //! frame the cursor advances. //! - A "Stop" button on the right that aborts playback and returns //! control to the player. //! //! When playback finishes ([`ReplayPlaybackState::Completed`]) the banner //! label swaps to "Replay complete" and stays visible until the playback //! core auto-clears the resource back to [`ReplayPlaybackState::Inactive`] //! a few seconds later, at which point the overlay despawns. //! //! The overlay sits at z-layer [`Z_REPLAY_OVERLAY`] — above gameplay but //! below every modal layer ([`Z_MODAL_SCRIM`] and up). That ordering lets //! the player still open Settings, Pause, and Help during a replay; those //! modals will render on top of the banner as expected. //! //! [`Replay`]: solitaire_data::Replay //! [`Z_MODAL_SCRIM`]: crate::ui_theme::Z_MODAL_SCRIM use bevy::prelude::*; use crate::font_plugin::FontResource; use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState}; use crate::ui_modal::{spawn_modal_button, ButtonVariant}; use crate::ui_theme::{ ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, TEXT_PRIMARY, TYPE_BODY, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY, }; // --------------------------------------------------------------------------- // Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above. // --------------------------------------------------------------------------- /// `bevy::ui` `ZIndex` value for the replay overlay banner. /// /// Numeric value is `Z_DROP_OVERLAY as i32 + 5 = 55`; chosen so the banner /// sits clearly above the HUD top layer (`Z_HUD_TOP = 60` is intentionally /// **below** modals, but the overlay needs to be above HUD readouts) yet /// well below `Z_MODAL_SCRIM = 200` so Settings, Pause, and Help modals /// continue to render on top of the overlay during a replay. /// /// The `Z_DROP_OVERLAY + 5` formula in the spec is reproduced here as an /// integer because `Z_DROP_OVERLAY` itself is a `f32` Sprite-space z used /// for the drop-target overlay sprites — UI nodes use `i32` `ZIndex`, so /// we materialise a separate constant rather than reuse the `f32` value. pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5; /// Total height of the banner in pixels. Thin enough to leave the /// gameplay surface visible underneath, tall enough to comfortably fit /// the headline-sized "Replay" label. const BANNER_HEIGHT: f32 = 48.0; /// 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 /// felt show through enough to anchor the banner to the play surface. const BANNER_ALPHA: f32 = 0.92; // --------------------------------------------------------------------------- // Marker components // --------------------------------------------------------------------------- /// Marker on the banner's root `Node`. Used by the spawn / despawn / /// progress-update systems to find the overlay. #[derive(Component, Debug)] pub struct ReplayOverlayRoot; /// Marker on the left-hand banner label `Text`. Carries either "Replay" /// (during playback) or "Replay complete" (once finished); the /// completion-text-update system swaps the contents in place. #[derive(Component, Debug)] pub struct ReplayOverlayBannerText; /// Marker on the centre progress `Text`. Updated every frame to reflect /// the current `(cursor, total)` returned by /// [`ReplayPlaybackState::progress`]. #[derive(Component, Debug)] pub struct ReplayOverlayProgressText; /// Marker on the right-hand "Stop" button. Click handler queries for this /// and calls [`stop_replay_playback`] when an `Interaction::Pressed` /// transition is seen. #[derive(Component, Debug)] pub struct ReplayStopButton; /// Marker on the cyan "fill" of the bottom-edge scrub bar. The /// `Node`'s `width` is rewritten every frame the cursor advances to /// `cursor / total` of the bar's full width, so the player has a /// continuous visual cue of how far through the replay they are. /// /// Distinct from the simpler text-based `ReplayOverlayProgressText` /// (which spells out "Move N of M"): the scrub fill gives immediate /// at-a-glance positioning; the text gives the exact numbers. Both /// surfaces stay together because they answer the same question for /// players with different scanning preferences. #[derive(Component, Debug)] pub struct ReplayOverlayScrubFill; // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- /// Bevy plugin that registers every system needed to drive the replay /// overlay's lifecycle. /// /// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`] /// — it only reads the shared `ReplayPlaybackState` resource. Tests insert /// the resource manually and exercise the overlay in isolation. pub struct ReplayOverlayPlugin; impl Plugin for ReplayOverlayPlugin { fn build(&self, app: &mut App) { // The systems are ordered so that, on a single frame: // 1. The state-watcher spawns or despawns the overlay if the // `ReplayPlaybackState` resource changed. // 2. The completion-text update swaps the banner label when the // state is `Completed`. // 3. The progress-text update writes the latest "Move N of M". // 4. The Stop-button click handler reads `Interaction::Pressed` // and calls `stop_replay_playback` (which mutates the state). // Putting Stop last means a click in frame N is observed by // `react_to_state_change` in frame N+1, which then despawns the // overlay in response — a clean state-driven loop. app.add_systems( Update, ( react_to_state_change, update_banner_label, update_progress_text, update_scrub_fill, handle_stop_button, ) .chain(), ); } } // --------------------------------------------------------------------------- // Spawning // --------------------------------------------------------------------------- /// Reads [`ReplayPlaybackState`] every time the resource changes and either /// spawns or despawns the overlay accordingly. Treats the resource as the /// single source of truth — the spawn / despawn decision is derived from /// `is_playing() || is_completed()` rather than tracking previous-state /// transitions explicitly, which keeps the system stateless. fn react_to_state_change( mut commands: Commands, state: Res, existing: Query>, font_res: Option>, ) { if !state.is_changed() { return; } let should_be_visible = state.is_playing() || state.is_completed(); let already_spawned = existing.iter().next().is_some(); if should_be_visible && !already_spawned { spawn_overlay(&mut commands, font_res.as_deref(), &state); } else if !should_be_visible && already_spawned { for entity in &existing { commands.entity(entity).despawn(); } } // The `should_be_visible && already_spawned` branch is a no-op here — // the per-frame text update systems below repaint the banner label // and progress readout in place without a respawn. } /// Spawns the banner — a flex-row Node anchored to the top edge of the /// window with three children: the "Replay" / "Replay complete" label, /// the centred progress text, and the right-aligned Stop button. fn spawn_overlay( commands: &mut Commands, font_res: Option<&FontResource>, state: &ReplayPlaybackState, ) { let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let banner_label = if state.is_completed() { "Replay complete" } else { "Replay" }; let progress_label = format_progress(state); let banner_bg = Color::srgba( BG_ELEVATED_HI.to_srgba().red, BG_ELEVATED_HI.to_srgba().green, BG_ELEVATED_HI.to_srgba().blue, BANNER_ALPHA, ); commands .spawn(( ReplayOverlayRoot, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), top: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Px(BANNER_HEIGHT), // Column outer so the content row sits above the 1px // scrub bar at the bottom edge. flex_direction: FlexDirection::Column, ..default() }, BackgroundColor(banner_bg), // Pin the banner to its z layer in both the local and the // global stacking context — `GlobalZIndex` matters because // the overlay is a top-level Node (no parent), and Bevy 0.18 // has historically had subtle stacking-context drift here. ZIndex(Z_REPLAY_OVERLAY), GlobalZIndex(Z_REPLAY_OVERLAY), )) .with_children(|banner| { // Top row: the existing content (label / progress / Stop). banner .spawn(Node { flex_grow: 1.0, flex_direction: FlexDirection::Row, align_items: AlignItems::Center, justify_content: JustifyContent::SpaceBetween, padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2), column_gap: VAL_SPACE_4, ..default() }) .with_children(|row| { // Left: "Replay" label in the cyan primary accent // (`ACCENT_PRIMARY`) so it reads unmistakably as a // non-gameplay surface. row.spawn(( ReplayOverlayBannerText, Text::new(banner_label), TextFont { font: font_handle.clone(), font_size: TYPE_HEADLINE, ..default() }, TextColor(ACCENT_PRIMARY), )); // Centre: progress readout — neutral primary text // colour so the eye treats it as data, not a // callout. row.spawn(( ReplayOverlayProgressText, Text::new(progress_label), TextFont { font: font_handle, font_size: TYPE_BODY, ..default() }, TextColor(TEXT_PRIMARY), )); // Right: Stop button. Tertiary variant — the // action is available but not the loudest element // in the banner; the "Replay" cyan accent owns // that slot. `spawn_modal_button` gives us hover / // press paint and focus rings for free via the // existing `UiModalPlugin` paint system. row.spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, ..default() }) .with_children(|wrap| { spawn_modal_button( wrap, ReplayStopButton, "Stop", None, ButtonVariant::Tertiary, font_res, ); }); }); // Bottom edge: 1px-tall scrub bar. Track in `BORDER_SUBTLE`, // fill in `ACCENT_PRIMARY`. The fill width is rewritten by // [`update_scrub_fill`] every tick the cursor advances. // Initial fill width matches the spawn-time progress so the // first-frame paint already reflects state instead of // popping from 0 → cursor on the first tick. let initial_scrub_pct = scrub_pct(state); banner .spawn(( Node { width: Val::Percent(100.0), height: Val::Px(1.0), ..default() }, BackgroundColor(BORDER_SUBTLE), )) .with_children(|track| { track.spawn(( ReplayOverlayScrubFill, Node { width: Val::Percent(initial_scrub_pct), height: Val::Percent(100.0), ..default() }, BackgroundColor(ACCENT_PRIMARY), )); }); }); } /// Pure helper — returns the scrub-fill width as a percentage of the /// track for the given playback state. `Completed` reads as 100 %; /// `Inactive` and `Playing` with no progress read as 0 %. fn scrub_pct(state: &ReplayPlaybackState) -> f32 { if state.is_completed() { return 100.0; } match state.progress() { Some((_, 0)) | None => 0.0, Some((cursor, total)) => { let frac = (cursor as f32 / total as f32).clamp(0.0, 1.0); frac * 100.0 } } } // --------------------------------------------------------------------------- // Per-frame text updates // --------------------------------------------------------------------------- /// Overwrites the banner label whenever the resource changes — covers the /// `Playing → Completed` transition by swapping "Replay" for /// "Replay complete" in place without despawning the overlay. fn update_banner_label( state: Res, mut q: Query<&mut Text, With>, ) { if !state.is_changed() { return; } let label = if state.is_completed() { "Replay complete" } else if state.is_playing() { "Replay" } else { return; }; for mut text in &mut q { **text = label.to_string(); } } /// Repaints the "Move N of M" centre readout every frame the cursor moves. /// Cheap — early-exits if the resource has not changed since the last /// frame so idle replays don't churn the text mesh. fn update_progress_text( state: Res, mut q: Query<&mut Text, With>, ) { if !state.is_changed() { return; } let label = format_progress(&state); for mut text in &mut q { **text = label.clone(); } } /// Repaints the bottom-edge cyan scrub fill to mirror cursor progress. /// Same change-detection guard as the text updaters — the overlay /// already early-exits when nothing moved, so an idle replay leaves the /// scrub bar's `Node` untouched. fn update_scrub_fill( state: Res, mut q: Query<&mut Node, With>, ) { if !state.is_changed() { return; } let pct = scrub_pct(&state); for mut node in &mut q { node.width = Val::Percent(pct); } } /// Pure helper — formats the centre progress readout for the given state. /// Exposed at module scope so the spawn path and the per-frame update /// path produce the exact same string. fn format_progress(state: &ReplayPlaybackState) -> String { match state.progress() { Some((cursor, total)) => format!("Move {cursor} of {total}"), None if state.is_completed() => "Replay complete".to_string(), None => String::new(), } } // --------------------------------------------------------------------------- // Stop button handler // --------------------------------------------------------------------------- /// Watches the Stop button for `Interaction::Pressed` transitions. On a /// click, calls [`stop_replay_playback`] which resets the state to /// `Inactive`; the next frame's `react_to_state_change` then despawns /// the overlay. fn handle_stop_button( mut commands: Commands, mut state: ResMut, buttons: Query<&Interaction, (With, Changed)>, ) { if !buttons.iter().any(|i| *i == Interaction::Pressed) { return; } stop_replay_playback(&mut commands, &mut state); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use chrono::NaiveDate; use solitaire_core::game_state::{DrawMode, GameMode}; use solitaire_data::{Replay, ReplayMove}; /// Build a minimal but well-formed [`Replay`] with `move_count` no-op /// `StockClick` entries. Tests only ever read `replay.moves.len()` /// (denominator of the progress indicator), so the move kind is /// irrelevant beyond producing the right count. fn synthetic_replay(move_count: usize) -> Replay { Replay::new( 42, DrawMode::DrawOne, GameMode::Classic, 120, 1_000, NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"), (0..move_count).map(|_| ReplayMove::StockClick).collect(), ) } /// Build a test app that has the overlay plugin but **not** the /// playback plugin — tests insert `ReplayPlaybackState` manually so /// they can drive every state transition deterministically. fn headless_app() -> App { let mut app = App::new(); app.add_plugins(MinimalPlugins).add_plugins(ReplayOverlayPlugin); app.init_resource::(); app } /// Count `ReplayOverlayRoot` entities in the world — the overlay's /// presence/absence is the spawn-test's primary observable. fn overlay_root_count(app: &mut App) -> usize { app.world_mut() .query::<&ReplayOverlayRoot>() .iter(app.world()) .count() } /// Read the current text content of the unique progress-text entity. fn progress_text(app: &mut App) -> String { let mut q = app .world_mut() .query_filtered::<&Text, With>(); q.iter(app.world()) .next() .map(|t| t.0.clone()) .unwrap_or_default() } /// Read the current text content of the unique banner-label entity. fn banner_text(app: &mut App) -> String { let mut q = app .world_mut() .query_filtered::<&Text, With>(); q.iter(app.world()) .next() .map(|t| t.0.clone()) .unwrap_or_default() } /// Set the playback resource without going through the playback core. fn set_state(app: &mut App, state: ReplayPlaybackState) { app.world_mut().insert_resource(state); } /// Find the unique `ReplayStopButton` entity for the click-handler /// test. There must be exactly one. fn stop_button_entity(app: &mut App) -> Entity { let mut q = app .world_mut() .query_filtered::>(); q.iter(app.world()) .next() .expect("Stop button must exist while overlay is spawned") } /// Going `Inactive → Playing` spawns exactly one overlay root and /// the banner label reads "Replay". #[test] fn overlay_spawns_when_playback_starts() { let mut app = headless_app(); // First update with the default `Inactive` resource — overlay // must not exist yet. app.update(); assert_eq!(overlay_root_count(&mut app), 0); set_state( &mut app, ReplayPlaybackState::Playing { replay: synthetic_replay(10), cursor: 0, secs_to_next: 0.5, }, ); app.update(); assert_eq!( overlay_root_count(&mut app), 1, "exactly one ReplayOverlayRoot must spawn on Inactive → Playing", ); assert_eq!(banner_text(&mut app), "Replay"); } /// The progress-text entity reads `"Move {cursor} of {total}"` for a /// well-formed `Playing` state. #[test] fn overlay_progress_text_reflects_cursor() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::Playing { replay: synthetic_replay(10), cursor: 5, secs_to_next: 0.5, }, ); app.update(); assert_eq!(progress_text(&mut app), "Move 5 of 10"); } /// Pressing the Stop button resets the state back to `Inactive` and /// the next frame's `react_to_state_change` despawns the overlay. /// Mirrors the synthetic `Interaction::Pressed` insertion pattern /// used elsewhere in the engine for headless click tests. #[test] fn overlay_stop_button_click_clears_playback() { 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!(overlay_root_count(&mut app), 1); let stop = stop_button_entity(&mut app); app.world_mut() .entity_mut(stop) .insert(Interaction::Pressed); // Tick once: the click handler runs late in the frame and resets // the state to `Inactive`. app.update(); // State must be back to Inactive. let state = app.world().resource::(); assert!( matches!(state, ReplayPlaybackState::Inactive), "Stop click must reset ReplayPlaybackState to Inactive; got {state:?}", ); // One more tick — `react_to_state_change` sees the resource // change to Inactive and despawns the overlay. app.update(); assert_eq!( overlay_root_count(&mut app), 0, "overlay must despawn the frame after state returns to Inactive", ); } /// Manually flipping the resource back to `Inactive` (e.g. via the /// playback core's auto-clear after `Completed`) tears the overlay /// down without any further input. #[test] fn overlay_despawns_when_playback_returns_to_inactive() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::Playing { replay: synthetic_replay(3), cursor: 1, secs_to_next: 0.5, }, ); app.update(); assert_eq!(overlay_root_count(&mut app), 1); set_state(&mut app, ReplayPlaybackState::Inactive); app.update(); assert_eq!( overlay_root_count(&mut app), 0, "overlay must despawn on Playing → Inactive transition", ); } /// On `Playing → Completed` the banner label updates in place rather /// than respawning. The overlay must still be present, and the label /// must read "Replay complete". #[test] fn overlay_text_changes_on_completed() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::Playing { replay: synthetic_replay(7), cursor: 7, secs_to_next: 0.0, }, ); app.update(); assert_eq!(banner_text(&mut app), "Replay"); set_state(&mut app, ReplayPlaybackState::Completed); app.update(); assert_eq!( overlay_root_count(&mut app), 1, "overlay must remain spawned while in Completed state", ); assert_eq!( banner_text(&mut app), "Replay complete", "banner label must swap on Playing → Completed", ); } /// Read the current `Node.width` of the unique scrub-fill entity as /// a percentage. Assertions can then compare against expected /// `cursor / total` ratios without poking ECS internals at the call /// site. fn scrub_fill_pct(app: &mut App) -> f32 { let mut q = app .world_mut() .query_filtered::<&Node, With>(); let node = q .iter(app.world()) .next() .expect("scrub-fill node must exist while overlay is spawned"); match node.width { Val::Percent(p) => p, other => panic!("scrub fill width must be Val::Percent; got {other:?}"), } } /// Pure-helper guard. Locks in the four corners of `scrub_pct` so a /// future refactor of `ReplayPlaybackState::progress()` can't /// silently regress the visual cue: `Inactive → 0 %`, /// `Playing { cursor: 0, total: N } → 0 %`, /// `Playing { cursor: N/2, total: N } → 50 %`, /// `Completed → 100 %`. #[test] fn scrub_pct_covers_state_corners() { assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0); assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0); assert_eq!( scrub_pct(&ReplayPlaybackState::Playing { replay: synthetic_replay(10), cursor: 0, secs_to_next: 0.5, }), 0.0, ); assert_eq!( scrub_pct(&ReplayPlaybackState::Playing { replay: synthetic_replay(10), cursor: 5, secs_to_next: 0.5, }), 50.0, ); assert_eq!( scrub_pct(&ReplayPlaybackState::Playing { replay: synthetic_replay(10), cursor: 10, secs_to_next: 0.5, }), 100.0, ); } /// End-to-end: the spawn path must paint the scrub fill at the /// initial cursor's percentage, and the per-frame `update_scrub_fill` /// system must repaint it as the cursor advances. Mirrors the shape /// of `overlay_progress_text_reflects_cursor`. #[test] fn overlay_scrub_fill_tracks_cursor() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::Playing { replay: synthetic_replay(8), cursor: 2, secs_to_next: 0.5, }, ); app.update(); assert_eq!( scrub_fill_pct(&mut app), 25.0, "spawn-time fill must reflect the initial cursor", ); set_state( &mut app, ReplayPlaybackState::Playing { replay: synthetic_replay(8), cursor: 6, secs_to_next: 0.5, }, ); app.update(); assert_eq!( scrub_fill_pct(&mut app), 75.0, "update_scrub_fill must repaint width on cursor advance", ); set_state(&mut app, ReplayPlaybackState::Completed); app.update(); assert_eq!( scrub_fill_pct(&mut app), 100.0, "Completed state must read as a fully-filled track", ); } }