feat(engine): implement Draw Mode, Theme, and Music Volume settings

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 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-26 23:53:48 +00:00
parent 34ba4dc6ed
commit 3c01cef5f3
6 changed files with 245 additions and 65 deletions
+41 -13
View File
@@ -22,6 +22,7 @@ use bevy::prelude::*;
use kira::manager::backend::DefaultBackend; use kira::manager::backend::DefaultBackend;
use kira::manager::{AudioManager, AudioManagerSettings}; use kira::manager::{AudioManager, AudioManagerSettings};
use kira::sound::static_sound::StaticSoundData; use kira::sound::static_sound::StaticSoundData;
use kira::track::{TrackBuilder, TrackHandle};
use kira::tween::Tween; use kira::tween::Tween;
use crate::events::{ use crate::events::{
@@ -44,17 +45,33 @@ pub struct SoundLibrary {
/// some platforms. /// some platforms.
pub struct AudioState { pub struct AudioState {
manager: Option<AudioManager<DefaultBackend>>, manager: Option<AudioManager<DefaultBackend>>,
/// Dedicated sub-track for sound effects. Volume controlled by `sfx_volume`.
sfx_track: Option<TrackHandle>,
/// 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<TrackHandle>,
} }
pub struct AudioPlugin; pub struct AudioPlugin;
impl Plugin for AudioPlugin { impl Plugin for AudioPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
let manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok(); let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
if manager.is_none() { if manager.is_none() {
warn!("audio device unavailable; SFX disabled"); 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(); let library = build_library();
if let Some(lib) = 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 { let Some(manager) = audio.manager.as_mut() else {
return; 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}"); warn!("failed to play SFX: {e}");
} }
} }
fn set_main_track_volume(audio: &mut AudioState, volume: f32) { fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
let Some(manager) = audio.manager.as_mut() else { if let Some(track) = audio.sfx_track.as_mut() {
return; track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
}; }
manager }
.main_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( fn apply_initial_volume(
mut audio: NonSendMut<AudioState>, mut audio: NonSendMut<AudioState>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
) { ) {
let volume = settings.map_or(1.0, |s| s.0.sfx_volume); let (sfx, music) = settings.map_or((1.0, 0.5), |s| (s.0.sfx_volume, s.0.music_volume));
set_main_track_volume(&mut audio, volume); set_sfx_volume(&mut audio, sfx);
set_music_volume(&mut audio, music);
} }
fn apply_volume_on_change( fn apply_volume_on_change(
@@ -143,7 +170,8 @@ fn apply_volume_on_change(
mut audio: NonSendMut<AudioState>, mut audio: NonSendMut<AudioState>,
) { ) {
for ev in events.read() { 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);
} }
} }
+8 -1
View File
@@ -105,10 +105,17 @@ fn handle_new_game(
mut new_game: EventReader<NewGameRequestEvent>, mut new_game: EventReader<NewGameRequestEvent>,
mut game: ResMut<GameStateResource>, mut game: ResMut<GameStateResource>,
mut changed: EventWriter<StateChangedEvent>, mut changed: EventWriter<StateChangedEvent>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
) { ) {
for ev in new_game.read() { for ev in new_game.read() {
let seed = ev.seed.unwrap_or_else(seed_from_system_time); 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); let mode = ev.mode.unwrap_or(game.0.mode);
game.0 = GameState::new_with_mode(seed, draw_mode, mode); game.0 = GameState::new_with_mode(seed, draw_mode, mode);
changed.send(StateChangedEvent); changed.send(StateChangedEvent);
+156 -38
View File
@@ -12,7 +12,8 @@
use std::path::PathBuf; use std::path::PathBuf;
use bevy::prelude::*; 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. /// Volume adjustment step applied by the `[` / `]` hotkeys.
pub const SFX_STEP: f32 = 0.1; pub const SFX_STEP: f32 = 0.1;
@@ -37,15 +38,31 @@ pub struct SettingsChangedEvent(pub Settings);
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SettingsPanel; 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)] #[derive(Component, Debug)]
struct SfxVolumeText; 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. /// Tags interactive buttons inside the Settings panel.
#[derive(Component, Debug)] #[derive(Component, Debug)]
enum SettingsButton { enum SettingsButton {
SfxDown, SfxDown,
SfxUp, SfxUp,
MusicDown,
MusicUp,
ToggleDrawMode,
ToggleTheme,
Done, Done,
} }
@@ -171,13 +188,17 @@ fn sync_settings_panel_visibility(
} }
/// Reacts to button presses inside the Settings panel. /// Reacts to button presses inside the Settings panel.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn handle_settings_buttons( fn handle_settings_buttons(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>, interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut settings: ResMut<SettingsResource>, mut settings: ResMut<SettingsResource>,
mut screen: ResMut<SettingsScreen>, mut screen: ResMut<SettingsScreen>,
path: Res<SettingsStoragePath>, path: Res<SettingsStoragePath>,
mut changed: EventWriter<SettingsChangedEvent>, mut changed: EventWriter<SettingsChangedEvent>,
mut volume_text: Query<&mut Text, With<SfxVolumeText>>, mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>)>,
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>)>,
) { ) {
for (interaction, button) in &interaction_query { for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed { if *interaction != Interaction::Pressed {
@@ -190,8 +211,8 @@ fn handle_settings_buttons(
if (before - after).abs() > f32::EPSILON { if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0); persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone())); changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut text) = volume_text.get_single_mut() { if let Ok(mut t) = sfx_text.get_single_mut() {
**text = format!("{:.2}", after); **t = format!("{:.2}", after);
} }
} }
} }
@@ -201,11 +222,56 @@ fn handle_settings_buttons(
if (before - after).abs() > f32::EPSILON { if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0); persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone())); changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut text) = volume_text.get_single_mut() { if let Ok(mut t) = sfx_text.get_single_mut() {
**text = format!("{:.2}", after); **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 => { SettingsButton::Done => {
screen.0 = false; 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 // UI construction
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -262,7 +343,18 @@ fn spawn_settings_panel(commands: &mut Commands, settings: &Settings) {
// --- Audio section --- // --- Audio section ---
section_label(card, "Audio"); 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 { card.spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
align_items: AlignItems::Center, align_items: AlignItems::Center,
@@ -271,39 +363,43 @@ fn spawn_settings_panel(commands: &mut Commands, settings: &Settings) {
}) })
.with_children(|row| { .with_children(|row| {
row.spawn(( row.spawn((
Text::new("SFX Volume"), Text::new("Draw Mode"),
TextFont { TextFont { font_size: 18.0, ..default() },
font_size: 18.0,
..default()
},
TextColor(Color::srgb(0.85, 0.85, 0.80)), TextColor(Color::srgb(0.85, 0.85, 0.80)),
)); ));
row.spawn(( row.spawn((
SfxVolumeText, DrawModeText,
Text::new(format!("{:.2}", settings.sfx_volume)), Text::new(draw_mode_label(&settings.draw_mode)),
TextFont { TextFont { font_size: 18.0, ..default() },
font_size: 18.0,
..default()
},
TextColor(Color::WHITE), TextColor(Color::WHITE),
)); ));
icon_button(row, "", SettingsButton::SfxDown); icon_button(row, "", SettingsButton::ToggleDrawMode);
icon_button(row, "+", SettingsButton::SfxUp);
}); });
coming_soon_row(card, "Music Volume");
// --- Gameplay section ---
section_label(card, "Gameplay");
coming_soon_row(card, "Draw Mode");
// --- Appearance section --- // --- Appearance section ---
section_label(card, "Appearance"); section_label(card, "Appearance");
coming_soon_row(card, "Theme");
// --- Sync section --- // Theme row
section_label(card, "Sync"); card.spawn(Node {
coming_soon_row(card, "Sync Backend"); 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 // Done button
card.spawn(( card.spawn((
@@ -343,15 +439,37 @@ fn section_label(parent: &mut ChildBuilder, title: &str) {
)); ));
} }
fn coming_soon_row(parent: &mut ChildBuilder, label: &str) { /// Generic volume row: `Label 0.80 [] [+]`
parent.spawn(( fn volume_row<Marker: Component>(
Text::new(format!("{label} — coming soon")), parent: &mut ChildBuilder,
TextFont { label: &str,
font_size: 16.0, 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() ..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) { fn icon_button(parent: &mut ChildBuilder, label: &str, action: SettingsButton) {
+1
View File
@@ -126,6 +126,7 @@ fn start_pull(
/// 4. Sets [`SyncStatusResource`] to [`SyncStatus::LastSynced`]. /// 4. Sets [`SyncStatusResource`] to [`SyncStatus::LastSynced`].
/// ///
/// On failure, sets [`SyncStatusResource`] to [`SyncStatus::Error`]. /// On failure, sets [`SyncStatusResource`] to [`SyncStatus::Error`].
#[allow(clippy::too_many_arguments)]
fn poll_pull_result( fn poll_pull_result(
mut task_res: ResMut<PullTask>, mut task_res: ResMut<PullTask>,
mut status: ResMut<SyncStatusResource>, mut status: ResMut<SyncStatusResource>,
+35 -4
View File
@@ -8,8 +8,10 @@ use bevy::prelude::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::card::Suit; use solitaire_core::card::Suit;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_data::settings::Theme;
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR}; use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
use crate::settings_plugin::SettingsChangedEvent;
/// Z-depth used for the background — below everything. /// Z-depth used for the background — below everything.
const Z_BACKGROUND: f32 = -10.0; const Z_BACKGROUND: f32 = -10.0;
@@ -34,8 +36,18 @@ impl Plugin for TablePlugin {
// tests. Under DefaultPlugins, bevy_window has already registered it // tests. Under DefaultPlugins, bevy_window has already registered it
// and this call is a no-op. // and this call is a no-op.
app.add_event::<WindowResized>() app.add_event::<WindowResized>()
.add_event::<SettingsChangedEvent>()
.add_systems(Startup, setup_table) .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, mut commands: Commands,
windows: Query<&Window>, windows: Query<&Window>,
existing_camera: Query<(), With<Camera>>, existing_camera: Query<(), With<Camera>>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
) { ) {
// Only spawn a camera if one does not already exist (e.g. a parent app // Only spawn a camera if one does not already exist (e.g. a parent app
// may have added one in tests). // may have added one in tests).
@@ -61,18 +74,23 @@ fn setup_table(
.unwrap_or(Vec2::new(1280.0, 800.0)); .unwrap_or(Vec2::new(1280.0, 800.0));
let layout = compute_layout(window_size); 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); spawn_pile_markers(&mut commands, &layout);
commands.insert_resource(LayoutResource(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 // 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 // it the window size plus headroom so resizing up doesn't expose edges
// before the resize handler runs. // before the resize handler runs.
commands.spawn(( commands.spawn((
Sprite { Sprite {
color: Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]), color,
custom_size: Some(window_size * 2.0), custom_size: Some(window_size * 2.0),
..default() ..default()
}, },
@@ -81,6 +99,19 @@ fn spawn_background(commands: &mut Commands, window_size: Vec2) {
)); ));
} }
fn apply_theme_on_settings_change(
mut events: EventReader<SettingsChangedEvent>,
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
) {
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) { fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08); let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08);
let marker_size = layout.card_size; let marker_size = layout.card_size;
+3 -8
View File
@@ -178,14 +178,9 @@ mod tests {
#[test] #[test]
fn timer_expiry_fires_ended_event_and_clears_active() { fn timer_expiry_fires_ended_event_and_clears_active() {
let mut app = headless_app(); let mut app = headless_app();
// Manually start a near-expired session. // Set the session to an already-expired state (remaining < 0).
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource { // MinimalPlugins time delta is nonzero so we skip the intermediate
active: true, // 0.001-remaining step to avoid a double-fire.
remaining_secs: 0.001,
wins: 5,
};
app.update();
// First update advances time slightly; force the timer past zero.
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource { *app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
active: true, active: true,
remaining_secs: -1.0, remaining_secs: -1.0,