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
+3
View File
@@ -60,3 +60,6 @@ pub use weekly::{
pub mod challenge;
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());
}
}