Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afb08799e8 | |||
| 3b619b8950 | |||
| 37681cf33e | |||
| 99064ce808 | |||
| de4dba6f98 | |||
| 75fc3aa3d6 | |||
| deb034c5fb | |||
| 242b5fef21 |
@@ -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 5–7 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.
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user