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:
@@ -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.
|
||||
|
||||
@@ -16,6 +16,7 @@ use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, Settings};
|
||||
|
||||
use crate::events::ManualSyncRequestEvent;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{SyncStatus, SyncStatusResource};
|
||||
|
||||
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
||||
@@ -61,6 +62,14 @@ struct ThemeText;
|
||||
#[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 active background index.
|
||||
#[derive(Component, Debug)]
|
||||
struct BackgroundText;
|
||||
|
||||
/// Tags interactive buttons inside the Settings panel.
|
||||
#[derive(Component, Debug)]
|
||||
enum SettingsButton {
|
||||
@@ -70,6 +79,8 @@ enum SettingsButton {
|
||||
MusicUp,
|
||||
ToggleDrawMode,
|
||||
ToggleTheme,
|
||||
CycleCardBack,
|
||||
CycleBackground,
|
||||
SyncNow,
|
||||
Done,
|
||||
}
|
||||
@@ -122,6 +133,8 @@ impl Plugin for SettingsPlugin {
|
||||
sync_settings_panel_visibility,
|
||||
handle_settings_buttons,
|
||||
update_sync_status_text,
|
||||
update_card_back_text,
|
||||
update_background_text,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -186,6 +199,7 @@ fn sync_settings_panel_visibility(
|
||||
mut commands: Commands,
|
||||
settings: Res<SettingsResource>,
|
||||
sync_status: Option<Res<SyncStatusResource>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
) {
|
||||
if !screen.is_changed() {
|
||||
return;
|
||||
@@ -195,7 +209,21 @@ fn sync_settings_panel_visibility(
|
||||
let status_label = sync_status
|
||||
.map(|s| sync_status_label(&s.0))
|
||||
.unwrap_or_else(|| "Status: not configured".to_string());
|
||||
spawn_settings_panel(&mut commands, &settings.0, &status_label);
|
||||
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 {
|
||||
@@ -204,6 +232,16 @@ fn sync_settings_panel_visibility(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the next unlocked index after `current` in the sorted `unlocked` list.
|
||||
/// Wraps around. Falls back to `unlocked[0]` if `current` is not found.
|
||||
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>>,
|
||||
@@ -221,6 +259,46 @@ fn update_sync_status_text(
|
||||
}
|
||||
}
|
||||
|
||||
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 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(),
|
||||
@@ -249,6 +327,7 @@ fn handle_settings_buttons(
|
||||
path: Res<SettingsStoragePath>,
|
||||
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>)>,
|
||||
@@ -326,6 +405,26 @@ fn handle_settings_buttons(
|
||||
**t = theme_label(&settings.0.theme);
|
||||
}
|
||||
}
|
||||
SettingsButton::CycleCardBack => {
|
||||
let unlocked = progress
|
||||
.as_ref()
|
||||
.map(|p| p.0.unlocked_card_backs.clone())
|
||||
.unwrap_or_else(|| vec![0]);
|
||||
settings.0.selected_card_back =
|
||||
cycle_unlocked(&unlocked, settings.0.selected_card_back);
|
||||
persist(&path, &settings.0);
|
||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
SettingsButton::CycleBackground => {
|
||||
let unlocked = progress
|
||||
.as_ref()
|
||||
.map(|p| p.0.unlocked_backgrounds.clone())
|
||||
.unwrap_or_else(|| vec![0]);
|
||||
settings.0.selected_background =
|
||||
cycle_unlocked(&unlocked, settings.0.selected_background);
|
||||
persist(&path, &settings.0);
|
||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
SettingsButton::SyncNow => {
|
||||
manual_sync.send(ManualSyncRequestEvent);
|
||||
}
|
||||
@@ -355,7 +454,13 @@ fn theme_label(theme: &Theme) -> String {
|
||||
// UI construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn spawn_settings_panel(commands: &mut Commands, settings: &Settings, sync_status: &str) {
|
||||
fn spawn_settings_panel(
|
||||
commands: &mut Commands,
|
||||
settings: &Settings,
|
||||
sync_status: &str,
|
||||
unlocked_card_backs: &[usize],
|
||||
unlocked_backgrounds: &[usize],
|
||||
) {
|
||||
commands
|
||||
.spawn((
|
||||
SettingsPanel,
|
||||
@@ -458,6 +563,54 @@ fn spawn_settings_panel(commands: &mut Commands, settings: &Settings, sync_statu
|
||||
icon_button(row, "⇄", SettingsButton::ToggleTheme);
|
||||
});
|
||||
|
||||
// Card back row — only shown when the player has unlocked more than one.
|
||||
if unlocked_card_backs.len() > 1 {
|
||||
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("Card Back"),
|
||||
TextFont { font_size: 18.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||
));
|
||||
row.spawn((
|
||||
CardBackText,
|
||||
Text::new(card_back_label(settings.selected_card_back)),
|
||||
TextFont { font_size: 18.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
icon_button(row, "⇄", SettingsButton::CycleCardBack);
|
||||
});
|
||||
}
|
||||
|
||||
// Background row — only shown when the player has unlocked more than one.
|
||||
if unlocked_backgrounds.len() > 1 {
|
||||
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("Background"),
|
||||
TextFont { font_size: 18.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||
));
|
||||
row.spawn((
|
||||
BackgroundText,
|
||||
Text::new(background_label(settings.selected_background)),
|
||||
TextFont { font_size: 18.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
icon_button(row, "⇄", SettingsButton::CycleBackground);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Sync section ---
|
||||
section_label(card, "Sync");
|
||||
card.spawn(Node {
|
||||
|
||||
Reference in New Issue
Block a user