//! Stock-pile indicators: the empty-stock recycle hint and count badge. use super::*; use bevy::color::Color; use solitaire_core::KlondikePile; use solitaire_core::game_state::GameState; use crate::events::StateChangedEvent; use crate::font_plugin::FontResource; use crate::layout::{Layout, LayoutResource}; use crate::resources::GameStateResource; use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker}; use crate::ui_theme::{ STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_BODY, Z_STOCK_BADGE, }; /// Sprite colour applied to the stock `PileMarker` when the stock pile is empty, /// to signal to the player that there are no more cards to draw. Pure white /// at 0.4 alpha — a deliberate brightness-boost over the default marker so /// the "empty" state is more visible, not less. Not derived from a palette /// token: this is a sprite tint, not chrome colour. const STOCK_EMPTY_DIM_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.4); /// Sprite colour applied to the stock `PileMarker` when cards remain in /// stock. Aliased to [`PILE_MARKER_DEFAULT_COLOUR`] so it tracks the rest /// of the engine's idle pile-marker tint automatically. const STOCK_NORMAL_COLOUR: Color = PILE_MARKER_DEFAULT_COLOUR; /// Shared logic for updating the stock pile marker's dim state and "↺" label. /// /// If the stock pile is empty the marker sprite is dimmed to /// `STOCK_EMPTY_DIM_COLOUR` and a child `Text2d` with `StockEmptyLabel` is /// spawned (if not already present). When the stock is non-empty the marker is /// restored to `STOCK_NORMAL_COLOUR` and any `StockEmptyLabel` children are /// despawned. pub(super) fn apply_stock_empty_indicator( commands: &mut Commands, game: &GameState, pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>, label_children: &Query<(Entity, &ChildOf), With>, layout: &Layout, font: Handle, ) { let stock_empty = game.stock_cards().is_empty(); for (entity, pile_marker, mut sprite) in pile_markers.iter_mut() { if pile_marker.0 != KlondikePile::Stock { continue; } if stock_empty { // Dim the marker sprite. sprite.color = STOCK_EMPTY_DIM_COLOUR; // Spawn the "↺" label only if one does not already exist. let already_has_label = label_children .iter() .any(|(_, parent)| parent.parent() == entity); if !already_has_label { let font_size = layout.card_size.x * 0.4; commands.entity(entity).with_children(|b| { b.spawn(( StockEmptyLabel, Text2d::new("↺"), TextFont { font: font.clone(), font_size, ..default() }, TextColor(TEXT_PRIMARY.with_alpha(0.7)), Transform::from_xyz(0.0, 0.0, 0.1), )); }); } } else { // Restore normal brightness. sprite.color = STOCK_NORMAL_COLOUR; // Despawn any existing "↺" label children. for (label_entity, parent) in label_children.iter() { if parent.parent() == entity { commands.entity(label_entity).despawn(); } } } } } /// Runs at `PostStartup` to apply the stock-empty indicator for the initial /// game state (before any `StateChangedEvent` fires). pub(super) fn update_stock_empty_indicator_startup( mut commands: Commands, game: Res, layout: Option>, font_res: Option>, mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>, label_children: Query<(Entity, &ChildOf), With>, ) { let Some(layout) = layout else { return }; let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(); apply_stock_empty_indicator( &mut commands, &game.0, &mut pile_markers, &label_children, &layout.0, font, ); } /// Runs each `Update` tick when a `StateChangedEvent` arrives, keeping the /// stock pile marker dim state and "↺" label in sync with the current stock. pub(super) fn update_stock_empty_indicator( mut events: MessageReader, mut commands: Commands, game: Res, layout: Option>, font_res: Option>, mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>, label_children: Query<(Entity, &ChildOf), With>, ) { if events.read().next().is_none() { return; } let Some(layout) = layout else { return }; let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(); apply_stock_empty_indicator( &mut commands, &game.0, &mut pile_markers, &label_children, &layout.0, font, ); } // --------------------------------------------------------------------------- // Stock-pile remaining-count badge // // Shows a small "N" chip pinned to the bottom-right corner of the stock pile so // the player can see how many cards remain before the next recycle. The // existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so // the badge hides itself when the stock has zero cards — the two indicators // never render at the same time. // --------------------------------------------------------------------------- /// Inset (in pixels) from the bottom-right corner of the stock pile sprite to /// the centre of the count badge. Anchoring to the bottom-right keeps the chip /// clear of the rank/suit pip in the card's top-left corner. Both components /// move the centre *inward* from that corner: `x` is subtracted from the right /// edge, `y` is added to the bottom edge. The `x` magnitude must satisfy /// `x >= STOCK_BADGE_SIZE.x / 2` so the badge right edge stays inside the stock /// pile and never overlaps the adjacent waste pile — critical on Android where /// `H_GAP_DIVISOR = 32` gives an inter-pile gap of only ~4 px. const STOCK_BADGE_INSET: Vec2 = Vec2::new(20.0, 8.0); /// Width / height of the badge background sprite, in world pixels. Sized so /// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text. const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0); /// Returns the count of cards currently in the stock pile. /// /// Pure helper extracted so the count source is identical between the spawn /// system, the update system, and the unit tests. pub(super) fn stock_card_count(game: &GameState) -> usize { game.stock_cards().len() } /// Returns the world-space `Vec3` for the centre of the stock-count badge, /// given the current `Layout`. The badge sits at the bottom-right corner of /// the stock pile sprite, inset by [`STOCK_BADGE_INSET`], so it stays clear of /// the rank/suit pip in the card's top-left corner. pub(super) fn stock_badge_translation(layout: &Layout) -> Vec3 { // Empty layouts don't contain a Stock entry — fall back to origin so // the badge stays in a deterministic spot until the layout is filled. let pile_pos = layout .pile_positions .get(&KlondikePile::Stock) .copied() .unwrap_or(Vec2::ZERO); let half = layout.card_size * 0.5; // Anchor to the bottom-right corner, then move the centre inward. let x = pile_pos.x + half.x - STOCK_BADGE_INSET.x; let y = pile_pos.y - half.y + STOCK_BADGE_INSET.y; Vec3::new(x, y, Z_STOCK_BADGE) } /// Spawns the stock-count badge entity (background sprite + child text) /// into the world. Called once, when the badge does not yet exist. pub(super) fn spawn_stock_count_badge( commands: &mut Commands, layout: &Layout, font: Option<&Handle>, count: usize, ) { let translation = stock_badge_translation(layout); let visibility = if count == 0 { Visibility::Hidden } else { Visibility::Inherited }; let text_font = TextFont { font: font.cloned().unwrap_or_default(), font_size: TYPE_BODY, ..default() }; commands .spawn(( StockCountBadge, Sprite { color: STOCK_BADGE_BG, custom_size: Some(STOCK_BADGE_SIZE), ..default() }, Transform::from_translation(translation), visibility, )) .with_children(|b| { b.spawn(( StockCountBadgeText, Text2d::new(format!("{count}")), text_font, TextColor(STOCK_BADGE_FG), // Slightly above the chip background so the digits aren't // occluded by the sprite they sit on. Transform::from_xyz(0.0, 0.0, 0.1), )); }); } /// Spawns the stock-pile remaining-count badge if it does not yet exist, /// and otherwise updates its text and visibility in place. /// /// Visibility rule: hidden when the stock is empty (the existing `↺` /// `StockEmptyLabel` overlay covers that state), shown when one or more /// cards remain. /// /// Position is recomputed from `LayoutResource` every tick so the badge /// follows the stock pile across `WindowResized` layout updates without /// needing a dedicated resize handler. #[allow(clippy::too_many_arguments)] pub(super) fn update_stock_count_badge( mut commands: Commands, game: Option>, layout: Option>, font: Option>, mut badges: Query<(Entity, &mut Transform, &mut Visibility), With>, children: Query<&Children, With>, mut texts: Query<&mut Text2d, With>, ) { let Some(game) = game else { return }; let Some(layout) = layout else { return }; let count = stock_card_count(&game.0); let translation = stock_badge_translation(&layout.0); let target_visibility = if count == 0 { Visibility::Hidden } else { Visibility::Inherited }; if badges.is_empty() { spawn_stock_count_badge(&mut commands, &layout.0, font.as_ref().map(|f| &f.0), count); return; } for (entity, mut transform, mut visibility) in badges.iter_mut() { transform.translation = translation; if *visibility != target_visibility { *visibility = target_visibility; } // Update the child text to reflect the latest count. The text node // is created at spawn time, so under normal operation we always // have exactly one child here. if let Ok(badge_children) = children.get(entity) { for child in badge_children.iter() { if let Ok(mut text) = texts.get_mut(child) { let new = format!("{count}"); if text.0 != new { text.0 = new; } } } } } }