113a933170
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95) wraps signatures and call sites differently, so every touched file was picking up unrelated formatting hunks. One mechanical pass, and a 'cargo fmt --check' step in the test workflow (same pinned 1.95.0 toolchain) so drift can't accumulate again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
287 lines
11 KiB
Rust
287 lines
11 KiB
Rust
//! 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<F: bevy::ecs::query::QueryFilter>(
|
|
commands: &mut Commands,
|
|
game: &GameState,
|
|
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
|
|
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
|
layout: &Layout,
|
|
font: Handle<Font>,
|
|
) {
|
|
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<GameStateResource>,
|
|
layout: Option<Res<LayoutResource>>,
|
|
font_res: Option<Res<FontResource>>,
|
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
|
) {
|
|
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<StateChangedEvent>,
|
|
mut commands: Commands,
|
|
game: Res<GameStateResource>,
|
|
layout: Option<Res<LayoutResource>>,
|
|
font_res: Option<Res<FontResource>>,
|
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
|
) {
|
|
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<Font>>,
|
|
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<Res<GameStateResource>>,
|
|
layout: Option<Res<LayoutResource>>,
|
|
font: Option<Res<FontResource>>,
|
|
mut badges: Query<(Entity, &mut Transform, &mut Visibility), With<StockCountBadge>>,
|
|
children: Query<&Children, With<StockCountBadge>>,
|
|
mut texts: Query<&mut Text2d, With<StockCountBadgeText>>,
|
|
) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|