Compare commits

..

4 Commits

Author SHA1 Message Date
funman300 71c0c273a1 chore(deps): migrate kira 0.9 → 0.12
- Import paths simplified: manager/tween modules re-exported from kira root
  (AudioManager, AudioManagerSettings, DefaultBackend, Tween all via kira::*)
- Volume::Amplitude removed; replaced with Value<Decibels> using a new
  amplitude_to_decibels() helper (20*log10 conversion, clamps to SILENCE)
- output_destination field removed from StaticSoundSettings; sounds routed
  to sub-tracks by calling TrackHandle::play() directly instead of
  AudioManager::play()
- set_volume() now accepts f32 (Decibels) not f64
- start_ambient_loop signature updated to take &mut Option<TrackHandle>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:54:01 -07:00
funman300 21d0c289b5 chore(deps): migrate to Bevy 0.18
- BorderRadius is no longer a Component; moved into Node.border_radius
  field at all 15 spawn sites across 6 plugin files
- Events<T> renamed to Messages<T> in test code (12 files)
- KeyboardEvents SystemParam renamed to KeyboardMessages to match the
  MessageWriter rename done in the 0.17 hop
- WindowResolution::from((f32,f32)) removed; use (u32,u32) tuple in main.rs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:48:41 -07:00
funman300 648cd44387 chore(deps): migrate to Bevy 0.17
- Event/EventReader/EventWriter renamed to Message/MessageReader/MessageWriter
- add_event → add_message for all 67 call sites
- ScrollPosition changed to tuple struct ScrollPosition(Vec2)
- CursorIcon import moved from bevy::winit::cursor to bevy::window
- WindowResolution::from((f32,f32)) removed — use (u32,u32) tuple
- World::send_event → World::write_message in test code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:04:44 -07:00
funman300 c8553dc8c5 chore(deps): migrate to Bevy 0.16, axum 0.8, and other package updates
- Bump bevy 0.15 → 0.16; fixes all breaking API changes:
  ChildBuilder → ChildSpawnerCommands, Parent → ChildOf,
  despawn_descendants → despawn_related::<Children>(),
  despawn_recursive → despawn (now recursive by default),
  EventWriter::send → write, Query::{get_single,get_single_mut}
  → {single,single_mut}, ChildOf::get → parent()
- Bump axum 0.7 → 0.8; remove axum::async_trait from FromRequestParts
- Bump tower_governor 0.4 → 0.8; fix GovernorLayer::new() API
- Bump jsonwebtoken 9 → 10 with rust_crypto feature only
- Bump thiserror 1 → 2, dirs 5 → 6, bcrypt 0.15 → 0.19,
  reqwest 0.12 → 0.13 (rustls feature rename)
