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:
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user