feat(engine): fire CardFlippedEvent + play flip sound on tableau reveal
When a move exposes a face-down tableau card, game_plugin now fires CardFlippedEvent carrying the flipped card's id. AudioPlugin listens and plays card_flip.wav so the reveal has satisfying audio feedback. Two unit tests verify the event fires only when needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,8 @@ use kira::track::{TrackBuilder, TrackHandle};
|
||||
use kira::tween::Tween;
|
||||
|
||||
use crate::events::{
|
||||
DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
||||
CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent,
|
||||
};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
|
||||
@@ -85,6 +86,7 @@ impl Plugin for AudioPlugin {
|
||||
.add_event::<MoveRejectedEvent>()
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<CardFlippedEvent>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.add_systems(
|
||||
Startup,
|
||||
@@ -98,6 +100,7 @@ impl Plugin for AudioPlugin {
|
||||
play_on_rejected,
|
||||
play_on_new_game,
|
||||
play_on_win,
|
||||
play_on_card_flip,
|
||||
apply_volume_on_change,
|
||||
),
|
||||
);
|
||||
@@ -240,6 +243,19 @@ fn play_on_win(
|
||||
}
|
||||
}
|
||||
|
||||
fn play_on_card_flip(
|
||||
mut events: EventReader<CardFlippedEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
lib: Option<Res<SoundLibrary>>,
|
||||
) {
|
||||
let Some(lib) = lib else {
|
||||
return;
|
||||
};
|
||||
for _ in events.read() {
|
||||
play(&mut audio, &lib.flip);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -175,12 +175,33 @@ fn handle_move(
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
mut won: EventWriter<GameWonEvent>,
|
||||
mut flipped: EventWriter<crate::events::CardFlippedEvent>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
) {
|
||||
for ev in moves.read() {
|
||||
let was_won = game.0.is_won;
|
||||
// Identify the card that will be exposed (and may flip face-up) by the move.
|
||||
// It's the card just below the bottom of the moving stack in the source pile.
|
||||
let flip_candidate_id = game.0.piles.get(&ev.from).and_then(|p| {
|
||||
let n = p.cards.len();
|
||||
if n > ev.count {
|
||||
let c = &p.cards[n - ev.count - 1];
|
||||
if !c.face_up { Some(c.id) } else { None }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
match game.0.move_cards(ev.from.clone(), ev.to.clone(), ev.count) {
|
||||
Ok(()) => {
|
||||
// Fire flip event if the candidate card is now face-up.
|
||||
if let Some(fid) = flip_candidate_id {
|
||||
if game.0.piles.get(&ev.from)
|
||||
.and_then(|p| p.cards.last())
|
||||
.is_some_and(|c| c.id == fid && c.face_up)
|
||||
{
|
||||
flipped.send(crate::events::CardFlippedEvent(fid));
|
||||
}
|
||||
}
|
||||
changed.send(StateChangedEvent);
|
||||
if !was_won && game.0.is_won {
|
||||
won.send(GameWonEvent {
|
||||
@@ -445,4 +466,90 @@ mod tests {
|
||||
|
||||
assert!(!path.exists(), "saved file should be deleted after new game");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_cards_off_face_down_card_fires_card_flipped_event() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut app = test_app(1);
|
||||
// Build a tableau with two cards: a face-down King at bottom, face-up Queen on top.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let t = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t.cards.clear();
|
||||
t.cards.push(Card { id: 900, suit: Suit::Spades, rank: Rank::King, face_up: false });
|
||||
t.cards.push(Card { id: 901, suit: Suit::Hearts, rank: Rank::Queen, face_up: true });
|
||||
}
|
||||
// Set up an empty Tableau(1) for the Queen to land on.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.piles
|
||||
.get_mut(&PileType::Tableau(1))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
|
||||
// A King must be in Tableau(1) for Queen to land there; skip validation
|
||||
// by placing a King first.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let t = gs.0.piles.get_mut(&PileType::Tableau(1)).unwrap();
|
||||
t.cards.push(Card { id: 902, suit: Suit::Clubs, rank: Rank::King, face_up: true });
|
||||
}
|
||||
|
||||
app.world_mut().send_event(MoveRequestEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Tableau(1),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<crate::events::CardFlippedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).collect();
|
||||
assert_eq!(fired.len(), 1, "CardFlippedEvent must fire when a face-down card is exposed");
|
||||
assert_eq!(fired[0].0, 900, "event must carry the flipped card's id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut app = test_app(1);
|
||||
// Build a tableau with two face-up cards.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let t = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t.cards.clear();
|
||||
t.cards.push(Card { id: 910, suit: Suit::Clubs, rank: Rank::King, face_up: true });
|
||||
t.cards.push(Card { id: 911, suit: Suit::Hearts, rank: Rank::Queen, face_up: true });
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.piles
|
||||
.get_mut(&PileType::Tableau(1))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Tableau(1))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card { id: 912, suit: Suit::Spades, rank: Rank::King, face_up: true });
|
||||
}
|
||||
|
||||
app.world_mut().send_event(MoveRequestEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Tableau(1),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<crate::events::CardFlippedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).collect();
|
||||
assert!(fired.is_empty(), "no flip event when exposed card was already face-up");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user