pub mod screen;
use crate::app::Screen;
use crate::app::audio::Music;
use crate::app::binds::{BOUND_SEATS, SeatBinds, default_binds};
use crate::app::i18n::Lang;
use crate::app::palette;
use crate::app::teams::TeamMode;
use crate::sim::MAX_PLAYERS;
use bevy::audio::Volume;
use bevy::prelude::*;
fn settings_path() -> std::path::PathBuf {
crate::app::paths::config_dir().join("settings.txt")
}
pub const REPLAY_CAP_MIN: u8 = 5;
pub const REPLAY_CAP_MAX: u8 = 99;
pub const REPLAY_CAP_DEFAULT: u8 = 20;
pub const UI_SCALE_MIN: u8 = 80;
pub const UI_SCALE_MAX: u8 = 150;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum SeatInput {
#[default]
Auto,
Keys,
Pad(u8),
}
pub const NAMED_PADS: u8 = 4;
impl crate::app::cycle::Cycle for SeatInput {
const VARIANTS: &'static [SeatInput] = &[
SeatInput::Auto,
SeatInput::Keys,
SeatInput::Pad(0),
SeatInput::Pad(1),
SeatInput::Pad(2),
SeatInput::Pad(3),
];
}
impl SeatInput {
fn name(self) -> String {
match self {
SeatInput::Auto => "auto".to_string(),
SeatInput::Keys => "keys".to_string(),
SeatInput::Pad(n) => format!("pad{}", n + 1),
}
}
fn from_name(text: &str) -> SeatInput {
match text.trim() {
"keys" => SeatInput::Keys,
other => other
.strip_prefix("pad")
.and_then(|n| n.parse::<u8>().ok())
.filter(|&n| (1..=NAMED_PADS).contains(&n))
.map_or(SeatInput::Auto, |n| SeatInput::Pad(n - 1)),
}
}
}
#[derive(Resource, Clone, PartialEq, Debug)]
pub struct GameSettings {
pub ijkl_commits: bool,
pub seat_input: [SeatInput; crate::app::binds::BOUND_SEATS],
pub repeat_delay: f32,
pub repeat_interval: f32,
pub music_volume: u8,
pub sfx_volume: u8,
pub puzzle_speed: u8,
pub team_mode: TeamMode,
pub language: Lang,
pub rumble: bool,
pub pad_deadzone: u8,
pub colorblind: bool,
pub ui_scale: u8,
pub reduced_motion: bool,
pub binds: [SeatBinds; BOUND_SEATS],
pub names: [String; MAX_PLAYERS],
pub replay_cap: u8,
pub last_beach: String,
pub check_updates: bool,
}
pub const NAME_MAX: usize = 12;
impl GameSettings {
pub fn tr(&self) -> &'static crate::app::i18n::Tr {
self.language.tr()
}
pub fn custom_binds(&self) -> bool {
self.binds != default_binds()
}
pub fn stock_legend(&self) -> bool {
!self.custom_binds() && !self.ijkl_commits
}
pub fn sfx_gain(&self) -> f32 {
f32::from(self.sfx_volume) / 100.0
}
pub fn music_gain(&self) -> f32 {
f32::from(self.music_volume) / 100.0
}
pub fn ui_ratio(&self) -> f32 {
f32::from(self.ui_scale) / 100.0
}
pub fn deadzone(&self) -> f32 {
f32::from(self.pad_deadzone) / 100.0
}
#[cfg(test)]
pub fn seat_name(&self, seat: u8) -> String {
match self.names.get(usize::from(seat)) {
Some(name) if !name.is_empty() => name.clone(),
_ => crate::app::seat_label(self.tr(), seat),
}
}
pub fn push_name_char(&mut self, seat: u8, ch: char) {
let Some(name) = self.names.get_mut(usize::from(seat)) else {
return;
};
let ok = !ch.is_control() && ch != '|' && ch != ':' && (ch != ' ' || !name.is_empty());
if ok && name.chars().count() < NAME_MAX {
name.push(ch);
}
}
pub fn pop_name_char(&mut self, seat: u8) {
if let Some(name) = self.names.get_mut(usize::from(seat)) {
name.pop();
}
}
pub fn tidy_name(&mut self, seat: u8) {
if let Some(name) = self.names.get_mut(usize::from(seat)) {
*name = name.trim().to_string();
}
}
}
impl Default for GameSettings {
fn default() -> Self {
GameSettings {
ijkl_commits: false,
seat_input: [SeatInput::Auto; crate::app::binds::BOUND_SEATS],
repeat_delay: 0.28,
repeat_interval: 0.09,
music_volume: 45,
sfx_volume: 80,
puzzle_speed: 100,
team_mode: TeamMode::default(),
language: Lang::default(),
rumble: true,
pad_deadzone: 50,
colorblind: false,
ui_scale: 100,
reduced_motion: false,
binds: default_binds(),
names: std::array::from_fn(|_| String::new()),
replay_cap: REPLAY_CAP_DEFAULT,
last_beach: String::new(),
check_updates: true,
}
}
}
impl GameSettings {
pub fn to_text(&self) -> String {
let Self {
ijkl_commits,
seat_input,
repeat_delay,
repeat_interval,
music_volume,
sfx_volume,
puzzle_speed,
team_mode,
language,
rumble,
pad_deadzone,
colorblind,
ui_scale,
reduced_motion,
binds,
names,
replay_cap,
last_beach,
check_updates,
} = self;
format!(
"commit_scheme: {}\nrepeat_delay: {:.2}\nrepeat_interval: {:.2}\n\
music: {}\nsfx: {}\npuzzle_speed: {}\nteams: {}\nlanguage: {}\n\
rumble: {}\npad_deadzone: {}\npalette: {}\nui_scale: {}\n\
reduced_motion: {}\nnames: {}\nreplay_cap: {}\nbeach: {}\n\
updates: {}\n\
p1_input: {}\np2_input: {}\n{}",
if *ijkl_commits { "ijkl" } else { "arrows" },
repeat_delay,
repeat_interval,
music_volume,
sfx_volume,
puzzle_speed,
team_mode.key(),
language.key(),
if *rumble { "on" } else { "off" },
pad_deadzone,
if *colorblind { "colorblind" } else { "classic" },
ui_scale,
if *reduced_motion { "on" } else { "off" },
names.join("|"),
replay_cap,
last_beach,
if *check_updates { "on" } else { "off" },
seat_input[0].name(),
seat_input[1].name(),
crate::app::binds::to_text(binds),
)
}
pub fn parse(text: &str) -> GameSettings {
let mut settings = GameSettings::default();
for line in text.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let value = value.trim();
match key.trim() {
"commit_scheme" => settings.ijkl_commits = value == "ijkl",
"p1_input" => settings.seat_input[0] = SeatInput::from_name(value),
"p2_input" => settings.seat_input[1] = SeatInput::from_name(value),
"repeat_delay" => {
if let Ok(v) = value.parse() {
settings.repeat_delay = f32::clamp(v, 0.1, 0.5);
}
}
"repeat_interval" => {
if let Ok(v) = value.parse() {
settings.repeat_interval = f32::clamp(v, 0.03, 0.2);
}
}
"music" => {
if let Ok(v) = value.parse::<u8>() {
settings.music_volume = v.min(100);
}
}
"sfx" => {
settings.sfx_volume = match value {
"off" => 0,
"on" => 100,
_ => value
.parse::<u8>()
.map_or(settings.sfx_volume, |v| v.min(100)),
};
}
"replay_cap" => {
if let Ok(v) = value.parse::<u8>() {
settings.replay_cap = v.clamp(REPLAY_CAP_MIN, REPLAY_CAP_MAX);
}
}
"teams" => settings.team_mode = TeamMode::from_key(value),
"language" => settings.language = Lang::from_key(value),
"rumble" => settings.rumble = value != "off",
"pad_deadzone" => {
if let Ok(v) = value.parse::<u8>() {
settings.pad_deadzone = v.clamp(20, 80);
}
}
"palette" => settings.colorblind = value == "colorblind",
"ui_scale" => {
if let Ok(v) = value.parse::<u8>() {
settings.ui_scale = v.clamp(UI_SCALE_MIN, UI_SCALE_MAX);
}
}
"reduced_motion" => settings.reduced_motion = value == "on",
"names" => {
for (seat, name) in value.split('|').take(MAX_PLAYERS).enumerate() {
let name = name.trim();
settings.names[seat] = String::new();
for ch in name.chars() {
settings.push_name_char(seat as u8, ch);
}
}
}
"beach" => {
if value.parse::<std::net::SocketAddr>().is_ok() {
settings.last_beach = value.to_string();
}
}
"updates" => settings.check_updates = value != "off",
"keys_p1" | "keys_p2" => {
let seat = usize::from(key.trim() == "keys_p2");
if let Some(seat_binds) = crate::app::binds::parse_seat(value) {
settings.binds[seat] = seat_binds;
}
}
"puzzle_speed" => {
if let Ok(v) = value.parse::<u8>() {
settings.puzzle_speed = match v {
0..=62 => 50,
63..=87 => 75,
88.. => 100,
};
}
}
_ => {}
}
}
if !crate::app::binds::all_distinct(&settings.binds) {
settings.binds = default_binds();
}
settings
}
pub fn load_saved() -> Option<GameSettings> {
std::fs::read_to_string(settings_path())
.ok()
.map(|text| GameSettings::parse(&text))
}
pub fn load() -> GameSettings {
GameSettings::load_saved().unwrap_or_default()
}
pub fn save(&self) {
let _ = crate::app::paths::write_atomic(&settings_path(), self.to_text());
}
}
pub const DESIGN_W: f32 = 1280.0;
pub const DESIGN_H: f32 = 720.0;
pub fn fit_ratio(window: &Window) -> f32 {
(window.width() / DESIGN_W)
.min(window.height() / DESIGN_H)
.clamp(0.1, 1.0)
}
pub fn apply_accessibility(
settings: Res<GameSettings>,
windows: Query<&Window>,
mut ui_scale: ResMut<UiScale>,
) {
if settings.is_changed() {
palette::set_colorblind(settings.colorblind);
}
let fit = windows.iter().next().map_or(1.0, fit_ratio);
let scale = settings.ui_ratio() * fit;
if (ui_scale.0 - scale).abs() > f32::EPSILON {
ui_scale.0 = scale;
}
}
pub fn apply_music_volume(
settings: Res<GameSettings>,
mut sinks: Query<&mut AudioSink, With<Music>>,
) {
if !settings.is_changed() {
return;
}
for mut sink in &mut sinks {
sink.set_volume(Volume::Linear(settings.music_gain()));
}
}
pub fn apply_sim_speed(
settings: Res<GameSettings>,
screen: Res<State<Screen>>,
mut fixed_time: ResMut<Time<Fixed>>,
) {
let hz = if *screen.get() == Screen::Puzzle {
f64::from(crate::sim::TICKS_PER_SECOND) * f64::from(settings.puzzle_speed) / 100.0
} else {
f64::from(crate::sim::TICKS_PER_SECOND)
};
if (fixed_time.timestep().as_secs_f64() - 1.0 / hz).abs() > 1e-9 {
fixed_time.set_timestep_hz(hz);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_round_trip_through_text() {
let settings = GameSettings {
ijkl_commits: true,
repeat_delay: 0.35,
music_volume: 80,
sfx_volume: 30,
puzzle_speed: 75,
team_mode: TeamMode::Trios,
language: Lang::Fr,
rumble: false,
pad_deadzone: 70,
colorblind: true,
ui_scale: 130,
reduced_motion: true,
check_updates: 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 reparsed = GameSettings::parse(&settings.to_text());
assert_eq!(reparsed, settings);
}
#[test]
fn a_kept_beach_address_has_to_still_be_one() {
assert_eq!(
GameSettings::parse("beach: 192.168.1.5:47777\n").last_beach,
"192.168.1.5:47777"
);
assert_eq!(
GameSettings::parse("beach: [::1]:47777\n").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).last_beach, "", "{junk:?}");
}
}
#[test]
fn a_named_seat_answers_to_its_name() {
let mut settings = GameSettings::default();
assert_eq!(settings.seat_name(0), "P1");
assert_eq!(settings.seat_name(3), "P4");
settings.language = Lang::Fr;
assert_eq!(settings.seat_name(1), "J2", "the default localizes");
for ch in "Zoé".chars() {
settings.push_name_char(1, ch);
}
assert_eq!(settings.seat_name(1), "Zoé");
settings.pop_name_char(1);
assert_eq!(settings.seat_name(1), "Zo");
assert_eq!(settings.seat_name(9), "J10");
}
#[test]
fn typed_names_are_kept_clean() {
let mut settings = GameSettings::default();
for ch in " \n|:Ann|a: ".chars() {
settings.push_name_char(0, ch);
}
assert_eq!(settings.names[0], "Anna ", "separators and controls drop");
settings.tidy_name(0);
assert_eq!(settings.names[0], "Anna", "trailing space trimmed away");
settings.names[1] = String::new();
for ch in "abcdefghijklmnopqrstuvwxyz".chars() {
settings.push_name_char(1, ch);
}
assert_eq!(settings.names[1].chars().count(), NAME_MAX);
settings.names[2] = " ".into();
settings.tidy_name(2);
assert_eq!(settings.seat_name(2), "P3");
let parsed = GameSettings::parse("names: Anna|Bo:bby|aaaaaaaaaaaaaaaaaaaa\n");
assert_eq!(parsed.names[0], "Anna");
assert_eq!(parsed.names[1], "Bobby");
assert_eq!(parsed.names[2].chars().count(), NAME_MAX);
assert_eq!(parsed.names[3], "", "a missing field stays unnamed");
}
#[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).binds, stock);
assert_eq!(GameSettings::parse("keys_p1: nonsense\n").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",
);
assert_eq!(settings.music_volume, GameSettings::default().music_volume);
assert_eq!(GameSettings::parse("music: 101\n").music_volume, 100);
assert_eq!(settings.pad_deadzone, 80);
assert!(settings.repeat_delay <= 0.5);
assert_eq!(settings.language, Lang::En);
}
#[test]
fn old_sfx_toggle_files_still_load() {
assert_eq!(GameSettings::parse("sfx: off\n").sfx_volume, 0);
assert_eq!(GameSettings::parse("sfx: on\n").sfx_volume, 100);
assert_eq!(GameSettings::parse("sfx: 65\n").sfx_volume, 65);
assert_eq!(GameSettings::parse("sfx: 900\n").sfx_volume, 80);
assert_eq!(GameSettings::parse("sfx: 101\n").sfx_volume, 100);
}
}