113a933170
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95) wraps signatures and call sites differently, so every touched file was picking up unrelated formatting hunks. One mechanical pass, and a 'cargo fmt --check' step in the test workflow (same pinned 1.95.0 toolchain) so drift can't accumulate again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
703 lines
26 KiB
Rust
703 lines
26 KiB
Rust
//! 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::{Card, Rank, Suit};
|
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
|
|
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;
|
|
};
|
|
|
|
// faces[suit_index(s)][rank_index(r)] — see the canonical helpers in
|
|
// card_plugin::mod; building from SUITS/RANKS order matches them.
|
|
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
|
|
std::array::from_fn(|ri| {
|
|
asset_server.load(card_face_asset_path(Rank::RANKS[ri], Suit::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 = suit_index(card.suit());
|
|
let rank_idx = rank_index(card.rank());
|
|
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 solitaire_core::FOUNDATIONS {
|
|
cards.extend(game.pile(KlondikePile::Foundation(foundation)));
|
|
}
|
|
for tableau in solitaire_core::TABLEAUS {
|
|
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);
|
|
}
|
|
}
|