Merge pull request 'refactor(engine): split card_plugin runtime code into submodules' (#129) from refactor/card-plugin-submodules into master
This commit was merged in pull request #129.
This commit is contained in:
@@ -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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -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+2660–U+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+2660–U+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+2660–U+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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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::layout::TABLEAU_FAN_FRAC;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use solitaire_core::Deck;
|
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.
|
/// Convenience constructor — all unit tests use Deck1.
|
||||||
fn make_card(suit: Suit, rank: Rank) -> Card {
|
fn make_card(suit: Suit, rank: Rank) -> Card {
|
||||||
|
|||||||
Reference in New Issue
Block a user