f8f1f26d64
H-3: cursor_plugin drop_overlay_rect and card_centre_for_index now use
layout.tableau_fan_frac instead of the static TABLEAU_FAN_FRAC constant,
so drop zones match the actual card fan on portrait Android.
Removed now-unused TABLEAU_FAN_FRAC import.
H-4: touch_end_drag uncommitted-tap branch no longer writes StateChangedEvent.
The mouse path (end_drag) already omits this event for uncommitted drags;
the touch path now matches, preventing double-animation on valid taps.
H-6: update_selection_highlight is now gated with run_if(resource_changed)
on SelectionState | KeyboardDragState | GameStateResource, eliminating
the unconditional every-frame despawn+respawn of highlight sprites.
H-7: toggle_home_screen (M-key) now checks other_modal_scrims.is_empty()
before spawning the home screen, preventing a second concurrent ModalScrim
when another overlay is already open.
H-8: spawn_mode_card now inserts ModalButton(ButtonVariant::Secondary) so
paint_modal_buttons applies hover/press colour feedback on Android.
H-10: auto_resume_on_overlay excludes ForfeitConfirmScreen from its
"other scrims" query via NonPauseFamilyScrim type alias. Opening the
forfeit confirm no longer immediately despawns its parent pause modal.
Also guards paused.0 assignment with an if-check to suppress spurious
change-detection writes (L-15).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2093 lines
76 KiB
Rust
2093 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,
|
|
ModalButton,
|
|
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), the
|
|
/// **Arrows** block (U+2190-21FF), and ASCII — ranges confirmed
|
|
/// present in the bundled FiraMono-Medium face. The Geometric
|
|
/// Shapes block (U+25xx) is NOT covered by FiraMono; glyphs in
|
|
/// that range render as missing-glyph rectangles on Android.
|
|
fn glyph(self) -> &'static str {
|
|
match self {
|
|
// Black club — card suit; the obvious solitaire mark.
|
|
HomeMode::Classic => "\u{2663}",
|
|
// Black diamond suit — "gem of the day" reading.
|
|
HomeMode::Daily => "\u{2666}",
|
|
// Black heart suit — calm/warm; conveys the Zen mood.
|
|
HomeMode::Zen => "\u{2665}",
|
|
// Black spade suit — sharp/high-stakes; signals difficulty.
|
|
HomeMode::Challenge => "\u{2660}",
|
|
// Rightwards arrow — "go / fast-forward" for the timed mode.
|
|
HomeMode::TimeAttack => "\u{2192}",
|
|
// Number sign — ASCII; "a specific seed ID".
|
|
HomeMode::PlayBySeed => "#",
|
|
}
|
|
}
|
|
|
|
/// The keyboard accelerator that dispatches the same launch event,
|
|
/// shown in a small chip on the card.
|
|
#[cfg(not(target_os = "android"))]
|
|
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>>,
|
|
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
|
diff_expanded: Res<DifficultyExpanded>,
|
|
) {
|
|
if !keys.just_pressed(KeyCode::KeyM) {
|
|
return;
|
|
}
|
|
if let Ok(entity) = screens.single() {
|
|
commands.entity(entity).despawn();
|
|
} else if other_modal_scrims.is_empty() {
|
|
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>>,
|
|
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<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;
|
|
}
|
|
// Don't respawn while another modal sits on top — the despawn queues
|
|
// immediately but executes at end of frame, so a respawn in the same
|
|
// frame would create a second concurrent ModalScrim.
|
|
if !other_modal_scrims.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>>,
|
|
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<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 !other_modal_scrims.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 { "v" } 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,
|
|
ModalButton(ButtonVariant::Secondary),
|
|
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 — suppressed on Android (touch builds have no keyboard).
|
|
#[cfg(not(target_os = "android"))]
|
|
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"
|
|
);
|
|
}
|
|
}
|