Compare commits

...

35 Commits

Author SHA1 Message Date
funman300 783f01628e Merge pull request 'fix(engine): raise dynamic tableau fan cap to fill tall viewports' (#115) from fix/cover-screen-fan-cap into master
Build and Deploy / build-and-push (push) Successful in 2m8s
Web WASM Rebuild / rebuild (push) Successful in 7m24s
Android Release / build-apk (push) Successful in 5m17s
2026-06-26 21:15:53 +00:00
funman300 94a6feb5db fix(engine): raise dynamic tableau fan cap to fill tall viewports
MAX_DYNAMIC_FAN_FRAC 0.6 -> 0.9 so the dynamic tableau fill spreads
further on very tall / narrow viewports (e.g. a foldable cover screen),
which were left ~40% empty at the cap. Fills the unfolded near-square
screen to ~100% and lets the cover screen fill further (and the rest fills
as columns deepen during play). Normal phones are unaffected — their fill
fraction is already below the cap. apply_dynamic_tableau_fan still floors
at TABLEAU_FAN_FRAC and deeper columns drive the fraction down, so nothing
overflows and hit-testing stays in sync.

Vertical centring of the residual was investigated but dropped: a 21:9
phone is aspect-identical to the cover screen, so centring can't be
targeted to foldables without also disconnecting the board from the HUD on
tall phones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:56:31 -07:00
Gitea CI 3daaf47689 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m3s
Web E2E / web-e2e (push) Successful in 4m47s
2026-06-26 20:20:59 +00:00
funman300 c00656ec8f Merge pull request 'docs(changelog): note foldable tableau fill and card-move jank fixes' (#114) from docs/changelog-v0.40.2 into master
Android Release / build-apk (push) Successful in 5m3s
2026-06-26 20:12:47 +00:00
funman300 aea2167eee docs(changelog): note foldable tableau fill and card-move jank fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:12:45 -07:00
funman300 0ed91ea24c Merge pull request 'fix(engine): fill tableau fan to viewport on all aspect ratios' (#113) from fix/foldable-tableau-fan-fill into master
Build and Deploy / build-and-push (push) Successful in 2m3s
Web WASM Rebuild / rebuild (push) Successful in 7m27s
2026-06-26 20:11:33 +00:00
funman300 18af49c0f3 fix(engine): fill tableau fan to viewport on all aspect ratios
On a near-square viewport (e.g. an unfolded Galaxy Fold) a fresh deal left
the bottom ~40% of the screen empty: the dynamic fan (update_tableau_fan_frac)
measured only face-up column depth and returned early at a fresh deal (face-up
depth 1), so it never spread the tableau, and the deep face-down stacks were
ignored. The cold-start deal also never fired StateChangedEvent, and on Android
the safe-area-inset resize (frames 1-3) reset the fan to compute_layout's
sparse worst-case value, so even the post-move fill was wiped.

Move the fill into layout::apply_dynamic_tableau_fan, driven by each column's
TOTAL weighted depth (face-down cards count, scaled by the face-down/face-up
step ratio) so the deepest column fills the available height. Run it in three
places so every path stays filled: PostStartup (cold-start deal), on
StateChangedEvent (moves), and inside on_window_resized after compute_layout
(safe-area resize + fold/unfold). MAX_DYNAMIC_FAN_FRAC caps the spread so a
near-empty column keeps readable overlap; TABLEAU_FAN_FRAC floors it. Deeper
columns drive the fraction down so everything still fits — no overflow.
card_position/card_positions read the same fractions, so hit-testing stays
in sync.

Adds regression tests: cold-start deal fills the fan, and a resize re-fills it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:11:00 -07:00
Gitea CI e208245036 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m48s
Web E2E / web-e2e (push) Successful in 4m47s
2026-06-26 19:11:53 +00:00
funman300 0247efcb07 Merge pull request 'perf(engine): rebuild card children only on appearance change' (#112) from fix/card-move-anim-jank into master
Build and Deploy / build-and-push (push) Successful in 2m1s
Web WASM Rebuild / rebuild (push) Successful in 6m40s
2026-06-26 19:03:19 +00:00
funman300 1d5266a811 perf(engine): rebuild card children only on appearance change
Every move fired StateChangedEvent -> sync_cards, which rebuilt the full
visual for all 52 cards: despawning and respawning every child entity
(drop-shadow, border frame, and on Android a Text2d corner label needing
a glyph re-layout) even for the ~50 cards that did not move. That ~250
entity despawn/spawns plus 52 text re-layouts in a single frame spiked
the StateChangedEvent frame and stuttered the slide animation on
high-resolution devices (reported on a Galaxy Fold 7).

Add a CardChildrenKey component capturing the only inputs the child
entities depend on (face_up, card_size, color_blind, high_contrast).
update_card_entity now rebuilds children only when that key changes (a
flip, resize/fold, or accessibility toggle); a position-only move just
updates the Transform. The Sprite is still refreshed every sync (a cheap
handle swap), so theme/card-back image changes need no child rebuild.

Adds a regression test asserting an appearance-neutral StateChangedEvent
no longer despawns/respawns card label children.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:02:42 -07:00
funman300 26283b5478 Merge pull request 'fix(android): sign release APK v2+v3 only (drop invalid v1 JAR signature)' (#111) from fix/apk-signing-v1-obtainium into master
Android Release / build-apk (push) Successful in 5m15s
2026-06-25 18:16:57 +00:00
funman300 3627e9f9cf fix(android): sign release APK v2+v3 only (drop invalid v1 JAR signature)
The apksigner step relied on auto scheme selection, which produced an APK
carrying invalid v1 (JAR) signature files: META-INF/*.SF and *.RSA were
present but failed v1 verification (apksigner reports `v1 scheme: false`
while v2/v3 verify). Android installs such an APK fine via v2/v3, but
Obtainium parses the legacy v1 certificate at install time, gets an empty
cert list, and crashes with:

  RangeError (length): Invalid value: valid value range is empty: 0

This is why the app adds fine in Obtainium (Gitea API only) but fails on
install (APK parse). minSdk is 26, so v1/JAR signing is unnecessary —
sign explicit v2+v3 only (matching modern Android tooling for minSdk >= 24)
and pass --min-sdk-version 26. Adds a post-sign guard that fails the build
if any META-INF v1 signature files remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:16:38 -07:00
funman300 b81a79c51c Merge pull request 'docs(handoff): mark physical-device smoke test as the only v0.40.0 item left' (#110) from docs/handoff-only-smoke-test into master 2026-06-25 17:48:31 +00:00
funman300 968721eeb4 docs(handoff): mark physical-device smoke test as the only v0.40.0 item left
Elevates the physical-device smoke test as the single remaining task for the
v0.40.0 release and clarifies the Matomo validation is an independent task,
not a release blocker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:48:16 -07:00
funman300 780e82ca4b Merge pull request 'docs(handoff): record v0.40.0 release' (#109) from docs/handoff-v0.40.0 into master 2026-06-25 17:46:06 +00:00
funman300 207747db4b docs(handoff): record v0.40.0 release
Updates SESSION_HANDOFF.md with the v0.40.0 Android release (PRs #105/#106/#108),
the pre-release validation performed, and the still-open physical-device smoke test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:45:49 -07:00
funman300 c66baceb10 Merge pull request 'docs(android): update NDK reference to 30.0.14904198' (#108) from docs/android-ndk-version into master
Android Release / build-apk (push) Successful in 5m39s
2026-06-25 17:37:03 +00:00
funman300 329f224ffd docs(android): update NDK reference to 30.0.14904198
The setup doc pinned NDK 26.3.11579264, but newer NDKs build fine
(verified locally on 30.0.14904198 / build-tools 37.0.0: cross-compile,
android-target clippy, and a full signed arm64-v8a APK). Note that the
exact versions are not load-bearing and build_android_apk.sh
auto-discovers the newest installed NDK/build-tools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:36:48 -07:00
Gitea CI 2fc190ee42 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m22s
Web E2E / web-e2e (push) Successful in 4m39s
2026-06-25 17:20:48 +00:00
funman300 060efaee7b Merge pull request 'docs(changelog): note Draw-Three waste fan hit-test fix' (#107) from docs/changelog-waste-fan into master 2026-06-25 17:13:51 +00:00
funman300 ef599ffa17 docs(changelog): note Draw-Three waste fan hit-test fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:13:39 -07:00
funman300 22334e0dd5 Merge pull request 'fix(engine): share Draw-Three waste fan step between renderer and hit-test' (#106) from fix/draw-three-waste-fan-hittest into master
Build and Deploy / build-and-push (push) Successful in 1m51s
Web WASM Rebuild / rebuild (push) Successful in 7m36s
2026-06-25 17:11:26 +00:00
funman300 942b9c2161 fix(engine): share Draw-Three waste fan step between renderer and hit-test
The waste fan x-offset was computed two ways: the renderer used
tableau_col_step * 0.224 while the hit-test hard-coded card_size.x * 0.28.
These coincide on desktop (col_step = 1.25*cw) but drift on Android, where
tighter column spacing (H_GAP_DIVISOR=32, col_step ~= 1.03*cw) makes the
renderer fan at ~0.231*cw. The top fanned waste card's sprite then sits
~14px left of its click target, so dragging the visible top card grabs
the card beneath it.

Extract waste_fan_step() and tableau_col_step() as the single source for
both the renderer (card_plugin::card_positions) and the hit-test
(input_plugin::card_position) so they can no longer diverge. Add a
regression test that simulates Android-tight spacing and asserts the hit
target tracks the renderer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:10:57 -07:00
funman300 fde863a4e4 Merge pull request 'test(engine): regression tests for waste-card draggability' (#105) from test/waste-draggable-regression into master
Build and Deploy / build-and-push (push) Successful in 2m6s
Web WASM Rebuild / rebuild (push) Successful in 6m42s
2026-06-25 16:58:15 +00:00
funman300 0cf5fc4293 test(engine): regression tests for waste-card draggability
Adds two find_draggable_at tests covering the reported stock/waste
drag bug: clicking the visible top of a multi-card waste must pick the
top index (not the buffer card beneath), and a lone waste card must
still be draggable. Both pass against current logic, pinning the
correct behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:56:56 -07:00
Gitea CI 79ddfbc034 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m3s
Web E2E / web-e2e (push) Successful in 4m1s
2026-06-24 17:40:04 +00:00
funman300 7919365775 Merge pull request 'fix(engine): clicking the waste card no longer draws from stock' (#104) from fix/waste-click-draws into master
Build and Deploy / build-and-push (push) Successful in 1m37s
Web WASM Rebuild / rebuild (push) Successful in 7m7s
2026-06-24 17:31:31 +00:00
funman300 f88b6f61d0 fix(engine): clicking the waste card no longer draws from stock
handle_stock_click (and handle_touch_stock_tap) hit-tested both the face-down
deck AND the waste slot, so a click/tap on the drawn waste card fired
DrawRequestEvent — drawing the next card instead of playing the waste card, and
swallowing the first click of a double-click so auto-move never triggered.

Only the deck draws now. The waste card is left free to play: double-click /
double-tap to auto-move, or drag it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:31:21 -07:00
funman300 189e0afd24 Merge pull request 'fix(e2e): cycle gate resets games in place (no 240 page reloads)' (#103) from fix/cycle-gate-newgame into master
Build and Deploy / build-and-push (push) Successful in 5m22s
Web E2E / web-e2e (push) Successful in 4m36s
2026-06-24 17:07:46 +00:00
funman300 a32d666751 fix(e2e): reset cycle-gate games in place instead of 240 page reloads
The cycle regression gate did a fresh page.goto() for each of 240 games in one
browser context. Around game ~100 the accumulated resources made
page.waitForFunction time out (30s), failing the gate on ~88% of runs — a
long-standing flaky-CI issue, not a product regression (the 18 e2e tests always
pass).

Load the page once and reset each game via a new __FERROUS_DEBUG__.newGame(seed,
drawThree) bridge method (added to game.js — it was already in play.html).
cycle_metrics.js now navigates once, then loops newGame() + runAutoplay with no
per-game reload, so the run stays fast and stable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:07:31 -07:00
funman300 a5902ac0af Merge pull request 'fix(server): scope no-cache to HTML pages (fix web-e2e cycle gate)' (#102) from fix/cache-scope-html-only into master
Build and Deploy / build-and-push (push) Successful in 6m24s
Web E2E / web-e2e (push) Failing after 5m28s
2026-06-24 16:46:36 +00:00
funman300 c3b83f30d1 fix(server): scope Cache-Control no-cache to HTML pages, not static assets
The blanket `no-cache` from the earlier fix (#99) regressed the web-e2e cycle
regression gate: it reloads /play-classic 240 times, and with no-cache the
browser re-validated and recompiled the wasm on every load, blowing past the
30s bridge-ready timeout (green at #98, red from #99 onward).

Scope no-cache to just the `include_str!` HTML routes (which change on every
deploy and have no validators — the actual staleness source). The `/web` +
`/assets` ServeDir keep their default Last-Modified caching, so repeated page
loads reuse the downloaded/compiled wasm and the cycle gate is fast again. HTML
freshness — the fix Rhys needed — is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:46:23 -07:00
Gitea CI a46505fe45 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 5m59s
Web E2E / web-e2e (push) Failing after 4m7s
2026-06-24 16:37:55 +00:00
funman300 1602f1952d Merge pull request 'fix(web): use adapter limits so /play renders at native res (no 2048 cap)' (#101) from test/web-adapter-limits into master
Build and Deploy / build-and-push (push) Successful in 5m38s
Web E2E / web-e2e (push) Failing after 5m44s
Web WASM Rebuild / rebuild (push) Successful in 6m33s
2026-06-24 16:20:07 +00:00
funman300 81893788c1 fix(web): use adapter limits (Functionality) so /play renders at native res
The 2048 surface limit was never wgpu's or the GPU's — it's
downlevel_webgl2_defaults().max_texture_dimension_2d (2048), which
WgpuSettingsPriority::WebGL2 forces. Switch to Functionality: on the WebGL2
(Gl) backend Bevy then adopts the adapter's real limits, which are still
WebGL2-constrained for features/buffers (shaders stay GLES-compatible) but
report the GPU's true max texture dimension (e.g. 16384). The device is
requested with exactly what the adapter offers, so creation can't fail, the
surface is no longer capped, and large viewports (4K) render with no letterbox
and no hardcoded cap.

Removes the resize_constraints cap and the play.html max-width/height caps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:18:07 -07:00
15 changed files with 722 additions and 257 deletions
+17
View File
@@ -37,6 +37,18 @@ project follows [Semantic Versioning](https://semver.org/).
### Fixed
- **Tableau fill on foldables / tall screens.** The tableau fan now spreads from
each column's total depth (face-down cards included) and refills on the
cold-start deal, every move, and on resize (incl. the Android safe-area-inset
resize and fold/unfold), so a near-square viewport such as an unfolded Galaxy
Fold no longer leaves the bottom of the screen empty. The fan spread cap was
raised so very tall / narrow viewports (e.g. a foldable cover screen) fill
further.
- **Card-move animation jank.** A move now rebuilds a card's child visuals only
when its appearance changes (flip / resize / accessibility) instead of
despawning and respawning every card's children each `StateChangedEvent`,
removing the per-move spike that stuttered the slide animation on
high-resolution devices.
- **Android and modal safe-area layout.** Modal cards now center within the
usable area between status and gesture bars, additional modal-spawn guards were
added, and Android build scripts now auto-discover SDK/NDK paths and strip
@@ -47,6 +59,11 @@ project follows [Semantic Versioning](https://semver.org/).
- **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste
clicks, delayed first-run onboarding until splash teardown, and kept dragged
stacks above all piles.
- **Draw-Three waste fan hit testing on Android.** The renderer and the click
hit-test now share a single `waste_fan_step` / `tableau_col_step` source. They
previously diverged under Android's tighter column spacing, shifting the top
fanned waste card's hit target onto the card beneath it, so dragging the visible
card played the wrong one.
- **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface
sizing, WebGL2 shader compatibility, and Firefox boot/render behavior.
- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched
+52 -17
View File
@@ -1,16 +1,40 @@
# Ferrous Solitaire — Session Handoff
**Last updated:** 2026-06-09AVD Android launch smoke passed; physical-device gate remains.
**Last updated:** 2026-06-25v0.40.0 released (Android APK published); physical-device gate remains.
---
## Current state
- **Branch state:** `master` pushed to origin; latest commits are validation runbooks, card-label test coverage, and Android AVD smoke notes.
- **Latest tag:** `v0.39.0`
- **Working tree:** clean. Local `scripts/` helpers are excluded through `.git/info/exclude` and intentionally not committed.
- **Latest verification in this follow-up:** `cargo test -p solitaire_core`; `cargo test -p solitaire_data matomo_client`; `cargo test -p solitaire_engine analytics_plugin`; `cargo test -p solitaire_engine settings_plugin`; `cargo test -p solitaire_engine card_plugin`; `cargo apk build -p solitaire_app --target x86_64-linux-android --lib`; AVD `Pixel_7` install/launch/input smoke.
- **Full previous gate:** Claude reported recent card_game work pushed to origin and `cargo test` / `clippy` gates passing before the changelog follow-up.
- **Branch state:** `master` pushed to origin; latest commits are the Draw-Three waste fan fix, its regression tests, and the NDK doc update (PRs #105#108).
- **Latest tag:** `v0.40.0` (released — signed arm64-v8a APK published to the Gitea release for Obtainium/sideload). `v0.39.1` was the prior published release.
- **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed.
- **Latest verification this session:** `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo build -p solitaire_app`; Android cross-compile + clippy for `aarch64-linux-android` (clean); full local signed arm64-v8a APK via `scripts/build_android_apk.sh`; CI `android-release` for `v0.40.0` completed/success with APK download HTTP 200.
- **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing.
---
## v0.40.0 release (2026-06-25)
Released via tag push → `.gitea/workflows/android-release.yml` built and signed the
arm64-v8a release APK (release keystore, `versionCode 4000` / `versionName 0.40.0`,
29.2 MB) and published it to the Gitea release. Obtainium clients tracking the repo
pick it up automatically.
- Release: https://git.aleshym.co/funman300/Ferrous-Solitaire/releases/tag/v0.40.0
| PR | Summary |
|----|---------|
| #106 | **fix(engine):** Draw-Three waste fan hit-test now shares the renderer's fan step (`card_plugin::waste_fan_step` / `tableau_col_step`). The two had diverged under Android's tighter column spacing (`H_GAP_DIVISOR=32`), shifting the top fanned waste card's click target onto the card beneath it — so dragging the visible top card played the wrong one. Desktop/web were unaffected (the formulas already coincided there). |
| #105 | **test(engine):** waste-card draggability regression tests (`find_draggable_at` picks the waste top with multiple cards and as a lone card). |
| #108 | **docs(android):** NDK reference updated `26.3.11579264``30.0.14904198`; noted versions are not load-bearing and `build_android_apk.sh` auto-discovers the newest NDK/build-tools. |
Pre-release validation performed locally this session: workspace clippy/test/build
gates; `aarch64-linux-android` cross-compile + clippy clean (covers the
`#[cfg(target_os = "android")]` paths that host CI never lints); release manifest
sanity (`solitaire_app/android/AndroidManifest.xml` has no version fields so CI
injection works; `lib_name` matches `[lib].name`); and a full signed local APK
proving the `build_android_apk.sh` packaging pipeline end-to-end.
---
@@ -103,12 +127,23 @@ Three bugs fixed:
## Open punch list
### 1. Android APK launch verification (Option A)
### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM
Physical device test: install the latest APK on a real Android device (not AVD),
and run the checklist in `docs/ANDROID.md`. This has never been gated in CI.
AVD `adb shell input tap` doesn't deliver real touch events, so physical-device
smoke testing is the only gate.
This is the **single outstanding task** for the v0.40.0 Android release. Everything
else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
clippy, release manifest sanity, a full local signed APK, the published release, and
Obtainium-facing delivery (public releases API, latest non-draft release, APK
downloadable anonymously). The only thing that cannot be done without hardware is
running the app on a real phone.
Install the published APK on a real Android device (not AVD) and run the checklist
in `docs/ANDROID.md §4`. This has never been gated in CI — AVD `adb shell input tap`
doesn't deliver real touch events, so physical-device smoke testing is the only gate.
The signed release APK is published (grab it from the release page, or use the local
`target/debug/apk/ferrous-solitaire.apk`). When testing, specifically exercise the
Draw-Three waste fan fixed in #106: switch to Draw-Three, draw several cards, and
confirm dragging the visible top waste card plays *that* card, not the one beneath it.
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
@@ -117,13 +152,13 @@ rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
filtered logcat showed no Ferrous panic/fatal/ANR.
### 2. Matomo analytics live validation
### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker)
`Settings` has `analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine
consumes them via `AnalyticsPlugin` on non-wasm targets. Remaining work is live
validation against the deployed Matomo instance. Use
`docs/analytics-validation.md` for the native validation checklist and the
current web/WASM decision notes.
Separate, ongoing task unrelated to the Android release. `Settings` has
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
`AnalyticsPlugin` on non-wasm targets. Remaining work is live validation against the
deployed Matomo instance. Use `docs/analytics-validation.md` for the native
validation checklist and the current web/WASM decision notes.
---
+7 -2
View File
@@ -35,7 +35,7 @@ rm /tmp/cmdline-tools.zip
echo ''
echo '# Android dev'
echo 'export ANDROID_HOME="$HOME/Android/Sdk"'
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.3.11579264"'
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/30.0.14904198"'
echo 'export JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"'
echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"'
} >> ~/.bashrc
@@ -49,10 +49,15 @@ sdkmanager \
"platform-tools" \
"platforms;android-34" \
"build-tools;34.0.0" \
"ndk;26.3.11579264" \
"ndk;30.0.14904198" \
"emulator" \
"system-images;android-34;google_apis;x86_64"
# The exact NDK/build-tools versions above are not load-bearing — newer ones
# work (verified on NDK 30.0.14904198 / build-tools 37.0.0). `scripts/build_android_apk.sh`
# auto-discovers the newest installed NDK and build-tools, so set ANDROID_NDK_HOME
# (step 3) to whatever version you actually install here.
# 6. AVD for testing (one-time).
echo no | avdmanager create avd \
-n bevy_test \
+21 -1
View File
@@ -213,15 +213,35 @@ KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
mkdir -p "$(dirname "$APK_OUT")"
echo ">>> apksigner sign -> $APK_OUT"
# Sign the schemes explicitly instead of relying on apksigner's auto behaviour.
# Left to "auto", this pipeline produced an APK carrying invalid v1 (JAR)
# signature files (META-INF/*.SF/.RSA present but failing v1 verification).
# Android installs it fine via v2/v3, but Obtainium parses the APK's legacy v1
# certificate at install time, gets an empty cert list, and crashes with
# "RangeError (length): Invalid value: valid value range is empty: 0".
# minSdk is 26 (solitaire_app/android/AndroidManifest.xml), so v1/JAR signing is
# not needed at all — disable it and ship a clean v2+v3 signature, matching what
# modern Android tooling produces for minSdk >= 24.
"$BT/apksigner" sign \
--ks "$KEYSTORE" \
--ks-pass "pass:$KEYSTORE_PASS" \
--ks-key-alias "$KEY_ALIAS" \
--key-pass "pass:$KEY_PASS" \
--min-sdk-version 26 \
--v1-signing-enabled false \
--v2-signing-enabled true \
--v3-signing-enabled true \
--out "$APK_OUT" \
"$STAGING/app-aligned.apk"
echo ">>> verify"
"$BT/apksigner" verify --verbose "$APK_OUT"
"$BT/apksigner" verify --min-sdk-version 26 --verbose "$APK_OUT"
# Guard: no leftover v1/JAR signature files may remain — their presence (valid or
# not) is what tripped Obtainium. Fail the build if any slipped through.
if unzip -l "$APK_OUT" 2>/dev/null | grep -qiE 'META-INF/.*\.(SF|RSA|DSA|EC)$'; then
echo "ERROR: APK still contains v1/JAR signature files; expected v2+v3 only" >&2
exit 1
fi
echo ">>> done: $APK_OUT"
+329 -122
View File
@@ -25,7 +25,7 @@ use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
use crate::layout::{Layout, LayoutResource, LayoutSystem, TABLEAU_FAN_FRAC};
use crate::layout::{Layout, LayoutResource, LayoutSystem};
use crate::pause_plugin::PausedResource;
use crate::platform::USE_TOUCH_UI_LAYOUT;
use crate::resources::{DragState, GameStateResource};
@@ -39,7 +39,7 @@ use crate::ui_theme::{
};
/// Per-card vertical step for face-down tableau cards, as a fraction of
/// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards
/// card height. Smaller than [`crate::layout::TABLEAU_FAN_FRAC`] because face-down cards
/// don't need their full body shown — only the back-pattern strip is
/// visible. Public so `input_plugin` can mirror the exact sprite layout
/// when hit-testing tableau columns; any drift between this and the
@@ -63,6 +63,36 @@ pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
// foundation piles bleeding through when a 2 sits on an Ace.
pub const STACK_FAN_FRAC: f32 = 0.025;
/// Per-card horizontal fan step for the Draw-Three waste, in logical pixels.
///
/// Derived from the actual tableau column spacing (`Tableau2.x Tableau1.x`)
/// rather than a fixed fraction of card width, so the fan scales with the
/// platform's `H_GAP_DIVISOR` (desktop ≈ 1.25×cw spacing, Android ≈ 1.03×cw).
/// Public so `input_plugin` can hit-test the fanned waste cards at the exact
/// x-offsets the renderer uses; any drift makes a click on the top fanned card
/// land on the card beneath it.
pub fn waste_fan_step(layout: &Layout) -> f32 {
tableau_col_step(layout) * 0.224
}
/// Horizontal distance between adjacent tableau columns (`Tableau2.x
/// Tableau1.x`), in logical pixels. The face-down stock is rendered one column
/// step left of the waste, and the Draw-Three waste fan ([`waste_fan_step`]) is
/// a fraction of it. Public so hit-testing mirrors the renderer exactly.
pub fn tableau_col_step(layout: &Layout) -> f32 {
let t1 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau1))
.copied()
.unwrap_or_default();
let t2 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau2))
.copied()
.unwrap_or_default();
(t2.x - t1.x).abs()
}
/// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28;
@@ -165,6 +195,40 @@ pub struct CardEntity {
pub card: Card,
}
/// Cached signature of the inputs that determine a card entity's *child*
/// visuals (drop-shadow, border frame, and the rank/suit label / large-print
/// corner overlay). Stored on each card so [`update_card_entity`] can skip the
/// expensive `despawn_related::<Children>()` + child respawn when nothing about
/// the appearance changed — the common case during a move, where only the
/// card's position changes. Without this guard every `StateChangedEvent`
/// rebuilds all 52 cards' children (≈250 entity spawn/despawns plus 52 `Text2d`
/// glyph re-layouts) in a single frame, which stutters the slide animation on
/// high-resolution devices.
///
/// The children depend only on these four inputs; the card identity is fixed
/// per entity, and the face/back *image* is handled by the always-refreshed
/// `Sprite` (a cheap handle swap), so theme/card-back changes need no child
/// rebuild.
#[derive(Component, Clone, Copy, PartialEq)]
struct CardChildrenKey {
face_up: bool,
card_size: Vec2,
color_blind: bool,
high_contrast: bool,
}
/// Query data read by the card-sync systems for each live card entity:
/// its id, card identity, current transform, any in-flight curve animation,
/// and its cached child-appearance key. Factored into an alias to keep the
/// system signatures readable (and satisfy clippy's `type_complexity`).
type CardSyncData = (
Entity,
&'static CardEntity,
&'static Transform,
Option<&'static CardAnimation>,
Option<&'static CardChildrenKey>,
);
/// Render-side index mapping each live board card to its [`CardEntity`].
///
/// Maintained exclusively by [`rebuild_card_entity_index`] in `PostUpdate`,
@@ -461,7 +525,13 @@ impl Plugin for CardPlugin {
.add_systems(Startup, load_card_images)
.add_systems(
PostStartup,
(sync_cards_startup, update_stock_empty_indicator_startup),
(
// Fill the tableau fan before the first render so the
// cold-start deal already uses the full viewport height.
fill_tableau_fan_on_startup.before(sync_cards_startup),
sync_cards_startup,
update_stock_empty_indicator_startup,
),
)
.add_systems(
Update,
@@ -702,7 +772,7 @@ fn sync_cards_startup(
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
entities: Query<CardSyncData>,
card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
) {
@@ -737,7 +807,7 @@ fn sync_cards_on_change(
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
entities: Query<CardSyncData>,
card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
) {
@@ -776,7 +846,7 @@ fn sync_cards(
back_colour: Color,
color_blind: bool,
high_contrast: bool,
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
entities: &Query<CardSyncData>,
card_images: Option<&CardImageSet>,
selected_back: usize,
font_handle: Option<&Handle<Font>>,
@@ -810,18 +880,24 @@ fn sync_cards(
// • end ≠ target → the game state has changed (e.g. a new game started
// while the win-cascade was mid-flight); cancel the
// stale `CardAnimation` and apply the new position.
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>)> = HashMap::new();
for (entity, marker, transform, anim) in entities.iter() {
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>, Option<CardChildrenKey>)> =
HashMap::new();
for (entity, marker, transform, anim, children_key) in entities.iter() {
existing.insert(
marker.card.clone(),
(entity, transform.translation, anim.map(|a| a.end)),
(
entity,
transform.translation,
anim.map(|a| a.end),
children_key.copied(),
),
);
}
let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect();
// Despawn any entity whose card is no longer tracked.
for (card, (entity, _, _)) in &existing {
for (card, (entity, _, _, _)) in &existing {
if !live_ids.contains(card) {
commands.entity(*entity).despawn();
}
@@ -832,7 +908,7 @@ fn sync_cards(
// behind the incoming top card during the draw slide animation.
for ((card, face_up), position, z) in positions {
let entity = match existing.get(&card) {
Some(&(entity, cur, anim_end)) => {
Some(&(entity, cur, anim_end, children_key)) => {
// If a CardAnimation is in flight, check whether its destination
// still matches the game-state target. If the game moved the card
// elsewhere (e.g. new game started during a win-cascade scatter),
@@ -859,6 +935,7 @@ fn sync_cards(
high_contrast,
cur,
has_anim,
children_key,
card_images,
selected_back,
font_handle,
@@ -908,34 +985,18 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2,
(KlondikePile::Tableau(Tableau::Tableau7), false),
];
// Compute the Draw-Three waste fan step proportional to the column spacing
// (waste_x stock_x = card_width + h_gap) rather than a fixed fraction of
// card_width. On desktop (H_GAP_DIVISOR=4) col_step = 1.25×cw and
// 0.224 × 1.25 = 0.28 — identical to the previous constant. On Android
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping
// the top fanned card's centre within the waste column's own horizontal
// footprint instead of spilling into the adjacent gap.
let tableau_col_step = {
let t1 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau1))
.copied()
.unwrap_or_default();
let t2 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau2))
.copied()
.unwrap_or_default();
(t2.x - t1.x).abs()
};
let waste_fan_step = tableau_col_step * 0.224;
// Draw-Three waste fan step, proportional to the column spacing so it scales
// with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via
// `waste_fan_step` so the two never drift (a drift puts the top fanned card's
// click target on the card beneath it).
let waste_fan_step = waste_fan_step(layout);
for (pile_type, is_stock_area) in piles {
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
continue;
};
if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
base.x -= tableau_col_step;
base.x -= tableau_col_step(layout);
}
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
@@ -1100,6 +1161,14 @@ fn spawn_card_entity(
);
});
}
// Record the appearance signature so subsequent `update_card_entity` calls
// can skip rebuilding these children until one of the inputs changes.
entity.insert(CardChildrenKey {
face_up,
card_size: layout.card_size,
color_blind,
high_contrast,
});
entity_id
}
@@ -1118,6 +1187,7 @@ fn update_card_entity(
high_contrast: bool,
cur: Vec3,
has_card_animation: bool,
existing_children_key: Option<CardChildrenKey>,
card_images: Option<&CardImageSet>,
selected_back: usize,
font_handle: Option<&Handle<Font>>,
@@ -1166,44 +1236,58 @@ fn update_card_entity(
}
}
// Despawn any stale children and re-add the per-card drop shadow plus,
// in solid-colour fallback mode, the label overlay. In image mode the
// rank/suit are baked into the PNG; on Android we also add a large-print
// corner overlay so they are legible at phone scale.
commands.entity(entity).despawn_related::<Children>();
commands.entity(entity).with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
commands.entity(entity).with_children(|b| {
add_card_back_frame_child(b, layout.card_size);
});
if card_images.is_none() {
// Rebuild the card's child visuals (drop-shadow, border frame, and the
// rank/suit label / large-print corner overlay) only when an input that
// affects them actually changed. The child set depends solely on
// `CardChildrenKey`; the face/back image is carried by the always-refreshed
// `Sprite` above, so theme/card-back swaps need no child rebuild. Skipping
// this on a position-only move avoids despawning and respawning the child
// entities (incl. a `Text2d` glyph re-layout) for all 52 cards on every
// `StateChangedEvent` — the spike that stuttered the slide animation on
// high-resolution devices.
let new_children_key = CardChildrenKey {
face_up,
card_size: layout.card_size,
color_blind,
high_contrast,
};
if existing_children_key != Some(new_children_key) {
commands.entity(entity).despawn_related::<Children>();
commands.entity(entity).with_children(|b| {
b.spawn((
CardLabel,
Text2d::new(label_for(card)),
TextFont {
font_size: layout.card_size.x * FONT_SIZE_FRAC,
..default()
},
TextColor(text_colour(card, color_blind, high_contrast)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(face_up),
));
add_card_shadow_child(b, layout.card_size);
});
}
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
commands.entity(entity).with_children(|b| {
add_android_corner_label(
b,
card,
face_up,
layout.card_size,
color_blind,
high_contrast,
font_handle,
);
add_card_back_frame_child(b, layout.card_size);
});
if card_images.is_none() {
commands.entity(entity).with_children(|b| {
b.spawn((
CardLabel,
Text2d::new(label_for(card)),
TextFont {
font_size: layout.card_size.x * FONT_SIZE_FRAC,
..default()
},
TextColor(text_colour(card, color_blind, high_contrast)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(face_up),
));
});
}
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
commands.entity(entity).with_children(|b| {
add_android_corner_label(
b,
card,
face_up,
layout.card_size,
color_blind,
high_contrast,
font_handle,
);
});
}
commands.entity(entity).insert(new_children_key);
}
}
@@ -2395,15 +2479,21 @@ fn resize_android_corner_labels(
}
}
/// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum
/// face-up column depth. Runs after every `StateChangedEvent` so the fan
/// expands as the player reveals cards while staying within the window.
/// Adjusts `LayoutResource.tableau_fan_frac` (and the face-down companion) so
/// the deepest tableau column fills the available vertical space at every stage
/// of play. Runs after every `StateChangedEvent`.
///
/// On fresh deal (max face-up depth = 1) the function returns early, leaving
/// both fracs at the window-size-adaptive values that `compute_layout` already
/// computed for the current viewport. Previously it overwrote the adaptive
/// value with the desktop minimum (0.25) — the wrong behaviour on portrait
/// phones where the adaptive value is much larger.
/// Depth is measured across *all* cards in a column, weighting each face-down
/// card by the fixed face-down/face-up step ratio. Counting the face-down
/// portion — not just the face-up tail — is what fills the lower screen on a
/// fresh deal (the deepest column is then six face-down cards under one face-up
/// one): the earlier face-up-only depth was 1, so the fan never spread and the
/// bottom half of a near-square viewport (e.g. an unfolded foldable) sat empty.
///
/// Deeper columns drive the fraction down so everything still fits the window;
/// [`crate::layout::TABLEAU_FAN_FRAC`] floors it to the desktop feel and
/// [`MAX_DYNAMIC_FAN_FRAC`] caps it so a near-empty column doesn't fling its few
/// cards far apart.
fn update_tableau_fan_frac(
mut events: MessageReader<StateChangedEvent>,
game: Option<Res<GameStateResource>>,
@@ -2418,59 +2508,35 @@ fn update_tableau_fan_frac(
let Some(layout) = layout.as_mut() else {
return;
};
let max_depth = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|tableau| {
game.0
.pile(KlondikePile::Tableau(tableau))
.into_iter()
.filter(|(_, face_up)| *face_up)
.count()
})
.max()
.unwrap_or(0);
let card_h = layout.0.card_size.y;
let avail = layout.0.available_tableau_height;
// With ≤ 1 face-up card per column (fresh deal, or completely face-down
// piles) the face-up fan fraction has no visible effect. Leave both fracs
// at the adaptive values set by compute_layout rather than snapping them
// to the desktop minimum.
if max_depth <= 1 || card_h <= 0.0 {
return;
}
let ideal = avail / ((max_depth - 1) as f32 * card_h);
let max_frac = if card_h > 0.0 {
avail / (12.0 * card_h)
} else {
TABLEAU_FAN_FRAC
};
let new_frac = ideal.clamp(TABLEAU_FAN_FRAC, max_frac.max(TABLEAU_FAN_FRAC));
let new_facedown_frac = new_frac * (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC);
if (layout.0.tableau_fan_frac - new_frac).abs() > 1e-4 {
layout.0.tableau_fan_frac = new_frac;
}
if (layout.0.tableau_facedown_fan_frac - new_facedown_frac).abs() > 1e-4 {
layout.0.tableau_facedown_fan_frac = new_facedown_frac;
}
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
}
/// PostStartup sibling of [`update_tableau_fan_frac`]. The initial deal is
/// inserted directly as `GameStateResource` at startup without a
/// `StateChangedEvent`, so the event-driven system never fires for it. This
/// runs once, before [`sync_cards_startup`] renders, so the very first board
/// (cold start) already fills the viewport — otherwise a fresh deal on a tall /
/// near-square screen (e.g. an unfolded foldable) renders with the unspread fan
/// and a large empty band below the tableau until the first move.
fn fill_tableau_fan_on_startup(
game: Option<Res<GameStateResource>>,
mut layout: Option<ResMut<LayoutResource>>,
) {
let Some(game) = game else {
return;
};
let Some(layout) = layout.as_mut() else {
return;
};
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::layout::TABLEAU_FAN_FRAC;
use crate::table_plugin::TablePlugin;
use solitaire_core::Deck;
@@ -2897,6 +2963,59 @@ mod tests {
}
}
#[test]
fn cold_start_deal_fills_tableau_fan() {
// The initial deal is inserted at startup without a StateChangedEvent, so
// the event-driven fan update never fires for it. The PostStartup fill
// must spread the fan from the deepest column's *total* depth (face-down
// included) so a fresh deal already fills the viewport — otherwise a
// near-square / unfolded-foldable screen renders with a large empty band
// below the tableau. Mirrors apply_dynamic_tableau_fan's formula against
// the actual dealt state so it fails if the startup fill is dropped or
// reverts to face-up-only depth.
let app = app();
let game = app.world().resource::<GameStateResource>();
let facedown_ratio = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
let max_demand = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|t| {
let pile = game.0.pile(KlondikePile::Tableau(t));
let steps = pile.len().saturating_sub(1);
pile.iter()
.take(steps)
.map(|(_, up)| if *up { 1.0 } else { facedown_ratio })
.sum::<f32>()
})
.fold(0.0_f32, f32::max);
assert!(
max_demand > 1.0,
"a fresh deal's deepest column should contribute several fan steps, got {max_demand}"
);
let layout = app.world().resource::<LayoutResource>();
let card_h = layout.0.card_size.y;
let avail = layout.0.available_tableau_height;
let expected = (avail / (max_demand * card_h))
.clamp(TABLEAU_FAN_FRAC, crate::layout::MAX_DYNAMIC_FAN_FRAC);
assert!(
(layout.0.tableau_fan_frac - expected).abs() < 1e-3,
"cold-start fan {} should equal the demand-filled value {} \
(card_h={card_h}, avail={avail}, demand={max_demand})",
layout.0.tableau_fan_frac,
expected,
);
}
#[test]
fn flip_half_secs_is_positive() {
const {
@@ -3162,6 +3281,56 @@ mod tests {
});
}
#[test]
fn resize_keeps_tableau_fan_filled() {
// The Android safe-area-inset update (frames 1-3) and fold/unfold both
// fire a WindowResized that recomputes the layout, resetting the fan to
// compute_layout's sparse worst-case value. on_window_resized must
// re-apply the dynamic fill so the tableau keeps filling the viewport
// after a resize — not only at deal time. Without the fill in the resize
// path the fan would snap back to the worst-case value and the lower
// screen would empty out on the first resize.
let mut app = app();
// A tall near-square window (like an unfolded foldable) where the dynamic
// fill clearly exceeds the worst-case fan.
fire_window_resize(&mut app, 1400.0, 1500.0);
advance_past_resize_throttle(&mut app);
let game = app.world().resource::<GameStateResource>();
let facedown_ratio = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
let max_demand = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|t| {
let pile = game.0.pile(KlondikePile::Tableau(t));
let steps = pile.len().saturating_sub(1);
pile.iter()
.take(steps)
.map(|(_, up)| if *up { 1.0 } else { facedown_ratio })
.sum::<f32>()
})
.fold(0.0_f32, f32::max);
let layout = app.world().resource::<LayoutResource>();
let expected = (layout.0.available_tableau_height / (max_demand * layout.0.card_size.y))
.clamp(TABLEAU_FAN_FRAC, crate::layout::MAX_DYNAMIC_FAN_FRAC);
assert!(
(layout.0.tableau_fan_frac - expected).abs() < 1e-3,
"after resize the fan {} should be re-filled to {} (the resize path must \
re-apply apply_dynamic_tableau_fan)",
layout.0.tableau_fan_frac,
expected,
);
}
#[test]
fn resize_does_not_despawn_card_labels() {
// Spawn a fresh app, capture the current set of CardLabel entity IDs,
@@ -3202,6 +3371,44 @@ mod tests {
}
}
#[test]
fn appearance_neutral_state_change_does_not_rebuild_card_children() {
// A StateChangedEvent that doesn't alter any card's appearance — the
// common case during a move, for the ~50 cards that didn't move or flip
// — must NOT despawn and respawn child entities. Before the
// CardChildrenKey guard every StateChangedEvent rebuilt all 52 cards'
// children (incl. a Text2d glyph re-layout each), the per-move spike
// that stuttered the slide animation on high-resolution devices.
let mut app = app();
let labels_before: HashSet<Entity> = app
.world_mut()
.query_filtered::<Entity, With<CardLabel>>()
.iter(app.world())
.collect();
assert!(
!labels_before.is_empty(),
"fixture should have spawned CardLabel children in the fallback path"
);
// Fire a StateChangedEvent without mutating the game: no card moves or
// flips, so every card's CardChildrenKey is unchanged.
app.world_mut().write_message(StateChangedEvent);
app.update();
let labels_after: HashSet<Entity> = app
.world_mut()
.query_filtered::<Entity, With<CardLabel>>()
.iter(app.world())
.collect();
assert_eq!(
labels_before, labels_after,
"an appearance-neutral StateChangedEvent must not despawn/respawn card \
children the CardChildrenKey guard should have skipped the rebuild"
);
}
#[test]
fn resize_in_place_updates_card_label_font_size() {
// Capture an arbitrary CardLabel's TextFont.font_size before resize,
+101 -14
View File
@@ -10,7 +10,8 @@
//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
//!
//! Mouse:
//! - Left-click on the stock pile (face-down deck) or waste slot → `DrawRequestEvent`
//! - Left-click on the stock pile (face-down deck) → `DrawRequestEvent`
//! (the waste card is left free to play: double-click to auto-move, or drag)
//! - Left-press-drag-release on a face-up card → `MoveRequestEvent` between
//! the origin pile and whatever pile the cursor is over at release.
//! On rejection, the drag cards snap back to their origin via a
@@ -34,7 +35,7 @@ use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning;
use crate::card_animation::{CardAnimation, MotionCurve};
use crate::card_plugin::{
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC,
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, waste_fan_step,
};
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
@@ -536,8 +537,9 @@ fn handle_stock_click(
// `pile_positions[Stock]` is the waste column (col_x(1)). card_plugin renders the
// face-down deck one column to the left via `base.x -= tableau_col_step`, placing it
// at Tableau1's x (col_x(0)). Hit-test both the deck AND the waste slot: in standard
// Klondike UX clicking either card draws from the deck.
// at Tableau1's x (col_x(0)). Only the deck draws — clicking the waste card must
// leave it free to be played (double-click to auto-move, or drag); hit-testing the
// waste slot here would intercept that click and draw the next card instead.
let Some(&waste_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
return;
};
@@ -549,9 +551,7 @@ fn handle_stock_click(
return;
};
let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
if point_in_rect(world, deck_pos, layout.0.card_size)
|| point_in_rect(world, waste_pos, layout.0.card_size)
{
if point_in_rect(world, deck_pos, layout.0.card_size) {
draw.write(DrawRequestEvent);
}
}
@@ -596,9 +596,9 @@ fn handle_touch_stock_tap(
continue;
};
let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
if point_in_rect(world, deck_pos, layout.0.card_size)
|| point_in_rect(world, waste_pos, layout.0.card_size)
{
// Only the face-down deck draws; tapping the waste card leaves it free to
// play (double-tap to auto-move, or drag).
if point_in_rect(world, deck_pos, layout.0.card_size) {
draw.write(DrawRequestEvent);
game_consumed.0 = true;
break; // one draw per tap frame
@@ -1175,12 +1175,15 @@ fn card_position(
Vec2::new(base.x, base.y + y_offset)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
// In Draw-Three mode the top 3 waste cards are fanned in X to match
// card_plugin::card_positions(). Hit-testing must use the same offsets
// so clicking the visually rightmost (top) card actually registers.
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
// so clicking the visually rightmost (top) card actually registers — a
// fixed `card_size.x * 0.28` matched the renderer on desktop but drifted
// on Android (tighter column spacing), shifting the top card's hit target
// onto the card beneath it.
let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3);
let slot = stack_index.saturating_sub(visible_start) as f32;
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
Vec2::new(base.x + slot * waste_fan_step(layout), base.y)
} else {
base
}
@@ -1829,7 +1832,7 @@ const _VEC3_REFERENCED: Option<Vec3> = None;
mod tests {
use super::*;
use crate::layout::compute_layout;
use solitaire_core::{Foundation, Tableau};
use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn clear_test_piles(game: &mut GameState) {
@@ -1910,6 +1913,90 @@ mod tests {
assert_eq!(result.2.len(), 1);
}
#[test]
fn find_draggable_picks_waste_top_with_multiple_cards() {
// Reproduces the reported "drags the wrong waste card" bug: with several
// cards in the waste, clicking the visible top must pick the actual top
// (last index), not the buffer card underneath it.
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game);
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
game.set_test_waste_cards(waste.clone());
let top_index = waste.len() - 1; // 2 = the visible top
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
}
#[test]
fn find_draggable_picks_lone_waste_card() {
// "can't play the first card in the stock" — a waste of one card must
// still be draggable.
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game);
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
game.set_test_waste_cards(vec![card.clone()]);
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
assert_eq!(result.0, KlondikePile::Stock);
assert_eq!(result.1, 0);
assert_eq!(result.2, vec![card]);
}
#[test]
fn draw_three_waste_hit_test_matches_render_fan_step() {
// Regression: the Draw-Three waste hit-test must use the same fan step as
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
// 1.25*cw); under tighter Android-style spacing the two drift and the top
// fanned card's click target lands on the card beneath it — so dragging
// the visible top card plays the wrong one.
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Force tight (Android-like) column spacing: ~1.03 * card_width.
let cw = layout.card_size.x;
let base = layout.pile_positions[&KlondikePile::Stock];
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
layout.pile_positions.insert(
KlondikePile::Tableau(Tableau::Tableau2),
Vec2::new(t1.x + cw * 1.03, t1.y),
);
clear_test_piles(&mut game);
let waste = vec![
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
];
game.set_test_waste_cards(waste.clone());
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
let top_index = waste.len() - 1;
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
let expected = base.x + 2.0 * waste_fan_step(&layout);
assert!(
(pos.x - expected).abs() < 1e-3,
"hit-test must use the shared waste fan step"
);
// The old fixed constant would have drifted from the renderer here.
let old = base.x + 2.0 * cw * 0.28;
assert!(
(pos.x - old).abs() > 1.0,
"shared step must differ from the old fixed step under tight spacing"
);
}
#[test]
fn find_draggable_skips_face_down_cards() {
let game = GameState::new(42, DrawStockConfig::DrawOne);
+88 -12
View File
@@ -7,6 +7,7 @@ use std::collections::HashMap;
use bevy::math::Vec2;
use bevy::prelude::{Resource, SystemSet};
use solitaire_core::game_state::GameState;
use solitaire_core::{Foundation, KlondikePile, Tableau};
/// Schedule labels for layout-related systems so cross-plugin ordering is
@@ -91,6 +92,15 @@ const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
/// this column inside the visible window.
const MAX_TABLEAU_CARDS: f32 = 13.0;
/// Upper bound for the dynamic tableau fan step (fraction of card height) chosen
/// by [`apply_dynamic_tableau_fan`]. The fan is spread to fill the available
/// height, but a near-empty column has tiny demand, so without a cap its few
/// cards would fling far apart on a tall viewport. At 0.6 the face-up cards keep
/// clear overlap (a readable stack) while still filling most of a near-square /
/// unfolded-foldable screen. Tunable purely for feel — no effect on correctness
/// or hit-testing.
pub(crate) const MAX_DYNAMIC_FAN_FRAC: f32 = 0.9;
/// Vertical pixel band reserved at the top of the play area for the HUD
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
/// below this band so the HUD doesn't bleed into the play surface.
@@ -275,13 +285,12 @@ pub fn compute_layout(
);
}
// 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.
// Adaptive tableau fan fraction. On height-limited windows the height-based
// sizing already ensures a worst-case 13-card column fits at TABLEAU_FAN_FRAC,
// so the formula returns the minimum and the clamp keeps it there. On
// width-limited (portrait phone) windows card_size is small and lots of
// vertical space is unused; solve for the fraction that fills the available
// space. `apply_dynamic_tableau_fan` later refines this for the actual deal.
//
// avail = distance from the top of the first tableau card to the bottom
// margin — i.e. the space available for 12 fan steps.
@@ -292,20 +301,87 @@ pub fn compute_layout(
} 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;
let available_tableau_height = avail;
Layout {
card_size,
pile_positions,
tableau_fan_frac,
tableau_facedown_fan_frac,
available_tableau_height: avail,
available_tableau_height,
}
}
/// Spread the tableau fan so the deepest column fills the available vertical
/// height for the *current* deal, mutating `layout.tableau_fan_frac` and its
/// face-down companion in place.
///
/// `compute_layout` is pure geometry and sizes the fan for a worst-case 13-card
/// column, so early in a game (shallow columns) a tall or near-square viewport
/// — e.g. an unfolded foldable — is left with a large empty band below the
/// tableau. This refines the fan once the actual deal is known.
///
/// Depth is measured across *all* cards in a column, each face-down card
/// weighted by the fixed face-down/face-up step ratio. Counting the face-down
/// portion (not just the face-up tail) is what fills the lower screen on a fresh
/// deal, where the deepest column is several face-down cards under one face-up
/// one. Deeper columns drive the fraction down so everything still fits;
/// [`TABLEAU_FAN_FRAC`] floors it and [`MAX_DYNAMIC_FAN_FRAC`] caps it.
///
/// Called from card-sync at startup and on every `StateChangedEvent`, and from
/// the resize pipeline after `compute_layout`, so the cold-start deal, ongoing
/// play, and fold/unfold all stay filled. `card_position` / `card_positions`
/// read the same fractions, so rendering and hit-testing remain in sync.
pub(crate) fn apply_dynamic_tableau_fan(game: &GameState, layout: &mut Layout) {
let card_h = layout.card_size.y;
let avail = layout.available_tableau_height;
if card_h <= 0.0 {
return;
}
let facedown_ratio = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
// "Step demand" of a column: the vertical offset of its bottom card from its
// top card, in units of the face-up fan step. Every card except the last
// contributes one step, weighted down to `facedown_ratio` while face-down.
let max_demand = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|tableau| {
let pile = game.pile(KlondikePile::Tableau(tableau));
let steps = pile.len().saturating_sub(1);
pile.iter()
.take(steps)
.map(|(_, face_up)| if *face_up { 1.0 } else { facedown_ratio })
.sum::<f32>()
})
.fold(0.0_f32, f32::max);
// No fannable column (every tableau pile has ≤ 1 card) — leave the fractions
// at the values compute_layout set.
if max_demand <= 0.0 {
return;
}
let ideal = avail / (max_demand * card_h);
let new_frac = ideal.clamp(TABLEAU_FAN_FRAC, MAX_DYNAMIC_FAN_FRAC);
let new_facedown_frac = new_frac * facedown_ratio;
if (layout.tableau_fan_frac - new_frac).abs() > 1e-4 {
layout.tableau_fan_frac = new_frac;
}
if (layout.tableau_facedown_fan_frac - new_facedown_frac).abs() > 1e-4 {
layout.tableau_facedown_fan_frac = new_facedown_frac;
}
}
+15 -3
View File
@@ -11,7 +11,9 @@ use solitaire_core::Suit;
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility;
use crate::layout::{Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, compute_layout};
use crate::layout::{
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
};
use crate::resources::GameStateResource;
use crate::safe_area::SafeAreaInsets;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
@@ -348,12 +350,13 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
}
}
#[allow(clippy::type_complexity)]
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn on_window_resized(
mut events: MessageReader<WindowResized>,
safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<&Window>,
hud_vis: Option<Res<HudVisibility>>,
game: Option<Res<GameStateResource>>,
mut layout_res: Option<ResMut<LayoutResource>>,
mut backgrounds: Query<
(&mut Sprite, &mut Transform),
@@ -370,7 +373,16 @@ fn on_window_resized(
let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / scale;
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
let new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
let mut new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
// compute_layout sizes the fan for a worst-case column; refine it to the
// current deal so a resize (incl. the Android safe-area-inset resize that
// fires in the first few frames, and fold/unfold on foldables) keeps the
// tableau filling the viewport instead of snapping back to the sparse
// worst-case fan.
if let Some(game) = game.as_ref() {
apply_dynamic_tableau_fan(&game.0, &mut new_layout);
}
if let Some(layout_res) = layout_res.as_deref_mut() {
layout_res.0 = new_layout.clone();
+27 -22
View File
@@ -142,10 +142,32 @@ async function main() {
const page = await context.newPage();
const results = [];
// Load the page once, then reset each game in place via the bridge's
// newGame(). A fresh page.goto() per game (hundreds of navigations in a
// single browser context) accumulates resources and eventually makes
// waitForFunction time out around game ~100. One load stays fast.
await page.goto(`${baseUrl}/${route}`, { waitUntil: "domcontentloaded" });
if (route === "play-classic") {
const resumeVisible = await page
.locator("#resume-overlay:not(.hidden)")
.isVisible()
.catch(() => false);
if (resumeVisible) {
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
await page.reload({ waitUntil: "domcontentloaded" });
}
}
await page.waitForFunction(
() =>
typeof window.__FERROUS_DEBUG__ === "object" &&
typeof window.__FERROUS_DEBUG__.newGame === "function",
null,
{ timeout: 30_000 }
);
for (let i = 0; i < games; i++) {
const seed = i;
const draw3 = i % 2 === 1;
const suffix = draw3 ? "&draw3=" : "";
const pageErrors = [];
const consoleErrors = [];
@@ -158,27 +180,10 @@ async function main() {
}
});
await page.goto(`${baseUrl}/${route}?seed=${seed}${suffix}`, {
waitUntil: "domcontentloaded",
});
if (route === "play-classic") {
const resumeVisible = await page
.locator("#resume-overlay:not(.hidden)")
.isVisible()
.catch(() => false);
if (resumeVisible) {
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
await page.reload({ waitUntil: "domcontentloaded" });
}
}
await page.waitForFunction(
() =>
typeof window.__FERROUS_DEBUG__ === "object" &&
window.__FERROUS_DEBUG__.seed() !== null,
null,
{ timeout: 30_000 }
// Reset to a fresh seeded game without navigating.
await page.evaluate(
({ seed, draw3 }) => window.__FERROUS_DEBUG__.newGame(seed, draw3),
{ seed, draw3 }
);
const run = await page.evaluate(({ stepCap, policyName, maxVisits }) => {
+22 -9
View File
@@ -203,7 +203,12 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
// and the wasm-bindgen-generated `web/pkg/`). The HTML page is the
// same regardless of `:id` — it reads the path from `location` in JS
// and fetches the replay JSON from `/api/replays/:id`.
let web = Router::new()
// HTML pages are `include_str!`'d into the binary and change on every
// deploy, so they get `Cache-Control: no-cache` (always revalidate). The
// `/web` + `/assets` static files keep ServeDir's default Last-Modified
// caching — applying no-cache to *those* too made the e2e cycle gate's 240
// page reloads recompile the wasm each time and time out.
let html_pages = Router::new()
.route(
"/",
get(|| async { Html(include_str!("../web/home.html")) }),
@@ -233,6 +238,10 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
"/replays",
get(|| async { Html(include_str!("../web/replays.html")) }),
)
.layer(axum_middleware::from_fn(no_cache_headers));
let web = Router::new()
.merge(html_pages)
.nest_service("/web", ServeDir::new("solitaire_server/web"))
.nest_service("/assets", ServeDir::new("assets"))
.layer(axum_middleware::from_fn(security_headers));
@@ -267,14 +276,18 @@ async fn security_headers(req: Request<axum::body::Body>, next: axum_middleware:
HeaderValue::from_static("nosniff"),
);
headers.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
// Force revalidation of the web assets. The HTML pages are compiled into
// the binary via `include_str!` and the wasm-bindgen output (canvas.js,
// canvas_bg.wasm, solitaire_wasm.*) keeps fixed filenames that change in
// place on every deploy. Without this, browsers heuristically cache them
// and keep serving stale builds even after a hard reload. `no-cache` lets
// the browser keep a copy but revalidate first; ServeDir supplies
// Last-Modified/ETag so unchanged assets still return a cheap 304.
headers.insert("Cache-Control", HeaderValue::from_static("no-cache"));
res
}
/// Adds `Cache-Control: no-cache` so the browser always revalidates before
/// using a cached copy. Scoped to the `include_str!` HTML pages (which change
/// on every deploy and have no validators) — not the ServeDir static assets,
/// which keep normal Last-Modified caching so repeated page loads can reuse the
/// already-downloaded/compiled wasm.
async fn no_cache_headers(req: Request<axum::body::Body>, next: axum_middleware::Next) -> Response {
let mut res = next.run(req).await;
res.headers_mut()
.insert("Cache-Control", HeaderValue::from_static("no-cache"));
res
}
+8
View File
@@ -994,6 +994,14 @@ window.__FERROUS_DEBUG__ = {
serialize() {
return game ? game.serialize() : null;
},
// Reset to a fresh seeded game in place (no page reload). Lets the cycle
// regression harness reuse one page across hundreds of games instead of
// navigating per game.
newGame(seed, drawThreeMode) {
drawThree = !!drawThreeMode;
startGame(seed ?? randomSeed());
return game ? game.state() : null;
},
applyLegalMove(index) {
if (!game) return { ok: false, error: "game_not_ready" };
const result = game.debug_apply_legal_move(index);
+12 -12
View File
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
return ret;
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114856, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114846, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hea4b5125da4e5ded);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9848, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9838, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0fec466277fb30e8);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_3);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_4);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_5);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_6);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_7);
return ret;
},
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_8);
return ret;
},
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_9);
return ret;
},
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9844, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9834, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf05069bc44820cc5);
return ret;
},
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9840, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h5e26b448f43bfba9);
return ret;
},
Binary file not shown.
+4 -17
View File
@@ -7,23 +7,10 @@
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: #000; overflow: hidden; }
/* Cap the canvas at WebGL2's 2048 max texture dimension (this mirrors
the Window `resize_constraints` in solitaire_web/src/lib.rs — winit
applies that constraint as the canvas's own max-width/max-height).
On wasm Bevy creates the device with downlevel_webgl2_defaults()
whose max_texture_dimension_2d is a fixed 2048, so a larger surface
(e.g. a 4K display at 150% scale → a 2560x1440 logical viewport)
makes Surface::configure panic. `max-*` directly on the canvas keeps
the surface ≤ 2048 regardless of how fit_canvas_to_parent resolves
its 100% width; `margin: 0 auto` centres the letterbox horizontally. */
#bevy-canvas {
display: block;
width: 100%;
height: 100%;
max-width: 2048px;
max-height: 2048px;
margin: 0 auto;
}
/* No size cap: the wgpu device now takes its max_texture_dimension from
the adapter (see solitaire_web/src/lib.rs), so the surface can match
the full viewport. fit_canvas_to_parent sizes the canvas to 100%. */
#bevy-canvas { display: block; width: 100%; height: 100%; }
</style>
</head>
<body>
+19 -26
View File
@@ -13,7 +13,7 @@ use bevy::asset::AssetMetaCheck;
use bevy::prelude::*;
use bevy::render::RenderPlugin;
use bevy::render::settings::{RenderCreation, WgpuSettings, WgpuSettingsPriority};
use bevy::window::{Window, WindowPlugin, WindowResizeConstraints, WindowResolution};
use bevy::window::{Window, WindowPlugin, WindowResolution};
use solitaire_data::LocalOnlyProvider;
use solitaire_engine::CoreGamePlugin;
use wasm_bindgen::prelude::*;
@@ -34,27 +34,15 @@ pub fn start() {
fit_canvas_to_parent: true,
// Prevent the browser stealing keyboard events and scroll.
prevent_default_event_handling: true,
// Force scale_factor = 1.0 so the wgpu surface is sized in
// CSS/logical pixels rather than physical pixels. Without this,
// HiDPI displays (devicePixelRatio ≥ 2) produce a framebuffer
// whose physical width can exceed WebGL2's 2048-pixel per-
// dimension limit, causing a wgpu validation panic on the first
// resize event and killing the WASM thread.
// Render at CSS/logical pixels (scale_factor 1.0) rather
// than physical (CSS × devicePixelRatio). This keeps the
// surface smaller on HiDPI displays — lighter GPU load and
// stable sizing — at the cost of some crispness. The wgpu
// texture-dimension limit is now taken from the adapter (see
// the RenderPlugin below), so this is purely a quality/perf
// choice, no longer a crash-avoidance hack.
resolution: WindowResolution::default()
.with_scale_factor_override(1.0),
// Cap the surface at WebGL2's max texture dimension (2048).
// On wasm Bevy creates the device with
// `Limits::downlevel_webgl2_defaults()` (max_texture_dimension_2d
// = 2048), so a larger surface — e.g. a 4K display at 150% scale
// gives a 2560x1440 logical viewport — makes Surface::configure
// panic on the first frame. winit maps this constraint to the
// canvas's `max-width`/`max-height` style, so the surface can
// never exceed it. Viewports wider/taller than 2048 letterbox.
resize_constraints: WindowResizeConstraints {
max_width: 2048.0,
max_height: 2048.0,
..default()
},
..default()
}),
..default()
@@ -66,14 +54,19 @@ pub fn start() {
meta_check: AssetMetaCheck::Never,
..default()
})
// WebGL2 priority constrains naga (the shader translator) to emit
// GLES 300es-compatible GLSL. Without this, Chromium's ANGLE driver
// rejects certain shader constructs (storage buffers, tight component
// limits) causing a fatal wgpu "Shader translation error". Firefox is
// more lenient; this setting makes both browsers work identically.
// `Functionality` makes wgpu adopt the *adapter's* real limits
// instead of the conservative `downlevel_webgl2_defaults()` that
// `WebGL2` priority forces. On the WebGL2 (Gl) backend the adapter
// already reports WebGL2-constrained features/limits — no storage
// buffers, etc., so shaders stay GLES-compatible on both Firefox and
// Chromium — but it reports the GPU's *true* `max_texture_dimension`
// (e.g. 16384) rather than 2048. The device is requested with exactly
// what the adapter offers, so creation can't fail, and the surface is
// no longer capped at 2048: large viewports (4K, etc.) render natively
// with no letterbox and no hardcoded cap.
.set(RenderPlugin {
render_creation: RenderCreation::Automatic(WgpuSettings {
priority: WgpuSettingsPriority::WebGL2,
priority: WgpuSettingsPriority::Functionality,
..default()
}),
..default()