feat(engine): grant achievement rewards + gate cosmetic selectors

- Add Reward enum to solitaire_core with CardBack/Background/BonusXp/Badge variants
- Wire rewards into ALL_ACHIEVEMENTS per architecture spec
- evaluate_on_win now applies rewards on first unlock: pushes cosmetic
  indices into PlayerProgress, awards BonusXp (with level-up detection),
  and marks reward_granted = true so rewards are never double-granted
- Add selected_card_back / selected_background fields to Settings
- Settings panel grows Card Back and Background cycle rows, shown only
  when the player has unlocked more than the default (index 0)
- cycle_unlocked() cycles only through earned options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-27 01:00:18 +00:00
parent b37fe5b49b
commit 6728a4311f
4 changed files with 250 additions and 8 deletions
+53 -6
View File
@@ -10,15 +10,16 @@ use std::path::PathBuf;
use bevy::prelude::*;
use chrono::{Local, Timelike, Utc};
use solitaire_core::achievement::{
achievement_by_id, check_achievements, AchievementContext, ALL_ACHIEVEMENTS,
achievement_by_id, check_achievements, AchievementContext, Reward, ALL_ACHIEVEMENTS,
};
use solitaire_data::{
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
save_progress_to,
};
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressUpdate};
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource;
use crate::stats_plugin::{StatsResource, StatsUpdate};
@@ -89,11 +90,13 @@ impl Plugin for AchievementPlugin {
fn evaluate_on_win(
mut wins: EventReader<GameWonEvent>,
mut unlocks: EventWriter<AchievementUnlockedEvent>,
mut levelups: EventWriter<LevelUpEvent>,
game: Res<GameStateResource>,
stats: Res<StatsResource>,
progress: Res<ProgressResource>,
path: Res<AchievementsStoragePath>,
progress_path: Res<ProgressStoragePath>,
mut achievements: ResMut<AchievementsResource>,
mut progress: ResMut<ProgressResource>,
) {
let Some(ev) = wins.read().last() else {
return;
@@ -119,7 +122,9 @@ fn evaluate_on_win(
}
let now = Utc::now();
let mut changed = false;
let mut achievements_changed = false;
let mut progress_changed = false;
for def in hits {
let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else {
continue;
@@ -128,17 +133,59 @@ fn evaluate_on_win(
continue;
}
record.unlock(now);
changed = true;
achievements_changed = true;
// Grant the reward on first unlock.
if !record.reward_granted {
if let Some(reward) = def.reward {
match reward {
Reward::CardBack(idx) => {
if !progress.0.unlocked_card_backs.contains(&idx) {
progress.0.unlocked_card_backs.push(idx);
progress_changed = true;
}
}
Reward::Background(idx) => {
if !progress.0.unlocked_backgrounds.contains(&idx) {
progress.0.unlocked_backgrounds.push(idx);
progress_changed = true;
}
}
Reward::BonusXp(amount) => {
let prev_level = progress.0.add_xp(amount);
if progress.0.leveled_up_from(prev_level) {
levelups.send(LevelUpEvent {
previous_level: prev_level,
new_level: progress.0.level,
total_xp: progress.0.total_xp,
});
}
progress_changed = true;
}
Reward::Badge => {}
}
}
record.reward_granted = true;
}
unlocks.send(AchievementUnlockedEvent(record.clone()));
}
if changed {
if achievements_changed {
if let Some(target) = &path.0 {
if let Err(e) = save_achievements_to(target, &achievements.0) {
warn!("failed to save achievements: {e}");
}
}
}
if progress_changed {
if let Some(target) = &progress_path.0 {
if let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after reward: {e}");
}
}
}
}
/// Convenience: resolve an achievement ID to its human-readable name.