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:
funman300
2026-06-01 21:23:52 -07:00
parent 20e5222148
commit 64f975ed6d
9 changed files with 571 additions and 216 deletions
+93 -31
View File
@@ -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