The previous formula card_width = window.x / 9 with card_height = 1.4 *
card_width ignored the window height entirely. On a 1920×1080 window a
13-card face-up tableau column extended ~377 px below the viewport
bottom — visible reproduction in the smoke test.
compute_layout now derives two card_width candidates: one from the
horizontal grid budget (window.x / 9, unchanged) and one from the
vertical budget needed to seat 13 fanned cards plus the foundation
row, vertical_gap, and h_gap bottom margin. The smaller of the two
wins, so width remains the limiter on standard landscape windows and
height takes over on tall or short-wide aspect ratios. The math is
solved algebraically in a single substitution to avoid iteration.
When height is the limiter the original layout would have squished the
grid against the left edge; col_x now folds in a horizontal centring
offset that collapses to the existing geometry whenever width is the
limiter, so no other module needed an update.
Adds MAX_TABLEAU_CARDS = 13.0 (King-down-to-Ace worst case) and a
locally mirrored TABLEAU_FAN_FRAC = 0.25 — the original lives in
card_plugin and importing it would have created a circular dep with
layout. The duplication is doc-flagged so future drift gets noticed.
Four new tests pin both regimes: the height-limiter activates on a
1920×1080 window, stays inactive on a 900×1600 portrait window, and
the worst-case 13-card column fits on both 1280×800 and 1920×1080
within the bottom margin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
on_window_resized was firing StateChangedEvent on every WindowResized
event. That ran sync_cards_on_change → update_card_entity, which
inserts a CardAnim slide tween for every card whose target moves >1
unit. During a corner drag the resize fires every frame, retargeting
the slide each time from the cards' current mid-tween positions, so
cards never reach steady state — the visible "snap back and forth"
jitter reported during the 2026-04-29 smoke test.
Replace the StateChangedEvent emit with a direct snap path:
- Add LayoutSystem::UpdateOnResize SystemSet in layout.rs so cross-
plugin ordering is explicit (Bevy's automatic conflict-based order
only forces non-parallel execution, not a particular order).
- table_plugin::on_window_resized: drop the StateChangedEvent emit;
mark the system in_set(LayoutSystem::UpdateOnResize). It already
snaps backgrounds and pile markers directly, so this aligns cards
with the same instant-snap policy.
- card_plugin: new snap_cards_on_window_resize system listens for
WindowResized, runs .after(LayoutSystem::UpdateOnResize), writes
fresh transforms via the existing card_positions() helper, and
removes any in-flight CardAnim. It also reapplies the stock-empty
indicator so the "↺" label's font_size (derived from
layout.card_size.x) still rescales on resize.
Other StateChangedEvent listeners — start_settle_anim,
detect_auto_complete, clear_selection_on_state_change, check_no_moves,
reset_hint_cycle_on_state_change, clear_right_click_highlights — no
longer fire spuriously on resize. They should not fire on a layout
change anyway; that was a pre-existing minor bug masked by the
jitter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
compute_layout is a pure function that maps window size to card size and
the 13 pile positions, with clamping at the 800x600 minimum and seven
tableau columns horizontally aligned with stock/waste (cols 0,1) and the
four foundations (cols 3,4,5,6). TablePlugin spawns a 2D camera, a felt
background sprite, and 13 translucent pile-marker sprites, and
repositions them on WindowResized. Plugin registers WindowResized
explicitly so it works under MinimalPlugins in tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>