feat(engine): gate decorative motion animations under reduce_motion_mode
Android Release / build-apk (push) Successful in 4m27s

ScorePulse, ScoreFloater, StreakFlourish (hud_plugin) and ShakeAnim,
FoundationFlourish, FoundationMarkerFlourish (feedback_anim_plugin) are
now all suppressed when Settings::reduce_motion_mode is on. Events are
still drained so no messages accumulate. Closes the remaining gap from
the v0.21.1 "future scope" footnote for the reduce-motion flag.

Three new tests pin the gates:
- score_change_skips_pulse_and_floater_under_reduce_motion
- shake_anim_skipped_under_reduce_motion
- foundation_flourish_skipped_under_reduce_motion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-17 23:18:11 -07:00
parent 6f5cebdb02
commit 87aec5bdf2
2 changed files with 140 additions and 1 deletions
+103 -1
View File
@@ -228,10 +228,15 @@ impl Plugin for FeedbackAnimPlugin {
fn start_shake_anim(
mut events: MessageReader<MoveRejectedEvent>,
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
card_entities: Query<(Entity, &CardEntity, &Transform)>,
mut commands: Commands,
) {
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
for ev in events.read() {
if reduce_motion {
continue;
}
let dest_pile = &ev.to;
// Collect the card ids that belong to the destination pile.
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
@@ -489,11 +494,16 @@ pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
fn start_foundation_flourish(
mut events: MessageReader<FoundationCompletedEvent>,
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
card_entities: Query<(Entity, &CardEntity)>,
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
mut commands: Commands,
) {
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
for ev in events.read() {
if reduce_motion {
continue;
}
let pile_type = PileType::Foundation(ev.slot);
// Top card of the completed foundation is the King.
let Some(king_id) = game
@@ -785,7 +795,7 @@ mod tests {
#[test]
fn deal_stagger_jitter_varies_across_card_ids() {
// 52 cards should produce more than a couple distinct jitter factors;
// a constant function would return one value for all ids.
// a constant function would return one function for all ids.
use std::collections::HashSet;
let unique: HashSet<u64> = (0u32..52)
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
@@ -796,4 +806,96 @@ mod tests {
unique.len()
);
}
// -----------------------------------------------------------------------
// Reduce-motion gates — ShakeAnim, FoundationFlourish
// -----------------------------------------------------------------------
/// `start_shake_anim` must not insert `ShakeAnim` when `reduce_motion_mode`
/// is on, even when the event targets a pile that has card entities present.
#[test]
fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
}));
app.update();
// Pick a card from Tableau(0) so the event refers to a real pile.
let dest_pile = PileType::Tableau(0);
let card_id = app
.world()
.resource::<GameStateResource>()
.0
.piles
.get(&dest_pile)
.and_then(|p| p.cards.last())
.map(|c| c.id)
.expect("Tableau(0) should have at least one card in a fresh game");
// Spawn a minimal CardEntity matching that id so the system would
// find it and insert ShakeAnim if the gate were absent.
app.world_mut().spawn((
CardEntity { card_id },
Transform::default(),
));
app.world_mut()
.resource_mut::<Messages<MoveRejectedEvent>>()
.write(MoveRejectedEvent {
from: PileType::Stock,
to: dest_pile,
count: 1,
});
app.update();
let shake_count = app
.world_mut()
.query::<&ShakeAnim>()
.iter(app.world())
.count();
assert_eq!(shake_count, 0, "ShakeAnim must not be inserted under reduce-motion");
}
/// `start_foundation_flourish` must not insert `FoundationFlourish` when
/// `reduce_motion_mode` is on.
#[test]
fn foundation_flourish_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
}));
app.update();
app.world_mut()
.resource_mut::<Messages<FoundationCompletedEvent>>()
.write(FoundationCompletedEvent {
slot: 0,
suit: solitaire_core::card::Suit::Spades,
});
app.update();
let flourish_count = app
.world_mut()
.query::<&FoundationFlourish>()
.iter(app.world())
.count();
assert_eq!(flourish_count, 0, "FoundationFlourish must not be inserted under reduce-motion");
}
}
+37
View File
@@ -1755,6 +1755,11 @@ fn detect_score_change(
return;
}
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
if reduce_motion {
return;
}
let speed = settings
.as_ref()
.map(|s| s.0.animation_speed)
@@ -1928,6 +1933,9 @@ fn start_streak_flourish(
let Some(latest) = events.read().last() else {
return;
};
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
return;
}
let speed = settings
.as_ref()
.map(|s| s.0.animation_speed)
@@ -3011,6 +3019,35 @@ mod tests {
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
}
// -----------------------------------------------------------------------
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
// -----------------------------------------------------------------------
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
#[test]
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
use solitaire_data::Settings;
let mut app = headless_app();
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
}));
// +100 would normally create both a ScorePulse and a ScoreFloater.
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
app.update();
assert_eq!(
count_with::<ScorePulse>(&mut app),
0,
"ScorePulse must not spawn under reduce-motion"
);
assert_eq!(
count_with::<ScoreFloater>(&mut app),
0,
"ScoreFloater must not spawn under reduce-motion"
);
}
// -----------------------------------------------------------------------
// Phase 2: keyboard focus ring — HUD action bar
// -----------------------------------------------------------------------