fix(engine): emit RequestRedraw from animation systems — restores 60 fps card animation on Android

Commit 38e4c03 (shipped in v0.40.0) switched Android's focused_mode to
reactive_low_power(100 ms) on the premise that every animation tick
system writes RequestRedraw while it has active work. The writers were
never actually added — the diff only contained the WinitSettings change,
imports, and add_message registrations. Result: with no touch input,
nothing wakes the winit loop during a card slide except the 100 ms
fallback ceiling, so animations render at ~10 fps on Android. Desktop
and web keep Continuous mode, which is why only Android was affected
(reported by Rhys the day v0.40.0 shipped; confirmed absent on v0.39.1).

Adds the missing MessageWriter<RequestRedraw> to all seven systems the
original commit message named:
- advance_card_animations (CardAnimationPlugin)
- advance_card_anims (AnimationPlugin — deal/win cascade/slides)
- tick_shake_anim, tick_settle_anim, tick_foundation_flourish
  (FeedbackAnimPlugin)
- drive_toast_display (AnimationPlugin — toast countdown)
- drive_auto_complete (AutoCompletePlugin — step-interval keepalive)

Each writes one RequestRedraw per frame while active work exists
(including delay phases, which also need per-frame ticks). Regression
test asserts an active CardAnimation emits RequestRedraw and an idle
board does not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-09 11:25:23 -07:00
parent d179d9d582
commit b5c1ba4867
5 changed files with 89 additions and 0 deletions
+14
View File
@@ -252,10 +252,17 @@ fn advance_card_anims(
time: Res<Time>, time: Res<Time>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>, mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; 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(); let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims { for (entity, mut transform, mut anim) in &mut anims {
if anim.delay > 0.0 { if anim.delay > 0.0 {
@@ -558,10 +565,17 @@ fn drive_toast_display(
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
mut queue: ResMut<ToastQueue>, mut queue: ResMut<ToastQueue>,
mut active: ResMut<ActiveToast>, mut active: ResMut<ActiveToast>,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; 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(); let dt = time.delta_secs();
// Tick down the active toast timer. // Tick down the active toast timer.
@@ -147,6 +147,7 @@ fn drive_auto_complete(
time: Res<Time>, time: Res<Time>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
mut moves: MessageWriter<MoveRequestEvent>, mut moves: MessageWriter<MoveRequestEvent>,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if !state.active { if !state.active {
return; return;
@@ -154,6 +155,10 @@ fn drive_auto_complete(
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; 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(); state.cooldown -= time.delta_secs();
if state.cooldown > 0.0 { if state.cooldown > 0.0 {
@@ -33,6 +33,7 @@
use std::f32::consts::PI; use std::f32::consts::PI;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::RequestRedraw;
use super::curves::{MotionCurve, sample_curve}; use super::curves::{MotionCurve, sample_curve};
use super::timing::compute_duration; use super::timing::compute_duration;
@@ -232,10 +233,18 @@ pub(crate) fn advance_card_animations(
time: Res<Time>, time: Res<Time>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>, mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; 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(); let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut q { 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] #[test]
fn card_animation_instant_snaps_on_zero_duration() { fn card_animation_instant_snaps_on_zero_duration() {
let mut app = App::new(); let mut app = App::new();
@@ -276,10 +276,16 @@ fn tick_shake_anim(
time: Res<Time>, time: Res<Time>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>, mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; 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(); let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims { for (entity, mut transform, mut anim) in &mut anims {
anim.elapsed += dt; anim.elapsed += dt;
@@ -356,10 +362,16 @@ fn tick_settle_anim(
time: Res<Time>, time: Res<Time>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>, mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; 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(); let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims { for (entity, mut transform, mut anim) in &mut anims {
anim.elapsed += dt; anim.elapsed += dt;
@@ -581,10 +593,16 @@ fn tick_foundation_flourish(
(Entity, &mut Sprite, &mut FoundationMarkerFlourish), (Entity, &mut Sprite, &mut FoundationMarkerFlourish),
Without<FoundationFlourish>, Without<FoundationFlourish>,
>, >,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; 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(); let dt = time.delta_secs();
// Advance the King's scale pulse. // Advance the King's scale pulse.