feat(replay): WIN MOVE marker on the scrub bar

Second commit on the B-2 replay screen-takeover redesign — the UI
that consumes the data field landed in `ab857bb`. Adds a small
green tick on the scrub bar at `replay.win_move_index / total`,
positioned so the playback cursor reaches the marker exactly when
the move it's about to apply IS the winning move.

Implementation: a new `ReplayOverlayWinMoveMarker` component
spawned alongside `ReplayOverlayScrubFill` as a sibling under the
1px scrub track. Position computed by a pure helper
`win_move_marker_pct` that returns `None` for any of: state not
`Playing`, replay's `win_move_index` is `None` (older replay
loaded from disk pre-dating the field), or empty move list. The
percentage is clamped to `[0, 100]` defensively. Marker is
absolute-positioned with `top: -1px` so the 3px-tall tick is
centered on the 1px track line — 1px above and 1px below.

Lifecycle is "spawn-time only" — the marker position never changes
during a single playback because the underlying replay is
immutable while `Playing`. Despawned with the rest of the overlay
tree when the state returns to `Inactive`.

8 new tests cover: pure helper for Inactive / Completed / no-field /
correct-position / clamp; spawn presence with field; spawn absence
without field; despawn-with-overlay lifecycle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 14:53:40 -07:00
parent e63046700c
commit 52befa6199
+190 -2
View File
@@ -32,8 +32,8 @@ 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,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY,
TYPE_BODY, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
};
// ---------------------------------------------------------------------------
@@ -135,6 +135,23 @@ pub struct ReplayOverlayGameCaption;
#[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;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
@@ -375,6 +392,7 @@ fn spawn_overlay(
// 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 {
@@ -394,6 +412,27 @@ fn spawn_overlay(
},
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),
));
}
});
});
@@ -438,6 +477,33 @@ fn scrub_pct(state: &ReplayPlaybackState) -> f32 {
}
}
/// 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, .. } = state else {
return None;
};
let idx = replay.win_move_index?;
let total = replay.moves.len();
if total == 0 {
return None;
}
let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
Some(frac * 100.0)
}
// ---------------------------------------------------------------------------
// Per-frame text updates
// ---------------------------------------------------------------------------
@@ -1080,4 +1146,126 @@ mod tests {
"Completed state must read as a fully-filled track",
);
}
// -----------------------------------------------------------------------
// win_move_marker_pct + ReplayOverlayWinMoveMarker spawn behaviour
// -----------------------------------------------------------------------
fn win_marker_count(app: &mut App) -> usize {
app.world_mut()
.query::<&ReplayOverlayWinMoveMarker>()
.iter(app.world())
.count()
}
#[test]
fn win_move_marker_pct_is_none_for_inactive() {
assert_eq!(win_move_marker_pct(&ReplayPlaybackState::Inactive), None);
}
#[test]
fn win_move_marker_pct_is_none_for_completed() {
// `Completed` carries no replay so the marker has no data to
// anchor against — the overlay treats this as "no marker".
assert_eq!(win_move_marker_pct(&ReplayPlaybackState::Completed), None);
}
#[test]
fn win_move_marker_pct_is_none_when_replay_lacks_field() {
// Synthetic replay constructor leaves win_move_index as None
// (legacy / pre-`ab857bb` path).
let state = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
};
assert_eq!(win_move_marker_pct(&state), None);
}
#[test]
fn win_move_marker_pct_is_some_at_correct_position() {
// 10 moves, win at index 9 → marker sits at 90 % of the track.
// Matches the recording semantic: cursor reaches the marker
// exactly when the about-to-apply move IS the win move.
let state = ReplayPlaybackState::Playing {
replay: synthetic_replay(10).with_win_move_index(Some(9)),
cursor: 0,
secs_to_next: 0.5,
};
assert_eq!(win_move_marker_pct(&state), Some(90.0));
}
#[test]
fn win_move_marker_pct_clamps_to_track_bounds() {
// Defensive: if a malformed replay carried `win_move_index >=
// total`, the marker must still sit on the track, not past it.
let state = ReplayPlaybackState::Playing {
replay: synthetic_replay(5).with_win_move_index(Some(99)),
cursor: 0,
secs_to_next: 0.5,
};
assert_eq!(win_move_marker_pct(&state), Some(100.0));
}
#[test]
fn marker_spawned_when_replay_has_win_move_index() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8).with_win_move_index(Some(7)),
cursor: 0,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(
win_marker_count(&mut app),
1,
"marker entity must spawn when replay carries Some(win_move_index)"
);
}
#[test]
fn marker_not_spawned_when_replay_lacks_win_move_index() {
let mut app = headless_app();
// Default constructor → win_move_index: None (legacy replay).
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8),
cursor: 0,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(
win_marker_count(&mut app),
0,
"no marker should spawn for a replay pre-dating the field"
);
}
#[test]
fn marker_despawns_when_replay_state_returns_to_inactive() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8).with_win_move_index(Some(7)),
cursor: 0,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(win_marker_count(&mut app), 1);
set_state(&mut app, ReplayPlaybackState::Inactive);
app.update();
assert_eq!(
win_marker_count(&mut app),
0,
"marker must despawn with the rest of the overlay tree"
);
}
}