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:
funman300
2026-07-06 14:01:10 -07:00
parent b3b53c4adf
commit d1e87765af
8 changed files with 2106 additions and 1985 deletions
+200
View File
@@ -0,0 +1,200 @@
//! Card flip animation and drag shadows.
use super::*;
use std::collections::HashSet;
use solitaire_core::Card;
use crate::animation_plugin::EffectiveSlideDuration;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
use crate::layout::LayoutResource;
use crate::resources::DragState;
use crate::ui_theme::{
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
};
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
///
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
pub(super) fn start_flip_anim(
mut events: MessageReader<CardFlippedEvent>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity)>,
) {
if slide_dur.is_some_and(|d| d.slide_secs == 0.0) {
// Instant animation speed — skip the flip effect entirely.
events.clear();
return;
}
for CardFlippedEvent(flipped_card) in events.read() {
for (entity, marker) in &card_entities {
if marker.card == *flipped_card {
commands.entity(entity).insert(CardFlipAnim {
timer: 0.0,
phase: FlipPhase::ScalingDown,
});
break;
}
}
}
}
/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
///
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
/// - At the midpoint the phase switches to `ScalingUp`, scale.x resets to 0,
/// and a `CardFaceRevealedEvent` is fired so audio plays in sync with the reveal.
/// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`.
/// - When complete the component is removed and scale.x is restored to 1.0.
pub(super) fn tick_flip_anim(
mut commands: Commands,
time: Res<Time>,
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
mut reveal_events: MessageWriter<CardFaceRevealedEvent>,
) {
let dt = time.delta_secs();
for (entity, card_entity, mut transform, mut anim) in &mut anims {
anim.timer += dt;
match anim.phase {
FlipPhase::ScalingDown => {
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
transform.scale.x = 1.0 - t;
if t >= 1.0 {
anim.phase = FlipPhase::ScalingUp;
anim.timer = 0.0;
transform.scale.x = 0.0;
// Fire the reveal event exactly once, at the phase transition,
// so the flip sound is synchronised with the visual face reveal.
reveal_events.write(CardFaceRevealedEvent(card_entity.card.clone()));
}
}
FlipPhase::ScalingUp => {
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
transform.scale.x = t;
if t >= 1.0 {
transform.scale.x = 1.0;
commands.entity(entity).remove::<CardFlipAnim>();
}
}
}
}
}
// ---------------------------------------------------------------------------
// Task #38 — Drag-elevation shadow
// ---------------------------------------------------------------------------
/// Maintains a single `ShadowEntity` while cards are being dragged.
///
/// - If a drag is active, spawns (or repositions) a semi-transparent dark
/// sprite behind the top dragged card.
/// - If no drag is active, despawns the shadow entity.
pub(super) fn update_drag_shadow(
mut commands: Commands,
drag: Res<DragState>,
layout: Option<Res<LayoutResource>>,
card_entities: Query<(&CardEntity, &Transform)>,
card_index: Res<CardEntityIndex>,
mut shadow: Local<Option<Entity>>,
) {
if drag.is_idle() {
// No drag in progress — remove shadow if it exists.
if let Some(e) = shadow.take() {
commands.entity(e).despawn();
}
return;
}
let Some(layout) = layout else { return };
let card_w = layout.0.card_size.x;
let card_h = layout.0.card_size.y;
// Find the world position of the first (top) dragged card.
let top_pos = drag.cards.first().and_then(|first_card| {
card_index
.get(first_card)
.and_then(|entity| card_entities.get(entity).ok())
.map(|(_, t)| t.translation)
});
let Some(top_pos) = top_pos else { return };
// Shadow is slightly larger, offset behind-and-below, at a z slightly
// below the dragged cards.
let shadow_pos = top_pos + Vec3::new(-4.0, 4.0, -1.0);
match *shadow {
Some(e) => {
// Reposition the existing shadow.
commands
.entity(e)
.insert(Transform::from_translation(shadow_pos));
}
None => {
// Spawn a new shadow sprite. Alpha tracks the per-card
// CARD_SHADOW_ALPHA_DRAG token so the Terminal palette's
// "no box-shadow" policy disables this stack shadow in
// lockstep with the per-card shadows. Re-enabling shadows
// is then a one-line change in `ui_theme`, not a hunt
// through plugin code.
let e = commands
.spawn((
ShadowEntity,
Sprite {
color: CARD_SHADOW_COLOR.with_alpha(CARD_SHADOW_ALPHA_DRAG),
custom_size: Some(Vec2::new(card_w + 8.0, card_h + 8.0)),
..default()
},
Transform::from_translation(shadow_pos),
Visibility::default(),
))
.id();
*shadow = Some(e);
}
}
}
/// Snaps every per-card [`CardShadow`] between its idle and lifted tunings
/// based on whether the parent [`CardEntity`] is currently in
/// [`DragState::cards`]. Runs every frame; the transition is an instant snap
/// (no lerp) — the existing shake / settle feedback already handles motion
/// at drag-end, so an additional shadow tween would compete with those cues.
///
/// The shadow size is rebuilt from the parent card's current `Sprite`
/// `custom_size` plus the appropriate padding, so the resize handler does
/// not need to pre-tune shadow sizes for the drag state — this system fixes
/// the geometry within one frame.
pub(super) fn update_card_shadows_on_drag(
drag: Res<DragState>,
cards: Query<(&CardEntity, &Sprite, &Children), Without<CardShadow>>,
mut shadows: Query<(&mut Sprite, &mut Transform), With<CardShadow>>,
) {
let dragged: HashSet<&Card> = drag.cards.iter().collect();
for (card_entity, card_sprite, children) in cards.iter() {
let is_dragged = dragged.contains(&card_entity.card);
let (offset, padding, alpha) = card_shadow_params(is_dragged);
let Some(card_size) = card_sprite.custom_size else {
continue;
};
for child in children.iter() {
let Ok((mut shadow_sprite, mut shadow_transform)) = shadows.get_mut(child) else {
continue;
};
shadow_sprite.color = CARD_SHADOW_COLOR.with_alpha(alpha);
shadow_sprite.custom_size = Some(card_size + padding);
shadow_transform.translation.x = offset.x;
shadow_transform.translation.y = offset.y;
shadow_transform.translation.z = CARD_SHADOW_LOCAL_Z;
}
}
}
// ---------------------------------------------------------------------------
// Task #28 — Hint highlight tick system
// ---------------------------------------------------------------------------
@@ -0,0 +1,276 @@
//! Hint and right-click highlights, plus cursor hit-testing helpers.
use super::*;
use bevy::color::Color;
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
use crate::events::StateChangedEvent;
use crate::layout::{Layout, LayoutResource};
use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource};
use crate::settings_plugin::SettingsResource;
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
/// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
/// removes both `HintHighlight` and `HintHighlightTimer` (if present) and
/// resets the card sprite to its normal face-up colour.
pub(super) fn tick_hint_highlight(
time: Res<Time>,
mut commands: Commands,
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
card_images: Option<Res<CardImageSet>>,
) {
let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
let use_images = card_images.is_some();
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
hint.remaining -= time.delta_secs();
if hint.remaining <= 0.0 {
// Restore the normal sprite colour.
// When image-based rendering is active, WHITE is the neutral tint;
// otherwise restore the solid colour appropriate to the card state.
sprite.color = if use_images {
Color::WHITE
} else {
let is_face_up = all_cards(&game.0)
.iter()
.find(|(c, _face_up)| *c == card_entity.card)
.is_some_and(|(_, face_up)| *face_up);
if is_face_up {
CARD_FACE_COLOUR
} else {
card_back_colour(back_idx)
}
};
commands
.entity(entity)
.remove::<HintHighlight>()
.remove::<HintHighlightTimer>();
}
}
}
// ---------------------------------------------------------------------------
// Task #46 — Right-click legal destination highlights
// ---------------------------------------------------------------------------
/// Lime tint applied to a `PileMarker` sprite when it is a legal
/// destination for the right-clicked card. Same RGB as the design-
/// system [`STATE_SUCCESS`] token at 60% alpha. Spelled as a literal
/// because `Alpha::with_alpha` is not yet a `const` trait method on
/// stable; the tracking test below pins the RGB to `STATE_SUCCESS`
/// so a palette swap can't drift the two apart silently.
pub(super) const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.675, 0.761, 0.404, 0.6);
/// Counts down `RightClickHighlightTimer` each frame and clears the highlight
/// when the timer expires.
///
/// This is a fallback expiry: highlights also clear immediately on
/// `StateChangedEvent` (move made) or when the game is paused, whichever comes
/// first. The 1.5 s timer ensures highlights always disappear even if the
/// player takes no further action.
pub(super) fn tick_right_click_highlights(
mut commands: Commands,
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut highlights: Query<
(Entity, &mut RightClickHighlightTimer, &mut Sprite),
With<RightClickHighlight>,
>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
for (entity, mut timer, mut sprite) in &mut highlights {
timer.0 -= dt;
if timer.0 <= 0.0 {
// Restore the pile marker to its default colour before removing
// the highlight marker component.
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
commands
.entity(entity)
.remove::<RightClickHighlight>()
.remove::<RightClickHighlightTimer>();
}
}
}
/// Removes the `RightClickHighlight` marker from every highlighted pile and
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
///
/// Shared by the on-state-change and on-pause clear systems to avoid
/// duplicating the removal logic.
pub(super) fn clear_right_click_highlights(
commands: &mut Commands,
highlighted: &Query<Entity, With<RightClickHighlight>>,
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
) {
for entity in highlighted.iter() {
commands.entity(entity).remove::<RightClickHighlight>();
}
for (_entity, _, mut sprite) in pile_markers.iter_mut() {
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
}
}
}
/// Clears all right-click destination highlights whenever any game-state
/// mutation succeeds (`StateChangedEvent` fires).
///
/// This ensures stale highlights do not linger after a card is moved.
pub(super) fn clear_right_click_highlights_on_state_change(
mut events: MessageReader<StateChangedEvent>,
mut commands: Commands,
highlighted: Query<Entity, With<RightClickHighlight>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
) {
if events.read().next().is_none() {
return;
}
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
}
/// Clears all right-click destination highlights when the game is paused
/// (`PausedResource` changes to `true`).
///
/// Prevents highlighted pile markers from remaining visible behind the pause
/// overlay.
pub(super) fn clear_right_click_highlights_on_pause(
paused: Option<Res<PausedResource>>,
mut commands: Commands,
highlighted: Query<Entity, With<RightClickHighlight>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
) {
let Some(paused) = paused else { return };
if paused.is_changed() && paused.0 {
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
}
}
/// Handles right-click: highlights legal destination piles for the clicked card,
/// and clears highlights on any subsequent right- or left-click.
///
/// This system lives in `CardPlugin` to keep `InputPlugin` untouched.
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_right_click(
buttons: Option<Res<ButtonInput<MouseButton>>>,
paused: Option<Res<PausedResource>>,
drag: Res<DragState>,
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
mut commands: Commands,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
card_entities: Query<(Entity, &CardEntity, &Transform)>,
highlighted: Query<Entity, With<RightClickHighlight>>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let Some(buttons) = buttons else { return };
let left_pressed = buttons.just_pressed(MouseButton::Left);
let right_pressed = buttons.just_pressed(MouseButton::Right);
// Clear existing highlights on any click.
if left_pressed || right_pressed {
for entity in &highlighted {
commands.entity(entity).remove::<RightClickHighlight>();
}
for (_entity, _, mut sprite) in &mut pile_markers {
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
}
}
}
// Only proceed for right-clicks while not dragging.
if !right_pressed || !drag.is_idle() {
return;
}
let Some(layout) = layout else { return };
// Convert cursor to world-space position.
let Some(world) = cursor_world_pos(&windows, &cameras) else {
return;
};
// Find the topmost face-up card under the cursor.
let Some(card) = find_top_card_at(world, &game.0, &layout.0, &card_entities) else {
return;
};
let Some(source_pile) = game.0.pile_containing_card(card.clone()) else {
return;
};
// Tint piles that legally accept the card.
for (entity, pile_marker, mut sprite) in &mut pile_markers {
let legal = game.0.can_move_cards(&source_pile, &pile_marker.0, 1);
if legal {
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
commands
.entity(entity)
.insert(RightClickHighlight)
.insert(RightClickHighlightTimer(1.5));
}
}
}
/// Converts cursor position to 2-D world coordinates.
pub(super) fn cursor_world_pos(
windows: &Query<&Window, With<bevy::window::PrimaryWindow>>,
cameras: &Query<(&Camera, &GlobalTransform)>,
) -> Option<Vec2> {
let window = windows.single().ok()?;
let cursor = window.cursor_position()?;
let (camera, camera_transform) = cameras.single().ok()?;
camera.viewport_to_world_2d(camera_transform, cursor).ok()
}
/// Returns the topmost face-up `Card` under `cursor` by checking axis-aligned
/// bounding rectangles of all card sprites, picking the highest Z.
pub(super) fn find_top_card_at(
cursor: Vec2,
game: &GameState,
layout: &Layout,
card_entities: &Query<(Entity, &CardEntity, &Transform)>,
) -> Option<Card> {
let half = layout.card_size / 2.0;
let mut best: Option<(f32, Card)> = None;
for (_, card_entity, transform) in card_entities.iter() {
let pos = transform.translation.truncate();
if cursor.x < pos.x - half.x
|| cursor.x > pos.x + half.x
|| cursor.y < pos.y - half.y
|| cursor.y > pos.y + half.y
{
continue;
}
let found = all_cards(game)
.into_iter()
.find(|(c, face_up)| *c == card_entity.card && *face_up);
if let Some((card, _)) = found {
let z = transform.translation.z;
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
best = Some((z, card));
}
}
}
best.map(|(_, card)| card)
}
// ---------------------------------------------------------------------------
// Task #28 — Stock-empty visual indicator
// ---------------------------------------------------------------------------
+208
View File
@@ -0,0 +1,208 @@
//! Card face labels: desktop text labels and Android corner labels.
use super::*;
use bevy::color::Color;
use bevy::sprite::Anchor;
use solitaire_core::{Card, Rank, Suit};
use crate::ui_theme::TEXT_PRIMARY_HC;
pub(super) fn label_for(card: &Card) -> String {
let rank = match card.rank() {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Seven => "7",
Rank::Eight => "8",
Rank::Nine => "9",
Rank::Ten => "10",
Rank::Jack => "J",
Rank::Queen => "Q",
Rank::King => "K",
};
let suit = match card.suit() {
Suit::Clubs => "C",
Suit::Diamonds => "D",
Suit::Hearts => "H",
Suit::Spades => "S",
};
format!("{rank}{suit}")
}
/// Suit colour for the rank/suit overlay rendered atop the constant
/// fallback sprite (only fires under `MinimalPlugins` — production
/// renders the suit glyph baked into the PNG). 2-colour traditional
/// pairing — hearts + diamonds share the saturated red, clubs +
/// spades share the near-white. Two accessibility flags compose:
///
/// - `color_blind`: red-suit cards swap to `RED_SUIT_COLOUR_CBM`
/// (lime) — the "Settings toggle swaps red→lime" half of the
/// design system's colour-blind support. CBM is a hue-replacement
/// for red, so HC has no further effect on red when CBM is on
/// (the lime is itself a high-luminance colour).
/// - `high_contrast`: when CBM is off, red suits boost to
/// `RED_SUIT_COLOUR_HC` (`#ff6868`); black suits boost from
/// `#e8e8e8` (near-white) to `#f5f5f5` (`TEXT_PRIMARY_HC`).
///
/// The other half of CBM support (always-on filled-vs-outlined
/// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art
/// and has no constant-fallback equivalent.
pub(super) fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color {
if card.suit().is_red() {
if color_blind {
// CBM lime wins — the colour-blind swap replaces the
// red hue entirely, and the lime is already high-
// luminance, so an HC boost on top has nothing to do.
RED_SUIT_COLOUR_CBM
} else if high_contrast {
RED_SUIT_COLOUR_HC
} else {
RED_SUIT_COLOUR
}
} else if high_contrast {
TEXT_PRIMARY_HC
} else {
BLACK_SUIT_COLOUR
}
}
pub(super) fn label_visibility(face_up: bool) -> Visibility {
if face_up {
Visibility::Inherited
} else {
Visibility::Hidden
}
}
/// Rank+suit string for the readability overlay on touch HUD layouts.
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660U+2666, covered by FiraMono).
pub(super) fn mobile_label_for(card: &Card) -> String {
let rank = match card.rank() {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Seven => "7",
Rank::Eight => "8",
Rank::Nine => "9",
Rank::Ten => "10",
Rank::Jack => "J",
Rank::Queen => "Q",
Rank::King => "K",
};
let suit = match card.suit() {
Suit::Clubs => "",
Suit::Diamonds => "",
Suit::Hearts => "",
Suit::Spades => "",
};
format!("{rank}{suit}")
}
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
/// face-up cards. The background sprite covers the card art's own small
/// corner text so only the large overlay is visible.
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
/// face-up cards using FiraMono (passed via `font_handle`) so that the
/// suit Unicode glyphs U+2660U+2666 render correctly. Without an explicit
/// font handle Bevy falls back to its built-in face which does not include
/// those glyphs, causing a coloured missing-glyph rectangle to appear in
/// the text colour — the root cause of the "red square on face-down cards"
/// visual bug (the box bleeds through near the card edge at z=0.02).
pub(super) fn add_android_corner_label(
parent: &mut ChildSpawnerCommands,
card: &Card,
face_up: bool,
card_size: Vec2,
color_blind: bool,
high_contrast: bool,
font_handle: Option<&Handle<Font>>,
) {
if !face_up {
return;
}
let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE;
let inset = 3.0_f32;
// Background covers ~3 monospace chars wide × 1 line tall.
// FiraMono char width ≈ 0.6 × font_size; 2.0× gives room for "10♠"
// (3 chars = 1.8× font_size) plus a small margin.
let bg_w = font_size * 2.0;
let bg_h = font_size * 1.25;
// Background covers the PNG's baked-in small corner text (top-left).
// Classic PNG cards have a white face, so the background must be white too.
// (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.)
parent.spawn((
AndroidCornerBg,
Sprite {
color: Color::WHITE,
custom_size: Some(Vec2::new(bg_w, bg_h)),
..default()
},
Transform::from_xyz(
-card_size.x / 2.0 + inset + bg_w / 2.0,
card_size.y / 2.0 - inset - bg_h / 2.0,
0.015,
),
));
// Cover the matching rotated baked-in text at the bottom-right corner.
parent.spawn((
AndroidCornerBg,
Sprite {
color: Color::WHITE,
custom_size: Some(Vec2::new(bg_w, bg_h)),
..default()
},
Transform::from_xyz(
card_size.x / 2.0 - inset - bg_w / 2.0,
-card_size.y / 2.0 + inset + bg_h / 2.0,
0.015,
),
));
// Large rank+suit text drawn on top of the background. FiraMono must be
// wired here explicitly — the suit glyphs (U+2660U+2666) are not in
// Bevy's built-in font and render as a coloured rectangle without it.
//
// Classic PNG cards have a white face: red suits stay the same saturated
// red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
// rather than the near-white BLACK_SUIT_COLOUR designed for the dark
// Terminal theme background.
let text_col = if card.suit().is_red() {
if color_blind {
RED_SUIT_COLOUR_CBM
} else if high_contrast {
RED_SUIT_COLOUR_HC
} else {
RED_SUIT_COLOUR
}
} else {
CARD_FACE_COLOUR
};
let label_text = mobile_label_for(card);
parent.spawn((
AndroidCornerLabel(label_text.clone()),
CardLabel,
Text2d::new(label_text),
TextFont {
font: font_handle.cloned().unwrap_or_default(),
font_size,
..default()
},
TextColor(text_col),
Anchor::TOP_LEFT,
Transform::from_xyz(-card_size.x / 2.0 + inset, card_size.y / 2.0 - inset, 0.02),
));
}
// ---------------------------------------------------------------------------
// Task #34 — Card-flip animation systems
// ---------------------------------------------------------------------------
+351
View File
@@ -0,0 +1,351 @@
//! Resize handling: window-resize snapping, in-place card resizing,
//! and tableau fan spread.
use super::*;
use std::collections::HashMap;
use bevy::window::WindowResized;
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
use crate::animation_plugin::CardAnim;
use crate::events::StateChangedEvent;
use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource;
use crate::table_plugin::PileMarker;
use crate::ui_theme::{
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG,
CARD_SHADOW_PADDING_IDLE,
};
/// Coalesces every `WindowResized` event arriving this frame into the latest
/// pending size on [`ResizeThrottle`].
///
/// `WindowResized` fires per pixel of resize drag, so a fast corner drag can
/// emit many events per frame. Reading `.last()` keeps only the final size —
/// every frame's snap target is the most recent window size, never a stale
/// one. Pending stays set across frames until the throttled applier consumes
/// it; that's how we still flush the final "release" position when the user
/// stops dragging.
pub(super) fn collect_resize_events(
mut events: MessageReader<WindowResized>,
mut throttle: ResMut<ResizeThrottle>,
) {
if let Some(ev) = events.read().last() {
throttle.pending = Some(Vec2::new(ev.width, ev.height));
}
}
/// Snaps every card sprite to its target position, size, and (in the
/// fallback Text2d label path) font size when the window is resized.
///
/// **In-place mutation only.** Resize is the hot path — events fire per
/// pixel of drag, so this system cannot afford the despawn/respawn churn
/// `update_card_entity` does. We mutate `Sprite.custom_size`, `Transform`,
/// and child `TextFont.font_size` directly, leaving the card image handle,
/// suit/rank, and `CardLabel` entity untouched. Cards keep their identity
/// across resizes; only their size and position change. The full repaint
/// path lives in [`update_card_entity`] and is still used by every non-resize
/// caller (deals, moves, flips, settings toggles).
///
/// **Throttled to ~20 Hz.** [`ResizeThrottle::pending`] is consumed at most
/// once per [`RESIZE_THROTTLE_SECS`]. When events stop arriving, the next
/// tick past the throttle window flushes the final size and clears
/// `pending`, so the steady-state always matches the user's release size.
///
/// **Cancels in-flight slides.** Any `CardAnim` is removed so a mid-slide
/// tween is not retargeted relative to the previous card-size's position.
///
/// The "↺" stock-empty label's `font_size` is derived from
/// `layout.card_size.x`, so this system also reapplies the stock indicator —
/// otherwise the label would not rescale on resize.
///
/// Scheduled after [`collect_resize_events`] (which itself runs after
/// `LayoutSystem::UpdateOnResize`) so `LayoutResource` reflects the latest
/// window size before we read it.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub(super) fn snap_cards_on_window_resize(
mut commands: Commands,
time: Res<Time>,
mut throttle: ResMut<ResizeThrottle>,
game: Option<Res<GameStateResource>>,
layout: Option<Res<LayoutResource>>,
card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
entities: Query<
(Entity, &CardEntity, &mut Sprite, &mut Transform),
(
Without<CardLabel>,
Without<CardShadow>,
Without<CardBackFrame>,
),
>,
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
shadow_query: Query<
&mut Sprite,
(
With<CardShadow>,
Without<CardEntity>,
Without<PileMarker>,
Without<CardBackFrame>,
),
>,
frame_query: Query<
&mut Sprite,
(
With<CardBackFrame>,
Without<CardEntity>,
Without<CardShadow>,
Without<PileMarker>,
),
>,
mut pile_markers: Query<
(Entity, &PileMarker, &mut Sprite),
(
Without<CardEntity>,
Without<CardShadow>,
Without<CardBackFrame>,
),
>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
if throttle.pending.is_none() {
return;
}
let now = time.elapsed_secs();
if !should_apply_resize(now, throttle.last_applied_secs) {
return;
}
let Some(game) = game else {
// Nothing to apply — clear pending so we don't busy-loop.
throttle.pending = None;
return;
};
let Some(layout) = layout else {
throttle.pending = None;
return;
};
resize_cards_in_place(
&mut commands,
&game.0,
&layout.0,
card_images.as_deref(),
entities,
label_query,
shadow_query,
frame_query,
);
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,
);
throttle.last_applied_secs = now;
throttle.pending = None;
}
/// In-place "size-only" sibling of [`sync_cards`]: walks every existing card
/// entity, updates `Sprite.custom_size` and the snap-`Transform` to match the
/// fresh layout, and (in fallback solid-colour mode) also updates the child
/// `TextFont.font_size` of any `CardLabel`. No despawning, no `Sprite`
/// replacement, no children rebuild — that's the entire point of this path.
///
/// Called only from the resize handler. Game-state changes (deals, moves,
/// flips, settings toggles) still flow through [`sync_cards`] /
/// [`update_card_entity`], which handle add/remove/repaint correctly.
///
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
/// retargeted relative to the previous card-size's position.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(super) fn resize_cards_in_place(
commands: &mut Commands,
game: &GameState,
layout: &Layout,
card_images: Option<&CardImageSet>,
mut entities: Query<
(Entity, &CardEntity, &mut Sprite, &mut Transform),
(
Without<CardLabel>,
Without<CardShadow>,
Without<CardBackFrame>,
),
>,
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
mut shadow_query: Query<
&mut Sprite,
(
With<CardShadow>,
Without<CardEntity>,
Without<PileMarker>,
Without<CardBackFrame>,
),
>,
mut frame_query: Query<
&mut Sprite,
(
With<CardBackFrame>,
Without<CardEntity>,
Without<CardShadow>,
Without<PileMarker>,
),
>,
) {
let positions = card_positions(game, layout);
let pos_by_id: HashMap<Card, (Vec2, f32)> = positions
.into_iter()
.map(|((c, _face_up), p, z)| (c, (p, z)))
.collect();
for (entity, marker, mut sprite, mut transform) in entities.iter_mut() {
let Some(&(pos, z)) = pos_by_id.get(&marker.card) else {
continue;
};
sprite.custom_size = Some(layout.card_size);
transform.translation.x = pos.x;
transform.translation.y = pos.y;
transform.translation.z = z;
// Cancel any in-flight slide so it doesn't retarget from a stale
// mid-animation position computed against the previous card size.
commands.entity(entity).remove::<CardAnim>();
}
// Resize every per-card shadow halo to match the new card size. Both
// idle and drag states scale with the card body, so we preserve the
// *current* padding (idle vs drag) by keeping the alpha as-is and only
// recomputing the geometry. The drag-tracking system runs every frame
// and will retune offset / alpha / padding-mode within one frame if the
// drag state diverges from the resized geometry.
let idle_padding = CARD_SHADOW_PADDING_IDLE;
let drag_padding = CARD_SHADOW_PADDING_DRAG;
for mut shadow_sprite in shadow_query.iter_mut() {
// Choose padding based on the shadow's current alpha — preserves
// a lifted shadow's larger halo across resize without needing to
// plumb DragState through the resize handler.
let alpha = shadow_sprite.color.alpha();
let padding = if alpha >= CARD_SHADOW_ALPHA_DRAG - 0.001 {
drag_padding
} else {
idle_padding
};
shadow_sprite.custom_size = Some(layout.card_size + padding);
}
// Only the solid-colour fallback path uses CardLabel/Text2d overlays;
// when PNG faces are loaded the rank/suit are baked into the image and
// there is nothing to resize on the label side.
if card_images.is_none() {
let new_font_size = layout.card_size.x * FONT_SIZE_FRAC;
for mut font in label_query.iter_mut() {
font.font_size = new_font_size;
}
}
// Resize every face-down border frame to match the new card size.
let frame_size = layout.card_size + Vec2::splat(CARD_BACK_FRAME_PADDING);
for mut frame_sprite in frame_query.iter_mut() {
frame_sprite.custom_size = Some(frame_size);
}
}
/// Updates font size and top-left anchor transform of every
/// [`AndroidCornerLabel`] entity when `LayoutResource` changes (orientation
/// change or any window resize). The full despawn/respawn path in
/// `update_card_entity` already handles game-state changes; this system
/// covers the resize-only path where children are mutated in place.
pub(super) fn resize_android_corner_labels(
layout: Res<LayoutResource>,
card_images: Option<Res<CardImageSet>>,
mut text_query: Query<(
&AndroidCornerLabel,
&mut Text2d,
&mut TextFont,
&mut Transform,
)>,
mut bg_query: Query<(&mut Sprite, &mut Transform), AndroidCornerBgFilter>,
) {
if !layout.is_changed() || card_images.is_none() {
return;
}
let font_size = layout.0.card_size.x * FONT_SIZE_FRAC_MOBILE;
let inset = 3.0_f32;
let bg_w = font_size * 2.0;
let bg_h = font_size * 1.25;
let text_x = -layout.0.card_size.x / 2.0 + inset;
let text_y = layout.0.card_size.y / 2.0 - inset;
for (label, mut text2d, mut font, mut transform) in text_query.iter_mut() {
text2d.0 = label.0.clone();
font.font_size = font_size;
transform.translation.x = text_x;
transform.translation.y = text_y;
}
for (mut sprite, mut transform) in bg_query.iter_mut() {
sprite.custom_size = Some(Vec2::new(bg_w, bg_h));
transform.translation.x = text_x + bg_w / 2.0;
transform.translation.y = text_y - bg_h / 2.0;
}
}
/// Adjusts `LayoutResource.tableau_fan_frac` (and the face-down companion) so
/// the deepest tableau column fills the available vertical space at every stage
/// of play. Runs after every `StateChangedEvent`.
///
/// Depth is measured across *all* cards in a column, weighting each face-down
/// card by the fixed face-down/face-up step ratio. Counting the face-down
/// portion — not just the face-up tail — is what fills the lower screen on a
/// fresh deal (the deepest column is then six face-down cards under one face-up
/// one): the earlier face-up-only depth was 1, so the fan never spread and the
/// bottom half of a near-square viewport (e.g. an unfolded foldable) sat empty.
///
/// Deeper columns drive the fraction down so everything still fits the window;
/// [`crate::layout::TABLEAU_FAN_FRAC`] floors it to the desktop feel and
/// [`MAX_DYNAMIC_FAN_FRAC`] caps it so a near-empty column doesn't fling its few
/// cards far apart.
pub(super) fn update_tableau_fan_frac(
mut events: MessageReader<StateChangedEvent>,
game: Option<Res<GameStateResource>>,
mut layout: Option<ResMut<LayoutResource>>,
) {
if events.read().next().is_none() {
return;
}
let Some(game) = game else {
return;
};
let Some(layout) = layout.as_mut() else {
return;
};
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
}
/// PostStartup sibling of [`update_tableau_fan_frac`]. The initial deal is
/// inserted directly as `GameStateResource` at startup without a
/// `StateChangedEvent`, so the event-driven system never fires for it. This
/// runs once, before [`sync_cards_startup`] renders, so the very first board
/// (cold start) already fills the viewport — otherwise a fresh deal on a tall /
/// near-square screen (e.g. an unfolded foldable) renders with the unspread fan
/// and a large empty band below the tableau until the first move.
pub(super) fn fill_tableau_fan_on_startup(
game: Option<Res<GameStateResource>>,
mut layout: Option<ResMut<LayoutResource>>,
) {
let Some(game) = game else {
return;
};
let Some(layout) = layout.as_mut() else {
return;
};
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
}
File diff suppressed because it is too large Load Diff
+291
View File
@@ -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;
}
}
}
}
}
}
+748
View File
@@ -0,0 +1,748 @@
//! Card asset loading and entity lifecycle: the sync systems that
//! spawn, update, and position card entities from `GameStateResource`.
use super::*;
use std::collections::{HashMap, HashSet};
use bevy::color::Color;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation;
use crate::events::StateChangedEvent;
use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource};
use crate::platform::USE_TOUCH_UI_LAYOUT;
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
/// Rebuild the [`CardEntityIndex`] from the live `CardEntity` set.
///
/// Runs in `PostUpdate` so that all spawn/despawn `Commands` issued by
/// [`sync_cards_on_change`] and [`snap_cards_on_window_resize`] in `Update`
/// have been flushed at the `Update -> PostUpdate` apply-deferred boundary.
/// Rebuilding from scratch (rather than incrementally patching at every
/// spawn/despawn site — waste cards churn on every draw) keeps a single writer
/// and makes a stale entry structurally impossible.
///
/// Gated to changed frames only: `Changed<CardEntity>` fires the frame a card
/// is spawned, `RemovedComponents<CardEntity>` the frame one is despawned. The
/// `card` field is write-once (never mutated in place), so card-reposition
/// frames don't trip `Changed` and correctly skip the O(52) rebuild.
pub(super) fn rebuild_card_entity_index(
mut index: ResMut<CardEntityIndex>,
cards: Query<(Entity, &CardEntity)>,
changed: Query<(), Changed<CardEntity>>,
removed: RemovedComponents<CardEntity>,
) {
if changed.is_empty() && removed.is_empty() {
return;
}
let map = &mut index.0;
map.clear();
for (entity, ce) in &cards {
map.insert(ce.card.clone(), entity);
}
}
/// Returns the relative asset path for a card face PNG.
///
/// The path format is `cards/faces/classic/{RANK}{SUIT}.png`, e.g. `QS.png`
/// for the Queen of Spades. Both `load_card_images` and the unit tests use
/// this function so the filename formula is tested in isolation from the
/// asset-loading machinery.
///
/// Note: this function verifies only the **code-side mapping**. If the PNG
/// file at the returned path contains wrong artwork (e.g. `QS.png` has a
/// diamond watermark baked in), that is an **asset content bug** and must be
/// fixed by replacing the file — no code change can correct it.
pub(super) fn card_face_asset_path(rank: Rank, suit: Suit) -> String {
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
const RANK_STRS: [&str; 13] = [
"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
];
let suit_idx = match suit {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
};
let rank_idx = match rank {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
Rank::Four => 3,
Rank::Five => 4,
Rank::Six => 5,
Rank::Seven => 6,
Rank::Eight => 7,
Rank::Nine => 8,
Rank::Ten => 9,
Rank::Jack => 10,
Rank::Queen => 11,
Rank::King => 12,
};
format!(
"cards/faces/classic/{}{}.png",
RANK_STRS[rank_idx], SUIT_CHARS[suit_idx]
)
}
/// Loads card face and back PNGs at startup via [`AssetServer`] and inserts
/// [`CardImageSet`].
///
/// Faces: `assets/cards/faces/{RANK}{SUIT}.png` (e.g. `AC.png`, `10H.png`)
/// Backs: `assets/cards/backs/back_{0..4}.png`
///
/// Under `MinimalPlugins` (tests) `AssetServer` is absent, so the system
/// returns without inserting `CardImageSet` and the plugin falls back to
/// solid-colour sprites.
pub(super) fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
let Some(asset_server) = asset_server else {
return;
};
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
const RANKS: [Rank; 13] = [
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
Rank::King,
];
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
std::array::from_fn(|ri| asset_server.load(card_face_asset_path(RANKS[ri], SUITS[si])))
});
let backs =
std::array::from_fn(|i| asset_server.load(format!("cards/backs/classic/back_{i}.png")));
commands.insert_resource(CardImageSet {
faces,
backs,
// Populated by the theme plugin once a `CardTheme` finishes loading.
// Until then the legacy back fallback (`backs[selected_card_back]`)
// is used.
theme_back: None,
});
}
/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
/// available and falling back to a solid-colour sprite in tests.
pub(super) fn card_sprite(
card: &Card,
face_up: bool,
card_size: Vec2,
back_colour: Color,
card_images: Option<&CardImageSet>,
selected_back: usize,
) -> Sprite {
if let Some(set) = card_images {
let image = if face_up {
let suit_idx = match card.suit() {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
};
let rank_idx = match card.rank() {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
Rank::Four => 3,
Rank::Five => 4,
Rank::Six => 5,
Rank::Seven => 6,
Rank::Eight => 7,
Rank::Nine => 8,
Rank::Ten => 9,
Rank::Jack => 10,
Rank::Queen => 11,
Rank::King => 12,
};
set.faces[suit_idx][rank_idx].clone()
} else if let Some(theme_back) = &set.theme_back {
// Active theme provides its own back — always wins over the
// legacy `selected_card_back` picker, so a theme switch swaps
// faces *and* the back. The picker is treated as informational
// only while a theme back is active (see settings_plugin).
theme_back.clone()
} else {
let idx = selected_back.min(set.backs.len() - 1);
set.backs[idx].clone()
};
Sprite {
image,
color: Color::WHITE,
custom_size: Some(card_size),
..default()
}
} else {
// Terminal aesthetic: face background is uniformly CARD_FACE_COLOUR
// regardless of colour-blind mode (CBM differentiation now lives in
// the suit glyph colour, applied by `text_colour`, not the face
// background). Pre-Terminal this branch dispatched through a
// separate `face_colour(card, color_blind)` helper.
let body_colour = if face_up {
CARD_FACE_COLOUR
} else {
back_colour
};
Sprite {
color: body_colour,
custom_size: Some(card_size),
..default()
}
}
}
/// When card-back selection changes in Settings, re-render all cards so the
/// new back colour is applied immediately (without waiting for a state change).
pub(super) fn resync_cards_on_settings_change(
mut setting_events: MessageReader<SettingsChangedEvent>,
mut state_events: MessageWriter<StateChangedEvent>,
) {
if setting_events.read().next().is_some() {
state_events.write(StateChangedEvent);
}
}
/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
/// have already completed.
#[allow(clippy::too_many_arguments)]
pub(super) fn sync_cards_startup(
commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<CardSyncData>,
card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
) {
if let Some(layout) = layout {
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
let back_colour = card_back_colour(selected_back);
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
let font_handle = font_res.as_ref().map(|r| &r.0);
sync_cards(
commands,
&game.0,
&layout.0,
slide_secs,
back_colour,
color_blind,
high_contrast,
&entities,
card_images.as_deref(),
selected_back,
font_handle,
);
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn sync_cards_on_change(
mut events: MessageReader<StateChangedEvent>,
commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<CardSyncData>,
card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
) {
if events.read().next().is_none() {
return;
}
if let Some(layout) = layout {
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
let back_colour = card_back_colour(selected_back);
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
let font_handle = font_res.as_ref().map(|r| &r.0);
sync_cards(
commands,
&game.0,
&layout.0,
slide_secs,
back_colour,
color_blind,
high_contrast,
&entities,
card_images.as_deref(),
selected_back,
font_handle,
);
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn sync_cards(
mut commands: Commands,
game: &GameState,
layout: &Layout,
slide_secs: f32,
back_colour: Color,
color_blind: bool,
high_contrast: bool,
entities: &Query<CardSyncData>,
card_images: Option<&CardImageSet>,
selected_back: usize,
font_handle: Option<&Handle<Font>>,
) {
let positions = card_positions(game, layout);
// The waste buffer card exists only to keep its entity alive while the new
// top card's slide animation plays — it must never be visible to the player.
// Without this, the buffer sits at waste_base uncovered during the animation
// and its rank/suit peek behind the incoming card.
let waste_buffer_id: Option<Card> = {
let visible = match game.draw_mode() {
DrawStockConfig::DrawOne => 1_usize,
DrawStockConfig::DrawThree => 3_usize,
};
let waste_cards = game.waste_cards();
(waste_cards.len() > visible)
.then_some(waste_cards)
.and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned())
.map(|(c, _face_up)| c)
};
// Map Card -> (Entity, current_translation, anim_end) for in-place
// updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
// is currently driving the card (e.g. a drag-rejection return tween).
//
// In the position loop below we compare `anim_end` against the new game-
// state target position to decide whether to honour or cancel the tween:
// • end ≈ target → animation is still heading to the right place; let
// it finish (skip the snap/slide path).
// • end ≠ target → the game state has changed (e.g. a new game started
// while the win-cascade was mid-flight); cancel the
// stale `CardAnimation` and apply the new position.
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>, Option<CardChildrenKey>)> =
HashMap::new();
for (entity, marker, transform, anim, children_key) in entities.iter() {
existing.insert(
marker.card.clone(),
(
entity,
transform.translation,
anim.map(|a| a.end),
children_key.copied(),
),
);
}
let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect();
// Despawn any entity whose card is no longer tracked.
for (card, (entity, _, _, _)) in &existing {
if !live_ids.contains(card) {
commands.entity(*entity).despawn();
}
}
// For each card in the current state: spawn or update its entity, then
// apply visibility. The waste buffer card is hidden so it cannot peek
// behind the incoming top card during the draw slide animation.
for ((card, face_up), position, z) in positions {
let entity = match existing.get(&card) {
Some(&(entity, cur, anim_end, children_key)) => {
// If a CardAnimation is in flight, check whether its destination
// still matches the game-state target. If the game moved the card
// elsewhere (e.g. new game started during a win-cascade scatter),
// cancel the stale tween so the card snaps/slides to its new home.
let has_anim = match anim_end {
Some(end_xy) if (end_xy - position).length() > 2.0 => {
commands.entity(entity).remove::<CardAnimation>();
false
}
Some(_) => true,
None => false,
};
update_card_entity(
&mut commands,
entity,
&card,
face_up,
position,
z,
layout,
slide_secs,
back_colour,
color_blind,
high_contrast,
cur,
has_anim,
children_key,
card_images,
selected_back,
font_handle,
);
entity
}
None => spawn_card_entity(
&mut commands,
&card,
face_up,
position,
z,
layout,
back_colour,
color_blind,
high_contrast,
card_images,
selected_back,
font_handle,
),
};
let visibility = if waste_buffer_id.as_ref() == Some(&card) {
Visibility::Hidden
} else {
Visibility::Inherited
};
commands.entity(entity).insert(visibility);
}
}
/// Returns an ordered vec of ((card, face_up), position, z) for every card in the game.
pub(super) fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, f32)> {
let mut out: Vec<((Card, bool), Vec2, f32)> = Vec::with_capacity(52);
let piles = [
(KlondikePile::Stock, true),
(KlondikePile::Stock, false),
(KlondikePile::Foundation(Foundation::Foundation1), false),
(KlondikePile::Foundation(Foundation::Foundation2), false),
(KlondikePile::Foundation(Foundation::Foundation3), false),
(KlondikePile::Foundation(Foundation::Foundation4), false),
(KlondikePile::Tableau(Tableau::Tableau1), false),
(KlondikePile::Tableau(Tableau::Tableau2), false),
(KlondikePile::Tableau(Tableau::Tableau3), false),
(KlondikePile::Tableau(Tableau::Tableau4), false),
(KlondikePile::Tableau(Tableau::Tableau5), false),
(KlondikePile::Tableau(Tableau::Tableau6), false),
(KlondikePile::Tableau(Tableau::Tableau7), false),
];
// Draw-Three waste fan step, proportional to the column spacing so it scales
// with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via
// `waste_fan_step` so the two never drift (a drift puts the top fanned card's
// click target on the card beneath it).
let waste_fan_step = waste_fan_step(layout);
for (pile_type, is_stock_area) in piles {
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
continue;
};
if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
base.x -= tableau_col_step(layout);
}
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
let cards = if matches!(pile_type, KlondikePile::Stock) {
if is_stock_area {
game.stock_cards()
} else {
game.waste_cards()
}
} else {
game.pile(pile_type)
};
// Tableau uses a two-speed fan: face-down cards are packed tighter
// than face-up cards so the visible (playable) portion stands out.
// Non-tableau piles stack with a negligible offset.
//
// Waste pile: only the top N cards are rendered to prevent bleed-through
// while new cards animate in from the stock. Draw-One shows 1; Draw-Three
// shows up to 3 fanned in X (matching the standard Klondike presentation).
let render_start = if is_waste {
let visible = match game.draw_mode() {
DrawStockConfig::DrawOne => 1_usize,
DrawStockConfig::DrawThree => 3_usize,
};
// Render one extra card so that the card sliding off the waste
// during a draw animation is still present in the world at z=0
// (hidden under the stack) rather than vanishing mid-tween.
cards.len().saturating_sub(visible + 1)
} else {
0
};
let mut y_offset = 0.0_f32;
let rendered_len = cards[render_start..].len();
for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() {
let x_offset = if is_waste && matches!(game.draw_mode(), DrawStockConfig::DrawThree) {
// When len > visible, slot 0 is a hidden buffer card kept at
// x=0 to prevent a flash during the draw tween. When len ≤
// visible (small pile), every card is visible and should fan
// normally — no card is hidden, so the shift is 0.
let visible = 3_usize;
let hidden = rendered_len.saturating_sub(visible);
slot.saturating_sub(hidden) as f32 * waste_fan_step
} else {
0.0
};
let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
out.push(((card.clone(), *face_up), pos, z));
if is_tableau {
let step = if *face_up {
layout.tableau_fan_frac
} else {
layout.tableau_facedown_fan_frac
};
y_offset -= layout.card_size.y * step;
}
}
}
out
}
pub(super) fn all_cards(game: &GameState) -> Vec<(Card, bool)> {
let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52);
cards.extend(game.stock_cards());
cards.extend(game.waste_cards());
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
cards.extend(game.pile(KlondikePile::Foundation(foundation)));
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
cards.extend(game.pile(KlondikePile::Tableau(tableau)));
}
cards
}
#[allow(clippy::too_many_arguments)]
pub(super) fn spawn_card_entity(
commands: &mut Commands,
card: &Card,
face_up: bool,
pos: Vec2,
z: f32,
layout: &Layout,
back_colour: Color,
color_blind: bool,
high_contrast: bool,
card_images: Option<&CardImageSet>,
selected_back: usize,
font_handle: Option<&Handle<Font>>,
) -> Entity {
let sprite = card_sprite(
card,
face_up,
layout.card_size,
back_colour,
card_images,
selected_back,
);
let mut entity = commands.spawn((
CardEntity { card: card.clone() },
sprite,
Transform::from_xyz(pos.x, pos.y, z),
Visibility::default(),
));
let entity_id = entity.id();
// Every card gets a subtle drop-shadow child so the play surface reads
// as physical instead of flat. Spawned in idle state; the drag-tracking
// system retunes its offset / alpha when this card joins the dragged
// stack.
entity.with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
// Every card gets a thin border frame so it reads as a distinct
// rectangle against the dark felt, regardless of face state.
entity.with_children(|b| {
add_card_back_frame_child(b, layout.card_size);
});
// When PNG faces are loaded the rank/suit are baked into the image.
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
// On Android we additionally spawn a large-print corner label even in
// image mode so the rank/suit are legible at phone scale.
if card_images.is_none() {
entity.with_children(|b| {
b.spawn((
CardLabel,
Text2d::new(label_for(card)),
TextFont {
font_size: layout.card_size.x * FONT_SIZE_FRAC,
..default()
},
TextColor(text_colour(card, color_blind, high_contrast)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(face_up),
));
});
}
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
entity.with_children(|b| {
add_android_corner_label(
b,
card,
face_up,
layout.card_size,
color_blind,
high_contrast,
font_handle,
);
});
}
// Record the appearance signature so subsequent `update_card_entity` calls
// can skip rebuilding these children until one of the inputs changes.
entity.insert(CardChildrenKey {
face_up,
card_size: layout.card_size,
color_blind,
high_contrast,
});
entity_id
}
#[allow(clippy::too_many_arguments)]
pub(super) fn update_card_entity(
commands: &mut Commands,
entity: Entity,
card: &Card,
face_up: bool,
pos: Vec2,
z: f32,
layout: &Layout,
slide_secs: f32,
back_colour: Color,
color_blind: bool,
high_contrast: bool,
cur: Vec3,
has_card_animation: bool,
existing_children_key: Option<CardChildrenKey>,
card_images: Option<&CardImageSet>,
selected_back: usize,
font_handle: Option<&Handle<Font>>,
) {
let target = Vec3::new(pos.x, pos.y, z);
// Always refresh the visual appearance.
commands.entity(entity).insert(card_sprite(
card,
face_up,
layout.card_size,
back_colour,
card_images,
selected_back,
));
// Skip the snap/slide path entirely when a curve-based `CardAnimation`
// is driving this card (e.g. the drag-rejection return tween). Writing
// `Transform` here would race that animation each frame and cause a
// visible jump. The animation system snaps the final position itself
// when it completes.
if !has_card_animation {
// Slide to the new position when it differs meaningfully; snap otherwise.
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
// Lift the card immediately on the first frame of the animation so
// it never appears behind a card that is already resting at the
// destination slot. `advance_card_anims` will maintain this lift
// throughout the tween and snap to `target` (without lift) on
// completion.
let start = Vec3::new(cur.x, cur.y, z + CARD_ANIM_Z_LIFT);
commands
.entity(entity)
.insert(Transform::from_translation(start))
.insert(CardAnim {
start,
target,
elapsed: 0.0,
duration: slide_secs,
delay: 0.0,
});
} else {
commands
.entity(entity)
.remove::<CardAnim>()
.insert(Transform::from_xyz(pos.x, pos.y, z));
}
}
// Rebuild the card's child visuals (drop-shadow, border frame, and the
// rank/suit label / large-print corner overlay) only when an input that
// affects them actually changed. The child set depends solely on
// `CardChildrenKey`; the face/back image is carried by the always-refreshed
// `Sprite` above, so theme/card-back swaps need no child rebuild. Skipping
// this on a position-only move avoids despawning and respawning the child
// entities (incl. a `Text2d` glyph re-layout) for all 52 cards on every
// `StateChangedEvent` — the spike that stuttered the slide animation on
// high-resolution devices.
let new_children_key = CardChildrenKey {
face_up,
card_size: layout.card_size,
color_blind,
high_contrast,
};
if existing_children_key != Some(new_children_key) {
commands.entity(entity).despawn_related::<Children>();
commands.entity(entity).with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
commands.entity(entity).with_children(|b| {
add_card_back_frame_child(b, layout.card_size);
});
if card_images.is_none() {
commands.entity(entity).with_children(|b| {
b.spawn((
CardLabel,
Text2d::new(label_for(card)),
TextFont {
font_size: layout.card_size.x * FONT_SIZE_FRAC,
..default()
},
TextColor(text_colour(card, color_blind, high_contrast)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(face_up),
));
});
}
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
commands.entity(entity).with_children(|b| {
add_android_corner_label(
b,
card,
face_up,
layout.card_size,
color_blind,
high_contrast,
font_handle,
);
});
}
commands.entity(entity).insert(new_children_key);
}
}
@@ -3,6 +3,15 @@ use crate::game_plugin::GamePlugin;
use crate::layout::TABLEAU_FAN_FRAC;
use crate::table_plugin::TablePlugin;
use solitaire_core::Deck;
use bevy::window::WindowResized;
use solitaire_core::{Card, Rank, Suit};
use std::collections::HashSet;
use crate::events::StateChangedEvent;
use crate::layout::LayoutResource;
use crate::resources::DragState;
use crate::ui_theme::TEXT_PRIMARY_HC;
use solitaire_core::{DrawStockConfig, game_state::GameState};
/// Convenience constructor — all unit tests use Deck1.
fn make_card(suit: Suit, rank: Rank) -> Card {