9bbb57134f
Pile-position types (Tableau, Foundation, KlondikePile, KlondikePileStack) are runtime-only and have no serde upstream. Per Rhys's guidance, the persistence layer now stores the moves (KlondikeInstruction) rather than board coordinates, decoding back to runtime pile positions on demand. Core / data: - game_state: instruction_history() -> Vec<KlondikeInstruction>; add instruction_to_piles() and apply_instruction(); drop AnyInstruction. - klondike_adapter: delete the entire Saved* serde mirror section (SavedTableau/Foundation/SkipCards/KlondikePile/TableauStack/ KlondikePileStack/DstFoundation/DstTableau/SavedInstruction). - replay: drop the bespoke ReplayMove serde mirror; Replay.moves is now Vec<KlondikeInstruction>; REPLAY_SCHEMA_VERSION 2 -> 3. - storage: game_state save format v3 rejected (v4/v5 only). Engine / wasm consumers: - record via KlondikeInstruction (stock click = RotateStock). - playback decodes each instruction to (from, to, count) against the live state via instruction_to_piles, then fires the canonical event; undecodable instructions are skipped with a warning, never panic. - remove all use solitaire_data::ReplayMove and Saved* imports. Workspace check, clippy -D warnings, and the full test suite all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1228 lines
54 KiB
Rust
1228 lines
54 KiB
Rust
#![allow(dead_code)]
|
||
|
||
use bevy::prelude::*;
|
||
|
||
use super::*;
|
||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||
use crate::replay_playback::{
|
||
ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback,
|
||
stop_replay_playback, toggle_pause_replay_playback,
|
||
};
|
||
use crate::resources::GameStateResource;
|
||
|
||
/// Per-arrow-key time-since-last-fire accumulators that drive the
|
||
/// continuous-scrub repeat behaviour for held arrow keys. Each
|
||
/// frame the key is held, the corresponding accumulator absorbs
|
||
/// `time.delta_secs()`; when it exceeds
|
||
/// [`SCRUB_REPEAT_INTERVAL_SECS`] the handler fires another step
|
||
/// and resets the accumulator.
|
||
///
|
||
/// `just_pressed` events bypass the accumulator entirely and fire
|
||
/// immediately — only *repeat* fires (while held) are gated by
|
||
/// the interval. Releases reset the accumulator to 0 so the next
|
||
/// fresh press fires immediately rather than at half-interval.
|
||
#[derive(Resource, Default, Debug)]
|
||
pub(crate) struct ReplayScrubKeyHold {
|
||
pub(crate) left_held_secs: f32,
|
||
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<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.
|
||
pub(crate) 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.
|
||
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<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 %.
|
||
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<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)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Playback-control button handlers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Pure helper — returns the label the Pause / Resume button should
|
||
/// carry for the given state. "Pause" while running, "Resume" while
|
||
/// paused, empty otherwise (the button is despawned with the rest of
|
||
/// the overlay tree on transitions to `Inactive` / `Completed`, so
|
||
/// the empty branch only fires for one frame around state changes).
|
||
pub(crate) fn pause_button_label(state: &ReplayPlaybackState) -> &'static str {
|
||
match state {
|
||
ReplayPlaybackState::Playing { paused: true, .. } => "Resume",
|
||
ReplayPlaybackState::Playing { paused: false, .. } => "Pause",
|
||
ReplayPlaybackState::Inactive | ReplayPlaybackState::Completed => "",
|
||
}
|
||
}
|
||
|
||
/// Watches the Stop button for `Interaction::Pressed` transitions. On a
|
||
/// click, calls [`stop_replay_playback`] which resets the state to
|
||
/// `Inactive`; the next frame's `react_to_state_change` then despawns
|
||
/// the overlay.
|
||
pub(crate) fn handle_stop_button(
|
||
mut commands: Commands,
|
||
mut state: ResMut<ReplayPlaybackState>,
|
||
buttons: Query<&Interaction, (With<ReplayStopButton>, Changed<Interaction>)>,
|
||
) {
|
||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||
return;
|
||
}
|
||
stop_replay_playback(&mut commands, &mut state);
|
||
}
|
||
|
||
/// Watches the Pause / Resume button for `Interaction::Pressed`
|
||
/// transitions. On a click, toggles the `paused` flag via
|
||
/// [`toggle_pause_replay_playback`]. The label repaint happens in
|
||
/// [`update_pause_button_label`] on the same frame the state mutation
|
||
/// flushes.
|
||
pub(crate) fn handle_pause_button(
|
||
mut state: ResMut<ReplayPlaybackState>,
|
||
buttons: Query<&Interaction, (With<ReplayPauseButton>, Changed<Interaction>)>,
|
||
) {
|
||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||
return;
|
||
}
|
||
toggle_pause_replay_playback(&mut state);
|
||
}
|
||
|
||
/// Watches the Step button for `Interaction::Pressed` transitions. On
|
||
/// a click, advances exactly one move via [`step_replay_playback`].
|
||
/// No-op while playback is unpaused (would race the tick loop) — the
|
||
/// guard lives inside `step_replay_playback`.
|
||
pub(crate) fn handle_step_button(
|
||
mut state: ResMut<ReplayPlaybackState>,
|
||
game: Option<Res<GameStateResource>>,
|
||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||
buttons: Query<&Interaction, (With<ReplayStepButton>, Changed<Interaction>)>,
|
||
) {
|
||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||
return;
|
||
}
|
||
step_replay_playback(
|
||
&mut state,
|
||
game.as_deref(),
|
||
&mut moves_writer,
|
||
&mut draws_writer,
|
||
);
|
||
}
|
||
|
||
/// Repaints the Pause / Resume button's label whenever
|
||
/// [`ReplayPlaybackState`] changes. Walks from the marked button
|
||
/// entity to its single child [`Text`] so the spawn path doesn't need
|
||
/// a second marker on the inner node.
|
||
pub(crate) fn update_pause_button_label(
|
||
state: Res<ReplayPlaybackState>,
|
||
buttons: Query<&Children, With<ReplayPauseButton>>,
|
||
mut texts: Query<&mut Text>,
|
||
) {
|
||
if !state.is_changed() {
|
||
return;
|
||
}
|
||
let label = pause_button_label(&state);
|
||
if label.is_empty() {
|
||
// Overlay is mid-teardown; the button entity will despawn
|
||
// this frame anyway. Skip the repaint to avoid touching a
|
||
// doomed entity.
|
||
return;
|
||
}
|
||
for children in &buttons {
|
||
for child in children.iter() {
|
||
if let Ok(mut text) = texts.get_mut(child) {
|
||
text.0 = label.to_string();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Watches `Space` for the keyboard pause / resume accelerator.
|
||
/// UI-first contract from CLAUDE.md §3.3 is satisfied by the on-
|
||
/// screen Pause / Resume button; this is the optional accelerator.
|
||
/// No-op when the playback isn't `Playing` (e.g. while a modal is
|
||
/// open and the player is using `Space` for something else).
|
||
pub(crate) fn handle_pause_keyboard(
|
||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||
mut state: ResMut<ReplayPlaybackState>,
|
||
) {
|
||
let Some(keys) = keys else { return };
|
||
if !keys.just_pressed(KeyCode::Space) {
|
||
return;
|
||
}
|
||
toggle_pause_replay_playback(&mut state);
|
||
}
|
||
|
||
/// Watches the arrow keys for the paused step / scrub
|
||
/// accelerators. UI-first contract from CLAUDE.md §3.3 is
|
||
/// satisfied by the on-screen Step button (forward only); these
|
||
/// are the optional accelerators that also surface a backwards
|
||
/// step plus continuous scrub.
|
||
///
|
||
/// Both keys are paused-only — the underlying step helpers
|
||
/// hard-gate via destructure on `paused: true`. Pressing → during
|
||
/// running playback or ← at cursor 0 are silent no-ops; the
|
||
/// player learns "pause first, then arrow."
|
||
///
|
||
/// **Single press fires once immediately**
|
||
/// (`just_pressed`). **Holding** the key triggers continuous
|
||
/// scrub at [`SCRUB_REPEAT_INTERVAL_SECS`] cadence (10 steps/sec
|
||
/// at 100 ms): the per-key accumulator on
|
||
/// [`ReplayScrubKeyHold`] absorbs `time.delta_secs()` each frame
|
||
/// the key is held, fires + resets when the threshold is hit, and
|
||
/// resets to 0 on key release so the next fresh press fires
|
||
/// immediately. This matches the mockup's `[← →] scrub`
|
||
/// terminology while keeping single-press = single-step semantics.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn handle_arrow_keyboard(
|
||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||
time: Res<Time>,
|
||
game: Option<Res<GameStateResource>>,
|
||
mut hold: ResMut<ReplayScrubKeyHold>,
|
||
mut state: ResMut<ReplayPlaybackState>,
|
||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||
mut undo_writer: MessageWriter<UndoRequestEvent>,
|
||
mut state_changed: MessageReader<StateChangedEvent>,
|
||
// `true` while a backward step is in-flight: cursor was decremented and
|
||
// `UndoRequestEvent` was written, but `handle_undo` hasn't applied it yet.
|
||
// Cleared when `StateChangedEvent` confirms the game state has caught up.
|
||
// Prevents rapid ← presses from accumulating multiple cursor decrements
|
||
// before any undo is applied (Bug #16).
|
||
mut back_pending: Local<bool>,
|
||
) {
|
||
let Some(keys) = keys else { return };
|
||
let dt = time.delta_secs();
|
||
|
||
// Clear the in-flight flag once the game confirms the undo landed.
|
||
if state_changed.read().count() > 0 {
|
||
*back_pending = false;
|
||
}
|
||
|
||
// Right (forward step) — initial press fires immediately;
|
||
// held repeats fire when the accumulator crosses the interval.
|
||
if keys.just_pressed(KeyCode::ArrowRight) {
|
||
step_replay_playback(
|
||
&mut state,
|
||
game.as_deref(),
|
||
&mut moves_writer,
|
||
&mut draws_writer,
|
||
);
|
||
hold.right_held_secs = 0.0;
|
||
} else if keys.pressed(KeyCode::ArrowRight) {
|
||
hold.right_held_secs += dt;
|
||
if hold.right_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
|
||
step_replay_playback(
|
||
&mut state,
|
||
game.as_deref(),
|
||
&mut moves_writer,
|
||
&mut draws_writer,
|
||
);
|
||
hold.right_held_secs = 0.0;
|
||
}
|
||
} else {
|
||
hold.right_held_secs = 0.0;
|
||
}
|
||
|
||
// Left (backwards step) — gate on `back_pending` so at most one undo
|
||
// is in-flight at a time. The cursor is only decremented inside
|
||
// `step_backwards_replay_playback`, which also writes `UndoRequestEvent`.
|
||
// `back_pending` is set after a successful step and cleared above when
|
||
// `StateChangedEvent` confirms the undo was applied.
|
||
if keys.just_pressed(KeyCode::ArrowLeft) {
|
||
if !*back_pending {
|
||
let fired = step_backwards_replay_playback(&mut state, &mut undo_writer);
|
||
if fired {
|
||
*back_pending = true;
|
||
}
|
||
}
|
||
hold.left_held_secs = 0.0;
|
||
} else if keys.pressed(KeyCode::ArrowLeft) {
|
||
hold.left_held_secs += dt;
|
||
if hold.left_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
|
||
if !*back_pending {
|
||
let fired = step_backwards_replay_playback(&mut state, &mut undo_writer);
|
||
if fired {
|
||
*back_pending = true;
|
||
}
|
||
}
|
||
hold.left_held_secs = 0.0;
|
||
}
|
||
} else {
|
||
hold.left_held_secs = 0.0;
|
||
}
|
||
}
|
||
|
||
/// Watches `Esc` for the keyboard stop accelerator. UI-first
|
||
/// contract from CLAUDE.md §3.3 is satisfied by the on-screen
|
||
/// Stop button; this is the optional accelerator.
|
||
///
|
||
/// Cross-plugin coordination: `pause_plugin::toggle_pause` also
|
||
/// listens for `Esc` and would otherwise open the pause modal on
|
||
/// the same press. The conflict is resolved by `toggle_pause`
|
||
/// gating itself on `ReplayPlaybackState::is_playing()` —
|
||
/// symmetrical to the existing `forfeit_screens` /
|
||
/// `other_modal_scrims` defer-if pattern in that system. So during
|
||
/// an active replay this handler owns the `Esc` press and the
|
||
/// pause modal stays closed.
|
||
///
|
||
/// No-op when the playback isn't `Playing` (the resource may still
|
||
/// exist as `Inactive` or `Completed`; only `Playing` means a
|
||
/// replay is on screen for the player to stop).
|
||
pub(crate) fn handle_stop_keyboard(
|
||
mut commands: Commands,
|
||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||
mut state: ResMut<ReplayPlaybackState>,
|
||
) {
|
||
let Some(keys) = keys else { return };
|
||
if !keys.just_pressed(KeyCode::Escape) {
|
||
return;
|
||
}
|
||
if !state.is_playing() {
|
||
return;
|
||
}
|
||
stop_replay_playback(&mut commands, &mut state);
|
||
}
|