Files
Ferrous-Solitaire/solitaire_engine/src/settings_plugin.rs
T
funman300 f32e53dd0b feat(engine): shake/settle/deal animations (#54, #55, #69)
Add FeedbackAnimPlugin with three card feedback animations:
- #54 ShakeAnim: horizontal shake on MoveRejectedEvent targeting
  destination pile cards; 0.3 s damped sine wave
- #55 SettleAnim: Y-scale bounce on valid placement (StateChangedEvent);
  1.0 → 0.92 → 1.0 over 0.15 s for all top-of-pile cards
- #69 Deal animation: slides each card from stock position to its deal
  position on NewGameRequestEvent (move_count == 0), using existing
  CardAnim with 0.04 s per-card stagger

Pure-function helpers shake_offset, settle_scale, and deal_stagger_delay
are public and covered by 6 unit tests. Fix pre-existing compile/clippy
errors: stubbed handle_confirm_input/handle_game_over_input, removed dead
CycleCardBack/CycleBackground variants, annotated ambient_handle field,
and fixed draw_mode.clone() in pause_plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:55:24 +00:00

1161 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Persists `solitaire_data::Settings`, exposes hotkeys for live tuning,
//! and renders a Bevy UI Settings panel.
//!
//! Hotkeys (always active, no overlay required):
//! - `[` — decrease SFX volume by `SFX_STEP`
//! - `]` — increase SFX volume by `SFX_STEP`
//! - `O` — open / close the Settings panel
//!
//! On change, the plugin persists `settings.json` and fires
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.
use std::path::PathBuf;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use solitaire_core::game_state::DrawMode;
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
use crate::events::ManualSyncRequestEvent;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SyncStatus, SyncStatusResource};
/// Volume adjustment step applied by the `[` / `]` hotkeys.
pub const SFX_STEP: f32 = 0.1;
/// Bevy resource wrapping the current `Settings`.
#[derive(Resource, Debug, Clone)]
pub struct SettingsResource(pub Settings);
/// Persistence path for `SettingsResource`. `None` disables I/O (used in tests).
#[derive(Resource, Debug, Clone)]
pub struct SettingsStoragePath(pub Option<PathBuf>);
/// Whether the Settings panel is currently visible. Toggle with `O`.
#[derive(Resource, Debug, Clone, Default)]
pub struct SettingsScreen(pub bool);
/// Fired whenever settings change so consumers (audio, UI) can react.
#[derive(Event, Debug, Clone)]
pub struct SettingsChangedEvent(pub Settings);
/// Marker on the root Settings panel entity.
#[derive(Component, Debug)]
struct SettingsPanel;
/// Marks the `Text` node showing the live SFX volume value.
#[derive(Component, Debug)]
struct SfxVolumeText;
/// Marks the `Text` node showing the live music volume value.
#[derive(Component, Debug)]
struct MusicVolumeText;
/// Marks the `Text` node showing the current draw mode.
#[derive(Component, Debug)]
struct DrawModeText;
/// Marks the `Text` node showing the current theme.
#[derive(Component, Debug)]
struct ThemeText;
/// Marks the `Text` node showing the live sync status.
#[derive(Component, Debug)]
struct SyncStatusText;
/// Marks the `Text` node showing the active card-back index.
#[derive(Component, Debug)]
struct CardBackText;
/// Marks the `Text` node showing the current animation speed.
#[derive(Component, Debug)]
struct AnimSpeedText;
/// Marks the `Text` node showing the active background index.
#[derive(Component, Debug)]
struct BackgroundText;
/// Marks the `Text` node showing the current color-blind mode state.
#[derive(Component, Debug)]
struct ColorBlindText;
/// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)]
struct SettingsPanelScrollable;
/// Tags interactive buttons inside the Settings panel.
#[derive(Component, Debug)]
enum SettingsButton {
SfxDown,
SfxUp,
MusicDown,
MusicUp,
ToggleDrawMode,
CycleAnimSpeed,
ToggleTheme,
ToggleColorBlind,
SyncNow,
Done,
/// Select a specific card-back by index from the picker row.
SelectCardBack(usize),
/// Select a specific background by index from the picker row.
SelectBackground(usize),
}
/// Plugin that owns the settings lifecycle.
pub struct SettingsPlugin {
/// Path to `settings.json`. `None` in headless/test mode.
pub storage_path: Option<PathBuf>,
/// When `false`, panel spawn/despawn systems are not registered.
/// Use [`SettingsPlugin::headless`] for tests running under `MinimalPlugins`.
pub ui_enabled: bool,
}
impl Default for SettingsPlugin {
fn default() -> Self {
Self {
storage_path: settings_file_path(),
ui_enabled: true,
}
}
}
impl SettingsPlugin {
/// No persistence, no UI — safe to use under `MinimalPlugins` in tests.
pub fn headless() -> Self {
Self {
storage_path: None,
ui_enabled: false,
}
}
}
impl Plugin for SettingsPlugin {
fn build(&self, app: &mut App) {
let loaded = match &self.storage_path {
Some(path) => load_settings_from(path),
None => Settings::default(),
};
app.insert_resource(SettingsResource(loaded))
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
.init_resource::<SettingsScreen>()
.add_event::<SettingsChangedEvent>()
.add_event::<ManualSyncRequestEvent>()
.add_event::<bevy::input::mouse::MouseWheel>()
.add_systems(Update, (handle_volume_keys, toggle_settings_screen, scroll_settings_panel));
if self.ui_enabled {
app.add_systems(
Update,
(
sync_settings_panel_visibility,
handle_settings_buttons,
update_sync_status_text,
update_card_back_text,
update_background_text,
update_anim_speed_text,
update_color_blind_text,
),
);
}
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
fn persist(path: &SettingsStoragePath, settings: &Settings) {
let Some(target) = &path.0 else { return };
if let Err(e) = save_settings_to(target, settings) {
warn!("failed to save settings: {e}");
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
fn handle_volume_keys(
keys: Res<ButtonInput<KeyCode>>,
mut settings: ResMut<SettingsResource>,
path: Res<SettingsStoragePath>,
mut changed: EventWriter<SettingsChangedEvent>,
) {
let mut delta = 0.0_f32;
if keys.just_pressed(KeyCode::BracketLeft) {
delta -= SFX_STEP;
}
if keys.just_pressed(KeyCode::BracketRight) {
delta += SFX_STEP;
}
if delta == 0.0 {
return;
}
let before = settings.0.sfx_volume;
let after = settings.0.adjust_sfx_volume(delta);
if (before - after).abs() < f32::EPSILON {
return;
}
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
}
/// Opens or closes the Settings panel when `O` is pressed.
fn toggle_settings_screen(
keys: Res<ButtonInput<KeyCode>>,
mut screen: ResMut<SettingsScreen>,
) {
if keys.just_pressed(KeyCode::KeyO) {
screen.0 = !screen.0;
}
}
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
/// despawns it when it becomes `false`.
fn sync_settings_panel_visibility(
screen: Res<SettingsScreen>,
panels: Query<Entity, With<SettingsPanel>>,
mut commands: Commands,
settings: Res<SettingsResource>,
sync_status: Option<Res<SyncStatusResource>>,
progress: Option<Res<ProgressResource>>,
) {
if !screen.is_changed() {
return;
}
if screen.0 {
if panels.is_empty() {
let status_label = sync_status
.map(|s| sync_status_label(&s.0))
.unwrap_or_else(|| "Status: not configured".to_string());
let unlocked_backs = progress
.as_ref()
.map(|p| p.0.unlocked_card_backs.as_slice())
.unwrap_or(&[0]);
let unlocked_bgs = progress
.as_ref()
.map(|p| p.0.unlocked_backgrounds.as_slice())
.unwrap_or(&[0]);
spawn_settings_panel(
&mut commands,
&settings.0,
&status_label,
unlocked_backs,
unlocked_bgs,
);
}
} else {
for entity in &panels {
commands.entity(entity).despawn_recursive();
}
}
}
/// Returns the next unlocked index after `current` in the sorted `unlocked` list.
/// Wraps around. Falls back to `unlocked[0]` if `current` is not found.
#[cfg(test)]
fn cycle_unlocked(unlocked: &[usize], current: usize) -> usize {
if unlocked.is_empty() {
return 0;
}
let pos = unlocked.iter().position(|&i| i == current).unwrap_or(0);
unlocked[(pos + 1) % unlocked.len()]
}
/// Keeps the sync-status text node current while the panel is open.
fn update_sync_status_text(
sync_status: Option<Res<SyncStatusResource>>,
mut text_nodes: Query<&mut Text, With<SyncStatusText>>,
) {
let Some(status) = sync_status else {
return;
};
if !status.is_changed() {
return;
}
let label = sync_status_label(&status.0);
for mut text in &mut text_nodes {
**text = label.clone();
}
}
fn update_card_back_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<CardBackText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = card_back_label(settings.0.selected_card_back);
}
}
fn update_background_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<BackgroundText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = background_label(settings.0.selected_background);
}
}
fn update_anim_speed_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<AnimSpeedText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = anim_speed_label(&settings.0.animation_speed);
}
}
fn update_color_blind_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<ColorBlindText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = color_blind_label(settings.0.color_blind_mode);
}
}
fn card_back_label(idx: usize) -> String {
if idx == 0 {
"Default".to_string()
} else {
format!("Style {idx}")
}
}
fn background_label(idx: usize) -> String {
if idx == 0 {
"Default".to_string()
} else {
format!("Style {idx}")
}
}
fn sync_status_label(status: &SyncStatus) -> String {
match status {
SyncStatus::Idle => "Status: idle".to_string(),
SyncStatus::Syncing => "Status: syncing…".to_string(),
SyncStatus::LastSynced(t) => {
let secs = chrono::Utc::now()
.signed_duration_since(*t)
.num_seconds()
.max(0);
if secs < 60 {
format!("Last synced: {secs}s ago")
} else {
format!("Last synced: {}m ago", secs / 60)
}
}
SyncStatus::Error(e) => format!("Sync error: {e}"),
}
}
/// Reacts to button presses inside the Settings panel.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn handle_settings_buttons(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut settings: ResMut<SettingsResource>,
mut screen: ResMut<SettingsScreen>,
path: Res<SettingsStoragePath>,
mut changed: EventWriter<SettingsChangedEvent>,
mut manual_sync: EventWriter<ManualSyncRequestEvent>,
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
mut anim_speed_text: Query<&mut Text, (With<AnimSpeedText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<ColorBlindText>)>,
mut color_blind_text: Query<&mut Text, (With<ColorBlindText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
match button {
SettingsButton::SfxDown => {
let before = settings.0.sfx_volume;
let after = settings.0.adjust_sfx_volume(-SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.get_single_mut() {
**t = format!("{:.2}", after);
}
}
}
SettingsButton::SfxUp => {
let before = settings.0.sfx_volume;
let after = settings.0.adjust_sfx_volume(SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.get_single_mut() {
**t = format!("{:.2}", after);
}
}
}
SettingsButton::MusicDown => {
let before = settings.0.music_volume;
let after = settings.0.adjust_music_volume(-SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.get_single_mut() {
**t = format!("{:.2}", after);
}
}
}
SettingsButton::MusicUp => {
let before = settings.0.music_volume;
let after = settings.0.adjust_music_volume(SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.get_single_mut() {
**t = format!("{:.2}", after);
}
}
}
SettingsButton::ToggleDrawMode => {
settings.0.draw_mode = match settings.0.draw_mode {
DrawMode::DrawOne => DrawMode::DrawThree,
DrawMode::DrawThree => DrawMode::DrawOne,
};
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = draw_text.get_single_mut() {
**t = draw_mode_label(&settings.0.draw_mode);
}
}
SettingsButton::CycleAnimSpeed => {
settings.0.animation_speed = match settings.0.animation_speed {
AnimSpeed::Normal => AnimSpeed::Fast,
AnimSpeed::Fast => AnimSpeed::Instant,
AnimSpeed::Instant => AnimSpeed::Normal,
};
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = anim_speed_text.get_single_mut() {
**t = anim_speed_label(&settings.0.animation_speed);
}
}
SettingsButton::ToggleTheme => {
settings.0.theme = match settings.0.theme {
Theme::Green => Theme::Blue,
Theme::Blue => Theme::Dark,
Theme::Dark => Theme::Green,
};
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = theme_text.get_single_mut() {
**t = theme_label(&settings.0.theme);
}
}
SettingsButton::ToggleColorBlind => {
settings.0.color_blind_mode = !settings.0.color_blind_mode;
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = color_blind_text.get_single_mut() {
**t = color_blind_label(settings.0.color_blind_mode);
}
}
SettingsButton::SelectCardBack(idx) => {
settings.0.selected_card_back = *idx;
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
}
SettingsButton::SelectBackground(idx) => {
settings.0.selected_background = *idx;
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
}
SettingsButton::SyncNow => {
manual_sync.send(ManualSyncRequestEvent);
}
SettingsButton::Done => {
screen.0 = false;
}
}
}
}
fn draw_mode_label(mode: &DrawMode) -> String {
match mode {
DrawMode::DrawOne => "Draw 1".into(),
DrawMode::DrawThree => "Draw 3".into(),
}
}
fn anim_speed_label(speed: &AnimSpeed) -> String {
match speed {
AnimSpeed::Normal => "Normal".into(),
AnimSpeed::Fast => "Fast".into(),
AnimSpeed::Instant => "Instant".into(),
}
}
fn theme_label(theme: &Theme) -> String {
match theme {
Theme::Green => "Green".into(),
Theme::Blue => "Blue".into(),
Theme::Dark => "Dark".into(),
}
}
fn color_blind_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() }
}
/// Scrolls the settings panel inner card in response to mouse-wheel events.
///
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
/// scrolls past the top.
fn scroll_settings_panel(
mut scroll_evr: EventReader<MouseWheel>,
screen: Res<SettingsScreen>,
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
) {
if !screen.0 {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.offset_y = (sp.offset_y - delta_y).max(0.0);
}
}
// ---------------------------------------------------------------------------
// UI construction
// ---------------------------------------------------------------------------
fn spawn_settings_panel(
commands: &mut Commands,
settings: &Settings,
sync_status: &str,
unlocked_card_backs: &[usize],
unlocked_backgrounds: &[usize],
) {
commands
.spawn((
SettingsPanel,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.72)),
ZIndex(200),
))
.with_children(|root| {
// Inner card — max_height + scroll_y lets the player reach all rows
// on small windows by scrolling with the mouse wheel.
root.spawn((
SettingsPanelScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(28.0)),
row_gap: Val::Px(14.0),
min_width: Val::Px(340.0),
max_height: Val::Percent(88.0),
overflow: Overflow::scroll_y(),
..default()
},
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
BorderRadius::all(Val::Px(8.0)),
))
.with_children(|card| {
// Title
card.spawn((
Text::new("Settings"),
TextFont {
font_size: 30.0,
..default()
},
TextColor(Color::WHITE),
));
// --- Audio section ---
section_label(card, "Audio");
// SFX volume row
volume_row(card, "SFX Volume", settings.sfx_volume, SfxVolumeText,
SettingsButton::SfxDown, SettingsButton::SfxUp);
// Music volume row
volume_row(card, "Music Volume", settings.music_volume, MusicVolumeText,
SettingsButton::MusicDown, SettingsButton::MusicUp);
// --- Gameplay section ---
section_label(card, "Gameplay");
// Draw mode row
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..default()
})
.with_children(|row| {
row.spawn((
Text::new("Draw Mode"),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.80)),
));
row.spawn((
DrawModeText,
Text::new(draw_mode_label(&settings.draw_mode)),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::WHITE),
));
icon_button(row, "", SettingsButton::ToggleDrawMode);
});
// Animation speed row
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..default()
})
.with_children(|row| {
row.spawn((
Text::new("Anim Speed"),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.80)),
));
row.spawn((
AnimSpeedText,
Text::new(anim_speed_label(&settings.animation_speed)),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::WHITE),
));
icon_button(row, "", SettingsButton::CycleAnimSpeed);
});
// --- Appearance section ---
section_label(card, "Appearance");
// Theme row
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..default()
})
.with_children(|row| {
row.spawn((
Text::new("Theme"),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.80)),
));
row.spawn((
ThemeText,
Text::new(theme_label(&settings.theme)),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::WHITE),
));
icon_button(row, "", SettingsButton::ToggleTheme);
});
// Color-blind mode row
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..default()
})
.with_children(|row| {
row.spawn((
Text::new("Color-blind Mode"),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.80)),
));
row.spawn((
ColorBlindText,
Text::new(color_blind_label(settings.color_blind_mode)),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::WHITE),
));
icon_button(row, "", SettingsButton::ToggleColorBlind);
});
// --- Card Back section ---
section_label(card, "Card Back");
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
flex_wrap: FlexWrap::Wrap,
..default()
})
.with_children(|row| {
// Always show at least button "1" (index 0 = default).
let backs = if unlocked_card_backs.is_empty() {
&[0usize][..]
} else {
unlocked_card_backs
};
for &back_idx in backs {
let is_selected = back_idx == settings.selected_card_back;
let bg_color = if is_selected {
Color::srgb(0.2, 0.9, 0.3)
} else {
Color::srgb(0.25, 0.25, 0.30)
};
row.spawn((
SettingsButton::SelectCardBack(back_idx),
Button,
Node {
width: Val::Px(40.0),
height: Val::Px(40.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(bg_color),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
Text::new(format!("{}", back_idx + 1)),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::WHITE),
));
});
}
});
// --- Background section ---
section_label(card, "Background");
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
flex_wrap: FlexWrap::Wrap,
..default()
})
.with_children(|row| {
// Always show at least button "1" (index 0 = default).
let bgs = if unlocked_backgrounds.is_empty() {
&[0usize][..]
} else {
unlocked_backgrounds
};
for &bg_idx in bgs {
let is_selected = bg_idx == settings.selected_background;
let bg_color = if is_selected {
Color::srgb(0.2, 0.9, 0.3)
} else {
Color::srgb(0.25, 0.25, 0.30)
};
row.spawn((
SettingsButton::SelectBackground(bg_idx),
Button,
Node {
width: Val::Px(40.0),
height: Val::Px(40.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(bg_color),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
Text::new(format!("{}", bg_idx + 1)),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::WHITE),
));
});
}
});
// --- Sync section ---
section_label(card, "Sync");
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(10.0),
..default()
})
.with_children(|row| {
row.spawn((
SyncStatusText,
Text::new(sync_status.to_string()),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.65, 0.65, 0.70)),
));
// "Sync Now" button — hidden when SyncPlugin is not installed;
// visible because ManualSyncRequestEvent is always registered.
row.spawn((
SettingsButton::SyncNow,
Button,
Node {
padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
Text::new("Sync Now"),
TextFont { font_size: 14.0, ..default() },
TextColor(Color::WHITE),
));
});
});
// Done button
card.spawn((
SettingsButton::Done,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), Val::Px(8.0)),
justify_content: JustifyContent::Center,
margin: UiRect::top(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
Text::new("Done"),
TextFont {
font_size: 18.0,
..default()
},
TextColor(Color::WHITE),
));
});
});
});
}
fn section_label(parent: &mut ChildBuilder, title: &str) {
parent.spawn((
Text::new(title),
TextFont {
font_size: 14.0,
..default()
},
TextColor(Color::srgb(0.55, 0.75, 0.55)),
));
}
/// Generic volume row: `Label 0.80 [] [+]`
fn volume_row<Marker: Component>(
parent: &mut ChildBuilder,
label: &str,
value: f32,
marker: Marker,
btn_down: SettingsButton,
btn_up: SettingsButton,
) {
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..default()
})
.with_children(|row| {
row.spawn((
Text::new(label.to_string()),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.80)),
));
row.spawn((
marker,
Text::new(format!("{:.2}", value)),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::WHITE),
));
icon_button(row, "", btn_down);
icon_button(row, "+", btn_up);
});
}
fn icon_button(parent: &mut ChildBuilder, label: &str, action: SettingsButton) {
parent
.spawn((
action,
Button,
Node {
width: Val::Px(28.0),
height: Val::Px(28.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
Text::new(label.to_string()),
TextFont {
font_size: 18.0,
..default()
},
TextColor(Color::WHITE),
));
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(SettingsPlugin::headless());
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn press(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
input.press(key);
}
#[test]
fn defaults_are_loaded() {
let app = headless_app();
assert_eq!(
app.world().resource::<SettingsResource>().0,
Settings::default()
);
}
#[test]
fn pressing_left_bracket_decreases_volume_and_emits_event() {
let mut app = headless_app();
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
press(&mut app, KeyCode::BracketLeft);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(after < before);
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 1);
}
#[test]
fn pressing_right_bracket_increases_volume() {
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.5;
press(&mut app, KeyCode::BracketRight);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!((after - 0.6).abs() < 1e-3);
}
#[test]
fn clamped_change_does_not_emit_event() {
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 1.0;
press(&mut app, KeyCode::BracketRight);
app.update();
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 0);
}
#[test]
fn volume_clamped_at_zero_does_not_emit_event() {
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.0;
press(&mut app, KeyCode::BracketLeft);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(after >= 0.0, "volume must not go below zero");
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 0, "no event when clamped at floor");
}
#[test]
fn pressing_o_toggles_settings_screen_flag() {
let mut app = headless_app();
assert!(!app.world().resource::<SettingsScreen>().0, "screen is closed initially");
press(&mut app, KeyCode::KeyO);
app.update();
assert!(app.world().resource::<SettingsScreen>().0, "O opens settings");
press(&mut app, KeyCode::KeyO);
app.update();
assert!(!app.world().resource::<SettingsScreen>().0, "second O closes settings");
}
// cycle_unlocked pure-function tests
#[test]
fn cycle_unlocked_wraps_at_end() {
// [0, 1, 2] → cycling from 2 wraps to 0
assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0);
}
#[test]
fn cycle_unlocked_advances_normally() {
assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1);
assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2);
}
#[test]
fn cycle_unlocked_single_element_stays() {
// Only one unlockable — cycling always returns it.
assert_eq!(cycle_unlocked(&[0], 0), 0);
}
#[test]
fn cycle_unlocked_current_not_in_list_falls_back_to_second() {
// current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1
assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1);
}
#[test]
fn cycle_unlocked_empty_returns_zero() {
assert_eq!(cycle_unlocked(&[], 0), 0);
}
#[test]
fn scroll_is_noop_when_settings_panel_closed() {
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
let mut app = headless_app();
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
let entity = app
.world_mut()
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
.id();
// Send a downward scroll event while the panel is closed.
app.world_mut().send_event(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -3.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER,
});
app.update();
// ScrollPosition must remain at 0.0 — panel was closed.
let offset = app
.world()
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.offset_y;
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
}
#[test]
fn scroll_moves_offset_when_panel_open() {
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
let mut app = headless_app();
// Open the panel.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
let entity = app
.world_mut()
.spawn((
SettingsPanelScrollable,
ScrollPosition { offset_y: 100.0, ..default() },
))
.id();
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
app.world_mut().send_event(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -2.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER,
});
app.update();
let offset = app
.world()
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.offset_y;
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
}
#[test]
fn scroll_clamps_offset_to_zero_at_top() {
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
// Entity starts at 10 px offset.
let entity = app
.world_mut()
.spawn((
SettingsPanelScrollable,
ScrollPosition { offset_y: 10.0, ..default() },
))
.id();
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
app.world_mut().send_event(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 5.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER,
});
app.update();
let offset = app
.world()
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.offset_y;
assert_eq!(offset, 0.0, "scrolling past top must clamp to 0, got {offset}");
}
}