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:
+14
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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::<WeeklyGoalCompletedEvent>()
|
||||
.add_event::<TimeAttackEndedEvent>()
|
||||
.add_event::<ChallengeAdvancedEvent>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.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<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(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
|
||||
@@ -22,10 +22,12 @@ use bevy::prelude::*;
|
||||
use kira::manager::backend::DefaultBackend;
|
||||
use kira::manager::{AudioManager, AudioManagerSettings};
|
||||
use kira::sound::static_sound::StaticSoundData;
|
||||
use kira::tween::Tween;
|
||||
|
||||
use crate::events::{
|
||||
DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
||||
};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
|
||||
/// Pre-decoded sound effects. Cheap to clone (frames are an `Arc<[Frame]>`),
|
||||
/// 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::<NewGameRequestEvent>()
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.add_systems(
|
||||
Startup,
|
||||
apply_initial_volume,
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -74,6 +81,7 @@ impl Plugin for AudioPlugin {
|
||||
play_on_rejected,
|
||||
play_on_new_game,
|
||||
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(
|
||||
mut events: EventReader<DrawRequestEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
|
||||
@@ -54,6 +54,7 @@ fn spawn_help_screen(commands: &mut Commands) {
|
||||
" S Toggle stats / progression".to_string(),
|
||||
" H or ? Toggle this help".to_string(),
|
||||
" Esc Pause / resume".to_string(),
|
||||
" [ / ] SFX volume down / up".to_string(),
|
||||
String::new(),
|
||||
"Press H or ? to close".to_string(),
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod help_plugin;
|
||||
pub mod input_plugin;
|
||||
pub mod layout;
|
||||
pub mod pause_plugin;
|
||||
pub mod settings_plugin;
|
||||
pub mod progress_plugin;
|
||||
pub mod resources;
|
||||
pub mod stats_plugin;
|
||||
@@ -39,6 +40,9 @@ pub use game_plugin::{GameMutation, GamePlugin};
|
||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||
pub use input_plugin::InputPlugin;
|
||||
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 resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
|
||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user