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::*;
|
||||
|
||||
@@ -6,10 +6,22 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::Resource;
|
||||
use bevy::prelude::{Resource, SystemSet};
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
/// Schedule labels for layout-related systems so cross-plugin ordering is
|
||||
/// explicit instead of relying on Bevy's automatic resource-conflict ordering
|
||||
/// (which only forces non-parallel execution, not a particular order).
|
||||
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum LayoutSystem {
|
||||
/// The system that updates [`LayoutResource`], the table background, and
|
||||
/// pile markers in response to a `WindowResized` event. Card-snap systems
|
||||
/// (in `card_plugin`) run `.after(LayoutSystem::UpdateOnResize)` so they
|
||||
/// see the fresh layout.
|
||||
UpdateOnResize,
|
||||
}
|
||||
|
||||
/// Minimum supported window dimensions. Layout is still computed below this
|
||||
/// size but cards will be small.
|
||||
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
|
||||
|
||||
@@ -10,7 +10,7 @@ use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||
use crate::layout::{compute_layout, Layout, LayoutResource};
|
||||
use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem};
|
||||
#[cfg(test)]
|
||||
use crate::layout::TABLE_COLOUR;
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
@@ -69,7 +69,7 @@ impl Plugin for TablePlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
on_window_resized,
|
||||
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
|
||||
apply_theme_on_settings_change,
|
||||
apply_hint_pile_highlight,
|
||||
tick_hint_pile_highlights,
|
||||
@@ -275,12 +275,10 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn on_window_resized(
|
||||
mut events: MessageReader<WindowResized>,
|
||||
mut layout_res: Option<ResMut<LayoutResource>>,
|
||||
mut state_changed: MessageWriter<StateChangedEvent>,
|
||||
mut backgrounds: Query<
|
||||
(&mut Sprite, &mut Transform),
|
||||
(With<TableBackground>, Without<PileMarker>),
|
||||
@@ -311,8 +309,12 @@ fn on_window_resized(
|
||||
}
|
||||
}
|
||||
|
||||
// Reposition card sprites to the new layout.
|
||||
state_changed.write(StateChangedEvent);
|
||||
// Card sprites are repositioned by `card_plugin::snap_cards_on_window_resize`
|
||||
// running `.after(LayoutSystem::UpdateOnResize)` — that system snaps card
|
||||
// transforms directly to the new layout instead of going through
|
||||
// `StateChangedEvent → sync_cards → CardAnim` which would retarget the
|
||||
// slide tween every frame during a corner drag (the visible "snap back
|
||||
// and forth" jitter).
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user