Compare commits
2 Commits
60a80369d4
...
1719fdada0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1719fdada0 | |||
| 8dda9541a3 |
@@ -161,6 +161,40 @@ const FLIP_HALF_SECS: f32 = 0.08;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ShadowEntity;
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -173,6 +207,7 @@ impl Plugin for CardPlugin {
|
||||
// `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>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<CardFlippedEvent>()
|
||||
.add_message::<CardFaceRevealedEvent>()
|
||||
@@ -192,7 +227,8 @@ impl Plugin for CardPlugin {
|
||||
clear_right_click_highlights_on_state_change.after(GameMutation),
|
||||
clear_right_click_highlights_on_pause,
|
||||
update_stock_empty_indicator.after(GameMutation),
|
||||
snap_cards_on_window_resize.after(LayoutSystem::UpdateOnResize),
|
||||
collect_resize_events.after(LayoutSystem::UpdateOnResize),
|
||||
snap_cards_on_window_resize.after(collect_resize_events),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1023,10 +1059,10 @@ const STOCK_NORMAL_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
/// spawned (if not already present). When the stock is non-empty the marker is
|
||||
/// restored to `STOCK_NORMAL_COLOUR` and any `StockEmptyLabel` children are
|
||||
/// despawned.
|
||||
fn apply_stock_empty_indicator(
|
||||
fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
|
||||
commands: &mut Commands,
|
||||
game: &GameState,
|
||||
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
|
||||
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||
layout: &Layout,
|
||||
) {
|
||||
@@ -1116,61 +1152,89 @@ fn update_stock_empty_indicator(
|
||||
);
|
||||
}
|
||||
|
||||
/// Snaps every card sprite to its target position and size when the window
|
||||
/// is resized.
|
||||
/// Coalesces every `WindowResized` event arriving this frame into the latest
|
||||
/// pending size on [`ResizeThrottle`].
|
||||
///
|
||||
/// This replaces the old "fire `StateChangedEvent` from `on_window_resized`"
|
||||
/// path. That path went through `sync_cards_on_change` → `update_card_entity`,
|
||||
/// which inserts a `CardAnim` slide tween whenever the card moves more than
|
||||
/// 1 unit. During a corner drag, every frame's `WindowResized` event
|
||||
/// retargeted the tween from the card's mid-slide position, so cards never
|
||||
/// reached steady state — the visible "snap back and forth" jitter.
|
||||
/// `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.
|
||||
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.
|
||||
///
|
||||
/// Calls `sync_cards` with `slide_secs = 0.0` so `update_card_entity` snaps
|
||||
/// instantly (line `(cur - target).length() > 1.0 && slide_secs > 0.0` falls
|
||||
/// to the snap branch), refreshes the `Sprite` with the new
|
||||
/// `layout.card_size` (so cards visibly resize, not just reposition), and
|
||||
/// removes any in-flight `CardAnim`.
|
||||
/// **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 once
|
||||
/// `update_stock_empty_indicator` stopped firing on resize.
|
||||
/// otherwise the label would not rescale on resize.
|
||||
///
|
||||
/// Scheduled `.after(LayoutSystem::UpdateOnResize)` so `LayoutResource` has
|
||||
/// been refreshed by `table_plugin::on_window_resized` before this runs.
|
||||
/// 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)]
|
||||
fn snap_cards_on_window_resize(
|
||||
mut events: MessageReader<WindowResized>,
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut throttle: ResMut<ResizeThrottle>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
entities: Query<(Entity, &CardEntity, &mut Sprite, &mut Transform), Without<CardLabel>>,
|
||||
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite), Without<CardEntity>>,
|
||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
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 { return };
|
||||
let Some(layout) = layout else { return };
|
||||
|
||||
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 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;
|
||||
};
|
||||
|
||||
sync_cards(
|
||||
commands.reborrow(),
|
||||
resize_cards_in_place(
|
||||
&mut commands,
|
||||
&game.0,
|
||||
&layout.0,
|
||||
0.0,
|
||||
back_colour,
|
||||
color_blind,
|
||||
&entities,
|
||||
card_images.as_deref(),
|
||||
selected_back,
|
||||
entities,
|
||||
label_query,
|
||||
);
|
||||
|
||||
apply_stock_empty_indicator(
|
||||
@@ -1180,6 +1244,59 @@ fn snap_cards_on_window_resize(
|
||||
&label_children,
|
||||
&layout.0,
|
||||
);
|
||||
|
||||
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.
|
||||
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>>,
|
||||
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
let pos_by_id: HashMap<u32, (Vec2, f32)> = positions
|
||||
.into_iter()
|
||||
.map(|(c, p, z)| (c.id, (p, z)))
|
||||
.collect();
|
||||
|
||||
for (entity, marker, mut sprite, mut transform) in entities.iter_mut() {
|
||||
let Some(&(pos, z)) = pos_by_id.get(&marker.card_id) 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>();
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1654,4 +1771,152 @@ mod tests {
|
||||
"tighter face-down fan should reduce column span ({actual_span:.1} >= uniform {uniform_span:.1})"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Resize-lag fix — throttle helper + in-place mutation regression tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn should_apply_resize_returns_false_below_threshold() {
|
||||
// 0 elapsed since last apply: still inside the throttle window.
|
||||
assert!(!should_apply_resize(0.0, 0.0));
|
||||
// Just under the threshold: still throttled.
|
||||
assert!(!should_apply_resize(RESIZE_THROTTLE_SECS - 0.001, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_apply_resize_returns_true_at_or_past_threshold() {
|
||||
// Exactly at the threshold the work should fire.
|
||||
assert!(should_apply_resize(RESIZE_THROTTLE_SECS, 0.0));
|
||||
// Comfortably past the threshold: definitely fire.
|
||||
assert!(should_apply_resize(1.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_apply_resize_uses_last_applied_as_baseline() {
|
||||
// After an apply at t=10.0, a subsequent check at t=10.04 is still
|
||||
// throttled (under the 50 ms window).
|
||||
assert!(!should_apply_resize(10.04, 10.0));
|
||||
// At t=10.05 the next apply is allowed.
|
||||
assert!(should_apply_resize(10.05, 10.0));
|
||||
}
|
||||
|
||||
/// Helper: drive enough `app.update()` ticks at 200 ms each to comfortably
|
||||
/// exceed the throttle window. `Time<Virtual>` clamps each delta to
|
||||
/// `max_delta` (default 250 ms) regardless of the requested step, so we
|
||||
/// step in 200 ms slices.
|
||||
fn advance_past_resize_throttle(app: &mut App) {
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use std::time::Duration;
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||
Duration::from_secs_f32(0.2),
|
||||
));
|
||||
// One tick to advance Time, plus one extra so the snap system runs
|
||||
// after the throttle window has elapsed.
|
||||
app.update();
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn fire_window_resize(app: &mut App, width: f32, height: f32) {
|
||||
// Any Entity will do — the snap system reads only width/height.
|
||||
let window = bevy::ecs::entity::Entity::from_raw_u32(0)
|
||||
.expect("Entity::from_raw_u32(0) is a valid placeholder");
|
||||
app.world_mut().write_message(WindowResized {
|
||||
window,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_does_not_despawn_card_labels() {
|
||||
// Spawn a fresh app, capture the current set of CardLabel entity IDs,
|
||||
// fire a WindowResized, run the throttled snap, and assert *every*
|
||||
// captured label still exists. The whole point of the in-place resize
|
||||
// path is that it doesn't despawn-and-respawn label children — old
|
||||
// entity IDs must remain alive.
|
||||
let mut app = app();
|
||||
|
||||
let labels_before: std::collections::HashSet<bevy::prelude::Entity> = app
|
||||
.world_mut()
|
||||
.query_filtered::<bevy::prelude::Entity, With<CardLabel>>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
assert!(
|
||||
!labels_before.is_empty(),
|
||||
"fixture should have spawned CardLabel children in the fallback solid-colour path"
|
||||
);
|
||||
|
||||
fire_window_resize(&mut app, 1024.0, 768.0);
|
||||
advance_past_resize_throttle(&mut app);
|
||||
|
||||
let labels_after: std::collections::HashSet<bevy::prelude::Entity> = app
|
||||
.world_mut()
|
||||
.query_filtered::<bevy::prelude::Entity, With<CardLabel>>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
|
||||
// Same set of entities — no entity was despawned. (Bevy reuses
|
||||
// indices but bumps generations on despawn, so direct Entity equality
|
||||
// is sufficient here.)
|
||||
for e in &labels_before {
|
||||
assert!(
|
||||
labels_after.contains(e),
|
||||
"CardLabel entity {e:?} was despawned by the resize handler — \
|
||||
expected the in-place path to leave label entities untouched"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_in_place_updates_card_label_font_size() {
|
||||
// Capture an arbitrary CardLabel's TextFont.font_size before resize,
|
||||
// fire a WindowResized to a *smaller* window, run the throttled snap,
|
||||
// and assert the font_size shrank. This proves the in-place path
|
||||
// actually mutates the existing TextFont (rather than skipping it or
|
||||
// falling back to despawn/respawn).
|
||||
let mut app = app();
|
||||
|
||||
// Read the first CardLabel's font size.
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&TextFont, With<CardLabel>>();
|
||||
let before = q
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("fixture should have at least one CardLabel")
|
||||
.font_size;
|
||||
assert!(before > 0.0, "baseline font size must be positive, got {before}");
|
||||
|
||||
// Resize to a window smaller than the default fixture so the
|
||||
// computed font size is unambiguously smaller.
|
||||
fire_window_resize(&mut app, 800.0, 600.0);
|
||||
advance_past_resize_throttle(&mut app);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&TextFont, With<CardLabel>>();
|
||||
let after = q
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("CardLabel must still exist after in-place resize")
|
||||
.font_size;
|
||||
|
||||
assert!(
|
||||
after < before,
|
||||
"smaller window should shrink CardLabel font size in place \
|
||||
(before={before}, after={after})"
|
||||
);
|
||||
|
||||
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
|
||||
// post-resize card width, so the in-place path is using the
|
||||
// refreshed Layout.
|
||||
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0));
|
||||
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
|
||||
assert!(
|
||||
(after - expected).abs() < 1e-3,
|
||||
"after-resize font size should equal layout.card_size.x * FONT_SIZE_FRAC \
|
||||
(got {after}, expected {expected})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,16 @@ const CARD_ASPECT: f32 = 1.4;
|
||||
/// the tableau row.
|
||||
const VERTICAL_GAP_FRAC: f32 = 0.2;
|
||||
|
||||
/// Fraction of card height contributed by each additional face-up tableau card
|
||||
/// when fanned. Mirrors `card_plugin::TABLEAU_FAN_FRAC` so layout sizing can
|
||||
/// solve for a worst-case column without depending on `card_plugin`.
|
||||
const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
|
||||
/// Largest possible face-up tableau column in Klondike: a King down to an Ace
|
||||
/// after every face-down card has flipped on column 7. Layout sizing must keep
|
||||
/// this column inside the visible window.
|
||||
const MAX_TABLEAU_CARDS: f32 = 13.0;
|
||||
|
||||
/// Table background colour (dark green felt).
|
||||
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
||||
|
||||
@@ -55,8 +65,13 @@ pub struct Layout {
|
||||
/// Compute the board layout from a window size.
|
||||
///
|
||||
/// # Geometry
|
||||
/// - `card_width = window.x / 9.0` — seven tableau columns with eight gaps
|
||||
/// (two outer margins + six inner).
|
||||
/// - `card_width` is the smaller of:
|
||||
/// - `window.x / 9.0` — seven tableau columns with eight gaps (two outer
|
||||
/// margins + six inner). This is the limiter on landscape windows.
|
||||
/// - the height-based candidate that keeps a worst-case fanned tableau
|
||||
/// column (13 face-up cards, see [`MAX_TABLEAU_CARDS`]) inside the
|
||||
/// window with a bottom margin equal to `h_gap`. Limiter on tall/narrow
|
||||
/// windows.
|
||||
/// - `card_height = card_width * 1.4`.
|
||||
/// - Horizontal gap `h_gap = card_width / 4.0`.
|
||||
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
|
||||
@@ -65,16 +80,43 @@ pub struct Layout {
|
||||
pub fn compute_layout(window: Vec2) -> Layout {
|
||||
let window = window.max(MIN_WINDOW);
|
||||
|
||||
let card_width = window.x / 9.0;
|
||||
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
|
||||
let card_width_width_based = window.x / 9.0;
|
||||
|
||||
// Height-based candidate. The vertical budget below the top row must hold
|
||||
// a worst-case fanned tableau column plus a bottom margin equal to h_gap.
|
||||
//
|
||||
// Letting w = card_width and h = w * CARD_ASPECT, the vertical layout is:
|
||||
// top edge of window = +window.y / 2
|
||||
// top of top-row card = window.y/2 - h_gap (h_gap top margin)
|
||||
// centre of top-row card = window.y/2 - h_gap - h/2
|
||||
// centre of tableau card = top centre - h - vertical_gap (vertical_gap = VERTICAL_GAP_FRAC * h)
|
||||
// bottom of last fanned = tableau_centre + h/2 - fan_factor * h
|
||||
// where fan_factor = 1 + (MAX_TABLEAU_CARDS - 1) * TABLEAU_FAN_FRAC
|
||||
// bottom of window = -window.y / 2; require bottom-of-fanned >= -window.y/2 + h_gap
|
||||
//
|
||||
// Substituting h_gap = w/4 and h = CARD_ASPECT * w and solving for the
|
||||
// largest w that fits gives:
|
||||
// window.y = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
|
||||
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
|
||||
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
|
||||
let card_width_height_based = window.y / height_denom;
|
||||
|
||||
let card_width = card_width_width_based.min(card_width_height_based);
|
||||
let card_height = card_width * CARD_ASPECT;
|
||||
let card_size = Vec2::new(card_width, card_height);
|
||||
|
||||
let h_gap = card_width / 4.0;
|
||||
// With h_gap = card_width/4, total width = 7*card_width + 8*h_gap = 9*card_width.
|
||||
// Leftmost card's centre sits at: -window.x/2 + h_gap + card_width/2.
|
||||
// Total occupied width = 7*card_width + 8*h_gap = 9*card_width. When card
|
||||
// sizing is height-limited (tall/narrow windows), this is smaller than
|
||||
// window.x, so the grid is centred horizontally; otherwise side_margin
|
||||
// collapses to h_gap and the geometry matches the original width-based
|
||||
// layout exactly.
|
||||
let total_grid_width = 9.0 * card_width;
|
||||
let side_margin = (window.x - total_grid_width) / 2.0 + h_gap;
|
||||
let left_edge = -window.x / 2.0;
|
||||
let col_x = |col: usize| -> f32 {
|
||||
left_edge + h_gap + card_width / 2.0 + (col as f32) * (card_width + h_gap)
|
||||
left_edge + side_margin + card_width / 2.0 + (col as f32) * (card_width + h_gap)
|
||||
};
|
||||
|
||||
let vertical_gap = card_height * VERTICAL_GAP_FRAC;
|
||||
@@ -202,6 +244,74 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_wide_window_constrains_card_width_via_height() {
|
||||
// Short wide window: vertical budget is the bottleneck, so card_width
|
||||
// must be strictly smaller than the naive window.x / 9 candidate to
|
||||
// keep a worst-case 13-card column inside the window. (Most desktop
|
||||
// monitors fall into this regime — e.g. 1280x800, 1920x1080.)
|
||||
let window = Vec2::new(2560.0, 1080.0);
|
||||
let layout = compute_layout(window);
|
||||
let width_based = window.x / 9.0;
|
||||
assert!(
|
||||
layout.card_size.x < width_based,
|
||||
"expected height to be the limiter (card_width {} should be < width-based candidate {})",
|
||||
layout.card_size.x,
|
||||
width_based
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_narrow_window_keeps_width_based_sizing() {
|
||||
// Tall narrow window: there's plenty of vertical budget, so width is
|
||||
// the bottleneck and card_width matches the legacy window.x / 9
|
||||
// derivation exactly.
|
||||
let window = Vec2::new(900.0, 1600.0);
|
||||
let layout = compute_layout(window);
|
||||
let width_based = window.x / 9.0;
|
||||
assert!(
|
||||
(layout.card_size.x - width_based).abs() < 1e-3,
|
||||
"expected width-based sizing (card_width {} should equal {})",
|
||||
layout.card_size.x,
|
||||
width_based
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worst_case_tableau_fits_vertically_on_default_resolution() {
|
||||
// Default app resolution (see solitaire_app/src/main.rs).
|
||||
let window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(window);
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
|
||||
let card_h = layout.card_size.y;
|
||||
// Bottom edge of the 13th fanned face-up card.
|
||||
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
|
||||
// Bottom of the visible window with the same h_gap-sized margin used at
|
||||
// the top.
|
||||
let h_gap = layout.card_size.x / 4.0;
|
||||
let window_bottom_with_margin = -window.y / 2.0 + h_gap;
|
||||
assert!(
|
||||
bottom_edge >= window_bottom_with_margin - 1e-3,
|
||||
"worst-case tableau bottom {bottom_edge} overflows window margin {window_bottom_with_margin}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worst_case_tableau_fits_vertically_on_full_hd() {
|
||||
// The bug originally reproduced at 1920x1080. Lock in a regression test.
|
||||
let window = Vec2::new(1920.0, 1080.0);
|
||||
let layout = compute_layout(window);
|
||||
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
|
||||
let card_h = layout.card_size.y;
|
||||
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
|
||||
let h_gap = layout.card_size.x / 4.0;
|
||||
let window_bottom_with_margin = -window.y / 2.0 + h_gap;
|
||||
assert!(
|
||||
bottom_edge >= window_bottom_with_margin - 1e-3,
|
||||
"worst-case tableau bottom {bottom_edge} overflows window margin {window_bottom_with_margin}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_piles_fit_inside_window_horizontally() {
|
||||
for window in [
|
||||
|
||||
Reference in New Issue
Block a user