Files
Ferrous-Solitaire/solitaire_engine/src/replay_overlay/update.rs
T
funman300 d864d985c8
Build and Deploy / build-and-push (push) Failing after 53s
Web E2E / web-e2e (push) Failing after 4m16s
refactor(engine,wasm,data): route all klondike/card_game imports through solitaire_core
All downstream crates now import Foundation, KlondikePile, Tableau,
Klondike, Session, Suit, Rank exclusively from solitaire_core.
solitaire_core is the single version-pin point for the upstream crates.

- solitaire_engine: 19 files updated, klondike direct dep removed
- solitaire_wasm: use statement updated, klondike direct dep removed
- solitaire_data: unused klondike dep removed
- Cargo.lock: klondike no longer a direct dep of engine/wasm/data
- Full workspace clippy clean, all tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:04:05 -07:00

250 lines
8.6 KiB
Rust

use bevy::prelude::*;
use super::format::{
format_active_move_row, format_foundations_row, format_kth_next_row, format_kth_recent_row,
format_move_log_header, format_progress, format_stock_waste_row,
};
use super::*;
use crate::layout::LayoutResource;
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource;
use solitaire_core::KlondikePile;
use solitaire_data::ReplayMove;
/// Overwrites the banner label whenever the resource changes — covers the
/// `Playing → Completed` transition by swapping "▌ replay" for
/// "▌ replay complete" in place without despawning the overlay.
pub(crate) fn update_banner_label(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayBannerText>>,
) {
if !state.is_changed() {
return;
}
let label = if state.is_completed() {
"\u{258C} replay complete" // ▌
} else if state.is_playing() {
"\u{258C} replay" // ▌
} else {
return;
};
for mut text in &mut q {
**text = label.to_string();
}
}
/// Repaints the "Move N of M" centre readout every frame the cursor moves.
/// Cheap — early-exits if the resource has not changed since the last
/// frame so idle replays don't churn the text mesh.
pub(crate) fn update_progress_text(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayProgressText>>,
) {
if !state.is_changed() {
return;
}
let label = format_progress(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// 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.
pub(crate) 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),
ReplayMove::StockClick => None,
}
}
_ => None,
};
let Some(world_pos) = dest_pile
.as_ref()
.and_then(|p| KlondikePile::try_from(*p).ok())
.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 move-log panel's `▌ MOVE LOG · N/M` header text
/// whenever [`ReplayPlaybackState`] changes. Cheap — early-exits
/// when nothing moved so an idle replay leaves the text mesh
/// untouched.
pub(crate) fn update_move_log_header(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayMoveLogHeader>>,
) {
if !state.is_changed() {
return;
}
let label = format_move_log_header(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// Repaints the move-log panel's active-row text whenever
/// [`ReplayPlaybackState`] changes. Same change-detection guard
/// as the header updater. Empty string at `cursor == 0` (no move
/// applied yet) and in non-`Playing` states; populated otherwise.
pub(crate) fn update_move_log_active_row(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayMoveLogActiveRow>>,
) {
if !state.is_changed() {
return;
}
let label = format_active_move_row(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// Repaints every "previous move" row text whenever
/// [`ReplayPlaybackState`] changes. Each row's `offset` is read
/// from the marker; `k = offset + 1` feeds [`format_kth_recent_row`]
/// (active is k=1, prev offset 1 is k=2, prev offset 2 is k=3).
/// Rows with `offset >= cursor` paint as empty — the panel
/// gracefully under-fills early in a replay without spurious
/// "out-of-range" text.
pub(crate) fn update_move_log_prev_rows(
state: Res<ReplayPlaybackState>,
mut q: Query<(&ReplayOverlayMoveLogPrevRow, &mut Text)>,
) {
if !state.is_changed() {
return;
}
for (row, mut text) in &mut q {
let label = format_kth_recent_row(&state, row.offset as usize + 1);
**text = label;
}
}
/// Repaints every "next move" row text whenever
/// [`ReplayPlaybackState`] changes. Symmetric to the prev-row
/// updater but feeds [`format_kth_next_row`]. Rows where
/// `cursor + offset > moves.len()` paint as empty — the panel
/// gracefully under-fills late in a replay (e.g. final moves)
/// without spurious out-of-range text.
pub(crate) fn update_move_log_next_rows(
state: Res<ReplayPlaybackState>,
mut q: Query<(&ReplayOverlayMoveLogNextRow, &mut Text)>,
) {
if !state.is_changed() {
return;
}
for (row, mut text) in &mut q {
let label = format_kth_next_row(&state, row.offset as usize);
**text = label;
}
}
/// 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
/// scrub bar's `Node` untouched.
pub(crate) fn update_scrub_fill(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Node, With<ReplayOverlayScrubFill>>,
) {
if !state.is_changed() {
return;
}
let pct = scrub_pct(&state);
for mut node in &mut q {
node.width = Val::Percent(pct);
}
}
/// Repaints the foundations row whenever [`GameStateResource`] changes.
/// Split into its own system (rather than combined with the stock/waste
/// updater) to avoid a Bevy B0001 query conflict: two `&mut Text`
/// queries in one system are always ambiguous regardless of marker
/// filters. Each updater owns exactly one `Query<&mut Text, With<…>>`.
pub(crate) fn update_mini_tableau_foundations(
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<ReplayMiniTableauFoundations>>,
) {
let Some(game) = game else { return };
if !game.is_changed() {
return;
}
let text = format_foundations_row(&game.0);
for mut t in &mut q {
**t = text.clone();
}
}
/// Repaints the stock/waste row whenever [`GameStateResource`] changes.
/// Sibling of [`update_mini_tableau_foundations`] — same change-detection
/// guard, separate system to avoid the B0001 query conflict.
pub(crate) fn update_mini_tableau_stock_waste(
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<ReplayMiniTableauStockWaste>>,
) {
let Some(game) = game else { return };
if !game.is_changed() {
return;
}
let text = format_stock_waste_row(&game.0);
for mut t in &mut q {
**t = text.clone();
}
}