c8fc3e7a09
Touch layout only (desktop bar unchanged): a floating glass pill (Undo / Draw / Hint / Pause) plus a detached circular Menu button, margin above the bottom edge on top of the safe-area inset. Draw is the persistently expanded accent pill (icon + label); other buttons are icon-only and slide their label out while pressed, snapping under reduce-motion. Buttons keep the existing marker components so click handlers, tooltips, focus ring, and chrome toggle work unchanged. Icons: None degrades to text-fallback labels (MinimalPlugins tests / SVG regression), keeping the bar fully usable. Toast stack clearance now derives from TAB_BAR_CLEARANCE_PX; the legacy touch bar spawn path and primary-button metrics are removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
643 lines
27 KiB
Rust
643 lines
27 KiB
Rust
//! Persistent in-game HUD: score, move count, elapsed time, mode badge,
|
|
//! daily-challenge constraint, and undo count.
|
|
//!
|
|
//! The HUD spawns once at startup and lives for the app's lifetime. Text is
|
|
//! refreshed whenever `GameStateResource` changes (which happens on every move
|
|
//! and every elapsed-time tick), so score, moves, and timer all stay current
|
|
//! without a separate tick system.
|
|
|
|
use bevy::prelude::*;
|
|
use bevy::window::WindowResized;
|
|
|
|
mod fx;
|
|
mod interaction;
|
|
mod spawn;
|
|
pub(crate) mod tab_bar;
|
|
mod updates;
|
|
|
|
pub use fx::*;
|
|
use interaction::*;
|
|
use spawn::*;
|
|
use tab_bar::*;
|
|
use updates::*;
|
|
|
|
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
|
|
// Option<Res<AvatarResource>> parameters below compile without changes.
|
|
// The resource is never inserted on wasm, so every call resolves to None.
|
|
#[cfg(target_arch = "wasm32")]
|
|
#[derive(bevy::prelude::Resource)]
|
|
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
|
use crate::events::{
|
|
DrawRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
|
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
|
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
|
|
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
|
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
|
|
};
|
|
use crate::font_plugin::FontResource;
|
|
use crate::game_plugin::{GameMutation, NewGameRequestWriters};
|
|
#[cfg(target_os = "android")]
|
|
use crate::input_plugin::TouchDragSet;
|
|
use crate::layout::HUD_BAND_HEIGHT;
|
|
use crate::layout::LayoutSystem;
|
|
use crate::pause_plugin::PausedResource;
|
|
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
|
|
use crate::progress_plugin::ProgressResource;
|
|
use crate::resources::GameStateResource;
|
|
#[cfg(target_os = "android")]
|
|
use crate::resources::{DragState, GameInputConsumedResource};
|
|
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
|
|
use crate::selection_plugin::SelectionState;
|
|
use crate::settings_plugin::SettingsResource;
|
|
use crate::time_attack_plugin::TimeAttackResource;
|
|
use crate::ui_focus::{FocusGroup, Focusable};
|
|
use crate::ui_modal::ModalScrim;
|
|
use crate::ui_theme::SPACE_2;
|
|
use crate::ui_theme::UiTextFx;
|
|
use crate::ui_theme::{
|
|
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
|
|
BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
|
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
|
|
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
|
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
|
scaled_duration,
|
|
};
|
|
use crate::ui_tooltip::Tooltip;
|
|
use solitaire_data::SyncBackend;
|
|
|
|
/// Marker on the score text node.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudScore;
|
|
|
|
/// Marker on the move-count text node.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudMoves;
|
|
|
|
/// Marker on the elapsed-time text node.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudTime;
|
|
|
|
/// Marker on the mode badge text node.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudMode;
|
|
|
|
/// Marker on the daily-challenge constraint text node.
|
|
///
|
|
/// Displays the active goal (time limit or score target) when a daily challenge
|
|
/// is in progress. Empty string when no challenge is active or the game is won.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudChallenge;
|
|
|
|
/// Marker on the "won this deal before" indicator text node.
|
|
///
|
|
/// Displays `"✓ Won before"` when the current deal's seed + draw_mode +
|
|
/// mode triple matches one of the entries in `ReplayHistoryResource`.
|
|
/// Empty string otherwise (including won games — the score readout
|
|
/// already conveys the win on the active deal). Only meaningful for
|
|
/// Classic / Zen / Challenge — daily-challenge and time-attack seeds
|
|
/// are filtered out implicitly because their replay entries always
|
|
/// carry a different mode tag.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudWonPreviously;
|
|
|
|
/// Marker on the undo-count text node.
|
|
///
|
|
/// Shows how many undos have been used this game. Displayed in amber when
|
|
/// `undo_count > 0` because using undo blocks the no-undo achievement.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudUndos;
|
|
|
|
/// Marker on the auto-complete badge text node.
|
|
///
|
|
/// Displays `"AUTO"` in green while `AutoCompleteState.active` is true;
|
|
/// empty string otherwise.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudAutoComplete;
|
|
|
|
/// Marker on the stock-recycle counter text node.
|
|
///
|
|
/// Displays `"Recycles: N"` whenever `recycle_count > 0`, regardless of draw
|
|
/// mode, so the player can track stock recycling in both Draw-One and
|
|
/// Draw-Three (relevant to the `comeback` achievement). Hidden (empty string)
|
|
/// until the first recycle occurs.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudRecycles;
|
|
|
|
/// Marker on the draw-cycle indicator text node.
|
|
///
|
|
/// Only shown in Draw-Three mode. Displays `"Cycle: N/3"` where N is the
|
|
/// number of cards that will be drawn on the next stock click
|
|
/// (`min(stock_len, 3)`). Shows `"Cycle: 0/3"` when the stock is empty
|
|
/// (recycle available). Hidden (empty string) in Draw-One mode or after the
|
|
/// game is won.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudDrawCycle;
|
|
|
|
/// Marker on the keyboard-selection indicator text node.
|
|
///
|
|
/// Displays `"▶ {pile_name}"` while a pile is selected via Tab, or an empty
|
|
/// string when no pile is selected. Uses a light-yellow colour so it stands
|
|
/// out from the other white HUD items.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudSelection;
|
|
|
|
/// Marker on the HUD band background node (the translucent band behind buttons).
|
|
#[derive(Component, Debug)]
|
|
pub struct HudBand;
|
|
|
|
/// Marker on the HUD score/info column root node.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudColumn;
|
|
|
|
/// Marker on the action button bar root node.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudActionBar;
|
|
|
|
/// Set wrapping the chained HUD button/popover interaction systems. Other
|
|
/// keyboard consumers order themselves around it (e.g.
|
|
/// [`crate::ui_focus::FocusKeys`] runs after) so input-consumption order is
|
|
/// deterministic (#143).
|
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct HudButtons;
|
|
|
|
/// Marker on the text node inside each touch-layout action-bar button.
|
|
/// Used by `resize_action_bar_labels` to update font size on window resize.
|
|
#[derive(Component, Debug)]
|
|
struct ActionButtonLabel;
|
|
|
|
/// Marker on the circular profile-picture button anchored to the
|
|
/// top-right of the HUD band. Pressing it opens the Profile overlay.
|
|
/// Shows the server avatar image when loaded; falls back to the player's
|
|
/// initial on a filled disc when no image is available.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudAvatar;
|
|
|
|
/// Controls whether the in-game HUD (band, score column, action buttons) is
|
|
/// visible. Toggled on Android by tapping empty board space; always `Visible`
|
|
/// on desktop. Resets to `Visible` whenever a modal opens.
|
|
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum HudVisibility {
|
|
#[default]
|
|
Visible,
|
|
Hidden,
|
|
}
|
|
|
|
#[cfg(target_os = "android")]
|
|
#[derive(Resource, Debug, Default)]
|
|
struct HudTapTracker {
|
|
start_pos: Option<Vec2>,
|
|
/// Set `true` when the finger-down hit an action button so the
|
|
/// finger-up never toggles bar visibility.
|
|
started_on_button: bool,
|
|
}
|
|
|
|
#[cfg(target_os = "android")]
|
|
const HUD_TAP_SLOP_PX: f32 = 25.0;
|
|
|
|
/// Drives the score-readout pulse: scales the [`HudScore`] text from
|
|
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
|
|
/// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score
|
|
/// entity whenever the score increases; removed once `elapsed >=
|
|
/// duration`.
|
|
#[derive(Component, Debug, Clone, Copy)]
|
|
pub struct ScorePulse {
|
|
/// Seconds elapsed since the pulse started.
|
|
pub elapsed: f32,
|
|
/// Total duration. Zero under `AnimSpeed::Instant` — the system
|
|
/// snaps the scale back to 1.0 on first tick so no half-state
|
|
/// is ever shown.
|
|
pub duration: f32,
|
|
}
|
|
|
|
/// Marker on a transient floating "+N" text spawned next to the score
|
|
/// readout when the score jumps by [`SCORE_FLOATER_THRESHOLD`] or more.
|
|
/// Drifts upward and fades out over `MOTION_SCORE_PULSE_SECS * 2`,
|
|
/// then despawns. Kept rare/meaningful by the threshold gate.
|
|
#[derive(Component, Debug, Clone, Copy)]
|
|
pub struct ScoreFloater {
|
|
/// Seconds elapsed since the floater spawned.
|
|
pub elapsed: f32,
|
|
/// Total lifetime. Zero under `AnimSpeed::Instant` — the system
|
|
/// despawns it on first tick.
|
|
pub duration: f32,
|
|
}
|
|
|
|
/// Drives the streak-milestone flourish: scales the [`HudScore`] text
|
|
/// from `1.0 → STREAK_FLOURISH_PEAK_SCALE → 1.0` over
|
|
/// [`MOTION_STREAK_FLOURISH_SECS`] (scaled by
|
|
/// [`AnimSpeed`](solitaire_data::AnimSpeed)) and tints it
|
|
/// [`ACCENT_SECONDARY`] for the same window before restoring the
|
|
/// original colour.
|
|
///
|
|
/// The streak readout currently lives in the Stats overlay (press
|
|
/// `S`) — there is no always-on HUD streak counter — so the flourish
|
|
/// piggybacks on the score readout, which is the most prominent
|
|
/// always-visible HUD number. Mirrors the `FoundationFlourish`
|
|
/// pattern: triangular scale curve, fixed duration, restores state
|
|
/// when the timer expires.
|
|
///
|
|
/// Inserted on `HudScore` entities by `start_streak_flourish` when a
|
|
/// `WinStreakMilestoneEvent` fires; removed once `elapsed >=
|
|
/// duration` so the readout returns to its rest state for the next
|
|
/// frame's transform sync.
|
|
///
|
|
/// Coexists with [`ScorePulse`]: the streak flourish lives on a
|
|
/// dedicated marker so a streak-crossing win that also ticks the
|
|
/// score (every win does) doesn't have the two animations stomp on
|
|
/// each other's `Transform.scale` writes — the streak flourish runs
|
|
/// in a `Without<ScorePulse>` query so only the loudest of the two
|
|
/// celebrations is active at a time.
|
|
#[derive(Component, Debug, Clone, Copy)]
|
|
pub struct StreakFlourish {
|
|
/// The streak milestone that triggered this flourish (3, 5, 10).
|
|
/// Carried for diagnostic logging only — the visual is identical
|
|
/// for every threshold so play-testing can decide later whether
|
|
/// to differentiate.
|
|
pub streak: u32,
|
|
/// Seconds elapsed since the flourish began.
|
|
pub elapsed: f32,
|
|
/// Total animation length in seconds. Zero under
|
|
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) — the system
|
|
/// snaps the scale back to 1.0 on the first tick so no half-state
|
|
/// is ever shown.
|
|
pub duration: f32,
|
|
/// The score readout's colour before the flourish began —
|
|
/// restored when the timer expires so the readout returns to its
|
|
/// resting `TEXT_PRIMARY` (or whatever it was) tint.
|
|
pub original_color: Color,
|
|
}
|
|
|
|
/// Tracks the score from the previous frame so the HUD can detect
|
|
/// changes without a `ScoreChangedEvent`. The plugin wires this to the
|
|
/// pulse + floater systems on every `Update`.
|
|
#[derive(Resource, Debug, Default, Clone, Copy)]
|
|
pub struct PreviousScore(pub i32);
|
|
|
|
/// Score increase (in points) below which no floating "+N" is spawned.
|
|
/// 50 keeps the feedback for foundation drops and tableau-to-foundation
|
|
/// promotions; single-card placements (which can earn as little as +5)
|
|
/// stay quiet so the floater feels like a reward instead of noise.
|
|
pub const SCORE_FLOATER_THRESHOLD: i32 = 50;
|
|
|
|
/// Marker shared by every clickable HUD action button so a single
|
|
/// `paint_action_buttons` system can recolour them on hover/press without
|
|
/// each button needing its own paint handler.
|
|
#[derive(Component, Debug)]
|
|
pub struct ActionButton;
|
|
|
|
/// Marker on rows inside a popover panel ([`ModesPopover`] or
|
|
/// [`MenuPopover`]). Popover rows already carry `ActionButton` so the
|
|
/// hover/press paint path applies to them, but the auto-fade applied
|
|
/// to the top-level action bar must NOT also fade these rows — the
|
|
/// popover only renders when the player has explicitly opened it, so
|
|
/// its content should always be at full opacity. `apply_action_fade`
|
|
/// excludes entities with this marker via `Without<PopoverRow>`.
|
|
#[derive(Component, Debug)]
|
|
pub struct PopoverRow;
|
|
|
|
/// Marker on the "New Game" action button anchored top-right of the play
|
|
/// area. Click fires [`NewGameRequestEvent`]; the existing
|
|
/// `ConfirmNewGameScreen` modal handles confirmation when a game is in
|
|
/// progress.
|
|
#[derive(Component, Debug)]
|
|
pub struct NewGameButton;
|
|
|
|
/// Marker on the "Undo" action button. Click fires [`UndoRequestEvent`],
|
|
/// mirroring the `U` keyboard accelerator.
|
|
#[derive(Component, Debug)]
|
|
pub struct UndoButton;
|
|
|
|
/// Marker on the "Pause" action button. Click fires [`PauseRequestEvent`],
|
|
/// mirroring the `Esc` keyboard accelerator. The pause overlay's own resume
|
|
/// affordance dismisses it from the paused state.
|
|
#[derive(Component, Debug)]
|
|
pub struct PauseButton;
|
|
|
|
/// Accumulated hold time on the Undo button, driving hold-to-repeat undo
|
|
/// (Phase F). `handle_undo_button` owns the instant first undo on press;
|
|
/// `repeat_undo_on_hold` starts firing additional [`UndoRequestEvent`]s
|
|
/// once the hold passes `UNDO_HOLD_INITIAL_DELAY_SECS` and then every
|
|
/// `UNDO_HOLD_REPEAT_INTERVAL_SECS`. Each repeated undo goes through the
|
|
/// normal request path, so the existing scoring penalty applies per step.
|
|
#[derive(Resource, Debug, Default)]
|
|
pub struct UndoHoldState {
|
|
/// Seconds the Undo button has been continuously held.
|
|
held_secs: f32,
|
|
}
|
|
|
|
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
|
|
/// mirroring the `F1` keyboard accelerator.
|
|
#[derive(Component, Debug)]
|
|
pub struct HelpButton;
|
|
|
|
/// Marker on the "Hint" action button. Click spawns an async solver task
|
|
/// (same as the `H` keyboard accelerator) and highlights the suggested card.
|
|
#[derive(Component, Debug)]
|
|
pub struct HintButton;
|
|
|
|
/// Marker on the touch action bar's "Draw" button (Phase F). Click fires
|
|
/// [`DrawRequestEvent`] — same queue as tapping the stock pile — so the
|
|
/// draw action is within thumb reach without stretching to the top of a
|
|
/// tall folded screen. Touch layout only; desktop draws via stock
|
|
/// click / `D` / `Space`.
|
|
#[derive(Component, Debug)]
|
|
pub struct DrawButton;
|
|
|
|
/// Android HUD label for the Hint button — shared with the help screen's
|
|
/// controls reference so both always agree.
|
|
#[cfg(target_os = "android")]
|
|
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
|
|
|
|
/// Hint label used by the touch action bar. Aliases [`ANDROID_HINT_LABEL`]
|
|
/// on Android so the bar and the help screen's controls reference can
|
|
/// never drift; the non-Android value only exists so the touch spawn
|
|
/// path stays host-compilable for tests.
|
|
#[cfg(target_os = "android")]
|
|
const TOUCH_HINT_LABEL: &str = ANDROID_HINT_LABEL;
|
|
#[cfg(not(target_os = "android"))]
|
|
const TOUCH_HINT_LABEL: &str = "Hint";
|
|
#[cfg(target_os = "android")]
|
|
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
|
|
#[cfg(not(target_os = "android"))]
|
|
const ACTION_BAR_COLUMN_GAP: Val = VAL_SPACE_2;
|
|
#[cfg(target_os = "android")]
|
|
const ACTION_POPOVER_BOTTOM_PX: f32 = 200.0;
|
|
#[cfg(not(target_os = "android"))]
|
|
const ACTION_POPOVER_BOTTOM_PX: f32 = 80.0;
|
|
#[cfg(target_os = "android")]
|
|
const HINT_WON_MSG: &str = "Game won! Tap New Game to play again";
|
|
#[cfg(not(target_os = "android"))]
|
|
const HINT_WON_MSG: &str = "Game won! Press N for a new game";
|
|
|
|
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
|
|
/// (a small dropdown panel) below the action bar. Each popover row starts
|
|
/// the corresponding game mode.
|
|
#[derive(Component, Debug)]
|
|
pub struct ModesButton;
|
|
|
|
/// Marker on the dropdown panel that opens below the [`ModesButton`].
|
|
/// Spawned on first click, despawned on second click or on mode select.
|
|
#[derive(Component, Debug)]
|
|
pub struct ModesPopover;
|
|
|
|
/// One row inside the [`ModesPopover`]. The variant carries which event
|
|
/// the click handler should fire — Classic uses `NewGameRequestEvent`
|
|
/// directly, the others go through their `Start*RequestEvent` so the
|
|
/// existing keyboard handler's level gate / resource setup runs.
|
|
#[derive(Component, Debug, Clone, Copy)]
|
|
pub enum ModeOption {
|
|
Classic,
|
|
DailyChallenge,
|
|
Zen,
|
|
Challenge,
|
|
TimeAttack,
|
|
}
|
|
|
|
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
|
|
/// which exposes the Home / Profile / Stats / Achievements /
|
|
/// Leaderboard / Settings / Help overlays without needing the
|
|
/// M/P/S/A/L/O/F1 hotkeys.
|
|
#[derive(Component, Debug)]
|
|
pub struct MenuButton;
|
|
|
|
/// Marker on the dropdown panel that opens below the [`MenuButton`].
|
|
#[derive(Component, Debug)]
|
|
pub struct MenuPopover;
|
|
|
|
/// Shared marker placed on both [`MenuPopover`] and [`ModesPopover`] entities
|
|
/// while they are open. External systems (e.g. `PausePlugin`) query this to
|
|
/// determine whether a HUD popover is currently visible without importing the
|
|
/// individual popover types.
|
|
#[derive(Component, Debug)]
|
|
pub struct HudPopoverOpen;
|
|
|
|
/// Fullscreen transparent backdrop spawned behind the [`MenuPopover`].
|
|
/// Pressing it (tap anywhere outside the popover) light-dismisses the menu.
|
|
#[derive(Component, Debug)]
|
|
struct MenuPopoverBackdrop;
|
|
|
|
/// Fullscreen transparent backdrop spawned behind the [`ModesPopover`].
|
|
/// Pressing it (tap anywhere outside the popover) light-dismisses it.
|
|
#[derive(Component, Debug)]
|
|
struct ModesPopoverBackdrop;
|
|
|
|
/// One row inside the [`MenuPopover`]. The variant selects which
|
|
/// `Toggle*RequestEvent` the click handler fires. Rows render grouped
|
|
/// under section headers (Play · You · Community · System); mode
|
|
/// selection lives on Home, so there is no Modes row here.
|
|
#[derive(Component, Debug, Clone, Copy)]
|
|
pub enum MenuOption {
|
|
Home,
|
|
Profile,
|
|
Stats,
|
|
Achievements,
|
|
Leaderboard,
|
|
Settings,
|
|
Help,
|
|
}
|
|
|
|
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
|
/// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module
|
|
/// can use it as a `const` without a non-const expression in `ZIndex(...)`.
|
|
const Z_HUD: i32 = crate::ui_theme::Z_HUD;
|
|
const Z_HUD_POPOVER_BACKDROP: i32 = crate::ui_theme::Z_HUD_POPOVER_BACKDROP;
|
|
const Z_HUD_POPOVER: i32 = crate::ui_theme::Z_HUD_POPOVER;
|
|
const Z_HUD_TOP: i32 = crate::ui_theme::Z_HUD_TOP;
|
|
|
|
/// Idle / hover / pressed colours shared by every action button. Aliased
|
|
/// to the theme tokens so the HUD picks up palette changes for free.
|
|
const ACTION_BTN_IDLE: Color = BG_ELEVATED;
|
|
const ACTION_BTN_HOVER: Color = BG_ELEVATED_HI;
|
|
const ACTION_BTN_PRESSED: Color = BG_ELEVATED_PRESSED;
|
|
|
|
/// Renders the in-game HUD: score counter, move counter, elapsed timer, draw-mode indicator, and the auto-complete badge that lights up when the game is solvable without further input.
|
|
pub struct HudPlugin;
|
|
|
|
impl Plugin for HudPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
// The click handlers write to messages registered elsewhere by their
|
|
// owning plugins (`GamePlugin`, `PausePlugin`, `HelpPlugin`,
|
|
// `challenge_plugin`, `daily_challenge_plugin`, `time_attack_plugin`,
|
|
// `input_plugin`). Re-register defensively so the HUD plugin works in
|
|
// isolation under `MinimalPlugins` (tests). `add_message` is
|
|
// idempotent.
|
|
app.add_message::<NewGameRequestEvent>()
|
|
.add_message::<UndoRequestEvent>()
|
|
.add_message::<DrawRequestEvent>()
|
|
.add_message::<PauseRequestEvent>()
|
|
.add_message::<HelpRequestEvent>()
|
|
.add_message::<StartZenRequestEvent>()
|
|
.add_message::<StartChallengeRequestEvent>()
|
|
.add_message::<StartTimeAttackRequestEvent>()
|
|
.add_message::<StartDailyChallengeRequestEvent>()
|
|
.add_message::<ToggleStatsRequestEvent>()
|
|
.add_message::<ToggleHomeRequestEvent>()
|
|
.add_message::<ToggleAchievementsRequestEvent>()
|
|
.add_message::<ToggleProfileRequestEvent>()
|
|
.add_message::<ToggleSettingsRequestEvent>()
|
|
.add_message::<ToggleLeaderboardRequestEvent>()
|
|
.add_message::<WinStreakMilestoneEvent>()
|
|
.init_resource::<PreviousScore>()
|
|
.init_resource::<HudActionFade>()
|
|
.init_resource::<HudVisibility>()
|
|
.init_resource::<UndoHoldState>()
|
|
// Escape-close handlers for popovers read this; init defensively
|
|
// so HudPlugin works under MinimalPlugins in tests.
|
|
.init_resource::<ButtonInput<KeyCode>>()
|
|
// WindowResized is registered by table_plugin; re-register
|
|
// defensively so the HUD plugin works standalone in tests.
|
|
.add_message::<WindowResized>()
|
|
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
|
|
// HUD text updaters run as one deterministic chain (they write
|
|
// disjoint Text nodes, but Bevy can't prove it); update_hud also
|
|
// reads AutoCompleteState, so the chain sits after the
|
|
// auto-complete detect/drive chain (#143).
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
update_hud,
|
|
update_selection_hud.run_if(
|
|
resource_exists_and_changed::<SelectionState>
|
|
.or(resource_exists_and_changed::<GameStateResource>),
|
|
),
|
|
update_won_previously,
|
|
)
|
|
.chain()
|
|
.after(GameMutation)
|
|
.after(crate::auto_complete_plugin::AutoComplete)
|
|
.in_set(UiTextFx)
|
|
.ambiguous_with(UiTextFx),
|
|
)
|
|
// HUD chrome visibility: modal-restore writes HudVisibility, the
|
|
// applier consumes it, and the layout recompute reads it — a
|
|
// fixed chain instead of three racing systems (#143).
|
|
.add_systems(
|
|
Update,
|
|
(restore_hud_on_modal, apply_hud_visibility)
|
|
.chain()
|
|
.before(LayoutSystem::UpdateOnResize),
|
|
)
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
update_hud_avatar.after(crate::settings_plugin::SettingsMutation),
|
|
handle_avatar_button.ambiguous_with(HudButtons),
|
|
),
|
|
)
|
|
.add_systems(
|
|
Update,
|
|
announce_auto_complete
|
|
.after(GameMutation)
|
|
.after(crate::auto_complete_plugin::AutoComplete)
|
|
.in_set(crate::game_plugin::InfoToastWriters)
|
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
|
)
|
|
// Typography rescale touches HUD TextFont only, but orders after
|
|
// the board painters that resize card/label text (#143).
|
|
.add_systems(
|
|
Update,
|
|
update_hud_typography.after(crate::card_plugin::BoardVisuals),
|
|
)
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
detect_score_change,
|
|
advance_score_pulse,
|
|
advance_score_floater,
|
|
)
|
|
.chain()
|
|
.after(GameMutation)
|
|
.in_set(UiTextFx)
|
|
.ambiguous_with(UiTextFx)
|
|
.ambiguous_with(crate::card_plugin::BoardVisuals),
|
|
)
|
|
.add_systems(
|
|
Update,
|
|
(start_streak_flourish, advance_streak_flourish)
|
|
.chain()
|
|
.after(GameMutation)
|
|
.in_set(UiTextFx)
|
|
.ambiguous_with(UiTextFx)
|
|
.ambiguous_with(crate::card_plugin::BoardVisuals),
|
|
)
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
handle_new_game_button
|
|
.in_set(NewGameRequestWriters)
|
|
.ambiguous_with(NewGameRequestWriters),
|
|
handle_undo_button
|
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
|
.before(GameMutation),
|
|
repeat_undo_on_hold
|
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
|
.before(GameMutation),
|
|
handle_draw_button
|
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
|
|
.before(GameMutation),
|
|
handle_pause_button,
|
|
handle_help_button,
|
|
handle_hint_button
|
|
.after(GameMutation)
|
|
.in_set(crate::game_plugin::InfoToastWriters)
|
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
|
handle_modes_button,
|
|
handle_mode_option_click
|
|
.in_set(NewGameRequestWriters)
|
|
.ambiguous_with(NewGameRequestWriters),
|
|
handle_modes_backdrop_click,
|
|
close_modes_popover_on_escape,
|
|
handle_menu_button,
|
|
handle_menu_option_click,
|
|
handle_menu_backdrop_click,
|
|
close_menu_popover_on_escape,
|
|
paint_action_buttons,
|
|
paint_glass_tab_buttons,
|
|
drive_tab_expansion,
|
|
// Writes tab-label TextColor and reads TextFont — entity-
|
|
// disjoint from the UiTextFx readout writers (tab labels
|
|
// vs HUD readouts), which Bevy can't prove, so declare it.
|
|
animate_tab_expansion.ambiguous_with(UiTextFx),
|
|
)
|
|
.chain()
|
|
.in_set(HudButtons)
|
|
.before(crate::ui_focus::FocusKeys),
|
|
)
|
|
// Fade lives in `Last` so it always overrides whatever the
|
|
// hover/paint pass set on `BackgroundColor` this frame.
|
|
// Otherwise on a hover-state change (`Changed<Interaction>`),
|
|
// `paint_action_buttons` would clobber the alpha back to 1.0
|
|
// mid-fade and produce a visible blip.
|
|
;
|
|
// Desktop-only: cursor-proximity fade. On Android the bar
|
|
// visibility is toggled explicitly; cursor_position() returning
|
|
// Some(touch_pos) during a tap would otherwise fade the bar out.
|
|
#[cfg(not(target_os = "android"))]
|
|
app.add_systems(Last, (update_action_fade, apply_action_fade).chain());
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
app.init_resource::<HudTapTracker>()
|
|
.add_message::<TouchInput>()
|
|
.add_systems(
|
|
Update,
|
|
toggle_hud_on_tap
|
|
.after(TouchDragSet::AfterStartDrag)
|
|
.in_set(TouchDragSet::BeforeEndDrag),
|
|
);
|
|
app.add_systems(
|
|
Update,
|
|
resize_action_bar_labels
|
|
.run_if(resource_exists_and_changed::<crate::layout::LayoutResource>),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|