Skip to main content

shuyi_ui/
settings.rs

1//! The player's settings: the engine's (how fast dialogue text is
2//! revealed, whether the game fills the screen, the color theme, the
3//! music and sound volumes, the frame cap and the layout picked) and the
4//! game's own
5//! ([`Settings::game`], a [`GameSettings`]). [`Ctx`](crate::Ctx) loads
6//! them with its storage, from one record under [`SETTINGS_KEY`] in the
7//! profile in use, whose form the game decides
8//! ([`GameSettings::to_ron`]); it saves them when they change
9//! ([`Ctx::change_settings`](crate::Ctx::change_settings)). Key bindings
10//! are saved apart.
11
12use crate::prelude::*;
13use core::fmt::Debug;
14
15use shuyi_assets::keymap::Layout;
16
17use crate::frame_cap::{self, FrameCap};
18
19/// [`Storage`](crate::storage::Storage) key under which the settings are
20/// saved ([`Settings::to_ron`]).
21pub const SETTINGS_KEY: &str = "settings";
22
23/// The loudest volume setting; 0 is silent.
24pub const MAX_VOLUME: u8 = 100;
25
26/// The volume settings' default. *Tunable.*
27pub const DEFAULT_VOLUME: u8 = 80;
28
29/// How fast dialogue text is revealed.
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
31pub enum TextSpeed {
32    /// Half the normal speed.
33    Slow,
34    /// The normal speed.
35    #[default]
36    Normal,
37    /// Twice the normal speed.
38    Fast,
39    /// A whole page at once.
40    Instant,
41}
42
43impl TextSpeed {
44    /// Every speed, slowest first.
45    pub const ALL: [Self; 4] = [Self::Slow, Self::Normal, Self::Fast, Self::Instant];
46
47    /// Characters revealed per second (*tunable*); `None` shows a page at
48    /// once.
49    pub const fn chars_per_s(self) -> Option<f32> {
50        match self {
51            Self::Slow => Some(30.0),
52            Self::Normal => Some(60.0),
53            Self::Fast => Some(120.0),
54            Self::Instant => None,
55        }
56    }
57
58    /// The key of the name an options screen shows
59    /// ([`Ctx::text`](crate::Ctx::text)).
60    pub const fn key(self) -> &'static str {
61        match self {
62            Self::Slow => "options.value.slow",
63            Self::Normal => "options.value.normal",
64            Self::Fast => "options.value.fast",
65            Self::Instant => "options.value.instant",
66        }
67    }
68}
69
70/// What the game's sound does while its window has no focus.
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
72#[non_exhaustive]
73pub enum BackgroundAudio {
74    /// The sound goes on as it was.
75    #[default]
76    Keep,
77    /// The music, the sounds and the voices are silent; the music keeps
78    /// its place, and the player's volumes come back with the focus.
79    Mute,
80}
81
82/// What a game does while its window has no focus
83/// ([`GameSettings::BACKGROUND`]): whether it pauses, and what its sound
84/// does. A game writes it from [`KEEP_RUNNING`](Self::KEEP_RUNNING):
85/// `Background::KEEP_RUNNING.paused().muted()`.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87#[non_exhaustive]
88pub struct Background {
89    /// Whether the game stops: no update, no steps and no time for its
90    /// screens, its last frame shown again.
91    pub pause: bool,
92    /// What its sound does.
93    pub audio: BackgroundAudio,
94}
95
96impl Background {
97    /// The game runs on with its sound, as with the focus.
98    pub const KEEP_RUNNING: Self = Self {
99        pause: false,
100        audio: BackgroundAudio::Keep,
101    };
102
103    /// The same, and the game pauses.
104    #[must_use]
105    pub const fn paused(self) -> Self {
106        Self {
107            pause: true,
108            ..self
109        }
110    }
111
112    /// The same, and the game's sound is muted.
113    #[must_use]
114    pub const fn muted(self) -> Self {
115        Self {
116            audio: BackgroundAudio::Mute,
117            ..self
118        }
119    }
120}
121
122impl Default for Background {
123    fn default() -> Self {
124        Self::KEEP_RUNNING
125    }
126}
127
128/// The settings a game adds to the engine's, and the saved form of both:
129/// one record, so the game decides its form (and keeps the one it has
130/// always written).
131pub trait GameSettings: Debug + Clone + PartialEq + Eq + Default + 'static {
132    /// The game's color themes, each a palette
133    /// ([`GameData::theme_def`](crate::GameData::theme_def)); `()` for a
134    /// game with one.
135    type Theme: Debug + Copy + PartialEq + Eq + Default + 'static;
136
137    /// The frame cap a new player starts with
138    /// ([`Settings::frame_cap`]): 60 frames a second unless the game says
139    /// otherwise ([`FrameCap`] says why).
140    const DEFAULT_FRAME_CAP: FrameCap = FrameCap::DEFAULT;
141
142    /// The frame caps the options screen's Frame rate row steps through,
143    /// in order ([`OptionRow::frame_cap`](crate::screens::options::OptionRow::frame_cap)):
144    /// 30, 60, 120, 144, 165 and 240 frames a second, then none, unless the
145    /// game says otherwise ([`frame_cap::CHOICES`]).
146    const FRAME_CAP_CHOICES: &'static [FrameCap] = &frame_cap::CHOICES;
147
148    /// What the game does while its window has no focus (ADR-0077): it
149    /// keeps running with its sound ([`Background::KEEP_RUNNING`]) unless
150    /// the game says otherwise.
151    const BACKGROUND: Background = Background::KEEP_RUNNING;
152
153    /// The saved form of `settings` (RON).
154    fn to_ron(settings: &Settings<Self>) -> String;
155
156    /// Reads the saved form. Text that can't be read gives `Err` with why
157    /// (the caller then uses the defaults); what can be repaired is.
158    // check-text: not player text (warnings for the log)
159    fn from_ron(text: &str) -> Result<Settings<Self>, String>;
160}
161
162/// The saved form of the settings of a game with none of its own
163/// ([`GameSettings`] for `()`): the engine's, and the layout picked by its
164/// name. What isn't there reads as its default.
165#[derive(serde::Serialize, serde::Deserialize)]
166#[serde(default, rename = "Settings")]
167struct EngineOnly {
168    text_speed: TextSpeed,
169    fullscreen: bool,
170    music_volume: u8,
171    sound_volume: u8,
172    voices: bool,
173    voice_volume: u8,
174    frame_cap: FrameCap,
175    layout: Option<String>,
176}
177
178impl Default for EngineOnly {
179    fn default() -> Self {
180        Self::of(&Settings::default())
181    }
182}
183
184impl EngineOnly {
185    fn of(s: &Settings<()>) -> Self {
186        Self {
187            text_speed: s.text_speed,
188            fullscreen: s.fullscreen,
189            music_volume: s.music_volume,
190            sound_volume: s.sound_volume,
191            voices: s.voices,
192            voice_volume: s.voice_volume,
193            frame_cap: s.frame_cap,
194            layout: s.layout.map(|l| l.name().to_owned()),
195        }
196    }
197}
198
199/// No settings of the game's own, and one palette: the engine's settings
200/// alone, saved as one record (`Settings(text_speed: Fast, …)`).
201impl GameSettings for () {
202    type Theme = ();
203
204    fn to_ron(s: &Settings<Self>) -> String {
205        ron::to_string(&EngineOnly::of(s)).unwrap_or_default()
206    }
207
208    fn from_ron(text: &str) -> Result<Settings<Self>, String> {
209        let file: EngineOnly = ron::from_str(text).map_err(|e| format!("unreadable: {e}"))?;
210        Ok(Settings {
211            text_speed: file.text_speed,
212            fullscreen: file.fullscreen,
213            music_volume: file.music_volume,
214            sound_volume: file.sound_volume,
215            voices: file.voices,
216            voice_volume: file.voice_volume,
217            frame_cap: file.frame_cap,
218            layout: file.layout.as_deref().and_then(Layout::from_name),
219            ..Settings::default()
220        })
221    }
222}
223
224/// Everything an options screen sets: the engine's settings and the
225/// game's. The defaults are a new player's.
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct Settings<S: GameSettings> {
228    /// How fast dialogue text is revealed.
229    pub text_speed: TextSpeed,
230    /// Whether the game fills the screen.
231    pub fullscreen: bool,
232    /// The color theme: the palette the game draws with.
233    pub theme: S::Theme,
234    /// Music volume, 0 (silent) to [`MAX_VOLUME`].
235    pub music_volume: u8,
236    /// Sound volume, 0 (silent) to [`MAX_VOLUME`].
237    pub sound_volume: u8,
238    /// Whether voice clips play
239    /// ([`Ctx::play_voice`](crate::Ctx::play_voice)). Turning it off stops
240    /// the one playing.
241    pub voices: bool,
242    /// Voice volume, 0 (silent) to [`MAX_VOLUME`].
243    pub voice_volume: u8,
244    /// The most frames a second the platform shows: the game's
245    /// [`GameSettings::DEFAULT_FRAME_CAP`] until the player picks another.
246    /// Every change of it takes the next serial, which the platform reports
247    /// once it has applied it; a game that wants to know when asks with
248    /// [`Ctx::request_frame_cap`](crate::Ctx::request_frame_cap).
249    pub frame_cap: FrameCap,
250    /// The layout the player picked; `None` until they have. Changed only
251    /// by [`Ctx::choose_layout`](crate::Ctx::choose_layout), which also
252    /// switches the keys.
253    pub(crate) layout: Option<Layout>,
254    /// The game's own settings.
255    pub game: S,
256}
257
258impl<S: GameSettings> Default for Settings<S> {
259    fn default() -> Self {
260        Self {
261            text_speed: TextSpeed::default(),
262            fullscreen: false,
263            theme: S::Theme::default(),
264            music_volume: DEFAULT_VOLUME,
265            sound_volume: DEFAULT_VOLUME,
266            voices: true,
267            voice_volume: DEFAULT_VOLUME,
268            frame_cap: S::DEFAULT_FRAME_CAP,
269            layout: None,
270            game: S::default(),
271        }
272    }
273}
274
275/// A volume setting as the multiplier the platform applies to every cue's
276/// own volume: 0 at 0, 1 at [`MAX_VOLUME`].
277fn volume_factor(volume: u8) -> f32 {
278    f32::from(volume.min(MAX_VOLUME)) / f32::from(MAX_VOLUME)
279}
280
281impl<S: GameSettings> Settings<S> {
282    /// The layout the player picked, if they have.
283    pub fn layout(&self) -> Option<Layout> {
284        self.layout
285    }
286
287    /// The same settings with `layout` as the one picked.
288    #[must_use]
289    pub fn with_layout(mut self, layout: Layout) -> Self {
290        self.layout = Some(layout);
291        self
292    }
293
294    /// The same settings with `other`'s layout picked (or none): to
295    /// compare two settings but for the layout.
296    #[must_use]
297    pub fn with_layout_of(mut self, other: &Self) -> Self {
298        self.layout = other.layout;
299        self
300    }
301
302    /// The defaults, keeping what isn't a preference with a default: the
303    /// layout picked (an options screen's "Restore defaults").
304    #[must_use]
305    pub fn restored(&self) -> Self {
306        Self {
307            layout: self.layout,
308            ..Self::default()
309        }
310    }
311
312    /// The multiplier on the music's volume, 0–1.
313    pub fn music_factor(&self) -> f32 {
314        volume_factor(self.music_volume)
315    }
316
317    /// The multiplier on every sound's volume, 0–1.
318    pub fn sound_factor(&self) -> f32 {
319        volume_factor(self.sound_volume)
320    }
321
322    /// The multiplier on every voice clip's volume, 0–1.
323    pub fn voice_factor(&self) -> f32 {
324        volume_factor(self.voice_volume)
325    }
326
327    /// The saved form ([`GameSettings::to_ron`]).
328    pub fn to_ron(&self) -> String {
329        S::to_ron(self)
330    }
331
332    /// Reads the saved form ([`GameSettings::from_ron`]).
333    // check-text: not player text (warnings for the log)
334    pub fn from_ron(text: &str) -> Result<Self, String> {
335        S::from_ron(text)
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::input::sample::LEFT_HANDED;
343    use crate::toy::{Prefs, Theme};
344
345    type Settings = super::Settings<Prefs>;
346
347    #[test]
348    fn the_defaults_are_a_new_players() {
349        let s = Settings::default();
350        assert_eq!(s.text_speed, TextSpeed::Normal);
351        assert_eq!(s.text_speed.chars_per_s(), Some(60.0));
352        assert!(!s.fullscreen);
353        assert_eq!(s.theme, Theme::default());
354        assert_eq!((s.music_volume, s.sound_volume), (80, 80));
355        assert_eq!((s.voices, s.voice_volume), (true, 80));
356        assert_eq!(s.frame_cap, FrameCap::Fps(60));
357        assert_eq!(s.layout(), None);
358        assert_eq!(s.game, Prefs::default());
359    }
360
361    #[test]
362    fn restoring_keeps_only_the_layout() {
363        let mut s = Settings::default().with_layout(LEFT_HANDED);
364        s.text_speed = TextSpeed::Fast;
365        s.fullscreen = true;
366        s.theme = Theme::Night;
367        s.music_volume = 3;
368        s.sound_volume = 4;
369        s.voices = false;
370        s.voice_volume = 5;
371        s.frame_cap = FrameCap::Off;
372        s.game.speed = 9;
373        assert_eq!(s.layout(), Some(LEFT_HANDED));
374        assert_eq!(s.restored(), Settings::default().with_layout(LEFT_HANDED));
375    }
376
377    #[test]
378    fn another_settings_layout_is_taken_with_or_without_one() {
379        let mut s = Settings::default();
380        s.game.speed = 4;
381        let left = Settings::default().with_layout(LEFT_HANDED);
382        let taken = s.clone().with_layout_of(&left);
383        assert_eq!(taken.layout(), Some(LEFT_HANDED));
384        assert_eq!(taken.game.speed, 4);
385        assert_eq!(taken.with_layout_of(&Settings::default()), s);
386    }
387
388    #[test]
389    fn the_saved_form_is_the_games() {
390        let mut s = Settings::default().with_layout(LEFT_HANDED);
391        s.game.speed = 7;
392        s.theme = Theme::Night;
393        let text = s.to_ron();
394        assert_eq!(text, Prefs::to_ron(&s));
395        assert_eq!(Settings::from_ron(&text), Ok(s));
396        assert!(Settings::from_ron("nonsense").is_err());
397    }
398
399    #[test]
400    fn a_game_with_no_settings_of_its_own_saves_the_engines() {
401        let mut s = super::Settings::<()>::default().with_layout(LEFT_HANDED);
402        s.text_speed = TextSpeed::Instant;
403        s.sound_volume = 12;
404        s.voices = false;
405        s.voice_volume = 30;
406        s.frame_cap = FrameCap::Fps(144);
407        assert_eq!(super::Settings::from_ron(&s.to_ron()), Ok(s));
408        let s = super::Settings::<()>::default();
409        assert_eq!(super::Settings::from_ron(&s.to_ron()), Ok(s));
410    }
411
412    #[test]
413    fn the_blank_templates_saved_settings_read_back_through_no_settings() {
414        // What `shuyi new blank`'s `Prefs::to_ron` wrote before its game
415        // took `()` for its settings.
416        let saved = "(text_speed:Fast,fullscreen:true,music_volume:3,sound_volume:7)";
417        let s = super::Settings::<()> {
418            text_speed: TextSpeed::Fast,
419            fullscreen: true,
420            music_volume: 3,
421            sound_volume: 7,
422            ..super::Settings::default()
423        };
424        assert_eq!(super::Settings::from_ron(saved), Ok(s));
425        assert_eq!(
426            super::Settings::from_ron("Settings()"),
427            Ok(super::Settings::<()>::default())
428        );
429        assert!(super::Settings::<()>::from_ron("(fullscreen: maybe)").is_err());
430        // A layout no keymap could name is dropped, not an error.
431        let odd = super::Settings::<()>::from_ron("(layout: Some(\"two words\"))");
432        assert_eq!(odd.map(|s| s.layout()), Ok(None));
433    }
434
435    /// A game with a default frame cap of its own.
436    #[derive(Debug, Clone, Default, PartialEq, Eq)]
437    struct Fast;
438
439    impl GameSettings for Fast {
440        type Theme = ();
441        const DEFAULT_FRAME_CAP: FrameCap = FrameCap::Fps(120);
442        const FRAME_CAP_CHOICES: &'static [FrameCap] = &[FrameCap::Fps(120), FrameCap::Off];
443
444        fn to_ron(_: &super::Settings<Self>) -> String {
445            String::new()
446        }
447
448        fn from_ron(_: &str) -> Result<super::Settings<Self>, String> {
449            Ok(super::Settings::default())
450        }
451    }
452
453    #[test]
454    fn the_frame_cap_is_the_games_default_until_the_player_picks() {
455        assert_eq!(
456            super::Settings::<()>::default().frame_cap,
457            FrameCap::Fps(60)
458        );
459        assert_eq!(<() as GameSettings>::FRAME_CAP_CHOICES, &frame_cap::CHOICES);
460        let mut s = super::Settings::<Fast>::default();
461        assert_eq!(s.frame_cap, FrameCap::Fps(120));
462        assert_eq!(Fast::FRAME_CAP_CHOICES.len(), 2);
463        s.frame_cap = FrameCap::Off;
464        assert_eq!(s.restored().frame_cap, FrameCap::Fps(120));
465    }
466
467    #[test]
468    fn the_background_keeps_running_until_the_game_says() {
469        let keep = Background::KEEP_RUNNING;
470        assert_eq!((keep.pause, keep.audio), (false, BackgroundAudio::Keep));
471        assert_eq!(Background::default(), keep);
472        assert_eq!(<() as GameSettings>::BACKGROUND, keep);
473        assert_eq!(Prefs::BACKGROUND, keep);
474        let paused = keep.paused();
475        assert_eq!((paused.pause, paused.audio), (true, BackgroundAudio::Keep));
476        let muted = keep.muted();
477        assert_eq!((muted.pause, muted.audio), (false, BackgroundAudio::Mute));
478        let both = keep.paused().muted();
479        assert_eq!(both, keep.muted().paused());
480        assert_eq!((both.pause, both.audio), (true, BackgroundAudio::Mute));
481    }
482
483    #[test]
484    fn a_toy_save_without_a_frame_cap_reads_the_default() {
485        let read = |text| Settings::from_ron(text).map(|s| s.frame_cap);
486        assert_eq!(read("Toy(speed: 1)"), Ok(FrameCap::Fps(60)));
487        assert_eq!(read("Toy(frame_cap: Off)"), Ok(FrameCap::Off));
488        let s = Settings {
489            frame_cap: FrameCap::Fps(240),
490            ..Settings::default()
491        };
492        assert!(s.to_ron().contains("frame_cap:Fps(240)"), "{}", s.to_ron());
493        assert_eq!(Settings::from_ron(&s.to_ron()), Ok(s));
494    }
495
496    #[test]
497    fn engine_settings_saved_without_a_frame_cap_read_the_default() {
498        let read = |text| super::Settings::<()>::from_ron(text).map(|s| s.frame_cap);
499        assert_eq!(read("(text_speed:Fast)"), Ok(FrameCap::Fps(60)));
500        assert_eq!(read("(frame_cap:Off)"), Ok(FrameCap::Off));
501        assert_eq!(read("(frame_cap:Fps(30))"), Ok(FrameCap::Fps(30)));
502    }
503
504    #[test]
505    fn volumes_are_factors_from_silent_to_full() {
506        let mut s = Settings::default();
507        assert!((s.music_factor() - 0.8).abs() < 1e-6);
508        assert!((s.sound_factor() - 0.8).abs() < 1e-6);
509        s.music_volume = 0;
510        s.sound_volume = 100;
511        assert!(s.music_factor().abs() < f32::EPSILON);
512        s.music_volume = 37;
513        assert!((s.music_factor() - 0.37).abs() < 1e-6);
514        s.music_volume = 0;
515        assert!((s.sound_factor() - 1.0).abs() < f32::EPSILON);
516        // Out of range (set in code) is still at most full volume.
517        s.sound_volume = 200;
518        assert!((s.sound_factor() - 1.0).abs() < f32::EPSILON);
519        assert!((s.voice_factor() - 0.8).abs() < 1e-6);
520        s.voice_volume = 0;
521        assert!(s.voice_factor().abs() < f32::EPSILON);
522        s.voice_volume = 30;
523        assert!((s.voice_factor() - 0.3).abs() < 1e-6);
524    }
525
526    #[test]
527    fn text_speeds_double_each_step_and_instant_has_none() {
528        let speeds: Vec<_> = TextSpeed::ALL.iter().map(|s| s.chars_per_s()).collect();
529        assert_eq!(speeds, [Some(30.0), Some(60.0), Some(120.0), None]);
530        let keys: Vec<_> = TextSpeed::ALL.iter().map(|s| s.key()).collect();
531        let value = |name: &str| format!("options.value.{name}");
532        assert_eq!(keys, ["slow", "normal", "fast", "instant"].map(value));
533    }
534}