From 9d0f9478b22d85c00296e272cbde812b94e40046 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 25 Apr 2026 23:08:20 -0700 Subject: [PATCH] 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 --- docs/SESSION_HANDOFF.md | 20 ++- solitaire_app/src/main.rs | 3 +- solitaire_data/src/lib.rs | 3 + solitaire_data/src/settings.rs | 145 +++++++++++++++++++ solitaire_engine/src/animation_plugin.rs | 18 +++ solitaire_engine/src/audio_plugin.rs | 34 +++++ solitaire_engine/src/help_plugin.rs | 1 + solitaire_engine/src/lib.rs | 4 + solitaire_engine/src/settings_plugin.rs | 168 +++++++++++++++++++++++ 9 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 solitaire_data/src/settings.rs create mode 100644 solitaire_engine/src/settings_plugin.rs diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md index 5f3986b..aa144ae 100644 --- a/docs/SESSION_HANDOFF.md +++ b/docs/SESSION_HANDOFF.md @@ -2,7 +2,7 @@ > Last updated: 2026-04-25 > 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 (3A–3F) 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. - `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 -### 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. -- **Ambient loop**: optional sixth WAV — needs taste, deferred. -- **Onboarding**: first-run banner pointing at the **H**/`?` cheat sheet (single-shot via `Settings.first_run_complete`). -- **Optional**: block input while paused (drag, hotkeys) for stricter pause semantics. +- **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 until artwork phase. +- **Block input while paused**: drag/hotkeys still work mid-pause; tightening this would make pause behave more like a true modal. ### Phase 8 — Sync diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 2b2351b..784c5ef 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -2,7 +2,7 @@ use bevy::prelude::*; use solitaire_engine::{ AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin, DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, PausePlugin, ProgressPlugin, - StatsPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, + SettingsPlugin, StatsPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, }; fn main() { @@ -31,6 +31,7 @@ fn main() { .add_plugins(TimeAttackPlugin) .add_plugins(HelpPlugin) .add_plugins(PausePlugin) + .add_plugins(SettingsPlugin::default()) .add_plugins(AudioPlugin) .run(); } diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 2afb375..5d037cc 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -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}; diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs new file mode 100644 index 0000000..7aebee8 --- /dev/null +++ b/solitaire_data/src/settings.rs @@ -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 { + 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::(&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()); + } +} diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index 12a0684..99e427f 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -13,6 +13,7 @@ use crate::events::{AchievementUnlockedEvent, GameWonEvent}; use crate::game_plugin::GameMutation; use crate::layout::LayoutResource; use crate::progress_plugin::LevelUpEvent; +use crate::settings_plugin::SettingsChangedEvent; use crate::time_attack_plugin::TimeAttackEndedEvent; 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 TIME_ATTACK_TOAST_SECS: f32 = 5.0; const CHALLENGE_TOAST_SECS: f32 = 3.0; +const VOLUME_TOAST_SECS: f32 = 1.4; const CASCADE_STAGGER: f32 = 0.05; const CASCADE_DURATION: f32 = 0.5; @@ -65,6 +67,7 @@ impl Plugin for AnimationPlugin { .add_event::() .add_event::() .add_event::() + .add_event::() .add_systems( Update, ( @@ -76,6 +79,7 @@ impl Plugin for AnimationPlugin { handle_weekly_toast, handle_time_attack_toast, handle_challenge_toast, + handle_settings_toast, tick_toasts, ) .after(GameMutation), @@ -215,6 +219,20 @@ fn handle_challenge_toast( } } +fn handle_settings_toast( + mut commands: Commands, + mut events: EventReader, +) { + 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( mut commands: Commands, time: Res