Compare commits
19 Commits
de52c8a7b7
...
60a80369d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 60a80369d4 | |||
| dbe6c60133 | |||
| 74597a8c84 | |||
| 5d57b67934 | |||
| 220e3f040c | |||
| 54d34972d4 | |||
| 0c86cac2d5 | |||
| 2e080d02ce | |||
| 73e210b243 | |||
| f866299021 | |||
| b78a493a0c | |||
| 51d3454344 | |||
| 12789529a1 | |||
| c1bde18a2c | |||
| fd7fb7b6da | |||
| 138436558f | |||
| 65d595ad12 | |||
| abeb4e5cdf | |||
| b082bd65a6 |
+4
-4
@@ -133,7 +133,7 @@ Owns:
|
||||
- `SyncProvider` trait — implemented by `SolitaireServerClient`
|
||||
|
||||
### `solitaire_engine`
|
||||
**Dependencies:** `bevy`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`.
|
||||
**Dependencies:** `bevy`, `kira`, `solitaire_core`, `solitaire_data`.
|
||||
|
||||
All Bevy-specific code. Structured as a collection of Plugins that `solitaire_app` registers.
|
||||
|
||||
@@ -246,7 +246,7 @@ The "Shortcut" column lists optional keyboard accelerators. Every action in this
|
||||
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
|
||||
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
|
||||
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
|
||||
| `AudioPlugin` | — | Sound effect and music playback via bevy_kira_audio |
|
||||
| `AudioPlugin` | — | Sound effect and music playback via kira |
|
||||
| `InputPlugin` | — | Keyboard and mouse input routing |
|
||||
| `CursorPlugin` | — | Custom cursor sprite during drag |
|
||||
| `SelectionPlugin` | — | Keyboard-driven card selection |
|
||||
@@ -754,7 +754,7 @@ Levels 11+: level = 10 + floor((total_xp - 5000) / 1000)
|
||||
|
||||
## 13. Audio System
|
||||
|
||||
Audio uses `bevy_kira_audio`. All sound files are `.wav`.
|
||||
Audio uses `kira`. All sound files are `.wav`.
|
||||
|
||||
| File | Trigger |
|
||||
|---|---|
|
||||
@@ -765,7 +765,7 @@ Audio uses `bevy_kira_audio`. All sound files are `.wav`.
|
||||
| `win_fanfare.wav` | Game won |
|
||||
| `ambient_loop.wav` | Looping background music |
|
||||
|
||||
Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `bevy_kira_audio` channel volumes.
|
||||
Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `kira` channel volumes.
|
||||
|
||||
Audio systems listen for Bevy events and never block the game thread.
|
||||
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# Credits
|
||||
|
||||
Solitaire Quest is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
|
||||
the work of many open-source projects and a small handful of third-party
|
||||
assets. This file lists every component that ships in the binary or in the
|
||||
`assets/` directory.
|
||||
|
||||
---
|
||||
|
||||
## Code & Framework
|
||||
|
||||
| Component | License | Role |
|
||||
|---|---|---|
|
||||
| [Bevy 0.18](https://bevyengine.org/) | MIT OR Apache-2.0 | Game engine, ECS, rendering, UI |
|
||||
| [kira 0.12](https://crates.io/crates/kira) | MIT OR Apache-2.0 | Audio playback (mixer, sub-tracks, looping ambient) |
|
||||
| [serde](https://crates.io/crates/serde) / [serde_json](https://crates.io/crates/serde_json) | MIT OR Apache-2.0 | Serialization for save files and the sync API |
|
||||
| [tokio](https://crates.io/crates/tokio) | MIT | Async runtime for the sync client and server |
|
||||
| [axum 0.8](https://crates.io/crates/axum) | MIT | HTTP framework for the self-hosted sync server |
|
||||
| [sqlx 0.8](https://crates.io/crates/sqlx) | MIT OR Apache-2.0 | Compile-time-checked SQLite access on the server |
|
||||
| [reqwest 0.13](https://crates.io/crates/reqwest) | MIT OR Apache-2.0 | HTTP client for the sync provider |
|
||||
| [jsonwebtoken 10](https://crates.io/crates/jsonwebtoken) | MIT | JWT issuance and validation |
|
||||
| [bcrypt 0.19](https://crates.io/crates/bcrypt) | MIT | Password hashing on the server |
|
||||
| [keyring 4](https://crates.io/crates/keyring) | MIT OR Apache-2.0 | OS keychain integration for credential storage |
|
||||
| [tower-governor 0.8](https://crates.io/crates/tower-governor) | MIT | Rate limiting on `/api/auth/*` |
|
||||
| [chrono](https://crates.io/crates/chrono) | MIT OR Apache-2.0 | Date / time handling |
|
||||
| [uuid](https://crates.io/crates/uuid) | MIT OR Apache-2.0 | User and session identifiers |
|
||||
| [thiserror](https://crates.io/crates/thiserror) | MIT OR Apache-2.0 | Error type derive |
|
||||
| [rand 0.9](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Seeded shuffler in `solitaire_core` |
|
||||
| [png 0.17](https://crates.io/crates/png) | MIT OR Apache-2.0 | PNG encoder used by `solitaire_assetgen` |
|
||||
| [ab_glyph 0.2](https://crates.io/crates/ab_glyph) | Apache-2.0 | Glyph rasterization for generated card art |
|
||||
|
||||
The full transitive dependency tree (several hundred crates) is captured in
|
||||
`Cargo.lock` and reachable via `cargo tree`. Every crate brought in is
|
||||
MIT, Apache-2.0, BSD-style, or a dual-licensed combination thereof — no
|
||||
copyleft code is statically linked into the game binary.
|
||||
|
||||
---
|
||||
|
||||
## Assets
|
||||
|
||||
### Card artwork
|
||||
|
||||
| File(s) | Source | License |
|
||||
|---|---|---|
|
||||
| `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | xCards @2x artwork | LGPL-3.0 |
|
||||
| `assets/cards/backs/back_0.png` (bicycle_blue) | xCards @2x artwork | LGPL-3.0 |
|
||||
| `assets/cards/backs/back_1.png` – `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
|
||||
|
||||
xCards is the playing-card artwork bundle by Huub de Beer, published under the
|
||||
LGPL-3.0. The art is consumed as unmodified PNG files at runtime; the game
|
||||
binary statically links no LGPL code, so distribution as a self-contained
|
||||
binary plus the `assets/` directory satisfies the LGPL's relinking clause.
|
||||
|
||||
### Backgrounds
|
||||
|
||||
| File(s) | Source | License |
|
||||
|---|---|---|
|
||||
| `assets/backgrounds/bg_0.png` – `bg_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
|
||||
|
||||
### Typography
|
||||
|
||||
| File | Source | License |
|
||||
|---|---|---|
|
||||
| `assets/fonts/main.ttf` (FiraMono-Medium) | [mozilla/Fira](https://github.com/mozilla/Fira) | SIL Open Font License 1.1 |
|
||||
|
||||
The OFL permits redistribution and embedding in software so long as the font
|
||||
file itself is not sold standalone. The file ships unmodified.
|
||||
|
||||
### Audio
|
||||
|
||||
All six WAV files in `assets/audio/` are **original work** — there are no
|
||||
third-party audio samples in this project. They are synthesized
|
||||
programmatically by `solitaire_assetgen/src/bin/gen_sfx.rs`, which writes
|
||||
44.1 kHz mono 16-bit PCM WAVs using a hand-rolled WAV writer (no `hound` or
|
||||
`dasp` dependency). The synthesis stack is entirely additive: sine /
|
||||
square waves, layered harmonics, deterministic LCG noise, AR envelopes,
|
||||
and a slow LFO for the ambient track.
|
||||
|
||||
| File | Synthesis approach |
|
||||
|---|---|
|
||||
| `card_deal.wav` | Filtered LCG noise with a sweeping low-pass cutoff for a "whoosh" |
|
||||
| `card_flip.wav` | High-passed LCG noise under a fast AR envelope |
|
||||
| `card_place.wav` | 120 Hz sine body + filtered noise click |
|
||||
| `card_invalid.wav` | Two dissonant square tones (196 Hz + 207.65 Hz) beating against each other |
|
||||
| `win_fanfare.wav` | C-major arpeggio (C5 / E5 / G5 / C6) with sine + 2nd harmonic |
|
||||
| `ambient_loop.wav` | 55 Hz fundamental with 2nd and 3rd harmonics, modulated by a 0.2 Hz LFO; loop length is chosen so the tone and LFO both complete an integer number of cycles for seamless looping |
|
||||
|
||||
Audio files are MIT-licensed alongside the rest of this project.
|
||||
|
||||
---
|
||||
|
||||
## License Summary
|
||||
|
||||
- **Project code:** MIT — see [LICENSE](LICENSE).
|
||||
- **xCards card artwork (52 faces + `back_0.png`):** LGPL-3.0, redistributed
|
||||
unmodified. The LGPL applies only to those PNG files; it does not extend to
|
||||
the game binary, which links no LGPL code.
|
||||
- **FiraMono-Medium font:** SIL Open Font License 1.1, redistributed unmodified.
|
||||
- **All other assets** (backgrounds, generated card backs, every audio file)
|
||||
are original work covered by this project's MIT license.
|
||||
|
||||
If you redistribute Solitaire Quest, you must ship this `CREDITS.md` and the
|
||||
`LICENSE` file alongside the binary so the LGPL and OFL notices remain
|
||||
visible to end users.
|
||||
@@ -68,6 +68,14 @@ cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_se
|
||||
cargo clippy --workspace -- -D warnings
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem (Tokio,
|
||||
Axum, sqlx, Serde, kira, and many more). Card faces and the default card back
|
||||
use xCards artwork (LGPL-3.0); the UI font is FiraMono-Medium (OFL). All audio
|
||||
is synthesized programmatically by this project. See [CREDITS.md](CREDITS.md)
|
||||
for the full list and license details.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
+103
-245
@@ -1,284 +1,142 @@
|
||||
# Solitaire Quest — UX Overhaul Session Handoff
|
||||
|
||||
**Last updated:** 2026-04-30 — Phase 3 complete + Phase 4 in progress (Track B landed on disk, Track G subset in flight via background agent).
|
||||
**Last updated:** 2026-04-30 — Phase 3 complete + Phase 4 polish landed. v1 release-readiness scope is largely done; remaining work is final smoke test, push, and tag.
|
||||
|
||||
## ⚠️ In-progress work at pause time
|
||||
## Status at pause
|
||||
|
||||
Smoke-test passed; Phase 4 was started. Pushed HEAD is `534870a`. The working tree has **uncommitted** work that is NOT pushed:
|
||||
- **HEAD:** `5d57b67` — local master is **16 commits ahead of `origin/master`** (unpushed).
|
||||
- **Working tree:** modified but uncommitted edits in `solitaire_engine/src/hud_plugin.rs` and `solitaire_engine/src/settings_plugin.rs` — an in-flight tooltip-popover extension threaded onto the Settings sliders/togglers/pickers. Not staged, not built against; review and finish-or-revert before resuming new work.
|
||||
- **Build:** `cargo build --workspace` and `cargo clippy --workspace -- -D warnings` clean as of last commit.
|
||||
- **Tests:** **872 passed / 0 failed / 9 ignored** across the workspace.
|
||||
|
||||
### Track B — window polish (on disk, ready to commit)
|
||||
## Where we are
|
||||
|
||||
- **File:** `solitaire_app/src/main.rs` (+44 lines)
|
||||
- **What landed:**
|
||||
- X11/Wayland WM_CLASS via `Window::name = Some("solitaire-quest".into())`
|
||||
- Default position `WindowPosition::Centered(MonitorSelection::Primary)`
|
||||
- `install_crash_log_hook()` wraps the default panic hook to also append a `crash.log` next to `settings.json`. Uses `std::time::SystemTime` (no new chrono dep). Falls through silently if the data dir is unavailable.
|
||||
- **Skipped this round (deferred):**
|
||||
- App icon hookup — no artwork asset exists yet; add the loader path when art lands.
|
||||
- Persisted window geometry — needs a `Settings` schema migration.
|
||||
- F11 fullscreen toggle — already wired in `input_plugin.rs:114`, no change needed.
|
||||
- **Build status:** `cargo build -p solitaire_app` clean; `cargo clippy -p solitaire_app -- -D warnings` clean.
|
||||
- **Suggested commit subject:** `feat(app): window polish — class name, centered position, crash-log hook`
|
||||
Phase 3 of the UX overhaul (design tokens, modal scaffold, animation curves) shipped earlier in the session and is unchanged. Phase 4 (release-grade polish) layered another 22 commits on top: window polish, modal animation, score feedback, three phases of focus rings, Home repurposed as a mode launcher, tooltip infrastructure + HUD wiring, branded splash screen, achievement integration tests, microcopy unification, leaderboard error/idle states, first-launch empty-state polish, hit-target accessibility fix, CREDITS.md, ARCHITECTURE doc-rot fix.
|
||||
|
||||
### Track G subset — modal open animation + score-change feedback (in flight)
|
||||
|
||||
- A **background agent** (`general-purpose`, no worktree) was launched against this turn's tree to:
|
||||
- Extend `spawn_modal` in `solitaire_engine/src/ui_modal.rs` with a `ModalEntering` component + `advance_modal_enter` system that animates scrim alpha 0 → `SCRIM` and card scale 0.96 → 1.0 over `MOTION_MODAL_SECS`. Respects `AnimSpeed::Instant` via `scaled_duration`. Animate-OUT path is intentionally out of scope.
|
||||
- In `solitaire_engine/src/hud_plugin.rs`, add a `ScorePulse` 1.0→1.1→1.0 readout pulse over `MOTION_SCORE_PULSE_SECS` and a floating "+N" Text2d (only for ≥ +50 jumps) that drifts up ~40 px and fades over `MOTION_SCORE_PULSE_SECS * 2`.
|
||||
- Tests for both behaviours.
|
||||
- **State at pause:** the agent had partial edits in `solitaire_engine/src/ui_modal.rs` (visible via `git status`) — at least one unused-import warning was already surfacing. It had not reported back when this snapshot was taken.
|
||||
- **Resume options for the next session:**
|
||||
1. **Wait for the notification.** The agent runs in background; if Claude Code is still alive, the completion notification will fire.
|
||||
2. **Inspect and finish manually.** `git diff solitaire_engine/src/ui_modal.rs solitaire_engine/src/hud_plugin.rs` to see what landed; finish or revert and restart with a tighter prompt.
|
||||
3. **Discard and restart.** `git restore solitaire_engine/src/ui_modal.rs solitaire_engine/src/hud_plugin.rs` then relaunch the agent with the prompt below.
|
||||
|
||||
### Next-session workflow at pause
|
||||
|
||||
1. Verify the workspace builds cleanly with **all** in-flight changes: `cargo build --workspace && cargo clippy --workspace -- -D warnings && cargo test --workspace`. The Track B `main.rs` change is independent — even if Track G is reverted, B compiles on its own.
|
||||
2. If Track B is clean and Track G is incomplete or broken: commit Track B first using the subject above, then deal with Track G.
|
||||
3. If both are clean: commit each as a separate landing — one feature per commit per project convention.
|
||||
4. Use:
|
||||
```
|
||||
git -c user.name=funman300 -c user.email=root@vscode.infinity commit -m "<subject>"
|
||||
```
|
||||
5. Push with `git push origin master` (requires interactive credentials on `git.aleshym.co`).
|
||||
|
||||
### Original Track G subset prompt (for relaunch if needed)
|
||||
|
||||
The agent's full brief is preserved here verbatim — paste into a fresh agent if the current one is unrecoverable:
|
||||
|
||||
```
|
||||
Two UI/UX polish items from track G. Tree clean at HEAD `534870a`.
|
||||
Sub-agents CANNOT git commit — stage your work; orchestrator commits.
|
||||
|
||||
G1. Modal open animation: extend spawn_modal in ui_modal.rs with a
|
||||
ModalEntering component + advance_modal_enter system that animates
|
||||
scrim alpha 0 → SCRIM and card scale 0.96 → 1.0 over MOTION_MODAL_SECS.
|
||||
Use scaled_duration for AnimSpeed respect; ease-out curve t*(2-t).
|
||||
Register the system in UiModalPlugin::build. Animate-OUT is OUT of
|
||||
scope. Add ≥2 tests covering ModalEntering presence on spawn and
|
||||
removal after duration elapses.
|
||||
|
||||
G2. Score-change feedback in hud_plugin.rs: ScorePulse component that
|
||||
scales the score Text 1.0→1.1→1.0 over MOTION_SCORE_PULSE_SECS using
|
||||
triangular curve. Plus a floating "+N" Text2d (only for ≥ +50 jumps)
|
||||
in ACCENT_PRIMARY that drifts up 40 px and fades over
|
||||
MOTION_SCORE_PULSE_SECS * 2. Add ≥2 tests for floater spawn on +50
|
||||
and despawn after lifetime, plus ≥1 test that +5 does NOT spawn.
|
||||
|
||||
Hard requirements: workspace build + clippy --workspace -- -D warnings
|
||||
+ test --workspace all green. Touch ONLY ui_modal.rs, hud_plugin.rs,
|
||||
optionally ui_theme.rs for new tokens (don't think you'll need any).
|
||||
DO NOT touch solitaire_app/src/main.rs (parallel work).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Where we are (Phase 3)
|
||||
|
||||
Phase 3 of the UX overhaul brief is **done**. The whole engine has been migrated to the `ui_theme` design-token system + `ui_modal` scaffold. Animation system upgraded. Final literal sweep landed. The work spans 17 commits this session, from the foundation (`e14852c`) through to the final sweep (`54e024c`).
|
||||
|
||||
### Design direction (already saved as project memory)
|
||||
### Design direction (unchanged)
|
||||
|
||||
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
|
||||
- **Palette:** Midnight Purple base (`BG_BASE` `#1A0F2E` → `BG_ELEVATED` `#2D1B69` → `BG_ELEVATED_HI` `#3A2580` → `BG_ELEVATED_TOP` `#482F97`) + Balatro yellow primary accent (`ACCENT_PRIMARY` `#FFD23F`) + warm magenta secondary (`ACCENT_SECONDARY` `#FF6B9D`).
|
||||
- See [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for the full direction.
|
||||
- **Palette:** Midnight Purple base + Balatro yellow primary + warm magenta secondary.
|
||||
- See [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for full direction.
|
||||
|
||||
### Top complaints from the original smoke test — all closed
|
||||
## Phase 3 (shipped)
|
||||
|
||||
1. **HUD too cluttered.** ✅ Closed by `73cad7e` — readouts now sit in a 4-tier vertical stack with progressive disclosure of penalty/bonus tiers.
|
||||
2. **Y/N keyboard prompts feel like debug panels.** ✅ Closed across Confirm, GameOver, Pause, Forfeit, and Settings modals — every prompt now has real Primary/Secondary/Tertiary buttons with hover/press feedback.
|
||||
- `solitaire_engine/src/ui_theme.rs` — every design token: colours, type scale, spacing scale, radius rungs, z-index hierarchy, motion durations.
|
||||
- `solitaire_engine/src/ui_modal.rs` — `spawn_modal` scaffold + button-variant helpers + `paint_modal_buttons` system.
|
||||
- All 12 overlays migrated to the modal scaffold with real Primary/Secondary/Tertiary buttons (no more Y/N debug prompts).
|
||||
- HUD restructured into a 4-tier vertical stack with progressive disclosure.
|
||||
- Animation upgrades: `SmoothSnap` slide curves, scoped settle bounce, deal jitter, win-cascade rotation.
|
||||
|
||||
## Foundation (done)
|
||||
## Phase 4 (shipped this session)
|
||||
|
||||
- **`solitaire_engine/src/ui_theme.rs`** — every design token: colours, 5-rung typography scale, 4-multiple spacing scale, three radius rungs, monotonically-ordered z-index hierarchy, motion durations with `scaled_duration(speed)` helper.
|
||||
- **`solitaire_engine/src/ui_modal.rs`** — `spawn_modal` scaffold + `spawn_modal_header` / `spawn_modal_body_text` / `spawn_modal_actions` / `spawn_modal_button` helpers + `ButtonVariant` enum (Primary / Secondary / Tertiary) + `paint_modal_buttons` system. `UiModalPlugin` registered in `solitaire_app/src/main.rs`.
|
||||
| Area | Commit | What landed |
|
||||
|---|---|---|
|
||||
| Workspace lint | `9bfca92` | Test-only clippy warnings under `--all-targets` resolved. |
|
||||
| App / window | `5f5aba8` | WM_CLASS, centered-on-primary window, panic hook → `crash.log`. |
|
||||
| Modal animation | `71999e1` | `ModalEntering` + ease-out scrim fade and 0.96→1.0 card scale over `MOTION_MODAL_SECS`; `Instant` collapses to zero. |
|
||||
| Score feedback | `dcfa976` | `ScorePulse` triangular 1.0→1.1→1.0; floating "+N" for jumps ≥ `SCORE_FLOATER_THRESHOLD`. |
|
||||
| Hit targets | `b082bd6` | `ICON_BUTTON_PX` 28 → 32; settings sync status reads "local only" not "not configured". |
|
||||
| Microcopy | `abeb4e5` | Help "Close" → "Done"; final onboarding CTA → "Let's play". |
|
||||
| Empty states | `65d595a` | First-launch em-dash zero-stats grid + welcome line on Profile. |
|
||||
| Leaderboard | `1384365` | Idle/Loaded/Error enum; local-only guard replaces opt-in/out buttons. |
|
||||
| Credits | `fd7fb7b`, `f866299` | CREDITS.md added (xCards, FiraMono, Bevy, kira, Rust deps); README links it. |
|
||||
| Home | `c1bde18` | Home repurposed as Mode Launcher: 5 mode cards, level-5 lock state, dispatches existing request events. |
|
||||
| Focus rings (Phase 1) | `1278952` | Tab/Shift-Tab/Enter on every modal button; auto-focus primary; overlay tracks `GlobalTransform` above scrim. |
|
||||
| Focus rings (Phase 2) | `51d3454` | HUD action bar (hover-gated) and Home mode cards. |
|
||||
| Focus rings (Phase 3) | `b78a493` | Settings: icon buttons, swatches, toggles; arrow-key navigation in `FocusRow`; auto-scroll keeps focused control in viewport. |
|
||||
| Achievement tests | `2e080d0` | Integration coverage for `draw_three_master` and `zen_winner` — every advertised achievement now has a full-flow unlock test. |
|
||||
| Microcopy | `0c86cac` | Drop "Yes," prefix on destructive confirms — "New game" / "Forfeit" replace "Yes, abandon" / "Yes, forfeit". |
|
||||
| Tooltip infra | `54d3497` | `Tooltip(Cow<'static, str>)` component, hover-delay overlay, `Z_TOOLTIP` rung. |
|
||||
| Tooltip wiring | `220e3f0` | Tooltips on 10 HUD readouts + 6 action-bar buttons; `spawn_action_button` requires a tooltip parameter. |
|
||||
| Splash | `5d57b67` | Branded splash overlay (fade-in 300ms / hold ~1s / fade-out 300ms); board deals behind; any keypress dismisses. |
|
||||
| Doc-rot | `73e210b` | ARCHITECTURE.md `bevy_kira_audio` references → `kira` to match Cargo.toml. |
|
||||
| Doc | `de52c8a` | Mid-session SESSION_HANDOFF refresh after first batch of Phase 4 landed. |
|
||||
|
||||
## Commits this session (Phase 3, latest first)
|
||||
## Commits this session, chronological
|
||||
|
||||
```
|
||||
54e024c chore(engine): final literal-to-token sweep
|
||||
3a01318 feat(engine): upgrade animations — curves, scoped settle, deal jitter, cascade rotation
|
||||
79d3917 chore(data): derive Copy on AnimSpeed
|
||||
ba019c0 feat(engine): convert SettingsPanel to modal scaffold + Done button
|
||||
18d7c12 feat(engine): convert OnboardingPlugin to 3-slide modal flow
|
||||
cb93bd9 fix(engine): pin modals via GlobalZIndex and surface forfeit-no-op toast
|
||||
6723416 feat(engine): convert PauseScreen to modal + add ForfeitConfirmScreen
|
||||
afb0879 docs: add SESSION_HANDOFF.md mid-overhaul checkpoint
|
||||
3b619b8 feat(engine): convert HomeScreen to modal scaffold + Done button
|
||||
37681cf feat(engine): convert LeaderboardScreen to modal scaffold + Done button
|
||||
99064ce feat(engine): convert ProfileScreen to modal scaffold + Done button
|
||||
de4dba6 feat(engine): convert AchievementsScreen to modal scaffold + Done button
|
||||
75fc3aa feat(engine): convert StatsScreen to modal scaffold + Done button
|
||||
deb034c feat(engine): convert HelpScreen to real-button modal with kbd-chip rows
|
||||
242b5fe feat(engine): convert GameOverScreen to real-button modal
|
||||
3f922ed feat(engine): convert ConfirmNewGameScreen to real-button modal
|
||||
8da62bd feat(engine): add ui_modal primitive (scaffold + button variants)
|
||||
73cad7e feat(engine): restructure HUD into 4-tier layout, adopt design tokens
|
||||
e14852c feat(engine): add ui_theme.rs design-token module
|
||||
9bfca92 chore(workspace): satisfy clippy --all-targets in test code
|
||||
5f5aba8 feat(app): window polish — WM_CLASS, centered window, crash log hook
|
||||
71999e1 feat(engine): modal open animation — fade + scale with ease-out
|
||||
dcfa976 feat(engine): score change feedback — pulse and floating delta
|
||||
de52c8a docs: update SESSION_HANDOFF for completed phase-4 polish tracks
|
||||
b082bd6 feat(engine): bump icon-button hit target to 32px and clarify local-only sync status
|
||||
abeb4e5 feat(engine): unify dismiss verb to Done and warm onboarding CTA to Let's play
|
||||
65d595a feat(engine): first-launch polish — em-dash zero stats and welcome line on profile
|
||||
1384365 feat(engine): leaderboard error and idle states plus local-only guard
|
||||
fd7fb7b docs: add CREDITS.md and link from README
|
||||
c1bde18 feat(engine): repurpose Home as mode launcher
|
||||
1278952 feat(engine): keyboard focus rings on modal buttons (Phase 1)
|
||||
51d3454 feat(engine): keyboard focus on HUD action bar and Home mode cards (Phase 2)
|
||||
b78a493 feat(engine): keyboard focus on Settings panel with arrow-key pickers (Phase 3)
|
||||
f866299 docs: drop xCards URL placeholder from CREDITS.md
|
||||
73e210b docs: replace bevy_kira_audio references with kira in ARCHITECTURE.md
|
||||
2e080d0 test(engine): integration coverage for draw_three_master and zen_winner
|
||||
0c86cac feat(engine): unify destructive-confirm verbs — drop "Yes," prefix
|
||||
54d3497 feat(engine): tooltip infrastructure with hover delay (foundation only)
|
||||
220e3f0 feat(engine): tooltips on every HUD readout and action button
|
||||
5d57b67 feat(engine): branded splash screen on launch
|
||||
```
|
||||
|
||||
**Test status:** `cargo build --workspace` clean, `cargo clippy --workspace -- -D warnings` clean, **819 tests pass / 0 failed / 8 ignored**.
|
||||
(Phase 3 commits `e14852c` through `54e024c` and the prior handoff update `0066ca6` are already pushed — see git history for full audit trail.)
|
||||
|
||||
## Smoke-test checklist
|
||||
## Open punch list for v1
|
||||
|
||||
The whole overhaul is on disk. Worth running through once end-to-end:
|
||||
Polish is essentially complete. Concretely scoped follow-ups:
|
||||
|
||||
1. **Run the game.** `cargo run -p solitaire_app --features bevy/dynamic_linking`.
|
||||
2. **HUD layout** reads as 4 stacked tiers (Score / Mode / Penalty / Selection) with the new midnight-purple palette.
|
||||
3. **Open every overlay** — `S` (Stats), `A` (Achievements), `P` (Profile), `O` (Settings), `L` (Leaderboard), `M` (Home), `F1` (Help). Each is a centred card on a uniform scrim with a yellow `Done` / `Close` primary button. Hover/press states on every button.
|
||||
4. **Settings.** Four sections (Audio / Gameplay / Cosmetic / Sync). Body scrolls within the modal on small windows; `Done` button stays fixed at the bottom regardless of scroll. Card-back / Background pickers tint the selected swatch with `STATE_SUCCESS`.
|
||||
5. **Confirm flow.** Click `New Game` while a game is in progress — the abandon-current-game modal has real Cancel/Confirm buttons. `Y/Enter` and the yellow primary button start a new game; `N/Esc` and the secondary button cancel.
|
||||
6. **Pause + Forfeit.** Press `Esc` — pause modal shows real Resume / Forfeit buttons. Forfeit button opens a Cancel/Forfeit confirmation modal stacked above the pause modal (z-index ordered correctly via `GlobalZIndex`).
|
||||
7. **First-run onboarding.** Delete `settings.json` (or set `first_run_complete = false`) — three-slide flow shows: Welcome → How to play → Keyboard shortcuts. Navigate with `Next` / `Back` buttons or `→` / `←` accelerators. `Esc` skips on slide 0.
|
||||
8. **Animations.**
|
||||
- Slide a card to a pile — motion curves through `SmoothSnap` (slight overshoot + settle), not linear lerp.
|
||||
- Drop a card on a valid destination — only the moved cards bounce; the rest of the table stays still.
|
||||
- Start a new game — deal stagger is no longer mechanically uniform; cards land with subtle ±10% timing variation.
|
||||
- Win a game — cascade now uses `Expressive` curve with per-card ±15° Z-rotation, screen shake driven by the new `MOTION_WIN_SHAKE_*` tokens.
|
||||
9. **Resize the window** — cards still snap, no "snap-back-and-forth" jitter.
|
||||
10. **Win modal** — restyled with the design tokens: midnight-purple card, yellow `Play Again` button.
|
||||
1. **Smoke-test pass.** Run the game end-to-end with the original Phase 3 checklist plus the Phase 4 additions (splash dismiss, focus rings on every screen, tooltip hover, mode launcher, leaderboard error state, first-launch em-dashes).
|
||||
2. **xCards upstream URL** in CREDITS.md is intentionally absent (`f866299`). One-line fill-in when the project owner picks a canonical mirror/fork; LGPL notice obligations are already satisfied without it.
|
||||
3. **Push to origin.** Local master is 16 commits ahead of `origin/master`. `git push origin master` (interactive credentials on `git.aleshym.co`).
|
||||
4. **Tag `v0.1.0`** once the smoke test passes and the push lands.
|
||||
5. **Release packaging** per ARCHITECTURE.md §17 — Docker compose for the server is documented; desktop client packaging (icon, .ico/.icns, signing, AppImage) is not yet done.
|
||||
|
||||
## Open follow-ups (not blockers)
|
||||
### Optional, deferred
|
||||
|
||||
- **Home / Help redundancy.** Home is still a kbd-reference modal that mostly duplicates Help. Three options: (1) keep as-is, (2) convert into a true mode launcher (Classic / Daily / Zen / Challenge / Time Attack cards, locked options visibly disabled below level 5), (3) drop entirely now that the action bar covers everything Home does. Worth asking the user which direction they want.
|
||||
- **Forfeit countdown toast** is now superseded by the Forfeit modal (`6723416`). Confirm the toast path is no longer reachable when smoke-testing.
|
||||
- **Sub-rung pixel sizes** (1 px borders, 64/80/110/150/160 px fixed widths, 28/36/50 px specific spacings) were intentionally left as literals during the step-10 sweep — they're below the smallest `SPACE_*` rung. If the design system grows a "fine" spacing tier in the future, those become candidates for migration.
|
||||
- Animated focus ring (currently a static overlay; could pulse on focus change).
|
||||
- Splash skip-on-subsequent-launches — currently every launch shows the full ~1.6s splash.
|
||||
- Achievement onboarding pass — show first-time players the achievement panel after their first win.
|
||||
- In-flight Settings tooltip popovers in the working tree — finish or revert.
|
||||
|
||||
## Resume prompt for the next session
|
||||
## Resume prompt
|
||||
|
||||
```
|
||||
You are a senior Rust + Bevy developer working toward a public release
|
||||
of Solitaire Quest. Working directory: /home/manage/Rusty_Solitare.
|
||||
Branch: master. Apply that lens to every decision: prefer shipping
|
||||
quality (polish, packaging, defaults, credits, crash safety) over
|
||||
greenfield features. If something is half-done, the question is
|
||||
"finish for v1 or cut for v1?" not "what else can we add?".
|
||||
You are a senior Rust + Bevy developer finishing v1 of Solitaire
|
||||
Quest. Working directory: /home/manage/Rusty_Solitare. Branch:
|
||||
master. Polish phase is complete; the remaining work is release prep,
|
||||
not new features.
|
||||
|
||||
State: HEAD=0066ca6. Phase 3 of the UX overhaul is shipped. cargo
|
||||
build / clippy --workspace -- -D warnings / test --workspace all
|
||||
green — 819 tests pass / 0 fail / 8 ignored.
|
||||
State: HEAD=5d57b67. Local master is 16 commits ahead of
|
||||
origin/master and unpushed. Working tree has uncommitted in-flight
|
||||
tooltip work in solitaire_engine/src/hud_plugin.rs and
|
||||
solitaire_engine/src/settings_plugin.rs — review and finish or revert
|
||||
before opening anything new.
|
||||
|
||||
Build: cargo build / clippy --workspace -- -D warnings clean as of
|
||||
HEAD. Tests: 872 passed / 0 failed / 9 ignored.
|
||||
|
||||
READ FIRST (in order, before doing anything):
|
||||
1. SESSION_HANDOFF.md — full state, smoke-test checklist, follow-ups
|
||||
1. SESSION_HANDOFF.md — full state and punch list
|
||||
2. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
||||
3. ARCHITECTURE.md §1, §15, §17 — design principles, platform
|
||||
targets, deployment guide
|
||||
3. ARCHITECTURE.md §15, §17 — platform targets, deployment guide
|
||||
4. ~/.claude/projects/-home-manage-Rusty-Solitare/memory/MEMORY.md
|
||||
— saved feedback / project context
|
||||
|
||||
GATING SIGNAL — ASK FIRST, DON'T ASSUME:
|
||||
Before proposing new work, ask: "Did the smoke-test (items 1-10 in
|
||||
SESSION_HANDOFF.md) pass, or did anything regress?" If a regression
|
||||
exists, fix it before opening any new thread.
|
||||
|
||||
LIKELY NEXT DIRECTIONS — surface for the user to choose, don't pick
|
||||
unilaterally. All framed through "what does v1 release need?":
|
||||
|
||||
A. Home modal decision (open in SESSION_HANDOFF.md).
|
||||
- keep as kbd-reference (duplicates Help — release-blocking
|
||||
confusion?)
|
||||
- repurpose as mode launcher (Classic / Daily / Zen / Challenge /
|
||||
Time Attack cards, locked options below level 5)
|
||||
- drop (action bar already covers every action)
|
||||
|
||||
B. Window + release polish — `solitaire_app/src/main.rs:34-48`
|
||||
currently sets only title + resolution + min size. For public
|
||||
release the window needs:
|
||||
- app icon (taskbar / dock / alt-tab) — Bevy `Window::window_icon`
|
||||
or platform `set_window_icon`; ship a .png/.ico asset.
|
||||
- window class / app id (`Window::name`) so X11/Wayland and
|
||||
Windows group taskbar entries correctly.
|
||||
- persist size + position across launches (Settings already
|
||||
saves to JSON; add `window_geometry` field).
|
||||
- F11 (or a Settings toggle) wired to real fullscreen mode.
|
||||
- centered default position on first launch (Bevy supports
|
||||
`WindowPosition::Centered`).
|
||||
- present_mode + vsync verification — make sure Linux/macOS
|
||||
don't ship at uncapped 4000 fps.
|
||||
- panic hook (`std::panic::set_hook`) that writes a crash
|
||||
report next to the save files instead of silently exiting.
|
||||
- macOS Info.plist / Windows .ico bundling — ARCHITECTURE.md
|
||||
§17 currently only covers server deploy.
|
||||
|
||||
C. Sound-design audit. The scoped settle bounce (3a01318) means
|
||||
audio_plugin.rs trigger sites may fire less often than before;
|
||||
verify card_place / card_flip / card_invalid still feel right.
|
||||
|
||||
D. Sync flow end-to-end on a real second machine. Server
|
||||
scaffolding exists but the register → push → pull → restore-on-
|
||||
other-device round trip hasn't been exercised against the new
|
||||
Settings sync section.
|
||||
|
||||
E. Achievement unlock completeness. ARCHITECTURE.md §11 lists 18.
|
||||
The three hidden ones (speed_and_skill, comeback, zen_winner)
|
||||
are most likely to be untested. For release, every advertised
|
||||
achievement needs to actually fire.
|
||||
|
||||
F. Release-readiness backlog:
|
||||
- README / store-page copy / screenshots
|
||||
- LICENSE + third-party credits (xCards art, FiraMono, Bevy)
|
||||
- SemVer + a v0.1.0 git tag
|
||||
- itch.io / Steam packaging per platform (ARCHITECTURE.md §15)
|
||||
- App signing — macOS notarization, Windows Authenticode,
|
||||
Linux AppImage
|
||||
- Telemetry / crash reporting — opt-in, off by default; or
|
||||
confirm we ship without and rely on player reports
|
||||
|
||||
G. UI/UX professional polish — Phase 3 shipped the design system;
|
||||
v1 wants the difference between "consistent" and "feels
|
||||
intentional":
|
||||
- Microcopy pass: every button label, empty state, error
|
||||
message, and onboarding line reviewed for voice + clarity.
|
||||
Pick one verb per concept ("Done" vs "Close" vs "OK") and
|
||||
apply it everywhere.
|
||||
- Empty / loading / error states: Leaderboard before any
|
||||
scores, Stats before any games, Sync UI before login.
|
||||
Today these are likely blank panels.
|
||||
- Modal open/close animation: `MOTION_MODAL_SECS` token exists
|
||||
in `ui_theme.rs:255` but isn't wired up — modals
|
||||
appear/disappear instantly. Add scale-from-0.96 + scrim fade
|
||||
per the token's doc comment.
|
||||
- Tooltips on HUD readouts and settings labels. Bevy has no
|
||||
built-in tooltip; build a small one. Hover a number to learn
|
||||
what it counts.
|
||||
- Accessibility: verify the AAA-contrast claim on
|
||||
`ACCENT_PRIMARY` over `BG_BASE` (ui_theme.rs:65). Confirm
|
||||
`AnimSpeed::Instant` disables every new animation (slide
|
||||
curve, scoped settle, deal jitter, cascade rotation). Add
|
||||
focus rings on `Button` entities for keyboard navigation.
|
||||
- Typography choice: FiraMono is one weight, monospace for
|
||||
everything. Consider shipping a second proportional face for
|
||||
body + headings, keep mono for numerics (HUD score, timer).
|
||||
Or commit to mono and lean into the "calm coder" feel — pick
|
||||
deliberately and document the decision.
|
||||
- Onboarding artwork: the 3 slides are text + buttons. For
|
||||
release, stylised illustrations (or simple animated card
|
||||
props on each slide) elevate the first-launch feel.
|
||||
- Score-change feedback: floating "+N" numbers when score
|
||||
jumps; pulse on the readout when value crosses a milestone.
|
||||
`MOTION_SCORE_PULSE_SECS` is already a token.
|
||||
- Splash / loading screen: today the window goes straight to
|
||||
gameplay. A 1-2 second branded splash signals "real game"
|
||||
vs "rust prototype".
|
||||
- Hit-target audit: every interactive element ≥ 32 px on
|
||||
desktop. Settings has 28 px icon buttons (`ICON_BUTTON_PX`
|
||||
in settings_plugin.rs); revisit.
|
||||
- Win-moment design: the cascade is good; consider a score-
|
||||
breakdown reveal, streak callout, "share your time"
|
||||
affordance for v1.
|
||||
PUNCH LIST (resolve in roughly this order):
|
||||
1. Decide on the in-flight settings_plugin/hud_plugin tooltip work.
|
||||
2. Smoke-test the binary end-to-end. If anything regresses, fix it
|
||||
before opening anything new.
|
||||
3. Confirm or fill the xCards upstream URL in CREDITS.md.
|
||||
4. git push origin master (16 commits unpushed; interactive creds).
|
||||
5. Tag v0.1.0.
|
||||
6. Release packaging per ARCHITECTURE.md §17 — desktop client icon,
|
||||
bundling, signing are not yet wired.
|
||||
|
||||
WORKFLOW NOTES:
|
||||
- Commits use:
|
||||
git -c user.name=funman300 -c user.email=root@vscode.infinity commit -m "..."
|
||||
- Sub-agents can Edit/Write but CANNOT `git commit`. Brief them to
|
||||
stage + verify only; orchestrator commits on their behalf.
|
||||
See memory/feedback_agent_commit_limit.md.
|
||||
- Remote push needs interactive credentials on git.aleshym.co; the
|
||||
user runs `git push origin master` themselves.
|
||||
- Every commit must pass build / clippy / test. Pause-and-verify
|
||||
is the user's preferred cadence — one feature per commit.
|
||||
- Sub-agents stage + verify only; orchestrator commits.
|
||||
- Every commit must pass build / clippy / test.
|
||||
|
||||
OPEN AT THE START: ask (1) did smoke-test pass, (2) which of A–G to
|
||||
pursue first. Do not assume.
|
||||
OPEN AT THE START: ask which punch-list item to start on. Don't pick
|
||||
unilaterally — release-readiness ordering is the user's call.
|
||||
```
|
||||
|
||||
@@ -10,8 +10,8 @@ use solitaire_engine::{
|
||||
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiModalPlugin, WeeklyGoalsPlugin,
|
||||
WinSummaryPlugin,
|
||||
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiFocusPlugin,
|
||||
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -99,6 +99,9 @@ fn main() {
|
||||
.add_plugins(LeaderboardPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(UiTooltipPlugin)
|
||||
.add_plugins(SplashPlugin)
|
||||
.run();
|
||||
}
|
||||
|
||||
|
||||
@@ -509,6 +509,173 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// draw_three_master integration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn draw_three_master_fires_on_tenth_draw_three_win() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// Pre-seed nine prior Draw-Three wins. The pending GameWonEvent will
|
||||
// trigger update_stats_on_win first (StatsUpdate runs before
|
||||
// evaluate_on_win), bumping draw_three_wins to 10 — the unlock
|
||||
// threshold for the draw_three_master achievement.
|
||||
app.world_mut().resource_mut::<StatsResource>().0.draw_three_wins = 9;
|
||||
|
||||
// The current game must be in DrawThree mode so update_on_win
|
||||
// increments draw_three_wins (and not draw_one_wins).
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 240,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// Sanity-check that the win was actually attributed to Draw-Three so
|
||||
// the achievement reads the correct counter.
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
assert_eq!(stats.draw_three_wins, 10);
|
||||
|
||||
let unlocked = app
|
||||
.world()
|
||||
.resource::<AchievementsResource>()
|
||||
.0
|
||||
.iter()
|
||||
.find(|r| r.id == "draw_three_master")
|
||||
.map(|r| r.unlocked)
|
||||
.unwrap_or(false);
|
||||
assert!(unlocked, "draw_three_master must unlock at the 10th Draw-Three win");
|
||||
|
||||
// Verify the AchievementUnlockedEvent fired for this id.
|
||||
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
||||
assert!(
|
||||
fired.contains(&"draw_three_master".to_string()),
|
||||
"AchievementUnlockedEvent for draw_three_master must fire; got {fired:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_master_does_not_fire_at_nine_wins() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// Pre-seed eight prior Draw-Three wins. The pending GameWonEvent
|
||||
// brings draw_three_wins to 9 — one short of the threshold.
|
||||
app.world_mut().resource_mut::<StatsResource>().0.draw_three_wins = 8;
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 240,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
assert_eq!(stats.draw_three_wins, 9);
|
||||
|
||||
let unlocked = app
|
||||
.world()
|
||||
.resource::<AchievementsResource>()
|
||||
.0
|
||||
.iter()
|
||||
.find(|r| r.id == "draw_three_master")
|
||||
.map(|r| r.unlocked)
|
||||
.unwrap_or(false);
|
||||
assert!(!unlocked, "draw_three_master must remain locked at 9 Draw-Three wins");
|
||||
|
||||
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
||||
assert!(
|
||||
!fired.contains(&"draw_three_master".to_string()),
|
||||
"draw_three_master must not fire below threshold; got {fired:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// zen_winner integration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn zen_winner_fires_on_zen_mode_win() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// Put the active game in Zen mode. evaluate_on_win reads
|
||||
// GameStateResource.mode directly to populate last_win_is_zen.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.mode = solitaire_core::game_state::GameMode::Zen;
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 0,
|
||||
time_seconds: 600,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let unlocked = app
|
||||
.world()
|
||||
.resource::<AchievementsResource>()
|
||||
.0
|
||||
.iter()
|
||||
.find(|r| r.id == "zen_winner")
|
||||
.map(|r| r.unlocked)
|
||||
.unwrap_or(false);
|
||||
assert!(unlocked, "zen_winner must unlock when the game mode is Zen");
|
||||
|
||||
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
||||
assert!(
|
||||
fired.contains(&"zen_winner".to_string()),
|
||||
"AchievementUnlockedEvent for zen_winner must fire; got {fired:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_winner_does_not_fire_for_classic_win() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// Default GameMode is Classic; assert and rely on it.
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.mode,
|
||||
solitaire_core::game_state::GameMode::Classic
|
||||
);
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 1000,
|
||||
time_seconds: 300,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let unlocked = app
|
||||
.world()
|
||||
.resource::<AchievementsResource>()
|
||||
.0
|
||||
.iter()
|
||||
.find(|r| r.id == "zen_winner")
|
||||
.map(|r| r.unlocked)
|
||||
.unwrap_or(false);
|
||||
assert!(!unlocked, "zen_winner must remain locked outside Zen mode");
|
||||
|
||||
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
||||
assert!(
|
||||
!fired.contains(&"zen_winner".to_string()),
|
||||
"zen_winner must not fire on a Classic-mode win; got {fired:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn press(app: &mut App, key: KeyCode) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(key);
|
||||
|
||||
@@ -214,7 +214,7 @@ fn handle_new_game(
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker on the primary "Yes, abandon" button inside the confirm modal.
|
||||
/// Marker on the primary "New game" button inside the confirm modal.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ConfirmYesButton;
|
||||
|
||||
@@ -265,7 +265,7 @@ fn spawn_confirm_dialog(
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
ConfirmYesButton,
|
||||
"Yes, abandon",
|
||||
"New game",
|
||||
Some("Y"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::ui_theme::{
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HelpScreen;
|
||||
|
||||
/// Marker on the "Close" button inside the Help modal.
|
||||
/// Marker on the "Done" button inside the Help modal.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HelpCloseButton;
|
||||
|
||||
@@ -56,7 +56,7 @@ fn toggle_help_screen(
|
||||
}
|
||||
}
|
||||
|
||||
/// Click handler for the modal's "Close" button. F1 toggles the overlay
|
||||
/// Click handler for the modal's "Done" button. F1 toggles the overlay
|
||||
/// the same way; this just exposes the close action to mouse / touch.
|
||||
fn handle_help_close_button(
|
||||
mut commands: Commands,
|
||||
@@ -194,7 +194,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
HelpCloseButton,
|
||||
"Close",
|
||||
"Done",
|
||||
Some("F1"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
|
||||
+735
-129
@@ -1,43 +1,160 @@
|
||||
//! Toggleable main menu overlay showing the current game mode and a full
|
||||
//! keyboard shortcut reference.
|
||||
//! Mode-launcher overlay shown when the player presses **M** or clicks the
|
||||
//! Modes affordance.
|
||||
//!
|
||||
//! Press **M** to open or close the overlay.
|
||||
//! 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::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::events::{
|
||||
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD, STATE_INFO,
|
||||
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
|
||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||
};
|
||||
|
||||
/// Marker component on the home-menu overlay root node.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public marker components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker component on the Home overlay root entity (the modal scrim).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HomeScreen;
|
||||
|
||||
/// Marker on the "Done" button inside the Home modal.
|
||||
/// Marker on the bottom-row "Cancel" button that dismisses the Home modal
|
||||
/// without launching a mode.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HomeCloseButton;
|
||||
pub struct HomeCancelButton;
|
||||
|
||||
/// Registers the M-key toggle and the overlay spawn/despawn logic.
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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?",
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyboard accelerator that dispatches the same launch event,
|
||||
/// shown in a small chip on the card.
|
||||
fn hotkey(self) -> &'static str {
|
||||
match self {
|
||||
HomeMode::Classic => "N",
|
||||
HomeMode::Daily => "C",
|
||||
HomeMode::Zen => "Z",
|
||||
HomeMode::Challenge => "X",
|
||||
HomeMode::TimeAttack => "T",
|
||||
}
|
||||
}
|
||||
|
||||
/// `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);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers the M-key toggle, the mode-card click handler, and the
|
||||
/// Cancel-button handler.
|
||||
pub struct HomePlugin;
|
||||
|
||||
impl Plugin for HomePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, (toggle_home_screen, handle_home_close_button));
|
||||
// Be defensive about message registration so HomePlugin works
|
||||
// standalone in tests (the actual handlers live in
|
||||
// input_plugin / challenge_plugin / time_attack_plugin /
|
||||
// daily_challenge_plugin, but those plugins might not be
|
||||
// installed in a tightly-scoped headless app).
|
||||
app.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<StartZenRequestEvent>()
|
||||
.add_message::<StartChallengeRequestEvent>()
|
||||
.add_message::<StartTimeAttackRequestEvent>()
|
||||
.add_message::<StartDailyChallengeRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
toggle_home_screen,
|
||||
attach_focusable_to_home_mode_cards,
|
||||
handle_home_card_click,
|
||||
handle_home_cancel_button,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M-key toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn toggle_home_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
game: Res<GameStateResource>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
) {
|
||||
@@ -47,16 +164,86 @@ fn toggle_home_screen(
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
spawn_home_screen(&mut commands, &game, font_res.as_deref());
|
||||
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
||||
spawn_home_screen(&mut commands, level, font_res.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_home_close_button(
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
close_buttons: Query<&Interaction, (With<HomeCloseButton>, Changed<Interaction>)>,
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
cancel_buttons: Query<&Interaction, (With<HomeCancelButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
) {
|
||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
if !cancel_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
@@ -64,139 +251,230 @@ fn handle_home_close_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the home-menu modal — a hotkey reference grouped into "Game
|
||||
/// Controls" and "Screens" sections plus the current game mode badge.
|
||||
/// A future pass can pivot Home into a true mode launcher (the
|
||||
/// Modes-popover already covers that path from the action bar).
|
||||
fn spawn_home_screen(
|
||||
commands: &mut Commands,
|
||||
game: &GameStateResource,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let mode_label = match game.0.mode {
|
||||
GameMode::Classic => "Classic",
|
||||
GameMode::Zen => "Zen",
|
||||
GameMode::Challenge => "Challenge",
|
||||
GameMode::TimeAttack => "Time Attack",
|
||||
};
|
||||
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_section = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
};
|
||||
let font_row = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
let font_kbd = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawns the Home modal with five mode cards plus a Cancel button.
|
||||
fn spawn_home_screen(commands: &mut Commands, level: u32, font_res: Option<&FontResource>) {
|
||||
spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Solitaire Quest", font_res);
|
||||
spawn_modal_header(card, "Choose a Mode", font_res);
|
||||
|
||||
// Mode badge — current game's mode, ACCENT_PRIMARY so it pops.
|
||||
card.spawn((
|
||||
Text::new(format!("Current mode: {mode_label}")),
|
||||
font_section.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
|
||||
// Game controls section.
|
||||
card.spawn((
|
||||
Text::new("Game Controls"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
for (key, action) in [
|
||||
("N", "New game (N again confirms)"),
|
||||
("U", "Undo last move"),
|
||||
("Space / D", "Draw from stock"),
|
||||
("G", "Forfeit current game"),
|
||||
("Tab", "Cycle hint highlight"),
|
||||
("Enter", "Auto-complete if available"),
|
||||
for mode in [
|
||||
HomeMode::Classic,
|
||||
HomeMode::Daily,
|
||||
HomeMode::Zen,
|
||||
HomeMode::Challenge,
|
||||
HomeMode::TimeAttack,
|
||||
] {
|
||||
spawn_shortcut_row(card, key, action, &font_row, &font_kbd);
|
||||
}
|
||||
|
||||
// Screens section.
|
||||
card.spawn((
|
||||
Text::new("Screens"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
for (key, action) in [
|
||||
("M", "Main menu (this screen)"),
|
||||
("S", "Statistics"),
|
||||
("A", "Achievements"),
|
||||
("O", "Settings"),
|
||||
("P", "Profile"),
|
||||
("L", "Leaderboard"),
|
||||
("F1", "Help"),
|
||||
("F11", "Toggle fullscreen"),
|
||||
("Esc", "Pause / Resume"),
|
||||
] {
|
||||
spawn_shortcut_row(card, key, action, &font_row, &font_kbd);
|
||||
spawn_mode_card(card, mode, level, font_res);
|
||||
}
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
HomeCloseButton,
|
||||
"Done",
|
||||
HomeCancelButton,
|
||||
"Cancel",
|
||||
Some("M"),
|
||||
ButtonVariant::Primary,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// One row inside Home's controls reference: a kbd-chip + description.
|
||||
/// Same look as Help's rows so the two screens read consistently.
|
||||
fn spawn_shortcut_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
key: &str,
|
||||
action: &str,
|
||||
font_row: &TextFont,
|
||||
font_kbd: &TextFont,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
level: u32,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
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,
|
||||
font_size: TYPE_CAPTION,
|
||||
..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 };
|
||||
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||
min_width: Val::Px(80.0),
|
||||
justify_content: JustifyContent::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
..default()
|
||||
},
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|chip| {
|
||||
chip.spawn((
|
||||
Text::new(key.to_string()),
|
||||
font_kbd.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
.spawn((
|
||||
HomeModeCard(mode),
|
||||
// Keep this a real Button entity so clicks resolve through
|
||||
// bevy::ui — the click handler queries on `&Interaction`
|
||||
// which Button drives.
|
||||
Button,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
padding: UiRect::all(VAL_SPACE_3),
|
||||
width: Val::Percent(100.0),
|
||||
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| {
|
||||
// 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,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new(mode.title().to_string()),
|
||||
font_title.clone(),
|
||||
TextColor(title_color),
|
||||
));
|
||||
|
||||
if unlocked {
|
||||
// Hotkey chip — same look as the kbd-chip rows used
|
||||
// elsewhere so accelerators read consistently.
|
||||
row.spawn((
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||
min_width: Val::Px(32.0),
|
||||
justify_content: JustifyContent::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
..default()
|
||||
},
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
))
|
||||
.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),
|
||||
));
|
||||
}
|
||||
});
|
||||
row.spawn((
|
||||
Text::new(action.to_string()),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
|
||||
// Description line.
|
||||
c.spawn((
|
||||
Text::new(mode.description().to_string()),
|
||||
font_desc.clone(),
|
||||
TextColor(desc_color),
|
||||
));
|
||||
|
||||
// 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()
|
||||
},
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -204,19 +482,75 @@ fn spawn_shortcut_row(
|
||||
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);
|
||||
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();
|
||||
@@ -267,4 +601,276 @@ mod tests {
|
||||
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,
|
||||
] {
|
||||
assert!(
|
||||
modes.contains(&expected),
|
||||
"missing card for {expected:?}; found {modes:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(modes.len(), 5, "exactly five 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);
|
||||
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(), 5, "all five 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 => 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ use crate::game_plugin::GameMutation;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::selection_plugin::SelectionState;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
use crate::ui_focus::{FocusGroup, Focusable};
|
||||
use crate::ui_tooltip::Tooltip;
|
||||
|
||||
/// Marker on the score text node.
|
||||
#[derive(Component, Debug)]
|
||||
@@ -342,18 +344,23 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
hud.spawn(row_node()).with_children(|t1| {
|
||||
t1.spawn((
|
||||
HudScore,
|
||||
Tooltip::new("Points earned this game. Hidden in Zen mode."),
|
||||
Text::new("Score: 0"),
|
||||
font_score.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
t1.spawn((
|
||||
HudMoves,
|
||||
Tooltip::new(
|
||||
"Moves you've made this game. Counts placements and stock draws.",
|
||||
),
|
||||
Text::new("Moves: 0"),
|
||||
font_lg.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
t1.spawn((
|
||||
HudTime,
|
||||
Tooltip::new("Time on this game. Counts down in Time Attack."),
|
||||
Text::new("0:00"),
|
||||
font_lg.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
@@ -366,18 +373,21 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
hud.spawn(row_node()).with_children(|t2| {
|
||||
t2.spawn((
|
||||
HudMode,
|
||||
Tooltip::new("Active game mode. Click Modes to switch."),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
t2.spawn((
|
||||
HudChallenge,
|
||||
Tooltip::new("Today's daily challenge target. Beat it for bonus XP."),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
t2.spawn((
|
||||
HudDrawCycle,
|
||||
Tooltip::new("Cards drawn on the next stock click in Draw-Three."),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
@@ -390,18 +400,25 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
hud.spawn(row_node()).with_children(|t3| {
|
||||
t3.spawn((
|
||||
HudUndos,
|
||||
Tooltip::new(
|
||||
"Undos used this game. Any undo blocks the No Undo achievement.",
|
||||
),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_WARNING),
|
||||
));
|
||||
t3.spawn((
|
||||
HudRecycles,
|
||||
Tooltip::new(
|
||||
"Times you've recycled the stock. Three or more unlocks Comeback.",
|
||||
),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_WARNING),
|
||||
));
|
||||
t3.spawn((
|
||||
HudAutoComplete,
|
||||
Tooltip::new("Board is solvable from here. Press Enter to auto-finish."),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
@@ -413,6 +430,7 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
hud.spawn(row_node()).with_children(|t4| {
|
||||
t4.spawn((
|
||||
HudSelection,
|
||||
Tooltip::new("Pile selected with Tab. Use arrows or Enter to act."),
|
||||
Text::new(""),
|
||||
font_body,
|
||||
TextColor(ACCENT_SECONDARY),
|
||||
@@ -452,24 +470,89 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
|
||||
// Menu and Modes don't have a single hotkey accelerator
|
||||
// (each row inside their popover has its own); their button
|
||||
// labels carry the dropdown chevron in lieu of a key chip.
|
||||
spawn_action_button(row, MenuButton, "Menu \u{25BE}", None, &font);
|
||||
spawn_action_button(row, UndoButton, "Undo", Some("U"), &font);
|
||||
spawn_action_button(row, PauseButton, "Pause", Some("Esc"), &font);
|
||||
spawn_action_button(row, HelpButton, "Help", Some("F1"), &font);
|
||||
spawn_action_button(row, ModesButton, "Modes \u{25BE}", None, &font);
|
||||
spawn_action_button(row, NewGameButton, "New Game", Some("N"), &font);
|
||||
//
|
||||
// The trailing `order` argument is the per-button index in
|
||||
// visual reading order (left → right). It feeds
|
||||
// `Focusable { group: Hud, order }` so Tab cycles the action
|
||||
// bar in the same order the eye scans it.
|
||||
spawn_action_button(
|
||||
row,
|
||||
MenuButton,
|
||||
"Menu \u{25BE}",
|
||||
None,
|
||||
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||
&font,
|
||||
0,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
UndoButton,
|
||||
"Undo",
|
||||
Some("U"),
|
||||
"Take back your last move. Costs points and blocks No Undo.",
|
||||
&font,
|
||||
1,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
PauseButton,
|
||||
"Pause",
|
||||
Some("Esc"),
|
||||
"Pause the game and freeze the timer.",
|
||||
&font,
|
||||
2,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
HelpButton,
|
||||
"Help",
|
||||
Some("F1"),
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
&font,
|
||||
3,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
ModesButton,
|
||||
"Modes \u{25BE}",
|
||||
None,
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||
&font,
|
||||
4,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
NewGameButton,
|
||||
"New Game",
|
||||
Some("N"),
|
||||
"Start a fresh deal. Confirms first if a game is in progress.",
|
||||
&font,
|
||||
5,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns a single action button as a child of `row`. Each button shares
|
||||
/// the same node geometry, idle colour, and `ActionButton` marker so
|
||||
/// `paint_action_buttons` can recolour all of them with one query.
|
||||
///
|
||||
/// `order` is the button's index inside the action bar (0 for the
|
||||
/// leftmost). It propagates into the [`Focusable`] this function inserts
|
||||
/// so Phase 2's keyboard focus ring cycles the HUD in visual order.
|
||||
///
|
||||
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
|
||||
/// action button ships with one — there is no opt-out — because each button
|
||||
/// represents a player-triggered action and benefits from a one-line
|
||||
/// reminder of what it does.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_action_button<M: Component>(
|
||||
row: &mut ChildSpawnerCommands,
|
||||
marker: M,
|
||||
label: &str,
|
||||
hotkey: Option<&'static str>,
|
||||
tooltip: &'static str,
|
||||
font: &TextFont,
|
||||
order: i32,
|
||||
) {
|
||||
let hotkey_font = TextFont {
|
||||
font: font.font.clone(),
|
||||
@@ -480,6 +563,16 @@ fn spawn_action_button<M: Component>(
|
||||
marker,
|
||||
ActionButton,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
// Joins the `Hud` focus group at the supplied order so Tab
|
||||
// cycles HUD buttons left-to-right under Phase 2. The HUD focus
|
||||
// ring still only engages when a HUD button is hovered (or in
|
||||
// future phases, when the player explicitly switches groups);
|
||||
// the marker just declares membership.
|
||||
Focusable {
|
||||
group: FocusGroup::Hud,
|
||||
order,
|
||||
},
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||
justify_content: JustifyContent::Center,
|
||||
@@ -597,14 +690,37 @@ fn spawn_modes_popover(
|
||||
..default()
|
||||
};
|
||||
|
||||
let mut rows: Vec<(ModeOption, &'static str)> = vec![(ModeOption::Classic, "Classic")];
|
||||
// Each row carries a tooltip alongside its label so hover reveals
|
||||
// a one-line description of what the mode does — mirroring the
|
||||
// tooltips on the action-bar buttons that opened this popover.
|
||||
let mut rows: Vec<(ModeOption, &'static str, &'static str)> = vec![(
|
||||
ModeOption::Classic,
|
||||
"Classic",
|
||||
"Standard Klondike. Score, timer, and full progression.",
|
||||
)];
|
||||
if daily.is_some() {
|
||||
rows.push((ModeOption::DailyChallenge, "Daily Challenge"));
|
||||
rows.push((
|
||||
ModeOption::DailyChallenge,
|
||||
"Daily Challenge",
|
||||
"Today's seeded deal. Same for every player worldwide.",
|
||||
));
|
||||
}
|
||||
if level >= CHALLENGE_UNLOCK_LEVEL {
|
||||
rows.push((ModeOption::Zen, "Zen"));
|
||||
rows.push((ModeOption::Challenge, "Challenge"));
|
||||
rows.push((ModeOption::TimeAttack, "Time Attack"));
|
||||
rows.push((
|
||||
ModeOption::Zen,
|
||||
"Zen",
|
||||
"No timer, no score, no penalties. Just play.",
|
||||
));
|
||||
rows.push((
|
||||
ModeOption::Challenge,
|
||||
"Challenge",
|
||||
"Hand-picked hard seeds. No undo allowed.",
|
||||
));
|
||||
rows.push((
|
||||
ModeOption::TimeAttack,
|
||||
"Time Attack",
|
||||
"Win as many games as you can in ten minutes.",
|
||||
));
|
||||
}
|
||||
|
||||
commands
|
||||
@@ -624,12 +740,13 @@ fn spawn_modes_popover(
|
||||
ZIndex(Z_HUD + 5),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
for (option, label) in rows {
|
||||
for (option, label, tooltip) in rows {
|
||||
panel
|
||||
.spawn((
|
||||
option,
|
||||
ActionButton,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
||||
justify_content: JustifyContent::FlexStart,
|
||||
@@ -728,12 +845,35 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
..default()
|
||||
};
|
||||
|
||||
let rows: [(MenuOption, &'static str); 5] = [
|
||||
(MenuOption::Stats, "Stats"),
|
||||
(MenuOption::Achievements, "Achievements"),
|
||||
(MenuOption::Profile, "Profile"),
|
||||
(MenuOption::Settings, "Settings"),
|
||||
(MenuOption::Leaderboard, "Leaderboard"),
|
||||
// Each row carries a tooltip alongside its label so hover reveals
|
||||
// a one-line description of what each overlay shows — mirroring
|
||||
// the tooltips on the action-bar buttons that opened this popover.
|
||||
let rows: [(MenuOption, &'static str, &'static str); 5] = [
|
||||
(
|
||||
MenuOption::Stats,
|
||||
"Stats",
|
||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||
),
|
||||
(
|
||||
MenuOption::Achievements,
|
||||
"Achievements",
|
||||
"Browse unlocked achievements and the rewards still ahead.",
|
||||
),
|
||||
(
|
||||
MenuOption::Profile,
|
||||
"Profile",
|
||||
"Your level, XP progress, and sync status.",
|
||||
),
|
||||
(
|
||||
MenuOption::Settings,
|
||||
"Settings",
|
||||
"Audio, animations, theme, draw mode, and sync.",
|
||||
),
|
||||
(
|
||||
MenuOption::Leaderboard,
|
||||
"Leaderboard",
|
||||
"Top players from your sync server. Opt in from Profile.",
|
||||
),
|
||||
];
|
||||
|
||||
commands
|
||||
@@ -753,12 +893,13 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
ZIndex(Z_HUD + 5),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
for (option, label) in rows {
|
||||
for (option, label, tooltip) in rows {
|
||||
panel
|
||||
.spawn((
|
||||
option,
|
||||
ActionButton,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
||||
justify_content: JustifyContent::FlexStart,
|
||||
@@ -1804,4 +1945,349 @@ mod tests {
|
||||
assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6);
|
||||
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2: keyboard focus ring — HUD action bar
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Returns the `Focusable` carried by the unique entity matching
|
||||
/// marker `M`. Helper for the HUD focus tests.
|
||||
fn focusable_for<M: Component>(app: &mut App) -> Focusable {
|
||||
app.world_mut()
|
||||
.query_filtered::<&Focusable, With<M>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.copied()
|
||||
.unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::<M>()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_buttons_get_focusable_marker() {
|
||||
let mut app = headless_app();
|
||||
// Every action-bar button is in `FocusGroup::Hud`.
|
||||
for f in [
|
||||
focusable_for::<MenuButton>(&mut app),
|
||||
focusable_for::<UndoButton>(&mut app),
|
||||
focusable_for::<PauseButton>(&mut app),
|
||||
focusable_for::<HelpButton>(&mut app),
|
||||
focusable_for::<ModesButton>(&mut app),
|
||||
focusable_for::<NewGameButton>(&mut app),
|
||||
] {
|
||||
assert_eq!(
|
||||
f.group,
|
||||
FocusGroup::Hud,
|
||||
"every HUD action button must be in FocusGroup::Hud"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the tooltip string carried by the unique entity matching
|
||||
/// marker `M`. Panics if zero or more than one such entity exists,
|
||||
/// which is the invariant we want to enforce for HUD readouts and
|
||||
/// action buttons (each marker is spawned exactly once).
|
||||
fn tooltip_for<M: Component>(app: &mut App) -> String {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Tooltip, With<M>>();
|
||||
let world = app.world();
|
||||
let mut iter = q.iter(world);
|
||||
let first = iter
|
||||
.next()
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expected a Tooltip on the {} entity",
|
||||
std::any::type_name::<M>()
|
||||
)
|
||||
})
|
||||
.0
|
||||
.clone()
|
||||
.into_owned();
|
||||
assert!(
|
||||
iter.next().is_none(),
|
||||
"expected exactly one Tooltip-bearing entity for {}",
|
||||
std::any::type_name::<M>()
|
||||
);
|
||||
first
|
||||
}
|
||||
|
||||
/// Every HUD readout and action button must spawn with a `Tooltip`
|
||||
/// carrying the approved canonical microcopy. Mirrors the structure
|
||||
/// of `hud_buttons_get_focusable_marker` (Phase 2 focus test) so the
|
||||
/// invariant — one marker entity, one tooltip, exact text — is
|
||||
/// asserted consistently across every element.
|
||||
#[test]
|
||||
fn hud_elements_carry_expected_tooltip_strings() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// HUD readouts (left column, top to bottom).
|
||||
assert_eq!(
|
||||
tooltip_for::<HudScore>(&mut app),
|
||||
"Points earned this game. Hidden in Zen mode."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudMoves>(&mut app),
|
||||
"Moves you've made this game. Counts placements and stock draws."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudTime>(&mut app),
|
||||
"Time on this game. Counts down in Time Attack."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudMode>(&mut app),
|
||||
"Active game mode. Click Modes to switch."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudChallenge>(&mut app),
|
||||
"Today's daily challenge target. Beat it for bonus XP."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudDrawCycle>(&mut app),
|
||||
"Cards drawn on the next stock click in Draw-Three."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudUndos>(&mut app),
|
||||
"Undos used this game. Any undo blocks the No Undo achievement."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudRecycles>(&mut app),
|
||||
"Times you've recycled the stock. Three or more unlocks Comeback."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudAutoComplete>(&mut app),
|
||||
"Board is solvable from here. Press Enter to auto-finish."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudSelection>(&mut app),
|
||||
"Pile selected with Tab. Use arrows or Enter to act."
|
||||
);
|
||||
|
||||
// Action bar (left to right).
|
||||
assert_eq!(
|
||||
tooltip_for::<MenuButton>(&mut app),
|
||||
"Open Stats, Achievements, Profile, Settings, or Leaderboard."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<UndoButton>(&mut app),
|
||||
"Take back your last move. Costs points and blocks No Undo."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<PauseButton>(&mut app),
|
||||
"Pause the game and freeze the timer."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HelpButton>(&mut app),
|
||||
"Show controls, rules, and keyboard shortcuts."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<ModesButton>(&mut app),
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<NewGameButton>(&mut app),
|
||||
"Start a fresh deal. Confirms first if a game is in progress."
|
||||
);
|
||||
}
|
||||
|
||||
/// Every interior row of the Modes and Menu popovers must carry a
|
||||
/// `Tooltip`. The popovers open from action-bar buttons whose own
|
||||
/// tooltips are already covered above; this test extends the
|
||||
/// invariant inward so hover discoverability is uniform across the
|
||||
/// HUD's nested controls.
|
||||
///
|
||||
/// We invoke the popover spawn helpers directly with a maxed-out
|
||||
/// `ProgressResource` and a `DailyChallengeResource` so every row
|
||||
/// branch fires (Classic, Daily, Zen, Challenge, Time Attack).
|
||||
/// Headless click simulation isn't needed — the contract under
|
||||
/// test is "every popover row spawns with a tooltip", which is a
|
||||
/// property of the spawn helpers themselves.
|
||||
#[test]
|
||||
fn popover_rows_carry_tooltip_strings() {
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use solitaire_sync::progress::PlayerProgress;
|
||||
|
||||
let mut app = headless_app();
|
||||
|
||||
// Force every mode row to render: level past the challenge
|
||||
// unlock threshold, plus a daily challenge resource so the
|
||||
// Daily row appears.
|
||||
let progress = ProgressResource(PlayerProgress {
|
||||
level: CHALLENGE_UNLOCK_LEVEL,
|
||||
..Default::default()
|
||||
});
|
||||
let daily = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: None,
|
||||
};
|
||||
|
||||
// Spawn both popovers via their helpers. Mirrors how the click
|
||||
// handlers invoke them in production — we just skip the click.
|
||||
{
|
||||
let world = app.world_mut();
|
||||
let mut commands = world.commands();
|
||||
spawn_modes_popover(&mut commands, Some(&progress), Some(&daily), None);
|
||||
spawn_menu_popover(&mut commands, None);
|
||||
world.flush();
|
||||
}
|
||||
app.update();
|
||||
|
||||
// Every ModeOption-tagged entity must also carry a Tooltip,
|
||||
// and the count must match the five canonical modes.
|
||||
let mut mode_q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Tooltip, With<ModeOption>>();
|
||||
let mode_tooltips: Vec<String> = mode_q
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone().into_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
mode_tooltips.len(),
|
||||
5,
|
||||
"expected a tooltip on each of the 5 mode rows, got {}",
|
||||
mode_tooltips.len()
|
||||
);
|
||||
// Every approved mode tooltip string must be present somewhere
|
||||
// among the ModeOption rows. Order isn't asserted — the spawn
|
||||
// order test elsewhere already covers that.
|
||||
for expected in [
|
||||
"Standard Klondike. Score, timer, and full progression.",
|
||||
"Today's seeded deal. Same for every player worldwide.",
|
||||
"No timer, no score, no penalties. Just play.",
|
||||
"Hand-picked hard seeds. No undo allowed.",
|
||||
"Win as many games as you can in ten minutes.",
|
||||
] {
|
||||
assert!(
|
||||
mode_tooltips.iter().any(|s| s == expected),
|
||||
"missing mode tooltip: {expected:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Same contract for MenuOption rows: five entries, each with a
|
||||
// tooltip, exact strings matching the approved microcopy.
|
||||
let mut menu_q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Tooltip, With<MenuOption>>();
|
||||
let menu_tooltips: Vec<String> = menu_q
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone().into_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
menu_tooltips.len(),
|
||||
5,
|
||||
"expected a tooltip on each of the 5 menu rows, got {}",
|
||||
menu_tooltips.len()
|
||||
);
|
||||
for expected in [
|
||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||
"Browse unlocked achievements and the rewards still ahead.",
|
||||
"Your level, XP progress, and sync status.",
|
||||
"Audio, animations, theme, draw mode, and sync.",
|
||||
"Top players from your sync server. Opt in from Profile.",
|
||||
] {
|
||||
assert!(
|
||||
menu_tooltips.iter().any(|s| s == expected),
|
||||
"missing menu tooltip: {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_button_order_matches_spawn_order() {
|
||||
let mut app = headless_app();
|
||||
// Visual reading order (left → right): Menu, Undo, Pause, Help,
|
||||
// Modes, New Game. Their `order` fields must be 0..=5 in that
|
||||
// order so Tab cycles them as the player reads them.
|
||||
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
|
||||
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
|
||||
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
|
||||
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
|
||||
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 4);
|
||||
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_focus_only_engages_when_button_hovered() {
|
||||
// Phase 2 declares membership in `FocusGroup::Hud`; the
|
||||
// engagement rule lives in `handle_focus_keys`. Two halves to
|
||||
// this test:
|
||||
// (a) no modal + no hover ⇒ Tab is a no-op (Phase 1 contract
|
||||
// still holds when nothing is hovered).
|
||||
// (b) no modal + a HUD button hovered ⇒ Tab advances
|
||||
// `FocusedButton` to a Hud-grouped entity.
|
||||
use crate::ui_focus::{FocusedButton, 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(HudPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
|
||||
// (a) Sanity: HUD buttons exist and are focusable, but no
|
||||
// modal open and no hover ⇒ FocusedButton stays None.
|
||||
assert!(
|
||||
app.world().resource::<FocusedButton>().0.is_none(),
|
||||
"no modal open, no auto-focus"
|
||||
);
|
||||
|
||||
// Press Tab. With no modal and no hover, `handle_focus_keys`
|
||||
// resolves no active group and returns early — Tab must not
|
||||
// advance the HUD focus ring on its own.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release_all();
|
||||
input.clear();
|
||||
input.press(KeyCode::Tab);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().resource::<FocusedButton>().0.is_none(),
|
||||
"Tab with no modal and no Hud hover must not engage the HUD focus ring"
|
||||
);
|
||||
|
||||
// (b) Hover the Menu button — the leftmost HUD action — and
|
||||
// Tab. The Hud-group cycle should pick a Hud-tagged entity.
|
||||
let menu_entity = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<MenuButton>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("MenuButton entity should exist");
|
||||
app.world_mut()
|
||||
.entity_mut(menu_entity)
|
||||
.insert(Interaction::Hovered);
|
||||
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release_all();
|
||||
input.clear();
|
||||
input.press(KeyCode::Tab);
|
||||
}
|
||||
app.update();
|
||||
|
||||
let focused = app
|
||||
.world()
|
||||
.resource::<FocusedButton>()
|
||||
.0
|
||||
.expect("Tab with a HUD button hovered must engage the HUD focus ring");
|
||||
// The focused entity must itself be Hud-grouped (i.e. one of
|
||||
// the action-bar buttons), not anything else in the world.
|
||||
let focusable = app
|
||||
.world()
|
||||
.entity(focused)
|
||||
.get::<Focusable>()
|
||||
.expect("focused entity must carry Focusable");
|
||||
assert_eq!(
|
||||
focusable.group,
|
||||
FocusGroup::Hud,
|
||||
"Hud-engaged Tab must focus a Hud-grouped entity"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,25 @@ use crate::ui_theme::{
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Cached leaderboard data. `None` means no fetch has completed yet.
|
||||
/// State of the cached leaderboard fetch.
|
||||
///
|
||||
/// Distinguishes "fetch hasn't completed yet" from "fetch failed" from
|
||||
/// "fetch succeeded but the leaderboard is empty" so the UI can show
|
||||
/// targeted copy for each case rather than a single ambiguous "no
|
||||
/// entries" line that hid network errors from the player.
|
||||
#[derive(Resource, Default, Debug, Clone)]
|
||||
pub struct LeaderboardResource(pub Option<Vec<LeaderboardEntry>>);
|
||||
pub enum LeaderboardResource {
|
||||
/// No fetch has completed yet — show "Fetching..." in the panel.
|
||||
#[default]
|
||||
Idle,
|
||||
/// Last fetch failed (network, auth, etc.) — show error copy.
|
||||
/// The wrapped string is the underlying error for logging only;
|
||||
/// the UI shows a fixed user-friendly message.
|
||||
Error(String),
|
||||
/// Fetch succeeded — wrapped Vec may be empty (legitimately empty
|
||||
/// leaderboard) or populated.
|
||||
Loaded(Vec<LeaderboardEntry>),
|
||||
}
|
||||
|
||||
/// Set to `true` in the frame the user explicitly closes the panel so that a
|
||||
/// fetch completing in the same frame doesn't immediately reopen it.
|
||||
@@ -134,8 +150,12 @@ fn toggle_leaderboard_screen(
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn the panel immediately with whatever data we have (may be None).
|
||||
spawn_leaderboard_screen(&mut commands, data.0.as_deref(), font_res.as_deref());
|
||||
// Spawn the panel immediately with whatever data we have so far.
|
||||
let remote_available = provider
|
||||
.as_ref()
|
||||
.map(|p| p.0.backend_name() != "local")
|
||||
.unwrap_or(false);
|
||||
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
|
||||
|
||||
// Start a background fetch if not already in flight.
|
||||
if task_res.0.is_none()
|
||||
@@ -167,6 +187,7 @@ fn update_leaderboard_panel(
|
||||
mut result_res: ResMut<LeaderboardFetchResult>,
|
||||
mut data: ResMut<LeaderboardResource>,
|
||||
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||
provider: Option<Res<SyncProviderResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
closed_flag: Res<ClosedThisFrame>,
|
||||
) {
|
||||
@@ -174,12 +195,15 @@ fn update_leaderboard_panel(
|
||||
|
||||
match result {
|
||||
Ok(entries) => {
|
||||
data.0 = Some(entries);
|
||||
*data = LeaderboardResource::Loaded(entries);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("leaderboard fetch failed: {e}");
|
||||
if data.0.is_none() {
|
||||
data.0 = Some(vec![]); // show empty rather than spinner forever
|
||||
// Preserve previously-loaded data on a transient failure so a
|
||||
// momentary network blip doesn't wipe a populated list. Only
|
||||
// surface an Error state when we have nothing better to show.
|
||||
if !matches!(*data, LeaderboardResource::Loaded(_)) {
|
||||
*data = LeaderboardResource::Error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,9 +213,13 @@ fn update_leaderboard_panel(
|
||||
if closed_flag.0 {
|
||||
return;
|
||||
}
|
||||
let remote_available = provider
|
||||
.as_ref()
|
||||
.map(|p| p.0.backend_name() != "local")
|
||||
.unwrap_or(false);
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
spawn_leaderboard_screen(&mut commands, data.0.as_deref(), font_res.as_deref());
|
||||
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +344,8 @@ pub struct LeaderboardCloseButton;
|
||||
|
||||
fn spawn_leaderboard_screen(
|
||||
commands: &mut Commands,
|
||||
entries: Option<&[LeaderboardEntry]>,
|
||||
data: &LeaderboardResource,
|
||||
remote_available: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
|
||||
@@ -345,32 +374,44 @@ fn spawn_leaderboard_screen(
|
||||
..default()
|
||||
};
|
||||
|
||||
card.spawn((
|
||||
Text::new("Use Opt In / Opt Out to control your visibility on the server."),
|
||||
font_caption.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
if remote_available {
|
||||
card.spawn((
|
||||
Text::new("Use Opt In / Opt Out to control your visibility on the server."),
|
||||
font_caption.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
// Opt In / Opt Out row uses the same modal-button helpers as
|
||||
// the rest of the UI for consistent hover / press feedback.
|
||||
spawn_modal_actions(card, |row| {
|
||||
spawn_modal_button(
|
||||
row,
|
||||
LeaderboardOptInButton,
|
||||
"Opt In",
|
||||
None,
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
row,
|
||||
LeaderboardOptOutButton,
|
||||
"Opt Out",
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
// Opt In / Opt Out row uses the same modal-button helpers as
|
||||
// the rest of the UI for consistent hover / press feedback.
|
||||
spawn_modal_actions(card, |row| {
|
||||
spawn_modal_button(
|
||||
row,
|
||||
LeaderboardOptInButton,
|
||||
"Opt In",
|
||||
None,
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
row,
|
||||
LeaderboardOptOutButton,
|
||||
"Opt Out",
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// No remote sync provider configured — opt-in/out would be a
|
||||
// silent no-op, so show a single explanatory line instead.
|
||||
card.spawn((
|
||||
Text::new(
|
||||
"Leaderboards require cloud sync. Configure a server in Settings to participate.",
|
||||
),
|
||||
font_caption.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
|
||||
// Subtle separator between the controls and the data area.
|
||||
card.spawn((
|
||||
@@ -381,22 +422,29 @@ fn spawn_leaderboard_screen(
|
||||
BackgroundColor(BORDER_SUBTLE),
|
||||
));
|
||||
|
||||
match entries {
|
||||
None => {
|
||||
match data {
|
||||
LeaderboardResource::Idle => {
|
||||
card.spawn((
|
||||
Text::new("Fetching\u{2026}"),
|
||||
font_status.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
}
|
||||
Some([]) => {
|
||||
LeaderboardResource::Error(_) => {
|
||||
card.spawn((
|
||||
Text::new("Couldn't reach the leaderboard. Try again later."),
|
||||
font_status.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
||||
card.spawn((
|
||||
Text::new("No entries yet \u{2014} sync and opt in to appear here."),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
Some(rows) => {
|
||||
LeaderboardResource::Loaded(rows) => {
|
||||
// Column headers
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
@@ -583,7 +631,10 @@ mod tests {
|
||||
#[test]
|
||||
fn resource_starts_empty() {
|
||||
let app = headless_app();
|
||||
assert!(app.world().resource::<LeaderboardResource>().0.is_none());
|
||||
assert!(matches!(
|
||||
app.world().resource::<LeaderboardResource>(),
|
||||
LeaderboardResource::Idle
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,12 +26,15 @@ pub mod settings_plugin;
|
||||
pub mod progress_plugin;
|
||||
pub mod resources;
|
||||
pub mod selection_plugin;
|
||||
pub mod splash_plugin;
|
||||
pub mod stats_plugin;
|
||||
pub mod sync_plugin;
|
||||
pub mod table_plugin;
|
||||
pub mod time_attack_plugin;
|
||||
pub mod ui_focus;
|
||||
pub mod ui_modal;
|
||||
pub mod ui_theme;
|
||||
pub mod ui_tooltip;
|
||||
pub mod weekly_goals_plugin;
|
||||
pub mod win_summary_plugin;
|
||||
|
||||
@@ -95,13 +98,16 @@ pub use settings_plugin::{
|
||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
|
||||
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
|
||||
pub use ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
spawn_modal_header, ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard,
|
||||
ModalHeader, ModalScrim, UiModalPlugin,
|
||||
};
|
||||
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
|
||||
pub use table_plugin::{
|
||||
BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin,
|
||||
};
|
||||
|
||||
@@ -166,7 +166,7 @@ fn handle_onboarding_buttons(
|
||||
}
|
||||
|
||||
if skip_pressed || (next_pressed && slide_index.0 == SLIDE_COUNT - 1) {
|
||||
// Skip or final-slide "Start playing" — complete onboarding.
|
||||
// Skip or final-slide "Let's play" — complete onboarding.
|
||||
complete_onboarding(
|
||||
&mut commands,
|
||||
&screens,
|
||||
@@ -412,7 +412,7 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
OnboardingNextButton,
|
||||
"Start playing",
|
||||
"Let's play",
|
||||
Some("→"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
|
||||
@@ -72,7 +72,7 @@ pub struct ForfeitConfirmScreen;
|
||||
#[derive(Component, Debug)]
|
||||
struct ForfeitCancelButton;
|
||||
|
||||
/// Marker on the "Yes, forfeit" primary button inside the forfeit-confirm modal.
|
||||
/// Marker on the "Forfeit" primary button inside the forfeit-confirm modal.
|
||||
#[derive(Component, Debug)]
|
||||
struct ForfeitConfirmButton;
|
||||
|
||||
@@ -468,7 +468,7 @@ fn spawn_draw_mode_row(
|
||||
));
|
||||
}
|
||||
|
||||
/// Spawns `ForfeitConfirmScreen` — a Cancel / "Yes, forfeit" modal
|
||||
/// Spawns `ForfeitConfirmScreen` — a Cancel / "Forfeit" modal
|
||||
/// stacked above the pause modal at `Z_PAUSE_DIALOG`.
|
||||
fn spawn_forfeit_confirm_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
spawn_modal(
|
||||
@@ -495,7 +495,7 @@ fn spawn_forfeit_confirm_screen(commands: &mut Commands, font_res: Option<&FontR
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
ForfeitConfirmButton,
|
||||
"Yes, forfeit",
|
||||
"Forfeit",
|
||||
Some("Y"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
|
||||
@@ -111,6 +111,27 @@ fn spawn_profile_screen(
|
||||
spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Profile", font_res);
|
||||
|
||||
// First-launch welcome — only when the player has zero XP and
|
||||
// zero daily streak, so the profile doesn't read as a wall of
|
||||
// zeros to a brand-new player.
|
||||
if let Some(p) = progress
|
||||
&& p.0.total_xp == 0
|
||||
&& p.0.daily_challenge_streak == 0
|
||||
{
|
||||
card.spawn((
|
||||
Text::new("Welcome! Play games to earn XP and unlock achievements."),
|
||||
font_section.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
Node {
|
||||
margin: UiRect {
|
||||
bottom: VAL_SPACE_2,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// ── Sync section ────────────────────────────────────────────
|
||||
card.spawn((
|
||||
Text::new("Sync"),
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::path::PathBuf;
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
|
||||
|
||||
@@ -20,12 +21,15 @@ use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ModalButton, ModalScrim,
|
||||
};
|
||||
use crate::ui_tooltip::Tooltip;
|
||||
use crate::ui_theme::{
|
||||
BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||
BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||
};
|
||||
|
||||
/// Side length of a swatch button in the card-back / background pickers.
|
||||
@@ -33,8 +37,9 @@ use crate::ui_theme::{
|
||||
const SWATCH_PX: f32 = 40.0;
|
||||
|
||||
/// Side length of a small toggle / cycle button (e.g. the "⇄" affordances).
|
||||
/// Sub-rung sizing — kept as a literal, see SWATCH_PX.
|
||||
const ICON_BUTTON_PX: f32 = 28.0;
|
||||
/// Sub-rung sizing — kept as a literal, see SWATCH_PX. 32 px meets the
|
||||
/// minimum desktop hit-target threshold while staying smaller than `SWATCH_PX`.
|
||||
const ICON_BUTTON_PX: f32 = 32.0;
|
||||
|
||||
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
||||
pub const SFX_STEP: f32 = 0.1;
|
||||
@@ -122,6 +127,39 @@ enum SettingsButton {
|
||||
SelectBackground(usize),
|
||||
}
|
||||
|
||||
impl SettingsButton {
|
||||
/// Tab-walk priority — lower numbers visited first. Visual reading
|
||||
/// order is top-to-bottom by section, left-to-right inside each row.
|
||||
/// Two buttons in the same picker row receive the same `order`;
|
||||
/// `handle_focus_keys` then breaks ties by entity index, which
|
||||
/// matches `Children` spawn order inside each row.
|
||||
fn focus_order(&self) -> i32 {
|
||||
match self {
|
||||
// Audio section
|
||||
SettingsButton::SfxDown => 10,
|
||||
SettingsButton::SfxUp => 11,
|
||||
SettingsButton::MusicDown => 20,
|
||||
SettingsButton::MusicUp => 21,
|
||||
// Gameplay section
|
||||
SettingsButton::ToggleDrawMode => 30,
|
||||
SettingsButton::CycleAnimSpeed => 40,
|
||||
// Cosmetic section
|
||||
SettingsButton::ToggleTheme => 50,
|
||||
SettingsButton::ToggleColorBlind => 60,
|
||||
// Picker rows — every swatch in a row shares the row's
|
||||
// priority so entity-index tiebreaking yields left → right.
|
||||
SettingsButton::SelectCardBack(_) => 70,
|
||||
SettingsButton::SelectBackground(_) => 80,
|
||||
// Sync section
|
||||
SettingsButton::SyncNow => 90,
|
||||
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
||||
// never reaches `attach_focusable_to_settings_buttons`; the
|
||||
// value here is only a fallback for completeness.
|
||||
SettingsButton::Done => 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin that owns the settings lifecycle.
|
||||
pub struct SettingsPlugin {
|
||||
/// Path to `settings.json`. `None` in headless/test mode.
|
||||
@@ -177,6 +215,8 @@ impl Plugin for SettingsPlugin {
|
||||
update_background_text,
|
||||
update_anim_speed_text,
|
||||
update_color_blind_text,
|
||||
attach_focusable_to_settings_buttons,
|
||||
scroll_focus_into_view,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -257,7 +297,7 @@ fn sync_settings_panel_visibility(
|
||||
if panels.is_empty() {
|
||||
let status_label = sync_status
|
||||
.map(|s| sync_status_label(&s.0))
|
||||
.unwrap_or_else(|| "Status: not configured".to_string());
|
||||
.unwrap_or_else(|| "Status: local only".to_string());
|
||||
let unlocked_backs = progress
|
||||
.as_ref()
|
||||
.map(|p| p.0.unlocked_card_backs.as_slice())
|
||||
@@ -553,6 +593,148 @@ fn color_blind_label(enabled: bool) -> String {
|
||||
if enabled { "ON".into() } else { "OFF".into() }
|
||||
}
|
||||
|
||||
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
||||
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
||||
/// background pickers), and the "Sync Now" button. The "Done" button is
|
||||
/// already tagged by `attach_focusable_to_modal_buttons` (it carries
|
||||
/// [`ModalButton`]) and is filtered out here.
|
||||
///
|
||||
/// Walks ancestors via [`ChildOf`] to find the [`ModalScrim`] that owns
|
||||
/// the panel so the new [`Focusable`]'s group is bound to that scrim —
|
||||
/// same defensive shape as the Phase 1 / 2 attach systems.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn attach_focusable_to_settings_buttons(
|
||||
mut commands: Commands,
|
||||
new_buttons: Query<
|
||||
(Entity, &SettingsButton),
|
||||
(With<Button>, Without<Focusable>, Without<ModalButton>),
|
||||
>,
|
||||
parents: Query<&ChildOf>,
|
||||
scrims: Query<(), With<ModalScrim>>,
|
||||
) {
|
||||
for (button, settings_button) in &new_buttons {
|
||||
let mut current = button;
|
||||
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,
|
||||
}
|
||||
}
|
||||
if let Some(scrim) = scrim_entity {
|
||||
commands.entity(button).insert(Focusable {
|
||||
group: FocusGroup::Modal(scrim),
|
||||
order: settings_button.focus_order(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical padding (logical px) added around the focused button when
|
||||
/// scrolling it into view. Keeps the focus ring's halo visible above /
|
||||
/// below the viewport edge.
|
||||
const FOCUS_SCROLL_PADDING: f32 = SPACE_2;
|
||||
|
||||
/// When the focused entity sits outside the visible Settings scroll
|
||||
/// viewport, adjust the viewport's [`ScrollPosition`] so the button is
|
||||
/// fully visible. No-op when:
|
||||
///
|
||||
/// - `FocusedButton` is `None`
|
||||
/// - the focused entity has no [`UiGlobalTransform`] / [`ComputedNode`]
|
||||
/// (e.g. a freshly-spawned modal hasn't laid out yet)
|
||||
/// - the focused entity is not a descendant of the
|
||||
/// [`SettingsPanelScrollable`] container
|
||||
///
|
||||
/// The viewport's visible Y range is `[scroll_y, scroll_y +
|
||||
/// viewport_height]` in physical pixels (matching `ComputedNode.size`).
|
||||
/// The focused button's vertical extent is computed from its
|
||||
/// `UiGlobalTransform.translation.y` (centre, physical) ± half its
|
||||
/// `ComputedNode.size.y`. Because the scroll container's local
|
||||
/// coordinates run [0, content_height] and the visible window is
|
||||
/// [scroll_y, scroll_y + viewport], we convert the button's window-
|
||||
/// space Y to container-local Y by subtracting the container's window-
|
||||
/// space top and adding the current scroll offset.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn scroll_focus_into_view(
|
||||
focused: Res<FocusedButton>,
|
||||
parents: Query<&ChildOf>,
|
||||
nodes: Query<(&UiGlobalTransform, &ComputedNode)>,
|
||||
mut containers: Query<
|
||||
(&mut ScrollPosition, &UiGlobalTransform, &ComputedNode),
|
||||
With<SettingsPanelScrollable>,
|
||||
>,
|
||||
) {
|
||||
let Some(target) = focused.0 else { return };
|
||||
// Gather button geometry.
|
||||
let Ok((target_transform, target_node)) = nodes.get(target) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Walk ancestors looking for the scroll container. Bounded to keep
|
||||
// a malformed hierarchy from hanging the system.
|
||||
let mut current = target;
|
||||
let mut container_entity: Option<Entity> = None;
|
||||
for _ in 0..32 {
|
||||
if containers.get(current).is_ok() {
|
||||
container_entity = Some(current);
|
||||
break;
|
||||
}
|
||||
match parents.get(current) {
|
||||
Ok(parent) => current = parent.parent(),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let Some(container) = container_entity else { return };
|
||||
|
||||
let Ok((mut scroll, container_transform, container_node)) =
|
||||
containers.get_mut(container)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Geometry is reported in physical pixels by `ComputedNode.size` and
|
||||
// `UiGlobalTransform.translation`. `ScrollPosition` is in logical px,
|
||||
// so convert via `inverse_scale_factor` before we write.
|
||||
let inv = target_node.inverse_scale_factor;
|
||||
let target_height = target_node.size().y;
|
||||
let target_centre_y = target_transform.translation.y;
|
||||
let target_top = target_centre_y - target_height * 0.5;
|
||||
let target_bottom = target_centre_y + target_height * 0.5;
|
||||
|
||||
let container_height = container_node.size().y;
|
||||
let container_top = container_transform.translation.y - container_height * 0.5;
|
||||
|
||||
// Convert button window-space Y to container-local Y. The container
|
||||
// is currently scrolled by `scroll.0.y` *logical* pixels — multiply
|
||||
// by physical-per-logical to compare with physical pixel extents.
|
||||
let scroll_phys = scroll.0.y / inv.max(f32::EPSILON);
|
||||
let viewport_top = container_top + scroll_phys;
|
||||
let viewport_bottom = viewport_top + container_height;
|
||||
|
||||
// Layout may not have run yet (zero size on first frame) — no
|
||||
// sensible scroll target until the container has dimensions.
|
||||
if container_height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let pad_phys = FOCUS_SCROLL_PADDING / inv.max(f32::EPSILON);
|
||||
if target_top < viewport_top {
|
||||
// Button extends above the viewport — scroll up.
|
||||
let new_top = target_top - pad_phys;
|
||||
let delta = new_top - viewport_top;
|
||||
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||||
} else if target_bottom > viewport_bottom {
|
||||
// Button extends below the viewport — scroll down.
|
||||
let new_bottom = target_bottom + pad_phys;
|
||||
let delta = new_bottom - viewport_bottom;
|
||||
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolls the settings panel inner card in response to mouse-wheel events.
|
||||
///
|
||||
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
|
||||
@@ -623,6 +805,8 @@ fn spawn_settings_panel(
|
||||
SfxVolumeText,
|
||||
SettingsButton::SfxDown,
|
||||
SettingsButton::SfxUp,
|
||||
"Lower sound effects volume.",
|
||||
"Raise sound effects volume.",
|
||||
font_res,
|
||||
);
|
||||
volume_row(
|
||||
@@ -632,6 +816,8 @@ fn spawn_settings_panel(
|
||||
MusicVolumeText,
|
||||
SettingsButton::MusicDown,
|
||||
SettingsButton::MusicUp,
|
||||
"Lower music and ambience volume.",
|
||||
"Raise music and ambience volume.",
|
||||
font_res,
|
||||
);
|
||||
|
||||
@@ -643,6 +829,7 @@ fn spawn_settings_panel(
|
||||
DrawModeText,
|
||||
draw_mode_label(&settings.draw_mode),
|
||||
SettingsButton::ToggleDrawMode,
|
||||
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
@@ -651,6 +838,7 @@ fn spawn_settings_panel(
|
||||
AnimSpeedText,
|
||||
anim_speed_label(&settings.animation_speed),
|
||||
SettingsButton::CycleAnimSpeed,
|
||||
"Cycle animation speed: Normal, Fast, Instant.",
|
||||
font_res,
|
||||
);
|
||||
|
||||
@@ -662,6 +850,7 @@ fn spawn_settings_panel(
|
||||
ThemeText,
|
||||
theme_label(&settings.theme),
|
||||
SettingsButton::ToggleTheme,
|
||||
"Cycle felt color: Green, Blue, Dark.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
@@ -670,6 +859,7 @@ fn spawn_settings_panel(
|
||||
ColorBlindText,
|
||||
color_blind_label(settings.color_blind_mode),
|
||||
SettingsButton::ToggleColorBlind,
|
||||
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
||||
font_res,
|
||||
);
|
||||
picker_row(
|
||||
@@ -678,6 +868,7 @@ fn spawn_settings_panel(
|
||||
unlocked_card_backs,
|
||||
settings.selected_card_back,
|
||||
SettingsButton::SelectCardBack,
|
||||
"Choose your deck art. New backs unlock at higher levels.",
|
||||
font_res,
|
||||
);
|
||||
picker_row(
|
||||
@@ -686,6 +877,7 @@ fn spawn_settings_panel(
|
||||
unlocked_backgrounds,
|
||||
settings.selected_background,
|
||||
SettingsButton::SelectBackground,
|
||||
"Choose your felt art. New felts unlock at higher levels.",
|
||||
font_res,
|
||||
);
|
||||
|
||||
@@ -720,6 +912,10 @@ fn section_label(parent: &mut ChildSpawnerCommands, title: &str, font_res: Optio
|
||||
}
|
||||
|
||||
/// `Label 0.80 [−] [+]` — used for SFX and Music volume rows.
|
||||
///
|
||||
/// `tooltip_down` / `tooltip_up` are attached to the `−` / `+` buttons
|
||||
/// respectively so each glyph carries a one-line reminder of which channel
|
||||
/// it adjusts.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn volume_row<Marker: Component>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
@@ -728,6 +924,8 @@ fn volume_row<Marker: Component>(
|
||||
marker: Marker,
|
||||
btn_down: SettingsButton,
|
||||
btn_up: SettingsButton,
|
||||
tooltip_down: &'static str,
|
||||
tooltip_up: &'static str,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let label_font = label_text_font(font_res);
|
||||
@@ -751,19 +949,24 @@ fn volume_row<Marker: Component>(
|
||||
value_font,
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
icon_button(row, "−", btn_down, font_res);
|
||||
icon_button(row, "+", btn_up, font_res);
|
||||
icon_button(row, "−", btn_down, tooltip_down, font_res);
|
||||
icon_button(row, "+", btn_up, tooltip_up, font_res);
|
||||
});
|
||||
}
|
||||
|
||||
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
|
||||
/// anim speed, colour-blind).
|
||||
///
|
||||
/// `tooltip` is attached to the `⇄` button so the cycle glyph carries a
|
||||
/// one-line reminder of what it iterates through.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn toggle_row<Marker: Component>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
label: &str,
|
||||
marker: Marker,
|
||||
value: String,
|
||||
action: SettingsButton,
|
||||
tooltip: &'static str,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let label_font = label_text_font(font_res);
|
||||
@@ -782,19 +985,24 @@ fn toggle_row<Marker: Component>(
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
row.spawn((marker, Text::new(value), value_font, TextColor(TEXT_PRIMARY)));
|
||||
icon_button(row, "⇄", action, font_res);
|
||||
icon_button(row, "⇄", action, tooltip, font_res);
|
||||
});
|
||||
}
|
||||
|
||||
/// Wrapping row of indexed swatch buttons — used for card-back and
|
||||
/// background pickers. The currently-selected swatch is tinted with
|
||||
/// `STATE_SUCCESS` so the user can see it without reading a label.
|
||||
///
|
||||
/// `tooltip` is attached to every swatch in the row so hovering any chip
|
||||
/// reveals what the picker controls and how new entries unlock.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn picker_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
label: &str,
|
||||
unlocked: &[usize],
|
||||
selected: usize,
|
||||
make_button: impl Fn(usize) -> SettingsButton,
|
||||
tooltip: &'static str,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let label_font = label_text_font(font_res);
|
||||
@@ -804,13 +1012,19 @@ fn picker_row(
|
||||
..default()
|
||||
};
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
..default()
|
||||
})
|
||||
.spawn((
|
||||
// The row container is a `FocusRow` so Left / Right arrow
|
||||
// keys cycle within its swatch children. Tab still escapes
|
||||
// the row to the next focusable in the modal.
|
||||
FocusRow,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new(label.to_string()),
|
||||
@@ -825,6 +1039,7 @@ fn picker_row(
|
||||
row.spawn((
|
||||
make_button(idx),
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
Node {
|
||||
width: Val::Px(SWATCH_PX),
|
||||
height: Val::Px(SWATCH_PX),
|
||||
@@ -880,6 +1095,9 @@ fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Opti
|
||||
row.spawn((
|
||||
SettingsButton::SyncNow,
|
||||
Button,
|
||||
Tooltip::new(
|
||||
"Push and pull stats now. Runs automatically on launch and exit.",
|
||||
),
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||
justify_content: JustifyContent::Center,
|
||||
@@ -916,10 +1134,16 @@ fn value_text_font(font_res: Option<&FontResource>) -> TextFont {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a small square icon button (volume +/−, toggle, cycle).
|
||||
///
|
||||
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
|
||||
/// Settings icon button ships with one because the glyph alone (`+`, `−`,
|
||||
/// `⇄`) does not name what it adjusts; the tooltip carries that meaning.
|
||||
fn icon_button(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
label: &str,
|
||||
action: SettingsButton,
|
||||
tooltip: &'static str,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let glyph_font = TextFont {
|
||||
@@ -931,6 +1155,7 @@ fn icon_button(
|
||||
.spawn((
|
||||
action,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
Node {
|
||||
width: Val::Px(ICON_BUTTON_PX),
|
||||
height: Val::Px(ICON_BUTTON_PX),
|
||||
@@ -1140,6 +1365,153 @@ mod tests {
|
||||
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
|
||||
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
|
||||
/// auto-tag systems fire end-to-end without writing to disk.
|
||||
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(SettingsPlugin {
|
||||
// No persistence — keep the test isolated.
|
||||
storage_path: None,
|
||||
ui_enabled: true,
|
||||
});
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_buttons_get_focusable_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
// Open the panel.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
||||
// and queues the spawn commands; the second flushes them and
|
||||
// runs `attach_focusable_to_settings_buttons`.
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
||||
// `ModalButton`) must carry a `Focusable`.
|
||||
let untagged: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
untagged.is_empty(),
|
||||
"every bespoke Settings button must carry Focusable; missing: {:?}",
|
||||
untagged
|
||||
);
|
||||
|
||||
// And there must be at least one tagged `SettingsButton` so the
|
||||
// assertion above isn't vacuously true (the panel really did
|
||||
// spawn).
|
||||
let tagged_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Focusable>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
tagged_count >= 6,
|
||||
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every bespoke `SettingsButton` (volume +/−, toggles, swatches,
|
||||
/// Sync Now) must spawn with a `Tooltip` so the glyph-only icons and
|
||||
/// indexed swatches carry hover-reveal context. Mirrors
|
||||
/// `settings_buttons_get_focusable_marker` (Phase 3 focus test) so
|
||||
/// the invariant — every interactive Settings element except the
|
||||
/// `Done` modal button has a tooltip — is asserted consistently.
|
||||
#[test]
|
||||
fn settings_buttons_carry_tooltip() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
// Open the panel and let spawn + child-flush run.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
|
||||
// also a `ModalButton`) may be missing a `Tooltip`.
|
||||
let untipped: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
assert!(
|
||||
untipped.is_empty(),
|
||||
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
||||
untipped
|
||||
);
|
||||
|
||||
// And there must be at least 6 tipped buttons so the assertion
|
||||
// above isn't vacuously true: SFX +/−, Music +/−, Draw Mode,
|
||||
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
|
||||
// card-back and one background swatch — well over the floor.
|
||||
let tipped_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
tipped_count >= 6,
|
||||
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
||||
);
|
||||
|
||||
// Spot-check: the Sync Now button's tooltip text is the
|
||||
// canonical microcopy. We find it via the `SettingsButton`
|
||||
// discriminant — there is exactly one Sync Now entity per panel.
|
||||
let sync_tip = app
|
||||
.world_mut()
|
||||
.query::<(&SettingsButton, &Tooltip)>()
|
||||
.iter(app.world())
|
||||
.find_map(|(btn, tip)| matches!(btn, SettingsButton::SyncNow).then(|| tip.0.clone()))
|
||||
.expect("Sync Now button should spawn with a Tooltip");
|
||||
assert_eq!(
|
||||
sync_tip.as_ref(),
|
||||
"Push and pull stats now. Runs automatically on launch and exit.",
|
||||
"Sync Now tooltip must use the canonical microcopy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_picker_rows_get_focus_row_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Two picker rows are spawned (card-back + background); each
|
||||
// must carry the FocusRow marker.
|
||||
let row_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<FocusRow>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
row_count >= 2,
|
||||
"expected at least two FocusRow containers (card-back + background); got {row_count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_clamps_offset_to_zero_at_top() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
//! Launch splash overlay.
|
||||
//!
|
||||
//! On app start the engine spawns a fullscreen, high-Z overlay that
|
||||
//! reads "Solitaire Quest" in the project font for ~1.6 s
|
||||
//! (300 ms fade-in, ~1 s hold, 300 ms fade-out), then despawns. The
|
||||
//! existing deal animation plays *behind* the splash during the hold —
|
||||
//! the user sees the dealt board appear as the splash dissolves.
|
||||
//!
|
||||
//! ## Why an overlay instead of an `AppState`
|
||||
//!
|
||||
//! Every existing plugin in this engine runs unconditionally on
|
||||
//! `Startup`/`Update`; gating them with `run_if(in_state(...))` would be
|
||||
//! a sweeping refactor for a one-off brand beat. The splash instead
|
||||
//! sits on top of `Z_SPLASH` (above tooltips, focus ring, and toasts)
|
||||
//! while the rest of the game runs normally beneath it. The handoff is
|
||||
//! intentional: the user finishes the splash and the dealt board is
|
||||
//! already there.
|
||||
//!
|
||||
//! ## Dismissal
|
||||
//!
|
||||
//! Any keypress, mouse click, or touch begin shortcuts the splash to its
|
||||
//! fade-out window — never to an instant despawn, so the dissolve still
|
||||
//! plays for visual continuity. The dismiss input is **not** consumed,
|
||||
//! so a player who instinctively taps Space to "skip the intro" still
|
||||
//! gets their stock draw the moment the splash clears (Space and most
|
||||
//! other gameplay keys read `just_pressed`, which by the next tick is
|
||||
//! already false — splash dismissal happens on the same tick as the
|
||||
//! press, so downstream gameplay handlers see exactly the keystroke
|
||||
//! they would have seen with no splash).
|
||||
//!
|
||||
//! ## Headless tests
|
||||
//!
|
||||
//! Under `MinimalPlugins + SplashPlugin`, the `Time<Virtual>` clock
|
||||
//! clamps each tick to `max_delta` (default 250 ms) regardless of the
|
||||
//! `TimeUpdateStrategy::ManualDuration` value, so tests advance time in
|
||||
//! 200 ms ticks and call `app.update()` enough times to cross the
|
||||
//! desired threshold (same approach used by `ui_tooltip::tests`).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use bevy::input::touch::Touches;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_BASE, MOTION_SPLASH_FADE_SECS, MOTION_SPLASH_TOTAL_SECS, TEXT_SECONDARY,
|
||||
TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_2, Z_SPLASH,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Drives the launch splash overlay. Add this plugin once at app start;
|
||||
/// the splash spawns during `Startup`, fades in/out over
|
||||
/// [`MOTION_SPLASH_TOTAL_SECS`], and despawns itself.
|
||||
///
|
||||
/// The overlay is a sibling of every other UI surface — it never
|
||||
/// becomes a parent of game systems, and the deal animation runs
|
||||
/// underneath it during the hold window. Dismissal on any keypress /
|
||||
/// click / touch shortcuts the timeline into the fade-out phase rather
|
||||
/// than despawning instantly, so the dissolve always plays.
|
||||
pub struct SplashPlugin;
|
||||
|
||||
impl Plugin for SplashPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, spawn_splash).add_systems(
|
||||
Update,
|
||||
(dismiss_splash_on_input, advance_splash).chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker on the splash overlay scrim (root entity for the launch beat).
|
||||
/// Despawned with descendants once [`MOTION_SPLASH_TOTAL_SECS`] elapses
|
||||
/// or once a user-input dismissal advances the timeline past the hold.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct SplashRoot;
|
||||
|
||||
/// Tracks the splash's elapsed visible duration. Stored as a component
|
||||
/// on the splash root rather than a global resource so despawning the
|
||||
/// splash root removes its state along with it — there's no second-run
|
||||
/// concern (the splash is one-shot at app start) and a component keeps
|
||||
/// the splash data co-located with its entity.
|
||||
#[derive(Component, Debug, Default)]
|
||||
pub struct SplashAge(pub Duration);
|
||||
|
||||
/// Marker on the splash title text. Used by [`advance_splash`] to write
|
||||
/// the per-frame alpha into the text colour without walking arbitrary
|
||||
/// children.
|
||||
#[derive(Component, Debug)]
|
||||
struct SplashTitle;
|
||||
|
||||
/// Marker on the splash subtitle text (build version). Faded together
|
||||
/// with the title so the brand beat dissolves as a single layer.
|
||||
#[derive(Component, Debug)]
|
||||
struct SplashSubtitle;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawns the splash overlay at `Startup`. Builds a fullscreen scrim
|
||||
/// at full alpha (the first `advance_splash` tick will overwrite the
|
||||
/// alpha based on age), centres a "Solitaire Quest" title in
|
||||
/// [`ACCENT_PRIMARY`], and pins a small build-version line below.
|
||||
fn spawn_splash(mut commands: Commands, font_res: Option<Res<FontResource>>) {
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let title_font = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_DISPLAY,
|
||||
..default()
|
||||
};
|
||||
let subtitle_font = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
|
||||
// Initial alpha is 0 (fade-in starts at 0 and grows). Without this
|
||||
// the first frame would flash full-opacity scrim before the
|
||||
// `advance_splash` tick lerped it down — visually a pop on slower
|
||||
// start-ups.
|
||||
let mut initial_bg = BG_BASE;
|
||||
initial_bg.set_alpha(0.0);
|
||||
let mut initial_title = ACCENT_PRIMARY;
|
||||
initial_title.set_alpha(0.0);
|
||||
let mut initial_subtitle = TEXT_SECONDARY;
|
||||
initial_subtitle.set_alpha(0.0);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
SplashRoot,
|
||||
SplashAge(Duration::ZERO),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(initial_bg),
|
||||
GlobalZIndex(Z_SPLASH),
|
||||
))
|
||||
.with_children(|root| {
|
||||
root.spawn((
|
||||
SplashTitle,
|
||||
Text::new("Solitaire Quest"),
|
||||
title_font,
|
||||
TextColor(initial_title),
|
||||
));
|
||||
root.spawn((
|
||||
SplashSubtitle,
|
||||
Text::new(format!("v{}", env!("CARGO_PKG_VERSION"))),
|
||||
subtitle_font,
|
||||
TextColor(initial_subtitle),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Computes the splash's per-frame alpha from its age. Three phases:
|
||||
///
|
||||
/// * `0..fade` — fade-in: `alpha = age / fade`.
|
||||
/// * `fade..total - fade` — hold: `alpha = 1.0`.
|
||||
/// * `total - fade..total` — fade-out: `alpha = (total - age) / fade`.
|
||||
/// * `>= total` — splash is complete; caller despawns the root.
|
||||
///
|
||||
/// Returns `None` once the timeline is finished, signalling the splash
|
||||
/// should be despawned.
|
||||
fn splash_alpha(age: Duration) -> Option<f32> {
|
||||
let age_s = age.as_secs_f32();
|
||||
let total = MOTION_SPLASH_TOTAL_SECS;
|
||||
let fade = MOTION_SPLASH_FADE_SECS;
|
||||
|
||||
if age_s >= total {
|
||||
return None;
|
||||
}
|
||||
if age_s < fade {
|
||||
// Fade-in.
|
||||
return Some((age_s / fade).clamp(0.0, 1.0));
|
||||
}
|
||||
if age_s < total - fade {
|
||||
// Hold.
|
||||
return Some(1.0);
|
||||
}
|
||||
// Fade-out.
|
||||
Some(((total - age_s) / fade).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Advances every splash root's age by `time.delta()` and updates the
|
||||
/// scrim + text alpha, despawning the splash once the timeline
|
||||
/// finishes. Despawns with descendants so the title and subtitle leave
|
||||
/// the world together.
|
||||
fn advance_splash(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut roots: Query<(Entity, &mut SplashAge, &mut BackgroundColor, &Children), With<SplashRoot>>,
|
||||
mut titles: Query<&mut TextColor, (With<SplashTitle>, Without<SplashSubtitle>)>,
|
||||
mut subtitles: Query<&mut TextColor, (With<SplashSubtitle>, Without<SplashTitle>)>,
|
||||
) {
|
||||
for (entity, mut age, mut bg, children) in &mut roots {
|
||||
age.0 = age.0.saturating_add(time.delta());
|
||||
let Some(alpha) = splash_alpha(age.0) else {
|
||||
commands.entity(entity).despawn();
|
||||
continue;
|
||||
};
|
||||
|
||||
// Scrim alpha — keeps BG_BASE's RGB and just rewrites alpha.
|
||||
let mut scrim = BG_BASE;
|
||||
scrim.set_alpha(alpha);
|
||||
bg.0 = scrim;
|
||||
|
||||
// Walk the splash root's direct children for the title /
|
||||
// subtitle markers and update their alpha. The hierarchy is
|
||||
// shallow (root → 2 text children) so a small loop is fine.
|
||||
for child in children.iter() {
|
||||
if let Ok(mut color) = titles.get_mut(child) {
|
||||
let mut c = ACCENT_PRIMARY;
|
||||
c.set_alpha(alpha);
|
||||
color.0 = c;
|
||||
continue;
|
||||
}
|
||||
if let Ok(mut color) = subtitles.get_mut(child) {
|
||||
let mut c = TEXT_SECONDARY;
|
||||
c.set_alpha(alpha);
|
||||
color.0 = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismisses the splash on any user input. Accelerates each splash
|
||||
/// root's age into the fade-out window so the dissolve still plays
|
||||
/// (despawning instantly would feel abrupt). If the timeline is
|
||||
/// already inside fade-out, the splash is left to finish on its own.
|
||||
///
|
||||
/// **Input is not consumed.** The splash neither calls
|
||||
/// `clear_just_pressed` nor drains the touch / mouse buffers, so a
|
||||
/// keystroke that dismissed the splash also reaches downstream
|
||||
/// systems on the same tick (e.g. Space → `DrawRequestEvent`). This
|
||||
/// matches what the user expects — the splash is a brand beat, not a
|
||||
/// modal stop sign.
|
||||
fn dismiss_splash_on_input(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mouse: Res<ButtonInput<MouseButton>>,
|
||||
touches: Option<Res<Touches>>,
|
||||
mut roots: Query<&mut SplashAge, With<SplashRoot>>,
|
||||
) {
|
||||
if roots.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let touch_pressed = touches
|
||||
.map(|t| t.iter_just_pressed().next().is_some())
|
||||
.unwrap_or(false);
|
||||
let dismissed = keys.get_just_pressed().next().is_some()
|
||||
|| mouse.get_just_pressed().next().is_some()
|
||||
|| touch_pressed;
|
||||
|
||||
if !dismissed {
|
||||
return;
|
||||
}
|
||||
|
||||
// Jump the age forward to the start of the fade-out so the
|
||||
// overlay dissolves cleanly. Saturating arithmetic on Duration
|
||||
// means an already-past-fade-out splash stays past fade-out.
|
||||
let fade_out_start = Duration::from_secs_f32(
|
||||
(MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS).max(0.0),
|
||||
);
|
||||
for mut age in &mut roots {
|
||||
if age.0 < fade_out_start {
|
||||
age.0 = fade_out_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
|
||||
/// Builds a headless `App` with `MinimalPlugins + SplashPlugin` and
|
||||
/// runs one tick so `spawn_splash` (Startup) has executed before
|
||||
/// the first asserting `update`.
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(SplashPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.init_resource::<ButtonInput<MouseButton>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
/// Tells `TimePlugin` to advance the virtual clock by `secs` on the
|
||||
/// next `app.update()`. Mirrors the helper in `ui_tooltip::tests`.
|
||||
fn set_manual_time_step(app: &mut App, secs: f32) {
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||
Duration::from_secs_f32(secs),
|
||||
));
|
||||
}
|
||||
|
||||
/// `Time<Virtual>` clamps per-tick deltas to `max_delta` (default
|
||||
/// 250 ms) regardless of the requested manual step, so we drive
|
||||
/// 200 ms ticks and call `update` enough times to exceed the target
|
||||
/// duration. Returns the splash root's recorded age after the
|
||||
/// stepping completes (or `None` if the splash was despawned).
|
||||
fn advance_by(app: &mut App, total_secs: f32) -> Option<Duration> {
|
||||
set_manual_time_step(app, 0.2);
|
||||
let ticks = (total_secs / 0.2).ceil() as usize + 1;
|
||||
for _ in 0..ticks {
|
||||
app.update();
|
||||
}
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SplashAge, With<SplashRoot>>();
|
||||
q.iter(app.world()).next().map(|a| a.0)
|
||||
}
|
||||
|
||||
fn count_splash_roots(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query_filtered::<Entity, With<SplashRoot>>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
fn press_key(app: &mut App, key: KeyCode) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release_all();
|
||||
input.clear();
|
||||
input.press(key);
|
||||
}
|
||||
|
||||
fn press_mouse(app: &mut App, button: MouseButton) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<MouseButton>>();
|
||||
input.release_all();
|
||||
input.clear();
|
||||
input.press(button);
|
||||
}
|
||||
|
||||
/// Reads the splash scrim's `BackgroundColor` alpha. Panics if the
|
||||
/// splash root is missing — that's a regression in `spawn_splash`.
|
||||
fn scrim_alpha(app: &mut App) -> f32 {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&BackgroundColor, With<SplashRoot>>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.expect("SplashRoot should exist")
|
||||
.0
|
||||
.alpha()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splash_spawns_on_startup() {
|
||||
let mut app = headless_app();
|
||||
assert_eq!(
|
||||
count_splash_roots(&mut app),
|
||||
1,
|
||||
"SplashRoot must exist after Startup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splash_despawns_after_total_duration() {
|
||||
let mut app = headless_app();
|
||||
// Comfortably past the total duration to absorb the
|
||||
// ManualDuration → Virtual-clock clamp + the despawn lag of
|
||||
// one extra tick.
|
||||
let _ = advance_by(&mut app, MOTION_SPLASH_TOTAL_SECS + 0.5);
|
||||
assert_eq!(
|
||||
count_splash_roots(&mut app),
|
||||
0,
|
||||
"SplashRoot must be despawned after MOTION_SPLASH_TOTAL_SECS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splash_alpha_curves_through_fade_hold_fade() {
|
||||
// Pure-function test on the curve so we don't need to wrangle
|
||||
// the virtual-clock clamp here. The integration assertion below
|
||||
// (`splash_dismisses_immediately_on_keypress`) covers the
|
||||
// wired-up version.
|
||||
// Start of fade-in.
|
||||
assert!(
|
||||
splash_alpha(Duration::ZERO).unwrap() < 0.05,
|
||||
"alpha at t=0 must be near 0 (fade-in start)"
|
||||
);
|
||||
// End of fade-in.
|
||||
let after_fade_in = Duration::from_secs_f32(MOTION_SPLASH_FADE_SECS);
|
||||
assert!(
|
||||
(splash_alpha(after_fade_in).unwrap() - 1.0).abs() < 0.001,
|
||||
"alpha at end of fade-in must be ~1.0"
|
||||
);
|
||||
// Mid-hold.
|
||||
let mid_hold = Duration::from_secs_f32(MOTION_SPLASH_TOTAL_SECS / 2.0);
|
||||
assert!(
|
||||
(splash_alpha(mid_hold).unwrap() - 1.0).abs() < f32::EPSILON,
|
||||
"alpha mid-hold must be exactly 1.0"
|
||||
);
|
||||
// Inside fade-out.
|
||||
let mid_fade_out = Duration::from_secs_f32(
|
||||
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS / 2.0,
|
||||
);
|
||||
let mid_out_alpha = splash_alpha(mid_fade_out).unwrap();
|
||||
assert!(
|
||||
mid_out_alpha < 0.6 && mid_out_alpha > 0.4,
|
||||
"alpha mid-fade-out should be ~0.5, got {mid_out_alpha}"
|
||||
);
|
||||
// Past total.
|
||||
let past_total = Duration::from_secs_f32(MOTION_SPLASH_TOTAL_SECS + 0.1);
|
||||
assert!(
|
||||
splash_alpha(past_total).is_none(),
|
||||
"alpha past total duration must be None (signal: despawn)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splash_dismisses_immediately_on_keypress() {
|
||||
let mut app = headless_app();
|
||||
// Run one fast tick under the fade-in window so the splash is
|
||||
// unambiguously not yet in fade-out before the dismiss.
|
||||
set_manual_time_step(&mut app, 0.05);
|
||||
app.update();
|
||||
let pre_alpha = scrim_alpha(&mut app);
|
||||
assert!(
|
||||
pre_alpha < 1.0,
|
||||
"precondition: splash should be inside fade-in, not yet at full alpha (got {pre_alpha})"
|
||||
);
|
||||
|
||||
// Press any key. The dismissal system should bump the age into
|
||||
// the fade-out window on this tick.
|
||||
press_key(&mut app, KeyCode::Space);
|
||||
app.update();
|
||||
|
||||
// Either still alive in fade-out, or already despawned (the
|
||||
// 200 ms test-clock clamp can shave the fade-out window
|
||||
// depending on how many ticks `app.update()` has accrued).
|
||||
if count_splash_roots(&mut app) == 0 {
|
||||
return; // already past fade-out — that's fine.
|
||||
}
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SplashAge, With<SplashRoot>>();
|
||||
let age = q
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("splash should exist after one post-dismiss tick")
|
||||
.0;
|
||||
let fade_out_start = Duration::from_secs_f32(
|
||||
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS,
|
||||
);
|
||||
assert!(
|
||||
age >= fade_out_start,
|
||||
"after a keypress dismiss the splash must be in fade-out (age >= {fade_out_start:?}); got {age:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splash_dismisses_on_mouse_click() {
|
||||
let mut app = headless_app();
|
||||
set_manual_time_step(&mut app, 0.05);
|
||||
app.update();
|
||||
assert!(scrim_alpha(&mut app) < 1.0);
|
||||
|
||||
press_mouse(&mut app, MouseButton::Left);
|
||||
app.update();
|
||||
|
||||
if count_splash_roots(&mut app) == 0 {
|
||||
return;
|
||||
}
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SplashAge, With<SplashRoot>>();
|
||||
let age = q
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("splash should exist after one post-dismiss tick")
|
||||
.0;
|
||||
let fade_out_start = Duration::from_secs_f32(
|
||||
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS,
|
||||
);
|
||||
assert!(
|
||||
age >= fade_out_start,
|
||||
"after a left-click dismiss the splash must be in fade-out; got {age:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bonus test: dismissing the splash with a keypress does NOT clear
|
||||
/// that key's `just_pressed` flag — downstream systems still see
|
||||
/// the keystroke that dismissed the splash. Important for parity
|
||||
/// with "no splash" behaviour where Space draws a card.
|
||||
#[test]
|
||||
fn dismissal_keypress_is_visible_to_other_systems() {
|
||||
let mut app = headless_app();
|
||||
press_key(&mut app, KeyCode::Space);
|
||||
app.update();
|
||||
let keys = app.world().resource::<ButtonInput<KeyCode>>();
|
||||
assert!(
|
||||
keys.just_pressed(KeyCode::Space),
|
||||
"Splash dismissal must NOT consume the input — downstream gameplay still needs it"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ use crate::ui_modal::{
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||
Z_MODAL_PANEL,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
||||
VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
};
|
||||
|
||||
/// Bevy resource wrapping the current stats.
|
||||
@@ -247,14 +247,19 @@ fn spawn_stats_screen(
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
// --- primary stat cells ---
|
||||
let win_rate_str = format_win_rate(stats);
|
||||
let played_str = format_stat_value(stats.games_played);
|
||||
let won_str = format_stat_value(stats.games_won);
|
||||
let lost_str = format_stat_value(stats.games_lost);
|
||||
let fastest_str = format_fastest_win(stats.fastest_win_seconds);
|
||||
let avg_time_str = format_avg_time(stats);
|
||||
let best_score_str = format_optional_u32(stats.best_single_score);
|
||||
let best_streak_str = format_stat_value(stats.win_streak_best);
|
||||
// First-launch zero-state: when no games have been played yet, render
|
||||
// every top-level cell as an em-dash so the panel doesn't read as a
|
||||
// mix of "0" counters and "—" sentinels (which feels buggy).
|
||||
let is_first_launch = stats.games_played == 0;
|
||||
let dash = "\u{2014}".to_string();
|
||||
let win_rate_str = if is_first_launch { dash.clone() } else { format_win_rate(stats) };
|
||||
let played_str = if is_first_launch { dash.clone() } else { format_stat_value(stats.games_played) };
|
||||
let won_str = if is_first_launch { dash.clone() } else { format_stat_value(stats.games_won) };
|
||||
let lost_str = if is_first_launch { dash.clone() } else { format_stat_value(stats.games_lost) };
|
||||
let fastest_str = if is_first_launch { dash.clone() } else { format_fastest_win(stats.fastest_win_seconds) };
|
||||
let avg_time_str = if is_first_launch { dash.clone() } else { format_avg_time(stats) };
|
||||
let best_score_str = if is_first_launch { dash.clone() } else { format_optional_u32(stats.best_single_score) };
|
||||
let best_streak_str = if is_first_launch { dash.clone() } else { format_stat_value(stats.win_streak_best) };
|
||||
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_section = TextFont {
|
||||
@@ -271,6 +276,27 @@ fn spawn_stats_screen(
|
||||
spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Statistics", font_res);
|
||||
|
||||
// First-launch caption — sits above the grid as gentle nudge so
|
||||
// the wall of em-dashes reads as "nothing to track yet" rather
|
||||
// than as broken state.
|
||||
if is_first_launch {
|
||||
card.spawn((
|
||||
Text::new("Play a game to start tracking stats."),
|
||||
TextFont {
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
Node {
|
||||
margin: UiRect {
|
||||
bottom: VAL_SPACE_2,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// --- primary stat cells grid ---
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@
|
||||
//! spawn_modal_button(
|
||||
//! actions,
|
||||
//! ConfirmButton,
|
||||
//! "Yes, abandon",
|
||||
//! "New game",
|
||||
//! Some("Y"),
|
||||
//! ButtonVariant::Primary,
|
||||
//! font_res,
|
||||
|
||||
@@ -94,6 +94,11 @@ pub const BORDER_SUBTLE: Color = Color::srgba(0.647, 0.549, 1.000, 0.12);
|
||||
/// Strong border — hover outline, focused button, active popover.
|
||||
pub const BORDER_STRONG: Color = Color::srgba(0.647, 0.549, 1.000, 0.30);
|
||||
|
||||
/// 2 px ring drawn around the focused interactive element. Balatro yellow
|
||||
/// (matches `ACCENT_PRIMARY`) at 85% alpha so the ring stays legible
|
||||
/// against both elevated surfaces and the modal scrim backdrop.
|
||||
pub const FOCUS_RING: Color = Color::srgba(1.0, 0.823, 0.247, 0.85);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typography scale (px) — 5 rungs replace the prior
|
||||
// 14/15/16/17/18/22/26/28/30/32/40/48 jungle. All UI uses FiraMono via
|
||||
@@ -187,6 +192,11 @@ pub const Z_PAUSE: i32 = 220;
|
||||
/// `Z_PAUSE` so the dialog is always visible over the paused state.
|
||||
pub const Z_PAUSE_DIALOG: i32 = 225;
|
||||
pub const Z_ONBOARDING: i32 = 230;
|
||||
/// Z-layer for the keyboard focus indicator. Sits one rung above the
|
||||
/// topmost modal layer (`Z_ONBOARDING`) so the ring is never occluded by
|
||||
/// a modal card's hover state, while staying below the win cascade and
|
||||
/// transient toasts that are allowed to overlay everything else.
|
||||
pub const Z_FOCUS_RING: i32 = 240;
|
||||
/// Win cascade sits between modals and toasts so the celebration plays
|
||||
/// over a paused / mid-modal screen.
|
||||
pub const Z_WIN_CASCADE: i32 = 300;
|
||||
@@ -265,6 +275,45 @@ pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25;
|
||||
/// 400 ms.
|
||||
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
|
||||
|
||||
/// Hover delay before a tooltip appears, in seconds. Long enough that
|
||||
/// players gliding the cursor across the HUD don't see flicker; short
|
||||
/// enough that "stop and read" feels responsive. Not run through
|
||||
/// [`scaled_duration`] — `AnimSpeed` controls gameplay motion, not the
|
||||
/// hover-discoverability budget for help text.
|
||||
pub const MOTION_TOOLTIP_DELAY_SECS: f32 = 0.5;
|
||||
|
||||
/// Total visible duration of the splash screen overlay, in seconds.
|
||||
/// Composed of a fade-in, a hold, and a fade-out — see
|
||||
/// [`MOTION_SPLASH_FADE_SECS`] for the per-edge fade budget. Not run
|
||||
/// through [`scaled_duration`]: the splash is a one-shot brand beat at
|
||||
/// app start, not gameplay motion that should track `AnimSpeed`.
|
||||
pub const MOTION_SPLASH_TOTAL_SECS: f32 = 1.6;
|
||||
|
||||
/// Fade-in and fade-out duration of the splash overlay, in seconds.
|
||||
/// The hold time is `MOTION_SPLASH_TOTAL_SECS - 2 * MOTION_SPLASH_FADE_SECS`.
|
||||
/// Mirroring fade-in and fade-out keeps the curve symmetric so the brand
|
||||
/// beat reads as a single dissolve instead of two separate animations.
|
||||
pub const MOTION_SPLASH_FADE_SECS: f32 = 0.3;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Z-index — tooltip layer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Z-layer for tooltips. Sits one rung above the focus ring so a
|
||||
/// tooltip rendered over a focused button is never occluded by the
|
||||
/// button's outline. Still below `Z_WIN_CASCADE` and `Z_TOAST` so the
|
||||
/// celebration and notification layers stay on top.
|
||||
pub const Z_TOOLTIP: i32 = Z_FOCUS_RING + 10;
|
||||
|
||||
/// Z-layer for the launch splash overlay. The splash owns the entire
|
||||
/// viewport for ~1.6 s before fading out, so it sits above every other
|
||||
/// UI rung — including `Z_TOAST` — to guarantee the brand beat is
|
||||
/// never occluded by a stray toast or tooltip. Neither toasts nor the
|
||||
/// win cascade can fire during the splash window in practice (no game
|
||||
/// has run yet, no toast queue has dispatched), but the relative order
|
||||
/// is kept tidy in case a future feature schedules either at startup.
|
||||
pub const Z_SPLASH: i32 = Z_TOAST + 100;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -331,8 +380,11 @@ mod tests {
|
||||
Z_PAUSE,
|
||||
Z_PAUSE_DIALOG,
|
||||
Z_ONBOARDING,
|
||||
Z_FOCUS_RING,
|
||||
Z_TOOLTIP,
|
||||
Z_WIN_CASCADE,
|
||||
Z_TOAST,
|
||||
Z_SPLASH,
|
||||
];
|
||||
for window in layers.windows(2) {
|
||||
assert!(
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
//! Hover-tooltip infrastructure. Adds a one-shot, design-token-styled
|
||||
//! popover that appears over any UI element carrying a [`Tooltip`]
|
||||
//! component once the cursor has lingered for
|
||||
//! [`crate::ui_theme::MOTION_TOOLTIP_DELAY_SECS`] seconds.
|
||||
//!
|
||||
//! ## Why a sibling overlay
|
||||
//!
|
||||
//! Like [`crate::ui_focus`], this module uses a single absolute-positioned
|
||||
//! overlay entity that is never a descendant of any modal or HUD card. On
|
||||
//! every frame, [`show_or_hide_tooltip`] reads the hovered target's
|
||||
//! [`bevy::ui::UiGlobalTransform`] + [`bevy::ui::ComputedNode`] and writes
|
||||
//! an absolute `Node.left` / `Node.top` so the overlay tracks the target
|
||||
//! without inheriting modal scale-in or scroll-clipping. The pattern
|
||||
//! mirrors [`crate::ui_focus::update_focus_overlay`] one-for-one.
|
||||
//!
|
||||
//! ## Public surface
|
||||
//!
|
||||
//! - [`Tooltip`] — component carrying the hover text. Add it to any
|
||||
//! interactive node and the rest is automatic.
|
||||
//! - [`UiTooltipPlugin`] — registers the resource, startup spawn, and the
|
||||
//! per-frame tracking + display systems.
|
||||
//!
|
||||
//! ## Scope
|
||||
//!
|
||||
//! Phase 1 of the tooltip rollout — *infrastructure only*. No HUD or
|
||||
//! Settings entity carries [`Tooltip`] yet; a follow-up commit applies
|
||||
//! tooltips to specific readouts and buttons. Treat this module as the
|
||||
//! library half of the feature.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::time::Duration;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::ui_theme::{
|
||||
BG_ELEVATED_HI, BORDER_SUBTLE, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM, TEXT_PRIMARY,
|
||||
TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public component / plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker on a UI element that should display a tooltip when the cursor
|
||||
/// hovers over it. The component carries the tooltip text — typically a
|
||||
/// short caption explaining what the element does or what its number
|
||||
/// represents.
|
||||
///
|
||||
/// Bevy UI hover detection requires the [`Interaction`] component (the
|
||||
/// picking system writes `Interaction::Hovered` only on entities that
|
||||
/// have it), so [`Tooltip`] declares it as a required component. Adding
|
||||
/// `Tooltip` to a node automatically inserts a default [`Interaction`].
|
||||
///
|
||||
/// The owning entity must also be a UI [`Node`] for picking to pick it
|
||||
/// up; that's a layout concern handled at the call site. Every interactive
|
||||
/// HUD readout and modal button in this codebase already carries `Node`,
|
||||
/// so in practice callers just attach `Tooltip::new("…")` and move on.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// use solitaire_engine::ui_tooltip::Tooltip;
|
||||
///
|
||||
/// commands.spawn((
|
||||
/// Node { /* ... */ ..default() },
|
||||
/// Tooltip::new("Cards left in the stock"),
|
||||
/// ));
|
||||
/// ```
|
||||
#[derive(Component, Debug, Clone)]
|
||||
#[require(Interaction)]
|
||||
pub struct Tooltip(pub Cow<'static, str>);
|
||||
|
||||
impl Tooltip {
|
||||
/// Builds a [`Tooltip`] from any string-like value. Prefer passing a
|
||||
/// `&'static str` for static labels — the underlying `Cow` keeps the
|
||||
/// allocation-free path open for the common case while still
|
||||
/// accepting owned `String`s for runtime-formatted text.
|
||||
pub fn new(text: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self(text.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the tooltip overlay and the systems that drive it. Add this
|
||||
/// plugin once, immediately after [`crate::ui_focus::UiFocusPlugin`], and
|
||||
/// every entity carrying a [`Tooltip`] component gains hover-to-reveal
|
||||
/// behaviour with no per-plugin wiring.
|
||||
pub struct UiTooltipPlugin;
|
||||
|
||||
impl Plugin for UiTooltipPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<TooltipState>()
|
||||
.add_systems(Startup, spawn_tooltip_overlay)
|
||||
.add_systems(
|
||||
Update,
|
||||
(track_tooltip_hover, show_or_hide_tooltip).chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private resource + markers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Internal state for the singleton tooltip overlay. Tracks which
|
||||
/// [`Tooltip`]-bearing entity the cursor is currently hovering and the
|
||||
/// `Time::elapsed()` timestamp at which the hover started, so the display
|
||||
/// system can fire only once the dwell threshold has elapsed.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
struct TooltipState {
|
||||
/// `(target_entity, hover_started_at)` — populated by
|
||||
/// [`track_tooltip_hover`] when an entity transitions to
|
||||
/// [`Interaction::Hovered`], cleared when the cursor leaves.
|
||||
hovered: Option<(Entity, Duration)>,
|
||||
/// The singleton overlay entity, populated by
|
||||
/// [`spawn_tooltip_overlay`] on Startup. Read by
|
||||
/// [`show_or_hide_tooltip`] to skip a `single_mut` query.
|
||||
overlay: Option<Entity>,
|
||||
}
|
||||
|
||||
/// Marker on the singleton tooltip-overlay container.
|
||||
#[derive(Component, Debug)]
|
||||
struct TooltipOverlay;
|
||||
|
||||
/// Marker on the overlay's [`Text`] child, so the display system can
|
||||
/// rewrite the tooltip string without despawning the whole overlay.
|
||||
#[derive(Component, Debug)]
|
||||
struct TooltipText;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tunables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Vertical gap between the target and the tooltip overlay, in logical
|
||||
/// pixels. Small enough to read as "attached"; big enough to clear the
|
||||
/// target's own border.
|
||||
const TOOLTIP_GAP_PX: f32 = 4.0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawns the singleton tooltip-overlay entity at Startup. Hidden until a
|
||||
/// [`Tooltip`]-bearing target is hovered for [`MOTION_TOOLTIP_DELAY_SECS`]
|
||||
/// seconds, then repositioned and revealed by [`show_or_hide_tooltip`].
|
||||
fn spawn_tooltip_overlay(
|
||||
mut commands: Commands,
|
||||
mut state: ResMut<TooltipState>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
|
||||
let overlay = commands
|
||||
.spawn((
|
||||
TooltipOverlay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
// Auto width/height so the overlay tracks its text content.
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED_HI),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
Visibility::Hidden,
|
||||
// Pin above the focus ring so a tooltip on a focused element
|
||||
// is never occluded by the focus outline.
|
||||
GlobalZIndex(Z_TOOLTIP),
|
||||
))
|
||||
.with_children(|root| {
|
||||
root.spawn((
|
||||
TooltipText,
|
||||
Text::new(String::new()),
|
||||
font,
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
})
|
||||
.id();
|
||||
|
||||
state.overlay = Some(overlay);
|
||||
}
|
||||
|
||||
/// Watches every interactive entity for `Changed<Interaction>` and
|
||||
/// updates [`TooltipState::hovered`] accordingly:
|
||||
///
|
||||
/// * Hovering a [`Tooltip`]-bearing entity records the start time so the
|
||||
/// display system can apply the dwell delay.
|
||||
/// * Leaving the currently-hovered entity (transition away from
|
||||
/// `Hovered`) clears the state so the overlay hides on the next tick.
|
||||
///
|
||||
/// Hovering a different `Tooltip` entity simply replaces the prior
|
||||
/// `(entity, t0)` pair — the dwell timer restarts, matching native
|
||||
/// tooltip behaviour where moving across multiple targets resets the
|
||||
/// reveal delay.
|
||||
fn track_tooltip_hover(
|
||||
time: Res<Time>,
|
||||
interactions: Query<
|
||||
(Entity, &Interaction, Option<&Tooltip>),
|
||||
Changed<Interaction>,
|
||||
>,
|
||||
mut state: ResMut<TooltipState>,
|
||||
) {
|
||||
for (entity, interaction, tooltip) in &interactions {
|
||||
match interaction {
|
||||
Interaction::Hovered => {
|
||||
if tooltip.is_some() {
|
||||
// Record the hover start. If the same entity is
|
||||
// already recorded, leave the original timestamp so
|
||||
// a re-emitted Hovered (e.g. pointer wiggle) doesn't
|
||||
// reset the dwell timer.
|
||||
let already = matches!(state.hovered, Some((e, _)) if e == entity);
|
||||
if !already {
|
||||
state.hovered = Some((entity, time.elapsed()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Interaction::Pressed | Interaction::None => {
|
||||
// Clear iff this is the entity we were tracking. Other
|
||||
// changed-interaction events on unrelated entities must
|
||||
// not blow away an in-flight hover.
|
||||
if matches!(state.hovered, Some((e, _)) if e == entity) {
|
||||
state.hovered = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame display driver. Reads [`TooltipState::hovered`] and:
|
||||
///
|
||||
/// * If `None`, hides the overlay.
|
||||
/// * If `Some((entity, t0))` and `time.elapsed() - t0 < delay`, hides the
|
||||
/// overlay (still in the dwell window).
|
||||
/// * If `Some((entity, t0))` and the dwell has elapsed, copies the
|
||||
/// target's [`Tooltip`] string into the overlay's [`TooltipText`] child,
|
||||
/// positions the overlay above the target (or below, if above would
|
||||
/// clip the screen top), and reveals it.
|
||||
///
|
||||
/// Positioning math mirrors
|
||||
/// [`crate::ui_focus::update_focus_overlay`]: `ComputedNode.size` and
|
||||
/// `UiGlobalTransform.translation` are converted from physical to
|
||||
/// logical pixels via `inverse_scale_factor` before being written into
|
||||
/// `Val::Px` slots on the overlay's `Node`. Headless tests run under
|
||||
/// `MinimalPlugins` and don't execute the layout schedule, so
|
||||
/// `ComputedNode` is `Vec2::ZERO` there — the test asserts the
|
||||
/// visibility-and-text invariant rather than position.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn show_or_hide_tooltip(
|
||||
time: Res<Time>,
|
||||
state: Res<TooltipState>,
|
||||
tooltips: Query<(&Tooltip, &UiGlobalTransform, &ComputedNode)>,
|
||||
tooltip_text_only: Query<&Tooltip>,
|
||||
mut overlay_q: Query<(&mut Node, &mut Visibility, &Children), With<TooltipOverlay>>,
|
||||
mut text_q: Query<&mut Text, With<TooltipText>>,
|
||||
) {
|
||||
let Ok((mut node, mut visibility, children)) = overlay_q.single_mut() else {
|
||||
// Overlay not yet spawned — first frame before Startup ran, or a
|
||||
// test harness without Startup. Nothing to do.
|
||||
return;
|
||||
};
|
||||
|
||||
// Helper: hide the overlay if not already hidden.
|
||||
let hide = |visibility: &mut Visibility| {
|
||||
if !matches!(*visibility, Visibility::Hidden) {
|
||||
*visibility = Visibility::Hidden;
|
||||
}
|
||||
};
|
||||
|
||||
let Some((target, started_at)) = state.hovered else {
|
||||
hide(&mut visibility);
|
||||
return;
|
||||
};
|
||||
|
||||
let elapsed = time.elapsed().saturating_sub(started_at);
|
||||
let delay = Duration::from_secs_f32(MOTION_TOOLTIP_DELAY_SECS);
|
||||
if elapsed < delay {
|
||||
hide(&mut visibility);
|
||||
return;
|
||||
}
|
||||
|
||||
// Past the dwell threshold. Pull the target's tooltip text and write
|
||||
// it into the overlay's Text child. The wider query
|
||||
// (`UiGlobalTransform + ComputedNode`) may miss in headless tests
|
||||
// where layout doesn't run; fall back to the text-only query so test
|
||||
// assertions on visibility + text content still pass even when
|
||||
// positioning data is unavailable.
|
||||
let label: Option<Cow<'static, str>> = tooltips
|
||||
.get(target)
|
||||
.ok()
|
||||
.map(|(t, _, _)| t.0.clone())
|
||||
.or_else(|| tooltip_text_only.get(target).ok().map(|t| t.0.clone()));
|
||||
|
||||
let Some(text) = label else {
|
||||
// Target despawned or no longer carries Tooltip — hide and bail.
|
||||
// We don't write back to the resource here because it's `Res`,
|
||||
// not `ResMut`; `track_tooltip_hover` will clear it the next
|
||||
// frame the entity changes interaction.
|
||||
hide(&mut visibility);
|
||||
return;
|
||||
};
|
||||
|
||||
// Update the visible text. Skip the write if it already matches so
|
||||
// we don't churn the change-detection flag every frame.
|
||||
for child in children.iter() {
|
||||
if let Ok(mut t) = text_q.get_mut(child)
|
||||
&& t.0 != text
|
||||
{
|
||||
t.0 = text.clone().into_owned();
|
||||
}
|
||||
}
|
||||
|
||||
// Compute placement. ComputedNode.size is in physical pixels;
|
||||
// inverse_scale_factor multiplies physical → logical so the result
|
||||
// matches the Val::Px logical-pixel coordinate space every other
|
||||
// Node uses.
|
||||
if let Ok((_, transform, computed)) = tooltips.get(target) {
|
||||
let inv = computed.inverse_scale_factor;
|
||||
let size_logical = computed.size() * inv;
|
||||
let center_logical = transform.translation * inv;
|
||||
|
||||
// Default placement: above the target, centered horizontally.
|
||||
// Tooltip width isn't known until layout — use a small assumed
|
||||
// width via auto sizing; we centre on the target's centre and
|
||||
// let the overlay's auto Node width do the rest. For the X
|
||||
// coordinate we still need to anchor *something*: place the
|
||||
// overlay's left edge at the target's centre minus half of the
|
||||
// target's width, then rely on auto-Node sizing. That's a small
|
||||
// approximation; the follow-up phase that wires real entities
|
||||
// will measure overlay width via ComputedNode and re-centre.
|
||||
let half = size_logical * 0.5;
|
||||
|
||||
let left_above = center_logical.x - half.x;
|
||||
let top_above = center_logical.y - half.y - TOOLTIP_GAP_PX;
|
||||
// If the tooltip would render above the screen top (top < 0),
|
||||
// flip below the target. We don't know overlay height yet, so
|
||||
// use the target's bottom edge plus the gap.
|
||||
let (left, top) = if top_above < 0.0 {
|
||||
(left_above, center_logical.y + half.y + TOOLTIP_GAP_PX)
|
||||
} else {
|
||||
(left_above, top_above)
|
||||
};
|
||||
|
||||
node.left = Val::Px(left);
|
||||
node.top = Val::Px(top);
|
||||
}
|
||||
|
||||
if !matches!(*visibility, Visibility::Visible) {
|
||||
*visibility = Visibility::Visible;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
|
||||
/// Builds a headless `App` with `MinimalPlugins + UiTooltipPlugin`.
|
||||
/// Ticks once so the Startup spawn system has run and the singleton
|
||||
/// overlay exists in the world before the first asserting `update`.
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(UiTooltipPlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
/// Tells `TimePlugin` to advance the clock by `secs` on the next
|
||||
/// `app.update()`. Mirrors the helper in `ui_modal::tests` and
|
||||
/// `hud_plugin::tests`.
|
||||
fn set_manual_time_step(app: &mut App, secs: f32) {
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||
Duration::from_secs_f32(secs),
|
||||
));
|
||||
}
|
||||
|
||||
/// Reads the current overlay visibility. Panics if the singleton is
|
||||
/// missing — that would indicate a bug in `spawn_tooltip_overlay`.
|
||||
fn overlay_visibility(app: &mut App) -> Visibility {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Visibility, With<TooltipOverlay>>();
|
||||
*q.iter(app.world())
|
||||
.next()
|
||||
.expect("TooltipOverlay singleton should exist")
|
||||
}
|
||||
|
||||
/// Reads the current tooltip text content from the overlay's Text
|
||||
/// child.
|
||||
fn overlay_text(app: &mut App) -> String {
|
||||
let mut q = app.world_mut().query_filtered::<&Text, With<TooltipText>>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.expect("TooltipText child should exist")
|
||||
.0
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Spawns a synthetic interactive node with a `Tooltip` component,
|
||||
/// pre-set to `Interaction::Hovered`. The picking pipeline doesn't
|
||||
/// run under `MinimalPlugins`, so we write `Hovered` directly.
|
||||
fn spawn_hovered_tooltip(app: &mut App, label: &'static str) -> Entity {
|
||||
let id = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Node::default(),
|
||||
Interaction::Hovered,
|
||||
Tooltip::new(label),
|
||||
))
|
||||
.id();
|
||||
// Mark the Interaction Changed by re-inserting it. `Changed`
|
||||
// requires component mutation since the previous tick; spawn
|
||||
// already counts, but a follow-up insert is the explicit signal.
|
||||
app.world_mut()
|
||||
.entity_mut(id)
|
||||
.insert(Interaction::Hovered);
|
||||
id
|
||||
}
|
||||
|
||||
/// Test 1: nothing is shown before the dwell delay elapses.
|
||||
#[test]
|
||||
fn tooltip_does_not_show_before_delay() {
|
||||
let mut app = headless_app();
|
||||
// Manual step well under the dwell delay. A handful of ticks
|
||||
// accumulates to far less than `MOTION_TOOLTIP_DELAY_SECS` so
|
||||
// the overlay must stay hidden the whole time.
|
||||
set_manual_time_step(&mut app, MOTION_TOOLTIP_DELAY_SECS * 0.1);
|
||||
|
||||
spawn_hovered_tooltip(&mut app, "Test");
|
||||
// Two ticks: track_tooltip_hover records the hover start on
|
||||
// tick #1; show_or_hide_tooltip on tick #2 sees a non-zero but
|
||||
// sub-threshold elapsed. Both must keep the overlay hidden.
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
matches!(overlay_visibility(&mut app), Visibility::Hidden),
|
||||
"overlay must stay hidden before MOTION_TOOLTIP_DELAY_SECS elapses"
|
||||
);
|
||||
}
|
||||
|
||||
/// Advances Bevy's virtual clock far enough that any
|
||||
/// `Time::elapsed()` reader observes more than
|
||||
/// `MOTION_TOOLTIP_DELAY_SECS` of progress since the last
|
||||
/// `track_tooltip_hover` recorded a hover start.
|
||||
///
|
||||
/// `Time<Virtual>` clamps each tick's delta to `max_delta`
|
||||
/// (default 250 ms) regardless of how big the underlying
|
||||
/// `TimeUpdateStrategy::ManualDuration` is, so a single oversized
|
||||
/// step doesn't actually advance virtual time by that much. We
|
||||
/// instead set a small per-tick step (200 ms — well under the
|
||||
/// 250 ms clamp) and call `app.update()` enough times to exceed
|
||||
/// the dwell threshold by a comfortable margin.
|
||||
fn advance_past_tooltip_delay(app: &mut App) {
|
||||
set_manual_time_step(app, 0.2);
|
||||
// 5 ticks × 200 ms = 1.0 s — comfortably past the 0.5 s delay
|
||||
// even after subtracting the first tick (when the hover gets
|
||||
// recorded; that tick's elapsed-since-hover is zero).
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
}
|
||||
|
||||
/// Test 2: after the dwell delay, the overlay reveals and the
|
||||
/// tooltip text matches the hovered entity's `Tooltip` string.
|
||||
/// Position is intentionally not asserted: layout doesn't run under
|
||||
/// `MinimalPlugins`, so `ComputedNode.size` is `Vec2::ZERO`. The
|
||||
/// invariants we *can* check headlessly are visibility and text.
|
||||
#[test]
|
||||
fn tooltip_shows_after_delay() {
|
||||
let mut app = headless_app();
|
||||
spawn_hovered_tooltip(&mut app, "Test");
|
||||
advance_past_tooltip_delay(&mut app);
|
||||
|
||||
assert!(
|
||||
matches!(overlay_visibility(&mut app), Visibility::Visible),
|
||||
"overlay must be visible after the dwell delay"
|
||||
);
|
||||
assert_eq!(
|
||||
overlay_text(&mut app),
|
||||
"Test",
|
||||
"overlay text must reflect the hovered entity's Tooltip string"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 3: after the tooltip is shown, transitioning the target's
|
||||
/// `Interaction` away from `Hovered` hides the overlay on the next
|
||||
/// tick.
|
||||
#[test]
|
||||
fn tooltip_hides_on_unhover() {
|
||||
let mut app = headless_app();
|
||||
let target = spawn_hovered_tooltip(&mut app, "Test");
|
||||
advance_past_tooltip_delay(&mut app);
|
||||
assert!(
|
||||
matches!(overlay_visibility(&mut app), Visibility::Visible),
|
||||
"precondition: tooltip should be visible before un-hover"
|
||||
);
|
||||
|
||||
// Unhover. `track_tooltip_hover` clears the state on the next
|
||||
// tick because the entity transitions Hovered → None.
|
||||
app.world_mut()
|
||||
.entity_mut(target)
|
||||
.insert(Interaction::None);
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
matches!(overlay_visibility(&mut app), Visibility::Hidden),
|
||||
"overlay must hide once the target is no longer hovered"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 4: when the cursor switches from one tooltip entity to
|
||||
/// another with different text, the overlay's text updates to match
|
||||
/// the new target's string after the dwell delay.
|
||||
#[test]
|
||||
fn tooltip_text_updates_when_hovered_target_changes() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// Phase A: hover entity A and let its tooltip appear.
|
||||
let a = spawn_hovered_tooltip(&mut app, "A label");
|
||||
advance_past_tooltip_delay(&mut app);
|
||||
assert_eq!(overlay_text(&mut app), "A label");
|
||||
|
||||
// Phase B: unhover A, hover B with a different label. Then
|
||||
// advance time past the dwell delay again so B's tooltip can
|
||||
// take over the overlay.
|
||||
app.world_mut().entity_mut(a).insert(Interaction::None);
|
||||
let _b = spawn_hovered_tooltip(&mut app, "B label");
|
||||
advance_past_tooltip_delay(&mut app);
|
||||
|
||||
assert!(
|
||||
matches!(overlay_visibility(&mut app), Visibility::Visible),
|
||||
"B's tooltip must be visible after switching hover"
|
||||
);
|
||||
assert_eq!(
|
||||
overlay_text(&mut app),
|
||||
"B label",
|
||||
"overlay text must update to the new hovered entity's Tooltip string"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user