From 2fb2d638bf2f103d7e2eb9bcbb0af1fb258f6d7f Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 8 May 2026 13:29:38 -0700 Subject: [PATCH] feat(replay): floating MOVE chip above the focused card during playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resume-prompt Option B (smaller scope variant) — closes the "floating MOVE chip" piece flagged as future scope in v0.21.1's replay-overlay punch list. Leaves the multi-session screen- takeover redesign for a future B-2. The existing banner-anchored MOVE chip stays put — it provides the at-a-glance overview. The new floating chip mirrors the same text but renders above the destination pile of the most-recently- applied move, keeping progress at the player's focal point so they don't have to look up at the banner during fast-paced playback. ### Architecture - New `ReplayFloatingProgressChip` marker component on a `Text2d` entity rendered in 2D world space. World-space placement (rather than UI-space + camera projection) keeps the math trivial — the chip uses the same `LayoutResource` pile coordinates that drive every other piece of pile geometry, so it stays correctly positioned through window resizes without any extra wiring. - Lifecycle matches the banner overlay: `spawn_overlay` spawns the chip alongside the banner when a replay starts; `react_to_state_change` despawns it when the replay ends. The chip lives outside the UI tree (because it's world-space) so the despawn needs its own query — added a second `Query>` parameter. - Z = 100 keeps the chip above every card stack (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular tableau cards stack to the low double digits at most). ### Position + visibility logic `update_floating_progress_chip` runs each Update tick: - Resolves the destination pile of the last-applied move (`replay.moves[cursor - 1]`'s `to`). - Hides the chip when `cursor == 0` (no moves applied yet — nowhere meaningful to land) or when the last move was a `StockClick` (no destination pile, and stock-click feedback already lives at the stock pile — letting the chip jitter back to the stock every cycle would be visual noise). - Otherwise positions the chip at `pile_position + (0, card_size.y * 0.6)` — half a card lifts above the pile centre, the extra 10 % is breathing room above the card's top edge so the chip doesn't visually clip. - Updates the chip text via `format_progress(&state)` — shares the same MOVE N/M format with the banner chip. ### Test New `floating_chip_spawns_and_despawns_with_overlay` pins the lifecycle: chip absent on Inactive, exactly one chip on Playing, absent again on return to Inactive. Position correctness needs `LayoutResource` (which the headless fixture doesn't set up); covered via running-game verification rather than a unit test — the system's gate logic is small enough that pixel positioning isn't load-bearing on a test. 1194 passing (+1 from prior 1193). Workspace clippy clean. Co-Authored-By: Claude Opus 4.7 --- solitaire_engine/src/replay_overlay.rs | 177 +++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/solitaire_engine/src/replay_overlay.rs b/solitaire_engine/src/replay_overlay.rs index 4c63ea9..d3232d3 100644 --- a/solitaire_engine/src/replay_overlay.rs +++ b/solitaire_engine/src/replay_overlay.rs @@ -27,7 +27,9 @@ use bevy::prelude::*; use chrono::Datelike; use crate::font_plugin::FontResource; +use crate::layout::LayoutResource; use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState}; +use solitaire_data::ReplayMove; use crate::ui_modal::{spawn_modal_button, ButtonVariant}; use crate::ui_theme::{ ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, @@ -88,6 +90,21 @@ pub struct ReplayOverlayBannerText; #[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. @@ -149,6 +166,7 @@ impl Plugin for ReplayOverlayPlugin { react_to_state_change, update_banner_label, update_progress_text, + update_floating_progress_chip, update_scrub_fill, handle_stop_button, ) @@ -170,6 +188,7 @@ fn react_to_state_change( mut commands: Commands, state: Res, existing: Query>, + floating_chips: Query>, font_res: Option>, ) { if !state.is_changed() { @@ -185,6 +204,13 @@ fn react_to_state_change( for entity in &existing { commands.entity(entity).despawn(); } + // Floating chip lives outside the UI tree (world-space + // entity), so the banner-root despawn doesn't reach it. + // Despawn separately on the same state transition so both + // disappear together when the replay ends. + for entity in &floating_chips { + 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 @@ -200,6 +226,11 @@ fn spawn_overlay( 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(); let banner_label = if state.is_completed() { "\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention. @@ -365,6 +396,30 @@ fn spawn_overlay( )); }); }); + + // 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, + 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, + )); } /// Pure helper — returns the scrub-fill width as a percentage of the @@ -425,6 +480,78 @@ fn update_progress_text( } } +/// Repositions the floating progress chip above the destination +/// pile of the most-recently-applied move and repaints its text. +/// +/// The chip is hidden when: +/// - the cursor is at 0 (no moves applied yet — chip would have +/// nowhere meaningful to land), OR +/// - the most-recently-applied move was a `StockClick` (no +/// destination pile — stock-click feedback already lives at +/// the stock pile and we don't want the chip to jitter back +/// to the stock pile every cycle). +/// +/// When visible, the chip's world-space `Transform.translation` +/// is set to the destination pile's centre plus a fixed upward +/// offset (`card_size.y * 0.6`) so the chip floats just above +/// the top edge of the card. World-space placement (rather than +/// UI-space + camera projection) keeps the math trivial and means +/// the chip stays correctly positioned through window resizes +/// without any extra wiring — `LayoutResource` already drives +/// every other piece of pile geometry. +fn update_floating_progress_chip( + state: Res, + layout: Option>, + mut chips: Query< + (&mut Transform, &mut Visibility, &mut Text2d), + With, + >, +) { + let Some(layout) = layout else { + return; + }; + + // Resolve the destination pile of the last-applied move (if + // any). `cursor` is the index of the *next* move to apply, so + // the most-recently-applied move sits at `cursor - 1`. + let dest_pile = match state.as_ref() { + ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => { + match &replay.moves[cursor - 1] { + ReplayMove::Move { to, .. } => Some(to.clone()), + ReplayMove::StockClick => None, + } + } + _ => None, + }; + + let Some(world_pos) = dest_pile + .as_ref() + .and_then(|p| layout.0.pile_positions.get(p).copied()) + else { + // Nothing to point at — hide every chip and exit. + for (_, mut visibility, _) in chips.iter_mut() { + *visibility = Visibility::Hidden; + } + return; + }; + + // Position above the destination pile by ~60 % of a card + // height. Half a card lifts above the centre, the extra 10 % + // is breathing room above the top edge so the chip doesn't + // visually clip the card. + let above = Vec2::new(0.0, layout.0.card_size.y * 0.6); + let target = (world_pos + above).extend(100.0); + let label = format_progress(&state); + + for (mut transform, mut visibility, mut text2d) in chips.iter_mut() { + transform.translation = target; + *visibility = Visibility::Inherited; + if **text2d != label { + **text2d = label.clone(); + } + } +} + /// Repaints the bottom-edge accent scrub fill to mirror cursor progress. /// Same change-detection guard as the text updaters — the overlay /// already early-exits when nothing moved, so an idle replay leaves the @@ -668,6 +795,56 @@ mod tests { ); } + /// Lifecycle: the floating progress chip spawns alongside the + /// banner overlay when playback starts, and despawns when + /// playback ends. (Position correctness needs `LayoutResource`, + /// which isn't set up in this headless fixture; the lifecycle + /// test below is what's load-bearing for the spawn/despawn + /// pairing.) + #[test] + fn floating_chip_spawns_and_despawns_with_overlay() { + let mut app = headless_app(); + // Inactive → no chip. + app.update(); + assert_eq!( + app.world_mut() + .query::<&ReplayFloatingProgressChip>() + .iter(app.world()) + .count(), + 0, + "no floating chip while playback is Inactive", + ); + + set_state( + &mut app, + ReplayPlaybackState::Playing { + replay: synthetic_replay(5), + cursor: 0, + secs_to_next: 0.5, + }, + ); + app.update(); + assert_eq!( + app.world_mut() + .query::<&ReplayFloatingProgressChip>() + .iter(app.world()) + .count(), + 1, + "floating chip must spawn when playback starts", + ); + + set_state(&mut app, ReplayPlaybackState::Inactive); + app.update(); + assert_eq!( + app.world_mut() + .query::<&ReplayFloatingProgressChip>() + .iter(app.world()) + .count(), + 0, + "floating chip must despawn when playback ends", + ); + } + /// Manually flipping the resource back to `Inactive` (e.g. via the /// playback core's auto-clear after `Completed`) tears the overlay /// down without any further input.