fix(engine): restore 60 fps Android card animations + dead-code sweep #166

Merged
funman300 merged 2 commits from fix/android-anim-redraw into master 2026-07-09 18:51:07 +00:00
5 changed files with 89 additions and 0 deletions
Showing only changes of commit b5c1ba4867 - Show all commits
+14
View File
@@ -252,10 +252,17 @@ fn advance_card_anims(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Keep the winit loop awake at full frame rate while slides (including
// staggered deals still in their delay phase) are in flight — required
// for Android's reactive_low_power focused_mode.
if !anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims {
if anim.delay > 0.0 {
@@ -558,10 +565,17 @@ fn drive_toast_display(
paused: Option<Res<PausedResource>>,
mut queue: ResMut<ToastQueue>,
mut active: ResMut<ActiveToast>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Keep the loop ticking while a toast is displayed or queued so the
// countdown advances and the despawn frame isn't held hostage by
// Android's reactive_low_power wake ceiling.
if active.entity.is_some() || !queue.0.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
// Tick down the active toast timer.
@@ -147,6 +147,7 @@ fn drive_auto_complete(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut moves: MessageWriter<MoveRequestEvent>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if !state.active {
return;
@@ -154,6 +155,10 @@ fn drive_auto_complete(
if paused.is_some_and(|p| p.0) {
return;
}
// Keepalive: the step-interval cooldown only advances on frames that
// actually run, so keep the winit loop awake for the whole burst under
// Android's reactive_low_power focused_mode.
redraw.write(RequestRedraw);
state.cooldown -= time.delta_secs();
if state.cooldown > 0.0 {
@@ -33,6 +33,7 @@
use std::f32::consts::PI;
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use super::curves::{MotionCurve, sample_curve};
use super::timing::compute_duration;
@@ -232,10 +233,18 @@ pub(crate) fn advance_card_animations(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Keep the winit event loop awake while any animation (including one
// still in its delay phase) needs per-frame ticks. Without this,
// Android's reactive_low_power focused_mode only wakes at its 100 ms
// ceiling and card slides render at ~10 fps.
if !q.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut q {
@@ -307,6 +307,49 @@ mod tests {
);
}
/// Regression test for the v0.40.0 Android animation-lag bug: commit
/// 38e4c03 switched Android to `reactive_low_power` focused_mode on the
/// premise that animation systems write `RequestRedraw` while active,
/// but the writers were never added — card slides rendered at the 100 ms
/// wake ceiling (~10 fps). Active animations MUST emit `RequestRedraw`
/// every frame; an idle board must not.
#[test]
fn active_card_animation_requests_redraw() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(CardAnimationPlugin);
// Idle board: no redraw requests.
app.update();
assert!(
app.world().resource::<Messages<RequestRedraw>>().is_empty(),
"no RequestRedraw expected while no animation is active"
);
app.world_mut().spawn((
Transform::from_translation(Vec3::ZERO),
CardAnimation {
start: Vec2::ZERO,
end: Vec2::new(100.0, 0.0),
elapsed: 0.0,
duration: 1.0,
curve: MotionCurve::Responsive,
delay: 0.0,
start_z: 0.0,
end_z: 0.0,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
},
));
app.update();
assert!(
!app.world().resource::<Messages<RequestRedraw>>().is_empty(),
"an active CardAnimation must write RequestRedraw each frame to \
sustain the reactive render loop"
);
}
#[test]
fn card_animation_instant_snaps_on_zero_duration() {
let mut app = App::new();
@@ -276,10 +276,16 @@ fn tick_shake_anim(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Sustain full-rate frames for the shake under Android's
// reactive_low_power focused_mode.
if !anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims {
anim.elapsed += dt;
@@ -356,10 +362,16 @@ fn tick_settle_anim(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Sustain full-rate frames for the settle bounce under Android's
// reactive_low_power focused_mode.
if !anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims {
anim.elapsed += dt;
@@ -581,10 +593,16 @@ fn tick_foundation_flourish(
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
Without<FoundationFlourish>,
>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Sustain full-rate frames for the flourish under Android's
// reactive_low_power focused_mode.
if !card_anims.is_empty() || !marker_anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
// Advance the King's scale pulse.