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>
613 lines
27 KiB
Rust
613 lines
27 KiB
Rust
//! PNG-based card rendering.
|
||
//!
|
||
//! Card entities are synced with [`GameStateResource`] on every
|
||
//! [`StateChangedEvent`]: missing cards are spawned, present cards are
|
||
//! repositioned/updated in place, and stale cards are despawned.
|
||
//!
|
||
//! When [`CardImageSet`] is available, each face-up card renders its own
|
||
//! 120×168 px `Handle<Image>` chosen from the 52 per-card PNGs loaded from
|
||
//! `assets/cards/faces/{rank}_{suit}.png`. A solid-colour `Sprite` with a
|
||
//! `Text2d` rank+suit overlay is used as a fallback when `CardImageSet` is
|
||
//! absent (e.g. in tests running under `MinimalPlugins`).
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use bevy::color::Color;
|
||
use bevy::prelude::*;
|
||
use solitaire_core::Card;
|
||
use solitaire_core::{KlondikePile, Tableau};
|
||
|
||
use crate::card_animation::CardAnimation;
|
||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
||
use crate::game_plugin::GameMutation;
|
||
use crate::layout::{Layout, LayoutSystem};
|
||
|
||
mod anim;
|
||
mod highlights;
|
||
mod labels;
|
||
mod layout;
|
||
mod stock;
|
||
mod sync;
|
||
|
||
use anim::*;
|
||
use highlights::*;
|
||
use labels::*;
|
||
use layout::*;
|
||
use stock::*;
|
||
use sync::*;
|
||
|
||
use crate::resources::GameStateResource;
|
||
use crate::settings_plugin::SettingsChangedEvent;
|
||
use crate::ui_theme::{
|
||
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
||
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
|
||
CARD_SHADOW_PADDING_IDLE,
|
||
};
|
||
|
||
/// Per-card vertical step for face-down tableau cards, as a fraction of
|
||
/// card height. Smaller than [`crate::layout::TABLEAU_FAN_FRAC`] because face-down cards
|
||
/// don't need their full body shown — only the back-pattern strip is
|
||
/// visible. Public so `input_plugin` can mirror the exact sprite layout
|
||
/// when hit-testing tableau columns; any drift between this and the
|
||
/// renderer creates a visible offset between the card face and where
|
||
/// clicks land.
|
||
///
|
||
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.14). Both constants must
|
||
/// stay in sync; the layout constant drives the adaptive LayoutResource value
|
||
/// used at runtime, while this one is the minimum floor used by
|
||
/// `update_tableau_fan_frac` when computing proportional updates.
|
||
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
|
||
|
||
/// Fraction of card height used as a tiny offset between stacked cards in
|
||
/// non-tableau piles, so stacking is visible. Public so other plugins
|
||
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
|
||
/// `Transform.translation.z` for a card at a given stack index without
|
||
/// drifting from the value used by [`card_positions`].
|
||
// Must exceed the highest child local-z of any card entity (0.02 for the
|
||
// Android corner label) so every card's sprite covers all children of the
|
||
// card below it. Raising from 0.003 → 0.025 fixes corner labels on
|
||
// foundation piles bleeding through when a 2 sits on an Ace.
|
||
pub const STACK_FAN_FRAC: f32 = 0.025;
|
||
|
||
/// Per-card horizontal fan step for the Draw-Three waste, in logical pixels.
|
||
///
|
||
/// Derived from the actual tableau column spacing (`Tableau2.x − Tableau1.x`)
|
||
/// rather than a fixed fraction of card width, so the fan scales with the
|
||
/// platform's `H_GAP_DIVISOR` (desktop ≈ 1.25×cw spacing, Android ≈ 1.03×cw).
|
||
/// Public so `input_plugin` can hit-test the fanned waste cards at the exact
|
||
/// x-offsets the renderer uses; any drift makes a click on the top fanned card
|
||
/// land on the card beneath it.
|
||
pub fn waste_fan_step(layout: &Layout) -> f32 {
|
||
tableau_col_step(layout) * 0.224
|
||
}
|
||
|
||
/// Horizontal distance between adjacent tableau columns (`Tableau2.x −
|
||
/// Tableau1.x`), in logical pixels. The face-down stock is rendered one column
|
||
/// step left of the waste, and the Draw-Three waste fan ([`waste_fan_step`]) is
|
||
/// a fraction of it. Public so hit-testing mirrors the renderer exactly.
|
||
pub fn tableau_col_step(layout: &Layout) -> f32 {
|
||
let t1 = layout
|
||
.pile_positions
|
||
.get(&KlondikePile::Tableau(Tableau::Tableau1))
|
||
.copied()
|
||
.unwrap_or_default();
|
||
let t2 = layout
|
||
.pile_positions
|
||
.get(&KlondikePile::Tableau(Tableau::Tableau2))
|
||
.copied()
|
||
.unwrap_or_default();
|
||
(t2.x - t1.x).abs()
|
||
}
|
||
|
||
/// Font size as a fraction of card width.
|
||
const FONT_SIZE_FRAC: f32 = 0.28;
|
||
|
||
/// Font-size fraction for the large-print readability overlay on touch HUD layouts.
|
||
/// Spawned on top of PNG face cards to make the rank+suit legible at phone
|
||
/// scale, where the baked-in PNG corner text is only ~10 px physical.
|
||
const FONT_SIZE_FRAC_MOBILE: f32 = 0.35;
|
||
|
||
/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
|
||
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
|
||
/// Suit colour for hearts + diamonds — saturated red `#e35353`.
|
||
/// 2-colour traditional pairing (the "Microsoft Solitaire on dark
|
||
/// mode" feel) replacing the brief 4-colour-deck experiment that
|
||
/// shipped between v0.21.0 and this commit. Brighter and more
|
||
/// saturated than the v0.21.0 pink `#fb9fb1` so the cards read as
|
||
/// a "real solitaire deck" rather than a Terminal-pastel theme.
|
||
/// Visually distinct from `ACCENT_PRIMARY` (`#a54242` brick red,
|
||
/// darker) so chrome and suit don't read as the same hue.
|
||
pub const RED_SUIT_COLOUR: Color = Color::srgb(0.890, 0.325, 0.325);
|
||
/// High-contrast variant of [`RED_SUIT_COLOUR`] — `#ff6868`. Lifted
|
||
/// luminance for the Settings → Accessibility → High-contrast mode
|
||
/// toggle. Pre-2-colour-revert this was `#ff8aa0` (pink-salmon)
|
||
/// matching the v0.21.0 pink default; rebumped to a brighter red
|
||
/// so it reads as "more chromatic" than the new saturated default,
|
||
/// not "less saturated." Independent of `RED_SUIT_COLOUR_CBM`
|
||
/// (lime) — high-contrast is *additive* over the default colour
|
||
/// palette; CBM is a *replacement* of red with a hue-distinct
|
||
/// alternative. The two modes can stack; CBM wins when both are on
|
||
/// because the CBM lime is itself a high-contrast colour.
|
||
pub const RED_SUIT_COLOUR_HC: Color = Color::srgb(1.000, 0.408, 0.408);
|
||
/// Suit colour for spades + clubs — near-white `#e8e8e8`. Brighter
|
||
/// than `TEXT_PRIMARY` (`#d0d0d0`, foreground gray) so the
|
||
/// "black suit" reads as a distinct, chromatic-neutral counterpart
|
||
/// to the new saturated red, not as "the same gray as body text."
|
||
/// `TEXT_PRIMARY_HC` (`#f5f5f5`) is still brighter for the
|
||
/// high-contrast boost path.
|
||
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.910, 0.910, 0.910);
|
||
|
||
/// Canonical outer index of `s` in [`CardImageSet::faces`].
|
||
///
|
||
/// Derived from the upstream `card_game::Suit` discriminants (0..=3 in
|
||
/// `Suit::SUITS` order), so every reader and writer of `faces` computes
|
||
/// the same layout from the same source. Three hand-rolled copies of this
|
||
/// mapping once lived in card_plugin and theme/plugin and were one
|
||
/// reorder away from drawing the wrong art.
|
||
pub(crate) const fn suit_index(s: solitaire_core::Suit) -> usize {
|
||
s as usize
|
||
}
|
||
|
||
/// Canonical inner index of `r` in [`CardImageSet::faces`] — upstream
|
||
/// `card_game::Rank` discriminants are 1..=13 in `Rank::RANKS` order.
|
||
pub(crate) const fn rank_index(r: solitaire_core::Rank) -> usize {
|
||
r as usize - 1
|
||
}
|
||
|
||
/// Pre-loaded [`Handle<Image>`]s for card face and back PNG textures.
|
||
///
|
||
/// Loaded once at startup by [`load_card_images`]. When this resource is
|
||
/// present, card sprites use the PNG artwork; otherwise they fall back to
|
||
/// solid-colour sprites (used in tests with `MinimalPlugins`).
|
||
#[derive(Resource)]
|
||
pub struct CardImageSet {
|
||
/// Per-card face images indexed by `[suit][rank]`.
|
||
///
|
||
/// Layout is pinned to the upstream declaration order — index with
|
||
/// [`suit_index`] / [`rank_index`], never a hand-rolled match.
|
||
/// Suit order: `Suit::SUITS` (Spades=0, Hearts=1, Clubs=2, Diamonds=3).
|
||
/// Rank order: `Rank::RANKS` (Ace=0 … King=12).
|
||
pub faces: [[Handle<Image>; 13]; 4],
|
||
/// One handle per unlockable card-back design (indices 0–4). These
|
||
/// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed
|
||
/// by `Settings::selected_card_back`. Used as a fallback when the active
|
||
/// theme does not provide its own back (see [`Self::theme_back`]).
|
||
pub backs: [Handle<Image>; 5],
|
||
/// Back image supplied by the currently-active card theme, if any.
|
||
///
|
||
/// Populated by `theme::plugin::apply_theme_to_card_image_set` whenever
|
||
/// a `CardTheme` finishes loading. The face-down render path in
|
||
/// [`card_sprite`] prefers this handle over the legacy `backs[]` array,
|
||
/// so a theme switch swaps both faces *and* the back without the player
|
||
/// needing to touch the legacy `selected_card_back` picker. `None` means
|
||
/// the active theme did not declare a back asset (or no theme has loaded
|
||
/// yet); in that case [`card_sprite`] falls back to the legacy array.
|
||
pub theme_back: Option<Handle<Image>>,
|
||
}
|
||
|
||
/// Suit-colour swap for red-suit cards in colour-blind mode — Terminal
|
||
/// `#acc267` (lime). Replaces `RED_SUIT_COLOUR` (pink) when CBM is on,
|
||
/// providing a hue-distinct alternative that survives the most common
|
||
/// red/green deficiencies. Pre-Terminal this was a *face tint*; the new
|
||
/// design moves CBM differentiation into the suit glyph colour itself
|
||
/// and keeps the face uniformly `CARD_FACE_COLOUR` regardless of CBM.
|
||
///
|
||
/// The CBM swap is lime (not the `ACCENT_PRIMARY` brick-red) because
|
||
/// the primary accent is itself in the red family — using it for
|
||
/// "the not-red CBM alternative" would defeat the purpose. Lime is
|
||
/// the next-best non-red base16-eighties accent; deuteranopia and
|
||
/// protanopia readers see it as visibly distinct from pink.
|
||
const RED_SUIT_COLOUR_CBM: Color = Color::srgb(0.675, 0.761, 0.404);
|
||
|
||
/// Returns the fallback card-back colour for the given unlocked card-back
|
||
/// index. Production renders backs from PNG artwork; this fallback only
|
||
/// fires under `MinimalPlugins` (tests). Mirrors the 5 accent colours
|
||
/// from `card_face_svg::BACK_ACCENTS` so the test-environment back lives
|
||
/// in the same hue family as the on-disk PNG art for that index.
|
||
fn card_back_colour(selected_card_back: usize) -> Color {
|
||
match selected_card_back {
|
||
0 => Color::srgb(0.647, 0.259, 0.259), // #a54242 brick red (Terminal canonical, ACCENT_PRIMARY)
|
||
1 => Color::srgb(0.675, 0.761, 0.404), // #acc267 lime
|
||
2 => Color::srgb(0.882, 0.639, 0.933), // #e1a3ee lavender
|
||
3 => Color::srgb(0.984, 0.624, 0.694), // #fb9fb1 pink
|
||
_ => Color::srgb(0.867, 0.698, 0.435), // #ddb26f gold (4+)
|
||
}
|
||
}
|
||
|
||
/// Marker component linking a Bevy entity to its `solitaire_core::Card`.
|
||
#[derive(Component, Debug, Clone)]
|
||
pub struct CardEntity {
|
||
pub card: Card,
|
||
}
|
||
|
||
/// Cached signature of the inputs that determine a card entity's *child*
|
||
/// visuals (drop-shadow, border frame, and the rank/suit label / large-print
|
||
/// corner overlay). Stored on each card so [`update_card_entity`] can skip the
|
||
/// expensive `despawn_related::<Children>()` + child respawn when nothing about
|
||
/// the appearance changed — the common case during a move, where only the
|
||
/// card's position changes. Without this guard every `StateChangedEvent`
|
||
/// rebuilds all 52 cards' children (≈250 entity spawn/despawns plus 52 `Text2d`
|
||
/// glyph re-layouts) in a single frame, which stutters the slide animation on
|
||
/// high-resolution devices.
|
||
///
|
||
/// The children depend only on these four inputs; the card identity is fixed
|
||
/// per entity, and the face/back *image* is handled by the always-refreshed
|
||
/// `Sprite` (a cheap handle swap), so theme/card-back changes need no child
|
||
/// rebuild.
|
||
#[derive(Component, Clone, Copy, PartialEq)]
|
||
struct CardChildrenKey {
|
||
face_up: bool,
|
||
card_size: Vec2,
|
||
color_blind: bool,
|
||
high_contrast: bool,
|
||
}
|
||
|
||
/// Query data read by the card-sync systems for each live card entity:
|
||
/// its id, card identity, current transform, any in-flight curve animation,
|
||
/// and its cached child-appearance key. Factored into an alias to keep the
|
||
/// system signatures readable (and satisfy clippy's `type_complexity`).
|
||
type CardSyncData = (
|
||
Entity,
|
||
&'static CardEntity,
|
||
&'static Transform,
|
||
Option<&'static CardAnimation>,
|
||
Option<&'static CardChildrenKey>,
|
||
);
|
||
|
||
/// Render-side index mapping each live board card to its [`CardEntity`].
|
||
///
|
||
/// Maintained exclusively by [`rebuild_card_entity_index`] in `PostUpdate`,
|
||
/// after the card-sync authority ([`sync_cards_on_change`]) and the resize
|
||
/// re-snap have flushed their spawn/despawn `Commands`. Consumers treat it as
|
||
/// read-only and must still call `Query::get(entity)` for components beyond the
|
||
/// `Entity` id (the map yields only the id).
|
||
///
|
||
/// Keyed by `Card`, which is unique across all live board entities in
|
||
/// single-deck Klondike. Transient entities (drag shadow, labels) carry no
|
||
/// `CardEntity` component, so the rebuild — which scans `&CardEntity` — never
|
||
/// records them.
|
||
#[derive(Resource, Debug, Default)]
|
||
pub struct CardEntityIndex(pub HashMap<Card, Entity>);
|
||
|
||
impl CardEntityIndex {
|
||
/// Resolve a card to its live entity, if one is currently spawned.
|
||
#[inline]
|
||
pub fn get(&self, card: &Card) -> Option<Entity> {
|
||
self.0.get(card).copied()
|
||
}
|
||
}
|
||
|
||
/// Marker for the text child inside a card entity.
|
||
#[derive(Component, Debug)]
|
||
pub struct CardLabel;
|
||
|
||
/// Marker for the large-print rank+suit corner overlay used by touch HUD layouts.
|
||
///
|
||
/// Spawned on top of PNG face cards (face-up only) at font size
|
||
/// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are
|
||
/// readable at phone scale. Only exists when `CardImageSet` is present
|
||
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
|
||
#[derive(Component, Debug, Clone)]
|
||
struct AndroidCornerLabel(pub String);
|
||
|
||
/// Solid-colour background sprite behind [`AndroidCornerLabel`].
|
||
///
|
||
/// Covers the card art's own small corner rank/suit text so only the
|
||
/// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived
|
||
/// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face.
|
||
#[derive(Component, Debug, Clone, Copy)]
|
||
struct AndroidCornerBg;
|
||
|
||
type AndroidCornerBgFilter = (With<AndroidCornerBg>, Without<AndroidCornerLabel>);
|
||
|
||
/// Marker component indicating the card is currently highlighted as a hint.
|
||
/// `remaining` counts down in real seconds; the highlight is removed when it
|
||
/// reaches zero and the card sprite colour is restored to its normal value.
|
||
#[derive(Component, Debug, Clone)]
|
||
pub struct HintHighlight {
|
||
/// Seconds remaining before the highlight is cleared.
|
||
pub remaining: f32,
|
||
}
|
||
|
||
/// Countdown (seconds) until the `HintHighlight` on a card entity is removed.
|
||
///
|
||
/// Inserted alongside `HintHighlight` by the hint-visual system. When the timer
|
||
/// reaches zero both `HintHighlight` and `HintHighlightTimer` are removed from
|
||
/// the entity and the sprite colour is restored.
|
||
#[derive(Component, Debug, Clone)]
|
||
pub struct HintHighlightTimer(pub f32);
|
||
|
||
/// Marker on a `PileMarker` entity that is highlighted because the right-clicked
|
||
/// card can legally be placed there.
|
||
#[derive(Component, Debug)]
|
||
pub struct RightClickHighlight;
|
||
|
||
/// Countdown (seconds) until this right-click destination highlight despawns.
|
||
///
|
||
/// Inserted alongside `RightClickHighlight` so that highlights auto-clear after
|
||
/// 1.5 s even if the player does not make a move or click again. The existing
|
||
/// clear-on-state-change and clear-on-pause logic still fires early when
|
||
/// appropriate.
|
||
#[derive(Component, Debug, Clone)]
|
||
pub struct RightClickHighlightTimer(pub f32);
|
||
|
||
/// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile
|
||
/// marker when the stock pile is empty.
|
||
#[derive(Component, Debug)]
|
||
pub struct StockEmptyLabel;
|
||
|
||
/// Marker on the chip-background sprite of the stock-pile remaining-count
|
||
/// badge.
|
||
///
|
||
/// The badge is spawned as a *top-level* world entity (not parented to the
|
||
/// stock [`PileMarker`]) and its `Transform` is recomputed each frame from
|
||
/// `LayoutResource` so it tracks the stock pile through window resizes.
|
||
/// The chip sits in the bottom-right corner of the stock pile and is hidden
|
||
/// while the stock is empty — the existing `↺` overlay
|
||
/// ([`StockEmptyLabel`]) covers the recycle hint instead, so the two
|
||
/// indicators never render simultaneously.
|
||
#[derive(Component, Debug)]
|
||
pub struct StockCountBadge;
|
||
|
||
/// Marker on the `Text2d` child of [`StockCountBadge`] showing the numeric
|
||
/// count of cards remaining in the stock pile.
|
||
///
|
||
/// Update systems query this component to write the new count in place rather
|
||
/// than despawning and respawning the text entity each tick.
|
||
#[derive(Component, Debug)]
|
||
pub struct StockCountBadgeText;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Task #34 — Card-flip animation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Phase of the two-stage flip animation.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum FlipPhase {
|
||
/// Scale X from 1.0 → 0.0 (hiding the back face).
|
||
ScalingDown,
|
||
/// Scale X from 0.0 → 1.0 (revealing the front face).
|
||
ScalingUp,
|
||
}
|
||
|
||
/// Drives a 2-phase "card flip" animation on `CardEntity` entities.
|
||
///
|
||
/// The animation squashes X to 0, swaps the sprite to the face-up colour,
|
||
/// then expands X back to 1. Total duration is `2 × FLIP_HALF_SECS`.
|
||
#[derive(Component, Debug, Clone)]
|
||
pub struct CardFlipAnim {
|
||
/// Seconds elapsed in the current phase.
|
||
pub timer: f32,
|
||
/// Which half of the flip we are in.
|
||
pub phase: FlipPhase,
|
||
}
|
||
|
||
/// Duration of each half of the flip animation (scale-down or scale-up).
|
||
const FLIP_HALF_SECS: f32 = 0.08;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Task #38 — Drag-elevation shadow
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Marker component for the semi-transparent shadow sprite shown while dragging.
|
||
#[derive(Component, Debug)]
|
||
pub struct ShadowEntity;
|
||
|
||
/// Marker component for the per-card drop-shadow child sprite.
|
||
///
|
||
/// Every `CardEntity` owns exactly one `CardShadow` child whose `Sprite` is a
|
||
/// neutral-black halo painted slightly down-and-right of the card. Idle state
|
||
/// uses [`CARD_SHADOW_OFFSET_IDLE`] / [`CARD_SHADOW_ALPHA_IDLE`]; while the
|
||
/// parent card is being dragged the shadow is pushed to the deeper
|
||
/// [`CARD_SHADOW_OFFSET_DRAG`] / [`CARD_SHADOW_ALPHA_DRAG`] values so the
|
||
/// stack reads as "lifted" off the felt.
|
||
#[derive(Component, Debug)]
|
||
pub struct CardShadow;
|
||
|
||
/// Marker on the thin contrasting border sprite spawned behind face-down cards.
|
||
///
|
||
/// Face-down cards use `back_0.png` which is near-black (`#1a1a1a`). On the
|
||
/// dark-green felt the edges are nearly invisible. This child sprite — slightly
|
||
/// larger than the card, rendered at local z=-0.01 so it peeks out as a thin
|
||
/// frame — gives every face-down card a visible perimeter.
|
||
#[derive(Component, Debug)]
|
||
pub struct CardBackFrame;
|
||
|
||
/// Fill colour for the face-down card border frame. Light-medium gray so it
|
||
/// reads as a clear "edge" without competing with the suit colours on face-up
|
||
/// cards. Brightened from `0.38` to `0.48` (≈ #7a7a7a) after a Pixel_7 smoke
|
||
/// test showed face-down `back_0.png` (≈ #1a1a1a) was nearly invisible against
|
||
/// the very dark `#151515` felt — the old gray was too close to the back fill
|
||
/// to define a crisp perimeter.
|
||
const CARD_BACK_FRAME_COLOR: Color = Color::srgb(0.48, 0.48, 0.48);
|
||
|
||
/// Extra width/height (in world units) added to each side of the card to form
|
||
/// the visible border. Widened from `3.0` to `6.0` so the frame peeks out as a
|
||
/// clearly readable perimeter at phone density (420 dpi) rather than a hairline.
|
||
const CARD_BACK_FRAME_PADDING: f32 = 6.0;
|
||
|
||
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
|
||
/// shadow given whether its parent card is currently part of the dragged
|
||
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
|
||
/// without spinning up a Bevy app.
|
||
///
|
||
/// `is_dragged = false` → resting `(IDLE, IDLE, IDLE)`
|
||
/// `is_dragged = true` → lifted `(DRAG, DRAG, DRAG)`
|
||
pub fn card_shadow_params(is_dragged: bool) -> (Vec2, Vec2, f32) {
|
||
if is_dragged {
|
||
(
|
||
CARD_SHADOW_OFFSET_DRAG,
|
||
CARD_SHADOW_PADDING_DRAG,
|
||
CARD_SHADOW_ALPHA_DRAG,
|
||
)
|
||
} else {
|
||
(
|
||
CARD_SHADOW_OFFSET_IDLE,
|
||
CARD_SHADOW_PADDING_IDLE,
|
||
CARD_SHADOW_ALPHA_IDLE,
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Builds the `Sprite` used for a per-card shadow at the resting state. The
|
||
/// alpha and size both use the idle tokens; `update_card_shadows_on_drag`
|
||
/// retunes them at runtime when the parent card joins / leaves the dragged
|
||
/// stack.
|
||
fn card_shadow_sprite(card_size: Vec2) -> Sprite {
|
||
let (_offset, padding, alpha) = card_shadow_params(false);
|
||
Sprite {
|
||
color: CARD_SHADOW_COLOR.with_alpha(alpha),
|
||
custom_size: Some(card_size + padding),
|
||
..default()
|
||
}
|
||
}
|
||
|
||
/// Builds the `Transform` used for a per-card shadow at the resting state.
|
||
/// Local — it is parented to the card entity, so positions are relative.
|
||
fn card_shadow_transform() -> Transform {
|
||
let (offset, _padding, _alpha) = card_shadow_params(false);
|
||
Transform::from_xyz(offset.x, offset.y, CARD_SHADOW_LOCAL_Z)
|
||
}
|
||
|
||
/// Spawns a single `CardShadow` child under the given card entity builder.
|
||
/// Extracted so `spawn_card_entity` and `update_card_entity` can share the
|
||
/// exact same shadow recipe — we never want one path to drift from the other.
|
||
fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||
parent.spawn((
|
||
CardShadow,
|
||
card_shadow_sprite(card_size),
|
||
card_shadow_transform(),
|
||
Visibility::default(),
|
||
));
|
||
}
|
||
|
||
/// Spawns a `CardBackFrame` child behind a card entity to give every card a
|
||
/// thin perimeter against the dark felt, regardless of face state.
|
||
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||
parent.spawn((
|
||
CardBackFrame,
|
||
Sprite {
|
||
color: CARD_BACK_FRAME_COLOR,
|
||
custom_size: Some(card_size + Vec2::splat(CARD_BACK_FRAME_PADDING)),
|
||
..default()
|
||
},
|
||
Transform::from_xyz(0.0, 0.0, -0.01),
|
||
Visibility::default(),
|
||
));
|
||
}
|
||
|
||
/// Throttle interval for resize-driven card snap work, in seconds.
|
||
///
|
||
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
|
||
/// produce dozens of events per frame. Re-running the per-card snap logic
|
||
/// (52 cards × sprite/transform/font_size touches) for every event is the
|
||
/// dominant cost of resize lag. We coalesce pending work and apply it at most
|
||
/// once per [`RESIZE_THROTTLE_SECS`] (~20 Hz). The user still sees updates
|
||
/// during a sustained drag, and the layout always catches up to the final
|
||
/// size when the drag stops because the pending size is held until applied.
|
||
const RESIZE_THROTTLE_SECS: f32 = 0.05;
|
||
|
||
/// Holds the latest pending window size from `WindowResized` events plus a
|
||
/// timestamp for the last applied snap, so the resize-snap work can be
|
||
/// rate-limited to ~20 Hz during sustained drags.
|
||
#[derive(Resource, Debug, Default)]
|
||
pub struct ResizeThrottle {
|
||
/// Latest unapplied window size from `WindowResized`. `None` when there is
|
||
/// nothing to apply.
|
||
pub pending: Option<Vec2>,
|
||
/// `Time::elapsed_secs()` value at the moment of the most recent applied
|
||
/// snap. `0.0` until the first apply.
|
||
pub last_applied_secs: f32,
|
||
}
|
||
|
||
/// Pure helper used by the throttled resize-snap system: returns `true` when
|
||
/// a pending resize should be flushed given the current `now_secs` and the
|
||
/// last-applied timestamp. Throttle interval is [`RESIZE_THROTTLE_SECS`].
|
||
///
|
||
/// Extracted so the rate-limit logic can be unit-tested without spinning up
|
||
/// a full Bevy app.
|
||
fn should_apply_resize(now_secs: f32, last_applied_secs: f32) -> bool {
|
||
(now_secs - last_applied_secs) >= RESIZE_THROTTLE_SECS
|
||
}
|
||
|
||
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
|
||
pub struct CardPlugin;
|
||
|
||
/// System set for everything that paints the board: card sprites, pile
|
||
/// markers, shadows, highlights, badges. Members mutate `Sprite` /
|
||
/// `Transform` on board entities and run as a deterministic chain (see the
|
||
/// registration in [`CardPlugin`]'s `build`); table-plugin marker painters
|
||
/// order themselves after this set. UI-domain systems that touch `Sprite`/
|
||
/// `Transform` on non-board entities (HUD text pulses, modal cards) declare
|
||
/// `.ambiguous_with(BoardVisuals)` instead — the entity domains are
|
||
/// disjoint by design (#143).
|
||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||
pub struct BoardVisuals;
|
||
|
||
impl Plugin for CardPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
// PostStartup ensures TablePlugin's Startup system has inserted
|
||
// LayoutResource before we try to read it.
|
||
//
|
||
// `handle_right_click` reads `ButtonInput<MouseButton>`. Under
|
||
// `MinimalPlugins` (tests) this resource is absent by default, so we
|
||
// ensure it exists here. Under `DefaultPlugins` the call is a no-op.
|
||
app.init_resource::<ButtonInput<MouseButton>>()
|
||
.init_resource::<ResizeThrottle>()
|
||
.init_resource::<CardEntityIndex>()
|
||
.add_message::<SettingsChangedEvent>()
|
||
.add_message::<CardFlippedEvent>()
|
||
.add_message::<CardFaceRevealedEvent>()
|
||
.add_systems(Startup, load_card_images)
|
||
.add_systems(
|
||
PostStartup,
|
||
(
|
||
// Fill the tableau fan before the first render so the
|
||
// cold-start deal already uses the full viewport height.
|
||
fill_tableau_fan_on_startup.before(sync_cards_startup),
|
||
sync_cards_startup,
|
||
update_stock_empty_indicator_startup,
|
||
),
|
||
)
|
||
// Layout recompute (UpdateOnResize) always precedes board
|
||
// painting, and the painters run as ONE deterministic chain in
|
||
// data-flow order: layout refinement → card authority → anims →
|
||
// shadows → highlights → indicators → resize snapping → labels.
|
||
// Every painter mutates card/marker Sprite+Transform, so without
|
||
// the chain each pair is a scheduler ambiguity (#143). All
|
||
// members are cheap and mostly change-gated; sequential
|
||
// execution is not a cost that matters here.
|
||
.configure_sets(Update, LayoutSystem::UpdateOnResize.before(BoardVisuals))
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
update_tableau_fan_frac,
|
||
resync_cards_on_settings_change,
|
||
sync_cards_on_change,
|
||
start_flip_anim,
|
||
tick_flip_anim,
|
||
update_drag_shadow,
|
||
update_card_shadows_on_drag,
|
||
handle_right_click,
|
||
tick_right_click_highlights,
|
||
clear_right_click_highlights_on_state_change,
|
||
clear_right_click_highlights_on_pause,
|
||
tick_hint_highlight,
|
||
update_stock_empty_indicator,
|
||
update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
|
||
collect_resize_events,
|
||
snap_cards_on_window_resize,
|
||
resize_android_corner_labels,
|
||
)
|
||
.chain()
|
||
.in_set(BoardVisuals)
|
||
.after(GameMutation),
|
||
);
|
||
|
||
app.add_systems(PostUpdate, rebuild_card_entity_index);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|