refactor(engine): split card_plugin runtime code into submodules
Final runtime split for #118: the 2,536-line mod.rs becomes seven focused submodules along existing system boundaries — mod.rs 574 fan-step helpers, CardImageSet, markers, plugin build sync.rs 748 asset loading + card entity lifecycle (spawn/update/position) layout.rs 351 resize snapping, in-place resize, tableau fan spread stock.rs 291 empty-stock recycle hint + count badge highlights.rs 276 hint/right-click highlights, cursor hit-testing labels.rs 208 desktop text labels + Android corner labels anim.rs 200 flip animation, drag shadows Moved items are pub(super); code moved verbatim apart from relocating the two stock colour constants next to their consumers. No behaviour change; all 70 card tests pass unchanged. Refs #118 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user