feat(engine): You hub — Profile/Stats/Achievements/Replays in one tabbed modal
Test / test (pull_request) Failing after 8m41s
Test / test (pull_request) Failing after 8m41s
Phase E of docs/ui-redesign-2026-07.md. New you_hub_plugin owns the modal shell (header, shared tab chips, single Done); each tab's content is a body builder extracted from its original plugin with every marker component unchanged, so per-row update/scroll/selector systems keep working. The replay selector gets its own Replays tab (Watch/Copy buttons move into the tab body). - Toggle*RequestEvents + P/S/A accelerators open the hub on the right tab, switch tabs in place, or toggle closed on a same-tab request; Esc/Done/scrim-click close - Legacy ProfileScreen/StatsScreen/AchievementsScreen markers ride the hub scrim for the active tab — external queries and tests keep their meaning - Standalone toggle/close systems and per-screen Done buttons removed (ProfileCloseButton, StatsCloseButton, AchievementsCloseButton) - Tests: 2 new hub lifecycle tests; profile/stats/achievements modal tests adapted (fixtures add YouHubPlugin; selector tests target the Replays tab). Engine suite 916 green, clippy -D warnings, fmt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,13 +30,9 @@ use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
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,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1,
|
||||
};
|
||||
use crate::ui_tooltip::Tooltip;
|
||||
|
||||
@@ -137,8 +133,8 @@ impl Plugin for AchievementPlugin {
|
||||
.after(GameMutation)
|
||||
.after(StatsUpdate),
|
||||
)
|
||||
.add_systems(Update, toggle_achievements_screen)
|
||||
.add_systems(Update, handle_achievements_close_button)
|
||||
// Open/close/tab handling moved to `you_hub_plugin`
|
||||
// (Phase E) — this plugin now owns body content + scroll.
|
||||
.add_systems(Update, scroll_achievements_panel)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -385,47 +381,6 @@ pub fn display_name_for(id: &str) -> String {
|
||||
achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.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
|
||||
/// `ToggleAchievementsRequestEvent` from the HUD Menu popover.
|
||||
fn toggle_achievements_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleAchievementsRequestEvent>,
|
||||
achievements: Res<AchievementsResource>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
screens: Query<Entity, With<AchievementsScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<AchievementsScreen>)>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else if other_modal_scrims.is_empty() {
|
||||
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes mouse-wheel events into the Achievements modal's scrollable body
|
||||
/// while the panel is open.
|
||||
///
|
||||
@@ -458,14 +413,18 @@ fn scroll_achievements_panel(
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_achievements_screen(
|
||||
commands: &mut Commands,
|
||||
/// Builds the Achievements tab body inside the You hub's card. The
|
||||
/// unlock-count line that used to live in the standalone modal's
|
||||
/// header renders as the first body line instead (the hub owns the
|
||||
/// header). All markers (`AchievementRow`, `AchievementsScrollable`)
|
||||
/// are unchanged.
|
||||
pub(crate) fn spawn_achievements_body(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
records: &[AchievementRecord],
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
|
||||
let total = ALL_ACHIEVEMENTS.len();
|
||||
let header = format!("Achievements ({}/{})", unlocked.len(), total);
|
||||
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_name = TextFont {
|
||||
@@ -486,8 +445,13 @@ fn spawn_achievements_screen(
|
||||
|
||||
let any_unlocked = records.iter().any(|r| r.unlocked);
|
||||
|
||||
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, header, font_res);
|
||||
{
|
||||
// Unlock progress — formerly the standalone modal's header.
|
||||
card.spawn((
|
||||
Text::new(format!("Unlocked {} / {}", unlocked.len(), total)),
|
||||
font_name.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
// First-time hint — shown until the player has unlocked anything.
|
||||
// The list itself describes individual rewards, but a top-level
|
||||
@@ -594,21 +558,7 @@ fn spawn_achievements_screen(
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
AchievementsCloseButton,
|
||||
"Done",
|
||||
Some("A"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Achievements is a read-only list — clicking the scrim outside
|
||||
// the card dismisses alongside the existing A / Done paths.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reward(reward: Reward) -> String {
|
||||
@@ -668,7 +618,8 @@ mod tests {
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(StatsPlugin::headless())
|
||||
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||
.add_plugins(AchievementPlugin::headless());
|
||||
.add_plugins(AchievementPlugin::headless())
|
||||
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
|
||||
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
||||
// MinimalPlugins it isn't auto-registered.
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
|
||||
Reference in New Issue
Block a user