feat(data,engine): persistent Settings + SFX volume hotkeys

- solitaire_data::Settings { sfx_volume, first_run_complete } with
  atomic JSON persistence and clamping sanitizer.
- SettingsPlugin (engine): [ / ] adjust SFX volume by 0.1, clamped;
  persists on change; emits SettingsChangedEvent. No-op at rails.
- AudioPlugin applies sfx_volume to kira's main track at startup
  and on every change so live tweaks take effect without restart.
- Brief "SFX: N%" toast on each change. Help cheat sheet updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-25 23:08:20 -07:00
parent b720588687
commit 9d0f9478b2
9 changed files with 389 additions and 7 deletions
+14 -6
View File
@@ -2,7 +2,7 @@
> Last updated: 2026-04-25 > Last updated: 2026-04-25
> Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git > Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git
> Test count: **228 passing** (83 core + 54 data + 91 engine), `cargo clippy --workspace -- -D warnings` clean > Test count: **238 passing** (83 core + 60 data + 95 engine), `cargo clippy --workspace -- -D warnings` clean
--- ---
@@ -166,14 +166,22 @@ All sub-phases (3A3F) done. Plugins: `GamePlugin`, `TablePlugin`, `CardPlugin
- New `PausePlugin` + `PausedResource(bool)`. **Esc** toggles a full-window pause overlay (ZIndex 220) and flips the resource. `tick_elapsed_time` and `advance_time_attack` skip work while paused. Input is deliberately not blocked — pause is a "stop the clock" screen, nothing more. - New `PausePlugin` + `PausedResource(bool)`. **Esc** toggles a full-window pause overlay (ZIndex 220) and flips the resource. `tick_elapsed_time` and `advance_time_attack` skip work while paused. Input is deliberately not blocked — pause is a "stop the clock" screen, nothing more.
- `HelpPlugin` cheat sheet updated to reflect the new Esc behaviour. - `HelpPlugin` cheat sheet updated to reflect the new Esc behaviour.
### Phase 7 (part 4) — Settings + SFX Volume Control ✅ COMPLETE
- New `solitaire_data::Settings { sfx_volume, first_run_complete }` with atomic JSON persistence (`save_settings_to` / `load_settings_from`). `sanitized()` clamps out-of-range volumes after deserialization. Default `sfx_volume = 0.8`.
- New `SettingsPlugin` (engine) with `SettingsResource`, `headless()` ctor, and `SettingsChangedEvent`. **\[** / **\]** adjust SFX volume by `SFX_STEP` (0.1), clamped; persists on change. No-op + no event when already at the rail.
- `AudioPlugin` applies `sfx_volume` to kira's main track at startup and on every `SettingsChangedEvent` (so changes take effect mid-game without restart).
- `AnimationPlugin` shows a brief "SFX: 70%" toast on every change so players see the new value.
- Help cheat sheet lists the **\[** / **\]** keys.
- 4 plugin tests + 6 data tests added — defaults, clamping, round-trip persistence.
## What Is Next ## What Is Next
### Phase 7 (part 4+) — Polish ### Phase 7 (part 5+) — Final Polish
- **Volume controls**: Settings overlay with `sfx_volume` slider; persist via `solitaire_data::Settings`. Apply to kira's main-track gain. - **Onboarding**: first-run banner pointing at the **H**/`?` cheat sheet, single-shot via `Settings.first_run_complete`.
- **Ambient loop**: optional sixth WAV — needs taste, deferred. - **Ambient loop**: optional sixth WAV — needs taste, deferred until artwork phase.
- **Onboarding**: first-run banner pointing at the **H**/`?` cheat sheet (single-shot via `Settings.first_run_complete`). - **Block input while paused**: drag/hotkeys still work mid-pause; tightening this would make pause behave more like a true modal.
- **Optional**: block input while paused (drag, hotkeys) for stricter pause semantics.
### Phase 8 — Sync ### Phase 8 — Sync
+2 -1
View File
@@ -2,7 +2,7 @@ use bevy::prelude::*;
use solitaire_engine::{ use solitaire_engine::{
AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin, AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin,
DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, PausePlugin, ProgressPlugin, DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, PausePlugin, ProgressPlugin,
StatsPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, SettingsPlugin, StatsPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
}; };
fn main() { fn main() {
@@ -31,6 +31,7 @@ fn main() {
.add_plugins(TimeAttackPlugin) .add_plugins(TimeAttackPlugin)
.add_plugins(HelpPlugin) .add_plugins(HelpPlugin)
.add_plugins(PausePlugin) .add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
.add_plugins(AudioPlugin) .add_plugins(AudioPlugin)
.run(); .run();
} }
+3
View File
@@ -60,3 +60,6 @@ pub use weekly::{
pub mod challenge; pub mod challenge;
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS}; pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
pub mod settings;
pub use settings::{load_settings_from, save_settings_to, settings_file_path, Settings};
+145
View File
@@ -0,0 +1,145 @@
//! User settings (persistent).
//!
//! Currently tracks SFX volume and the first-run flag. Other fields from
//! ARCHITECTURE.md §9 (`draw_mode`, `music_volume`, `theme`, `sync_backend`)
//! will land alongside the systems that need them.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const APP_DIR_NAME: &str = "solitaire_quest";
const SETTINGS_FILE_NAME: &str = "settings.json";
/// Persistent user settings.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Settings {
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's main track gain.
pub sfx_volume: f32,
/// Set to `true` once the player has dismissed the first-run banner.
pub first_run_complete: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
sfx_volume: 0.8,
first_run_complete: false,
}
}
}
impl Settings {
/// Clamps `sfx_volume` into `[0.0, 1.0]` after deserialization or
/// hand-editing of `settings.json`.
pub fn sanitized(self) -> Self {
Self {
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
..self
}
}
/// Adjust SFX volume by `delta`, clamped to `[0.0, 1.0]`. Returns the new value.
pub fn adjust_sfx_volume(&mut self, delta: f32) -> f32 {
self.sfx_volume = (self.sfx_volume + delta).clamp(0.0, 1.0);
self.sfx_volume
}
}
/// Returns the platform-specific path to `settings.json`, or `None` if
/// `dirs::data_dir()` is unavailable.
pub fn settings_file_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME))
}
/// Load settings from an explicit path. Returns `Settings::default()` if the
/// file is missing or cannot be deserialized.
pub fn load_settings_from(path: &Path) -> Settings {
let Ok(data) = fs::read(path) else {
return Settings::default();
};
serde_json::from_slice::<Settings>(&data)
.unwrap_or_default()
.sanitized()
}
/// Save settings to an explicit path using an atomic write (`.tmp` → rename).
pub fn save_settings_to(path: &Path, settings: &Settings) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(settings).map_err(io::Error::other)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
fn tmp_path(name: &str) -> PathBuf {
env::temp_dir().join(format!("solitaire_settings_test_{name}.json"))
}
#[test]
fn defaults_are_reasonable() {
let s = Settings::default();
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
assert!(!s.first_run_complete);
}
#[test]
fn adjust_sfx_volume_clamps() {
let mut s = Settings::default();
s.sfx_volume = 0.5;
assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6);
assert!((s.adjust_sfx_volume(0.5) - 1.0).abs() < 1e-6);
assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6);
assert!((s.adjust_sfx_volume(-1.0) - 0.0).abs() < 1e-6);
}
#[test]
fn sanitized_clamps_out_of_range_volume() {
let s = Settings {
sfx_volume: 5.0,
first_run_complete: true,
}
.sanitized();
assert_eq!(s.sfx_volume, 1.0);
assert!(s.first_run_complete);
}
#[test]
fn round_trip_save_and_load() {
let path = tmp_path("round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
sfx_volume: 0.42,
first_run_complete: true,
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded, s);
}
#[test]
fn load_from_missing_file_returns_default() {
let path = tmp_path("missing_xyz");
let _ = fs::remove_file(&path);
let s = load_settings_from(&path);
assert_eq!(s, Settings::default());
}
#[test]
fn load_from_corrupt_file_returns_default() {
let path = tmp_path("corrupt");
fs::write(&path, b"definitely not json").expect("write");
let s = load_settings_from(&path);
assert_eq!(s, Settings::default());
}
}
+18
View File
@@ -13,6 +13,7 @@ use crate::events::{AchievementUnlockedEvent, GameWonEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::progress_plugin::LevelUpEvent; use crate::progress_plugin::LevelUpEvent;
use crate::settings_plugin::SettingsChangedEvent;
use crate::time_attack_plugin::TimeAttackEndedEvent; use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent; use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
@@ -26,6 +27,7 @@ const DAILY_TOAST_SECS: f32 = 3.0;
const WEEKLY_TOAST_SECS: f32 = 3.0; const WEEKLY_TOAST_SECS: f32 = 3.0;
const TIME_ATTACK_TOAST_SECS: f32 = 5.0; const TIME_ATTACK_TOAST_SECS: f32 = 5.0;
const CHALLENGE_TOAST_SECS: f32 = 3.0; const CHALLENGE_TOAST_SECS: f32 = 3.0;
const VOLUME_TOAST_SECS: f32 = 1.4;
const CASCADE_STAGGER: f32 = 0.05; const CASCADE_STAGGER: f32 = 0.05;
const CASCADE_DURATION: f32 = 0.5; const CASCADE_DURATION: f32 = 0.5;
@@ -65,6 +67,7 @@ impl Plugin for AnimationPlugin {
.add_event::<WeeklyGoalCompletedEvent>() .add_event::<WeeklyGoalCompletedEvent>()
.add_event::<TimeAttackEndedEvent>() .add_event::<TimeAttackEndedEvent>()
.add_event::<ChallengeAdvancedEvent>() .add_event::<ChallengeAdvancedEvent>()
.add_event::<SettingsChangedEvent>()
.add_systems( .add_systems(
Update, Update,
( (
@@ -76,6 +79,7 @@ impl Plugin for AnimationPlugin {
handle_weekly_toast, handle_weekly_toast,
handle_time_attack_toast, handle_time_attack_toast,
handle_challenge_toast, handle_challenge_toast,
handle_settings_toast,
tick_toasts, tick_toasts,
) )
.after(GameMutation), .after(GameMutation),
@@ -215,6 +219,20 @@ fn handle_challenge_toast(
} }
} }
fn handle_settings_toast(
mut commands: Commands,
mut events: EventReader<SettingsChangedEvent>,
) {
for ev in events.read() {
let pct = (ev.0.sfx_volume * 100.0).round() as i32;
spawn_toast(
&mut commands,
format!("SFX: {pct}%"),
VOLUME_TOAST_SECS,
);
}
}
fn tick_toasts( fn tick_toasts(
mut commands: Commands, mut commands: Commands,
time: Res<Time>, time: Res<Time>,
+34
View File
@@ -22,10 +22,12 @@ 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::tween::Tween;
use crate::events::{ use crate::events::{
DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
}; };
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
/// Pre-decoded sound effects. Cheap to clone (frames are an `Arc<[Frame]>`), /// 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 `manager.play()` on every event.
@@ -66,6 +68,11 @@ impl Plugin for AudioPlugin {
.add_event::<MoveRejectedEvent>() .add_event::<MoveRejectedEvent>()
.add_event::<NewGameRequestEvent>() .add_event::<NewGameRequestEvent>()
.add_event::<GameWonEvent>() .add_event::<GameWonEvent>()
.add_event::<SettingsChangedEvent>()
.add_systems(
Startup,
apply_initial_volume,
)
.add_systems( .add_systems(
Update, Update,
( (
@@ -74,6 +81,7 @@ impl Plugin for AudioPlugin {
play_on_rejected, play_on_rejected,
play_on_new_game, play_on_new_game,
play_on_win, play_on_win,
apply_volume_on_change,
), ),
); );
} }
@@ -113,6 +121,32 @@ fn play(audio: &mut AudioState, sound: &StaticSoundData) {
} }
} }
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 apply_initial_volume(
mut audio: NonSendMut<AudioState>,
settings: Option<Res<SettingsResource>>,
) {
let volume = settings.map_or(1.0, |s| s.0.sfx_volume);
set_main_track_volume(&mut audio, volume);
}
fn apply_volume_on_change(
mut events: EventReader<SettingsChangedEvent>,
mut audio: NonSendMut<AudioState>,
) {
for ev in events.read() {
set_main_track_volume(&mut audio, ev.0.sfx_volume);
}
}
fn play_on_draw( fn play_on_draw(
mut events: EventReader<DrawRequestEvent>, mut events: EventReader<DrawRequestEvent>,
mut audio: NonSendMut<AudioState>, mut audio: NonSendMut<AudioState>,
+1
View File
@@ -54,6 +54,7 @@ fn spawn_help_screen(commands: &mut Commands) {
" S Toggle stats / progression".to_string(), " S Toggle stats / progression".to_string(),
" H or ? Toggle this help".to_string(), " H or ? Toggle this help".to_string(),
" Esc Pause / resume".to_string(), " Esc Pause / resume".to_string(),
" [ / ] SFX volume down / up".to_string(),
String::new(), String::new(),
"Press H or ? to close".to_string(), "Press H or ? to close".to_string(),
]; ];
+4
View File
@@ -12,6 +12,7 @@ pub mod help_plugin;
pub mod input_plugin; pub mod input_plugin;
pub mod layout; pub mod layout;
pub mod pause_plugin; pub mod pause_plugin;
pub mod settings_plugin;
pub mod progress_plugin; pub mod progress_plugin;
pub mod resources; pub mod resources;
pub mod stats_plugin; pub mod stats_plugin;
@@ -39,6 +40,9 @@ pub use game_plugin::{GameMutation, GamePlugin};
pub use help_plugin::{HelpPlugin, HelpScreen}; pub use help_plugin::{HelpPlugin, HelpScreen};
pub use input_plugin::InputPlugin; pub use input_plugin::InputPlugin;
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource}; pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
pub use settings_plugin::{
SettingsChangedEvent, SettingsPlugin, SettingsResource, SFX_STEP,
};
pub use layout::{compute_layout, Layout, LayoutResource}; pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource}; pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate}; pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
+168
View File
@@ -0,0 +1,168 @@
//! Persists `solitaire_data::Settings` and exposes hotkeys for live tuning.
//!
//! Hotkeys (always active, no overlay required):
//! - `[` decrease SFX volume by `SFX_STEP`
//! - `]` increase SFX volume by `SFX_STEP`
//!
//! On change, the plugin persists `settings.json` and fires
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.
use std::path::PathBuf;
use bevy::prelude::*;
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, Settings};
/// Volume adjustment step.
pub const SFX_STEP: f32 = 0.1;
/// Bevy resource wrapping the current `Settings`.
#[derive(Resource, Debug, Clone)]
pub struct SettingsResource(pub Settings);
/// Persistence path for `SettingsResource`. `None` disables I/O.
#[derive(Resource, Debug, Clone)]
pub struct SettingsStoragePath(pub Option<PathBuf>);
/// Fired any time settings change so consumers (audio, UI) can react.
#[derive(Event, Debug, Clone)]
pub struct SettingsChangedEvent(pub Settings);
pub struct SettingsPlugin {
pub storage_path: Option<PathBuf>,
}
impl Default for SettingsPlugin {
fn default() -> Self {
Self {
storage_path: settings_file_path(),
}
}
}
impl SettingsPlugin {
/// Plugin configured with no persistence — for tests and headless apps.
pub fn headless() -> Self {
Self { storage_path: None }
}
}
impl Plugin for SettingsPlugin {
fn build(&self, app: &mut App) {
let loaded = match &self.storage_path {
Some(path) => load_settings_from(path),
None => Settings::default(),
};
app.insert_resource(SettingsResource(loaded))
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
.add_event::<SettingsChangedEvent>()
.add_systems(Update, handle_volume_keys);
}
}
fn persist(path: &SettingsStoragePath, settings: &Settings) {
let Some(target) = &path.0 else {
return;
};
if let Err(e) = save_settings_to(target, settings) {
warn!("failed to save settings: {e}");
}
}
fn handle_volume_keys(
keys: Res<ButtonInput<KeyCode>>,
mut settings: ResMut<SettingsResource>,
path: Res<SettingsStoragePath>,
mut changed: EventWriter<SettingsChangedEvent>,
) {
let mut delta = 0.0;
if keys.just_pressed(KeyCode::BracketLeft) {
delta -= SFX_STEP;
}
if keys.just_pressed(KeyCode::BracketRight) {
delta += SFX_STEP;
}
if delta == 0.0 {
return;
}
let before = settings.0.sfx_volume;
let after = settings.0.adjust_sfx_volume(delta);
if (before - after).abs() < f32::EPSILON {
// Already at the rail — no point persisting or notifying.
return;
}
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
}
#[cfg(test)]
mod tests {
use super::*;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(SettingsPlugin::headless());
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn press(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
input.press(key);
}
#[test]
fn defaults_are_loaded() {
let app = headless_app();
assert_eq!(
app.world().resource::<SettingsResource>().0,
Settings::default()
);
}
#[test]
fn pressing_left_bracket_decreases_volume_and_emits_event() {
let mut app = headless_app();
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
press(&mut app, KeyCode::BracketLeft);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(after < before);
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 1);
}
#[test]
fn pressing_right_bracket_increases_volume() {
let mut app = headless_app();
// Drop volume first so there's headroom to grow.
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.5;
press(&mut app, KeyCode::BracketRight);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!((after - 0.6).abs() < 1e-3);
}
#[test]
fn clamped_change_does_not_emit_event() {
let mut app = headless_app();
// Already at max — pressing right bracket should be a no-op.
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 1.0;
press(&mut app, KeyCode::BracketRight);
app.update();
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 0);
}
}