Compare commits

..

8 Commits

Author SHA1 Message Date
funman300 afb08799e8 docs: add SESSION_HANDOFF.md mid-overhaul checkpoint
CI / Test & Lint (push) Failing after 23s
CI / Release Build (push) Has been skipped
Pause point in the UX overhaul Phase 3. Documents:
- The 11 commits landed this session (foundation + HUD + 8 overlay
  conversions).
- Test status (797 / 797 pass, clippy clean).
- The 5 steps still pending (Pause + Forfeit, Settings, Onboarding,
  animation upgrades, final literal sweep) and what each touches.
- A smoke-test checklist for the user to run on the current state.
- A copy-pasteable kickoff prompt for the next session.
- An open question about whether to keep, repurpose, or drop the
  Home overlay (now that the action bar + Help cover its surface).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:46:35 +00:00
funman300 3b619b8950 feat(engine): convert HomeScreen to modal scaffold + Done button
Phase 3 step 5f of the UX overhaul. Closes the per-overlay
conversion phase: every read-only overlay (Help, Stats, Achievements,
Profile, Leaderboard, and now Home) sits inside the same ui_modal
scaffold, picks colours from ui_theme, and dismisses via a real
"Done" primary button alongside its keyboard accelerator.

Home modal:
- Header: "Solitaire Quest"
- Mode badge: "Current mode: <mode>" in ACCENT_PRIMARY (yellow)
- Two sections (Game Controls / Screens), each rendering keyboard
  shortcuts as kbd-chip rows — the same pattern Help uses, so the
  two reference screens read consistently. Section titles use
  STATE_INFO.
- "L" leaderboard row added so the screens list is now complete.
- Actions: primary Done button with the M hotkey chip.
- handle_home_close_button is the click counterpart to M.

Home overlap with Help is intentional during the overhaul — both
exist as hotkey references for now. A future commit can repurpose
Home as a true mode launcher (the proposal called for this) or
remove it entirely if Help is sufficient. Either path is easier with
both screens already in the consistent shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:44:33 +00:00
funman300 37681cf33e feat(engine): convert LeaderboardScreen to modal scaffold + Done button
Phase 3 step 5e of the UX overhaul. Wraps the leaderboard list inside
the standard ui_modal scaffold; converts the Opt In / Opt Out buttons
to use spawn_modal_button (so they pick up the shared hover / press
paint system); replaces "Press L to close" prose with a primary Done
button.

Changes:
- spawn_leaderboard_screen now goes through spawn_modal(LeaderboardScreen,
  Z_MODAL_PANEL, ...). The bespoke 0.82-alpha scrim and hand-rolled
  card surface are gone — same visual contract as every other overlay.
- Opt In becomes a Secondary modal button; Opt Out becomes Tertiary.
  Both fire the same fetch tasks they did before.
- Header / data cells switch to ui_theme tokens. The top-3 podium
  effect now uses ACCENT_PRIMARY (yellow) for #1 and TEXT_PRIMARY for
  #2/#3 instead of metallic-coloured srgb literals; #4+ use
  TEXT_SECONDARY.
- Header-cell and data-cell helpers now take a `&TextFont` so all
  three sizes (HEADLINE / BODY_LG / BODY / CAPTION) come from the
  shared scale instead of inline 13px / 15px sizes.
- "Fetching\u{2026}" loading state uses STATE_INFO; empty-state copy
  uses TEXT_SECONDARY.
- handle_leaderboard_close_button is the click counterpart to L; it
  also sets ClosedThisFrame so update_leaderboard_panel doesn't
  immediately respawn the modal when a fetch completes in the same
  frame.

The sort-by-score code is replaced with `sort_by_key(Reverse(...))`
to satisfy clippy's unnecessary_sort_by lint that surfaced once the
file was otherwise warning-free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:40:59 +00:00
funman300 99064ce808 feat(engine): convert ProfileScreen to modal scaffold + Done button
Phase 3 step 5d of the UX overhaul. Wraps the profile sections (Sync,
Progression, Achievements, Statistics Summary) in the standard modal
scaffold; replaces every inline colour with a ui_theme token; adds an
explicit "Sync" section header so the four sections all read in the
same shape; replaces the "Press P to close" prose hint with a primary
Done button.

The previous bare full-screen scrim + inline-text approach was on the
audit's "feels like a debug panel" list — same fix as Stats /
Achievements / Help.