- Regenerate .sqlx offline cache for sqlx compile-time query checks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:31:12 -07:00
36 changed files with 2735 additions and 1500 deletions
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "36bab3ca8f126c66a6f4865d2699702689518bd3a796c374f932aba0434a4c94"
}
Generated
+2102 -890
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -20,27 +20,27 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "1"
thiserror = "2"
rand = "0.8"
async-trait = "0.1"
tokio = { version = "1", features = ["full"] }
dirs = "5"
dirs = "6"
keyring = "2"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" }
bevy = "0.15"
kira = "0.9"
bevy = "0.18"
kira = "0.12"
axum = "0.7"
axum = "0.8"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
jsonwebtoken = "9"
bcrypt = "0.15"
tower_governor = "0.4"
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
bcrypt = "0.19"
tower_governor = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
+1 -1
View File
@@ -21,7 +21,7 @@ fn main() {
DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Solitaire Quest".into(),
resolution: (1280.0, 800.0).into(),
resolution: (1280u32, 800u32).into(),
..default()
}),
..default()
+23 -23
View File
@@ -70,9 +70,9 @@ impl Plugin for AchievementPlugin {
app.insert_resource(AchievementsResource(records))
.insert_resource(AchievementsStoragePath(self.storage_path.clone()))
.add_event::<AchievementUnlockedEvent>()
.add_event::<GameWonEvent>()
.add_event::<XpAwardedEvent>()
.add_message::<AchievementUnlockedEvent>()
.add_message::<GameWonEvent>()
.add_message::<XpAwardedEvent>()
// Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
// (so daily_challenge_streak is up to date for daily_devotee).
@@ -89,10 +89,10 @@ impl Plugin for AchievementPlugin {
#[allow(clippy::too_many_arguments)]
fn evaluate_on_win(
mut wins: EventReader<GameWonEvent>,
mut unlocks: EventWriter<AchievementUnlockedEvent>,
mut levelups: EventWriter<LevelUpEvent>,
mut xp_awarded: EventWriter<XpAwardedEvent>,
mut wins: MessageReader<GameWonEvent>,
mut unlocks: MessageWriter<AchievementUnlockedEvent>,
mut levelups: MessageWriter<LevelUpEvent>,
mut xp_awarded: MessageWriter<XpAwardedEvent>,
game: Res<GameStateResource>,
stats: Res<StatsResource>,
path: Res<AchievementsStoragePath>,
@@ -156,10 +156,10 @@ fn evaluate_on_win(
}
}
Reward::BonusXp(amount) => {
xp_awarded.send(XpAwardedEvent { amount });
xp_awarded.write(XpAwardedEvent { amount });
let prev_level = progress.0.add_xp(amount);
if progress.0.leveled_up_from(prev_level) {
levelups.send(LevelUpEvent {
levelups.write(LevelUpEvent {
previous_level: prev_level,
new_level: progress.0.level,
total_xp: progress.0.total_xp,
@@ -173,7 +173,7 @@ fn evaluate_on_win(
record.reward_granted = true;
}
unlocks.send(AchievementUnlockedEvent(record.clone()));
unlocks.write(AchievementUnlockedEvent(record.clone()));
}
if achievements_changed {
@@ -211,8 +211,8 @@ fn toggle_achievements_screen(
if !keys.just_pressed(KeyCode::KeyA) {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_achievements_screen(&mut commands, &achievements.0);
}
@@ -248,10 +248,10 @@ fn spawn_achievements_screen(commands: &mut Commands, records: &[AchievementReco
min_width: Val::Px(380.0),
max_height: Val::Percent(80.0),
overflow: Overflow::clip_y(),
border_radius: BorderRadius::all(Val::Px(8.0)),
..default()
},
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
BorderRadius::all(Val::Px(8.0)),
))
.with_children(|card| {
// Header
@@ -398,7 +398,7 @@ mod tests {
// StatsPlugin runs update_stats_on_win first (after GameMutation); that
// bumps games_won to 1 before evaluate_on_win reads StatsResource.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
@@ -415,7 +415,7 @@ mod tests {
assert!(unlocked_first_win);
// Verify the event was emitted.
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
assert!(fired.contains(&"first_win".to_string()));
@@ -425,7 +425,7 @@ mod tests {
fn repeated_win_does_not_refire_already_unlocked_achievement() {
let mut app = headless_app();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
@@ -433,16 +433,16 @@ mod tests {
// Clear events from first win.
app.world_mut()
.resource_mut::<Events<AchievementUnlockedEvent>>()
.resource_mut::<Messages<AchievementUnlockedEvent>>()
.clear();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
assert!(
@@ -462,13 +462,13 @@ mod tests {
let mut app = headless_app();
// "no_undo" achievement awards BonusXp(25). Trigger it by sending a
// GameWonEvent with undo_count == 0 (default) and enough stats to match.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let events = app.world().resource::<Events<XpAwardedEvent>>();
let events = app.world().resource::<Messages<XpAwardedEvent>>();
let mut cursor = events.get_cursor();
let xp_events: Vec<u64> = cursor.read(events).map(|e| e.amount).collect();
// The no_undo achievement (BonusXp 25) must have fired an XpAwardedEvent.
@@ -487,14 +487,14 @@ mod tests {
.0
.undo_count = 1;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
// "no_undo" awards BonusXp(25). If undo was used it must NOT fire.
let events = app.world().resource::<Events<XpAwardedEvent>>();
let events = app.world().resource::<Messages<XpAwardedEvent>>();
let mut cursor = events.get_cursor();
let xp_events: Vec<u64> = cursor.read(events).map(|e| e.amount).collect();
assert!(
+31 -31
View File
@@ -156,18 +156,18 @@ impl Plugin for AnimationPlugin {
// Register the events this plugin consumes so tests that don't include
// GamePlugin can still run AnimationPlugin in isolation. Double-registration
// is idempotent in Bevy.
app.add_event::<GameWonEvent>()
.add_event::<AchievementUnlockedEvent>()
.add_event::<LevelUpEvent>()
.add_event::<DailyChallengeCompletedEvent>()
.add_event::<DailyGoalAnnouncementEvent>()
.add_event::<WeeklyGoalCompletedEvent>()
.add_event::<TimeAttackEndedEvent>()
.add_event::<ChallengeAdvancedEvent>()
.add_event::<SettingsChangedEvent>()
.add_event::<NewGameConfirmEvent>()
.add_event::<InfoToastEvent>()
.add_event::<XpAwardedEvent>()
app.add_message::<GameWonEvent>()
.add_message::<AchievementUnlockedEvent>()
.add_message::<LevelUpEvent>()
.add_message::<DailyChallengeCompletedEvent>()
.add_message::<DailyGoalAnnouncementEvent>()
.add_message::<WeeklyGoalCompletedEvent>()
.add_message::<TimeAttackEndedEvent>()
.add_message::<ChallengeAdvancedEvent>()
.add_message::<SettingsChangedEvent>()
.add_message::<NewGameConfirmEvent>()
.add_message::<InfoToastEvent>()
.add_message::<XpAwardedEvent>()
.init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>()
.init_resource::<ActiveToast>()
@@ -207,7 +207,7 @@ fn init_slide_duration(
}
fn sync_slide_duration(
mut events: EventReader<SettingsChangedEvent>,
mut events: MessageReader<SettingsChangedEvent>,
mut dur: ResMut<EffectiveSlideDuration>,
) {
for ev in events.read() {
@@ -245,7 +245,7 @@ fn advance_card_anims(
fn handle_win_cascade(
mut commands: Commands,
mut events: EventReader<GameWonEvent>,
mut events: MessageReader<GameWonEvent>,
cards: Query<(Entity, &Transform), With<CardEntity>>,
layout: Option<Res<LayoutResource>>,
settings: Option<Res<SettingsResource>>,
@@ -290,7 +290,7 @@ fn handle_win_cascade(
fn handle_achievement_toast(
mut commands: Commands,
mut events: EventReader<AchievementUnlockedEvent>,
mut events: MessageReader<AchievementUnlockedEvent>,
) {
for ev in events.read() {
spawn_toast(
@@ -301,7 +301,7 @@ fn handle_achievement_toast(
}
}
fn handle_levelup_toast(mut commands: Commands, mut events: EventReader<LevelUpEvent>) {
fn handle_levelup_toast(mut commands: Commands, mut events: MessageReader<LevelUpEvent>) {
for ev in events.read() {
spawn_toast(
&mut commands,
@@ -313,7 +313,7 @@ fn handle_levelup_toast(mut commands: Commands, mut events: EventReader<LevelUpE
fn handle_daily_goal_announcement_toast(
mut commands: Commands,
mut events: EventReader<DailyGoalAnnouncementEvent>,
mut events: MessageReader<DailyGoalAnnouncementEvent>,
) {
for ev in events.read() {
spawn_toast(&mut commands, format!("Goal: {}", ev.0), DAILY_TOAST_SECS);
@@ -322,7 +322,7 @@ fn handle_daily_goal_announcement_toast(
fn handle_daily_toast(
mut commands: Commands,
mut events: EventReader<DailyChallengeCompletedEvent>,
mut events: MessageReader<DailyChallengeCompletedEvent>,
) {
for ev in events.read() {
spawn_toast(
@@ -335,7 +335,7 @@ fn handle_daily_toast(
fn handle_weekly_toast(
mut commands: Commands,
mut events: EventReader<WeeklyGoalCompletedEvent>,
mut events: MessageReader<WeeklyGoalCompletedEvent>,
) {
for ev in events.read() {
spawn_toast(
@@ -348,7 +348,7 @@ fn handle_weekly_toast(
fn handle_time_attack_toast(
mut commands: Commands,
mut events: EventReader<TimeAttackEndedEvent>,
mut events: MessageReader<TimeAttackEndedEvent>,
) {
for ev in events.read() {
spawn_toast(
@@ -361,7 +361,7 @@ fn handle_time_attack_toast(
fn handle_challenge_toast(
mut commands: Commands,
mut events: EventReader<ChallengeAdvancedEvent>,
mut events: MessageReader<ChallengeAdvancedEvent>,
) {
for ev in events.read() {
spawn_toast(
@@ -374,7 +374,7 @@ fn handle_challenge_toast(
fn handle_settings_toast(
mut commands: Commands,
mut events: EventReader<SettingsChangedEvent>,
mut events: MessageReader<SettingsChangedEvent>,
mut last_sfx: Local<Option<f32>>,
mut last_music: Local<Option<f32>>,
) {
@@ -417,7 +417,7 @@ fn handle_auto_complete_toast(
fn handle_new_game_confirm_toast(
mut commands: Commands,
mut events: EventReader<NewGameConfirmEvent>,
mut events: MessageReader<NewGameConfirmEvent>,
) {
for _ in events.read() {
spawn_toast(&mut commands, "Press N again to start a new game".to_string(), 3.0);
@@ -430,7 +430,7 @@ fn handle_new_game_confirm_toast(
/// decouples event production from rendering so multiple simultaneous events do
/// not cause overlapping toast text on screen.
fn enqueue_toasts(
mut events: EventReader<InfoToastEvent>,
mut events: MessageReader<InfoToastEvent>,
mut queue: ResMut<ToastQueue>,
) {
for ev in events.read() {
@@ -465,7 +465,7 @@ fn drive_toast_display(
active.timer -= dt;
if active.timer <= 0.0 {
// Despawn the toast entity and clear the active slot.
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
active.entity = None;
active.timer = 0.0;
}
@@ -509,7 +509,7 @@ fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
.id()
}
fn handle_xp_awarded_toast(mut commands: Commands, mut events: EventReader<XpAwardedEvent>) {
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
for ev in events.read() {
spawn_toast(&mut commands, format!("+{} XP", ev.amount), 3.0);
}
@@ -532,7 +532,7 @@ fn tick_toasts(
for (entity, mut timer) in &mut toasts {
timer.0 -= dt;
if timer.0 <= 0.0 {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
}
}
@@ -709,7 +709,7 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.world_mut().send_event(InfoToastEvent("hello".to_string()));
app.world_mut().write_message(InfoToastEvent("hello".to_string()));
app.update();
let count = app
@@ -745,7 +745,7 @@ mod tests {
fn toast_queue_enqueues_on_event() {
let mut app = queue_app();
app.world_mut()
.send_event(InfoToastEvent("test message".to_string()));
.write_message(InfoToastEvent("test message".to_string()));
app.update();
// After one update the message should have been consumed (shown) or is
// still in the queue — either way we verify the system processed it by
@@ -776,7 +776,7 @@ mod tests {
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
let fast_settings = Settings { animation_speed: AnimSpeed::Fast, ..Default::default() };
app.world_mut().send_event(SettingsChangedEvent(fast_settings));
app.world_mut().write_message(SettingsChangedEvent(fast_settings));
app.update();
let dur = app.world().resource::<EffectiveSlideDuration>().slide_secs;
@@ -795,7 +795,7 @@ mod tests {
assert_eq!(before, 0, "no animations before win");
app.world_mut()
.send_event(GameWonEvent { score: 500, time_seconds: 60 });
.write_message(GameWonEvent { score: 500, time_seconds: 60 });
app.update();
let after = app
+71 -56
View File
@@ -23,13 +23,10 @@
use std::io::Cursor;
use bevy::prelude::*;
use kira::manager::backend::DefaultBackend;
use kira::manager::{AudioManager, AudioManagerSettings};
use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
use kira::sound::Region;
use kira::track::{TrackBuilder, TrackHandle};
use kira::tween::Tween;
use kira::Volume;
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
use crate::events::{
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
@@ -46,6 +43,16 @@ const RECYCLE_VOLUME: f64 = 0.5;
/// Volume amplitude for the ambient music loop placeholder.
const AMBIENT_VOLUME: f64 = 0.05;
/// Converts a linear amplitude (0.01.0+) to the `Decibels` type used by
/// kira 0.12. Clamps to `Decibels::SILENCE` for non-positive amplitudes.
fn amplitude_to_decibels(amplitude: f32) -> Decibels {
if amplitude <= 0.0 {
Decibels::SILENCE
} else {
Decibels(20.0 * amplitude.log10())
}
}
/// Returns `true` when a `DrawRequestEvent` will recycle the waste pile back
/// to stock rather than drawing a new card.
///
@@ -56,7 +63,7 @@ fn is_recycle(stock_len: usize) -> bool {
}
/// Pre-decoded sound effects. Cheap to clone (frames are an `Arc<[Frame]>`),
/// so we hand a fresh handle to `manager.play()` on every event.
/// so we hand a fresh handle to `track.play()` on every event.
#[derive(Resource, Clone)]
pub struct SoundLibrary {
pub deal: StaticSoundData,
@@ -104,7 +111,7 @@ impl Plugin for AudioPlugin {
warn!("failed to decode embedded SFX assets; SFX disabled");
}
let (sfx_track, music_track) = match manager.as_mut() {
let (sfx_track, mut music_track) = match manager.as_mut() {
Some(mgr) => {
let sfx = mgr.add_sub_track(TrackBuilder::default()).ok();
let music = mgr.add_sub_track(TrackBuilder::default()).ok();
@@ -116,7 +123,7 @@ impl Plugin for AudioPlugin {
// Start the ambient loop placeholder (card_flip.wav looped at very low
// volume through music_track).
let ambient_handle =
start_ambient_loop(manager.as_mut(), library.as_ref(), &music_track);
start_ambient_loop(manager.as_mut(), library.as_ref(), &mut music_track);
app.insert_non_send_resource(AudioState {
manager,
@@ -130,15 +137,15 @@ impl Plugin for AudioPlugin {
app.insert_resource(lib);
}
app.add_event::<DrawRequestEvent>()
.add_event::<MoveRequestEvent>()
.add_event::<MoveRejectedEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<GameWonEvent>()
.add_event::<CardFlippedEvent>()
.add_event::<CardFaceRevealedEvent>()
.add_event::<UndoRequestEvent>()
.add_event::<SettingsChangedEvent>()
app.add_message::<DrawRequestEvent>()
.add_message::<MoveRequestEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<GameWonEvent>()
.add_message::<CardFlippedEvent>()
.add_message::<CardFaceRevealedEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<SettingsChangedEvent>()
.add_systems(Startup, apply_initial_volume)
.add_systems(
Update,
@@ -190,20 +197,22 @@ fn decode(bytes: &'static [u8]) -> Option<StaticSoundData> {
fn start_ambient_loop(
manager: Option<&mut AudioManager<DefaultBackend>>,
library: Option<&SoundLibrary>,
music_track: &Option<TrackHandle>,
music_track: &mut Option<TrackHandle>,
) -> Option<StaticSoundHandle> {
let manager = manager?;
let lib = library?;
let mut data = lib.flip.clone();
// Loop the entire file from start to end.
data.settings.loop_region = Some(Region::default());
data.settings.volume = Volume::Amplitude(AMBIENT_VOLUME).into();
if let Some(track) = music_track {
data.settings.output_destination = track.id().into();
}
data.settings.volume = Value::Fixed(amplitude_to_decibels(AMBIENT_VOLUME as f32));
match manager.play(data) {
let result = if let Some(track) = music_track.as_mut() {
track.play(data)
} else {
manager.play(data)
};
match result {
Ok(handle) => Some(handle),
Err(e) => {
warn!("failed to start ambient loop: {e}");
@@ -213,16 +222,17 @@ fn start_ambient_loop(
}
fn play(audio: &mut AudioState, sound: &StaticSoundData) {
let Some(manager) = audio.manager.as_mut() else {
return;
};
let data = sound.clone();
// Route SFX through the dedicated sfx_track so its volume is independent
// of the music_track volume.
let mut data = sound.clone();
if let Some(track) = &audio.sfx_track {
data.settings.output_destination = track.id().into();
}
if let Err(e) = manager.play(data) {
let result = if let Some(track) = audio.sfx_track.as_mut() {
track.play(data)
} else if let Some(manager) = audio.manager.as_mut() {
manager.play(data)
} else {
return;
};
if let Err(e) = result {
warn!("failed to play SFX: {e}");
}
}
@@ -234,15 +244,17 @@ impl AudioState {
/// explicit volume override so callers can play sounds at a fraction of their
/// normal level. Silently does nothing when audio is unavailable.
pub fn play_sfx_at_volume(&mut self, sound: &StaticSoundData, volume: f64) {
let Some(manager) = self.manager.as_mut() else {
let mut data = sound.clone();
data.settings.volume = Value::Fixed(amplitude_to_decibels(volume as f32));
let result = if let Some(track) = self.sfx_track.as_mut() {
track.play(data)
} else if let Some(manager) = self.manager.as_mut() {
manager.play(data)
} else {
return;
};
let mut data = sound.clone();
data.settings.volume = Volume::Amplitude(volume).into();
if let Some(track) = &self.sfx_track {
data.settings.output_destination = track.id().into();
}
if let Err(e) = manager.play(data) {
if let Err(e) = result {
warn!("failed to play SFX at volume {volume}: {e}");
}
}
@@ -250,13 +262,13 @@ impl AudioState {
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
if let Some(track) = audio.sfx_track.as_mut() {
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
track.set_volume(amplitude_to_decibels(volume.clamp(0.0, 1.0)), Tween::default());
}
}
fn set_music_volume(audio: &mut AudioState, volume: f32) {
if let Some(track) = audio.music_track.as_mut() {
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
track.set_volume(amplitude_to_decibels(volume.clamp(0.0, 1.0)), Tween::default());
}
}
@@ -270,7 +282,7 @@ fn apply_initial_volume(
}
fn play_on_undo(
mut events: EventReader<UndoRequestEvent>,
mut events: MessageReader<UndoRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
@@ -281,7 +293,7 @@ fn play_on_undo(
}
fn apply_volume_on_change(
mut events: EventReader<SettingsChangedEvent>,
mut events: MessageReader<SettingsChangedEvent>,
mut audio: NonSendMut<AudioState>,
mute: Option<Res<MuteState>>,
) {
@@ -326,7 +338,7 @@ fn handle_mute_keys(
}
fn play_on_draw(
mut events: EventReader<DrawRequestEvent>,
mut events: MessageReader<DrawRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
game: Option<Res<GameStateResource>>,
@@ -345,14 +357,17 @@ fn play_on_draw(
if is_recycle(stock_len) {
let mut data = lib.flip.clone();
data.settings.volume = Volume::Amplitude(RECYCLE_VOLUME).into();
if let Some(track) = &audio.sfx_track {
data.settings.output_destination = track.id().into();
}
if let Some(manager) = audio.manager.as_mut() {
if let Err(e) = manager.play(data) {
warn!("failed to play recycle SFX: {e}");
}
data.settings.volume =
Value::Fixed(amplitude_to_decibels(RECYCLE_VOLUME as f32));
let result = if let Some(track) = audio.sfx_track.as_mut() {
track.play(data)
} else if let Some(manager) = audio.manager.as_mut() {
manager.play(data)
} else {
continue;
};
if let Err(e) = result {
warn!("failed to play recycle SFX: {e}");
}
} else {
play(&mut audio, &lib.flip);
@@ -361,7 +376,7 @@ fn play_on_draw(
}
fn play_on_move(
mut events: EventReader<MoveRequestEvent>,
mut events: MessageReader<MoveRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
@@ -374,7 +389,7 @@ fn play_on_move(
}
fn play_on_rejected(
mut events: EventReader<MoveRejectedEvent>,
mut events: MessageReader<MoveRejectedEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
@@ -387,7 +402,7 @@ fn play_on_rejected(
}
fn play_on_new_game(
mut events: EventReader<NewGameRequestEvent>,
mut events: MessageReader<NewGameRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
@@ -400,7 +415,7 @@ fn play_on_new_game(
}
fn play_on_win(
mut events: EventReader<GameWonEvent>,
mut events: MessageReader<GameWonEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
@@ -418,7 +433,7 @@ fn play_on_win(
/// Driven by `CardFaceRevealedEvent`, which is fired by `tick_flip_anim` at
/// the phase transition (scale.x crosses 0), not by the move event itself.
fn play_on_face_revealed(
mut events: EventReader<CardFaceRevealedEvent>,
mut events: MessageReader<CardFaceRevealedEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
+7 -7
View File
@@ -57,7 +57,7 @@ impl Plugin for AutoCompletePlugin {
fn detect_auto_complete(
mut state: ResMut<AutoCompleteState>,
game: Res<GameStateResource>,
mut changed: EventReader<StateChangedEvent>,
mut changed: MessageReader<StateChangedEvent>,
) {
// Only re-evaluate on state changes to avoid per-frame allocations.
if changed.is_empty() && !game.is_changed() {
@@ -106,7 +106,7 @@ fn drive_auto_complete(
mut state: ResMut<AutoCompleteState>,
game: Res<GameStateResource>,
time: Res<Time>,
mut moves: EventWriter<MoveRequestEvent>,
mut moves: MessageWriter<MoveRequestEvent>,
) {
if !state.active {
return;
@@ -122,7 +122,7 @@ fn drive_auto_complete(
return;
};
moves.send(MoveRequestEvent { from, to, count: 1 });
moves.write(MoveRequestEvent { from, to, count: 1 });
state.cooldown = STEP_INTERVAL;
}
@@ -176,7 +176,7 @@ mod tests {
let mut app = headless_app();
// Install a nearly-won state and fire StateChangedEvent.
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update();
assert!(app.world().resource::<AutoCompleteState>().active);
@@ -186,11 +186,11 @@ mod tests {
fn drive_fires_move_request_when_active() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update(); // detect runs, sets active
app.update(); // drive fires the move
let events = app.world().resource::<Events<MoveRequestEvent>>();
let events = app.world().resource::<Messages<MoveRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
// At least one MoveRequestEvent should have been fired.
@@ -206,7 +206,7 @@ mod tests {
let mut gs = nearly_won_state();
gs.is_won = true;
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update();
assert!(!app.world().resource::<AutoCompleteState>().active);
@@ -227,22 +227,22 @@ pub(crate) fn apply_drag_visual(
pub(crate) fn drain_input_buffer(
mut buffer: ResMut<InputBuffer>,
anims: Query<&CardAnimation>,
mut move_events: EventWriter<MoveRequestEvent>,
mut draw_events: EventWriter<DrawRequestEvent>,
mut undo_events: EventWriter<UndoRequestEvent>,
mut move_events: MessageWriter<MoveRequestEvent>,
mut draw_events: MessageWriter<DrawRequestEvent>,
mut undo_events: MessageWriter<UndoRequestEvent>,
) {
if !anims.is_empty() {
return;
}
match buffer.queue.pop_front() {
Some(BufferedInput::Move { from }) => {
move_events.send(from);
move_events.write(from);
}
Some(BufferedInput::Draw) => {
draw_events.send(DrawRequestEvent);
draw_events.write(DrawRequestEvent);
}
Some(BufferedInput::Undo) => {
undo_events.send(UndoRequestEvent);
undo_events.write(UndoRequestEvent);
}
None => {}
}
@@ -259,9 +259,9 @@ fn cursor_world(
windows: &Query<&Window, With<PrimaryWindow>>,
cameras: &Query<(&Camera, &GlobalTransform)>,
) -> Option<Vec2> {
let window = windows.get_single().ok()?;
let window = windows.single().ok()?;
let cursor = window.cursor_position()?;
let (camera, camera_transform) = cameras.get_single().ok()?;
let (camera, camera_transform) = cameras.single().ok()?;
camera.viewport_to_world_2d(camera_transform, cursor).ok()
}
+5 -5
View File
@@ -111,10 +111,10 @@ impl Plugin for CardAnimationPlugin {
fn build(&self, app: &mut App) {
// Register events and resources that interaction systems depend on,
// idempotently — double-registration is safe in Bevy.
app.add_event::<MoveRequestEvent>()
.add_event::<DrawRequestEvent>()
.add_event::<UndoRequestEvent>()
.add_event::<GameWonEvent>()
app.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<GameWonEvent>()
.init_resource::<DragState>()
.init_resource::<HoverState>()
.init_resource::<InputBuffer>()
@@ -163,7 +163,7 @@ impl Plugin for WinCascadePlugin {
/// Cards scatter to 8 off-screen positions with per-card stagger. The z-lift
/// creates a "burst" effect as cards fly outward.
fn trigger_expressive_win_cascade(
mut events: EventReader<GameWonEvent>,
mut events: MessageReader<GameWonEvent>,
cards: Query<(Entity, &Transform), With<CardEntity>>,
layout: Option<Res<LayoutResource>>,
mut commands: Commands,
+24 -24
View File
@@ -157,9 +157,9 @@ impl Plugin for CardPlugin {
// `MinimalPlugins` (tests) this resource is absent by default, so we
// ensure it exists here. Under `DefaultPlugins` the call is a no-op.
app.init_resource::<ButtonInput<MouseButton>>()
.add_event::<SettingsChangedEvent>()
.add_event::<CardFlippedEvent>()
.add_event::<CardFaceRevealedEvent>()
.add_message::<SettingsChangedEvent>()
.add_message::<CardFlippedEvent>()
.add_message::<CardFaceRevealedEvent>()
.add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup))
.add_systems(
Update,
@@ -183,11 +183,11 @@ impl Plugin for CardPlugin {
/// When card-back selection changes in Settings, re-render all cards so the
/// new back colour is applied immediately (without waiting for a state change).
fn resync_cards_on_settings_change(
mut setting_events: EventReader<SettingsChangedEvent>,
mut state_events: EventWriter<StateChangedEvent>,
mut setting_events: MessageReader<SettingsChangedEvent>,
mut state_events: MessageWriter<StateChangedEvent>,
) {
if setting_events.read().next().is_some() {
state_events.send(StateChangedEvent);
state_events.write(StateChangedEvent);
}
}
@@ -213,7 +213,7 @@ fn sync_cards_startup(
}
fn sync_cards_on_change(
mut events: EventReader<StateChangedEvent>,
mut events: MessageReader<StateChangedEvent>,
commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
@@ -256,7 +256,7 @@ fn sync_cards(
// Despawn any entity whose card is no longer tracked.
for (card_id, (entity, _)) in &existing {
if !live_ids.contains(card_id) {
commands.entity(*entity).despawn_recursive();
commands.entity(*entity).despawn();
}
}
@@ -443,7 +443,7 @@ fn update_card_entity(
// Despawn the old label child and respawn a fresh one, so rank/suit/
// colour/visibility all stay in sync with the card's current state.
commands.entity(entity).despawn_descendants();
commands.entity(entity).despawn_related::<Children>();
commands.entity(entity).with_children(|b| {
b.spawn((
CardLabel,
@@ -508,7 +508,7 @@ fn label_visibility(card: &Card) -> Visibility {
///
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
fn start_flip_anim(
mut events: EventReader<CardFlippedEvent>,
mut events: MessageReader<CardFlippedEvent>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity)>,
@@ -543,7 +543,7 @@ fn tick_flip_anim(
mut commands: Commands,
time: Res<Time>,
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
mut reveal_events: EventWriter<CardFaceRevealedEvent>,
mut reveal_events: MessageWriter<CardFaceRevealedEvent>,
) {
let dt = time.delta_secs();
for (entity, card_entity, mut transform, mut anim) in &mut anims {
@@ -558,7 +558,7 @@ fn tick_flip_anim(
transform.scale.x = 0.0;
// Fire the reveal event exactly once, at the phase transition,
// so the flip sound is synchronised with the visual face reveal.
reveal_events.send(CardFaceRevealedEvent(card_entity.card_id));
reveal_events.write(CardFaceRevealedEvent(card_entity.card_id));
}
}
FlipPhase::ScalingUp => {
@@ -592,7 +592,7 @@ fn update_drag_shadow(
if drag.is_idle() {
// No drag in progress — remove shadow if it exists.
if let Some(e) = shadow.take() {
commands.entity(e).despawn_recursive();
commands.entity(e).despawn();
}
return;
}
@@ -742,7 +742,7 @@ fn clear_right_click_highlights(
///
/// This ensures stale highlights do not linger after a card is moved.
fn clear_right_click_highlights_on_state_change(
mut events: EventReader<StateChangedEvent>,
mut events: MessageReader<StateChangedEvent>,
mut commands: Commands,
highlighted: Query<Entity, With<RightClickHighlight>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
@@ -847,9 +847,9 @@ fn cursor_world_pos(
windows: &Query<&Window, With<bevy::window::PrimaryWindow>>,
cameras: &Query<(&Camera, &GlobalTransform)>,
) -> Option<Vec2> {
let window = windows.get_single().ok()?;
let window = windows.single().ok()?;
let cursor = window.cursor_position()?;
let (camera, camera_transform) = cameras.get_single().ok()?;
let (camera, camera_transform) = cameras.single().ok()?;
camera.viewport_to_world_2d(camera_transform, cursor).ok()
}
@@ -911,7 +911,7 @@ fn apply_stock_empty_indicator(
commands: &mut Commands,
game: &GameState,
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: &Query<(Entity, &Parent), With<StockEmptyLabel>>,
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
layout: &Layout,
) {
let stock_empty = game
@@ -931,7 +931,7 @@ fn apply_stock_empty_indicator(
// Spawn the "↺" label only if one does not already exist.
let already_has_label = label_children
.iter()
.any(|(_, parent)| parent.get() == entity);
.any(|(_, parent)| parent.parent() == entity);
if !already_has_label {
let font_size = layout.card_size.x * 0.4;
commands.entity(entity).with_children(|b| {
@@ -950,8 +950,8 @@ fn apply_stock_empty_indicator(
// Despawn any existing "↺" label children.
for (label_entity, parent) in label_children.iter() {
if parent.get() == entity {
commands.entity(label_entity).despawn_recursive();
if parent.parent() == entity {
commands.entity(label_entity).despawn();
}
}
}
@@ -965,7 +965,7 @@ fn update_stock_empty_indicator_startup(
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: Query<(Entity, &Parent), With<StockEmptyLabel>>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
let Some(layout) = layout else { return };
apply_stock_empty_indicator(
@@ -980,12 +980,12 @@ fn update_stock_empty_indicator_startup(
/// Runs each `Update` tick when a `StateChangedEvent` arrives, keeping the
/// stock pile marker dim state and "↺" label in sync with the current stock.
fn update_stock_empty_indicator(
mut events: EventReader<StateChangedEvent>,
mut events: MessageReader<StateChangedEvent>,
mut commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: Query<(Entity, &Parent), With<StockEmptyLabel>>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
if events.read().next().is_none() {
return;
@@ -1106,7 +1106,7 @@ mod tests {
let mut app = app();
// Trigger a draw, which moves a card from stock to waste and should
// flip it face-up. Count visible labels after.
app.world_mut().send_event(crate::events::DrawRequestEvent);
app.world_mut().write_message(crate::events::DrawRequestEvent);
app.update();
// Now 1 card in waste (face-up), 23 in stock (face-down). So 24
// hidden labels total in stock, plus 21 in tableau = 44.
+25 -25
View File
@@ -18,7 +18,7 @@ pub const CHALLENGE_UNLOCK_LEVEL: u32 = 5;
/// Fired when the player has just completed a Challenge-mode game and the
/// `challenge_index` cursor advances.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct ChallengeAdvancedEvent {
pub previous_index: u32,
pub new_index: u32,
@@ -28,10 +28,10 @@ pub struct ChallengePlugin;
impl Plugin for ChallengePlugin {
fn build(&self, app: &mut App) {
app.add_event::<ChallengeAdvancedEvent>()
.add_event::<GameWonEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<InfoToastEvent>()
app.add_message::<ChallengeAdvancedEvent>()
.add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<InfoToastEvent>()
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
.add_systems(Update, advance_on_challenge_win.after(ProgressUpdate))
.add_systems(Update, handle_start_challenge_request.before(GameMutation));
@@ -39,12 +39,12 @@ impl Plugin for ChallengePlugin {
}
fn advance_on_challenge_win(
mut wins: EventReader<GameWonEvent>,
mut wins: MessageReader<GameWonEvent>,
game: Res<GameStateResource>,
mut progress: ResMut<ProgressResource>,
path: Res<ProgressStoragePath>,
mut advanced: EventWriter<ChallengeAdvancedEvent>,
mut toast: EventWriter<InfoToastEvent>,
mut advanced: MessageWriter<ChallengeAdvancedEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
for _ in wins.read() {
if game.0.mode != GameMode::Challenge {
@@ -59,8 +59,8 @@ fn advance_on_challenge_win(
}
// Human-readable level is 1-based (index 0 → "Challenge 1").
let level_number = prev.saturating_add(1);
toast.send(InfoToastEvent(format!("Challenge {level_number} complete!")));
advanced.send(ChallengeAdvancedEvent {
toast.write(InfoToastEvent(format!("Challenge {level_number} complete!")));
advanced.write(ChallengeAdvancedEvent {
previous_index: prev,
new_index: progress.0.challenge_index,
});
@@ -70,14 +70,14 @@ fn advance_on_challenge_win(
fn handle_start_challenge_request(
keys: Res<ButtonInput<KeyCode>>,
progress: Res<ProgressResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut info_toast: EventWriter<InfoToastEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
if !keys.just_pressed(KeyCode::KeyX) {
return;
}
if progress.0.level < CHALLENGE_UNLOCK_LEVEL {
info_toast.send(InfoToastEvent(format!(
info_toast.write(InfoToastEvent(format!(
"Challenge mode unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
)));
return;
@@ -86,7 +86,7 @@ fn handle_start_challenge_request(
warn!("challenge seed list is empty");
return;
};
new_game.send(NewGameRequestEvent {
new_game.write(NewGameRequestEvent {
seed: Some(seed),
mode: Some(GameMode::Challenge),
});
@@ -124,7 +124,7 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 100,
});
@@ -133,7 +133,7 @@ mod tests {
let p = &app.world().resource::<ProgressResource>().0;
assert_eq!(p.challenge_index, 1);
let events = app.world().resource::<Events<ChallengeAdvancedEvent>>();
let events = app.world().resource::<Messages<ChallengeAdvancedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -145,7 +145,7 @@ mod tests {
fn classic_win_does_not_advance_challenge_index() {
let mut app = headless_app();
// Default GameStateResource is Classic mode.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 100,
});
@@ -154,7 +154,7 @@ mod tests {
let p = &app.world().resource::<ProgressResource>().0;
assert_eq!(p.challenge_index, 0);
let events = app.world().resource::<Events<ChallengeAdvancedEvent>>();
let events = app.world().resource::<Messages<ChallengeAdvancedEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
@@ -168,7 +168,7 @@ mod tests {
.press(KeyCode::KeyX);
app.update();
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
@@ -188,7 +188,7 @@ mod tests {
.press(KeyCode::KeyX);
app.update();
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -211,13 +211,13 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 100,
});
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert_eq!(fired.len(), 1, "exactly one toast must fire on challenge win");
@@ -231,13 +231,13 @@ mod tests {
fn classic_win_does_not_fire_challenge_complete_toast() {
let mut app = headless_app();
// Default mode is Classic.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 100,
});
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
assert!(
cursor.read(events).next().is_none(),
@@ -254,7 +254,7 @@ mod tests {
.press(KeyCode::KeyX);
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert_eq!(fired.len(), 1, "must show a toast explaining the lock");
+3 -4
View File
@@ -12,8 +12,7 @@
//! The tint is cleared to default the frame the drag ends.
use bevy::prelude::*;
use bevy::window::{PrimaryWindow, SystemCursorIcon};
use bevy::winit::cursor::CursorIcon;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::card::Suit;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
@@ -52,7 +51,7 @@ fn update_cursor_icon(
game: Option<Res<GameStateResource>>,
mut commands: Commands,
) {
let Ok((win_entity, window)) = windows.get_single() else { return };
let Ok((win_entity, window)) = windows.single() else { return };
if !drag.is_idle() {
commands
@@ -63,7 +62,7 @@ fn update_cursor_icon(
let hovering = (|| {
let cursor = window.cursor_position()?;
let (camera, cam_xf) = cameras.get_single().ok()?;
let (camera, cam_xf) = cameras.single().ok()?;
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
let layout = layout.as_ref()?.0.clone();
let game = game.as_ref()?;
+27 -27
View File
@@ -43,7 +43,7 @@ pub struct DailyChallengeResource {
/// Fired when the player presses C to start the daily challenge.
/// Carries the current goal description so it can be displayed as a toast.
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct DailyGoalAnnouncementEvent(pub String);
impl DailyChallengeResource {
@@ -60,7 +60,7 @@ impl DailyChallengeResource {
}
/// Fired when the player has just completed today's daily challenge.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct DailyChallengeCompletedEvent {
pub date: NaiveDate,
pub streak: u32,
@@ -77,11 +77,11 @@ impl Plugin for DailyChallengePlugin {
fn build(&self, app: &mut App) {
app.insert_resource(DailyChallengeResource::for_today())
.init_resource::<DailyChallengeTask>()
.add_event::<DailyChallengeCompletedEvent>()
.add_event::<DailyGoalAnnouncementEvent>()
.add_event::<GameWonEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<XpAwardedEvent>()
.add_message::<DailyChallengeCompletedEvent>()
.add_message::<DailyGoalAnnouncementEvent>()
.add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<XpAwardedEvent>()
.add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge)
// record/award after the base ProgressUpdate so we don't fight
@@ -145,14 +145,14 @@ fn poll_server_challenge(
#[allow(clippy::too_many_arguments)]
fn handle_daily_completion(
mut wins: EventReader<GameWonEvent>,
mut wins: MessageReader<GameWonEvent>,
daily: Res<DailyChallengeResource>,
game: Res<GameStateResource>,
mut progress: ResMut<ProgressResource>,
path: Res<ProgressStoragePath>,
mut completed: EventWriter<DailyChallengeCompletedEvent>,
mut xp_awarded: EventWriter<XpAwardedEvent>,
mut toast: EventWriter<InfoToastEvent>,
mut completed: MessageWriter<DailyChallengeCompletedEvent>,
mut xp_awarded: MessageWriter<XpAwardedEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
for ev in wins.read() {
if game.0.seed != daily.seed {
@@ -174,28 +174,28 @@ fn handle_daily_completion(
continue;
}
progress.0.add_xp(DAILY_BONUS_XP);
xp_awarded.send(XpAwardedEvent { amount: DAILY_BONUS_XP });
xp_awarded.write(XpAwardedEvent { amount: DAILY_BONUS_XP });
if let Some(target) = &path.0 {
if let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after daily completion: {e}");
}
}
completed.send(DailyChallengeCompletedEvent {
completed.write(DailyChallengeCompletedEvent {
date: daily.date,
streak: progress.0.daily_challenge_streak,
});
toast.send(InfoToastEvent("Daily challenge complete! +100 XP".to_string()));
toast.write(InfoToastEvent("Daily challenge complete! +100 XP".to_string()));
}
}
fn handle_start_daily_request(
keys: Res<ButtonInput<KeyCode>>,
daily: Res<DailyChallengeResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut announce: EventWriter<DailyGoalAnnouncementEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut announce: MessageWriter<DailyGoalAnnouncementEvent>,
) {
if keys.just_pressed(KeyCode::KeyC) {
new_game.send(NewGameRequestEvent {
new_game.write(NewGameRequestEvent {
seed: Some(daily.seed),
mode: None,
});
@@ -203,7 +203,7 @@ fn handle_start_daily_request(
.goal_description
.clone()
.unwrap_or_else(|| "Daily Challenge".to_string());
announce.send(DailyGoalAnnouncementEvent(desc));
announce.write(DailyGoalAnnouncementEvent(desc));
}
}
@@ -244,7 +244,7 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(daily_seed, DrawMode::DrawOne);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
@@ -255,7 +255,7 @@ mod tests {
// +100 from the daily bonus
assert!(progress.total_xp >= DAILY_BONUS_XP);
let events = app.world().resource::<Events<DailyChallengeCompletedEvent>>();
let events = app.world().resource::<Messages<DailyChallengeCompletedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -270,7 +270,7 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
@@ -279,7 +279,7 @@ mod tests {
let progress = &app.world().resource::<ProgressResource>().0;
assert_eq!(progress.daily_challenge_streak, 0);
let events = app.world().resource::<Events<DailyChallengeCompletedEvent>>();
let events = app.world().resource::<Messages<DailyChallengeCompletedEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
@@ -291,13 +291,13 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(daily_seed, DrawMode::DrawOne);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
app.update();
// Re-send win.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
@@ -317,7 +317,7 @@ mod tests {
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -337,7 +337,7 @@ mod tests {
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Events<DailyGoalAnnouncementEvent>>();
let events = app.world().resource::<Messages<DailyGoalAnnouncementEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert_eq!(fired.len(), 1);
@@ -355,7 +355,7 @@ mod tests {
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Events<DailyGoalAnnouncementEvent>>();
let events = app.world().resource::<Messages<DailyGoalAnnouncementEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert_eq!(fired.len(), 1);
+17 -17
View File
@@ -1,13 +1,13 @@
//! Cross-system events used by the engine's plugins.
use bevy::prelude::Event;
use bevy::prelude::Message;
use solitaire_core::game_state::GameMode;
use solitaire_core::pile::PileType;
use solitaire_data::AchievementRecord;
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
/// consumed by `GamePlugin`.
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct MoveRequestEvent {
pub from: PileType,
pub to: PileType,
@@ -15,16 +15,16 @@ pub struct MoveRequestEvent {
}
/// Request to draw from the stock (or recycle waste when stock is empty).
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct DrawRequestEvent;
/// Request to undo the most recent state change.
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct UndoRequestEvent;
/// Request to start a new game. `seed = None` uses a system-time seed.
/// `mode = None` reuses the current game's `GameMode`.
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct NewGameRequestEvent {
pub seed: Option<u64>,
pub mode: Option<GameMode>,
@@ -32,13 +32,13 @@ pub struct NewGameRequestEvent {
/// Fired by `GamePlugin` after any successful state mutation. Rendering and
/// score-display systems listen for this to refresh.
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct StateChangedEvent;
/// Fired by input/UI systems when a player attempts to drop dragged cards
/// on a real pile but the move violates the rules. Drives the
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct MoveRejectedEvent {
pub from: PileType,
pub to: PileType,
@@ -46,14 +46,14 @@ pub struct MoveRejectedEvent {
}
/// Fired once when the active game transitions to won.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct GameWonEvent {
pub score: i32,
pub time_seconds: u64,
}
/// Fired when a card's face-up state changes during gameplay.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct CardFlippedEvent(pub u32);
/// Fired by the flip animation at its midpoint — the instant the card face
@@ -62,37 +62,37 @@ pub struct CardFlippedEvent(pub u32);
/// Audio systems should listen to this event rather than `CardFlippedEvent`
/// so the flip sound is synchronised with the visual reveal, not the move
/// that triggered the animation.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct CardFaceRevealedEvent(pub u32);
/// Achievement unlocked notification carrying the full `AchievementRecord` for
/// the newly unlocked achievement. Consumed by the toast renderer and any
/// persistence/UI systems that need unlock metadata.
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct AchievementUnlockedEvent(pub AchievementRecord);
/// Request to manually trigger a sync pull from the active backend.
///
/// Fired by the Settings panel "Sync Now" button. `SyncPlugin` responds by
/// starting a new pull task if one is not already in flight.
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ManualSyncRequestEvent;
/// Fired by `InputPlugin` when N is pressed while a game is in progress
/// but confirmation has not yet been received. The animation plugin shows
/// a "Press N again to confirm" toast. A second N press within the
/// confirmation window sends `NewGameRequestEvent`.
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct NewGameConfirmEvent;
/// Generic informational toast message. Any system can fire this to display
/// a short string to the player, e.g. "Locked — reach level 5".
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct InfoToastEvent(pub String);
/// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
/// animation layer can display a "+N XP" toast alongside the win cascade.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct XpAwardedEvent {
pub amount: u64,
}
@@ -100,7 +100,7 @@ pub struct XpAwardedEvent {
/// Fired by `InputPlugin` when the player presses G to forfeit the current
/// game. Consumed by `StatsPlugin` which records the abandoned game,
/// persists stats, and starts a fresh deal.
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ForfeitEvent;
/// Fired when the player requests a hint (H key). Carries the source card ID
@@ -108,7 +108,7 @@ pub struct ForfeitEvent;
///
/// Consumed by `CardPlugin` (to apply `HintHighlight` on the card entity) and
/// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s).
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct HintVisualEvent {
/// The `Card::id` of the source card to be highlighted.
pub source_card_id: u32,
+3 -3
View File
@@ -184,7 +184,7 @@ impl Plugin for FeedbackAnimPlugin {
/// Inserts `ShakeAnim` on all card entities belonging to the destination pile
/// when a `MoveRejectedEvent` fires.
fn start_shake_anim(
mut events: EventReader<MoveRejectedEvent>,
mut events: MessageReader<MoveRejectedEvent>,
game: Res<GameStateResource>,
card_entities: Query<(Entity, &CardEntity, &Transform)>,
mut commands: Commands,
@@ -243,7 +243,7 @@ fn tick_shake_anim(
/// Inserts `SettleAnim` on the top card of every non-empty pile when
/// `StateChangedEvent` fires.
fn start_settle_anim(
mut events: EventReader<StateChangedEvent>,
mut events: MessageReader<StateChangedEvent>,
game: Res<GameStateResource>,
card_entities: Query<(Entity, &CardEntity)>,
mut commands: Commands,
@@ -304,7 +304,7 @@ fn tick_settle_anim(
/// and fires the deal animation for every card entity currently in the world.
/// The stagger is looked up from `SettingsResource` via `deal_stagger_secs_for_speed`.
fn start_deal_anim(
mut events: EventReader<NewGameRequestEvent>,
mut events: MessageReader<NewGameRequestEvent>,
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
+78 -78
View File
@@ -71,16 +71,16 @@ impl Plugin for GamePlugin {
.insert_resource(GameStatePath(path))
.init_resource::<DragState>()
.init_resource::<SyncStatusResource>()
.add_event::<MoveRequestEvent>()
.add_event::<DrawRequestEvent>()
.add_event::<UndoRequestEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<StateChangedEvent>()
.add_event::<crate::events::MoveRejectedEvent>()
.add_event::<GameWonEvent>()
.add_event::<crate::events::CardFlippedEvent>()
.add_event::<crate::events::AchievementUnlockedEvent>()
.add_event::<InfoToastEvent>()
.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<StateChangedEvent>()
.add_message::<crate::events::MoveRejectedEvent>()
.add_message::<GameWonEvent>()
.add_message::<crate::events::CardFlippedEvent>()
.add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<InfoToastEvent>()
.add_systems(
Update,
(
@@ -152,9 +152,9 @@ fn seed_from_system_time() -> u64 {
#[allow(clippy::too_many_arguments)]
fn handle_new_game(
mut commands: Commands,
mut new_game: EventReader<NewGameRequestEvent>,
mut new_game: MessageReader<NewGameRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: EventWriter<StateChangedEvent>,
mut changed: MessageWriter<StateChangedEvent>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
path: Option<Res<GameStatePath>>,
confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>,
@@ -169,7 +169,7 @@ fn handle_new_game(
if needs_confirm && !confirm_already_open {
// Despawn any stale game-over overlay before showing confirm dialog.
for entity in &game_over_screens {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
spawn_confirm_dialog(&mut commands, *ev);
continue;
@@ -177,10 +177,10 @@ fn handle_new_game(
// Despawn confirm and game-over overlays before starting the new game.
for entity in &confirm_screens {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
for entity in &game_over_screens {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
@@ -199,7 +199,7 @@ fn handle_new_game(
warn!("game_state: failed to delete saved game: {e}");
}
}
changed.send(StateChangedEvent);
changed.write(StateChangedEvent);
}
}
@@ -238,10 +238,10 @@ fn spawn_confirm_dialog(commands: &mut Commands, original_request: NewGameReques
row_gap: Val::Px(20.0),
min_width: Val::Px(360.0),
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.12, 0.15)),
BorderRadius::all(Val::Px(12.0)),
))
.with_children(|card| {
// Heading
@@ -287,9 +287,9 @@ fn handle_confirm_input(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
screens: Query<(Entity, &OriginalNewGameRequest), With<ConfirmNewGameScreen>>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
) {
let Ok((entity, original)) = screens.get_single() else {
let Ok((entity, original)) = screens.single() else {
return;
};
let Some(keys) = keys else {
@@ -300,24 +300,24 @@ fn handle_confirm_input(
let cancelled = keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape);
if confirmed {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
// Re-send with move_count already 0 would bypass the dialog next time.
// We fire the event — handle_new_game will skip the dialog because
// the screen is despawned before the next read.
new_game.send(NewGameRequestEvent {
new_game.write(NewGameRequestEvent {
seed: original.0.seed,
mode: original.0.mode,
});
} else if cancelled {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
}
fn handle_draw(
mut draws: EventReader<DrawRequestEvent>,
mut draws: MessageReader<DrawRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: EventWriter<StateChangedEvent>,
mut flipped: EventWriter<CardFlippedEvent>,
mut changed: MessageWriter<StateChangedEvent>,
mut flipped: MessageWriter<CardFlippedEvent>,
) {
use solitaire_core::pile::PileType;
@@ -347,9 +347,9 @@ fn handle_draw(
Ok(()) => {
// Fire a flip event for each card that moved from stock to waste.
for id in drawn_ids {
flipped.send(CardFlippedEvent(id));
flipped.write(CardFlippedEvent(id));
}
changed.send(StateChangedEvent);
changed.write(StateChangedEvent);
}
Err(e) => warn!("draw rejected: {e}"),
}
@@ -357,11 +357,11 @@ fn handle_draw(
}
fn handle_move(
mut moves: EventReader<MoveRequestEvent>,
mut moves: MessageReader<MoveRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: EventWriter<StateChangedEvent>,
mut won: EventWriter<GameWonEvent>,
mut flipped: EventWriter<crate::events::CardFlippedEvent>,
mut changed: MessageWriter<StateChangedEvent>,
mut won: MessageWriter<GameWonEvent>,
mut flipped: MessageWriter<crate::events::CardFlippedEvent>,
path: Option<Res<GameStatePath>>,
) {
for ev in moves.read() {
@@ -385,12 +385,12 @@ fn handle_move(
.and_then(|p| p.cards.last())
.is_some_and(|c| c.id == fid && c.face_up)
{
flipped.send(crate::events::CardFlippedEvent(fid));
flipped.write(crate::events::CardFlippedEvent(fid));
}
}
changed.send(StateChangedEvent);
changed.write(StateChangedEvent);
if !was_won && game.0.is_won {
won.send(GameWonEvent {
won.write(GameWonEvent {
score: game.0.score,
time_seconds: game.0.elapsed_seconds,
});
@@ -408,20 +408,20 @@ fn handle_move(
}
fn handle_undo(
mut undos: EventReader<UndoRequestEvent>,
mut undos: MessageReader<UndoRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: EventWriter<StateChangedEvent>,
mut toast: EventWriter<InfoToastEvent>,
mut changed: MessageWriter<StateChangedEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
use solitaire_core::error::MoveError;
for _ in undos.read() {
match game.0.undo() {
Ok(()) => {
changed.send(StateChangedEvent);
changed.write(StateChangedEvent);
}
Err(MoveError::UndoStackEmpty) => {
toast.send(InfoToastEvent("Nothing to undo".to_string()));
toast.write(InfoToastEvent("Nothing to undo".to_string()));
}
Err(e) => warn!("undo rejected: {e}"),
}
@@ -500,9 +500,9 @@ pub fn has_legal_moves(game: &GameState) -> bool {
/// game is won.
fn check_no_moves(
mut commands: Commands,
mut events: EventReader<StateChangedEvent>,
mut events: MessageReader<StateChangedEvent>,
game: Res<GameStateResource>,
mut toast: EventWriter<InfoToastEvent>,
mut toast: MessageWriter<InfoToastEvent>,
mut already_fired: Local<bool>,
game_over_screens: Query<Entity, With<GameOverScreen>>,
) {
@@ -523,7 +523,7 @@ fn check_no_moves(
let moves_ok = has_legal_moves(&game.0);
if moves_ok || game.0.is_won {
for entity in &game_over_screens {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
}
@@ -532,7 +532,7 @@ fn check_no_moves(
}
if !moves_ok && !*already_fired {
toast.send(InfoToastEvent(
toast.write(InfoToastEvent(
"No moves available \u{2014} press D to draw or N for a new game".to_string(),
));
*already_fired = true;
@@ -574,10 +574,10 @@ fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
row_gap: Val::Px(16.0),
min_width: Val::Px(340.0),
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.08, 0.08)),
BorderRadius::all(Val::Px(12.0)),
))
.with_children(|card| {
// Header — explains why the overlay appeared.
@@ -628,8 +628,8 @@ fn handle_game_over_input(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
screens: Query<Entity, With<GameOverScreen>>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut undo: EventWriter<UndoRequestEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut undo: MessageWriter<UndoRequestEvent>,
) {
if screens.is_empty() {
return;
@@ -639,12 +639,12 @@ fn handle_game_over_input(
};
if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) {
new_game.send(NewGameRequestEvent::default());
new_game.write(NewGameRequestEvent::default());
} else if keys.just_pressed(KeyCode::KeyU) {
for entity in &screens {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
undo.send(UndoRequestEvent);
undo.write(UndoRequestEvent);
}
}
@@ -685,7 +685,7 @@ fn auto_save_game_state(
/// `save_game_state_to` helper skips them). Blocking on exit is acceptable
/// because the game loop is already shutting down.
fn save_game_state_on_exit(
mut exit_events: EventReader<AppExit>,
mut exit_events: MessageReader<AppExit>,
game: Res<GameStateResource>,
path: Res<GameStatePath>,
) {
@@ -739,7 +739,7 @@ mod tests {
.cards
.len();
app.world_mut().send_event(DrawRequestEvent);
app.world_mut().write_message(DrawRequestEvent);
app.update();
let stock_after = app
@@ -763,9 +763,9 @@ mod tests {
#[test]
fn draw_request_fires_state_changed_event() {
let mut app = test_app(42);
app.world_mut().send_event(DrawRequestEvent);
app.world_mut().write_message(DrawRequestEvent);
app.update();
let events = app.world().resource::<Events<StateChangedEvent>>();
let events = app.world().resource::<Messages<StateChangedEvent>>();
let mut reader = events.get_cursor();
assert!(reader.read(events).next().is_some());
}
@@ -773,9 +773,9 @@ mod tests {
#[test]
fn undo_after_draw_restores_state() {
let mut app = test_app(42);
app.world_mut().send_event(DrawRequestEvent);
app.world_mut().write_message(DrawRequestEvent);
app.update();
app.world_mut().send_event(UndoRequestEvent);
app.world_mut().write_message(UndoRequestEvent);
app.update();
let g = &app.world().resource::<GameStateResource>().0;
assert_eq!(g.piles[&PileType::Stock].cards.len(), 24);
@@ -795,7 +795,7 @@ mod tests {
.map(|c| c.id)
.collect();
app.world_mut().send_event(NewGameRequestEvent { seed: Some(999), mode: None });
app.world_mut().write_message(NewGameRequestEvent { seed: Some(999), mode: None });
app.update();
let after: Vec<u32> = app
@@ -858,13 +858,13 @@ mod tests {
fn invalid_move_does_not_fire_state_changed() {
let mut app = test_app(42);
// Stock -> Waste is InvalidDestination; no state change expected.
app.world_mut().send_event(MoveRequestEvent {
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Stock,
to: PileType::Waste,
count: 1,
});
app.update();
let events = app.world().resource::<Events<StateChangedEvent>>();
let events = app.world().resource::<Messages<StateChangedEvent>>();
let mut reader = events.get_cursor();
assert!(reader.read(events).next().is_none());
}
@@ -892,7 +892,7 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(7654, DrawMode::DrawOne);
app.world_mut().send_event(AppExit::Success);
app.world_mut().write_message(AppExit::Success);
app.update();
let loaded = load_game_state_from(&path).expect("file should exist after exit");
@@ -913,7 +913,7 @@ mod tests {
let mut app = test_app(1);
app.insert_resource(GameStatePath(Some(path.clone())));
app.world_mut().send_event(NewGameRequestEvent { seed: Some(2), mode: None });
app.world_mut().write_message(NewGameRequestEvent { seed: Some(2), mode: None });
app.update();
assert!(!path.exists(), "saved file should be deleted after new game");
@@ -949,14 +949,14 @@ mod tests {
t.cards.push(Card { id: 902, suit: Suit::Clubs, rank: Rank::King, face_up: true });
}
app.world_mut().send_event(MoveRequestEvent {
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
count: 1,
});
app.update();
let events = app.world().resource::<Events<crate::events::CardFlippedEvent>>();
let events = app.world().resource::<Messages<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");
@@ -1035,14 +1035,14 @@ mod tests {
.push(Card { id: 912, suit: Suit::Spades, rank: Rank::King, face_up: true });
}
app.world_mut().send_event(MoveRequestEvent {
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
count: 1,
});
app.update();
let events = app.world().resource::<Events<crate::events::CardFlippedEvent>>();
let events = app.world().resource::<Messages<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");
@@ -1125,7 +1125,7 @@ mod tests {
// Simulate an active game with moves made.
app.world_mut().resource_mut::<GameStateResource>().0.move_count = 5;
app.world_mut()
.send_event(NewGameRequestEvent { seed: None, mode: None });
.write_message(NewGameRequestEvent { seed: None, mode: None });
app.update();
let count = app
@@ -1146,7 +1146,7 @@ mod tests {
"test assumes a fresh game with no moves"
);
app.world_mut()
.send_event(NewGameRequestEvent { seed: None, mode: None });
.write_message(NewGameRequestEvent { seed: None, mode: None });
app.update();
let count = app
@@ -1165,7 +1165,7 @@ mod tests {
fn game_over_screen_absent_when_moves_available() {
// A fresh game always has moves (stock is non-empty).
let mut app = test_app_with_input(42);
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update();
let count = app
@@ -1201,7 +1201,7 @@ mod tests {
});
}
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update();
let count = app
@@ -1240,7 +1240,7 @@ mod tests {
});
}
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update();
// Collect all Text values that are children of the GameOverScreen entity tree.
@@ -1295,7 +1295,7 @@ mod tests {
face_up: true,
});
}
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update();
// Confirm the overlay is present.
@@ -1309,7 +1309,7 @@ mod tests {
);
// Clear the NewGameRequestEvent queue so we start with a clean slate.
app.world_mut().resource_mut::<Events<NewGameRequestEvent>>().clear();
app.world_mut().resource_mut::<Messages<NewGameRequestEvent>>().clear();
// Simulate Escape press.
{
@@ -1320,7 +1320,7 @@ mod tests {
app.update();
// NewGameRequestEvent must have been fired.
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut reader = events.get_cursor();
assert!(
reader.read(events).next().is_some(),
@@ -1338,10 +1338,10 @@ mod tests {
fn undo_on_empty_stack_fires_info_toast() {
let mut app = test_app(42);
// Fresh game — undo stack is empty, so undo() returns UndoStackEmpty.
app.world_mut().send_event(UndoRequestEvent);
app.world_mut().write_message(UndoRequestEvent);
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut reader = events.get_cursor();
let fired: Vec<_> = reader.read(events).collect();
assert_eq!(fired.len(), 1, "exactly one InfoToastEvent must fire on empty-stack undo");
@@ -1357,15 +1357,15 @@ mod tests {
fn undo_after_draw_does_not_fire_info_toast() {
let mut app = test_app(42);
// Make a move so the undo stack is non-empty.
app.world_mut().send_event(DrawRequestEvent);
app.world_mut().write_message(DrawRequestEvent);
app.update();
// Clear events from the draw so we start with a clean slate.
app.world_mut().resource_mut::<Events<InfoToastEvent>>().clear();
app.world_mut().resource_mut::<Messages<InfoToastEvent>>().clear();
app.world_mut().send_event(UndoRequestEvent);
app.world_mut().write_message(UndoRequestEvent);
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut reader = events.get_cursor();
let fired: Vec<_> = reader.read(events).collect();
assert!(
+2 -2
View File
@@ -25,8 +25,8 @@ fn toggle_help_screen(
if !keys.just_pressed(KeyCode::F1) {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_help_screen(&mut commands);
}
+3 -3
View File
@@ -31,8 +31,8 @@ fn toggle_home_screen(
if !keys.just_pressed(KeyCode::KeyM) {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_home_screen(&mut commands, &game);
}
@@ -139,7 +139,7 @@ fn spawn_home_screen(commands: &mut Commands, game: &GameStateResource) {
});
}
fn spawn_shortcut_row(parent: &mut ChildBuilder, key: &str, action: &str) {
fn spawn_shortcut_row(parent: &mut ChildSpawnerCommands, key: &str, action: &str) {
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
+13 -13
View File
@@ -325,7 +325,7 @@ fn update_hud(
if game.is_changed() {
let g = &game.0;
let is_zen = g.mode == GameMode::Zen;
if let Ok(mut t) = score_q.get_single_mut() {
if let Ok(mut t) = score_q.single_mut() {
// Zen mode suppresses score display per spec ("No score display").
**t = if is_zen {
String::new()
@@ -333,10 +333,10 @@ fn update_hud(
format!("Score: {}", g.score)
};
}
if let Ok(mut t) = moves_q.get_single_mut() {
if let Ok(mut t) = moves_q.single_mut() {
**t = format!("Moves: {}", g.move_count);
}
if let Ok(mut t) = mode_q.get_single_mut() {
if let Ok(mut t) = mode_q.single_mut() {
**t = match g.mode {
GameMode::Classic => match g.draw_mode {
DrawMode::DrawOne => String::new(),
@@ -349,7 +349,7 @@ fn update_hud(
}
// --- Daily challenge constraint (with time-low colour warning) ---
if let Ok((mut t, mut color)) = challenge_q.get_single_mut() {
if let Ok((mut t, mut color)) = challenge_q.single_mut() {
if g.is_won {
**t = String::new();
} else if let Some(dc) = daily.as_deref() {
@@ -364,7 +364,7 @@ fn update_hud(
}
// --- Undo count ---
if let Ok((mut t, mut color)) = undos_q.get_single_mut() {
if let Ok((mut t, mut color)) = undos_q.single_mut() {
let count = g.undo_count;
if count == 0 {
**t = String::new();
@@ -377,7 +377,7 @@ fn update_hud(
}
// --- Recycle counter (both modes, hidden until first recycle) ---
if let Ok(mut t) = recycles_q.get_single_mut() {
if let Ok(mut t) = recycles_q.single_mut() {
**t = if g.recycle_count > 0 {
format!("Recycles: {}", g.recycle_count)
} else {
@@ -386,7 +386,7 @@ fn update_hud(
}
// --- Draw-cycle indicator (Draw-Three mode only) ---
if let Ok(mut t) = draw_cycle_q.get_single_mut() {
if let Ok(mut t) = draw_cycle_q.single_mut() {
**t = if g.is_won || g.draw_mode != DrawMode::DrawThree {
// Hide when not in Draw-Three or after the game is won.
String::new()
@@ -405,7 +405,7 @@ fn update_hud(
let is_zen = game.0.mode == GameMode::Zen;
let update_time = (ta_active || game.is_changed()) && !is_zen;
if update_time {
if let Ok(mut t) = time_q.get_single_mut() {
if let Ok(mut t) = time_q.single_mut() {
if let Some(ta) = time_attack.as_ref().filter(|ta| ta.active) {
let remaining = ta.remaining_secs.max(0.0) as u64;
let m = remaining / 60;
@@ -422,7 +422,7 @@ fn update_hud(
// Clear the time display immediately whenever Zen mode is active —
// do not guard on game.is_changed() so it clears on the same frame
// the player presses Z, before any move is made.
if let Ok(mut t) = time_q.get_single_mut() {
if let Ok(mut t) = time_q.single_mut() {
**t = String::new();
}
}
@@ -432,7 +432,7 @@ fn update_hud(
let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
if ac_changed || game.is_changed() {
if let Ok(mut t) = auto_q.get_single_mut() {
if let Ok(mut t) = auto_q.single_mut() {
**t = if ac_active {
"AUTO".to_string()
} else {
@@ -451,7 +451,7 @@ fn update_selection_hud(
selection: Option<Res<SelectionState>>,
mut q: Query<&mut Text, With<HudSelection>>,
) {
let Ok(mut t) = q.get_single_mut() else { return };
let Ok(mut t) = q.single_mut() else { return };
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
None => String::new(),
Some(PileType::Waste) => "▶ Waste".to_string(),
@@ -475,12 +475,12 @@ fn update_selection_hud(
/// to debounce so the toast only appears on the leading edge.
fn announce_auto_complete(
auto_complete: Option<Res<AutoCompleteState>>,
mut toast: EventWriter<InfoToastEvent>,
mut toast: MessageWriter<InfoToastEvent>,
mut was_active: Local<bool>,
) {
let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
if now_active && !*was_active {
toast.send(InfoToastEvent("Auto-completing...".to_string()));
toast.write(InfoToastEvent("Auto-completing...".to_string()));
}
*was_active = now_active;
}
+50 -50
View File
@@ -58,10 +58,10 @@ pub struct InputPlugin;
impl Plugin for InputPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<HintCycleIndex>()
.add_event::<NewGameConfirmEvent>()
.add_event::<InfoToastEvent>()
.add_event::<ForfeitEvent>()
.add_event::<HintVisualEvent>()
.add_message::<NewGameConfirmEvent>()
.add_message::<InfoToastEvent>()
.add_message::<ForfeitEvent>()
.add_message::<HintVisualEvent>()
.add_systems(
Update,
(
@@ -88,14 +88,14 @@ const FORFEIT_CONFIRM_WINDOW: f32 = 3.0;
/// Bundles all event writers used by `handle_keyboard` so the system stays
/// within Bevy's 16-parameter limit.
#[derive(SystemParam)]
struct KeyboardEvents<'w> {
undo: EventWriter<'w, UndoRequestEvent>,
new_game: EventWriter<'w, NewGameRequestEvent>,
confirm_event: EventWriter<'w, NewGameConfirmEvent>,
info_toast: EventWriter<'w, InfoToastEvent>,
draw: EventWriter<'w, DrawRequestEvent>,
forfeit: EventWriter<'w, ForfeitEvent>,
hint_visual: EventWriter<'w, HintVisualEvent>,
struct KeyboardMessages<'w> {
undo: MessageWriter<'w, UndoRequestEvent>,
new_game: MessageWriter<'w, NewGameRequestEvent>,
confirm_event: MessageWriter<'w, NewGameConfirmEvent>,
info_toast: MessageWriter<'w, InfoToastEvent>,
draw: MessageWriter<'w, DrawRequestEvent>,
forfeit: MessageWriter<'w, ForfeitEvent>,
hint_visual: MessageWriter<'w, HintVisualEvent>,
}
#[allow(clippy::too_many_arguments)]
@@ -108,7 +108,7 @@ fn handle_keyboard(
mut confirm_countdown: Local<f32>,
mut confirm_pending: Local<bool>,
mut forfeit_countdown: Local<f32>,
mut ev: KeyboardEvents,
mut ev: KeyboardMessages<'_>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Sprite)>,
layout: Option<Res<LayoutResource>>,
@@ -126,7 +126,7 @@ fn handle_keyboard(
// Countdown expired without a second N press — notify the player.
if *confirm_pending {
*confirm_pending = false;
ev.info_toast.send(InfoToastEvent("New game cancelled".to_string()));
ev.info_toast.write(InfoToastEvent("New game cancelled".to_string()));
}
}
}
@@ -140,7 +140,7 @@ fn handle_keyboard(
if keys.just_pressed(KeyCode::KeyU) {
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; }
ev.undo.send(UndoRequestEvent);
ev.undo.write(UndoRequestEvent);
}
if keys.just_pressed(KeyCode::KeyN) {
// If a Time Attack session is running, cancel it and start a Classic game.
@@ -148,8 +148,8 @@ fn handle_keyboard(
if session.active {
session.active = false;
session.remaining_secs = 0.0;
ev.info_toast.send(InfoToastEvent("Time Attack ended".to_string()));
ev.new_game.send(NewGameRequestEvent {
ev.info_toast.write(InfoToastEvent("Time Attack ended".to_string()));
ev.new_game.write(NewGameRequestEvent {
seed: None,
mode: Some(solitaire_core::game_state::GameMode::Classic),
});
@@ -162,19 +162,19 @@ fn handle_keyboard(
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
if shift_held || !active_game {
// Shift+N or no active game — start immediately, no confirmation.
ev.new_game.send(NewGameRequestEvent::default());
ev.new_game.write(NewGameRequestEvent::default());
*confirm_countdown = 0.0;
*confirm_pending = false;
} else if *confirm_countdown > 0.0 {
// Second press within the window — confirmed.
ev.new_game.send(NewGameRequestEvent::default());
ev.new_game.write(NewGameRequestEvent::default());
*confirm_countdown = 0.0;
*confirm_pending = false;
} else {
// First press on an active game — require confirmation.
*confirm_countdown = NEW_GAME_CONFIRM_WINDOW;
*confirm_pending = true;
ev.confirm_event.send(NewGameConfirmEvent);
ev.confirm_event.write(NewGameConfirmEvent);
}
}
if keys.just_pressed(KeyCode::KeyZ) {
@@ -183,19 +183,19 @@ fn handle_keyboard(
// X is gated separately by ChallengePlugin.
let level = progress.as_ref().map_or(0, |p| p.0.level);
if level >= CHALLENGE_UNLOCK_LEVEL {
ev.new_game.send(NewGameRequestEvent {
ev.new_game.write(NewGameRequestEvent {
seed: None,
mode: Some(solitaire_core::game_state::GameMode::Zen),
});
} else {
ev.info_toast.send(InfoToastEvent(format!(
ev.info_toast.write(InfoToastEvent(format!(
"Zen mode unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
)));
}
}
if keys.just_pressed(KeyCode::KeyD) || keys.just_pressed(KeyCode::Space) {
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; }
ev.draw.send(DrawRequestEvent);
ev.draw.write(DrawRequestEvent);
}
// H — cycle through all available hints on each press, highlighting the
// source card yellow for 1.5 s. The index wraps around once all hints have
@@ -204,13 +204,13 @@ fn handle_keyboard(
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; }
if let Some(ref g) = game {
if g.0.is_won {
ev.info_toast.send(InfoToastEvent(
ev.info_toast.write(InfoToastEvent(
"Game won! Press N for a new game".to_string(),
));
} else if let Some(ref layout_res) = layout {
let hints = all_hints(&g.0);
if hints.is_empty() {
ev.info_toast.send(InfoToastEvent("No hints available".to_string()));
ev.info_toast.write(InfoToastEvent("No hints available".to_string()));
} else {
// Pick the hint at the current cycle index (wrapping) and advance.
let idx = hint_cycle.0 % hints.len();
@@ -229,7 +229,7 @@ fn handle_keyboard(
} else {
"Hint: draw from stock (D)".to_string()
};
ev.info_toast.send(InfoToastEvent(msg));
ev.info_toast.write(InfoToastEvent(msg));
} else {
// Find the top face-up card in the source pile and highlight it.
let top_card_id = g.0.piles.get(from)
@@ -251,7 +251,7 @@ fn handle_keyboard(
}
// Emit HintVisualEvent so the destination pile
// marker is also tinted gold for 2 s.
ev.hint_visual.send(HintVisualEvent {
ev.hint_visual.write(HintVisualEvent {
source_card_id: card_id,
dest_pile: to.clone(),
});
@@ -273,7 +273,7 @@ fn handle_keyboard(
}
_ => "Hint: move card".to_string(),
};
ev.info_toast.send(InfoToastEvent(msg));
ev.info_toast.write(InfoToastEvent(msg));
}
}
}
@@ -287,12 +287,12 @@ fn handle_keyboard(
if active_game {
if *forfeit_countdown > 0.0 {
// Second press within the confirmation window — confirmed.
ev.forfeit.send(ForfeitEvent);
ev.forfeit.write(ForfeitEvent);
*forfeit_countdown = 0.0;
} else {
// First press — start the countdown and warn the player.
*forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
ev.info_toast.send(InfoToastEvent("Press G again to forfeit".to_string()));
ev.info_toast.write(InfoToastEvent("Press G again to forfeit".to_string()));
}
}
}
@@ -308,8 +308,8 @@ fn handle_keyboard(
/// game plugin fires after dealing — preventing a stale hint from the previous
/// game being shown when H is pressed in that gap frame.
fn reset_hint_cycle_on_state_change(
mut state_events: EventReader<StateChangedEvent>,
mut new_game_events: EventReader<NewGameRequestEvent>,
mut state_events: MessageReader<StateChangedEvent>,
mut new_game_events: MessageReader<NewGameRequestEvent>,
mut hint_cycle: ResMut<HintCycleIndex>,
) {
if state_events.read().next().is_some() || new_game_events.read().next().is_some() {
@@ -322,12 +322,12 @@ fn reset_hint_cycle_on_state_change(
fn handle_fullscreen(
keys: Res<ButtonInput<KeyCode>>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
mut toast: EventWriter<InfoToastEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if !keys.just_pressed(KeyCode::F11) {
return;
}
let Ok(mut window) = windows.get_single_mut() else { return };
let Ok(mut window) = windows.single_mut() else { return };
let new_mode = match window.mode {
WindowMode::Windowed => WindowMode::BorderlessFullscreen(MonitorSelection::Current),
_ => WindowMode::Windowed,
@@ -337,7 +337,7 @@ fn handle_fullscreen(
WindowMode::Windowed => "Fullscreen: off",
_ => "Fullscreen: on",
};
toast.send(InfoToastEvent(label.to_string()));
toast.write(InfoToastEvent(label.to_string()));
}
fn handle_stock_click(
@@ -347,7 +347,7 @@ fn handle_stock_click(
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
mut draw: EventWriter<DrawRequestEvent>,
mut draw: MessageWriter<DrawRequestEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
@@ -366,7 +366,7 @@ fn handle_stock_click(
return;
};
if point_in_rect(world, stock_pos, layout.0.card_size) {
draw.send(DrawRequestEvent);
draw.write(DrawRequestEvent);
}
}
@@ -467,9 +467,9 @@ fn end_drag(
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
mut drag: ResMut<DragState>,
mut moves: EventWriter<MoveRequestEvent>,
mut rejected: EventWriter<MoveRejectedEvent>,
mut changed: EventWriter<StateChangedEvent>,
mut moves: MessageWriter<MoveRequestEvent>,
mut rejected: MessageWriter<MoveRejectedEvent>,
mut changed: MessageWriter<StateChangedEvent>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Transform)>,
) {
@@ -517,14 +517,14 @@ fn end_drag(
_ => false,
};
if ok {
moves.send(MoveRequestEvent {
moves.write(MoveRequestEvent {
from: origin.clone(),
to: target.clone(),
count,
});
fired = true;
} else {
rejected.send(MoveRejectedEvent {
rejected.write(MoveRejectedEvent {
from: origin.clone(),
to: target.clone(),
count,
@@ -552,7 +552,7 @@ fn end_drag(
// Either the move succeeded (GamePlugin will also fire StateChangedEvent)
// or it didn't — in both cases we emit one so cards resync to the current
// game state. Duplicate events are harmless.
changed.send(StateChangedEvent);
changed.write(StateChangedEvent);
let _ = fired;
}
@@ -564,9 +564,9 @@ fn cursor_world(
windows: &Query<&Window, With<PrimaryWindow>>,
cameras: &Query<(&Camera, &GlobalTransform)>,
) -> Option<Vec2> {
let window = windows.get_single().ok()?;
let window = windows.single().ok()?;
let cursor = window.cursor_position()?;
let (camera, camera_transform) = cameras.get_single().ok()?;
let (camera, camera_transform) = cameras.single().ok()?;
camera.viewport_to_world_2d(camera_transform, cursor).ok()
}
@@ -809,8 +809,8 @@ fn handle_double_click(
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
mut last_click: Local<HashMap<u32, f32>>,
mut moves: EventWriter<MoveRequestEvent>,
mut rejected: EventWriter<MoveRejectedEvent>,
mut moves: MessageWriter<MoveRequestEvent>,
mut rejected: MessageWriter<MoveRejectedEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
@@ -844,7 +844,7 @@ fn handle_double_click(
// Priority 1: move the single top card (foundation preferred, then tableau).
if let Some(dest) = best_destination(top_card, &game.0) {
moves.send(MoveRequestEvent {
moves.write(MoveRequestEvent {
from: pile,
to: dest,
count: 1,
@@ -864,7 +864,7 @@ fn handle_double_click(
&game.0,
card_ids.len(),
) {
moves.send(MoveRequestEvent {
moves.write(MoveRequestEvent {
from: pile,
to: dest,
count,
@@ -874,7 +874,7 @@ fn handle_double_click(
// sound and shake the source pile cards as feedback.
// `MoveRejectedEvent` with `from == to` routes the shake to
// the source pile (which `start_shake_anim` reads from `ev.to`).
rejected.send(MoveRejectedEvent {
rejected.write(MoveRejectedEvent {
from: pile.clone(),
to: pile,
count: card_ids.len(),
+14 -14
View File
@@ -112,8 +112,8 @@ fn toggle_leaderboard_screen(
if !keys.just_pressed(KeyCode::KeyL) {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
closed_flag.0 = true;
return;
}
@@ -174,7 +174,7 @@ fn update_leaderboard_panel(
return;
}
for entity in &screens {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
spawn_leaderboard_screen(&mut commands, data.0.as_deref());
}
}
@@ -218,18 +218,18 @@ fn handle_opt_in_button(
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure.
fn poll_opt_in_task(
mut task_res: ResMut<OptInTask>,
mut toast: EventWriter<InfoToastEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
task_res.0 = None;
match result {
Ok(()) => {
toast.send(InfoToastEvent("Opted in to leaderboard".to_string()));
toast.write(InfoToastEvent("Opted in to leaderboard".to_string()));
}
Err(e) => {
warn!("leaderboard opt-in failed: {e}");
toast.send(InfoToastEvent("Leaderboard update failed".to_string()));
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
}
}
}
@@ -258,18 +258,18 @@ fn handle_opt_out_button(
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure.
fn poll_opt_out_task(
mut task_res: ResMut<OptOutTask>,
mut toast: EventWriter<InfoToastEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
task_res.0 = None;
match result {
Ok(()) => {
toast.send(InfoToastEvent("Opted out of leaderboard".to_string()));
toast.write(InfoToastEvent("Opted out of leaderboard".to_string()));
}
Err(e) => {
warn!("leaderboard opt-out failed: {e}");
toast.send(InfoToastEvent("Leaderboard update failed".to_string()));
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
}
}
}
@@ -305,10 +305,10 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
min_width: Val::Px(420.0),
max_height: Val::Percent(80.0),
overflow: Overflow::clip_y(),
border_radius: BorderRadius::all(Val::Px(8.0)),
..default()
},
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
BorderRadius::all(Val::Px(8.0)),
))
.with_children(|card| {
// Header
@@ -347,10 +347,10 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
Node {
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.18, 0.35, 0.50)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -366,10 +366,10 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
Node {
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.42, 0.15, 0.15)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -454,7 +454,7 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
});
}
fn header_cell(parent: &mut ChildBuilder, text: &str, width: f32) {
fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32) {
parent.spawn((
Text::new(text.to_string()),
TextFont { font_size: 13.0, ..default() },
@@ -463,7 +463,7 @@ fn header_cell(parent: &mut ChildBuilder, text: &str, width: f32) {
));
}
fn data_cell(parent: &mut ChildBuilder, text: &str, width: f32, color: Color) {
fn data_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, color: Color) {
parent.spawn((
Text::new(text.to_string()),
TextFont { font_size: 15.0, ..default() },
+2 -2
View File
@@ -59,7 +59,7 @@ fn dismiss_on_any_input(
path: Option<Res<SettingsStoragePath>>,
screens: Query<Entity, With<OnboardingScreen>>,
) {
let Ok(entity) = screens.get_single() else {
let Ok(entity) = screens.single() else {
return;
};
let pressed = keys.get_just_pressed().next().is_some()
@@ -67,7 +67,7 @@ fn dismiss_on_any_input(
if !pressed {
return;
}
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
settings.0.first_run_complete = true;
persist(path.as_deref().map(|p| &p.0), &settings.0);
}
+10 -10
View File
@@ -54,8 +54,8 @@ impl Plugin for PausePlugin {
fn build(&self, app: &mut App) {
// Both add_event calls are idempotent — other plugins may register these
// events first, but calling add_event again is always safe.
app.add_event::<SettingsChangedEvent>()
.add_event::<StateChangedEvent>()
app.add_message::<SettingsChangedEvent>()
.add_message::<StateChangedEvent>()
.init_resource::<PausedResource>()
.add_systems(Update, (toggle_pause, handle_pause_draw_toggle));
}
@@ -74,7 +74,7 @@ fn toggle_pause(
stats: Option<Res<StatsResource>>,
settings: Option<Res<SettingsResource>>,
mut drag: Option<ResMut<DragState>>,
mut changed: EventWriter<StateChangedEvent>,
mut changed: MessageWriter<StateChangedEvent>,
) {
if !keys.just_pressed(KeyCode::Escape) {
return;
@@ -90,12 +90,12 @@ fn toggle_pause(
if let Some(ref mut d) = drag {
if !d.is_idle() {
d.clear();
changed.send(StateChangedEvent);
changed.write(StateChangedEvent);
return;
}
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
paused.0 = false;
} else {
// Snapshot current level and streak at pause time.
@@ -125,7 +125,7 @@ fn handle_pause_draw_toggle(
paused: Res<PausedResource>,
settings: Option<ResMut<SettingsResource>>,
path: Option<Res<SettingsStoragePath>>,
mut changed: EventWriter<SettingsChangedEvent>,
mut changed: MessageWriter<SettingsChangedEvent>,
) {
if !paused.0 {
return;
@@ -146,7 +146,7 @@ fn handle_pause_draw_toggle(
}
}
}
changed.send(SettingsChangedEvent(settings.0.clone()));
changed.write(SettingsChangedEvent(settings.0.clone()));
}
}
@@ -224,10 +224,10 @@ fn spawn_pause_screen(
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|btn| {
btn.spawn((
@@ -496,7 +496,7 @@ mod tests {
);
// Verify a SettingsChangedEvent was fired.
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
let count = cursor.read(events).count();
assert!(count >= 1, "SettingsChangedEvent must be fired on toggle");
+3 -3
View File
@@ -42,8 +42,8 @@ fn toggle_profile_screen(
if !keys.just_pressed(KeyCode::KeyP) {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_profile_screen(
&mut commands,
@@ -246,7 +246,7 @@ fn spawn_profile_screen(
}
/// Spawn a fixed-height vertical spacer node.
fn spawn_spacer(parent: &mut ChildBuilder, height_px: f32) {
fn spawn_spacer(parent: &mut ChildSpawnerCommands, height_px: f32) {
parent.spawn(Node {
height: Val::Px(height_px),
..default()
+21 -21
View File
@@ -25,7 +25,7 @@ pub struct ProgressResource(pub PlayerProgress);
pub struct ProgressStoragePath(pub Option<PathBuf>);
/// Fired when a win pushes the player to a new level.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct LevelUpEvent {
pub previous_level: u32,
pub new_level: u32,
@@ -64,9 +64,9 @@ impl Plugin for ProgressPlugin {
};
app.insert_resource(ProgressResource(loaded))
.insert_resource(ProgressStoragePath(self.storage_path.clone()))
.add_event::<LevelUpEvent>()
.add_event::<XpAwardedEvent>()
.add_event::<GameWonEvent>()
.add_message::<LevelUpEvent>()
.add_message::<XpAwardedEvent>()
.add_message::<GameWonEvent>()
.add_systems(
Update,
award_xp_on_win
@@ -77,9 +77,9 @@ impl Plugin for ProgressPlugin {
}
fn award_xp_on_win(
mut wins: EventReader<GameWonEvent>,
mut levelups: EventWriter<LevelUpEvent>,
mut xp_awarded: EventWriter<XpAwardedEvent>,
mut wins: MessageReader<GameWonEvent>,
mut levelups: MessageWriter<LevelUpEvent>,
mut xp_awarded: MessageWriter<XpAwardedEvent>,
game: Res<GameStateResource>,
path: Res<ProgressStoragePath>,
mut progress: ResMut<ProgressResource>,
@@ -88,9 +88,9 @@ fn award_xp_on_win(
let used_undo = game.0.undo_count > 0;
let amount = xp_for_win(ev.time_seconds, used_undo);
let prev_level = progress.0.add_xp(amount);
xp_awarded.send(XpAwardedEvent { amount });
xp_awarded.write(XpAwardedEvent { amount });
if progress.0.leveled_up_from(prev_level) {
levelups.send(LevelUpEvent {
levelups.write(LevelUpEvent {
previous_level: prev_level,
new_level: progress.0.level,
total_xp: progress.0.total_xp,
@@ -131,7 +131,7 @@ mod tests {
fn win_awards_base_xp() {
let mut app = headless_app();
// Game starts with undo_count = 0, so the no-undo bonus applies.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300, // no speed bonus
});
@@ -150,7 +150,7 @@ mod tests {
.0
.undo_count = 1;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300,
});
@@ -164,7 +164,7 @@ mod tests {
#[test]
fn fast_win_includes_speed_bonus() {
let mut app = headless_app();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 0,
});
@@ -181,13 +181,13 @@ mod tests {
// Pre-load 480 XP so a 75-XP win pushes us over the 500 boundary.
app.world_mut().resource_mut::<ProgressResource>().0.total_xp = 480;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300,
});
app.update();
let events = app.world().resource::<Events<LevelUpEvent>>();
let events = app.world().resource::<Messages<LevelUpEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1, "exactly one level-up");
@@ -198,13 +198,13 @@ mod tests {
#[test]
fn win_without_level_change_does_not_fire_levelup() {
let mut app = headless_app();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300,
});
app.update();
let events = app.world().resource::<Events<LevelUpEvent>>();
let events = app.world().resource::<Messages<LevelUpEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
@@ -213,13 +213,13 @@ mod tests {
fn xp_awarded_event_fired_with_correct_amount() {
let mut app = headless_app();
// Slow win, no undo → base 50 + no_undo 25 = 75
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300,
});
app.update();
let events = app.world().resource::<Events<XpAwardedEvent>>();
let events = app.world().resource::<Messages<XpAwardedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -231,14 +231,14 @@ mod tests {
let mut app = headless_app();
app.world_mut().resource_mut::<ProgressResource>().0.total_xp = 480;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300,
});
app.update();
let total_xp = app.world().resource::<ProgressResource>().0.total_xp;
let events = app.world().resource::<Events<LevelUpEvent>>();
let events = app.world().resource::<Messages<LevelUpEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -256,7 +256,7 @@ mod tests {
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 0, // Zen mode keeps score at 0
time_seconds: 300,
});
+8 -8
View File
@@ -162,8 +162,8 @@ fn handle_selection_keys(
paused: Option<Res<PausedResource>>,
game: Res<GameStateResource>,
mut selection: ResMut<SelectionState>,
mut moves: EventWriter<MoveRequestEvent>,
mut info_toast: EventWriter<InfoToastEvent>,
mut moves: MessageWriter<MoveRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
@@ -200,11 +200,11 @@ fn handle_selection_keys(
if keys.just_pressed(KeyCode::Tab) {
let next = cycle_next_pile(&available, selection.selected_pile.as_ref());
if next.is_none() {
info_toast.send(InfoToastEvent("No cards to select".to_string()));
info_toast.write(InfoToastEvent("No cards to select".to_string()));
} else if selection.selected_pile.is_some()
&& did_wrap(&available, selection.selected_pile.as_ref(), next.as_ref())
{
info_toast.send(InfoToastEvent("Back to first card".to_string()));
info_toast.write(InfoToastEvent("Back to first card".to_string()));
}
selection.selected_pile = next;
return;
@@ -236,7 +236,7 @@ fn handle_selection_keys(
// --- Priority 1: foundation move (single card) ---
let foundation_dest = try_foundation_dest(card, &game.0);
if let Some(dest) = foundation_dest {
moves.send(MoveRequestEvent {
moves.write(MoveRequestEvent {
from: pile.clone(),
to: dest,
count: 1,
@@ -260,7 +260,7 @@ fn handle_selection_keys(
if let Some((dest, count)) =
best_tableau_destination_for_stack(bottom, pile, &game.0, run_len)
{
moves.send(MoveRequestEvent {
moves.write(MoveRequestEvent {
from: pile.clone(),
to: dest,
count,
@@ -274,7 +274,7 @@ fn handle_selection_keys(
// Covers non-tableau sources (Waste, Foundation) that have no
// stack-move logic.
if let Some(dest) = best_destination(card, &game.0) {
moves.send(MoveRequestEvent {
moves.write(MoveRequestEvent {
from: pile.clone(),
to: dest,
count: 1,
@@ -343,7 +343,7 @@ fn update_selection_highlight(
) {
// Always despawn any existing highlight first.
for entity in &highlights {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
let Some(ref pile) = selection.selected_pile else {
+53 -53
View File
@@ -36,7 +36,7 @@ pub struct SettingsStoragePath(pub Option<PathBuf>);
pub struct SettingsScreen(pub bool);
/// Fired whenever settings change so consumers (audio, UI) can react.
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct SettingsChangedEvent(pub Settings);
/// Marker on the root Settings panel entity.
@@ -144,9 +144,9 @@ impl Plugin for SettingsPlugin {
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
.init_resource::<SettingsScreen>()
.init_resource::<SettingsScrollPos>()
.add_event::<SettingsChangedEvent>()
.add_event::<ManualSyncRequestEvent>()
.add_event::<bevy::input::mouse::MouseWheel>()
.add_message::<SettingsChangedEvent>()
.add_message::<ManualSyncRequestEvent>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_systems(Update, (handle_volume_keys, toggle_settings_screen, scroll_settings_panel));
if self.ui_enabled {
@@ -185,7 +185,7 @@ fn handle_volume_keys(
keys: Res<ButtonInput<KeyCode>>,
mut settings: ResMut<SettingsResource>,
path: Res<SettingsStoragePath>,
mut changed: EventWriter<SettingsChangedEvent>,
mut changed: MessageWriter<SettingsChangedEvent>,
) {
let mut delta = 0.0_f32;
if keys.just_pressed(KeyCode::BracketLeft) {
@@ -203,7 +203,7 @@ fn handle_volume_keys(
return;
}
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
changed.write(SettingsChangedEvent(settings.0.clone()));
}
/// Opens or closes the Settings panel when `O` is pressed.
@@ -256,11 +256,11 @@ fn sync_settings_panel_visibility(
}
} else {
// Save the current scroll offset before despawning the panel.
if let Ok(sp) = scroll_nodes.get_single() {
scroll_pos.0 = sp.offset_y;
if let Ok(sp) = scroll_nodes.single() {
scroll_pos.0 = sp.0.y;
}
for entity in &panels {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
}
}
@@ -383,8 +383,8 @@ fn handle_settings_buttons(
mut settings: ResMut<SettingsResource>,
mut screen: ResMut<SettingsScreen>,
path: Res<SettingsStoragePath>,
mut changed: EventWriter<SettingsChangedEvent>,
mut manual_sync: EventWriter<ManualSyncRequestEvent>,
mut changed: MessageWriter<SettingsChangedEvent>,
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
@@ -402,8 +402,8 @@ fn handle_settings_buttons(
let after = settings.0.adjust_sfx_volume(-SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.single_mut() {
**t = format!("{:.2}", after);
}
}
@@ -413,8 +413,8 @@ fn handle_settings_buttons(
let after = settings.0.adjust_sfx_volume(SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.single_mut() {
**t = format!("{:.2}", after);
}
}
@@ -424,8 +424,8 @@ fn handle_settings_buttons(
let after = settings.0.adjust_music_volume(-SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.single_mut() {
**t = format!("{:.2}", after);
}
}
@@ -435,8 +435,8 @@ fn handle_settings_buttons(
let after = settings.0.adjust_music_volume(SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.single_mut() {
**t = format!("{:.2}", after);
}
}
@@ -447,8 +447,8 @@ fn handle_settings_buttons(
DrawMode::DrawThree => DrawMode::DrawOne,
};
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = draw_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = draw_text.single_mut() {
**t = draw_mode_label(&settings.0.draw_mode);
}
}
@@ -459,8 +459,8 @@ fn handle_settings_buttons(
AnimSpeed::Instant => AnimSpeed::Normal,
};
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = anim_speed_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = anim_speed_text.single_mut() {
**t = anim_speed_label(&settings.0.animation_speed);
}
}
@@ -471,31 +471,31 @@ fn handle_settings_buttons(
Theme::Dark => Theme::Green,
};
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = theme_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = theme_text.single_mut() {
**t = theme_label(&settings.0.theme);
}
}
SettingsButton::ToggleColorBlind => {
settings.0.color_blind_mode = !settings.0.color_blind_mode;
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = color_blind_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = color_blind_text.single_mut() {
**t = color_blind_label(settings.0.color_blind_mode);
}
}
SettingsButton::SelectCardBack(idx) => {
settings.0.selected_card_back = *idx;
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
changed.write(SettingsChangedEvent(settings.0.clone()));
}
SettingsButton::SelectBackground(idx) => {
settings.0.selected_background = *idx;
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
changed.write(SettingsChangedEvent(settings.0.clone()));
}
SettingsButton::SyncNow => {
manual_sync.send(ManualSyncRequestEvent);
manual_sync.write(ManualSyncRequestEvent);
}
SettingsButton::Done => {
screen.0 = false;
@@ -537,7 +537,7 @@ fn color_blind_label(enabled: bool) -> String {
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
/// scrolls past the top.
fn scroll_settings_panel(
mut scroll_evr: EventReader<MouseWheel>,
mut scroll_evr: MessageReader<MouseWheel>,
screen: Res<SettingsScreen>,
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
) {
@@ -556,7 +556,7 @@ fn scroll_settings_panel(
return;
}
for mut sp in scrollables.iter_mut() {
sp.offset_y = (sp.offset_y - delta_y).max(0.0);
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
@@ -595,7 +595,7 @@ fn spawn_settings_panel(
root.spawn((
SettingsPanelScrollable,
SettingsScrollNode,
ScrollPosition { offset_y: scroll_offset, ..default() },
ScrollPosition(Vec2::new(0.0, scroll_offset)),
Node {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(28.0)),
@@ -603,10 +603,10 @@ fn spawn_settings_panel(
min_width: Val::Px(340.0),
max_height: Val::Percent(88.0),
overflow: Overflow::scroll_y(),
border_radius: BorderRadius::all(Val::Px(8.0)),
..default()
},
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
BorderRadius::all(Val::Px(8.0)),
))
.with_children(|card| {
// Title
@@ -755,10 +755,10 @@ fn spawn_settings_panel(
height: Val::Px(40.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(bg_color),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -801,10 +801,10 @@ fn spawn_settings_panel(
height: Val::Px(40.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(bg_color),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -839,10 +839,10 @@ fn spawn_settings_panel(
Node {
padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -861,10 +861,10 @@ fn spawn_settings_panel(
padding: UiRect::axes(Val::Px(20.0), Val::Px(8.0)),
justify_content: JustifyContent::Center,
margin: UiRect::top(Val::Px(6.0)),
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -880,7 +880,7 @@ fn spawn_settings_panel(
});
}
fn section_label(parent: &mut ChildBuilder, title: &str) {
fn section_label(parent: &mut ChildSpawnerCommands, title: &str) {
parent.spawn((
Text::new(title),
TextFont {
@@ -893,7 +893,7 @@ fn section_label(parent: &mut ChildBuilder, title: &str) {
/// Generic volume row: `Label 0.80 [] [+]`
fn volume_row<Marker: Component>(
parent: &mut ChildBuilder,
parent: &mut ChildSpawnerCommands,
label: &str,
value: f32,
marker: Marker,
@@ -924,7 +924,7 @@ fn volume_row<Marker: Component>(
});
}
fn icon_button(parent: &mut ChildBuilder, label: &str, action: SettingsButton) {
fn icon_button(parent: &mut ChildSpawnerCommands, label: &str, action: SettingsButton) {
parent
.spawn((
action,
@@ -934,10 +934,10 @@ fn icon_button(parent: &mut ChildBuilder, label: &str, action: SettingsButton) {
height: Val::Px(28.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -995,7 +995,7 @@ mod tests {
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(after < before);
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 1);
}
@@ -1020,7 +1020,7 @@ mod tests {
press(&mut app, KeyCode::BracketRight);
app.update();
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 0);
}
@@ -1036,7 +1036,7 @@ mod tests {
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(after >= 0.0, "volume must not go below zero");
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 0, "no event when clamped at floor");
}
@@ -1095,7 +1095,7 @@ mod tests {
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
.id();
// Send a downward scroll event while the panel is closed.
app.world_mut().send_event(MouseWheel {
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -3.0,
@@ -1108,7 +1108,7 @@ mod tests {
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.offset_y;
.0.y;
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
}
@@ -1123,11 +1123,11 @@ mod tests {
.world_mut()
.spawn((
SettingsPanelScrollable,
ScrollPosition { offset_y: 100.0, ..default() },
ScrollPosition(Vec2::new(0.0, 100.0)),
))
.id();
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
app.world_mut().send_event(MouseWheel {
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -2.0,
@@ -1139,7 +1139,7 @@ mod tests {
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.offset_y;
.0.y;
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
}
@@ -1153,11 +1153,11 @@ mod tests {
.world_mut()
.spawn((
SettingsPanelScrollable,
ScrollPosition { offset_y: 10.0, ..default() },
ScrollPosition(Vec2::new(0.0, 10.0)),
))
.id();
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
app.world_mut().send_event(MouseWheel {
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 5.0,
@@ -1169,7 +1169,7 @@ mod tests {
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.offset_y;
.0.y;
assert_eq!(offset, 0.0, "scrolling past top must clamp to 0, got {offset}");
}
}
+25 -25
View File
@@ -77,10 +77,10 @@ impl Plugin for StatsPlugin {
};
app.insert_resource(StatsResource(loaded))
.insert_resource(StatsStoragePath(self.storage_path.clone()))
.add_event::<GameWonEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<ForfeitEvent>()
.add_event::<InfoToastEvent>()
.add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<ForfeitEvent>()
.add_message::<InfoToastEvent>()
// record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game.
.add_systems(
@@ -111,7 +111,7 @@ fn persist(path: &StatsStoragePath, stats: &StatsSnapshot, context: &str) {
}
fn update_stats_on_win(
mut events: EventReader<GameWonEvent>,
mut events: MessageReader<GameWonEvent>,
game: Res<GameStateResource>,
mut stats: ResMut<StatsResource>,
path: Res<StatsStoragePath>,
@@ -125,11 +125,11 @@ fn update_stats_on_win(
}
fn update_stats_on_new_game(
mut events: EventReader<NewGameRequestEvent>,
mut events: MessageReader<NewGameRequestEvent>,
game: Res<GameStateResource>,
mut stats: ResMut<StatsResource>,
path: Res<StatsStoragePath>,
mut toast: EventWriter<InfoToastEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
for _ in events.read() {
if game.0.move_count > 0 && !game.0.is_won {
@@ -137,7 +137,7 @@ fn update_stats_on_new_game(
stats.0.record_abandoned();
persist(&path, &stats.0, "abandoned game");
if streak > 1 {
toast.send(InfoToastEvent(format!("Streak of {streak} broken!")));
toast.write(InfoToastEvent(format!("Streak of {streak} broken!")));
}
}
}
@@ -149,12 +149,12 @@ fn update_stats_on_new_game(
/// `AutoCompleteState` is reset here so the "AUTO" badge and chime do not bleed
/// into the new deal (task #41).
fn handle_forfeit(
mut events: EventReader<ForfeitEvent>,
mut events: MessageReader<ForfeitEvent>,
game: Res<GameStateResource>,
mut stats: ResMut<StatsResource>,
path: Res<StatsStoragePath>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut toast: EventWriter<InfoToastEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
mut auto_complete: Option<ResMut<AutoCompleteState>>,
) {
for _ in events.read() {
@@ -163,7 +163,7 @@ fn handle_forfeit(
stats.0.record_abandoned();
persist(&path, &stats.0, "forfeit");
if streak > 1 {
toast.send(InfoToastEvent(format!("Streak of {streak} broken!")));
toast.write(InfoToastEvent(format!("Streak of {streak} broken!")));
}
}
// Reset auto-complete so the badge and chime don't carry over to the
@@ -171,8 +171,8 @@ fn handle_forfeit(
if let Some(ref mut ac) = auto_complete {
**ac = AutoCompleteState::default();
}
toast.send(InfoToastEvent("Game forfeited".to_string()));
new_game.send(NewGameRequestEvent::default());
toast.write(InfoToastEvent("Game forfeited".to_string()));
new_game.write(NewGameRequestEvent::default());
}
}
@@ -187,8 +187,8 @@ fn toggle_stats_screen(
if !keys.just_pressed(KeyCode::KeyS) {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_stats_screen(
&mut commands,
@@ -349,7 +349,7 @@ fn spawn_stats_screen(
/// Spawn a single stat cell: a large value label on top and a small grey
/// descriptor below, inside a fixed-width column node with a [`StatsCell`] marker.
fn spawn_stat_cell(parent: &mut ChildBuilder, value: &str, label: &str) {
fn spawn_stat_cell(parent: &mut ChildSpawnerCommands, value: &str, label: &str) {
parent
.spawn((
StatsCell,
@@ -513,7 +513,7 @@ mod tests {
#[test]
fn win_event_increments_games_won() {
let mut app = headless_app();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 120,
});
@@ -532,7 +532,7 @@ mod tests {
.0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
@@ -553,7 +553,7 @@ mod tests {
.move_count = 3;
app.world_mut()
.send_event(NewGameRequestEvent { seed: Some(999), mode: None });
.write_message(NewGameRequestEvent { seed: Some(999), mode: None });
app.update();
let stats = &app.world().resource::<StatsResource>().0;
@@ -566,7 +566,7 @@ mod tests {
fn new_game_without_moves_does_not_record_abandoned() {
let mut app = headless_app();
app.world_mut()
.send_event(NewGameRequestEvent { seed: Some(42), mode: None });
.write_message(NewGameRequestEvent { seed: Some(42), mode: None });
app.update();
let stats = &app.world().resource::<StatsResource>().0;
@@ -781,10 +781,10 @@ mod tests {
.0
.move_count = 1;
app.world_mut().send_event(ForfeitEvent);
app.world_mut().write_message(ForfeitEvent);
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut reader = events.get_cursor();
let messages: Vec<&str> = reader
.read(events)
@@ -810,10 +810,10 @@ mod tests {
.0
.move_count = 1;
app.world_mut().send_event(ForfeitEvent);
app.world_mut().write_message(ForfeitEvent);
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut reader = events.get_cursor();
let messages: Vec<&str> = reader
.read(events)
+3 -3
View File
@@ -93,7 +93,7 @@ impl Plugin for SyncPlugin {
.init_resource::<SyncStatusResource>()
.init_resource::<PullTaskResult>()
.init_resource::<PullTask>()
.add_event::<ManualSyncRequestEvent>()
.add_message::<ManualSyncRequestEvent>()
.add_systems(Startup, start_pull)
.add_systems(Update, (poll_pull_result, handle_manual_sync_request))
.add_systems(Last, push_on_exit);
@@ -121,7 +121,7 @@ fn start_pull(
/// Update system: starts a new pull task when `ManualSyncRequestEvent` is
/// received, but only if no pull is already in flight.
fn handle_manual_sync_request(
mut events: EventReader<ManualSyncRequestEvent>,
mut events: MessageReader<ManualSyncRequestEvent>,
provider: Res<SyncProviderResource>,
mut task_res: ResMut<PullTask>,
mut status: ResMut<SyncStatusResource>,
@@ -217,7 +217,7 @@ fn poll_pull_result(
/// that blocking on exit is permitted because the game loop is already
/// shutting down.
fn push_on_exit(
mut exit_events: EventReader<AppExit>,
mut exit_events: MessageReader<AppExit>,
provider: Res<SyncProviderResource>,
stats: Res<StatsResource>,
achievements: Res<AchievementsResource>,
+6 -6
View File
@@ -47,9 +47,9 @@ impl Plugin for TablePlugin {
// Register WindowResized so the plugin works under MinimalPlugins in
// tests. Under DefaultPlugins, bevy_window has already registered it
// and this call is a no-op.
app.add_event::<WindowResized>()
.add_event::<SettingsChangedEvent>()
.add_event::<HintVisualEvent>()
app.add_message::<WindowResized>()
.add_message::<SettingsChangedEvent>()
.add_message::<HintVisualEvent>()
.add_systems(Startup, setup_table)
.add_systems(
Update,
@@ -133,7 +133,7 @@ fn spawn_background(commands: &mut Commands, window_size: Vec2, color: Color) {
}
fn apply_theme_on_settings_change(
mut events: EventReader<SettingsChangedEvent>,
mut events: MessageReader<SettingsChangedEvent>,
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
) {
let Some(ev) = events.read().last() else {
@@ -213,7 +213,7 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
#[allow(clippy::type_complexity)]
fn on_window_resized(
mut events: EventReader<WindowResized>,
mut events: MessageReader<WindowResized>,
mut layout_res: Option<ResMut<LayoutResource>>,
mut backgrounds: Query<
(&mut Sprite, &mut Transform),
@@ -261,7 +261,7 @@ const HINT_PILE_HIGHLIGHT_COLOUR: Color = Color::srgb(1.0, 0.85, 0.1);
/// If the pile marker already has a `HintPileHighlight` from a previous hint
/// press, the timer is reset to 2 s without changing `original_color`.
fn apply_hint_pile_highlight(
mut events: EventReader<HintVisualEvent>,
mut events: MessageReader<HintVisualEvent>,
mut commands: Commands,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite, Option<&HintPileHighlight>)>,
) {
+22 -22
View File
@@ -26,7 +26,7 @@ pub struct TimeAttackResource {
/// Fired when the Time Attack timer expires. The summary toast in
/// `AnimationPlugin` consumes this; UI/stats consumers can also subscribe.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct TimeAttackEndedEvent {
pub wins: u32,
}
@@ -36,10 +36,10 @@ pub struct TimeAttackPlugin;
impl Plugin for TimeAttackPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TimeAttackResource>()
.add_event::<TimeAttackEndedEvent>()
.add_event::<GameWonEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<InfoToastEvent>()
.add_message::<TimeAttackEndedEvent>()
.add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<InfoToastEvent>()
.add_systems(
Update,
handle_start_time_attack_request.before(GameMutation),
@@ -53,14 +53,14 @@ fn handle_start_time_attack_request(
keys: Res<ButtonInput<KeyCode>>,
progress: Res<ProgressResource>,
mut session: ResMut<TimeAttackResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut info_toast: EventWriter<InfoToastEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
if !keys.just_pressed(KeyCode::KeyT) {
return;
}
if progress.0.level < CHALLENGE_UNLOCK_LEVEL {
info_toast.send(InfoToastEvent(format!(
info_toast.write(InfoToastEvent(format!(
"Time Attack unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
)));
return;
@@ -70,7 +70,7 @@ fn handle_start_time_attack_request(
remaining_secs: TIME_ATTACK_DURATION_SECS,
wins: 0,
};
new_game.send(NewGameRequestEvent {
new_game.write(NewGameRequestEvent {
seed: None,
mode: Some(GameMode::TimeAttack),
});
@@ -79,7 +79,7 @@ fn handle_start_time_attack_request(
fn advance_time_attack(
time: Res<Time>,
mut session: ResMut<TimeAttackResource>,
mut ended: EventWriter<TimeAttackEndedEvent>,
mut ended: MessageWriter<TimeAttackEndedEvent>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
) {
if !session.active {
@@ -93,22 +93,22 @@ fn advance_time_attack(
let wins = session.wins;
session.active = false;
session.remaining_secs = 0.0;
ended.send(TimeAttackEndedEvent { wins });
ended.write(TimeAttackEndedEvent { wins });
}
}
fn auto_deal_on_time_attack_win(
mut wins: EventReader<GameWonEvent>,
mut wins: MessageReader<GameWonEvent>,
game: Res<GameStateResource>,
mut session: ResMut<TimeAttackResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
) {
for _ in wins.read() {
if !session.active || game.0.mode != GameMode::TimeAttack {
continue;
}
session.wins = session.wins.saturating_add(1);
new_game.send(NewGameRequestEvent {
new_game.write(NewGameRequestEvent {
seed: None,
mode: Some(GameMode::TimeAttack),
});
@@ -151,7 +151,7 @@ mod tests {
let session = app.world().resource::<TimeAttackResource>();
assert!(!session.active);
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
@@ -169,7 +169,7 @@ mod tests {
assert_eq!(session.wins, 0);
assert!((session.remaining_secs - TIME_ATTACK_DURATION_SECS).abs() < 1.0);
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -193,7 +193,7 @@ mod tests {
assert!(!session.active);
assert_eq!(session.remaining_secs, 0.0);
let events = app.world().resource::<Events<TimeAttackEndedEvent>>();
let events = app.world().resource::<Messages<TimeAttackEndedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -213,7 +213,7 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
@@ -222,7 +222,7 @@ mod tests {
let session = app.world().resource::<TimeAttackResource>();
assert_eq!(session.wins, 1);
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -237,7 +237,7 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
@@ -256,7 +256,7 @@ mod tests {
wins: 0,
};
// GameStateResource defaults to Classic mode.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
@@ -286,7 +286,7 @@ mod tests {
assert!(session.remaining_secs < 0.0, "remaining_secs must not change while paused");
// No ended event must have been emitted.
let events = app.world().resource::<Events<TimeAttackEndedEvent>>();
let events = app.world().resource::<Messages<TimeAttackEndedEvent>>();
let mut cursor = events.get_cursor();
assert!(
cursor.read(events).next().is_none(),
+18 -18
View File
@@ -15,7 +15,7 @@ use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath
use crate::resources::GameStateResource;
/// Fired when the player has just completed a weekly goal.
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct WeeklyGoalCompletedEvent {
pub goal_id: String,
pub description: String,
@@ -25,9 +25,9 @@ pub struct WeeklyGoalsPlugin;
impl Plugin for WeeklyGoalsPlugin {
fn build(&self, app: &mut App) {
app.add_event::<WeeklyGoalCompletedEvent>()
.add_event::<GameWonEvent>()
.add_event::<XpAwardedEvent>()
app.add_message::<WeeklyGoalCompletedEvent>()
.add_message::<GameWonEvent>()
.add_message::<XpAwardedEvent>()
.add_systems(Startup, roll_weekly_goals_on_startup)
// Run after GameMutation (so GameWonEvent is available) and
// ProgressUpdate (so we don't fight ProgressPlugin's add_xp).
@@ -57,13 +57,13 @@ fn roll_weekly_goals_on_startup(
}
fn evaluate_weekly_goals(
mut wins: EventReader<GameWonEvent>,
mut wins: MessageReader<GameWonEvent>,
game: Res<GameStateResource>,
mut progress: ResMut<ProgressResource>,
path: Res<ProgressStoragePath>,
mut completions: EventWriter<WeeklyGoalCompletedEvent>,
mut levelups: EventWriter<LevelUpEvent>,
mut xp_awarded: EventWriter<XpAwardedEvent>,
mut completions: MessageWriter<WeeklyGoalCompletedEvent>,
mut levelups: MessageWriter<LevelUpEvent>,
mut xp_awarded: MessageWriter<XpAwardedEvent>,
) {
let mut events: Vec<&GameWonEvent> = wins.read().collect();
if events.is_empty() {
@@ -92,7 +92,7 @@ fn evaluate_weekly_goals(
any_change = true;
if just_completed {
bonus_xp = bonus_xp.saturating_add(WEEKLY_GOAL_XP);
completions.send(WeeklyGoalCompletedEvent {
completions.write(WeeklyGoalCompletedEvent {
goal_id: def.id.to_string(),
description: def.description.to_string(),
});
@@ -101,10 +101,10 @@ fn evaluate_weekly_goals(
}
if bonus_xp > 0 {
xp_awarded.send(XpAwardedEvent { amount: bonus_xp });
xp_awarded.write(XpAwardedEvent { amount: bonus_xp });
let prev_level = progress.0.add_xp(bonus_xp);
if progress.0.leveled_up_from(prev_level) {
levelups.send(LevelUpEvent {
levelups.write(LevelUpEvent {
previous_level: prev_level,
new_level: progress.0.level,
total_xp: progress.0.total_xp,
@@ -149,7 +149,7 @@ mod tests {
#[test]
fn first_win_increments_win_game_goal() {
let mut app = headless_app();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
@@ -164,7 +164,7 @@ mod tests {
#[test]
fn fast_win_ticks_fast_goal_too() {
let mut app = headless_app();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
@@ -181,7 +181,7 @@ mod tests {
.0
.undo_count = 1;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
@@ -214,7 +214,7 @@ mod tests {
let xp_before = app.world().resource::<ProgressResource>().0.total_xp;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
@@ -228,7 +228,7 @@ mod tests {
let base_win_xp = solitaire_data::xp_for_win(60, false);
assert_eq!(p.total_xp - xp_before, base_win_xp + WEEKLY_GOAL_XP);
let events = app.world().resource::<Events<WeeklyGoalCompletedEvent>>();
let events = app.world().resource::<Messages<WeeklyGoalCompletedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert!(fired.iter().any(|e| e.goal_id == "weekly_3_fast"));
@@ -280,13 +280,13 @@ mod tests {
.0
.weekly_goal_week_iso = Some(key);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
app.update();
let events = app.world().resource::<Events<LevelUpEvent>>();
let events = app.world().resource::<Messages<LevelUpEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert!(!fired.is_empty(), "LevelUpEvent must fire when weekly bonus pushes past a level threshold");
+35 -35
View File
@@ -159,11 +159,11 @@ impl Plugin for WinSummaryPlugin {
app.init_resource::<WinSummaryPending>()
.init_resource::<ScreenShakeResource>()
.init_resource::<SessionAchievements>()
.add_event::<GameWonEvent>()
.add_event::<XpAwardedEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<InfoToastEvent>()
.add_event::<AchievementUnlockedEvent>()
.add_message::<GameWonEvent>()
.add_message::<XpAwardedEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<AchievementUnlockedEvent>()
// `cache_win_data` must run BEFORE `StatsUpdate` so it can compare
// the player's old personal-best values before `StatsPlugin` overwrites them.
.add_systems(
@@ -221,13 +221,13 @@ pub fn format_win_time(seconds: u64) -> String {
/// This system is scheduled `.before(StatsUpdate)` so the comparison always
/// sees the old best values.
fn cache_win_data(
mut won: EventReader<GameWonEvent>,
mut xp: EventReader<XpAwardedEvent>,
mut won: MessageReader<GameWonEvent>,
mut xp: MessageReader<XpAwardedEvent>,
mut pending: ResMut<WinSummaryPending>,
stats: Res<StatsResource>,
game: Res<GameStateResource>,
progress: Res<ProgressResource>,
mut toast: EventWriter<InfoToastEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
for ev in won.read() {
// Compare against old personal bests BEFORE StatsPlugin updates them.
@@ -255,7 +255,7 @@ fn cache_win_data(
pending.challenge_level = challenge_level;
if is_new_record {
toast.send(InfoToastEvent("New Record!".to_string()));
toast.write(InfoToastEvent("New Record!".to_string()));
}
}
for ev in xp.read() {
@@ -274,8 +274,8 @@ fn cache_win_data(
/// reader covers every implicit game-context reset in addition to the
/// explicit N / "Play Again" new-game requests.
fn collect_session_achievements(
mut unlocks: EventReader<AchievementUnlockedEvent>,
mut new_games: EventReader<NewGameRequestEvent>,
mut unlocks: MessageReader<AchievementUnlockedEvent>,
mut new_games: MessageReader<NewGameRequestEvent>,
mut session: ResMut<SessionAchievements>,
) {
// Reset on any new-game request (including mode switches via Z/X/C/T) so
@@ -303,8 +303,8 @@ fn collect_session_achievements(
#[allow(clippy::too_many_arguments)]
fn spawn_win_summary_after_delay(
mut commands: Commands,
mut won: EventReader<GameWonEvent>,
mut xp_events: EventReader<XpAwardedEvent>,
mut won: MessageReader<GameWonEvent>,
mut xp_events: MessageReader<XpAwardedEvent>,
mut shake: ResMut<ScreenShakeResource>,
mut pending: ResMut<WinSummaryPending>,
session: Res<SessionAchievements>,
@@ -321,7 +321,7 @@ fn spawn_win_summary_after_delay(
*delay = Some(WIN_SUMMARY_DELAY_SECS);
// Clear any stale overlay from a previous win.
for entity in &overlays {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
}
@@ -352,7 +352,7 @@ fn handle_win_summary_buttons(
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
overlays: Query<Entity, With<WinSummaryOverlay>>,
mut commands: Commands,
mut new_game: EventWriter<NewGameRequestEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
@@ -362,9 +362,9 @@ fn handle_win_summary_buttons(
WinSummaryButton::PlayAgain => {
// Despawn the modal.
for entity in &overlays {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
new_game.send(NewGameRequestEvent::default());
new_game.write(NewGameRequestEvent::default());
}
}
}
@@ -442,10 +442,10 @@ fn spawn_overlay(
row_gap: Val::Px(18.0),
min_width: Val::Px(320.0),
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.12, 0.10)),
BorderRadius::all(Val::Px(12.0)),
))
.with_children(|card| {
// Heading
@@ -518,10 +518,10 @@ fn spawn_overlay(
padding: UiRect::axes(Val::Px(28.0), Val::Px(12.0)),
justify_content: JustifyContent::Center,
margin: UiRect::top(Val::Px(8.0)),
border_radius: BorderRadius::all(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
BorderRadius::all(Val::Px(6.0)),
))
.with_children(|b| {
b.spawn((
@@ -543,7 +543,7 @@ const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
/// Shows at most [`MAX_ACHIEVEMENTS_SHOWN`] names. When more achievements were
/// unlocked than the cap, appends a "...and N more" line so the player knows
/// there are additional unlocks visible on the achievements screen.
fn spawn_achievements_section(card: &mut ChildBuilder, names: &[String]) {
fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String]) {
card.spawn((
Text::new("Achievements Unlocked"),
TextFont { font_size: 18.0, ..default() },
@@ -677,7 +677,7 @@ mod tests {
use solitaire_data::AchievementRecord;
let record = AchievementRecord::locked("first_win");
app.world_mut()
.send_event(AchievementUnlockedEvent(record));
.write_message(AchievementUnlockedEvent(record));
app.update();
let session = app.world().resource::<SessionAchievements>();
@@ -693,7 +693,7 @@ mod tests {
use solitaire_data::AchievementRecord;
let record = AchievementRecord::locked("first_win");
app.world_mut()
.send_event(AchievementUnlockedEvent(record));
.write_message(AchievementUnlockedEvent(record));
app.update();
// Confirm it was recorded.
@@ -703,7 +703,7 @@ mod tests {
);
// Fire NewGameRequestEvent — should clear the list.
app.world_mut().send_event(NewGameRequestEvent::default());
app.world_mut().write_message(NewGameRequestEvent::default());
app.update();
assert!(
@@ -727,7 +727,7 @@ mod tests {
// Simulate an achievement unlock during the current session.
let record = AchievementRecord::locked("first_win");
app.world_mut()
.send_event(AchievementUnlockedEvent(record));
.write_message(AchievementUnlockedEvent(record));
app.update();
assert_eq!(
@@ -739,7 +739,7 @@ mod tests {
// Simulate pressing Z (Zen mode switch) — fires NewGameRequestEvent
// with mode = Some(Zen). Same event shape used by X (Challenge),
// C (Daily Challenge), and T (Time Attack).
app.world_mut().send_event(NewGameRequestEvent {
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: Some(GameMode::Zen),
});
@@ -756,7 +756,7 @@ mod tests {
let mut app = make_app();
app.world_mut()
.send_event(GameWonEvent { score: 1234, time_seconds: 90 });
.write_message(GameWonEvent { score: 1234, time_seconds: 90 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -771,8 +771,8 @@ mod tests {
fn cache_win_data_sets_xp_from_xp_awarded_event() {
let mut app = make_app();
app.world_mut().send_event(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().send_event(XpAwardedEvent { amount: 75 });
app.world_mut().write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(XpAwardedEvent { amount: 75 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -784,7 +784,7 @@ mod tests {
let mut app = make_app();
app.world_mut()
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.update();
let shake = app.world().resource::<ScreenShakeResource>();
@@ -802,7 +802,7 @@ mod tests {
let mut app = make_app();
app.world_mut()
.send_event(GameWonEvent { score: 500, time_seconds: 120 });
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -820,7 +820,7 @@ mod tests {
// Score 500 beats previous best of 400.
app.world_mut()
.send_event(GameWonEvent { score: 500, time_seconds: 300 });
.write_message(GameWonEvent { score: 500, time_seconds: 300 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -838,7 +838,7 @@ mod tests {
// Score 500 does not beat 800, but time 100 < 200.
app.world_mut()
.send_event(GameWonEvent { score: 500, time_seconds: 100 });
.write_message(GameWonEvent { score: 500, time_seconds: 100 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -856,7 +856,7 @@ mod tests {
// Score 500 < 800 and time 120 > 60 — neither record broken.
app.world_mut()
.send_event(GameWonEvent { score: 500, time_seconds: 120 });
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -887,7 +887,7 @@ mod tests {
}
app.world_mut()
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -903,7 +903,7 @@ mod tests {
let mut app = make_app();
// Default game mode is Classic — challenge_level should stay None.
app.world_mut()
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
+1 -3
View File
@@ -64,9 +64,7 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
.finish()
.expect("invalid governor config"),
);
auth_routes.layer(GovernorLayer {
config: governor_conf,
})
auth_routes.layer(GovernorLayer::new(governor_conf))
} else {
auth_routes
};
-1
View File
@@ -100,7 +100,6 @@ pub fn validate_refresh_token(token: &str, secret: &str) -> Result<Claims, AppEr
// Axum extractor — allows handlers to receive AuthenticatedUser directly
// ---------------------------------------------------------------------------
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
S: Send + Sync,