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 DEADZONE_RANGE: std::ops::RangeInclusive<u8> = 20..=80;
pub const VOLUME_RANGE: std::ops::RangeInclusive<u8> = 0..=100;
pub const REPEAT_DELAY_RANGE: std::ops::RangeInclusive<f32> = 0.1..=0.5;
pub const REPEAT_INTERVAL_RANGE: std::ops::RangeInclusive<f32> = 0.03..=0.2;
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;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum CommitScheme {
#[default]
Arrows,
Ijkl,
}
impl CommitScheme {
pub fn key(self) -> &'static str {
match self {
CommitScheme::Arrows => "arrows",
CommitScheme::Ijkl => "ijkl",
}
}
pub fn from_key(key: &str) -> CommitScheme {
use crate::app::cycle::Cycle;
Self::VARIANTS
.iter()
.copied()
.find(|scheme| scheme.key() == key)
.unwrap_or_default()
}
}
impl crate::app::cycle::Cycle for CommitScheme {
const VARIANTS: &'static [CommitScheme] = &[CommitScheme::Arrows, CommitScheme::Ijkl];
}
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 commit: CommitScheme,
pub seat_input: [SeatInput; BOUND_SEATS],
pub repeat_delay: f32,
pub repeat_interval: f32,
pub music_on: bool,
pub music_volume: u8,
pub sfx_on: bool,
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 keyboard: Option<crate::app::keycaps::Layout>,
}
pub const NAME_MAX: usize = 12;
impl GameSettings {
pub fn tr(&self) -> &'static crate::app::i18n::Tr {
self.language.tr()
}
pub fn set_keyboard(
&mut self,
keyboard: Option<crate::app::keycaps::Layout>,
caps: &mut crate::app::keycaps::KeyCaps,
) {
self.keyboard = keyboard;
caps.force(keyboard);
}
pub fn set_language(&mut self, language: Lang, caps: &mut crate::app::keycaps::KeyCaps) {
self.language = language;
caps.presume(crate::app::keycaps::Layout::of(language));
}
pub fn custom_binds(&self) -> bool {
self.binds != default_binds()
}
pub fn stock_legend(&self) -> bool {
!self.custom_binds() && self.commit == CommitScheme::Arrows
}
pub fn sfx_gain(&self) -> f32 {
if self.sfx_on {
f32::from(self.sfx_volume) / 100.0
} else {
0.0
}
}
pub fn music_gain(&self) -> f32 {
if self.music_on {
f32::from(self.music_volume) / 100.0
} else {
0.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 {
crate::app::name_or_label(&self.names, 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 set_name(&mut self, seat: u8, name: &str) {
let Some(slot) = self.names.get_mut(usize::from(seat)) else {
return;
};
slot.clear();
for ch in name.chars() {
self.push_name_char(seat, 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 {
commit: CommitScheme::Arrows,
seat_input: [SeatInput::Auto; BOUND_SEATS],
repeat_delay: 0.28,
repeat_interval: 0.09,
music_on: true,
music_volume: 45,
sfx_on: true,
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,
keyboard: None,
}
}
}
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,
ui_scale,
reduced_motion,
binds,
names,
replay_cap,
last_beach,
check_updates,
keyboard,
} = 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: {}\nui_scale: {}\n\
reduced_motion: {}\nnames: {}\nreplay_cap: {}\nbeach: {}\n\
updates: {}\nkeycaps: {}\nkeyboard: {}\n\
p1_input: {}\np2_input: {}\n{}",
commit.key(),
repeat_delay,
repeat_interval,
if *music_on { "on" } else { "off" },
music_volume,
if *sfx_on { "on" } else { "off" },
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" },
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 mut settings = GameSettings::default();
let mut caps = crate::app::keycaps::KeyCaps::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.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" => {
if let Ok(v) = value.parse() {
settings.repeat_delay =
f32::clamp(v, *REPEAT_DELAY_RANGE.start(), *REPEAT_DELAY_RANGE.end());
}
}
"repeat_interval" => {
if let Ok(v) = value.parse() {
settings.repeat_interval = f32::clamp(
v,
*REPEAT_INTERVAL_RANGE.start(),
*REPEAT_INTERVAL_RANGE.end(),
);
}
}
"music_on" => settings.music_on = value != "off",
"sfx_on" => settings.sfx_on = value != "off",
"music" => {
if let Ok(v) = value.parse::<u8>() {
settings.music_volume = v.min(*VOLUME_RANGE.end());
}
}
"sfx" => {
settings.sfx_volume = match value {
"off" => 0,
"on" => 100,
_ => value
.parse::<u8>()
.map_or(settings.sfx_volume, |v| v.min(*VOLUME_RANGE.end())),
};
}
"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(*DEADZONE_RANGE.start(), *DEADZONE_RANGE.end());
}
}
"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() {
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 = value != "off",
"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) = 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();
}
let (language, keyboard) = (settings.language, settings.keyboard);
settings.set_language(language, &mut caps);
settings.set_keyboard(keyboard, &mut caps);
(settings, caps)
}
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 _ = crate::app::paths::write_atomic(&settings_path(), self.to_text(caps));
}
}
pub const DESIGN_W: f32 = 1280.0;
pub const DESIGN_H: f32 = 720.0;
pub fn ui_scale_cap(width: f32, height: f32) -> u8 {
let headroom = (width / DESIGN_W).min(height / DESIGN_H).max(1.0);
let pct = (headroom * 100.0).floor();
(pct as i32).clamp(i32::from(UI_SCALE_MIN), i32::from(UI_SCALE_MAX)) as u8
}
pub fn applied_ui_ratio(ui_ratio: f32, width: f32, height: f32) -> f32 {
let headroom = (width / DESIGN_W).min(height / DESIGN_H).max(0.01);
(ui_ratio * headroom.clamp(0.1, 1.0)).min(headroom)
}
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 scale = windows.iter().next().map_or(settings.ui_ratio(), |window| {
applied_ui_ratio(settings.ui_ratio(), window.width(), window.height())
});
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::*;
fn clocked(screen: Screen, puzzle_speed: u8) -> App {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.insert_resource(State::new(screen));
app.insert_resource(GameSettings {
puzzle_speed,
..GameSettings::default()
});
app.insert_resource(Time::<Fixed>::from_hz(f64::from(
crate::sim::TICKS_PER_SECOND,
)));
app.add_systems(Update, apply_sim_speed);
app
}
fn hz(app: &App) -> f64 {
1.0 / app
.world()
.resource::<Time<Fixed>>()
.timestep()
.as_secs_f64()
}
#[test]
fn a_puzzle_runs_at_the_speed_asked_for() {
let mut app = clocked(Screen::Puzzle, 50);
app.update();
assert!((hz(&app) - 15.0).abs() < 1e-6, "{} Hz", hz(&app));
}
#[test]
fn every_other_screen_keeps_the_canonical_rate() {
for screen in [Screen::Versus, Screen::Editor, Screen::Menu] {
let mut app = clocked(screen, 50);
app.update();
assert!(
(hz(&app) - 30.0).abs() < 1e-6,
"{screen:?}: {} Hz",
hz(&app)
);
}
}
#[test]
fn the_ui_scale_follows_the_dial_without_a_window() {
let mut app = App::new();
app.insert_resource(GameSettings {
ui_scale: 80,
..GameSettings::default()
});
app.init_resource::<UiScale>();
app.add_systems(Update, apply_accessibility);
app.update();
assert!((app.world().resource::<UiScale>().0 - 0.8).abs() < f32::EPSILON);
}
#[test]
fn the_scale_never_asks_for_more_room_than_the_window_has() {
assert_eq!(ui_scale_cap(DESIGN_W, DESIGN_H), 100);
assert_eq!(applied_ui_ratio(1.5, DESIGN_W, DESIGN_H), 1.0);
assert_eq!(applied_ui_ratio(1.25, DESIGN_W, DESIGN_H), 1.0);
assert_eq!(ui_scale_cap(1920.0, 1080.0), 150);
assert_eq!(applied_ui_ratio(1.5, 1920.0, 1080.0), 1.5);
assert_eq!(ui_scale_cap(1600.0, 900.0), 125);
assert_eq!(applied_ui_ratio(1.5, 1600.0, 900.0), 1.25);
assert_eq!(ui_scale_cap(3840.0, 800.0), 111);
assert_eq!(ui_scale_cap(3840.0, 2160.0), UI_SCALE_MAX);
let small = applied_ui_ratio(1.0, 1024.0, 576.0);
assert!((small - 0.8).abs() < 1e-6, "{small}");
assert_eq!(applied_ui_ratio(1.5, 1024.0, 576.0), small, "capped");
assert_eq!(ui_scale_cap(1024.0, 576.0), 100);
assert!(applied_ui_ratio(0.8, 1024.0, 576.0) < small);
}
#[test]
fn a_name_cannot_carry_the_save_files_own_punctuation() {
let caps = crate::app::keycaps::KeyCaps::default();
let mut settings = GameSettings::default();
settings.set_name(0, "Ann|Bob");
settings.set_name(1, "Cass");
assert_eq!(settings.names[0], "AnnBob", "the separator is not a name");
let (back, _) = GameSettings::parse(&settings.to_text(&caps));
assert_eq!(back.names[0], "AnnBob");
assert_eq!(back.names[1], "Cass", "still in their own seat");
assert_eq!(back.names[2], "", "and nobody invented behind them");
settings.set_name(0, "a: b");
assert_eq!(settings.names[0], "a b");
settings.set_name(0, &"W".repeat(48));
assert_eq!(settings.names[0].chars().count(), NAME_MAX);
}
#[test]
fn settings_round_trip_through_text() {
let settings = GameSettings {
commit: CommitScheme::Ijkl,
repeat_delay: 0.35,
music_on: false,
music_volume: 80,
sfx_on: false,
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 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 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 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").0;
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).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);
}
#[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 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);
}
}