feat(toast): wire ToastVariant::Error for invalid-move feedback
Resume-prompt Option C — first in-engine consumer of `ToastVariant::Error`. The variant has had a slot in the enum since v0.20.0's toast system landed; this commit wires a real driver event so the slot is no longer dead code. ### Driver: MoveRejectedEvent When a player tries an illegal placement (drops dragged cards on a real pile but the move violates the rules), `MoveRejectedEvent` fires. The existing rejection-feedback chain plays `card_invalid.wav` (audio cue) and triggers the destination-pile shake (visual cue via `feedback_anim_plugin`). This commit adds a third leg — a 2-second pink-bordered Error toast reading "Invalid move" — primarily for accessibility: - **Audio cue alone** doesn't help deaf players. - **Visual shake alone** is brief and easy to miss for low-vision players or anyone with reduce-motion enabled (which gates the shake's animation timing). - **Toast text** is persistent ~2 s, readable, and unambiguous. The three legs together cover the major perception channels. ### Implementation New `handle_move_rejected_toast` system in `animation_plugin` mirrors the shape of `handle_xp_awarded_toast` — read events, fire `spawn_toast(commands, "Invalid move", 2.0, ToastVariant::Error)`. Registered in the plugin's Update set between `handle_xp_awarded_toast` and `tick_toasts` so the toast spawn pipeline picks it up the same frame the event fires. `AnimationPlugin::build` gains `.add_message::<MoveRejectedEvent>()` so the message is initialized when the plugin runs under MinimalPlugins (tests). The message is also registered by `feedback_anim_plugin` — Bevy's `add_message` is idempotent, so both registrations coexist cleanly. Also drops the `#[allow(dead_code)]` from `ToastVariant::Error` (stale now that the variant has a real consumer) and updates the variant's doc comment to point at `handle_move_rejected_toast`. ### Test New `move_rejected_event_spawns_error_toast` pins the wiring: firing a `MoveRejectedEvent` spawns exactly one `ToastOverlay` on the next tick. Matches the shape of the existing `info_toast_event_spawns_toast_overlay` test. 1195 passing (+1 from prior 1194). Workspace clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -21,8 +21,9 @@ use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
|
|||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
||||||
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
||||||
use crate::events::{InfoToastEvent, XpAwardedEvent};
|
use crate::events::{
|
||||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, XpAwardedEvent,
|
||||||
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
@@ -162,6 +163,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
.add_message::<ChallengeAdvancedEvent>()
|
.add_message::<ChallengeAdvancedEvent>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
.init_resource::<ToastQueue>()
|
.init_resource::<ToastQueue>()
|
||||||
@@ -183,6 +185,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
handle_settings_toast,
|
handle_settings_toast,
|
||||||
handle_auto_complete_toast,
|
handle_auto_complete_toast,
|
||||||
handle_xp_awarded_toast,
|
handle_xp_awarded_toast,
|
||||||
|
handle_move_rejected_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
(enqueue_toasts, drive_toast_display).chain(),
|
(enqueue_toasts, drive_toast_display).chain(),
|
||||||
)
|
)
|
||||||
@@ -565,9 +568,11 @@ pub enum ToastVariant {
|
|||||||
/// event; kept so future warning-flavoured toasts have a slot.
|
/// event; kept so future warning-flavoured toasts have a slot.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
Warning,
|
Warning,
|
||||||
/// Failure / rejected action — pink border. Currently unused; kept so
|
/// Failure / rejected action — pink border. Used by
|
||||||
/// future error-flavoured toasts have a slot.
|
/// [`handle_move_rejected_toast`] for illegal-placement
|
||||||
#[allow(dead_code)]
|
/// feedback; the third leg of the rejection-feedback stool
|
||||||
|
/// alongside `card_invalid.wav` (audio) and the destination-
|
||||||
|
/// pile shake (visual).
|
||||||
Error,
|
Error,
|
||||||
/// Reward / milestone — lavender border. Used for XP awards,
|
/// Reward / milestone — lavender border. Used for XP awards,
|
||||||
/// achievement unlocks, level-ups, daily/weekly/challenge completions.
|
/// achievement unlocks, level-ups, daily/weekly/challenge completions.
|
||||||
@@ -622,6 +627,30 @@ fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpA
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns a 2-second pink-bordered Error toast when the player tries an
|
||||||
|
/// illegal placement (`MoveRejectedEvent`). Adds a third leg to the
|
||||||
|
/// existing rejection feedback stool — `card_invalid.wav` already plays
|
||||||
|
/// (audio cue) and `feedback_anim_plugin::queue_shake_for_rejected_move`
|
||||||
|
/// fires the destination-pile shake (visual cue). The toast is the
|
||||||
|
/// accessibility-focused leg: persistent ~2 s text that's readable for
|
||||||
|
/// deaf players and impossible to miss for players who blink during the
|
||||||
|
/// shake. First in-engine consumer of `ToastVariant::Error` — exercises
|
||||||
|
/// the variant's pink border accent and the design-system "rejected
|
||||||
|
/// action" semantic.
|
||||||
|
fn handle_move_rejected_toast(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut events: MessageReader<MoveRejectedEvent>,
|
||||||
|
) {
|
||||||
|
for _ev in events.read() {
|
||||||
|
spawn_toast(
|
||||||
|
&mut commands,
|
||||||
|
"Invalid move".to_string(),
|
||||||
|
2.0,
|
||||||
|
ToastVariant::Error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ticks down `ToastTimer` on each toast and despawns it when the timer expires.
|
/// Ticks down `ToastTimer` on each toast and despawns it when the timer expires.
|
||||||
///
|
///
|
||||||
/// Skipped while the game is paused so toast countdowns freeze along with the
|
/// Skipped while the game is paused so toast countdowns freeze along with the
|
||||||
@@ -966,6 +995,44 @@ mod tests {
|
|||||||
let _ = count;
|
let _ = count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_rejected_event_spawns_error_toast() {
|
||||||
|
// The first in-engine consumer of `ToastVariant::Error`. Firing
|
||||||
|
// a `MoveRejectedEvent` (illegal placement) must spawn exactly
|
||||||
|
// one `ToastOverlay` carrying the rejection-feedback message.
|
||||||
|
// Pairs the existing audio (`card_invalid.wav`) and visual
|
||||||
|
// (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback
|
||||||
|
// with an accessibility-focused readable text cue.
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
|
||||||
|
// Baseline: no toast overlays exist before the event.
|
||||||
|
let before = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&ToastOverlay>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
|
||||||
|
app.world_mut().write_message(MoveRejectedEvent {
|
||||||
|
from: PileType::Tableau(0),
|
||||||
|
to: PileType::Tableau(1),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let after = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&ToastOverlay>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
after,
|
||||||
|
before + 1,
|
||||||
|
"MoveRejectedEvent must spawn exactly one error toast",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Task #67 — Toast queue pure-function tests
|
// Task #67 — Toast queue pure-function tests
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user