refactor(engine): split card_plugin runtime code into submodules
Final runtime split for #118: the 2,536-line mod.rs becomes seven focused submodules along existing system boundaries — mod.rs 574 fan-step helpers, CardImageSet, markers, plugin build sync.rs 748 asset loading + card entity lifecycle (spawn/update/position) layout.rs 351 resize snapping, in-place resize, tableau fan spread stock.rs 291 empty-stock recycle hint + count badge highlights.rs 276 hint/right-click highlights, cursor hit-testing labels.rs 208 desktop text labels + Android corner labels anim.rs 200 flip animation, drag shadows Moved items are pub(super); code moved verbatim apart from relocating the two stock colour constants next to their consumers. No behaviour change; all 70 card tests pass unchanged. Refs #118 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
//! 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user