//! 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` 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`]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; 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; 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>, } /// 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::()` + 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); impl CardEntityIndex { /// Resolve a card to its live entity, if one is currently spawned. #[inline] pub fn get(&self, card: &Card) -> Option { 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, Without); /// 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, /// `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`. 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::>() .init_resource::() .init_resource::() .add_message::() .add_message::() .add_message::() .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::), 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;