Compare commits
4 Commits
73c7f50f74
...
6d061d23a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d061d23a1 | |||
| 25f22231a6 | |||
| c66ff26d1d | |||
| cd792b20b2 |
@@ -8,6 +8,10 @@
|
|||||||
data/
|
data/
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# ruflo runtime state
|
||||||
|
agentdb.rvf
|
||||||
|
agentdb.rvf.lock
|
||||||
|
|
||||||
# IDE project files
|
# IDE project files
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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+2660–U+2666) are not in
|
// wired here explicitly — the suit glyphs (U+2660–U+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;
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
Reference in New Issue
Block a user