feat(replay): floating MOVE chip above the focused card during playback

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<Entity, With<ReplayFloatingProgressChip>>` 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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 13:29:38 -07:00
parent c9af1ead22
commit 2fb2d638bf
+177
View File
@@ -27,7 +27,9 @@ use bevy::prelude::*;
use chrono::Datelike; use chrono::Datelike;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::layout::LayoutResource;
use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState}; use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState};
use solitaire_data::ReplayMove;
use crate::ui_modal::{spawn_modal_button, ButtonVariant}; use crate::ui_modal::{spawn_modal_button, ButtonVariant};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
@@ -88,6 +90,21 @@ pub struct ReplayOverlayBannerText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ReplayOverlayProgressText; 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 /// Marker on the right-hand "Stop" button. Click handler queries for this
/// and calls [`stop_replay_playback`] when an `Interaction::Pressed` /// and calls [`stop_replay_playback`] when an `Interaction::Pressed`
/// transition is seen. /// transition is seen.
@@ -149,6 +166,7 @@ impl Plugin for ReplayOverlayPlugin {
react_to_state_change, react_to_state_change,
update_banner_label, update_banner_label,
update_progress_text, update_progress_text,
update_floating_progress_chip,
update_scrub_fill, update_scrub_fill,
handle_stop_button, handle_stop_button,
) )
@@ -170,6 +188,7 @@ fn react_to_state_change(
mut commands: Commands, mut commands: Commands,
state: Res<ReplayPlaybackState>, state: Res<ReplayPlaybackState>,
existing: Query<Entity, With<ReplayOverlayRoot>>, existing: Query<Entity, With<ReplayOverlayRoot>>,
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
if !state.is_changed() { if !state.is_changed() {
@@ -185,6 +204,13 @@ fn react_to_state_change(
for entity in &existing { for entity in &existing {
commands.entity(entity).despawn(); 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 `should_be_visible && already_spawned` branch is a no-op here —
// the per-frame text update systems below repaint the banner label // the per-frame text update systems below repaint the banner label
@@ -200,6 +226,11 @@ fn spawn_overlay(
state: &ReplayPlaybackState, state: &ReplayPlaybackState,
) { ) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); 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();
let banner_label = if state.is_completed() { let banner_label = if state.is_completed() {
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention. "\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 /// 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<ReplayPlaybackState>,
layout: Option<Res<LayoutResource>>,
mut chips: Query<
(&mut Transform, &mut Visibility, &mut Text2d),
With<ReplayFloatingProgressChip>,
>,
) {
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. /// Repaints the bottom-edge accent scrub fill to mirror cursor progress.
/// Same change-detection guard as the text updaters — the overlay /// Same change-detection guard as the text updaters — the overlay
/// already early-exits when nothing moved, so an idle replay leaves the /// 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 /// Manually flipping the resource back to `Inactive` (e.g. via the
/// playback core's auto-clear after `Completed`) tears the overlay /// playback core's auto-clear after `Completed`) tears the overlay
/// down without any further input. /// down without any further input.