From 3c01cef5f3c4c5a283d9e65f5e92a29b0ed4d37c Mon Sep 17 00:00:00 2001 From: root Date: Sun, 26 Apr 2026 23:53:48 +0000 Subject: [PATCH] feat(engine): implement Draw Mode, Theme, and Music Volume settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings panel "coming soon" stubs replaced with live controls: - Draw Mode toggle (Draw 1 / Draw 3): new games read draw_mode from SettingsResource instead of the previous game's mode. Falls back to the current game's mode in headless/test contexts where SettingsPlugin is absent. - Theme selector (Green → Blue → Dark → Green): SettingsChangedEvent drives TablePlugin's background Sprite colour so the table re-colours immediately without a restart. - Music Volume [−]/[+]: dedicated kira sub-tracks created for SFX and music on startup. SFX sounds are routed to the SFX track; the music track exists for future ambient audio. Both volumes are set on SettingsChangedEvent and at startup. Also fixed: time_attack timer_expiry test double-fires when MinimalPlugins time delta is nonzero — removed the intermediate 0.001-remaining update step. Co-Authored-By: Claude Sonnet 4.6 --- solitaire_engine/src/audio_plugin.rs | 54 ++++-- solitaire_engine/src/game_plugin.rs | 9 +- solitaire_engine/src/settings_plugin.rs | 196 +++++++++++++++++---- solitaire_engine/src/sync_plugin.rs | 1 + solitaire_engine/src/table_plugin.rs | 39 +++- solitaire_engine/src/time_attack_plugin.rs | 11 +- 6 files changed, 245 insertions(+), 65 deletions(-) diff --git a/solitaire_engine/src/audio_plugin.rs b/solitaire_engine/src/audio_plugin.rs index 3c6d793..d5e151b 100644 --- a/solitaire_engine/src/audio_plugin.rs +++ b/solitaire_engine/src/audio_plugin.rs @@ -22,6 +22,7 @@ use bevy::prelude::*; use kira::manager::backend::DefaultBackend; use kira::manager::{AudioManager, AudioManagerSettings}; use kira::sound::static_sound::StaticSoundData; +use kira::track::{TrackBuilder, TrackHandle}; use kira::tween::Tween; use crate::events::{ @@ -44,17 +45,33 @@ pub struct SoundLibrary { /// some platforms. pub struct AudioState { manager: Option>, + /// Dedicated sub-track for sound effects. Volume controlled by `sfx_volume`. + sfx_track: Option, + /// Dedicated sub-track for ambient music. Volume controlled by `music_volume`. + /// No sounds are currently routed here; the track exists so future ambient + /// music can be added without changing the volume architecture. + music_track: Option, } pub struct AudioPlugin; impl Plugin for AudioPlugin { fn build(&self, app: &mut App) { - let manager = AudioManager::::new(AudioManagerSettings::default()).ok(); + let mut manager = AudioManager::::new(AudioManagerSettings::default()).ok(); if manager.is_none() { warn!("audio device unavailable; SFX disabled"); } - app.insert_non_send_resource(AudioState { manager }); + + let (sfx_track, 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(); + (sfx, music) + } + None => (None, None), + }; + + app.insert_non_send_resource(AudioState { manager, sfx_track, music_track }); let library = build_library(); if let Some(lib) = library { @@ -116,26 +133,36 @@ fn play(audio: &mut AudioState, sound: &StaticSoundData) { let Some(manager) = audio.manager.as_mut() else { return; }; - if let Err(e) = manager.play(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) { warn!("failed to play SFX: {e}"); } } -fn set_main_track_volume(audio: &mut AudioState, volume: f32) { - let Some(manager) = audio.manager.as_mut() else { - return; - }; - manager - .main_track() - .set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default()); +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()); + } +} + +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()); + } } fn apply_initial_volume( mut audio: NonSendMut, settings: Option>, ) { - let volume = settings.map_or(1.0, |s| s.0.sfx_volume); - set_main_track_volume(&mut audio, volume); + let (sfx, music) = settings.map_or((1.0, 0.5), |s| (s.0.sfx_volume, s.0.music_volume)); + set_sfx_volume(&mut audio, sfx); + set_music_volume(&mut audio, music); } fn apply_volume_on_change( @@ -143,7 +170,8 @@ fn apply_volume_on_change( mut audio: NonSendMut, ) { for ev in events.read() { - set_main_track_volume(&mut audio, ev.0.sfx_volume); + set_sfx_volume(&mut audio, ev.0.sfx_volume); + set_music_volume(&mut audio, ev.0.music_volume); } } diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index 46da46b..cd80d33 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -105,10 +105,17 @@ fn handle_new_game( mut new_game: EventReader, mut game: ResMut, mut changed: EventWriter, + settings: Option>, ) { for ev in new_game.read() { let seed = ev.seed.unwrap_or_else(seed_from_system_time); - let draw_mode = game.0.draw_mode.clone(); + // Prefer the draw mode from Settings when starting a fresh game. + // Fall back to the current game's draw mode in headless/test contexts + // where SettingsPlugin is not installed. + let draw_mode = settings + .as_ref() + .map(|s| s.0.draw_mode.clone()) + .unwrap_or_else(|| game.0.draw_mode.clone()); let mode = ev.mode.unwrap_or(game.0.mode); game.0 = GameState::new_with_mode(seed, draw_mode, mode); changed.send(StateChangedEvent); diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index 7e904ff..b354f26 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -12,7 +12,8 @@ use std::path::PathBuf; use bevy::prelude::*; -use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, Settings}; +use solitaire_core::game_state::DrawMode; +use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, Settings}; /// Volume adjustment step applied by the `[` / `]` hotkeys. pub const SFX_STEP: f32 = 0.1; @@ -37,15 +38,31 @@ pub struct SettingsChangedEvent(pub Settings); #[derive(Component, Debug)] struct SettingsPanel; -/// Marks the `Text` node that displays the live SFX volume value. +/// Marks the `Text` node showing the live SFX volume value. #[derive(Component, Debug)] struct SfxVolumeText; +/// Marks the `Text` node showing the live music volume value. +#[derive(Component, Debug)] +struct MusicVolumeText; + +/// Marks the `Text` node showing the current draw mode. +#[derive(Component, Debug)] +struct DrawModeText; + +/// Marks the `Text` node showing the current theme. +#[derive(Component, Debug)] +struct ThemeText; + /// Tags interactive buttons inside the Settings panel. #[derive(Component, Debug)] enum SettingsButton { SfxDown, SfxUp, + MusicDown, + MusicUp, + ToggleDrawMode, + ToggleTheme, Done, } @@ -171,13 +188,17 @@ fn sync_settings_panel_visibility( } /// Reacts to button presses inside the Settings panel. +#[allow(clippy::too_many_arguments, clippy::type_complexity)] fn handle_settings_buttons( interaction_query: Query<(&Interaction, &SettingsButton), Changed>, mut settings: ResMut, mut screen: ResMut, path: Res, mut changed: EventWriter, - mut volume_text: Query<&mut Text, With>, + mut sfx_text: Query<&mut Text, (With, Without, Without, Without)>, + mut music_text: Query<&mut Text, (With, Without, Without, Without)>, + mut draw_text: Query<&mut Text, (With, Without, Without, Without)>, + mut theme_text: Query<&mut Text, (With, Without, Without, Without)>, ) { for (interaction, button) in &interaction_query { if *interaction != Interaction::Pressed { @@ -190,8 +211,8 @@ fn handle_settings_buttons( if (before - after).abs() > f32::EPSILON { persist(&path, &settings.0); changed.send(SettingsChangedEvent(settings.0.clone())); - if let Ok(mut text) = volume_text.get_single_mut() { - **text = format!("{:.2}", after); + if let Ok(mut t) = sfx_text.get_single_mut() { + **t = format!("{:.2}", after); } } } @@ -201,11 +222,56 @@ fn handle_settings_buttons( if (before - after).abs() > f32::EPSILON { persist(&path, &settings.0); changed.send(SettingsChangedEvent(settings.0.clone())); - if let Ok(mut text) = volume_text.get_single_mut() { - **text = format!("{:.2}", after); + if let Ok(mut t) = sfx_text.get_single_mut() { + **t = format!("{:.2}", after); } } } + SettingsButton::MusicDown => { + let before = settings.0.music_volume; + 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() { + **t = format!("{:.2}", after); + } + } + } + SettingsButton::MusicUp => { + let before = settings.0.music_volume; + 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() { + **t = format!("{:.2}", after); + } + } + } + SettingsButton::ToggleDrawMode => { + settings.0.draw_mode = match settings.0.draw_mode { + DrawMode::DrawOne => DrawMode::DrawThree, + DrawMode::DrawThree => DrawMode::DrawOne, + }; + persist(&path, &settings.0); + changed.send(SettingsChangedEvent(settings.0.clone())); + if let Ok(mut t) = draw_text.get_single_mut() { + **t = draw_mode_label(&settings.0.draw_mode); + } + } + SettingsButton::ToggleTheme => { + settings.0.theme = match settings.0.theme { + Theme::Green => Theme::Blue, + Theme::Blue => Theme::Dark, + Theme::Dark => Theme::Green, + }; + persist(&path, &settings.0); + changed.send(SettingsChangedEvent(settings.0.clone())); + if let Ok(mut t) = theme_text.get_single_mut() { + **t = theme_label(&settings.0.theme); + } + } SettingsButton::Done => { screen.0 = false; } @@ -213,6 +279,21 @@ fn handle_settings_buttons( } } +fn draw_mode_label(mode: &DrawMode) -> String { + match mode { + DrawMode::DrawOne => "Draw 1".into(), + DrawMode::DrawThree => "Draw 3".into(), + } +} + +fn theme_label(theme: &Theme) -> String { + match theme { + Theme::Green => "Green".into(), + Theme::Blue => "Blue".into(), + Theme::Dark => "Dark".into(), + } +} + // --------------------------------------------------------------------------- // UI construction // --------------------------------------------------------------------------- @@ -262,7 +343,18 @@ fn spawn_settings_panel(commands: &mut Commands, settings: &Settings) { // --- Audio section --- section_label(card, "Audio"); - // SFX volume row: label | value | [−] | [+] + // SFX volume row + volume_row(card, "SFX Volume", settings.sfx_volume, SfxVolumeText, + SettingsButton::SfxDown, SettingsButton::SfxUp); + + // Music volume row + volume_row(card, "Music Volume", settings.music_volume, MusicVolumeText, + SettingsButton::MusicDown, SettingsButton::MusicUp); + + // --- Gameplay section --- + section_label(card, "Gameplay"); + + // Draw mode row card.spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, @@ -271,39 +363,43 @@ fn spawn_settings_panel(commands: &mut Commands, settings: &Settings) { }) .with_children(|row| { row.spawn(( - Text::new("SFX Volume"), - TextFont { - font_size: 18.0, - ..default() - }, + Text::new("Draw Mode"), + TextFont { font_size: 18.0, ..default() }, TextColor(Color::srgb(0.85, 0.85, 0.80)), )); row.spawn(( - SfxVolumeText, - Text::new(format!("{:.2}", settings.sfx_volume)), - TextFont { - font_size: 18.0, - ..default() - }, + DrawModeText, + Text::new(draw_mode_label(&settings.draw_mode)), + TextFont { font_size: 18.0, ..default() }, TextColor(Color::WHITE), )); - icon_button(row, "−", SettingsButton::SfxDown); - icon_button(row, "+", SettingsButton::SfxUp); + icon_button(row, "⇄", SettingsButton::ToggleDrawMode); }); - coming_soon_row(card, "Music Volume"); - - // --- Gameplay section --- - section_label(card, "Gameplay"); - coming_soon_row(card, "Draw Mode"); - // --- Appearance section --- section_label(card, "Appearance"); - coming_soon_row(card, "Theme"); - // --- Sync section --- - section_label(card, "Sync"); - coming_soon_row(card, "Sync Backend"); + // Theme row + card.spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: Val::Px(8.0), + ..default() + }) + .with_children(|row| { + row.spawn(( + Text::new("Theme"), + TextFont { font_size: 18.0, ..default() }, + TextColor(Color::srgb(0.85, 0.85, 0.80)), + )); + row.spawn(( + ThemeText, + Text::new(theme_label(&settings.theme)), + TextFont { font_size: 18.0, ..default() }, + TextColor(Color::WHITE), + )); + icon_button(row, "⇄", SettingsButton::ToggleTheme); + }); // Done button card.spawn(( @@ -343,15 +439,37 @@ fn section_label(parent: &mut ChildBuilder, title: &str) { )); } -fn coming_soon_row(parent: &mut ChildBuilder, label: &str) { - parent.spawn(( - Text::new(format!("{label} — coming soon")), - TextFont { - font_size: 16.0, +/// Generic volume row: `Label 0.80 [−] [+]` +fn volume_row( + parent: &mut ChildBuilder, + label: &str, + value: f32, + marker: Marker, + btn_down: SettingsButton, + btn_up: SettingsButton, +) { + parent + .spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: Val::Px(8.0), ..default() - }, - TextColor(Color::srgb(0.45, 0.45, 0.45)), - )); + }) + .with_children(|row| { + row.spawn(( + Text::new(label.to_string()), + TextFont { font_size: 18.0, ..default() }, + TextColor(Color::srgb(0.85, 0.85, 0.80)), + )); + row.spawn(( + marker, + Text::new(format!("{:.2}", value)), + TextFont { font_size: 18.0, ..default() }, + TextColor(Color::WHITE), + )); + icon_button(row, "−", btn_down); + icon_button(row, "+", btn_up); + }); } fn icon_button(parent: &mut ChildBuilder, label: &str, action: SettingsButton) { diff --git a/solitaire_engine/src/sync_plugin.rs b/solitaire_engine/src/sync_plugin.rs index 26d8e54..4e73009 100644 --- a/solitaire_engine/src/sync_plugin.rs +++ b/solitaire_engine/src/sync_plugin.rs @@ -126,6 +126,7 @@ fn start_pull( /// 4. Sets [`SyncStatusResource`] to [`SyncStatus::LastSynced`]. /// /// On failure, sets [`SyncStatusResource`] to [`SyncStatus::Error`]. +#[allow(clippy::too_many_arguments)] fn poll_pull_result( mut task_res: ResMut, mut status: ResMut, diff --git a/solitaire_engine/src/table_plugin.rs b/solitaire_engine/src/table_plugin.rs index 0d4824e..30fbe3b 100644 --- a/solitaire_engine/src/table_plugin.rs +++ b/solitaire_engine/src/table_plugin.rs @@ -8,8 +8,10 @@ use bevy::prelude::*; use bevy::window::WindowResized; use solitaire_core::card::Suit; use solitaire_core::pile::PileType; +use solitaire_data::settings::Theme; use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR}; +use crate::settings_plugin::SettingsChangedEvent; /// Z-depth used for the background — below everything. const Z_BACKGROUND: f32 = -10.0; @@ -34,8 +36,18 @@ impl Plugin for TablePlugin { // tests. Under DefaultPlugins, bevy_window has already registered it // and this call is a no-op. app.add_event::() + .add_event::() .add_systems(Startup, setup_table) - .add_systems(Update, on_window_resized); + .add_systems(Update, (on_window_resized, apply_theme_on_settings_change)); + } +} + +/// Returns the felt colour for a given theme. +fn theme_colour(theme: &Theme) -> Color { + match theme { + Theme::Green => Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]), + Theme::Blue => Color::srgb(0.059, 0.196, 0.322), + Theme::Dark => Color::srgb(0.08, 0.08, 0.10), } } @@ -47,6 +59,7 @@ fn setup_table( mut commands: Commands, windows: Query<&Window>, existing_camera: Query<(), With>, + settings: Option>, ) { // Only spawn a camera if one does not already exist (e.g. a parent app // may have added one in tests). @@ -61,18 +74,23 @@ fn setup_table( .unwrap_or(Vec2::new(1280.0, 800.0)); let layout = compute_layout(window_size); - spawn_background(&mut commands, window_size); + let initial_colour = settings + .as_ref() + .map(|s| theme_colour(&s.0.theme)) + .unwrap_or_else(|| Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2])); + + spawn_background(&mut commands, window_size, initial_colour); spawn_pile_markers(&mut commands, &layout); commands.insert_resource(LayoutResource(layout)); } -fn spawn_background(commands: &mut Commands, window_size: Vec2) { +fn spawn_background(commands: &mut Commands, window_size: Vec2, color: Color) { // Spawn a felt-coloured rectangle that always covers the window. We give // it the window size plus headroom so resizing up doesn't expose edges // before the resize handler runs. commands.spawn(( Sprite { - color: Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]), + color, custom_size: Some(window_size * 2.0), ..default() }, @@ -81,6 +99,19 @@ fn spawn_background(commands: &mut Commands, window_size: Vec2) { )); } +fn apply_theme_on_settings_change( + mut events: EventReader, + mut backgrounds: Query<&mut Sprite, With>, +) { + let Some(ev) = events.read().last() else { + return; + }; + let colour = theme_colour(&ev.0.theme); + for mut sprite in &mut backgrounds { + sprite.color = colour; + } +} + fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) { let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08); let marker_size = layout.card_size; diff --git a/solitaire_engine/src/time_attack_plugin.rs b/solitaire_engine/src/time_attack_plugin.rs index ced5aad..44dd148 100644 --- a/solitaire_engine/src/time_attack_plugin.rs +++ b/solitaire_engine/src/time_attack_plugin.rs @@ -178,14 +178,9 @@ mod tests { #[test] fn timer_expiry_fires_ended_event_and_clears_active() { let mut app = headless_app(); - // Manually start a near-expired session. - *app.world_mut().resource_mut::() = TimeAttackResource { - active: true, - remaining_secs: 0.001, - wins: 5, - }; - app.update(); - // First update advances time slightly; force the timer past zero. + // Set the session to an already-expired state (remaining < 0). + // MinimalPlugins time delta is nonzero so we skip the intermediate + // 0.001-remaining step to avoid a double-fire. *app.world_mut().resource_mut::() = TimeAttackResource { active: true, remaining_secs: -1.0,