4cb4212829
Test / test (pull_request) Successful in 36m34s
Replays previously persisted only seed + moves and re-dealt the board
from the seed at playback time, so any change to the seed->deal mapping
(RNG bumps, upstream upgrades) silently invalidated every existing
replay. Schema v4 instead embeds a SessionRecording - the upstream
card_game Session serde ({config, initial_state, instructions}) - so
playback rebuilds the exact recorded board; seed/draw_mode/mode remain
caption metadata only.
- core: SessionRecording newtype delegating to Session<Klondike> serde;
GameState::recording() / from_recording(); from_instructions_unchecked
fixture helper; serde_json added to dev-deps (tests only)
- data: Replay v4 (recording replaces moves); v1-v3 files rejected by
the existing version gate
- engine: win-recording and sync upload freeze game.recording();
playback rebuilds from the recording; Playing carries the extracted
move list (+ Box<Replay> for clippy large_enum_variant)
- wasm: replay_export() builds the full v4 upload payload so JS never
hand-assembles it (the old game.js path hardcoded schema_version: 2
and corrupted u64 seeds via Math.round); ReplayPlayer::from_json
enforces schema_version == 4 with a descriptive error
- web: game.js/play.html use replay_export; replay.js surfaces player
construction errors in the caption instead of dying silently
- server: mode validation accepts data-carrying GameMode variants
(Difficulty uploads previously 400'd against the String field)
Both replays on prod are May-era v1 rows with empty move lists - every
shared replay was already unplayable; the viewer now says why.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1272 lines
58 KiB
Rust
1272 lines
58 KiB
Rust
//! 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
|
||
|
||
mod format;
|
||
mod input;
|
||
#[cfg(test)]
|
||
mod tests;
|
||
mod update;
|
||
|
||
pub(crate) use self::format::*;
|
||
pub(crate) use self::input::*;
|
||
pub(crate) use self::update::*;
|
||
|
||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||
use crate::font_plugin::FontResource;
|
||
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
|
||
use crate::replay_playback::ReplayPlaybackState;
|
||
use crate::ui_modal::{ButtonVariant, spawn_modal_button};
|
||
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,
|
||
};
|
||
use bevy::prelude::*;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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;
|
||
|
||
/// 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
|
||
/// (`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<Entity, With<DespawnWithReplay>>`
|
||
/// 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::<ReplayScrubKeyHold>()
|
||
.add_message::<MoveRequestEvent>()
|
||
.add_message::<DrawRequestEvent>()
|
||
.add_message::<UndoRequestEvent>()
|
||
.add_message::<StateChangedEvent>()
|
||
.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<ReplayPlaybackState>,
|
||
roots: Query<Entity, With<ReplayOverlayRoot>>,
|
||
despawnable: Query<Entity, With<DespawnWithReplay>>,
|
||
font_res: Option<Res<FontResource>>,
|
||
) {
|
||
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<Font>` 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),
|
||
));
|
||
if SHOW_KEYBOARD_ACCELERATORS {
|
||
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).
|
||
fn keybind_footer_hint_text() -> &'static str {
|
||
if SHOW_KEYBOARD_ACCELERATORS {
|
||
"[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
|
||
} else {
|
||
""
|
||
}
|
||
}
|
||
|
||
/// 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<f32> {
|
||
let ReplayPlaybackState::Playing { replay, moves, .. } = state else {
|
||
return None;
|
||
};
|
||
let idx = replay.win_move_index?;
|
||
let total = moves.len();
|
||
if total == 0 {
|
||
return None;
|
||
}
|
||
let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
|
||
Some(frac * 100.0)
|
||
}
|