use super::{
CommitScheme, DEADZONE_RANGE, GameSettings, PuzzleSpeed, REPEAT_DELAY_RANGE,
REPEAT_INTERVAL_RANGE, REPLAY_CAP_MAX, REPLAY_CAP_MIN, SeatInput, UI_SCALE_MAX, UI_SCALE_MIN,
VOLUME_RANGE, clamp_in,
};
use crate::app::binds::default_binds;
use crate::app::i18n::Lang;
use crate::app::teams::TeamMode;
use crate::sim::MAX_PLAYERS;
fn on_off(on: bool) -> &'static str {
if on { "on" } else { "off" }
}
fn is_on(value: &str) -> bool {
value != "off"
}
fn parse_in<T: std::str::FromStr + PartialOrd + Copy>(
value: &str,
range: &std::ops::RangeInclusive<T>,
) -> Option<T> {
value
.parse()
.ok()
.filter(|v: &T| v.partial_cmp(v).is_some())
.map(|v| clamp_in(v, range))
}
fn settings_path() -> std::path::PathBuf {
crate::app::paths::config_dir().join("settings.txt")
}
impl GameSettings {
pub fn to_text(&self, caps: &crate::app::keycaps::KeyCaps) -> String {
let Self {
commit,
seat_input,
repeat_delay,
repeat_interval,
music_on,
music_volume,
sfx_on,
sfx_volume,
puzzle_speed,
team_mode,
language,
rumble,
pad_deadzone,
colorblind,
fullscreen,
ui_scale,
reduced_motion,
antialiasing,
binds,
names,
replay_cap,
last_beach,
check_updates,
keyboard,
editor_tips,
} = self;
format!(
"commit_scheme: {}\nrepeat_delay: {:.2}\nrepeat_interval: {:.2}\n\
music_on: {}\nmusic: {}\nsfx_on: {}\nsfx: {}\n\
puzzle_speed: {}\nteams: {}\nlanguage: {}\n\
rumble: {}\npad_deadzone: {}\npalette: {}\nfullscreen: {}\nui_scale: {}\n\
reduced_motion: {}\nantialiasing: {}\nnames: {}\nreplay_cap: {}\nbeach: {}\n\
updates: {}\neditor_tips: {}\nkeycaps: {}\nkeyboard: {}\n\
p1_input: {}\np2_input: {}\n{}",
commit.key(),
repeat_delay,
repeat_interval,
on_off(*music_on),
music_volume,
on_off(*sfx_on),
sfx_volume,
puzzle_speed.percent(),
team_mode.key(),
language.key(),
on_off(*rumble),
pad_deadzone,
if *colorblind { "colorblind" } else { "classic" },
on_off(*fullscreen),
ui_scale,
on_off(*reduced_motion),
on_off(*antialiasing),
names.join("|"),
replay_cap,
last_beach,
on_off(*check_updates),
on_off(*editor_tips),
caps.to_text(),
keyboard.map_or("auto", crate::app::keycaps::Layout::key),
seat_input[0].name(),
seat_input[1].name(),
crate::app::binds::to_text(binds),
)
}
pub fn parse(text: &str) -> (GameSettings, crate::app::keycaps::KeyCaps) {
let (settings, caps, _) = GameSettings::read(text);
(settings, caps)
}
fn read(
text: &str,
) -> (
GameSettings,
crate::app::keycaps::KeyCaps,
crate::app::paths::ForeignLines,
) {
let mut unknown = crate::app::paths::ForeignLines::default();
let mut settings = GameSettings::default();
let mut caps = crate::app::keycaps::KeyCaps::default();
let mut moved: Vec<(usize, usize)> = Vec::new();
for line in text.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let value = value.trim();
match key.trim() {
"commit_scheme" => settings.commit = CommitScheme::from_key(value),
"p1_input" => settings.seat_input[0] = SeatInput::from_name(value),
"p2_input" => settings.seat_input[1] = SeatInput::from_name(value),
"repeat_delay" => {
let read = parse_in(value, &REPEAT_DELAY_RANGE);
settings.repeat_delay = read.unwrap_or(settings.repeat_delay);
}
"repeat_interval" => {
let read = parse_in(value, &REPEAT_INTERVAL_RANGE);
settings.repeat_interval = read.unwrap_or(settings.repeat_interval);
}
"music_on" => settings.music_on = is_on(value),
"sfx_on" => settings.sfx_on = is_on(value),
"music" => {
let read = parse_in(value, &VOLUME_RANGE);
settings.music_volume = read.unwrap_or(settings.music_volume);
}
"sfx" => {
settings.sfx_volume = match value {
"off" => 0,
"on" => 100,
_ => parse_in(value, &VOLUME_RANGE).unwrap_or(settings.sfx_volume),
};
}
"replay_cap" => {
let read = parse_in(value, &(REPLAY_CAP_MIN..=REPLAY_CAP_MAX));
settings.replay_cap = read.unwrap_or(settings.replay_cap);
}
"teams" => settings.team_mode = TeamMode::from_key(value),
"language" => settings.language = Lang::from_key(value),
"rumble" => settings.rumble = is_on(value),
"pad_deadzone" => {
let read = parse_in(value, &DEADZONE_RANGE);
settings.pad_deadzone = read.unwrap_or(settings.pad_deadzone);
}
"palette" => settings.colorblind = value == "colorblind",
"fullscreen" => settings.fullscreen = is_on(value),
"ui_scale" => {
let read = parse_in(value, &(UI_SCALE_MIN..=UI_SCALE_MAX));
settings.ui_scale = read.unwrap_or(settings.ui_scale);
}
"reduced_motion" => settings.reduced_motion = value == "on",
"antialiasing" => settings.antialiasing = is_on(value),
"names" => {
for (seat, name) in value.split('|').take(MAX_PLAYERS).enumerate() {
settings.set_name(seat as u8, name.trim());
}
}
"beach" => {
if value.parse::<std::net::SocketAddr>().is_ok() {
settings.last_beach = value.to_string();
}
}
"updates" => settings.check_updates = is_on(value),
"editor_tips" => settings.editor_tips = is_on(value),
"keycaps" => caps = crate::app::keycaps::KeyCaps::parse(value),
"keyboard" => settings.keyboard = crate::app::keycaps::Layout::from_key(value),
"keys_p1" | "keys_p2" => {
let seat = usize::from(key.trim() == "keys_p2");
if let Some((seat_binds, slots)) = crate::app::binds::parse_seat_moved(value) {
settings.binds[seat] = seat_binds;
moved.extend(slots.into_iter().map(|slot| (seat, slot)));
}
}
"puzzle_speed" => {
if let Ok(v) = value.parse::<u8>() {
settings.puzzle_speed = PuzzleSpeed::from_percent(v);
}
}
"" => {}
_ => unknown.keep(line),
}
}
for (seat, slot) in moved {
crate::app::binds::rehome(&mut settings.binds, seat, slot);
}
if !crate::app::binds::all_distinct(&settings.binds) {
settings.binds = default_binds();
}
let (language, keyboard) = (settings.language, settings.keyboard);
settings.set_language(language, &mut caps);
settings.set_keyboard(keyboard, &mut caps);
(settings, caps, unknown)
}
pub fn load_saved() -> Option<(GameSettings, crate::app::keycaps::KeyCaps)> {
std::fs::read_to_string(settings_path())
.ok()
.map(|text| GameSettings::parse(&text))
}
pub fn load() -> (GameSettings, crate::app::keycaps::KeyCaps) {
GameSettings::load_saved().unwrap_or_default()
}
pub fn save(&self, caps: &crate::app::keycaps::KeyCaps) {
let _ = self.save_in(&settings_path(), caps);
}
fn save_in(
&self,
path: &std::path::Path,
caps: &crate::app::keycaps::KeyCaps,
) -> std::io::Result<()> {
let on_disk = std::fs::read_to_string(path).unwrap_or_default();
let (_, _, unknown) = GameSettings::read(&on_disk);
let mut text = self.to_text(caps);
unknown.append_to(&mut text);
crate::app::paths::write_atomic(path, text)
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::prelude::KeyCode;
#[test]
fn a_setting_from_a_newer_build_is_written_back() {
let dir = crate::app::paths::test_dir("settings");
let path = dir.join("settings.txt");
let caps = crate::app::keycaps::KeyCaps::default();
let mut text = GameSettings::default().to_text(&caps);
text.push_str("hover_glow: amber\n");
crate::app::paths::write_atomic(&path, &text).expect("written");
let (mut settings, caps) = GameSettings::parse(&text);
settings.music_volume = 15;
settings.save_in(&path, &caps).expect("saved");
let back = std::fs::read_to_string(&path).expect("read back");
assert!(back.contains("hover_glow: amber\n"), "{back}");
assert_eq!(GameSettings::parse(&back).0.music_volume, 15, "{back}");
assert_eq!(back.matches("hover_glow").count(), 1, "kept once");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn settings_round_trip_through_text() {
let settings = GameSettings {
commit: CommitScheme::Ijkl,
seat_input: [SeatInput::Keys, SeatInput::Pad(2)],
repeat_delay: 0.35,
repeat_interval: 0.12,
music_on: false,
music_volume: 80,
sfx_on: false,
sfx_volume: 30,
puzzle_speed: PuzzleSpeed::ThreeQuarter,
team_mode: TeamMode::Trios,
language: Lang::Fr,
rumble: false,
pad_deadzone: 70,
colorblind: true,
fullscreen: false,
ui_scale: 70,
reduced_motion: true,
antialiasing: true,
check_updates: false,
editor_tips: false,
binds: {
let mut binds = default_binds();
binds[0].set(crate::app::binds::Action::Remove, KeyCode::Backquote);
binds[1].set(crate::app::binds::Action::ClearAll, KeyCode::End);
binds
},
replay_cap: 35,
names: [
"Anna".into(),
"Bo Diddley".into(),
String::new(),
"Dee".into(),
"Eve".into(),
String::new(),
],
last_beach: "10.0.0.5:47777".into(),
..GameSettings::default()
};
let mut caps = crate::app::keycaps::KeyCaps::parse("KeyW=Z KeyA=Q");
let mut settings = settings;
settings.set_language(Lang::Fr, &mut caps);
settings.set_keyboard(Some(crate::app::keycaps::Layout::Azerty), &mut caps);
let (reparsed, recaps) = GameSettings::parse(&settings.to_text(&caps));
assert_eq!(reparsed, settings);
assert_eq!(recaps, caps, "the caps table rides the same file");
}
#[test]
fn an_old_num_enter_beside_a_held_num_dot_keeps_both_layouts() {
use crate::app::binds::{Action, key_name};
let mut p1 = default_binds()[0];
p1.0[0] = KeyCode::NumpadDecimal;
let p1_line: Vec<String> = p1.0.iter().copied().map(key_name).collect();
let text = format!(
"keys_p1: {}\nkeys_p2: KeyU KeyK KeyJ KeyL Numpad8 Numpad5 Numpad4 Numpad6 Numpad0 NumpadEnter\n",
p1_line.join(" ")
);
let (settings, _) = GameSettings::parse(&text);
assert_eq!(settings.binds[0], p1, "P1 as saved");
assert_eq!(settings.binds[1].key(Action::MoveUp), KeyCode::KeyU);
assert!(crate::app::binds::all_distinct(&settings.binds));
}
#[test]
fn a_kept_beach_address_has_to_still_be_one() {
assert_eq!(
GameSettings::parse("beach: 192.168.1.5:47777\n")
.0
.last_beach,
"192.168.1.5:47777"
);
assert_eq!(
GameSettings::parse("beach: [::1]:47777\n").0.last_beach,
"[::1]:47777",
"including the six-legged sort"
);
for junk in ["beach: 192.168.1.5\n", "beach: over there\n", "beach:\n"] {
assert_eq!(GameSettings::parse(junk).0.last_beach, "", "{junk:?}");
}
}
#[test]
fn clashing_saved_bindings_fall_back_to_defaults() {
let stock = default_binds();
let text = "keys_p2: KeyW KeyK KeyJ KeyL Numpad8 Numpad5 Numpad4 Numpad6 \
Numpad0 NumpadEnter\n";
assert_eq!(GameSettings::parse(text).0.binds, stock);
assert_eq!(GameSettings::parse("keys_p1: nonsense\n").0.binds, stock);
}
#[test]
fn parse_is_lenient_and_clamps() {
let settings = GameSettings::parse(
"garbage\nmusic: 900\npad_deadzone: 99\nrepeat_delay: 9.0\nwibble: 3\n",
)
.0;
assert_eq!(settings.music_volume, GameSettings::default().music_volume);
assert_eq!(GameSettings::parse("music: 101\n").0.music_volume, 100);
assert_eq!(settings.pad_deadzone, 80);
assert!(settings.repeat_delay <= 0.5);
assert_eq!(settings.language, Lang::En);
let nan = GameSettings::parse(
"repeat_delay: NaN
repeat_interval: nan
",
)
.0;
assert_eq!(nan.repeat_delay, GameSettings::default().repeat_delay);
assert_eq!(nan.repeat_interval, GameSettings::default().repeat_interval);
}
#[test]
fn a_file_without_the_switches_still_has_sound() {
let old = "music: 60\nsfx: 40\n";
let settings = GameSettings::parse(old).0;
assert!(settings.music_on, "no music_on line means music");
assert!(settings.sfx_on, "no sfx_on line means effects");
assert_eq!(settings.music_volume, 60);
assert_eq!(settings.sfx_volume, 40);
let quiet = GameSettings::parse("music: 60\nsfx: 40\nmusic_on: off\n").0;
assert_eq!(quiet.music_gain(), 0.0, "the music is off");
assert!(quiet.sfx_gain() > 0.0, "the effects are not");
assert_eq!(quiet.music_volume, 60, "and the slider is where it was");
}
#[test]
fn a_file_without_the_fullscreen_line_fills_the_screen() {
assert!(GameSettings::parse("music: 60\n").0.fullscreen);
assert!(!GameSettings::parse("fullscreen: off\n").0.fullscreen);
}
#[test]
fn old_sfx_toggle_files_still_load() {
assert_eq!(GameSettings::parse("sfx: off\n").0.sfx_volume, 0);
assert_eq!(GameSettings::parse("sfx: on\n").0.sfx_volume, 100);
assert_eq!(GameSettings::parse("sfx: 65\n").0.sfx_volume, 65);
assert_eq!(GameSettings::parse("sfx: 900\n").0.sfx_volume, 80);
assert_eq!(GameSettings::parse("sfx: 101\n").0.sfx_volume, 100);
}
}