Compare commits
22 Commits
v0.22.2
...
4398403418
| Author | SHA1 | Date | |
|---|---|---|---|
| 4398403418 | |||
| 002d96f2c8 | |||
| cc161cc37f | |||
| 8a3e30bd16 | |||
| 2a206b994c | |||
| ae7c6c97f1 | |||
| 016fb7214d | |||
| 948864e653 | |||
| 76a754d8e5 | |||
| 9fb59c7d47 | |||
| d714a11cfb | |||
| e107f5e218 | |||
| 463b7465ed | |||
| 92a5ebb15e | |||
| 89a21c0587 | |||
| 304cb050a7 | |||
| fcc7337c97 | |||
| 16ce2b88d2 | |||
| b9aa2620b8 | |||
| 47f02a60ae | |||
| a5c3188686 | |||
| 6a289b7b50 |
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
libxkbcommon-dev
|
libxkbcommon-dev
|
||||||
|
|
||||||
- name: Cache cargo registry and build artifacts
|
- name: Cache cargo registry and build artifacts
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
needs: test
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
libxkbcommon-dev
|
libxkbcommon-dev
|
||||||
|
|
||||||
- name: Cache cargo registry and build artifacts
|
- name: Cache cargo registry and build artifacts
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
|
libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
|
||||||
|
|
||||||
- name: Cache cargo registry + build artifacts
|
- name: Cache cargo registry + build artifacts
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
cp -r assets solitaire-quest/
|
cp -r assets solitaire-quest/
|
||||||
tar -czf solitaire-quest-linux-x86_64.tar.gz solitaire-quest
|
tar -czf solitaire-quest-linux-x86_64.tar.gz solitaire-quest
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: linux
|
name: linux
|
||||||
path: solitaire-quest-linux-x86_64.tar.gz
|
path: solitaire-quest-linux-x86_64.tar.gz
|
||||||
@@ -76,7 +76,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust stable + Android targets
|
- name: Install Rust stable + Android targets
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -90,7 +90,7 @@ jobs:
|
|||||||
run: echo "ANDROID_NDK_ROOT=$ANDROID_NDK_LATEST_HOME" >> $GITHUB_ENV
|
run: echo "ANDROID_NDK_ROOT=$ANDROID_NDK_LATEST_HOME" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache cargo registry + cargo-apk binary + build artifacts
|
- name: Cache cargo registry + cargo-apk binary + build artifacts
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
@@ -126,7 +126,13 @@ jobs:
|
|||||||
} >> solitaire_app/Cargo.toml
|
} >> solitaire_app/Cargo.toml
|
||||||
|
|
||||||
- name: Build and sign APK (release profile)
|
- name: Build and sign APK (release profile)
|
||||||
run: cargo apk build -p solitaire_app --release
|
# `--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
|
- name: Stage APK for upload
|
||||||
run: |
|
run: |
|
||||||
@@ -134,7 +140,7 @@ jobs:
|
|||||||
"solitaire-quest-${{ github.ref_name }}.apk"
|
"solitaire-quest-${{ github.ref_name }}.apk"
|
||||||
rm release.keystore
|
rm release.keystore
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: android
|
name: android
|
||||||
path: solitaire-quest-${{ github.ref_name }}.apk
|
path: solitaire-quest-${{ github.ref_name }}.apk
|
||||||
@@ -148,11 +154,11 @@ jobs:
|
|||||||
needs: [build-linux, build-android]
|
needs: [build-linux, build-android]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: linux
|
name: linux
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: android
|
name: android
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ data/
|
|||||||
|
|
||||||
# IDE project files
|
# IDE project files
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Android signing keystores — never commit
|
||||||
|
*.jks
|
||||||
|
*.jks.bak
|
||||||
|
*.keystore
|
||||||
|
|||||||
Generated
+1
@@ -6986,6 +6986,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"bevy",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -60,6 +60,15 @@ package = "com.solitairequest.app"
|
|||||||
apk_name = "solitaire-quest"
|
apk_name = "solitaire-quest"
|
||||||
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
|
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
|
||||||
assets = "../assets"
|
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,
|
# No `runtime_libs` — we don't ship any precompiled .so files,
|
||||||
# the entire app is pure Rust + Bevy. cargo-apk would try to
|
# the entire app is pure Rust + Bevy. cargo-apk would try to
|
||||||
# resolve `runtime_libs/<arch>/` if set, and fail on a non-existent
|
# resolve `runtime_libs/<arch>/` if set, and fail on a non-existent
|
||||||
@@ -79,6 +88,14 @@ name = "android.permission.INTERNET"
|
|||||||
|
|
||||||
[package.metadata.android.application]
|
[package.metadata.android.application]
|
||||||
label = "Solitaire Quest"
|
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
|
# `debuggable` defaults to false on release builds; cargo-apk flips it
|
||||||
# automatically for debug profiles. Leaving the field unset keeps the
|
# automatically for debug profiles. Leaving the field unset keeps the
|
||||||
# default behaviour.
|
# 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 |
@@ -30,7 +30,8 @@ use solitaire_engine::{
|
|||||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||||
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||||
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin,
|
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
||||||
|
SelectionPlugin, SettingsPlugin,
|
||||||
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
||||||
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||||
WinSummaryPlugin,
|
WinSummaryPlugin,
|
||||||
@@ -131,11 +132,20 @@ pub fn run() {
|
|||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
// The `assets/` directory lives at the workspace root, but
|
// The `assets/` directory lives at the workspace root, but
|
||||||
// Bevy resolves `AssetPlugin::file_path` relative to the
|
// on desktop Bevy resolves `AssetPlugin::file_path` relative
|
||||||
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
|
// to the binary package's `CARGO_MANIFEST_DIR`
|
||||||
// Point one level up so `cargo run -p solitaire_app` finds
|
// (`solitaire_app/`), so `cargo run -p solitaire_app` would
|
||||||
// card faces, backs, backgrounds, and the UI font.
|
// 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 {
|
.set(bevy::asset::AssetPlugin {
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
file_path: "../assets".to_string(),
|
file_path: "../assets".to_string(),
|
||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
@@ -173,6 +183,7 @@ pub fn run() {
|
|||||||
.add_plugins(PlayBySeedPlugin)
|
.add_plugins(PlayBySeedPlugin)
|
||||||
.add_plugins(DifficultyPlugin)
|
.add_plugins(DifficultyPlugin)
|
||||||
.add_plugins(TimeAttackPlugin)
|
.add_plugins(TimeAttackPlugin)
|
||||||
|
.add_plugins(SafeAreaInsetsPlugin)
|
||||||
.add_plugins(HudPlugin)
|
.add_plugins(HudPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
.add_plugins(HomePlugin::default())
|
.add_plugins(HomePlugin::default())
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ impl AnimationTuning {
|
|||||||
platform: InputPlatform::Touch,
|
platform: InputPlatform::Touch,
|
||||||
duration_scale: 0.75,
|
duration_scale: 0.75,
|
||||||
overshoot_scale: 0.5,
|
overshoot_scale: 0.5,
|
||||||
drag_threshold_px: 10.0,
|
drag_threshold_px: 8.0, // Android ViewConfiguration.getScaledTouchSlop()
|
||||||
drag_scale: 1.12,
|
drag_scale: 1.12,
|
||||||
hover_scale: 1.0, // no hover affordance on touch
|
hover_scale: 1.0, // no hover affordance on touch
|
||||||
hover_lerp_speed: 20.0,
|
hover_lerp_speed: 20.0,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ use crate::ui_theme::{
|
|||||||
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
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_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,
|
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.
|
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
||||||
@@ -263,6 +263,23 @@ pub struct ShadowEntity;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct CardShadow;
|
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
|
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
|
||||||
/// shadow given whether its parent card is currently part of the dragged
|
/// 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
|
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
|
||||||
@@ -318,6 +335,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.
|
/// Throttle interval for resize-driven card snap work, in seconds.
|
||||||
///
|
///
|
||||||
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
|
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
|
||||||
@@ -373,6 +405,9 @@ impl Plugin for CardPlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
|
update_tableau_fan_frac
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(sync_cards_on_change),
|
||||||
sync_cards_on_change.after(GameMutation),
|
sync_cards_on_change.after(GameMutation),
|
||||||
resync_cards_on_settings_change.before(sync_cards_on_change),
|
resync_cards_on_settings_change.before(sync_cards_on_change),
|
||||||
start_flip_anim.after(GameMutation),
|
start_flip_anim.after(GameMutation),
|
||||||
@@ -667,9 +702,9 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
|||||||
out.push((card, pos, z));
|
out.push((card, pos, z));
|
||||||
if is_tableau {
|
if is_tableau {
|
||||||
let step = if card.face_up {
|
let step = if card.face_up {
|
||||||
TABLEAU_FAN_FRAC
|
layout.tableau_fan_frac
|
||||||
} else {
|
} else {
|
||||||
TABLEAU_FACEDOWN_FAN_FRAC
|
layout.tableau_facedown_fan_frac
|
||||||
};
|
};
|
||||||
y_offset -= layout.card_size.y * step;
|
y_offset -= layout.card_size.y * step;
|
||||||
}
|
}
|
||||||
@@ -706,6 +741,13 @@ fn spawn_card_entity(
|
|||||||
entity.with_children(|b| {
|
entity.with_children(|b| {
|
||||||
add_card_shadow_child(b, layout.card_size);
|
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.
|
// When PNG faces are loaded the rank/suit are baked into the image.
|
||||||
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
||||||
if card_images.is_none() {
|
if card_images.is_none() {
|
||||||
@@ -781,6 +823,11 @@ fn update_card_entity(
|
|||||||
commands.entity(entity).with_children(|b| {
|
commands.entity(entity).with_children(|b| {
|
||||||
add_card_shadow_child(b, layout.card_size);
|
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() {
|
if card_images.is_none() {
|
||||||
commands.entity(entity).with_children(|b| {
|
commands.entity(entity).with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -1438,8 +1485,8 @@ fn update_stock_empty_indicator(
|
|||||||
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
|
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
|
||||||
|
|
||||||
/// Width / height of the badge background sprite, in world pixels. Sized so
|
/// Width / height of the badge background sprite, in world pixels. Sized so
|
||||||
/// a 2-digit count (max "24") fits comfortably with `TYPE_CAPTION` text.
|
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
|
||||||
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(28.0, 16.0);
|
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0);
|
||||||
|
|
||||||
/// Returns the count of cards currently in the stock pile.
|
/// Returns the count of cards currently in the stock pile.
|
||||||
///
|
///
|
||||||
@@ -1484,7 +1531,7 @@ fn spawn_stock_count_badge(
|
|||||||
};
|
};
|
||||||
let text_font = TextFont {
|
let text_font = TextFont {
|
||||||
font: font.cloned().unwrap_or_default(),
|
font: font.cloned().unwrap_or_default(),
|
||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_BODY,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1629,13 +1676,20 @@ fn snap_cards_on_window_resize(
|
|||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
entities: Query<
|
entities: Query<
|
||||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
(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>)>,
|
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<
|
mut pile_markers: Query<
|
||||||
(Entity, &PileMarker, &mut Sprite),
|
(Entity, &PileMarker, &mut Sprite),
|
||||||
(Without<CardEntity>, Without<CardShadow>),
|
(Without<CardEntity>, Without<CardShadow>, Without<CardBackFrame>),
|
||||||
>,
|
>,
|
||||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
) {
|
) {
|
||||||
@@ -1665,6 +1719,7 @@ fn snap_cards_on_window_resize(
|
|||||||
entities,
|
entities,
|
||||||
label_query,
|
label_query,
|
||||||
shadow_query,
|
shadow_query,
|
||||||
|
frame_query,
|
||||||
);
|
);
|
||||||
|
|
||||||
apply_stock_empty_indicator(
|
apply_stock_empty_indicator(
|
||||||
@@ -1691,7 +1746,7 @@ fn snap_cards_on_window_resize(
|
|||||||
///
|
///
|
||||||
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
|
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
|
||||||
/// retargeted relative to the previous card-size's position.
|
/// 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(
|
fn resize_cards_in_place(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
game: &GameState,
|
game: &GameState,
|
||||||
@@ -1699,12 +1754,16 @@ fn resize_cards_in_place(
|
|||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
mut entities: Query<
|
mut entities: Query<
|
||||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
(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 label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||||
mut shadow_query: Query<
|
mut shadow_query: Query<
|
||||||
&mut Sprite,
|
&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);
|
let positions = card_positions(game, layout);
|
||||||
@@ -1756,6 +1815,55 @@ fn resize_cards_in_place(
|
|||||||
font.font_size = new_font_size;
|
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
|
||||||
|
/// grows as the player reveals cards — preventing over-spread early-game
|
||||||
|
/// (fresh deal: max depth = 1, fan_frac = TABLEAU_FAN_FRAC = 0.25) while
|
||||||
|
/// allowing the full window-sized fan late-game (up to 13 face-up cards).
|
||||||
|
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;
|
||||||
|
|
||||||
|
let new_frac = if max_depth <= 1 || card_h <= 0.0 {
|
||||||
|
TABLEAU_FAN_FRAC
|
||||||
|
} else {
|
||||||
|
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 };
|
||||||
|
ideal.clamp(TABLEAU_FAN_FRAC, max_frac.max(TABLEAU_FAN_FRAC))
|
||||||
|
};
|
||||||
|
let new_facedown_frac = new_frac * (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC);
|
||||||
|
|
||||||
|
// Only update the face-up fan. The face-down fan is left at the
|
||||||
|
// window-size-adaptive value from compute_layout so stacked face-down
|
||||||
|
// cards remain visible regardless of how many face-up cards are out.
|
||||||
|
let _ = new_facedown_frac; // computed but unused — leave facedown alone
|
||||||
|
if (layout.0.tableau_fan_frac - new_frac).abs() > 1e-4 {
|
||||||
|
layout.0.tableau_fan_frac = new_frac;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1862,7 +1970,7 @@ mod tests {
|
|||||||
// At game start waste is empty, so all 52 cards are across stock + tableau.
|
// 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 g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||||
let layout =
|
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);
|
let positions = card_positions(&g, &layout);
|
||||||
assert_eq!(positions.len(), 52);
|
assert_eq!(positions.len(), 52);
|
||||||
}
|
}
|
||||||
@@ -1882,7 +1990,7 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(waste_ids.len(), 3);
|
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);
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
// Filter rendered positions to only waste cards (by card ID).
|
// Filter rendered positions to only waste cards (by card ID).
|
||||||
@@ -1911,7 +2019,7 @@ mod tests {
|
|||||||
let waste_ids: std::collections::HashSet<u32> =
|
let waste_ids: std::collections::HashSet<u32> =
|
||||||
waste_pile.iter().map(|c| c.id).collect();
|
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 positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
let mut waste_rendered: Vec<_> = positions
|
let mut waste_rendered: Vec<_> = positions
|
||||||
@@ -1936,7 +2044,7 @@ mod tests {
|
|||||||
fn card_positions_tableau_cards_are_fanned_downward() {
|
fn card_positions_tableau_cards_are_fanned_downward() {
|
||||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||||
let layout =
|
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);
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
// Collect positions for Tableau(6) (should have 7 cards).
|
// Collect positions for Tableau(6) (should have 7 cards).
|
||||||
@@ -2248,7 +2356,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
||||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
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);
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
// Tableau(6) has 7 cards: 6 face-down + 1 face-up on top.
|
// Tableau(6) has 7 cards: 6 face-down + 1 face-up on top.
|
||||||
@@ -2409,7 +2517,7 @@ mod tests {
|
|||||||
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
|
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
|
||||||
// post-resize card width, so the in-place path is using the
|
// post-resize card width, so the in-place path is using the
|
||||||
// refreshed Layout.
|
// 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;
|
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
|
||||||
assert!(
|
assert!(
|
||||||
(after - expected).abs() < 1e-3,
|
(after - expected).abs() < 1e-3,
|
||||||
|
|||||||
@@ -604,7 +604,7 @@ mod tests {
|
|||||||
use crate::layout::compute_layout;
|
use crate::layout::compute_layout;
|
||||||
|
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
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.
|
// A cursor far off-screen should never hit anything.
|
||||||
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
|
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
|
||||||
}
|
}
|
||||||
@@ -624,7 +624,7 @@ mod tests {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins)
|
app.add_plugins(MinimalPlugins)
|
||||||
.insert_resource(GameStateResource(game))
|
.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())
|
.insert_resource(DragState::default())
|
||||||
.add_systems(Update, update_drop_target_overlays);
|
.add_systems(Update, update_drop_target_overlays);
|
||||||
app
|
app
|
||||||
|
|||||||
@@ -250,9 +250,8 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
|||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|line| {
|
.with_children(|line| {
|
||||||
// The hotkey rendered as a small chip with a border —
|
// Keyboard chip — suppressed on Android (no keyboard).
|
||||||
// visual cue that it's a key reference, not part of
|
#[cfg(not(target_os = "android"))]
|
||||||
// the description text.
|
|
||||||
line.spawn((
|
line.spawn((
|
||||||
Node {
|
Node {
|
||||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||||
|
|||||||
@@ -1385,8 +1385,8 @@ fn spawn_mode_card(
|
|||||||
));
|
));
|
||||||
|
|
||||||
if unlocked {
|
if unlocked {
|
||||||
// Hotkey chip — same look as the kbd-chip rows used
|
// Hotkey chip — suppressed on Android (touch builds have no keyboard).
|
||||||
// elsewhere so accelerators read consistently.
|
#[cfg(not(target_os = "android"))]
|
||||||
row.spawn((
|
row.spawn((
|
||||||
Node {
|
Node {
|
||||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! without a separate tick system.
|
//! without a separate tick system.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::card::Suit;
|
use solitaire_core::card::Suit;
|
||||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
@@ -17,6 +18,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
|
|||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::layout::HUD_BAND_HEIGHT;
|
use crate::layout::HUD_BAND_HEIGHT;
|
||||||
|
use crate::safe_area::{SafeAreaAnchoredTop, SafeAreaInsets};
|
||||||
|
use crate::ui_theme::SPACE_2;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
||||||
@@ -273,6 +276,16 @@ pub struct MenuButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct MenuPopover;
|
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
|
/// One row inside the [`MenuPopover`]. The variant selects which
|
||||||
/// `Toggle*RequestEvent` the click handler fires.
|
/// `Toggle*RequestEvent` the click handler fires.
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
@@ -322,11 +335,15 @@ impl Plugin for HudPlugin {
|
|||||||
.add_message::<WinStreakMilestoneEvent>()
|
.add_message::<WinStreakMilestoneEvent>()
|
||||||
.init_resource::<PreviousScore>()
|
.init_resource::<PreviousScore>()
|
||||||
.init_resource::<HudActionFade>()
|
.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(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
||||||
.add_systems(Update, update_hud.after(GameMutation))
|
.add_systems(Update, update_hud.after(GameMutation))
|
||||||
.add_systems(Update, update_won_previously.after(GameMutation))
|
.add_systems(Update, update_won_previously.after(GameMutation))
|
||||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||||
.add_systems(Update, update_selection_hud)
|
.add_systems(Update, update_selection_hud)
|
||||||
|
.add_systems(Update, update_hud_typography)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -352,8 +369,10 @@ impl Plugin for HudPlugin {
|
|||||||
handle_help_button,
|
handle_help_button,
|
||||||
handle_modes_button,
|
handle_modes_button,
|
||||||
handle_mode_option_click,
|
handle_mode_option_click,
|
||||||
|
handle_modes_backdrop_click,
|
||||||
handle_menu_button,
|
handle_menu_button,
|
||||||
handle_menu_option_click,
|
handle_menu_option_click,
|
||||||
|
handle_menu_backdrop_click,
|
||||||
paint_action_buttons,
|
paint_action_buttons,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -376,11 +395,13 @@ impl Plugin for HudPlugin {
|
|||||||
/// bottom edge lines up exactly with the top edge of the highest
|
/// 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
|
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
|
||||||
/// alpha, so the green felt reads through subtly.
|
/// 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((
|
commands.spawn((
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
top: Val::Px(0.0),
|
top: Val::Px(BASE_TOP + top_inset),
|
||||||
left: Val::Px(0.0),
|
left: Val::Px(0.0),
|
||||||
width: Val::Percent(100.0),
|
width: Val::Percent(100.0),
|
||||||
height: Val::Px(HUD_BAND_HEIGHT),
|
height: Val::Px(HUD_BAND_HEIGHT),
|
||||||
@@ -391,6 +412,7 @@ fn spawn_hud_band(mut commands: Commands) {
|
|||||||
// paint on top, but above the card sprites (which are 2D-world
|
// paint on top, but above the card sprites (which are 2D-world
|
||||||
// entities and rendered behind UI regardless).
|
// entities and rendered behind UI regardless).
|
||||||
ZIndex(Z_HUD - 1),
|
ZIndex(Z_HUD - 1),
|
||||||
|
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +435,12 @@ fn spawn_hud_band(mut commands: Commands) {
|
|||||||
/// player's #1 complaint. This restructure groups by purpose, lets
|
/// player's #1 complaint. This restructure groups by purpose, lets
|
||||||
/// transient items disappear cleanly, and uses the typography scale to
|
/// transient items disappear cleanly, and uses the typography scale to
|
||||||
/// make Score the visual protagonist.
|
/// 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_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
let font_score = TextFont {
|
let font_score = TextFont {
|
||||||
font: font_handle.clone(),
|
font: font_handle.clone(),
|
||||||
@@ -434,6 +461,16 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
|||||||
let row_node = || Node {
|
let row_node = || Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
column_gap: VAL_SPACE_3,
|
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,
|
align_items: AlignItems::Baseline,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
@@ -443,12 +480,21 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
|||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: VAL_SPACE_3,
|
left: VAL_SPACE_3,
|
||||||
top: VAL_SPACE_2,
|
top: Val::Px(SPACE_2 + top_inset),
|
||||||
flex_direction: FlexDirection::Column,
|
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,
|
row_gap: VAL_SPACE_1,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
ZIndex(Z_HUD),
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||||
))
|
))
|
||||||
.with_children(|hud| {
|
.with_children(|hud| {
|
||||||
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
||||||
@@ -568,7 +614,12 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
|||||||
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
|
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
|
||||||
/// because it's the most consequential action; the destructive button sits
|
/// because it's the most consequential action; the destructive button sits
|
||||||
/// on its own visual edge.
|
/// 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;
|
||||||
let font = TextFont {
|
let font = TextFont {
|
||||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
// TYPE_BODY (14.0) — was a hardcoded `16.0` until the
|
// TYPE_BODY (14.0) — was a hardcoded `16.0` until the
|
||||||
@@ -585,13 +636,26 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
|
|||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
right: VAL_SPACE_3,
|
right: VAL_SPACE_3,
|
||||||
top: VAL_SPACE_2,
|
top: Val::Px(SPACE_2 + top_inset),
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
|
// 6 buttons total ~510 px wide; on a desktop window
|
||||||
|
// (typically >= 1280 px) `max_width: 65%` is >= 832 px
|
||||||
|
// and the row stays a single line. On a 411 dp phone
|
||||||
|
// 65% is 267 px; the 6 buttons wrap to 2 lines instead
|
||||||
|
// of 3, reclaiming one row of vertical HUD space.
|
||||||
|
max_width: Val::Percent(65.0),
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
// When the row wraps, buttons pack to the *end* of each
|
||||||
|
// line so the row stays visually right-aligned (matches
|
||||||
|
// the `right: VAL_SPACE_3` anchor).
|
||||||
|
justify_content: JustifyContent::FlexEnd,
|
||||||
column_gap: VAL_SPACE_2,
|
column_gap: VAL_SPACE_2,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
ZIndex(Z_HUD),
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||||
))
|
))
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
// Menu and Modes don't have a single hotkey accelerator
|
// Menu and Modes don't have a single hotkey accelerator
|
||||||
@@ -681,6 +745,14 @@ fn spawn_action_button<M: Component>(
|
|||||||
font: &TextFont,
|
font: &TextFont,
|
||||||
order: i32,
|
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 {
|
let hotkey_font = TextFont {
|
||||||
font: font.font.clone(),
|
font: font.font.clone(),
|
||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
@@ -707,6 +779,14 @@ fn spawn_action_button<M: Component>(
|
|||||||
// companion commit). Vertical padding stays at VAL_SPACE_2
|
// companion commit). Vertical padding stays at VAL_SPACE_2
|
||||||
// so button height tracks the rest of the chrome band.
|
// so button height tracks the rest of the chrome band.
|
||||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||||
|
// 48 px floors meet Material's recommended thumb-target
|
||||||
|
// size on touch and are a no-op on desktop for buttons
|
||||||
|
// whose content already exceeds 48 px in either axis
|
||||||
|
// (Menu, Modes, New Game, etc.). Without these, "Undo"
|
||||||
|
// ends up ~46 × 33 px — comfortably tappable with a mouse
|
||||||
|
// but right at the threshold for a finger.
|
||||||
|
min_width: Val::Px(48.0),
|
||||||
|
min_height: Val::Px(48.0),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
@@ -783,6 +863,7 @@ fn handle_help_button(
|
|||||||
fn handle_modes_button(
|
fn handle_modes_button(
|
||||||
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
|
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
|
||||||
popovers: Query<Entity, With<ModesPopover>>,
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
daily: Option<Res<DailyChallengeResource>>,
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
@@ -796,6 +877,9 @@ fn handle_modes_button(
|
|||||||
}
|
}
|
||||||
if let Ok(entity) = popovers.single() {
|
if let Ok(entity) = popovers.single() {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
spawn_modes_popover(
|
spawn_modes_popover(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
@@ -896,6 +980,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,
|
/// Dispatches the click on a popover row to the matching request event,
|
||||||
@@ -909,6 +1010,7 @@ fn spawn_modes_popover(
|
|||||||
fn handle_mode_option_click(
|
fn handle_mode_option_click(
|
||||||
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
|
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
|
||||||
popovers: Query<Entity, With<ModesPopover>>,
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
mut zen: MessageWriter<StartZenRequestEvent>,
|
mut zen: MessageWriter<StartZenRequestEvent>,
|
||||||
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||||
@@ -941,8 +1043,12 @@ fn handle_mode_option_click(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if clicked_any
|
if clicked_any
|
||||||
&& let Ok(entity) = popovers.single() {
|
&& let Ok(entity) = popovers.single()
|
||||||
|
{
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,6 +1058,7 @@ fn handle_mode_option_click(
|
|||||||
fn handle_menu_button(
|
fn handle_menu_button(
|
||||||
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
|
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
|
||||||
popovers: Query<Entity, With<MenuPopover>>,
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
@@ -963,6 +1070,9 @@ fn handle_menu_button(
|
|||||||
}
|
}
|
||||||
if let Ok(entity) = popovers.single() {
|
if let Ok(entity) = popovers.single() {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
spawn_menu_popover(&mut commands, font_res.as_deref());
|
spawn_menu_popover(&mut commands, font_res.as_deref());
|
||||||
}
|
}
|
||||||
@@ -1050,6 +1160,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,
|
/// Dispatches the click on a menu row to the matching toggle event,
|
||||||
@@ -1058,6 +1185,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
fn handle_menu_option_click(
|
fn handle_menu_option_click(
|
||||||
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
|
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
|
||||||
popovers: Query<Entity, With<MenuPopover>>,
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
mut stats: MessageWriter<ToggleStatsRequestEvent>,
|
mut stats: MessageWriter<ToggleStatsRequestEvent>,
|
||||||
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
|
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
|
||||||
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||||
@@ -1092,6 +1220,43 @@ fn handle_menu_option_click(
|
|||||||
if clicked_any
|
if clicked_any
|
||||||
&& let Ok(entity) = popovers.single() {
|
&& let Ok(entity) = popovers.single() {
|
||||||
commands.entity(entity).despawn();
|
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 +2103,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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -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::tuning::AnimationTuning;
|
||||||
use crate::card_animation::{CardAnimation, MotionCurve};
|
use crate::card_animation::{CardAnimation, MotionCurve};
|
||||||
use crate::card_plugin::{
|
use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC};
|
||||||
CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC,
|
use crate::radial_menu::RightClickRadialState;
|
||||||
TABLEAU_FAN_FRAC,
|
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
|
||||||
};
|
|
||||||
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_WARNING};
|
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::events::{
|
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
|
/// 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`]
|
/// dragged. Cards are **not** elevated yet — that happens in [`follow_drag`]
|
||||||
/// once the drag threshold is crossed.
|
/// once the drag threshold is crossed.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn start_drag(
|
fn start_drag(
|
||||||
buttons: Res<ButtonInput<MouseButton>>,
|
buttons: Res<ButtonInput<MouseButton>>,
|
||||||
|
touches: Option<Res<Touches>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
windows: Query<&Window, With<PrimaryWindow>>,
|
windows: Query<&Window, With<PrimaryWindow>>,
|
||||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||||
@@ -538,6 +538,15 @@ fn start_drag(
|
|||||||
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
|
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
|
||||||
return;
|
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(layout) = layout else { return };
|
||||||
let Some(world) = cursor_world(&windows, &cameras) else { return };
|
let Some(world) = cursor_world(&windows, &cameras) else { return };
|
||||||
|
|
||||||
@@ -614,7 +623,7 @@ fn follow_drag(
|
|||||||
|
|
||||||
// Move cards to the cursor.
|
// Move cards to the cursor.
|
||||||
let bottom_pos = world + drag.cursor_offset;
|
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() {
|
for (i, &id) in drag.cards.iter().enumerate() {
|
||||||
if let Some((_, mut transform, _)) =
|
if let Some((_, mut transform, _)) =
|
||||||
@@ -875,7 +884,7 @@ fn touch_follow_drag(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let bottom_pos = world + drag.cursor_offset;
|
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() {
|
for (i, &id) in drag.cards.iter().enumerate() {
|
||||||
if let Some((_, mut transform, _)) =
|
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.
|
/// 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
|
/// 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`,
|
/// every preceding card — face-down cards step by `layout.tableau_facedown_fan_frac`,
|
||||||
/// face-up cards by `TABLEAU_FAN_FRAC`. Mirrors `card_plugin::card_positions`
|
/// 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
|
/// exactly; any drift creates an offset between the visible card face and
|
||||||
/// where clicks land.
|
/// where clicks land.
|
||||||
fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
|
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) {
|
if let Some(pile_cards) = game.piles.get(pile) {
|
||||||
for card in pile_cards.cards.iter().take(stack_index) {
|
for card in pile_cards.cards.iter().take(stack_index) {
|
||||||
let step = if card.face_up {
|
let step = if card.face_up {
|
||||||
TABLEAU_FAN_FRAC
|
layout.tableau_fan_frac
|
||||||
} else {
|
} else {
|
||||||
TABLEAU_FACEDOWN_FAN_FRAC
|
layout.tableau_facedown_fan_frac
|
||||||
};
|
};
|
||||||
y_offset -= layout.card_size.y * step;
|
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(_)) {
|
if matches!(pile, PileType::Tableau(_)) {
|
||||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||||
if card_count > 1 {
|
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 bottom_card_center_y = center.y + fan * (card_count - 1) as f32;
|
||||||
let top_edge = center.y + layout.card_size.y / 2.0;
|
let top_edge = center.y + layout.card_size.y / 2.0;
|
||||||
let bottom_edge = bottom_card_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.
|
/// Maximum seconds between two clicks to count as a double-click.
|
||||||
const DOUBLE_CLICK_WINDOW: f32 = 0.35;
|
const DOUBLE_CLICK_WINDOW: f32 = 0.35;
|
||||||
|
|
||||||
/// Maximum seconds between two taps to count as a double-tap.
|
/// Duration of the lime flash applied to moved cards when a tap
|
||||||
/// Slightly wider than the mouse window — touch screens have higher latency.
|
/// auto-move succeeds. Short enough not to linger, long enough to register
|
||||||
const DOUBLE_TAP_WINDOW: f32 = 0.5;
|
/// 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.
|
/// 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`
|
/// Fires `MoveRequestEvent` when the player taps a face-up card without
|
||||||
/// to the best legal destination — the touch equivalent of [`handle_double_click`].
|
/// dragging — the touch equivalent of the mouse auto-move flow.
|
||||||
///
|
///
|
||||||
/// Must run **before** `touch_end_drag` in the system chain. At
|
/// Must run **before** `touch_end_drag` in the system chain. At
|
||||||
/// `TouchPhase::Ended` the drag state still holds `active_touch_id`,
|
/// `TouchPhase::Ended` the drag state still holds `active_touch_id`,
|
||||||
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
|
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
|
||||||
/// are cleared and the tap/drag distinction is permanently lost.
|
/// are cleared and the tap/drag distinction is permanently lost.
|
||||||
///
|
///
|
||||||
/// A pure tap is identified by `drag.active_touch_id.is_some() &&
|
/// Move priority:
|
||||||
/// !drag.committed`: the touch began (so `touch_start_drag` populated
|
/// 1. Single top card to its best foundation (or tableau).
|
||||||
/// `drag`) but the drag threshold was never crossed.
|
/// 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.
|
||||||
/// 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.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_double_tap(
|
fn handle_double_tap(
|
||||||
mut touch_events: MessageReader<TouchInput>,
|
mut touch_events: MessageReader<TouchInput>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
time: Res<Time>,
|
radial: Option<Res<RightClickRadialState>>,
|
||||||
drag: Res<DragState>,
|
drag: Res<DragState>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut last_tap: Local<HashMap<u32, f32>>,
|
|
||||||
mut moves: MessageWriter<MoveRequestEvent>,
|
mut moves: MessageWriter<MoveRequestEvent>,
|
||||||
mut rejected: MessageWriter<MoveRejectedEvent>,
|
mut rejected: MessageWriter<MoveRejectedEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
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 };
|
let Some(active_id) = drag.active_touch_id else { return };
|
||||||
if drag.committed {
|
if drag.committed {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for event in touch_events.read() {
|
for event in touch_events.read() {
|
||||||
if event.id != active_id {
|
if event.id != active_id || event.phase != TouchPhase::Ended {
|
||||||
continue;
|
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.
|
// Uncommitted touch ended = pure tap.
|
||||||
let Some(&top_card_id) = drag.cards.last() else { return };
|
let Some(&top_card_id) = drag.cards.last() else { return };
|
||||||
@@ -1445,14 +1443,15 @@ fn handle_double_tap(
|
|||||||
return;
|
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.
|
// Priority 1: move single top card.
|
||||||
if let Some(dest) = best_destination(top_card, &game.0) {
|
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 {
|
moves.write(MoveRequestEvent {
|
||||||
from: pile.clone(),
|
from: pile.clone(),
|
||||||
to: dest,
|
to: dest,
|
||||||
@@ -1472,6 +1471,12 @@ fn handle_double_tap(
|
|||||||
drag.cards.len(),
|
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 {
|
moves.write(MoveRequestEvent {
|
||||||
from: pile.clone(),
|
from: pile.clone(),
|
||||||
to: dest,
|
to: dest,
|
||||||
@@ -1486,9 +1491,6 @@ fn handle_double_tap(
|
|||||||
to: pile.clone(),
|
to: pile.clone(),
|
||||||
count: drag.cards.len(),
|
count: drag.cards.len(),
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
last_tap.insert(top_card_id, now);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1630,7 +1632,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn find_draggable_picks_top_of_tableau() {
|
fn find_draggable_picks_top_of_tableau() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
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.
|
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||||
// Its position: base.y + fan * 6.
|
// Its position: base.y + fan * 6.
|
||||||
@@ -1644,7 +1646,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn find_draggable_skips_face_down_cards() {
|
fn find_draggable_skips_face_down_cards() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
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
|
// 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
|
// 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
|
// face-up bottom card, clicking the visible card face missed the
|
||||||
// hit-test box and only the bottom strip of the card responded.
|
// hit-test box and only the bottom strip of the card responded.
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
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
|
// 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
|
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||||
@@ -1704,7 +1706,7 @@ mod tests {
|
|||||||
face_up: true,
|
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
|
// 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
|
// (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
|
// 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,
|
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
|
// Both cards in waste sit at the same (x, y). Clicking should pick
|
||||||
// the visually top card (id 201), with count = 1.
|
// the visually top card (id 201), with count = 1.
|
||||||
let pos = card_position(&game, &layout, &PileType::Waste, 0);
|
let pos = card_position(&game, &layout, &PileType::Waste, 0);
|
||||||
@@ -1749,7 +1751,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
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.
|
// Move all cards out of tableau 0 so its marker is the only drop area.
|
||||||
let mut game = game;
|
let mut game = game;
|
||||||
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
|
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
|
||||||
@@ -1761,7 +1763,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn find_drop_target_returns_none_for_origin() {
|
fn find_drop_target_returns_none_for_origin() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
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 pos = layout.pile_positions[&PileType::Tableau(3)];
|
||||||
let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3));
|
let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3));
|
||||||
assert_eq!(target, None);
|
assert_eq!(target, None);
|
||||||
@@ -1770,7 +1772,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
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.
|
// Tableau 6 has 7 cards.
|
||||||
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
|
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
|
||||||
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so
|
// 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: 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 });
|
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];
|
let waste_base = layout.pile_positions[&PileType::Waste];
|
||||||
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
|
// 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;
|
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
|
||||||
@@ -1811,7 +1813,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
||||||
let mut game = GameState::new(42, DrawMode::DrawOne);
|
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.
|
// Clear tableau 0 so it's an empty slot.
|
||||||
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
|
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
|
||||||
let pos = layout.pile_positions[&PileType::Tableau(0)];
|
let pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||||
@@ -1822,7 +1824,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
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 [
|
for pile in [
|
||||||
PileType::Waste,
|
PileType::Waste,
|
||||||
PileType::Foundation(2),
|
PileType::Foundation(2),
|
||||||
@@ -2323,7 +2325,7 @@ mod tests {
|
|||||||
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.insert_resource(crate::layout::LayoutResource(
|
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.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
|
||||||
app.add_systems(Update, handle_keyboard_hint);
|
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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+253
-27
@@ -21,9 +21,25 @@ pub enum LayoutSystem {
|
|||||||
UpdateOnResize,
|
UpdateOnResize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimum supported window dimensions. Layout is still computed below this
|
/// Minimum window dimensions used as a layout floor.
|
||||||
/// size but cards will be small.
|
///
|
||||||
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
|
/// `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.
|
/// Aspect ratio (height / width) of a standard playing card.
|
||||||
///
|
///
|
||||||
@@ -36,11 +52,17 @@ const CARD_ASPECT: f32 = 1.4523;
|
|||||||
/// the tableau row.
|
/// the tableau row.
|
||||||
const VERTICAL_GAP_FRAC: f32 = 0.2;
|
const VERTICAL_GAP_FRAC: f32 = 0.2;
|
||||||
|
|
||||||
/// Fraction of card height contributed by each additional face-up tableau card
|
/// Minimum fraction of card height used as vertical offset between face-up
|
||||||
/// when fanned. Mirrors `card_plugin::TABLEAU_FAN_FRAC` so layout sizing can
|
/// tableau cards. Used for the height-based sizing candidate (worst-case
|
||||||
/// solve for a worst-case column without depending on `card_plugin`.
|
/// 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;
|
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.
|
||||||
|
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
|
||||||
|
|
||||||
/// Largest possible face-up tableau column in Klondike: a King down to an Ace
|
/// 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
|
/// after every face-down card has flipped on column 7. Layout sizing must keep
|
||||||
/// this column inside the visible window.
|
/// this column inside the visible window.
|
||||||
@@ -72,9 +94,33 @@ pub struct Layout {
|
|||||||
/// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
|
/// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
|
||||||
/// entry. The map always contains exactly 13 entries after `compute_layout`.
|
/// entry. The map always contains exactly 13 entries after `compute_layout`.
|
||||||
pub pile_positions: HashMap<PileType, Vec2>,
|
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
|
/// # Geometry
|
||||||
/// - `card_width` is the smaller of:
|
/// - `card_width` is the smaller of:
|
||||||
@@ -90,7 +136,7 @@ pub struct Layout {
|
|||||||
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
|
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
|
||||||
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
|
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
|
||||||
/// waste/stock cluster from the foundations.
|
/// 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);
|
let window = window.max(MIN_WINDOW);
|
||||||
|
|
||||||
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
|
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
|
||||||
@@ -113,7 +159,7 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
|||||||
// (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
|
// (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 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 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_width = card_width_width_based.min(card_width_height_based);
|
||||||
let card_height = card_width * CARD_ASPECT;
|
let card_height = card_width * CARD_ASPECT;
|
||||||
@@ -133,7 +179,7 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let vertical_gap = card_height * VERTICAL_GAP_FRAC;
|
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 tableau_y = top_y - card_height - vertical_gap;
|
||||||
|
|
||||||
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
|
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
|
||||||
@@ -153,9 +199,36 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
|||||||
pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y));
|
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 {
|
Layout {
|
||||||
card_size,
|
card_size,
|
||||||
pile_positions,
|
pile_positions,
|
||||||
|
tableau_fan_frac,
|
||||||
|
tableau_facedown_fan_frac,
|
||||||
|
available_tableau_height: avail,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,15 +260,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn layout_has_all_thirteen_piles() {
|
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(1280.0, 800.0), 0.0, 0.0));
|
||||||
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.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)));
|
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn card_size_scales_with_window_width() {
|
fn card_size_scales_with_window_width() {
|
||||||
let small = compute_layout(Vec2::new(800.0, 600.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));
|
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.x > small.card_size.x);
|
||||||
assert!(
|
assert!(
|
||||||
(large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5,
|
(large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5,
|
||||||
@@ -205,14 +278,42 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn layout_below_minimum_clamps_to_minimum() {
|
fn layout_below_minimum_clamps_to_minimum() {
|
||||||
let below = compute_layout(Vec2::new(400.0, 300.0));
|
// 200×200 sits below the floor on both axes, so the clamp pulls each
|
||||||
let at_min = compute_layout(MIN_WINDOW);
|
// 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);
|
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]
|
#[test]
|
||||||
fn tableau_columns_are_sorted_left_to_right() {
|
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 {
|
for i in 0..6 {
|
||||||
let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
|
let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
|
||||||
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
|
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
|
||||||
@@ -222,7 +323,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn top_row_is_above_tableau_row() {
|
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 stock_y = layout.pile_positions[&PileType::Stock].y;
|
||||||
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
|
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
|
||||||
assert!(stock_y > tableau_y);
|
assert!(stock_y > tableau_y);
|
||||||
@@ -235,7 +336,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn top_row_clears_hud_band() {
|
fn top_row_clears_hud_band() {
|
||||||
let window = Vec2::new(1280.0, 800.0);
|
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 stock_y = layout.pile_positions[&PileType::Stock].y;
|
||||||
let card_top = stock_y + layout.card_size.y / 2.0;
|
let card_top = stock_y + layout.card_size.y / 2.0;
|
||||||
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
|
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
|
||||||
@@ -247,7 +348,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
|
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 stock_x = layout.pile_positions[&PileType::Stock].x;
|
||||||
let waste_x = layout.pile_positions[&PileType::Waste].x;
|
let waste_x = layout.pile_positions[&PileType::Waste].x;
|
||||||
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
|
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
|
||||||
@@ -258,7 +359,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn foundations_align_with_tableau_cols_3_to_6() {
|
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 {
|
for slot in 0..4_u8 {
|
||||||
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
|
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
|
||||||
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
|
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
|
||||||
@@ -277,7 +378,7 @@ mod tests {
|
|||||||
// keep a worst-case 13-card column inside the window. (Most desktop
|
// keep a worst-case 13-card column inside the window. (Most desktop
|
||||||
// monitors fall into this regime — e.g. 1280x800, 1920x1080.)
|
// monitors fall into this regime — e.g. 1280x800, 1920x1080.)
|
||||||
let window = Vec2::new(2560.0, 1080.0);
|
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;
|
let width_based = window.x / 9.0;
|
||||||
assert!(
|
assert!(
|
||||||
layout.card_size.x < width_based,
|
layout.card_size.x < width_based,
|
||||||
@@ -293,7 +394,7 @@ mod tests {
|
|||||||
// the bottleneck and card_width matches the legacy window.x / 9
|
// the bottleneck and card_width matches the legacy window.x / 9
|
||||||
// derivation exactly.
|
// derivation exactly.
|
||||||
let window = Vec2::new(900.0, 1600.0);
|
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;
|
let width_based = window.x / 9.0;
|
||||||
assert!(
|
assert!(
|
||||||
(layout.card_size.x - width_based).abs() < 1e-3,
|
(layout.card_size.x - width_based).abs() < 1e-3,
|
||||||
@@ -307,7 +408,7 @@ mod tests {
|
|||||||
fn worst_case_tableau_fits_vertically_on_default_resolution() {
|
fn worst_case_tableau_fits_vertically_on_default_resolution() {
|
||||||
// Default app resolution (see solitaire_app/src/main.rs).
|
// Default app resolution (see solitaire_app/src/main.rs).
|
||||||
let window = Vec2::new(1280.0, 800.0);
|
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 tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
|
||||||
let card_h = layout.card_size.y;
|
let card_h = layout.card_size.y;
|
||||||
// Bottom edge of the 13th fanned face-up card.
|
// Bottom edge of the 13th fanned face-up card.
|
||||||
@@ -326,7 +427,7 @@ mod tests {
|
|||||||
fn worst_case_tableau_fits_vertically_on_full_hd() {
|
fn worst_case_tableau_fits_vertically_on_full_hd() {
|
||||||
// The bug originally reproduced at 1920x1080. Lock in a regression test.
|
// The bug originally reproduced at 1920x1080. Lock in a regression test.
|
||||||
let window = Vec2::new(1920.0, 1080.0);
|
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 tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
|
||||||
let card_h = layout.card_size.y;
|
let card_h = layout.card_size.y;
|
||||||
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
|
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
|
||||||
@@ -338,6 +439,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]
|
#[test]
|
||||||
fn all_piles_fit_inside_window_horizontally() {
|
fn all_piles_fit_inside_window_horizontally() {
|
||||||
for window in [
|
for window in [
|
||||||
@@ -345,7 +490,7 @@ mod tests {
|
|||||||
Vec2::new(1280.0, 800.0),
|
Vec2::new(1280.0, 800.0),
|
||||||
Vec2::new(1920.0, 1080.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_w = window.x / 2.0;
|
||||||
let half_card = layout.card_size.x / 2.0;
|
let half_card = layout.card_size.x / 2.0;
|
||||||
for (pile, pos) in &layout.pile_positions {
|
for (pile, pos) in &layout.pile_positions {
|
||||||
@@ -364,4 +509,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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ pub mod replay_playback;
|
|||||||
pub mod settings_plugin;
|
pub mod settings_plugin;
|
||||||
pub mod progress_plugin;
|
pub mod progress_plugin;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
|
pub mod safe_area;
|
||||||
pub mod selection_plugin;
|
pub mod selection_plugin;
|
||||||
pub mod splash_plugin;
|
pub mod splash_plugin;
|
||||||
pub mod stats_plugin;
|
pub mod stats_plugin;
|
||||||
@@ -138,6 +139,7 @@ pub use settings_plugin::{
|
|||||||
};
|
};
|
||||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||||
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
|
pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
|
||||||
pub use selection_plugin::{
|
pub use selection_plugin::{
|
||||||
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
//! real `PrimaryWindow` / camera, since `MinimalPlugins` provides
|
//! real `PrimaryWindow` / camera, since `MinimalPlugins` provides
|
||||||
//! neither.
|
//! neither.
|
||||||
|
|
||||||
|
use bevy::input::touch::Touches;
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::math::Vec2;
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
@@ -59,6 +60,11 @@ use crate::resources::{DragState, GameStateResource};
|
|||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS};
|
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.
|
/// 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
|
/// 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,
|
Update,
|
||||||
(
|
(
|
||||||
radial_open_on_right_click,
|
radial_open_on_right_click,
|
||||||
|
radial_open_on_long_press,
|
||||||
radial_track_cursor,
|
radial_track_cursor,
|
||||||
radial_handle_release_or_cancel,
|
radial_handle_release_or_cancel,
|
||||||
radial_redraw_overlay,
|
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
|
/// Each frame while `Active`, updates `hovered_index` based on the
|
||||||
/// current cursor position. Cheap — just re-runs hit-testing against
|
/// current cursor position. Cheap — just re-runs hit-testing against
|
||||||
/// the precomputed anchors. The overlay redraw system reads this index
|
/// the precomputed anchors. The overlay redraw system reads this index
|
||||||
@@ -454,6 +523,7 @@ fn radial_track_cursor(
|
|||||||
cursor_override: Option<Res<RadialCursorOverride>>,
|
cursor_override: Option<Res<RadialCursorOverride>>,
|
||||||
windows: Query<&Window, With<PrimaryWindow>>,
|
windows: Query<&Window, With<PrimaryWindow>>,
|
||||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||||
|
touches: Option<Res<Touches>>,
|
||||||
mut state: ResMut<RightClickRadialState>,
|
mut state: ResMut<RightClickRadialState>,
|
||||||
) {
|
) {
|
||||||
let RightClickRadialState::Active {
|
let RightClickRadialState::Active {
|
||||||
@@ -464,21 +534,28 @@ fn radial_track_cursor(
|
|||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(world) = cursor_world(cursor_override.as_ref(), &windows, &cameras) else {
|
// Cursor first (mouse / test override); fall back to first active touch
|
||||||
return;
|
// 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();
|
let anchors: Vec<Vec2> = legal_destinations.iter().map(|(_, a)| *a).collect();
|
||||||
*hovered_index = radial_hovered_index(world, &anchors);
|
*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.
|
/// 1. Right-mouse release → confirm if hovering, otherwise cancel.
|
||||||
/// 2. `Escape` → cancel.
|
/// 2. Touch lift (`Touches::iter_just_released`) → confirm if hovering, cancel otherwise.
|
||||||
/// 3. Left-mouse press → cancel (keeps the existing drag pipeline clean).
|
/// 3. `Escape` → cancel.
|
||||||
|
/// 4. Left-mouse press → cancel (keeps the existing drag pipeline clean).
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn radial_handle_release_or_cancel(
|
fn radial_handle_release_or_cancel(
|
||||||
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
||||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
|
touches: Option<Res<Touches>>,
|
||||||
mut state: ResMut<RightClickRadialState>,
|
mut state: ResMut<RightClickRadialState>,
|
||||||
mut moves: MessageWriter<MoveRequestEvent>,
|
mut moves: MessageWriter<MoveRequestEvent>,
|
||||||
) {
|
) {
|
||||||
@@ -495,13 +572,18 @@ fn radial_handle_release_or_cancel(
|
|||||||
let left_pressed = buttons
|
let left_pressed = buttons
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|b| b.just_pressed(MouseButton::Left));
|
.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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On confirm, fire a MoveRequestEvent. On any other exit, just clear.
|
// On confirm (right-release or touch-lift while hovering), fire a move.
|
||||||
if right_released
|
let confirm = right_released || touch_ended;
|
||||||
|
if confirm
|
||||||
&& let RightClickRadialState::Active {
|
&& let RightClickRadialState::Active {
|
||||||
source_pile,
|
source_pile,
|
||||||
count,
|
count,
|
||||||
@@ -719,7 +801,7 @@ mod tests {
|
|||||||
|
|
||||||
fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) {
|
fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) {
|
||||||
app.insert_resource(GameStateResource(state));
|
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);
|
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() {
|
fn right_click_press_on_face_up_card_opens_radial() {
|
||||||
let mut app = radial_test_app();
|
let mut app = radial_test_app();
|
||||||
let layout_window = Vec2::new(1280.0, 800.0);
|
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)];
|
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||||
|
|
||||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
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() {
|
fn right_click_release_over_destination_fires_move_request() {
|
||||||
let mut app = radial_test_app();
|
let mut app = radial_test_app();
|
||||||
let layout_window = Vec2::new(1280.0, 800.0);
|
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)];
|
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||||
|
|
||||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
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() {
|
fn right_click_release_outside_any_destination_cancels() {
|
||||||
let mut app = radial_test_app();
|
let mut app = radial_test_app();
|
||||||
let layout_window = Vec2::new(1280.0, 800.0);
|
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)];
|
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||||
|
|
||||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||||
@@ -934,7 +1016,7 @@ mod tests {
|
|||||||
fn escape_cancels_active_radial() {
|
fn escape_cancels_active_radial() {
|
||||||
let mut app = radial_test_app();
|
let mut app = radial_test_app();
|
||||||
let layout_window = Vec2::new(1280.0, 800.0);
|
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)];
|
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||||
|
|
||||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
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() {
|
fn right_click_on_face_down_card_does_not_open_radial() {
|
||||||
let mut app = radial_test_app();
|
let mut app = radial_test_app();
|
||||||
let layout_window = Vec2::new(1280.0, 800.0);
|
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)];
|
let king_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||||
|
|
||||||
install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
|
install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
|
||||||
|
|||||||
@@ -944,6 +944,7 @@ fn spawn_overlay(
|
|||||||
},
|
},
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
footer.spawn((
|
footer.spawn((
|
||||||
Text::new(keybind_footer_hint_text()),
|
Text::new(keybind_footer_hint_text()),
|
||||||
TextFont {
|
TextFont {
|
||||||
|
|||||||
@@ -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 1–3 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,7 +130,14 @@ fn start_pull(
|
|||||||
) {
|
) {
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
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);
|
task_res.0 = Some(task);
|
||||||
status.0 = SyncStatus::Syncing;
|
status.0 = SyncStatus::Syncing;
|
||||||
@@ -153,7 +160,11 @@ fn handle_manual_sync_request(
|
|||||||
}
|
}
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
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);
|
task_res.0 = Some(task);
|
||||||
status.0 = SyncStatus::Syncing;
|
status.0 = SyncStatus::Syncing;
|
||||||
@@ -259,11 +270,18 @@ fn push_on_exit(
|
|||||||
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
|
|
||||||
// Prefer an existing tokio runtime; fall back to futures_lite block_on
|
// Prefer an existing tokio runtime; fall back to a temporary one for
|
||||||
// for environments (e.g. tests) that don't have one.
|
// 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() {
|
let result = match tokio::runtime::Handle::try_current() {
|
||||||
Ok(handle) => handle.block_on(provider.push(&payload)),
|
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 {
|
match result {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
@@ -314,8 +332,13 @@ fn push_replay_on_win(
|
|||||||
recording.moves.clone(),
|
recording.moves.clone(),
|
||||||
);
|
);
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
let task = AsyncComputeTaskPool::get()
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
.spawn(async move { provider.push_replay(&replay).await });
|
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
|
// If a previous upload is still in flight, drop it — the most
|
||||||
// recent win is the one whose share link the player will care
|
// recent win is the one whose share link the player will care
|
||||||
// about. Bevy's `Task` Drop cancels cooperatively.
|
// about. Bevy's `Task` Drop cancels cooperatively.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use solitaire_core::pile::PileType;
|
|||||||
|
|
||||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||||
use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem};
|
use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem};
|
||||||
|
use crate::safe_area::SafeAreaInsets;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::layout::TABLE_COLOUR;
|
use crate::layout::TABLE_COLOUR;
|
||||||
@@ -82,6 +83,7 @@ impl Plugin for TablePlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
|
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
|
||||||
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
|
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
|
||||||
apply_theme_on_settings_change,
|
apply_theme_on_settings_change,
|
||||||
apply_hint_pile_highlight,
|
apply_hint_pile_highlight,
|
||||||
@@ -146,6 +148,7 @@ fn setup_table(
|
|||||||
existing_camera: Query<(), With<Camera>>,
|
existing_camera: Query<(), With<Camera>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
bg_images: Option<Res<BackgroundImageSet>>,
|
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
|
// 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).
|
||||||
@@ -153,11 +156,17 @@ fn setup_table(
|
|||||||
commands.spawn(Camera2d);
|
commands.spawn(Camera2d);
|
||||||
}
|
}
|
||||||
|
|
||||||
let window_size = windows
|
let (window_size, scale) = windows.iter().next().map_or(
|
||||||
.iter()
|
(Vec2::new(1280.0, 800.0), 1.0f32),
|
||||||
.next()
|
|w| (default_window_size(w), w.scale_factor()),
|
||||||
.map_or(Vec2::new(1280.0, 800.0), default_window_size);
|
);
|
||||||
let layout = compute_layout(window_size);
|
// 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);
|
let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background);
|
||||||
|
|
||||||
@@ -279,6 +288,8 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
|||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
fn on_window_resized(
|
fn on_window_resized(
|
||||||
mut events: MessageReader<WindowResized>,
|
mut events: MessageReader<WindowResized>,
|
||||||
|
safe_area: Option<Res<SafeAreaInsets>>,
|
||||||
|
windows: Query<&Window>,
|
||||||
mut layout_res: Option<ResMut<LayoutResource>>,
|
mut layout_res: Option<ResMut<LayoutResource>>,
|
||||||
mut backgrounds: Query<
|
mut backgrounds: Query<
|
||||||
(&mut Sprite, &mut Transform),
|
(&mut Sprite, &mut Transform),
|
||||||
@@ -290,7 +301,11 @@ fn on_window_resized(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let window_size = Vec2::new(ev.width, ev.height);
|
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() {
|
if let Some(layout_res) = layout_res.as_deref_mut() {
|
||||||
layout_res.0 = new_layout.clone();
|
layout_res.0 = new_layout.clone();
|
||||||
@@ -318,6 +333,33 @@ fn on_window_resized(
|
|||||||
// and forth" jitter).
|
// 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
|
// Task #6 — Hint pile-marker highlight
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -328,6 +328,8 @@ pub fn spawn_modal_button<M: Component>(
|
|||||||
variant: ButtonVariant,
|
variant: ButtonVariant,
|
||||||
font_res: Option<&FontResource>,
|
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_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
let font_label = TextFont {
|
let font_label = TextFont {
|
||||||
font: font_handle.clone(),
|
font: font_handle.clone(),
|
||||||
|
|||||||
Reference in New Issue
Block a user