feat(engine): wire AnimSpeed to animation, new achievements, leaderboard opt-in, daily goal display

- AnimSpeed setting now drives card slide duration (Normal=0.15s, Fast=0.07s, Instant=snap);
  EffectiveSlideDuration resource updated on SettingsChangedEvent; AnimSpeed row added to Settings panel
- GameState.recycle_count tracks waste recycles; perfectionist/comeback/zen_winner achievements added
  with full unit tests
- SyncProvider gains opt_in_leaderboard(); SolitaireServerClient implements POST /api/leaderboard/opt-in;
  Opt In button added to leaderboard panel
- DailyChallengeResource stores goal_description/target_score/max_time_secs from server;
  pressing C shows goal description as toast (DailyGoalAnnouncementEvent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-27 01:38:25 +00:00
parent bd48813900
commit f579b96d76
10 changed files with 453 additions and 19 deletions
+82
View File
@@ -32,6 +32,11 @@ pub struct AchievementContext {
/// Local hour (023) at the time of win. `None` if unknown.
pub wall_clock_hour: Option<u32>,
/// Number of times waste was recycled back to stock during the won game.
pub last_win_recycle_count: u32,
/// `true` if the game was played in Zen mode.
pub last_win_is_zen: bool,
}
/// Reward granted when an achievement is first unlocked.
@@ -118,6 +123,15 @@ fn speed_and_skill(c: &AchievementContext) -> bool {
fn daily_devotee(c: &AchievementContext) -> bool {
c.daily_challenge_streak >= 7
}
fn perfectionist(c: &AchievementContext) -> bool {
!c.last_win_used_undo && c.last_win_score >= 5_000
}
fn comeback(c: &AchievementContext) -> bool {
c.last_win_recycle_count >= 3
}
fn zen_winner(c: &AchievementContext) -> bool {
c.last_win_is_zen
}
/// All currently-evaluable achievements. Order is stable so persistence files
/// remain readable across versions (new achievements append).
@@ -242,6 +256,30 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
reward: Some(Reward::Background(3)),
condition: daily_devotee,
},
AchievementDef {
id: "perfectionist",
name: "Perfectionist",
description: "Win without undo and score at least 5,000",
secret: false,
reward: Some(Reward::Badge),
condition: perfectionist,
},
AchievementDef {
id: "comeback",
name: "???",
description: "A secret achievement",
secret: true,
reward: Some(Reward::Background(4)),
condition: comeback,
},
AchievementDef {
id: "zen_winner",
name: "???",
description: "A secret achievement",
secret: true,
reward: Some(Reward::Badge),
condition: zen_winner,
},
];
/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
@@ -274,6 +312,8 @@ mod tests {
last_win_time_seconds: u64::MAX,
last_win_used_undo: true,
wall_clock_hour: None,
last_win_recycle_count: 0,
last_win_is_zen: false,
}
}
@@ -367,6 +407,48 @@ mod tests {
assert!(ids.contains(&"daily_devotee"));
}
#[test]
fn perfectionist_requires_no_undo_and_high_score() {
let mut c = ctx();
c.last_win_used_undo = false;
c.last_win_score = 5_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"perfectionist"));
c.last_win_used_undo = true;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"perfectionist"));
c.last_win_used_undo = false;
c.last_win_score = 4_999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"perfectionist"));
}
#[test]
fn comeback_requires_at_least_three_recycles() {
let mut c = ctx();
c.last_win_recycle_count = 2;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"comeback"));
c.last_win_recycle_count = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"comeback"));
}
#[test]
fn zen_winner_requires_zen_mode() {
let mut c = ctx();
c.last_win_is_zen = false;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"zen_winner"));
c.last_win_is_zen = true;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"zen_winner"));
}
#[test]
fn achievement_by_id_finds_known_and_returns_none_for_unknown() {
assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win"));
+24
View File
@@ -80,6 +80,10 @@ pub struct GameState {
/// Number of times `undo()` has been successfully invoked this game.
/// Used by achievement conditions like `no_undo`.
pub undo_count: u32,
/// Number of times the waste pile has been recycled back to stock this game.
/// Used by the `comeback` achievement condition.
#[serde(default)]
pub recycle_count: u32,
undo_stack: VecDeque<StateSnapshot>,
}
@@ -116,6 +120,7 @@ impl GameState {
is_won: false,
is_auto_completable: false,
undo_count: 0,
recycle_count: 0,
undo_stack: VecDeque::new(),
}
}
@@ -167,6 +172,7 @@ impl GameState {
card.face_up = false;
stock.cards.push(card);
}
self.recycle_count = self.recycle_count.saturating_add(1);
return Ok(());
}
@@ -481,6 +487,24 @@ mod tests {
assert!(g.piles[&PileType::Waste].cards.is_empty());
}
#[test]
fn recycle_count_increments_on_each_waste_recycle() {
let mut g = new_game();
assert_eq!(g.recycle_count, 0);
// Drain entire stock to waste.
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
}
g.draw().unwrap(); // first recycle
assert_eq!(g.recycle_count, 1);
// Drain again and recycle a second time.
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
}
g.draw().unwrap(); // second recycle
assert_eq!(g.recycle_count, 2);
}
#[test]
fn draw_from_empty_stock_and_waste_returns_error() {
// The only stop condition for draw() is: both stock AND waste are
+8
View File
@@ -41,6 +41,11 @@ pub trait SyncProvider: Send + Sync {
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError> {
Ok(None)
}
/// Opt the authenticated player into the leaderboard with the given
/// display name. No-op for backends that don't support leaderboards.
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError> {
Ok(())
}
}
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
@@ -68,6 +73,9 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError> {
(**self).fetch_daily_challenge().await
}
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
(**self).opt_in_leaderboard(display_name).await
}
}
pub mod stats;
+36
View File
@@ -225,6 +225,42 @@ impl SyncProvider for SolitaireServerClient {
}
}
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
let token = self.access_token()?;
let url = format!("{}/api/leaderboard/opt-in", self.base_url);
let resp = self
.client
.post(&url)
.bearer_auth(&token)
.json(&serde_json::json!({ "display_name": display_name }))
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
.post(&url)
.bearer_auth(new_token)
.json(&serde_json::json!({ "display_name": display_name }))
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("opt-in failed: {}", resp.status())));
}
return Ok(());
}
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("opt-in failed: {}", resp.status())));
}
Ok(())
}
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
let token = self.access_token()?;
let url = format!("{}/api/leaderboard", self.base_url);
@@ -114,6 +114,8 @@ fn evaluate_on_win(
last_win_time_seconds: ev.time_seconds,
last_win_used_undo: game.0.undo_count > 0,
wall_clock_hour: Some(Local::now().hour()),
last_win_recycle_count: game.0.recycle_count,
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
};
let hits = check_achievements(&ctx);
+56 -3
View File
@@ -4,23 +4,44 @@
//! it directly when adding animations outside this file.
use bevy::prelude::*;
use solitaire_data::AnimSpeed;
use crate::achievement_plugin::display_name_for;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_plugin::CardEntity;
use crate::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::DailyChallengeCompletedEvent;
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
use crate::progress_plugin::LevelUpEvent;
use crate::settings_plugin::SettingsChangedEvent;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
/// Duration of a card slide (move) animation in seconds.
/// Duration of a card slide (move) animation in seconds at Normal speed.
pub const SLIDE_SECS: f32 = 0.15;
/// The effective slide duration, updated whenever `Settings::animation_speed` changes.
#[derive(Resource, Debug, Clone, Copy)]
pub struct EffectiveSlideDuration {
pub slide_secs: f32,
}
impl Default for EffectiveSlideDuration {
fn default() -> Self {
Self { slide_secs: SLIDE_SECS }
}
}
fn anim_speed_to_secs(speed: &AnimSpeed) -> f32 {
match speed {
AnimSpeed::Normal => SLIDE_SECS,
AnimSpeed::Fast => 0.07,
AnimSpeed::Instant => 0.0,
}
}
const WIN_TOAST_SECS: f32 = 4.0;
const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
const LEVELUP_TOAST_SECS: f32 = 3.0;
@@ -65,17 +86,22 @@ impl Plugin for AnimationPlugin {
.add_event::<AchievementUnlockedEvent>()
.add_event::<LevelUpEvent>()
.add_event::<DailyChallengeCompletedEvent>()
.add_event::<DailyGoalAnnouncementEvent>()
.add_event::<WeeklyGoalCompletedEvent>()
.add_event::<TimeAttackEndedEvent>()
.add_event::<ChallengeAdvancedEvent>()
.add_event::<SettingsChangedEvent>()
.init_resource::<EffectiveSlideDuration>()
.add_systems(Startup, init_slide_duration)
.add_systems(
Update,
(
advance_card_anims,
sync_slide_duration,
handle_win_cascade,
handle_achievement_toast,
handle_levelup_toast,
handle_daily_goal_announcement_toast,
handle_daily_toast,
handle_weekly_toast,
handle_time_attack_toast,
@@ -89,6 +115,24 @@ impl Plugin for AnimationPlugin {
}
}
fn init_slide_duration(
settings: Option<Res<SettingsResource>>,
mut dur: ResMut<EffectiveSlideDuration>,
) {
if let Some(s) = settings {
dur.slide_secs = anim_speed_to_secs(&s.0.animation_speed);
}
}
fn sync_slide_duration(
mut events: EventReader<SettingsChangedEvent>,
mut dur: ResMut<EffectiveSlideDuration>,
) {
for ev in events.read() {
dur.slide_secs = anim_speed_to_secs(&ev.0.animation_speed);
}
}
fn advance_card_anims(
mut commands: Commands,
time: Res<Time>,
@@ -169,6 +213,15 @@ fn handle_levelup_toast(mut commands: Commands, mut events: EventReader<LevelUpE
}
}
fn handle_daily_goal_announcement_toast(
mut commands: Commands,
mut events: EventReader<DailyGoalAnnouncementEvent>,
) {
for ev in events.read() {
spawn_toast(&mut commands, format!("Goal: {}", ev.0), DAILY_TOAST_SECS);
}
}
fn handle_daily_toast(
mut commands: Commands,
mut events: EventReader<DailyChallengeCompletedEvent>,
+13 -6
View File
@@ -19,7 +19,7 @@ use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use crate::animation_plugin::{CardAnim, SLIDE_SECS};
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
use crate::events::StateChangedEvent;
use crate::game_plugin::GameMutation;
use crate::layout::{Layout, LayoutResource};
@@ -69,10 +69,12 @@ fn sync_cards_startup(
commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
entities: Query<(Entity, &CardEntity, &Transform)>,
) {
if let Some(layout) = layout {
sync_cards(commands, &game.0, &layout.0, &entities);
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
sync_cards(commands, &game.0, &layout.0, slide_secs, &entities);
}
}
@@ -81,13 +83,15 @@ fn sync_cards_on_change(
commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
entities: Query<(Entity, &CardEntity, &Transform)>,
) {
if events.read().next().is_none() {
return;
}
if let Some(layout) = layout {
sync_cards(commands, &game.0, &layout.0, &entities);
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
sync_cards(commands, &game.0, &layout.0, slide_secs, &entities);
}
}
@@ -95,6 +99,7 @@ fn sync_cards(
mut commands: Commands,
game: &GameState,
layout: &Layout,
slide_secs: f32,
entities: &Query<(Entity, &CardEntity, &Transform)>,
) {
let positions = card_positions(game, layout);
@@ -118,7 +123,7 @@ fn sync_cards(
for (card, position, z) in positions {
match existing.get(&card.id) {
Some(&(entity, cur)) => {
update_card_entity(&mut commands, entity, &card, position, z, layout, cur)
update_card_entity(&mut commands, entity, &card, position, z, layout, slide_secs, cur)
}
None => spawn_card_entity(&mut commands, &card, position, z, layout),
}
@@ -202,6 +207,7 @@ fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, la
});
}
#[allow(clippy::too_many_arguments)]
fn update_card_entity(
commands: &mut Commands,
entity: Entity,
@@ -209,6 +215,7 @@ fn update_card_entity(
pos: Vec2,
z: f32,
layout: &Layout,
slide_secs: f32,
cur: Vec3,
) {
let body_colour = if card.face_up {
@@ -227,7 +234,7 @@ fn update_card_entity(
});
// Slide to the new position when it differs meaningfully; snap otherwise.
if (cur.truncate() - target.truncate()).length() > 1.0 {
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
commands
.entity(entity)
@@ -236,7 +243,7 @@ fn update_card_entity(
start,
target,
elapsed: 0.0,
duration: SLIDE_SECS,
duration: slide_secs,
delay: 0.0,
});
} else {
+84 -4
View File
@@ -27,19 +27,34 @@ use crate::sync_plugin::SyncProviderResource;
/// Bonus XP awarded for completing today's daily challenge.
pub const DAILY_BONUS_XP: u64 = 100;
/// The active daily challenge — date + RNG seed for that date's deal.
#[derive(Resource, Debug, Clone, Copy)]
/// The active daily challenge — date + RNG seed for that date's deal,
/// plus optional goal metadata fetched from the server.
#[derive(Resource, Debug, Clone)]
pub struct DailyChallengeResource {
pub date: NaiveDate,
pub seed: u64,
/// Human-readable goal description from the server, e.g. "Win in under 5 minutes".
pub goal_description: Option<String>,
/// Optional target score the server requires for this challenge.
pub target_score: Option<i32>,
/// Optional time limit in seconds the server imposes.
pub max_time_secs: Option<u64>,
}
/// Fired when the player presses C to start the daily challenge.
/// Carries the current goal description so it can be displayed as a toast.
#[derive(Event, Debug, Clone)]
pub struct DailyGoalAnnouncementEvent(pub String);
impl DailyChallengeResource {
pub fn for_today() -> Self {
let date = Local::now().date_naive();
Self {
date,
seed: daily_seed_for(date),
goal_description: None,
target_score: None,
max_time_secs: None,
}
}
}
@@ -63,6 +78,7 @@ impl Plugin for DailyChallengePlugin {
app.insert_resource(DailyChallengeResource::for_today())
.init_resource::<DailyChallengeTask>()
.add_event::<DailyChallengeCompletedEvent>()
.add_event::<DailyGoalAnnouncementEvent>()
.add_event::<GameWonEvent>()
.add_event::<NewGameRequestEvent>()
.add_systems(Startup, fetch_server_challenge)
@@ -115,9 +131,13 @@ fn poll_server_challenge(
if date == daily.date {
let old_seed = daily.seed;
daily.seed = goal.seed;
daily.goal_description = Some(goal.description.clone());
daily.target_score = goal.target_score;
daily.max_time_secs = goal.max_time_secs;
info!(
"daily challenge seed updated from server: {old_seed} → {}",
goal.seed
"daily challenge seed updated from server: {old_seed} → {} ({})",
goal.seed,
goal.description
);
}
}
@@ -155,12 +175,18 @@ fn handle_start_daily_request(
keys: Res<ButtonInput<KeyCode>>,
daily: Res<DailyChallengeResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut announce: EventWriter<DailyGoalAnnouncementEvent>,
) {
if keys.just_pressed(KeyCode::KeyC) {
new_game.send(NewGameRequestEvent {
seed: Some(daily.seed),
mode: None,
});
let desc = daily
.goal_description
.clone()
.unwrap_or_else(|| "Daily Challenge".to_string());
announce.send(DailyGoalAnnouncementEvent(desc));
}
}
@@ -280,4 +306,58 @@ mod tests {
assert_eq!(fired.len(), 1);
assert_eq!(fired[0].seed, Some(daily_seed));
}
#[test]
fn pressing_c_fires_announcement_event_with_description() {
let mut app = headless_app();
// Inject a goal description.
app.world_mut()
.resource_mut::<DailyChallengeResource>()
.goal_description = Some("Win in under 5 minutes".to_string());
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Events<DailyGoalAnnouncementEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert_eq!(fired.len(), 1);
assert_eq!(fired[0].0, "Win in under 5 minutes");
}
#[test]
fn pressing_c_with_no_description_uses_fallback() {
let mut app = headless_app();
// Ensure no description is set.
assert!(app.world().resource::<DailyChallengeResource>().goal_description.is_none());
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Events<DailyGoalAnnouncementEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert_eq!(fired.len(), 1);
assert_eq!(fired[0].0, "Daily Challenge");
}
#[test]
fn goal_fields_stored_from_server_fetch() {
let mut app = headless_app();
// Simulate what poll_server_challenge does when the server responds.
{
let mut daily = app.world_mut().resource_mut::<DailyChallengeResource>();
daily.goal_description = Some("Win without undo".to_string());
daily.target_score = Some(1_000);
daily.max_time_secs = Some(300);
}
let r = app.world().resource::<DailyChallengeResource>();
assert_eq!(r.goal_description.as_deref(), Some("Win without undo"));
assert_eq!(r.target_score, Some(1_000));
assert_eq!(r.max_time_secs, Some(300));
}
}
+82 -1
View File
@@ -11,8 +11,10 @@
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use solitaire_data::settings::SyncBackend;
use solitaire_sync::LeaderboardEntry;
use crate::settings_plugin::SettingsResource;
use crate::sync_plugin::SyncProviderResource;
// ---------------------------------------------------------------------------
@@ -39,6 +41,14 @@ struct LeaderboardFetchTask(Option<Task<Result<Vec<LeaderboardEntry>, String>>>)
#[derive(Component, Debug)]
pub struct LeaderboardScreen;
/// Marker on the "Opt In" button inside the leaderboard panel.
#[derive(Component, Debug)]
struct LeaderboardOptInButton;
/// In-flight opt-in task.
#[derive(Resource, Default)]
struct OptInTask(Option<Task<Result<(), String>>>);
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
@@ -51,6 +61,7 @@ impl Plugin for LeaderboardPlugin {
.init_resource::<LeaderboardFetchResult>()
.init_resource::<LeaderboardFetchTask>()
.init_resource::<ClosedThisFrame>()
.init_resource::<OptInTask>()
.add_systems(
Update,
(
@@ -58,6 +69,8 @@ impl Plugin for LeaderboardPlugin {
toggle_leaderboard_screen,
poll_leaderboard_fetch,
update_leaderboard_panel,
handle_opt_in_button,
poll_opt_in_task,
)
.chain(),
);
@@ -154,6 +167,52 @@ fn update_leaderboard_panel(
}
}
/// Fires an async opt-in request when the player presses the "Opt In" button.
///
/// The display name is taken from the configured server username in
/// `SettingsResource`. If no server backend is active, the button is a no-op.
fn handle_opt_in_button(
interaction_query: Query<&Interaction, (Changed<Interaction>, With<LeaderboardOptInButton>)>,
settings: Option<Res<SettingsResource>>,
provider: Option<Res<SyncProviderResource>>,
mut task_res: ResMut<OptInTask>,
) {
if task_res.0.is_some() {
return; // already in flight
}
let Some(provider) = provider else { return };
for interaction in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
let display_name = settings
.as_ref()
.and_then(|s| {
if let SyncBackend::SolitaireServer { username, .. } = &s.0.sync_backend {
Some(username.clone())
} else {
None
}
})
.unwrap_or_else(|| "Player".to_string());
let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get()
.spawn(async move { provider.opt_in_leaderboard(&display_name).await.map_err(|e| e.to_string()) });
task_res.0 = Some(task);
}
}
/// Polls the opt-in task; logs on error, clears on completion.
fn poll_opt_in_task(mut task_res: ResMut<OptInTask>) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
task_res.0 = None;
if let Err(e) = result {
warn!("leaderboard opt-in failed: {e}");
}
}
// ---------------------------------------------------------------------------
// UI construction
// ---------------------------------------------------------------------------
@@ -198,7 +257,7 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
TextColor(Color::WHITE),
));
card.spawn((
Text::new("Press L to close"),
Text::new("Press L to close • Opt in to appear on the board"),
TextFont { font_size: 14.0, ..default() },
TextColor(Color::srgb(0.55, 0.55, 0.60)),
));
@@ -213,6 +272,28 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
));
// Opt-in button
card.spawn((
LeaderboardOptInButton,
Button,
Node {
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
justify_content: JustifyContent::Center,
margin: UiRect::bottom(Val::Px(8.0)),
align_self: AlignSelf::FlexStart,
..default()
},
BackgroundColor(Color::srgb(0.18, 0.35, 0.50)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
Text::new("Opt In to Leaderboard"),
TextFont { font_size: 15.0, ..default() },
TextColor(Color::WHITE),
));
});
match entries {
None => {
// Fetch in progress
+66 -5
View File
@@ -13,7 +13,7 @@ use std::path::PathBuf;
use bevy::prelude::*;
use solitaire_core::game_state::DrawMode;
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, Settings};
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;
@@ -66,6 +66,10 @@ struct SyncStatusText;
#[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;
@@ -78,6 +82,7 @@ enum SettingsButton {
MusicDown,
MusicUp,
ToggleDrawMode,
CycleAnimSpeed,
ToggleTheme,
CycleCardBack,
CycleBackground,
@@ -135,6 +140,7 @@ impl Plugin for SettingsPlugin {
update_sync_status_text,
update_card_back_text,
update_background_text,
update_anim_speed_text,
),
);
}
@@ -283,6 +289,18 @@ fn update_background_text(
}
}
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 card_back_label(idx: usize) -> String {
if idx == 0 {
"Default".to_string()
@@ -328,10 +346,11 @@ fn handle_settings_buttons(
mut changed: EventWriter<SettingsChangedEvent>,
mut manual_sync: EventWriter<ManualSyncRequestEvent>,
progress: Option<Res<ProgressResource>>,
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>)>,
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<AnimSpeedText>)>,
mut anim_speed_text: Query<&mut Text, (With<AnimSpeedText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
@@ -393,6 +412,18 @@ fn handle_settings_buttons(
**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,
@@ -442,6 +473,14 @@ fn draw_mode_label(mode: &DrawMode) -> String {
}
}
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(),
@@ -538,6 +577,28 @@ fn spawn_settings_panel(
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");