Section headers now use STATE_INFO at TYPE_BODY_LG, body lines use
TEXT_PRIMARY at TYPE_BODY, secondary lines (sync status, "no
achievements yet") use TEXT_SECONDARY. The achievement-count line
adopts ACCENT_PRIMARY (yellow) and unlocked-achievement entries use
STATE_SUCCESS (green) — same colour vocabulary the Achievements
overlay uses.

The unused `spawn_spacer` helper now takes a `Val` so callers can
pass spacing-token constants directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:36:33 +00:00
funman300 de4dba6f98 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>
2026-04-30 01:32:45 +00:00
funman300 75fc3aa3d6 feat(engine): convert StatsScreen to modal scaffold + Done button
Phase 3 step 5b of the UX overhaul. Wraps the existing 8-cell stats
grid + progression / weekly-goals / time-attack sections inside the
standard modal scaffold. The cell layout (the audit's pick for
"best layout in the codebase") is preserved.

Changes:
- spawn_stats_screen now calls spawn_modal(StatsScreen, ...) and
  populates the card with the same content as before, retoned to
  ui_theme: stat values are TYPE_HEADLINE in ACCENT_PRIMARY (yellow
  numbers pop against the midnight-purple card), labels are TYPE_BODY
  in TEXT_SECONDARY.
- Stat cells lose their 6%-alpha-white fill (clashed with the new
  card surface) and gain a BORDER_SUBTLE outline at RADIUS_SM
  instead — same visual purpose, fits the new palette.
- Section headers ("Progression", "Weekly Goals") use STATE_INFO and
  TEXT_SECONDARY respectively at TYPE_BODY_LG.
- Time Attack callout uses STATE_WARNING.
- "Press S to close" prose hint replaced by a primary "Done" button
  carrying its "S" hotkey chip.

A new handle_stats_close_button system mirrors the keyboard `S`
toggle for clicks. font_res threaded through toggle_stats_screen so
the modal scaffold can pick up FiraMono.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:12:55 +00:00
funman300 deb034c5fb feat(engine): convert HelpScreen to real-button modal with kbd-chip rows
Phase 3 step 5a of the UX overhaul. Replaces the old monospace
text-dump (a flat vertical column of "  D            Draw from
stock"-style lines) with a proper modal layout: section titles,
two-column rows where each shortcut renders inside a small
border-outlined chip alongside its description.

Modal contents:
- Header: "Controls"
- Body: three sections (Gameplay / New Game / Overlays), each with a
  section title in TEXT_SECONDARY plus a row per shortcut.
- Each row: a 64 px-min-width chip (caption font, border, radius-sm)
  carrying the key name, then the description in TEXT_PRIMARY at
  TYPE_BODY.
- Actions: a primary "Close" button (hotkey hint "F1").

CONTROL_SECTIONS is a static const-data table of `ControlRow`
records grouped into `ControlSection`s — easier to maintain than the
prior `Vec<String>` of free-form text and easier to extend.

handle_help_close_button is the click counterpart to F1; it
despawns the modal when the player clicks Close.

The audit identified the prior layout as the worst of the
"feels like a 2010 monospace debug dump" overlays. This
restructure is the largest visual upgrade so far in the overhaul.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:06:00 +00:00
funman300 242b5fef21 feat(engine): convert GameOverScreen to real-button modal
Phase 3 step 4b of the UX overhaul. Same shape as the Confirm modal
conversion (3f922ed): replace plain "Press N for new game" /
"Press G to forfeit" text hints with real Button entities, hover
and press feedback included.

The audit flagged the Game Over overlay as the second instance of
the "feels like a debug panel" problem. Players had to know the
hotkeys to escape the screen — there was no clickable affordance.

Modal contents:
- Header: "No more moves available"
- Body:   "Final score: {N}" (TYPE_BODY_LG, TEXT_PRIMARY)
- Actions:
    Undo (Secondary, hotkey "U")        — left
    New Game (Primary yellow, hotkey "N") — right

The G/forfeit hint is dropped from the modal because:
1. Forfeit is handled globally by `input_plugin::handle_forfeit`
   (which works whether the modal is up or not).
2. The proposal calls for replacing the toast-countdown forfeit
   flow with its own modal in step 4c (next commit).

A new `handle_game_over_button_input` system mirrors the keyboard
handler for clicks. Existing N/Esc and U accelerators continue to
work via the original `handle_game_over_input`.

The `game_over_screen_text_content` test is updated to assert the
new button-label / hotkey-chip strings instead of the prior prose
hints. All 797 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:01:39 +00:00
8 changed files with 1164 additions and 826 deletions
+108
View File
@@ -0,0 +1,108 @@
# Solitaire Quest — UX Overhaul Session Handoff
**Last updated:** 2026-04-30 — paused mid-overhaul to push and let the user smoke-test before resuming.
## Where we are
Phase 3 of the UX overhaul brief in `docs/UX_OVERHAUL_BRIEF.md` (or the inline brief used to kick off the work). 11 commits landed this session — the foundation, the HUD, and every read-only overlay have been migrated to the new design system. Pause / Settings / Onboarding modals, animation upgrades, and the final literal sweep are still pending.
### Design direction (already saved as project memory)
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
- **Palette:** Midnight Purple base (`BG_BASE` `#1A0F2E``BG_ELEVATED` `#2D1B69``BG_ELEVATED_HI` `#3A2580``BG_ELEVATED_TOP` `#482F97`) + Balatro yellow primary accent (`ACCENT_PRIMARY` `#FFD23F`) + warm magenta secondary (`ACCENT_SECONDARY` `#FF6B9D`).
- See [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for the full direction.
### Top complaints from the original smoke test
1. **HUD too cluttered.** ✅ Closed by `73cad7e` — readouts now sit in a 4-tier vertical stack with progressive disclosure of penalty/bonus tiers.
2. **Y/N keyboard prompts feel like debug panels.** ✅ Closed for the Confirm + GameOver modals (`3f922ed`, `242b5fe`); still open for the Forfeit toast countdown (folded into step 6 below).
## Foundation (done)
- **`solitaire_engine/src/ui_theme.rs`** — every design token: colours, 5-rung typography scale, 4-multiple spacing scale, three radius rungs, monotonically-ordered z-index hierarchy, motion durations with `scaled_duration(speed)` helper.
- **`solitaire_engine/src/ui_modal.rs`** — `spawn_modal` scaffold + `spawn_modal_header` / `spawn_modal_body_text` / `spawn_modal_actions` / `spawn_modal_button` helpers + `ButtonVariant` enum (Primary / Secondary / Tertiary) + `paint_modal_buttons` system. `UiModalPlugin` registered in `solitaire_app/src/main.rs`.
Every overlay conversion follows the same pattern: drop the bespoke scrim/card, call `spawn_modal(MarkerComponent, Z_MODAL_PANEL, |card| { ... })`, swap inline colours for tokens, replace "Press X to close" prose with a primary `Done` button + matching click-handler system.
## Commits this session
```
3b619b8 feat(engine): convert HomeScreen to modal scaffold + Done button
37681cf feat(engine): convert LeaderboardScreen to modal scaffold + Done button
99064ce feat(engine): convert ProfileScreen to modal scaffold + Done button
de4dba6 feat(engine): convert AchievementsScreen to modal scaffold + Done button
75fc3aa feat(engine): convert StatsScreen to modal scaffold + Done button
deb034c feat(engine): convert HelpScreen to real-button modal with kbd-chip rows
242b5fe feat(engine): convert GameOverScreen to real-button modal
3f922ed feat(engine): convert ConfirmNewGameScreen to real-button modal
8da62bd feat(engine): add ui_modal primitive (scaffold + button variants)
73cad7e feat(engine): restructure HUD into 4-tier layout, adopt design tokens
e14852c feat(engine): add ui_theme.rs design-token module
```
**Test status across every commit:** `cargo build --workspace` clean, `cargo clippy --workspace -- -D warnings` clean, **797 tests pass / 0 failed / 8 ignored**.
## What's still on disk in pre-overhaul style
| Step | Area | What it'll touch |
|---|---|---|
| 6 | Pause overlay + Forfeit modal | `pause_plugin.rs` (replace bespoke spawn with modal scaffold); fold Forfeit toast countdown into a real Cancel/Forfeit modal — Forfeit then becomes a button inside Pause. Tests around `forfeit_countdown` will need updating. |
| 7 | Settings panel | `settings_plugin.rs` — replaces the side-panel pattern with the modal scaffold. Sections: Audio / Gameplay / Cosmetic / Sync. The existing per-button click handlers stay; only the spawn structure and styling change. |
| 8 | Onboarding | `onboarding_plugin.rs` — multi-slide flow (Welcome → Drag-drop demo → Hotkeys), navigated by a primary `Next` button + arrow-key accelerators. Final slide `Start playing` writes `first_run_complete` like today. |
| 9 | Animation upgrades | `animation_plugin.rs`, `feedback_anim_plugin.rs`, `card_animation/`, `win_summary_plugin.rs` — promote `card_animation::MotionCurve` to default for slide; settle bounce only on the moved card; deal stagger ±10% jitter; win cascade with `Expressive` curve + per-card rotation; align all durations through `ui_theme::scaled_duration`. |
| 10 | Literal-to-token sweep | Grep the engine for any remaining `Color::srgb`/`Val::Px(<literal>)`/font-size literal and migrate. Clippy gates this — anything left over surfaces during the build. |
ETA for the rest: roughly 57 more focused commits, each independently passing `build` / `clippy` / `test`.
## Smoke-test checklist before resuming
The user smoke-tested at the action-bar checkpoint and reported "nothing looks off". Worth re-running once these 11 commits are pushed so the next session starts from a verified baseline:
- HUD layout reads as 4 stacked tiers (Score / Mode / Penalty / Selection) with the new midnight-purple palette.
- Open every overlay (S, A, P, O, L, M, F1) — each should now be a centred card on a uniform scrim, with a `Done` primary button.
- Click `New Game` while a game is in progress — the abandon-current-game modal should be a real-button card; both `Y/Enter` and the yellow primary button start a new game; both `N/Esc` and the secondary button cancel.
- Force a stuck state — the Game Over modal should show real Undo / New Game buttons.
- Resize the window — cards still snap, no "snap-back-and-forth" jitter (covered by previous fixes `366fd6d` / `b10e1a5`).
- Press G to forfeit — still uses the toast countdown (step 6 will replace this with a modal).
## Kickoff prompt for the next session
Paste this at the start of a fresh chat to resume:
```
Resume the UX overhaul of Solitaire Quest at /home/manage/Rusty_Solitare/.
Read these in order before doing anything:
1. SESSION_HANDOFF.md (this file)
2. CLAUDE.md (project conventions; UI-first principle)
3. The "Design direction" section of
~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md
The foundation (ui_theme.rs, ui_modal.rs), the HUD restructure, and
all 8 read-only overlay conversions are committed. Steps 6 → 10 are
documented in SESSION_HANDOFF.md and listed in
.claude/projects/.../memory/MEMORY.md.
Pick up at step 6 (Pause modal + folded-in Forfeit modal). Same
shape as the existing modal conversions: spawn_modal scaffold, real
buttons, hotkey-hint chips, ui_theme tokens for every colour /
spacing / typography decision. Update or replace any test that
asserted the prior bespoke layout.
Each commit must pass `cargo build --workspace`,
`cargo clippy --workspace -- -D warnings`, and `cargo test --workspace`
clean. Use commits authored by funman300 <root@vscode.infinity> via
the -c flag (per the user's convention; never write to git config).
Stop after step 6 and ask the user to smoke-test before continuing to
step 7. They prefer pause-and-verify over running through the
remaining 4 steps in one push.
```
## Open question
When the next session resumes, ask whether the user wants to:
1. **Keep Home as a kbd-reference modal** (current state — duplicates Help).
2. **Convert Home into a true mode launcher** (Classic / Daily / Zen / Challenge / Time Attack cards, locked options visibly disabled below level 5) per the original Phase 2 proposal. The action-bar `Modes` popover already covers this path; pivoting Home buys discoverability for first-time players who hit `M`.
3. **Drop Home entirely.** With the action bar covering every action and Help covering every shortcut, Home may be redundant.
+83 -79
View File
@@ -20,10 +20,18 @@ use solitaire_data::{
use crate::events::{
AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent,
};
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource;
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.
#[derive(Component, Debug)]
@@ -86,7 +94,8 @@ impl Plugin for AchievementPlugin {
.after(StatsUpdate)
.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())
}
/// 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(
@@ -207,6 +220,7 @@ fn toggle_achievements_screen(
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleAchievementsRequestEvent>,
achievements: Res<AchievementsResource>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<AchievementsScreen>>,
) {
let button_clicked = requests.read().count() > 0;
@@ -216,73 +230,55 @@ fn toggle_achievements_screen(
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} 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 total = ALL_ACHIEVEMENTS.len();
let header = format!("Achievements ({}/{})", unlocked.len(), total);
commands
.spawn((
AchievementsScreen,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_name = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.82)),
ZIndex(210),
))
.with_children(|root| {
root.spawn((
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)),
};
let font_desc = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
..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((
Text::new("Press A to close"),
TextFont { font_size: 14.0, ..default() },
TextColor(Color::srgb(0.55, 0.55, 0.60)),
));
};
let font_meta = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
// Separator
card.spawn((
Node {
height: Val::Px(1.0),
margin: UiRect::vertical(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
));
spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, header, font_res);
// Achievement rows — unlocked first, then locked
// Achievement rows — unlocked first, then locked alphabetical.
let mut sorted: Vec<_> = records.iter().collect();
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
@@ -292,63 +288,71 @@ fn spawn_achievements_screen(commands: &mut Commands, records: &[AchievementReco
.map(|d| (d.name, d.description))
.unwrap_or((&record.id, ""));
// Hide secret locked achievements
// 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 {
(
Color::srgb(1.0, 0.87, 0.0),
Color::srgb(0.75, 0.75, 0.70),
"",
)
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
} else {
(
Color::srgb(0.45, 0.45, 0.50),
Color::srgb(0.35, 0.35, 0.40),
"",
)
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
};
card.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(1.0),
margin: UiRect::bottom(Val::Px(4.0)),
row_gap: VAL_SPACE_1,
..default()
})
.with_children(|row| {
row.spawn((
Text::new(format!("{prefix}{name}")),
TextFont { font_size: 16.0, ..default() },
font_name.clone(),
TextColor(name_color),
));
if !description.is_empty() {
row.spawn((
Text::new(format!(" {description}")),
TextFont { font_size: 13.0, ..default() },
font_desc.clone(),
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)),
font_meta.clone(),
TextColor(STATE_SUCCESS),
));
}
// 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)),
font_meta.clone(),
TextColor(TEXT_SECONDARY),
));
}
});
// 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,
);
});
});
}
+96 -75
View File
@@ -102,6 +102,7 @@ impl Plugin for GamePlugin {
.add_systems(Update, handle_confirm_input.after(GameMutation))
.add_systems(Update, handle_confirm_button_input.after(GameMutation))
.add_systems(Update, handle_game_over_input.after(GameMutation))
.add_systems(Update, handle_game_over_button_input.after(GameMutation))
.init_resource::<AutoSaveTimer>()
.add_systems(Update, tick_elapsed_time)
.add_systems(Update, auto_save_game_state)
@@ -537,6 +538,7 @@ pub fn has_legal_moves(game: &GameState) -> bool {
/// spawns a `GameOverScreen` overlay. The overlay is despawned automatically
/// when `has_legal_moves` returns true again (e.g. after undo) or when the
/// game is won.
#[allow(clippy::too_many_arguments)]
fn check_no_moves(
mut commands: Commands,
mut events: MessageReader<StateChangedEvent>,
@@ -544,6 +546,7 @@ fn check_no_moves(
mut toast: MessageWriter<InfoToastEvent>,
mut already_fired: Local<bool>,
game_over_screens: Query<Entity, With<GameOverScreen>>,
font_res: Option<Res<FontResource>>,
) {
// Reset the debounce flag on every state change so if something changes
// we re-evaluate on the next state change.
@@ -577,84 +580,64 @@ fn check_no_moves(
*already_fired = true;
// Only spawn the overlay if one does not already exist.
if game_over_screens.is_empty() {
spawn_game_over_screen(&mut commands, game.0.score);
spawn_game_over_screen(&mut commands, game.0.score, font_res.as_deref());
}
}
}
/// Spawns the full-screen game-over overlay with score display and action hints.
/// Marker on the "Undo" secondary button inside the game-over modal.
#[derive(Component, Debug)]
pub struct GameOverUndoButton;
/// Marker on the "New Game" primary button inside the game-over modal.
#[derive(Component, Debug)]
pub struct GameOverNewGameButton;
/// Spawns the game-over modal using the standard `ui_modal` primitive.
///
/// The background is intentionally semi-transparent (alpha 0.6) so the stuck
/// card layout remains visible behind the dialog.
fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
commands
.spawn((
/// Replaces a bespoke layout that listed action hints as plain text
/// ("Press N for a new game", "Press G to forfeit") — the audit
/// flagged this as the same class of "feels like a debug panel"
/// problem the confirm modal had. Now there are real buttons with
/// hover/press feedback; the keyboard accelerators stay as optional
/// shortcuts displayed inside the buttons' caption chips.
fn spawn_game_over_screen(
commands: &mut Commands,
score: i32,
font_res: Option<&FontResource>,
) {
spawn_modal(
commands,
GameOverScreen,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
row_gap: Val::Px(20.0),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.6)),
ZIndex(200),
))
.with_children(|root| {
root.spawn((
Node {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(40.0)),
row_gap: Val::Px(16.0),
min_width: Val::Px(340.0),
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.08, 0.08)),
))
.with_children(|card| {
// Header — explains why the overlay appeared.
card.spawn((
Text::new("No more moves available"),
TextFont { font_size: 36.0, ..default() },
TextColor(Color::srgb(1.0, 0.4, 0.1)),
));
// Score
card.spawn((
Text::new(format!("Score: {score}")),
TextFont { font_size: 24.0, ..default() },
TextColor(Color::WHITE),
));
// Action hints — stacked vertically for legibility.
card.spawn((
Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(8.0),
margin: UiRect::top(Val::Px(8.0)),
align_items: AlignItems::Center,
..default()
},
))
.with_children(|hints| {
hints.spawn((
Text::new("Press N or Escape for a new game"),
TextFont { font_size: 20.0, ..default() },
TextColor(Color::srgb(0.3, 1.0, 0.4)),
));
hints.spawn((
Text::new("Press G to forfeit (counts as a loss)"),
TextFont { font_size: 20.0, ..default() },
TextColor(Color::srgb(1.0, 0.6, 0.2)),
));
});
});
ui_theme::Z_MODAL_PANEL,
|card| {
spawn_modal_header(card, "No more moves available", font_res);
spawn_modal_body_text(
card,
format!("Final score: {score}"),
ui_theme::TEXT_PRIMARY,
font_res,
);
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
GameOverUndoButton,
"Undo",
Some("U"),
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
GameOverNewGameButton,
"New Game",
Some("N"),
ButtonVariant::Primary,
font_res,
);
});
},
);
}
/// Handles keyboard input while `GameOverScreen` is open.
@@ -687,6 +670,33 @@ fn handle_game_over_input(
}
}
/// Mouse / touch counterpart to `handle_game_over_input`. Click on the
/// modal's Undo button → fire `UndoRequestEvent` and despawn so
/// `check_no_moves` can re-evaluate. Click on New Game → fire
/// `NewGameRequestEvent` (the abandon-current-game guard does not apply
/// here because the game is unwinnable).
#[allow(clippy::type_complexity)]
fn handle_game_over_button_input(
mut commands: Commands,
new_game_buttons: Query<&Interaction, (With<GameOverNewGameButton>, Changed<Interaction>)>,
undo_buttons: Query<&Interaction, (With<GameOverUndoButton>, Changed<Interaction>)>,
screens: Query<Entity, With<GameOverScreen>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut undo: MessageWriter<UndoRequestEvent>,
) {
if screens.is_empty() {
return;
}
if new_game_buttons.iter().any(|i| *i == Interaction::Pressed) {
new_game.write(NewGameRequestEvent::default());
} else if undo_buttons.iter().any(|i| *i == Interaction::Pressed) {
for entity in &screens {
commands.entity(entity).despawn();
}
undo.write(UndoRequestEvent);
}
}
const AUTO_SAVE_INTERVAL_SECS: f32 = 30.0;
/// Accumulated real-world seconds since the last auto-save. Exposed as a
@@ -1294,13 +1304,24 @@ mod tests {
texts.iter().any(|t| t == "No more moves available"),
"header must read 'No more moves available'; found: {texts:?}"
);
// The modal now uses real buttons instead of plain action-hint
// text, so we assert on the button labels and their hotkey
// chips rather than the prior "Press N…" / "Press G…" prose.
assert!(
texts.iter().any(|t| t == "Press N or Escape for a new game"),
"hint 1 must read 'Press N or Escape for a new game'; found: {texts:?}"
texts.iter().any(|t| t == "New Game"),
"primary action button must label 'New Game'; found: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "Press G to forfeit (counts as a loss)"),
"hint 2 must read 'Press G to forfeit (counts as a loss)'; found: {texts:?}"
texts.iter().any(|t| t == "N"),
"primary action must show its 'N' hotkey chip; found: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "Undo"),
"secondary action button must label 'Undo'; found: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "U"),
"secondary action must show its 'U' hotkey chip; found: {texts:?}"
);
}
+153 -55
View File
@@ -7,11 +7,23 @@
use bevy::prelude::*;
use crate::events::HelpRequestEvent;
use crate::font_plugin::FontResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
};
use crate::ui_theme::{
Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
};
/// Marker on the help overlay root node.
#[derive(Component, Debug)]
pub struct HelpScreen;
/// Marker on the "Close" button inside the Help modal.
#[derive(Component, Debug)]
pub struct HelpCloseButton;
/// Spawns and despawns the help / controls overlay shown when the player
/// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture
/// guides live here.
@@ -20,7 +32,7 @@ pub struct HelpPlugin;
impl Plugin for HelpPlugin {
fn build(&self, app: &mut App) {
app.add_message::<HelpRequestEvent>()
.add_systems(Update, toggle_help_screen);
.add_systems(Update, (toggle_help_screen, handle_help_close_button));
}
}
@@ -29,6 +41,7 @@ fn toggle_help_screen(
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<HelpRequestEvent>,
screens: Query<Entity, With<HelpScreen>>,
font_res: Option<Res<FontResource>>,
) {
// Either F1 or a click on the HUD "Help" button (which fires
// HelpRequestEvent) toggles the overlay.
@@ -39,69 +52,154 @@ fn toggle_help_screen(
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_help_screen(&mut commands);
spawn_help_screen(&mut commands, font_res.as_deref());
}
}
fn spawn_help_screen(commands: &mut Commands) {
let lines: Vec<String> = vec![
"=== Controls ===".to_string(),
String::new(),
"-- Gameplay --".to_string(),
" D Draw from stock".to_string(),
" U Undo last move".to_string(),
" Drag Move cards between piles".to_string(),
" Click stock Draw".to_string(),
String::new(),
"-- New Game --".to_string(),
" N New Classic game (N twice if in progress)".to_string(),
" C Start today's daily challenge".to_string(),
" Z Start a Zen game (level 5+)".to_string(),
" X Start the next Challenge (level 5+)".to_string(),
" T Start a Time Attack session (level 5+)".to_string(),
String::new(),
"-- Overlays --".to_string(),
" S Stats & progression".to_string(),
" A Achievements".to_string(),
" L Leaderboard".to_string(),
" O Settings".to_string(),
" F1 This help screen".to_string(),
" F11 Toggle fullscreen".to_string(),
" Esc Pause / resume".to_string(),
" [ / ] SFX volume down / up".to_string(),
String::new(),
"Press F1 to close".to_string(),
/// Click handler for the modal's "Close" button. F1 toggles the overlay
/// the same way; this just exposes the close action to mouse / touch.
fn handle_help_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HelpScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
/// Each entry in the controls reference table.
struct ControlRow {
keys: &'static str,
description: &'static str,
}
/// Each section of the controls reference. Sections render with a
/// section title and a vertically stacked list of `ControlRow`s.
struct ControlSection {
title: &'static str,
rows: &'static [ControlRow],
}
const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Gameplay",
rows: &[
ControlRow { keys: "Drag", description: "Move cards between piles" },
ControlRow { keys: "D / Space", description: "Draw from stock" },
ControlRow { keys: "U", description: "Undo last move" },
ControlRow { keys: "Click stock", description: "Draw" },
],
},
ControlSection {
title: "New Game",
rows: &[
ControlRow { keys: "N", description: "New Classic game (N twice if in progress)" },
ControlRow { keys: "C", description: "Start today's daily challenge" },
ControlRow { keys: "Z", description: "Start a Zen game (level 5+)" },
ControlRow { keys: "X", description: "Start the next Challenge (level 5+)" },
ControlRow { keys: "T", description: "Start a Time Attack session (level 5+)" },
],
},
ControlSection {
title: "Overlays",
rows: &[
ControlRow { keys: "S", description: "Stats & progression" },
ControlRow { keys: "A", description: "Achievements" },
ControlRow { keys: "L", description: "Leaderboard" },
ControlRow { keys: "O", description: "Settings" },
ControlRow { keys: "F1", description: "This help screen" },
ControlRow { keys: "F11", description: "Toggle fullscreen" },
ControlRow { keys: "Esc", description: "Pause / resume" },
ControlRow { keys: "[ / ]", description: "SFX volume down / up" },
],
},
];
commands
.spawn((
HelpScreen,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
row_gap: Val::Px(4.0),
fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_section = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)),
ZIndex(210),
))
.with_children(|b| {
for line in lines {
b.spawn((
Text::new(line),
TextFont {
font_size: 22.0,
};
let font_row = font_section.clone();
let font_kbd = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
},
TextColor(Color::srgb(0.95, 0.95, 0.90)),
};
spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Controls", font_res);
for section in CONTROL_SECTIONS {
// Section title in muted text — distinguishes from row content.
card.spawn((
Text::new(section.title),
font_section.clone(),
TextColor(TEXT_SECONDARY),
));
// Each row is a flex-row: kbd-style chip + description.
for row in section.rows {
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|line| {
// The hotkey rendered as a small chip with a border —
// visual cue that it's a key reference, not part of
// the description text.
line.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(64.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(row.keys),
font_kbd.clone(),
TextColor(TEXT_PRIMARY),
));
});
line.spawn((
Text::new(row.description),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
});
}
// Section spacer — small empty box. Keeps each section
// visually grouped.
card.spawn(Node {
height: Val::Px(SPACE_2),
..default()
});
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
HelpCloseButton,
"Close",
Some("F1"),
ButtonVariant::Primary,
font_res,
);
});
});
}
+124 -90
View File
@@ -7,18 +7,30 @@ use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_core::game_state::GameMode;
use crate::font_plugin::FontResource;
use crate::resources::GameStateResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
/// Marker component on the home-menu overlay root node.
#[derive(Component, Debug)]
pub struct HomeScreen;
/// Marker on the "Done" button inside the Home modal.
#[derive(Component, Debug)]
pub struct HomeCloseButton;
/// Registers the M-key toggle and the overlay spawn/despawn logic.
pub struct HomePlugin;
impl Plugin for HomePlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, toggle_home_screen);
app.add_systems(Update, (toggle_home_screen, handle_home_close_button));
}
}
@@ -26,6 +38,7 @@ fn toggle_home_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
game: Res<GameStateResource>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<HomeScreen>>,
) {
if !keys.just_pressed(KeyCode::KeyM) {
@@ -34,12 +47,32 @@ fn toggle_home_screen(
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_home_screen(&mut commands, &game);
spawn_home_screen(&mut commands, &game, font_res.as_deref());
}
}
/// Spawns the full-window home-menu overlay derived from the current `game` state.
fn spawn_home_screen(commands: &mut Commands, game: &GameStateResource) {
fn handle_home_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<HomeCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
/// Spawns the home-menu modal — a hotkey reference grouped into "Game
/// Controls" and "Screens" sections plus the current game mode badge.
/// A future pass can pivot Home into a true mode launcher (the
/// Modes-popover already covers that path from the action bar).
fn spawn_home_screen(
commands: &mut Commands,
game: &GameStateResource,
font_res: Option<&FontResource>,
) {
let mode_label = match game.0.mode {
GameMode::Classic => "Classic",
GameMode::Zen => "Zen",
@@ -47,121 +80,122 @@ fn spawn_home_screen(commands: &mut Commands, game: &GameStateResource) {
GameMode::TimeAttack => "Time Attack",
};
commands
.spawn((
HomeScreen,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
row_gap: Val::Px(6.0),
padding: UiRect::all(Val::Px(24.0)),
overflow: Overflow::clip(),
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_section = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)),
ZIndex(200),
))
.with_children(|root| {
// Title
root.spawn((
Text::new("Solitaire Quest"),
TextFont { font_size: 48.0, ..default() },
TextColor(Color::srgb(1.0, 0.85, 0.3)),
));
};
let font_row = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
..default()
};
let font_kbd = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
// Mode subtitle
root.spawn((
spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Solitaire Quest", font_res);
// Mode badge — current game's mode, ACCENT_PRIMARY so it pops.
card.spawn((
Text::new(format!("Current mode: {mode_label}")),
TextFont { font_size: 28.0, ..default() },
TextColor(Color::srgb(0.8, 0.8, 0.8)),
font_section.clone(),
TextColor(ACCENT_PRIMARY),
));
// Spacer
root.spawn(Node {
height: Val::Px(8.0),
..default()
});
// "Game Controls" section header
root.spawn((
// Game controls section.
card.spawn((
Text::new("Game Controls"),
TextFont { font_size: 22.0, ..default() },
TextColor(Color::srgb(0.9, 0.9, 0.9)),
font_section.clone(),
TextColor(STATE_INFO),
));
for (key, action) in [
("N", "New game (N again confirms)"),
("U", "Undo last move"),
("Space / D", "Draw from stock"),
("G", "Forfeit current game"),
("Tab", "Cycle hint highlight"),
("Enter", "Auto-complete if available"),
] {
spawn_shortcut_row(card, key, action, &font_row, &font_kbd);
}
spawn_shortcut_row(root, "N", "New game (N again confirms)");
spawn_shortcut_row(root, "U", "Undo last move");
spawn_shortcut_row(root, "Space / D", "Draw from stock");
spawn_shortcut_row(root, "G", "Forfeit current game");
spawn_shortcut_row(root, "Tab", "Cycle hint highlight");
spawn_shortcut_row(root, "Enter", "Auto-complete if available");
// Spacer
root.spawn(Node {
height: Val::Px(8.0),
..default()
});
// "Screens" section header
root.spawn((
// Screens section.
card.spawn((
Text::new("Screens"),
TextFont { font_size: 22.0, ..default() },
TextColor(Color::srgb(0.9, 0.9, 0.9)),
font_section.clone(),
TextColor(STATE_INFO),
));
for (key, action) in [
("M", "Main menu (this screen)"),
("S", "Statistics"),
("A", "Achievements"),
("O", "Settings"),
("P", "Profile"),
("L", "Leaderboard"),
("F1", "Help"),
("F11", "Toggle fullscreen"),
("Esc", "Pause / Resume"),
] {
spawn_shortcut_row(card, key, action, &font_row, &font_kbd);
}
spawn_shortcut_row(root, "M", "Main menu (this screen)");
spawn_shortcut_row(root, "S", "Statistics");
spawn_shortcut_row(root, "A", "Achievements");
spawn_shortcut_row(root, "O", "Settings");
spawn_shortcut_row(root, "P", "Profile");
spawn_shortcut_row(root, "F1", "Help");
spawn_shortcut_row(root, "F11", "Toggle fullscreen");
spawn_shortcut_row(root, "Esc", "Pause / Resume");
// Spacer
root.spawn(Node {
height: Val::Px(16.0),
..default()
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
HomeCloseButton,
"Done",
Some("M"),
ButtonVariant::Primary,
font_res,
);
});
// Dismiss hint
root.spawn((
Text::new("Press M to close"),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.55, 0.55, 0.55)),
));
});
}
fn spawn_shortcut_row(parent: &mut ChildSpawnerCommands, key: &str, action: &str) {
/// One row inside Home's controls reference: a kbd-chip + description.
/// Same look as Help's rows so the two screens read consistently.
fn spawn_shortcut_row(
parent: &mut ChildSpawnerCommands,
key: &str,
action: &str,
font_row: &TextFont,
font_kbd: &TextFont,
) {
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
min_width: Val::Px(380.0),
column_gap: Val::Px(16.0),
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|row| {
row.spawn((
Text::new(key.to_string()),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(1.0, 0.85, 0.4)),
Node {
min_width: Val::Px(120.0),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(80.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(key.to_string()),
font_kbd.clone(),
TextColor(TEXT_PRIMARY),
));
});
row.spawn((
Text::new(action.to_string()),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.85)),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
});
}
+140 -127
View File
@@ -15,8 +15,16 @@ use solitaire_data::settings::SyncBackend;
use solitaire_sync::LeaderboardEntry;
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
use crate::font_plugin::FontResource;
use crate::settings_plugin::SettingsResource;
use crate::sync_plugin::SyncProviderResource;
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_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_4, Z_MODAL_PANEL,
};
// ---------------------------------------------------------------------------
// Resources
@@ -79,6 +87,7 @@ impl Plugin for LeaderboardPlugin {
(
reset_closed_flag,
toggle_leaderboard_screen,
handle_leaderboard_close_button,
poll_leaderboard_fetch,
update_leaderboard_panel,
handle_opt_in_button,
@@ -111,6 +120,7 @@ fn toggle_leaderboard_screen(
screens: Query<Entity, With<LeaderboardScreen>>,
data: Res<LeaderboardResource>,
provider: Option<Res<SyncProviderResource>>,
font_res: Option<Res<FontResource>>,
mut task_res: ResMut<LeaderboardFetchTask>,
mut closed_flag: ResMut<ClosedThisFrame>,
) {
@@ -125,7 +135,7 @@ fn toggle_leaderboard_screen(
}
// Spawn the panel immediately with whatever data we have (may be None).
spawn_leaderboard_screen(&mut commands, data.0.as_deref());
spawn_leaderboard_screen(&mut commands, data.0.as_deref(), font_res.as_deref());
// Start a background fetch if not already in flight.
if task_res.0.is_none()
@@ -157,6 +167,7 @@ fn update_leaderboard_panel(
mut result_res: ResMut<LeaderboardFetchResult>,
mut data: ResMut<LeaderboardResource>,
screens: Query<Entity, With<LeaderboardScreen>>,
font_res: Option<Res<FontResource>>,
closed_flag: Res<ClosedThisFrame>,
) {
let Some(result) = result_res.0.take() else { return };
@@ -180,7 +191,23 @@ fn update_leaderboard_panel(
}
for entity in &screens {
commands.entity(entity).despawn();
spawn_leaderboard_screen(&mut commands, data.0.as_deref());
spawn_leaderboard_screen(&mut commands, data.0.as_deref(), font_res.as_deref());
}
}
/// Click handler for the modal's "Done" button — despawns the overlay.
fn handle_leaderboard_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<LeaderboardScreen>>,
mut closed_flag: ResMut<ClosedThisFrame>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
closed_flag.0 = true;
}
}
@@ -283,153 +310,117 @@ fn poll_opt_out_task(
// UI construction
// ---------------------------------------------------------------------------
fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[LeaderboardEntry]>) {
commands
.spawn((
LeaderboardScreen,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
/// Marker on the "Done" button inside the Leaderboard modal.
#[derive(Component, Debug)]
pub struct LeaderboardCloseButton;
fn spawn_leaderboard_screen(
commands: &mut Commands,
entries: Option<&[LeaderboardEntry]>,
font_res: Option<&FontResource>,
) {
spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Leaderboard", font_res);
// Subhead — what the screen does + what the buttons control.
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_caption = TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.82)),
ZIndex(210),
))
.with_children(|root| {
root.spawn((
Node {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(28.0)),
row_gap: Val::Px(8.0),
min_width: Val::Px(420.0),
max_height: Val::Percent(80.0),
overflow: Overflow::clip_y(),
border_radius: BorderRadius::all(Val::Px(8.0)),
};
let font_status = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
},
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
))
.with_children(|card| {
// Header
};
let font_row = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
..default()
};
let font_header = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
card.spawn((
Text::new("Leaderboard"),
TextFont { font_size: 26.0, ..default() },
TextColor(Color::WHITE),
));
card.spawn((
Text::new("Press L to close • Opt In / Opt Out to control your visibility"),
TextFont { font_size: 14.0, ..default() },
TextColor(Color::srgb(0.55, 0.55, 0.60)),
Text::new("Use Opt In / Opt Out to control your visibility on the server."),
font_caption.clone(),
TextColor(TEXT_SECONDARY),
));
// Separator
// Opt In / Opt Out row uses the same modal-button helpers as
// the rest of the UI for consistent hover / press feedback.
spawn_modal_actions(card, |row| {
spawn_modal_button(
row,
LeaderboardOptInButton,
"Opt In",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
row,
LeaderboardOptOutButton,
"Opt Out",
None,
ButtonVariant::Tertiary,
font_res,
);
});
// Subtle separator between the controls and the data area.
card.spawn((
Node {
height: Val::Px(1.0),
margin: UiRect::vertical(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
BackgroundColor(BORDER_SUBTLE),
));
// Opt-in / Opt-out buttons row
card.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(10.0),
margin: UiRect::bottom(Val::Px(8.0)),
..default()
})
.with_children(|row| {
row.spawn((
LeaderboardOptInButton,
Button,
Node {
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.18, 0.35, 0.50)),
))
.with_children(|b| {
b.spawn((
Text::new("Opt In"),
TextFont { font_size: 15.0, ..default() },
TextColor(Color::WHITE),
));
});
row.spawn((
LeaderboardOptOutButton,
Button,
Node {
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.42, 0.15, 0.15)),
))
.with_children(|b| {
b.spawn((
Text::new("Opt Out"),
TextFont { font_size: 15.0, ..default() },
TextColor(Color::WHITE),
));
});
});
match entries {
None => {
// Fetch in progress
card.spawn((
Text::new("Fetching…"),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.65, 0.65, 0.70)),
Text::new("Fetching\u{2026}"),
font_status.clone(),
TextColor(STATE_INFO),
));
}
Some([]) => {
card.spawn((
Text::new("No entries yet sync and opt in to appear here."),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.55, 0.55, 0.60)),
Text::new("No entries yet \u{2014} sync and opt in to appear here."),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
}
Some(rows) => {
// Column headers
card.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(16.0),
margin: UiRect::bottom(Val::Px(4.0)),
column_gap: VAL_SPACE_4,
..default()
})
.with_children(|row| {
header_cell(row, "#", 30.0);
header_cell(row, "Player", 160.0);
header_cell(row, "Best Score", 100.0);
header_cell(row, "Fastest Win", 110.0);
header_cell(row, "#", 30.0, &font_header);
header_cell(row, "Player", 160.0, &font_header);
header_cell(row, "Best Score", 100.0, &font_header);
header_cell(row, "Fastest Win", 110.0, &font_header);
});
// Data rows (top 10)
let mut sorted = rows.to_vec();
sorted.sort_by(|a, b| {
b.best_score
.unwrap_or(0)
.cmp(&a.best_score.unwrap_or(0))
});
sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0)));
for (i, entry) in sorted.iter().take(10).enumerate() {
// Top three get accent treatments to highlight the
// podium without leaning on hand-picked metallic
// colours that sit outside the token system.
let rank_color = match i {
0 => Color::srgb(1.0, 0.84, 0.0),
1 => Color::srgb(0.75, 0.75, 0.75),
2 => Color::srgb(0.80, 0.50, 0.20),
_ => Color::srgb(0.80, 0.80, 0.80),
0 => ACCENT_PRIMARY, // Balatro yellow for #1
1 | 2 => TEXT_PRIMARY,
_ => TEXT_SECONDARY,
};
let time_str = entry
@@ -443,37 +434,59 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
card.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(16.0),
column_gap: VAL_SPACE_4,
..default()
})
.with_children(|row| {
data_cell(row, &format!("{}", i + 1), 30.0, rank_color);
data_cell(row, &entry.display_name, 160.0, Color::WHITE);
data_cell(row, &score_str, 100.0, Color::WHITE);
data_cell(row, &time_str, 110.0, Color::WHITE);
data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row);
data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row);
data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row);
data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row);
});
}
}
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
LeaderboardCloseButton,
"Done",
Some("L"),
ButtonVariant::Primary,
font_res,
);
});
});
}
fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32) {
fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, font: &TextFont) {
parent.spawn((
Text::new(text.to_string()),
TextFont { font_size: 13.0, ..default() },
TextColor(Color::srgb(0.55, 0.75, 0.55)),
Node { width: Val::Px(width), ..default() },
font.clone(),
TextColor(TEXT_SECONDARY),
Node {
width: Val::Px(width),
..default()
},
));
}
fn data_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, color: Color) {
fn data_cell(
parent: &mut ChildSpawnerCommands,
text: &str,
width: f32,
color: Color,
font: &TextFont,
) {
parent.spawn((
Text::new(text.to_string()),
TextFont { font_size: 15.0, ..default() },
font.clone(),
TextColor(color),
Node { width: Val::Px(width), ..default() },
Node {
width: Val::Px(width),
..default()
},
));
}
+102 -80
View File
@@ -11,10 +11,18 @@ use solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource;
use crate::events::ToggleProfileRequestEvent;
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SyncStatus, SyncStatusResource};
use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{format_fastest_win, format_win_rate, StatsResource};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
};
use crate::ui_theme::{
ACCENT_PRIMARY, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, VAL_SPACE_2, Z_MODAL_PANEL,
};
/// Marker component on the profile overlay root node.
#[derive(Component, Debug)]
@@ -23,10 +31,27 @@ pub struct ProfileScreen;
/// Registers the `P` key toggle for the profile overlay.
pub struct ProfilePlugin;
/// Marker on the "Done" button inside the Profile modal.
#[derive(Component, Debug)]
pub struct ProfileCloseButton;
impl Plugin for ProfilePlugin {
fn build(&self, app: &mut App) {
app.add_message::<ToggleProfileRequestEvent>()
.add_systems(Update, toggle_profile_screen);
.add_systems(Update, (toggle_profile_screen, handle_profile_close_button));
}
}
fn handle_profile_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<ProfileCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<ProfileScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
@@ -40,6 +65,7 @@ fn toggle_profile_screen(
progress: Option<Res<ProgressResource>>,
achievements: Option<Res<AchievementsResource>>,
stats: Option<Res<StatsResource>>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<ProfileScreen>>,
) {
let button_clicked = requests.read().count() > 0;
@@ -56,6 +82,7 @@ fn toggle_profile_screen(
progress.as_deref(),
achievements.as_deref(),
stats.as_deref(),
font_res.as_deref(),
);
}
}
@@ -67,42 +94,35 @@ fn spawn_profile_screen(
progress: Option<&ProgressResource>,
achievements: Option<&AchievementsResource>,
stats: Option<&StatsResource>,
font_res: Option<&FontResource>,
) {
commands
.spawn((
ProfileScreen,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
row_gap: Val::Px(4.0),
padding: UiRect::all(Val::Px(24.0)),
overflow: Overflow::clip(),
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_section = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)),
ZIndex(200),
))
.with_children(|root| {
// ── Title ────────────────────────────────────────────────────────
root.spawn((
Text::new("Profile"),
TextFont { font_size: 28.0, ..default() },
TextColor(Color::srgb(1.0, 0.85, 0.3)),
));
};
let font_row = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
// ── Sync section ─────────────────────────────────────────────────
spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Profile", font_res);
// ── Sync section ────────────────────────────────────────────
card.spawn((
Text::new("Sync"),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(s) = settings {
let (backend_name, username) = sync_info(&s.0.sync_backend);
root.spawn((
card.spawn((
Text::new(format!("Account: {username} | Backend: {backend_name}")),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(0.7, 0.9, 1.0)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
}
if let Some(ss) = sync_status {
@@ -114,19 +134,19 @@ fn spawn_profile_screen(
}
SyncStatus::Error(e) => format!("Sync error: {e}"),
};
root.spawn((
card.spawn((
Text::new(status_text),
TextFont { font_size: 15.0, ..default() },
TextColor(Color::srgb(0.7, 0.7, 0.7)),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
}
// ── Progression section ───────────────────────────────────────────
spawn_spacer(root, 4.0);
root.spawn((
// ── Progression section ─────────────────────────────────────
spawn_spacer(card, VAL_SPACE_2);
card.spawn((
Text::new("Progression"),
TextFont { font_size: 22.0, ..default() },
TextColor(Color::srgb(0.8, 0.9, 0.8)),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(p) = progress {
let prog = &p.0;
@@ -136,46 +156,45 @@ fn spawn_profile_screen(
} else {
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
};
root.spawn((
card.spawn((
Text::new(format!(
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
prog.level, prog.total_xp, xp_done, xp_span, pct
)),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.85)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
root.spawn((
card.spawn((
Text::new(format!(
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
prog.daily_challenge_streak,
prog.unlocked_card_backs.len(),
prog.unlocked_backgrounds.len(),
)),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.85)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
}
// ── Achievements section ──────────────────────────────────────────
spawn_spacer(root, 4.0);
root.spawn((
// ── Achievements section ────────────────────────────────────
spawn_spacer(card, VAL_SPACE_2);
card.spawn((
Text::new("Achievements"),
TextFont { font_size: 22.0, ..default() },
TextColor(Color::srgb(0.8, 0.9, 0.8)),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(ar) = achievements {
let records = &ar.0;
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
root.spawn((
card.spawn((
Text::new(format!("{} / 18 unlocked", unlocked_count)),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(1.0, 0.85, 0.4)),
font_row.clone(),
TextColor(ACCENT_PRIMARY),
));
let mut any_unlocked = false;
for record in records {
let def = achievement_by_id(record.id.as_str());
// Skip secret achievements that are not unlocked.
let is_secret = def.map(|d| d.secret).unwrap_or(false);
if is_secret && !record.unlocked {
continue;
@@ -189,27 +208,27 @@ fn spawn_profile_screen(
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
None => String::new(),
};
root.spawn((
card.spawn((
Text::new(format!(" [x] {name}{date_str}")),
TextFont { font_size: 14.0, ..default() },
TextColor(Color::srgb(0.7, 1.0, 0.7)),
font_row.clone(),
TextColor(STATE_SUCCESS),
));
}
if !any_unlocked {
root.spawn((
card.spawn((
Text::new(" No achievements unlocked yet."),
TextFont { font_size: 14.0, ..default() },
TextColor(Color::srgb(0.7, 0.7, 0.7)),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
}
}
// ── Statistics summary section ────────────────────────────────────
spawn_spacer(root, 4.0);
root.spawn((
// ── Statistics summary section ──────────────────────────────
spawn_spacer(card, VAL_SPACE_2);
card.spawn((
Text::new("Statistics Summary"),
TextFont { font_size: 22.0, ..default() },
TextColor(Color::srgb(0.8, 0.9, 0.8)),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(sr) = stats {
let s = &sr.0;
@@ -218,7 +237,7 @@ fn spawn_profile_screen(
} else {
s.best_single_score.to_string()
};
root.spawn((
card.spawn((
Text::new(format!(
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
s.games_played,
@@ -226,33 +245,36 @@ fn spawn_profile_screen(
format_win_rate(s),
format_fastest_win(s.fastest_win_seconds),
)),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.85)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
root.spawn((
card.spawn((
Text::new(format!(
"Win streak: {} current, {} best | Best score: {}",
s.win_streak_current, s.win_streak_best, best_score_str,
)),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.85)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
}
// ── Dismiss hint ──────────────────────────────────────────────────
spawn_spacer(root, 8.0);
root.spawn((
Text::new("Press P to close"),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.55, 0.55, 0.55)),
));
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
ProfileCloseButton,
"Done",
Some("P"),
ButtonVariant::Primary,
font_res,
);
});
});
}
/// Spawn a fixed-height vertical spacer node.
fn spawn_spacer(parent: &mut ChildSpawnerCommands, height_px: f32) {
fn spawn_spacer(parent: &mut ChildSpawnerCommands, height: Val) {
parent.spawn(Node {
height: Val::Px(height_px),
height,
..default()
});
}
+110 -72
View File
@@ -22,8 +22,17 @@ use crate::events::{
};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource;
use crate::font_plugin::FontResource;
use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_MODAL_PANEL,
};
/// Bevy resource wrapping the current stats.
#[derive(Resource, Debug, Clone)]
@@ -102,7 +111,8 @@ impl Plugin for StatsPlugin {
Update,
handle_forfeit.before(GameMutation),
)
.add_systems(Update, toggle_stats_screen.after(GameMutation));
.add_systems(Update, toggle_stats_screen.after(GameMutation))
.add_systems(Update, handle_stats_close_button);
}
}
@@ -181,6 +191,12 @@ fn handle_forfeit(
}
}
/// Marker on the "Done" button inside the Stats modal. Click despawns
/// the overlay; `S` keyboard shortcut toggles it the same way.
#[derive(Component, Debug)]
pub struct StatsCloseButton;
#[allow(clippy::too_many_arguments)]
fn toggle_stats_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
@@ -188,6 +204,7 @@ fn toggle_stats_screen(
stats: Res<StatsResource>,
progress: Option<Res<ProgressResource>>,
time_attack: Option<Res<TimeAttackResource>>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<StatsScreen>>,
) {
let button_clicked = requests.read().count() > 0;
@@ -202,17 +219,34 @@ fn toggle_stats_screen(
&stats.0,
progress.as_deref().map(|p| &p.0),
time_attack.as_deref(),
font_res.as_deref(),
);
}
}
/// Click handler for the modal's "Done" button — despawns the overlay
/// the same way the `S` accelerator does.
fn handle_stats_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<StatsCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<StatsScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
fn spawn_stats_screen(
commands: &mut Commands,
stats: &StatsSnapshot,
progress: Option<&PlayerProgress>,
time_attack: Option<&TimeAttackResource>,
font_res: Option<&FontResource>,
) {
// --- primary stat cells (tasks #65, #66, and #38) ---
// --- primary stat cells ---
let win_rate_str = format_win_rate(stats);
let played_str = format_stat_value(stats.games_played);
let won_str = format_stat_value(stats.games_won);
@@ -222,44 +256,30 @@ fn spawn_stats_screen(
let best_score_str = format_optional_u32(stats.best_single_score);
let best_streak_str = format_stat_value(stats.win_streak_best);
commands
.spawn((
StatsScreen,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
row_gap: Val::Px(6.0),
padding: UiRect::all(Val::Px(24.0)),
overflow: Overflow::clip(),
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_section = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)),
ZIndex(200),
))
.with_children(|root| {
// Title
root.spawn((
Text::new("Statistics"),
TextFont { font_size: 28.0, ..default() },
TextColor(Color::srgb(1.0, 0.85, 0.3)),
));
};
let font_row = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
// Two-column grid of stat cells
root.spawn(Node {
spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Statistics", font_res);
// --- primary stat cells grid ---
card.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::Center,
align_items: AlignItems::FlexStart,
column_gap: Val::Px(24.0),
row_gap: Val::Px(16.0),
column_gap: VAL_SPACE_4,
row_gap: VAL_SPACE_3,
width: Val::Percent(100.0),
margin: UiRect::top(Val::Px(16.0)),
..default()
})
.with_children(|grid| {
@@ -273,12 +293,12 @@ fn spawn_stats_screen(
spawn_stat_cell(grid, &best_streak_str, "Best Streak");
});
// Progression section
// --- progression section ---
if let Some(p) = progress {
root.spawn((
card.spawn((
Text::new("Progression"),
TextFont { font_size: 22.0, ..default() },
TextColor(Color::srgb(0.7, 0.9, 1.0)),
font_section.clone(),
TextColor(STATE_INFO),
));
let level_str = format_stat_value(p.level);
@@ -287,13 +307,13 @@ fn spawn_stats_screen(
let daily_str = format_stat_value(p.daily_challenge_streak);
let challenge_str = challenge_progress_label(p.challenge_index);
root.spawn(Node {
card.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::Center,
align_items: AlignItems::FlexStart,
column_gap: Val::Px(24.0),
row_gap: Val::Px(12.0),
column_gap: VAL_SPACE_4,
row_gap: VAL_SPACE_3,
width: Val::Percent(100.0),
..default()
})
@@ -305,56 +325,65 @@ fn spawn_stats_screen(
spawn_stat_cell(grid, &challenge_str, "Challenge");
});
// Weekly goals row
root.spawn((
// Weekly goals
card.spawn((
Text::new("Weekly Goals"),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.8, 0.8, 0.8)),
font_section.clone(),
TextColor(TEXT_SECONDARY),
));
for goal in WEEKLY_GOALS {
let pv = p.weekly_goal_progress.get(goal.id).copied().unwrap_or(0);
root.spawn((
card.spawn((
Text::new(format!(" {}: {}/{}", goal.description, pv, goal.target)),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.80)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
}
// Unlocks row
root.spawn((
// Unlocks line
card.spawn((
Text::new(format!(
"Card Backs: {} | Backgrounds: {}",
format_id_list(&p.unlocked_card_backs),
format_id_list(&p.unlocked_backgrounds),
)),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.75, 0.75, 0.75)),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
}
// Time Attack section
// --- Time Attack section ---
if let Some(ta) = time_attack
&& ta.active {
let mins = (ta.remaining_secs / 60.0).floor() as u64;
let secs = (ta.remaining_secs % 60.0).floor() as u64;
root.spawn((
Text::new(format!("Time Attack — {mins}m {secs:02}s left | Wins: {}", ta.wins)),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(1.0, 0.6, 0.2)),
card.spawn((
Text::new(format!(
"Time Attack \u{2014} {mins}m {secs:02}s left | Wins: {}",
ta.wins
)),
font_section.clone(),
TextColor(STATE_WARNING),
));
}
// Dismiss hint
root.spawn((
Text::new("Press S to close"),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.6, 0.6, 0.6)),
));
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
StatsCloseButton,
"Done",
Some("S"),
ButtonVariant::Primary,
font_res,
);
});
});
}
/// Spawn a single stat cell: a large value label on top and a small grey
/// descriptor below, inside a fixed-width column node with a [`StatsCell`] marker.
/// Spawn a single stat cell: a large value label on top and a small
/// descriptor below, inside a fixed-min-width column with a subtle
/// border. Recoloured to use ui_theme tokens — the prior 6%-alpha-white
/// fill clashed against the new midnight-purple modal surface.
fn spawn_stat_cell(parent: &mut ChildSpawnerCommands, value: &str, label: &str) {
parent
.spawn((
@@ -364,23 +393,32 @@ fn spawn_stat_cell(parent: &mut ChildSpawnerCommands, value: &str, label: &str)
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
min_width: Val::Px(110.0),
padding: UiRect::all(Val::Px(8.0)),
padding: UiRect::all(VAL_SPACE_2),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(Color::srgba(1.0, 1.0, 1.0, 0.06)),
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|cell| {
// Large value label.
// Large value label — accent yellow makes the number sing
// against the dark card surface.
cell.spawn((
Text::new(value.to_string()),
TextFont { font_size: 32.0, ..default() },
TextColor(Color::srgb(1.0, 1.0, 1.0)),
TextFont {
font_size: TYPE_HEADLINE,
..default()
},
TextColor(ACCENT_PRIMARY),
));
// Small descriptor below.
// Small descriptor below the value.
cell.spawn((
Text::new(label.to_string()),
TextFont { font_size: 14.0, ..default() },
TextColor(Color::srgb(0.65, 0.65, 0.65)),
TextFont {
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_SECONDARY),
));
});
}