feat(engine): gate decorative motion animations under reduce_motion_mode
Android Release / build-apk (push) Successful in 4m27s
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:
@@ -228,10 +228,15 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
fn start_shake_anim(
|
fn start_shake_anim(
|
||||||
mut events: MessageReader<MoveRejectedEvent>,
|
mut events: MessageReader<MoveRejectedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
|
if reduce_motion {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let dest_pile = &ev.to;
|
let dest_pile = &ev.to;
|
||||||
// Collect the card ids that belong to the destination pile.
|
// Collect the card ids that belong to the destination pile.
|
||||||
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
|
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(
|
fn start_foundation_flourish(
|
||||||
mut events: MessageReader<FoundationCompletedEvent>,
|
mut events: MessageReader<FoundationCompletedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
card_entities: Query<(Entity, &CardEntity)>,
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
|
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
|
if reduce_motion {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let pile_type = PileType::Foundation(ev.slot);
|
let pile_type = PileType::Foundation(ev.slot);
|
||||||
// Top card of the completed foundation is the King.
|
// Top card of the completed foundation is the King.
|
||||||
let Some(king_id) = game
|
let Some(king_id) = game
|
||||||
@@ -785,7 +795,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn deal_stagger_jitter_varies_across_card_ids() {
|
fn deal_stagger_jitter_varies_across_card_ids() {
|
||||||
// 52 cards should produce more than a couple distinct jitter factors;
|
// 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;
|
use std::collections::HashSet;
|
||||||
let unique: HashSet<u64> = (0u32..52)
|
let unique: HashSet<u64> = (0u32..52)
|
||||||
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
|
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
|
||||||
@@ -796,4 +806,96 @@ mod tests {
|
|||||||
unique.len()
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1755,6 +1755,11 @@ fn detect_score_change(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
|
if reduce_motion {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let speed = settings
|
let speed = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.0.animation_speed)
|
.map(|s| s.0.animation_speed)
|
||||||
@@ -1928,6 +1933,9 @@ fn start_streak_flourish(
|
|||||||
let Some(latest) = events.read().last() else {
|
let Some(latest) = events.read().last() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let speed = settings
|
let speed = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.0.animation_speed)
|
.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);
|
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
|
// Phase 2: keyboard focus ring — HUD action bar
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user