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, mut q: Query<&mut Text, With>, ) { 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, mut q: Query<&mut Text, With>, ) { 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, layout: Option>, mut chips: Query< (&mut Transform, &mut Visibility, &mut Text2d), With, >, ) { 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, mut q: Query<&mut Text, With>, ) { 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, mut q: Query<&mut Text, With>, ) { 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, 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, 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, mut q: Query<&mut Node, With>, ) { 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>, mut q: Query<&mut Text, With>, ) { 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>, mut q: Query<&mut Text, With>, ) { 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(); } }