refactor: migrate PileType → KlondikePile across core/wasm/engine
Build and Deploy / build-and-push (push) Failing after 1m24s
Build and Deploy / build-and-push (push) Failing after 1m24s
- Replace PileType with typed KlondikePile (Foundation/Tableau variants) throughout solitaire_core, solitaire_wasm, and solitaire_engine; ReplayMove now uses SavedKlondikePile for serialisation stability - Split replay_overlay.rs into replay_overlay/ module (mod, format, input, update, tests) for maintainability - Add klondike dep to solitaire_engine and solitaire_data Cargo.toml - Add TestPileState infrastructure to game_state.rs for engine unit tests - Rebuild solitaire_wasm pkg (js + wasm artefacts updated) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::*;
|
||||
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 crate::layout::LayoutResource;
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::GameStateResource;
|
||||
use klondike::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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user