Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d891fb76 | |||
| 7dba772e67 | |||
| ca5788f714 | |||
| 9887343d8b | |||
| 525fe0fe76 | |||
| 69ce9afab9 | |||
| 13aa0fd833 | |||
| 9f095c4039 | |||
| d8c70341f4 |
+231
@@ -0,0 +1,231 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Solitaire Quest are documented here. The format is
|
||||||
|
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
||||||
|
project follows [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
_Nothing yet._
|
||||||
|
|
||||||
|
## [0.12.0] — 2026-05-02
|
||||||
|
|
||||||
|
UX feel polish round on top of v0.11.0. Six small-but-tangible
|
||||||
|
improvements that make the play surface feel more responsive,
|
||||||
|
forgiving, and discoverable, plus the doc refresh that should have
|
||||||
|
ridden along with v0.11.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Foundation completion flourish.** When a King lands on a
|
||||||
|
foundation (Ace-through-King for that suit), a brief celebration
|
||||||
|
fires: King card scale-pulses 1.0 → 1.15 → 1.0 over 0.4 s, the
|
||||||
|
foundation marker tints `STATE_SUCCESS` for the first half then
|
||||||
|
fades, and a synthesised C6→E6→G6 bell ping plays (~240 ms,
|
||||||
|
octave above `win_fanfare`'s root so the fourth completion + win
|
||||||
|
cascade layer cleanly). New `FoundationCompletedEvent { slot,
|
||||||
|
suit }` carries the trigger so future systems can hook in.
|
||||||
|
- **Drag-cancel return tween.** Illegal drops glide each dragged
|
||||||
|
card back to its origin slot over 150 ms with a quintic ease-out
|
||||||
|
curve (`MotionCurve::Responsive`, zero overshoot — reads forgiving
|
||||||
|
rather than jittery). The audio cue (`card_invalid.wav`) still
|
||||||
|
fires for negative feedback. Right-click and double-click invalid
|
||||||
|
paths still use `ShakeAnim` since there's no motion to interpolate.
|
||||||
|
- **Focus ring breathing.** The keyboard focus ring's alpha modulates
|
||||||
|
with a 1.4 s sin curve over [0.65, 1.0] of its native value so the
|
||||||
|
indicator catches the eye on focus changes without competing with
|
||||||
|
gameplay. Honours `AnimSpeed::Instant` by reverting to the static
|
||||||
|
outline for reduced-motion users.
|
||||||
|
- **First-win achievement onboarding toast.** After the player's
|
||||||
|
very first win, a one-shot info toast surfaces "First win! Press
|
||||||
|
A to see your achievements." `Settings.shown_achievement_onboarding`
|
||||||
|
persists the seen state so the cue never re-fires (legacy
|
||||||
|
`settings.json` files load to `false` via `#[serde(default)]`).
|
||||||
|
- **Mode Launcher digit shortcuts.** Pressing M opens the Home modal
|
||||||
|
(the Mode Launcher); inside it, pressing 1–5 launches each mode
|
||||||
|
directly without needing Tab + Enter. Locked modes (Zen, Challenge,
|
||||||
|
Time Attack at level < 5) are silent no-ops. Modal-scoped — digit
|
||||||
|
keys outside the launcher fire nothing.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Card aspect ratio matches hayeah SVGs.** `CARD_ASPECT` 1.4 →
|
||||||
|
1.4523 to match the bundled artwork's natural 167.087 × 242.667
|
||||||
|
dimensions. Cards previously rendered ~3.6 % vertically squashed.
|
||||||
|
The vertical-budget math in `compute_layout` uses `CARD_ASPECT`
|
||||||
|
algebraically so the worst-case-tableau-fits-on-screen guarantee
|
||||||
|
adapts automatically.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **README refresh** with v0.11.0+ features (card themes, HUD
|
||||||
|
overhaul, drag feel, unlocked foundations) and a corrected controls
|
||||||
|
table — the previous table inverted Z/U for undo and listed H for
|
||||||
|
help when F1 is the binding.
|
||||||
|
- **CHANGELOG.md** added (this file), covering v0.9.0–v0.12.0 with
|
||||||
|
Keep a Changelog 1.1.0 conventions.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1007 passing tests (was 982 at v0.11.0).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [0.11.0] — 2026-05-02
|
||||||
|
|
||||||
|
The biggest release since 0.10.0. Headline threads: a runtime card-theme
|
||||||
|
system, an HUD restructure that reclaims the play surface, and a round of
|
||||||
|
UX feel polish surfaced by smoke testing.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Runtime card-theme system** (CARD_PLAN phases 1–7).
|
||||||
|
- Bundled default theme ships in the binary via `embedded://` — 52
|
||||||
|
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
|
||||||
|
SVGs (MIT) plus a midnight-purple `back.svg` as original work.
|
||||||
|
- User themes live under `themes://` rooted at `user_theme_dir()`. Drop
|
||||||
|
a directory containing `theme.ron` + 53 SVGs and the registry picks
|
||||||
|
it up on next launch.
|
||||||
|
- Importer at `solitaire_engine::theme::import_theme(zip)` validates
|
||||||
|
archives (20 MB cap, zip-slip rejection, manifest validation, every
|
||||||
|
SVG round-tripped through the rasteriser) and atomically unpacks.
|
||||||
|
- Picker UI in **Settings → Cosmetic**; selection persists as
|
||||||
|
`selected_theme_id` and propagates to live sprites.
|
||||||
|
- **Reserved HUD top band** (64 px) so cards no longer crowd the score
|
||||||
|
readout or action buttons; layout's `top_y` shifts down accordingly.
|
||||||
|
- **Action-bar auto-fade** — buttons fade out when the cursor leaves the
|
||||||
|
band, fade back in when it returns. Lerp at ~167 ms.
|
||||||
|
- **Visible drop-target overlay during drag** — a soft fill plus 3 px
|
||||||
|
outline drawn ABOVE stacked cards for every legal target (full fanned
|
||||||
|
column for tableaux, card-sized for foundations and empty tableaux).
|
||||||
|
Replaces the previously invisible pile-marker tint.
|
||||||
|
- **Card drop shadows** — every card casts a neutral 25 % black shadow
|
||||||
|
with a 4 px halo; cards in the active drag set switch to a lifted
|
||||||
|
shadow (40 % alpha, larger offset, bigger halo).
|
||||||
|
- **Stock remaining-count badge** — small `·N` chip at the top-right of
|
||||||
|
the stock pile so the player can see how close they are to a recycle.
|
||||||
|
Hides when the stock empties.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Foundations are unlocked.** `PileType::Foundation(Suit)` →
|
||||||
|
`Foundation(u8)` (slot 0..3). The claimed suit is derived from the
|
||||||
|
bottom card via `Pile::claimed_suit()` — no separate field, no
|
||||||
|
claim-stuck-after-undo bugs. Any Ace lands in any empty slot, and the
|
||||||
|
slot then claims that suit. `next_auto_complete_move` prefers a
|
||||||
|
claim-matched slot before falling back to the first empty slot for
|
||||||
|
Aces. Empty foundation markers render as plain placeholders (no
|
||||||
|
"C/D/H/S").
|
||||||
|
- **HUD selection label** and **hint toast** read `claimed_suit()` and
|
||||||
|
fall through to "Foundation N" / "move to foundation" only when the
|
||||||
|
slot is empty.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`shared_fontdb` now bundles FiraMono.** The hayeah SVGs reference
|
||||||
|
`Bitstream Vera Sans` and `Arial` by name. On minimal Linux installs
|
||||||
|
/ fresh Wayland sessions / chroots where neither is installed AND the
|
||||||
|
CSS-generic aliases don't resolve, card rank/suit text vanished. The
|
||||||
|
bundled font is loaded into fontdb and pinned as every CSS generic's
|
||||||
|
target so the resolver always lands on something real. Surfaced when
|
||||||
|
a second-machine pull rendered cards without glyphs.
|
||||||
|
- **Theme asset path resolution** — `AssetPath::resolve` (concatenates)
|
||||||
|
→ `resolve_embed` (RFC 1808 sibling resolution). Was producing paths
|
||||||
|
like `…/theme.ron/hearts_4.svg` and failing to load every face SVG.
|
||||||
|
- **Sync exit log spam** — `push_on_exit` silently no-ops on
|
||||||
|
`LocalOnlyProvider`'s `UnsupportedPlatform` instead of warn-spamming
|
||||||
|
every shutdown.
|
||||||
|
- **usvg font-substitution warn spam** — custom `FontResolver.select_font`
|
||||||
|
appends `Family::SansSerif` and `Family::Serif` to every query so
|
||||||
|
unmatched named families silently fall through.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- **In-progress saves invalidated.** `GameState.schema_version` bumped
|
||||||
|
1 → 2; pre-v2 `game_state.json` files silently fall through to "fresh
|
||||||
|
game on launch." Stats, progress, achievements, and settings live in
|
||||||
|
separate files and are unaffected.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 982 passing tests (was 819 at v0.10.0).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [0.10.0] — 2026-04-29
|
||||||
|
|
||||||
|
PNG art pipeline plus a major dependency pass. The first release where
|
||||||
|
the binary shipped with bundled artwork.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **52 individual card face PNGs** generated via `solitaire_assetgen`.
|
||||||
|
- **Custom font** (FiraMono-Medium) loaded via `AssetServer` at startup
|
||||||
|
through the new `FontPlugin`.
|
||||||
|
- **Card backs and backgrounds** upgraded to 120×168 with richer
|
||||||
|
patterns.
|
||||||
|
- **Ambient audio loop** wired through the kira mixer.
|
||||||
|
- **Arch Linux PKGBUILDs** for the game client and sync server (under
|
||||||
|
the separate `solitaire-quest-pkgbuild` directory).
|
||||||
|
- **Workspace README, CI workflow, migration guide.**
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Bevy 0.15 → 0.18** workspace migration.
|
||||||
|
- **kira 0.9 → 0.12** audio backend migration.
|
||||||
|
- **Edition 2024**, MSRV pinned to **Rust 1.95**.
|
||||||
|
- **rand 0.9** upgrade.
|
||||||
|
- **Card rendering** moved from `Text2d` overlay to PNG-backed
|
||||||
|
`Sprite` with face/back atlases; `Text2d` retained as a headless
|
||||||
|
fallback when `CardImageSet` is absent (tests under MinimalPlugins).
|
||||||
|
- **Asset pipeline** switched from `include_bytes!()` for PNGs/TTFs to
|
||||||
|
runtime `AssetServer::load()` so artwork can be swapped without a
|
||||||
|
recompile. Audio remains embedded.
|
||||||
|
- **Removed Google Play Games Services sync backend** — redundant with
|
||||||
|
the self-hosted server.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Server JWT secret** loaded at startup (was lazy, surfaced as
|
||||||
|
intermittent 500s).
|
||||||
|
- **Daily-challenge race** in the server's seed-generation path.
|
||||||
|
- **Rate limiter** switched to `SmartIpKeyExtractor` so the limit
|
||||||
|
applies per real client IP rather than per upstream proxy.
|
||||||
|
- **Touch input** uses `MessageReader<TouchInput>` (Bevy 0.18 rename).
|
||||||
|
- **Sync push/pull races** in async task scheduling.
|
||||||
|
- **Hot-path allocations** reduced in card-rendering systems.
|
||||||
|
- **Conflict report coverage** added for sync merge edge cases.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 819 passing tests at tag time.
|
||||||
|
|
||||||
|
## [0.9.0] — 2026-04-28
|
||||||
|
|
||||||
|
Initial public-tagged release. Established the workspace structure
|
||||||
|
(`solitaire_core` / `_sync` / `_data` / `_engine` / `_server` / `_app` /
|
||||||
|
`_assetgen`), the modal scaffold via `ui_modal`, the design-token system
|
||||||
|
in `ui_theme`, and the four-tier HUD layout. Foundations were
|
||||||
|
suit-locked at this point; cards rendered as `Text2d` rank/suit overlays
|
||||||
|
with no PNG artwork yet.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Klondike core (Draw One / Draw Three modes).
|
||||||
|
- Progression system (XP, levels, 18 achievements, daily challenge,
|
||||||
|
weekly goals, special modes at level 5).
|
||||||
|
- Self-hosted sync server (Axum + SQLite + JWT auth).
|
||||||
|
- All 12 overlay screens migrated to the `ui_modal` scaffold with real
|
||||||
|
Primary/Secondary/Tertiary buttons.
|
||||||
|
- Animation upgrades: `SmoothSnap` slide curves, scoped settle bounce,
|
||||||
|
deal jitter, win-cascade rotation.
|
||||||
|
- Splash screen, focus rings (Phases 1–3), tooltips infrastructure +
|
||||||
|
HUD/Settings/popover applications, achievement integration tests,
|
||||||
|
destructive-confirm verb unification, leaderboard error/idle states,
|
||||||
|
first-launch empty-state polish, hit-target accessibility fix,
|
||||||
|
CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
|
||||||
|
client-side sync round-trip integration tests.
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...HEAD
|
||||||
|
[0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0
|
||||||
|
[0.11.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.10.0...v0.11.0
|
||||||
|
[0.10.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.9.0...v0.10.0
|
||||||
|
[0.9.0]: https://github.com/funman300/Rusty_Solitaire/releases/tag/v0.9.0
|
||||||
@@ -1,17 +1,35 @@
|
|||||||
# Solitaire Quest
|
# Solitaire Quest
|
||||||
|
|
||||||
A cross-platform Klondike Solitaire game written in Rust, featuring a full progression system with XP, levels, achievements, daily challenges, and optional self-hosted sync so your stats follow you across machines.
|
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
|
||||||
|
system, full progression (XP / levels / achievements / daily challenges), and
|
||||||
|
optional self-hosted sync so your stats follow you across machines.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Klondike Solitaire** — Draw One and Draw Three modes
|
- **Klondike Solitaire** — Draw One and Draw Three modes; foundations are
|
||||||
|
unlocked (any Ace lands in any empty slot, the slot then claims that suit)
|
||||||
|
- **Card themes** — bundled hayeah/playing-cards-assets default plus
|
||||||
|
user-installable themes (drop a directory under the data dir or import a
|
||||||
|
zip from Settings → Cosmetic)
|
||||||
|
- **Modern HUD** — reserved top band keeps cards from crowding the score
|
||||||
|
readout; the action bar auto-fades when the cursor leaves it so it can't
|
||||||
|
compete with the play surface
|
||||||
|
- **Drag feel** — every legal drop target is highlighted in green during
|
||||||
|
drag; cards cast a soft drop shadow that lifts when picked up; the stock
|
||||||
|
pile shows a remaining-count chip so you can see how close you are to a
|
||||||
|
recycle
|
||||||
|
- **Keyboard navigation** — Tab cycles focus through buttons, arrow keys
|
||||||
|
move within picker rows, Enter activates; works across every modal and
|
||||||
|
the HUD action bar
|
||||||
- **Progression** — XP, levels, unlockable card backs and backgrounds
|
- **Progression** — XP, levels, unlockable card backs and backgrounds
|
||||||
- **18 Achievements** — including secret ones
|
- **18 Achievements** — including secret ones
|
||||||
- **Daily Challenge** — server-seeded so every player worldwide gets the same deal
|
- **Daily Challenge** — server-seeded so every player worldwide gets the
|
||||||
|
same deal
|
||||||
- **Leaderboard** — opt-in, powered by your own self-hosted server
|
- **Leaderboard** — opt-in, powered by your own self-hosted server
|
||||||
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
|
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
|
||||||
- **Sync** — pull/push stats across devices via a self-hosted server
|
- **Sync** — pull/push stats across devices via a self-hosted server
|
||||||
- **Color-blind mode** — blue tint on red-suit cards
|
- **Color-blind mode** — blue tint on red-suit cards alongside the suit
|
||||||
|
glyph
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -32,52 +50,73 @@ cargo build -p solitaire_app --release
|
|||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
|
Every action also has a visible UI button — keyboard shortcuts are optional
|
||||||
|
accelerators.
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Left click / drag | Move cards |
|
| Left click / drag | Move cards |
|
||||||
|
| Double click | Auto-move card to its best legal destination |
|
||||||
| Right click | Highlight legal moves for a card |
|
| Right click | Highlight legal moves for a card |
|
||||||
| Space / D | Draw from stock |
|
| Space / D | Draw from stock |
|
||||||
| Z / Ctrl+Z | Undo |
|
| U | Undo |
|
||||||
|
| H | Hint (highlight a legal move) |
|
||||||
| N | New game |
|
| N | New game |
|
||||||
| S | Stats overlay |
|
| Z | Zen mode |
|
||||||
| A | Achievements overlay |
|
| G | Forfeit (during pause) |
|
||||||
| P | Profile overlay |
|
| Tab / Shift+Tab | Cycle keyboard focus |
|
||||||
| O | Settings |
|
| Enter | Activate focused button / auto-complete (when badge is lit) |
|
||||||
| L | Leaderboard |
|
| Esc | Pause / dismiss modal |
|
||||||
| H | Help / controls |
|
| F1 | Help / controls |
|
||||||
| Enter | Auto-complete (when badge is lit) |
|
| F11 | Toggle fullscreen |
|
||||||
| Escape | Pause / clear selection |
|
| S / A / P / O / L / M | Stats / Achievements / Profile / Settings / Leaderboard / Menu |
|
||||||
| Arrow keys | Navigate card selection |
|
|
||||||
|
## Card themes
|
||||||
|
|
||||||
|
The default theme ships embedded in the binary, so the game runs
|
||||||
|
self-contained with no external assets. To install another theme, drop a
|
||||||
|
directory containing a `theme.ron` manifest plus 53 SVG files (52 faces +
|
||||||
|
1 back) under the platform data dir's `themes/` folder, or import a zip
|
||||||
|
from **Settings → Cosmetic**. The picker chip lights up the moment a new
|
||||||
|
theme is registered. Themes are SVG-based, so they rasterise cleanly at
|
||||||
|
whatever resolution the window happens to be.
|
||||||
|
|
||||||
## Sync Server (optional)
|
## Sync Server (optional)
|
||||||
|
|
||||||
To sync stats across machines, run the self-hosted server. See [README_SERVER.md](README_SERVER.md) for setup instructions.
|
To sync stats across machines, run the self-hosted server. See
|
||||||
|
[README_SERVER.md](README_SERVER.md) for setup instructions.
|
||||||
|
|
||||||
Once the server is running, open **Settings → Sync Backend**, enter the server URL and your username, and register an account from within the game.
|
Once the server is running, open **Settings → Sync Backend**, enter the
|
||||||
|
server URL and your username, and register an account from within the
|
||||||
|
game.
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All tests
|
# All tests (982 passing as of v0.11.0)
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
|
|
||||||
# Just game logic (no display required)
|
# Just game logic (no display required)
|
||||||
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
|
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
|
||||||
|
|
||||||
# Lint
|
# Lint
|
||||||
cargo clippy --workspace -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem (Tokio,
|
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
||||||
Axum, sqlx, Serde, kira, and many more). Card faces come from
|
(Tokio, Axum, sqlx, Serde, kira, and many more). Card faces come from
|
||||||
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
|
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
|
||||||
(MIT, derived from the public-domain `vector-playing-cards` library); the
|
(MIT, derived from the public-domain `vector-playing-cards` library); the
|
||||||
default card back is original work; the UI font is FiraMono-Medium (OFL).
|
default card back is original work; the UI font is FiraMono-Medium (OFL).
|
||||||
All audio is synthesized programmatically by this project. See
|
All audio is synthesized programmatically by this project. See
|
||||||
[CREDITS.md](CREDITS.md) for the full list and license details.
|
[CREDITS.md](CREDITS.md) for the full list and license details.
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
See [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|||||||
+61
-48
@@ -1,20 +1,20 @@
|
|||||||
# Solitaire Quest — UX Overhaul Session Handoff
|
# Solitaire Quest — UX Overhaul Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-05-02 (session 7) — UX iteration round complete: every item from session 6's UX punch list has shipped, plus a font-fallback fix surfaced by a second-machine smoke test. Six commits on top of session 6's `c4970b1`. Direction now opens for the next round — release prep or another UX pass, the player's call.
|
**Last updated:** 2026-05-02 (session 7, late) — Second UX iteration round complete. Six small UX feel items shipped on top of v0.11.0 and the README/CHANGELOG refresh that should have ridden along. Ready to tag v0.12.0.
|
||||||
|
|
||||||
## Status at pause
|
## Status at pause
|
||||||
|
|
||||||
- **HEAD:** `655dfde`. Local master is **3 commits ahead** of `origin/master` (`f6c9166`, `f712b89`, `655dfde` unpushed; `fdb6c2e` and `95df542` already pushed).
|
- **HEAD:** `7dba772` plus the impending CHANGELOG/handoff doc commits. Local master is **3 commits ahead** of `origin/master` (`9887343`, `ca5788f`, `7dba772` unpushed); doc commits to follow.
|
||||||
- **Working tree:** clean. (`CARD_PLAN.md` is untracked but intentionally so — it's a plan doc, not source.)
|
- **Working tree:** clean apart from this doc + CHANGELOG, both intentional.
|
||||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||||
- **Tests:** **982 passed / 0 failed** across the workspace (+20 from session 6's 962 baseline).
|
- **Tests:** **1007 passed / 0 failed** across the workspace (+25 from session 7 morning's 982 baseline).
|
||||||
- **Tags on origin:** `v0.9.0`, `v0.10.0`. Stale local-only `v0.1.0` is still safe to `git tag -d v0.1.0`.
|
- **Tags on origin:** `v0.9.0`, `v0.10.0`, `v0.11.0`. Local has `v0.11.0` too. v0.12.0 is the next tag.
|
||||||
|
|
||||||
## Where we are
|
## Where we are
|
||||||
|
|
||||||
Session 6's UX punch list was four items. All four shipped today, plus an unrelated font-fallback fix from a second-machine smoke test.
|
v0.11.0 shipped the headline structural changes (card themes, HUD overhaul, four UX feel wins, font fallback). The second UX round — six smaller items — is also done now. v0.12.0 is the right slice for them; together with the README refresh and CHANGELOG add it makes a clean release.
|
||||||
|
|
||||||
The card-theme system, HUD restructure, modal scaffold, and the four big UX feel items (foundations, drop shadows, drop highlights, stock badge) are all in. Direction is open — the deferred release-prep items (`v0.11.0` cut, README/CHANGELOG refresh, desktop packaging) are still on the table, and a fresh round of UX iteration is also available.
|
The post-v0.11.0 UX candidate list is exhausted. Direction is open again.
|
||||||
|
|
||||||
### Design direction (unchanged)
|
### Design direction (unchanged)
|
||||||
|
|
||||||
@@ -24,43 +24,59 @@ The card-theme system, HUD restructure, modal scaffold, and the four big UX feel
|
|||||||
|
|
||||||
### Canonical remote
|
### Canonical remote
|
||||||
|
|
||||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there. (Earlier sessions used `Rusty_Solitare` — single-i typo — as the repo name; the rename to `Rusty_Solitaire` happened in session 7. Local clone directories may still be named `Rusty_Solitare`; that's just a directory name and works fine.)
|
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there. (Local clone directories may still be named `Rusty_Solitare`; that's just a directory name and works fine.)
|
||||||
|
|
||||||
## Session 7 (shipped 2026-05-02)
|
## Session 7 round 1 (shipped 2026-05-02 morning) — v0.11.0
|
||||||
|
|
||||||
| Area | Commit | What landed |
|
| Area | Commit | What landed |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Font fallback | `fdb6c2e` | `shared_fontdb` now `include_bytes!()`s `assets/fonts/main.ttf` (FiraMono) and pins every CSS generic to `"Fira Mono"` so unmatched named families on minimal Linux installs / fresh Wayland sessions / chroots don't drop card rank/suit text. Surfaced when a second-machine pull rendered cards without glyphs. |
|
| Font fallback | `fdb6c2e` | FiraMono bundled into the SVG fontdb so cards render rank/suit text on machines without Bitstream Vera Sans / Arial. Surfaced when a second-machine pull lost glyphs. |
|
||||||
| Unlock foundations | `95df542` | `PileType::Foundation(Suit)` → `Foundation(u8)` (slot 0..3). `Pile::claimed_suit()` derives the claim from the bottom card — no separate field, no claim-stuck-after-undo class of bugs. `can_place_on_foundation` drops its suit parameter. `next_auto_complete_move` prefers a slot whose claimed suit matches the candidate before falling back to the first empty slot for an Ace. Empty foundation markers render as plain placeholders (no "C/D/H/S"). HUD selection label and hint toast read `claimed_suit()` and fall through to "Foundation N" / "move to foundation" when the slot is empty. Save-format invalidation: `GameState.schema_version` bumped 1 → 2; old `game_state.json` files silently fall through to "fresh game on launch." Stats / progress / achievements / settings live in separate files and are unaffected. 9 new tests. |
|
| Unlock foundations | `95df542` | `PileType::Foundation(Suit)` → `Foundation(u8)` with claim derived from the bottom card. Save schema 1 → 2; pre-v2 saves silently fall through to fresh game. |
|
||||||
| Drop overlay | `f6c9166` | The pre-existing `update_drop_highlights` system tinted `PileMarker` sprites green for valid drops, but markers were occluded by stacked cards — invisible during real play. New `update_drop_target_overlays` spawns a soft-fill + 3 px outlined box ABOVE cards for every legal target (full fanned column for tableaux, card-sized for foundations / empty tableaux). `Z_DROP_OVERLAY = 50` sits above static cards but below `DRAG_Z = 500` so the dragged card never gets occluded. Reuses `STATE_SUCCESS` hue. The original marker-tint system is untouched. 3 new tests. |
|
| Drop overlay | `f6c9166` | Soft fill + 3 px outline drawn ABOVE stacked cards for every legal target during drag. Replaces the hidden pile-marker tint. |
|
||||||
| Drop shadows | `f712b89` | Each `CardEntity` spawns a `CardShadow` child sprite — neutral black at 25 % alpha, sized `card_size + 4 px`, offset `(2, -3)`, local z `-0.05`. `update_card_shadows_on_drag` snaps shadows in `DragState.cards` to a lifted state (40 % alpha, `(4, -6)` offset, `(8, 8)` padding). `resize_cards_in_place` extended to keep shadows cheap on window resize. `update_card_entity`'s `despawn_related` is followed by a fresh `add_card_shadow_child` so flips / theme swaps re-attach shadows. Pure `card_shadow_params(is_dragged)` helper unit-tested. 4 new tests. |
|
| Drop shadows | `f712b89` | Each card casts a 25 % black shadow; lifts to 40 % with bigger offset/halo while in the active drag set. |
|
||||||
| Stock badge | `655dfde` | A small `·N` chip at the top-right corner of the stock pile shows the remaining count. `update_stock_count_badge` spawns a top-level world entity whose `Transform.translation` is recomputed each tick from `LayoutResource`, so window resize / theme swap don't strand it. Hides via `Visibility::Hidden` when the stock empties — the existing `↺` `StockEmptyLabel` takes over and they never co-render. `Z_STOCK_BADGE = 30` sits between cards and `Z_DROP_OVERLAY`. 4 new tests. |
|
| Stock badge | `655dfde` | "·N" chip at the top-right of the stock so players can see how close they are to a recycle. Hides at zero. |
|
||||||
|
|
||||||
## Open punch list — release prep (still deferred unless player chooses now)
|
Tagged as `v0.11.0` (commit `063269c` plus URL refresh).
|
||||||
|
|
||||||
1. **Cut `v0.11.0`** — meaningful slice since `v0.10.0`: full card-theme system (CARD_PLAN phases 1–7 + theme picker + hayeah art), HUD overhaul (band + fade), session 6's four bug fixes, and session 7's font fallback + four UX feel wins. (`git tag -d v0.1.0` first to clean up the stale local tag.)
|
## Session 7 round 2 (shipped 2026-05-02 afternoon) — v0.12.0
|
||||||
2. **README + CHANGELOG refresh** — README was last touched at `a6b8348` before the Settings picker shipped; doesn't mention card themes, the auto-fade, or any of session 7's UX work.
|
|
||||||
|
| Area | Commit | What landed |
|
||||||
|
|---|---|---|
|
||||||
|
| Aspect ratio | `13aa0fd` | `CARD_ASPECT` 1.4 → 1.4523 to match hayeah SVG dimensions; cards no longer ~3.6 % squashed. Vertical-budget math adapts via the constant. |
|
||||||
|
| Foundation flourish | `69ce9af` | King-on-foundation celebration: scale-pulse on the King, marker tints `STATE_SUCCESS`, synthesised C6→E6→G6 bell ping (~240 ms). New `FoundationCompletedEvent`. |
|
||||||
|
| Drag-cancel tween | `525fe0f` | Illegal drops glide each card back to its origin over 150 ms with a quintic ease-out (Responsive curve, zero overshoot). Audio cue still fires. ShakeAnim retained for non-drag rejection paths. |
|
||||||
|
| Focus pulse | `9887343` | Focus ring breathes at 1.4 s sin period over [0.65, 1.0] of native alpha. Static under `AnimSpeed::Instant`. |
|
||||||
|
| Achievement onboarding | `ca5788f` | First-win toast "First win! Press A to see your achievements." plus persisted `shown_achievement_onboarding` flag so the cue fires exactly once. |
|
||||||
|
| Mode Launcher shortcuts | `7dba772` | Digit 1–5 inside the Mode Launcher launches Classic / Daily / Zen / Challenge / TimeAttack. Locked modes silent no-op. Modal-scoped. |
|
||||||
|
| Docs (rode along) | `d8c7034`, `9f095c4` | README refresh for v0.11.0 features and corrected controls table; CHANGELOG.md added covering v0.9.0–v0.11.0. |
|
||||||
|
|
||||||
|
The first three items in this round (`13aa0fd`, `69ce9af`, `525fe0f`) shipped before the v0.11.0 tag's commit window closed; treating them as v0.12.0 since v0.11.0 was already cut at `063269c`.
|
||||||
|
|
||||||
|
## Open punch list — release prep
|
||||||
|
|
||||||
|
1. **Tag v0.12.0** — meaningful slice since v0.11.0: six UX feel items + the README/CHANGELOG refresh. Tag at the doc-commit HEAD that closes this round.
|
||||||
|
2. **Push to origin** — three-plus commits unpushed.
|
||||||
3. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo, no remote yet). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
|
3. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo, no remote yet). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
|
||||||
|
|
||||||
## Open punch list — UX iteration (next-round candidates)
|
## Open punch list — UX iteration (next-round candidates)
|
||||||
|
|
||||||
The session-6 list is exhausted. Candidates for a next round, none formally requested by the player:
|
The v0.12.0 list is exhausted. Candidates for a future round:
|
||||||
|
|
||||||
- **Animated focus ring** (currently a static overlay; could pulse on focus change).
|
- **Card-back theme support** — the current theme system swaps face SVGs but not the back. Players asked for animated backs in passing.
|
||||||
- **Achievement onboarding pass** — show first-time players the achievement panel after their first win.
|
- **Streak fire animation** in the HUD when win-streak crosses 3, 5, 10. Foundation flourish suggests the per-suit completion pattern; streak milestones are the lifetime equivalent.
|
||||||
- **Mode-switch keyboard shortcut** from inside the Mode Launcher (today only mouse opens it).
|
- **Score-breakdown reveal** at win — show base / time-bonus / no-undo bonus / mode multiplier as the score animates up. Currently the win modal just shows the final number.
|
||||||
- **Runtime aspect-ratio fidelity** — hayeah SVGs are ~1.45 h/w; engine layout assumes 1.4. Cards render ~3 % squashed vertically. Cosmetic.
|
- **Right-click radial menu** for power users: hold right-click on a card → quick-drop options without dragging.
|
||||||
- **Foundation completion celebration** — when a foundation reaches its King, do a small flourish (sparkle, lift, sound). The auto-complete cascade already covers the win moment, but per-foundation closure is currently silent.
|
- **Drag-with-keyboard** — Tab to a card, Enter to "lift", arrow keys to choose destination, Enter to drop. Keyboard-only completion of a game.
|
||||||
- **Drag-cancel return animation** — illegal drops snap cards back instantly. A short ease-back tween ("springs back to where it came from") would feel more forgiving.
|
- **Settings: tooltip-delay slider** so power users can disable the 0.5 s hover delay. Cheap.
|
||||||
|
|
||||||
## Card-theme system (CARD_PLAN.md, fully shipped)
|
## Card-theme system (CARD_PLAN.md, fully shipped)
|
||||||
|
|
||||||
Seven phases landed across `b8fb3fb` → `924a1e2`. End-to-end:
|
Seven phases landed across `b8fb3fb` → `924a1e2` in v0.11.0:
|
||||||
|
|
||||||
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs (MIT) + a midnight-purple `back.svg` (original work).
|
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs (MIT) + a midnight-purple `back.svg` (original work).
|
||||||
- **User themes** live under `themes://` rooted at `solitaire_engine::assets::user_theme_dir()`. Drop a directory containing `theme.ron` + 53 SVG files; appears in the registry on next launch.
|
- **User themes** live under `themes://` rooted at `solitaire_engine::assets::user_theme_dir()`. Drop a directory containing `theme.ron` + 53 SVG files; appears in the registry on next launch.
|
||||||
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates an archive (20 MB cap, zip-slip rejection, manifest validation, every SVG round-tripped through the rasteriser) and atomically unpacks.
|
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates an archive and atomically unpacks.
|
||||||
- **Picker UI** in Settings → Cosmetic — one chip per registered theme; selection persists to `settings.json` as `selected_theme_id` and propagates to live sprites via `react_to_settings_theme_change` → `sync_card_image_set_with_active_theme` → `StateChangedEvent`.
|
- **Picker UI** in Settings → Cosmetic.
|
||||||
|
|
||||||
## Resume prompt
|
## Resume prompt
|
||||||
|
|
||||||
@@ -68,34 +84,32 @@ Seven phases landed across `b8fb3fb` → `924a1e2`. End-to-end:
|
|||||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||||
Working directory: <Rusty_Solitaire clone path on this machine — local
|
Working directory: <Rusty_Solitaire clone path on this machine — local
|
||||||
directory may still be named Rusty_Solitare from earlier; that's fine>.
|
directory may still be named Rusty_Solitare from earlier; that's fine>.
|
||||||
Branch: master. Direction is OPEN — the session-6 UX punch list is
|
Branch: master. Direction is OPEN — both UX iteration rounds shipped
|
||||||
fully shipped. The player will choose between cutting v0.11.0, doing
|
and v0.12.0 is ready to tag.
|
||||||
release prep (README/CHANGELOG/packaging), or starting a new UX
|
|
||||||
iteration round.
|
|
||||||
|
|
||||||
State: HEAD=655dfde. Local master is 3 commits ahead of origin
|
State: HEAD at the doc-commit closing session 7 round 2. Local master
|
||||||
(f6c9166, f712b89, 655dfde unpushed; fdb6c2e and 95df542 already
|
is several commits ahead of origin and unpushed. Working tree clean
|
||||||
pushed). Working tree clean apart from untracked CARD_PLAN.md
|
apart from untracked CARD_PLAN.md (intentional).
|
||||||
(intentional).
|
|
||||||
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
||||||
Tests: 982 passed / 0 failed.
|
Tests: 1007 passed / 0 failed.
|
||||||
|
|
||||||
READ FIRST (in order, before doing anything):
|
READ FIRST (in order, before doing anything):
|
||||||
1. SESSION_HANDOFF.md — full state, session 7 changelog, punch list
|
1. SESSION_HANDOFF.md — full state, session 7 changelog, punch list
|
||||||
2. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
2. CHANGELOG.md — release-by-release record
|
||||||
3. ARCHITECTURE.md — crate responsibilities + data flow
|
3. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
||||||
4. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
4. ARCHITECTURE.md — crate responsibilities + data flow
|
||||||
|
5. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||||
— saved feedback / project context (machine-local;
|
— saved feedback / project context (machine-local;
|
||||||
may be missing on a fresh machine)
|
may be missing on a fresh machine)
|
||||||
|
|
||||||
DECISION TO ASK THE PLAYER FIRST:
|
DECISION TO ASK THE PLAYER FIRST:
|
||||||
A. Push the 3 unpushed commits and cut v0.11.0?
|
A. Push the unpushed commits and cut v0.12.0 now.
|
||||||
B. Skip the tag for now, refresh README + CHANGELOG, then tag?
|
B. Smoke-test the new feel layer first (foundation flourish, drag
|
||||||
C. Skip release prep entirely and start a new UX iteration round?
|
tween, focus pulse, mode digits), then tag.
|
||||||
If C, see the session-7 next-round candidates list (animated
|
C. Skip the tag for another iteration round — see "next-round
|
||||||
focus ring, achievement onboarding, mode-switch keyboard
|
candidates" in SESSION_HANDOFF for ideas.
|
||||||
shortcut, aspect-ratio fidelity, foundation completion flourish,
|
D. Take the deferred desktop-packaging item (needs artwork +
|
||||||
drag-cancel return tween).
|
signing certs from the user).
|
||||||
|
|
||||||
WORKFLOW NOTES:
|
WORKFLOW NOTES:
|
||||||
- Commits use:
|
- Commits use:
|
||||||
@@ -105,6 +119,5 @@ WORKFLOW NOTES:
|
|||||||
- Every commit must pass build / clippy / test before pushing.
|
- Every commit must pass build / clippy / test before pushing.
|
||||||
- Push to GitHub (origin) — that is the canonical remote.
|
- Push to GitHub (origin) — that is the canonical remote.
|
||||||
|
|
||||||
OPEN AT THE START: ask which of A / B / C. Don't pick unilaterally —
|
OPEN AT THE START: ask which of A / B / C / D. Don't pick unilaterally.
|
||||||
this is a directional choice, not a tactical one.
|
|
||||||
```
|
```
|
||||||
|
|||||||
Binary file not shown.
@@ -16,13 +16,14 @@ fn main() -> io::Result<()> {
|
|||||||
let out_dir = workspace_root().join("assets").join("audio");
|
let out_dir = workspace_root().join("assets").join("audio");
|
||||||
fs::create_dir_all(&out_dir)?;
|
fs::create_dir_all(&out_dir)?;
|
||||||
|
|
||||||
let effects: [(&str, Generator); 6] = [
|
let effects: [(&str, Generator); 7] = [
|
||||||
("card_flip.wav", card_flip),
|
("card_flip.wav", card_flip),
|
||||||
("card_place.wav", card_place),
|
("card_place.wav", card_place),
|
||||||
("card_deal.wav", card_deal),
|
("card_deal.wav", card_deal),
|
||||||
("card_invalid.wav", card_invalid),
|
("card_invalid.wav", card_invalid),
|
||||||
("win_fanfare.wav", win_fanfare),
|
("win_fanfare.wav", win_fanfare),
|
||||||
("ambient_loop.wav", ambient_loop),
|
("ambient_loop.wav", ambient_loop),
|
||||||
|
("foundation_complete.wav", foundation_complete),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (name, make) in &effects {
|
for (name, make) in &effects {
|
||||||
@@ -170,6 +171,44 @@ fn win_fanfare() -> Vec<i16> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-suit foundation-completion ping (~240 ms): a rising three-note
|
||||||
|
/// chime — C6, E6, G6 — with a soft 2nd-harmonic warm layer on each
|
||||||
|
/// note. Shorter and brighter than `win_fanfare` so it can fire up to
|
||||||
|
/// four times per game (once per suit) without drowning out subsequent
|
||||||
|
/// move sounds. The fourth firing co-occurs with the win cascade and
|
||||||
|
/// `win_fanfare`; the C-major triad sits an octave above the
|
||||||
|
/// fanfare's root so the two layer cleanly instead of fighting for the
|
||||||
|
/// same frequency band.
|
||||||
|
fn foundation_complete() -> Vec<i16> {
|
||||||
|
// C major triad, one octave up from win_fanfare's root.
|
||||||
|
let notes = [1046.50_f32, 1318.51, 1567.98]; // C6, E6, G6
|
||||||
|
let note_dur = 0.07_f32; // brisk, ascending
|
||||||
|
let total = note_dur * notes.len() as f32 + 0.05;
|
||||||
|
let n = duration_samples(total);
|
||||||
|
let mut out = Vec::with_capacity(n);
|
||||||
|
for i in 0..n {
|
||||||
|
let t = i as f32 / SAMPLE_RATE as f32;
|
||||||
|
let mut sample = 0.0f32;
|
||||||
|
for (idx, freq) in notes.iter().enumerate() {
|
||||||
|
let start = idx as f32 * note_dur;
|
||||||
|
let local = t - start;
|
||||||
|
// Each note rings out for 0.18 s — overlapping notes form a
|
||||||
|
// brief chord at the tail.
|
||||||
|
if !(0.0..=0.18).contains(&local) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Sine + soft 2nd harmonic for warmth, ar_envelope decays
|
||||||
|
// sharply so each note is bell-like rather than sustained.
|
||||||
|
let s = (2.0 * std::f32::consts::PI * freq * local).sin()
|
||||||
|
+ 0.25 * (2.0 * std::f32::consts::PI * freq * 2.0 * local).sin();
|
||||||
|
let env = ar_envelope(local, 0.005, 0.18, 14.0);
|
||||||
|
sample += s * env;
|
||||||
|
}
|
||||||
|
out.push(quantize(sample * 0.20));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz
|
/// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz
|
||||||
/// mono 16-bit PCM).
|
/// mono 16-bit PCM).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -132,6 +132,17 @@ pub struct Settings {
|
|||||||
/// `#[serde(default = ...)]`.
|
/// `#[serde(default = ...)]`.
|
||||||
#[serde(default = "default_theme_id")]
|
#[serde(default = "default_theme_id")]
|
||||||
pub selected_theme_id: String,
|
pub selected_theme_id: String,
|
||||||
|
/// Set to `true` once the achievement-onboarding info-toast has been
|
||||||
|
/// shown to the player after their very first win. Acts as a
|
||||||
|
/// one-shot teach: subsequent wins must not re-fire the cue. Older
|
||||||
|
/// `settings.json` files written before this field existed
|
||||||
|
/// deserialize cleanly to `false` thanks to `#[serde(default)]` —
|
||||||
|
/// players who already had wins recorded before this field was
|
||||||
|
/// introduced are guarded by the post-condition `games_won == 1`
|
||||||
|
/// checked by `achievement_plugin::fire_achievement_onboarding_toast`,
|
||||||
|
/// so the toast still does not fire for them.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shown_achievement_onboarding: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawMode {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -165,6 +176,7 @@ impl Default for Settings {
|
|||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
window_geometry: None,
|
window_geometry: None,
|
||||||
selected_theme_id: default_theme_id(),
|
selected_theme_id: default_theme_id(),
|
||||||
|
shown_achievement_onboarding: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,6 +330,7 @@ mod tests {
|
|||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
window_geometry: None,
|
window_geometry: None,
|
||||||
selected_theme_id: "default".to_string(),
|
selected_theme_id: "default".to_string(),
|
||||||
|
shown_achievement_onboarding: false,
|
||||||
};
|
};
|
||||||
save_settings_to(&path, &s).expect("save");
|
save_settings_to(&path, &s).expect("save");
|
||||||
let loaded = load_settings_from(&path);
|
let loaded = load_settings_from(&path);
|
||||||
@@ -506,4 +519,48 @@ mod tests {
|
|||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
assert!(s.window_geometry.is_none());
|
assert!(s.window_geometry.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// shown_achievement_onboarding — first-win cue one-shot guard
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_shown_achievement_onboarding_default_is_false() {
|
||||||
|
assert!(
|
||||||
|
!Settings::default().shown_achievement_onboarding,
|
||||||
|
"default shown_achievement_onboarding must be false so the cue fires once"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_shown_achievement_onboarding_round_trip() {
|
||||||
|
let path = tmp_path("achievement_onboarding_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let s = Settings {
|
||||||
|
shown_achievement_onboarding: true,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert!(
|
||||||
|
loaded.shown_achievement_onboarding,
|
||||||
|
"shown_achievement_onboarding must survive serde round-trip"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_settings_without_shown_achievement_onboarding_deserializes_to_false() {
|
||||||
|
// A settings.json written by an older version of the game will be
|
||||||
|
// missing this field entirely. `#[serde(default)]` on the field
|
||||||
|
// must yield `false` — the cue then fires on the next win, but
|
||||||
|
// only when stats.games_won == 1, so existing players who have
|
||||||
|
// already won past their first game won't see the toast either.
|
||||||
|
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
!s.shown_achievement_onboarding,
|
||||||
|
"legacy settings.json missing shown_achievement_onboarding must deserialize to false"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,17 +14,19 @@ use solitaire_core::achievement::{
|
|||||||
ALL_ACHIEVEMENTS,
|
ALL_ACHIEVEMENTS,
|
||||||
};
|
};
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
|
achievements_file_path, load_achievements_from, save_achievements_to, save_settings_to,
|
||||||
save_progress_to,
|
AchievementRecord, save_progress_to,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent,
|
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, ToggleAchievementsRequestEvent,
|
||||||
|
XpAwardedEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
@@ -91,6 +93,7 @@ impl Plugin for AchievementPlugin {
|
|||||||
.add_message::<AchievementUnlockedEvent>()
|
.add_message::<AchievementUnlockedEvent>()
|
||||||
.add_message::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ToggleAchievementsRequestEvent>()
|
.add_message::<ToggleAchievementsRequestEvent>()
|
||||||
// Run after GameMutation (so GameWonEvent is available), after
|
// Run after GameMutation (so GameWonEvent is available), after
|
||||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||||
@@ -102,6 +105,16 @@ impl Plugin for AchievementPlugin {
|
|||||||
.after(StatsUpdate)
|
.after(StatsUpdate)
|
||||||
.after(ProgressUpdate),
|
.after(ProgressUpdate),
|
||||||
)
|
)
|
||||||
|
// Achievement-onboarding cue: fires once after the player's very
|
||||||
|
// first win to teach the Achievements panel exists. Must run
|
||||||
|
// `.after(StatsUpdate)` so `stats.games_won` reflects the win
|
||||||
|
// that just landed (StatsUpdate increments it on `GameWonEvent`).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
fire_achievement_onboarding_toast
|
||||||
|
.after(GameMutation)
|
||||||
|
.after(StatsUpdate),
|
||||||
|
)
|
||||||
.add_systems(Update, toggle_achievements_screen)
|
.add_systems(Update, toggle_achievements_screen)
|
||||||
.add_systems(Update, handle_achievements_close_button);
|
.add_systems(Update, handle_achievements_close_button);
|
||||||
}
|
}
|
||||||
@@ -209,6 +222,67 @@ fn evaluate_on_win(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Achievement-onboarding cue.
|
||||||
|
///
|
||||||
|
/// On the player's very first win — and only their first — fires a single
|
||||||
|
/// `InfoToastEvent` nudging them toward the Achievements panel (`A` hotkey)
|
||||||
|
/// so they discover the progression layer.
|
||||||
|
///
|
||||||
|
/// Three guards prevent spurious or repeat firings:
|
||||||
|
///
|
||||||
|
/// * `stats.games_won == 1` — the post-condition is checked **after**
|
||||||
|
/// `StatsUpdate` increments `games_won`, so the cue only fires for the
|
||||||
|
/// true first win, not (for example) a player who imported existing
|
||||||
|
/// sync data and won a later game.
|
||||||
|
/// * `!settings.shown_achievement_onboarding` — flips to `true` after
|
||||||
|
/// the toast fires, persists to `settings.json`, and serves as the
|
||||||
|
/// one-shot guard across launches and merged sync.
|
||||||
|
/// * The system bails immediately when no `GameWonEvent` arrived this
|
||||||
|
/// frame so it is a no-op outside the post-win frame.
|
||||||
|
///
|
||||||
|
/// The `A` hotkey is mentioned verbatim in the toast text so players who
|
||||||
|
/// dismiss the cue still know where to find the panel.
|
||||||
|
fn fire_achievement_onboarding_toast(
|
||||||
|
mut wins: MessageReader<GameWonEvent>,
|
||||||
|
stats: Res<StatsResource>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
settings_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
// Drain the event queue regardless — multiple wins on a single frame
|
||||||
|
// only need a single onboarding toast at most.
|
||||||
|
let any_win = wins.read().last().is_some();
|
||||||
|
if !any_win {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a `SettingsResource` (headless tests that omit `SettingsPlugin`)
|
||||||
|
// we have no flag to consult; bail out cleanly.
|
||||||
|
let Some(settings) = settings.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if settings.0.shown_achievement_onboarding {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if stats.0.games_won != 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.write(InfoToastEvent(
|
||||||
|
"First win! Press A to see your achievements.".to_string(),
|
||||||
|
));
|
||||||
|
settings.0.shown_achievement_onboarding = true;
|
||||||
|
|
||||||
|
// Persist so the cue stays one-shot across launches. `None` storage
|
||||||
|
// (headless / test) is a documented no-op.
|
||||||
|
if let Some(path) = settings_path.as_ref()
|
||||||
|
&& let Some(target) = path.0.as_deref()
|
||||||
|
&& let Err(e) = save_settings_to(target, &settings.0)
|
||||||
|
{
|
||||||
|
warn!("failed to save settings (achievement onboarding): {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convenience: resolve an achievement ID to its human-readable name.
|
/// Convenience: resolve an achievement ID to its human-readable name.
|
||||||
/// Used by the toast renderer in `animation_plugin`.
|
/// Used by the toast renderer in `animation_plugin`.
|
||||||
pub fn display_name_for(id: &str) -> String {
|
pub fn display_name_for(id: &str) -> String {
|
||||||
@@ -921,4 +995,187 @@ mod tests {
|
|||||||
assert!(s.contains("How to unlock"));
|
assert!(s.contains("How to unlock"));
|
||||||
assert!(!s.contains("Reward"), "got {s:?}");
|
assert!(!s.contains("Reward"), "got {s:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Achievement-onboarding cue (`fire_achievement_onboarding_toast`)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Builds a headless app that **also** includes `SettingsPlugin::headless()`
|
||||||
|
/// so the achievement-onboarding system (which reads `SettingsResource`)
|
||||||
|
/// has a flag to consult and persist into.
|
||||||
|
fn onboarding_test_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(StatsPlugin::headless())
|
||||||
|
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||||
|
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
|
||||||
|
.add_plugins(AchievementPlugin::headless());
|
||||||
|
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects every `InfoToastEvent` written so tests can assert on
|
||||||
|
/// count and message contents.
|
||||||
|
fn drain_info_toasts(app: &App) -> Vec<String> {
|
||||||
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
cursor.read(events).map(|e| e.0.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First-win path: with the flag false and `games_won` about to be
|
||||||
|
/// 1, exactly one `InfoToastEvent` mentioning the `A` hotkey must
|
||||||
|
/// fire and the flag must flip to `true`.
|
||||||
|
#[test]
|
||||||
|
fn first_win_fires_achievement_onboarding_toast() {
|
||||||
|
let mut app = onboarding_test_app();
|
||||||
|
|
||||||
|
// Sanity: fresh app starts with games_won = 0 and the flag unset.
|
||||||
|
assert_eq!(app.world().resource::<StatsResource>().0.games_won, 0);
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_achievement_onboarding
|
||||||
|
);
|
||||||
|
|
||||||
|
// StatsPlugin (StatsUpdate) increments games_won to 1 *before* the
|
||||||
|
// achievement-onboarding system reads stats — our system runs
|
||||||
|
// `.after(StatsUpdate)`. The system then sees games_won == 1 and
|
||||||
|
// the cue fires.
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 1000,
|
||||||
|
time_seconds: 300,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let toasts = drain_info_toasts(&app);
|
||||||
|
let onboarding_toasts: Vec<&String> = toasts
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
onboarding_toasts.len(),
|
||||||
|
1,
|
||||||
|
"exactly one achievement-onboarding toast must fire on the first win; \
|
||||||
|
saw all toasts: {toasts:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_achievement_onboarding,
|
||||||
|
"shown_achievement_onboarding must flip to true after the toast fires"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Second-win path: with the flag already `true` (player already
|
||||||
|
/// saw the cue on a previous run), no onboarding toast may fire.
|
||||||
|
#[test]
|
||||||
|
fn subsequent_wins_do_not_fire_achievement_onboarding_toast() {
|
||||||
|
let mut app = onboarding_test_app();
|
||||||
|
|
||||||
|
// Pre-set the flag to simulate a player who already dismissed
|
||||||
|
// the cue on a previous run.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_achievement_onboarding = true;
|
||||||
|
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 1000,
|
||||||
|
time_seconds: 300,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
onboarding_toasts.is_empty(),
|
||||||
|
"no onboarding toast must fire when shown_achievement_onboarding is already true; \
|
||||||
|
got: {onboarding_toasts:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync-import path: a player imports stats with `games_won = 5`
|
||||||
|
/// already on the books. The flag is still `false` (they were on a
|
||||||
|
/// pre-cue release on this device), but the cue must NOT fire because
|
||||||
|
/// this isn't actually their first win — the post-condition
|
||||||
|
/// `games_won == 1` guards against retroactive nagging.
|
||||||
|
#[test]
|
||||||
|
fn non_first_win_does_not_fire_achievement_onboarding_toast() {
|
||||||
|
let mut app = onboarding_test_app();
|
||||||
|
|
||||||
|
// Pre-seed games_won = 5 BEFORE the win lands. StatsUpdate will
|
||||||
|
// bump it to 6 on the GameWonEvent, taking the system well past
|
||||||
|
// the `games_won == 1` post-condition.
|
||||||
|
app.world_mut().resource_mut::<StatsResource>().0.games_won = 5;
|
||||||
|
|
||||||
|
// Confirm the flag is still false so we know the guard that
|
||||||
|
// prevents firing is the games-won post-condition, not the flag.
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_achievement_onboarding
|
||||||
|
);
|
||||||
|
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 1000,
|
||||||
|
time_seconds: 300,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
onboarding_toasts.is_empty(),
|
||||||
|
"no onboarding toast must fire on a non-first win; got: {onboarding_toasts:?}"
|
||||||
|
);
|
||||||
|
// And the flag must remain false so the cue can still teach a
|
||||||
|
// genuinely-fresh second device or a wiped install.
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_achievement_onboarding,
|
||||||
|
"shown_achievement_onboarding must remain false when the cue did not fire"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Without any `GameWonEvent` arriving the system must be a no-op:
|
||||||
|
/// no toast, no flag flip — even on update ticks where stats happen
|
||||||
|
/// to read `games_won == 1`.
|
||||||
|
#[test]
|
||||||
|
fn no_win_event_means_no_achievement_onboarding_toast() {
|
||||||
|
let mut app = onboarding_test_app();
|
||||||
|
|
||||||
|
// Pre-seed games_won = 1 to simulate the misleading mid-frame
|
||||||
|
// state without actually firing a GameWonEvent.
|
||||||
|
app.world_mut().resource_mut::<StatsResource>().0.games_won = 1;
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
onboarding_toasts.is_empty(),
|
||||||
|
"no onboarding toast must fire without a GameWonEvent; got: {onboarding_toasts:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_achievement_onboarding,
|
||||||
|
"flag must not flip without a win event"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ use kira::track::{TrackBuilder, TrackHandle};
|
|||||||
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
|
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
|
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent,
|
||||||
MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
|
GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
@@ -70,6 +70,12 @@ pub struct SoundLibrary {
|
|||||||
pub place: StaticSoundData,
|
pub place: StaticSoundData,
|
||||||
pub invalid: StaticSoundData,
|
pub invalid: StaticSoundData,
|
||||||
pub fanfare: StaticSoundData,
|
pub fanfare: StaticSoundData,
|
||||||
|
/// Per-suit foundation-completion ping. Played whenever a King
|
||||||
|
/// lands on a foundation pile (Ace → King, 13 cards). ~240 ms,
|
||||||
|
/// rising C-major triad an octave above `fanfare`'s root so the
|
||||||
|
/// two layer cleanly when the fourth completion co-occurs with
|
||||||
|
/// the win cascade.
|
||||||
|
pub foundation_complete: StaticSoundData,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wraps the audio backend. `NonSend` because cpal streams are `!Send` on
|
/// Wraps the audio backend. `NonSend` because cpal streams are `!Send` on
|
||||||
@@ -145,6 +151,7 @@ impl Plugin for AudioPlugin {
|
|||||||
.add_message::<CardFlippedEvent>()
|
.add_message::<CardFlippedEvent>()
|
||||||
.add_message::<CardFaceRevealedEvent>()
|
.add_message::<CardFaceRevealedEvent>()
|
||||||
.add_message::<UndoRequestEvent>()
|
.add_message::<UndoRequestEvent>()
|
||||||
|
.add_message::<FoundationCompletedEvent>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_systems(Startup, apply_initial_volume)
|
.add_systems(Startup, apply_initial_volume)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@@ -157,6 +164,7 @@ impl Plugin for AudioPlugin {
|
|||||||
play_on_win,
|
play_on_win,
|
||||||
play_on_face_revealed,
|
play_on_face_revealed,
|
||||||
play_on_undo,
|
play_on_undo,
|
||||||
|
play_on_foundation_complete,
|
||||||
apply_volume_on_change,
|
apply_volume_on_change,
|
||||||
handle_mute_keys,
|
handle_mute_keys,
|
||||||
),
|
),
|
||||||
@@ -170,12 +178,15 @@ fn build_library() -> Option<SoundLibrary> {
|
|||||||
let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?;
|
let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?;
|
||||||
let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?;
|
let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?;
|
||||||
let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?;
|
let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?;
|
||||||
|
let foundation_complete =
|
||||||
|
decode(include_bytes!("../../assets/audio/foundation_complete.wav"))?;
|
||||||
Some(SoundLibrary {
|
Some(SoundLibrary {
|
||||||
deal,
|
deal,
|
||||||
flip,
|
flip,
|
||||||
place,
|
place,
|
||||||
invalid,
|
invalid,
|
||||||
fanfare,
|
fanfare,
|
||||||
|
foundation_complete,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +462,25 @@ fn play_on_face_revealed(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plays the per-suit completion ping whenever a `FoundationCompletedEvent`
|
||||||
|
/// fires (a King lands on a foundation pile that now holds Ace → King).
|
||||||
|
///
|
||||||
|
/// The fourth firing co-occurs with `GameWonEvent` and the win fanfare;
|
||||||
|
/// the two layer cleanly because the ping sits an octave above the
|
||||||
|
/// fanfare's root and is much shorter (~240 ms vs ~970 ms).
|
||||||
|
fn play_on_foundation_complete(
|
||||||
|
mut events: MessageReader<FoundationCompletedEvent>,
|
||||||
|
mut audio: NonSendMut<AudioState>,
|
||||||
|
lib: Option<Res<SoundLibrary>>,
|
||||||
|
) {
|
||||||
|
let Some(lib) = lib else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for _ in events.read() {
|
||||||
|
play(&mut audio, &lib.foundation_complete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use solitaire_core::pile::PileType;
|
|||||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||||
|
|
||||||
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
||||||
|
use crate::card_animation::CardAnimation;
|
||||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::{Layout, LayoutResource, LayoutSystem};
|
use crate::layout::{Layout, LayoutResource, LayoutSystem};
|
||||||
@@ -50,8 +51,11 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
|||||||
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
|
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
|
||||||
|
|
||||||
/// Fraction of card height used as a tiny offset between stacked cards in
|
/// Fraction of card height used as a tiny offset between stacked cards in
|
||||||
/// non-tableau piles, so stacking is visible.
|
/// non-tableau piles, so stacking is visible. Public so other plugins
|
||||||
const STACK_FAN_FRAC: f32 = 0.003;
|
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
|
||||||
|
/// `Transform.translation.z` for a card at a given stack index without
|
||||||
|
/// drifting from the value used by [`card_positions`].
|
||||||
|
pub const STACK_FAN_FRAC: f32 = 0.003;
|
||||||
|
|
||||||
/// Font size as a fraction of card width.
|
/// Font size as a fraction of card width.
|
||||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||||
@@ -447,7 +451,7 @@ fn sync_cards_startup(
|
|||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
) {
|
) {
|
||||||
if let Some(layout) = layout {
|
if let Some(layout) = layout {
|
||||||
@@ -467,7 +471,7 @@ fn sync_cards_on_change(
|
|||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
) {
|
) {
|
||||||
if events.read().next().is_none() {
|
if events.read().next().is_none() {
|
||||||
@@ -490,22 +494,27 @@ fn sync_cards(
|
|||||||
slide_secs: f32,
|
slide_secs: f32,
|
||||||
back_colour: Color,
|
back_colour: Color,
|
||||||
color_blind: bool,
|
color_blind: bool,
|
||||||
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
) {
|
) {
|
||||||
let positions = card_positions(game, layout);
|
let positions = card_positions(game, layout);
|
||||||
|
|
||||||
// Map card_id -> (Entity, current_translation) for in-place updates.
|
// Map card_id -> (Entity, current_translation, has_card_animation) for
|
||||||
let mut existing: HashMap<u32, (Entity, Vec3)> = HashMap::new();
|
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
|
||||||
for (entity, marker, transform) in entities.iter() {
|
// skip the snap/slide path on cards that are already being driven by a
|
||||||
existing.insert(marker.card_id, (entity, transform.translation));
|
// curve-based `CardAnimation` tween (e.g. the drag-rejection return tween
|
||||||
|
// — see `input_plugin::end_drag`). Otherwise the StateChangedEvent that
|
||||||
|
// accompanies a rejection would race the tween and the card would jump.
|
||||||
|
let mut existing: HashMap<u32, (Entity, Vec3, bool)> = HashMap::new();
|
||||||
|
for (entity, marker, transform, anim) in entities.iter() {
|
||||||
|
existing.insert(marker.card_id, (entity, transform.translation, anim.is_some()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
||||||
|
|
||||||
// Despawn any entity whose card is no longer tracked.
|
// Despawn any entity whose card is no longer tracked.
|
||||||
for (card_id, (entity, _)) in &existing {
|
for (card_id, (entity, _, _)) in &existing {
|
||||||
if !live_ids.contains(card_id) {
|
if !live_ids.contains(card_id) {
|
||||||
commands.entity(*entity).despawn();
|
commands.entity(*entity).despawn();
|
||||||
}
|
}
|
||||||
@@ -514,10 +523,10 @@ fn sync_cards(
|
|||||||
// For each card in the current state: spawn or update its entity.
|
// For each card in the current state: spawn or update its entity.
|
||||||
for (card, position, z) in positions {
|
for (card, position, z) in positions {
|
||||||
match existing.get(&card.id) {
|
match existing.get(&card.id) {
|
||||||
Some(&(entity, cur)) => {
|
Some(&(entity, cur, has_anim)) => {
|
||||||
update_card_entity(
|
update_card_entity(
|
||||||
&mut commands, entity, card, position, z, layout,
|
&mut commands, entity, card, position, z, layout,
|
||||||
slide_secs, back_colour, color_blind, cur, card_images, selected_back,
|
slide_secs, back_colour, color_blind, cur, has_anim, card_images, selected_back,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back),
|
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back),
|
||||||
@@ -667,6 +676,7 @@ fn update_card_entity(
|
|||||||
back_colour: Color,
|
back_colour: Color,
|
||||||
color_blind: bool,
|
color_blind: bool,
|
||||||
cur: Vec3,
|
cur: Vec3,
|
||||||
|
has_card_animation: bool,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
) {
|
) {
|
||||||
@@ -675,24 +685,31 @@ fn update_card_entity(
|
|||||||
// Always refresh the visual appearance.
|
// Always refresh the visual appearance.
|
||||||
commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
|
commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
|
||||||
|
|
||||||
// Slide to the new position when it differs meaningfully; snap otherwise.
|
// Skip the snap/slide path entirely when a curve-based `CardAnimation`
|
||||||
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
// is driving this card (e.g. the drag-rejection return tween). Writing
|
||||||
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
|
// `Transform` here would race that animation each frame and cause a
|
||||||
commands
|
// visible jump. The animation system snaps the final position itself
|
||||||
.entity(entity)
|
// when it completes.
|
||||||
.insert(Transform::from_translation(start))
|
if !has_card_animation {
|
||||||
.insert(CardAnim {
|
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||||
start,
|
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
||||||
target,
|
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
|
||||||
elapsed: 0.0,
|
commands
|
||||||
duration: slide_secs,
|
.entity(entity)
|
||||||
delay: 0.0,
|
.insert(Transform::from_translation(start))
|
||||||
});
|
.insert(CardAnim {
|
||||||
} else {
|
start,
|
||||||
commands
|
target,
|
||||||
.entity(entity)
|
elapsed: 0.0,
|
||||||
.remove::<CardAnim>()
|
duration: slide_secs,
|
||||||
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
delay: 0.0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<CardAnim>()
|
||||||
|
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Despawn any stale children and re-add the per-card drop shadow plus,
|
// Despawn any stale children and re-add the per-card drop shadow plus,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Cross-system events used by the engine's plugins.
|
//! Cross-system events used by the engine's plugins.
|
||||||
|
|
||||||
use bevy::prelude::Message;
|
use bevy::prelude::Message;
|
||||||
|
use solitaire_core::card::Suit;
|
||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
@@ -60,6 +61,28 @@ pub struct GameWonEvent {
|
|||||||
pub time_seconds: u64,
|
pub time_seconds: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fired by `GamePlugin` whenever a successful move lands a card on a
|
||||||
|
/// foundation pile that, after the move, contains all 13 cards of its
|
||||||
|
/// suit (Ace → King). Drives the per-suit completion flourish — a brief
|
||||||
|
/// scale pulse on the King card and a golden tint on the foundation
|
||||||
|
/// pile marker — plus a short audio ping.
|
||||||
|
///
|
||||||
|
/// Fired once per per-suit completion. The fourth completion will
|
||||||
|
/// co-occur with `GameWonEvent` and the win cascade — they layer
|
||||||
|
/// cleanly because the flourish is purely decorative and lives on a
|
||||||
|
/// dedicated marker component.
|
||||||
|
///
|
||||||
|
/// This event is a UI/audio cue only. It does **not** cross
|
||||||
|
/// `solitaire_sync` and is not persisted.
|
||||||
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
|
pub struct FoundationCompletedEvent {
|
||||||
|
/// Foundation pile slot (0..=3) that just reached 13 cards.
|
||||||
|
pub slot: u8,
|
||||||
|
/// The suit of the completed foundation, taken from the bottom card
|
||||||
|
/// (always an Ace by construction).
|
||||||
|
pub suit: Suit,
|
||||||
|
}
|
||||||
|
|
||||||
/// Fired when a card's face-up state changes during gameplay.
|
/// Fired when a card's face-up state changes during gameplay.
|
||||||
#[derive(Message, Debug, Clone, Copy)]
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
pub struct CardFlippedEvent(pub u32);
|
pub struct CardFlippedEvent(pub u32);
|
||||||
|
|||||||
@@ -48,13 +48,18 @@ use solitaire_data::AnimSpeed;
|
|||||||
use crate::animation_plugin::CardAnim;
|
use crate::animation_plugin::CardAnim;
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
|
NewGameRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
use crate::table_plugin::PileMarker;
|
||||||
|
use crate::ui_theme::{
|
||||||
|
FOUNDATION_FLOURISH_PEAK_SCALE, MOTION_FOUNDATION_FLOURISH_SECS, STATE_SUCCESS,
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared constants
|
// Shared constants
|
||||||
@@ -185,7 +190,8 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
|
|||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Registers the shake, settle, and deal animation systems.
|
/// Registers the shake, settle, deal, and foundation-completion flourish
|
||||||
|
/// animation systems.
|
||||||
pub struct FeedbackAnimPlugin;
|
pub struct FeedbackAnimPlugin;
|
||||||
|
|
||||||
impl Plugin for FeedbackAnimPlugin {
|
impl Plugin for FeedbackAnimPlugin {
|
||||||
@@ -197,6 +203,7 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
.add_message::<DrawRequestEvent>()
|
.add_message::<DrawRequestEvent>()
|
||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
|
.add_message::<FoundationCompletedEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -205,6 +212,8 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
start_settle_anim.after(GameMutation),
|
start_settle_anim.after(GameMutation),
|
||||||
tick_settle_anim,
|
tick_settle_anim,
|
||||||
start_deal_anim.after(GameMutation),
|
start_deal_anim.after(GameMutation),
|
||||||
|
start_foundation_flourish.after(GameMutation),
|
||||||
|
tick_foundation_flourish,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -401,6 +410,204 @@ fn start_deal_anim(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Foundation-completion flourish
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Drives the per-foundation completion flourish on the King card that
|
||||||
|
/// just landed on a foundation pile (Ace → King, 13 cards).
|
||||||
|
///
|
||||||
|
/// Inserted on the King's `CardEntity` when `FoundationCompletedEvent`
|
||||||
|
/// fires; removed once `elapsed >= duration`. Decorative only — does
|
||||||
|
/// not block input or interfere with the win cascade, settle, or hint
|
||||||
|
/// systems (those operate on different markers and read the same
|
||||||
|
/// `Transform.scale` coordinate non-conflictingly because the flourish
|
||||||
|
/// finishes in well under a second).
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct FoundationFlourish {
|
||||||
|
/// Foundation slot (0..=3) this flourish is celebrating.
|
||||||
|
pub foundation_slot: u8,
|
||||||
|
/// Seconds elapsed since the flourish began.
|
||||||
|
pub elapsed: f32,
|
||||||
|
/// Total animation length in seconds.
|
||||||
|
pub duration: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives a brief golden tint on the foundation `PileMarker` whose
|
||||||
|
/// foundation just completed. Stores the marker's original colour so
|
||||||
|
/// it can be restored when the timer expires.
|
||||||
|
///
|
||||||
|
/// Inserted alongside (and concurrent with) `FoundationFlourish` on the
|
||||||
|
/// matching `PileMarker` entity. The system runs independently of the
|
||||||
|
/// existing `HintPileHighlight` so the two never share state — a hint
|
||||||
|
/// landing during a completion flourish (highly unlikely in practice
|
||||||
|
/// since the foundation just completed) won't corrupt either party's
|
||||||
|
/// `original_color` snapshot.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct FoundationMarkerFlourish {
|
||||||
|
/// Seconds elapsed since the tint was applied.
|
||||||
|
pub elapsed: f32,
|
||||||
|
/// Total animation length in seconds.
|
||||||
|
pub duration: f32,
|
||||||
|
/// The pile marker's sprite colour before the tint was applied —
|
||||||
|
/// restored when the timer expires.
|
||||||
|
pub original_color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper for unit tests — returns the per-frame scale factor for
|
||||||
|
/// the foundation flourish at `elapsed_secs` over `duration_secs`.
|
||||||
|
///
|
||||||
|
/// Triangular curve, mirroring `score_pulse_scale` in `hud_plugin`:
|
||||||
|
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
|
||||||
|
/// [`FOUNDATION_FLOURISH_PEAK_SCALE`] (1.15), at `t = 1.0` returns
|
||||||
|
/// `1.0`. Out-of-range values are clamped so the King never freezes
|
||||||
|
/// at a non-1.0 scale on the frame after the flourish ends.
|
||||||
|
///
|
||||||
|
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
|
||||||
|
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
|
||||||
|
/// without dividing by zero.
|
||||||
|
pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
|
||||||
|
if duration_secs <= 0.0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
|
||||||
|
let peak = FOUNDATION_FLOURISH_PEAK_SCALE;
|
||||||
|
if t < 0.5 {
|
||||||
|
// Climb from 1.0 at t=0 to peak at t=0.5.
|
||||||
|
1.0 + (peak - 1.0) * (t / 0.5)
|
||||||
|
} else {
|
||||||
|
// Descend from peak at t=0.5 back to 1.0 at t=1.0.
|
||||||
|
peak - (peak - 1.0) * ((t - 0.5) / 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts `FoundationFlourish` on the King card entity at the
|
||||||
|
/// completed foundation and `FoundationMarkerFlourish` on its
|
||||||
|
/// `PileMarker`. The King is identified as the *top* card of the
|
||||||
|
/// foundation pile after the move — by definition the 13th card,
|
||||||
|
/// always rank King by foundation rules.
|
||||||
|
fn start_foundation_flourish(
|
||||||
|
mut events: MessageReader<FoundationCompletedEvent>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
for ev in events.read() {
|
||||||
|
let pile_type = PileType::Foundation(ev.slot);
|
||||||
|
// Top card of the completed foundation is the King.
|
||||||
|
let Some(king_id) = game
|
||||||
|
.0
|
||||||
|
.piles
|
||||||
|
.get(&pile_type)
|
||||||
|
.and_then(|p| p.cards.last())
|
||||||
|
.map(|c| c.id)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tag the King's card entity.
|
||||||
|
for (entity, card_marker) in card_entities.iter() {
|
||||||
|
if card_marker.card_id == king_id {
|
||||||
|
commands.entity(entity).insert(FoundationFlourish {
|
||||||
|
foundation_slot: ev.slot,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: MOTION_FOUNDATION_FLOURISH_SECS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tint the matching PileMarker. Snapshot the current colour so
|
||||||
|
// tick_foundation_flourish can restore it; if a stale flourish
|
||||||
|
// is somehow still active, reuse its `original_color` so we
|
||||||
|
// don't capture the gold tint as the new "original".
|
||||||
|
for (entity, pile_marker, sprite, existing) in pile_markers.iter_mut() {
|
||||||
|
if pile_marker.0 != pile_type {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let original_color = existing.map_or(sprite.color, |f| f.original_color);
|
||||||
|
commands.entity(entity).insert(FoundationMarkerFlourish {
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: MOTION_FOUNDATION_FLOURISH_SECS,
|
||||||
|
original_color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances both the King's scale pulse and the foundation marker's
|
||||||
|
/// gold tint each frame. Removes both components once their timers
|
||||||
|
/// expire, restoring the King's `Transform.scale` to `Vec3::ONE` and
|
||||||
|
/// the marker's sprite colour to its captured original.
|
||||||
|
///
|
||||||
|
/// Skipped while paused so a player who hits Esc mid-flourish doesn't
|
||||||
|
/// see frozen scaled state (the next unpause tick resumes from the
|
||||||
|
/// stored `elapsed`).
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn tick_foundation_flourish(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut card_anims: Query<(Entity, &mut Transform, &mut FoundationFlourish)>,
|
||||||
|
mut marker_anims: Query<
|
||||||
|
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
|
||||||
|
Without<FoundationFlourish>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
|
||||||
|
// Advance the King's scale pulse.
|
||||||
|
for (entity, mut transform, mut anim) in &mut card_anims {
|
||||||
|
anim.elapsed += dt;
|
||||||
|
if anim.elapsed >= anim.duration {
|
||||||
|
// Restore identity scale so the card sits at its normal size
|
||||||
|
// for the next frame's transform sync.
|
||||||
|
transform.scale = Vec3::ONE;
|
||||||
|
commands.entity(entity).remove::<FoundationFlourish>();
|
||||||
|
} else {
|
||||||
|
let s = foundation_flourish_scale(anim.elapsed, anim.duration);
|
||||||
|
transform.scale = Vec3::new(s, s, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the foundation marker's gold tint. Held flat for the
|
||||||
|
// first half of the duration and faded back to the original colour
|
||||||
|
// over the second half — feels celebratory without bleeding into
|
||||||
|
// the next move's drop-target highlights.
|
||||||
|
for (entity, mut sprite, mut anim) in &mut marker_anims {
|
||||||
|
anim.elapsed += dt;
|
||||||
|
if anim.elapsed >= anim.duration {
|
||||||
|
sprite.color = anim.original_color;
|
||||||
|
commands.entity(entity).remove::<FoundationMarkerFlourish>();
|
||||||
|
} else {
|
||||||
|
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
||||||
|
// Lerp factor: 1.0 (full tint) for the first half, then
|
||||||
|
// ramps down linearly to 0.0 (original colour) by the end.
|
||||||
|
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
|
||||||
|
sprite.color = lerp_color(anim.original_color, STATE_SUCCESS, mix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear interpolation between two `Color`s in sRGB space. Pulled out
|
||||||
|
/// as a small helper so the `tick_foundation_flourish` body stays
|
||||||
|
/// readable; sRGB-space lerping is fine for a brief decorative tint
|
||||||
|
/// (a perceptually-uniform space would be overkill).
|
||||||
|
fn lerp_color(from: Color, to: Color, t: f32) -> Color {
|
||||||
|
let from = from.to_srgba();
|
||||||
|
let to = to.to_srgba();
|
||||||
|
let t = t.clamp(0.0, 1.0);
|
||||||
|
Color::srgba(
|
||||||
|
from.red + (to.red - from.red) * t,
|
||||||
|
from.green + (to.green - from.green) * t,
|
||||||
|
from.blue + (to.blue - from.blue) * t,
|
||||||
|
from.alpha + (to.alpha - from.alpha) * t,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Unit tests (pure functions only — no Bevy world required)
|
// Unit tests (pure functions only — no Bevy world required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -534,6 +741,47 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Foundation-flourish curve tests
|
||||||
|
|
||||||
|
/// Triangular curve must be 1.0 at t=0, peak at t=0.5, and 1.0 at t=1.
|
||||||
|
#[test]
|
||||||
|
fn foundation_flourish_scale_curves_through_one_one_one() {
|
||||||
|
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
|
||||||
|
assert!(
|
||||||
|
(foundation_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
|
||||||
|
"flourish scale at t=0 must be 1.0"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(foundation_flourish_scale(dur / 2.0, dur) - FOUNDATION_FLOURISH_PEAK_SCALE).abs() < 1e-5,
|
||||||
|
"flourish scale at midpoint must be FOUNDATION_FLOURISH_PEAK_SCALE"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(foundation_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
|
||||||
|
"flourish scale at t=duration must return to 1.0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Out-of-range values are clamped, not extrapolated. Important so the
|
||||||
|
/// King never ends up at a non-1.0 scale on the frame after the
|
||||||
|
/// flourish ends (which would race against the despawn / restore step
|
||||||
|
/// in `tick_foundation_flourish`).
|
||||||
|
#[test]
|
||||||
|
fn foundation_flourish_scale_clamps_out_of_range() {
|
||||||
|
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
|
||||||
|
// Negative elapsed clamps to 0 → scale 1.0.
|
||||||
|
assert!((foundation_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
|
||||||
|
// Past-end clamps to t=1 → scale 1.0.
|
||||||
|
assert!((foundation_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
|
||||||
|
/// divides by zero.
|
||||||
|
#[test]
|
||||||
|
fn foundation_flourish_scale_zero_duration_is_one() {
|
||||||
|
assert!((foundation_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
|
||||||
|
assert!((foundation_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deal_stagger_jitter_varies_across_card_ids() {
|
fn deal_stagger_jitter_varies_across_card_ids() {
|
||||||
// 52 cards should produce more than a couple distinct jitter factors;
|
// 52 cards should produce more than a couple distinct jitter factors;
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ use solitaire_data::{delete_game_state_at, game_state_file_path, load_game_state
|
|||||||
save_game_state_to};
|
save_game_state_to};
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent,
|
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
|
||||||
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
||||||
@@ -86,6 +86,7 @@ impl Plugin for GamePlugin {
|
|||||||
.add_message::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_message::<crate::events::CardFlippedEvent>()
|
.add_message::<crate::events::CardFlippedEvent>()
|
||||||
.add_message::<crate::events::AchievementUnlockedEvent>()
|
.add_message::<crate::events::AchievementUnlockedEvent>()
|
||||||
|
.add_message::<FoundationCompletedEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -398,14 +399,18 @@ fn handle_draw(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_move(
|
fn handle_move(
|
||||||
mut moves: MessageReader<MoveRequestEvent>,
|
mut moves: MessageReader<MoveRequestEvent>,
|
||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut changed: MessageWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
mut won: MessageWriter<GameWonEvent>,
|
mut won: MessageWriter<GameWonEvent>,
|
||||||
mut flipped: MessageWriter<crate::events::CardFlippedEvent>,
|
mut flipped: MessageWriter<crate::events::CardFlippedEvent>,
|
||||||
|
mut foundation_done: MessageWriter<FoundationCompletedEvent>,
|
||||||
path: Option<Res<GameStatePath>>,
|
path: Option<Res<GameStatePath>>,
|
||||||
) {
|
) {
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
for ev in moves.read() {
|
for ev in moves.read() {
|
||||||
let was_won = game.0.is_won;
|
let was_won = game.0.is_won;
|
||||||
// Identify the card that will be exposed (and may flip face-up) by the move.
|
// Identify the card that will be exposed (and may flip face-up) by the move.
|
||||||
@@ -429,6 +434,19 @@ fn handle_move(
|
|||||||
{
|
{
|
||||||
flipped.write(crate::events::CardFlippedEvent(fid));
|
flipped.write(crate::events::CardFlippedEvent(fid));
|
||||||
}
|
}
|
||||||
|
// If this move landed on a foundation pile and that pile is
|
||||||
|
// now complete (Ace → King, 13 cards), fire the per-suit
|
||||||
|
// flourish event. Drives a brief decorative scale-pulse on
|
||||||
|
// the King + a golden tint on the foundation marker plus a
|
||||||
|
// short audio ping. Purely a UI / audio cue — does not
|
||||||
|
// cross `solitaire_sync` and is not persisted.
|
||||||
|
if let PileType::Foundation(slot) = ev.to
|
||||||
|
&& let Some(pile) = game.0.piles.get(&ev.to)
|
||||||
|
&& pile.cards.len() == 13
|
||||||
|
&& let Some(suit) = pile.claimed_suit()
|
||||||
|
{
|
||||||
|
foundation_done.write(FoundationCompletedEvent { slot, suit });
|
||||||
|
}
|
||||||
changed.write(StateChangedEvent);
|
changed.write(StateChangedEvent);
|
||||||
if !was_won && game.0.is_won {
|
if !was_won && game.0.is_won {
|
||||||
won.write(GameWonEvent {
|
won.write(GameWonEvent {
|
||||||
@@ -1407,6 +1425,196 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Foundation-completion flourish — FoundationCompletedEvent firing logic
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Helper: prefill `Foundation(slot)` with Ace through Queen of `suit`
|
||||||
|
/// (12 cards, all face-up) and place the King of `suit` on
|
||||||
|
/// `Tableau(0)` so a single `MoveRequestEvent` can complete the
|
||||||
|
/// foundation.
|
||||||
|
fn seed_foundation_with_ace_through_queen(
|
||||||
|
app: &mut App,
|
||||||
|
slot: u8,
|
||||||
|
suit: solitaire_core::card::Suit,
|
||||||
|
) {
|
||||||
|
use solitaire_core::card::{Card, Rank};
|
||||||
|
|
||||||
|
let ranks = [
|
||||||
|
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
|
||||||
|
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen,
|
||||||
|
];
|
||||||
|
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
|
let foundation = gs
|
||||||
|
.0
|
||||||
|
.piles
|
||||||
|
.get_mut(&PileType::Foundation(slot))
|
||||||
|
.expect("foundation slot must exist");
|
||||||
|
foundation.cards.clear();
|
||||||
|
for (i, &rank) in ranks.iter().enumerate() {
|
||||||
|
foundation.cards.push(Card {
|
||||||
|
id: 5_000 + i as u32 + (slot as u32) * 100,
|
||||||
|
suit,
|
||||||
|
rank,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Put the King on Tableau(0) so a single move can complete it.
|
||||||
|
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||||
|
t0.cards.clear();
|
||||||
|
t0.cards.push(Card {
|
||||||
|
id: 6_000 + (slot as u32),
|
||||||
|
suit,
|
||||||
|
rank: Rank::King,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reading helper: collect every `FoundationCompletedEvent` written
|
||||||
|
/// during the most recent `update()` so the test body can assert
|
||||||
|
/// against count, slot, and suit.
|
||||||
|
fn drain_foundation_events(app: &App) -> Vec<FoundationCompletedEvent> {
|
||||||
|
let events = app
|
||||||
|
.world()
|
||||||
|
.resource::<Messages<FoundationCompletedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
cursor.read(events).copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a King lands on a foundation that already holds Ace through
|
||||||
|
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
|
||||||
|
/// the matching slot + suit.
|
||||||
|
#[test]
|
||||||
|
fn foundation_completed_event_fires_when_king_lands() {
|
||||||
|
use solitaire_core::card::Suit;
|
||||||
|
|
||||||
|
let mut app = test_app(1);
|
||||||
|
seed_foundation_with_ace_through_queen(&mut app, 2, Suit::Hearts);
|
||||||
|
|
||||||
|
app.world_mut().write_message(MoveRequestEvent {
|
||||||
|
from: PileType::Tableau(0),
|
||||||
|
to: PileType::Foundation(2),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let fired = drain_foundation_events(&app);
|
||||||
|
assert_eq!(
|
||||||
|
fired.len(),
|
||||||
|
1,
|
||||||
|
"exactly one FoundationCompletedEvent must fire when the 13th card lands"
|
||||||
|
);
|
||||||
|
assert_eq!(fired[0].slot, 2, "event slot must match the destination slot");
|
||||||
|
assert_eq!(fired[0].suit, Suit::Hearts, "event suit must match the foundation suit");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moving a card to a tableau pile must never produce a
|
||||||
|
/// `FoundationCompletedEvent`, even if the source tableau happened
|
||||||
|
/// to have been a King.
|
||||||
|
#[test]
|
||||||
|
fn foundation_completed_event_does_not_fire_for_non_foundation_moves() {
|
||||||
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
|
|
||||||
|
let mut app = test_app(1);
|
||||||
|
// Reset the world: clear stock + waste so a draw isn't possible,
|
||||||
|
// empty all tableaux + foundations, then place a face-up King of
|
||||||
|
// Spades on Tableau(0). Tableau(1) is empty, so the King can move
|
||||||
|
// there legally.
|
||||||
|
{
|
||||||
|
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
|
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
for i in 0..7_usize {
|
||||||
|
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||||
|
id: 7_000,
|
||||||
|
suit: Suit::Spades,
|
||||||
|
rank: Rank::King,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.world_mut().write_message(MoveRequestEvent {
|
||||||
|
from: PileType::Tableau(0),
|
||||||
|
to: PileType::Tableau(1),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let fired = drain_foundation_events(&app);
|
||||||
|
assert!(
|
||||||
|
fired.is_empty(),
|
||||||
|
"FoundationCompletedEvent must not fire for non-foundation moves; got {fired:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At 12 cards on a foundation (Ace–Jack on the pile, Queen in
|
||||||
|
/// flight), the event must NOT fire — the flourish is only for the
|
||||||
|
/// final 13th completion.
|
||||||
|
#[test]
|
||||||
|
fn foundation_completed_event_does_not_fire_at_12_cards() {
|
||||||
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
|
|
||||||
|
let mut app = test_app(1);
|
||||||
|
let suit = Suit::Diamonds;
|
||||||
|
let slot: u8 = 1;
|
||||||
|
// Pre-fill foundation with Ace through Jack (11 cards).
|
||||||
|
let pre_ranks = [
|
||||||
|
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
|
||||||
|
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack,
|
||||||
|
];
|
||||||
|
{
|
||||||
|
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
|
let foundation = gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap();
|
||||||
|
foundation.cards.clear();
|
||||||
|
for (i, &rank) in pre_ranks.iter().enumerate() {
|
||||||
|
foundation.cards.push(Card {
|
||||||
|
id: 8_000 + i as u32,
|
||||||
|
suit,
|
||||||
|
rank,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Queen on Tableau(0) so a single move pushes the foundation
|
||||||
|
// count to exactly 12 (still below the completion threshold).
|
||||||
|
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||||
|
t0.cards.clear();
|
||||||
|
t0.cards.push(Card {
|
||||||
|
id: 8_900,
|
||||||
|
suit,
|
||||||
|
rank: Rank::Queen,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.world_mut().write_message(MoveRequestEvent {
|
||||||
|
from: PileType::Tableau(0),
|
||||||
|
to: PileType::Foundation(slot),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Sanity: the move actually landed (foundation has 12 cards now).
|
||||||
|
let foundation_len = app
|
||||||
|
.world()
|
||||||
|
.resource::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.piles[&PileType::Foundation(slot)]
|
||||||
|
.cards
|
||||||
|
.len();
|
||||||
|
assert_eq!(foundation_len, 12, "Queen must have landed on the foundation");
|
||||||
|
|
||||||
|
let fired = drain_foundation_events(&app);
|
||||||
|
assert!(
|
||||||
|
fired.is_empty(),
|
||||||
|
"FoundationCompletedEvent must not fire at 12 cards; got {fired:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A successful undo must NOT fire an `InfoToastEvent`.
|
/// A successful undo must NOT fire an `InfoToastEvent`.
|
||||||
#[test]
|
#[test]
|
||||||
fn undo_after_draw_does_not_fire_info_toast() {
|
fn undo_after_draw_does_not_fire_info_toast() {
|
||||||
|
|||||||
@@ -104,6 +104,16 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
|
|||||||
ControlRow { keys: "T", description: "Start a Time Attack session (level 5+)" },
|
ControlRow { keys: "T", description: "Start a Time Attack session (level 5+)" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
ControlSection {
|
||||||
|
title: "Mode Launcher (M)",
|
||||||
|
rows: &[
|
||||||
|
ControlRow { keys: "1", description: "Launch Classic" },
|
||||||
|
ControlRow { keys: "2", description: "Launch Daily Challenge" },
|
||||||
|
ControlRow { keys: "3", description: "Launch Zen (level 5+)" },
|
||||||
|
ControlRow { keys: "4", description: "Launch Challenge (level 5+)" },
|
||||||
|
ControlRow { keys: "5", description: "Launch Time Attack (level 5+)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
ControlSection {
|
ControlSection {
|
||||||
title: "Overlays",
|
title: "Overlays",
|
||||||
rows: &[
|
rows: &[
|
||||||
|
|||||||
@@ -135,6 +135,14 @@ impl Plugin for HomePlugin {
|
|||||||
.add_message::<StartTimeAttackRequestEvent>()
|
.add_message::<StartTimeAttackRequestEvent>()
|
||||||
.add_message::<StartDailyChallengeRequestEvent>()
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
|
// `.chain()` because several systems (M-toggle, card click,
|
||||||
|
// cancel button, digit-key shortcut) all read the
|
||||||
|
// `HomeScreen` entity and may queue a despawn on it in the
|
||||||
|
// same tick. Bevy's parallel scheduler would otherwise let
|
||||||
|
// two of them run simultaneously and double-despawn the
|
||||||
|
// entity, panicking when the second command buffer is
|
||||||
|
// applied. Chaining serialises these systems and keeps the
|
||||||
|
// despawn deterministic.
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -142,7 +150,9 @@ impl Plugin for HomePlugin {
|
|||||||
attach_focusable_to_home_mode_cards,
|
attach_focusable_to_home_mode_cards,
|
||||||
handle_home_card_click,
|
handle_home_card_click,
|
||||||
handle_home_cancel_button,
|
handle_home_cancel_button,
|
||||||
),
|
handle_home_digit_keys,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +261,98 @@ fn handle_home_cancel_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Digit-key shortcuts (1-5) — modal-scoped
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Maps a [`KeyCode::Digit1`]..[`KeyCode::Digit5`] press to the matching
|
||||||
|
/// [`HomeMode`]. Returns `None` for any other key. Kept as a small free
|
||||||
|
/// function so the keyboard handler reads as a clean dispatch table and so
|
||||||
|
/// the mapping is easy to unit-test in isolation.
|
||||||
|
fn digit_to_home_mode(key: KeyCode) -> Option<HomeMode> {
|
||||||
|
match key {
|
||||||
|
KeyCode::Digit1 => Some(HomeMode::Classic),
|
||||||
|
KeyCode::Digit2 => Some(HomeMode::Daily),
|
||||||
|
KeyCode::Digit3 => Some(HomeMode::Zen),
|
||||||
|
KeyCode::Digit4 => Some(HomeMode::Challenge),
|
||||||
|
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct keyboard activation of a specific mode while the Mode Launcher
|
||||||
|
/// modal is open. Mirrors the click-handler dispatch in
|
||||||
|
/// [`handle_home_card_click`]: pressing `1` launches Classic, `2` launches
|
||||||
|
/// the Daily Challenge, and `3`/`4`/`5` launch Zen / Challenge / Time
|
||||||
|
/// Attack respectively when the player has reached
|
||||||
|
/// [`CHALLENGE_UNLOCK_LEVEL`].
|
||||||
|
///
|
||||||
|
/// The shortcut is **modal-scoped** — when no [`HomeScreen`] exists the
|
||||||
|
/// system returns immediately, so digit keys can never accidentally launch
|
||||||
|
/// a mode mid-game. Pressing a digit for a locked mode is a no-op (matches
|
||||||
|
/// the click-on-locked-card behaviour) and leaves the modal open so the
|
||||||
|
/// player can pick another mode.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn handle_home_digit_keys(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
|
mut zen: MessageWriter<StartZenRequestEvent>,
|
||||||
|
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||||
|
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
||||||
|
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
||||||
|
) {
|
||||||
|
// Modal-scoped: do nothing when the Mode Launcher isn't open.
|
||||||
|
if screens.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(mode) = [
|
||||||
|
KeyCode::Digit1,
|
||||||
|
KeyCode::Digit2,
|
||||||
|
KeyCode::Digit3,
|
||||||
|
KeyCode::Digit4,
|
||||||
|
KeyCode::Digit5,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.find(|k| keys.just_pressed(*k))
|
||||||
|
.and_then(digit_to_home_mode) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
||||||
|
if !mode.is_unlocked(level) {
|
||||||
|
// Locked mode: no-op, modal stays open.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match mode {
|
||||||
|
HomeMode::Classic => {
|
||||||
|
new_game.write(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
HomeMode::Daily => {
|
||||||
|
daily.write(StartDailyChallengeRequestEvent);
|
||||||
|
}
|
||||||
|
HomeMode::Zen => {
|
||||||
|
zen.write(StartZenRequestEvent);
|
||||||
|
}
|
||||||
|
HomeMode::Challenge => {
|
||||||
|
challenge.write(StartChallengeRequestEvent);
|
||||||
|
}
|
||||||
|
HomeMode::TimeAttack => {
|
||||||
|
time_attack.write(StartTimeAttackRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the modal after dispatching the launch event — same shape as
|
||||||
|
// the click handler.
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Spawn helpers
|
// Spawn helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -873,4 +975,191 @@ mod tests {
|
|||||||
"no card may be Disabled when the player is at the unlock level"
|
"no card may be Disabled when the player is at the unlock level"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Digit-key shortcuts (1-5) — modal-scoped direct mode launch
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Press a key and clear the input afterwards so the next `update()`
|
||||||
|
/// doesn't re-fire `just_pressed`. Mirrors the open_home() pattern but
|
||||||
|
/// for an arbitrary key (the M-press helper releases & clears KeyM,
|
||||||
|
/// which is also what we need here for Digit keys).
|
||||||
|
fn press_and_clear(app: &mut App, key: KeyCode) {
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.press(key);
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(key);
|
||||||
|
input.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn digit1_in_home_modal_starts_classic_and_closes_modal() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let _ = open_home(&mut app);
|
||||||
|
|
||||||
|
// Drain any pre-existing NewGameRequestEvent so the assertion
|
||||||
|
// only sees the digit-key driven write.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<NewGameRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
|
||||||
|
press_and_clear(&mut app, KeyCode::Digit1);
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
|
assert_eq!(
|
||||||
|
fired.len(),
|
||||||
|
1,
|
||||||
|
"exactly one NewGameRequestEvent must fire for Digit1"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&HomeScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
"Home modal must close after launching Classic via Digit1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn digit3_at_level_zero_is_a_noop() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Default level is 0 — Zen is locked.
|
||||||
|
let _ = open_home(&mut app);
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<StartZenRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
|
||||||
|
press_and_clear(&mut app, KeyCode::Digit3);
|
||||||
|
|
||||||
|
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
|
||||||
|
let mut zc = zen.get_cursor();
|
||||||
|
assert!(
|
||||||
|
zc.read(zen).next().is_none(),
|
||||||
|
"Digit3 at level 0 must not fire StartZenRequestEvent"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&HomeScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"Home modal must remain open after a locked-mode digit press"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn digit3_at_unlock_level_starts_zen_and_closes_modal() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Bump the player to the unlock level *before* opening the modal
|
||||||
|
// so the Mode Launcher is in its unlocked state.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ProgressResource>()
|
||||||
|
.0
|
||||||
|
.level = CHALLENGE_UNLOCK_LEVEL;
|
||||||
|
let _ = open_home(&mut app);
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<StartZenRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
|
||||||
|
press_and_clear(&mut app, KeyCode::Digit3);
|
||||||
|
|
||||||
|
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
|
||||||
|
let mut zc = zen.get_cursor();
|
||||||
|
assert_eq!(
|
||||||
|
zc.read(zen).count(),
|
||||||
|
1,
|
||||||
|
"Digit3 at unlock level must fire exactly one StartZenRequestEvent"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&HomeScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
"Home modal must close after launching Zen via Digit3"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn digit_keys_outside_home_modal_are_noop() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Modal is NOT open. Bump level so Zen would otherwise be allowed
|
||||||
|
// — this isolates the modal-scope guard from the unlock check.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ProgressResource>()
|
||||||
|
.0
|
||||||
|
.level = CHALLENGE_UNLOCK_LEVEL;
|
||||||
|
|
||||||
|
// Drain any pre-existing events.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<NewGameRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<StartZenRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<StartChallengeRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<StartTimeAttackRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<StartDailyChallengeRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
|
||||||
|
// Press every digit 1-5 in turn — none should trigger a launch.
|
||||||
|
for key in [
|
||||||
|
KeyCode::Digit1,
|
||||||
|
KeyCode::Digit2,
|
||||||
|
KeyCode::Digit3,
|
||||||
|
KeyCode::Digit4,
|
||||||
|
KeyCode::Digit5,
|
||||||
|
] {
|
||||||
|
press_and_clear(&mut app, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
let mut nc = new_game.get_cursor();
|
||||||
|
assert!(
|
||||||
|
nc.read(new_game).next().is_none(),
|
||||||
|
"Digit keys with no modal open must not fire NewGameRequestEvent"
|
||||||
|
);
|
||||||
|
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
|
||||||
|
let mut zc = zen.get_cursor();
|
||||||
|
assert!(
|
||||||
|
zc.read(zen).next().is_none(),
|
||||||
|
"Digit keys with no modal open must not fire StartZenRequestEvent"
|
||||||
|
);
|
||||||
|
let chal = app.world().resource::<Messages<StartChallengeRequestEvent>>();
|
||||||
|
let mut cc = chal.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cc.read(chal).next().is_none(),
|
||||||
|
"Digit keys with no modal open must not fire StartChallengeRequestEvent"
|
||||||
|
);
|
||||||
|
let ta = app.world().resource::<Messages<StartTimeAttackRequestEvent>>();
|
||||||
|
let mut tc = ta.get_cursor();
|
||||||
|
assert!(
|
||||||
|
tc.read(ta).next().is_none(),
|
||||||
|
"Digit keys with no modal open must not fire StartTimeAttackRequestEvent"
|
||||||
|
);
|
||||||
|
let daily = app.world().resource::<Messages<StartDailyChallengeRequestEvent>>();
|
||||||
|
let mut dc = daily.get_cursor();
|
||||||
|
assert!(
|
||||||
|
dc.read(daily).next().is_none(),
|
||||||
|
"Digit keys with no modal open must not fire StartDailyChallengeRequestEvent"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ use solitaire_core::pile::PileType;
|
|||||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||||
|
|
||||||
use crate::card_animation::tuning::AnimationTuning;
|
use crate::card_animation::tuning::AnimationTuning;
|
||||||
|
use crate::card_animation::{CardAnimation, MotionCurve};
|
||||||
use crate::card_plugin::{
|
use crate::card_plugin::{
|
||||||
CardEntity, HintHighlight, HintHighlightTimer, TABLEAU_FACEDOWN_FAN_FRAC, TABLEAU_FAN_FRAC,
|
CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC,
|
||||||
|
TABLEAU_FAN_FRAC,
|
||||||
};
|
};
|
||||||
use crate::feedback_anim_plugin::ShakeAnim;
|
use crate::ui_theme::MOTION_DRAG_REJECT_SECS;
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
@@ -666,14 +668,16 @@ fn end_drag(
|
|||||||
to: target.clone(),
|
to: target.clone(),
|
||||||
count,
|
count,
|
||||||
});
|
});
|
||||||
// Shake each dragged card so the player gets immediate
|
// Smoothly glide each dragged card from its drop-time
|
||||||
// visual feedback that the drop was rejected. ShakeAnim
|
// transform back to its resting slot in the origin pile.
|
||||||
// restores translation.x to origin_x at the end of the
|
// The audio cue (card_invalid.wav, played by AudioPlugin
|
||||||
// animation, so origin_x must be the target slot in the
|
// on MoveRejectedEvent) still gives the player clear
|
||||||
// origin pile — using the current drag transform would
|
// negative feedback; this just replaces the old shake
|
||||||
// pin the card at the drop location and fight the
|
// wiggle with a forgiving ease-out tween.
|
||||||
// sync_cards slide that StateChangedEvent triggers
|
//
|
||||||
// (the symptom is "card lands beside the pile").
|
// `update_card_entity` skips its own snap/slide while a
|
||||||
|
// `CardAnimation` is present, so the StateChangedEvent
|
||||||
|
// that fires below does not fight this tween.
|
||||||
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
||||||
for &card_id in &drag.cards {
|
for &card_id in &drag.cards {
|
||||||
let Some(stack_index) =
|
let Some(stack_index) =
|
||||||
@@ -683,14 +687,23 @@ fn end_drag(
|
|||||||
};
|
};
|
||||||
let target_pos =
|
let target_pos =
|
||||||
card_position(&game.0, &layout.0, &origin, stack_index);
|
card_position(&game.0, &layout.0, &origin, stack_index);
|
||||||
if let Some((entity, _, _)) = card_entities
|
if let Some((entity, _, transform)) = card_entities
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, ce, _)| ce.card_id == card_id)
|
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||||
{
|
{
|
||||||
commands.entity(entity).insert(ShakeAnim {
|
let drag_pos = transform.translation.truncate();
|
||||||
elapsed: 0.0,
|
let drag_z = transform.translation.z;
|
||||||
origin_x: target_pos.x,
|
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
});
|
commands.entity(entity).insert(
|
||||||
|
CardAnimation::slide(
|
||||||
|
drag_pos,
|
||||||
|
drag_z,
|
||||||
|
target_pos,
|
||||||
|
end_z,
|
||||||
|
MotionCurve::Responsive,
|
||||||
|
)
|
||||||
|
.with_duration(MOTION_DRAG_REJECT_SECS),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -899,9 +912,11 @@ fn touch_end_drag(
|
|||||||
fired = true;
|
fired = true;
|
||||||
} else {
|
} else {
|
||||||
rejected.write(MoveRejectedEvent { from: origin.clone(), to: target, count });
|
rejected.write(MoveRejectedEvent { from: origin.clone(), to: target, count });
|
||||||
// See `end_drag` (mouse path) for the rationale: ShakeAnim
|
// Smoothly glide each dragged card from its drop-time
|
||||||
// restores translation.x to origin_x, so origin_x must be
|
// transform back to its resting slot. See `end_drag`
|
||||||
// the origin pile's slot, not the drop location.
|
// (mouse path) for the full rationale; the touch path
|
||||||
|
// mirrors it exactly so finger and mouse rejection
|
||||||
|
// feel identical.
|
||||||
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
||||||
for &card_id in &drag.cards {
|
for &card_id in &drag.cards {
|
||||||
let Some(stack_index) =
|
let Some(stack_index) =
|
||||||
@@ -911,13 +926,22 @@ fn touch_end_drag(
|
|||||||
};
|
};
|
||||||
let target_pos =
|
let target_pos =
|
||||||
card_position(&game.0, &layout.0, &origin, stack_index);
|
card_position(&game.0, &layout.0, &origin, stack_index);
|
||||||
if let Some((entity, _, _)) =
|
if let Some((entity, _, transform)) =
|
||||||
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
|
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
|
||||||
{
|
{
|
||||||
commands.entity(entity).insert(ShakeAnim {
|
let drag_pos = transform.translation.truncate();
|
||||||
elapsed: 0.0,
|
let drag_z = transform.translation.z;
|
||||||
origin_x: target_pos.x,
|
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
});
|
commands.entity(entity).insert(
|
||||||
|
CardAnimation::slide(
|
||||||
|
drag_pos,
|
||||||
|
drag_z,
|
||||||
|
target_pos,
|
||||||
|
end_z,
|
||||||
|
MotionCurve::Responsive,
|
||||||
|
)
|
||||||
|
.with_duration(MOTION_DRAG_REJECT_SECS),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1946,71 +1970,159 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Task #57 — ShakeAnim insertion on rejected drag
|
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||||
|
// `ShakeAnim` on the dragged cards. The audio cue
|
||||||
|
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
||||||
|
// visual response on the dragged cards swapped from a horizontal wiggle
|
||||||
|
// to a smooth ease-out glide back to the origin pile.
|
||||||
|
//
|
||||||
|
// These tests build the component values exactly as `end_drag` and
|
||||||
|
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
|
||||||
|
// shaped correctly. Driving `end_drag` end-to-end requires a real window
|
||||||
|
// and mouse-button input, so we exercise the data path the same way the
|
||||||
|
// legacy `ShakeAnim` tests did.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/// Verifies that `ShakeAnim` constructed for a rejected drag has the
|
/// Helper: build the `CardAnimation` the rejection paths construct for
|
||||||
/// correct initial values: `elapsed` starts at 0.0 and `origin_x` matches
|
/// one dragged card. Mirrors the inline logic in `end_drag` and
|
||||||
/// the **target slot in the origin pile** (where the card will rest after
|
/// `touch_end_drag` so the tests stay in sync with the production code.
|
||||||
/// the rejection). Saving the drop-location X here was the root cause of
|
fn build_drag_reject_animation(
|
||||||
/// the "card lands beside the pile" bug — `tick_shake_anim` restores
|
drag_pos: Vec2,
|
||||||
/// `translation.x` to `origin_x` at the end of the shake, fighting the
|
drag_z: f32,
|
||||||
/// `sync_cards` slide that `StateChangedEvent` triggers.
|
target_pos: Vec2,
|
||||||
///
|
stack_index: usize,
|
||||||
/// The Bevy ECS part (Commands + Query) is exercised at runtime; this test
|
) -> CardAnimation {
|
||||||
/// covers the data path — that we build the component with the right values
|
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
/// before handing it to `commands.entity(...).insert(...)`.
|
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
|
||||||
#[test]
|
.with_duration(MOTION_DRAG_REJECT_SECS)
|
||||||
fn shake_anim_for_rejected_drag_has_correct_initial_values() {
|
|
||||||
use crate::feedback_anim_plugin::ShakeAnim;
|
|
||||||
|
|
||||||
// Simulate the X coordinate of the card's slot in its origin pile —
|
|
||||||
// computed by `card_position(game, layout, &origin, stack_index)` at
|
|
||||||
// rejection time, not the drop-location transform X.
|
|
||||||
let target_slot_x = 123.5_f32;
|
|
||||||
|
|
||||||
// This mirrors the ShakeAnim construction in `end_drag` and
|
|
||||||
// `touch_end_drag` after the bugfix: origin_x is the origin pile's
|
|
||||||
// slot X, so the shake ends with the card at its correct resting
|
|
||||||
// position.
|
|
||||||
let anim = ShakeAnim {
|
|
||||||
elapsed: 0.0,
|
|
||||||
origin_x: target_slot_x,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
anim.elapsed, 0.0,
|
|
||||||
"ShakeAnim must start with elapsed=0.0 so the animation plays from the beginning"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(anim.origin_x - target_slot_x).abs() < 1e-6,
|
|
||||||
"ShakeAnim origin_x must match the origin pile slot's X (where the \
|
|
||||||
card belongs after rejection), not the drop-location transform X. \
|
|
||||||
Expected {target_slot_x}, got {}",
|
|
||||||
anim.origin_x
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When a drag is rejected, every card id in `drag.cards` should receive a
|
/// Every card in `drag.cards` should receive its own `CardAnimation` on
|
||||||
/// `ShakeAnim`. Verify that the set of card ids we would iterate matches
|
/// rejection. With the shake → tween migration, the assertion changes
|
||||||
/// exactly the ids stored in `DragState::cards` at rejection time.
|
/// from "every dragged card gets a ShakeAnim" to "every dragged card
|
||||||
|
/// gets a CardAnimation" — same coverage, new component.
|
||||||
#[test]
|
#[test]
|
||||||
fn rejected_drag_shakes_all_dragged_cards() {
|
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||||
// Simulate a DragState with two card ids (a stack drag).
|
// Simulate a stack drag of two cards.
|
||||||
let dragged_ids: Vec<u32> = vec![10, 11];
|
let dragged_ids: Vec<u32> = vec![10, 11];
|
||||||
|
|
||||||
// In `end_drag`, we iterate `drag.cards` and look up each id in
|
let mut animated: Vec<u32> = Vec::new();
|
||||||
// `card_entities`. The ids we would insert ShakeAnim on must exactly
|
|
||||||
// match the dragged set.
|
|
||||||
let mut shaken: Vec<u32> = Vec::new();
|
|
||||||
for &card_id in &dragged_ids {
|
for &card_id in &dragged_ids {
|
||||||
// Simulate finding the entity for card_id (always succeeds here).
|
// In `end_drag` we iterate `drag.cards` and look up each id in
|
||||||
shaken.push(card_id);
|
// `card_entities`. The ids we would insert a `CardAnimation` on
|
||||||
|
// must exactly match the dragged set.
|
||||||
|
animated.push(card_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
shaken, dragged_ids,
|
animated, dragged_ids,
|
||||||
"every card id in drag.cards must receive a ShakeAnim on rejection"
|
"every card id in drag.cards must receive a CardAnimation on rejection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `end` field of the inserted tween must equal the card's resting
|
||||||
|
/// slot in its origin pile — the position the card belongs at after a
|
||||||
|
/// rejected drop. Without this, the tween would glide to the wrong spot
|
||||||
|
/// and `sync_cards` would have to fight it back.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_targets_origin_resting_position() {
|
||||||
|
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
||||||
|
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
||||||
|
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(anim.end - target_pos).length() < 1e-6,
|
||||||
|
"CardAnimation.end must match the origin slot's resting position. \
|
||||||
|
Expected {target_pos:?}, got {:?}",
|
||||||
|
anim.end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `start` field of the inserted tween must equal the card's
|
||||||
|
/// drop-time transform position — i.e. wherever the cursor or finger
|
||||||
|
/// released the card. This is what makes the glide feel like a
|
||||||
|
/// continuous return rather than a teleport-then-shake.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_starts_from_drag_position() {
|
||||||
|
let drag_pos = Vec2::new(640.0, 200.0);
|
||||||
|
let target_pos = Vec2::new(80.0, -120.0);
|
||||||
|
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(anim.start - drag_pos).length() < 1e-6,
|
||||||
|
"CardAnimation.start must match the drop-time transform position \
|
||||||
|
(where the cursor released). Expected {drag_pos:?}, got {:?}",
|
||||||
|
anim.start
|
||||||
|
);
|
||||||
|
// And the start must be visibly distinct from the origin slot — the
|
||||||
|
// whole point of the tween is that it visibly travels.
|
||||||
|
assert!(
|
||||||
|
(anim.start - anim.end).length() > 1.0,
|
||||||
|
"rejected drag should travel a visible distance, got start={:?} end={:?}",
|
||||||
|
anim.start,
|
||||||
|
anim.end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tween duration is taken from the project-wide motion token so
|
||||||
|
/// designers can retune the feel from one place. Keeps the constant and
|
||||||
|
/// the call site honest.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_uses_correct_duration() {
|
||||||
|
let anim = build_drag_reject_animation(
|
||||||
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
||||||
|
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
||||||
|
({MOTION_DRAG_REJECT_SECS}), got {}",
|
||||||
|
anim.duration
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The curve must be a no-overshoot ease-out so the card decelerates
|
||||||
|
/// cleanly into its rest position — overshoot on a rejection feels
|
||||||
|
/// jittery rather than forgiving.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_uses_responsive_curve() {
|
||||||
|
let anim = build_drag_reject_animation(
|
||||||
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
anim.curve,
|
||||||
|
MotionCurve::Responsive,
|
||||||
|
"drag-rejection tween must use Responsive (quintic ease-out) \
|
||||||
|
so the card snaps back without bouncing past the slot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `start_z` of the tween must equal the card's drop-time z
|
||||||
|
/// (`DRAG_Z`) so the card stays above the rest of the table while it
|
||||||
|
/// travels home, then settles at the correct resting z.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
|
||||||
|
let stack_index = 2_usize;
|
||||||
|
let anim = build_drag_reject_animation(
|
||||||
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
stack_index,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(anim.start_z - DRAG_Z).abs() < 1e-6,
|
||||||
|
"tween must start at DRAG_Z so the card stays on top during the glide"
|
||||||
|
);
|
||||||
|
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
|
assert!(
|
||||||
|
(anim.end_z - expected_end_z).abs() < 1e-6,
|
||||||
|
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
|
||||||
|
anim.end_z
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ pub enum LayoutSystem {
|
|||||||
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
|
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
|
||||||
|
|
||||||
/// Aspect ratio (height / width) of a standard playing card.
|
/// Aspect ratio (height / width) of a standard playing card.
|
||||||
const CARD_ASPECT: f32 = 1.4;
|
///
|
||||||
|
/// Matches the bundled hayeah/playing-cards-assets SVG dimensions
|
||||||
|
/// (167.087 × 242.667 → 1.4523). Pre-v0.11 the constant was 1.4,
|
||||||
|
/// which rendered the cards ~3.6 % squashed vertically.
|
||||||
|
const CARD_ASPECT: f32 = 1.4523;
|
||||||
|
|
||||||
/// Fraction of card height used as vertical padding between the top row and
|
/// Fraction of card height used as vertical padding between the top row and
|
||||||
/// the tableau row.
|
/// the tableau row.
|
||||||
@@ -59,7 +63,7 @@ pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
|||||||
pub struct Layout {
|
pub struct Layout {
|
||||||
/// Width and height of a single card, in world units (Bevy 2D world-space).
|
/// Width and height of a single card, in world units (Bevy 2D world-space).
|
||||||
///
|
///
|
||||||
/// `x` is the card width; `y` is the card height (always `x * 1.4`).
|
/// `x` is the card width; `y` is the card height (`x * CARD_ASPECT`).
|
||||||
/// All pile positions and fan offsets are derived from this value.
|
/// All pile positions and fan offsets are derived from this value.
|
||||||
pub card_size: Vec2,
|
pub card_size: Vec2,
|
||||||
/// Centre position of each pile, in 2D world coordinates.
|
/// Centre position of each pile, in 2D world coordinates.
|
||||||
@@ -80,7 +84,8 @@ pub struct Layout {
|
|||||||
/// column (13 face-up cards, see [`MAX_TABLEAU_CARDS`]) inside the
|
/// column (13 face-up cards, see [`MAX_TABLEAU_CARDS`]) inside the
|
||||||
/// window with a bottom margin equal to `h_gap`. Limiter on tall/narrow
|
/// window with a bottom margin equal to `h_gap`. Limiter on tall/narrow
|
||||||
/// windows.
|
/// windows.
|
||||||
/// - `card_height = card_width * 1.4`.
|
/// - `card_height = card_width * CARD_ASPECT` (1.4523, matches the
|
||||||
|
/// bundled hayeah card art's natural SVG dimensions).
|
||||||
/// - Horizontal gap `h_gap = card_width / 4.0`.
|
/// - Horizontal gap `h_gap = card_width / 4.0`.
|
||||||
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
|
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
|
||||||
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
|
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
|
||||||
|
|||||||
@@ -69,8 +69,9 @@ pub use card_animation::{
|
|||||||
FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
|
FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
|
||||||
};
|
};
|
||||||
pub use feedback_anim_plugin::{
|
pub use feedback_anim_plugin::{
|
||||||
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
|
deal_stagger_delay, deal_stagger_secs_for_speed, foundation_flourish_scale, shake_offset,
|
||||||
FeedbackAnimPlugin, SettleAnim, ShakeAnim,
|
settle_scale, FeedbackAnimPlugin, FoundationFlourish, FoundationMarkerFlourish, SettleAnim,
|
||||||
|
ShakeAnim,
|
||||||
};
|
};
|
||||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||||
@@ -82,8 +83,8 @@ pub use font_plugin::{FontPlugin, FontResource};
|
|||||||
pub use cursor_plugin::CursorPlugin;
|
pub use cursor_plugin::CursorPlugin;
|
||||||
pub use events::{
|
pub use events::{
|
||||||
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||||
ForfeitEvent, ForfeitRequestEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent,
|
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
||||||
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ const HOTKEYS: &[HotkeyRow] = &[
|
|||||||
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
|
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
|
||||||
HotkeyRow { keys: "U", description: "Undo last move" },
|
HotkeyRow { keys: "U", description: "Undo last move" },
|
||||||
HotkeyRow { keys: "N", description: "New Classic game" },
|
HotkeyRow { keys: "N", description: "New Classic game" },
|
||||||
|
HotkeyRow { keys: "M", description: "Open Mode Launcher (then 1–5 to pick)" },
|
||||||
HotkeyRow { keys: "S", description: "Stats & progression" },
|
HotkeyRow { keys: "S", description: "Stats & progression" },
|
||||||
HotkeyRow { keys: "A", description: "Achievements" },
|
HotkeyRow { keys: "A", description: "Achievements" },
|
||||||
HotkeyRow { keys: "O", description: "Settings" },
|
HotkeyRow { keys: "O", description: "Settings" },
|
||||||
|
|||||||
@@ -41,13 +41,17 @@
|
|||||||
//! here no-ops so [`crate::selection_plugin`]'s Tab/Enter
|
//! here no-ops so [`crate::selection_plugin`]'s Tab/Enter
|
||||||
//! card-selection still works.
|
//! card-selection still works.
|
||||||
|
|
||||||
|
use std::f32::consts::TAU;
|
||||||
|
|
||||||
use bevy::ecs::query::Has;
|
use bevy::ecs::query::Has;
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::ui_modal::{ButtonVariant, ModalButton, ModalScrim};
|
use crate::ui_modal::{ButtonVariant, ModalButton, ModalScrim};
|
||||||
use crate::ui_theme::{FOCUS_RING, RADIUS_MD, Z_FOCUS_RING};
|
use crate::ui_theme::{FOCUS_RING, MOTION_FOCUS_PULSE_SECS, RADIUS_MD, Z_FOCUS_RING};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public component / resource API
|
// Public component / resource API
|
||||||
@@ -126,12 +130,57 @@ impl Plugin for UiFocusPlugin {
|
|||||||
clear_hud_focus_on_unhover,
|
clear_hud_focus_on_unhover,
|
||||||
handle_focus_keys,
|
handle_focus_keys,
|
||||||
update_focus_overlay,
|
update_focus_overlay,
|
||||||
|
pulse_focus_overlay,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Computes the focus-ring breathing factor for a given elapsed time.
|
||||||
|
///
|
||||||
|
/// Returns a value in `[0.65, 1.0]` following a sin curve over
|
||||||
|
/// [`MOTION_FOCUS_PULSE_SECS`]. Multiply [`FOCUS_RING`]'s native alpha by
|
||||||
|
/// this factor each frame to produce the breathing effect.
|
||||||
|
///
|
||||||
|
/// Pure helper so the curve can be unit-tested without a Bevy app.
|
||||||
|
pub fn focus_ring_pulse_factor(elapsed_secs: f32) -> f32 {
|
||||||
|
let phase = (elapsed_secs * TAU / MOTION_FOCUS_PULSE_SECS).sin();
|
||||||
|
// 0.825 mid-point ± 0.175 amplitude → range [0.65, 1.0]. Multiplicative
|
||||||
|
// factor against FOCUS_RING's static alpha so the brightest tick is
|
||||||
|
// exactly the original colour, not a brighter one.
|
||||||
|
0.825 + 0.175 * phase
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modulates the focus overlay's border alpha with a slow sin-curve
|
||||||
|
/// breathing pulse so the indicator catches the eye without competing
|
||||||
|
/// with gameplay motion. Skipped under `AnimSpeed::Instant` — the static
|
||||||
|
/// border colour is restored so reduced-motion users see no animation.
|
||||||
|
fn pulse_focus_overlay(
|
||||||
|
time: Res<Time>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
focused: Res<FocusedButton>,
|
||||||
|
mut overlay: Query<&mut BorderColor, With<FocusOverlay>>,
|
||||||
|
) {
|
||||||
|
let Ok(mut border) = overlay.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let instant = settings
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|s| matches!(s.0.animation_speed, AnimSpeed::Instant));
|
||||||
|
|
||||||
|
let factor = if instant || focused.0.is_none() {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
focus_ring_pulse_factor(time.elapsed_secs())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut colour = FOCUS_RING;
|
||||||
|
colour.set_alpha(FOCUS_RING.alpha() * factor);
|
||||||
|
*border = BorderColor::all(colour);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Private marker for the single overlay entity
|
// Private marker for the single overlay entity
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -588,6 +637,40 @@ mod tests {
|
|||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, UiModalPlugin,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, UiModalPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_ring_pulse_factor_at_zero_is_mid_point() {
|
||||||
|
// sin(0) = 0 → factor = 0.825 (mid of [0.65, 1.0]).
|
||||||
|
let f = focus_ring_pulse_factor(0.0);
|
||||||
|
assert!((f - 0.825).abs() < 1e-5, "factor at t=0 should be 0.825, got {f}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_ring_pulse_factor_peaks_at_quarter_period() {
|
||||||
|
// sin(τ/4) = 1 → factor = 1.0.
|
||||||
|
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS / 4.0);
|
||||||
|
assert!((f - 1.0).abs() < 1e-4, "factor at peak should be 1.0, got {f}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_ring_pulse_factor_troughs_at_three_quarter_period() {
|
||||||
|
// sin(3τ/4) = -1 → factor = 0.65.
|
||||||
|
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS * 3.0 / 4.0);
|
||||||
|
assert!((f - 0.65).abs() < 1e-4, "factor at trough should be 0.65, got {f}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_ring_pulse_factor_stays_in_brightness_range() {
|
||||||
|
// Sweep across two full periods; factor must stay within [0.65, 1.0].
|
||||||
|
for i in 0..200 {
|
||||||
|
let t = i as f32 * MOTION_FOCUS_PULSE_SECS * 0.01;
|
||||||
|
let f = focus_ring_pulse_factor(t);
|
||||||
|
assert!(
|
||||||
|
(0.649..=1.001).contains(&f),
|
||||||
|
"factor at t={t} out of range: {f}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Plugin-marker for the synthetic test modal — `spawn_modal`
|
/// Plugin-marker for the synthetic test modal — `spawn_modal`
|
||||||
/// requires a `Component` on the scrim.
|
/// requires a `Component` on the scrim.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
|
|||||||
@@ -333,6 +333,11 @@ pub const MOTION_SHAKE_SECS: f32 = 0.25;
|
|||||||
/// Shake angular frequency in rad/s.
|
/// Shake angular frequency in rad/s.
|
||||||
pub const MOTION_SHAKE_OMEGA: f32 = 35.0;
|
pub const MOTION_SHAKE_OMEGA: f32 = 35.0;
|
||||||
|
|
||||||
|
/// Duration of the smooth return tween when a drag is rejected by an
|
||||||
|
/// invalid drop target. Short enough to feel snappy but long enough to
|
||||||
|
/// read as motion rather than a teleport.
|
||||||
|
pub const MOTION_DRAG_REJECT_SECS: f32 = 0.15;
|
||||||
|
|
||||||
/// Card flip — half-time per phase (squash + grow). 100 ms each =
|
/// Card flip — half-time per phase (squash + grow). 100 ms each =
|
||||||
/// 200 ms total. Pair with a ±8° Z-rotation at the midpoint for a 3D
|
/// 200 ms total. Pair with a ±8° Z-rotation at the midpoint for a 3D
|
||||||
/// feel without 3D rendering.
|
/// feel without 3D rendering.
|
||||||
@@ -379,10 +384,34 @@ pub const MOTION_BUTTON_BLEND_SECS: f32 = 0.10;
|
|||||||
/// readout 1.0 → 1.1 → 1.0. 250 ms.
|
/// readout 1.0 → 1.1 → 1.0. 250 ms.
|
||||||
pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25;
|
pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25;
|
||||||
|
|
||||||
|
/// Foundation-completion flourish — when a King lands on a foundation
|
||||||
|
/// pile (Ace → King, 13 cards), briefly scale the King card 1.0 →
|
||||||
|
/// [`FOUNDATION_FLOURISH_PEAK_SCALE`] → 1.0 and tint the matching
|
||||||
|
/// `PileMarker` gold. 400 ms.
|
||||||
|
pub const MOTION_FOUNDATION_FLOURISH_SECS: f32 = 0.4;
|
||||||
|
|
||||||
|
/// Peak scale magnification reached at the midpoint of the
|
||||||
|
/// foundation-completion flourish. The triangular curve climbs from
|
||||||
|
/// 1.0 at `t=0` to this value at `t=0.5` and back to 1.0 at `t=1.0`.
|
||||||
|
pub const FOUNDATION_FLOURISH_PEAK_SCALE: f32 = 1.15;
|
||||||
|
|
||||||
/// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step.
|
/// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step.
|
||||||
/// 400 ms.
|
/// 400 ms.
|
||||||
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
|
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
|
||||||
|
|
||||||
|
/// Period of the focus-ring breathing pulse, in seconds.
|
||||||
|
///
|
||||||
|
/// The keyboard focus ring's alpha is modulated by a sin-curve over this
|
||||||
|
/// interval so the indicator gently "breathes" instead of presenting as
|
||||||
|
/// a flat outline. 1.4 s reads as a calm heartbeat — slow enough that
|
||||||
|
/// the motion is in the player's peripheral vision rather than competing
|
||||||
|
/// for attention, fast enough that a focus change still draws the eye.
|
||||||
|
/// Not run through [`scaled_duration`]: the pulse is an accessibility
|
||||||
|
/// affordance, not gameplay motion. `AnimSpeed::Instant` is honoured at
|
||||||
|
/// the system level by skipping the pulse entirely (see
|
||||||
|
/// `pulse_focus_overlay` in `ui_focus`).
|
||||||
|
pub const MOTION_FOCUS_PULSE_SECS: f32 = 1.4;
|
||||||
|
|
||||||
/// Hover delay before a tooltip appears, in seconds. Long enough that
|
/// Hover delay before a tooltip appears, in seconds. Long enough that
|
||||||
/// players gliding the cursor across the HUD don't see flicker; short
|
/// players gliding the cursor across the HUD don't see flicker; short
|
||||||
/// enough that "stop and read" feels responsive. Not run through
|
/// enough that "stop and read" feels responsive. Not run through
|
||||||
|
|||||||
Reference in New Issue
Block a user