feat(engine): implement Draw Mode, Theme, and Music Volume settings
Settings panel "coming soon" stubs replaced with live controls: - Draw Mode toggle (Draw 1 / Draw 3): new games read draw_mode from SettingsResource instead of the previous game's mode. Falls back to the current game's mode in headless/test contexts where SettingsPlugin is absent. - Theme selector (Green → Blue → Dark → Green): SettingsChangedEvent drives TablePlugin's background Sprite colour so the table re-colours immediately without a restart. - Music Volume [−]/[+]: dedicated kira sub-tracks created for SFX and music on startup. SFX sounds are routed to the SFX track; the music track exists for future ambient audio. Both volumes are set on SettingsChangedEvent and at startup. Also fixed: time_attack timer_expiry test double-fires when MinimalPlugins time delta is nonzero — removed the intermediate 0.001-remaining update step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ use bevy::prelude::*;
|
|||||||
use kira::manager::backend::DefaultBackend;
|
use kira::manager::backend::DefaultBackend;
|
||||||
use kira::manager::{AudioManager, AudioManagerSettings};
|
use kira::manager::{AudioManager, AudioManagerSettings};
|
||||||
use kira::sound::static_sound::StaticSoundData;
|
use kira::sound::static_sound::StaticSoundData;
|
||||||
|
use kira::track::{TrackBuilder, TrackHandle};
|
||||||
use kira::tween::Tween;
|
use kira::tween::Tween;
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
@@ -44,17 +45,33 @@ pub struct SoundLibrary {
|
|||||||
/// some platforms.
|
/// some platforms.
|
||||||
pub struct AudioState {
|
pub struct AudioState {
|
||||||
manager: Option<AudioManager<DefaultBackend>>,
|
manager: Option<AudioManager<DefaultBackend>>,
|
||||||
|
/// Dedicated sub-track for sound effects. Volume controlled by `sfx_volume`.
|
||||||
|
sfx_track: Option<TrackHandle>,
|
||||||
|
/// Dedicated sub-track for ambient music. Volume controlled by `music_volume`.
|
||||||
|
/// No sounds are currently routed here; the track exists so future ambient
|
||||||
|
/// music can be added without changing the volume architecture.
|
||||||
|
music_track: Option<TrackHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AudioPlugin;
|
pub struct AudioPlugin;
|
||||||
|
|
||||||
impl Plugin for AudioPlugin {
|
impl Plugin for AudioPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
let manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
|
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
|
||||||
if manager.is_none() {
|
if manager.is_none() {
|
||||||
warn!("audio device unavailable; SFX disabled");
|
warn!("audio device unavailable; SFX disabled");
|
||||||
}
|
}
|
||||||
app.insert_non_send_resource(AudioState { manager });
|
|
||||||
|
let (sfx_track, music_track) = match manager.as_mut() {
|
||||||
|
Some(mgr) => {
|
||||||
|
let sfx = mgr.add_sub_track(TrackBuilder::default()).ok();
|
||||||
|
let music = mgr.add_sub_track(TrackBuilder::default()).ok();
|
||||||
|
(sfx, music)
|
||||||
|
}
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
app.insert_non_send_resource(AudioState { manager, sfx_track, music_track });
|
||||||
|
|
||||||
let library = build_library();
|
let library = build_library();
|
||||||
if let Some(lib) = library {
|
if let Some(lib) = library {
|
||||||
@@ -116,26 +133,36 @@ fn play(audio: &mut AudioState, sound: &StaticSoundData) {
|
|||||||
let Some(manager) = audio.manager.as_mut() else {
|
let Some(manager) = audio.manager.as_mut() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Err(e) = manager.play(sound.clone()) {
|
// Route SFX through the dedicated sfx_track so its volume is independent
|
||||||
|
// of the music_track volume.
|
||||||
|
let mut data = sound.clone();
|
||||||
|
if let Some(track) = &audio.sfx_track {
|
||||||
|
data.settings.output_destination = track.id().into();
|
||||||
|
}
|
||||||
|
if let Err(e) = manager.play(data) {
|
||||||
warn!("failed to play SFX: {e}");
|
warn!("failed to play SFX: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_main_track_volume(audio: &mut AudioState, volume: f32) {
|
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
|
||||||
let Some(manager) = audio.manager.as_mut() else {
|
if let Some(track) = audio.sfx_track.as_mut() {
|
||||||
return;
|
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
|
||||||
};
|
}
|
||||||
manager
|
}
|
||||||
.main_track()
|
|
||||||
.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
|
fn set_music_volume(audio: &mut AudioState, volume: f32) {
|
||||||
|
if let Some(track) = audio.music_track.as_mut() {
|
||||||
|
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_initial_volume(
|
fn apply_initial_volume(
|
||||||
mut audio: NonSendMut<AudioState>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
) {
|
) {
|
||||||
let volume = settings.map_or(1.0, |s| s.0.sfx_volume);
|
let (sfx, music) = settings.map_or((1.0, 0.5), |s| (s.0.sfx_volume, s.0.music_volume));
|
||||||
set_main_track_volume(&mut audio, volume);
|
set_sfx_volume(&mut audio, sfx);
|
||||||
|
set_music_volume(&mut audio, music);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_volume_on_change(
|
fn apply_volume_on_change(
|
||||||
@@ -143,7 +170,8 @@ fn apply_volume_on_change(
|
|||||||
mut audio: NonSendMut<AudioState>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
set_main_track_volume(&mut audio, ev.0.sfx_volume);
|
set_sfx_volume(&mut audio, ev.0.sfx_volume);
|
||||||
|
set_music_volume(&mut audio, ev.0.music_volume);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,10 +105,17 @@ fn handle_new_game(
|
|||||||
mut new_game: EventReader<NewGameRequestEvent>,
|
mut new_game: EventReader<NewGameRequestEvent>,
|
||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut changed: EventWriter<StateChangedEvent>,
|
mut changed: EventWriter<StateChangedEvent>,
|
||||||
|
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||||
) {
|
) {
|
||||||
for ev in new_game.read() {
|
for ev in new_game.read() {
|
||||||
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
|
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
|
||||||
let draw_mode = game.0.draw_mode.clone();
|
// Prefer the draw mode from Settings when starting a fresh game.
|
||||||
|
// Fall back to the current game's draw mode in headless/test contexts
|
||||||
|
// where SettingsPlugin is not installed.
|
||||||
|
let draw_mode = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.0.draw_mode.clone())
|
||||||
|
.unwrap_or_else(|| game.0.draw_mode.clone());
|
||||||
let mode = ev.mode.unwrap_or(game.0.mode);
|
let mode = ev.mode.unwrap_or(game.0.mode);
|
||||||
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
|
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
|
||||||
changed.send(StateChangedEvent);
|
changed.send(StateChangedEvent);
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, Settings};
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, Settings};
|
||||||
|
|
||||||
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
||||||
pub const SFX_STEP: f32 = 0.1;
|
pub const SFX_STEP: f32 = 0.1;
|
||||||
@@ -37,15 +38,31 @@ pub struct SettingsChangedEvent(pub Settings);
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct SettingsPanel;
|
struct SettingsPanel;
|
||||||
|
|
||||||
/// Marks the `Text` node that displays the live SFX volume value.
|
/// Marks the `Text` node showing the live SFX volume value.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct SfxVolumeText;
|
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;
|
||||||
|
|
||||||
/// Tags interactive buttons inside the Settings panel.
|
/// Tags interactive buttons inside the Settings panel.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
enum SettingsButton {
|
enum SettingsButton {
|
||||||
SfxDown,
|
SfxDown,
|
||||||
SfxUp,
|
SfxUp,
|
||||||
|
MusicDown,
|
||||||
|
MusicUp,
|
||||||
|
ToggleDrawMode,
|
||||||
|
ToggleTheme,
|
||||||
Done,
|
Done,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,13 +188,17 @@ fn sync_settings_panel_visibility(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reacts to button presses inside the Settings panel.
|
/// Reacts to button presses inside the Settings panel.
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||||
fn handle_settings_buttons(
|
fn handle_settings_buttons(
|
||||||
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||||
mut settings: ResMut<SettingsResource>,
|
mut settings: ResMut<SettingsResource>,
|
||||||
mut screen: ResMut<SettingsScreen>,
|
mut screen: ResMut<SettingsScreen>,
|
||||||
path: Res<SettingsStoragePath>,
|
path: Res<SettingsStoragePath>,
|
||||||
mut changed: EventWriter<SettingsChangedEvent>,
|
mut changed: EventWriter<SettingsChangedEvent>,
|
||||||
mut volume_text: Query<&mut Text, With<SfxVolumeText>>,
|
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
|
||||||
|
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
|
||||||
|
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>)>,
|
||||||
|
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>)>,
|
||||||
) {
|
) {
|
||||||
for (interaction, button) in &interaction_query {
|
for (interaction, button) in &interaction_query {
|
||||||
if *interaction != Interaction::Pressed {
|
if *interaction != Interaction::Pressed {
|
||||||
@@ -190,8 +211,8 @@ fn handle_settings_buttons(
|
|||||||
if (before - after).abs() > f32::EPSILON {
|
if (before - after).abs() > f32::EPSILON {
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut text) = volume_text.get_single_mut() {
|
if let Ok(mut t) = sfx_text.get_single_mut() {
|
||||||
**text = format!("{:.2}", after);
|
**t = format!("{:.2}", after);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,11 +222,56 @@ fn handle_settings_buttons(
|
|||||||
if (before - after).abs() > f32::EPSILON {
|
if (before - after).abs() > f32::EPSILON {
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut text) = volume_text.get_single_mut() {
|
if let Ok(mut t) = sfx_text.get_single_mut() {
|
||||||
**text = format!("{:.2}", after);
|
**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::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::Done => {
|
SettingsButton::Done => {
|
||||||
screen.0 = false;
|
screen.0 = false;
|
||||||
}
|
}
|
||||||
@@ -213,6 +279,21 @@ fn handle_settings_buttons(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_mode_label(mode: &DrawMode) -> String {
|
||||||
|
match mode {
|
||||||
|
DrawMode::DrawOne => "Draw 1".into(),
|
||||||
|
DrawMode::DrawThree => "Draw 3".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_label(theme: &Theme) -> String {
|
||||||
|
match theme {
|
||||||
|
Theme::Green => "Green".into(),
|
||||||
|
Theme::Blue => "Blue".into(),
|
||||||
|
Theme::Dark => "Dark".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// UI construction
|
// UI construction
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -262,7 +343,18 @@ fn spawn_settings_panel(commands: &mut Commands, settings: &Settings) {
|
|||||||
// --- Audio section ---
|
// --- Audio section ---
|
||||||
section_label(card, "Audio");
|
section_label(card, "Audio");
|
||||||
|
|
||||||
// SFX volume row: label | value | [−] | [+]
|
// 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 {
|
card.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
@@ -271,39 +363,43 @@ fn spawn_settings_panel(commands: &mut Commands, settings: &Settings) {
|
|||||||
})
|
})
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
row.spawn((
|
row.spawn((
|
||||||
Text::new("SFX Volume"),
|
Text::new("Draw Mode"),
|
||||||
TextFont {
|
TextFont { font_size: 18.0, ..default() },
|
||||||
font_size: 18.0,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||||
));
|
));
|
||||||
row.spawn((
|
row.spawn((
|
||||||
SfxVolumeText,
|
DrawModeText,
|
||||||
Text::new(format!("{:.2}", settings.sfx_volume)),
|
Text::new(draw_mode_label(&settings.draw_mode)),
|
||||||
TextFont {
|
TextFont { font_size: 18.0, ..default() },
|
||||||
font_size: 18.0,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(Color::WHITE),
|
TextColor(Color::WHITE),
|
||||||
));
|
));
|
||||||
icon_button(row, "−", SettingsButton::SfxDown);
|
icon_button(row, "⇄", SettingsButton::ToggleDrawMode);
|
||||||
icon_button(row, "+", SettingsButton::SfxUp);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
coming_soon_row(card, "Music Volume");
|
|
||||||
|
|
||||||
// --- Gameplay section ---
|
|
||||||
section_label(card, "Gameplay");
|
|
||||||
coming_soon_row(card, "Draw Mode");
|
|
||||||
|
|
||||||
// --- Appearance section ---
|
// --- Appearance section ---
|
||||||
section_label(card, "Appearance");
|
section_label(card, "Appearance");
|
||||||
coming_soon_row(card, "Theme");
|
|
||||||
|
|
||||||
// --- Sync section ---
|
// Theme row
|
||||||
section_label(card, "Sync");
|
card.spawn(Node {
|
||||||
coming_soon_row(card, "Sync Backend");
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
// Done button
|
// Done button
|
||||||
card.spawn((
|
card.spawn((
|
||||||
@@ -343,15 +439,37 @@ fn section_label(parent: &mut ChildBuilder, title: &str) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn coming_soon_row(parent: &mut ChildBuilder, label: &str) {
|
/// Generic volume row: `Label 0.80 [−] [+]`
|
||||||
parent.spawn((
|
fn volume_row<Marker: Component>(
|
||||||
Text::new(format!("{label} — coming soon")),
|
parent: &mut ChildBuilder,
|
||||||
TextFont {
|
label: &str,
|
||||||
font_size: 16.0,
|
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()
|
..default()
|
||||||
},
|
})
|
||||||
TextColor(Color::srgb(0.45, 0.45, 0.45)),
|
.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) {
|
fn icon_button(parent: &mut ChildBuilder, label: &str, action: SettingsButton) {
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ fn start_pull(
|
|||||||
/// 4. Sets [`SyncStatusResource`] to [`SyncStatus::LastSynced`].
|
/// 4. Sets [`SyncStatusResource`] to [`SyncStatus::LastSynced`].
|
||||||
///
|
///
|
||||||
/// On failure, sets [`SyncStatusResource`] to [`SyncStatus::Error`].
|
/// On failure, sets [`SyncStatusResource`] to [`SyncStatus::Error`].
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn poll_pull_result(
|
fn poll_pull_result(
|
||||||
mut task_res: ResMut<PullTask>,
|
mut task_res: ResMut<PullTask>,
|
||||||
mut status: ResMut<SyncStatusResource>,
|
mut status: ResMut<SyncStatusResource>,
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ use bevy::prelude::*;
|
|||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::card::Suit;
|
use solitaire_core::card::Suit;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
use solitaire_data::settings::Theme;
|
||||||
|
|
||||||
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
|
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
|
||||||
|
use crate::settings_plugin::SettingsChangedEvent;
|
||||||
|
|
||||||
/// Z-depth used for the background — below everything.
|
/// Z-depth used for the background — below everything.
|
||||||
const Z_BACKGROUND: f32 = -10.0;
|
const Z_BACKGROUND: f32 = -10.0;
|
||||||
@@ -34,8 +36,18 @@ impl Plugin for TablePlugin {
|
|||||||
// tests. Under DefaultPlugins, bevy_window has already registered it
|
// tests. Under DefaultPlugins, bevy_window has already registered it
|
||||||
// and this call is a no-op.
|
// and this call is a no-op.
|
||||||
app.add_event::<WindowResized>()
|
app.add_event::<WindowResized>()
|
||||||
|
.add_event::<SettingsChangedEvent>()
|
||||||
.add_systems(Startup, setup_table)
|
.add_systems(Startup, setup_table)
|
||||||
.add_systems(Update, on_window_resized);
|
.add_systems(Update, (on_window_resized, apply_theme_on_settings_change));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the felt colour for a given theme.
|
||||||
|
fn theme_colour(theme: &Theme) -> Color {
|
||||||
|
match theme {
|
||||||
|
Theme::Green => Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]),
|
||||||
|
Theme::Blue => Color::srgb(0.059, 0.196, 0.322),
|
||||||
|
Theme::Dark => Color::srgb(0.08, 0.08, 0.10),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +59,7 @@ fn setup_table(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
existing_camera: Query<(), With<Camera>>,
|
existing_camera: Query<(), With<Camera>>,
|
||||||
|
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||||
) {
|
) {
|
||||||
// Only spawn a camera if one does not already exist (e.g. a parent app
|
// Only spawn a camera if one does not already exist (e.g. a parent app
|
||||||
// may have added one in tests).
|
// may have added one in tests).
|
||||||
@@ -61,18 +74,23 @@ fn setup_table(
|
|||||||
.unwrap_or(Vec2::new(1280.0, 800.0));
|
.unwrap_or(Vec2::new(1280.0, 800.0));
|
||||||
let layout = compute_layout(window_size);
|
let layout = compute_layout(window_size);
|
||||||
|
|
||||||
spawn_background(&mut commands, window_size);
|
let initial_colour = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| theme_colour(&s.0.theme))
|
||||||
|
.unwrap_or_else(|| Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]));
|
||||||
|
|
||||||
|
spawn_background(&mut commands, window_size, initial_colour);
|
||||||
spawn_pile_markers(&mut commands, &layout);
|
spawn_pile_markers(&mut commands, &layout);
|
||||||
commands.insert_resource(LayoutResource(layout));
|
commands.insert_resource(LayoutResource(layout));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_background(commands: &mut Commands, window_size: Vec2) {
|
fn spawn_background(commands: &mut Commands, window_size: Vec2, color: Color) {
|
||||||
// Spawn a felt-coloured rectangle that always covers the window. We give
|
// Spawn a felt-coloured rectangle that always covers the window. We give
|
||||||
// it the window size plus headroom so resizing up doesn't expose edges
|
// it the window size plus headroom so resizing up doesn't expose edges
|
||||||
// before the resize handler runs.
|
// before the resize handler runs.
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
Sprite {
|
Sprite {
|
||||||
color: Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]),
|
color,
|
||||||
custom_size: Some(window_size * 2.0),
|
custom_size: Some(window_size * 2.0),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
@@ -81,6 +99,19 @@ fn spawn_background(commands: &mut Commands, window_size: Vec2) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_theme_on_settings_change(
|
||||||
|
mut events: EventReader<SettingsChangedEvent>,
|
||||||
|
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
|
||||||
|
) {
|
||||||
|
let Some(ev) = events.read().last() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let colour = theme_colour(&ev.0.theme);
|
||||||
|
for mut sprite in &mut backgrounds {
|
||||||
|
sprite.color = colour;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||||
let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||||
let marker_size = layout.card_size;
|
let marker_size = layout.card_size;
|
||||||
|
|||||||
@@ -178,14 +178,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn timer_expiry_fires_ended_event_and_clears_active() {
|
fn timer_expiry_fires_ended_event_and_clears_active() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Manually start a near-expired session.
|
// Set the session to an already-expired state (remaining < 0).
|
||||||
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
// MinimalPlugins time delta is nonzero so we skip the intermediate
|
||||||
active: true,
|
// 0.001-remaining step to avoid a double-fire.
|
||||||
remaining_secs: 0.001,
|
|
||||||
wins: 5,
|
|
||||||
};
|
|
||||||
app.update();
|
|
||||||
// First update advances time slightly; force the timer past zero.
|
|
||||||
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
||||||
active: true,
|
active: true,
|
||||||
remaining_secs: -1.0,
|
remaining_secs: -1.0,
|
||||||
|
|||||||
Reference in New Issue
Block a user