Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0247efcb07 | |||
| 1d5266a811 | |||
| 26283b5478 | |||
| 3627e9f9cf | |||
| b81a79c51c | |||
| 968721eeb4 | |||
| 780e82ca4b | |||
| 207747db4b |
+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"
|
||||||
|
|||||||
@@ -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`,
|
||||||
@@ -732,7 +766,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 +801,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 +840,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 +874,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 +902,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 +929,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 +1155,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 +1181,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,44 +1230,58 @@ 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
|
||||||
commands.entity(entity).despawn_related::<Children>();
|
// `Sprite` above, so theme/card-back swaps need no child rebuild. Skipping
|
||||||
commands.entity(entity).with_children(|b| {
|
// this on a position-only move avoids despawning and respawning the child
|
||||||
add_card_shadow_child(b, layout.card_size);
|
// entities (incl. a `Text2d` glyph re-layout) for all 52 cards on every
|
||||||
});
|
// `StateChangedEvent` — the spike that stuttered the slide animation on
|
||||||
commands.entity(entity).with_children(|b| {
|
// high-resolution devices.
|
||||||
add_card_back_frame_child(b, layout.card_size);
|
let new_children_key = CardChildrenKey {
|
||||||
});
|
face_up,
|
||||||
if card_images.is_none() {
|
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| {
|
commands.entity(entity).with_children(|b| {
|
||||||
b.spawn((
|
add_card_shadow_child(b, layout.card_size);
|
||||||
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| {
|
commands.entity(entity).with_children(|b| {
|
||||||
add_android_corner_label(
|
add_card_back_frame_child(b, layout.card_size);
|
||||||
b,
|
|
||||||
card,
|
|
||||||
face_up,
|
|
||||||
layout.card_size,
|
|
||||||
color_blind,
|
|
||||||
high_contrast,
|
|
||||||
font_handle,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3216,6 +3280,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,
|
||||||
|
|||||||
Reference in New Issue
Block a user