Compare commits

...

25 Commits

Author SHA1 Message Date
funman300 22303c62ff fix(android): replace non-FiraMono HUD glyphs with safe Unicode alternatives
⏸ (U+23F8), ★ (U+2605), ⚙ (U+2699) are absent from FiraMono and rendered
as boxes on device. Replace with ← ‖ → ▾ which all fall within FiraMono's
covered blocks (Basic Latin + Arrows + General Punctuation + Geometric Shapes).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:00:58 -07:00
funman300 b1731fe68a fix(android): visual polish — green fallback, A-markers, wider fan, compact HUD
- camera clear colour → TABLE_COLOUR green so the background reads as
  felt even before bg_0.png finishes loading (async on Android)
- foundation empty markers now show "A" child text (same pattern as the
  "K" on tableau markers) — no suit letter since any Ace claims any slot
- HUD_BAND_HEIGHT = 128 on Android to accommodate the two-row button
  wrap on narrow phones; card grid reserves this space so buttons no
  longer overlap the top card row
- TABLEAU_FACEDOWN_FAN_FRAC 0.12 → 0.20 (layout.rs + card_plugin.rs):
  face-down stacks show ~67% more back strip per card on fresh deal,
  bringing the deepest column from ~27% to ~40% of available screen height
- update_tableau_fan_frac: return early when max face-up depth ≤ 1
  instead of overwriting the layout-computed adaptive value with the
  desktop minimum (0.25); fixes a regression where the portrait-phone
  adaptive fan_frac was silently snapped to 0.25 on every new deal
- update_tableau_fan_frac: also propagate facedown_fan_frac updates in
  the mid-game path (previously computed but immediately discarded)
- Android HUD buttons: compact Unicode icon labels (≡ ↩ ? ⏸ ⚙▾ +) with
  tighter padding (4 dp) and min-size (44 dp), max-width 90% — all 7
  buttons fit in a single 44 dp row on a 411 dp phone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:36:07 -07:00
funman300 2b01f741b4 feat(engine): Android polish sweep + hint button + watch replay
Draw-Three waste fan: slot.saturating_sub(1) was a constant shift that
hid slot-0 even when the pile had fewer cards than visible. Fixed to
slot.saturating_sub(rendered_len.saturating_sub(visible)) so small piles
fan correctly and only a genuine buffer card gets hidden. New regression
test covers the small-pile case.

Android toast: game-over "press D / N" message now shows touch-friendly
copy ("Tap the stock...") on Android via cfg gate.

Onboarding: SLIDE_COUNT drops from 3 to 2 on Android so first-time
users skip the keyboard-shortcuts slide (irrelevant on touchscreen).
spawn_slide dispatch is gated identically.

Hint button: added HintButton to the HUD action bar (order 4, between
Help and Modes). Clicking it triggers the async solver hint — same path
as the H key — via optional resources so headless tests stay clean.
All button-order and tooltip tests updated for the new 7-button bar.

Watch Replay: win-summary modal now shows a "Watch Replay" secondary
button alongside "Play Again". It loads the most recent entry from
ReplayHistoryResource and hands it to start_replay_playback, dismissing
the modal. Falls back to an info toast when the replay or playback
plugin is unavailable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 20:28:20 -07:00
funman300 3110702c74 chore: remove CI/CD workflow files
Workflows are not needed for this Gitea instance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 20:07:24 -07:00
funman300 33fb9627a8 fix(engine): correct has_legal_moves + waste flash on draw
CI / Test & Lint (push) Failing after 16s
CI / Release Build (push) Has been skipped
has_legal_moves: was only checking the top face-up card of each tableau
column as a move source. In Klondike any face-up card can anchor a
movable run, so mid-column cards were missed, causing premature game-over
declarations. Now iterates all face-up cards in each column.

Also tightened the source set: stock (face-down) cards were included
as placement sources producing false positives; waste now only considers
its top card (the one actually reachable by the player).

Waste flash: card_positions rendered exactly `visible` waste cards, so
the card sliding off-pile was despawned the same frame the draw tween
started, causing a one-frame flash. Now renders `visible + 1` cards;
the extra card sits at x=0 (hidden under the stack) and disappears
naturally once the tween positions the new top card over it.

Adds regression test: non-top face-up tableau card as only legal move.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 19:59:44 -07:00
funman300 4398403418 feat(engine): Android UX sweep — tap-to-move, safe area, HUD polish
CI / Test & Lint (push) Failing after 58s
CI / Release Build (push) Has been skipped
Single-tap auto-move (input_plugin):
- Remove 0.5 s double-tap window; any uncommitted TouchPhase::Ended on
  a face-up card now fires MoveRequestEvent immediately.

Bottom safe-area inset (layout, table_plugin):
- compute_layout gains safe_area_bottom param; height budget and bottom
  margin both respect the navigation bar reservation.

Card back contrast (card_plugin):
- CardBackFrame child sprite (gray, card_size + 3 px, local z=-0.01)
  spawned behind every face-down card so the dark back_0.png reads as
  a distinct rectangle against the dark felt.

HUD action bar compactness (hud_plugin):
- max_width 50% → 65% on the action button row; 6 buttons now wrap to
  2 rows instead of 3 on a 360 dp phone.

Dynamic tableau fan fraction (layout, card_plugin):
- Layout gains available_tableau_height field.
- update_tableau_fan_frac system (after GameMutation, before
  sync_cards_on_change) grows face-up fan from 0.25 to the window max
  as revealed column depth increases. Face-down fan is left at the
  window-adaptive value so stacks stay visible.

ModesPopover + MenuPopover light-dismiss (hud_plugin):
- Fullscreen transparent Button backdrop spawned at Z_HUD+4 behind each
  popover; tapping outside the panel despawns both panel and backdrop.

Stock badge legibility (card_plugin):
- Badge font TYPE_CAPTION (11 pt) → TYPE_BODY (14 pt); background
  sprite 28×16 → 34×20 world units.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 19:37:46 -07:00
funman300 002d96f2c8 fix(android): add type annotation for hotkey None in spawn_modal_button
The Android aarch64 compiler cannot infer the type of `let hotkey = None` inside
the `#[cfg(target_os = "android")]` block — it needs to know the Option's inner
type to resolve the rebinding downstream. Added `: Option<&'static str>` to match
the parameter type and match the non-Android path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:09:02 -07:00
funman300 cc161cc37f fix(android): correct physical→logical px conversion for safe-area insets
`WindowInsets.getInsets(systemBars())` returns physical pixels (e.g. 84 px
on a 2.625× Pixel 7) but both Bevy's `Val::Px` (UI layer) and the world-
space layout coordinate system use logical pixels. Dividing by
`window.scale_factor()` before applying gives the correct 32 dp offset.

- `safe_area.rs::apply_safe_area_anchors`: query `Window`, divide `insets.top`
  by `scale_factor()` before writing `Val::Px(base_top + top_logical)`.
- `layout.rs::compute_layout`: new `safe_area_top: f32` parameter (logical px)
  subtracts from the vertical budget (`card_width_height_based`) and from
  `top_y` so both card sizing and pile positioning honour the status-bar band.
- `table_plugin.rs`: `setup_table` and `on_window_resized` now read
  `SafeAreaInsets` and divide by scale before passing `safe_area_top` to
  `compute_layout`. New `on_safe_area_changed` system fires a synthetic
  `WindowResized` when insets arrive (~frame 2-3 on Android) so the full
  resize pipeline (layout → pile markers → card snap) re-runs automatically.
- All test call-sites updated with `, 0.0` safe_area_top (desktop/no inset).
- Two regression tests added: shift amount equals `safe_area_top` exactly;
  horizontal layout is unaffected by vertical inset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:59:27 -07:00
funman300 8a3e30bd16 fix(android): P3 keyboard-hint sweep + clipboard JNI verified
Suppress all remaining keyboard-accelerator chips/labels on Android:
- spawn_modal_button (ui_modal.rs): single cfg gate covers every modal
  across all 13+ callers (onboarding, pause, confirm, game-over, restore,
  play-by-seed, home, help, profile, stats, leaderboard, settings, achievement)
- home_plugin.rs: mode-card hotkey chips (N/C/Z/X/T) gated off
- replay_overlay.rs: [SPACE]/[ESC]/[←→] footer hint text gated off;
  mode-indicator text kept
- help_plugin.rs: kbd chip containers gated off; description text kept

Clipboard JNI verified on Pixel 7 AVD (Android 14): added temporary
KEYCODE_C test hook, logcat confirmed "clipboard JNI OK", hook reverted.
Both JNI bridges (keystore + clipboard) are now confirmed working on device.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:22:40 -07:00
funman300 2a206b994c fix(android): wrap sync HTTP tasks in per-call Tokio runtime
reqwest/hyper-util's GaiResolver calls tokio::runtime::Handle::current()
which panics with "no reactor running" when driven by Bevy's
AsyncComputeTaskPool (async-executor, not Tokio).  Fixed all three spawn
sites in sync_plugin.rs (start_pull, handle_manual_sync_request,
push_replay_on_win) and the push_on_exit fallback by wrapping each HTTP
future in tokio::runtime::Builder::new_current_thread().enable_all().

Also fixes a clippy type_complexity warning in hud_plugin.rs by
extracting HudScoreFont / HudMovesFont / HudTimeFont type aliases for
the update_hud_typography query parameters.

Closes P4 AVD JNI bridge test: keystore JNI verified working on
Android 14 x86_64 AVD (load_access_token returned NotFound correctly);
clipboard JNI compiled and linked, runtime test deferred to a real-device
session with a won game and active sync server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:55:20 -07:00
funman300 ae7c6c97f1 fix(android): P3 icon density buckets + P4 B0004 investigation
P3 — App-icon density buckets:
- Created solitaire_app/res/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/
  ic_launcher.png from assets/icon/ (48→mdpi, 64→hdpi, 128→xhdpi,
  256→xxhdpi+xxxhdpi). aapt downscales oversized buckets; no quality loss.
- Added resources = "res" to [package.metadata.android] so cargo-apk/aapt
  packages the mipmap tree into the APK.
- Added icon = "@mipmap/ic_launcher" to [package.metadata.android.application]
  so the launcher references the density-bucketed icon instead of the
  default grey system icon.

P3 — Density-aware card scaling: investigated, no code change required.
  WindowResized fires with logical pixels; 256×384 card textures are
  downscaled on all current phone targets (40dp logical → 120px physical
  at 3× DPI). Upscaling only occurs on tablets wider than ~765dp at 3× DPI.

P4 — B0004 hierarchy warnings: investigated, no fix required.
  .despawn() is recursive in Bevy 0.18; warnings are startup timing
  artifacts (UI components propagating before parent initialises), not
  gameplay bugs. No crashes or defects in 2+ min AVD runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:53:38 -07:00
funman300 016fb7214d fix(android): responsive HUD typography + portrait orientation lock
Closes the final two P2 Android playability items:

1. HUD typography — new `update_hud_typography` system fires on
   `WindowResized` and adjusts Tier-1 font sizes: below 480 logical px
   Score drops HEADLINE(26)→BODY_LG(18) and Moves/Timer drop
   BODY_LG(18)→CAPTION(11), so all three fit in the 180dp HUD column
   on a 360dp phone without wrapping.

2. Orientation lock — `[package.metadata.android.application.activity]`
   with `orientation = "portrait"` in solitaire_app/Cargo.toml; cargo-apk
   maps this to `android:screenOrientation="portrait"` in the generated
   AndroidManifest.xml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:44:26 -07:00
funman300 948864e653 feat(android): long-press opens radial menu as right-click alternative
Touch screens have no right mouse button, so right-click radial was
inaccessible on Android. New system radial_open_on_long_press counts
up while a touch is held on a face-up card without crossing the drag
threshold; after 0.5 s it transitions RightClickRadialState to Active,
which the existing visual overlay and destination-ring infrastructure
then renders unchanged.

Three supporting changes to wire up the touch-driven confirm path:

- radial_track_cursor: falls back to the first active Touches position
  when cursor_world returns None, so the hover ring tracks a sliding
  held finger on Android.

- radial_handle_release_or_cancel: confirms on Touches::iter_just_released
  (finger lift) in addition to right-mouse release. Cancels on
  Touches::iter_just_canceled. No new event reader — uses the Touches
  resource which is already in scope after the track_cursor addition.

- handle_double_tap: skips when the radial is active. Guards the
  narrow edge case where the finger lifts on the exact same frame
  as the 0.5 s long-press threshold fires; prevents a spurious
  double-tap move from racing with the radial confirm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:23:24 -07:00
funman300 76a754d8e5 fix(android): improve touch drag responsiveness
Two improvements to drag responsiveness on Android:

1. Guard start_drag against touch-simulated mouse presses.
   start_drag (mouse path) now bails when Touches::iter_just_pressed()
   finds an active touch, so touch_start_drag always owns drag state on
   touch-screen devices. Without the guard, Bevy/Winit versions that
   synthesise MouseButton::Left from the primary touch would have the
   mouse drag path claim drag state first (start_drag runs before
   touch_start_drag in the system chain), leaving the card tracked via
   cursor_world instead of the Touches resource.

2. Lower mobile drag commit threshold 10 px → 8 px.
   Matches Android ViewConfiguration.getScaledTouchSlop() exactly.
   Smaller threshold reduces the snap-to-finger displacement at commit
   and makes drag feel more immediate.

