From ccfb9394e0f780005d2808de3d8b151080dc86ff Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 9 Jul 2026 11:48:33 -0700 Subject: [PATCH] chore: remove dead code and stale doc claims found in multi-agent sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compiler-verified via RUSTFLAGS=--force-warn dead_code plus workspace-wide reference greps; three parallel audit agents covered the engine crate, the other eight crates, and Copilot commit-message-vs-diff drift. Removed: - replay_overlay/input.rs: 19 orphaned twins (~950 lines) of items also defined in mod.rs — the glob re-export made the mod.rs copies win and the file-level #![allow(dead_code)] hid the corpses. The live keyboard/ button handlers and ReplayScrubKeyHold stay; the allow is retired. - retarget_animation (never called; doc examples were its only refs) - ScanThemesRequestEvent (never registered/written/read; its doc claimed a handle_scan_themes consumer that does not exist) - _VEC3_REFERENCED workaround const + now-unneeded Vec3 import - solitaire_data: load_stats/save_stats/time_attack_session_with_now default-path wrappers (the _from/_to variants are the live API) and surplus re-export names (settings MIN/MAX bounds, token loaders) - solitaire_core: Session re-export (no external consumer) - solitaire_wasm: ReplayPlayer::is_finished (no JS caller) - solitaire_app: build_app wrapper (real entry is run()) Doc fixes: - audio_plugin: WAV count 5→7, add FoundationCompletedEvent table row, drop bogus 'placeholder' label, bevy_kira_audio→kira - ToastVariant::Warning: variant is live (5 writer plugins); dropped the stale allow(dead_code) and its 'currently unused' comment Deliberately kept: Spider module (staged forward work), WinCascadePlugin (documented alternative cascade, pending owner decision), SyncCompleteEvent and solitaire_sync ApiError/merge_at (§8 change-controlled, flagged to owner). Co-Authored-By: Claude Fable 5 --- solitaire_app/src/lib.rs | 5 - solitaire_core/src/lib.rs | 2 +- solitaire_data/src/lib.rs | 19 +- solitaire_data/src/storage.rs | 28 - solitaire_engine/src/animation_plugin.rs | 6 +- solitaire_engine/src/audio_plugin.rs | 9 +- .../src/card_animation/animation.rs | 91 -- solitaire_engine/src/card_animation/mod.rs | 24 +- solitaire_engine/src/events.rs | 9 - solitaire_engine/src/input_plugin/mod.rs | 7 +- solitaire_engine/src/lib.rs | 4 +- solitaire_engine/src/replay_overlay/input.rs | 956 ------------------ solitaire_wasm/src/lib.rs | 5 - 13 files changed, 20 insertions(+), 1145 deletions(-) diff --git a/solitaire_app/src/lib.rs b/solitaire_app/src/lib.rs index 3d84350..10b9a66 100644 --- a/solitaire_app/src/lib.rs +++ b/solitaire_app/src/lib.rs @@ -37,11 +37,6 @@ fn load_settings() -> Settings { .unwrap_or_default() } -/// Build the Bevy app without entering the event loop. -pub fn build_app(sync_provider: Box) -> App { - build_app_with_settings(load_settings(), sync_provider) -} - /// App entry point — configures runtime services, builds, and runs the app. /// /// Called from both the desktop `bin` target's `main` shim and (on diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index ec35564..8000091 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -13,7 +13,7 @@ pub mod spider; // re-exported — they are only used internally (in `klondike_adapter.rs` and // when decoding instructions to piles in `instruction_to_piles`) and do not // appear in any public method signature. -pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit}; +pub use card_game::{Card, Deck, Rank, SolveError, Suit}; pub use klondike::{ DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau, }; diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index b5f1b94..9dcddc6 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -105,10 +105,9 @@ pub use stats::{StatsExt, StatsSnapshot}; pub mod storage; pub use storage::{ TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at, - delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats, - load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at, - save_game_state_to, save_stats, save_stats_to, save_time_attack_session_to, stats_file_path, - time_attack_session_path, time_attack_session_with_now, + delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats_from, + load_time_attack_session_from, save_game_state_to, save_stats_to, save_time_attack_session_to, + stats_file_path, time_attack_session_path, }; pub mod achievements; @@ -136,11 +135,9 @@ pub use difficulty_seeds::{DifficultySeeds, seeds_for}; pub mod settings; pub use settings::{ - AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS, - REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend, - TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, - TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry, - load_settings_from, save_settings_to, settings_file_path, + AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend, + TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry, load_settings_from, + save_settings_to, settings_file_path, }; #[cfg(target_os = "android")] @@ -152,9 +149,7 @@ mod android_keystore; #[cfg(not(target_arch = "wasm32"))] pub mod auth_tokens; #[cfg(not(target_arch = "wasm32"))] -pub use auth_tokens::{ - TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens, -}; +pub use auth_tokens::{TokenError, delete_tokens, store_tokens}; pub mod sync_client; pub use sync_client::LocalOnlyProvider; diff --git a/solitaire_data/src/storage.rs b/solitaire_data/src/storage.rs index 4562cc6..294ab3a 100644 --- a/solitaire_data/src/storage.rs +++ b/solitaire_data/src/storage.rs @@ -46,22 +46,6 @@ pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> { Ok(()) } -/// Load stats from the platform default path. Returns default if the path -/// is unavailable or the file is missing/corrupt. -pub fn load_stats() -> StatsSnapshot { - stats_file_path() - .map(|p| load_stats_from(&p)) - .unwrap_or_default() -} - -/// Save stats to the platform default path. Returns an error if the platform -/// data dir is unavailable or the write fails. -pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> { - let path = stats_file_path() - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?; - save_stats_to(&path, stats) -} - // --------------------------------------------------------------------------- // In-progress game state // --------------------------------------------------------------------------- @@ -245,18 +229,6 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> { } } -/// Convenience helper for callers that want to stamp a session with the -/// current wall-clock time. Equivalent to constructing the struct -/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`. -pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession { - let now = Utc::now().timestamp().max(0) as u64; - TimeAttackSession { - remaining_secs, - wins, - saved_at_unix_secs: now, - } -} - /// Inner helper: delete `*.tmp` entries inside `dir`. /// /// Per-file errors (already deleted, permission denied) are silently ignored. diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index 44e7323..d57e131 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -607,9 +607,9 @@ pub enum ToastVariant { /// Neutral system message — teal border. Default for `InfoToastEvent`, /// settings volume notifications, and the auto-complete announcement. Info, - /// Caution / penalty — gold border. Currently unused by an in-engine - /// event; kept so future warning-flavoured toasts have a slot. - #[allow(dead_code)] + /// Caution / penalty — gold border. Used by [`handle_warning_toast`] + /// for `WarningToastEvent` messages (daily-challenge expiry, sync, + /// theme-store, and leaderboard warnings). Warning, /// Failure / rejected action — pink border. Used by /// [`handle_move_rejected_toast`] for illegal-placement diff --git a/solitaire_engine/src/audio_plugin.rs b/solitaire_engine/src/audio_plugin.rs index ff8a18c..7de2d65 100644 --- a/solitaire_engine/src/audio_plugin.rs +++ b/solitaire_engine/src/audio_plugin.rs @@ -1,7 +1,7 @@ //! Sound-effect playback via `kira`. //! -//! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in -//! response to gameplay events: +//! Loads seven embedded WAVs (`include_bytes!`) at startup — six SFX plus +//! the ambient loop — and plays them in response to gameplay events: //! //! | Event | Sound | //! |---|---| @@ -10,6 +10,7 @@ //! | `MoveRejectedEvent` | `card_invalid.wav` | //! | `NewGameRequestEvent` | `card_deal.wav` | //! | `GameWonEvent` | `win_fanfare.wav` | +//! | `FoundationCompletedEvent` | `foundation_complete.wav` | //! //! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very //! low volume (0.05 amplitude) routed through `music_track`. @@ -38,7 +39,7 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; /// Volume amplitude for the stock-recycle draw sound (half of normal 1.0). const RECYCLE_VOLUME: f64 = 0.5; -/// Volume amplitude for the ambient music loop placeholder. +/// Volume amplitude for the ambient music loop. const AMBIENT_VOLUME: f64 = 0.05; /// Converts a linear amplitude (0.0–1.0+) to the `Decibels` type used by @@ -101,7 +102,7 @@ pub struct MuteState { pub music_muted: bool, } -/// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`. +/// Plays sound effects and background music via `kira`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`. pub struct AudioPlugin; impl Plugin for AudioPlugin { diff --git a/solitaire_engine/src/card_animation/animation.rs b/solitaire_engine/src/card_animation/animation.rs index 806a226..34fb46c 100644 --- a/solitaire_engine/src/card_animation/animation.rs +++ b/solitaire_engine/src/card_animation/animation.rs @@ -18,11 +18,6 @@ //! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the //! card "floats up" in the middle of its travel and lands at its correct rest z. //! -//! # Retargeting -//! -//! When a card is redirected mid-flight, call [`retarget_animation`]. It reads -//! the current interpolated position so the card never snaps. -//! //! # Coexistence with `CardAnim` //! //! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but @@ -123,8 +118,6 @@ impl CardAnimation { } /// Returns the current interpolated XY position without advancing time. - /// - /// Used by [`retarget_animation`] to read mid-flight position cleanly. pub fn current_xy(&self) -> Vec2 { if self.duration <= 0.0 { return self.end; @@ -135,90 +128,6 @@ impl CardAnimation { } } -// --------------------------------------------------------------------------- -// Retarget helper -// --------------------------------------------------------------------------- - -/// Redirects a card to a new destination without snapping or interrupting motion. -/// -/// Reads the card's current interpolated position (from a live [`CardAnimation`] -/// if present, or from `Transform` if stationary) and starts a fresh -/// [`CardAnimation`] from that position. Duration is recalculated from the -/// remaining distance so short paths stay quick. -/// -/// # Velocity continuity -/// -/// When a card is mid-flight, the new animation starts with a small positive -/// `elapsed` offset (`carry`) derived from how far through the current animation -/// the card is. This preserves a sense of forward momentum: the new curve does -/// not restart from zero velocity, avoiding a visible "lurch" when the target -/// changes rapidly. -/// -/// The carry is deliberately small (≤ 10 % of the new duration) so that it -/// never causes a visible position jump — the card's start position is still -/// read from the current transform. -/// -/// # Example -/// -/// ```ignore -/// // Inside a system that decides to move a card to a new target: -/// let (entity, transform, anim) = cards.get(card_entity)?; -/// retarget_animation( -/// &mut commands, -/// entity, -/// anim, // Option<&CardAnimation> -/// transform, -/// Vec2::new(400.0, 200.0), -/// resting_z, -/// MotionCurve::SmoothSnap, -/// ); -/// ``` -pub fn retarget_animation( - commands: &mut Commands, - entity: Entity, - current_anim: Option<&CardAnimation>, - transform: &Transform, - new_end: Vec2, - new_end_z: f32, - curve: MotionCurve, -) { - let (current_xy, current_z, momentum_carry) = match current_anim { - Some(anim) if anim.duration > 0.0 => { - // Estimate how far into the current animation we are and carry - // a small fraction of that progress into the new animation. - // This avoids restarting from zero velocity and makes the motion - // feel continuous when the target changes mid-flight. - let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0); - // Cap at 10 % of the new animation so there's no visible jump. - let carry = (t * 0.12).min(0.10); - (anim.current_xy(), transform.translation.z, carry) - } - _ => ( - transform.translation.truncate(), - transform.translation.z, - 0.0, - ), - }; - - let distance = current_xy.distance(new_end); - let duration = compute_duration(distance); - - commands.entity(entity).insert(CardAnimation { - start: current_xy, - end: new_end, - // Start slightly into the new animation to carry forward momentum. - elapsed: momentum_carry * duration, - duration, - curve, - delay: 0.0, - start_z: current_z, - end_z: new_end_z, - z_lift: 8.0, - scale_start: 1.0, - scale_end: 1.0, - }); -} - // --------------------------------------------------------------------------- // System // --------------------------------------------------------------------------- diff --git a/solitaire_engine/src/card_animation/mod.rs b/solitaire_engine/src/card_animation/mod.rs index 83d788e..e740c96 100644 --- a/solitaire_engine/src/card_animation/mod.rs +++ b/solitaire_engine/src/card_animation/mod.rs @@ -31,28 +31,6 @@ //! )); //! ``` //! -//! Retarget a card mid-flight: -//! -//! ```ignore -//! use solitaire_engine::card_animation::retarget_animation; -//! -//! fn handle_drop( -//! mut commands: Commands, -//! q: Query<(Entity, &Transform, Option<&CardAnimation>), With>, -//! ) { -//! let (entity, transform, anim) = q.get(card_entity).unwrap(); -//! retarget_animation( -//! &mut commands, -//! entity, -//! anim, -//! transform, -//! new_target_xy, -//! new_target_z, -//! MotionCurve::SmoothSnap, -//! ); -//! } -//! ``` -//! //! # Win cascade with `Expressive` curve //! //! The existing `AnimationPlugin` drives the win cascade with `CardAnim` @@ -80,7 +58,7 @@ pub mod interaction; pub mod timing; pub mod tuning; -pub use animation::{CardAnimation, retarget_animation, win_scatter_targets}; +pub use animation::{CardAnimation, win_scatter_targets}; pub use chain::AnimationChain; pub use curves::{MotionCurve, sample_curve}; pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE}; diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 9a8c1f3..c908fc7 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -300,15 +300,6 @@ pub struct ForfeitEvent; #[derive(Message, Debug, Clone, Copy, Default)] pub struct ForfeitRequestEvent; -/// Fired when the player clicks "Scan for new themes" in Settings. -/// -/// Consumed by `handle_scan_themes` in `SettingsPlugin`, which scans -/// `user_theme_dir()` for `.zip` files, calls `import_theme()` on each -/// unrecognised archive, refreshes [`crate::theme::ThemeRegistry`], and -/// fires [`InfoToastEvent`] messages to report results. -#[derive(Message, Debug, Clone, Copy, Default)] -pub struct ScanThemesRequestEvent; - /// Fired when the player requests a hint (H key). Carries the source card ID /// and destination pile for visual highlighting. /// diff --git a/solitaire_engine/src/input_plugin/mod.rs b/solitaire_engine/src/input_plugin/mod.rs index d44b3ba..0577bf3 100644 --- a/solitaire_engine/src/input_plugin/mod.rs +++ b/solitaire_engine/src/input_plugin/mod.rs @@ -22,7 +22,7 @@ use std::collections::HashMap; use bevy::ecs::system::SystemParam; use bevy::input::ButtonInput; use bevy::input::touch::{TouchInput, TouchPhase, Touches}; -use bevy::math::{Vec2, Vec3}; +use bevy::math::Vec2; use bevy::prelude::*; use bevy::window::PrimaryWindow; #[cfg(not(target_os = "android"))] @@ -1806,10 +1806,5 @@ pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> { all_hints(game).into_iter().next() } -// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced -// when the compiler can't see it used. -#[allow(dead_code)] -const _VEC3_REFERENCED: Option = None; - #[cfg(test)] mod tests; diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index bb30176..56b20c4 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -84,8 +84,8 @@ pub use card_animation::{ AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin, DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer, InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS, - WinCascadePlugin, cascade_delay, compute_duration, micro_vary, retarget_animation, - sample_curve, win_scatter_targets, + WinCascadePlugin, cascade_delay, compute_duration, micro_vary, sample_curve, + win_scatter_targets, }; pub use card_plugin::{ CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer, diff --git a/solitaire_engine/src/replay_overlay/input.rs b/solitaire_engine/src/replay_overlay/input.rs index 46ea0d1..f0d9287 100644 --- a/solitaire_engine/src/replay_overlay/input.rs +++ b/solitaire_engine/src/replay_overlay/input.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use bevy::prelude::*; use super::*; @@ -27,960 +25,6 @@ pub(crate) struct ReplayScrubKeyHold { pub(crate) 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_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. -pub(crate) 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. -pub(crate) 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), - )); - 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 %. -pub(crate) 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. -pub(crate) 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. -pub(crate) 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. -pub(crate) 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). -pub(crate) 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. -pub(crate) 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) -} - // --------------------------------------------------------------------------- // Playback-control button handlers // --------------------------------------------------------------------------- diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs index cb3efe3..311a75a 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -228,11 +228,6 @@ impl ReplayPlayer { pub fn step_idx(&self) -> usize { self.step_idx } - - /// Returns `true` once every move has been applied. - pub fn is_finished(&self) -> bool { - self.step_idx >= self.moves.len() - } } // ---------------------------------------------------------------------------