Compare commits

...

4 Commits

Author SHA1 Message Date
funman300 6d061d23a1 fix(engine): cancel stale win-cascade CardAnimation on new-game; refresh Android corner label text on resize (closes #6, closes #7)
Issue #7 — new game during win cascade:
sync_cards now stores each in-flight CardAnimation's end position instead of
a plain bool. Before calling update_card_entity, the end position is compared
against the game-state target. If they differ by more than 2 px (stale cascade
scatter vs. new-game dealt position) the CardAnimation is removed immediately
so the card slides to its correct dealt position. Drag-rejection tweens are
unaffected because their end equals the card's current game-state position.

Issue #6 — Android stale corner label text:
AndroidCornerLabel now carries the label string as AndroidCornerLabel(String).
resize_android_corner_labels refreshes Text2d content from the stored value
alongside the existing font-size and transform updates, closing the narrow
race where a layout change could display a previous card's rank/suit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:32:07 -07:00
funman300 25f22231a6 fix(test): make leaderboard opt-in/opt-out tests robust under parallel runner (closes #5)
The four tests polled the async task pool with a fixed budget of five
app.update() calls. Under cargo test --workspace the pool's background
threads are starved by other tests, so even an instantly-resolving future
can take more than five frames to be polled. Replace the fixed loop with a
deadline-bounded loop (5 s timeout) that exits early once the expected
side-effect is observable — the same pattern used in sync_plugin.rs tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:32:07 -07:00
funman300 c66ff26d1d fix(engine): lift card z during CardAnim to prevent corner bleed-through
When a card slides to a foundation slot already occupied, both card entities
share the same x,y for the duration of the tween. With STACK_FAN_FRAC only
0.003 apart, the incoming card partially occludes the stationary one, making
the two exposed corners look like a single mismatched card.

Elevate every CardAnim-driven card to target.z + 50 during transit so it
fully occludes any card resting at the destination. On completion the card
snaps to the correct resting z. The value sits below DRAG_Z (500) so dragged
cards still render above animated ones.

Closes #implicitly-related-to-corner-mismatch-investigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:32:07 -07:00
funman300 cd792b20b2 chore: ignore ruflo runtime state files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:32:07 -07:00
4 changed files with 112 additions and 21 deletions
+4
View File
@@ -8,6 +8,10 @@
data/ data/
.claude/ .claude/
# ruflo runtime state
agentdb.rvf
agentdb.rvf.lock
# IDE project files # IDE project files
.idea/ .idea/
+16 -1
View File
@@ -72,6 +72,17 @@ const TIME_ATTACK_TOAST_SECS: f32 = 5.0;
const CHALLENGE_TOAST_SECS: f32 = 3.0; const CHALLENGE_TOAST_SECS: f32 = 3.0;
const VOLUME_TOAST_SECS: f32 = 1.4; const VOLUME_TOAST_SECS: f32 = 1.4;
/// Z added to a card's render depth while its `CardAnim` is in-flight.
///
/// Foundation and tableau cards share x,y during the slide (destination equals
/// a slot that already holds a card). Without this lift the incoming card's
/// bottom-right corner overlaps the stationary card's top-left, which the
/// player perceives as a single card with mismatched rank/suit indices.
///
/// 50.0 sits comfortably above the highest pile depth (~1.04) and well below
/// `DRAG_Z` (500), so a dragged card always renders above an animated one.
const CARD_ANIM_Z_LIFT: f32 = 50.0;
/// Per-card stagger interval for the win cascade at Normal speed (seconds). /// Per-card stagger interval for the win cascade at Normal speed (seconds).
/// ///
/// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS` so all motion timing /// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS` so all motion timing
@@ -254,7 +265,11 @@ fn advance_card_anims(
// shared `CardAnim` struct stays a simple linear-tween container — the // shared `CardAnim` struct stays a simple linear-tween container — the
// upgrade is one extra `sample_curve` call per advancing animation. // upgrade is one extra `sample_curve` call per advancing animation.
let s = sample_curve(MotionCurve::SmoothSnap, t); let s = sample_curve(MotionCurve::SmoothSnap, t);
transform.translation = anim.start.lerp(anim.target, s); let mut pos = anim.start.lerp(anim.target, s);
// Elevate z during transit so the moving card always renders in front
// of any card already resting at the destination position.
pos.z = anim.target.z + CARD_ANIM_Z_LIFT;
transform.translation = pos;
if t >= 1.0 { if t >= 1.0 {
transform.translation = anim.target; transform.translation = anim.target;
commands.entity(entity).remove::<CardAnim>(); commands.entity(entity).remove::<CardAnim>();
+34 -15
View File
@@ -178,8 +178,8 @@ pub struct CardLabel;
/// readable at phone scale. Only exists when `CardImageSet` is present /// readable at phone scale. Only exists when `CardImageSet` is present
/// (the fallback solid-colour path uses a plain `CardLabel` instead). /// (the fallback solid-colour path uses a plain `CardLabel` instead).
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[derive(Component, Debug, Clone, Copy)] #[derive(Component, Debug, Clone)]
struct AndroidCornerLabel; struct AndroidCornerLabel(pub String);
/// Solid-colour background sprite behind [`AndroidCornerLabel`]. /// Solid-colour background sprite behind [`AndroidCornerLabel`].
/// ///
@@ -707,15 +707,20 @@ fn sync_cards(
.map(|c| c.id) .map(|c| c.id)
}; };
// Map card_id -> (Entity, current_translation, has_card_animation) for // Map card_id -> (Entity, current_translation, anim_end) for in-place
// in-place updates. The `has_card_animation` flag lets `update_card_entity` // updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
// skip the snap/slide path on cards that are already being driven by a // is currently driving the card (e.g. a drag-rejection return tween).
// curve-based `CardAnimation` tween (e.g. the drag-rejection return tween //
// — see `input_plugin::end_drag`). Otherwise the StateChangedEvent that // In the position loop below we compare `anim_end` against the new game-
// accompanies a rejection would race the tween and the card would jump. // state target position to decide whether to honour or cancel the tween:
let mut existing: HashMap<u32, (Entity, Vec3, bool)> = HashMap::new(); // • end ≈ target → animation is still heading to the right place; let
// it finish (skip the snap/slide path).
// • 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<u32, (Entity, Vec3, Option<Vec2>)> = HashMap::new();
for (entity, marker, transform, anim) in entities.iter() { for (entity, marker, transform, anim) in entities.iter() {
existing.insert(marker.card_id, (entity, transform.translation, anim.is_some())); existing.insert(marker.card_id, (entity, transform.translation, anim.map(|a| a.end)));
} }
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect(); let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
@@ -732,7 +737,19 @@ 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, position, z) in positions { for (card, position, z) in positions {
let entity = match existing.get(&card.id) { let entity = match existing.get(&card.id) {
Some(&(entity, cur, has_anim)) => { Some(&(entity, cur, anim_end)) => {
// 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),
// cancel the stale tween so the card snaps/slides to its new home.
let has_anim = match anim_end {
Some(end_xy) if (end_xy - position).length() > 2.0 => {
commands.entity(entity).remove::<CardAnimation>();
false
}
Some(_) => true,
None => false,
};
update_card_entity( update_card_entity(
&mut commands, entity, card, position, z, layout, &mut commands, entity, card, position, z, layout,
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle, slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle,
@@ -1142,10 +1159,11 @@ fn add_android_corner_label(
// Large rank+suit text drawn on top of the background. FiraMono must be // Large rank+suit text drawn on top of the background. FiraMono must be
// wired here explicitly — the suit glyphs (U+2660U+2666) are not in // wired here explicitly — the suit glyphs (U+2660U+2666) are not in
// Bevy's built-in font and render as a coloured rectangle without it. // Bevy's built-in font and render as a coloured rectangle without it.
let label_text = mobile_label_for(card);
parent.spawn(( parent.spawn((
AndroidCornerLabel, AndroidCornerLabel(label_text.clone()),
CardLabel, CardLabel,
Text2d::new(mobile_label_for(card)), Text2d::new(label_text),
TextFont { TextFont {
font: font_handle.cloned().unwrap_or_default(), font: font_handle.cloned().unwrap_or_default(),
font_size, font_size,
@@ -2089,7 +2107,7 @@ fn resize_cards_in_place(
fn resize_android_corner_labels( fn resize_android_corner_labels(
layout: Res<LayoutResource>, layout: Res<LayoutResource>,
card_images: Option<Res<CardImageSet>>, card_images: Option<Res<CardImageSet>>,
mut text_query: Query<(&mut TextFont, &mut Transform), With<AndroidCornerLabel>>, mut text_query: Query<(&AndroidCornerLabel, &mut Text2d, &mut TextFont, &mut Transform)>,
mut bg_query: Query< mut bg_query: Query<
(&mut Sprite, &mut Transform), (&mut Sprite, &mut Transform),
(With<AndroidCornerBg>, Without<AndroidCornerLabel>), (With<AndroidCornerBg>, Without<AndroidCornerLabel>),
@@ -2105,7 +2123,8 @@ fn resize_android_corner_labels(
let text_x = -layout.0.card_size.x / 2.0 + inset; let text_x = -layout.0.card_size.x / 2.0 + inset;
let text_y = layout.0.card_size.y / 2.0 - inset; let text_y = layout.0.card_size.y / 2.0 - inset;
for (mut font, mut transform) in text_query.iter_mut() { for (label, mut text2d, mut font, mut transform) in text_query.iter_mut() {
text2d.0 = label.0.clone();
font.font_size = font_size; font.font_size = font_size;
transform.translation.x = text_x; transform.translation.x = text_x;
transform.translation.y = text_y; transform.translation.y = text_y;
+58 -5
View File
@@ -1159,9 +1159,23 @@ mod tests {
.spawn(async { Err::<(), String>("network error".to_string()) }); .spawn(async { Err::<(), String>("network error".to_string()) });
app.world_mut().resource_mut::<OptInTask>().0 = Some(failed_task); app.world_mut().resource_mut::<OptInTask>().0 = Some(failed_task);
// Allow the task to complete and be polled. // Pump until the task is polled or a deadline elapses. A fixed
for _ in 0..5 { // update count is unreliable under parallel `cargo test --workspace`
// load — the AsyncComputeTaskPool background threads can be starved
// long enough that 5 updates finish before the task completes.
// Mirrors the deadline-loop pattern used in sync_plugin tests.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update(); app.update();
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = msgs.get_cursor();
if cursor.read(msgs).next().is_some() {
break;
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::yield_now();
} }
let msgs = app.world().resource::<Messages<WarningToastEvent>>(); let msgs = app.world().resource::<Messages<WarningToastEvent>>();
@@ -1183,8 +1197,19 @@ mod tests {
.spawn(async { Err::<(), String>("network error".to_string()) }); .spawn(async { Err::<(), String>("network error".to_string()) });
app.world_mut().resource_mut::<OptOutTask>().0 = Some(failed_task); app.world_mut().resource_mut::<OptOutTask>().0 = Some(failed_task);
for _ in 0..5 { // Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update(); app.update();
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = msgs.get_cursor();
if cursor.read(msgs).next().is_some() {
break;
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::yield_now();
} }
let msgs = app.world().resource::<Messages<WarningToastEvent>>(); let msgs = app.world().resource::<Messages<WarningToastEvent>>();
@@ -1210,8 +1235,22 @@ mod tests {
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) }); let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
app.world_mut().resource_mut::<OptInTask>().0 = Some(ok_task); app.world_mut().resource_mut::<OptInTask>().0 = Some(ok_task);
for _ in 0..5 { // Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update(); app.update();
if app
.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in
{
break;
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::yield_now();
} }
assert!( assert!(
@@ -1237,8 +1276,22 @@ mod tests {
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) }); let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
app.world_mut().resource_mut::<OptOutTask>().0 = Some(ok_task); app.world_mut().resource_mut::<OptOutTask>().0 = Some(ok_task);
for _ in 0..5 { // Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update(); app.update();
if !app
.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in
{
break;
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::yield_now();
} }
assert!( assert!(