Compare commits
4 Commits
6240156fee
...
3f922ede28
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f922ede28 | |||
| 8da62bd05f | |||
| 73cad7e205 | |||
| e14852c093 |
@@ -5,7 +5,8 @@ use solitaire_engine::{
|
||||
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiModalPlugin, WeeklyGoalsPlugin,
|
||||
WinSummaryPlugin,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -83,5 +84,6 @@ fn main() {
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(LeaderboardPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -18,7 +18,13 @@ use crate::events::{
|
||||
CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
spawn_modal_header, ButtonVariant,
|
||||
};
|
||||
use crate::ui_theme;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #57 — Confirm-new-game dialog
|
||||
@@ -94,6 +100,7 @@ impl Plugin for GamePlugin {
|
||||
)
|
||||
.add_systems(Update, check_no_moves.after(GameMutation))
|
||||
.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))
|
||||
.init_resource::<AutoSaveTimer>()
|
||||
.add_systems(Update, tick_elapsed_time)
|
||||
@@ -157,6 +164,7 @@ fn handle_new_game(
|
||||
mut changed: MessageWriter<StateChangedEvent>,
|
||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>,
|
||||
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
||||
) {
|
||||
@@ -174,7 +182,7 @@ fn handle_new_game(
|
||||
for entity in &game_over_screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
spawn_confirm_dialog(&mut commands, *ev);
|
||||
spawn_confirm_dialog(&mut commands, *ev, font_res.as_deref());
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -205,75 +213,70 @@ fn handle_new_game(
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the confirm-new-game modal overlay.
|
||||
/// Marker on the primary "Yes, abandon" button inside the confirm modal.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ConfirmYesButton;
|
||||
|
||||
/// Marker on the secondary "Cancel" button inside the confirm modal.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ConfirmNoButton;
|
||||
|
||||
/// Spawns the confirm-new-game modal using the standard `ui_modal`
|
||||
/// primitive — uniform scrim, centred card, real buttons with hover /
|
||||
/// press states.
|
||||
///
|
||||
/// Shown when the player requests a new game while moves have been made and
|
||||
/// the game is not yet won. The overlay stores the original request so the
|
||||
/// `handle_confirm_input` system can replay it on confirmation.
|
||||
fn spawn_confirm_dialog(commands: &mut Commands, original_request: NewGameRequestEvent) {
|
||||
commands
|
||||
.spawn((
|
||||
ConfirmNewGameScreen,
|
||||
// Store the request so we can replay it on confirmation.
|
||||
OriginalNewGameRequest(original_request),
|
||||
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.70)),
|
||||
ZIndex(250),
|
||||
))
|
||||
.with_children(|root| {
|
||||
// Dialog card
|
||||
root.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
padding: UiRect::all(Val::Px(40.0)),
|
||||
row_gap: Val::Px(20.0),
|
||||
min_width: Val::Px(360.0),
|
||||
align_items: AlignItems::Center,
|
||||
border_radius: BorderRadius::all(Val::Px(12.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.10, 0.12, 0.15)),
|
||||
))
|
||||
.with_children(|card| {
|
||||
// Heading
|
||||
card.spawn((
|
||||
Text::new("Abandon current game?"),
|
||||
TextFont { font_size: 30.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
// Button row
|
||||
card.spawn((Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: Val::Px(24.0),
|
||||
..default()
|
||||
},))
|
||||
.with_children(|row| {
|
||||
// Yes button
|
||||
row.spawn((
|
||||
Text::new("Yes (Y)"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.3, 1.0, 0.4)),
|
||||
));
|
||||
// No button
|
||||
row.spawn((
|
||||
Text::new("No (N)"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.4, 0.4)),
|
||||
));
|
||||
});
|
||||
/// Shown when the player requests a new game while moves have been made
|
||||
/// and the game is not yet won. The original `NewGameRequestEvent` is
|
||||
/// stored on the scrim entity so `handle_confirm_input` /
|
||||
/// `handle_confirm_button` can replay it with the same seed / mode on
|
||||
/// confirmation.
|
||||
///
|
||||
/// Replaces a bespoke layout that used plain `Text` labels for "Yes (Y)"
|
||||
/// and "No (N)" — those were not real Button entities, so the player
|
||||
/// had no hover / press feedback and the modal felt like a debug panel
|
||||
/// (the user's smoke-test "#2 complaint").
|
||||
fn spawn_confirm_dialog(
|
||||
commands: &mut Commands,
|
||||
original_request: NewGameRequestEvent,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let scrim = spawn_modal(
|
||||
commands,
|
||||
ConfirmNewGameScreen,
|
||||
ui_theme::Z_MODAL_PANEL,
|
||||
|card| {
|
||||
spawn_modal_header(card, "Abandon current game?", font_res);
|
||||
spawn_modal_body_text(
|
||||
card,
|
||||
"Your progress will be lost.",
|
||||
ui_theme::TEXT_SECONDARY,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
ConfirmNoButton,
|
||||
"Cancel",
|
||||
Some("Esc"),
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
ConfirmYesButton,
|
||||
"Yes, abandon",
|
||||
Some("Y"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
// Attach the original request to the scrim so handle_confirm_input
|
||||
// and handle_confirm_button can read it on confirmation.
|
||||
commands
|
||||
.entity(scrim)
|
||||
.insert(OriginalNewGameRequest(original_request));
|
||||
}
|
||||
|
||||
/// Carries the original `NewGameRequestEvent` on the confirm overlay so
|
||||
@@ -318,6 +321,41 @@ fn handle_confirm_input(
|
||||
}
|
||||
}
|
||||
|
||||
/// Mouse / touch counterpart to `handle_confirm_input`. Reads
|
||||
/// `Changed<Interaction>` on the modal's `ConfirmYesButton` /
|
||||
/// `ConfirmNoButton` so the modal closes and (on confirm) starts a new
|
||||
/// game whether the player uses the keyboard accelerator or clicks.
|
||||
///
|
||||
/// This is the system that closes the user's #2 smoke-test complaint:
|
||||
/// previously the dialog had only `Text::new("Yes (Y)")` labels — not
|
||||
/// real button entities — so clicks did nothing and the only path
|
||||
/// through the modal was the keyboard.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_confirm_button_input(
|
||||
mut commands: Commands,
|
||||
yes_buttons: Query<&Interaction, (With<ConfirmYesButton>, Changed<Interaction>)>,
|
||||
no_buttons: Query<&Interaction, (With<ConfirmNoButton>, Changed<Interaction>)>,
|
||||
screens: Query<(Entity, &OriginalNewGameRequest), With<ConfirmNewGameScreen>>,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
) {
|
||||
let Ok((entity, original)) = screens.single() else {
|
||||
return;
|
||||
};
|
||||
let confirmed = yes_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
let cancelled = no_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
|
||||
if confirmed {
|
||||
commands.entity(entity).despawn();
|
||||
new_game.write(NewGameRequestEvent {
|
||||
seed: original.0.seed,
|
||||
mode: original.0.mode,
|
||||
confirmed: true,
|
||||
});
|
||||
} else if cancelled {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_draw(
|
||||
mut draws: MessageReader<DrawRequestEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
|
||||
+199
-103
@@ -15,6 +15,12 @@ use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
|
||||
BORDER_SUBTLE, RADIUS_MD, STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING,
|
||||
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE,
|
||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||
};
|
||||
use crate::events::{
|
||||
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
||||
@@ -167,12 +173,15 @@ pub enum MenuOption {
|
||||
}
|
||||
|
||||
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||
const Z_HUD: i32 = 50;
|
||||
/// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module
|
||||
/// can use it as a `const` without a non-const expression in `ZIndex(...)`.
|
||||
const Z_HUD: i32 = crate::ui_theme::Z_HUD;
|
||||
|
||||
/// Idle / hover / pressed colours shared by every action button.
|
||||
const ACTION_BTN_IDLE: Color = Color::srgb(0.20, 0.55, 0.85);
|
||||
const ACTION_BTN_HOVER: Color = Color::srgb(0.28, 0.65, 0.95);
|
||||
const ACTION_BTN_PRESSED: Color = Color::srgb(0.15, 0.45, 0.75);
|
||||
/// Idle / hover / pressed colours shared by every action button. Aliased
|
||||
/// to the theme tokens so the HUD picks up palette changes for free.
|
||||
const ACTION_BTN_IDLE: Color = BG_ELEVATED;
|
||||
const ACTION_BTN_HOVER: Color = BG_ELEVATED_HI;
|
||||
const ACTION_BTN_PRESSED: Color = BG_ELEVATED_PRESSED;
|
||||
|
||||
/// Renders the in-game HUD: score counter, move counter, elapsed timer, draw-mode indicator, and the auto-complete badge that lights up when the game is solvable without further input.
|
||||
pub struct HudPlugin;
|
||||
@@ -219,78 +228,144 @@ impl Plugin for HudPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the in-game HUD as a 4-tier vertical column anchored to the
|
||||
/// top-left of the play area.
|
||||
///
|
||||
/// Tiers (top to bottom):
|
||||
/// 1. **Primary** — Score (display weight) · Moves · Timer.
|
||||
/// Always visible during gameplay.
|
||||
/// 2. **Mode context** — Mode badge · Daily-challenge constraint ·
|
||||
/// Draw-cycle indicator. Each cell is empty when not relevant; the
|
||||
/// row collapses visually when all cells are empty.
|
||||
/// 3. **Penalty / bonus** — Undos · Recycles · Auto-complete badge.
|
||||
/// Both penalty counters share `STATE_WARNING` (the audit found
|
||||
/// they were inconsistent: Undos amber, Recycles white).
|
||||
/// 4. **Selection** — keyboard-driven pile selector chip.
|
||||
///
|
||||
/// The audit identified the original single-row layout (10 readouts in
|
||||
/// one horizontal flex row, 5+ colour families competing) as the
|
||||
/// player's #1 complaint. This restructure groups by purpose, lets
|
||||
/// transient items disappear cleanly, and uses the typography scale to
|
||||
/// make Score the visual protagonist.
|
||||
fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
let white = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
|
||||
let font = TextFont {
|
||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: 18.0,
|
||||
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_score = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_HEADLINE,
|
||||
..default()
|
||||
};
|
||||
let font_lg = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
};
|
||||
let font_body = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
|
||||
let row_node = || Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_3,
|
||||
align_items: AlignItems::Baseline,
|
||||
..default()
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(12.0),
|
||||
top: Val::Px(8.0),
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: Val::Px(20.0),
|
||||
align_items: AlignItems::Center,
|
||||
left: VAL_SPACE_3,
|
||||
top: VAL_SPACE_2,
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
..default()
|
||||
},
|
||||
ZIndex(Z_HUD),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((HudScore, Text::new("Score: 0"), font.clone(), white));
|
||||
b.spawn((HudMoves, Text::new("Moves: 0"), font.clone(), white));
|
||||
b.spawn((HudTime, Text::new("0:00"), font.clone(), white));
|
||||
b.spawn((
|
||||
HudMode,
|
||||
Text::new(""),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.85, 0.25)),
|
||||
));
|
||||
// Daily-challenge constraint (hidden until a challenge is active).
|
||||
b.spawn((
|
||||
HudChallenge,
|
||||
Text::new(""),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(0.4, 0.9, 1.0)),
|
||||
));
|
||||
// Undo counter (white by default; turns amber when undos are used).
|
||||
b.spawn((
|
||||
HudUndos,
|
||||
Text::new(""),
|
||||
font.clone(),
|
||||
white,
|
||||
));
|
||||
// Auto-complete badge (green "AUTO" when sequence is running).
|
||||
b.spawn((
|
||||
HudAutoComplete,
|
||||
Text::new(""),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(0.2, 0.9, 0.3)),
|
||||
));
|
||||
// Recycle counter — hidden until the first recycle in either draw mode.
|
||||
b.spawn((
|
||||
HudRecycles,
|
||||
Text::new(""),
|
||||
font.clone(),
|
||||
white,
|
||||
));
|
||||
// Draw-cycle indicator — only visible in Draw-Three mode.
|
||||
b.spawn((
|
||||
HudDrawCycle,
|
||||
Text::new(""),
|
||||
font,
|
||||
TextColor(Color::srgb(0.7, 0.85, 1.0)),
|
||||
));
|
||||
// Keyboard-selection indicator — shows which pile is Tab-selected.
|
||||
b.spawn((
|
||||
HudSelection,
|
||||
Text::new(""),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 1.0, 0.5)),
|
||||
));
|
||||
.with_children(|hud| {
|
||||
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
||||
// Moves and Timer are supporting context (BODY_LG, secondary tone).
|
||||
hud.spawn(row_node()).with_children(|t1| {
|
||||
t1.spawn((
|
||||
HudScore,
|
||||
Text::new("Score: 0"),
|
||||
font_score.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
t1.spawn((
|
||||
HudMoves,
|
||||
Text::new("Moves: 0"),
|
||||
font_lg.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
t1.spawn((
|
||||
HudTime,
|
||||
Text::new("0:00"),
|
||||
font_lg.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
|
||||
// Tier 2 — mode context. Each cell is empty until update_hud
|
||||
// populates it (and clears it when no longer relevant), so the
|
||||
// row collapses when nothing in this tier applies.
|
||||
hud.spawn(row_node()).with_children(|t2| {
|
||||
t2.spawn((
|
||||
HudMode,
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
t2.spawn((
|
||||
HudChallenge,
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
t2.spawn((
|
||||
HudDrawCycle,
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
});
|
||||
|
||||
// Tier 3 — penalty / bonus. Undos and Recycles share the
|
||||
// warning hue so they read as the same category ("you took a
|
||||
// penalty"); the auto-complete badge stays success-green.
|
||||
hud.spawn(row_node()).with_children(|t3| {
|
||||
t3.spawn((
|
||||
HudUndos,
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_WARNING),
|
||||
));
|
||||
t3.spawn((
|
||||
HudRecycles,
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_WARNING),
|
||||
));
|
||||
t3.spawn((
|
||||
HudAutoComplete,
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
});
|
||||
|
||||
// Tier 4 — selection chip. Stays in HUD for now; a future
|
||||
// pass can reposition it next to the selected pile.
|
||||
hud.spawn(row_node()).with_children(|t4| {
|
||||
t4.spawn((
|
||||
HudSelection,
|
||||
Text::new(""),
|
||||
font_body,
|
||||
TextColor(ACCENT_SECONDARY),
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -322,12 +397,15 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
|
||||
ZIndex(Z_HUD),
|
||||
))
|
||||
.with_children(|row| {
|
||||
spawn_action_button(row, MenuButton, "Menu \u{25BE}", &font);
|
||||
spawn_action_button(row, UndoButton, "Undo", &font);
|
||||
spawn_action_button(row, PauseButton, "Pause", &font);
|
||||
spawn_action_button(row, HelpButton, "Help", &font);
|
||||
spawn_action_button(row, ModesButton, "Modes \u{25BE}", &font);
|
||||
spawn_action_button(row, NewGameButton, "New Game", &font);
|
||||
// Menu and Modes don't have a single hotkey accelerator
|
||||
// (each row inside their popover has its own); their button
|
||||
// labels carry the dropdown chevron in lieu of a key chip.
|
||||
spawn_action_button(row, MenuButton, "Menu \u{25BE}", None, &font);
|
||||
spawn_action_button(row, UndoButton, "Undo", Some("U"), &font);
|
||||
spawn_action_button(row, PauseButton, "Pause", Some("Esc"), &font);
|
||||
spawn_action_button(row, HelpButton, "Help", Some("F1"), &font);
|
||||
spawn_action_button(row, ModesButton, "Modes \u{25BE}", None, &font);
|
||||
spawn_action_button(row, NewGameButton, "New Game", Some("N"), &font);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -338,23 +416,37 @@ fn spawn_action_button<M: Component>(
|
||||
row: &mut ChildSpawnerCommands,
|
||||
marker: M,
|
||||
label: &str,
|
||||
hotkey: Option<&'static str>,
|
||||
font: &TextFont,
|
||||
) {
|
||||
let hotkey_font = TextFont {
|
||||
font: font.font.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
row.spawn((
|
||||
marker,
|
||||
ActionButton,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(14.0), Val::Px(8.0)),
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
border_radius: BorderRadius::all(Val::Px(6.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
column_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACTION_BTN_IDLE),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((Text::new(label), font.clone(), TextColor(Color::WHITE)));
|
||||
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||
if let Some(key) = hotkey {
|
||||
// Hotkey hint rendered as a dim caption next to the label —
|
||||
// keeps the keyboard accelerator discoverable without
|
||||
// hijacking the button's primary affordance.
|
||||
b.spawn((Text::new(key), hotkey_font, TextColor(TEXT_SECONDARY)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -476,7 +568,7 @@ fn spawn_modes_popover(
|
||||
border_radius: BorderRadius::all(Val::Px(6.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.10, 0.12, 0.15, 0.96)),
|
||||
BackgroundColor(BG_ELEVATED),
|
||||
ZIndex(Z_HUD + 5),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
@@ -497,7 +589,7 @@ fn spawn_modes_popover(
|
||||
BackgroundColor(ACTION_BTN_IDLE),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((Text::new(label), font.clone(), TextColor(Color::WHITE)));
|
||||
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -605,7 +697,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
border_radius: BorderRadius::all(Val::Px(6.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.10, 0.12, 0.15, 0.96)),
|
||||
BackgroundColor(BG_ELEVATED),
|
||||
ZIndex(Z_HUD + 5),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
@@ -626,7 +718,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
BackgroundColor(ACTION_BTN_IDLE),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((Text::new(label), font.clone(), TextColor(Color::WHITE)));
|
||||
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -679,6 +771,7 @@ fn handle_menu_option_click(
|
||||
/// states by mutating `BackgroundColor` whenever the interaction state
|
||||
/// changes. One query covers all action buttons via the shared
|
||||
/// `ActionButton` marker.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn paint_action_buttons(
|
||||
mut buttons: Query<
|
||||
(&Interaction, &mut BackgroundColor),
|
||||
@@ -894,11 +987,12 @@ fn update_hud(
|
||||
let count = g.undo_count;
|
||||
if count == 0 {
|
||||
**t = String::new();
|
||||
*color = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
|
||||
*color = TextColor(TEXT_PRIMARY);
|
||||
} else {
|
||||
**t = format!("Undos: {count}");
|
||||
// Amber warning: using undo blocks the no-undo achievement.
|
||||
*color = TextColor(Color::srgb(1.0, 0.7, 0.2));
|
||||
// STATE_WARNING signals "you took a penalty" — same hue
|
||||
// as the Recycles counter so they read as one category.
|
||||
*color = TextColor(STATE_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,20 +1119,22 @@ fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the colour for the challenge time-limit HUD label based on seconds remaining.
|
||||
/// Returns the colour for the challenge time-limit HUD label based on
|
||||
/// seconds remaining. Uses theme tokens so the urgency ramp picks up
|
||||
/// palette changes for free.
|
||||
///
|
||||
/// | Remaining | Colour |
|
||||
/// |-------------|--------|
|
||||
/// | ≥ 60 s | Cyan (default) |
|
||||
/// | 30 – 59 s | Orange (warning) |
|
||||
/// | < 30 s | Red (urgent) |
|
||||
/// | Remaining | Token |
|
||||
/// |-------------|------------------|
|
||||
/// | ≥ 60 s | `STATE_INFO` |
|
||||
/// | 30 – 59 s | `STATE_WARNING` |
|
||||
/// | < 30 s | `STATE_DANGER` |
|
||||
pub fn challenge_time_color(remaining: u64) -> Color {
|
||||
if remaining < 30 {
|
||||
Color::srgb(1.0, 0.2, 0.2)
|
||||
STATE_DANGER
|
||||
} else if remaining < 60 {
|
||||
Color::srgb(1.0, 0.6, 0.0)
|
||||
STATE_WARNING
|
||||
} else {
|
||||
Color::srgb(0.4, 0.9, 1.0)
|
||||
STATE_INFO
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1189,39 +1285,39 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_above_60_is_cyan() {
|
||||
fn challenge_time_color_above_60_is_info() {
|
||||
let c = challenge_time_color(61);
|
||||
assert_eq!(c, Color::srgb(0.4, 0.9, 1.0));
|
||||
assert_eq!(c, STATE_INFO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_exactly_60_is_cyan() {
|
||||
fn challenge_time_color_exactly_60_is_info() {
|
||||
let c = challenge_time_color(60);
|
||||
assert_eq!(c, Color::srgb(0.4, 0.9, 1.0));
|
||||
assert_eq!(c, STATE_INFO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_59_is_orange() {
|
||||
fn challenge_time_color_59_is_warning() {
|
||||
let c = challenge_time_color(59);
|
||||
assert_eq!(c, Color::srgb(1.0, 0.6, 0.0));
|
||||
assert_eq!(c, STATE_WARNING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_30_is_orange() {
|
||||
fn challenge_time_color_30_is_warning() {
|
||||
let c = challenge_time_color(30);
|
||||
assert_eq!(c, Color::srgb(1.0, 0.6, 0.0));
|
||||
assert_eq!(c, STATE_WARNING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_29_is_red() {
|
||||
fn challenge_time_color_29_is_danger() {
|
||||
let c = challenge_time_color(29);
|
||||
assert_eq!(c, Color::srgb(1.0, 0.2, 0.2));
|
||||
assert_eq!(c, STATE_DANGER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_zero_is_red() {
|
||||
fn challenge_time_color_zero_is_danger() {
|
||||
let c = challenge_time_color(0);
|
||||
assert_eq!(c, Color::srgb(1.0, 0.2, 0.2));
|
||||
assert_eq!(c, STATE_DANGER);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -30,6 +30,8 @@ pub mod stats_plugin;
|
||||
pub mod sync_plugin;
|
||||
pub mod table_plugin;
|
||||
pub mod time_attack_plugin;
|
||||
pub mod ui_modal;
|
||||
pub mod ui_theme;
|
||||
pub mod weekly_goals_plugin;
|
||||
pub mod win_summary_plugin;
|
||||
|
||||
@@ -95,6 +97,11 @@ pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScroll
|
||||
pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
|
||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||
pub use ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
spawn_modal_header, ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard,
|
||||
ModalHeader, ModalScrim, UiModalPlugin,
|
||||
};
|
||||
pub use table_plugin::{
|
||||
BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
//! Reusable modal-overlay primitive: a uniform scrim + centred card with
|
||||
//! header / body / actions slots, plus a button variant system that maps
|
||||
//! to the design tokens in [`crate::ui_theme`].
|
||||
//!
|
||||
//! The audit found that the 11 existing overlay screens used three
|
||||
//! different visual styles (card-centred dialog, bare full-screen, and
|
||||
//! one outlier) with scrim alpha drift between 0.60 and 0.92. Every
|
||||
//! overlay built its own root `Node` and its own colour decisions.
|
||||
//!
|
||||
//! This module collapses all of that into a single helper. Each
|
||||
//! conversion commit replaces an overlay's bespoke spawn function with
|
||||
//! a call to [`spawn_modal`] plus body content built in a closure.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! spawn_modal(
|
||||
//! &mut commands,
|
||||
//! ConfirmNewGameScreen,
|
||||
//! ui_theme::Z_MODAL_PANEL,
|
||||
//! |card| {
|
||||
//! spawn_modal_header(card, "Abandon current game?", font_res);
|
||||
//! spawn_modal_body_text(
|
||||
//! card,
|
||||
//! "Your progress will be lost.",
|
||||
//! ui_theme::TEXT_SECONDARY,
|
||||
//! font_res,
|
||||
//! );
|
||||
//! spawn_modal_actions(card, |actions| {
|
||||
//! spawn_modal_button(
|
||||
//! actions,
|
||||
//! CancelButton,
|
||||
//! "Cancel",
|
||||
//! Some("Esc"),
|
||||
//! ButtonVariant::Secondary,
|
||||
//! font_res,
|
||||
//! );
|
||||
//! spawn_modal_button(
|
||||
//! actions,
|
||||
//! ConfirmButton,
|
||||
//! "Yes, abandon",
|
||||
//! Some("Y"),
|
||||
//! ButtonVariant::Primary,
|
||||
//! font_res,
|
||||
//! );
|
||||
//! });
|
||||
//! },
|
||||
//! );
|
||||
//! ```
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
|
||||
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, RADIUS_LG, RADIUS_MD,
|
||||
SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2,
|
||||
VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Marker components — let click handlers query / paint systems target /
|
||||
// despawn helpers find every part of a standard modal.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker on the full-screen scrim entity. Carries `BackgroundColor`
|
||||
/// `SCRIM` and the modal's z-index.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ModalScrim;
|
||||
|
||||
/// Marker on the centred card entity. Child of the scrim.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ModalCard;
|
||||
|
||||
/// Marker on a header `Text` (`TYPE_HEADLINE` + `TEXT_PRIMARY`).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ModalHeader;
|
||||
|
||||
/// Marker on a body paragraph `Text`.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ModalBody;
|
||||
|
||||
/// Marker on the actions row (flex-row, justify-end).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ModalActions;
|
||||
|
||||
/// Marker on a button inside a modal. Carries its variant so the paint
|
||||
/// system can recolour it on hover / press.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct ModalButton(pub ButtonVariant);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Button variants — three rungs of emphasis. A single overlay should have
|
||||
// at most one Primary; Secondary and Tertiary fill out the rest.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Visual emphasis tier applied to a [`ModalButton`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ButtonVariant {
|
||||
/// Loud yellow CTA — Confirm, Play Again. One per modal; right-aligned.
|
||||
Primary,
|
||||
/// Mid-emphasis — Cancel, Close, Done.
|
||||
Secondary,
|
||||
/// Low-emphasis — Quit, secondary navigation.
|
||||
Tertiary,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawns a full-screen scrim and a centred card. The closure populates
|
||||
/// the card's children — typically `spawn_modal_header`,
|
||||
/// `spawn_modal_body_text`, and `spawn_modal_actions`.
|
||||
///
|
||||
/// Returns the scrim entity so callers can despawn the whole modal with
|
||||
/// a single `commands.entity(scrim).despawn()` call (Bevy's hierarchy
|
||||
/// despawn cascades to the card and its descendants).
|
||||
///
|
||||
/// `plugin_marker` is the overlay's plugin-specific marker
|
||||
/// (`ConfirmNewGameScreen`, `HelpScreen`, etc.) so plugin click handlers
|
||||
/// can find their own modal.
|
||||
pub fn spawn_modal<M: Component, F>(
|
||||
commands: &mut Commands,
|
||||
plugin_marker: M,
|
||||
z_panel: i32,
|
||||
build_card: F,
|
||||
) -> Entity
|
||||
where
|
||||
F: FnOnce(&mut ChildSpawnerCommands),
|
||||
{
|
||||
commands
|
||||
.spawn((
|
||||
plugin_marker,
|
||||
ModalScrim,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(SCRIM),
|
||||
ZIndex(z_panel),
|
||||
))
|
||||
.with_children(|root| {
|
||||
root.spawn((
|
||||
ModalCard,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_4,
|
||||
padding: UiRect::all(VAL_SPACE_5),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
|
||||
max_width: Val::Px(720.0),
|
||||
min_width: Val::Px(360.0),
|
||||
align_items: AlignItems::Stretch,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED),
|
||||
BorderColor::all(BORDER_STRONG),
|
||||
))
|
||||
.with_children(build_card);
|
||||
})
|
||||
.id()
|
||||
}
|
||||
|
||||
/// Spawns the standard modal header — `TYPE_HEADLINE` + `TEXT_PRIMARY`.
|
||||
pub fn spawn_modal_header(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
title: impl Into<String>,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_HEADLINE,
|
||||
..default()
|
||||
};
|
||||
parent.spawn((
|
||||
ModalHeader,
|
||||
Text::new(title.into()),
|
||||
font,
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
|
||||
/// Spawns a body paragraph at `TYPE_BODY_LG`. Pass `TEXT_PRIMARY` for
|
||||
/// primary copy, `TEXT_SECONDARY` for caption-style supporting copy.
|
||||
pub fn spawn_modal_body_text(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
text: impl Into<String>,
|
||||
color: Color,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
};
|
||||
parent.spawn((
|
||||
ModalBody,
|
||||
Text::new(text.into()),
|
||||
font,
|
||||
TextColor(color),
|
||||
));
|
||||
}
|
||||
|
||||
/// Spawns the bottom actions row — flex-row with primary right-aligned.
|
||||
/// The closure populates the row's buttons via `spawn_modal_button`.
|
||||
pub fn spawn_modal_actions<F>(parent: &mut ChildSpawnerCommands, build_buttons: F)
|
||||
where
|
||||
F: FnOnce(&mut ChildSpawnerCommands),
|
||||
{
|
||||
parent
|
||||
.spawn((
|
||||
ModalActions,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_3,
|
||||
justify_content: JustifyContent::FlexEnd,
|
||||
margin: UiRect::top(VAL_SPACE_2),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(build_buttons);
|
||||
}
|
||||
|
||||
/// Spawns a real `Button` entity with consistent geometry, colours, and
|
||||
/// optional hotkey-hint chip.
|
||||
///
|
||||
/// `marker` is the click-handler-targeting component (e.g.
|
||||
/// `ConfirmYesButton`); plugin systems query for it on
|
||||
/// `Changed<Interaction>` to detect clicks.
|
||||
pub fn spawn_modal_button<M: Component>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
marker: M,
|
||||
label: impl Into<String>,
|
||||
hotkey: Option<&'static str>,
|
||||
variant: ButtonVariant,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_label = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
};
|
||||
let font_caption = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
|
||||
let label_color = match variant {
|
||||
// Primary buttons sit on the loud yellow accent — dark text on
|
||||
// top reads well and passes AAA contrast.
|
||||
ButtonVariant::Primary => BG_BASE,
|
||||
ButtonVariant::Secondary | ButtonVariant::Tertiary => TEXT_PRIMARY,
|
||||
};
|
||||
let caption_color = match variant {
|
||||
// Use a slightly muted version of the label colour so the chip
|
||||
// reads as a secondary detail without disappearing.
|
||||
ButtonVariant::Primary => Color::srgba(0.0, 0.0, 0.0, 0.55),
|
||||
ButtonVariant::Secondary | ButtonVariant::Tertiary => TEXT_SECONDARY,
|
||||
};
|
||||
|
||||
parent
|
||||
.spawn((
|
||||
marker,
|
||||
ModalButton(variant),
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(idle_bg(variant)),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((Text::new(label.into()), font_label, TextColor(label_color)));
|
||||
if let Some(key) = hotkey {
|
||||
b.spawn((Text::new(key), font_caption, TextColor(caption_color)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers + paint system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Idle-state background colour for a button variant.
|
||||
fn idle_bg(variant: ButtonVariant) -> Color {
|
||||
match variant {
|
||||
ButtonVariant::Primary => ACCENT_PRIMARY,
|
||||
// Secondary sits at a higher elevation than Tertiary at idle so
|
||||
// the hierarchy reads even before hover; the paint system then
|
||||
// bumps each variant one rung on hover.
|
||||
ButtonVariant::Secondary => BG_ELEVATED_HI,
|
||||
ButtonVariant::Tertiary => BG_ELEVATED,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hover-state background colour. Each variant steps up one rung from
|
||||
/// its idle colour so idle / hover / pressed are visually distinct.
|
||||
fn hover_bg(variant: ButtonVariant) -> Color {
|
||||
match variant {
|
||||
ButtonVariant::Primary => ACCENT_PRIMARY_HOVER,
|
||||
ButtonVariant::Secondary => BG_ELEVATED_TOP,
|
||||
ButtonVariant::Tertiary => BG_ELEVATED_HI,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pressed-state background colour. Primary swaps to the magenta
|
||||
/// secondary accent for a moment of celebration; Secondary darkens to
|
||||
/// the base elevation; Tertiary darkens further.
|
||||
fn pressed_bg(variant: ButtonVariant) -> Color {
|
||||
match variant {
|
||||
ButtonVariant::Primary => ACCENT_SECONDARY,
|
||||
ButtonVariant::Secondary => BG_ELEVATED,
|
||||
ButtonVariant::Tertiary => BG_ELEVATED_PRESSED,
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints every `ModalButton` on `Changed<Interaction>` so hover and
|
||||
/// press states are visible without each overlay registering its own
|
||||
/// paint system.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn paint_modal_buttons(
|
||||
mut buttons: Query<
|
||||
(&Interaction, &ModalButton, &mut BackgroundColor),
|
||||
Changed<Interaction>,
|
||||
>,
|
||||
) {
|
||||
for (interaction, modal_button, mut bg) in &mut buttons {
|
||||
bg.0 = match interaction {
|
||||
Interaction::Pressed => pressed_bg(modal_button.0),
|
||||
Interaction::Hovered => hover_bg(modal_button.0),
|
||||
Interaction::None => idle_bg(modal_button.0),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers `paint_modal_buttons` so every `ModalButton` automatically
|
||||
/// gets hover / press feedback. Add this plugin to the app once;
|
||||
/// individual overlay plugins don't need their own paint systems.
|
||||
pub struct UiModalPlugin;
|
||||
|
||||
impl Plugin for UiModalPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, paint_modal_buttons);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Idle / hover / pressed cycle through three distinct colours per
|
||||
/// variant — guards against a future refactor accidentally mapping
|
||||
/// two states to the same colour.
|
||||
#[test]
|
||||
fn paint_states_are_distinct_per_variant() {
|
||||
for variant in [
|
||||
ButtonVariant::Primary,
|
||||
ButtonVariant::Secondary,
|
||||
ButtonVariant::Tertiary,
|
||||
] {
|
||||
let i = idle_bg(variant);
|
||||
let h = hover_bg(variant);
|
||||
let p = pressed_bg(variant);
|
||||
assert_ne!(i, h, "idle and hover must differ for {variant:?}");
|
||||
assert_ne!(h, p, "hover and pressed must differ for {variant:?}");
|
||||
assert_ne!(i, p, "idle and pressed must differ for {variant:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_modal_plugin_registers_paint_system() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(UiModalPlugin);
|
||||
// App built without panic — paint_modal_buttons is registered.
|
||||
app.update();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Centralised UI design tokens — colours, typography, spacing, radius,
|
||||
//! z-index hierarchy, and motion durations.
|
||||
//!
|
||||
//! Every UI surface (HUD, modals, popovers, toasts) reads from these
|
||||
//! tokens instead of hardcoding hex codes or magic numbers. The audit
|
||||
//! that produced this module found 40+ scattered colour literals, 12+
|
||||
//! distinct font sizes, and 8+ hardcoded z-index values across the
|
||||
//! engine; collapsing them into one source of truth keeps the visual
|
||||
//! system coherent and makes future palette swaps a single-file change.
|
||||
//!
|
||||
//! Palette is "Midnight Purple + Balatro accent" — see the 2026-04-30
|
||||
//! UX overhaul Phase 2 proposal for the rationale behind specific
|
||||
//! values. The tokens are exposed as `pub const` so static contexts
|
||||
//! (default colours on Sprite components, etc.) can use them; a future
|
||||
//! `UiTheme` resource can layer runtime switching on top without
|
||||
//! changing the constant API.
|
||||
|
||||
use bevy::color::Color;
|
||||
use bevy::prelude::Val;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colours — Midnight Purple base with a Balatro-yellow primary accent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Window backstop and the default text colour on top of `ACCENT_PRIMARY`.
|
||||
/// Deep midnight purple, near-black. `#1A0F2E`.
|
||||
pub const BG_BASE: Color = Color::srgb(0.102, 0.059, 0.180);
|
||||
|
||||
/// Elevated surface — modal cards, popover panels, button backgrounds.
|
||||
/// One step lighter than `BG_BASE` so cards visually float above the
|
||||
/// felt without needing real drop shadows. `#2D1B69`.
|
||||
pub const BG_ELEVATED: Color = Color::srgb(0.176, 0.106, 0.412);
|
||||
|
||||
/// Hovered/highlighted surface — used on button hover and on the
|
||||
/// currently-active row of a popover. `#3A2580`.
|
||||
pub const BG_ELEVATED_HI: Color = Color::srgb(0.227, 0.145, 0.502);
|
||||
|
||||
/// Top elevation step — Secondary button hover, popover currently-
|
||||
/// hovered row. One rung above `BG_ELEVATED_HI`. `#482F97`.
|
||||
pub const BG_ELEVATED_TOP: Color = Color::srgb(0.282, 0.184, 0.592);
|
||||
|
||||
/// Pressed-button surface — `BG_ELEVATED` darkened ~15%. `#26155B`.
|
||||
pub const BG_ELEVATED_PRESSED: Color = Color::srgb(0.149, 0.082, 0.357);
|
||||
|
||||
/// Uniform scrim under every modal. The audit found 0.60–0.92 alpha
|
||||
/// drift across 11 overlay plugins; this single value replaces all of
|
||||
/// them. `rgba(13, 7, 28, 0.85)`.
|
||||
pub const SCRIM: Color = Color::srgba(0.051, 0.027, 0.110, 0.85);
|
||||
|
||||
/// Primary text — warm off-white with a hint of purple to fit the
|
||||
/// midnight palette without feeling clinical. `#F5F0FF`.
|
||||
pub const TEXT_PRIMARY: Color = Color::srgb(0.961, 0.941, 1.000);
|
||||
|
||||
/// Secondary text — captions, hints, muted labels. Lavender-grey.
|
||||
/// `#B5A8D5`.
|
||||
pub const TEXT_SECONDARY: Color = Color::srgb(0.710, 0.659, 0.835);
|
||||
|
||||
/// Disabled text — greyed-out buttons, locked items. `#6B5F85`.
|
||||
pub const TEXT_DISABLED: Color = Color::srgb(0.420, 0.373, 0.522);
|
||||
|
||||
/// Balatro-yellow primary accent — the loudest colour in the palette.
|
||||
/// Reserved for primary actions (Confirm, Play Again), win states, and
|
||||
/// "look here" callouts. `BG_BASE` text on top of this colour passes
|
||||
/// AAA contrast. `#FFD23F`.
|
||||
pub const ACCENT_PRIMARY: Color = Color::srgb(1.000, 0.824, 0.247);
|
||||
|
||||
/// Brightened `ACCENT_PRIMARY` for hover states on primary buttons.
|
||||
/// Picks up saturation while keeping the same hue. `#FFE36B`.
|
||||
pub const ACCENT_PRIMARY_HOVER: Color = Color::srgb(1.000, 0.890, 0.420);
|
||||
|
||||
/// Warm magenta secondary accent — celebratory states (achievement
|
||||
/// unlocked, streak milestones). Used sparingly so it stays special.
|
||||
/// `#FF6B9D`.
|
||||
pub const ACCENT_SECONDARY: Color = Color::srgb(1.000, 0.420, 0.616);
|
||||
|
||||
/// Success — foundation completion, valid drop tint, sync OK. `#4ADE80`.
|
||||
pub const STATE_SUCCESS: Color = Color::srgb(0.290, 0.871, 0.502);
|
||||
|
||||
/// Warning — penalty signal. **Both** Undo and Recycle counters use
|
||||
/// this when non-zero (the audit found these were inconsistent — Undos
|
||||
/// amber, Recycles white). `#FFA94D`.
|
||||
pub const STATE_WARNING: Color = Color::srgb(1.000, 0.663, 0.302);
|
||||
|
||||
/// Danger — rejection shake, illegal placement, sync error. `#F77272`.
|
||||
pub const STATE_DANGER: Color = Color::srgb(0.969, 0.447, 0.447);
|
||||
|
||||
/// Info — daily-challenge constraint, draw-cycle indicator. `#6BBBFF`.
|
||||
pub const STATE_INFO: Color = Color::srgb(0.420, 0.733, 1.000);
|
||||
|
||||
/// Subtle border — default popover, card, and idle button outline.
|
||||
pub const BORDER_SUBTLE: Color = Color::srgba(0.647, 0.549, 1.000, 0.12);
|
||||
|
||||
/// Strong border — hover outline, focused button, active popover.
|
||||
pub const BORDER_STRONG: Color = Color::srgba(0.647, 0.549, 1.000, 0.30);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typography scale (px) — 5 rungs replace the prior
|
||||
// 14/15/16/17/18/22/26/28/30/32/40/48 jungle. All UI uses FiraMono via
|
||||
// `FontResource`; sizes carry the hierarchy.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Display titles — Home, Win Summary, Onboarding header. 40 px.
|
||||
pub const TYPE_DISPLAY: f32 = 40.0;
|
||||
|
||||
/// Modal / overlay headers. 26 px.
|
||||
pub const TYPE_HEADLINE: f32 = 26.0;
|
||||
|
||||
/// Primary HUD numbers, button labels, body copy that needs weight.
|
||||
/// 18 px.
|
||||
pub const TYPE_BODY_LG: f32 = 18.0;
|
||||
|
||||
/// Secondary HUD, body copy, list items. 14 px.
|
||||
pub const TYPE_BODY: f32 = 14.0;
|
||||
|
||||
/// Hotkey-hint chips, microcopy, dates. 11 px.
|
||||
pub const TYPE_CAPTION: f32 = 11.0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spacing scale (px) — 4-multiple rungs. Every padding, margin, and gap
|
||||
// in the engine snaps to one of these.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 4 px — inline padding, chip gap.
|
||||
pub const SPACE_1: f32 = 4.0;
|
||||
/// 8 px — default gap between row items.
|
||||
pub const SPACE_2: f32 = 8.0;
|
||||
/// 12 px — standard button padding-X, action row gap.
|
||||
pub const SPACE_3: f32 = 12.0;
|
||||
/// 16 px — section gap inside a modal.
|
||||
pub const SPACE_4: f32 = 16.0;
|
||||
/// 24 px — modal card outer padding.
|
||||
pub const SPACE_5: f32 = 24.0;
|
||||
/// 32 px — block separator inside large overlays.
|
||||
pub const SPACE_6: f32 = 32.0;
|
||||
/// 48 px — outer modal margin from the window edge.
|
||||
pub const SPACE_7: f32 = 48.0;
|
||||
|
||||
/// `Val::Px` form of `SPACE_1`, for ergonomic Node construction.
|
||||
pub const VAL_SPACE_1: Val = Val::Px(SPACE_1);
|
||||
/// `Val::Px` form of `SPACE_2`.
|
||||
pub const VAL_SPACE_2: Val = Val::Px(SPACE_2);
|
||||
/// `Val::Px` form of `SPACE_3`.
|
||||
pub const VAL_SPACE_3: Val = Val::Px(SPACE_3);
|
||||
/// `Val::Px` form of `SPACE_4`.
|
||||
pub const VAL_SPACE_4: Val = Val::Px(SPACE_4);
|
||||
/// `Val::Px` form of `SPACE_5`.
|
||||
pub const VAL_SPACE_5: Val = Val::Px(SPACE_5);
|
||||
/// `Val::Px` form of `SPACE_6`.
|
||||
pub const VAL_SPACE_6: Val = Val::Px(SPACE_6);
|
||||
/// `Val::Px` form of `SPACE_7`.
|
||||
pub const VAL_SPACE_7: Val = Val::Px(SPACE_7);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Border radius (px)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 4 px — hotkey chip, inline pill.
|
||||
pub const RADIUS_SM: f32 = 4.0;
|
||||
/// 8 px — buttons, popover panels.
|
||||
pub const RADIUS_MD: f32 = 8.0;
|
||||
/// 16 px — modal cards.
|
||||
pub const RADIUS_LG: f32 = 16.0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Z-index hierarchy — replaces 8+ scattered magic numbers across plugins
|
||||
// (background, pile markers, HUD, several overlay tiers, win cascade,
|
||||
// toasts). Documented order:
|
||||
//
|
||||
// background → pile markers → cards → HUD → HUD popovers →
|
||||
// modal scrim → modal panel → pause → onboarding →
|
||||
// win cascade → toasts (always on top)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const Z_BACKGROUND: i32 = -10;
|
||||
pub const Z_PILE_MARKER: i32 = -1;
|
||||
/// Base layer for HUD readouts (top-left).
|
||||
pub const Z_HUD: i32 = 50;
|
||||
/// Action bar + popovers — above HUD readouts so dropdowns can overlap.
|
||||
pub const Z_HUD_TOP: i32 = 60;
|
||||
pub const Z_MODAL_SCRIM: i32 = 200;
|
||||
pub const Z_MODAL_PANEL: i32 = 210;
|
||||
/// Pause overlay outranks normal modals — pausing should always be on top.
|
||||
pub const Z_PAUSE: i32 = 220;
|
||||
pub const Z_ONBOARDING: i32 = 230;
|
||||
/// Win cascade sits between modals and toasts so the celebration plays
|
||||
/// over a paused / mid-modal screen.
|
||||
pub const Z_WIN_CASCADE: i32 = 300;
|
||||
/// Toasts always render above everything else.
|
||||
pub const Z_TOAST: i32 = 400;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Motion — durations in seconds at `AnimSpeed::Normal`. `Fast` halves
|
||||
// every value, `Instant` zeroes them. Use `scaled_duration` to apply.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Card slide during gameplay — tweened with `MotionCurve::SmoothSnap`
|
||||
/// (ease-out-cubic). 180 ms; bumped from the old 150 ms because cubic
|
||||
/// feels slower at endpoints — the perceived speed is unchanged.
|
||||
pub const MOTION_SLIDE_SECS: f32 = 0.18;
|
||||
|
||||
/// Settle bounce on placement — only the moved card, not every top
|
||||
/// card on every state change. 180 ms.
|
||||
pub const MOTION_SETTLE_SECS: f32 = 0.18;
|
||||
|
||||
/// Shake on rejected drop — tightened from 300 ms; frequency drops to
|
||||
/// 35 rad/s to match the new settle bounce so the two feedback signals
|
||||
/// no longer feel discordant. 250 ms.
|
||||
pub const MOTION_SHAKE_SECS: f32 = 0.25;
|
||||
|
||||
/// Shake angular frequency in rad/s.
|
||||
pub const MOTION_SHAKE_OMEGA: f32 = 35.0;
|
||||
|
||||
/// Card flip — half-time per phase (squash + grow). 100 ms each =
|
||||
/// 200 ms total. Pair with a ±8° Z-rotation at the midpoint for a 3D
|
||||
/// feel without 3D rendering.
|
||||
pub const MOTION_FLIP_HALF_SECS: f32 = 0.10;
|
||||
|
||||
/// Per-card stagger on the new-game deal animation — centre value;
|
||||
/// each card gets ±10% jitter applied at deal time so the deal feels
|
||||
/// organic instead of mechanical. 40 ms.
|
||||
pub const MOTION_DEAL_STAGGER_SECS: f32 = 0.04;
|
||||
|
||||
/// Deal slide duration with an `ease-out` curve and a 40 ms
|
||||
/// scale-pop on land so cards "arrive" instead of just stopping.
|
||||
/// 280 ms.
|
||||
pub const MOTION_DEAL_SLIDE_SECS: f32 = 0.28;
|
||||
|
||||
/// Win cascade per-card stagger — slightly slower than the prior
|
||||
/// 50 ms for a more theatrical feel. 60 ms.
|
||||
pub const MOTION_CASCADE_STAGGER_SECS: f32 = 0.06;
|
||||
|
||||
/// Win cascade per-card slide — uses `MotionCurve::Expressive`
|
||||
/// (overshoot) plus ±15° Z-rotation. 500 ms.
|
||||
pub const MOTION_CASCADE_SLIDE_SECS: f32 = 0.50;
|
||||
|
||||
/// Screen shake on win — wider and longer than the old 0.6 s / 8 px.
|
||||
/// 800 ms.
|
||||
pub const MOTION_WIN_SHAKE_SECS: f32 = 0.80;
|
||||
|
||||
/// Peak displacement of the win screen shake. 12 px.
|
||||
pub const MOTION_WIN_SHAKE_AMPLITUDE: f32 = 12.0;
|
||||
|
||||
/// Toast in — scale-from-0.92-to-1.0 fade-in. 200 ms.
|
||||
pub const MOTION_TOAST_IN_SECS: f32 = 0.20;
|
||||
|
||||
/// Toast out — fade. 250 ms.
|
||||
pub const MOTION_TOAST_OUT_SECS: f32 = 0.25;
|
||||
|
||||
/// Modal in/out — scale-from-0.96-to-1.0 + scrim fade. 220 ms.
|
||||
pub const MOTION_MODAL_SECS: f32 = 0.22;
|
||||
|
||||
/// Button hover/press colour blend — short, snappy. 100 ms.
|
||||
pub const MOTION_BUTTON_BLEND_SECS: f32 = 0.10;
|
||||
|
||||
/// Score-pulse — when score increases by ≥ 50, briefly scale the
|
||||
/// readout 1.0 → 1.1 → 1.0. 250 ms.
|
||||
pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25;
|
||||
|
||||
/// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step.
|
||||
/// 400 ms.
|
||||
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Scales a `MOTION_*_SECS` value by the player's animation-speed
|
||||
/// preference. `Normal` × 1.0, `Fast` × 0.5, `Instant` → 0.
|
||||
///
|
||||
/// Pass any duration constant from this module through this helper
|
||||
/// before handing it to a tween. The audit found that only slide and
|
||||
/// cascade respected `AnimSpeed`; toasts, deal stagger, shake, and
|
||||
/// settle were hardcoded. Routing every duration through this function
|
||||
/// fixes that.
|
||||
pub fn scaled_duration(secs: f32, speed: AnimSpeed) -> f32 {
|
||||
match speed {
|
||||
AnimSpeed::Normal => secs,
|
||||
AnimSpeed::Fast => secs * 0.5,
|
||||
AnimSpeed::Instant => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every spacing rung is a positive multiple of 4 — keeps the scale
|
||||
/// honest if someone tweaks values later.
|
||||
#[test]
|
||||
fn spacing_scale_is_a_4_multiple_geometric_progression() {
|
||||
for v in [SPACE_1, SPACE_2, SPACE_3, SPACE_4, SPACE_5, SPACE_6, SPACE_7] {
|
||||
assert!(v > 0.0, "spacing tokens must be positive");
|
||||
assert!(
|
||||
(v.rem_euclid(4.0)).abs() < f32::EPSILON,
|
||||
"spacing token {v} must be a 4-multiple"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Type scale is monotonically decreasing display → caption.
|
||||
#[test]
|
||||
fn type_scale_is_monotonically_decreasing() {
|
||||
let scale = [TYPE_DISPLAY, TYPE_HEADLINE, TYPE_BODY_LG, TYPE_BODY, TYPE_CAPTION];
|
||||
for window in scale.windows(2) {
|
||||
assert!(
|
||||
window[0] > window[1],
|
||||
"type scale must be monotonically decreasing: {} should be > {}",
|
||||
window[0],
|
||||
window[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Z-index hierarchy is monotonically increasing through documented
|
||||
/// layers, so a future add-a-layer change can't accidentally land
|
||||
/// in the wrong slot.
|
||||
#[test]
|
||||
fn z_index_hierarchy_is_monotonically_increasing() {
|
||||
let layers = [
|
||||
Z_BACKGROUND,
|
||||
Z_PILE_MARKER,
|
||||
Z_HUD,
|
||||
Z_HUD_TOP,
|
||||
Z_MODAL_SCRIM,
|
||||
Z_MODAL_PANEL,
|
||||
Z_PAUSE,
|
||||
Z_ONBOARDING,
|
||||
Z_WIN_CASCADE,
|
||||
Z_TOAST,
|
||||
];
|
||||
for window in layers.windows(2) {
|
||||
assert!(
|
||||
window[0] < window[1],
|
||||
"z-index hierarchy must be monotonically increasing: {} should be < {}",
|
||||
window[0],
|
||||
window[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_duration_matches_anim_speed() {
|
||||
assert!((scaled_duration(0.18, AnimSpeed::Normal) - 0.18).abs() < f32::EPSILON);
|
||||
assert!((scaled_duration(0.18, AnimSpeed::Fast) - 0.09).abs() < f32::EPSILON);
|
||||
assert_eq!(scaled_duration(0.18, AnimSpeed::Instant), 0.0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user