fix(engine): snap cards directly on window resize
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>
This commit is contained in:
@@ -14,6 +14,7 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bevy::color::Color;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
@@ -23,7 +24,7 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::layout::{Layout, LayoutResource, LayoutSystem};
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
@@ -191,6 +192,7 @@ 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1114,6 +1116,63 @@ fn update_stock_empty_indicator(
|
||||
);
|
||||
}
|
||||
|
||||
/// Snaps every card sprite to its target position when the window is resized.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// Cards now snap directly (no slide), matching the instant repositioning
|
||||
/// already used for backgrounds and pile markers in
|
||||
/// `table_plugin::on_window_resized`. Any in-flight `CardAnim` is removed so
|
||||
/// it cannot keep writing the old target translation after the snap.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// Scheduled `.after(LayoutSystem::UpdateOnResize)` so `LayoutResource` has
|
||||
/// been refreshed by `table_plugin::on_window_resized` before this runs.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn snap_cards_on_window_resize(
|
||||
mut events: MessageReader<WindowResized>,
|
||||
mut commands: Commands,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
mut entities: Query<(Entity, &CardEntity, &mut Transform)>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(game) = game else { return };
|
||||
let Some(layout) = layout else { return };
|
||||
|
||||
let mut targets: HashMap<u32, (Vec2, f32)> = HashMap::new();
|
||||
for (card, pos, z) in card_positions(&game.0, &layout.0) {
|
||||
targets.insert(card.id, (pos, z));
|
||||
}
|
||||
for (entity, marker, mut transform) in &mut entities {
|
||||
if let Some(&(pos, z)) = targets.get(&marker.card_id) {
|
||||
transform.translation = Vec3::new(pos.x, pos.y, z);
|
||||
commands.entity(entity).remove::<CardAnim>();
|
||||
}
|
||||
}
|
||||
|
||||
apply_stock_empty_indicator(
|
||||
&mut commands,
|
||||
&game.0,
|
||||
&mut pile_markers,
|
||||
&label_children,
|
||||
&layout.0,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user