fix(ux): 14 cross-platform UX/UI fixes from 500-game audit
Web client (game.js): - Restart game timer after undo exits auto-complete sequence - Pause timer while browser tab is hidden (visibilitychange) - Validate URL seed — NaN / negative falls back to randomSeed() - Guard onBoardClick/onBoardDblClick during win (snap.is_won) - Delay win overlay 320 ms so last card CSS transition finishes - Force reflow in flashIllegal() to restart shake on rapid re-trigger Android (safe_area.rs): - Preserve last-known insets on app resume instead of zeroing them; eliminates double layout flash on every foreground cycle All clients — Bevy engine: - Radial menu: clamp icon anchors to viewport bounds so icons are never placed off-screen on narrow phones - Auto-complete: deactivate state.active when is_auto_completable goes false (undo mid-sequence) to stop perpetual background retry - Touch selection: gate highlight rebuild on is_changed() — was despawning/respawning entities every frame unnecessarily - Input: fire "Tap a pile to move" InfoToast on first tap in TapToSelect mode; document cursor_world 1:1 viewport invariant - Drag threshold: raise desktop from 4 → 6 px to prevent accidental drags from cursor jitter on HiDPI displays Desktop / Android (solitaire_app): - Call cleanup_orphaned_tmp_files() at startup to remove .tmp files left by crashes between atomic write and rename Design clarification (klondike_adapter.rs): - Doc comment: Draw-1 recycling is penalty-only by design (never blocked) to avoid creating unwinnable positions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -51,15 +51,15 @@ impl Plugin for AutoCompletePlugin {
|
||||
app.init_resource::<AutoCompleteState>()
|
||||
.add_message::<RequestRedraw>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
detect_auto_complete,
|
||||
on_auto_complete_start,
|
||||
drive_auto_complete,
|
||||
)
|
||||
.chain()
|
||||
.after(GameMutation),
|
||||
);
|
||||
Update,
|
||||
(
|
||||
detect_auto_complete,
|
||||
on_auto_complete_start,
|
||||
drive_auto_complete,
|
||||
)
|
||||
.chain()
|
||||
.after(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,14 +83,21 @@ fn detect_auto_complete(
|
||||
if game.0.is_auto_completable && !state.active {
|
||||
state.active = true;
|
||||
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
|
||||
} else if !game.0.is_auto_completable && state.active {
|
||||
// `is_auto_completable` only becomes false after an explicit undo
|
||||
// (which puts a card back on the tableau or re-fills the stock/waste)
|
||||
// or a new-game reset — never as a transient gap during a normal
|
||||
// auto-complete sequence. Deactivate here so `drive_auto_complete`
|
||||
// does not keep retrying indefinitely after the player undoes out of
|
||||
// the sequence.
|
||||
//
|
||||
// Note: the transient-`None` case mentioned in older versions of this
|
||||
// comment referred to `next_auto_complete_move()` returning `None`, not
|
||||
// to `is_auto_completable` being false. Those are independent fields;
|
||||
// `drive_auto_complete` still retries on a transient `None` return from
|
||||
// `next_auto_complete_move` because that check happens there, not here.
|
||||
state.active = false;
|
||||
}
|
||||
// Intentionally no `else if !is_auto_completable` branch here.
|
||||
// Deactivating on every frame where `is_auto_completable` is false
|
||||
// would hard-stop the sequence mid-flight whenever `next_auto_complete_move`
|
||||
// transiently returns `None` (e.g. while the previous move is still
|
||||
// in-flight). The `is_won` check above already handles the definitive
|
||||
// end-of-game case; `drive_auto_complete` simply retries next tick
|
||||
// when no move is available yet.
|
||||
}
|
||||
|
||||
/// Plays a distinct chime the moment auto-complete first activates.
|
||||
@@ -244,9 +251,7 @@ mod tests {
|
||||
|
||||
// Zero out the cooldown so drive fires on the next update regardless
|
||||
// of the initial delay constant.
|
||||
app.world_mut()
|
||||
.resource_mut::<AutoCompleteState>()
|
||||
.cooldown = 0.0;
|
||||
app.world_mut().resource_mut::<AutoCompleteState>().cooldown = 0.0;
|
||||
app.update(); // drive fires the move
|
||||
|
||||
let events = app.world().resource::<Messages<MoveRequestEvent>>();
|
||||
|
||||
@@ -100,7 +100,7 @@ impl AnimationTuning {
|
||||
platform: InputPlatform::Mouse,
|
||||
duration_scale: 1.0,
|
||||
overshoot_scale: 1.0,
|
||||
drag_threshold_px: 4.0,
|
||||
drag_threshold_px: 6.0,
|
||||
drag_scale: 1.08,
|
||||
hover_scale: 1.04,
|
||||
hover_lerp_speed: 14.0,
|
||||
|
||||
@@ -24,9 +24,9 @@ use bevy::input::touch::{TouchInput, TouchPhase, Touches};
|
||||
use bevy::math::{Vec2, Vec3};
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
use klondike::{Foundation, KlondikePile, Tableau};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use bevy::window::{MonitorSelection, WindowMode};
|
||||
use klondike::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::card::{Card, Suit};
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
@@ -789,8 +789,9 @@ fn end_drag(
|
||||
continue;
|
||||
};
|
||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||
if let Some((entity, _, transform)) =
|
||||
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
|
||||
if let Some((entity, _, transform)) = card_entities
|
||||
.iter()
|
||||
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||
{
|
||||
let drag_pos = transform.translation.truncate();
|
||||
let drag_z = transform.translation.z;
|
||||
@@ -1027,8 +1028,9 @@ fn touch_end_drag(
|
||||
continue;
|
||||
};
|
||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||
if let Some((entity, _, transform)) =
|
||||
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
|
||||
if let Some((entity, _, transform)) = card_entities
|
||||
.iter()
|
||||
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||
{
|
||||
let drag_pos = transform.translation.truncate();
|
||||
let drag_z = transform.translation.z;
|
||||
@@ -1060,6 +1062,13 @@ fn touch_end_drag(
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Converts the mouse cursor position to world-space 2-D coordinates.
|
||||
///
|
||||
/// **Invariant:** assumes a single un-zoomed 2-D camera whose viewport exactly
|
||||
/// covers the primary window (centre at world origin, 1 logical pixel = 1 world
|
||||
/// unit). Hit-testing in `find_draggable_at` / `find_drop_target` relies on
|
||||
/// this 1:1 mapping. Do not add camera zoom or offset this without auditing
|
||||
/// every call site of `cursor_world` and `touch_to_world`.
|
||||
fn cursor_world(
|
||||
windows: &Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||
@@ -1073,6 +1082,9 @@ fn cursor_world(
|
||||
/// Converts a touch screen position (logical pixels, top-left origin) to
|
||||
/// world-space 2-D coordinates using the primary camera.
|
||||
///
|
||||
/// Shares the same 1:1 viewport invariant as [`cursor_world`] — see that
|
||||
/// function's doc for the constraints.
|
||||
///
|
||||
/// Returns `None` if no camera is present or the projection fails.
|
||||
fn touch_to_world(cameras: &Query<(&Camera, &GlobalTransform)>, screen_pos: Vec2) -> Option<Vec2> {
|
||||
let (camera, camera_transform) = cameras.single().ok()?;
|
||||
@@ -1097,7 +1109,12 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
||||
/// face-up cards by `layout.tableau_fan_frac`. Mirrors `card_plugin::card_positions`
|
||||
/// exactly; any drift creates an offset between the visible card face and
|
||||
/// where clicks land.
|
||||
fn card_position(game: &GameState, layout: &Layout, pile: &KlondikePile, stack_index: usize) -> Vec2 {
|
||||
fn card_position(
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
pile: &KlondikePile,
|
||||
stack_index: usize,
|
||||
) -> Vec2 {
|
||||
let base = layout.pile_positions[pile];
|
||||
if matches!(pile, KlondikePile::Tableau(_)) {
|
||||
let mut y_offset = 0.0_f32;
|
||||
@@ -1436,6 +1453,7 @@ fn handle_double_tap(
|
||||
mut touch_selection: Option<ResMut<TouchSelectionState>>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
mut rejected: MessageWriter<MoveRejectedEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
mut commands: Commands,
|
||||
mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
) {
|
||||
@@ -1509,8 +1527,9 @@ fn handle_double_tap(
|
||||
sel.clear();
|
||||
return;
|
||||
}
|
||||
// First tap: select the source.
|
||||
// First tap: select the source, then nudge the player.
|
||||
sel.set(*tapped_pile, drag.cards.clone());
|
||||
toast.write(InfoToastEvent("Tap a pile to move".into()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1540,8 +1559,12 @@ fn handle_double_tap(
|
||||
if drag.cards.len() > 1 {
|
||||
let stack_index = pile_cards.len() - drag.cards.len();
|
||||
if let Some(bottom_card) = pile_cards.get(stack_index)
|
||||
&& let Some((dest, count)) =
|
||||
best_tableau_destination_for_stack(bottom_card, tapped_pile, &game.0, drag.cards.len())
|
||||
&& let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||
bottom_card,
|
||||
tapped_pile,
|
||||
&game.0,
|
||||
drag.cards.len(),
|
||||
)
|
||||
{
|
||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||
if drag.cards.contains(&ce.card_id) {
|
||||
@@ -1573,9 +1596,7 @@ fn handle_double_tap(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the complete list of legal moves available in `game`, ordered so that
|
||||
/// foundation moves come first, then tableau-to-tableau moves, with "draw from
|
||||
/// stock" appended last when the stock is non-empty and nothing else is
|
||||
/// available.
|
||||
/// upstream `klondike` priorities are preserved.
|
||||
///
|
||||
/// Each entry is `(from, to, count)` — the same triple used by
|
||||
/// [`MoveRequestEvent`]. The list may be empty when no move exists at all
|
||||
@@ -1584,6 +1605,23 @@ fn handle_double_tap(
|
||||
/// This is the backing data for the cycling hint system: the H key steps
|
||||
/// through `hints[HintCycleIndex % hints.len()]` on each press.
|
||||
pub fn all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> {
|
||||
if game.has_test_pile_overrides() {
|
||||
return legacy_all_hints(game);
|
||||
}
|
||||
|
||||
game.possible_instructions()
|
||||
.into_iter()
|
||||
.filter(|(_, _, count)| *count == 1)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Legacy hint enumeration used only when test pile overrides are active.
|
||||
///
|
||||
/// `possible_instructions()` reflects the internal upstream `Session` state.
|
||||
/// In test fixtures that inject synthetic piles via `set_test_*`, these
|
||||
/// synthetic piles can diverge from the session state; this fallback preserves
|
||||
/// deterministic test semantics in those fixtures.
|
||||
fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> {
|
||||
let sources: Vec<KlondikePile> = {
|
||||
let mut s = vec![KlondikePile::Stock];
|
||||
for tableau in tableaus() {
|
||||
@@ -1818,7 +1856,8 @@ mod tests {
|
||||
// face-up card, but the iterator should skip face-down cards and
|
||||
// the cursor sits above the face-up card's AABB, so the result
|
||||
// is None.
|
||||
let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
||||
let face_down_pos =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
||||
let result = find_draggable_at(face_down_pos, &game, &layout);
|
||||
assert!(result.is_none(), "face-down cards should not be draggable");
|
||||
}
|
||||
@@ -1836,7 +1875,8 @@ mod tests {
|
||||
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
||||
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
||||
let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||
let face_up_pos =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||
let result = find_draggable_at(face_up_pos, &game, &layout)
|
||||
.expect("clicking the face-up card's visible centre must initiate a drag");
|
||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||
@@ -1878,7 +1918,8 @@ mod tests {
|
||||
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||
let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
||||
let queen_center =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
||||
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
@@ -1923,7 +1964,12 @@ mod tests {
|
||||
let mut game = game;
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
let target = find_drop_target(pos, &game, &layout, &KlondikePile::Tableau(Tableau::Tableau7));
|
||||
let target = find_drop_target(
|
||||
pos,
|
||||
&game,
|
||||
&layout,
|
||||
&KlondikePile::Tableau(Tableau::Tableau7),
|
||||
);
|
||||
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
}
|
||||
|
||||
@@ -1932,7 +1978,12 @@ mod tests {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
||||
let target = find_drop_target(pos, &game, &layout, &KlondikePile::Tableau(Tableau::Tableau4));
|
||||
let target = find_drop_target(
|
||||
pos,
|
||||
&game,
|
||||
&layout,
|
||||
&KlondikePile::Tableau(Tableau::Tableau4),
|
||||
);
|
||||
assert_eq!(target, None);
|
||||
}
|
||||
|
||||
@@ -2012,7 +2063,10 @@ mod tests {
|
||||
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
for pile in [KlondikePile::Stock, KlondikePile::Foundation(Foundation::Foundation3)] {
|
||||
for pile in [
|
||||
KlondikePile::Stock,
|
||||
KlondikePile::Foundation(Foundation::Foundation3),
|
||||
] {
|
||||
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
||||
assert_eq!(size, layout.card_size);
|
||||
}
|
||||
@@ -2022,7 +2076,7 @@ mod tests {
|
||||
// Task #27 — best_destination pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
#[test]
|
||||
fn best_destination_returns_none_when_no_legal_move() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
@@ -2044,7 +2098,7 @@ mod tests {
|
||||
// best_tableau_destination_for_stack pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
#[test]
|
||||
fn best_tableau_destination_for_stack_skips_source_pile() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
@@ -2070,8 +2124,12 @@ mod tests {
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
};
|
||||
let result =
|
||||
best_tableau_destination_for_stack(&bottom_card, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1);
|
||||
let result = best_tableau_destination_for_stack(
|
||||
&bottom_card,
|
||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||
&game,
|
||||
1,
|
||||
);
|
||||
// Result must be some other empty tableau column, never the source.
|
||||
if let Some((dest, _)) = result {
|
||||
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
@@ -2103,8 +2161,12 @@ mod tests {
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
};
|
||||
let result =
|
||||
best_tableau_destination_for_stack(&bottom_card, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1);
|
||||
let result = best_tableau_destination_for_stack(
|
||||
&bottom_card,
|
||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||
&game,
|
||||
1,
|
||||
);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"Two of Clubs has no legal tableau destination on empty piles"
|
||||
@@ -2140,7 +2202,7 @@ mod tests {
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------
|
||||
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -2176,11 +2238,11 @@ mod tests {
|
||||
clear_test_piles(&mut game);
|
||||
// Put one card back into the stock so "draw" is a valid suggestion.
|
||||
game.set_test_stock_cards(vec![Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: false,
|
||||
}]);
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: false,
|
||||
}]);
|
||||
|
||||
let hints = all_hints(&game);
|
||||
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
||||
@@ -2192,7 +2254,7 @@ mod tests {
|
||||
|
||||
/// `all_hints` must be empty when both stock and waste are empty and no
|
||||
/// pile-to-pile move exists — the game is truly stuck.
|
||||
// -----------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------
|
||||
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||
// `ShakeAnim` on the dragged cards. The audio cue
|
||||
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
||||
|
||||
@@ -329,7 +329,12 @@ pub fn find_top_face_up_card_at(
|
||||
/// Mirror of `input_plugin::card_position` — kept private to this
|
||||
/// module so the radial's hit-test geometry tracks renderer geometry
|
||||
/// without depending on `input_plugin` internals.
|
||||
fn card_position(game: &GameState, layout: &Layout, pile: &KlondikePile, stack_index: usize) -> Vec2 {
|
||||
fn card_position(
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
pile: &KlondikePile,
|
||||
stack_index: usize,
|
||||
) -> Vec2 {
|
||||
let base = layout.pile_positions[pile];
|
||||
if matches!(pile, KlondikePile::Tableau(_)) {
|
||||
let mut y_offset = 0.0_f32;
|
||||
@@ -376,16 +381,27 @@ const fn tableaus() -> [Tableau; 7] {
|
||||
}
|
||||
|
||||
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
||||
fn build_radial_destinations(centre: Vec2, dests: Vec<KlondikePile>) -> Vec<(KlondikePile, Vec2)> {
|
||||
///
|
||||
/// `half_extents` is the window half-size in world space — icons are clamped
|
||||
/// so that their edges stay within the viewport, preventing them from appearing
|
||||
/// off-screen on small or narrow devices.
|
||||
fn build_radial_destinations(
|
||||
centre: Vec2,
|
||||
dests: Vec<KlondikePile>,
|
||||
half_extents: Vec2,
|
||||
) -> Vec<(KlondikePile, Vec2)> {
|
||||
let count = dests.len();
|
||||
let margin = RADIAL_ICON_SIZE_PX / 2.0;
|
||||
dests
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| {
|
||||
(
|
||||
d,
|
||||
radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX),
|
||||
)
|
||||
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
|
||||
let clamped = Vec2::new(
|
||||
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
|
||||
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
|
||||
);
|
||||
(d, clamped)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -472,7 +488,12 @@ fn radial_open_on_right_click(
|
||||
});
|
||||
return;
|
||||
}
|
||||
let legal_destinations = build_radial_destinations(world, dests);
|
||||
let half_extents = windows
|
||||
.single()
|
||||
.ok()
|
||||
.map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0))
|
||||
.unwrap_or(Vec2::splat(f32::MAX));
|
||||
let legal_destinations = build_radial_destinations(world, dests, half_extents);
|
||||
|
||||
*state = RightClickRadialState::Active {
|
||||
source_pile,
|
||||
@@ -498,6 +519,7 @@ fn radial_open_on_long_press(
|
||||
drag: Res<DragState>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
touches: Option<Res<Touches>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
@@ -540,7 +562,12 @@ fn radial_open_on_long_press(
|
||||
if dests.is_empty() {
|
||||
return;
|
||||
}
|
||||
let legal_destinations = build_radial_destinations(world, dests);
|
||||
let half_extents = windows
|
||||
.single()
|
||||
.ok()
|
||||
.map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0))
|
||||
.unwrap_or(Vec2::splat(f32::MAX));
|
||||
let legal_destinations = build_radial_destinations(world, dests, half_extents);
|
||||
*state = RightClickRadialState::Active {
|
||||
source_pile,
|
||||
count: 1,
|
||||
@@ -958,7 +985,8 @@ mod tests {
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let dests = legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g);
|
||||
let dests =
|
||||
legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g);
|
||||
// Ace can be placed on every empty foundation. We only need
|
||||
// the count to be ≥ 1 and the source pile to be excluded.
|
||||
assert!(
|
||||
@@ -977,7 +1005,11 @@ mod tests {
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let dests = legal_destinations_for_card(&card, &KlondikePile::Foundation(Foundation::Foundation1), &g);
|
||||
let dests = legal_destinations_for_card(
|
||||
&card,
|
||||
&KlondikePile::Foundation(Foundation::Foundation1),
|
||||
&g,
|
||||
);
|
||||
assert!(!dests.contains(&KlondikePile::Foundation(Foundation::Foundation1)));
|
||||
}
|
||||
|
||||
@@ -988,7 +1020,7 @@ mod tests {
|
||||
/// Pressing right-click on a face-up card with at least one legal
|
||||
/// destination must transition the state to `Active` carrying the
|
||||
/// expected source / count / legal-destination set.
|
||||
/// Releasing the right button while the cursor is over a destination
|
||||
/// Releasing the right button while the cursor is over a destination
|
||||
/// icon must fire a `MoveRequestEvent` and return the state to Idle.
|
||||
#[test]
|
||||
fn right_click_release_over_destination_fires_move_request() {
|
||||
|
||||
@@ -253,24 +253,24 @@ mod android {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the inset poller and clears cached insets on
|
||||
/// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the
|
||||
/// frames immediately after the app returns to the foreground.
|
||||
/// Resets the inset poller on `AppLifecycle::WillResume` so that
|
||||
/// `refresh_insets` re-queries JNI in the frames immediately after the app
|
||||
/// returns to the foreground.
|
||||
///
|
||||
/// Clearing `SafeAreaInsets` to the default (all-zero) fires
|
||||
/// `on_safe_area_changed` in `table_plugin`, which emits a synthetic
|
||||
/// `WindowResized`. `on_window_resized` then recomputes the layout;
|
||||
/// once `refresh_insets` resolves the real values a second synthetic
|
||||
/// `WindowResized` fires and the layout converges to the correct position.
|
||||
/// The cached `SafeAreaInsets` are intentionally **not** zeroed here.
|
||||
/// Zeroing them would cause two layout recomputes on every resume:
|
||||
/// once with zero insets (wrong position) and again when JNI resolves the
|
||||
/// real values — visible as a flash. By preserving the last-known values
|
||||
/// the layout remains stable; if JNI returns a different value (e.g. after
|
||||
/// a rotation) the single update that fires when `SafeAreaInsets` actually
|
||||
/// changes is enough.
|
||||
pub(super) fn rearm_on_resumed(
|
||||
mut lifecycle: MessageReader<AppLifecycle>,
|
||||
mut poll: ResMut<SafeAreaPollTries>,
|
||||
mut insets: ResMut<SafeAreaInsets>,
|
||||
) {
|
||||
for event in lifecycle.read() {
|
||||
if matches!(event, AppLifecycle::WillResume) {
|
||||
poll.0 = 0;
|
||||
*insets = SafeAreaInsets::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,9 @@ impl TouchSelectionState {
|
||||
|
||||
/// Marker component placed on the highlight sprite child of a selected source card.
|
||||
///
|
||||
/// Despawned and respawned each frame by [`update_touch_selection_highlight`] so
|
||||
/// stale highlights never linger after a game-state change.
|
||||
/// Despawned and respawned by [`update_touch_selection_highlight`] whenever
|
||||
/// [`TouchSelectionState`] changes. The system is gated on `is_changed()` so it
|
||||
/// is a no-op every frame that the selection is stable.
|
||||
#[derive(Component)]
|
||||
pub struct TouchSelectionHighlight;
|
||||
|
||||
@@ -91,16 +92,15 @@ pub struct TouchSelectionPlugin;
|
||||
|
||||
impl Plugin for TouchSelectionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<TouchSelectionState>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
clear_touch_selection_on_state_change,
|
||||
update_touch_selection_highlight,
|
||||
)
|
||||
.chain()
|
||||
.after(GameMutation),
|
||||
);
|
||||
app.init_resource::<TouchSelectionState>().add_systems(
|
||||
Update,
|
||||
(
|
||||
clear_touch_selection_on_state_change,
|
||||
update_touch_selection_highlight,
|
||||
)
|
||||
.chain()
|
||||
.after(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,9 +121,9 @@ pub(crate) fn clear_touch_selection_on_state_change(
|
||||
|
||||
/// Maintains the `TouchSelectionHighlight` outline sprite on the selected source card.
|
||||
///
|
||||
/// All existing `TouchSelectionHighlight` entities are despawned each frame and
|
||||
/// a new one is spawned on the top card of the selected pile (if any). This
|
||||
/// matches the pattern used by `selection_plugin::update_selection_highlight`.
|
||||
/// Rebuilds the highlight set only when [`TouchSelectionState`] or the layout
|
||||
/// actually changes — not every frame. Existing highlights are despawned first,
|
||||
/// then a fresh highlight is spawned on every card in the selected stack.
|
||||
pub(crate) fn update_touch_selection_highlight(
|
||||
mut commands: Commands,
|
||||
selection: Res<TouchSelectionState>,
|
||||
@@ -131,6 +131,12 @@ pub(crate) fn update_touch_selection_highlight(
|
||||
highlights: Query<Entity, With<TouchSelectionHighlight>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
) {
|
||||
// Skip when neither the selection nor the layout changed this frame.
|
||||
let layout_changed = layout.as_ref().map(|l| l.is_changed()).unwrap_or(false);
|
||||
if !selection.is_changed() && !layout_changed {
|
||||
return;
|
||||
}
|
||||
|
||||
// Despawn stale highlights first.
|
||||
for entity in &highlights {
|
||||
commands.entity(entity).despawn();
|
||||
|
||||
Reference in New Issue
Block a user