d864d985c8
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>
250 lines
8.6 KiB
Rust
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();
|
|
}
|
|
}
|