diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 6c048e1..ddc5f5e 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -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, + /// `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::>() + .init_resource::() .add_message::() .add_message::() .add_message::() @@ -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( 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>, 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, + mut throttle: ResMut, +) { + 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, mut commands: Commands, + time: Res