Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 783f01628e | |||
| 94a6feb5db | |||
| 3daaf47689 | |||
| c00656ec8f | |||
| aea2167eee | |||
| 0ed91ea24c | |||
| 18af49c0f3 | |||
| e208245036 | |||
| 0247efcb07 | |||
| 1d5266a811 | |||
| 26283b5478 | |||
| 3627e9f9cf | |||
| b81a79c51c | |||
| 968721eeb4 | |||
| 780e82ca4b | |||
| 207747db4b |
@@ -37,6 +37,18 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
- **Android and modal safe-area layout.** Modal cards now center within the
|
||||||
usable area between status and gesture bars, additional modal-spawn guards were
|
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
|
added, and Android build scripts now auto-discover SDK/NDK paths and strip
|
||||||
|
|||||||
+52
-17
@@ -1,16 +1,40 @@
|
|||||||
# Ferrous Solitaire — Session Handoff
|
# Ferrous Solitaire — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-06-09 — AVD Android launch smoke passed; physical-device gate remains.
|
**Last updated:** 2026-06-25 — v0.40.0 released (Android APK published); physical-device gate remains.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
- **Branch state:** `master` pushed to origin; latest commits are validation runbooks, card-label test coverage, and Android AVD smoke notes.
|
- **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.39.0`
|
- **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 are excluded through `.git/info/exclude` and intentionally not committed.
|
- **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are 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.
|
- **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:** Claude reported recent card_game work pushed to origin and `cargo test` / `clippy` gates passing before the changelog follow-up.
|
- **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
|
## 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),
|
This is the **single outstanding task** for the v0.40.0 Android release. Everything
|
||||||
and run the checklist in `docs/ANDROID.md`. This has never been gated in CI.
|
else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
|
||||||
AVD `adb shell input tap` doesn't deliver real touch events, so physical-device
|
clippy, release manifest sanity, a full local signed APK, the published release, and
|
||||||
smoke testing is the only gate.
|
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
|
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
|
||||||
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
|
`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
|
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
|
||||||
filtered logcat showed no Ferrous panic/fatal/ANR.
|
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
|
Separate, ongoing task unrelated to the Android release. `Settings` has
|
||||||
consumes them via `AnalyticsPlugin` on non-wasm targets. Remaining work is live
|
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
|
||||||
validation against the deployed Matomo instance. Use
|
`AnalyticsPlugin` on non-wasm targets. Remaining work is live validation against the
|
||||||
`docs/analytics-validation.md` for the native validation checklist and the
|
deployed Matomo instance. Use `docs/analytics-validation.md` for the native
|
||||||
current web/WASM decision notes.
|
validation checklist and the current web/WASM decision notes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -213,15 +213,35 @@ KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
|
|||||||
|
|
||||||
mkdir -p "$(dirname "$APK_OUT")"
|
mkdir -p "$(dirname "$APK_OUT")"
|
||||||
echo ">>> apksigner sign -> $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 \
|
"$BT/apksigner" sign \
|
||||||
--ks "$KEYSTORE" \
|
--ks "$KEYSTORE" \
|
||||||
--ks-pass "pass:$KEYSTORE_PASS" \
|
--ks-pass "pass:$KEYSTORE_PASS" \
|
||||||
--ks-key-alias "$KEY_ALIAS" \
|
--ks-key-alias "$KEY_ALIAS" \
|
||||||
--key-pass "pass:$KEY_PASS" \
|
--key-pass "pass:$KEY_PASS" \
|
||||||
|
--min-sdk-version 26 \
|
||||||
|
--v1-signing-enabled false \
|
||||||
|
--v2-signing-enabled true \
|
||||||
|
--v3-signing-enabled true \
|
||||||
--out "$APK_OUT" \
|
--out "$APK_OUT" \
|
||||||
"$STAGING/app-aligned.apk"
|
"$STAGING/app-aligned.apk"
|
||||||
|
|
||||||
echo ">>> verify"
|
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"
|
echo ">>> done: $APK_OUT"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use crate::card_animation::CardAnimation;
|
|||||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::GameMutation;
|
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::pause_plugin::PausedResource;
|
||||||
use crate::platform::USE_TOUCH_UI_LAYOUT;
|
use crate::platform::USE_TOUCH_UI_LAYOUT;
|
||||||
use crate::resources::{DragState, GameStateResource};
|
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
|
/// 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
|
/// don't need their full body shown — only the back-pattern strip is
|
||||||
/// visible. Public so `input_plugin` can mirror the exact sprite layout
|
/// visible. Public so `input_plugin` can mirror the exact sprite layout
|
||||||
/// when hit-testing tableau columns; any drift between this and the
|
/// when hit-testing tableau columns; any drift between this and the
|
||||||
@@ -195,6 +195,40 @@ pub struct CardEntity {
|
|||||||
pub card: Card,
|
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`].
|
/// Render-side index mapping each live board card to its [`CardEntity`].
|
||||||
///
|
///
|
||||||
/// Maintained exclusively by [`rebuild_card_entity_index`] in `PostUpdate`,
|
/// Maintained exclusively by [`rebuild_card_entity_index`] in `PostUpdate`,
|
||||||
@@ -491,7 +525,13 @@ impl Plugin for CardPlugin {
|
|||||||
.add_systems(Startup, load_card_images)
|
.add_systems(Startup, load_card_images)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
PostStartup,
|
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(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -732,7 +772,7 @@ fn sync_cards_startup(
|
|||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
entities: Query<CardSyncData>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
@@ -767,7 +807,7 @@ fn sync_cards_on_change(
|
|||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
entities: Query<CardSyncData>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
@@ -806,7 +846,7 @@ fn sync_cards(
|
|||||||
back_colour: Color,
|
back_colour: Color,
|
||||||
color_blind: bool,
|
color_blind: bool,
|
||||||
high_contrast: bool,
|
high_contrast: bool,
|
||||||
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
entities: &Query<CardSyncData>,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
font_handle: Option<&Handle<Font>>,
|
font_handle: Option<&Handle<Font>>,
|
||||||
@@ -840,18 +880,24 @@ fn sync_cards(
|
|||||||
// • end ≠ target → the game state has changed (e.g. a new game started
|
// • end ≠ target → the game state has changed (e.g. a new game started
|
||||||
// while the win-cascade was mid-flight); cancel the
|
// while the win-cascade was mid-flight); cancel the
|
||||||
// stale `CardAnimation` and apply the new position.
|
// stale `CardAnimation` and apply the new position.
|
||||||
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>)> = HashMap::new();
|
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>, Option<CardChildrenKey>)> =
|
||||||
for (entity, marker, transform, anim) in entities.iter() {
|
HashMap::new();
|
||||||
|
for (entity, marker, transform, anim, children_key) in entities.iter() {
|
||||||
existing.insert(
|
existing.insert(
|
||||||
marker.card.clone(),
|
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();
|
let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect();
|
||||||
|
|
||||||
// Despawn any entity whose card is no longer tracked.
|
// Despawn any entity whose card is no longer tracked.
|
||||||
for (card, (entity, _, _)) in &existing {
|
for (card, (entity, _, _, _)) in &existing {
|
||||||
if !live_ids.contains(card) {
|
if !live_ids.contains(card) {
|
||||||
commands.entity(*entity).despawn();
|
commands.entity(*entity).despawn();
|
||||||
}
|
}
|
||||||
@@ -862,7 +908,7 @@ fn sync_cards(
|
|||||||
// behind the incoming top card during the draw slide animation.
|
// behind the incoming top card during the draw slide animation.
|
||||||
for ((card, face_up), position, z) in positions {
|
for ((card, face_up), position, z) in positions {
|
||||||
let entity = match existing.get(&card) {
|
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
|
// If a CardAnimation is in flight, check whether its destination
|
||||||
// still matches the game-state target. If the game moved the card
|
// still matches the game-state target. If the game moved the card
|
||||||
// elsewhere (e.g. new game started during a win-cascade scatter),
|
// elsewhere (e.g. new game started during a win-cascade scatter),
|
||||||
@@ -889,6 +935,7 @@ fn sync_cards(
|
|||||||
high_contrast,
|
high_contrast,
|
||||||
cur,
|
cur,
|
||||||
has_anim,
|
has_anim,
|
||||||
|
children_key,
|
||||||
card_images,
|
card_images,
|
||||||
selected_back,
|
selected_back,
|
||||||
font_handle,
|
font_handle,
|
||||||
@@ -1114,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
|
entity_id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1132,6 +1187,7 @@ fn update_card_entity(
|
|||||||
high_contrast: bool,
|
high_contrast: bool,
|
||||||
cur: Vec3,
|
cur: Vec3,
|
||||||
has_card_animation: bool,
|
has_card_animation: bool,
|
||||||
|
existing_children_key: Option<CardChildrenKey>,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
font_handle: Option<&Handle<Font>>,
|
font_handle: Option<&Handle<Font>>,
|
||||||
@@ -1180,10 +1236,22 @@ fn update_card_entity(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Despawn any stale children and re-add the per-card drop shadow plus,
|
// Rebuild the card's child visuals (drop-shadow, border frame, and the
|
||||||
// in solid-colour fallback mode, the label overlay. In image mode the
|
// rank/suit label / large-print corner overlay) only when an input that
|
||||||
// rank/suit are baked into the PNG; on Android we also add a large-print
|
// affects them actually changed. The child set depends solely on
|
||||||
// corner overlay so they are legible at phone scale.
|
// `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).despawn_related::<Children>();
|
||||||
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);
|
||||||
@@ -1219,6 +1287,8 @@ fn update_card_entity(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
commands.entity(entity).insert(new_children_key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn label_for(card: &Card) -> String {
|
fn label_for(card: &Card) -> String {
|
||||||
@@ -2409,15 +2479,21 @@ fn resize_android_corner_labels(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum
|
/// Adjusts `LayoutResource.tableau_fan_frac` (and the face-down companion) so
|
||||||
/// face-up column depth. Runs after every `StateChangedEvent` so the fan
|
/// the deepest tableau column fills the available vertical space at every stage
|
||||||
/// expands as the player reveals cards while staying within the window.
|
/// of play. Runs after every `StateChangedEvent`.
|
||||||
///
|
///
|
||||||
/// On fresh deal (max face-up depth = 1) the function returns early, leaving
|
/// Depth is measured across *all* cards in a column, weighting each face-down
|
||||||
/// both fracs at the window-size-adaptive values that `compute_layout` already
|
/// card by the fixed face-down/face-up step ratio. Counting the face-down
|
||||||
/// computed for the current viewport. Previously it overwrote the adaptive
|
/// portion — not just the face-up tail — is what fills the lower screen on a
|
||||||
/// value with the desktop minimum (0.25) — the wrong behaviour on portrait
|
/// fresh deal (the deepest column is then six face-down cards under one face-up
|
||||||
/// phones where the adaptive value is much larger.
|
/// 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(
|
fn update_tableau_fan_frac(
|
||||||
mut events: MessageReader<StateChangedEvent>,
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
@@ -2432,59 +2508,35 @@ fn update_tableau_fan_frac(
|
|||||||
let Some(layout) = layout.as_mut() else {
|
let Some(layout) = layout.as_mut() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::layout::TABLEAU_FAN_FRAC;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use solitaire_core::Deck;
|
use solitaire_core::Deck;
|
||||||
|
|
||||||
@@ -2911,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]
|
#[test]
|
||||||
fn flip_half_secs_is_positive() {
|
fn flip_half_secs_is_positive() {
|
||||||
const {
|
const {
|
||||||
@@ -3176,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]
|
#[test]
|
||||||
fn resize_does_not_despawn_card_labels() {
|
fn resize_does_not_despawn_card_labels() {
|
||||||
// Spawn a fresh app, capture the current set of CardLabel entity IDs,
|
// Spawn a fresh app, capture the current set of CardLabel entity IDs,
|
||||||
@@ -3216,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]
|
#[test]
|
||||||
fn resize_in_place_updates_card_label_font_size() {
|
fn resize_in_place_updates_card_label_font_size() {
|
||||||
// Capture an arbitrary CardLabel's TextFont.font_size before resize,
|
// Capture an arbitrary CardLabel's TextFont.font_size before resize,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use bevy::math::Vec2;
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::{Resource, SystemSet};
|
use bevy::prelude::{Resource, SystemSet};
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
/// Schedule labels for layout-related systems so cross-plugin ordering is
|
/// 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.
|
/// this column inside the visible window.
|
||||||
const MAX_TABLEAU_CARDS: f32 = 13.0;
|
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
|
/// Vertical pixel band reserved at the top of the play area for the HUD
|
||||||
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
|
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
|
||||||
/// below this band so the HUD doesn't bleed into the play surface.
|
/// 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
|
// Adaptive tableau fan fraction. On height-limited windows the height-based
|
||||||
// height-based sizing already ensures a worst-case 13-card column fits at
|
// sizing already ensures a worst-case 13-card column fits at TABLEAU_FAN_FRAC,
|
||||||
// TABLEAU_FAN_FRAC (0.25), so the formula returns ≈0.25 and the clamp
|
// so the formula returns the minimum and the clamp keeps it there. On
|
||||||
// keeps it there — no change from prior behaviour. On width-limited
|
// width-limited (portrait phone) windows card_size is small and lots of
|
||||||
// (portrait phone) windows card_size is small and lots of vertical space
|
// vertical space is unused; solve for the fraction that fills the available
|
||||||
// is unused; we solve for the fraction that exactly fills the available
|
// space. `apply_dynamic_tableau_fan` later refines this for the actual deal.
|
||||||
// space to the bottom margin.
|
|
||||||
//
|
//
|
||||||
// avail = distance from the top of the first tableau card to the bottom
|
// avail = distance from the top of the first tableau card to the bottom
|
||||||
// margin — i.e. the space available for 12 fan steps.
|
// margin — i.e. the space available for 12 fan steps.
|
||||||
@@ -292,20 +301,87 @@ pub fn compute_layout(
|
|||||||
} else {
|
} else {
|
||||||
TABLEAU_FAN_FRAC
|
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);
|
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 facedown_scale = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
|
||||||
let tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale;
|
let tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale;
|
||||||
|
let available_tableau_height = avail;
|
||||||
|
|
||||||
Layout {
|
Layout {
|
||||||
card_size,
|
card_size,
|
||||||
pile_positions,
|
pile_positions,
|
||||||
tableau_fan_frac,
|
tableau_fan_frac,
|
||||||
tableau_facedown_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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ use solitaire_core::Suit;
|
|||||||
|
|
||||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||||
use crate::hud_plugin::HudVisibility;
|
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::resources::GameStateResource;
|
||||||
use crate::safe_area::SafeAreaInsets;
|
use crate::safe_area::SafeAreaInsets;
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
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(
|
fn on_window_resized(
|
||||||
mut events: MessageReader<WindowResized>,
|
mut events: MessageReader<WindowResized>,
|
||||||
safe_area: Option<Res<SafeAreaInsets>>,
|
safe_area: Option<Res<SafeAreaInsets>>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
hud_vis: Option<Res<HudVisibility>>,
|
hud_vis: Option<Res<HudVisibility>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
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),
|
||||||
@@ -370,7 +373,16 @@ fn on_window_resized(
|
|||||||
let safe_area_top = insets.top / scale;
|
let safe_area_top = insets.top / scale;
|
||||||
let safe_area_bottom = insets.bottom / scale;
|
let safe_area_bottom = insets.bottom / scale;
|
||||||
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
|
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() {
|
if let Some(layout_res) = layout_res.as_deref_mut() {
|
||||||
layout_res.0 = new_layout.clone();
|
layout_res.0 = new_layout.clone();
|
||||||
|
|||||||
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
|
|||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114855, 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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hea4b5125da4e5ded);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9841, 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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9847, 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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0fec466277fb30e8);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9841, 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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_3);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9841, 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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_4);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9841, 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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_5);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9841, 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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_6);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9841, 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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_7);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9841, 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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_8);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9841, 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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_9);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9843, 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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf05069bc44820cc5);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9839, 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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h5e26b448f43bfba9);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user