mod file;
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::*;
use bevy::window::{MonitorSelection, WindowMode};
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 = 60;
pub const UI_SCALE_MAX: u8 = 100;
pub(crate) fn clamp_in<T: PartialOrd + Copy>(value: T, range: &std::ops::RangeInclusive<T>) -> T {
if value < *range.start() {
*range.start()
} else if value > *range.end() {
*range.end()
} else {
value
}
}
#[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(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum PuzzleSpeed {
Half,
ThreeQuarter,
#[default]
Full,
}
impl PuzzleSpeed {
const ALL: [PuzzleSpeed; 3] = [
PuzzleSpeed::Half,
PuzzleSpeed::ThreeQuarter,
PuzzleSpeed::Full,
];
pub fn percent(self) -> u8 {
match self {
PuzzleSpeed::Half => 50,
PuzzleSpeed::ThreeQuarter => 75,
PuzzleSpeed::Full => 100,
}
}
pub fn from_percent(percent: u8) -> PuzzleSpeed {
match percent {
0..=62 => PuzzleSpeed::Half,
63..=87 => PuzzleSpeed::ThreeQuarter,
88.. => PuzzleSpeed::Full,
}
}
pub fn stepped(self, turn: crate::app::cycle::Turn) -> PuzzleSpeed {
let at = Self::ALL.iter().position(|&s| s == self).unwrap_or(0);
let to = match turn {
crate::app::cycle::Turn::Left => at.saturating_sub(1),
crate::app::cycle::Turn::Right => (at + 1).min(Self::ALL.len() - 1),
};
Self::ALL[to]
}
}
#[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: PuzzleSpeed,
pub team_mode: TeamMode,
pub language: Lang,
pub rumble: bool,
pub pad_deadzone: u8,
pub colorblind: bool,
pub fullscreen: bool,
pub ui_scale: u8,
pub reduced_motion: bool,
pub antialiasing: 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 editor_tips: bool,
}
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 commit_in_play(&self, shared: bool) -> CommitScheme {
if shared {
CommitScheme::Arrows
} else {
self.commit
}
}
pub fn stock_legend(&self, shared: bool) -> bool {
!self.custom_binds() && self.commit_in_play(shared) == CommitScheme::Arrows
}
pub fn sfx_gain(&self) -> f32 {
if self.sfx_on {
f32::from(self.sfx_volume) / 100.0 * SFX_TRIM
} else {
0.0
}
}
pub fn music_gain(&self) -> f32 {
if self.music_on {
f32::from(self.music_volume) / 100.0 * MUSIC_LIFT
} 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: PuzzleSpeed::Full,
team_mode: TeamMode::default(),
language: Lang::default(),
rumble: true,
pad_deadzone: 50,
colorblind: false,
fullscreen: true,
ui_scale: 100,
reduced_motion: false,
antialiasing: false,
binds: default_binds(),
names: std::array::from_fn(|_| String::new()),
replay_cap: REPLAY_CAP_DEFAULT,
last_beach: String::new(),
check_updates: true,
editor_tips: true,
keyboard: None,
}
}
}
pub const MUSIC_LIFT: f32 = 2.2;
pub const SFX_TRIM: f32 = 0.7;
pub const DESIGN_W: f32 = 1280.0;
pub const DESIGN_H: f32 = 720.0;
pub fn applied_ui_ratio(ui_ratio: f32, width: f32, height: f32) -> f32 {
let fit = (width / DESIGN_W).min(height / DESIGN_H).max(0.01);
fit * ui_ratio.min(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 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 window_mode(settings: &GameSettings) -> WindowMode {
if settings.fullscreen && !crate::app::dev::window_pinned() {
WindowMode::BorderlessFullscreen(MonitorSelection::Current)
} else {
WindowMode::Windowed
}
}
pub fn apply_window_mode(settings: Res<GameSettings>, mut windows: Query<&mut Window>) {
if !settings.is_changed() {
return;
}
let mode = window_mode(&settings);
for mut window in &mut windows {
if window.mode != mode {
window.mode = mode;
}
}
}
pub fn toggle_fullscreen(
keys: Res<ButtonInput<KeyCode>>,
mut settings: ResMut<GameSettings>,
caps: Res<crate::app::keycaps::KeyCaps>,
unasked: Res<crate::app::language::LanguageUnasked>,
) {
if flip_fullscreen(&keys, &mut settings, &unasked) {
settings.save(&caps);
}
}
fn flip_fullscreen(
keys: &ButtonInput<KeyCode>,
settings: &mut GameSettings,
unasked: &crate::app::language::LanguageUnasked,
) -> bool {
if !keys.just_pressed(KeyCode::F11) {
return false;
}
settings.fullscreen = !settings.fullscreen;
unasked.may_save()
}
pub fn apply_music_volume(
settings: Res<GameSettings>,
mut sinks: Query<&mut AudioSink, (With<Music>, Without<crate::app::audio::FadingOut>)>,
) {
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>>,
editor: Res<crate::app::editor::EditorState>,
mut fixed_time: ResMut<Time<Fixed>>,
) {
let puzzle = match screen.get() {
Screen::Puzzle => true,
Screen::Editor => editor.is_testing() && editor.kind == crate::sim::LevelKind::Puzzle,
Screen::Menu
| Screen::Versus
| Screen::Lobby
| Screen::Settings
| Screen::Controls
| Screen::MatchSetup
| Screen::Achievements
| Screen::StageSelect
| Screen::Replays
| Screen::Interlude
| Screen::Language
| Screen::NewVersion => false,
};
let hz = if puzzle {
f64::from(crate::sim::TICKS_PER_SECOND) * f64::from(settings.puzzle_speed.percent()) / 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 fullscreen_before_a_language_is_taken_is_not_written_yet() {
use crate::app::language::LanguageUnasked;
let mut keys = ButtonInput::<KeyCode>::default();
let mut settings = GameSettings::default();
let was = settings.fullscreen;
let unasked = LanguageUnasked(true);
assert!(!flip_fullscreen(&keys, &mut settings, &unasked));
assert_eq!(settings.fullscreen, was, "no F11, no flip");
keys.press(KeyCode::F11);
assert!(!flip_fullscreen(&keys, &mut settings, &unasked));
assert_ne!(settings.fullscreen, was, "the window still flips");
assert!(flip_fullscreen(
&keys,
&mut settings,
&LanguageUnasked(false)
));
assert_eq!(settings.fullscreen, was);
}
fn clocked(screen: Screen, puzzle_speed: PuzzleSpeed) -> 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.init_resource::<crate::app::editor::EditorState>();
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 the_speed_dial_stops_at_its_ends() {
use crate::app::cycle::Turn;
assert_eq!(PuzzleSpeed::Full.stepped(Turn::Right), PuzzleSpeed::Full);
assert_eq!(PuzzleSpeed::Half.stepped(Turn::Left), PuzzleSpeed::Half);
assert_eq!(
PuzzleSpeed::Full.stepped(Turn::Left),
PuzzleSpeed::ThreeQuarter
);
assert_eq!(
PuzzleSpeed::Half.stepped(Turn::Right),
PuzzleSpeed::ThreeQuarter
);
for speed in PuzzleSpeed::ALL {
assert_eq!(PuzzleSpeed::from_percent(speed.percent()), speed);
}
assert_eq!(PuzzleSpeed::from_percent(70), PuzzleSpeed::ThreeQuarter);
assert_eq!(PuzzleSpeed::from_percent(0), PuzzleSpeed::Half);
}
#[test]
fn a_puzzle_runs_at_the_speed_asked_for() {
let mut app = clocked(Screen::Puzzle, PuzzleSpeed::Half);
app.update();
assert!((hz(&app) - 15.0).abs() < 1e-6, "{} Hz", hz(&app));
}
#[test]
fn an_editor_playtest_of_a_stage_runs_at_the_speed_asked_for() {
use crate::app::editor::{EditorState, Mode};
use crate::sim::LevelKind;
for (kind, want) in [(LevelKind::Puzzle, 15.0), (LevelKind::Arena, 30.0)] {
let mut app = clocked(Screen::Editor, PuzzleSpeed::Half);
let mut editor = app.world_mut().resource_mut::<EditorState>();
editor.kind = kind;
editor.mode = Mode::testing(crate::sim::Board::new(9, 9, 1));
app.update();
assert!((hz(&app) - want).abs() < 1e-6, "{kind:?}: {} 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, PuzzleSpeed::Half);
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_interface_fills_the_window_and_the_dial_takes_some_back() {
assert_eq!(applied_ui_ratio(1.0, DESIGN_W, DESIGN_H), 1.0);
assert_eq!(applied_ui_ratio(1.0, 1920.0, 1080.0), 1.5);
assert_eq!(applied_ui_ratio(1.0, 2560.0, 1440.0), 2.0);
assert_eq!(applied_ui_ratio(1.0, 3840.0, 800.0), 800.0 / DESIGN_H);
let small = applied_ui_ratio(1.0, 1024.0, 576.0);
assert!((small - 0.8).abs() < 1e-6, "{small}");
assert!((applied_ui_ratio(0.8, 1920.0, 1080.0) - 1.2).abs() < 1e-6);
assert_eq!(
applied_ui_ratio(1.5, 1920.0, 1080.0),
1.5,
"capped at the fit"
);
let (old, _) = GameSettings::parse("ui_scale: 150\n");
assert_eq!(old.ui_scale, UI_SCALE_MAX);
}
#[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 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 the_music_sits_level_with_the_effects() {
const TRACK_MEAN_DB: f32 = -23.0;
const EFFECT_MEAN_DB: f32 = -18.7;
const LOUDEST_TRACK_PEAK_DB: f32 = -7.1;
let db = |gain: f32| 20.0 * gain.log10();
let stock = GameSettings::default();
let music = TRACK_MEAN_DB + db(stock.music_gain());
let effects = EFFECT_MEAN_DB + db(stock.sfx_gain());
let under = effects - music;
assert!(
(-2.0..=2.0).contains(&under),
"the music plays {under:.1} dB under the effects"
);
let top = GameSettings {
music_volume: 100,
..GameSettings::default()
};
let peak = LOUDEST_TRACK_PEAK_DB + db(top.music_gain());
assert!(peak <= 0.0, "the music clips at the top: {peak:.1} dB");
}
}