feat(engine): convert AchievementsScreen to modal scaffold + Done button

Phase 3 step 5c of the UX overhaul. Wraps the achievements list in
the standard ui_modal scaffold, recolours every line via tokens, and
replaces the "Press A to close" caption with a primary Done button.

The achievements list itself keeps its previous shape (unlocked
first then alphabetical, secret achievements hidden until unlocked,
each row showing name + description + reward + unlock date). The
visual changes:

- Headline now comes from spawn_modal_header (TYPE_HEADLINE,
  TEXT_PRIMARY) — was bespoke 26px white.
- Unlocked names use ACCENT_PRIMARY (yellow); descriptions in
  TEXT_PRIMARY at TYPE_BODY.
- Locked names and descriptions use TEXT_DISABLED so they read as
  "future content" without disappearing.
- Reward lines use STATE_SUCCESS (green) at TYPE_CAPTION.
- Unlock dates use TEXT_SECONDARY at TYPE_CAPTION.
- A subtle BORDER_SUBTLE separator follows each row instead of one
  big separator under the header — easier to scan a long list.
- The "✓" / "○" status glyphs stay; their colours come from the
  per-state tokens.

handle_achievements_close_button is the click counterpart to the A
key. font_res threaded through toggle_achievements_screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-30 01:32:45 +00:00
parent 75fc3aa3d6
commit de4dba6f98
+128 -124
View File
@@ -20,10 +20,18 @@ use solitaire_data::{
use crate::events::{ use crate::events::{
AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent, AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent,
}; };
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::stats_plugin::{StatsResource, StatsUpdate}; use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL,
};
/// Marker on the achievements overlay root node. /// Marker on the achievements overlay root node.
#[derive(Component, Debug)] #[derive(Component, Debug)]
@@ -86,7 +94,8 @@ impl Plugin for AchievementPlugin {
.after(StatsUpdate) .after(StatsUpdate)
.after(ProgressUpdate), .after(ProgressUpdate),
) )
.add_systems(Update, toggle_achievements_screen); .add_systems(Update, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button);
} }
} }
@@ -200,6 +209,10 @@ pub fn display_name_for(id: &str) -> String {
.unwrap_or_else(|| id.to_string()) .unwrap_or_else(|| id.to_string())
} }
/// Marker on the "Done" button inside the Achievements modal.
#[derive(Component, Debug)]
pub struct AchievementsCloseButton;
/// Toggle the achievements overlay — `A` keyboard accelerator or /// Toggle the achievements overlay — `A` keyboard accelerator or
/// `ToggleAchievementsRequestEvent` from the HUD Menu popover. /// `ToggleAchievementsRequestEvent` from the HUD Menu popover.
fn toggle_achievements_screen( fn toggle_achievements_screen(
@@ -207,6 +220,7 @@ fn toggle_achievements_screen(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleAchievementsRequestEvent>, mut requests: MessageReader<ToggleAchievementsRequestEvent>,
achievements: Res<AchievementsResource>, achievements: Res<AchievementsResource>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<AchievementsScreen>>, screens: Query<Entity, With<AchievementsScreen>>,
) { ) {
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
@@ -216,141 +230,131 @@ fn toggle_achievements_screen(
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} else { } else {
spawn_achievements_screen(&mut commands, &achievements.0); spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
} }
} }
fn spawn_achievements_screen(commands: &mut Commands, records: &[AchievementRecord]) { /// Click handler for the modal's "Done" button — despawns the overlay
/// the same way the `A` accelerator does.
fn handle_achievements_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<AchievementsCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<AchievementsScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
fn spawn_achievements_screen(
commands: &mut Commands,
records: &[AchievementRecord],
font_res: Option<&FontResource>,
) {
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect(); let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
let total = ALL_ACHIEVEMENTS.len(); let total = ALL_ACHIEVEMENTS.len();
let header = format!("Achievements ({}/{})", unlocked.len(), total);
commands let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
.spawn(( let font_name = TextFont {
AchievementsScreen, font: font_handle.clone(),
Node { font_size: TYPE_BODY_LG,
position_type: PositionType::Absolute, ..default()
left: Val::Percent(0.0), };
top: Val::Percent(0.0), let font_desc = TextFont {
width: Val::Percent(100.0), font: font_handle.clone(),
height: Val::Percent(100.0), font_size: TYPE_BODY,
..default()
};
let font_meta = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, header, font_res);
// Achievement rows — unlocked first, then locked alphabetical.
let mut sorted: Vec<_> = records.iter().collect();
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
for record in &sorted {
let def = achievement_by_id(&record.id);
let (name, description) = def
.map(|d| (d.name, d.description))
.unwrap_or((&record.id, ""));
// Hide secret locked achievements so they remain a surprise.
let is_secret = def.map(|d| d.secret).unwrap_or(false);
if is_secret && !record.unlocked {
continue;
}
let (name_color, desc_color, prefix) = if record.unlocked {
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
} else {
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
};
card.spawn(Node {
flex_direction: FlexDirection::Column, flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center, row_gap: VAL_SPACE_1,
align_items: AlignItems::Center,
..default() ..default()
}, })
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.82)), .with_children(|row| {
ZIndex(210), row.spawn((
)) Text::new(format!("{prefix}{name}")),
.with_children(|root| { font_name.clone(),
root.spawn(( TextColor(name_color),
Node {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(28.0)),
row_gap: Val::Px(8.0),
min_width: Val::Px(380.0),
max_height: Val::Percent(80.0),
overflow: Overflow::clip_y(),
border_radius: BorderRadius::all(Val::Px(8.0)),
..default()
},
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
))
.with_children(|card| {
// Header
card.spawn((
Text::new(format!(
"Achievements ({}/{})",
unlocked.len(),
total
)),
TextFont { font_size: 26.0, ..default() },
TextColor(Color::WHITE),
)); ));
card.spawn(( if !description.is_empty() {
Text::new("Press A to close"), row.spawn((
TextFont { font_size: 14.0, ..default() }, Text::new(format!(" {description}")),
TextColor(Color::srgb(0.55, 0.55, 0.60)), font_desc.clone(),
)); TextColor(desc_color),
));
// Separator }
card.spawn(( if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) {
Node { row.spawn((
height: Val::Px(1.0), Text::new(format!(" Reward: {reward_str}")),
margin: UiRect::vertical(Val::Px(6.0)), font_meta.clone(),
..default() TextColor(STATE_SUCCESS),
}, ));
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)), }
)); if let Some(date) = record.unlock_date {
row.spawn((
// Achievement rows — unlocked first, then locked Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))),
let mut sorted: Vec<_> = records.iter().collect(); font_meta.clone(),
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone())); TextColor(TEXT_SECONDARY),
));
for record in &sorted {
let def = achievement_by_id(&record.id);
let (name, description) = def
.map(|d| (d.name, d.description))
.unwrap_or((&record.id, ""));
// Hide secret locked achievements
let is_secret = def.map(|d| d.secret).unwrap_or(false);
if is_secret && !record.unlocked {
continue;
}
let (name_color, desc_color, prefix) = if record.unlocked {
(
Color::srgb(1.0, 0.87, 0.0),
Color::srgb(0.75, 0.75, 0.70),
"",
)
} else {
(
Color::srgb(0.45, 0.45, 0.50),
Color::srgb(0.35, 0.35, 0.40),
"",
)
};
card.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(1.0),
margin: UiRect::bottom(Val::Px(4.0)),
..default()
})
.with_children(|row| {
row.spawn((
Text::new(format!("{prefix}{name}")),
TextFont { font_size: 16.0, ..default() },
TextColor(name_color),
));
if !description.is_empty() {
row.spawn((
Text::new(format!(" {description}")),
TextFont { font_size: 13.0, ..default() },
TextColor(desc_color),
));
}
// Reward line
if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) {
row.spawn((
Text::new(format!(" Reward: {reward_str}")),
TextFont { font_size: 12.0, ..default() },
TextColor(Color::srgb(0.45, 0.75, 0.45)),
));
}
// Unlock date for unlocked achievements
if let Some(date) = record.unlock_date {
row.spawn((
Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))),
TextFont { font_size: 11.0, ..default() },
TextColor(Color::srgb(0.40, 0.40, 0.45)),
));
}
});
} }
}); });
// Subtle row separator — keeps the long list scannable.
card.spawn((
Node {
height: Val::Px(1.0),
..default()
},
BackgroundColor(BORDER_SUBTLE),
));
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
AchievementsCloseButton,
"Done",
Some("A"),
ButtonVariant::Primary,
font_res,
);
}); });
});
} }
fn format_reward(reward: Reward) -> String { fn format_reward(reward: Reward) -> String {