4303ef3f5b
Adds DifficultyLevel (Easy/Medium/Hard/Expert/Grandmaster/Random) to solitaire_core::game_state alongside GameMode::Difficulty(DifficultyLevel). Five seed catalogs (40 seeds each) are pre-verified by the new gen_difficulty_seeds binary using tiered solver budgets (1K–200K moves). DifficultyPlugin resolves StartDifficultyRequestEvent → catalog seed → NewGameRequestEvent; Random uses a system-time seed and bypasses the winnable-only filter. The home overlay gets an expandable Difficulty section between Draw Mode and the mode grid; last-played tier persists in Settings. Difficulty wins pool into Classic stats. 5 unit tests in difficulty_plugin. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2086 lines
76 KiB
Rust
2086 lines
76 KiB
Rust
//! Mode-launcher overlay shown when the player presses **M** or clicks the
|
|
//! Modes affordance.
|
|
//!
|
|
//! Replaces the prior "keyboard shortcut reference" Home modal with a
|
|
//! vertical stack of five mode cards — Classic, Daily Challenge, Zen,
|
|
//! Challenge, Time Attack. Clicking a card fires the same launch event
|
|
//! the corresponding hotkey does, then closes the overlay. The shortcut
|
|
//! reference now lives only in Help (`F1`), which is the canonical place
|
|
//! for that information.
|
|
//!
|
|
//! Level-gated modes (Zen, Challenge, Time Attack) are disabled below
|
|
//! `CHALLENGE_UNLOCK_LEVEL`; clicking a locked card fires an
|
|
//! [`InfoToastEvent`] explaining the gate but does not launch the mode
|
|
//! or close the overlay.
|
|
|
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
|
use bevy::input::ButtonInput;
|
|
use bevy::prelude::*;
|
|
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
|
use solitaire_data::save_settings_to;
|
|
|
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
|
use crate::events::{
|
|
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
|
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
|
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent,
|
|
};
|
|
use crate::font_plugin::FontResource;
|
|
use crate::progress_plugin::ProgressResource;
|
|
use crate::settings_plugin::{
|
|
SettingsChangedEvent, SettingsResource, SettingsStoragePath,
|
|
};
|
|
use crate::stats_plugin::StatsResource;
|
|
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
|
|
use crate::ui_modal::{
|
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
|
ScrimDismissible,
|
|
};
|
|
use crate::ui_theme::{
|
|
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
|
|
RADIUS_MD, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
|
TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public marker components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Marker component on the Home overlay root entity (the modal scrim).
|
|
#[derive(Component, Debug)]
|
|
pub struct HomeScreen;
|
|
|
|
/// Marker on the bottom-row "Cancel" button that dismisses the Home modal
|
|
/// without launching a mode.
|
|
#[derive(Component, Debug)]
|
|
pub struct HomeCancelButton;
|
|
|
|
/// Marker on the player-stats chip strip at the top of the Home modal.
|
|
/// Clicking the strip opens the Profile overlay so the player can drill
|
|
/// into level / XP / cosmetics without first dismissing Home.
|
|
#[derive(Component, Debug)]
|
|
struct HomeProfileChip;
|
|
|
|
/// Marker on the "Draw 1" toggle button inside the Home modal's
|
|
/// draw-mode row. Clicking flips `Settings.draw_mode` to `DrawOne` and
|
|
/// fires `SettingsChangedEvent` so audio / UI dependents react.
|
|
#[derive(Component, Debug)]
|
|
struct HomeDrawOneButton;
|
|
|
|
/// Marker on the "Draw 3" toggle button inside the Home modal's
|
|
/// draw-mode row. Mirror of [`HomeDrawOneButton`] for `DrawThree`.
|
|
#[derive(Component, Debug)]
|
|
struct HomeDrawThreeButton;
|
|
|
|
/// Marker on the scrollable inner Node containing the player chips,
|
|
/// draw-mode row, and tile grid. Wrapping these in a scrollable
|
|
/// container keeps the modal usable on small viewports — without it,
|
|
/// the 3-row tile stack pushes the Cancel button off the bottom of
|
|
/// the screen on 800x600 hardware. Mirrors `SettingsPanelScrollable`.
|
|
#[derive(Component, Debug)]
|
|
struct HomeScrollable;
|
|
|
|
/// Marker on the "▶ Difficulty" / "▼ Difficulty" toggle button that
|
|
/// expands / collapses the difficulty tier chip row.
|
|
#[derive(Component, Debug)]
|
|
struct HomeDifficultyToggle;
|
|
|
|
/// Marker on each difficulty tier chip inside the expanded difficulty
|
|
/// section. The wrapped `DifficultyLevel` identifies which tier was
|
|
/// clicked so the handler can fire `StartDifficultyRequestEvent`.
|
|
#[derive(Component, Debug)]
|
|
struct HomeDifficultyChip(DifficultyLevel);
|
|
|
|
/// Whether the difficulty section is currently expanded. Toggled by
|
|
/// `handle_home_difficulty_toggle` and checked by `spawn_home_screen`
|
|
/// to determine initial render state.
|
|
///
|
|
/// Initialised at plugin startup; `spawn_home_on_launch` upgrades it
|
|
/// to `true` when `settings.last_difficulty` is already set so
|
|
/// returning players see their tier pre-expanded.
|
|
#[derive(Resource, Default, Debug)]
|
|
pub struct DifficultyExpanded(pub bool);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Private mode-card data shape
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Which game mode a [`HomeModeCard`] represents.
|
|
///
|
|
/// Kept private — external consumers should write the corresponding
|
|
/// `Start*RequestEvent` (or [`NewGameRequestEvent`] for Classic) directly.
|
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum HomeMode {
|
|
Classic,
|
|
Daily,
|
|
Zen,
|
|
Challenge,
|
|
TimeAttack,
|
|
PlayBySeed,
|
|
}
|
|
|
|
impl HomeMode {
|
|
/// Display title shown on the card.
|
|
fn title(self) -> &'static str {
|
|
match self {
|
|
HomeMode::Classic => "Classic",
|
|
HomeMode::Daily => "Daily Challenge",
|
|
HomeMode::Zen => "Zen Mode",
|
|
HomeMode::Challenge => "Challenge",
|
|
HomeMode::TimeAttack => "Time Attack",
|
|
HomeMode::PlayBySeed => "Play by Seed",
|
|
}
|
|
}
|
|
|
|
/// One-line description shown below the title.
|
|
fn description(self) -> &'static str {
|
|
match self {
|
|
HomeMode::Classic => "The standard Klondike deal — score, time, and a fresh shuffle.",
|
|
HomeMode::Daily => "Today's seed, same for everyone. Build a streak.",
|
|
HomeMode::Zen => "No timer, no score. Just the cards.",
|
|
HomeMode::Challenge => "Hand-picked hard deals. No undo. Win to advance.",
|
|
HomeMode::TimeAttack => "How many can you finish in ten minutes?",
|
|
HomeMode::PlayBySeed => "Enter any number to play a specific deal.",
|
|
}
|
|
}
|
|
|
|
/// Unicode glyph rendered as the picture-tile centrepiece. Stand-in
|
|
/// for real per-mode artwork — chosen for one-glyph-tells-the-mode
|
|
/// readability rather than visual fidelity. Swap to `Image` nodes
|
|
/// when art lands; the rest of the tile layout doesn't change.
|
|
///
|
|
/// Picks are constrained to **card suits** (U+2660-2666) and basic
|
|
/// **Geometric Shapes** (U+25xx) — the two ranges the bundled
|
|
/// FiraMono-Medium face actually covers. Earlier choices in
|
|
/// Dingbats (★ ❀ ✦) and Misc Symbols (⌚) rendered as
|
|
/// missing-glyph rectangles because FiraMono's coverage there is
|
|
/// minimal.
|
|
fn glyph(self) -> &'static str {
|
|
match self {
|
|
// Black club — card suit, the obvious solitaire mark.
|
|
HomeMode::Classic => "\u{2663}",
|
|
// Black diamond — Geometric Shapes; reads as the day's gem.
|
|
HomeMode::Daily => "\u{25C6}",
|
|
// White circle — Geometric Shapes; reads as the Zen enso.
|
|
HomeMode::Zen => "\u{25CB}",
|
|
// Black up-pointing triangle — Geometric Shapes; reads as
|
|
// a mountain / a step up in difficulty.
|
|
HomeMode::Challenge => "\u{25B2}",
|
|
// Rightwards arrow — Arrows block (U+2190-21FF), a core
|
|
// range every dev-oriented monospace font (FiraMono
|
|
// included) ships. Reads as "go / fast-forward" for the
|
|
// timed mode. Earlier ▶ (U+25B6) did not render; FiraMono
|
|
// ships ▲ (up triangle) but evidently not the sideways
|
|
// siblings.
|
|
HomeMode::TimeAttack => "\u{2192}",
|
|
// Number sign — ASCII, universally available. Reads as
|
|
// "a specific number / seed ID".
|
|
HomeMode::PlayBySeed => "#",
|
|
}
|
|
}
|
|
|
|
/// The keyboard accelerator that dispatches the same launch event,
|
|
/// shown in a small chip on the card.
|
|
fn hotkey(self) -> &'static str {
|
|
match self {
|
|
HomeMode::Classic => "N",
|
|
HomeMode::Daily => "C",
|
|
HomeMode::Zen => "Z",
|
|
HomeMode::Challenge => "X",
|
|
HomeMode::TimeAttack => "T",
|
|
HomeMode::PlayBySeed => "6",
|
|
}
|
|
}
|
|
|
|
/// `true` when the mode is gated behind `CHALLENGE_UNLOCK_LEVEL`.
|
|
fn requires_unlock(self) -> bool {
|
|
matches!(self, HomeMode::Zen | HomeMode::Challenge | HomeMode::TimeAttack)
|
|
}
|
|
|
|
/// `true` if the player at `level` is allowed to launch the mode.
|
|
fn is_unlocked(self, level: u32) -> bool {
|
|
!self.requires_unlock() || level >= CHALLENGE_UNLOCK_LEVEL
|
|
}
|
|
}
|
|
|
|
/// Marker component placed on each mode-card `Button` so the click
|
|
/// handler can identify which mode was pressed.
|
|
#[derive(Component, Debug)]
|
|
struct HomeModeCard(HomeMode);
|
|
|
|
/// Tracks whether the launch-time Home modal has already been auto-shown
|
|
/// for this app session. Flipped to `true` by [`spawn_home_on_launch`]
|
|
/// the first time it spawns the modal, so the auto-show is one-shot per
|
|
/// process — subsequent dismissals (Cancel / mode pick) don't trigger
|
|
/// a respawn, but the player can still re-open the picker with `M`.
|
|
///
|
|
/// Other plugins (e.g. `game_plugin`'s restore-prompt handler) can flip
|
|
/// the flag manually to suppress the launch auto-show when the player
|
|
/// has already made a launch-time choice through a different surface.
|
|
#[derive(Resource, Debug, Default)]
|
|
pub struct LaunchHomeShown(pub bool);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Plugin
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Registers the M-key toggle, the mode-card click handler, and the
|
|
/// Cancel-button handler.
|
|
///
|
|
/// `auto_show_on_launch` (default true) controls whether the picker
|
|
/// auto-spawns once the splash clears at app start. Headless tests use
|
|
/// [`HomePlugin::headless`] to opt out so each test starts with no
|
|
/// modal in the world.
|
|
pub struct HomePlugin {
|
|
auto_show_on_launch: bool,
|
|
}
|
|
|
|
impl Default for HomePlugin {
|
|
fn default() -> Self {
|
|
Self {
|
|
auto_show_on_launch: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl HomePlugin {
|
|
/// Test-only constructor that disables the launch-time auto-show.
|
|
/// `MinimalPlugins` test setups don't include a splash, so the
|
|
/// gating system would otherwise fire on the first tick and
|
|
/// pre-spawn the modal that every test asserts is absent.
|
|
pub fn headless() -> Self {
|
|
Self {
|
|
auto_show_on_launch: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Plugin for HomePlugin {
|
|
fn build(&self, app: &mut App) {
|
|
// Pre-mark the auto-show as already done in headless mode so the
|
|
// gating system is a permanent no-op for tests.
|
|
app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch))
|
|
.init_resource::<DifficultyExpanded>()
|
|
.add_message::<NewGameRequestEvent>()
|
|
.add_message::<StartZenRequestEvent>()
|
|
.add_message::<StartChallengeRequestEvent>()
|
|
.add_message::<StartTimeAttackRequestEvent>()
|
|
.add_message::<StartDailyChallengeRequestEvent>()
|
|
.add_message::<StartPlayBySeedRequestEvent>()
|
|
.add_message::<StartDifficultyRequestEvent>()
|
|
.add_message::<InfoToastEvent>()
|
|
.add_message::<ToggleProfileRequestEvent>()
|
|
.add_message::<SettingsChangedEvent>()
|
|
// Defensively register MouseWheel so `scroll_home_panel`
|
|
// runs cleanly under MinimalPlugins headless tests too.
|
|
.add_message::<MouseWheel>()
|
|
// `.chain()` because several systems (M-toggle, card click,
|
|
// cancel button, digit-key shortcut, difficulty handlers)
|
|
// all read the `HomeScreen` entity and may queue a despawn
|
|
// on it in the same tick. Chaining serialises these systems
|
|
// and keeps the despawn deterministic.
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
spawn_home_on_launch,
|
|
toggle_home_screen,
|
|
attach_focusable_to_home_mode_cards,
|
|
handle_home_card_click,
|
|
handle_home_cancel_button,
|
|
handle_home_profile_chip,
|
|
handle_home_draw_mode_buttons,
|
|
handle_home_difficulty_toggle,
|
|
handle_home_difficulty_chip_click,
|
|
handle_home_digit_keys,
|
|
)
|
|
.chain(),
|
|
)
|
|
.add_systems(Update, scroll_home_panel);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auto-show on launch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Auto-spawns the Home / mode-picker modal once per app session, so
|
|
/// the player lands on a deliberate "what mode do I want to play"
|
|
/// screen instead of the default Classic deal.
|
|
///
|
|
/// Gated on the launch-time UI being clear:
|
|
///
|
|
/// * `SplashRoot` must be gone — the splash owns the foreground during
|
|
/// the brand beat and the home modal appearing under it would feel
|
|
/// like a flash of half-rendered UI.
|
|
/// * `RestorePromptScreen` must not be open and `PendingRestoredGame`
|
|
/// must be empty — when the player has a saved in-progress game the
|
|
/// restore prompt takes precedence; the home picker would compete
|
|
/// with it for attention.
|
|
/// * `HomeScreen` must not already exist (defensive — e.g. the player
|
|
/// pressed `M` between ticks).
|
|
/// * `LaunchHomeShown` flips to `true` after the first spawn so this
|
|
/// system becomes a no-op for the rest of the session. Cancelling
|
|
/// the modal therefore goes to the underlying default deal rather
|
|
/// than respawning the picker.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn spawn_home_on_launch(
|
|
mut commands: Commands,
|
|
mut shown: ResMut<LaunchHomeShown>,
|
|
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
|
|
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
|
|
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
|
|
existing: Query<(), With<HomeScreen>>,
|
|
progress: Option<Res<ProgressResource>>,
|
|
stats: Option<Res<StatsResource>>,
|
|
settings: Option<Res<SettingsResource>>,
|
|
daily: Option<Res<DailyChallengeResource>>,
|
|
font_res: Option<Res<FontResource>>,
|
|
mut diff_expanded: ResMut<DifficultyExpanded>,
|
|
) {
|
|
if shown.0
|
|
|| !splash.is_empty()
|
|
|| !restore_prompts.is_empty()
|
|
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some())
|
|
|| !existing.is_empty()
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Pre-expand the difficulty section when the player has a saved preference.
|
|
if settings.as_ref().is_some_and(|s| s.0.last_difficulty.is_some()) {
|
|
diff_expanded.0 = true;
|
|
}
|
|
|
|
spawn_home_screen(
|
|
&mut commands,
|
|
build_home_context(
|
|
progress.as_deref(),
|
|
stats.as_deref(),
|
|
settings.as_deref(),
|
|
daily.as_deref(),
|
|
font_res.as_deref(),
|
|
diff_expanded.0,
|
|
),
|
|
);
|
|
shown.0 = true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// M-key toggle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn toggle_home_screen(
|
|
mut commands: Commands,
|
|
keys: Res<ButtonInput<KeyCode>>,
|
|
progress: Option<Res<ProgressResource>>,
|
|
stats: Option<Res<StatsResource>>,
|
|
settings: Option<Res<SettingsResource>>,
|
|
daily: Option<Res<DailyChallengeResource>>,
|
|
font_res: Option<Res<FontResource>>,
|
|
screens: Query<Entity, With<HomeScreen>>,
|
|
diff_expanded: Res<DifficultyExpanded>,
|
|
) {
|
|
if !keys.just_pressed(KeyCode::KeyM) {
|
|
return;
|
|
}
|
|
if let Ok(entity) = screens.single() {
|
|
commands.entity(entity).despawn();
|
|
} else {
|
|
spawn_home_screen(
|
|
&mut commands,
|
|
build_home_context(
|
|
progress.as_deref(),
|
|
stats.as_deref(),
|
|
settings.as_deref(),
|
|
daily.as_deref(),
|
|
font_res.as_deref(),
|
|
diff_expanded.0,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Builds a [`HomeContext`] from the live resources the Home modal
|
|
/// reads. Falls back to safe defaults when a resource is missing
|
|
/// (typical for `MinimalPlugins` headless tests that don't install
|
|
/// every contributor plugin).
|
|
fn build_home_context<'a>(
|
|
progress: Option<&ProgressResource>,
|
|
stats: Option<&StatsResource>,
|
|
settings: Option<&SettingsResource>,
|
|
daily: Option<&DailyChallengeResource>,
|
|
font_res: Option<&'a FontResource>,
|
|
difficulty_expanded: bool,
|
|
) -> HomeContext<'a> {
|
|
let daily_today = daily.map(|d| {
|
|
let completed_today = progress
|
|
.and_then(|p| p.0.daily_challenge_last_completed)
|
|
.is_some_and(|d_last| d_last == d.date);
|
|
DailyToday {
|
|
date_label: d.date.format("%b %-d").to_string(),
|
|
goal: d.goal_description.clone(),
|
|
completed_today,
|
|
}
|
|
});
|
|
|
|
HomeContext {
|
|
level: progress.map_or(0, |p| p.0.level),
|
|
total_xp: progress.map_or(0, |p| p.0.total_xp),
|
|
daily_streak: progress.map_or(0, |p| p.0.daily_challenge_streak),
|
|
lifetime_score: stats.map_or(0, |s| s.0.lifetime_score),
|
|
classic_best: stats.map_or(0, |s| s.0.classic_best_score),
|
|
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
|
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
|
daily_today,
|
|
draw_mode: settings
|
|
.map(|s| s.0.draw_mode.clone())
|
|
.unwrap_or(DrawMode::DrawOne),
|
|
font_res,
|
|
difficulty_expanded,
|
|
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Card click handler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Dispatches a click on a mode card.
|
|
///
|
|
/// - **Unlocked** modes fire the matching `Start*RequestEvent` (or
|
|
/// [`NewGameRequestEvent`] for Classic) and despawn the modal.
|
|
/// - **Locked** modes (level below [`CHALLENGE_UNLOCK_LEVEL`]) fire only
|
|
/// an [`InfoToastEvent`] and leave the modal open so the player can
|
|
/// pick another mode.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn handle_home_card_click(
|
|
mut commands: Commands,
|
|
cards: Query<(&Interaction, &HomeModeCard), Changed<Interaction>>,
|
|
progress: Option<Res<ProgressResource>>,
|
|
screens: Query<Entity, With<HomeScreen>>,
|
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
|
mut zen: MessageWriter<StartZenRequestEvent>,
|
|
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
|
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
|
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
|
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
|
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
|
) {
|
|
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
|
|
|
for (interaction, card) in &cards {
|
|
if *interaction != Interaction::Pressed {
|
|
continue;
|
|
}
|
|
|
|
if !card.0.is_unlocked(level) {
|
|
info_toast.write(InfoToastEvent(format!(
|
|
"{} unlocks at level {CHALLENGE_UNLOCK_LEVEL}",
|
|
card.0.title()
|
|
)));
|
|
// Leave the modal open so the player can pick another mode.
|
|
continue;
|
|
}
|
|
|
|
match card.0 {
|
|
HomeMode::Classic => {
|
|
new_game.write(NewGameRequestEvent::default());
|
|
}
|
|
HomeMode::Daily => {
|
|
daily.write(StartDailyChallengeRequestEvent);
|
|
}
|
|
HomeMode::Zen => {
|
|
zen.write(StartZenRequestEvent);
|
|
}
|
|
HomeMode::Challenge => {
|
|
challenge.write(StartChallengeRequestEvent);
|
|
}
|
|
HomeMode::TimeAttack => {
|
|
time_attack.write(StartTimeAttackRequestEvent);
|
|
}
|
|
HomeMode::PlayBySeed => {
|
|
play_by_seed.write(StartPlayBySeedRequestEvent);
|
|
}
|
|
}
|
|
|
|
// Close the modal after dispatching the launch event.
|
|
for entity in &screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cancel button handler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn handle_home_cancel_button(
|
|
mut commands: Commands,
|
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
|
cancel_buttons: Query<&Interaction, (With<HomeCancelButton>, Changed<Interaction>)>,
|
|
screens: Query<Entity, With<HomeScreen>>,
|
|
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
|
) {
|
|
if screens.is_empty() {
|
|
return;
|
|
}
|
|
let click = cancel_buttons.iter().any(|i| *i == Interaction::Pressed);
|
|
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape));
|
|
// Esc only closes Home when it is the *topmost* modal. With Profile
|
|
// (or any other ModalScrim) layered on top, the topmost owns the
|
|
// dismissal — without this gate a single Esc closed the back
|
|
// modal (Home) and left the front modal orphaned.
|
|
let esc_targets_home = esc && other_modal_scrims.is_empty();
|
|
if !click && !esc_targets_home {
|
|
return;
|
|
}
|
|
for entity in &screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Header chip + draw-mode button handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Routes mouse-wheel events into the Home modal's scrollable body
|
|
/// while the modal is open. No-op when no `HomeScrollable` exists in
|
|
/// the world (modal closed). Mirrors `scroll_settings_panel` and
|
|
/// `scroll_leaderboard_panel`.
|
|
fn scroll_home_panel(
|
|
mut scroll_evr: MessageReader<MouseWheel>,
|
|
mut scrollables: Query<&mut ScrollPosition, With<HomeScrollable>>,
|
|
) {
|
|
if scrollables.is_empty() {
|
|
scroll_evr.clear();
|
|
return;
|
|
}
|
|
let delta_y: f32 = scroll_evr
|
|
.read()
|
|
.map(|ev| match ev.unit {
|
|
MouseScrollUnit::Line => ev.y * 50.0,
|
|
MouseScrollUnit::Pixel => ev.y,
|
|
})
|
|
.sum();
|
|
if delta_y == 0.0 {
|
|
return;
|
|
}
|
|
for mut sp in scrollables.iter_mut() {
|
|
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
|
}
|
|
}
|
|
|
|
/// Click on the player-stats header chip → fire
|
|
/// [`ToggleProfileRequestEvent`] so the Profile overlay opens on top
|
|
/// of Home. Closing Profile (`P` / `Esc`) returns the player to the
|
|
/// Home picker without losing their context.
|
|
fn handle_home_profile_chip(
|
|
chips: Query<&Interaction, (With<HomeProfileChip>, Changed<Interaction>)>,
|
|
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
|
) {
|
|
if chips.iter().any(|i| *i == Interaction::Pressed) {
|
|
profile.write(ToggleProfileRequestEvent);
|
|
}
|
|
}
|
|
|
|
/// Click on a draw-mode chip — flip `Settings.draw_mode`, persist,
|
|
/// fire `SettingsChangedEvent`, and respawn the Home modal so the
|
|
/// active-chip styling reflects the new state. Repaint by full
|
|
/// rebuild keeps the helper code small (no per-entity colour
|
|
/// surgery) and the modal is light enough to respawn cleanly.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn handle_home_draw_mode_buttons(
|
|
mut commands: Commands,
|
|
one_buttons: Query<&Interaction, (With<HomeDrawOneButton>, Changed<Interaction>)>,
|
|
three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>,
|
|
screens: Query<Entity, With<HomeScreen>>,
|
|
mut settings: Option<ResMut<SettingsResource>>,
|
|
storage_path: Option<Res<SettingsStoragePath>>,
|
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
|
progress: Option<Res<ProgressResource>>,
|
|
stats: Option<Res<StatsResource>>,
|
|
daily: Option<Res<DailyChallengeResource>>,
|
|
font_res: Option<Res<FontResource>>,
|
|
diff_expanded: Res<DifficultyExpanded>,
|
|
) {
|
|
if screens.is_empty() {
|
|
return;
|
|
}
|
|
let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed);
|
|
let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed);
|
|
if !want_one && !want_three {
|
|
return;
|
|
}
|
|
let Some(settings) = settings.as_mut() else {
|
|
return;
|
|
};
|
|
let target = if want_one {
|
|
DrawMode::DrawOne
|
|
} else {
|
|
DrawMode::DrawThree
|
|
};
|
|
if settings.0.draw_mode == target {
|
|
return; // already in this mode — avoid a redundant respawn.
|
|
}
|
|
settings.0.draw_mode = target;
|
|
if let Some(p) = storage_path
|
|
&& let Some(path) = p.0.as_deref()
|
|
&& let Err(e) = save_settings_to(path, &settings.0)
|
|
{
|
|
warn!("home: failed to persist draw-mode change: {e}");
|
|
}
|
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
|
|
|
// Repaint by despawn + respawn so the chip styling and any
|
|
// dependent labels (none today, but Phase B may surface a
|
|
// "Standard (Draw 1)" caption like MSSC) reflect the new state.
|
|
for entity in &screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
spawn_home_screen(
|
|
&mut commands,
|
|
build_home_context(
|
|
progress.as_deref(),
|
|
stats.as_deref(),
|
|
Some(settings),
|
|
daily.as_deref(),
|
|
font_res.as_deref(),
|
|
diff_expanded.0,
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Difficulty section handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Click on the "▶/▼ Difficulty" header — toggle `DifficultyExpanded` and
|
|
/// repaint the Home modal so the chevron and chip row update. Mirrors
|
|
/// `handle_home_draw_mode_buttons`: despawn + respawn keeps all styling in
|
|
/// `spawn_difficulty_section` rather than scattered across mutation helpers.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn handle_home_difficulty_toggle(
|
|
mut commands: Commands,
|
|
toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>,
|
|
screens: Query<Entity, With<HomeScreen>>,
|
|
mut diff_expanded: ResMut<DifficultyExpanded>,
|
|
progress: Option<Res<ProgressResource>>,
|
|
stats: Option<Res<StatsResource>>,
|
|
settings: Option<Res<SettingsResource>>,
|
|
daily: Option<Res<DailyChallengeResource>>,
|
|
font_res: Option<Res<FontResource>>,
|
|
) {
|
|
if screens.is_empty() {
|
|
return;
|
|
}
|
|
if !toggles.iter().any(|i| *i == Interaction::Pressed) {
|
|
return;
|
|
}
|
|
diff_expanded.0 = !diff_expanded.0;
|
|
for entity in &screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
spawn_home_screen(
|
|
&mut commands,
|
|
build_home_context(
|
|
progress.as_deref(),
|
|
stats.as_deref(),
|
|
settings.as_deref(),
|
|
daily.as_deref(),
|
|
font_res.as_deref(),
|
|
diff_expanded.0,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Click on a difficulty tier chip — persist `last_difficulty`, fire
|
|
/// `StartDifficultyRequestEvent`, and close the Home modal.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn handle_home_difficulty_chip_click(
|
|
mut commands: Commands,
|
|
chips: Query<(&Interaction, &HomeDifficultyChip), Changed<Interaction>>,
|
|
screens: Query<Entity, With<HomeScreen>>,
|
|
mut difficulty_ev: MessageWriter<StartDifficultyRequestEvent>,
|
|
mut settings: Option<ResMut<SettingsResource>>,
|
|
storage_path: Option<Res<SettingsStoragePath>>,
|
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
|
) {
|
|
if screens.is_empty() {
|
|
return;
|
|
}
|
|
let Some((_, chip)) = chips.iter().find(|(i, _)| **i == Interaction::Pressed) else {
|
|
return;
|
|
};
|
|
let level = chip.0;
|
|
|
|
if let Some(s) = settings.as_mut() {
|
|
s.0.last_difficulty = Some(level);
|
|
if let Some(p) = storage_path
|
|
&& let Some(path) = p.0.as_deref()
|
|
&& let Err(e) = save_settings_to(path, &s.0)
|
|
{
|
|
warn!("home: failed to persist last_difficulty: {e}");
|
|
}
|
|
changed.write(SettingsChangedEvent(s.0.clone()));
|
|
}
|
|
|
|
difficulty_ev.write(StartDifficultyRequestEvent { level });
|
|
|
|
for entity in &screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Digit-key shortcuts (1-5) — modal-scoped
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Maps a [`KeyCode::Digit1`]..[`KeyCode::Digit5`] press to the matching
|
|
/// [`HomeMode`]. Returns `None` for any other key. Kept as a small free
|
|
/// function so the keyboard handler reads as a clean dispatch table and so
|
|
/// the mapping is easy to unit-test in isolation.
|
|
fn digit_to_home_mode(key: KeyCode) -> Option<HomeMode> {
|
|
match key {
|
|
KeyCode::Digit1 => Some(HomeMode::Classic),
|
|
KeyCode::Digit2 => Some(HomeMode::Daily),
|
|
KeyCode::Digit3 => Some(HomeMode::Zen),
|
|
KeyCode::Digit4 => Some(HomeMode::Challenge),
|
|
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
|
|
KeyCode::Digit6 => Some(HomeMode::PlayBySeed),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Direct keyboard activation of a specific mode while the Mode Launcher
|
|
/// modal is open. Mirrors the click-handler dispatch in
|
|
/// [`handle_home_card_click`]: pressing `1` launches Classic, `2` launches
|
|
/// the Daily Challenge, and `3`/`4`/`5` launch Zen / Challenge / Time
|
|
/// Attack respectively when the player has reached
|
|
/// [`CHALLENGE_UNLOCK_LEVEL`].
|
|
///
|
|
/// The shortcut is **modal-scoped** — when no [`HomeScreen`] exists the
|
|
/// system returns immediately, so digit keys can never accidentally launch
|
|
/// a mode mid-game. Pressing a digit for a locked mode is a no-op (matches
|
|
/// the click-on-locked-card behaviour) and leaves the modal open so the
|
|
/// player can pick another mode.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn handle_home_digit_keys(
|
|
mut commands: Commands,
|
|
keys: Res<ButtonInput<KeyCode>>,
|
|
progress: Option<Res<ProgressResource>>,
|
|
screens: Query<Entity, With<HomeScreen>>,
|
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
|
mut zen: MessageWriter<StartZenRequestEvent>,
|
|
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
|
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
|
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
|
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
|
|
) {
|
|
// Modal-scoped: do nothing when the Mode Launcher isn't open.
|
|
if screens.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let Some(mode) = [
|
|
KeyCode::Digit1,
|
|
KeyCode::Digit2,
|
|
KeyCode::Digit3,
|
|
KeyCode::Digit4,
|
|
KeyCode::Digit5,
|
|
KeyCode::Digit6,
|
|
]
|
|
.into_iter()
|
|
.find(|k| keys.just_pressed(*k))
|
|
.and_then(digit_to_home_mode) else {
|
|
return;
|
|
};
|
|
|
|
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
|
if !mode.is_unlocked(level) {
|
|
// Locked mode: no-op, modal stays open.
|
|
return;
|
|
}
|
|
|
|
match mode {
|
|
HomeMode::Classic => {
|
|
new_game.write(NewGameRequestEvent::default());
|
|
}
|
|
HomeMode::Daily => {
|
|
daily.write(StartDailyChallengeRequestEvent);
|
|
}
|
|
HomeMode::Zen => {
|
|
zen.write(StartZenRequestEvent);
|
|
}
|
|
HomeMode::Challenge => {
|
|
challenge.write(StartChallengeRequestEvent);
|
|
}
|
|
HomeMode::TimeAttack => {
|
|
time_attack.write(StartTimeAttackRequestEvent);
|
|
}
|
|
HomeMode::PlayBySeed => {
|
|
play_by_seed.write(StartPlayBySeedRequestEvent);
|
|
}
|
|
}
|
|
|
|
// Close the modal after dispatching the launch event — same shape as
|
|
// the click handler.
|
|
for entity in &screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spawn helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Bundles the data the Home modal needs to render the new
|
|
/// MSSC-inspired header chips, per-mode score chips, and draw-mode
|
|
/// row. Built fresh by the two call sites (`spawn_home_on_launch`
|
|
/// and `toggle_home_screen`) from the live progress / stats /
|
|
/// settings resources, with sensible defaults when a resource is
|
|
/// missing under `MinimalPlugins` headless tests.
|
|
struct HomeContext<'a> {
|
|
level: u32,
|
|
total_xp: u64,
|
|
lifetime_score: u64,
|
|
classic_best: u32,
|
|
zen_best: u32,
|
|
challenge_best: u32,
|
|
daily_streak: u32,
|
|
daily_today: Option<DailyToday>,
|
|
draw_mode: DrawMode,
|
|
font_res: Option<&'a FontResource>,
|
|
/// Whether the difficulty section header is currently expanded.
|
|
difficulty_expanded: bool,
|
|
/// The last difficulty tier the player selected (persisted in Settings).
|
|
/// When `Some`, that tier's chip is highlighted.
|
|
last_difficulty: Option<DifficultyLevel>,
|
|
}
|
|
|
|
/// Today's daily-challenge metadata as the Home picker needs it. Only
|
|
/// populated when both [`DailyChallengeResource`] is present (the
|
|
/// plugin is wired) and we have something useful to show — otherwise
|
|
/// the Daily card falls back to its baseline description without a
|
|
/// dated callout.
|
|
struct DailyToday {
|
|
/// Short calendar label, e.g. `"May 6"`. Always populated.
|
|
date_label: String,
|
|
/// Server-supplied goal copy ("Win in under 5 minutes"). `None`
|
|
/// when no server backend is wired or the fetch hasn't returned.
|
|
goal: Option<String>,
|
|
/// `true` when the player has already recorded today's daily.
|
|
/// Surfaces a "Done" badge so the picker reads as reward-state
|
|
/// rather than "you still owe today's run".
|
|
completed_today: bool,
|
|
}
|
|
|
|
/// Spawns the Home modal with the player-stats header strip, draw-mode
|
|
/// row, five mode cards, and a Cancel button.
|
|
fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) {
|
|
let HomeContext { font_res, .. } = ctx;
|
|
let scrim = spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
|
|
spawn_modal_header(card, "Choose a Mode", font_res);
|
|
|
|
// Scrollable middle — chips + draw row + tile grid. Constrained
|
|
// to 70vh so the modal fits on small viewports (the 5-tile
|
|
// grid alone is ~540 px). Cancel button sits outside this
|
|
// node so it's always one click away.
|
|
card.spawn((
|
|
HomeScrollable,
|
|
ScrollPosition::default(),
|
|
Node {
|
|
flex_direction: FlexDirection::Column,
|
|
row_gap: VAL_SPACE_3,
|
|
width: Val::Percent(100.0),
|
|
max_height: Val::Vh(70.0),
|
|
overflow: Overflow::scroll_y(),
|
|
..default()
|
|
},
|
|
))
|
|
.with_children(|body| {
|
|
spawn_home_header_chips(body, &ctx);
|
|
spawn_draw_mode_row(body, &ctx);
|
|
|
|
// Mode tiles in a wrapping 2-column grid. Each tile takes 48%
|
|
// of the row so column_gap fits comfortably; the 5 modes wrap
|
|
// to a third row of one tile, which we leave left-aligned —
|
|
// the asymmetry matches MSSC's "Daily Challenges / Today's
|
|
// Event" half-cell on the right of their grid and keeps the
|
|
// visual rhythm.
|
|
body.spawn(Node {
|
|
flex_direction: FlexDirection::Row,
|
|
flex_wrap: FlexWrap::Wrap,
|
|
row_gap: VAL_SPACE_3,
|
|
column_gap: VAL_SPACE_3,
|
|
width: Val::Percent(100.0),
|
|
..default()
|
|
})
|
|
.with_children(|grid| {
|
|
for mode in [
|
|
HomeMode::Classic,
|
|
HomeMode::Daily,
|
|
HomeMode::Zen,
|
|
HomeMode::Challenge,
|
|
HomeMode::TimeAttack,
|
|
HomeMode::PlayBySeed,
|
|
] {
|
|
spawn_mode_card(grid, mode, &ctx);
|
|
}
|
|
});
|
|
|
|
spawn_difficulty_section(body, &ctx);
|
|
});
|
|
|
|
spawn_modal_actions(card, |actions| {
|
|
spawn_modal_button(
|
|
actions,
|
|
HomeCancelButton,
|
|
"Cancel",
|
|
Some("M"),
|
|
ButtonVariant::Tertiary,
|
|
font_res,
|
|
);
|
|
});
|
|
});
|
|
// Home is read-only — opt into click-outside-to-dismiss.
|
|
commands.entity(scrim).insert(ScrimDismissible);
|
|
}
|
|
|
|
/// Player-stats chip strip — Level, XP, Lifetime Score. Clickable as a
|
|
/// whole to open the Profile overlay (mirrors the MSSC top-right
|
|
/// avatar+rewards corner that surfaces level + premium status). Falls
|
|
/// back to plain Text in headless contexts where `Button` interaction
|
|
/// isn't driven by the input pipeline anyway.
|
|
fn spawn_home_header_chips(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
|
|
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
|
|
let font_label = TextFont {
|
|
font: font_handle.clone(),
|
|
font_size: TYPE_CAPTION,
|
|
..default()
|
|
};
|
|
let font_value = TextFont {
|
|
font: font_handle,
|
|
font_size: TYPE_BODY,
|
|
..default()
|
|
};
|
|
|
|
parent
|
|
.spawn((
|
|
HomeProfileChip,
|
|
Button,
|
|
Node {
|
|
flex_direction: FlexDirection::Row,
|
|
align_items: AlignItems::Center,
|
|
justify_content: JustifyContent::SpaceBetween,
|
|
column_gap: VAL_SPACE_2,
|
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
|
border: UiRect::all(Val::Px(1.0)),
|
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
width: Val::Percent(100.0),
|
|
..default()
|
|
},
|
|
BackgroundColor(BG_ELEVATED),
|
|
BorderColor::all(BORDER_SUBTLE),
|
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
|
))
|
|
.with_children(|row| {
|
|
for (label, value) in [
|
|
("Level".to_string(), format_compact(ctx.level as u64)),
|
|
("XP".to_string(), format_compact(ctx.total_xp)),
|
|
("Score".to_string(), format_compact(ctx.lifetime_score)),
|
|
] {
|
|
row.spawn(Node {
|
|
flex_direction: FlexDirection::Column,
|
|
align_items: AlignItems::Center,
|
|
row_gap: VAL_SPACE_1,
|
|
..default()
|
|
})
|
|
.with_children(|col| {
|
|
col.spawn((
|
|
Text::new(label),
|
|
font_label.clone(),
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
col.spawn((
|
|
Text::new(value),
|
|
font_value.clone(),
|
|
TextColor(ACCENT_PRIMARY),
|
|
));
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Draw-mode row — "Draw 1" / "Draw 3" toggle. Affects the next Classic
|
|
/// deal (the Settings value the new-game flow reads). Surfacing it on
|
|
/// the Home modal keeps the per-game choice one tap away rather than
|
|
/// buried in Settings, mirroring the dropdown MSSC puts on its
|
|
/// difficulty picker.
|
|
fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
|
|
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
|
|
let font_label = TextFont {
|
|
font: font_handle.clone(),
|
|
font_size: TYPE_CAPTION,
|
|
..default()
|
|
};
|
|
let font_btn = TextFont {
|
|
font: font_handle,
|
|
font_size: TYPE_BODY,
|
|
..default()
|
|
};
|
|
|
|
let active_one = matches!(ctx.draw_mode, DrawMode::DrawOne);
|
|
|
|
parent
|
|
.spawn(Node {
|
|
flex_direction: FlexDirection::Row,
|
|
align_items: AlignItems::Center,
|
|
column_gap: VAL_SPACE_3,
|
|
..default()
|
|
})
|
|
.with_children(|row| {
|
|
row.spawn((
|
|
Text::new("Draw mode"),
|
|
font_label.clone(),
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
spawn_draw_mode_chip::<HomeDrawOneButton>(
|
|
row,
|
|
HomeDrawOneButton,
|
|
"Draw 1",
|
|
active_one,
|
|
&font_btn,
|
|
);
|
|
spawn_draw_mode_chip::<HomeDrawThreeButton>(
|
|
row,
|
|
HomeDrawThreeButton,
|
|
"Draw 3",
|
|
!active_one,
|
|
&font_btn,
|
|
);
|
|
});
|
|
}
|
|
|
|
fn spawn_draw_mode_chip<M: Component>(
|
|
parent: &mut ChildSpawnerCommands,
|
|
marker: M,
|
|
label: &str,
|
|
active: bool,
|
|
font: &TextFont,
|
|
) {
|
|
let (bg, fg) = if active {
|
|
(ACCENT_PRIMARY, BG_ELEVATED)
|
|
} else {
|
|
(BG_ELEVATED_HI, TEXT_PRIMARY)
|
|
};
|
|
parent
|
|
.spawn((
|
|
marker,
|
|
Button,
|
|
Node {
|
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1),
|
|
border: UiRect::all(Val::Px(1.0)),
|
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
..default()
|
|
},
|
|
BackgroundColor(bg),
|
|
BorderColor::all(BORDER_SUBTLE),
|
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
|
))
|
|
.with_children(|c| {
|
|
c.spawn((Text::new(label.to_string()), font.clone(), TextColor(fg)));
|
|
});
|
|
}
|
|
|
|
/// Collapsible difficulty-tier section injected below the mode tile grid.
|
|
///
|
|
/// Structure:
|
|
/// ```text
|
|
/// ▶ Difficulty ← HomeDifficultyToggle (Button, row)
|
|
/// [Easy] [Medium] [Hard] [Expert] [GM] [Random] ← visible only when expanded
|
|
/// ```
|
|
///
|
|
/// The toggle header despawns + respawns the home screen (same pattern as
|
|
/// the draw-mode toggle) so the chevron direction and chip row visibility
|
|
/// update without Visibility component surgery.
|
|
fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
|
|
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
|
|
let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() };
|
|
let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() };
|
|
|
|
let chevron = if ctx.difficulty_expanded { "▼" } else { "▶" };
|
|
|
|
// Header row — click to toggle expand/collapse.
|
|
parent
|
|
.spawn((
|
|
HomeDifficultyToggle,
|
|
Button,
|
|
Node {
|
|
flex_direction: FlexDirection::Row,
|
|
align_items: AlignItems::Center,
|
|
column_gap: VAL_SPACE_2,
|
|
padding: UiRect::axes(Val::Px(0.0), VAL_SPACE_1),
|
|
..default()
|
|
},
|
|
))
|
|
.with_children(|row| {
|
|
row.spawn((
|
|
Text::new(chevron),
|
|
font_label.clone(),
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
row.spawn((
|
|
Text::new("Difficulty"),
|
|
font_label.clone(),
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
});
|
|
|
|
// Tier chips — only rendered when expanded.
|
|
if ctx.difficulty_expanded {
|
|
parent
|
|
.spawn(Node {
|
|
flex_direction: FlexDirection::Row,
|
|
flex_wrap: FlexWrap::Wrap,
|
|
row_gap: VAL_SPACE_2,
|
|
column_gap: VAL_SPACE_2,
|
|
width: Val::Percent(100.0),
|
|
..default()
|
|
})
|
|
.with_children(|row| {
|
|
for level in [
|
|
DifficultyLevel::Easy,
|
|
DifficultyLevel::Medium,
|
|
DifficultyLevel::Hard,
|
|
DifficultyLevel::Expert,
|
|
DifficultyLevel::Grandmaster,
|
|
DifficultyLevel::Random,
|
|
] {
|
|
let active = ctx.last_difficulty == Some(level);
|
|
let (bg, fg) = if active {
|
|
(ACCENT_PRIMARY, BG_ELEVATED)
|
|
} else {
|
|
(BG_ELEVATED_HI, TEXT_PRIMARY)
|
|
};
|
|
row.spawn((
|
|
HomeDifficultyChip(level),
|
|
Button,
|
|
Node {
|
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1),
|
|
border: UiRect::all(Val::Px(1.0)),
|
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
..default()
|
|
},
|
|
BackgroundColor(bg),
|
|
BorderColor::all(BORDER_SUBTLE),
|
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
|
))
|
|
.with_children(|c| {
|
|
c.spawn((
|
|
Text::new(level.label()),
|
|
font_chip.clone(),
|
|
TextColor(fg),
|
|
));
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Compact decimal formatter: `1234567` → `"1.2M"`, `12345` → `"12.3K"`,
|
|
/// otherwise the raw number with thousands separators. Keeps chip text
|
|
/// short enough to fit a 3-up header strip without wrapping.
|
|
fn format_compact(n: u64) -> String {
|
|
if n >= 1_000_000 {
|
|
format!("{:.1}M", n as f64 / 1_000_000.0)
|
|
} else if n >= 10_000 {
|
|
format!("{:.1}K", n as f64 / 1_000.0)
|
|
} else if n >= 1_000 {
|
|
let (high, low) = (n / 1_000, n % 1_000);
|
|
format!("{high},{low:03}")
|
|
} else {
|
|
n.to_string()
|
|
}
|
|
}
|
|
|
|
/// Per-mode score / streak chip text. `None` for modes where no
|
|
/// per-mode best exists yet (Time Attack uses session scoring; modes
|
|
/// with `0` recorded mean "no win yet" and we hide the chip rather
|
|
/// than show a 0).
|
|
fn score_chip_text_for(mode: HomeMode, ctx: &HomeContext<'_>) -> Option<String> {
|
|
match mode {
|
|
HomeMode::Classic if ctx.classic_best > 0 => {
|
|
Some(format!("Best {}", format_compact(ctx.classic_best as u64)))
|
|
}
|
|
HomeMode::Zen if ctx.zen_best > 0 => {
|
|
Some(format!("Best {}", format_compact(ctx.zen_best as u64)))
|
|
}
|
|
HomeMode::Challenge if ctx.challenge_best > 0 => {
|
|
Some(format!("Best {}", format_compact(ctx.challenge_best as u64)))
|
|
}
|
|
HomeMode::Daily if ctx.daily_streak > 0 => {
|
|
Some(format!("Streak {}", ctx.daily_streak))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Tab-walk order for each mode card, matching the visual top-to-bottom
|
|
/// stack inside the Home modal. Lower numbers receive focus first under
|
|
/// `Focusable`'s sort.
|
|
fn home_mode_focus_order(mode: HomeMode) -> i32 {
|
|
match mode {
|
|
HomeMode::Classic => 0,
|
|
HomeMode::Daily => 1,
|
|
HomeMode::Zen => 2,
|
|
HomeMode::Challenge => 3,
|
|
HomeMode::TimeAttack => 4,
|
|
HomeMode::PlayBySeed => 5,
|
|
}
|
|
}
|
|
|
|
/// Auto-attaches [`Focusable`] (and [`Disabled`] when locked) to every
|
|
/// newly-spawned [`HomeModeCard`]. Walks ancestors to find the
|
|
/// [`crate::ui_modal::ModalScrim`] so each card's focus group is bound
|
|
/// to its parent modal — mirrors the convention that
|
|
/// `attach_focusable_to_modal_buttons` uses for `ModalButton`s.
|
|
///
|
|
/// Doing this in a system (instead of inline at spawn time) lets
|
|
/// `spawn_home_screen` keep using the existing `spawn_modal`'s
|
|
/// build-closure shape; the scrim entity isn't visible inside that
|
|
/// closure, only after the call returns. The system runs every frame
|
|
/// and is a no-op once every card has been tagged.
|
|
fn attach_focusable_to_home_mode_cards(
|
|
mut commands: Commands,
|
|
new_cards: Query<(Entity, &HomeModeCard), Without<Focusable>>,
|
|
parents: Query<&ChildOf>,
|
|
scrims: Query<(), With<crate::ui_modal::ModalScrim>>,
|
|
progress: Option<Res<ProgressResource>>,
|
|
) {
|
|
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
|
for (card_entity, card) in &new_cards {
|
|
// Walk ancestors until we find the ModalScrim. Bounded loop so a
|
|
// malformed hierarchy can't hang the system — same defensive
|
|
// shape as `attach_focusable_to_modal_buttons`.
|
|
let mut current = card_entity;
|
|
let mut scrim_entity: Option<Entity> = None;
|
|
for _ in 0..32 {
|
|
if scrims.get(current).is_ok() {
|
|
scrim_entity = Some(current);
|
|
break;
|
|
}
|
|
match parents.get(current) {
|
|
Ok(parent) => current = parent.parent(),
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
let Some(scrim) = scrim_entity else { continue };
|
|
commands.entity(card_entity).insert(Focusable {
|
|
group: FocusGroup::Modal(scrim),
|
|
order: home_mode_focus_order(card.0),
|
|
});
|
|
if !card.0.is_unlocked(level) {
|
|
commands.entity(card_entity).insert(Disabled);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Spawns one mode card — a `Button` whose children are a title row, a
|
|
/// description line, and (when locked) a "Reach level N" hint.
|
|
///
|
|
/// The visual deliberately diverges from `spawn_modal_button` because a
|
|
/// mode card is a wide, two-line tile rather than a compact action; the
|
|
/// `ButtonVariant` palette would not apply cleanly here. Hover/press
|
|
/// feedback is supplied by `paint_modal_buttons` via the `ModalButton`
|
|
/// component, which we attach with `ButtonVariant::Secondary` so the card
|
|
/// reads as a standard interactive surface.
|
|
fn spawn_mode_card(
|
|
parent: &mut ChildSpawnerCommands,
|
|
mode: HomeMode,
|
|
ctx: &HomeContext<'_>,
|
|
) {
|
|
let level = ctx.level;
|
|
let font_res = ctx.font_res;
|
|
let score_chip = score_chip_text_for(mode, ctx);
|
|
let unlocked = mode.is_unlocked(level);
|
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
|
let font_title = TextFont {
|
|
font: font_handle.clone(),
|
|
font_size: TYPE_BODY_LG,
|
|
..default()
|
|
};
|
|
let font_desc = TextFont {
|
|
font: font_handle.clone(),
|
|
font_size: TYPE_BODY,
|
|
..default()
|
|
};
|
|
let font_chip = TextFont {
|
|
font: font_handle.clone(),
|
|
font_size: TYPE_CAPTION,
|
|
..default()
|
|
};
|
|
// Glyph rendered at display size — Unicode emoji standing in for
|
|
// the per-mode artwork. Centred at the top of the tile.
|
|
let font_glyph = TextFont {
|
|
font: font_handle,
|
|
font_size: TYPE_DISPLAY,
|
|
..default()
|
|
};
|
|
|
|
// Locked cards mute their text to communicate the disabled state at
|
|
// a glance; the explicit "Unlocks at level N" caption underneath
|
|
// backs that up with copy.
|
|
let title_color = if unlocked { TEXT_PRIMARY } else { TEXT_DISABLED };
|
|
let desc_color = if unlocked { TEXT_SECONDARY } else { TEXT_DISABLED };
|
|
let border_color = if unlocked { BORDER_SUBTLE } else { BORDER_STRONG };
|
|
let glyph_color = if unlocked { ACCENT_PRIMARY } else { TEXT_DISABLED };
|
|
|
|
parent
|
|
.spawn((
|
|
HomeModeCard(mode),
|
|
// Keep this a real Button entity so clicks resolve through
|
|
// bevy::ui — the click handler queries on `&Interaction`
|
|
// which Button drives.
|
|
Button,
|
|
Node {
|
|
flex_direction: FlexDirection::Column,
|
|
row_gap: VAL_SPACE_2,
|
|
padding: UiRect::all(VAL_SPACE_3),
|
|
// 48% per tile + the row's column_gap = a clean 2-up
|
|
// grid that wraps to a single tile on the third row.
|
|
width: Val::Percent(48.0),
|
|
min_height: Val::Px(180.0),
|
|
align_items: AlignItems::Center,
|
|
border: UiRect::all(Val::Px(1.0)),
|
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
..default()
|
|
},
|
|
BackgroundColor(BG_ELEVATED_HI),
|
|
BorderColor::all(border_color),
|
|
))
|
|
.with_children(|c| {
|
|
// Centerpiece glyph — placeholder for real per-mode art.
|
|
c.spawn((
|
|
Text::new(mode.glyph().to_string()),
|
|
font_glyph.clone(),
|
|
TextColor(glyph_color),
|
|
));
|
|
|
|
// Title row — title text on the left, hotkey chip on the right.
|
|
c.spawn(Node {
|
|
flex_direction: FlexDirection::Row,
|
|
align_items: AlignItems::Center,
|
|
justify_content: JustifyContent::SpaceBetween,
|
|
column_gap: VAL_SPACE_3,
|
|
width: Val::Percent(100.0),
|
|
..default()
|
|
})
|
|
.with_children(|row| {
|
|
row.spawn((
|
|
Text::new(mode.title().to_string()),
|
|
font_title.clone(),
|
|
TextColor(title_color),
|
|
));
|
|
|
|
if unlocked {
|
|
// Hotkey chip — same look as the kbd-chip rows used
|
|
// elsewhere so accelerators read consistently.
|
|
row.spawn((
|
|
Node {
|
|
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
|
min_width: Val::Px(32.0),
|
|
justify_content: JustifyContent::Center,
|
|
border: UiRect::all(Val::Px(1.0)),
|
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
..default()
|
|
},
|
|
BorderColor::all(BORDER_SUBTLE),
|
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
|
))
|
|
.with_children(|chip| {
|
|
chip.spawn((
|
|
Text::new(mode.hotkey().to_string()),
|
|
font_chip.clone(),
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
});
|
|
} else {
|
|
// Lock icon stand-in — text glyph keeps the layout
|
|
// dependency-free (no asset loader required) and
|
|
// reads at every supported font size.
|
|
row.spawn((
|
|
Text::new("LOCKED".to_string()),
|
|
font_chip.clone(),
|
|
TextColor(STATE_INFO),
|
|
));
|
|
}
|
|
});
|
|
|
|
// Description line.
|
|
c.spawn((
|
|
Text::new(mode.description().to_string()),
|
|
font_desc.clone(),
|
|
TextColor(desc_color),
|
|
));
|
|
|
|
// Per-mode score / streak chip — populated only when the
|
|
// player has data for this mode. Hidden on a 0 best so a
|
|
// fresh profile doesn't show "Best 0" everywhere.
|
|
if let Some(text) = score_chip.clone()
|
|
&& unlocked
|
|
{
|
|
c.spawn((
|
|
Text::new(text),
|
|
font_chip.clone(),
|
|
TextColor(ACCENT_PRIMARY),
|
|
Node {
|
|
margin: UiRect::top(VAL_SPACE_1),
|
|
..default()
|
|
},
|
|
));
|
|
}
|
|
|
|
// Daily-only "Today's Event" caption — date, optional
|
|
// server goal, and a "Done" badge once the player has
|
|
// already recorded today's completion. Only renders for
|
|
// the Daily card when DailyChallengeResource is present.
|
|
if matches!(mode, HomeMode::Daily)
|
|
&& unlocked
|
|
&& let Some(today) = ctx.daily_today.as_ref()
|
|
{
|
|
let date_text = if today.completed_today {
|
|
format!("Today, {} \u{2022} Done", today.date_label)
|
|
} else {
|
|
format!("Today, {}", today.date_label)
|
|
};
|
|
let date_color = if today.completed_today {
|
|
ACCENT_PRIMARY
|
|
} else {
|
|
STATE_INFO
|
|
};
|
|
c.spawn((
|
|
Text::new(date_text),
|
|
font_chip.clone(),
|
|
TextColor(date_color),
|
|
Node {
|
|
margin: UiRect::top(VAL_SPACE_1),
|
|
..default()
|
|
},
|
|
));
|
|
if let Some(goal) = today.goal.as_ref() {
|
|
c.spawn((
|
|
Text::new(format!("Goal: {goal}")),
|
|
font_chip.clone(),
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Locked footnote — explicit copy so the gate is unambiguous.
|
|
if !unlocked {
|
|
c.spawn((
|
|
Text::new(format!(
|
|
"Unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
|
|
)),
|
|
TextFont {
|
|
font: font_desc.font.clone(),
|
|
font_size: TYPE_CAPTION,
|
|
..default()
|
|
},
|
|
TextColor(ACCENT_PRIMARY),
|
|
Node {
|
|
margin: UiRect::top(VAL_SPACE_1),
|
|
..default()
|
|
},
|
|
));
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::game_plugin::GamePlugin;
|
|
use crate::progress_plugin::ProgressPlugin;
|
|
use crate::table_plugin::TablePlugin;
|
|
use bevy::ecs::message::Messages;
|
|
|
|
/// Builds a headless `App` with just the plugins Home actually
|
|
/// reaches into. We deliberately skip input_plugin /
|
|
/// challenge_plugin / time_attack_plugin / daily_challenge_plugin —
|
|
/// Home only needs to dispatch their request events; the events
|
|
/// themselves are registered defensively by `HomePlugin::build`.
|
|
fn headless_app() -> App {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(GamePlugin)
|
|
.add_plugins(TablePlugin)
|
|
.add_plugins(ProgressPlugin::headless())
|
|
.add_plugins(HomePlugin::headless());
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
app.update();
|
|
app
|
|
}
|
|
|
|
/// Press M, run a tick, and return the resulting screen entity.
|
|
/// Panics if the modal does not appear (failure mode that any later
|
|
/// assertion would mask anyway). The keyboard input is cleared after
|
|
/// the press so the next `app.update()` doesn't re-toggle the modal
|
|
/// closed — `MinimalPlugins` doesn't run the bevy_input update system
|
|
/// that would normally clear `just_pressed` between frames.
|
|
fn open_home(app: &mut App) -> Entity {
|
|
{
|
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
input.press(KeyCode::KeyM);
|
|
}
|
|
app.update();
|
|
{
|
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
input.release(KeyCode::KeyM);
|
|
input.clear();
|
|
}
|
|
app.world_mut()
|
|
.query::<(Entity, &HomeScreen)>()
|
|
.single(app.world())
|
|
.map(|(e, _)| e)
|
|
.expect("HomeScreen must spawn after M press")
|
|
}
|
|
|
|
/// Pump a button-press synthetic interaction onto the entity. Bevy
|
|
/// 0.18 surfaces interactions through the `Interaction` component
|
|
/// driven by the UI input pipeline, but MinimalPlugins does not run
|
|
/// that pipeline — so we insert `Interaction::Pressed` directly,
|
|
/// which triggers `Changed<Interaction>` on the next update tick.
|
|
/// Pattern is borrowed verbatim from `pause_plugin`'s tests.
|
|
fn press_button(app: &mut App, entity: Entity) {
|
|
app.world_mut()
|
|
.entity_mut(entity)
|
|
.insert(Interaction::Pressed);
|
|
app.update();
|
|
}
|
|
|
|
/// Find the unique `HomeModeCard` entity for a specific mode. Used
|
|
/// by the click-handler tests to target the right card.
|
|
fn find_card(app: &mut App, mode: HomeMode) -> Entity {
|
|
app.world_mut()
|
|
.query::<(Entity, &HomeModeCard)>()
|
|
.iter(app.world())
|
|
.find(|(_, c)| c.0 == mode)
|
|
.map(|(e, _)| e)
|
|
.unwrap_or_else(|| panic!("no HomeModeCard for {mode:?}"))
|
|
}
|
|
|
|
#[test]
|
|
fn pressing_m_spawns_home_screen() {
|
|
let mut app = headless_app();
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&HomeScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
0
|
|
);
|
|
|
|
app.world_mut()
|
|
.resource_mut::<ButtonInput<KeyCode>>()
|
|
.press(KeyCode::KeyM);
|
|
app.update();
|
|
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&HomeScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pressing_m_twice_closes_home_screen() {
|
|
let mut app = headless_app();
|
|
|
|
app.world_mut()
|
|
.resource_mut::<ButtonInput<KeyCode>>()
|
|
.press(KeyCode::KeyM);
|
|
app.update();
|
|
|
|
{
|
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
input.release(KeyCode::KeyM);
|
|
input.clear();
|
|
input.press(KeyCode::KeyM);
|
|
}
|
|
app.update();
|
|
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&HomeScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn modal_contains_a_card_for_each_mode() {
|
|
let mut app = headless_app();
|
|
let _ = open_home(&mut app);
|
|
|
|
let modes: Vec<HomeMode> = app
|
|
.world_mut()
|
|
.query::<&HomeModeCard>()
|
|
.iter(app.world())
|
|
.map(|c| c.0)
|
|
.collect();
|
|
|
|
for expected in [
|
|
HomeMode::Classic,
|
|
HomeMode::Daily,
|
|
HomeMode::Zen,
|
|
HomeMode::Challenge,
|
|
HomeMode::TimeAttack,
|
|
HomeMode::PlayBySeed,
|
|
] {
|
|
assert!(
|
|
modes.contains(&expected),
|
|
"missing card for {expected:?}; found {modes:?}"
|
|
);
|
|
}
|
|
assert_eq!(modes.len(), 6, "exactly six cards expected");
|
|
}
|
|
|
|
#[test]
|
|
fn classic_click_fires_new_game_event_and_closes_modal() {
|
|
let mut app = headless_app();
|
|
let _ = open_home(&mut app);
|
|
|
|
// Drain any pre-existing NewGameRequestEvent so the assertion
|
|
// only sees the click-driven write.
|
|
app.world_mut()
|
|
.resource_mut::<Messages<NewGameRequestEvent>>()
|
|
.clear();
|
|
|
|
let card = find_card(&mut app, HomeMode::Classic);
|
|
press_button(&mut app, card);
|
|
|
|
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
|
assert_eq!(fired.len(), 1, "one NewGameRequestEvent must fire");
|
|
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&HomeScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
0,
|
|
"Home modal must close after launching Classic"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn locked_zen_click_is_a_noop_below_unlock_level() {
|
|
let mut app = headless_app();
|
|
// Default level is 0 — Zen is locked.
|
|
let _ = open_home(&mut app);
|
|
|
|
// Reset event queues so the assertion is clean.
|
|
app.world_mut()
|
|
.resource_mut::<Messages<NewGameRequestEvent>>()
|
|
.clear();
|
|
app.world_mut()
|
|
.resource_mut::<Messages<StartZenRequestEvent>>()
|
|
.clear();
|
|
|
|
let card = find_card(&mut app, HomeMode::Zen);
|
|
press_button(&mut app, card);
|
|
|
|
// No launch events should have fired.
|
|
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
|
|
let mut nc = new_game.get_cursor();
|
|
assert!(
|
|
nc.read(new_game).next().is_none(),
|
|
"locked Zen click must not fire NewGameRequestEvent"
|
|
);
|
|
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
|
|
let mut zc = zen.get_cursor();
|
|
assert!(
|
|
zc.read(zen).next().is_none(),
|
|
"locked Zen click must not fire StartZenRequestEvent"
|
|
);
|
|
|
|
// Modal must still be open so the player can pick another mode.
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&HomeScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
1,
|
|
"Home modal must remain open after a locked-mode click"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unlocked_zen_click_fires_start_zen_event_and_closes_modal() {
|
|
let mut app = headless_app();
|
|
// Bump the player to the unlock level.
|
|
app.world_mut()
|
|
.resource_mut::<ProgressResource>()
|
|
.0
|
|
.level = CHALLENGE_UNLOCK_LEVEL;
|
|
let _ = open_home(&mut app);
|
|
|
|
app.world_mut()
|
|
.resource_mut::<Messages<StartZenRequestEvent>>()
|
|
.clear();
|
|
|
|
let card = find_card(&mut app, HomeMode::Zen);
|
|
press_button(&mut app, card);
|
|
|
|
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
|
|
let mut zc = zen.get_cursor();
|
|
assert_eq!(
|
|
zc.read(zen).count(),
|
|
1,
|
|
"unlocked Zen click must fire exactly one StartZenRequestEvent"
|
|
);
|
|
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&HomeScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
0,
|
|
"Home modal must close after launching Zen"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cancel_button_closes_modal_without_launching_anything() {
|
|
let mut app = headless_app();
|
|
let _ = open_home(&mut app);
|
|
|
|
app.world_mut()
|
|
.resource_mut::<Messages<NewGameRequestEvent>>()
|
|
.clear();
|
|
|
|
let cancel = app
|
|
.world_mut()
|
|
.query::<(Entity, &HomeCancelButton)>()
|
|
.single(app.world())
|
|
.map(|(e, _)| e)
|
|
.expect("HomeCancelButton must exist when modal is open");
|
|
press_button(&mut app, cancel);
|
|
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&HomeScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
0,
|
|
"Cancel must despawn the modal"
|
|
);
|
|
|
|
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
|
|
let mut nc = new_game.get_cursor();
|
|
assert!(
|
|
nc.read(new_game).next().is_none(),
|
|
"Cancel must not fire NewGameRequestEvent"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Phase 2: keyboard focus ring — Home mode cards
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Headless app variant that also installs the focus and modal
|
|
/// plugins so `attach_focusable_to_modal_buttons` and Phase 2's
|
|
/// `attach_focusable_to_home_mode_cards` can run.
|
|
fn headless_app_with_focus() -> App {
|
|
use crate::ui_focus::UiFocusPlugin;
|
|
use crate::ui_modal::UiModalPlugin;
|
|
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(UiModalPlugin)
|
|
.add_plugins(UiFocusPlugin)
|
|
.add_plugins(GamePlugin)
|
|
.add_plugins(TablePlugin)
|
|
.add_plugins(ProgressPlugin::headless())
|
|
.add_plugins(HomePlugin::headless());
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
app.update();
|
|
app
|
|
}
|
|
|
|
/// Open the Home modal at the given player level. Tags the cards
|
|
/// with `Focusable` (and, when locked, `Disabled`) by running an
|
|
/// extra tick after the M press so the focus-attach system fires.
|
|
fn open_home_at_level(app: &mut App, level: u32) -> Entity {
|
|
app.world_mut().resource_mut::<ProgressResource>().0.level = level;
|
|
let entity = open_home(app);
|
|
// One more tick so `attach_focusable_to_home_mode_cards` runs
|
|
// on the freshly-spawned cards.
|
|
app.update();
|
|
entity
|
|
}
|
|
|
|
#[test]
|
|
fn home_mode_cards_get_focusable_marker() {
|
|
let mut app = headless_app_with_focus();
|
|
let scrim = open_home_at_level(&mut app, CHALLENGE_UNLOCK_LEVEL);
|
|
|
|
// Every card carries `Focusable` in `FocusGroup::Modal(scrim)`.
|
|
let cards: Vec<(HomeMode, Focusable)> = app
|
|
.world_mut()
|
|
.query::<(&HomeModeCard, &Focusable)>()
|
|
.iter(app.world())
|
|
.map(|(c, f)| (c.0, *f))
|
|
.collect();
|
|
|
|
assert_eq!(cards.len(), 6, "all six cards must carry a Focusable");
|
|
for (mode, focusable) in &cards {
|
|
assert_eq!(
|
|
focusable.group,
|
|
FocusGroup::Modal(scrim),
|
|
"{mode:?} card must be in the Home scrim's focus group"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn home_locked_cards_get_disabled_marker() {
|
|
let mut app = headless_app_with_focus();
|
|
// Level 0: Zen, Challenge, Time Attack are locked; Classic and
|
|
// Daily are not.
|
|
let _ = open_home_at_level(&mut app, 0);
|
|
|
|
let states: Vec<(HomeMode, bool)> = app
|
|
.world_mut()
|
|
.query::<(&HomeModeCard, bevy::ecs::query::Has<Disabled>)>()
|
|
.iter(app.world())
|
|
.map(|(c, d)| (c.0, d))
|
|
.collect();
|
|
|
|
for (mode, disabled) in states {
|
|
match mode {
|
|
HomeMode::Classic | HomeMode::Daily | HomeMode::PlayBySeed => assert!(
|
|
!disabled,
|
|
"{mode:?} must not be Disabled at level 0 (it's never locked)"
|
|
),
|
|
HomeMode::Zen | HomeMode::Challenge | HomeMode::TimeAttack => assert!(
|
|
disabled,
|
|
"{mode:?} must carry the Disabled marker at level 0 so Tab skips it"
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn home_unlocked_cards_no_disabled_marker() {
|
|
let mut app = headless_app_with_focus();
|
|
let _ = open_home_at_level(&mut app, CHALLENGE_UNLOCK_LEVEL);
|
|
|
|
let any_disabled = app
|
|
.world_mut()
|
|
.query_filtered::<&HomeModeCard, With<Disabled>>()
|
|
.iter(app.world())
|
|
.next()
|
|
.is_some();
|
|
|
|
assert!(
|
|
!any_disabled,
|
|
"no card may be Disabled when the player is at the unlock level"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Digit-key shortcuts (1-5) — modal-scoped direct mode launch
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Press a key and clear the input afterwards so the next `update()`
|
|
/// doesn't re-fire `just_pressed`. Mirrors the open_home() pattern but
|
|
/// for an arbitrary key (the M-press helper releases & clears KeyM,
|
|
/// which is also what we need here for Digit keys).
|
|
fn press_and_clear(app: &mut App, key: KeyCode) {
|
|
{
|
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
input.press(key);
|
|
}
|
|
app.update();
|
|
{
|
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
input.release(key);
|
|
input.clear();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn digit1_in_home_modal_starts_classic_and_closes_modal() {
|
|
let mut app = headless_app();
|
|
let _ = open_home(&mut app);
|
|
|
|
// Drain any pre-existing NewGameRequestEvent so the assertion
|
|
// only sees the digit-key driven write.
|
|
app.world_mut()
|
|
.resource_mut::<Messages<NewGameRequestEvent>>()
|
|
.clear();
|
|
|
|
press_and_clear(&mut app, KeyCode::Digit1);
|
|
|
|
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
|
assert_eq!(
|
|
fired.len(),
|
|
1,
|
|
"exactly one NewGameRequestEvent must fire for Digit1"
|
|
);
|
|
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&HomeScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
0,
|
|
"Home modal must close after launching Classic via Digit1"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn digit3_at_level_zero_is_a_noop() {
|
|
let mut app = headless_app();
|
|
// Default level is 0 — Zen is locked.
|
|
let _ = open_home(&mut app);
|
|
|
|
app.world_mut()
|
|
.resource_mut::<Messages<StartZenRequestEvent>>()
|
|
.clear();
|
|
|
|
press_and_clear(&mut app, KeyCode::Digit3);
|
|
|
|
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
|
|
let mut zc = zen.get_cursor();
|
|
assert!(
|
|
zc.read(zen).next().is_none(),
|
|
"Digit3 at level 0 must not fire StartZenRequestEvent"
|
|
);
|
|
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&HomeScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
1,
|
|
"Home modal must remain open after a locked-mode digit press"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn digit3_at_unlock_level_starts_zen_and_closes_modal() {
|
|
let mut app = headless_app();
|
|
// Bump the player to the unlock level *before* opening the modal
|
|
// so the Mode Launcher is in its unlocked state.
|
|
app.world_mut()
|
|
.resource_mut::<ProgressResource>()
|
|
.0
|
|
.level = CHALLENGE_UNLOCK_LEVEL;
|
|
let _ = open_home(&mut app);
|
|
|
|
app.world_mut()
|
|
.resource_mut::<Messages<StartZenRequestEvent>>()
|
|
.clear();
|
|
|
|
press_and_clear(&mut app, KeyCode::Digit3);
|
|
|
|
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
|
|
let mut zc = zen.get_cursor();
|
|
assert_eq!(
|
|
zc.read(zen).count(),
|
|
1,
|
|
"Digit3 at unlock level must fire exactly one StartZenRequestEvent"
|
|
);
|
|
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&HomeScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
0,
|
|
"Home modal must close after launching Zen via Digit3"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn digit_keys_outside_home_modal_are_noop() {
|
|
let mut app = headless_app();
|
|
// Modal is NOT open. Bump level so Zen would otherwise be allowed
|
|
// — this isolates the modal-scope guard from the unlock check.
|
|
app.world_mut()
|
|
.resource_mut::<ProgressResource>()
|
|
.0
|
|
.level = CHALLENGE_UNLOCK_LEVEL;
|
|
|
|
// Drain any pre-existing events.
|
|
app.world_mut()
|
|
.resource_mut::<Messages<NewGameRequestEvent>>()
|
|
.clear();
|
|
app.world_mut()
|
|
.resource_mut::<Messages<StartZenRequestEvent>>()
|
|
.clear();
|
|
app.world_mut()
|
|
.resource_mut::<Messages<StartChallengeRequestEvent>>()
|
|
.clear();
|
|
app.world_mut()
|
|
.resource_mut::<Messages<StartTimeAttackRequestEvent>>()
|
|
.clear();
|
|
app.world_mut()
|
|
.resource_mut::<Messages<StartDailyChallengeRequestEvent>>()
|
|
.clear();
|
|
|
|
// Press every digit 1-5 in turn — none should trigger a launch.
|
|
for key in [
|
|
KeyCode::Digit1,
|
|
KeyCode::Digit2,
|
|
KeyCode::Digit3,
|
|
KeyCode::Digit4,
|
|
KeyCode::Digit5,
|
|
] {
|
|
press_and_clear(&mut app, key);
|
|
}
|
|
|
|
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
|
|
let mut nc = new_game.get_cursor();
|
|
assert!(
|
|
nc.read(new_game).next().is_none(),
|
|
"Digit keys with no modal open must not fire NewGameRequestEvent"
|
|
);
|
|
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
|
|
let mut zc = zen.get_cursor();
|
|
assert!(
|
|
zc.read(zen).next().is_none(),
|
|
"Digit keys with no modal open must not fire StartZenRequestEvent"
|
|
);
|
|
let chal = app.world().resource::<Messages<StartChallengeRequestEvent>>();
|
|
let mut cc = chal.get_cursor();
|
|
assert!(
|
|
cc.read(chal).next().is_none(),
|
|
"Digit keys with no modal open must not fire StartChallengeRequestEvent"
|
|
);
|
|
let ta = app.world().resource::<Messages<StartTimeAttackRequestEvent>>();
|
|
let mut tc = ta.get_cursor();
|
|
assert!(
|
|
tc.read(ta).next().is_none(),
|
|
"Digit keys with no modal open must not fire StartTimeAttackRequestEvent"
|
|
);
|
|
let daily = app.world().resource::<Messages<StartDailyChallengeRequestEvent>>();
|
|
let mut dc = daily.get_cursor();
|
|
assert!(
|
|
dc.read(daily).next().is_none(),
|
|
"Digit keys with no modal open must not fire StartDailyChallengeRequestEvent"
|
|
);
|
|
}
|
|
}
|