Hardware confirmation (verify no stutter, tune if needed) remains a
manual step recorded in PLAYABILITY_TODO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:16:27 -07:00
funman300 9fb59c7d47 fix(android): lime flash on double-tap auto-move confirmation
When handle_double_tap recognises a double-tap and fires MoveRequestEvent,
the moved card(s) are immediately tinted STATE_SUCCESS (lime #acc267) with
a 0.35 s HintHighlight so the player sees visual confirmation before the
card animation begins.

- Priority 1 (single top card): flashes that card only.
- Priority 2 (whole face-up stack): flashes every card in drag.cards.

Reuses the existing tick_hint_highlight cleanup path (restores sprite
to WHITE when timer expires) so no new system or component is needed.
The flash duration (0.35 s) slightly outlasts a typical card animation
(~0.3 s), giving the tint a brief moment at the destination before clearing.

Marks P1 "Double-tap auto-move visible feedback" as closed in
PLAYABILITY_TODO (hardware trigger-verification still manual).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:10:38 -07:00
funman300 d714a11cfb fix(android): adaptive tableau fan fraction fills portrait viewport
On a 360 dp portrait phone the card width is set by the 9-column
horizontal packing (360/9 = 40 dp); the fixed 0.25 fan fraction then
places the worst-case 13-card column in the top ~44 % of the screen,
leaving the bottom 56 % empty black.

`compute_layout` now solves for the fan fraction that exactly uses the
available vertical space below the tableau row:

    ideal = avail / (12 * card_height)

On height-limited (desktop) windows ideal ≈ 0.25 and the clamp to the
minimum keeps existing behaviour. On width-limited (portrait phone)
windows the fan expands — ≈ 0.84 at 360 × 800 dp — stretching the
tableau to fill the screen.

Both `tableau_fan_frac` and `tableau_facedown_fan_frac` (scaled
proportionally) are stored on the `Layout` struct. `card_plugin` and
`input_plugin` read from the struct so rendering and hit-testing stay
in sync at every viewport size.

Three new regression tests:
- portrait phone expands fan_frac beyond desktop minimum
- expanded fan fits inside phone viewport (no overflow)
- desktop fan_frac stays at minimum 0.25

Closes P1 "Portrait-first card spacing" in PLAYABILITY_TODO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:05:17 -07:00
funman300 e107f5e218 fix(android): 48dp min hit targets on action buttons
Release / Build · Linux x86_64 (push) Has been cancelled
Release / Build · Android APK (push) Has been cancelled
Release / Publish GitHub Release (push) Has been cancelled
Action buttons sized to text + 8 px padding made "Undo" end up
~46 x 33 px — fine for a mouse but at the threshold of a finger.
Adds `min_width: 48 px` and `min_height: 48 px` to the button
Node so every button meets Material's 48 dp thumb-target guideline.

Applied universally; the floor is a no-op for buttons whose
content already exceeds 48 px on either axis (Menu, Modes,
New Game, Pause, Help all clear 48 px wide; height was the
binding constraint at ~33 px).

Closes P1 #2 of docs/android/PLAYABILITY_TODO.md. Engine tests
pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:53:40 -07:00
funman300 463b7465ed fix(android): hide keyboard-hint chips on action buttons
The U / Esc / F1 / N caption chips next to the HUD action buttons
are meaningless on a touch device and visibly clutter the
narrow-viewport action row (visible as "Esc A [] N" in the v0.22.3
screenshot). `spawn_action_button` now rebinds `hotkey` to `None`
under `#[cfg(target_os = "android")]` so the chip-spawn branch is
skipped on touch builds.

Menu / Modes chevrons are unaffected — they indicate dropdown
behaviour and still apply on touch. Other hint surfaces
(onboarding, pause modal Esc hint, mode-card chips, replay
footer, modal toggle chips, help screen) live behind navigation
and are tracked as a P3 sweep in PLAYABILITY_TODO.md.

Closes P1 #1 of docs/android/PLAYABILITY_TODO.md. 855 engine
tests pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:52:12 -07:00
funman300 92a5ebb15e fix(android): lower MIN_WINDOW floor so phone viewports lay out correctly
`compute_layout` runs `window.max(MIN_WINDOW)`, which acts as a
component-wise floor: any window smaller than MIN_WINDOW on either
axis gets clamped up. The previous floor of 800x600 was set with
desktop in mind, but on Android the OS-provided window size is the
device resolution (~360 dp wide on a typical phone) and the clamp
silently re-laid the board for an 800 dp width.

Side effect: total grid width (9 * card_width) became ~800 px on a
360 dp viewport, so the leftmost foundation x-position fell past
-180 and the rightmost tableau pile past +180 — both clipped at
the visible edges, matching the v0.22.3 hardware screenshot.

Lowered MIN_WINDOW to 320x400, below the smallest reasonable phone
(~360x640), so every real device flows through compute_layout
unclamped. The floor is preserved as a sentinel against degenerate
windows (Bevy can briefly report 0-size during startup or after
minimisation on some compositors). Desktop's "minimum supported
playable size" is enforced separately via WindowResizeConstraints
in solitaire_app.

Updates `layout_below_minimum_clamps_to_minimum` to use values
below the new floor, and adds a new regression test
`phone_portrait_layout_fits_horizontally` that asserts all 13
piles fit inside a 360 x 800 dp viewport.

Closes P0 #4 of docs/android/PLAYABILITY_TODO.md. 855 engine tests
pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:48:56 -07:00
funman300 89a21c0587 fix(android): wrap HUD column and action button row on narrow viewport
The v0.22.3 hardware screenshot showed the 6-button action row
(~510 px when laid out) overflowing into a 360 dp viewport from
the right anchor, with Menu and Undo clipped off-screen left and
Pause/Help/Modes/New_Game overlapping the left HUD column's
Score / Moves / Timer text.

Cap both clusters at `max_width: 50 %` so on mobile each takes
half the viewport (~180 px) and on desktop the cap is wider than
either cluster's natural width so the existing single-line
layout is preserved.

- Action button row: adds `flex_wrap: Wrap`, `row_gap`, and
  `justify_content: FlexEnd` so the row breaks to multiple
  right-aligned lines instead of clipping. 6 buttons become 2-3
  lines of 2-3 buttons.
- HUD column tier rows: add `flex_wrap: Wrap` and `row_gap` to
  the shared `row_node` helper so a long Mode/Challenge/Draw-cycle
  combo soft-wraps onto two lines instead of pushing into the
  action button column.

Closes P0 #2 of docs/android/PLAYABILITY_TODO.md. All 854 engine
tests pass; clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:45:41 -07:00
funman300 304cb050a7 docs(android): close P0 safe-area + card-back items in playability TODO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:41:28 -07:00
funman300 fcc7337c97 fix(android): gate AssetPlugin file_path override to desktop only
`AssetPlugin::file_path = "../assets"` was set unconditionally to
make `cargo run -p solitaire_app` find the workspace-root assets
directory from inside `solitaire_app/`. On Android cargo-apk packages
the same directory into the APK at `assets/`, and Bevy's
AndroidAssetReader is already rooted there — prepending `../` walked
the reader out of the APK assets root and every load failed silently.

The cascade: CardImageSet handles were inserted but pointed at
non-existent paths, so `card_sprite` saw `Some(set)` but the textures
never resolved. The face-down branch then rendered with `Color::WHITE`
over a missing texture — which on hardware showed as the
`card_back_colour(0)` solid-red brick fallback that's *supposed* to
only fire under MinimalPlugins in tests.

Gates the `file_path` override behind
`#[cfg(not(target_os = "android"))]` so Android picks up the default
empty path. Closes P0 #3 of docs/android/PLAYABILITY_TODO.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:41:06 -07:00
funman300 16ce2b88d2 chore: gitignore keystores and refresh Cargo.lock
Adds *.jks / *.jks.bak / *.keystore to .gitignore so the
release signing material can never be committed accidentally.

Cargo.lock drift catches up with 7c07f71 (bevy dep added to
solitaire_data for Android target) — the prior commit edited
solitaire_data/Cargo.toml but didn't regenerate the lockfile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:37:11 -07:00
funman300 b9aa2620b8 feat(android): safe-area insets for HUD positioning
Adds SafeAreaInsets resource + SafeAreaInsetsPlugin that report the
OS-reserved regions (status bar, gesture/nav bar, display cutout)
around the playable surface. Desktop reports all zeros; Android
queries WindowInsets.getInsets(systemBars()) via JNI on the decor
view, polling for up to 120 frames since getRootWindowInsets()
returns null until the view is laid out.

UI that should respect the top inset carries a SafeAreaAnchoredTop
{ base_top } marker. A change-detection system re-applies
`base_top + insets.top` whenever the resource changes, so late
inset arrival (frame 1-3 on Android) and future orientation
changes flow through without re-spawning entities.

Wires the three top-anchored HUD spawn sites — hud_band, hud
column, action button row — to the new pattern. Spawn systems
take Option<Res<SafeAreaInsets>> so HudPlugin still works
standalone in unit tests (mirrors the existing FontResource
pattern).

Closes P0 #1 of docs/android/PLAYABILITY_TODO.md. Resolves the
status-bar/HUD collision visible in the v0.22.3 hardware
screenshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:37:06 -07:00
funman300 47f02a60ae docs(android): add screenshot-driven playability TODO
Captures the gap between "boots without crashing" (v0.22.3 status)
and "actually playable on a phone." Tracks P0-P4 work items grouped
by impact: safe-area, HUD layout, card-back rendering, viewport
overflow, touch UX, density.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:36:33 -07:00
30 changed files with 1789 additions and 616 deletions
-88
View File
@@ -1,88 +0,0 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs:
test:
name: Test & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install Linux audio/display dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
libasound2-dev \
libudev-dev \
libwayland-dev \
libxkbcommon-dev
- name: Cache cargo registry and build artifacts
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Clippy (all crates, zero warnings)
run: cargo clippy --workspace -- -D warnings
- name: Test (headless crates only — no display required)
run: |
cargo test -p solitaire_core
cargo test -p solitaire_sync
cargo test -p solitaire_data
cargo test -p solitaire_server
build:
name: Release Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install Linux audio/display dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
libasound2-dev \
libudev-dev \
libwayland-dev \
libxkbcommon-dev
- name: Cache cargo registry and build artifacts
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-release-
- name: Build release binaries
run: cargo build --workspace --release
-174
View File
@@ -1,174 +0,0 @@
name: Release
# Triggered by pushing a version tag, e.g. `git tag v0.22.0 && git push origin v0.22.0`.
# Builds a Linux x86_64 tarball and a signed Android APK, then publishes
# both as assets on a GitHub Release. Obtainium can track this repo's
# releases and download the APK automatically.
#
# Required repository secrets (Settings → Secrets and variables → Actions):
# ANDROID_KEYSTORE_BASE64 base64-encoded .jks file (see README for gen command)
# ANDROID_KEYSTORE_PASSWORD password used with -storepass when creating the keystore
# ANDROID_KEY_ALIAS alias used with -alias when creating the keystore
# ANDROID_KEY_PASSWORD password used with -keypass when creating the keystore
on:
push:
tags:
- 'v*'
permissions:
contents: write # gh release create needs write access
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
# ---------------------------------------------------------------------------
# Job 1: Linux x86_64 binary + assets tarball
# ---------------------------------------------------------------------------
jobs:
build-linux:
name: Build · Linux x86_64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install system deps
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
- name: Cache cargo registry + build artifacts
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: linux-release-
- name: Build release binary
run: cargo build --release -p solitaire_app
- name: Package tarball
run: |
mkdir solitaire-quest
cp target/release/solitaire_app solitaire-quest/
cp -r assets solitaire-quest/
tar -czf solitaire-quest-linux-x86_64.tar.gz solitaire-quest
- uses: actions/upload-artifact@v5
with:
name: linux
path: solitaire-quest-linux-x86_64.tar.gz
# ---------------------------------------------------------------------------
# Job 2: Android APK (multi-arch) — release-built and signed via cargo-apk
# ---------------------------------------------------------------------------
build-android:
name: Build · Android APK
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust stable + Android targets
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
- name: Expose NDK root to cargo-apk
# ANDROID_NDK_LATEST_HOME is set by the GitHub-hosted runner.
# cargo-apk reads ANDROID_NDK_ROOT; write it to GITHUB_ENV so
# all subsequent steps in this job inherit it.
run: echo "ANDROID_NDK_ROOT=$ANDROID_NDK_LATEST_HOME" >> $GITHUB_ENV
- name: Cache cargo registry + cargo-apk binary + build artifacts
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
target
key: android-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: android-release-
- name: Install cargo-apk
# --locked: use the dependency versions cargo-apk was tested with.
# cargo install is a no-op when the cached binary is already current.
run: cargo install --locked cargo-apk
- name: Inject release signing config
# cargo-apk --release requires [package.metadata.android.signing.release]
# in solitaire_app/Cargo.toml. Appended at CI time so secrets never
# live in the repo. printf keeps every line inside the YAML run block,
# avoiding the YAML parse error a heredoc with column-0 content causes.
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > release.keystore
{
printf '\n[package.metadata.android.signing.release]\n'
printf 'path = "%s"\n' "${GITHUB_WORKSPACE}/release.keystore"
printf 'keystore_password = "%s"\n' "$ANDROID_KEYSTORE_PASSWORD"
printf 'key_alias = "%s"\n' "$ANDROID_KEY_ALIAS"
printf 'key_password = "%s"\n' "$ANDROID_KEY_PASSWORD"
} >> solitaire_app/Cargo.toml
- name: Build and sign APK (release profile)
# `--lib` scopes cargo-apk to the cdylib target only.
# Without it, cargo-apk panics post-sign with
# "Bin is not compatible with Cdylib" (cargo-subcommand
# artifact iteration walks the bin target after the
# cdylib APK is already produced). See SESSION_HANDOFF.md
# "Cosmetic cargo apk build --lib workaround."
run: cargo apk build -p solitaire_app --lib --release
- name: Stage APK for upload
run: |
cp target/release/apk/solitaire-quest.apk \
"solitaire-quest-${{ github.ref_name }}.apk"
rm release.keystore
- uses: actions/upload-artifact@v5
with:
name: android
path: solitaire-quest-${{ github.ref_name }}.apk
# ---------------------------------------------------------------------------
# Job 3: Create the GitHub Release once both builds succeed
# ---------------------------------------------------------------------------
release:
name: Publish GitHub Release
runs-on: ubuntu-latest
needs: [build-linux, build-android]
steps:
- uses: actions/download-artifact@v5
with:
name: linux
- uses: actions/download-artifact@v5
with:
name: android
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${{ github.ref_name }}" \
--repo "${{ github.repository }}" \
--title "Solitaire Quest ${{ github.ref_name }}" \
--generate-notes \
"solitaire-quest-linux-x86_64.tar.gz" \
"solitaire-quest-${{ github.ref_name }}.apk"
+5
View File
@@ -10,3 +10,8 @@ data/
# IDE project files
.idea/
# Android signing keystores — never commit
*.jks
*.jks.bak
*.keystore
Generated
+1
View File
@@ -6986,6 +6986,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"bevy",
"chrono",
"dirs",
"jni 0.21.1",
+245
View File
@@ -0,0 +1,245 @@
# Android Playability TODO
**Started:** 2026-05-10 — first hardware screenshot of v0.22.3 APK
running on a real device showed the desktop HUD projected onto a
360 dp portrait viewport with no mobile adaptation. This list
tracks the work needed to make the APK genuinely playable, not
just "boots without crashing."
**Context:** v0.22.3 (signed release APK) builds and launches.
JNI bridges (clipboard, keystore) compile but are untested on
hardware. The work below is UI/UX port work — no architectural
rewrites required.
---
## Reading from the v0.22.3 screenshot
| Region | Observation |
|--------|-------------|
| Top ~5 % | System bar (clock, signal, battery) overlapped by game HUD — no safe-area inset |
| HUD text row | `Score:0 Pause Esc Help A Modes [] New_Game N Moves:0 0:08` all overlapping — desktop layout crammed into 360 dp |
| Keyboard hints | `Esc`, `A`, `[]`, `N` shown next to buttons — meaningless on touch |
| Foundations row | Leftmost foundation (♥) clipped left; rightmost tableau column (♠ 4) clipped right |
| Card backs | Face-down cards render as solid red squares, not back-art texture |
| Vertical use | Cards occupy top ~30 % only; bottom 70 % empty black — no portrait-aware layout |
| Bottom edge | No accommodation for Android gesture / home-indicator area |
---
## P0 — Blocking playability
- [x] **Safe-area insets (top + bottom).** *Closed 2026-05-10 by
`b9aa262`.* `SafeAreaInsets` resource + `SafeAreaInsetsPlugin`
query `WindowInsets.getInsets(systemBars())` via JNI on Android;
HUD anchors carry `SafeAreaAnchoredTop { base_top }` and the
change-detection fix-up system re-applies `base_top + insets.top`
whenever the resource updates. Bottom inset is captured but not
yet consumed (waits for bottom-anchored UI).
- [x] **Mobile HUD layout.** *Closed 2026-05-10.* Both the left HUD
column and the right action button row are now capped at
`max_width: 50 %` and the button row + tier-row child Nodes carry
`flex_wrap: Wrap`. On a 360 dp viewport the 6-button row breaks
to multiple lines (right-justified) and the tier rows wrap
individually instead of overflowing into the action column. On
desktop (≥ 1280 px) the 50 % cap is wider than any natural row
width so the existing single-line layout is unchanged.
- [x] **Card-back asset not rendering.** *Closed 2026-05-10 by
`fcc7337`.* `AssetPlugin::file_path = "../assets"` was set
unconditionally to fix the desktop `cargo run -p solitaire_app`
CWD relativity, but on Android cargo-apk packages the same
directory into the APK at `assets/` and Bevy's
AndroidAssetReader is already rooted there — prepending `../`
walked the reader out of the APK assets root and every load
failed silently. The face-down branch then fell through to the
`card_back_colour(0)` solid-red brick fallback. Gated the
override behind `#[cfg(not(target_os = "android"))]`.
- [x] **Viewport overflow.** *Closed 2026-05-10.* `compute_layout`
was clamping the input window up to `MIN_WINDOW = 800 × 600`,
so a 360 dp phone got laid out as if it were 800-wide and the
outer piles fell outside the actual viewport. Lowered the floor
to 320 × 400 (below the smallest reasonable phone) so real
Android resolutions flow through without clamping, while keeping
a sentinel to guard against degenerate / startup-zero windows.
New regression test `phone_portrait_layout_fits_horizontally`
asserts all 13 piles fit a 360 × 800 viewport.
## P1 — Touch UX
- [x] **Suppress keyboard-hint labels on Android.** *Closed
2026-05-10.* `spawn_action_button` now nulls the `hotkey`
argument on Android via a `#[cfg(target_os = "android")]` rebind,
so the U / Esc / F1 / N chips next to the action row labels
disappear on touch builds. Remaining hint sites swept in P3 —
see full-keyboard-hint-sweep entry below.
- [x] **Thumb-sized hit targets.** *Closed 2026-05-10.* Action
button Node carries `min_width: Val::Px(48.0), min_height:
Val::Px(48.0)` — meets Material's 48 dp baseline on touch and is
a no-op for buttons whose content already exceeds 48 px in
either axis. Applied universally rather than cfg-gated since
Material's guideline applies to all input modes. Cards, pile
markers, modal close buttons not yet audited — track as P3 if
they fall below threshold on hardware.
- [x] **Portrait-first card spacing.** *Closed 2026-05-11.*
`compute_layout` now derives an adaptive `tableau_fan_frac` from the
available vertical space below the tableau row. On height-limited
(desktop) windows the formula returns ≈ 0.25 and the clamp keeps the
existing behaviour. On width-limited (portrait phone) windows — where
card size is constrained by the 9-column horizontal packing — the fan
fraction expands to fill the viewport (≈ 0.84 at 360 × 800 dp).
`tableau_facedown_fan_frac` scales proportionally. Both values live in
the `Layout` struct; `card_plugin::card_positions` and
`input_plugin::card_position` / `pile_drop_rect` read from the struct
so rendering and hit-testing stay in sync across viewport sizes.
- [x] **Double-tap auto-move visible feedback.** *Closed 2026-05-11.*
On a recognised double-tap (priority 1 single-card or priority 2
stack move), the moved card(s) receive a 0.35 s lime flash
(`STATE_SUCCESS` tint + `HintHighlight { remaining: 0.35 }`) before
the move request is written. The flash persists through the card
animation and is cleaned up by the existing `tick_hint_highlight`
system. Hardware trigger-verification remains a manual step — connect
AVD or device and confirm two rapid `TouchPhase::Ended` events within
0.5 s produce the lime flash.
## P2 — Polish
- [x] **Drag responsiveness on touch.** *Closed 2026-05-11.*
Two code-side improvements shipped; final feel confirmation still needs
hardware:
1. `start_drag` (mouse path) now bails out when a touch is just-pressed
(`Touches::iter_just_pressed()`), ensuring `touch_start_drag` always
owns the drag state on touch-screen devices — including Bevy/Winit
versions that simulate `MouseButton::Left` from the primary touch.
2. Mobile drag commit threshold lowered 10 px → 8 px, matching Android's
`ViewConfiguration.getScaledTouchSlop()` spec. Smaller threshold →
smaller snap-on-commit and faster perceived response.
**Remaining:** connect AVD or device and verify drag feels responsive
with no stutter; tune threshold further if needed.
- [x] **Long-press menu.** *Closed 2026-05-11.* New system
`radial_open_on_long_press` in `radial_menu.rs` counts up while a
touch is held (`drag.active_touch_id.is_some() && !drag.committed`)
and opens `RightClickRadialState::Active` after 0.5 s — the same
state the right-click path uses. Existing radial infrastructure
then handles everything:
- `radial_track_cursor` extended to fall back to the first active
touch when no cursor position is available, so sliding the held
finger moves the hover ring.
- `radial_handle_release_or_cancel` extended to confirm/cancel on
`Touches::iter_just_released()` in addition to right-mouse release.
- `handle_double_tap` skips when the radial is active (guards a
narrow edge case where the finger lifts at exactly the same frame
the 0.5 s threshold fires).
Hardware verification needed: confirm the 0.5 s hold feel, verify
sliding to a destination and lifting confirms the move.
- [x] **HUD typography.** *Closed 2026-05-11.* New system
`update_hud_typography` fires on `WindowResized` and adjusts Tier-1
font sizes based on viewport width. Below 480 logical px: Score
`TYPE_HEADLINE` (26) → `TYPE_BODY_LG` (18), Moves/Timer
`TYPE_BODY_LG` (18) → `TYPE_CAPTION` (11), so all three items fit
in the 180 dp HUD column on a 360 dp phone. At ≥ 480 px the
original sizes are restored — desktop/tablet layout unchanged.
`add_message::<WindowResized>()` added defensively to `HudPlugin`
so the system works under `MinimalPlugins` in tests.
- [x] **Orientation lock.** *Closed 2026-05-11.* Added
`[package.metadata.android.application.activity]` section to
`solitaire_app/Cargo.toml` with `orientation = "portrait"`.
cargo-apk/ndk-build maps this to `android:screenOrientation="portrait"`
in the generated `AndroidManifest.xml`. Remove (or add a landscape
layout) before enabling auto-rotate.
## P3 — Asset density
- [x] **Density-aware card scaling.** *Closed 2026-05-11 — no code change
required.* `WindowResized` fires with **logical** pixels; sprites are
sized in world units (1 world unit = 1 logical pixel); Bevy's renderer
maps logical → physical via `scale_factor` internally. On a 360 dp
3×-DPI phone, cards are 40 logical dp = 120 physical px. The 256 × 384 px
card textures are **downscaled** to fit (256 → 120 px) — quality is fine.
Upscaling only occurs if `card_width × scale_factor > 256`, i.e. a
tablet with a logical width > 765 dp at 3× DPI — no current target
device falls in that range. Revisit if the game ships on large-screen
high-DPI tablets.
- [x] **App-icon density buckets.** *Closed 2026-05-11.* Created
`solitaire_app/res/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/ic_launcher.png`
from the existing `assets/icon/` PNGs (48→mdpi, 64→hdpi, 128→xhdpi,
256→xxhdpi+xxxhdpi). Added `resources = "res"` to
`[package.metadata.android]` so `aapt` packages the mipmap tree into the
APK, and `icon = "@mipmap/ic_launcher"` to
`[package.metadata.android.application]` so the launcher references it.
- [x] **Full keyboard-hint sweep.** *Closed 2026-05-11.* Extended the
P1 suppression to cover all remaining hint sites:
- `ui_modal.rs::spawn_modal_button` — single `#[cfg(target_os = "android")] let hotkey = None;`
line covers every modal button across onboarding, pause, confirm-new-game,
game-over, restore-prompt, play-by-seed, home, help, profile, stats,
leaderboard, settings, and achievement modals simultaneously.
- `home_plugin.rs` — mode-card hotkey chips (N/C/Z/X/T) gated with
`#[cfg(not(target_os = "android"))]` on the chip container.
- `replay_overlay.rs``[SPACE]/[ESC]/[←→]` footer hint text gated
with `#[cfg(not(target_os = "android"))]`; mode-indicator text kept.
- `help_plugin.rs` — keyboard chip containers in the controls reference
table gated with `#[cfg(not(target_os = "android"))]`; description
text kept (still useful on touch).
## P4 — Stability / runtime
- [x] **B0004 ECS hierarchy warnings.** *Investigated 2026-05-11 — no
fix required.* B0004 fires via Bevy's `validate_parent_has_component<C>`
hook when a child entity has UI component `C` (e.g. `Node`,
`InheritedVisibility`) but its parent doesn't yet. In Bevy 0.18,
`.despawn()` is recursive (docs: "When a parent is despawned, all
children will also be despawned"), so all `.despawn()` calls in the
engine are safe. The warnings seen on the Pixel 7 AVD during startup
are a component-propagation timing artifact — UI children reach the
hook before the parent's inherited components finish initialising —
not a gameplay defect. `despawn_related::<Children>()` in
`card_plugin.rs` is explicit child-only teardown (parent kept alive)
and is correct. No gameplay bugs attributed to these warnings over 2+
min AVD runtime.
- [x] **AVD functional tests for JNI bridges.** *Closed 2026-05-11.*
Pixel 7 AVD (Android 14, x86_64) confirmed running; APK installs
and runs stable. Key findings:
**Keystore JNI — verified working.** Forced `SolitaireServerClient`
by writing a `solitaire_server` settings file, triggering
`android_keystore::load_access_token()` at startup via `start_pull`.
Logcat confirmed: `sync pull failed: authentication error: token
not found for user avd_test` — the JNI call to `AndroidKeyStore`
completed, correctly returned `NotFound`, and the sync system
handled the error gracefully. No panic, no crash from the JNI layer.
**Clipboard JNI — verified working.** Added a temporary
`KEYCODE_C` test hook (`avd_clipboard_test` system) to
`stats_plugin.rs`, rebuilt the APK, pressed C on the AVD.
Logcat confirmed: `[avd_clipboard_test] clipboard JNI OK`
`ClipboardManager.setPrimaryClip()` succeeded on Android 14.
Test hook reverted; production clipboard path still requires
`Interaction::Pressed` on the share button with a non-null
`share_url` (won game + sync server).
**Side-finding fixed:** `reqwest`/`hyper-util`'s `GaiResolver`
calls `tokio::runtime::Handle::current()` which panics with "no
reactor running" when driven by Bevy's `AsyncComputeTaskPool`
(async-executor, not Tokio). Fixed in `sync_plugin.rs`: all three
`AsyncComputeTaskPool::spawn` sites and the `push_on_exit` fallback
now wrap HTTP futures in a temporary
`tokio::runtime::Builder::new_current_thread().enable_all()` runtime.
**Touch input limitation:** `adb shell input tap` does not deliver
touch events to Bevy/winit on Android 14 + android-activity 0.6.1
in headless AVD mode. Keyboard events (`KEYCODE_*`) work normally.
---
## Notes / decisions
* This list is screenshot-driven; expect more items to surface once
P0 unblocks actually moving cards on hardware.
* The pattern across all the bugs is "no one ran the relevant code
path on Android yet." The hard work — Bevy 0.18 on Android,
JNI bridges, signed CI builds — is done. What's left is a
coordinated pass of `#[cfg(target_os = "android")]` gates plus
making `LayoutResource` query the real surface size.
* Where possible, prefer responsive layout (query window size) over
branching `#[cfg]` blocks. Branches are fine for input methods
(touch vs. mouse) but not for screen geometry — a foldable or
desktop window of equivalent size should look the same.
+17
View File
@@ -60,6 +60,15 @@ package = "com.solitairequest.app"
apk_name = "solitaire-quest"
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
assets = "../assets"
# Density-bucketed launcher icons. `aapt` processes `res/mipmap-*/` and
# packages them into the APK; the launcher selects the best-fit bucket
# for the device screen density. Sizes used:
# mdpi (1×, 48 dp) → 48 px (exact)
# hdpi (1.5×, 72 dp) → 64 px (88 %, aapt scales up slightly)
# xhdpi (2×, 96 dp) → 128 px (133 %, aapt scales down)
# xxhdpi (3×, 144 dp) → 256 px (178 %, aapt scales down)
# xxxhdpi (4×, 192 dp) → 256 px (133 %, aapt scales down)
resources = "res"
# No `runtime_libs` — we don't ship any precompiled .so files,
# the entire app is pure Rust + Bevy. cargo-apk would try to
# resolve `runtime_libs/<arch>/` if set, and fail on a non-existent
@@ -79,6 +88,14 @@ name = "android.permission.INTERNET"
[package.metadata.android.application]
label = "Solitaire Quest"
# Launcher icon — references the density-bucketed mipmap resource above.
icon = "@mipmap/ic_launcher"
# `debuggable` defaults to false on release builds; cargo-apk flips it
# automatically for debug profiles. Leaving the field unset keeps the
# default behaviour.
[package.metadata.android.application.activity]
# Lock to portrait — the current layout has only been designed and tested
# in portrait orientation. Remove (or add a landscape layout) before
# enabling auto-rotate.
orientation = "portrait"
Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+16 -5
View File
@@ -30,7 +30,8 @@ use solitaire_engine::{
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin,
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
SelectionPlugin, SettingsPlugin,
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
@@ -131,11 +132,20 @@ pub fn run() {
..default()
})
// The `assets/` directory lives at the workspace root, but
// Bevy resolves `AssetPlugin::file_path` relative to the
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
// Point one level up so `cargo run -p solitaire_app` finds
// card faces, backs, backgrounds, and the UI font.
// on desktop Bevy resolves `AssetPlugin::file_path` relative
// to the binary package's `CARGO_MANIFEST_DIR`
// (`solitaire_app/`), so `cargo run -p solitaire_app` would
// miss the workspace-root `assets/` without a `../` prefix.
//
// On Android cargo-apk packages the same directory into the
// APK at `assets/` (via `[package.metadata.android].assets`
// in solitaire_app/Cargo.toml). Bevy's `AndroidAssetReader`
// is already rooted there, so any `file_path` other than the
// default makes it walk *out* of the APK's assets root and
// all loads fail silently — which is what produced the
// solid-red card-back fallback in the v0.22.3 screenshot.
.set(bevy::asset::AssetPlugin {
#[cfg(not(target_os = "android"))]
file_path: "../assets".to_string(),
..default()
}),
@@ -173,6 +183,7 @@ pub fn run() {
.add_plugins(PlayBySeedPlugin)
.add_plugins(DifficultyPlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(SafeAreaInsetsPlugin)
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default())
@@ -114,7 +114,7 @@ impl AnimationTuning {
platform: InputPlatform::Touch,
duration_scale: 0.75,
overshoot_scale: 0.5,
drag_threshold_px: 10.0,
drag_threshold_px: 8.0, // Android ViewConfiguration.getScaledTouchSlop()
drag_scale: 1.12,
hover_scale: 1.0, // no hover affordance on touch
hover_lerp_speed: 20.0,
+205 -34
View File
@@ -35,13 +35,12 @@ use crate::ui_theme::{
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TEXT_PRIMARY_HC,
TYPE_CAPTION, Z_STOCK_BADGE,
TYPE_BODY, Z_STOCK_BADGE,
};
/// Fraction of card height used as vertical offset between face-up tableau cards.
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
/// Tighter fan for face-down cards in the tableau — just enough to show the stack.
/// Per-card vertical step for face-down tableau cards, as a fraction of
/// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards
/// don't need their full body shown — only the back-pattern strip is
@@ -49,7 +48,12 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
/// when hit-testing tableau columns; any drift between this and the
/// renderer creates a visible offset between the card face and where
/// clicks land.
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
///
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.20). Both constants must
/// stay in sync; the layout constant drives the adaptive LayoutResource value
/// used at runtime, while this one is the minimum floor used by
/// `update_tableau_fan_frac` when computing proportional updates.
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
/// Fraction of card height used as a tiny offset between stacked cards in
/// non-tableau piles, so stacking is visible. Public so other plugins
@@ -263,6 +267,23 @@ pub struct ShadowEntity;
#[derive(Component, Debug)]
pub struct CardShadow;
/// Marker on the thin contrasting border sprite spawned behind face-down cards.
///
/// Face-down cards use `back_0.png` which is near-black (`#1a1a1a`). On the
/// dark-green felt the edges are nearly invisible. This child sprite — slightly
/// larger than the card, rendered at local z=-0.01 so it peeks out as a thin
/// frame — gives every face-down card a visible perimeter.
#[derive(Component, Debug)]
pub struct CardBackFrame;
/// Fill colour for the face-down card border frame. Medium gray so it reads as
/// a neutral "edge" without competing with the suit colours on face-up cards.
const CARD_BACK_FRAME_COLOR: Color = Color::srgb(0.38, 0.38, 0.38);
/// Extra width/height (in world units) added to each side of the card to form
/// the visible border. 3 world units ≈ 3 dp on a 1× screen.
const CARD_BACK_FRAME_PADDING: f32 = 3.0;
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
/// shadow given whether its parent card is currently part of the dragged
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
@@ -318,6 +339,21 @@ fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
));
}
/// Spawns a `CardBackFrame` child behind a face-down card entity so the dark
/// back PNG has a visible perimeter against the dark felt.
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
parent.spawn((
CardBackFrame,
Sprite {
color: CARD_BACK_FRAME_COLOR,
custom_size: Some(card_size + Vec2::splat(CARD_BACK_FRAME_PADDING)),
..default()
},
Transform::from_xyz(0.0, 0.0, -0.01),
Visibility::default(),
));
}
/// Throttle interval for resize-driven card snap work, in seconds.
///
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
@@ -373,6 +409,9 @@ impl Plugin for CardPlugin {
.add_systems(
Update,
(
update_tableau_fan_frac
.after(GameMutation)
.before(sync_cards_on_change),
sync_cards_on_change.after(GameMutation),
resync_cards_on_settings_change.before(sync_cards_on_change),
start_flip_anim.after(GameMutation),
@@ -649,16 +688,25 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
cards.len().saturating_sub(visible)
// Render one extra card so that the card sliding off the waste
// during a draw animation is still present in the world at z=0
// (hidden under the stack) rather than vanishing mid-tween.
cards.len().saturating_sub(visible + 1)
} else {
0
};
let mut y_offset = 0.0_f32;
let rendered_len = cards[render_start..].len();
for (slot, card) in cards[render_start..].iter().enumerate() {
let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) {
// Fan left→right; top card (last slot) is rightmost and playable.
slot as f32 * layout.card_size.x * 0.28
// When len > visible, slot 0 is a hidden buffer card kept at
// x=0 to prevent a flash during the draw tween. When len ≤
// visible (small pile), every card is visible and should fan
// normally — no card is hidden, so the shift is 0.
let visible = 3_usize;
let hidden = rendered_len.saturating_sub(visible);
slot.saturating_sub(hidden) as f32 * layout.card_size.x * 0.28
} else {
0.0
};
@@ -667,9 +715,9 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
out.push((card, pos, z));
if is_tableau {
let step = if card.face_up {
TABLEAU_FAN_FRAC
layout.tableau_fan_frac
} else {
TABLEAU_FACEDOWN_FAN_FRAC
layout.tableau_facedown_fan_frac
};
y_offset -= layout.card_size.y * step;
}
@@ -706,6 +754,13 @@ fn spawn_card_entity(
entity.with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
// Face-down cards get a thin contrasting border frame so the dark back
// PNG reads as a distinct rectangle against the dark felt.
if !card.face_up {
entity.with_children(|b| {
add_card_back_frame_child(b, layout.card_size);
});
}
// When PNG faces are loaded the rank/suit are baked into the image.
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
if card_images.is_none() {
@@ -781,6 +836,11 @@ fn update_card_entity(
commands.entity(entity).with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
if !card.face_up {
commands.entity(entity).with_children(|b| {
add_card_back_frame_child(b, layout.card_size);
});
}
if card_images.is_none() {
commands.entity(entity).with_children(|b| {
b.spawn((
@@ -1438,8 +1498,8 @@ fn update_stock_empty_indicator(
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
/// Width / height of the badge background sprite, in world pixels. Sized so
/// a 2-digit count (max "24") fits comfortably with `TYPE_CAPTION` text.
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(28.0, 16.0);
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0);
/// Returns the count of cards currently in the stock pile.
///
@@ -1484,7 +1544,7 @@ fn spawn_stock_count_badge(
};
let text_font = TextFont {
font: font.cloned().unwrap_or_default(),
font_size: TYPE_CAPTION,
font_size: TYPE_BODY,
..default()
};
@@ -1629,13 +1689,20 @@ fn snap_cards_on_window_resize(
card_images: Option<Res<CardImageSet>>,
entities: Query<
(Entity, &CardEntity, &mut Sprite, &mut Transform),
(Without<CardLabel>, Without<CardShadow>),
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
>,
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
shadow_query: Query<&mut Sprite, (With<CardShadow>, Without<CardEntity>, Without<PileMarker>)>,
shadow_query: Query<
&mut Sprite,
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>, Without<CardBackFrame>),
>,
frame_query: Query<
&mut Sprite,
(With<CardBackFrame>, Without<CardEntity>, Without<CardShadow>, Without<PileMarker>),
>,
mut pile_markers: Query<
(Entity, &PileMarker, &mut Sprite),
(Without<CardEntity>, Without<CardShadow>),
(Without<CardEntity>, Without<CardShadow>, Without<CardBackFrame>),
>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
@@ -1665,6 +1732,7 @@ fn snap_cards_on_window_resize(
entities,
label_query,
shadow_query,
frame_query,
);
apply_stock_empty_indicator(
@@ -1691,7 +1759,7 @@ fn snap_cards_on_window_resize(
///
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
/// retargeted relative to the previous card-size's position.
#[allow(clippy::type_complexity)]
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn resize_cards_in_place(
commands: &mut Commands,
game: &GameState,
@@ -1699,12 +1767,16 @@ fn resize_cards_in_place(
card_images: Option<&CardImageSet>,
mut entities: Query<
(Entity, &CardEntity, &mut Sprite, &mut Transform),
(Without<CardLabel>, Without<CardShadow>),
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
>,
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
mut shadow_query: Query<
&mut Sprite,
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>),
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>, Without<CardBackFrame>),
>,
mut frame_query: Query<
&mut Sprite,
(With<CardBackFrame>, Without<CardEntity>, Without<CardShadow>, Without<PileMarker>),
>,
) {
let positions = card_positions(game, layout);
@@ -1756,6 +1828,62 @@ fn resize_cards_in_place(
font.font_size = new_font_size;
}
}
// Resize every face-down border frame to match the new card size.
let frame_size = layout.card_size + Vec2::splat(CARD_BACK_FRAME_PADDING);
for mut frame_sprite in frame_query.iter_mut() {
frame_sprite.custom_size = Some(frame_size);
}
}
/// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum
/// face-up column depth. Runs after every `StateChangedEvent` so the fan
/// expands as the player reveals cards while staying within the window.
///
/// On fresh deal (max face-up depth = 1) the function returns early, leaving
/// both fracs at the window-size-adaptive values that `compute_layout` already
/// computed for the current viewport. Previously it overwrote the adaptive
/// value with the desktop minimum (0.25) — the wrong behaviour on portrait
/// phones where the adaptive value is much larger.
fn update_tableau_fan_frac(
mut events: MessageReader<StateChangedEvent>,
game: Option<Res<GameStateResource>>,
mut layout: Option<ResMut<LayoutResource>>,
) {
if events.read().next().is_none() {
return;
}
let Some(game) = game else { return; };
let Some(layout) = layout.as_mut() else { return; };
let max_depth = (0..7_usize)
.filter_map(|i| game.0.piles.get(&solitaire_core::pile::PileType::Tableau(i)))
.map(|pile| pile.cards.iter().filter(|c| c.face_up).count())
.max()
.unwrap_or(0);
let card_h = layout.0.card_size.y;
let avail = layout.0.available_tableau_height;
// With ≤ 1 face-up card per column (fresh deal, or completely face-down
// piles) the face-up fan fraction has no visible effect. Leave both fracs
// at the adaptive values set by compute_layout rather than snapping them
// to the desktop minimum.
if max_depth <= 1 || card_h <= 0.0 {
return;
}
let ideal = avail / ((max_depth - 1) as f32 * card_h);
let max_frac = if card_h > 0.0 { avail / (12.0 * card_h) } else { TABLEAU_FAN_FRAC };
let new_frac = ideal.clamp(TABLEAU_FAN_FRAC, max_frac.max(TABLEAU_FAN_FRAC));
let new_facedown_frac = new_frac * (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC);
if (layout.0.tableau_fan_frac - new_frac).abs() > 1e-4 {
layout.0.tableau_fan_frac = new_frac;
}
if (layout.0.tableau_facedown_fan_frac - new_facedown_frac).abs() > 1e-4 {
layout.0.tableau_facedown_fan_frac = new_facedown_frac;
}
}
#[cfg(test)]
@@ -1862,7 +1990,7 @@ mod tests {
// At game start waste is empty, so all 52 cards are across stock + tableau.
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout =
crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let positions = card_positions(&g, &layout);
assert_eq!(positions.len(), 52);
}
@@ -1882,7 +2010,7 @@ mod tests {
.collect();
assert_eq!(waste_ids.len(), 3);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let positions = card_positions(&g, &layout);
// Filter rendered positions to only waste cards (by card ID).
@@ -1890,11 +2018,13 @@ mod tests {
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.collect();
// Draw-One: only 1 waste card should be rendered regardless of pile depth.
assert_eq!(waste_rendered.len(), 1);
// The single rendered card must be the top (last) waste card.
// Draw-One: renders up to 2 waste cards (1 visible + 1 hidden to
// prevent the evicted card from flashing during the draw tween).
assert!(waste_rendered.len() <= 2, "Draw-One renders at most 2 waste cards");
assert!(!waste_rendered.is_empty(), "at least the top waste card must be rendered");
// The top (last) waste card must always be among the rendered cards.
let top_id = g.piles[&PileType::Waste].cards.last().unwrap().id;
assert_eq!(waste_rendered[0].0.id, top_id);
assert!(waste_rendered.iter().any(|(c, _, _)| c.id == top_id), "top waste card must be rendered");
}
#[test]
@@ -1911,32 +2041,73 @@ mod tests {
let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.collect();
// Draw-Three: at most 3 waste cards rendered.
assert_eq!(waste_rendered.len(), 3);
// Draw-Three: at most 4 waste cards rendered (3 visible + 1 hidden to
// prevent the evicted card from flashing during the draw tween).
assert!(waste_rendered.len() <= 4, "Draw-Three renders at most 4 waste cards");
assert!(waste_rendered.len() >= 3, "Draw-Three renders at least 3 waste cards when pile is deep enough");
// The three fanned cards must have strictly increasing X coordinates
// (left = oldest visible, right = top/playable).
// The three visible fanned cards (slots 13) must have strictly
// increasing X coordinates. The hidden extra card at slot 0 sits at x=0.
waste_rendered.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
for w in waste_rendered.windows(2) {
assert!(w[1].1.x > w[0].1.x, "fanned waste cards must have distinct X positions");
// The top 3 cards (after the hidden one) must be fanned.
let visible = &waste_rendered[waste_rendered.len().saturating_sub(3)..];
for w in visible.windows(2) {
assert!(w[1].1.x >= w[0].1.x, "fanned waste cards must have non-decreasing X positions");
}
// Top card (rightmost) must be the last card in the waste pile.
// Top card (rightmost by x) must be the last card in the waste pile.
let top_id = waste_pile.last().unwrap().id;
assert_eq!(waste_rendered.last().unwrap().0.id, top_id);
}
#[test]
fn waste_draw_three_fans_correctly_when_pile_smaller_than_visible() {
// Regression: slot.saturating_sub(1) always hid slot-0 even when the
// pile was too small to have a buffer card, collapsing 2 visible cards
// onto x=0 instead of fanning them.
use solitaire_core::game_state::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawThree);
// Draw exactly once — in Draw-Three mode with a full stock this gives
// 3 waste cards (still ≤ visible=3, so no hidden buffer needed).
let _ = g.draw();
let waste_pile = &g.piles[&PileType::Waste].cards;
// We need exactly 2 or 3 waste cards to hit the small-pile path.
// One draw in Draw-Three adds up to 3 cards; take the first 2 if needed.
let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.collect();
// All waste cards should be visible (no hidden buffer when len ≤ visible).
assert_eq!(waste_rendered.len(), count, "all waste cards rendered when pile ≤ visible");
// Cards must be fanned with distinct x positions (or equal for 1-card).
waste_rendered.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
if count >= 2 {
let last = waste_rendered.last().unwrap();
let second_last = &waste_rendered[waste_rendered.len() - 2];
assert!(last.1.x > second_last.1.x, "top 2 waste cards must fan to distinct x positions");
}
}
#[test]
fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout =
crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let positions = card_positions(&g, &layout);
// Collect positions for Tableau(6) (should have 7 cards).
@@ -2248,7 +2419,7 @@ mod tests {
#[test]
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let positions = card_positions(&g, &layout);
// Tableau(6) has 7 cards: 6 face-down + 1 face-up on top.
@@ -2409,7 +2580,7 @@ mod tests {
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
// post-resize card width, so the in-place path is using the
// refreshed Layout.
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0));
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0);
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
assert!(
(after - expected).abs() < 1e-3,
+2 -2
View File
@@ -604,7 +604,7 @@ mod tests {
use crate::layout::compute_layout;
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// A cursor far off-screen should never hit anything.
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
}
@@ -624,7 +624,7 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(GameStateResource(game))
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0))))
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0)))
.insert_resource(DragState::default())
.add_systems(Update, update_drop_target_overlays);
app
+55 -11
View File
@@ -990,18 +990,26 @@ pub fn has_legal_moves(game: &GameState) -> bool {
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
let mut sources: Vec<Card> = Vec::new();
for ty in [PileType::Stock, PileType::Waste] {
if let Some(p) = game.piles.get(&ty) {
sources.extend(p.cards.iter().cloned());
}
}
for i in 0..7_usize {
if let Some(t) = game.piles.get(&PileType::Tableau(i))
&& let Some(top) = t.cards.last().filter(|c| c.face_up)
// Only the top waste card is playable.
if let Some(p) = game.piles.get(&PileType::Waste)
&& let Some(top) = p.cards.last()
{
sources.push(top.clone());
}
// Any face-up card in a tableau column can be the base of a movable run.
for i in 0..7_usize {
if let Some(t) = game.piles.get(&PileType::Tableau(i)) {
for card in t.cards.iter().filter(|c| c.face_up) {
sources.push(card.clone());
}
}
}
// Stock cards are face-down and cannot be placed directly; drawing is
// only useful if the drawn card can subsequently be placed, which the
// waste-card check above already covers for the currently visible card.
// Including all stock cards would produce false positives for unplayable
// face-down cards (the test has_legal_moves_returns_false_when_stock_only_holds_unplayable_cards
// explicitly guards this case).
for card in &sources {
for slot in 0..4_u8 {
@@ -1064,9 +1072,11 @@ fn check_no_moves(
}
if !moves_ok && !*already_fired {
toast.write(InfoToastEvent(
"No moves available \u{2014} press D to draw or N for a new game".to_string(),
));
#[cfg(target_os = "android")]
let no_moves_msg = "No moves available \u{2014} tap the stock to draw or start a new game";
#[cfg(not(target_os = "android"))]
let no_moves_msg = "No moves available \u{2014} press D to draw or N for a new game";
toast.write(InfoToastEvent(no_moves_msg.to_string()));
*already_fired = true;
// Only spawn the overlay if one does not already exist.
if game_over_screens.is_empty() {
@@ -1730,6 +1740,40 @@ mod tests {
assert!(!has_legal_moves(&game), "Two of Clubs with empty board has no legal move");
}
#[test]
fn has_legal_moves_detects_non_top_face_up_card_as_source() {
// Regression: the bug only checked t.cards.last() (top face-up card).
// If the only legal move involves a face-up card that is NOT the top
// card of its column the previous code would return false (softlock)
// even though the player can still move that run.
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Tableau 0: face-up Queen of Spades (non-top) + face-up Jack of Hearts on top.
// King of Diamonds is on Tableau 1 (empty otherwise), so Queen→King is the
// only legal tableau move, and that move targets the Queen which is non-top.
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.push(Card { id: 10, suit: Suit::Spades, rank: Rank::Queen, face_up: true });
t0.cards.push(Card { id: 11, suit: Suit::Hearts, rank: Rank::Jack, face_up: true });
let t1 = game.piles.get_mut(&PileType::Tableau(1)).unwrap();
t1.cards.push(Card { id: 12, suit: Suit::Diamonds, rank: Rank::King, face_up: true });
assert!(
has_legal_moves(&game),
"Queen (non-top face-up) should be detected as a valid move source onto King",
);
}
// -----------------------------------------------------------------------
// Task #57 — Confirm-new-game dialog tests
// -----------------------------------------------------------------------
+2 -3
View File
@@ -250,9 +250,8 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
..default()
})
.with_children(|line| {
// The hotkey rendered as a small chip with a border —
// visual cue that it's a key reference, not part of
// the description text.
// Keyboard chip — suppressed on Android (no keyboard).
#[cfg(not(target_os = "android"))]
line.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
+2 -2
View File
@@ -1385,8 +1385,8 @@ fn spawn_mode_card(
));
if unlocked {
// Hotkey chip — same look as the kbd-chip rows used
// elsewhere so accelerators read consistently.
// Hotkey chip — suppressed on Android (touch builds have no keyboard).
#[cfg(not(target_os = "android"))]
row.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
+303 -91
View File
@@ -7,6 +7,7 @@
//! without a separate tick system.
use bevy::prelude::*;
use bevy::window::WindowResized;
use solitaire_core::card::Suit;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType;
@@ -17,6 +18,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::progress_plugin::ProgressResource;
use crate::settings_plugin::SettingsResource;
use crate::layout::HUD_BAND_HEIGHT;
use crate::safe_area::{SafeAreaAnchoredTop, SafeAreaInsets};
use crate::ui_theme::SPACE_2;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
@@ -239,6 +242,11 @@ pub struct PauseButton;
#[derive(Component, Debug)]
pub struct HelpButton;
/// Marker on the "Hint" action button. Click spawns an async solver task
/// (same as the `H` keyboard accelerator) and highlights the suggested card.
#[derive(Component, Debug)]
pub struct HintButton;
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
/// (a small dropdown panel) below the action bar. Each popover row starts
/// the corresponding game mode.
@@ -273,6 +281,16 @@ pub struct MenuButton;
#[derive(Component, Debug)]
pub struct MenuPopover;
/// Fullscreen transparent backdrop spawned behind the [`MenuPopover`].
/// Pressing it (tap anywhere outside the popover) light-dismisses the menu.
#[derive(Component, Debug)]
struct MenuPopoverBackdrop;
/// Fullscreen transparent backdrop spawned behind the [`ModesPopover`].
/// Pressing it (tap anywhere outside the popover) light-dismisses it.
#[derive(Component, Debug)]
struct ModesPopoverBackdrop;
/// One row inside the [`MenuPopover`]. The variant selects which
/// `Toggle*RequestEvent` the click handler fires.
#[derive(Component, Debug, Clone, Copy)]
@@ -322,11 +340,15 @@ impl Plugin for HudPlugin {
.add_message::<WinStreakMilestoneEvent>()
.init_resource::<PreviousScore>()
.init_resource::<HudActionFade>()
// WindowResized is registered by table_plugin; re-register
// defensively so the HUD plugin works standalone in tests.
.add_message::<WindowResized>()
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
.add_systems(Update, update_hud.after(GameMutation))
.add_systems(Update, update_won_previously.after(GameMutation))
.add_systems(Update, announce_auto_complete.after(GameMutation))
.add_systems(Update, update_selection_hud)
.add_systems(Update, update_hud_typography)
.add_systems(
Update,
(
@@ -350,10 +372,13 @@ impl Plugin for HudPlugin {
handle_undo_button,
handle_pause_button,
handle_help_button,
handle_hint_button,
handle_modes_button,
handle_mode_option_click,
handle_modes_backdrop_click,
handle_menu_button,
handle_menu_option_click,
handle_menu_backdrop_click,
paint_action_buttons,
),
)
@@ -376,11 +401,13 @@ impl Plugin for HudPlugin {
/// bottom edge lines up exactly with the top edge of the highest
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
/// alpha, so the green felt reads through subtly.
fn spawn_hud_band(mut commands: Commands) {
fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
const BASE_TOP: f32 = 0.0;
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
commands.spawn((
Node {
position_type: PositionType::Absolute,
top: Val::Px(0.0),
top: Val::Px(BASE_TOP + top_inset),
left: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(HUD_BAND_HEIGHT),
@@ -391,6 +418,7 @@ fn spawn_hud_band(mut commands: Commands) {
// paint on top, but above the card sprites (which are 2D-world
// entities and rendered behind UI regardless).
ZIndex(Z_HUD - 1),
SafeAreaAnchoredTop { base_top: BASE_TOP },
));
}
@@ -413,7 +441,12 @@ fn spawn_hud_band(mut commands: Commands) {
/// player's #1 complaint. This restructure groups by purpose, lets
/// transient items disappear cleanly, and uses the typography scale to
/// make Score the visual protagonist.
fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
fn spawn_hud(
font_res: Option<Res<FontResource>>,
insets: Option<Res<SafeAreaInsets>>,
mut commands: Commands,
) {
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
let font_score = TextFont {
font: font_handle.clone(),
@@ -434,6 +467,16 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
let row_node = || Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_3,
// On a narrow viewport the four tier rows (Score/Moves/Timer,
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
// Auto-complete, selection chip) can collectively be wider than
// the available space and overflow into the action-button column
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
// a second line; on a desktop window the rows stay single-line
// because the parent column has no width cap and the row never
// exceeds the natural line width.
flex_wrap: FlexWrap::Wrap,
row_gap: VAL_SPACE_1,
align_items: AlignItems::Baseline,
..default()
};
@@ -443,12 +486,21 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
Node {
position_type: PositionType::Absolute,
left: VAL_SPACE_3,
top: VAL_SPACE_2,
top: Val::Px(SPACE_2 + top_inset),
flex_direction: FlexDirection::Column,
// Cap the column at 50% of viewport so on narrow
// (mobile) widths the inner tier rows have a bounded
// width to wrap against, and the column can't bleed
// into the right-anchored action button row (also
// capped at 50%). On desktop 50% of 1920 = 960 px,
// wider than any tier row's natural width, so the
// visible layout is unaffected.
max_width: Val::Percent(50.0),
row_gap: VAL_SPACE_1,
..default()
},
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
))
.with_children(|hud| {
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
@@ -568,94 +620,83 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
/// because it's the most consequential action; the destructive button sits
/// on its own visual edge.
fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Commands) {
fn spawn_action_buttons(
font_res: Option<Res<FontResource>>,
insets: Option<Res<SafeAreaInsets>>,
mut commands: Commands,
) {
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
#[cfg(not(target_os = "android"))]
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
// TYPE_BODY (14.0) — was a hardcoded `16.0` until the
// top-bar-overlap fix. Aligns with the rest of `hud_plugin`'s
// text (which already routes through the `TYPE_*` tokens) and
// reclaims horizontal space so the action button row doesn't
// collide with the left-anchored HUD column at narrow window
// widths.
font_size: TYPE_BODY,
..default()
};
// Android labels use only FiraMono-safe glyphs (≡ ← ‖ → ▾), so the same
// embedded font works — no system font fallback required.
#[cfg(target_os = "android")]
let font = TextFont { font_size: TYPE_BODY, ..default() };
// On Android, 7 text-labelled buttons at 48 dp each wrap to two rows on
// a 411 dp phone. Use compact Unicode symbols and tighter gaps so all 7
// fit in a single row (7×44 + 6×4 = 332 dp, well within a 90%-wide band
// of 370 dp). On desktop, keep the descriptive text labels.
#[cfg(target_os = "android")]
let (max_width, col_gap, row_gap_val) =
(Val::Percent(90.0), Val::Px(4.0), Val::Px(4.0));
#[cfg(not(target_os = "android"))]
let (max_width, col_gap, row_gap_val) =
(Val::Percent(65.0), VAL_SPACE_2, VAL_SPACE_2);
#[cfg(target_os = "android")]
let labels = (
/* menu */ "\u{2261}", // ≡ identical-to (hamburger look-alike, in FiraMono)
/* undo */ "\u{2190}", // ← leftwards arrow (in FiraMono)
/* pause */ "\u{2016}", // ‖ double vertical line (in FiraMono general-punct)
/* help */ "?",
/* hint */ "\u{2192}", // → rightwards arrow (in FiraMono)
/* modes */ "\u{25BE}", // ▾ small down-pointing triangle (in FiraMono)
/* new */ "+",
);
#[cfg(not(target_os = "android"))]
let labels = (
"Menu \u{25BE}",
"Undo",
"Pause",
"Help",
"Hint",
"Modes \u{25BE}",
"New Game",
);
commands
.spawn((
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
top: VAL_SPACE_2,
top: Val::Px(SPACE_2 + top_inset),
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_2,
max_width,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::FlexEnd,
column_gap: col_gap,
row_gap: row_gap_val,
align_items: AlignItems::Center,
..default()
},
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
))
.with_children(|row| {
// Menu and Modes don't have a single hotkey accelerator
// (each row inside their popover has its own); their button
// labels carry the dropdown chevron in lieu of a key chip.
//
// The trailing `order` argument is the per-button index in
// visual reading order (left → right). It feeds
// `Focusable { group: Hud, order }` so Tab cycles the action
// bar in the same order the eye scans it.
spawn_action_button(
row,
MenuButton,
"Menu \u{25BE}",
None,
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
&font,
0,
);
spawn_action_button(
row,
UndoButton,
"Undo",
Some("U"),
"Take back your last move. Costs points and blocks No Undo.",
&font,
1,
);
spawn_action_button(
row,
PauseButton,
"Pause",
Some("Esc"),
"Pause the game and freeze the timer.",
&font,
2,
);
spawn_action_button(
row,
HelpButton,
"Help",
Some("F1"),
"Show controls, rules, and keyboard shortcuts.",
&font,
3,
);
spawn_action_button(
row,
ModesButton,
"Modes \u{25BE}",
None,
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
&font,
4,
);
spawn_action_button(
row,
NewGameButton,
"New Game",
Some("N"),
"Start a fresh deal. Confirms first if a game is in progress.",
&font,
5,
);
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab cycles the action bar in visual reading order.
spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0);
spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1);
spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2);
spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3);
spawn_action_button(row, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4);
spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5);
spawn_action_button(row, NewGameButton,labels.6, Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6);
});
}
@@ -681,32 +722,42 @@ fn spawn_action_button<M: Component>(
font: &TextFont,
order: i32,
) {
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
// touch device — the button itself is the affordance — and they
// visibly clutter the narrow-viewport action row. Force the hint
// off on Android; the chevrons on Menu/Modes remain because they
// indicate dropdown behaviour and still apply on touch.
#[cfg(target_os = "android")]
let hotkey: Option<&'static str> = None;
let hotkey_font = TextFont {
font: font.font.clone(),
font_size: TYPE_CAPTION,
..default()
};
// On Android, use tighter padding and a slightly smaller min-size so all
// 7 icon-label buttons fit in one row on a ~411 dp phone. 44 dp ≥
// Apple's minimum touch target; padding of 4 dp each side keeps the icon
// centred with room to breathe. On desktop, keep the comfortable 48 dp
// floor and 8 dp side padding.
#[cfg(target_os = "android")]
let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(44.0), Val::Px(44.0));
#[cfg(not(target_os = "android"))]
let (pad, min_w, min_h) = (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0));
row.spawn((
marker,
ActionButton,
Button,
Tooltip::new(tooltip),
// Joins the `Hud` focus group at the supplied order so Tab
// cycles HUD buttons left-to-right under Phase 2. The HUD focus
// ring still only engages when a HUD button is hovered (or in
// future phases, when the player explicitly switches groups);
// the marker just declares membership.
Focusable {
group: FocusGroup::Hud,
order,
},
Node {
// Horizontal padding stepped down from VAL_SPACE_3 to
// VAL_SPACE_2 to reclaim ~96px across the 6-button row at
// narrow window widths (see top-bar-overlap fix in the
// companion commit). Vertical padding stays at VAL_SPACE_2
// so button height tracks the rest of the chrome band.
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
padding: pad,
min_width: min_w,
min_height: min_h,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
@@ -777,12 +828,43 @@ fn handle_help_button(
}
}
fn handle_hint_button(
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
paused: Option<Res<crate::PausedResource>>,
game: Option<Res<GameStateResource>>,
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
for interaction in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
if paused.as_ref().is_some_and(|p| p.0) {
return;
}
let Some(ref g) = game else { return };
if g.0.is_won {
#[cfg(target_os = "android")]
let won_msg = "Game won! Tap New Game to play again";
#[cfg(not(target_os = "android"))]
let won_msg = "Game won! Press N for a new game";
info_toast.write(InfoToastEvent(won_msg.to_string()));
return;
}
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
hint.spawn(g.0.clone(), cfg.0);
}
}
}
/// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on
/// second click. Mode rows are populated per the player's current level so
/// only unlocked options appear.
fn handle_modes_button(
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
@@ -796,6 +878,9 @@ fn handle_modes_button(
}
if let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
} else {
spawn_modes_popover(
&mut commands,
@@ -896,6 +981,23 @@ fn spawn_modes_popover(
});
}
});
// Fullscreen transparent backdrop at Z_HUD+4 (below the popover at
// Z_HUD+5) so tapping outside the panel light-dismisses it.
commands.spawn((
ModesPopoverBackdrop,
Button,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(Color::NONE),
ZIndex(Z_HUD + 4),
));
}
/// Dispatches the click on a popover row to the matching request event,
@@ -909,6 +1011,7 @@ fn spawn_modes_popover(
fn handle_mode_option_click(
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut zen: MessageWriter<StartZenRequestEvent>,
mut challenge: MessageWriter<StartChallengeRequestEvent>,
@@ -941,8 +1044,12 @@ fn handle_mode_option_click(
}
}
if clicked_any
&& let Ok(entity) = popovers.single() {
&& let Ok(entity) = popovers.single()
{
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
}
}
@@ -952,6 +1059,7 @@ fn handle_mode_option_click(
fn handle_menu_button(
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
@@ -963,6 +1071,9 @@ fn handle_menu_button(
}
if let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
} else {
spawn_menu_popover(&mut commands, font_res.as_deref());
}
@@ -1050,6 +1161,23 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
});
}
});
// Transparent fullscreen backdrop behind the popover — tapping anywhere
// outside the panel light-dismisses it via handle_menu_backdrop_click.
commands.spawn((
MenuPopoverBackdrop,
Button,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(Color::NONE),
ZIndex(Z_HUD + 4),
));
}
/// Dispatches the click on a menu row to the matching toggle event,
@@ -1058,6 +1186,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
fn handle_menu_option_click(
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut stats: MessageWriter<ToggleStatsRequestEvent>,
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
mut profile: MessageWriter<ToggleProfileRequestEvent>,
@@ -1092,6 +1221,43 @@ fn handle_menu_option_click(
if clicked_any
&& let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
}
}
/// Despawns the [`ModesPopover`] and its backdrop when the player taps
/// anywhere outside the panel.
fn handle_modes_backdrop_click(
interaction_query: Query<&Interaction, (With<ModesPopoverBackdrop>, Changed<Interaction>)>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
mut commands: Commands,
) {
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
commands.entity(e).despawn();
}
}
/// Despawns the [`MenuPopover`] and its backdrop when the player taps
/// anywhere outside the panel (i.e. the transparent backdrop is pressed).
fn handle_menu_backdrop_click(
interaction_query: Query<&Interaction, (With<MenuPopoverBackdrop>, Changed<Interaction>)>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut commands: Commands,
) {
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
commands.entity(e).despawn();
}
}
@@ -1938,6 +2104,46 @@ pub fn challenge_time_color(remaining: u64) -> Color {
}
}
/// Scales HUD Tier-1 font sizes to fit a narrow viewport.
///
/// Fires on every `WindowResized` event. Below 480 logical pixels wide the
/// score drops from `TYPE_HEADLINE` (26 px) to `TYPE_BODY_LG` (18 px) and the
/// Moves/Timer labels drop from `TYPE_BODY_LG` to `TYPE_CAPTION` (11 px), so
/// all three items remain on one row inside the 50 %-wide HUD column
/// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are
/// restored so desktop/tablet layouts are unaffected.
type HudScoreFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudScore>, Without<HudMoves>, Without<HudTime>)>;
type HudMovesFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudMoves>, Without<HudScore>, Without<HudTime>)>;
type HudTimeFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudTime>, Without<HudScore>, Without<HudMoves>)>;
fn update_hud_typography(
mut events: MessageReader<WindowResized>,
mut score_q: HudScoreFont,
mut moves_q: HudMovesFont,
mut time_q: HudTimeFont,
) {
let Some(ev) = events.read().last() else {
return;
};
let (score_size, secondary_size) = if ev.width < 480.0 {
(TYPE_BODY_LG, TYPE_CAPTION)
} else {
(TYPE_HEADLINE, TYPE_BODY_LG)
};
for mut font in &mut score_q {
font.font_size = score_size;
}
for mut font in &mut moves_q {
font.font_size = secondary_size;
}
for mut font in &mut time_q {
font.font_size = secondary_size;
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2443,6 +2649,7 @@ mod tests {
focusable_for::<UndoButton>(&mut app),
focusable_for::<PauseButton>(&mut app),
focusable_for::<HelpButton>(&mut app),
focusable_for::<HintButton>(&mut app),
focusable_for::<ModesButton>(&mut app),
focusable_for::<NewGameButton>(&mut app),
] {
@@ -2551,6 +2758,10 @@ mod tests {
tooltip_for::<HelpButton>(&mut app),
"Show controls, rules, and keyboard shortcuts."
);
assert_eq!(
tooltip_for::<HintButton>(&mut app),
"Highlight a suggested move. Cycles through alternatives on repeat taps."
);
assert_eq!(
tooltip_for::<ModesButton>(&mut app),
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
@@ -2670,14 +2881,15 @@ mod tests {
fn hud_button_order_matches_spawn_order() {
let mut app = headless_app();
// Visual reading order (left → right): Menu, Undo, Pause, Help,
// Modes, New Game. Their `order` fields must be 0..=5 in that
// order so Tab cycles them as the player reads them.
// Hint, Modes, New Game. Their `order` fields must be 0..=6 in
// that order so Tab cycles them as the player reads them.
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 4);
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 5);
assert_eq!(focusable_for::<HintButton>(&mut app).order, 4);
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 5);
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 6);
}
#[test]
+66 -72
View File
@@ -33,11 +33,9 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_animation::tuning::AnimationTuning;
use crate::card_animation::{CardAnimation, MotionCurve};
use crate::card_plugin::{
CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC,
TABLEAU_FAN_FRAC,
};
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_WARNING};
use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC};
use crate::radial_menu::RightClickRadialState;
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
use solitaire_core::game_state::DrawMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
@@ -522,8 +520,10 @@ fn handle_touch_stock_tap(
/// Begins a mouse drag: records the press position and the cards that would be
/// dragged. Cards are **not** elevated yet — that happens in [`follow_drag`]
/// once the drag threshold is crossed.
#[allow(clippy::too_many_arguments)]
fn start_drag(
buttons: Res<ButtonInput<MouseButton>>,
touches: Option<Res<Touches>>,
paused: Option<Res<PausedResource>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
@@ -538,6 +538,15 @@ fn start_drag(
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
return;
}
// On platforms where Winit simulates a MouseButton::Left press from the
// first touch, this guard ensures touch_start_drag (which runs after this
// system) claims the drag state instead of the mouse path. Without it the
// card is tracked via cursor_world (updated from the simulated mouse
// position) rather than the Touches resource, which can be one frame
// behind the actual finger position on Android.
if touches.as_ref().is_some_and(|t| t.iter_just_pressed().next().is_some()) {
return;
}
let Some(layout) = layout else { return };
let Some(world) = cursor_world(&windows, &cameras) else { return };
@@ -614,7 +623,7 @@ fn follow_drag(
// Move cards to the cursor.
let bottom_pos = world + drag.cursor_offset;
let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC;
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
for (i, &id) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, _)) =
@@ -875,7 +884,7 @@ fn touch_follow_drag(
}
let bottom_pos = world + drag.cursor_offset;
let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC;
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
for (i, &id) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, _)) =
@@ -1047,8 +1056,8 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
/// Where a card at `stack_index` in pile `pile` would be rendered.
///
/// For tableau columns the per-card fan step depends on the face-up state of
/// every preceding card — face-down cards step by `TABLEAU_FACEDOWN_FAN_FRAC`,
/// face-up cards by `TABLEAU_FAN_FRAC`. Mirrors `card_plugin::card_positions`
/// every preceding card — face-down cards step by `layout.tableau_facedown_fan_frac`,
/// 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: &PileType, stack_index: usize) -> Vec2 {
@@ -1058,9 +1067,9 @@ fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index
if let Some(pile_cards) = game.piles.get(pile) {
for card in pile_cards.cards.iter().take(stack_index) {
let step = if card.face_up {
TABLEAU_FAN_FRAC
layout.tableau_fan_frac
} else {
TABLEAU_FACEDOWN_FAN_FRAC
layout.tableau_facedown_fan_frac
};
y_offset -= layout.card_size.y * step;
}
@@ -1195,7 +1204,7 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
if matches!(pile, PileType::Tableau(_)) {
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
if card_count > 1 {
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
let fan = -layout.card_size.y * layout.tableau_fan_frac;
let bottom_card_center_y = center.y + fan * (card_count - 1) as f32;
let top_edge = center.y + layout.card_size.y / 2.0;
let bottom_edge = bottom_card_center_y - layout.card_size.y / 2.0;
@@ -1217,9 +1226,10 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
/// Maximum seconds between two clicks to count as a double-click.
const DOUBLE_CLICK_WINDOW: f32 = 0.35;
/// Maximum seconds between two taps to count as a double-tap.
/// Slightly wider than the mouse window — touch screens have higher latency.
const DOUBLE_TAP_WINDOW: f32 = 0.5;
/// Duration of the lime flash applied to moved cards when a tap
/// auto-move succeeds. Short enough not to linger, long enough to register
/// during the card animation (~0.3 s).
const DOUBLE_TAP_FLASH_SECS: f32 = 0.35;
/// Find the best legal destination for `card` — Foundation first, then Tableau.
///
@@ -1375,63 +1385,51 @@ fn handle_double_click(
}
// ---------------------------------------------------------------------------
// Task #27b — Double-tap to auto-move (touch equivalent of double-click)
// Tap-to-move (touch equivalent of mouse auto-move)
// ---------------------------------------------------------------------------
/// System that detects double-taps on face-up cards and fires `MoveRequestEvent`
/// to the best legal destination — the touch equivalent of [`handle_double_click`].
/// Fires `MoveRequestEvent` when the player taps a face-up card without
/// dragging — the touch equivalent of the mouse auto-move flow.
///
/// Must run **before** `touch_end_drag` in the system chain. At
/// `TouchPhase::Ended` the drag state still holds `active_touch_id`,
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
/// are cleared and the tap/drag distinction is permanently lost.
///
/// A pure tap is identified by `drag.active_touch_id.is_some() &&
/// !drag.committed`: the touch began (so `touch_start_drag` populated
/// `drag`) but the drag threshold was never crossed.
///
/// Move priority matches [`handle_double_click`]:
/// 1. Move the single top card to its best foundation (or tableau).
/// 2. If no single-card move exists and the selection spans multiple
/// face-up cards, move the whole stack to the best tableau column.
/// 3. If both priorities fail, fire `MoveRejectedEvent` for audio + shake
/// feedback.
/// Move priority:
/// 1. Single top card to its best foundation (or tableau).
/// 2. Whole face-up run to best tableau column when no single-card move exists.
/// 3. `MoveRejectedEvent` for audio + shake feedback when no legal move found.
#[allow(clippy::too_many_arguments)]
fn handle_double_tap(
mut touch_events: MessageReader<TouchInput>,
paused: Option<Res<PausedResource>>,
time: Res<Time>,
radial: Option<Res<RightClickRadialState>>,
drag: Res<DragState>,
game: Res<GameStateResource>,
mut last_tap: Local<HashMap<u32, f32>>,
mut moves: MessageWriter<MoveRequestEvent>,
mut rejected: MessageWriter<MoveRejectedEvent>,
mut commands: Commands,
mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Long-press opened the radial — let radial_handle_release_or_cancel own
// the finger-lift event.
if radial.is_some_and(|r| r.is_active()) {
return;
}
// Only active when a touch is tracked and hasn't crossed the drag threshold.
let Some(active_id) = drag.active_touch_id else { return };
if drag.committed {
return;
}
for event in touch_events.read() {
if event.id != active_id {
if event.id != active_id || event.phase != TouchPhase::Ended {
continue;
}
match event.phase {
TouchPhase::Canceled => {
// Cancelled touch — clear any pending tap state for these cards.
for &id in &drag.cards {
last_tap.remove(&id);
}
return;
}
TouchPhase::Ended => {}
_ => continue,
}
// Uncommitted touch ended = pure tap.
let Some(&top_card_id) = drag.cards.last() else { return };
@@ -1445,14 +1443,15 @@ fn handle_double_tap(
return;
}
let now = time.elapsed_secs();
let prev = last_tap.get(&top_card_id).copied().unwrap_or(f32::NEG_INFINITY);
if now - prev <= DOUBLE_TAP_WINDOW {
last_tap.remove(&top_card_id);
// Priority 1: move single top card.
if let Some(dest) = best_destination(top_card, &game.0) {
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
if ce.card_id == top_card_id {
sprite.color = STATE_SUCCESS;
commands.entity(entity).insert(HintHighlight { remaining: DOUBLE_TAP_FLASH_SECS });
break;
}
}
moves.write(MoveRequestEvent {
from: pile.clone(),
to: dest,
@@ -1472,6 +1471,12 @@ fn handle_double_tap(
drag.cards.len(),
)
{
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
if drag.cards.contains(&ce.card_id) {
sprite.color = STATE_SUCCESS;
commands.entity(entity).insert(HintHighlight { remaining: DOUBLE_TAP_FLASH_SECS });
}
}
moves.write(MoveRequestEvent {
from: pile.clone(),
to: dest,
@@ -1486,9 +1491,6 @@ fn handle_double_tap(
to: pile.clone(),
count: drag.cards.len(),
});
} else {
last_tap.insert(top_card_id, now);
}
}
}
@@ -1630,7 +1632,7 @@ mod tests {
#[test]
fn find_draggable_picks_top_of_tableau() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// In tableau 6, the visually topmost card is the last (face-up) one.
// Its position: base.y + fan * 6.
@@ -1644,7 +1646,7 @@ mod tests {
#[test]
fn find_draggable_skips_face_down_cards() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
// the bottom (index 6). Click at the topmost face-down card's
@@ -1665,7 +1667,7 @@ mod tests {
// face-up bottom card, clicking the visible card face missed the
// hit-test box and only the bottom strip of the card responded.
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// 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
@@ -1704,7 +1706,7 @@ mod tests {
face_up: true,
});
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// The Queen's geometric center (index 1) is inside the Jack's bounding box
// (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
@@ -1736,7 +1738,7 @@ mod tests {
face_up: true,
});
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// Both cards in waste sit at the same (x, y). Clicking should pick
// the visually top card (id 201), with count = 1.
let pos = card_position(&game, &layout, &PileType::Waste, 0);
@@ -1749,7 +1751,7 @@ mod tests {
#[test]
fn find_drop_target_hits_empty_tableau_pile_marker() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// Move all cards out of tableau 0 so its marker is the only drop area.
let mut game = game;
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
@@ -1761,7 +1763,7 @@ mod tests {
#[test]
fn find_drop_target_returns_none_for_origin() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let pos = layout.pile_positions[&PileType::Tableau(3)];
let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3));
assert_eq!(target, None);
@@ -1770,7 +1772,7 @@ mod tests {
#[test]
fn pile_drop_rect_extends_for_tableau_with_cards() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// Tableau 6 has 7 cards.
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so
@@ -1795,7 +1797,7 @@ mod tests {
waste.cards.push(Card { id: 201, suit: Suit::Hearts, rank: Rank::Three, face_up: true });
waste.cards.push(Card { id: 202, suit: Suit::Clubs, rank: Rank::Four, face_up: true });
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let waste_base = layout.pile_positions[&PileType::Waste];
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
@@ -1811,7 +1813,7 @@ mod tests {
#[test]
fn find_draggable_returns_none_for_click_on_empty_pile() {
let mut game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
// Clear tableau 0 so it's an empty slot.
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
let pos = layout.pile_positions[&PileType::Tableau(0)];
@@ -1822,7 +1824,7 @@ mod tests {
#[test]
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));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
for pile in [
PileType::Waste,
PileType::Foundation(2),
@@ -2323,7 +2325,7 @@ mod tests {
app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(crate::layout::LayoutResource(
crate::layout::compute_layout(Vec2::new(1280.0, 800.0)),
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0),
));
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
app.add_systems(Update, handle_keyboard_hint);
@@ -2345,13 +2347,5 @@ mod tests {
);
}
// Task #27b — double-tap constants
#[test]
fn double_tap_window_is_wider_than_double_click_window() {
// Compile-time check: touch needs a wider window than mouse due to
// higher input latency. `const { assert! }` catches regressions at
// build time rather than waiting for a test run.
const { assert!(DOUBLE_TAP_WINDOW > DOUBLE_CLICK_WINDOW) }
}
}
+266 -30
View File
@@ -21,9 +21,25 @@ pub enum LayoutSystem {
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);
/// Minimum window dimensions used as a layout floor.
///
/// `compute_layout` runs `window.max(MIN_WINDOW)` so a window smaller than this
/// on either axis is laid out as if it were at least this size. The floor
/// exists to guard against degenerate / divide-by-zero layouts on very small
/// surfaces (Bevy can briefly report 0-size windows during startup or after
/// minimisation on some compositors); it is not a "minimum supported playable
/// size" — desktop builds enforce that via `WindowResizeConstraints` set in
/// `solitaire_app::lib`.
///
/// The previous floor of 800×600 was set with desktop in mind and produced
/// the wrong behaviour on Android: a 360 dp phone got laid out as if it were
/// 800-wide, pushing the leftmost foundation past `-180` and the rightmost
/// tableau pile past `+180`, which clipped both at the visible viewport
/// edges (visible in the v0.22.3 hardware screenshot). 320×400 is below the
/// smallest reasonable phone (≈ 360×640) so every real device flows through
/// without clamping, while still being large enough that the layout math
/// produces non-degenerate card sizes.
pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0);
/// Aspect ratio (height / width) of a standard playing card.
///
@@ -36,11 +52,22 @@ const CARD_ASPECT: f32 = 1.4523;
/// the tableau row.
const VERTICAL_GAP_FRAC: f32 = 0.2;
/// Fraction of card height contributed by each additional face-up tableau card
/// when fanned. Mirrors `card_plugin::TABLEAU_FAN_FRAC` so layout sizing can
/// solve for a worst-case column without depending on `card_plugin`.
/// Minimum fraction of card height used as vertical offset between face-up
/// tableau cards. Used for the height-based sizing candidate (worst-case
/// column must fit at this fraction). On desktop (height-limited) windows the
/// adaptive computation returns this value exactly; on portrait phones it
/// expands to fill available vertical space.
const TABLEAU_FAN_FRAC: f32 = 0.25;
/// Minimum fraction for face-down tableau cards. Scales proportionally with
/// the adaptive face-up fraction so hit-testing and rendering stay in sync.
///
/// Raised from 0.12 to 0.20 so face-down stacks on portrait phones show
/// enough of each card back to read as a meaningful stack rather than a
/// thin sliver. The ratio to TABLEAU_FAN_FRAC (0.80) is preserved by
/// the adaptive scaling in `compute_layout`.
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
/// Largest possible face-up tableau column in Klondike: a King down to an Ace
/// after every face-down card has flipped on column 7. Layout sizing must keep
/// this column inside the visible window.
@@ -50,10 +77,15 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
/// below this band so the HUD doesn't bleed into the play surface.
///
/// 64 px comfortably fits the action button bar (~32 px tall) plus the
/// Score/Moves text line plus padding, with a few pixels of breathing room.
/// The matching translucent background is painted by `hud_plugin::spawn_hud_band`.
/// Desktop: 64 px fits the single-row action bar plus the Score/Moves line.
/// Android: 128 px accommodates the two-row button wrap on narrow phones
/// (7 buttons × ~52 dp each, with a 65% max-width constraint, wraps to two
/// ~48 dp rows plus row-gap). Without this larger reserve the bottom row of
/// buttons overlaps the top card row.
#[cfg(not(target_os = "android"))]
pub const HUD_BAND_HEIGHT: f32 = 64.0;
#[cfg(target_os = "android")]
pub const HUD_BAND_HEIGHT: f32 = 128.0;
/// Table background colour (dark green felt).
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
@@ -72,9 +104,33 @@ pub struct Layout {
/// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
/// entry. The map always contains exactly 13 entries after `compute_layout`.
pub pile_positions: HashMap<PileType, Vec2>,
/// Per-step vertical offset fraction for face-up tableau cards, as a
/// fraction of `card_size.y`. On height-limited (desktop) windows this
/// equals `TABLEAU_FAN_FRAC` (0.25); on width-limited (portrait phone)
/// windows it expands to fill the available vertical space so the tableau
/// stretches to the bottom of the screen. Card rendering (`card_plugin`)
/// and hit testing (`input_plugin`) both read from this field so they
/// stay in sync.
pub tableau_fan_frac: f32,
/// Per-step vertical offset fraction for face-down tableau cards, as a
/// fraction of `card_size.y`. Scales proportionally with `tableau_fan_frac`
/// (ratio preserved from `TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC`).
pub tableau_facedown_fan_frac: f32,
/// Vertical pixel budget available for tableau fan steps — the distance
/// from the top edge of the first tableau card to the bottom margin, in
/// logical pixels. Used by `card_plugin::update_tableau_fan_frac` to
/// recompute `tableau_fan_frac` dynamically based on the actual max
/// face-up column depth after each game state change.
pub available_tableau_height: f32,
}
/// Compute the board layout from a window size.
/// Compute the board layout from a window size and safe-area insets.
///
/// `safe_area_top` and `safe_area_bottom` are the **logical-pixel** heights of
/// the OS-reserved regions at the top and bottom of the screen (status bar and
/// gesture / navigation bar on Android). Pass `0.0` on desktop or when the
/// inset is unknown. Android's `WindowInsets` API returns **physical** pixels;
/// callers must divide by `window.scale_factor()` before passing values here.
///
/// # Geometry
/// - `card_width` is the smaller of:
@@ -90,7 +146,7 @@ pub struct Layout {
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
/// waste/stock cluster from the foundations.
pub fn compute_layout(window: Vec2) -> Layout {
pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32) -> Layout {
let window = window.max(MIN_WINDOW);
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
@@ -113,7 +169,7 @@ pub fn compute_layout(window: Vec2) -> Layout {
// (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
let card_width_height_based = (window.y - HUD_BAND_HEIGHT).max(0.0) / height_denom;
let card_width_height_based = (window.y - safe_area_top - safe_area_bottom - HUD_BAND_HEIGHT).max(0.0) / height_denom;
let card_width = card_width_width_based.min(card_width_height_based);
let card_height = card_width * CARD_ASPECT;
@@ -133,7 +189,7 @@ pub fn compute_layout(window: Vec2) -> Layout {
};
let vertical_gap = card_height * VERTICAL_GAP_FRAC;
let top_y = window.y / 2.0 - HUD_BAND_HEIGHT - h_gap - card_height / 2.0;
let top_y = window.y / 2.0 - safe_area_top - HUD_BAND_HEIGHT - h_gap - card_height / 2.0;
let tableau_y = top_y - card_height - vertical_gap;
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
@@ -153,9 +209,36 @@ pub fn compute_layout(window: Vec2) -> Layout {
pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y));
}
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
// height-based sizing already ensures a worst-case 13-card column fits at
// TABLEAU_FAN_FRAC (0.25), so the formula returns ≈0.25 and the clamp
// keeps it there — no change from prior behaviour. On width-limited
// (portrait phone) windows card_size is small and lots of vertical space
// is unused; we solve for the fraction that exactly fills the available
// space to the bottom margin.
//
// avail = distance from the top of the first tableau card to the bottom
// margin — i.e. the space available for 12 fan steps.
let avail = (tableau_y - (-window.y / 2.0 + safe_area_bottom + h_gap) - card_height / 2.0).max(0.0);
let ideal_fan_frac = if card_height > 0.0 {
avail / ((MAX_TABLEAU_CARDS - 1.0) * card_height)
} else {
TABLEAU_FAN_FRAC
};
// Never go below the desktop minimum — avoids shrinking the fan on
// degenerate near-square windows where the formula might undershoot.
let tableau_fan_frac = ideal_fan_frac.max(TABLEAU_FAN_FRAC);
// Scale the face-down fraction proportionally so rendering and hit-testing
// stay in sync (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC = 0.48 ratio).
let facedown_scale = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
let tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale;
Layout {
card_size,
pile_positions,
tableau_fan_frac,
tableau_facedown_fan_frac,
available_tableau_height: avail,
}
}
@@ -187,15 +270,15 @@ mod tests {
#[test]
fn layout_has_all_thirteen_piles() {
assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0)));
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0)));
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0)));
assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0));
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0));
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0));
}
#[test]
fn card_size_scales_with_window_width() {
let small = compute_layout(Vec2::new(800.0, 600.0));
let large = compute_layout(Vec2::new(1920.0, 1080.0));
let small = compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0);
let large = compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0);
assert!(large.card_size.x > small.card_size.x);
assert!(
(large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5,
@@ -205,14 +288,42 @@ mod tests {
#[test]
fn layout_below_minimum_clamps_to_minimum() {
let below = compute_layout(Vec2::new(400.0, 300.0));
let at_min = compute_layout(MIN_WINDOW);
// 200×200 sits below the floor on both axes, so the clamp pulls each
// axis up to MIN_WINDOW and the layout matches compute_layout(MIN_WINDOW, 0.0, 0.0).
let below = compute_layout(Vec2::new(200.0, 200.0), 0.0, 0.0);
let at_min = compute_layout(MIN_WINDOW, 0.0, 0.0);
assert_eq!(below.card_size, at_min.card_size);
}
/// Regression for the v0.22.3 Android viewport-overflow bug. A typical
/// portrait-phone viewport (360 dp × 800 dp) must produce a layout
/// where every pile fits horizontally — i.e. card_width is derived
/// from the actual window, not a clamped-up desktop floor.
#[test]
fn phone_portrait_layout_fits_horizontally() {
let window = Vec2::new(360.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0);
let half_w = window.x / 2.0;
let half_card = layout.card_size.x / 2.0;
for (pile, pos) in &layout.pile_positions {
assert!(
pos.x - half_card >= -half_w - 1e-3,
"{:?} overflows left at portrait phone window {:?}",
pile,
window
);
assert!(
pos.x + half_card <= half_w + 1e-3,
"{:?} overflows right at portrait phone window {:?}",
pile,
window
);
}
}
#[test]
fn tableau_columns_are_sorted_left_to_right() {
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
for i in 0..6 {
let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
@@ -222,7 +333,7 @@ mod tests {
#[test]
fn top_row_is_above_tableau_row() {
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let stock_y = layout.pile_positions[&PileType::Stock].y;
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
assert!(stock_y > tableau_y);
@@ -235,7 +346,7 @@ mod tests {
#[test]
fn top_row_clears_hud_band() {
let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window);
let layout = compute_layout(window, 0.0, 0.0);
let stock_y = layout.pile_positions[&PileType::Stock].y;
let card_top = stock_y + layout.card_size.y / 2.0;
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
@@ -247,7 +358,7 @@ mod tests {
#[test]
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let stock_x = layout.pile_positions[&PileType::Stock].x;
let waste_x = layout.pile_positions[&PileType::Waste].x;
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
@@ -258,7 +369,7 @@ mod tests {
#[test]
fn foundations_align_with_tableau_cols_3_to_6() {
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
for slot in 0..4_u8 {
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
@@ -277,7 +388,7 @@ mod tests {
// keep a worst-case 13-card column inside the window. (Most desktop
// monitors fall into this regime — e.g. 1280x800, 1920x1080.)
let window = Vec2::new(2560.0, 1080.0);
let layout = compute_layout(window);
let layout = compute_layout(window, 0.0, 0.0);
let width_based = window.x / 9.0;
assert!(
layout.card_size.x < width_based,
@@ -293,7 +404,7 @@ mod tests {
// the bottleneck and card_width matches the legacy window.x / 9
// derivation exactly.
let window = Vec2::new(900.0, 1600.0);
let layout = compute_layout(window);
let layout = compute_layout(window, 0.0, 0.0);
let width_based = window.x / 9.0;
assert!(
(layout.card_size.x - width_based).abs() < 1e-3,
@@ -307,7 +418,7 @@ mod tests {
fn worst_case_tableau_fits_vertically_on_default_resolution() {
// Default app resolution (see solitaire_app/src/main.rs).
let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window);
let layout = compute_layout(window, 0.0, 0.0);
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let card_h = layout.card_size.y;
// Bottom edge of the 13th fanned face-up card.
@@ -326,7 +437,7 @@ mod tests {
fn worst_case_tableau_fits_vertically_on_full_hd() {
// The bug originally reproduced at 1920x1080. Lock in a regression test.
let window = Vec2::new(1920.0, 1080.0);
let layout = compute_layout(window);
let layout = compute_layout(window, 0.0, 0.0);
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let card_h = layout.card_size.y;
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
@@ -338,6 +449,50 @@ mod tests {
);
}
/// Portrait phone (width-limited) should expand the fan fraction beyond
/// the desktop minimum so the tableau fills the available vertical space.
#[test]
fn portrait_phone_expands_tableau_fan_frac() {
let desktop = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0, 0.0);
assert!(
phone.tableau_fan_frac > desktop.tableau_fan_frac,
"portrait phone fan_frac ({:.3}) should exceed desktop ({:.3})",
phone.tableau_fan_frac,
desktop.tableau_fan_frac,
);
}
/// The expanded fan on a portrait phone must not overflow the visible
/// window — the worst-case 13-card column must stay above the bottom margin.
#[test]
fn expanded_fan_fits_phone_viewport() {
let window = Vec2::new(360.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0);
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
let card_h = layout.card_size.y;
let h_gap = layout.card_size.x / 4.0;
// Bottom of the 13th (worst-case) fanned face-up card.
let bottom = tableau_y - 12.0 * layout.tableau_fan_frac * card_h - card_h / 2.0;
let margin = -window.y / 2.0 + h_gap;
assert!(
bottom >= margin - 1e-3,
"worst-case fan overflows phone viewport: bottom={bottom:.1} < margin={margin:.1}",
);
}
/// Desktop (height-limited) must keep the minimum fan fraction so the
/// existing worst-case-fits-vertically invariant is preserved.
#[test]
fn desktop_tableau_fan_frac_is_minimum() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
assert!(
(layout.tableau_fan_frac - TABLEAU_FAN_FRAC).abs() < 1e-3,
"desktop fan_frac should stay at minimum {TABLEAU_FAN_FRAC}, got {:.4}",
layout.tableau_fan_frac,
);
}
#[test]
fn all_piles_fit_inside_window_horizontally() {
for window in [
@@ -345,7 +500,7 @@ mod tests {
Vec2::new(1280.0, 800.0),
Vec2::new(1920.0, 1080.0),
] {
let layout = compute_layout(window);
let layout = compute_layout(window, 0.0, 0.0);
let half_w = window.x / 2.0;
let half_card = layout.card_size.x / 2.0;
for (pile, pos) in &layout.pile_positions {
@@ -364,4 +519,85 @@ mod tests {
}
}
}
/// A non-zero `safe_area_top` must shift both the top row and the tableau
/// downward by the same amount — so the first card row stays below the
/// status-bar band and the tableau tracks it proportionally.
#[test]
fn safe_area_top_shifts_top_row_downward() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0);
let with_inset = compute_layout(window, 32.0, 0.0);
let stock_no_inset = without.pile_positions[&PileType::Stock].y;
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
assert!(
stock_with_inset < stock_no_inset,
"safe_area_top=32 must shift stock pile down (y decreased): {} → {}",
stock_no_inset,
stock_with_inset,
);
assert!(
(stock_no_inset - stock_with_inset - 32.0).abs() < 1e-3,
"stock pile must shift by exactly safe_area_top (32 dp): delta was {:.3}",
stock_no_inset - stock_with_inset,
);
}
/// With a safe-area inset the card grid must still fit horizontally —
/// safe_area_top only affects the vertical budget.
#[test]
fn safe_area_top_does_not_affect_horizontal_layout() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0);
let with_inset = compute_layout(window, 32.0, 0.0);
for pile in [
PileType::Stock,
PileType::Waste,
PileType::Tableau(0),
PileType::Tableau(6),
] {
assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
"{pile:?} x-position must not change with safe_area_top",
);
}
}
/// A bottom safe-area inset must shrink the tableau fan so the worst-case
/// column stays above the gesture bar.
#[test]
fn safe_area_bottom_reduces_tableau_fan() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0);
let with_inset = compute_layout(window, 0.0, 48.0);
assert!(
with_inset.tableau_fan_frac <= without.tableau_fan_frac,
"safe_area_bottom=48 must not increase tableau_fan_frac: {:.4} → {:.4}",
without.tableau_fan_frac,
with_inset.tableau_fan_frac,
);
let card_h = with_inset.card_size.y;
let tableau_y = with_inset.pile_positions[&PileType::Tableau(6)].y;
let bottom_edge = tableau_y - 12.0 * card_h * with_inset.tableau_fan_frac - card_h / 2.0;
let h_gap = with_inset.card_size.x / 4.0;
let margin = -window.y / 2.0 + 48.0 + h_gap;
assert!(
bottom_edge >= margin - 1e-3,
"worst-case tableau bottom {bottom_edge:.2} overflows gesture-bar margin {margin:.2}",
);
}
/// safe_area_bottom must not affect horizontal positions.
#[test]
fn safe_area_bottom_does_not_affect_horizontal_layout() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0);
let with_inset = compute_layout(window, 0.0, 48.0);
for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] {
assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
"{pile:?} x-position must not change with safe_area_bottom",
);
}
}
}
+2
View File
@@ -35,6 +35,7 @@ pub mod replay_playback;
pub mod settings_plugin;
pub mod progress_plugin;
pub mod resources;
pub mod safe_area;
pub mod selection_plugin;
pub mod splash_plugin;
pub mod stats_plugin;
@@ -138,6 +139,7 @@ pub use settings_plugin::{
};
pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
pub use selection_plugin::{
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
};
+16 -1
View File
@@ -41,7 +41,13 @@ use crate::ui_theme::{
// ---------------------------------------------------------------------------
/// Total number of onboarding slides (0-based index goes 0..SLIDE_COUNT-1).
///
/// Android omits the keyboard-shortcuts slide (index 2) because there is no
/// physical keyboard on a touchscreen device, dropping the count to 2.
#[cfg(not(target_os = "android"))]
const SLIDE_COUNT: u8 = 3;
#[cfg(target_os = "android")]
const SLIDE_COUNT: u8 = 2;
// ---------------------------------------------------------------------------
// Components (private — never re-exported)
@@ -276,6 +282,8 @@ fn spawn_slide(commands: &mut Commands, index: u8, font_res: Option<&FontResourc
match index {
0 => spawn_slide_welcome(commands, font_res),
1 => spawn_slide_how_to_play(commands, font_res),
// Slide 2 (keyboard shortcuts) is desktop-only; Android has no keyboard.
#[cfg(not(target_os = "android"))]
2 => spawn_slide_hotkeys(commands, font_res),
_ => spawn_slide_welcome(commands, font_res),
}
@@ -664,8 +672,15 @@ mod tests {
// -----------------------------------------------------------------------
#[test]
#[cfg(not(target_os = "android"))]
fn slide_count_constant_is_three() {
assert_eq!(SLIDE_COUNT, 3, "SLIDE_COUNT must be 3");
assert_eq!(SLIDE_COUNT, 3, "SLIDE_COUNT must be 3 on desktop");
}
#[test]
#[cfg(target_os = "android")]
fn slide_count_constant_is_two_on_android() {
assert_eq!(SLIDE_COUNT, 2, "SLIDE_COUNT must be 2 on Android (no keyboard slide)");
}
#[test]
+97 -15
View File
@@ -42,6 +42,7 @@
//! real `PrimaryWindow` / camera, since `MinimalPlugins` provides
//! neither.
use bevy::input::touch::Touches;
use bevy::input::ButtonInput;
use bevy::math::Vec2;
use bevy::prelude::*;
@@ -59,6 +60,11 @@ use crate::resources::{DragState, GameStateResource};
use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS};
/// Seconds a finger must be held on a face-up card (without crossing the
/// drag threshold) before the radial menu opens. Matches Android's long-press
/// gesture recogniser default.
const LONG_PRESS_SECS: f32 = 0.5;
/// Sprite-space `Transform.z` for radial-menu overlay sprites.
///
/// One rung above [`crate::ui_theme::Z_DROP_OVERLAY`] (`50.0`) so the radial icons render
@@ -181,6 +187,7 @@ impl Plugin for RadialMenuPlugin {
Update,
(
radial_open_on_right_click,
radial_open_on_long_press,
radial_track_cursor,
radial_handle_release_or_cancel,
radial_redraw_overlay,
@@ -446,6 +453,68 @@ fn radial_open_on_right_click(
};
}
/// Opens the radial menu after a sustained touch hold on a face-up card.
///
/// Counts up while the touch is down, the drag threshold has not been
/// crossed, and the radial is not yet active. Fires after
/// [`LONG_PRESS_SECS`] (0.5 s). The timer resets whenever these
/// conditions are not met, so lifting, committing a drag, or the radial
/// already being open all clear it cleanly.
#[allow(clippy::too_many_arguments)]
fn radial_open_on_long_press(
time: Res<Time>,
mut hold_timer: Local<f32>,
drag: Res<DragState>,
paused: Option<Res<PausedResource>>,
touches: Option<Res<Touches>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>,
mut state: ResMut<RightClickRadialState>,
) {
// Guard: only count while a touch is down, uncommitted, and radial is idle.
let active_id = drag.active_touch_id;
if active_id.is_none() || drag.committed || state.is_active() || paused.is_some_and(|p| p.0) {
*hold_timer = 0.0;
return;
}
*hold_timer += time.delta_secs();
if *hold_timer < LONG_PRESS_SECS {
return;
}
*hold_timer = 0.0;
// Resolve current touch world position.
let Some(touches) = touches else { return };
let Some(touch) = touches.iter().find(|t| t.id() == active_id.unwrap()) else {
return;
};
let Some((camera, cam_xf)) = cameras.single().ok() else { return };
let Some(world) = camera.viewport_to_world_2d(cam_xf, touch.position()).ok() else {
return;
};
let Some(layout) = layout else { return };
let Some(game) = game else { return };
let Some((source_pile, card)) = find_top_face_up_card_at(world, &game.0, &layout.0) else {
return;
};
let dests = legal_destinations_for_card(&card, &source_pile, &game.0);
if dests.is_empty() {
return;
}
let legal_destinations = build_radial_destinations(world, dests);
*state = RightClickRadialState::Active {
source_pile,
count: 1,
cards: vec![card.id],
legal_destinations,
centre: world,
hovered_index: None,
};
}
/// Each frame while `Active`, updates `hovered_index` based on the
/// current cursor position. Cheap — just re-runs hit-testing against
/// the precomputed anchors. The overlay redraw system reads this index
@@ -454,6 +523,7 @@ fn radial_track_cursor(
cursor_override: Option<Res<RadialCursorOverride>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
touches: Option<Res<Touches>>,
mut state: ResMut<RightClickRadialState>,
) {
let RightClickRadialState::Active {
@@ -464,21 +534,28 @@ fn radial_track_cursor(
else {
return;
};
let Some(world) = cursor_world(cursor_override.as_ref(), &windows, &cameras) else {
return;
};
// Cursor first (mouse / test override); fall back to first active touch
// so the player can slide their held finger over radial icons on Android.
let world = cursor_world(cursor_override.as_ref(), &windows, &cameras).or_else(|| {
let (camera, cam_xf) = cameras.single().ok()?;
let touch_pos = touches.as_ref()?.iter().next()?.position();
camera.viewport_to_world_2d(cam_xf, touch_pos).ok()
});
let Some(world) = world else { return };
let anchors: Vec<Vec2> = legal_destinations.iter().map(|(_, a)| *a).collect();
*hovered_index = radial_hovered_index(world, &anchors);
}
/// Handles three exit conditions while `Active`:
/// Handles exit conditions while `Active`:
/// 1. Right-mouse release → confirm if hovering, otherwise cancel.
/// 2. `Escape` → cancel.
/// 3. Left-mouse press → cancel (keeps the existing drag pipeline clean).
/// 2. Touch lift (`Touches::iter_just_released`) → confirm if hovering, cancel otherwise.
/// 3. `Escape` → cancel.
/// 4. Left-mouse press → cancel (keeps the existing drag pipeline clean).
#[allow(clippy::too_many_arguments)]
fn radial_handle_release_or_cancel(
buttons: Option<Res<ButtonInput<MouseButton>>>,
keys: Option<Res<ButtonInput<KeyCode>>>,
touches: Option<Res<Touches>>,
mut state: ResMut<RightClickRadialState>,
mut moves: MessageWriter<MoveRequestEvent>,
) {
@@ -495,13 +572,18 @@ fn radial_handle_release_or_cancel(
let left_pressed = buttons
.as_ref()
.is_some_and(|b| b.just_pressed(MouseButton::Left));
// Finger lift: any touch that ended or was cancelled this frame.
let touch_ended = touches.as_ref().is_some_and(|t| {
t.iter_just_released().next().is_some() || t.iter_just_canceled().next().is_some()
});
if !escape_pressed && !right_released && !left_pressed {
if !escape_pressed && !right_released && !left_pressed && !touch_ended {
return;
}
// On confirm, fire a MoveRequestEvent. On any other exit, just clear.
if right_released
// On confirm (right-release or touch-lift while hovering), fire a move.
let confirm = right_released || touch_ended;
if confirm
&& let RightClickRadialState::Active {
source_pile,
count,
@@ -719,7 +801,7 @@ mod tests {
fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) {
app.insert_resource(GameStateResource(state));
app.insert_resource(LayoutResource(compute_layout(layout_window)));
app.insert_resource(LayoutResource(compute_layout(layout_window, 0.0, 0.0)));
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(cursor);
}
@@ -831,7 +913,7 @@ mod tests {
fn right_click_press_on_face_up_card_opens_radial() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window);
let layout = compute_layout(layout_window, 0.0, 0.0);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -868,7 +950,7 @@ mod tests {
fn right_click_release_over_destination_fires_move_request() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window);
let layout = compute_layout(layout_window, 0.0, 0.0);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -907,7 +989,7 @@ mod tests {
fn right_click_release_outside_any_destination_cancels() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window);
let layout = compute_layout(layout_window, 0.0, 0.0);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -934,7 +1016,7 @@ mod tests {
fn escape_cancels_active_radial() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window);
let layout = compute_layout(layout_window, 0.0, 0.0);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -957,7 +1039,7 @@ mod tests {
fn right_click_on_face_down_card_does_not_open_radial() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window);
let layout = compute_layout(layout_window, 0.0, 0.0);
let king_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
+1
View File
@@ -944,6 +944,7 @@ fn spawn_overlay(
},
TextColor(TEXT_SECONDARY),
));
#[cfg(not(target_os = "android"))]
footer.spawn((
Text::new(keybind_footer_hint_text()),
TextFont {
+248
View File
@@ -0,0 +1,248 @@
//! Safe-area insets.
//!
//! Reports the OS-reserved regions around the playable surface (status
//! bar at the top, gesture / navigation bar at the bottom on Android,
//! display cutouts, etc.) so UI anchored to a screen edge can avoid
//! collisions.
//!
//! On non-Android targets all four edges report `0.0`. On Android the
//! values come from `WindowInsets.getInsets(WindowInsets.Type.systemBars())`
//! via JNI; the call is retried for the first few frames because
//! `getRootWindowInsets()` only returns useful values after the decor
//! view has been laid out at least once.
//!
//! UI that wants to respect the top inset should tag itself with the
//! [`SafeAreaAnchoredTop`] marker carrying the layout's original top
//! offset; [`apply_safe_area_anchors`] re-applies `base_top + insets.top`
//! whenever the resource changes, so late inset arrival or orientation
//! changes flow through automatically.
use bevy::prelude::*;
/// Pixel sizes of the system-reserved regions on each edge of the
/// surface. Zero on desktop.
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq)]
pub struct SafeAreaInsets {
pub top: f32,
pub bottom: f32,
pub left: f32,
pub right: f32,
}
impl SafeAreaInsets {
/// `true` when any edge has a non-zero reservation. Used by the
/// Android polling system to know it can stop querying.
pub fn is_populated(&self) -> bool {
self.top > 0.0 || self.bottom > 0.0 || self.left > 0.0 || self.right > 0.0
}
}
/// Marker for `Node` entities whose `top` offset should be re-applied
/// as `base_top + SafeAreaInsets::top`.
///
/// `base_top` is the offset the layout would have used on a surface
/// with no system reservation (i.e. on desktop). The fix-up system
/// adds the current top inset on top of it whenever the resource
/// changes.
#[derive(Component, Debug, Clone, Copy)]
pub struct SafeAreaAnchoredTop {
pub base_top: f32,
}
pub struct SafeAreaInsetsPlugin;
impl Plugin for SafeAreaInsetsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SafeAreaInsets>()
.add_systems(Update, apply_safe_area_anchors);
#[cfg(target_os = "android")]
app.add_systems(Update, android::refresh_insets);
}
}
/// Re-applies `base_top + insets.top` to every entity carrying the
/// [`SafeAreaAnchoredTop`] marker whenever [`SafeAreaInsets`] changes.
///
/// Bevy resource change detection (`Res::is_changed`) is `true` on the
/// frame the resource is inserted and every frame a `ResMut` borrow
/// occurs. Combined with the Android polling loop short-circuiting
/// once insets are populated, this runs at most a handful of times in
/// a session.
fn apply_safe_area_anchors(
insets: Res<SafeAreaInsets>,
windows: Query<&Window>,
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
) {
if !insets.is_changed() {
return;
}
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
// expects logical pixels (≈ dp). Divide by the window scale factor so
// the HUD band shifts by the correct number of dp on high-DPI devices.
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let top_logical = insets.top / scale;
for (anchor, mut node) in &mut q {
node.top = Val::Px(anchor.base_top + top_logical);
}
}
#[cfg(target_os = "android")]
mod android {
use super::SafeAreaInsets;
use bevy::prelude::*;
/// Polls Android for safe-area insets until we get a non-zero
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
/// all-zero `Insets`) until the decor view has been laid out, which
/// is typically frame 13 of a fresh launch.
pub(super) fn refresh_insets(
mut insets: ResMut<SafeAreaInsets>,
mut tries: Local<u32>,
) {
// Cap retries so we don't burn CPU forever on edge-to-edge
// devices that genuinely report zero insets.
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
if *tries >= MAX_TRIES || insets.is_populated() {
return;
}
*tries += 1;
match query_insets() {
Ok(v) if v.is_populated() => {
info!(
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
v.top, v.bottom, v.left, v.right, *tries
);
*insets = v;
}
Ok(_) => {
// Layout not ready yet; try again next frame.
}
Err(e) => {
// Don't spam — log once and let polling continue silently.
if *tries == 1 {
warn!("safe_area: JNI query failed (will retry): {e}");
}
}
}
}
fn query_insets() -> Result<SafeAreaInsets, String> {
use bevy::android::ANDROID_APP;
use jni::{objects::JObject, JavaVM};
let app = ANDROID_APP
.get()
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
// SAFETY: `vm_as_ptr()` returns the JavaVM* set up by the Android
// runtime; valid for the lifetime of the process.
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject
// pointer — valid for the lifetime of the process.
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
(|| -> jni::errors::Result<SafeAreaInsets> {
// Window window = activity.getWindow();
let window = env
.call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])?
.l()?;
// View decor = window.getDecorView();
let decor = env
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
.l()?;
// WindowInsets insets = decor.getRootWindowInsets();
let raw_insets = env
.call_method(
&decor,
"getRootWindowInsets",
"()Landroid/view/WindowInsets;",
&[],
)?
.l()?;
if raw_insets.is_null() {
return Ok(SafeAreaInsets::default());
}
// int types = WindowInsets.Type.systemBars();
// (Static method on the WindowInsets$Type inner class.
// Available since API 30 / Android 11.)
let type_class = env.find_class("android/view/WindowInsets$Type")?;
let bars_type = env
.call_static_method(&type_class, "systemBars", "()I", &[])?
.i()?;
// Insets bars = insets.getInsets(types);
let bars = env
.call_method(
&raw_insets,
"getInsets",
"(I)Landroid/graphics/Insets;",
&[bars_type.into()],
)?
.l()?;
// `Insets` exposes `top`, `bottom`, `left`, `right` as public
// `int` fields (pixel values, not dp).
let top = env.get_field(&bars, "top", "I")?.i()? as f32;
let bottom = env.get_field(&bars, "bottom", "I")?.i()? as f32;
let left = env.get_field(&bars, "left", "I")?.i()? as f32;
let right = env.get_field(&bars, "right", "I")?.i()? as f32;
Ok(SafeAreaInsets {
top,
bottom,
left,
right,
})
})()
.map_err(|e| format!("safe-area JNI: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_zero_and_not_populated() {
let i = SafeAreaInsets::default();
assert_eq!(i.top, 0.0);
assert_eq!(i.bottom, 0.0);
assert!(!i.is_populated());
}
#[test]
fn is_populated_returns_true_for_any_nonzero_edge() {
assert!(SafeAreaInsets {
top: 24.0,
..Default::default()
}
.is_populated());
assert!(SafeAreaInsets {
bottom: 16.0,
..Default::default()
}
.is_populated());
assert!(SafeAreaInsets {
left: 8.0,
..Default::default()
}
.is_populated());
assert!(SafeAreaInsets {
right: 8.0,
..Default::default()
}
.is_populated());
}
}
+30 -7
View File
@@ -130,7 +130,14 @@ fn start_pull(
) {
let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
provider.pull().await
// Bevy's AsyncComputeTaskPool uses async-executor (not Tokio), but
// reqwest/hyper require a Tokio reactor for DNS and HTTP I/O. Provide
// a short-lived single-threaded runtime for this network round-trip.
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.pull())
});
task_res.0 = Some(task);
status.0 = SyncStatus::Syncing;
@@ -153,7 +160,11 @@ fn handle_manual_sync_request(
}
let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
provider.pull().await
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.pull())
});
task_res.0 = Some(task);
status.0 = SyncStatus::Syncing;
@@ -259,11 +270,18 @@ fn push_on_exit(
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let provider = provider.0.clone();
// Prefer an existing tokio runtime; fall back to futures_lite block_on
// for environments (e.g. tests) that don't have one.
// Prefer an existing tokio runtime; fall back to a temporary one for
// environments (e.g. tests, Android's non-Tokio async executor) where
// reqwest/hyper would otherwise panic with "no reactor running".
let result = match tokio::runtime::Handle::try_current() {
Ok(handle) => handle.block_on(provider.push(&payload)),
Err(_) => future::block_on(provider.push(&payload)),
Err(_) => match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt.block_on(provider.push(&payload)),
Err(e) => Err(SyncError::Network(format!("tokio rt on exit: {e}"))),
},
};
match result {
Ok(_) => {}
@@ -314,8 +332,13 @@ fn push_replay_on_win(
recording.moves.clone(),
);
let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get()
.spawn(async move { provider.push_replay(&replay).await });
let task = AsyncComputeTaskPool::get().spawn(async move {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.push_replay(&replay))
});
// If a previous upload is still in flight, drop it — the most
// recent win is the one whose share link the player will care
// about. Bevy's `Task` Drop cancels cooperatively.
+80 -14
View File
@@ -11,6 +11,7 @@ use solitaire_core::pile::PileType;
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem};
use crate::safe_area::SafeAreaInsets;
use crate::resources::GameStateResource;
#[cfg(test)]
use crate::layout::TABLE_COLOUR;
@@ -82,6 +83,7 @@ impl Plugin for TablePlugin {
.add_systems(
Update,
(
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
apply_theme_on_settings_change,
apply_hint_pile_highlight,
@@ -146,18 +148,38 @@ fn setup_table(
existing_camera: Query<(), With<Camera>>,
settings: Option<Res<SettingsResource>>,
bg_images: Option<Res<BackgroundImageSet>>,
safe_area: Option<Res<SafeAreaInsets>>,
) {
// Only spawn a camera if one does not already exist (e.g. a parent app
// may have added one in tests).
// may have added one in tests). Use the felt-green clear colour so the
// background reads as green even before the background PNG finishes
// loading (which is asynchronous and can lag by several frames on
// Android).
if existing_camera.is_empty() {
commands.spawn(Camera2d);
commands.spawn((
Camera2d,
Camera {
clear_color: ClearColorConfig::Custom(Color::srgb(
crate::layout::TABLE_COLOUR[0],
crate::layout::TABLE_COLOUR[1],
crate::layout::TABLE_COLOUR[2],
)),
..default()
},
));
}
let window_size = windows
.iter()
.next()
.map_or(Vec2::new(1280.0, 800.0), default_window_size);
let layout = compute_layout(window_size);
let (window_size, scale) = windows.iter().next().map_or(
(Vec2::new(1280.0, 800.0), 1.0f32),
|w| (default_window_size(w), w.scale_factor()),
);
// Safe-area insets arrive from JNI asynchronously; they are almost always
// 0 here (populated ~frame 2-3). on_safe_area_changed fires when they
// arrive and issues a synthetic WindowResized to re-snap all game objects.
let insets = safe_area.as_deref().copied().unwrap_or_default();
let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / scale;
let layout = compute_layout(window_size, safe_area_top, safe_area_bottom);
let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background);
@@ -258,12 +280,11 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
PileMarker(pile.clone()),
));
// Foundation slots no longer carry a suit letter — any Ace can claim
// any empty slot, so a fixed C/D/H/S badge would be misleading. Empty
// foundation markers render as plain translucent rectangles.
// Task #43 — King indicator on empty tableau placeholders.
if let PileType::Tableau(_) = &pile {
// Tableau markers show "K" (only a King may start an empty column).
// Foundation markers show "A" (only an Ace may claim an empty slot).
// Neither label carries a suit because any suit may start any slot.
match &pile {
PileType::Tableau(_) => {
entity.with_children(|b| {
b.spawn((
Text2d::new("K"),
@@ -273,12 +294,26 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
));
});
}
PileType::Foundation(_) => {
entity.with_children(|b| {
b.spawn((
Text2d::new("A"),
TextFont { font_size, ..default() },
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
}
_ => {}
}
}
}
#[allow(clippy::type_complexity)]
fn on_window_resized(
mut events: MessageReader<WindowResized>,
safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<&Window>,
mut layout_res: Option<ResMut<LayoutResource>>,
mut backgrounds: Query<
(&mut Sprite, &mut Transform),
@@ -290,7 +325,11 @@ fn on_window_resized(
return;
};
let window_size = Vec2::new(ev.width, ev.height);
let new_layout = compute_layout(window_size);
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let insets = safe_area.as_deref().copied().unwrap_or_default();
let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / scale;
let new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom);
if let Some(layout_res) = layout_res.as_deref_mut() {
layout_res.0 = new_layout.clone();
@@ -318,6 +357,33 @@ fn on_window_resized(
// and forth" jitter).
}
/// Bridges the asynchronous safe-area inset update into the synchronous
/// window-resize pipeline. When Android's JNI delivers the real inset values
/// (typically frame 2-3 of a fresh launch), this system writes a synthetic
/// `WindowResized` event carrying the current window size. `on_window_resized`
/// (which runs in `LayoutSystem::UpdateOnResize`) will then recompute the
/// layout with the correct `safe_area_top`, update `LayoutResource` and the
/// pile markers, and `snap_cards_on_window_resize` (running after the set)
/// will snap card sprites to the corrected positions.
fn on_safe_area_changed(
safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<(Entity, &Window)>,
mut resize_events: MessageWriter<WindowResized>,
) {
let Some(safe_area) = safe_area else { return; };
if !safe_area.is_changed() {
return;
}
let Some((entity, window)) = windows.iter().next() else {
return;
};
resize_events.write(WindowResized {
window: entity,
width: window.resolution.width(),
height: window.resolution.height(),
});
}
// ---------------------------------------------------------------------------
// Task #6 — Hint pile-marker highlight
// ---------------------------------------------------------------------------
+2
View File
@@ -328,6 +328,8 @@ pub fn spawn_modal_button<M: Component>(
variant: ButtonVariant,
font_res: Option<&FontResource>,
) {
#[cfg(target_os = "android")]
let hotkey: Option<&'static str> = None;
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_label = TextFont {
font: font_handle.clone(),
+76 -15
View File
@@ -167,10 +167,11 @@ pub struct SessionAchievements {
#[derive(Component, Debug)]
pub struct WinSummaryOverlay;
/// Marker on the "Play Again" button inside the win-summary modal.
/// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal.
#[derive(Component, Debug)]
enum WinSummaryButton {
PlayAgain,
WatchReplay,
}
/// Marker for one row of the win-modal score-breakdown reveal.
@@ -602,26 +603,58 @@ fn spawn_win_summary_after_delay(
}
}
/// Despawns the win-summary modal and fires `NewGameRequestEvent` when
/// the player presses "Play Again".
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
fn handle_win_summary_buttons(
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
overlays: Query<Entity, With<WinSummaryOverlay>>,
mut commands: Commands,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
mut playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
// Collect all pressed buttons first to avoid moving `playback` inside the loop.
let pressed: Vec<&WinSummaryButton> = interaction_query
.iter()
.filter(|(i, _)| **i == Interaction::Pressed)
.map(|(_, b)| b)
.collect();
for button in pressed {
match button {
WinSummaryButton::PlayAgain => {
// Despawn the modal.
for entity in &overlays {
commands.entity(entity).despawn();
}
new_game.write(NewGameRequestEvent::default());
}
WinSummaryButton::WatchReplay => {
let latest = history
.as_ref()
.and_then(|h| h.0.replays.last())
.cloned();
match (latest, playback.as_mut()) {
(Some(replay), Some(pb)) => {
for entity in &overlays {
commands.entity(entity).despawn();
}
crate::replay_playback::start_replay_playback(
&mut commands,
pb,
replay,
);
}
(Some(_), None) => {
toast.write(InfoToastEvent(
"Replay playback not available".to_string(),
));
}
(None, _) => {
toast.write(InfoToastEvent("No replay saved yet".to_string()));
}
}
}
}
}
}
@@ -811,23 +844,50 @@ fn spawn_overlay(
spawn_achievements_section(card, &session.names);
}
// Play Again button
card.spawn((
// Button row: Watch Replay + Play Again side by side.
card.spawn(Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
column_gap: VAL_SPACE_3,
margin: UiRect::top(VAL_SPACE_2),
..default()
})
.with_children(|row| {
// Watch Replay (secondary style)
row.spawn((
WinSummaryButton::WatchReplay,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(Color::NONE),
BorderColor::all(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new("Watch Replay"),
TextFont { font_size: TYPE_BODY_LG, ..default() },
TextColor(ACCENT_PRIMARY),
));
});
// Play Again (primary style)
row.spawn((
WinSummaryButton::PlayAgain,
Button,
Node {
padding: UiRect::axes(Val::Px(28.0), VAL_SPACE_3),
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
margin: UiRect::top(VAL_SPACE_2),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
))
.with_children(|b| {
// Append the Enter / Return glyph so keyboard players see
// the accelerator on the button itself — mirrors the
// chip-style hints on every modal button helper.
b.spawn((
Text::new("Play Again \u{21B5}"),
TextFont { font_size: TYPE_BODY_LG, ..default() },
@@ -836,6 +896,7 @@ fn spawn_overlay(
});
});
});
});
}
/// Maximum number of achievement names shown explicitly in the win modal before