//! 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/M" progress chip in the centre, recomputed every frame //! the cursor advances and bordered in `ACCENT_PRIMARY` so it //! reads as a discrete callout. //! - 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 chrono::Datelike; use crate::font_plugin::FontResource; use crate::layout::LayoutResource; use crate::events::{DrawRequestEvent, MoveRequestEvent, UndoRequestEvent}; use crate::replay_playback::{ step_backwards_replay_playback, step_replay_playback, stop_replay_playback, toggle_pause_replay_playback, ReplayPlaybackState, }; use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::game_state::GameState; use solitaire_core::pile::PileType; use solitaire_data::ReplayMove; use crate::resources::GameStateResource; use crate::ui_modal::{spawn_modal_button, ButtonVariant}; use crate::ui_theme::{ ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder, STATE_SUCCESS, STATE_SUCCESS_HC, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, 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; /// `bevy::ui` `ZIndex` for the full-screen tableau dim layer. /// /// One rung below [`Z_REPLAY_OVERLAY`] (= 54) so the replay chrome /// (banner + move-log panel) renders clearly on top while the dim scrim /// darkens the card world beneath it. World-space sprites (cards, /// badges, drop-target overlays) are always below any UI node regardless /// of their Transform.z — the dim layer doesn't need to know their z /// values. const Z_REPLAY_DIM: i32 = Z_REPLAY_OVERLAY - 1; /// Alpha for the tableau dim layer — 50 % opacity black. Dark enough /// to visually separate the gameplay scene from the replay chrome /// above it; light enough that card positions remain legible through /// the scrim. Matches the mockup's "Game Peek Band at 50 % opacity" /// spec in `docs/ui-mockups/replay-overlay-mobile.html`. const TABLEAU_DIM_ALPHA: f32 = 0.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 stacked above the /// `TYPE_CAPTION` "GAME #YYYY-DDD" subtitle (the left column needs /// ~26 + 2 + 11 = 39 px of inner content; banner = top row (59 /// flex-grow) + scrub track (1) + label row (16) + footer (16) /// gives 92). /// /// Growth history: /// - 60 → 76 in the scrub-notch-labels commit to make room for the /// `0%` / … / `100%` percentage labels under each notch. /// - 76 → 92 in the keybind-footer commit to make room for the /// vim-style mode line + keybind-hint footer at the bottom. const BANNER_HEIGHT: f32 = 92.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; /// Height of the keybind-hint footer that sits below the notch-label /// row. Carries a vim-style mode indicator on the left and a /// keybind-hint on the right (`[SPACE] pause/resume`). 16 px matches /// `SCRUB_LABEL_ROW_HEIGHT` for visual symmetry — `TYPE_CAPTION` text /// (12 px) + 4 px breathing room. const KEYBIND_FOOTER_HEIGHT: f32 = 16.0; /// Fixed pixel width of the centred scrub-bar notch-label container. /// Wide enough to hold the widest label ("100%" at 4 chars) while /// narrower than the 25 % gap between adjacent notches (≈ banner_w /// × 0.25; on a 320 px banner that's 80 px). A 36 px container /// leaves ≥ 44 px of clearance on each side at the narrowest common /// screen width. /// /// Container width drives the `margin.left = -width / 2` centering /// trick: the container's left edge is placed at `left: Percent(pct)` /// and then shifted left by half its own width, so the container's /// centre coincides with the notch line. `Justify::Center` then /// renders the text centred within the container. This is the /// CSS `translateX(-50%)` pattern adapted for Bevy 0.18 UI. const SCRUB_LABEL_CENTER_WIDTH: f32 = 36.0; /// How long a held arrow key waits before firing the next repeat /// step. 100 ms = 10 steps/sec — fast enough to scrub through a /// hundred-move replay in ~10 seconds while held, slow enough that /// the player can release after a known number of steps. Initial /// `just_pressed` always fires immediately; this interval gates /// only the *repeat* fires while the key remains held. const SCRUB_REPEAT_INTERVAL_SECS: f32 = 0.1; /// Total height of the bottom-edge Move Log panel in pixels. /// Sized for: header (`TYPE_CAPTION` 11) + 2 prev rows + active /// row + 2 next rows (`TYPE_BODY` 14 each = 70) + row gaps (~10) /// + vertical padding (~16) ≈ 107; round to 112. /// /// Growth history: /// - 56 in the move-log-panel-init commit (header + active row). /// - 56 → 84 in the move-log-prev-rows commit (+ 2 prev rows). /// - 84 → 112 in the move-log-next-rows commit (+ 2 next rows). const MOVE_LOG_PANEL_HEIGHT: f32 = 112.0; /// Number of "previous move" rows rendered above the active row /// in the move-log panel. Tuned to fit the panel height comfortably /// alongside the header + active row at `TYPE_BODY`. The active /// row plus this many prev rows gives the player a 3-row window /// onto recent move history. const MOVE_LOG_PREV_ROWS: usize = 2; /// Number of "next move" rows rendered below the active row. /// Same logic as [`MOVE_LOG_PREV_ROWS`] — symmetric window /// around the active row showing about-to-apply moves. For a /// post-game replay these aren't spoilers (the game is already /// won); for a future "live preview during play" use case the /// preview-shape might need rethinking. const MOVE_LOG_NEXT_ROWS: usize = 2; /// Vertical offset from the top edge of the window to the top edge of the /// mini-tableau preview panel. Places the panel 8 px below the banner's /// bottom edge so the two surfaces don't overlap. Derived from /// `BANNER_HEIGHT` so the gap stays consistent if the banner ever grows. const MINI_TABLEAU_TOP_OFFSET: f32 = BANNER_HEIGHT + 8.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 cursor-block prefix matches the splash boot-screen /// idiom so the surface reads as a Terminal output line); 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 **floating** progress chip — a 2D world-space text /// entity rendered above the destination pile of the most-recently- /// applied move. Sits independently of the banner overlay (which /// lives in the UI tree and never moves) so the player can see /// progress without breaking eye contact with the focal card. /// /// Lifecycle matches the banner overlay: spawned by `spawn_overlay` /// when a replay starts, despawned by `react_to_state_change` when /// it ends. Position updated each frame by /// `update_floating_progress_chip`. Hidden when cursor=0 (no moves /// applied yet) or the last applied move was a `StockClick` (no /// destination pile to follow). #[derive(Component, Debug)] pub struct ReplayFloatingProgressChip; /// 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 Pause / Resume button. Click handler queries for this /// and calls [`toggle_pause_replay_playback`] on each press. The /// button's label text is repainted in lockstep by /// `update_pause_button_label` so it always reflects the action the /// next click will perform ("Pause" while running, "Resume" while /// paused). #[derive(Component, Debug)] pub struct ReplayPauseButton; /// Marker on the Step button. Click handler queries for this and /// calls [`step_replay_playback`] — only meaningful when paused /// (clicks while running are no-ops because the tick loop would race /// the manual advance). The button stays visually present but /// unresponsive while the playback is running so the player has a /// stable layout to scan. #[derive(Component, Debug)] pub struct ReplayStepButton; /// Marker on the full-screen tableau dim layer spawned at the start of /// every replay. The dim layer is a 100 % × 100 % `Node` at /// [`Z_REPLAY_DIM`] (= `Z_REPLAY_OVERLAY - 1`) with a semi-transparent /// black `BackgroundColor`. It darkens the card world so the replay /// chrome reads clearly against it without obscuring card positions. /// /// Carries no [`Interaction`] component — purely visual; pointer events /// pass through to the underlying UI and world-space systems. /// Despawned by `react_to_state_change` when the replay ends. #[derive(Component, Debug)] pub struct ReplayTableauDimLayer; /// 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 accent "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/M" in a chip): 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; /// Marker for the WIN MOVE tick on the scrub bar — a small absolute- /// positioned `Node` anchored at `replay.win_move_index / total` along /// the track. Painted in [`STATE_SUCCESS`] so the player can see at a /// glance where the winning move sits relative to the playback cursor. /// /// Static — the position is set at spawn time and never changes during /// playback (the underlying replay's `win_move_index` is immutable /// while `Playing`). Despawned with the rest of the overlay tree when /// the replay state transitions back to `Inactive`. /// /// Spawned only when the active replay carries /// [`Replay::win_move_index`](solitaire_data::Replay::win_move_index) /// `= Some(_)` — older replays loaded from disk pre-date the field /// and have no win index to surface. #[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; /// 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; /// Per-arrow-key time-since-last-fire accumulators that drive the /// continuous-scrub repeat behaviour for held arrow keys. Each /// frame the key is held, the corresponding accumulator absorbs /// `time.delta_secs()`; when it exceeds /// [`SCRUB_REPEAT_INTERVAL_SECS`] the handler fires another step /// and resets the accumulator. /// /// `just_pressed` events bypass the accumulator entirely and fire /// immediately — only *repeat* fires (while held) are gated by /// the interval. Releases reset the accumulator to 0 so the next /// fresh press fires immediately rather than at half-interval. #[derive(Resource, Default, Debug)] struct ReplayScrubKeyHold { left_held_secs: f32, right_held_secs: f32, } /// Marker on the keybind-hint footer row at the bottom edge of the /// banner. Carries two `Text` children: a vim-style mode indicator /// (`▌ NORMAL │ replay`) on the left and the keybind hint /// (`[SPACE] pause/resume`) on the right. 1 px top border in /// [`BORDER_SUBTLE`] separates it from the notch-label row above. /// /// Surfaces the existing Space-key accelerator visually so the /// UI-first contract from CLAUDE.md §3.3 (every player action has /// a visible UI control) holds for keyboard accelerators too. /// Future commits that wire ESC for stop or ← / → for scrub will /// extend the right-hand text in lockstep — the footer always /// reflects what's actually wired, never aspirational. #[derive(Component, Debug)] pub struct ReplayOverlayKeybindFooter; /// Marker on the bottom-edge **Move Log** panel — a separate root /// UI entity (not a child of the banner) that sits anchored to the /// viewport's bottom edge. Carries a header (`▌ MOVE LOG · N/M`) /// plus a row showing the most-recently-applied move. /// /// Spawned by `spawn_overlay` alongside the banner and the /// floating progress chip; despawned by `react_to_state_change` /// on the same `Playing → Inactive` transition. Same lifecycle /// pattern as `ReplayFloatingProgressChip` — a sibling root, not /// a banner child, because it lives at a different screen anchor. /// /// First slice of the move-log mockup at /// `docs/ui-mockups/replay-overlay-mobile.html` § "Move Log Card". /// Subsequent commits add prev/next rows and scrolling. #[derive(Component, Debug)] pub struct ReplayOverlayMoveLogPanel; /// Marker on the move-log panel's header `Text`. Carries /// `▌ MOVE LOG · N/M` while a replay is playing; the /// `update_move_log_header` system repaints it as the cursor /// advances. #[derive(Component, Debug)] pub struct ReplayOverlayMoveLogHeader; /// Marker on the move-log panel's active-row `Text`. Carries the /// most-recently-applied move's text (`47 │ waste → tableau 5`) /// when `cursor > 0`; empty when no moves have been applied yet /// (initial spawn) or in `Completed`/`Inactive` states. The /// `update_move_log_active_row` system repaints it as the cursor /// advances. #[derive(Component, Debug)] pub struct ReplayOverlayMoveLogActiveRow; /// Marker on a "previous move" row above the active row. /// `offset` is the 1-based distance backwards from the active /// row: `offset = 1` is the move applied just before the active /// one (e.g. cursor=47 → row reads "46 │ ..."), `offset = 2` is /// the one before that, and so on. Up to [`MOVE_LOG_PREV_ROWS`] /// rows render above the active row. /// /// Empty text when there isn't enough history (`offset >= cursor`, /// e.g. cursor=1 has no prev rows; cursor=2 has only the /// `offset = 1` row populated). #[derive(Component, Debug)] pub struct ReplayOverlayMoveLogPrevRow { /// Distance backwards from the active row (1-based). pub offset: u8, } /// Marker on a "next move" row below the active row. `offset` /// is the 1-based distance forward from the active row: /// `offset = 1` is the move that will apply next /// (`replay.moves[cursor]`, displayed as `cursor + 1`), /// `offset = 2` is the one after that, and so on. Up to /// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row. /// /// Empty text when there isn't enough remaining replay /// (`cursor + offset - 1 >= moves.len()`, e.g. cursor=99 of /// a 100-move replay shows offset 1 but offset 2 stays empty). #[derive(Component, Debug)] pub struct ReplayOverlayMoveLogNextRow { /// Distance forward from the active row (1-based). pub offset: u8, } /// Marker added to every top-level entity spawned by [`spawn_overlay`]. /// `react_to_state_change` uses a single `Query>` /// to despawn all of them, rather than keeping a separate query per /// entity type. Future sibling overlay surfaces just need this marker /// at spawn time — no changes to the despawn logic required. #[derive(Component, Debug)] pub struct DespawnWithReplay; /// Marker on the mini-tableau preview panel root. A right-edge-anchored /// panel that shows a compact summary of the live game state during /// replay: the four foundation tops and the stock / waste heads. /// Spawned as a sibling root entity (same lifecycle pattern as /// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`. #[derive(Component, Debug)] pub struct ReplayMiniTableauPanel; /// Marker on the foundations row `Text` inside the mini-tableau panel. /// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by /// `update_mini_tableau` whenever [`GameStateResource`] changes. #[derive(Component, Debug)] pub struct ReplayMiniTableauFoundations; /// Marker on the stock/waste row `Text` inside the mini-tableau panel. /// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever /// [`GameStateResource`] changes. #[derive(Component, Debug)] pub struct ReplayMiniTableauStockWaste; // --------------------------------------------------------------------------- // 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. // Step-button handler dispatches into the same canonical move // / draw events that the tick loop fires. Register them // defensively here so this plugin can run under // `MinimalPlugins` without the playback plugin attached; // `add_message` is idempotent so the duplicate registration // in production (alongside `replay_playback`) is harmless. app.init_resource::() .add_message::() .add_message::() .add_message::() .add_systems( Update, ( react_to_state_change, update_banner_label, update_progress_text, update_floating_progress_chip, update_scrub_fill, update_move_log_header, update_move_log_active_row, update_move_log_prev_rows, update_move_log_next_rows, update_mini_tableau_foundations, update_mini_tableau_stock_waste, update_pause_button_label, handle_pause_button, handle_step_button, handle_pause_keyboard, handle_stop_keyboard, handle_arrow_keyboard, 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, roots: Query>, despawnable: Query>, font_res: Option>, ) { if !state.is_changed() { return; } let should_be_visible = state.is_playing() || state.is_completed(); let already_spawned = roots.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 { // Despawn all sibling root entities in one loop — every entity // spawned by `spawn_overlay` carries `DespawnWithReplay` for // exactly this purpose. for entity in &despawnable { 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(); // Clone for the floating chip spawn that runs *after* the // banner's `.with_children(|banner| { ... })` closure consumes // the original `font_handle`. Cheap — Bevy's `Handle` is // `Arc`-backed, the clone bumps a refcount. let font_handle_for_floating = font_handle.clone(); // Second clone for the scrub-bar label row and keybind footer // 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 / footer spawns the original is gone. // `font_handle_for_labels` is `.clone()`'d (never moved) inside // the labels closure, so it's still alive for the footer // spawn afterwards — single shared clone covers both. let font_handle_for_labels = font_handle.clone(); // Third clone for the move-log panel — a separate root // entity spawned after the banner closure closes. Mirrors the // floating-chip clone reasoning. let font_handle_for_move_log = font_handle.clone(); // Fourth clone for the mini-tableau preview panel. let font_handle_for_mini_tableau = font_handle.clone(); let banner_label = if state.is_completed() { "\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention. } else { "\u{258C} replay" // ▌ }; let progress_label = format_progress(state); // Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54). // Spawned first so it sits behind the banner (z=55) and move-log (z=55) // in the UI stacking context. World-space sprites (cards, badges) are // always below any UI node, so the dim layer darkens the entire // gameplay scene without needing to touch card_plugin. No Interaction // component — purely visual. commands.spawn(( ReplayTableauDimLayer, DespawnWithReplay, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), top: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, BackgroundColor(Color::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)), ZIndex(Z_REPLAY_DIM), GlobalZIndex(Z_REPLAY_DIM), )); 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, DespawnWithReplay, 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: column with the accent "▌ replay" headline // above and a small `GAME #YYYY-DDD` caption below. // The caption mirrors the mockup's right-anchored // 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, 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, wrapped in a 1 px // ACCENT_PRIMARY-bordered chip so it reads as a // discrete callout rather than free-floating // text. No fill — the Terminal aesthetic gets // depth from borders + tonal layering, not // shadows. The marker stays on the inner Text so // `update_progress_text` keeps working unchanged. row.spawn(( Node { border: UiRect::all(Val::Px(1.0)), padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), ..default() }, BorderColor::all(ACCENT_PRIMARY), )) .with_children(|chip| { chip.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" primary 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| { // Pause / Resume label is set from the current // state so a freshly-spawned overlay (which // currently always starts unpaused) reads // "Pause". `update_pause_button_label` // repaints it whenever the state changes. spawn_modal_button( wrap, ReplayPauseButton, pause_button_label(state), None, ButtonVariant::Tertiary, font_res, ); spawn_modal_button( wrap, ReplayStepButton, "Step", None, ButtonVariant::Tertiary, font_res, ); 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); let win_pct = win_move_marker_pct(state); banner .spawn(( Node { width: Val::Percent(100.0), height: Val::Px(1.0), ..default() }, BackgroundColor(BORDER_SUBTLE), // HC marker: bumps the 1 px track from #505050 // → #a0a0a0 under high-contrast mode. The track // paints via BackgroundColor (it's a 1 px Node, // not a border on a wider container) so the // BorderColor-targeting HighContrastBorder marker // doesn't apply — HighContrastBackground is the // parallel primitive for this case. HighContrastBackground::with_default(BORDER_SUBTLE), )) .with_children(|track| { track.spawn(( ReplayOverlayScrubFill, Node { width: Val::Percent(initial_scrub_pct), height: Val::Percent(100.0), ..default() }, BackgroundColor(ACCENT_PRIMARY), )); // WIN MOVE marker — small green tick anchored at // `win_move_index / total`. Spawned only when the // active replay carries the field; older replays // pre-dating `win_move_index` simply don't get a // marker. Centered vertically on the 1px track via // a 3px-tall node offset 1px above the track top so // 1px sits above and 1px below the track line. if let Some(pct) = win_pct { track.spawn(( ReplayOverlayWinMoveMarker, Node { position_type: PositionType::Absolute, left: Val::Percent(pct), top: Val::Px(-1.0), width: Val::Px(2.0), height: Val::Px(3.0), ..default() }, BackgroundColor(STATE_SUCCESS), // HC bump: lime → brighter lime so the win // marker reads clearly above the bumped // notch ticks (BORDER_SUBTLE_HC gray) under // high-contrast mode. HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC), )); } // 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), // Same HC-paint reasoning as the track // above: 5 px tall × 1 px wide tick mark // paints via BackgroundColor, so // HighContrastBackground (not -Border) is // the right marker. HighContrastBackground::with_default(BORDER_SUBTLE), )); } }); // 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 use the `translateX(-50%)` // pattern for Bevy 0.18 UI: a fixed-width // container is placed at `left: Percent(pct)` // then shifted left by half its own width via // `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`. // `Justify::Center` renders the text centred // within the container so the text's visual // centre coincides with the notch line. let (node, justify) = if i == 0 { ( Node { position_type: PositionType::Absolute, top: Val::Px(2.0), left: Val::Px(0.0), ..default() }, Justify::Left, ) } else if i == labels.len() - 1 { ( Node { position_type: PositionType::Absolute, top: Val::Px(2.0), right: Val::Px(0.0), ..default() }, Justify::Right, ) } else { ( Node { position_type: PositionType::Absolute, top: Val::Px(2.0), left: Val::Percent(*pct), width: Val::Px(SCRUB_LABEL_CENTER_WIDTH), margin: UiRect { left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0), ..default() }, ..default() }, Justify::Center, ) }; row.spawn(( ReplayOverlayScrubNotchLabel, node, Text::new(*label), TextLayout::new_with_justify(justify), TextFont { font: font_handle_for_labels.clone(), font_size: TYPE_CAPTION, ..default() }, // TEXT_SECONDARY keeps the subdued visual // hierarchy (caption, not headline) while // staying readable against BG_ELEVATED_HI. TextColor(TEXT_SECONDARY), )); } }); // Fourth banner row: keybind-hint footer. Vim-style // mode line on the left (`▌ NORMAL │ replay`), keybind // hint on the right (`[SPACE] pause/resume`), 1px top // border in BORDER_SUBTLE separating it from the // labels row above. Surfaces the existing Space // accelerator visually so CLAUDE.md §3.3's UI-first // contract holds for keyboard accelerators too. banner .spawn(( ReplayOverlayKeybindFooter, Node { width: Val::Percent(100.0), height: Val::Px(KEYBIND_FOOTER_HEIGHT), flex_direction: FlexDirection::Row, justify_content: JustifyContent::SpaceBetween, align_items: AlignItems::Center, padding: UiRect::horizontal(VAL_SPACE_4), border: UiRect::top(Val::Px(1.0)), ..default() }, BorderColor::all(BORDER_SUBTLE), // Marker for `apply_high_contrast_borders`: bumps // the 1 px top border from BORDER_SUBTLE (#505050) // to BORDER_SUBTLE_HC (#a0a0a0) when // `Settings::high_contrast_mode` is on. Without // this the footer reads as floating loose under // HC because the border that visually anchors it // to the labels row above is near-invisible. HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|footer| { footer.spawn(( Text::new(keybind_footer_mode_text()), TextFont { font: font_handle_for_labels.clone(), font_size: TYPE_CAPTION, ..default() }, TextColor(TEXT_SECONDARY), )); #[cfg(not(target_os = "android"))] footer.spawn(( Text::new(keybind_footer_hint_text()), TextFont { font: font_handle_for_labels.clone(), font_size: TYPE_CAPTION, ..default() }, TextColor(TEXT_SECONDARY), )); }); }); // Floating progress chip — a 2D world-space `Text2d` rendered // above the destination pile of the most-recently-applied move. // Sibling of (not child of) the banner overlay because it lives // in world-space coordinates, not the UI tree. Spawned hidden; // `update_floating_progress_chip` shows + positions it on the // first frame the cursor advances past 0. Lifecycle matches // the banner overlay — `react_to_state_change` despawns both // when the replay state transitions back to `Inactive`. commands.spawn(( ReplayFloatingProgressChip, DespawnWithReplay, Text2d::new(format_progress(state)), TextFont { font: font_handle_for_floating, font_size: TYPE_BODY, ..default() }, TextColor(TEXT_PRIMARY), // High Z keeps the chip above every card stack // (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular cards // stack to the low double digits at most). Transform::from_xyz(0.0, 0.0, 100.0), Visibility::Hidden, )); // Move-log panel — a separate root UI entity anchored to the // viewport's bottom edge. Carries a `▌ MOVE LOG · N/M` header // plus a row showing the most-recently-applied move. // Sibling-of-banner pattern (not a banner child) because the // panel lives at a different screen anchor and has its own // spawn/despawn lifecycle synced via `react_to_state_change`. 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(( ReplayOverlayMoveLogPanel, DespawnWithReplay, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), bottom: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Px(MOVE_LOG_PANEL_HEIGHT), flex_direction: FlexDirection::Column, align_items: AlignItems::FlexStart, justify_content: JustifyContent::Center, padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2), row_gap: VAL_SPACE_1, border: UiRect::top(Val::Px(1.0)), ..default() }, BackgroundColor(banner_bg), BorderColor::all(BORDER_SUBTLE), // Same z-stack rationale as the banner — above gameplay, // below modals. ZIndex(Z_REPLAY_OVERLAY), GlobalZIndex(Z_REPLAY_OVERLAY), // HC marker so the top border bumps under HC mode. // Without it the panel reads as floating loose because // the border that anchors it to the gameplay area above // is near-invisible at #505050. HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|panel| { // Header row: `▌ MOVE LOG · N/M` in ACCENT_PRIMARY for // the cursor-block prefix consistency with the banner // headline. panel.spawn(( ReplayOverlayMoveLogHeader, Text::new(format_move_log_header(state)), TextFont { font: font_handle_for_move_log.clone(), font_size: TYPE_CAPTION, ..default() }, TextColor(ACCENT_PRIMARY), )); // Prev rows — render above the active row in display // order (oldest first), so the active row sits at the // bottom of the visible window. Spawn from // MOVE_LOG_PREV_ROWS down to 1 (offset 2, then 1) so // the highest-offset (oldest) row is topmost in the // panel's flex column. Each carries // ReplayOverlayMoveLogPrevRow { offset } — the // per-frame system reads `offset` and recomputes the // text on cursor advance. Painted in TEXT_SECONDARY // so the active row stands out from context rows. for offset in (1..=MOVE_LOG_PREV_ROWS as u8).rev() { panel.spawn(( ReplayOverlayMoveLogPrevRow { offset }, Text::new(format_kth_recent_row( state, offset as usize + 1, )), TextFont { font: font_handle_for_move_log.clone(), font_size: TYPE_BODY, ..default() }, TextColor(TEXT_SECONDARY), )); } // Active move row. Wrapped in a Node with an // ACCENT_PRIMARY background so the row reads as // "current focus" — the player can scan vertically // and the highlighted row is the move that just // applied. Empty text at spawn time when cursor=0; // the per-frame update system populates it as the // cursor advances. Text colour is TEXT_PRIMARY_HC // (near-white) for contrast against the brick-red // background — same trick as the modal-button // primary-variant paint. panel .spawn(( Node { width: Val::Percent(100.0), padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), ..default() }, BackgroundColor(ACCENT_PRIMARY), )) .with_children(|active| { active.spawn(( ReplayOverlayMoveLogActiveRow, Text::new(format_active_move_row(state)), TextFont { font: font_handle_for_move_log.clone(), font_size: TYPE_BODY, ..default() }, TextColor(TEXT_PRIMARY_HC), )); }); // Next rows — render below the active row in display // order (offset 1 directly below active, then offset // 2). Same TEXT_SECONDARY de-emphasis as prev rows so // the active row stays the focal point. Empty text // late in the replay (when cursor + offset exceeds // moves.len()) — the panel under-fills gracefully. for offset in 1..=MOVE_LOG_NEXT_ROWS as u8 { panel.spawn(( ReplayOverlayMoveLogNextRow { offset }, Text::new(format_kth_next_row(state, offset as usize)), TextFont { font: font_handle_for_move_log.clone(), font_size: TYPE_BODY, ..default() }, TextColor(TEXT_SECONDARY), )); } }); // Mini-tableau preview panel — right-edge anchor, just below the banner. // Compact two-row readout: foundation tops then stock/waste head. // Sibling-of-banner pattern (separate root entity, own spawn/despawn). 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(( ReplayMiniTableauPanel, DespawnWithReplay, Node { position_type: PositionType::Absolute, right: Val::Px(0.0), top: Val::Px(MINI_TABLEAU_TOP_OFFSET), padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), flex_direction: FlexDirection::Column, align_items: AlignItems::FlexStart, row_gap: VAL_SPACE_1, border: UiRect::left(Val::Px(1.0)), ..default() }, BackgroundColor(banner_bg), BorderColor::all(BORDER_SUBTLE), ZIndex(Z_REPLAY_OVERLAY), GlobalZIndex(Z_REPLAY_OVERLAY), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|panel| { panel.spawn(( Text::new("\u{258C} BOARD"), TextFont { font: font_handle_for_mini_tableau.clone(), font_size: TYPE_CAPTION, ..default() }, TextColor(ACCENT_PRIMARY), )); panel.spawn(( ReplayMiniTableauFoundations, Text::new("F: -- -- -- --"), TextFont { font: font_handle_for_mini_tableau.clone(), font_size: TYPE_CAPTION, ..default() }, TextColor(TEXT_PRIMARY), )); panel.spawn(( ReplayMiniTableauStockWaste, Text::new("STK:-- WST:--"), TextFont { font: font_handle_for_mini_tableau, font_size: TYPE_CAPTION, ..default() }, TextColor(TEXT_SECONDARY), )); }); } /// 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 } } } /// 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 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 vim-style mode indicator text shown on /// the left side of the keybind-hint footer row. `▌ NORMAL │ replay` /// matches the `▌replay.tsx` motif from the splash boot-screen and /// the screen-takeover mockup. The cursor block (`▌`) matches the /// banner-label prefix; "NORMAL" is the vim mode (mockup parity); /// "replay" identifies the surface. fn keybind_footer_mode_text() -> &'static str { "\u{258C} NORMAL \u{2502} replay" // ▌ NORMAL │ replay } /// Pure helper — returns the keybind-hint text shown on the right /// side of the keybind-hint footer row. Lists only the keys that /// are *actually wired* today: the Space accelerator for /// pause/resume, the ESC accelerator for stop, and the ← / → /// accelerators for paused single-move stepping. The footer never /// lists unimplemented keybinds (would lie to users). #[cfg(not(target_os = "android"))] fn keybind_footer_hint_text() -> &'static str { "[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator } /// 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. /// /// `None` is returned in any of these cases: /// - The state isn't `Playing` (no replay attached). /// - The replay's `win_move_index` is `None` (older replay loaded /// from disk pre-dating the field). /// - The replay's move list is empty (shouldn't happen for real wins, /// but guards the divide-by-zero). /// /// The percentage clamps to `[0, 100]` so a malformed /// `win_move_index >= total` (defensive — shouldn't happen) doesn't /// position the marker outside the track. fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option { let ReplayPlaybackState::Playing { replay, .. } = state else { return None; }; let idx = replay.win_move_index?; let total = replay.moves.len(); if total == 0 { return None; } let frac = (idx as f32 / total as f32).clamp(0.0, 1.0); Some(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() { "\u{258C} replay complete" // ▌ } else if state.is_playing() { "\u{258C} 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(); } } /// Repositions the floating progress chip above the destination /// pile of the most-recently-applied move and repaints its text. /// /// The chip is hidden when: /// - the cursor is at 0 (no moves applied yet — chip would have /// nowhere meaningful to land), OR /// - the most-recently-applied move was a `StockClick` (no /// destination pile — stock-click feedback already lives at /// the stock pile and we don't want the chip to jitter back /// to the stock pile every cycle). /// /// When visible, the chip's world-space `Transform.translation` /// is set to the destination pile's centre plus a fixed upward /// offset (`card_size.y * 0.6`) so the chip floats just above /// the top edge of the card. World-space placement (rather than /// UI-space + camera projection) keeps the math trivial and means /// the chip stays correctly positioned through window resizes /// without any extra wiring — `LayoutResource` already drives /// every other piece of pile geometry. fn update_floating_progress_chip( state: Res, layout: Option>, mut chips: Query< (&mut Transform, &mut Visibility, &mut Text2d), With, >, ) { let Some(layout) = layout else { return; }; // Resolve the destination pile of the last-applied move (if // any). `cursor` is the index of the *next* move to apply, so // the most-recently-applied move sits at `cursor - 1`. let dest_pile = match state.as_ref() { ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => { match &replay.moves[cursor - 1] { ReplayMove::Move { to, .. } => Some(to.clone()), ReplayMove::StockClick => None, } } _ => None, }; let Some(world_pos) = dest_pile .as_ref() .and_then(|p| layout.0.pile_positions.get(p).copied()) else { // Nothing to point at — hide every chip and exit. for (_, mut visibility, _) in chips.iter_mut() { *visibility = Visibility::Hidden; } return; }; // Position above the destination pile by ~60 % of a card // height. Half a card lifts above the centre, the extra 10 % // is breathing room above the top edge so the chip doesn't // visually clip the card. let above = Vec2::new(0.0, layout.0.card_size.y * 0.6); let target = (world_pos + above).extend(100.0); let label = format_progress(&state); for (mut transform, mut visibility, mut text2d) in chips.iter_mut() { transform.translation = target; *visibility = Visibility::Inherited; if **text2d != label { **text2d = label.clone(); } } } /// Repaints the move-log panel's `▌ MOVE LOG · N/M` header text /// whenever [`ReplayPlaybackState`] changes. Cheap — early-exits /// when nothing moved so an idle replay leaves the text mesh /// untouched. fn update_move_log_header( state: Res, mut q: Query<&mut Text, With>, ) { if !state.is_changed() { return; } let label = format_move_log_header(&state); for mut text in &mut q { **text = label.clone(); } } /// Repaints the move-log panel's active-row text whenever /// [`ReplayPlaybackState`] changes. Same change-detection guard /// as the header updater. Empty string at `cursor == 0` (no move /// applied yet) and in non-`Playing` states; populated otherwise. fn update_move_log_active_row( state: Res, mut q: Query<&mut Text, With>, ) { if !state.is_changed() { return; } let label = format_active_move_row(&state); for mut text in &mut q { **text = label.clone(); } } /// Repaints every "previous move" row text whenever /// [`ReplayPlaybackState`] changes. Each row's `offset` is read /// from the marker; `k = offset + 1` feeds [`format_kth_recent_row`] /// (active is k=1, prev offset 1 is k=2, prev offset 2 is k=3). /// Rows with `offset >= cursor` paint as empty — the panel /// gracefully under-fills early in a replay without spurious /// "out-of-range" text. fn update_move_log_prev_rows( state: Res, mut q: Query<(&ReplayOverlayMoveLogPrevRow, &mut Text)>, ) { if !state.is_changed() { return; } for (row, mut text) in &mut q { let label = format_kth_recent_row(&state, row.offset as usize + 1); **text = label; } } /// Repaints every "next move" row text whenever /// [`ReplayPlaybackState`] changes. Symmetric to the prev-row /// updater but feeds [`format_kth_next_row`]. Rows where /// `cursor + offset > moves.len()` paint as empty — the panel /// gracefully under-fills late in a replay (e.g. final moves) /// without spurious out-of-range text. fn update_move_log_next_rows( state: Res, mut q: Query<(&ReplayOverlayMoveLogNextRow, &mut Text)>, ) { if !state.is_changed() { return; } for (row, mut text) in &mut q { let label = format_kth_next_row(&state, row.offset as usize); **text = label; } } /// Repaints the bottom-edge accent 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 `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 { 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. /// 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() { // `MOVE N/M` (uppercase + slash) reads as a Terminal output // line and matches the floating-chip motif in the mockup at // `docs/ui-mockups/replay-overlay-mobile.html`. Some((cursor, total)) => format!("MOVE {cursor}/{total}"), None if state.is_completed() => "REPLAY COMPLETE".to_string(), None => String::new(), } } /// Pure helper — formats a [`PileType`] as a short, lowercase, /// 1-indexed display string for the move-log row. `Foundation(2)` /// renders as `"foundation 3"` rather than `"foundation 2"` so /// players see human-friendly numbers; the underlying enum /// remains 0-indexed. /// /// Returns `String` rather than `&'static str` because the /// `Foundation` / `Tableau` variants need formatting; the static /// variants (`Stock`, `Waste`) still allocate but the cost is /// trivial against the per-frame update cadence. fn format_pile(p: &PileType) -> String { match p { PileType::Stock => "stock".to_string(), PileType::Waste => "waste".to_string(), PileType::Foundation(i) => format!("foundation {}", i + 1), PileType::Tableau(i) => format!("tableau {}", i + 1), } } /// Pure helper — formats a [`ReplayMove`] as the body of a /// move-log row. `StockClick` reads as `"stock cycle"`; `Move` /// reads as `"{from} → {to}"` using [`format_pile`] for both /// endpoints. The `count` field is omitted from the row body — /// at row scale it adds visual noise without meaningful /// information for the typical 1-card moves. fn format_move_body(m: &ReplayMove) -> String { match m { ReplayMove::StockClick => "stock cycle".to_string(), ReplayMove::Move { from, to, .. } => { format!("{} \u{2192} {}", format_pile(from), format_pile(to)) } } } /// Pure helper — formats the move-log panel's header text. Reads /// `▌ MOVE LOG · N/M` while playing, where `N` is the count of /// moves applied so far and `M` is the total in the replay. The /// cursor-block prefix (`▌`) matches the splash and replay-banner /// motifs. Empty in `Inactive` (no replay attached); reads /// `▌ MOVE LOG · COMPLETE` in `Completed`. fn format_move_log_header(state: &ReplayPlaybackState) -> String { match state { ReplayPlaybackState::Playing { replay, cursor, .. } => { format!("\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, replay.moves.len()) } ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(), ReplayPlaybackState::Inactive => String::new(), } } /// Pure helper — formats the kth-most-recently-applied move's row /// text. `k = 1` is the active row (`replay.moves[cursor - 1]`, /// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above /// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`), /// and so on. /// /// Returns the empty string in any of these cases: /// - State isn't `Playing` (no replay attached). /// - `k == 0` (no kth-most-recent for k=0; the active is k=1). /// - `k > cursor` (not enough history — e.g. cursor=2 has rows /// for k=1 and k=2 only, k=3 returns empty). /// - The move list is shorter than expected (defensive guard). fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String { let ReplayPlaybackState::Playing { replay, cursor, .. } = state else { return String::new(); }; if k == 0 || k > *cursor { return String::new(); } let zero_idx = *cursor - k; let Some(m) = replay.moves.get(zero_idx) else { return String::new(); }; let display_idx = *cursor - k + 1; format!("{} \u{2502} {}", display_idx, format_move_body(m)) } /// Pure helper — formats the kth-NEXT move's row text. `k = 1` /// is the move that will apply next (`replay.moves[cursor]`, /// displayed as `cursor + 1`); `k = 2` is the move after that, /// and so on. /// /// Returns the empty string in any of these cases: /// - State isn't `Playing` (no replay attached). /// - `k == 0` (degenerate; the active is k=1 of *recent*, not /// *next*). /// - `cursor + k - 1 >= moves.len()` (not enough remaining /// replay — late in the move list, the trailing next rows /// stay empty). fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String { let ReplayPlaybackState::Playing { replay, cursor, .. } = state else { return String::new(); }; if k == 0 { return String::new(); } let zero_idx = *cursor + k - 1; let Some(m) = replay.moves.get(zero_idx) else { return String::new(); }; let display_idx = *cursor + k; format!("{} \u{2502} {}", display_idx, format_move_body(m)) } /// Pure helper — formats the active-row text for the move-log /// panel. Wraps [`format_kth_recent_row`] with `k=1` and prepends /// a `▶` focus marker so the active row reads visually distinct /// from prev rows even before the highlight background lands. /// Returns empty when there's no row to render (cursor=0 or /// non-`Playing` state) — never `"▶ "` alone, which would paint /// a stray prefix. fn format_active_move_row(state: &ReplayPlaybackState) -> String { let body = format_kth_recent_row(state, 1); if body.is_empty() { return String::new(); } format!("\u{25B6} {body}") // ▶ } // --------------------------------------------------------------------------- // Mini-tableau format helpers and update system // --------------------------------------------------------------------------- /// Pure helper — short rank symbol. Single character for all ranks /// except Ten which uses "T" (keeps every card a consistent 2-char /// wide render: rank-char + suit-glyph). Players familiar with /// solitaire shorthand read "T" instantly; the suit glyph immediately /// follows and disambiguates from an ambiguous "T". fn format_rank_short(rank: Rank) -> &'static str { match rank { Rank::Ace => "A", Rank::Two => "2", Rank::Three => "3", Rank::Four => "4", Rank::Five => "5", Rank::Six => "6", Rank::Seven => "7", Rank::Eight => "8", Rank::Nine => "9", Rank::Ten => "T", Rank::Jack => "J", Rank::Queen => "Q", Rank::King => "K", } } /// Pure helper — Unicode suit glyph from FiraMono's covered range /// (U+2660–U+2666). These four code points are confirmed present in /// the bundled FiraMono on Android (verified on Pixel 7 / API 34). fn format_suit_glyph(suit: Suit) -> &'static str { match suit { Suit::Spades => "\u{2660}", // ♠ Suit::Hearts => "\u{2665}", // ♥ Suit::Diamonds => "\u{2666}", // ♦ Suit::Clubs => "\u{2663}", // ♣ } } /// Pure helper — compact 2-char card label (`rank + suit glyph`) for a /// known card, or `"--"` for an absent top card (empty pile). fn format_card_short(card: Option<&Card>) -> String { match card { Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)), None => "--".to_string(), } } /// Pure helper — one-line summary of the four foundation tops. /// Renders as `F: A♠ 7♥ 5♦ K♣` with `--` for any empty slot. /// Foundation slots are displayed in their natural 0-3 order /// (matching the visual left-to-right order on screen). fn format_foundations_row(game: &GameState) -> String { let slots: [String; 4] = std::array::from_fn(|i| { let top = game.piles .get(&PileType::Foundation(i as u8)) .and_then(|p| p.cards.last()); format_card_short(top) }); format!("F: {} {} {} {}", slots[0], slots[1], slots[2], slots[3]) } /// Pure helper — one-line stock / waste summary. /// Renders as `STK:N WST:X♠` where N is the stock card count and /// X♠ is the top waste card (or `--` when the waste pile is empty). fn format_stock_waste_row(game: &GameState) -> String { let stock_count = game.piles .get(&PileType::Stock) .map(|p| p.cards.len()) .unwrap_or(0); let waste_top = game.piles .get(&PileType::Waste) .and_then(|p| p.cards.last()); format!("STK:{} WST:{}", stock_count, format_card_short(waste_top)) } /// Repaints the foundations row whenever [`GameStateResource`] changes. /// Split into its own system (rather than combined with the stock/waste /// updater) to avoid a Bevy B0001 query conflict: two `&mut Text` /// queries in one system are always ambiguous regardless of marker /// filters. Each updater owns exactly one `Query<&mut Text, With<…>>`. fn update_mini_tableau_foundations( game: Option>, mut q: Query<&mut Text, With>, ) { let Some(game) = game else { return }; if !game.is_changed() { return; } let text = format_foundations_row(&game.0); for mut t in &mut q { **t = text.clone(); } } /// Repaints the stock/waste row whenever [`GameStateResource`] changes. /// Sibling of [`update_mini_tableau_foundations`] — same change-detection /// guard, separate system to avoid the B0001 query conflict. fn update_mini_tableau_stock_waste( game: Option>, mut q: Query<&mut Text, With>, ) { let Some(game) = game else { return }; if !game.is_changed() { return; } let text = format_stock_waste_row(&game.0); for mut t in &mut q { **t = text.clone(); } } // --------------------------------------------------------------------------- // Playback-control button handlers // --------------------------------------------------------------------------- /// Pure helper — returns the label the Pause / Resume button should /// carry for the given state. "Pause" while running, "Resume" while /// paused, empty otherwise (the button is despawned with the rest of /// the overlay tree on transitions to `Inactive` / `Completed`, so /// the empty branch only fires for one frame around state changes). fn pause_button_label(state: &ReplayPlaybackState) -> &'static str { match state { ReplayPlaybackState::Playing { paused: true, .. } => "Resume", ReplayPlaybackState::Playing { paused: false, .. } => "Pause", ReplayPlaybackState::Inactive | ReplayPlaybackState::Completed => "", } } /// 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); } /// Watches the Pause / Resume button for `Interaction::Pressed` /// transitions. On a click, toggles the `paused` flag via /// [`toggle_pause_replay_playback`]. The label repaint happens in /// [`update_pause_button_label`] on the same frame the state mutation /// flushes. fn handle_pause_button( mut state: ResMut, buttons: Query<&Interaction, (With, Changed)>, ) { if !buttons.iter().any(|i| *i == Interaction::Pressed) { return; } toggle_pause_replay_playback(&mut state); } /// Watches the Step button for `Interaction::Pressed` transitions. On /// a click, advances exactly one move via [`step_replay_playback`]. /// No-op while playback is unpaused (would race the tick loop) — the /// guard lives inside `step_replay_playback`. fn handle_step_button( mut state: ResMut, mut moves_writer: MessageWriter, mut draws_writer: MessageWriter, buttons: Query<&Interaction, (With, Changed)>, ) { if !buttons.iter().any(|i| *i == Interaction::Pressed) { return; } step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer); } /// Repaints the Pause / Resume button's label whenever /// [`ReplayPlaybackState`] changes. Walks from the marked button /// entity to its single child [`Text`] so the spawn path doesn't need /// a second marker on the inner node. fn update_pause_button_label( state: Res, buttons: Query<&Children, With>, mut texts: Query<&mut Text>, ) { if !state.is_changed() { return; } let label = pause_button_label(&state); if label.is_empty() { // Overlay is mid-teardown; the button entity will despawn // this frame anyway. Skip the repaint to avoid touching a // doomed entity. return; } for children in &buttons { for child in children.iter() { if let Ok(mut text) = texts.get_mut(child) { text.0 = label.to_string(); break; } } } } /// Watches `Space` for the keyboard pause / resume accelerator. /// UI-first contract from CLAUDE.md §3.3 is satisfied by the on- /// screen Pause / Resume button; this is the optional accelerator. /// No-op when the playback isn't `Playing` (e.g. while a modal is /// open and the player is using `Space` for something else). fn handle_pause_keyboard( keys: Option>>, mut state: ResMut, ) { let Some(keys) = keys else { return }; if !keys.just_pressed(KeyCode::Space) { return; } toggle_pause_replay_playback(&mut state); } /// Watches the arrow keys for the paused step / scrub /// accelerators. UI-first contract from CLAUDE.md §3.3 is /// satisfied by the on-screen Step button (forward only); these /// are the optional accelerators that also surface a backwards /// step plus continuous scrub. /// /// Both keys are paused-only — the underlying step helpers /// hard-gate via destructure on `paused: true`. Pressing → during /// running playback or ← at cursor 0 are silent no-ops; the /// player learns "pause first, then arrow." /// /// **Single press fires once immediately** /// (`just_pressed`). **Holding** the key triggers continuous /// scrub at [`SCRUB_REPEAT_INTERVAL_SECS`] cadence (10 steps/sec /// at 100 ms): the per-key accumulator on /// [`ReplayScrubKeyHold`] absorbs `time.delta_secs()` each frame /// the key is held, fires + resets when the threshold is hit, and /// resets to 0 on key release so the next fresh press fires /// immediately. This matches the mockup's `[← →] scrub` /// terminology while keeping single-press = single-step semantics. fn handle_arrow_keyboard( keys: Option>>, time: Res