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:
@@ -32,6 +32,11 @@ pub struct AchievementContext {
|
||||
|
||||
/// Local hour (0–23) 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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user