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>
347 lines
13 KiB
Rust
347 lines
13 KiB
Rust
//! 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);
|
|
}
|