use super::{GameSettings, SeatInput, UI_SCALE_MAX, UI_SCALE_MIN};
use crate::app::Screen;
use crate::app::cycle::Cycle;
use crate::app::i18n::fill;
use crate::app::menu_ui::{self, Half};
use crate::app::palette;
use bevy::prelude::*;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Row {
InputP1,
InputP2,
CommitKeys,
KeyBindings,
RepeatDelay,
RepeatRate,
Music,
Sfx,
Speed,
VersusMode,
Rumble,
Deadzone,
Palette,
UiScale,
ReducedMotion,
ReplayCap,
Language,
UpdateCheck,
ResetProgress,
}
impl Row {
pub const ALL: [Row; 19] = [
Row::InputP1,
Row::InputP2,
Row::CommitKeys,
Row::KeyBindings,
Row::RepeatDelay,
Row::RepeatRate,
Row::Rumble,
Row::Deadzone,
Row::Music,
Row::Sfx,
Row::Speed,
Row::VersusMode,
Row::ReplayCap,
Row::Palette,
Row::UiScale,
Row::ReducedMotion,
Row::Language,
Row::UpdateCheck,
Row::ResetProgress,
];
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Group {
Controls,
Sound,
Round,
Look,
Game,
Danger,
}
impl Group {
fn label(self, tr: &crate::app::i18n::Tr) -> &'static str {
match self {
Group::Controls => tr.set_group_controls,
Group::Sound => tr.set_group_sound,
Group::Round => tr.set_group_round,
Group::Look => tr.set_group_look,
Group::Game => tr.set_group_game,
Group::Danger => tr.set_group_danger,
}
}
}
pub const SECTIONS: [&[(Group, &[Row])]; 2] = [
&[
(
Group::Controls,
&[
Row::InputP1,
Row::InputP2,
Row::CommitKeys,
Row::KeyBindings,
Row::RepeatDelay,
Row::RepeatRate,
Row::Rumble,
Row::Deadzone,
],
),
(Group::Sound, &[Row::Music, Row::Sfx]),
],
&[
(Group::Round, &[Row::Speed, Row::VersusMode, Row::ReplayCap]),
(
Group::Look,
&[
Row::Palette,
Row::UiScale,
Row::ReducedMotion,
Row::Language,
],
),
(Group::Game, &[Row::UpdateCheck]),
(Group::Danger, &[Row::ResetProgress]),
],
];
const ROWS: usize = Row::ALL.len();
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum ResetPrompt {
#[default]
Idle,
Armed,
Done,
}
fn next_reset_prompt(current: ResetPrompt) -> ResetPrompt {
match current {
ResetPrompt::Armed => ResetPrompt::Done,
ResetPrompt::Idle | ResetPrompt::Done => ResetPrompt::Armed,
}
}
#[derive(Resource, Default)]
pub struct SettingsMenu {
pub selected: usize,
pub reset: ResetPrompt,
}
#[derive(Component)]
pub struct SettingsRow(pub usize);
#[derive(Component)]
pub struct SettingsCell(pub usize, pub Half);
#[derive(Component)]
pub struct SettingsUi;
const LABEL_W: f32 = 252.0;
const VALUE_W: f32 = 322.0;
const ROW_FONT: f32 = 18.0;
const FLAG_W: f32 = 21.0;
const FLAG_H: f32 = 14.0;
const FLAG_GAP: f32 = 7.0;
#[derive(Component)]
pub struct LanguageFlag;
pub fn enter_settings(
mut commands: Commands,
settings: Res<GameSettings>,
art: Res<crate::app::art::Art>,
mut menu: ResMut<SettingsMenu>,
) {
let tr = settings.tr();
menu.selected = 0;
menu.reset = ResetPrompt::Idle;
let mut index = 0usize;
commands
.spawn((
SettingsUi,
Node {
position_type: PositionType::Absolute,
top: Val::Px(52.0),
bottom: Val::Px(52.0),
left: Val::Px(0.0),
right: Val::Px(0.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
row_gap: Val::Px(10.0),
..default()
},
))
.with_children(|wrap| {
wrap.spawn((
Node {
column_gap: Val::Px(28.0),
padding: UiRect::axes(Val::Px(22.0), Val::Px(16.0)),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(16.0)),
..default()
},
BackgroundColor(palette::CARD_FILL),
BorderColor::all(palette::CARD_EDGE),
))
.with_children(|card| {
for column in SECTIONS {
card.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(2.0),
..default()
})
.with_children(|side| {
for (group, rows) in column {
side.spawn((
Text::new(group.label(tr)),
TextFont {
font_size: FontSize::Px(14.0),
..default()
},
TextColor(palette::GOLD.with_alpha(0.55)),
Node {
margin: UiRect::top(Val::Px(if index == 0 {
0.0
} else {
10.0
}))
.with_bottom(Val::Px(3.0))
.with_left(Val::Px(10.0)),
..default()
},
));
for row in *rows {
let flag =
(*row == Row::Language).then(|| art.flag(settings.language));
spawn_setting_row(side, index, flag);
index += 1;
}
}
});
}
});
for line in [tr.pad_help1, tr.pad_help2] {
wrap.spawn((
Text::new(line),
TextFont {
font_size: FontSize::Px(15.0),
..default()
},
TextLayout::no_wrap(),
TextColor(palette::PARCHMENT.with_alpha(0.40)),
));
}
});
}
fn spawn_setting_row(side: &mut ChildSpawnerCommands, index: usize, flag: Option<Handle<Image>>) {
side.spawn((
SettingsRow(index),
Node {
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(10.0), Val::Px(3.0)),
border_radius: BorderRadius::all(Val::Px(7.0)),
..default()
},
BackgroundColor(Color::NONE),
))
.with_children(|line| {
for (half, width) in [(Half::Label, LABEL_W), (Half::Value, VALUE_W)] {
line.spawn(Node {
width: Val::Px(width),
flex_shrink: 0.0,
align_items: AlignItems::Center,
column_gap: Val::Px(FLAG_GAP),
overflow: Overflow::clip_x(),
..default()
})
.with_children(|cell| {
if let (Half::Value, Some(flag)) = (half, flag.clone()) {
cell.spawn((
LanguageFlag,
ImageNode::new(flag),
Node {
width: Val::Px(FLAG_W),
height: Val::Px(FLAG_H),
flex_shrink: 0.0,
..default()
},
));
}
cell.spawn((
SettingsCell(index, half),
Text::new(""),
TextFont {
font_size: FontSize::Px(ROW_FONT),
..default()
},
TextLayout::no_wrap(),
TextColor(palette::IDLE_ROW),
));
});
}
});
}
pub fn settings_input(
keys: Res<ButtonInput<KeyCode>>,
mut menu: ResMut<SettingsMenu>,
mut settings: ResMut<GameSettings>,
mut progress: ResMut<crate::app::progress::Progress>,
mut next_screen: ResMut<NextState<Screen>>,
) {
if keys.just_pressed(KeyCode::Enter) {
match Row::ALL[menu.selected] {
Row::KeyBindings => next_screen.set(Screen::Controls),
Row::ResetProgress => {
menu.reset = next_reset_prompt(menu.reset);
if menu.reset == ResetPrompt::Done {
progress.clear_all();
crate::app::progress::save(&progress);
}
}
Row::InputP1
| Row::InputP2
| Row::CommitKeys
| Row::RepeatDelay
| Row::RepeatRate
| Row::Music
| Row::Sfx
| Row::Speed
| Row::VersusMode
| Row::Rumble
| Row::Deadzone
| Row::Palette
| Row::UiScale
| Row::ReducedMotion
| Row::ReplayCap
| Row::Language
| Row::UpdateCheck => next_screen.set(Screen::Menu),
}
return;
}
if keys.just_pressed(KeyCode::Escape) {
next_screen.set(Screen::Menu);
return;
}
let was = menu.selected;
menu.selected = menu_ui::nav(&keys, menu.selected, ROWS);
if menu.selected != was {
menu.reset = ResetPrompt::Idle;
}
let Some(right) = menu_ui::left_right(&keys) else {
return;
};
let step = if right { 1.0 } else { -1.0 };
match Row::ALL[menu.selected] {
Row::InputP1 | Row::InputP2 => {
let seat = usize::from(Row::ALL[menu.selected] == Row::InputP2);
settings.seat_input[seat] = settings.seat_input[seat].cycled(right);
}
Row::CommitKeys => settings.ijkl_commits = !settings.ijkl_commits,
Row::RepeatDelay => {
settings.repeat_delay = f32::clamp(settings.repeat_delay + step * 0.05, 0.1, 0.5);
}
Row::RepeatRate => {
settings.repeat_interval =
f32::clamp(settings.repeat_interval + step * 0.02, 0.03, 0.2);
}
Row::Music => {
let volume = i32::from(settings.music_volume) + if right { 10 } else { -10 };
settings.music_volume = volume.clamp(0, 100) as u8;
}
Row::Sfx => {
let volume = i32::from(settings.sfx_volume) + if right { 10 } else { -10 };
settings.sfx_volume = volume.clamp(0, 100) as u8;
}
Row::VersusMode => settings.team_mode = settings.team_mode.cycled(right),
Row::Speed => {
settings.puzzle_speed = match (settings.puzzle_speed, right) {
(100, false) | (50, true) => 75,
(75, false) => 50,
(75, true) => 100,
(other, _) => other,
};
}
Row::Rumble => settings.rumble = !settings.rumble,
Row::Deadzone => {
let dz = i32::from(settings.pad_deadzone) + if right { 10 } else { -10 };
settings.pad_deadzone = dz.clamp(20, 80) as u8;
}
Row::Palette => settings.colorblind = !settings.colorblind,
Row::UiScale => {
let scale = i32::from(settings.ui_scale) + if right { 10 } else { -10 };
settings.ui_scale = scale.clamp(i32::from(UI_SCALE_MIN), i32::from(UI_SCALE_MAX)) as u8;
}
Row::ReducedMotion => settings.reduced_motion = !settings.reduced_motion,
Row::ReplayCap => {
let cap = i32::from(settings.replay_cap) + if right { 5 } else { -5 };
settings.replay_cap = cap.clamp(
i32::from(crate::app::settings::REPLAY_CAP_MIN),
i32::from(crate::app::settings::REPLAY_CAP_MAX),
) as u8;
}
Row::KeyBindings | Row::ResetProgress => {}
Row::Language => settings.language = settings.language.cycled(right),
Row::UpdateCheck => settings.check_updates = !settings.check_updates,
}
}
fn has_arrows(row: Row) -> bool {
!matches!(row, Row::KeyBindings | Row::ResetProgress)
}
pub(super) fn row_text(
tr: &crate::app::i18n::Tr,
settings: &GameSettings,
row: Row,
reset: ResetPrompt,
) -> (String, String) {
let on_off = |on: bool| if on { tr.val_on } else { tr.val_off }.to_string();
let (label, value) = match row {
Row::InputP1 | Row::InputP2 => {
let seat = usize::from(row == Row::InputP2);
return (
fill(tr.set_seat_input, &[("n", &(seat + 1).to_string())]),
match settings.seat_input[seat] {
SeatInput::Auto => tr.val_input_auto.to_string(),
SeatInput::Keys => tr.val_input_keys.to_string(),
SeatInput::Pad(n) => fill(tr.val_input_pad, &[("n", &(n + 1).to_string())]),
},
);
}
Row::CommitKeys => (
tr.set_commit_keys,
if settings.ijkl_commits {
tr.val_ijkl.to_string()
} else {
tr.val_arrows.to_string()
},
),
Row::RepeatDelay => (
tr.set_repeat_delay,
format!("{:.2}s", settings.repeat_delay),
),
Row::RepeatRate => (
tr.set_repeat_rate,
format!("{:.2}s", settings.repeat_interval),
),
Row::Music => (tr.set_music, format!("{}%", settings.music_volume)),
Row::Sfx => (tr.set_sfx, format!("{}%", settings.sfx_volume)),
Row::Speed => (tr.set_speed, format!("{}%", settings.puzzle_speed)),
Row::VersusMode => (
tr.set_versus_mode,
tr.team_modes[settings.team_mode.index()].to_string(),
),
Row::Rumble => (tr.set_rumble, on_off(settings.rumble)),
Row::Deadzone => (tr.set_deadzone, format!("{}%", settings.pad_deadzone)),
Row::Palette => (
tr.set_palette,
if settings.colorblind {
tr.val_palette_safe
} else {
tr.val_palette_classic
}
.to_string(),
),
Row::UiScale => (tr.set_ui_scale, format!("{}%", settings.ui_scale)),
Row::ReducedMotion => (tr.set_reduced_motion, on_off(settings.reduced_motion)),
Row::ReplayCap => (tr.set_replay_cap, settings.replay_cap.to_string()),
Row::Language => (tr.set_language, settings.language.native_name().to_string()),
Row::UpdateCheck => (tr.set_update_check, on_off(settings.check_updates)),
Row::KeyBindings => {
return (tr.set_key_bindings.to_string(), tr.val_open.to_string());
}
Row::ResetProgress => {
let state = match reset {
ResetPrompt::Idle => tr.val_reset,
ResetPrompt::Armed => tr.val_reset_confirm,
ResetPrompt::Done => tr.val_reset_done,
};
return (tr.set_reset_progress.to_string(), state.to_string());
}
};
(label.to_string(), value)
}
pub fn update_settings_ui(
settings: Res<GameSettings>,
menu: Res<SettingsMenu>,
art: Res<crate::app::art::Art>,
mut cells: Query<(&SettingsCell, &mut Text, &mut TextColor)>,
mut rows: Query<(&SettingsRow, &mut BackgroundColor)>,
mut flags: Query<&mut ImageNode, With<LanguageFlag>>,
) {
let tr = settings.tr();
let wanted = art.flag(settings.language);
for mut flag in &mut flags {
if flag.image != wanted {
flag.image = wanted.clone();
}
}
for (cell, mut text, mut color) in &mut cells {
let row = Row::ALL[cell.0];
let picked = cell.0 == menu.selected;
let (label, value) = row_text(tr, &settings, row, menu.reset);
let line = match cell.1 {
Half::Label => label,
Half::Value if picked && has_arrows(row) => format!("< {value} >"),
Half::Value => value,
};
let target = match (cell.1, picked) {
(Half::Label, true) => Color::WHITE,
(Half::Label, false) => palette::PARCHMENT.with_alpha(0.62),
(Half::Value, true) => palette::GOLD,
(Half::Value, false) => palette::PARCHMENT.with_alpha(0.92),
};
menu_ui::set_text(&mut text, &line);
menu_ui::set_color(&mut color, target);
}
for (row, mut fill) in &mut rows {
let ground = if row.0 == menu.selected {
palette::GOLD.with_alpha(0.16)
} else {
Color::NONE
};
if fill.0 != ground {
fill.0 = ground;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::i18n::EN;
#[test]
fn the_sections_are_the_row_order() {
let flat: Vec<Row> = SECTIONS
.iter()
.flat_map(|column| column.iter())
.flat_map(|(_, rows)| rows.iter().copied())
.collect();
assert_eq!(flat, Row::ALL.to_vec());
}
#[test]
fn every_settings_row_reads_as_a_setting() {
for lang in crate::app::i18n::ALL_LANGS {
let settings = GameSettings {
language: lang,
..GameSettings::default()
};
for row in Row::ALL {
let (label, value) = row_text(settings.tr(), &settings, row, ResetPrompt::Idle);
assert!(!label.trim().is_empty(), "{row:?} in {lang:?} has no name");
assert!(!value.trim().is_empty(), "{row:?} in {lang:?} has no value");
}
}
}
#[test]
fn every_row_fits_its_cell_in_every_language() {
use crate::app::i18n::metrics::text_px;
for lang in crate::app::i18n::ALL_LANGS {
let settings = GameSettings {
language: lang,
seat_input: [SeatInput::Pad(9); crate::app::binds::BOUND_SEATS],
music_volume: 100,
sfx_volume: 100,
puzzle_speed: 100,
pad_deadzone: 100,
ui_scale: 150,
replay_cap: 99,
colorblind: true,
ijkl_commits: true,
rumble: true,
reduced_motion: true,
check_updates: true,
repeat_delay: 0.88,
repeat_interval: 0.88,
..GameSettings::default()
};
let tr = settings.tr();
for row in Row::ALL {
for reset in [ResetPrompt::Idle, ResetPrompt::Armed, ResetPrompt::Done] {
for team_mode in crate::app::teams::TeamMode::ALL {
let settings = GameSettings {
team_mode,
..settings.clone()
};
let (label, value) = row_text(tr, &settings, row, reset);
let label_w = text_px(&label, ROW_FONT);
assert!(
label_w <= LABEL_W,
"{row:?} in {lang:?}: label {label:?} is {label_w:.1}px, \
and the cell holds {LABEL_W}"
);
let decorated = if has_arrows(row) {
format!("< {value} >")
} else {
value
};
let chip = if row == Row::Language {
FLAG_W + FLAG_GAP
} else {
0.0
};
let value_w = text_px(&decorated, ROW_FONT) + chip;
assert!(
value_w <= VALUE_W,
"{row:?} in {lang:?}: value {decorated:?} is {value_w:.1}px, \
and the cell holds {VALUE_W}"
);
}
}
}
}
}
#[test]
fn the_card_fits_the_window_it_was_drawn_for() {
let column = LABEL_W + VALUE_W + 2.0 * 10.0;
let card = 2.0 * column + 28.0 + 2.0 * 22.0 + 2.0 * 1.0;
assert!(
card <= crate::app::settings::DESIGN_W,
"the settings card is {card}px of the {}px it is allowed",
crate::app::settings::DESIGN_W
);
}
#[test]
fn a_dial_shows_the_value_it_is_set_to() {
let mut settings = GameSettings {
music_volume: 42,
..GameSettings::default()
};
let at = |s: &GameSettings, row| row_text(&EN, s, row, ResetPrompt::Idle).1;
assert!(at(&settings, Row::Music).contains("42%"));
settings.ui_scale = 130;
assert!(at(&settings, Row::UiScale).contains("130%"));
settings.colorblind = true;
assert!(at(&settings, Row::Palette).contains(EN.val_palette_safe));
settings.team_mode = crate::app::teams::TeamMode::Trios;
assert!(at(&settings, Row::VersusMode).contains(EN.team_modes[2]));
}
#[test]
fn resetting_progress_asks_twice() {
assert_eq!(next_reset_prompt(ResetPrompt::Idle), ResetPrompt::Armed);
assert_eq!(next_reset_prompt(ResetPrompt::Armed), ResetPrompt::Done);
assert_eq!(next_reset_prompt(ResetPrompt::Done), ResetPrompt::Armed);
let settings = GameSettings::default();
let line = |reset| row_text(&EN, &settings, Row::ResetProgress, reset).1;
assert!(line(ResetPrompt::Idle).contains(EN.val_reset));
assert!(line(ResetPrompt::Armed).contains(EN.val_reset_confirm));
assert!(line(ResetPrompt::Done).contains(EN.val_reset_done));
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
let settings = GameSettings {
language: lang,
..GameSettings::default()
};
let say = |reset| row_text(tr, &settings, Row::ResetProgress, reset).1;
assert_ne!(say(ResetPrompt::Idle), say(ResetPrompt::Armed), "{lang:?}");
assert_ne!(say(ResetPrompt::Armed), say(ResetPrompt::Done), "{lang:?}");
}
}
#[test]
fn arming_the_reset_does_not_leave_the_screen() {
use crate::app::CampaignKind;
use crate::app::progress::Progress;
let mut progress = Progress::default();
progress.mark(CampaignKind::TidePool, "Welcome Ashore");
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<SettingsMenu>();
app.insert_resource(progress);
app.insert_resource(GameSettings::default());
app.add_systems(Update, settings_input);
app.insert_resource(State::new(Screen::Settings));
let reset_row = Row::ALL.iter().position(|&r| r == Row::ResetProgress);
app.world_mut().resource_mut::<SettingsMenu>().selected =
reset_row.expect("the row is in the ladder");
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Enter);
app.update();
assert_eq!(
app.world().resource::<SettingsMenu>().reset,
ResetPrompt::Armed,
"the first press arms it"
);
assert_eq!(
*app.world().resource::<State<Screen>>().get(),
Screen::Settings,
"and does not fall through to leaving the screen"
);
assert!(
app.world()
.resource::<Progress>()
.is_cleared(CampaignKind::TidePool, "Welcome Ashore"),
"arming alone must not clear anything"
);
}
#[test]
fn resetting_progress_clears_both_campaigns() {
use crate::app::CampaignKind;
use crate::app::progress::Progress;
let mut progress = Progress::default();
progress.mark(CampaignKind::TidePool, "Welcome Ashore");
progress.mark(CampaignKind::BeachDay, "First Flood");
progress.clear_all();
assert!(!progress.is_cleared(CampaignKind::TidePool, "Welcome Ashore"));
assert!(!progress.is_cleared(CampaignKind::BeachDay, "First Flood"));
assert!(progress.to_text().is_empty(), "and nothing to write back");
}
}