Compare commits
19 Commits
efa063fb8f
...
v0.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d891fb76 | |||
| 7dba772e67 | |||
| ca5788f714 | |||
| 9887343d8b | |||
| 525fe0fe76 | |||
| 69ce9afab9 | |||
| 13aa0fd833 | |||
| 9f095c4039 | |||
| d8c70341f4 | |||
| 063269c70e | |||
| b126df82b2 | |||
| 655dfde736 | |||
| f712b89fe4 | |||
| f6c916641a | |||
| 95df5421c9 | |||
| fdb6c2ecfe | |||
| 9a3d7f3876 | |||
| c4970b16ea | |||
| 2c72e1fc87 |
+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
|
||||
|
||||
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
|
||||
|
||||
- **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
|
||||
- **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
|
||||
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
|
||||
- **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
|
||||
|
||||
@@ -32,52 +50,73 @@ cargo build -p solitaire_app --release
|
||||
|
||||
## Controls
|
||||
|
||||
Every action also has a visible UI button — keyboard shortcuts are optional
|
||||
accelerators.
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| Left click / drag | Move cards |
|
||||
| Double click | Auto-move card to its best legal destination |
|
||||
| Right click | Highlight legal moves for a card |
|
||||
| Space / D | Draw from stock |
|
||||
| Z / Ctrl+Z | Undo |
|
||||
| U | Undo |
|
||||
| H | Hint (highlight a legal move) |
|
||||
| N | New game |
|
||||
| S | Stats overlay |
|
||||
| A | Achievements overlay |
|
||||
| P | Profile overlay |
|
||||
| O | Settings |
|
||||
| L | Leaderboard |
|
||||
| H | Help / controls |
|
||||
| Enter | Auto-complete (when badge is lit) |
|
||||
| Escape | Pause / clear selection |
|
||||
| Arrow keys | Navigate card selection |
|
||||
| Z | Zen mode |
|
||||
| G | Forfeit (during pause) |
|
||||
| Tab / Shift+Tab | Cycle keyboard focus |
|
||||
| Enter | Activate focused button / auto-complete (when badge is lit) |
|
||||
| Esc | Pause / dismiss modal |
|
||||
| F1 | Help / controls |
|
||||
| F11 | Toggle fullscreen |
|
||||
| S / A / P / O / L / M | Stats / Achievements / Profile / Settings / Leaderboard / Menu |
|
||||
|
||||
## 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)
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
# All tests (982 passing as of v0.11.0)
|
||||
cargo test --workspace
|
||||
|
||||
# Just game logic (no display required)
|
||||
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
|
||||
|
||||
# Lint
|
||||
cargo clippy --workspace -- -D warnings
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem (Tokio,
|
||||
Axum, sqlx, Serde, kira, and many more). Card faces come from
|
||||
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
||||
(Tokio, Axum, sqlx, Serde, kira, and many more). Card faces come from
|
||||
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
|
||||
(MIT, derived from the public-domain `vector-playing-cards` library); the
|
||||
default card back is original work; the UI font is FiraMono-Medium (OFL).
|
||||
All audio is synthesized programmatically by this project. See
|
||||
[CREDITS.md](CREDITS.md) for the full list and license details.
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
+74
-110
@@ -1,159 +1,123 @@
|
||||
# Solitaire Quest — UX Overhaul Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-01 — Phases 3, 4, 5 + the seven CARD_PLAN phases all shipped. v0.1.0 tagged locally. Bundled card art + runtime SVG theme system + in-Settings theme picker all live. Remaining work is desktop packaging and a player-side smoke test of the new theme.
|
||||
**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
|
||||
|
||||
- **HEAD:** `924a1e2`. v0.1.0 tag created locally (push pending interactive credentials).
|
||||
- **Working tree:** clean after the post-Phase cleanup pass.
|
||||
- **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 apart from this doc + CHANGELOG, both intentional.
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||
- **Tests:** **960 passed / 0 failed / 9 ignored** across the workspace.
|
||||
- **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`, `v0.11.0`. Local has `v0.11.0` too. v0.12.0 is the next tag.
|
||||
|
||||
## Where we are
|
||||
|
||||
Phase 3 (design tokens + modal scaffold) and Phase 4 (release polish) shipped earlier. Phase 5 — running the binary end-to-end and fixing what broke — landed nine more commits today: a layout fit fix so tableau columns stop spilling off-screen, a three-pronged resize-lag fix, persisted window geometry, splash skip on subsequent launches, achievement tooltips, a code-quality sweep, client-side sync round-trip tests, and a hit-test fix so dragging a card no longer requires aiming for the bottom strip.
|
||||
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.
|
||||
|
||||
Polish is essentially complete; the remaining work is tagging v0.1.0 and desktop packaging.
|
||||
The post-v0.11.0 UX candidate list is exhausted. Direction is open again.
|
||||
|
||||
### Design direction (unchanged)
|
||||
|
||||
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
|
||||
- **Palette:** Midnight Purple base + Balatro yellow primary + warm magenta secondary.
|
||||
- See [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for full direction.
|
||||
- See `~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md` (auto-memory; on a different machine, recreate this fresh from the README + ARCHITECTURE.md).
|
||||
|
||||
## Phase 3 (shipped)
|
||||
### Canonical remote
|
||||
|
||||
- `solitaire_engine/src/ui_theme.rs` — every design token: colours, type scale, spacing scale, radius rungs, z-index hierarchy, motion durations.
|
||||
- `solitaire_engine/src/ui_modal.rs` — `spawn_modal` scaffold + button-variant helpers + `paint_modal_buttons` system.
|
||||
- All 12 overlays migrated to the modal scaffold with real Primary/Secondary/Tertiary buttons (no more Y/N debug prompts).
|
||||
- HUD restructured into a 4-tier vertical stack with progressive disclosure.
|
||||
- Animation upgrades: `SmoothSnap` slide curves, scoped settle bounce, deal jitter, win-cascade rotation.
|
||||
`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.)
|
||||
|
||||
## Phase 4 (shipped 2026-04-30)
|
||||
## Session 7 round 1 (shipped 2026-05-02 morning) — v0.11.0
|
||||
|
||||
| Area | Commit | What landed |
|
||||
|---|---|---|
|
||||
| Workspace lint | `9bfca92` | Test-only clippy warnings under `--all-targets` resolved. |
|
||||
| App / window | `5f5aba8` | WM_CLASS, centered-on-primary window, panic hook → `crash.log`. |
|
||||
| Modal animation | `71999e1` | `ModalEntering` + ease-out scrim fade and 0.96→1.0 card scale. |
|
||||
| Score feedback | `dcfa976` | `ScorePulse` triangular 1.0→1.1→1.0; floating "+N" for jumps ≥ threshold. |
|
||||
| Hit targets | `b082bd6` | `ICON_BUTTON_PX` 28 → 32; sync status reads "local only". |
|
||||
| Microcopy | `abeb4e5` | Help "Close" → "Done"; final onboarding CTA → "Let's play". |
|
||||
| Empty states | `65d595a` | First-launch em-dash zero-stats grid + welcome line on Profile. |
|
||||
| Leaderboard | `1384365` | Idle/Loaded/Error enum; local-only guard. |
|
||||
| Credits | `fd7fb7b`, `f866299` | CREDITS.md added; README links it. |
|
||||
| Home | `c1bde18` | Home repurposed as Mode Launcher with level-5 lock state. |
|
||||
| Focus rings (Phase 1) | `1278952` | Tab/Shift-Tab/Enter on every modal button; auto-focus primary. |
|
||||
| Focus rings (Phase 2) | `51d3454` | HUD action bar (hover-gated) and Home mode cards. |
|
||||
| Focus rings (Phase 3) | `b78a493` | Settings: icon buttons, swatches, toggles; arrow-key `FocusRow`; auto-scroll. |
|
||||
| Achievement tests | `2e080d0` | Integration coverage for `draw_three_master` and `zen_winner`. |
|
||||
| Microcopy | `0c86cac` | "New game" / "Forfeit" replace "Yes, abandon" / "Yes, forfeit". |
|
||||
| Tooltip infra | `54d3497` | `Tooltip(Cow<'static, str>)` component + hover-delay overlay. |
|
||||
| HUD tooltips | `220e3f0` | 10 readouts + 6 action buttons. |
|
||||
| Settings tooltips | `74597a8` | Volume, toggles, swatches, Sync Now. |
|
||||
| Popover tooltips | `dbe6c60` | Modes and Menu rows. |
|
||||
| Splash | `5d57b67` | Branded splash overlay (300ms fade-in / ~1s hold / 300ms fade-out). |
|
||||
| Doc-rot | `73e210b` | ARCHITECTURE.md `bevy_kira_audio` references → `kira`. |
|
||||
| Doc | `de52c8a`, `60a8036` | Mid-session and end-of-Phase-4 SESSION_HANDOFF refreshes. |
|
||||
| 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)` with claim derived from the bottom card. Save schema 1 → 2; pre-v2 saves silently fall through to fresh game. |
|
||||
| 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 card casts a 25 % black shadow; lifts to 40 % with bigger offset/halo while in the active drag set. |
|
||||
| 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. |
|
||||
|
||||
## Phase 5 (shipped 2026-05-01)
|
||||
Tagged as `v0.11.0` (commit `063269c` plus URL refresh).
|
||||
|
||||
Smoke test surfaced three issues: window-resize lag, tableau columns clipped below viewport, hit-target offset on cards. All fixed, plus four bonus polish items.
|
||||
## Session 7 round 2 (shipped 2026-05-02 afternoon) — v0.12.0
|
||||
|
||||
| Area | Commit | What landed |
|
||||
|---|---|---|
|
||||
| Layout fit | `8dda954` | `card_height` constrained by vertical budget; worst-case 13-card column always fits. |
|
||||
| Resize perf | `1719fda` | In-place sprite/text mutation + 50ms `ResizeThrottle` (was full re-spawn per pixel). |
|
||||
| Resize stall | `59316de` | `PresentMode::AutoNoVsync` eliminates the X11/Wayland vsync stall during drag. |
|
||||
| Window geometry | `6e7705b` | `WindowGeometry` persisted to settings.json; debounced save on resize/move. |
|
||||
| Achievements | `7448225` | Tooltips on rows: reward shown when unlocked, condition + reward when locked, secrets stay cryptic. |
|
||||
| Lint sweep | `4b9d008` | 33 pedantic warnings cleared (`map_unwrap_or`, `uninlined_format_args`, `match_same_arms`). |
|
||||
| Sync tests | `3ef4ecb` | Five client-side round-trip integration tests via in-process axum + mock keyring. |
|
||||
| Splash | `912b08c` | Splash skipped on subsequent launches via existing `first_run_complete` flag. |
|
||||
| Hit test | `902560c` | `card_position` mirrors face-down fan step (0.12) for accurate AABB on tableau columns. |
|
||||
| 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. |
|
||||
|
||||
## Open punch list for v1
|
||||
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`.
|
||||
|
||||
1. **Player smoke-test of the new theme system.** Launch
|
||||
`cargo run -p solitaire_app --features bevy/dynamic_linking` and
|
||||
confirm: (a) hayeah card faces render correctly, (b) the
|
||||
midnight-purple `back.svg` shows on face-down cards, (c) the
|
||||
"Card Theme" picker appears in Settings → Cosmetic with at least
|
||||
the "Default" chip, (d) clicking the chip is a no-op (already
|
||||
selected) without errors.
|
||||
2. **Push the v0.1.0 tag** — `git push origin v0.1.0` once you're
|
||||
happy with the smoke-test outcome. Tag exists locally; not yet on
|
||||
origin.
|
||||
3. **Desktop packaging** per ARCHITECTURE.md §17. The Arch PKGBUILD
|
||||
exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo,
|
||||
no remote yet — `git remote add origin <URL>` and push to your
|
||||
gitea / AUR when ready). Still pending: app icon, macOS .icns +
|
||||
notarisation cert, Windows .ico + Authenticode cert, AppImage
|
||||
recipe.
|
||||
## Open punch list — release prep
|
||||
|
||||
### Optional, deferred
|
||||
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.
|
||||
|
||||
- Animated focus ring (currently a static overlay; could pulse on focus change).
|
||||
- Achievement onboarding pass — show first-time players the achievement panel after their first win.
|
||||
- Mode-switch keyboard shortcut from inside the Mode Launcher (today only mouse opens it).
|
||||
- Runtime aspect-ratio fidelity for the bundled hayeah cards: the SVG
|
||||
source is ~1.45 height/width while the engine layout assumes 1.4.
|
||||
Cards display ~3% squashed vertically; either widen the layout or
|
||||
letterbox the SVGs to match. Cosmetic-only; not blocking.
|
||||
## Open punch list — UX iteration (next-round candidates)
|
||||
|
||||
The v0.12.0 list is exhausted. Candidates for a future round:
|
||||
|
||||
- **Card-back theme support** — the current theme system swaps face SVGs but not the back. Players asked for animated backs in passing.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Right-click radial menu** for power users: hold right-click on a card → quick-drop options without dragging.
|
||||
- **Drag-with-keyboard** — Tab to a card, Enter to "lift", arrow keys to choose destination, Enter to drop. Keyboard-only completion of a game.
|
||||
- **Settings: tooltip-delay slider** so power users can disable the 0.5 s hover delay. Cheap.
|
||||
|
||||
## Card-theme system (CARD_PLAN.md, fully shipped)
|
||||
|
||||
Seven phases landed across `b8fb3fb` → `924a1e2`. End-to-end flow:
|
||||
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).
|
||||
- **User themes** live under `themes://` rooted at
|
||||
`solitaire_engine::assets::user_theme_dir()`. Drop a directory
|
||||
containing a valid `theme.ron` + 53 SVG files there and it
|
||||
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 referenced SVG round-tripped through the
|
||||
rasteriser) and atomically unpacks it into the user themes dir.
|
||||
- **Picker UI** in Settings → Cosmetic offers one chip per
|
||||
registered theme; selection persists to `settings.json` as
|
||||
`selected_theme_id` and propagates to live card sprites via
|
||||
`react_to_settings_theme_change` →
|
||||
`sync_card_image_set_with_active_theme` → `StateChangedEvent`.
|
||||
- **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.
|
||||
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates an archive and atomically unpacks.
|
||||
- **Picker UI** in Settings → Cosmetic.
|
||||
|
||||
## Resume prompt
|
||||
|
||||
```
|
||||
You are a senior Rust + Bevy developer finishing v1 of Solitaire
|
||||
Quest. Working directory: /home/manage/Rusty_Solitare. Branch:
|
||||
master. The polish phase is complete; the remaining work is release
|
||||
prep, not new features.
|
||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||
Working directory: <Rusty_Solitaire clone path on this machine — local
|
||||
directory may still be named Rusty_Solitare from earlier; that's fine>.
|
||||
Branch: master. Direction is OPEN — both UX iteration rounds shipped
|
||||
and v0.12.0 is ready to tag.
|
||||
|
||||
State: HEAD=902560c, fully pushed to origin. Working tree clean.
|
||||
State: HEAD at the doc-commit closing session 7 round 2. Local master
|
||||
is several commits ahead of origin and unpushed. Working tree clean
|
||||
apart from untracked CARD_PLAN.md (intentional).
|
||||
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
||||
Tests: 906 passed / 0 failed.
|
||||
Tests: 1007 passed / 0 failed.
|
||||
|
||||
READ FIRST (in order, before doing anything):
|
||||
1. SESSION_HANDOFF.md — full state and punch list
|
||||
2. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
||||
3. ARCHITECTURE.md §15, §17 — platform targets, deployment guide
|
||||
4. ~/.claude/projects/-home-manage-Rusty-Solitare/memory/MEMORY.md
|
||||
— saved feedback / project context
|
||||
1. SESSION_HANDOFF.md — full state, session 7 changelog, punch list
|
||||
2. CHANGELOG.md — release-by-release record
|
||||
3. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
||||
4. ARCHITECTURE.md — crate responsibilities + data flow
|
||||
5. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||
— saved feedback / project context (machine-local;
|
||||
may be missing on a fresh machine)
|
||||
|
||||
PUNCH LIST (in priority order):
|
||||
1. Confirm or fill the xCards upstream URL in CREDITS.md (one-line
|
||||
edit; not a release blocker).
|
||||
2. Tag v0.1.0 once the user signs off.
|
||||
3. Desktop packaging: icon hookup, platform bundles (.ico/.icns/
|
||||
AppImage), signing. Needs artwork and certs from the user.
|
||||
DECISION TO ASK THE PLAYER FIRST:
|
||||
A. Push the unpushed commits and cut v0.12.0 now.
|
||||
B. Smoke-test the new feel layer first (foundation flourish, drag
|
||||
tween, focus pulse, mode digits), then tag.
|
||||
C. Skip the tag for another iteration round — see "next-round
|
||||
candidates" in SESSION_HANDOFF for ideas.
|
||||
D. Take the deferred desktop-packaging item (needs artwork +
|
||||
signing certs from the user).
|
||||
|
||||
WORKFLOW NOTES:
|
||||
- Commits use:
|
||||
git -c user.name=funman300 -c user.email=root@vscode.infinity commit -m "..."
|
||||
git -c user.name=funman300 -c user.email=root@vscode.infinity \
|
||||
commit -m "..."
|
||||
- Sub-agents stage + verify only; orchestrator commits.
|
||||
- Every commit must pass build / clippy / test.
|
||||
- Every commit must pass build / clippy / test before pushing.
|
||||
- Push to GitHub (origin) — that is the canonical remote.
|
||||
|
||||
OPEN AT THE START: ask which punch-list item to start on. Don't pick
|
||||
unilaterally — release-readiness ordering is the user's call.
|
||||
OPEN AT THE START: ask which of A / B / C / D. Don't pick unilaterally.
|
||||
```
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
> Last updated: 2026-04-25
|
||||
> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitare.git
|
||||
> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitaire.git
|
||||
> Test count: **242 passing** (83 core + 60 data + 99 engine), `cargo clippy --workspace -- -D warnings` clean
|
||||
|
||||
---
|
||||
|
||||
@@ -16,13 +16,14 @@ fn main() -> io::Result<()> {
|
||||
let out_dir = workspace_root().join("assets").join("audio");
|
||||
fs::create_dir_all(&out_dir)?;
|
||||
|
||||
let effects: [(&str, Generator); 6] = [
|
||||
let effects: [(&str, Generator); 7] = [
|
||||
("card_flip.wav", card_flip),
|
||||
("card_place.wav", card_place),
|
||||
("card_deal.wav", card_deal),
|
||||
("card_invalid.wav", card_invalid),
|
||||
("win_fanfare.wav", win_fanfare),
|
||||
("ambient_loop.wav", ambient_loop),
|
||||
("foundation_complete.wav", foundation_complete),
|
||||
];
|
||||
|
||||
for (name, make) in &effects {
|
||||
@@ -170,6 +171,44 @@ fn win_fanfare() -> Vec<i16> {
|
||||
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
|
||||
/// mono 16-bit PCM).
|
||||
///
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::card::{Card, Suit};
|
||||
use crate::card::Card;
|
||||
use crate::deck::{deal_klondike, Deck};
|
||||
use crate::error::MoveError;
|
||||
use crate::pile::{Pile, PileType};
|
||||
@@ -9,6 +9,20 @@ use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score
|
||||
|
||||
const MAX_UNDO_STACK: usize = 64;
|
||||
|
||||
/// Save-file schema version for `GameState`. Increment when the on-disk
|
||||
/// representation changes incompatibly so `load_game_state_from` can refuse
|
||||
/// older formats and start the player on a fresh game.
|
||||
///
|
||||
/// History:
|
||||
/// - v1: `Foundation(Suit)` keys.
|
||||
/// - v2 (current): `Foundation(u8)` slot keys; claimed suit derived from the
|
||||
/// bottom card of the pile.
|
||||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
/// Default value for `GameState::schema_version` when deserialising older
|
||||
/// save files that pre-date the field.
|
||||
fn schema_v1() -> u32 { 1 }
|
||||
|
||||
/// Serialize `HashMap<PileType, Pile>` as a `Vec` of `(key, value)` pairs so
|
||||
/// that JSON (which requires string map keys) round-trips correctly.
|
||||
mod pile_map_serde {
|
||||
@@ -98,6 +112,11 @@ pub struct GameState {
|
||||
/// Used by the `comeback` achievement condition.
|
||||
#[serde(default)]
|
||||
pub recycle_count: u32,
|
||||
/// Save-file schema version. Defaults to `1` for older files that pre-date
|
||||
/// the field. The loader refuses any value other than
|
||||
/// [`GAME_STATE_SCHEMA_VERSION`].
|
||||
#[serde(default = "schema_v1")]
|
||||
pub schema_version: u32,
|
||||
undo_stack: VecDeque<StateSnapshot>,
|
||||
}
|
||||
|
||||
@@ -116,8 +135,8 @@ impl GameState {
|
||||
let mut piles: HashMap<PileType, Pile> = HashMap::new();
|
||||
piles.insert(PileType::Stock, stock);
|
||||
piles.insert(PileType::Waste, Pile::new(PileType::Waste));
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
piles.insert(PileType::Foundation(suit), Pile::new(PileType::Foundation(suit)));
|
||||
for slot in 0..4_u8 {
|
||||
piles.insert(PileType::Foundation(slot), Pile::new(PileType::Foundation(slot)));
|
||||
}
|
||||
for (i, pile) in tableau.into_iter().enumerate() {
|
||||
piles.insert(PileType::Tableau(i), pile);
|
||||
@@ -135,6 +154,7 @@ impl GameState {
|
||||
is_auto_completable: false,
|
||||
undo_count: 0,
|
||||
recycle_count: 0,
|
||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||
undo_stack: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
@@ -247,14 +267,14 @@ impl GameState {
|
||||
let bottom_card = from_pile.cards[start].clone();
|
||||
|
||||
match &to {
|
||||
PileType::Foundation(suit) => {
|
||||
PileType::Foundation(_) => {
|
||||
if count != 1 {
|
||||
return Err(MoveError::RuleViolation(
|
||||
"only one card can move to foundation at a time".into(),
|
||||
));
|
||||
}
|
||||
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
|
||||
if !can_place_on_foundation(&bottom_card, dest, *suit) {
|
||||
if !can_place_on_foundation(&bottom_card, dest) {
|
||||
return Err(MoveError::RuleViolation("invalid foundation placement".into()));
|
||||
}
|
||||
}
|
||||
@@ -332,13 +352,11 @@ impl GameState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` when all four foundations each contain 13 cards.
|
||||
/// Returns `true` when all four foundation slots each contain 13 cards.
|
||||
pub fn check_win(&self) -> bool {
|
||||
[Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]
|
||||
.iter()
|
||||
.all(|&suit| {
|
||||
(0..4_u8).all(|slot| {
|
||||
self.piles
|
||||
.get(&PileType::Foundation(suit))
|
||||
.get(&PileType::Foundation(slot))
|
||||
.is_some_and(|p| p.cards.len() == 13)
|
||||
})
|
||||
}
|
||||
@@ -379,13 +397,34 @@ impl GameState {
|
||||
if !self.is_auto_completable || self.is_won {
|
||||
return None;
|
||||
}
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
for i in 0..7 {
|
||||
let tableau = PileType::Tableau(i);
|
||||
if let Some(card) = self.piles[&tableau].cards.last() {
|
||||
for &suit in &suits {
|
||||
let foundation = PileType::Foundation(suit);
|
||||
if can_place_on_foundation(card, &self.piles[&foundation], suit) {
|
||||
// Prefer the slot that already claims this card's suit so
|
||||
// Aces don't sometimes land in slot 0 and then leave the
|
||||
// matching suit-claimed slot empty.
|
||||
let mut candidate: Option<u8> = None;
|
||||
let mut empty_slot: Option<u8> = None;
|
||||
for slot in 0..4_u8 {
|
||||
let foundation = PileType::Foundation(slot);
|
||||
let pile = &self.piles[&foundation];
|
||||
if pile.cards.is_empty() {
|
||||
if empty_slot.is_none() {
|
||||
empty_slot = Some(slot);
|
||||
}
|
||||
} else if pile.claimed_suit() == Some(card.suit) {
|
||||
candidate = Some(slot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let target_slot = candidate.or_else(|| {
|
||||
// Only fall back to an empty slot if the card is an Ace,
|
||||
// which is the only rank that can claim an empty slot.
|
||||
if card.rank.value() == 1 { empty_slot } else { None }
|
||||
});
|
||||
if let Some(slot) = target_slot {
|
||||
let foundation = PileType::Foundation(slot);
|
||||
if can_place_on_foundation(card, &self.piles[&foundation]) {
|
||||
return Some((tableau, foundation));
|
||||
}
|
||||
}
|
||||
@@ -403,7 +442,7 @@ impl GameState {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card::{Card, Rank};
|
||||
use crate::card::{Card, Rank, Suit};
|
||||
|
||||
fn new_game() -> GameState {
|
||||
GameState::new(42, DrawMode::DrawOne)
|
||||
@@ -434,8 +473,8 @@ mod tests {
|
||||
#[test]
|
||||
fn new_game_foundations_are_empty() {
|
||||
let g = new_game();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
assert!(g.piles[&PileType::Foundation(suit)].cards.is_empty());
|
||||
for slot in 0..4_u8 {
|
||||
assert!(g.piles[&PileType::Foundation(slot)].cards.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,7 +701,7 @@ mod tests {
|
||||
];
|
||||
let result = g.move_cards(
|
||||
PileType::Tableau(0),
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(0),
|
||||
2,
|
||||
);
|
||||
assert!(
|
||||
@@ -706,8 +745,9 @@ mod tests {
|
||||
#[test]
|
||||
fn win_detection_all_foundations_complete() {
|
||||
let mut g = new_game();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let f = g.piles.get_mut(&PileType::Foundation(suit)).unwrap();
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
for (slot, suit) in suits.into_iter().enumerate() {
|
||||
let f = g.piles.get_mut(&PileType::Foundation(slot as u8)).unwrap();
|
||||
f.cards.clear();
|
||||
for rank in [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||
@@ -1039,7 +1079,8 @@ mod tests {
|
||||
|
||||
let mv = g.next_auto_complete_move().expect("should find a move");
|
||||
assert_eq!(mv.0, PileType::Tableau(0));
|
||||
assert_eq!(mv.1, PileType::Foundation(Suit::Clubs));
|
||||
// Slot 0 is the first empty foundation; the Ace lands there.
|
||||
assert_eq!(mv.1, PileType::Foundation(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1049,4 +1090,143 @@ mod tests {
|
||||
g.is_won = true;
|
||||
assert!(g.next_auto_complete_move().is_none());
|
||||
}
|
||||
|
||||
// --- Slot-based foundation behaviour (refactor coverage) ---
|
||||
|
||||
/// Aces land in the first empty slot regardless of suit, and successive
|
||||
/// Aces fan out across slots 0, 1, 2, 3 in deterministic order.
|
||||
#[test]
|
||||
fn any_ace_lands_in_first_empty_foundation() {
|
||||
let mut g = new_game();
|
||||
// Clear stock/waste/tableau so we can hand-construct moves directly.
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
// Place an Ace of Clubs on tableau 0; move it to slot 0.
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||||
// Now place an Ace of Spades on tableau 0 and move it to slot 1.
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(1), 1).unwrap();
|
||||
|
||||
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Clubs));
|
||||
assert_eq!(g.piles[&PileType::Foundation(1)].claimed_suit(), Some(Suit::Spades));
|
||||
}
|
||||
|
||||
/// `Pile::claimed_suit` reads the bottom card's suit on a populated
|
||||
/// foundation slot, regardless of which slot index the pile occupies.
|
||||
#[test]
|
||||
fn claimed_suit_is_derived_from_bottom_card() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 50, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(2), 1).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
g.piles[&PileType::Foundation(2)].claimed_suit(),
|
||||
Some(Suit::Hearts)
|
||||
);
|
||||
}
|
||||
|
||||
/// Undoing the only card from a foundation slot drops the claimed suit;
|
||||
/// the slot then accepts a different Ace.
|
||||
#[test]
|
||||
fn foundation_claim_drops_when_emptied_via_undo() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||||
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Hearts));
|
||||
|
||||
g.undo().unwrap();
|
||||
assert!(g.piles[&PileType::Foundation(0)].cards.is_empty());
|
||||
assert!(g.piles[&PileType::Foundation(0)].claimed_suit().is_none());
|
||||
|
||||
// A different Ace can now claim slot 0.
|
||||
let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.clear();
|
||||
t0.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||||
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Spades));
|
||||
}
|
||||
|
||||
/// Successive Aces from the waste pile distribute across slots 0..=3 in
|
||||
/// order — the player picks the slot, but `move_cards` accepts any
|
||||
/// empty-slot placement for an Ace.
|
||||
#[test]
|
||||
fn multiple_aces_distribute_across_slots() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
let aces = [
|
||||
(Suit::Clubs, 10),
|
||||
(Suit::Diamonds, 11),
|
||||
(Suit::Hearts, 12),
|
||||
(Suit::Spades, 13),
|
||||
];
|
||||
for (slot, (suit, id)) in aces.iter().enumerate() {
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||||
id: *id, suit: *suit, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Waste, PileType::Foundation(slot as u8), 1).unwrap();
|
||||
}
|
||||
for (slot, (suit, _)) in aces.iter().enumerate() {
|
||||
assert_eq!(
|
||||
g.piles[&PileType::Foundation(slot as u8)].claimed_suit(),
|
||||
Some(*suit),
|
||||
"slot {slot} should claim {suit:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-complete prefers the foundation slot whose claimed suit matches
|
||||
/// the candidate card's suit, even if an empty slot exists at a lower
|
||||
/// index.
|
||||
#[test]
|
||||
fn next_auto_complete_move_picks_slot_with_matching_claim() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
// Slot 0 is empty; slot 1 already claims Hearts via Ace of Hearts.
|
||||
g.piles.get_mut(&PileType::Foundation(1)).unwrap().cards.push(Card {
|
||||
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
// Tableau 0 holds the 2 of Hearts to play.
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 2, suit: Suit::Hearts, rank: Rank::Two, face_up: true,
|
||||
});
|
||||
g.is_auto_completable = true;
|
||||
|
||||
let mv = g.next_auto_complete_move().expect("auto-complete must find slot 1");
|
||||
assert_eq!(mv.0, PileType::Tableau(0));
|
||||
assert_eq!(
|
||||
mv.1,
|
||||
PileType::Foundation(1),
|
||||
"must target the Hearts-claimed slot, not the empty slot 0",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ pub enum PileType {
|
||||
Stock,
|
||||
/// The face-up discard pile drawn to.
|
||||
Waste,
|
||||
/// One of the four suit-ordered foundation piles.
|
||||
Foundation(Suit),
|
||||
/// One of the four foundation slots (0..=3). The claimed suit, if any,
|
||||
/// is derived from the bottom card of the pile (always an Ace by
|
||||
/// construction).
|
||||
Foundation(u8),
|
||||
/// One of the seven tableau columns (0–6).
|
||||
Tableau(usize),
|
||||
}
|
||||
@@ -17,7 +19,7 @@ pub enum PileType {
|
||||
/// A named collection of cards in a specific board position.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Pile {
|
||||
/// Which pile this is (Stock, Waste, Foundation suit, or Tableau column).
|
||||
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
|
||||
pub pile_type: PileType,
|
||||
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||||
pub cards: Vec<Card>,
|
||||
@@ -33,6 +35,16 @@ impl Pile {
|
||||
pub fn top(&self) -> Option<&Card> {
|
||||
self.cards.last()
|
||||
}
|
||||
|
||||
/// For foundation piles: returns `Some(suit)` once at least one card has
|
||||
/// landed (the bottom card is always an Ace of the claimed suit).
|
||||
/// Returns `None` for empty foundations or non-foundation piles.
|
||||
pub fn claimed_suit(&self) -> Option<Suit> {
|
||||
match self.pile_type {
|
||||
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -61,12 +73,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_type_foundation_uses_suit() {
|
||||
assert_ne!(PileType::Foundation(Suit::Hearts), PileType::Foundation(Suit::Spades));
|
||||
fn pile_type_foundation_uses_slot_index() {
|
||||
assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_type_tableau_uses_index() {
|
||||
assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_is_none_for_empty_foundation() {
|
||||
let pile = Pile::new(PileType::Foundation(0));
|
||||
assert!(pile.claimed_suit().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_is_none_for_non_foundation() {
|
||||
let mut pile = Pile::new(PileType::Tableau(0));
|
||||
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
|
||||
assert!(pile.claimed_suit().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_returns_bottom_card_suit() {
|
||||
let mut pile = Pile::new(PileType::Foundation(2));
|
||||
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
|
||||
pile.cards.push(Card { id: 1, suit: Suit::Hearts, rank: Rank::Two, face_up: true });
|
||||
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
|
||||
}
|
||||
}
|
||||
|
||||
+37
-26
@@ -1,16 +1,18 @@
|
||||
use crate::card::{Card, Suit};
|
||||
use crate::card::Card;
|
||||
use crate::pile::Pile;
|
||||
|
||||
/// Returns `true` if `card` can be placed on `pile` as the next card in the foundation for `suit`.
|
||||
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
||||
///
|
||||
/// Foundation rules: same suit, Ace starts, each subsequent card is one rank higher.
|
||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile, suit: Suit) -> bool {
|
||||
if card.suit != suit {
|
||||
return false;
|
||||
}
|
||||
/// Foundation rules:
|
||||
/// - When the pile is empty, any Ace is accepted; the placed Ace's suit
|
||||
/// becomes the pile's claimed suit (derived from the bottom card via
|
||||
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
|
||||
/// - When the pile is non-empty, the next card must match the top card's
|
||||
/// suit and be exactly one rank higher.
|
||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
||||
match pile.cards.last() {
|
||||
None => card.rank.value() == 1,
|
||||
Some(top) => card.rank.value() == top.rank.value() + 1,
|
||||
Some(top) => card.suit == top.suit && card.rank.value() == top.rank.value() + 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,37 +47,46 @@ mod tests {
|
||||
// Foundation tests
|
||||
#[test]
|
||||
fn foundation_ace_on_empty_is_valid() {
|
||||
let c = card(Suit::Hearts, Rank::Ace);
|
||||
let p = Pile::new(PileType::Foundation(Suit::Hearts));
|
||||
assert!(can_place_on_foundation(&c, &p, Suit::Hearts));
|
||||
// Every suit's Ace must land on an empty foundation slot regardless of
|
||||
// its slot index; the slot claims the suit only after the Ace lands.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let c = card(suit, Rank::Ace);
|
||||
let p = Pile::new(PileType::Foundation(0));
|
||||
assert!(
|
||||
can_place_on_foundation(&c, &p),
|
||||
"Ace of {suit:?} must land on empty slot 0",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_non_ace_on_empty_is_invalid() {
|
||||
let c = card(Suit::Hearts, Rank::Two);
|
||||
let p = Pile::new(PileType::Foundation(Suit::Hearts));
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Hearts));
|
||||
let p = Pile::new(PileType::Foundation(0));
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_two_on_ace_same_suit_is_valid() {
|
||||
let c = card(Suit::Clubs, Rank::Two);
|
||||
let p = pile_with(PileType::Foundation(Suit::Clubs), vec![card(Suit::Clubs, Rank::Ace)]);
|
||||
assert!(can_place_on_foundation(&c, &p, Suit::Clubs));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
|
||||
assert!(can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_wrong_suit_is_invalid() {
|
||||
let c = card(Suit::Hearts, Rank::Ace);
|
||||
let p = Pile::new(PileType::Foundation(Suit::Spades));
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
|
||||
fn foundation_second_card_must_match_claimed_suit() {
|
||||
// Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected
|
||||
// because the slot's claimed suit is Hearts after the Ace lands.
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Hearts, Rank::Ace)]);
|
||||
let c = card(Suit::Spades, Rank::Two);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_skipping_rank_is_invalid() {
|
||||
let c = card(Suit::Diamonds, Rank::Three);
|
||||
let p = pile_with(PileType::Foundation(Suit::Diamonds), vec![card(Suit::Diamonds, Rank::Ace)]);
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Diamonds));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Diamonds, Rank::Ace)]);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
// Tableau tests
|
||||
@@ -125,16 +136,16 @@ mod tests {
|
||||
fn foundation_king_on_queen_completes_suit() {
|
||||
// The last card placed to complete a foundation is always King on Queen.
|
||||
let c = card(Suit::Spades, Rank::King);
|
||||
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(can_place_on_foundation(&c, &p, Suit::Spades));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_king_wrong_suit_is_invalid() {
|
||||
// King of Hearts cannot go on a Spades foundation even if rank matches.
|
||||
// King of Hearts cannot go on a Spades-claimed foundation even if rank matches.
|
||||
let c = card(Suit::Hearts, Rank::King);
|
||||
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,12 +33,11 @@ pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card::Suit;
|
||||
|
||||
#[test]
|
||||
fn move_to_foundation_scores_ten() {
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(Suit::Hearts)), 10);
|
||||
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(Suit::Clubs)), 10);
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
|
||||
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(0)), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -74,7 +73,7 @@ mod tests {
|
||||
#[test]
|
||||
fn non_waste_to_tableau_scores_zero() {
|
||||
// Foundation → Tableau is impossible in practice but must score 0.
|
||||
assert_eq!(score_move(&PileType::Foundation(Suit::Clubs), &PileType::Tableau(0)), 0);
|
||||
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), 0);
|
||||
// Tableau → Tableau (restack) scores 0.
|
||||
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
|
||||
}
|
||||
|
||||
@@ -132,6 +132,17 @@ pub struct Settings {
|
||||
/// `#[serde(default = ...)]`.
|
||||
#[serde(default = "default_theme_id")]
|
||||
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 {
|
||||
@@ -165,6 +176,7 @@ impl Default for Settings {
|
||||
color_blind_mode: false,
|
||||
window_geometry: None,
|
||||
selected_theme_id: default_theme_id(),
|
||||
shown_achievement_onboarding: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,6 +330,7 @@ mod tests {
|
||||
color_blind_mode: false,
|
||||
window_geometry: None,
|
||||
selected_theme_id: "default".to_string(),
|
||||
shown_achievement_onboarding: false,
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
@@ -506,4 +519,48 @@ mod tests {
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
|
||||
|
||||
use crate::stats::StatsSnapshot;
|
||||
|
||||
@@ -72,10 +72,21 @@ pub fn game_state_file_path() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
|
||||
/// missing, corrupt, or represents a finished game.
|
||||
/// missing, corrupt, represents a finished game, or carries a save-schema
|
||||
/// version other than [`GAME_STATE_SCHEMA_VERSION`].
|
||||
///
|
||||
/// Schema mismatch is treated as "no save" so a player upgrading across an
|
||||
/// incompatible game-state format change starts fresh instead of seeing a
|
||||
/// half-loaded game (or a deserialiser error). v1 saves with the old
|
||||
/// `Foundation(Suit)` key shape will fail to parse outright; any v1 saves
|
||||
/// that happen to round-trip but report `schema_version: 1` are also rejected
|
||||
/// here.
|
||||
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
|
||||
let data = fs::read(path).ok()?;
|
||||
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
||||
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
|
||||
return None;
|
||||
}
|
||||
if gs.is_won {
|
||||
None
|
||||
} else {
|
||||
@@ -331,4 +342,49 @@ mod tests {
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||
}
|
||||
|
||||
/// Pre-v2 save files used `Foundation(Suit)` keys and either fail to
|
||||
/// parse outright or surface a `schema_version: 1`. Either path must
|
||||
/// produce `None` so the player launches into a fresh game.
|
||||
///
|
||||
/// Sibling assertion: the stats round-trip path is unaffected — only
|
||||
/// the game-state schema bumped.
|
||||
#[test]
|
||||
fn save_format_v1_is_rejected() {
|
||||
let path = gs_path("schema_v1");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// A pared-down v1 JSON literal: foundation pile keys use the old
|
||||
// suit-tagged form and the file omits `schema_version` (so it
|
||||
// deserialises with the default of 1). Even if a future change
|
||||
// makes `Foundation(Suit)` parse-compatible, the schema-version
|
||||
// gate keeps this case rejected.
|
||||
let v1_json = r#"{
|
||||
"piles": [
|
||||
[{"Foundation": "Hearts"}, {"pile_type": {"Foundation": "Hearts"}, "cards": []}]
|
||||
],
|
||||
"draw_mode": "DrawOne",
|
||||
"score": 0,
|
||||
"move_count": 0,
|
||||
"elapsed_seconds": 0,
|
||||
"seed": 42,
|
||||
"is_won": false,
|
||||
"is_auto_completable": false,
|
||||
"undo_count": 0,
|
||||
"undo_stack": []
|
||||
}"#;
|
||||
fs::write(&path, v1_json).expect("write v1 fixture");
|
||||
|
||||
assert!(
|
||||
load_game_state_from(&path).is_none(),
|
||||
"v1 game_state.json must be rejected (parse failure or schema bump)",
|
||||
);
|
||||
|
||||
// Sibling sanity: stats files are independent and still round-trip.
|
||||
let stats_path = tmp_path("schema_unrelated_stats");
|
||||
let _ = fs::remove_file(&stats_path);
|
||||
save_stats_to(&stats_path, &StatsSnapshot::default()).expect("save stats");
|
||||
let loaded = load_stats_from(&stats_path);
|
||||
assert_eq!(loaded, StatsSnapshot::default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,17 +14,19 @@ use solitaire_core::achievement::{
|
||||
ALL_ACHIEVEMENTS,
|
||||
};
|
||||
use solitaire_data::{
|
||||
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
|
||||
save_progress_to,
|
||||
achievements_file_path, load_achievements_from, save_achievements_to, save_settings_to,
|
||||
AchievementRecord, save_progress_to,
|
||||
};
|
||||
|
||||
use crate::events::{
|
||||
AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent,
|
||||
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, ToggleAchievementsRequestEvent,
|
||||
XpAwardedEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
use crate::ui_modal::{
|
||||
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::<GameWonEvent>()
|
||||
.add_message::<XpAwardedEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ToggleAchievementsRequestEvent>()
|
||||
// Run after GameMutation (so GameWonEvent is available), after
|
||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||
@@ -102,6 +105,16 @@ impl Plugin for AchievementPlugin {
|
||||
.after(StatsUpdate)
|
||||
.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, 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.
|
||||
/// Used by the toast renderer in `animation_plugin`.
|
||||
pub fn display_name_for(id: &str) -> String {
|
||||
@@ -921,4 +995,187 @@ mod tests {
|
||||
assert!(s.contains("How to unlock"));
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,13 +153,28 @@ pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoader
|
||||
}
|
||||
|
||||
/// Returns a process-wide font database populated with the OS-installed
|
||||
/// fonts the user has available. Initialised lazily on first SVG that
|
||||
/// references text, then shared (via `Arc`) across every subsequent
|
||||
/// rasterisation. `usvg::Options::default()` ships an empty `fontdb`,
|
||||
/// so without this call any text glyph in an SVG renders with no font
|
||||
/// match — the visible symptom on the bundled hayeah artwork is the
|
||||
/// "No match for Arial font-family" warn spam plus glyphs that fall
|
||||
/// through to whatever shape-only path usvg uses for missing fonts.
|
||||
/// fonts plus the bundled FiraMono-Medium face. Initialised lazily on
|
||||
/// first SVG that references text, then shared (via `Arc`) across every
|
||||
/// subsequent rasterisation.
|
||||
///
|
||||
/// `usvg::Options::default()` ships an empty `fontdb`, so without this
|
||||
/// call any text glyph in an SVG renders with no font match — the
|
||||
/// visible symptom on the bundled hayeah artwork is the "No match for
|
||||
/// Arial font-family" warn spam plus glyphs that fall through to
|
||||
/// whatever shape-only path usvg uses for missing fonts.
|
||||
///
|
||||
/// **Bundled font as last-resort fallback.** Loading only system fonts
|
||||
/// breaks on minimal Linux installs, fresh Wayland sessions, and
|
||||
/// chroots where fontconfig has nothing usable to serve as
|
||||
/// `sans-serif`. The cards on the bundled hayeah theme reference
|
||||
/// `Bitstream Vera Sans` and `Arial` by name — if neither is installed
|
||||
/// AND the resolver's CSS-generic fallbacks (`SansSerif`/`Serif`) also
|
||||
/// don't resolve, the rank/suit text vanishes entirely. Loading the
|
||||
/// project's bundled FiraMono via `include_bytes!()` and pinning it as
|
||||
/// the generic-family target guarantees a working last-resort glyph
|
||||
/// source on every machine. This was the cause of "card font didn't
|
||||
/// carry over" on a fresh second-machine pull.
|
||||
///
|
||||
/// `load_system_fonts` is comparatively expensive (~50–200 ms on a
|
||||
/// typical desktop) so we only pay it once for the lifetime of the
|
||||
/// process, gated by `OnceLock`.
|
||||
@@ -168,6 +183,20 @@ fn shared_fontdb() -> Arc<fontdb::Database> {
|
||||
DB.get_or_init(|| {
|
||||
let mut db = fontdb::Database::new();
|
||||
db.load_system_fonts();
|
||||
// The bundled FiraMono lives at the workspace root, so the
|
||||
// include_bytes! path goes up three levels from this source
|
||||
// file (assets → src → solitaire_engine → workspace root).
|
||||
db.load_font_data(include_bytes!("../../../assets/fonts/main.ttf").to_vec());
|
||||
// Pin the CSS generics to the bundled face as the resolution
|
||||
// target. Named-family lookups (Bitstream Vera Sans, Arial)
|
||||
// still try the system db first; only when those miss does
|
||||
// the resolver fall through to SansSerif / Serif, and now
|
||||
// those are guaranteed to land on FiraMono.
|
||||
db.set_sans_serif_family("Fira Mono");
|
||||
db.set_serif_family("Fira Mono");
|
||||
db.set_monospace_family("Fira Mono");
|
||||
db.set_cursive_family("Fira Mono");
|
||||
db.set_fantasy_family("Fira Mono");
|
||||
Arc::new(db)
|
||||
})
|
||||
.clone()
|
||||
|
||||
@@ -28,8 +28,8 @@ use kira::track::{TrackBuilder, TrackHandle};
|
||||
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
|
||||
|
||||
use crate::events::{
|
||||
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
|
||||
MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
|
||||
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent,
|
||||
GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
|
||||
};
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
@@ -70,6 +70,12 @@ pub struct SoundLibrary {
|
||||
pub place: StaticSoundData,
|
||||
pub invalid: 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
|
||||
@@ -145,6 +151,7 @@ impl Plugin for AudioPlugin {
|
||||
.add_message::<CardFlippedEvent>()
|
||||
.add_message::<CardFaceRevealedEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_systems(Startup, apply_initial_volume)
|
||||
.add_systems(
|
||||
@@ -157,6 +164,7 @@ impl Plugin for AudioPlugin {
|
||||
play_on_win,
|
||||
play_on_face_revealed,
|
||||
play_on_undo,
|
||||
play_on_foundation_complete,
|
||||
apply_volume_on_change,
|
||||
handle_mute_keys,
|
||||
),
|
||||
@@ -170,12 +178,15 @@ fn build_library() -> Option<SoundLibrary> {
|
||||
let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?;
|
||||
let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?;
|
||||
let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?;
|
||||
let foundation_complete =
|
||||
decode(include_bytes!("../../assets/audio/foundation_complete.wav"))?;
|
||||
Some(SoundLibrary {
|
||||
deal,
|
||||
flip,
|
||||
place,
|
||||
invalid,
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -196,7 +196,8 @@ mod tests {
|
||||
// At least one MoveRequestEvent should have been fired.
|
||||
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
|
||||
assert_eq!(fired[0].from, PileType::Tableau(0));
|
||||
assert_eq!(fired[0].to, PileType::Foundation(Suit::Clubs));
|
||||
// First empty foundation slot wins on a fresh nearly-won board.
|
||||
assert_eq!(fired[0].to, PileType::Foundation(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,6 +22,7 @@ use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
||||
use crate::card_animation::CardAnimation;
|
||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::{Layout, LayoutResource, LayoutSystem};
|
||||
@@ -29,6 +30,12 @@ use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
use crate::table_plugin::PileMarker;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::ui_theme::{
|
||||
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
||||
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
|
||||
CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TYPE_CAPTION, Z_STOCK_BADGE,
|
||||
};
|
||||
|
||||
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
||||
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
@@ -44,8 +51,11 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
|
||||
|
||||
/// Fraction of card height used as a tiny offset between stacked cards in
|
||||
/// non-tableau piles, so stacking is visible.
|
||||
const STACK_FAN_FRAC: f32 = 0.003;
|
||||
/// non-tableau piles, so stacking is visible. Public so other plugins
|
||||
/// (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.
|
||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||
@@ -132,6 +142,27 @@ pub struct RightClickHighlightTimer(pub f32);
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StockEmptyLabel;
|
||||
|
||||
/// Marker on the chip-background sprite of the stock-pile remaining-count
|
||||
/// badge.
|
||||
///
|
||||
/// The badge is spawned as a *top-level* world entity (not parented to the
|
||||
/// stock [`PileMarker`]) and its `Transform` is recomputed each frame from
|
||||
/// `LayoutResource` so it tracks the stock pile through window resizes.
|
||||
/// The chip sits in the top-right corner of the stock pile and is hidden
|
||||
/// while the stock is empty — the existing `↺` overlay
|
||||
/// ([`StockEmptyLabel`]) covers the recycle hint instead, so the two
|
||||
/// indicators never render simultaneously.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StockCountBadge;
|
||||
|
||||
/// Marker on the `Text2d` child of [`StockCountBadge`] showing the numeric
|
||||
/// count of cards remaining in the stock pile.
|
||||
///
|
||||
/// Update systems query this component to write the new count in place rather
|
||||
/// than despawning and respawning the text entity each tick.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StockCountBadgeText;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #34 — Card-flip animation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -168,6 +199,72 @@ const FLIP_HALF_SECS: f32 = 0.08;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ShadowEntity;
|
||||
|
||||
/// Marker component for the per-card drop-shadow child sprite.
|
||||
///
|
||||
/// Every `CardEntity` owns exactly one `CardShadow` child whose `Sprite` is a
|
||||
/// neutral-black halo painted slightly down-and-right of the card. Idle state
|
||||
/// uses [`CARD_SHADOW_OFFSET_IDLE`] / [`CARD_SHADOW_ALPHA_IDLE`]; while the
|
||||
/// parent card is being dragged the shadow is pushed to the deeper
|
||||
/// [`CARD_SHADOW_OFFSET_DRAG`] / [`CARD_SHADOW_ALPHA_DRAG`] values so the
|
||||
/// stack reads as "lifted" off the felt.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardShadow;
|
||||
|
||||
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
|
||||
/// shadow given whether its parent card is currently part of the dragged
|
||||
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
|
||||
/// without spinning up a Bevy app.
|
||||
///
|
||||
/// `is_dragged = false` → resting `(IDLE, IDLE, IDLE)`
|
||||
/// `is_dragged = true` → lifted `(DRAG, DRAG, DRAG)`
|
||||
pub fn card_shadow_params(is_dragged: bool) -> (Vec2, Vec2, f32) {
|
||||
if is_dragged {
|
||||
(
|
||||
CARD_SHADOW_OFFSET_DRAG,
|
||||
CARD_SHADOW_PADDING_DRAG,
|
||||
CARD_SHADOW_ALPHA_DRAG,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
CARD_SHADOW_OFFSET_IDLE,
|
||||
CARD_SHADOW_PADDING_IDLE,
|
||||
CARD_SHADOW_ALPHA_IDLE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `Sprite` used for a per-card shadow at the resting state. The
|
||||
/// alpha and size both use the idle tokens; `update_card_shadows_on_drag`
|
||||
/// retunes them at runtime when the parent card joins / leaves the dragged
|
||||
/// stack.
|
||||
fn card_shadow_sprite(card_size: Vec2) -> Sprite {
|
||||
let (_offset, padding, alpha) = card_shadow_params(false);
|
||||
Sprite {
|
||||
color: CARD_SHADOW_COLOR.with_alpha(alpha),
|
||||
custom_size: Some(card_size + padding),
|
||||
..default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `Transform` used for a per-card shadow at the resting state.
|
||||
/// Local — it is parented to the card entity, so positions are relative.
|
||||
fn card_shadow_transform() -> Transform {
|
||||
let (offset, _padding, _alpha) = card_shadow_params(false);
|
||||
Transform::from_xyz(offset.x, offset.y, CARD_SHADOW_LOCAL_Z)
|
||||
}
|
||||
|
||||
/// Spawns a single `CardShadow` child under the given card entity builder.
|
||||
/// Extracted so `spawn_card_entity` and `update_card_entity` can share the
|
||||
/// exact same shadow recipe — we never want one path to drift from the other.
|
||||
fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||
parent.spawn((
|
||||
CardShadow,
|
||||
card_shadow_sprite(card_size),
|
||||
card_shadow_transform(),
|
||||
Visibility::default(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Throttle interval for resize-driven card snap work, in seconds.
|
||||
///
|
||||
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
|
||||
@@ -228,12 +325,14 @@ impl Plugin for CardPlugin {
|
||||
start_flip_anim.after(GameMutation),
|
||||
tick_flip_anim,
|
||||
update_drag_shadow,
|
||||
update_card_shadows_on_drag.after(sync_cards_on_change),
|
||||
tick_hint_highlight,
|
||||
handle_right_click,
|
||||
tick_right_click_highlights,
|
||||
clear_right_click_highlights_on_state_change.after(GameMutation),
|
||||
clear_right_click_highlights_on_pause,
|
||||
update_stock_empty_indicator.after(GameMutation),
|
||||
update_stock_count_badge.after(GameMutation),
|
||||
collect_resize_events.after(LayoutSystem::UpdateOnResize),
|
||||
snap_cards_on_window_resize.after(collect_resize_events),
|
||||
),
|
||||
@@ -352,7 +451,7 @@ fn sync_cards_startup(
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
) {
|
||||
if let Some(layout) = layout {
|
||||
@@ -372,7 +471,7 @@ fn sync_cards_on_change(
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
@@ -395,22 +494,27 @@ fn sync_cards(
|
||||
slide_secs: f32,
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
|
||||
// Map card_id -> (Entity, current_translation) for in-place updates.
|
||||
let mut existing: HashMap<u32, (Entity, Vec3)> = HashMap::new();
|
||||
for (entity, marker, transform) in entities.iter() {
|
||||
existing.insert(marker.card_id, (entity, transform.translation));
|
||||
// Map card_id -> (Entity, current_translation, has_card_animation) for
|
||||
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
|
||||
// skip the snap/slide path on cards that are already being driven by a
|
||||
// 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();
|
||||
|
||||
// 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) {
|
||||
commands.entity(*entity).despawn();
|
||||
}
|
||||
@@ -419,10 +523,10 @@ fn sync_cards(
|
||||
// For each card in the current state: spawn or update its entity.
|
||||
for (card, position, z) in positions {
|
||||
match existing.get(&card.id) {
|
||||
Some(&(entity, cur)) => {
|
||||
Some(&(entity, cur, has_anim)) => {
|
||||
update_card_entity(
|
||||
&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),
|
||||
@@ -436,10 +540,10 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
||||
let piles = [
|
||||
PileType::Stock,
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
@@ -534,6 +638,13 @@ fn spawn_card_entity(
|
||||
Transform::from_xyz(pos.x, pos.y, z),
|
||||
Visibility::default(),
|
||||
));
|
||||
// Every card gets a subtle drop-shadow child so the play surface reads
|
||||
// as physical instead of flat. Spawned in idle state; the drag-tracking
|
||||
// system retunes its offset / alpha when this card joins the dragged
|
||||
// stack.
|
||||
entity.with_children(|b| {
|
||||
add_card_shadow_child(b, layout.card_size);
|
||||
});
|
||||
// When PNG faces are loaded the rank/suit are baked into the image.
|
||||
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
||||
if card_images.is_none() {
|
||||
@@ -565,6 +676,7 @@ fn update_card_entity(
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
cur: Vec3,
|
||||
has_card_animation: bool,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) {
|
||||
@@ -573,6 +685,12 @@ fn update_card_entity(
|
||||
// Always refresh the visual appearance.
|
||||
commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
|
||||
|
||||
// Skip the snap/slide path entirely when a curve-based `CardAnimation`
|
||||
// is driving this card (e.g. the drag-rejection return tween). Writing
|
||||
// `Transform` here would race that animation each frame and cause a
|
||||
// visible jump. The animation system snaps the final position itself
|
||||
// when it completes.
|
||||
if !has_card_animation {
|
||||
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
||||
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
|
||||
@@ -592,11 +710,15 @@ fn update_card_entity(
|
||||
.remove::<CardAnim>()
|
||||
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
||||
}
|
||||
}
|
||||
|
||||
// Despawn any stale children and re-add the label overlay only when
|
||||
// operating in solid-colour mode (no PNG faces). In image mode the
|
||||
// rank/suit are baked into the PNG, so no Text2d overlay is needed.
|
||||
// Despawn any stale children and re-add the per-card drop shadow plus,
|
||||
// in solid-colour fallback mode, the label overlay. In image mode the
|
||||
// rank/suit are baked into the PNG, so no `Text2d` overlay is needed.
|
||||
commands.entity(entity).despawn_related::<Children>();
|
||||
commands.entity(entity).with_children(|b| {
|
||||
add_card_shadow_child(b, layout.card_size);
|
||||
});
|
||||
if card_images.is_none() {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
@@ -795,6 +917,43 @@ fn update_drag_shadow(
|
||||
}
|
||||
}
|
||||
|
||||
/// Snaps every per-card [`CardShadow`] between its idle and lifted tunings
|
||||
/// based on whether the parent [`CardEntity`] is currently in
|
||||
/// [`DragState::cards`]. Runs every frame; the transition is an instant snap
|
||||
/// (no lerp) — the existing shake / settle feedback already handles motion
|
||||
/// at drag-end, so an additional shadow tween would compete with those cues.
|
||||
///
|
||||
/// The shadow size is rebuilt from the parent card's current `Sprite`
|
||||
/// `custom_size` plus the appropriate padding, so the resize handler does
|
||||
/// not need to pre-tune shadow sizes for the drag state — this system fixes
|
||||
/// the geometry within one frame.
|
||||
fn update_card_shadows_on_drag(
|
||||
drag: Res<DragState>,
|
||||
cards: Query<(&CardEntity, &Sprite, &Children), Without<CardShadow>>,
|
||||
mut shadows: Query<(&mut Sprite, &mut Transform), With<CardShadow>>,
|
||||
) {
|
||||
let dragged: HashSet<u32> = drag.cards.iter().copied().collect();
|
||||
|
||||
for (card_entity, card_sprite, children) in cards.iter() {
|
||||
let is_dragged = dragged.contains(&card_entity.card_id);
|
||||
let (offset, padding, alpha) = card_shadow_params(is_dragged);
|
||||
let Some(card_size) = card_sprite.custom_size else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for child in children.iter() {
|
||||
let Ok((mut shadow_sprite, mut shadow_transform)) = shadows.get_mut(child) else {
|
||||
continue;
|
||||
};
|
||||
shadow_sprite.color = CARD_SHADOW_COLOR.with_alpha(alpha);
|
||||
shadow_sprite.custom_size = Some(card_size + padding);
|
||||
shadow_transform.translation.x = offset.x;
|
||||
shadow_transform.translation.y = offset.y;
|
||||
shadow_transform.translation.z = CARD_SHADOW_LOCAL_Z;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #28 — Hint highlight tick system
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -985,8 +1144,8 @@ fn handle_right_click(
|
||||
let pile_type = &pile_marker.0;
|
||||
let Some(pile) = game.0.piles.get(pile_type) else { continue };
|
||||
let legal = match pile_type {
|
||||
PileType::Foundation(suit) => {
|
||||
can_place_on_foundation(&card, pile, *suit)
|
||||
PileType::Foundation(_) => {
|
||||
can_place_on_foundation(&card, pile)
|
||||
}
|
||||
PileType::Tableau(_) => can_place_on_tableau(&card, pile),
|
||||
_ => false,
|
||||
@@ -1159,6 +1318,159 @@ fn update_stock_empty_indicator(
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stock-pile remaining-count badge
|
||||
//
|
||||
// Shows a small "·N" chip pinned to the top-right corner of the stock pile so
|
||||
// the player can see how many cards remain before the next recycle. The
|
||||
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
|
||||
// the badge hides itself when the stock has zero cards — the two indicators
|
||||
// never render at the same time.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Inset (in pixels) from the top-right corner of the stock pile sprite to
|
||||
/// the centre of the count badge. A small inward offset keeps the chip from
|
||||
/// drifting half-off the card while still reading as "attached" to the
|
||||
/// corner.
|
||||
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
|
||||
|
||||
/// Width / height of the badge background sprite, in world pixels. Sized so
|
||||
/// a 2-digit count (max "24") fits comfortably with `TYPE_CAPTION` text.
|
||||
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(28.0, 16.0);
|
||||
|
||||
/// Returns the count of cards currently in the stock pile.
|
||||
///
|
||||
/// Pure helper extracted so the count source is identical between the spawn
|
||||
/// system, the update system, and the unit tests.
|
||||
fn stock_card_count(game: &GameState) -> usize {
|
||||
game.piles
|
||||
.get(&PileType::Stock)
|
||||
.map_or(0, |p| p.cards.len())
|
||||
}
|
||||
|
||||
/// Returns the world-space `Vec3` for the centre of the stock-count badge,
|
||||
/// given the current `Layout`. The badge sits at the top-right corner of
|
||||
/// the stock pile sprite, inset by [`STOCK_BADGE_INSET`].
|
||||
fn stock_badge_translation(layout: &Layout) -> Vec3 {
|
||||
// Empty layouts don't contain a Stock entry — fall back to origin so
|
||||
// the badge stays in a deterministic spot until the layout is filled.
|
||||
let pile_pos = layout
|
||||
.pile_positions
|
||||
.get(&PileType::Stock)
|
||||
.copied()
|
||||
.unwrap_or(Vec2::ZERO);
|
||||
let half = layout.card_size * 0.5;
|
||||
let x = pile_pos.x + half.x + STOCK_BADGE_INSET.x;
|
||||
let y = pile_pos.y + half.y + STOCK_BADGE_INSET.y;
|
||||
Vec3::new(x, y, Z_STOCK_BADGE)
|
||||
}
|
||||
|
||||
/// Spawns the stock-count badge entity (background sprite + child text)
|
||||
/// into the world. Called once, when the badge does not yet exist.
|
||||
fn spawn_stock_count_badge(
|
||||
commands: &mut Commands,
|
||||
layout: &Layout,
|
||||
font: Option<&Handle<Font>>,
|
||||
count: usize,
|
||||
) {
|
||||
let translation = stock_badge_translation(layout);
|
||||
let visibility = if count == 0 {
|
||||
Visibility::Hidden
|
||||
} else {
|
||||
Visibility::Inherited
|
||||
};
|
||||
let text_font = TextFont {
|
||||
font: font.cloned().unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
StockCountBadge,
|
||||
Sprite {
|
||||
color: STOCK_BADGE_BG,
|
||||
custom_size: Some(STOCK_BADGE_SIZE),
|
||||
..default()
|
||||
},
|
||||
Transform::from_translation(translation),
|
||||
visibility,
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
StockCountBadgeText,
|
||||
Text2d::new(format!("·{count}")),
|
||||
text_font,
|
||||
TextColor(STOCK_BADGE_FG),
|
||||
// Slightly above the chip background so the digits aren't
|
||||
// occluded by the sprite they sit on.
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns the stock-pile remaining-count badge if it does not yet exist,
|
||||
/// and otherwise updates its text and visibility in place.
|
||||
///
|
||||
/// Visibility rule: hidden when the stock is empty (the existing `↺`
|
||||
/// `StockEmptyLabel` overlay covers that state), shown when one or more
|
||||
/// cards remain.
|
||||
///
|
||||
/// Position is recomputed from `LayoutResource` every tick so the badge
|
||||
/// follows the stock pile across `WindowResized` layout updates without
|
||||
/// needing a dedicated resize handler.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn update_stock_count_badge(
|
||||
mut commands: Commands,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
font: Option<Res<FontResource>>,
|
||||
mut badges: Query<(Entity, &mut Transform, &mut Visibility), With<StockCountBadge>>,
|
||||
children: Query<&Children, With<StockCountBadge>>,
|
||||
mut texts: Query<&mut Text2d, With<StockCountBadgeText>>,
|
||||
) {
|
||||
let Some(game) = game else { return };
|
||||
let Some(layout) = layout else { return };
|
||||
|
||||
let count = stock_card_count(&game.0);
|
||||
let translation = stock_badge_translation(&layout.0);
|
||||
let target_visibility = if count == 0 {
|
||||
Visibility::Hidden
|
||||
} else {
|
||||
Visibility::Inherited
|
||||
};
|
||||
|
||||
if badges.is_empty() {
|
||||
spawn_stock_count_badge(
|
||||
&mut commands,
|
||||
&layout.0,
|
||||
font.as_ref().map(|f| &f.0),
|
||||
count,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (entity, mut transform, mut visibility) in badges.iter_mut() {
|
||||
transform.translation = translation;
|
||||
if *visibility != target_visibility {
|
||||
*visibility = target_visibility;
|
||||
}
|
||||
// Update the child text to reflect the latest count. The text node
|
||||
// is created at spawn time, so under normal operation we always
|
||||
// have exactly one child here.
|
||||
if let Ok(badge_children) = children.get(entity) {
|
||||
for child in badge_children.iter() {
|
||||
if let Ok(mut text) = texts.get_mut(child) {
|
||||
let new = format!("·{count}");
|
||||
if text.0 != new {
|
||||
text.0 = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Coalesces every `WindowResized` event arriving this frame into the latest
|
||||
/// pending size on [`ResizeThrottle`].
|
||||
///
|
||||
@@ -1204,7 +1516,7 @@ fn collect_resize_events(
|
||||
/// Scheduled after [`collect_resize_events`] (which itself runs after
|
||||
/// `LayoutSystem::UpdateOnResize`) so `LayoutResource` reflects the latest
|
||||
/// window size before we read it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||
fn snap_cards_on_window_resize(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
@@ -1212,9 +1524,16 @@ fn snap_cards_on_window_resize(
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
entities: Query<(Entity, &CardEntity, &mut Sprite, &mut Transform), Without<CardLabel>>,
|
||||
entities: Query<
|
||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||
(Without<CardLabel>, Without<CardShadow>),
|
||||
>,
|
||||
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite), Without<CardEntity>>,
|
||||
shadow_query: Query<&mut Sprite, (With<CardShadow>, Without<CardEntity>, Without<PileMarker>)>,
|
||||
mut pile_markers: Query<
|
||||
(Entity, &PileMarker, &mut Sprite),
|
||||
(Without<CardEntity>, Without<CardShadow>),
|
||||
>,
|
||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||
) {
|
||||
if throttle.pending.is_none() {
|
||||
@@ -1242,6 +1561,7 @@ fn snap_cards_on_window_resize(
|
||||
card_images.as_deref(),
|
||||
entities,
|
||||
label_query,
|
||||
shadow_query,
|
||||
);
|
||||
|
||||
apply_stock_empty_indicator(
|
||||
@@ -1268,13 +1588,21 @@ fn snap_cards_on_window_resize(
|
||||
///
|
||||
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
|
||||
/// retargeted relative to the previous card-size's position.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn resize_cards_in_place(
|
||||
commands: &mut Commands,
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
card_images: Option<&CardImageSet>,
|
||||
mut entities: Query<(Entity, &CardEntity, &mut Sprite, &mut Transform), Without<CardLabel>>,
|
||||
mut entities: Query<
|
||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||
(Without<CardLabel>, Without<CardShadow>),
|
||||
>,
|
||||
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||
mut shadow_query: Query<
|
||||
&mut Sprite,
|
||||
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>),
|
||||
>,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
let pos_by_id: HashMap<u32, (Vec2, f32)> = positions
|
||||
@@ -1295,6 +1623,27 @@ fn resize_cards_in_place(
|
||||
commands.entity(entity).remove::<CardAnim>();
|
||||
}
|
||||
|
||||
// Resize every per-card shadow halo to match the new card size. Both
|
||||
// idle and drag states scale with the card body, so we preserve the
|
||||
// *current* padding (idle vs drag) by keeping the alpha as-is and only
|
||||
// recomputing the geometry. The drag-tracking system runs every frame
|
||||
// and will retune offset / alpha / padding-mode within one frame if the
|
||||
// drag state diverges from the resized geometry.
|
||||
let idle_padding = CARD_SHADOW_PADDING_IDLE;
|
||||
let drag_padding = CARD_SHADOW_PADDING_DRAG;
|
||||
for mut shadow_sprite in shadow_query.iter_mut() {
|
||||
// Choose padding based on the shadow's current alpha — preserves
|
||||
// a lifted shadow's larger halo across resize without needing to
|
||||
// plumb DragState through the resize handler.
|
||||
let alpha = shadow_sprite.color.alpha();
|
||||
let padding = if alpha >= CARD_SHADOW_ALPHA_DRAG - 0.001 {
|
||||
drag_padding
|
||||
} else {
|
||||
idle_padding
|
||||
};
|
||||
shadow_sprite.custom_size = Some(layout.card_size + padding);
|
||||
}
|
||||
|
||||
// Only the solid-colour fallback path uses CardLabel/Text2d overlays;
|
||||
// when PNG faces are loaded the rank/suit are baked into the image and
|
||||
// there is nothing to resize on the label side.
|
||||
@@ -1926,4 +2275,271 @@ mod tests {
|
||||
(got {after}, expected {expected})"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Per-card drop-shadow — pure helper + spawn / drag-snap regressions.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// `card_shadow_params(false)` returns the IDLE token triple.
|
||||
#[test]
|
||||
fn card_shadow_params_idle_returns_idle_tokens() {
|
||||
let (offset, padding, alpha) = card_shadow_params(false);
|
||||
assert_eq!(offset, CARD_SHADOW_OFFSET_IDLE);
|
||||
assert_eq!(padding, CARD_SHADOW_PADDING_IDLE);
|
||||
assert!((alpha - CARD_SHADOW_ALPHA_IDLE).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
/// `card_shadow_params(true)` returns the DRAG token triple, and each
|
||||
/// drag value differs from its idle counterpart so the player visibly
|
||||
/// sees the lift.
|
||||
#[test]
|
||||
fn card_shadow_params_drag_returns_drag_tokens_and_differs_from_idle() {
|
||||
let (idle_offset, idle_padding, idle_alpha) = card_shadow_params(false);
|
||||
let (drag_offset, drag_padding, drag_alpha) = card_shadow_params(true);
|
||||
|
||||
assert_eq!(drag_offset, CARD_SHADOW_OFFSET_DRAG);
|
||||
assert_eq!(drag_padding, CARD_SHADOW_PADDING_DRAG);
|
||||
assert!((drag_alpha - CARD_SHADOW_ALPHA_DRAG).abs() < f32::EPSILON);
|
||||
|
||||
assert_ne!(idle_offset, drag_offset, "drag offset must differ from idle");
|
||||
assert_ne!(idle_padding, drag_padding, "drag padding must differ from idle");
|
||||
assert!(
|
||||
drag_alpha > idle_alpha,
|
||||
"drag alpha must be stronger than idle (got drag={drag_alpha}, idle={idle_alpha})"
|
||||
);
|
||||
// Drag offset magnitude should be larger than idle so the parallax
|
||||
// reads as "lifted".
|
||||
assert!(
|
||||
drag_offset.length() > idle_offset.length(),
|
||||
"drag offset magnitude ({}) must exceed idle ({}) so the lift is visible",
|
||||
drag_offset.length(),
|
||||
idle_offset.length(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Every spawned `CardEntity` owns exactly one `CardShadow` child.
|
||||
/// Total counts must match: 52 cards → 52 shadows.
|
||||
#[test]
|
||||
fn cards_spawn_with_shadow_child() {
|
||||
let mut app = app();
|
||||
|
||||
let card_count = app
|
||||
.world_mut()
|
||||
.query::<&CardEntity>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(card_count, 52, "fixture should spawn 52 cards");
|
||||
|
||||
let shadow_count = app
|
||||
.world_mut()
|
||||
.query::<&CardShadow>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
shadow_count, 52,
|
||||
"every CardEntity must own exactly one CardShadow child (got {shadow_count})"
|
||||
);
|
||||
|
||||
// Each shadow's parent must be a CardEntity, so the child relation
|
||||
// is wired correctly.
|
||||
let cards: HashSet<bevy::prelude::Entity> = app
|
||||
.world_mut()
|
||||
.query_filtered::<bevy::prelude::Entity, With<CardEntity>>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&ChildOf, With<CardShadow>>();
|
||||
for parent in q.iter(app.world()) {
|
||||
assert!(
|
||||
cards.contains(&parent.parent()),
|
||||
"CardShadow parent {:?} is not a CardEntity",
|
||||
parent.parent()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Driving `DragState.cards` with a card id and ticking the app must
|
||||
/// move that card's shadow to the lifted offset and alpha; cards
|
||||
/// outside the dragged set keep the idle tuning.
|
||||
#[test]
|
||||
fn shadow_offset_increases_during_drag() {
|
||||
let mut app = app();
|
||||
|
||||
// Pick any spawned card id and stage it in DragState.
|
||||
let card_id: u32 = {
|
||||
let mut q = app.world_mut().query::<&CardEntity>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.expect("fixture should spawn at least one CardEntity")
|
||||
.card_id
|
||||
};
|
||||
|
||||
// Pick a *different* card id to act as the negative control —
|
||||
// its shadow must remain at the idle offset.
|
||||
let other_id: u32 = {
|
||||
let mut q = app.world_mut().query::<&CardEntity>();
|
||||
q.iter(app.world())
|
||||
.map(|c| c.card_id)
|
||||
.find(|id| *id != card_id)
|
||||
.expect("fixture should spawn more than one CardEntity")
|
||||
};
|
||||
|
||||
// Stage the drag and run one Update so `update_card_shadows_on_drag`
|
||||
// sees the new DragState.
|
||||
app.world_mut().resource_mut::<DragState>().cards = vec![card_id];
|
||||
app.update();
|
||||
|
||||
// Find the shadow whose parent's CardEntity matches `card_id`.
|
||||
let dragged_shadow_offset = shadow_offset_for_card(&mut app, card_id);
|
||||
let other_shadow_offset = shadow_offset_for_card(&mut app, other_id);
|
||||
|
||||
let drag_off = CARD_SHADOW_OFFSET_DRAG;
|
||||
let idle_off = CARD_SHADOW_OFFSET_IDLE;
|
||||
|
||||
assert!(
|
||||
(dragged_shadow_offset.x - drag_off.x).abs() < 1e-3
|
||||
&& (dragged_shadow_offset.y - drag_off.y).abs() < 1e-3,
|
||||
"dragged shadow offset should match CARD_SHADOW_OFFSET_DRAG \
|
||||
(got {dragged_shadow_offset:?}, expected {drag_off:?})"
|
||||
);
|
||||
assert!(
|
||||
(other_shadow_offset.x - idle_off.x).abs() < 1e-3
|
||||
&& (other_shadow_offset.y - idle_off.y).abs() < 1e-3,
|
||||
"non-dragged shadow offset should remain at CARD_SHADOW_OFFSET_IDLE \
|
||||
(got {other_shadow_offset:?}, expected {idle_off:?})"
|
||||
);
|
||||
|
||||
// Sanity-check: clearing the drag returns the shadow to the idle
|
||||
// offset on the next frame.
|
||||
app.world_mut().resource_mut::<DragState>().clear();
|
||||
app.update();
|
||||
let after_clear = shadow_offset_for_card(&mut app, card_id);
|
||||
assert!(
|
||||
(after_clear.x - idle_off.x).abs() < 1e-3
|
||||
&& (after_clear.y - idle_off.y).abs() < 1e-3,
|
||||
"shadow must snap back to idle offset after drag clears \
|
||||
(got {after_clear:?}, expected {idle_off:?})"
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: given a `card_id`, returns the world-space offset (x, y) of
|
||||
/// its `CardShadow` child relative to the parent card's origin.
|
||||
fn shadow_offset_for_card(app: &mut App, card_id: u32) -> Vec2 {
|
||||
// Map every CardEntity to its (Entity, card_id).
|
||||
let card_entity = {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query::<(bevy::prelude::Entity, &CardEntity)>();
|
||||
q.iter(app.world())
|
||||
.find(|(_, c)| c.card_id == card_id)
|
||||
.map(|(e, _)| e)
|
||||
.expect("card_id not found in spawned CardEntity set")
|
||||
};
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<(&ChildOf, &Transform), With<CardShadow>>();
|
||||
for (parent, transform) in q.iter(app.world()) {
|
||||
if parent.parent() == card_entity {
|
||||
return Vec2::new(transform.translation.x, transform.translation.y);
|
||||
}
|
||||
}
|
||||
panic!("no CardShadow child found for card_id {card_id}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stock-pile remaining-count badge tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Reads the current `Text2d` payload of the single `StockCountBadgeText`
|
||||
/// in the world, panicking if zero or more than one are spawned.
|
||||
fn stock_badge_text(app: &mut App) -> String {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Text2d, With<StockCountBadgeText>>();
|
||||
let texts: Vec<String> = q.iter(app.world()).map(|t| t.0.clone()).collect();
|
||||
assert_eq!(
|
||||
texts.len(),
|
||||
1,
|
||||
"expected exactly one StockCountBadgeText, got {}",
|
||||
texts.len()
|
||||
);
|
||||
texts.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
/// Reads the `Visibility` of the single `StockCountBadge` background sprite.
|
||||
fn stock_badge_visibility(app: &mut App) -> Visibility {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Visibility, With<StockCountBadge>>();
|
||||
let vs: Vec<Visibility> = q.iter(app.world()).copied().collect();
|
||||
assert_eq!(
|
||||
vs.len(),
|
||||
1,
|
||||
"expected exactly one StockCountBadge entity, got {}",
|
||||
vs.len()
|
||||
);
|
||||
vs.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_badge_shows_count_after_startup() {
|
||||
// Fresh Klondike (DrawOne) deals 24 face-down cards into stock — the
|
||||
// canonical starting count. After the first `app.update()` the badge
|
||||
// must exist and read "·24".
|
||||
let mut app = app();
|
||||
// First update inside `app()` runs the spawn path; run one more to
|
||||
// confirm the in-place update path is also stable.
|
||||
app.update();
|
||||
assert_eq!(stock_badge_text(&mut app), "·24");
|
||||
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_badge_hides_when_stock_empty() {
|
||||
// Drain the stock pile to zero cards and assert the badge becomes
|
||||
// hidden, leaving the existing `↺` `StockEmptyLabel` overlay as the
|
||||
// sole indicator (the two never render simultaneously).
|
||||
let mut app = app();
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
|
||||
stock.cards.clear();
|
||||
}
|
||||
}
|
||||
app.update();
|
||||
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Hidden));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_badge_updates_when_stock_count_changes() {
|
||||
// Mutate the stock pile so it holds 23 cards (one fewer than the
|
||||
// initial 24) and assert the badge text follows.
|
||||
let mut app = app();
|
||||
// Sanity-check the starting count.
|
||||
assert_eq!(stock_badge_text(&mut app), "·24");
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
|
||||
let _ = stock.cards.pop();
|
||||
}
|
||||
}
|
||||
app.update();
|
||||
assert_eq!(stock_badge_text(&mut app), "·23");
|
||||
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_card_count_helper_reads_zero_when_pile_missing() {
|
||||
// If the stock pile entry is somehow absent (defensive path), the
|
||||
// helper must return 0 rather than panicking — the badge then
|
||||
// renders as hidden via the count-zero branch in the update system.
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
let mut g_no_stock = g.clone();
|
||||
g_no_stock.piles.remove(&PileType::Stock);
|
||||
assert_eq!(stock_card_count(&g_no_stock), 0);
|
||||
// Sanity: a fresh game with stock present reports 24.
|
||||
assert_eq!(stock_card_count(&g), 24);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,20 @@
|
||||
//! - **Green** if the dragged stack can legally land there.
|
||||
//! - **Default** (nearly transparent white) otherwise.
|
||||
//! The tint is cleared to default the frame the drag ends.
|
||||
//!
|
||||
//! **Drop-target overlays** (`update_drop_target_overlays`)
|
||||
//! Pile markers sit *behind* the card stack, so on a tableau column with
|
||||
//! any cards on it the green tint applied above is fully occluded. To
|
||||
//! make legal targets unmistakable mid-drag, this system spawns a
|
||||
//! translucent green rectangle plus four outline edges over every legal
|
||||
//! destination pile. For tableau columns the overlay covers the full
|
||||
//! visible fan (matching `input_plugin::pile_drop_rect`); for
|
||||
//! foundations and empty tableaux it is card-sized. Overlays are
|
||||
//! despawned the frame the drag ends or whenever the legal-target set
|
||||
//! changes.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
@@ -22,6 +32,9 @@ use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC};
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::table_plugin::PileMarker;
|
||||
use crate::ui_theme::{
|
||||
DROP_TARGET_FILL, DROP_TARGET_OUTLINE, DROP_TARGET_OUTLINE_PX, Z_DROP_OVERLAY,
|
||||
};
|
||||
|
||||
/// Semi-transparent white that `table_plugin` uses for idle pile markers.
|
||||
/// Kept in sync with the `marker_colour` constant there.
|
||||
@@ -30,12 +43,26 @@ const MARKER_DEFAULT: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
/// Green tint applied to pile markers that are valid drop targets during drag.
|
||||
const MARKER_VALID: Color = Color::srgba(0.15, 0.85, 0.25, 0.55);
|
||||
|
||||
/// Marker component on a parent entity that owns one drop-target overlay
|
||||
/// (a translucent fill plus four outline edges as children). The wrapped
|
||||
/// `PileType` identifies which pile this overlay highlights, so test
|
||||
/// queries and the despawn-on-target-change logic can filter by pile.
|
||||
#[derive(Component, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DropTargetOverlay(pub PileType);
|
||||
|
||||
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
|
||||
pub struct CursorPlugin;
|
||||
|
||||
impl Plugin for CursorPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, (update_cursor_icon, update_drop_highlights));
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
update_cursor_icon,
|
||||
update_drop_highlights,
|
||||
update_drop_target_overlays,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,10 +109,10 @@ fn update_cursor_icon(
|
||||
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
|
||||
let piles = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
@@ -158,12 +185,12 @@ fn update_drop_highlights(
|
||||
|
||||
for (marker, mut sprite, _rch) in &mut markers {
|
||||
let valid = match &marker.0 {
|
||||
PileType::Foundation(suit) => {
|
||||
PileType::Foundation(slot) => {
|
||||
if drag_count != 1 {
|
||||
false
|
||||
} else {
|
||||
let pile = game.0.piles.get(&PileType::Foundation(*suit));
|
||||
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p, *suit))
|
||||
let pile = game.0.piles.get(&PileType::Foundation(*slot));
|
||||
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
}
|
||||
PileType::Tableau(idx) => {
|
||||
@@ -176,6 +203,213 @@ fn update_drop_highlights(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drop-target overlay sprites — render in front of cards, unlike the pile
|
||||
// markers above which sit behind the stack.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawns / despawns translucent overlay sprites over every legal drop
|
||||
/// target while a drag is in progress.
|
||||
///
|
||||
/// The overlay is a parent `Sprite` (the soft fill) with four child
|
||||
/// `Sprite`s (top, bottom, left, right edges) that together form the
|
||||
/// outline. A new parent is spawned whenever a target appears in the
|
||||
/// valid set; a parent is despawned (with its children) whenever its
|
||||
/// pile leaves the valid set or the drag ends.
|
||||
///
|
||||
/// Geometry mirrors `input_plugin::pile_drop_rect` exactly so the
|
||||
/// highlighted region matches the actual drop hit-box.
|
||||
fn update_drop_target_overlays(
|
||||
mut commands: Commands,
|
||||
drag: Res<DragState>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
overlays: Query<(Entity, &DropTargetOverlay)>,
|
||||
) {
|
||||
// Drag idle → despawn every existing overlay and exit.
|
||||
if drag.is_idle() {
|
||||
for (entity, _) in &overlays {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let (Some(game), Some(layout)) = (game, layout) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Resolve the bottom card of the dragged stack — same logic as
|
||||
// `update_drop_highlights` so rules can't drift between the marker
|
||||
// tint and the overlay.
|
||||
let Some(&bottom_id) = drag.cards.first() else {
|
||||
return;
|
||||
};
|
||||
let bottom_card = game
|
||||
.0
|
||||
.piles
|
||||
.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == bottom_id)
|
||||
.cloned();
|
||||
let Some(bottom_card) = bottom_card else {
|
||||
return;
|
||||
};
|
||||
let drag_count = drag.cards.len();
|
||||
|
||||
// Iterate the same pile list as `update_drop_highlights`. Stock and
|
||||
// Waste are excluded because they are never legal drop targets.
|
||||
let candidates = [
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
];
|
||||
|
||||
// Compute the new set of valid piles for this frame.
|
||||
let mut valid: Vec<PileType> = Vec::new();
|
||||
for pile in &candidates {
|
||||
let is_valid = match pile {
|
||||
PileType::Foundation(_) => {
|
||||
if drag_count != 1 {
|
||||
false
|
||||
} else {
|
||||
game.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
}
|
||||
PileType::Tableau(_) => game
|
||||
.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.is_some_and(|p| can_place_on_tableau(&bottom_card, p)),
|
||||
_ => false,
|
||||
};
|
||||
// Don't highlight the origin pile — dropping onto the source is
|
||||
// a no-op.
|
||||
if is_valid && drag.origin_pile.as_ref() != Some(pile) {
|
||||
valid.push(pile.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Despawn overlays whose pile is no longer valid.
|
||||
for (entity, marker) in &overlays {
|
||||
if !valid.contains(&marker.0) {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn overlays for piles that are now valid but don't yet have one.
|
||||
let already_overlaid: Vec<PileType> = overlays
|
||||
.iter()
|
||||
.map(|(_, m)| m.0.clone())
|
||||
.filter(|p| valid.contains(p))
|
||||
.collect();
|
||||
|
||||
for pile in valid {
|
||||
if already_overlaid.contains(&pile) {
|
||||
continue;
|
||||
}
|
||||
spawn_drop_target_overlay(&mut commands, &pile, &layout.0, &game.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the `(centre, size)` of the drop-target overlay for a pile.
|
||||
///
|
||||
/// Mirrors `input_plugin::pile_drop_rect` — for tableau columns with two
|
||||
/// or more cards the rectangle extends downward to cover the full fan;
|
||||
/// for everything else it is card-sized. Replicated here rather than
|
||||
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
||||
/// this overlay is the only other consumer.
|
||||
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2, Vec2) {
|
||||
let centre = layout.pile_positions[pile];
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
if card_count > 1 {
|
||||
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
||||
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
||||
let top_edge = centre.y + layout.card_size.y / 2.0;
|
||||
let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0;
|
||||
let span_height = top_edge - bottom_edge;
|
||||
let new_centre_y = (top_edge + bottom_edge) / 2.0;
|
||||
return (
|
||||
Vec2::new(centre.x, new_centre_y),
|
||||
Vec2::new(layout.card_size.x, span_height),
|
||||
);
|
||||
}
|
||||
}
|
||||
(centre, layout.card_size)
|
||||
}
|
||||
|
||||
/// Spawns one overlay parent (fill) plus four edge sprites (outline) at
|
||||
/// the appropriate world position for `pile`.
|
||||
fn spawn_drop_target_overlay(
|
||||
commands: &mut Commands,
|
||||
pile: &PileType,
|
||||
layout: &Layout,
|
||||
game: &GameState,
|
||||
) {
|
||||
let (centre, size) = drop_overlay_rect(pile, layout, game);
|
||||
let edge = DROP_TARGET_OUTLINE_PX;
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_FILL,
|
||||
custom_size: Some(size),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
|
||||
DropTargetOverlay(pile.clone()),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
// Top edge.
|
||||
parent.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_OUTLINE,
|
||||
custom_size: Some(Vec2::new(size.x, edge)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, size.y / 2.0 - edge / 2.0, 0.01),
|
||||
));
|
||||
// Bottom edge.
|
||||
parent.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_OUTLINE,
|
||||
custom_size: Some(Vec2::new(size.x, edge)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, -size.y / 2.0 + edge / 2.0, 0.01),
|
||||
));
|
||||
// Left edge.
|
||||
parent.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_OUTLINE,
|
||||
custom_size: Some(Vec2::new(edge, size.y)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(-size.x / 2.0 + edge / 2.0, 0.0, 0.01),
|
||||
));
|
||||
// Right edge.
|
||||
parent.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_OUTLINE,
|
||||
custom_size: Some(Vec2::new(edge, size.y)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(size.x / 2.0 - edge / 2.0, 0.0, 0.01),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -258,4 +492,159 @@ mod tests {
|
||||
// A cursor far off-screen should never hit anything.
|
||||
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Drop-target overlay tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||
|
||||
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
||||
/// registered, plus the resources the system needs. Callers
|
||||
/// customise `GameStateResource` and `DragState` after construction.
|
||||
fn overlay_test_app(game: GameState) -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.insert_resource(GameStateResource(game))
|
||||
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0))))
|
||||
.insert_resource(DragState::default())
|
||||
.add_systems(Update, update_drop_target_overlays);
|
||||
app
|
||||
}
|
||||
|
||||
/// Replaces the top card of a tableau pile with a fresh face-up
|
||||
/// card. Used to make a specific tableau column accept a chosen
|
||||
/// drag stack.
|
||||
fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) {
|
||||
let pile = game
|
||||
.piles
|
||||
.get_mut(&PileType::Tableau(idx))
|
||||
.expect("tableau pile exists");
|
||||
pile.cards.clear();
|
||||
pile.cards.push(card);
|
||||
}
|
||||
|
||||
/// Inserts a single face-up dragged card into the waste pile and
|
||||
/// configures `DragState` so the overlay system treats it as the
|
||||
/// active drag.
|
||||
fn begin_drag_with(app: &mut App, dragged: Card) {
|
||||
// Place the dragged card on the waste pile (origin).
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let waste = game
|
||||
.0
|
||||
.piles
|
||||
.get_mut(&PileType::Waste)
|
||||
.expect("waste pile exists");
|
||||
waste.cards.clear();
|
||||
waste.cards.push(dragged.clone());
|
||||
}
|
||||
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||
drag.cards = vec![dragged.id];
|
||||
drag.origin_pile = Some(PileType::Waste);
|
||||
drag.committed = true;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlay_spawns_for_valid_tableau_during_drag() {
|
||||
// 5 of Hearts (red, rank 5) on top of Tableau(2)'s 6 of Spades
|
||||
// (black, rank 6) — alternating colour, one rank lower → legal.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card { id: 9001, suit: Suit::Spades, rank: Rank::Six, face_up: true },
|
||||
);
|
||||
let dragged = Card { id: 9002, suit: Suit::Hearts, rank: Rank::Five, face_up: true };
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
|
||||
app.update();
|
||||
|
||||
let overlays: Vec<PileType> = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.map(|o| o.0.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
overlays.contains(&PileType::Tableau(2)),
|
||||
"expected Tableau(2) to be highlighted as a legal drop target, got {overlays:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
|
||||
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
||||
// — same colour family, illegal. Tableau(2) must NOT be
|
||||
// highlighted.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card { id: 9101, suit: Suit::Clubs, rank: Rank::Six, face_up: true },
|
||||
);
|
||||
let dragged = Card { id: 9102, suit: Suit::Spades, rank: Rank::Five, face_up: true };
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
|
||||
app.update();
|
||||
|
||||
let overlays: Vec<PileType> = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.map(|o| o.0.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
!overlays.contains(&PileType::Tableau(2)),
|
||||
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlays_despawn_on_drag_end() {
|
||||
// Set up a scenario that produces at least one valid overlay,
|
||||
// confirm it spawns, then clear the drag and confirm every
|
||||
// overlay is despawned.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card { id: 9201, suit: Suit::Spades, rank: Rank::Six, face_up: true },
|
||||
);
|
||||
let dragged = Card { id: 9202, suit: Suit::Hearts, rank: Rank::Five, face_up: true };
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
app.update();
|
||||
|
||||
let count_during_drag = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
count_during_drag >= 1,
|
||||
"expected ≥1 overlay during drag, got {count_during_drag}"
|
||||
);
|
||||
|
||||
// End the drag — every overlay should despawn next frame.
|
||||
app.world_mut().resource_mut::<DragState>().clear();
|
||||
app.update();
|
||||
|
||||
let count_after_drag = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count_after_drag, 0,
|
||||
"all overlays must despawn when the drag ends"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Cross-system events used by the engine's plugins.
|
||||
|
||||
use bevy::prelude::Message;
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_data::AchievementRecord;
|
||||
@@ -60,6 +61,28 @@ pub struct GameWonEvent {
|
||||
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.
|
||||
#[derive(Message, Debug, Clone, Copy)]
|
||||
pub struct CardFlippedEvent(pub u32);
|
||||
|
||||
@@ -48,13 +48,18 @@ use solitaire_data::AnimSpeed;
|
||||
use crate::animation_plugin::CardAnim;
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{
|
||||
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
||||
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent,
|
||||
};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
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
|
||||
@@ -185,7 +190,8 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers the shake, settle, and deal animation systems.
|
||||
/// Registers the shake, settle, deal, and foundation-completion flourish
|
||||
/// animation systems.
|
||||
pub struct FeedbackAnimPlugin;
|
||||
|
||||
impl Plugin for FeedbackAnimPlugin {
|
||||
@@ -197,6 +203,7 @@ impl Plugin for FeedbackAnimPlugin {
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_message::<MoveRejectedEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -205,6 +212,8 @@ impl Plugin for FeedbackAnimPlugin {
|
||||
start_settle_anim.after(GameMutation),
|
||||
tick_settle_anim,
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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]
|
||||
fn deal_stagger_jitter_varies_across_card_ids() {
|
||||
// 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};
|
||||
|
||||
use crate::events::{
|
||||
CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
|
||||
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
||||
@@ -86,6 +86,7 @@ impl Plugin for GamePlugin {
|
||||
.add_message::<GameWonEvent>()
|
||||
.add_message::<crate::events::CardFlippedEvent>()
|
||||
.add_message::<crate::events::AchievementUnlockedEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -398,14 +399,18 @@ fn handle_draw(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_move(
|
||||
mut moves: MessageReader<MoveRequestEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: MessageWriter<StateChangedEvent>,
|
||||
mut won: MessageWriter<GameWonEvent>,
|
||||
mut flipped: MessageWriter<crate::events::CardFlippedEvent>,
|
||||
mut foundation_done: MessageWriter<FoundationCompletedEvent>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
) {
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
for ev in moves.read() {
|
||||
let was_won = game.0.is_won;
|
||||
// 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));
|
||||
}
|
||||
// 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);
|
||||
if !was_won && game.0.is_won {
|
||||
won.write(GameWonEvent {
|
||||
@@ -479,7 +497,6 @@ fn handle_undo(
|
||||
/// - Any face-up card on Waste or Tableau piles that can legally move to any
|
||||
/// Foundation or Tableau destination.
|
||||
pub fn has_legal_moves(game: &GameState) -> bool {
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
@@ -490,8 +507,6 @@ pub fn has_legal_moves(game: &GameState) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
|
||||
// Check each playable source pile.
|
||||
let sources: Vec<PileType> = {
|
||||
let mut v = vec![PileType::Waste];
|
||||
@@ -505,11 +520,11 @@ pub fn has_legal_moves(game: &GameState) -> bool {
|
||||
let Some(from_pile) = game.piles.get(from) else { continue };
|
||||
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
|
||||
|
||||
// Check foundations.
|
||||
for &suit in &suits {
|
||||
let dest = PileType::Foundation(suit);
|
||||
// Check foundation slots.
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if let Some(dest_pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, dest_pile, suit) {
|
||||
&& can_place_on_foundation(card, dest_pile) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1116,8 +1131,8 @@ mod tests {
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
|
||||
// Clear all tableau and foundations, put Ace of Clubs on tableau 0.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1139,8 +1154,8 @@ mod tests {
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
|
||||
// Clear all foundations and all tableau.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1234,8 +1249,8 @@ mod tests {
|
||||
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 suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(suit)).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();
|
||||
@@ -1273,8 +1288,8 @@ mod tests {
|
||||
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 suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(suit)).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();
|
||||
@@ -1340,8 +1355,8 @@ mod tests {
|
||||
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 suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(suit)).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();
|
||||
@@ -1410,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`.
|
||||
#[test]
|
||||
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+)" },
|
||||
],
|
||||
},
|
||||
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 {
|
||||
title: "Overlays",
|
||||
rows: &[
|
||||
|
||||
@@ -135,6 +135,14 @@ impl Plugin for HomePlugin {
|
||||
.add_message::<StartTimeAttackRequestEvent>()
|
||||
.add_message::<StartDailyChallengeRequestEvent>()
|
||||
.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(
|
||||
Update,
|
||||
(
|
||||
@@ -142,7 +150,9 @@ impl Plugin for HomePlugin {
|
||||
attach_focusable_to_home_mode_cards,
|
||||
handle_home_card_click,
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -873,4 +975,191 @@ mod tests {
|
||||
"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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,10 @@ use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::layout::HUD_BAND_HEIGHT;
|
||||
use crate::ui_theme::{
|
||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||
BG_ELEVATED_PRESSED, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM,
|
||||
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM,
|
||||
STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||
};
|
||||
@@ -251,7 +252,8 @@ impl Plugin for HudPlugin {
|
||||
.add_message::<ToggleSettingsRequestEvent>()
|
||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||
.init_resource::<PreviousScore>()
|
||||
.add_systems(Startup, (spawn_hud, spawn_action_buttons))
|
||||
.init_resource::<HudActionFade>()
|
||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
||||
.add_systems(Update, update_hud.after(GameMutation))
|
||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||
.add_systems(Update, update_selection_hud)
|
||||
@@ -278,10 +280,44 @@ impl Plugin for HudPlugin {
|
||||
handle_menu_option_click,
|
||||
paint_action_buttons,
|
||||
),
|
||||
);
|
||||
)
|
||||
// Fade lives in `Last` so it always overrides whatever the
|
||||
// hover/paint pass set on `BackgroundColor` this frame.
|
||||
// Otherwise on a hover-state change (`Changed<Interaction>`),
|
||||
// `paint_action_buttons` would clobber the alpha back to 1.0
|
||||
// mid-fade and produce a visible blip.
|
||||
.add_systems(Last, (update_action_fade, apply_action_fade).chain());
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the translucent HUD band that anchors the action buttons
|
||||
/// and primary readouts visually. Sits behind every other HUD element
|
||||
/// (one z-rung below `Z_HUD`) so it reads as the band's "container"
|
||||
/// without intercepting clicks from the buttons it sits under.
|
||||
///
|
||||
/// Width is full-window, height matches `layout::HUD_BAND_HEIGHT` (the
|
||||
/// same constant the card layout reserves at the top), so the band's
|
||||
/// bottom edge lines up exactly with the top edge of the highest
|
||||
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
|
||||
/// alpha, so the green felt reads through subtly.
|
||||
fn spawn_hud_band(mut commands: Commands) {
|
||||
commands.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(0.0),
|
||||
left: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(HUD_BAND_HEIGHT),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_HUD_BAND),
|
||||
// Sit one z-rung below the HUD content so the buttons and text
|
||||
// paint on top, but above the card sprites (which are 2D-world
|
||||
// entities and rendered behind UI regardless).
|
||||
ZIndex(Z_HUD - 1),
|
||||
));
|
||||
}
|
||||
|
||||
/// Spawns the in-game HUD as a 4-tier vertical column anchored to the
|
||||
/// top-left of the play area.
|
||||
///
|
||||
@@ -960,6 +996,93 @@ fn handle_menu_option_click(
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-fade state for the action button bar. The bar fades out when
|
||||
/// the cursor is in the play area (below the HUD band) and back in when
|
||||
/// the cursor approaches the top of the window — same UX as a video
|
||||
/// player's auto-hide controls. Buttons remain fully interactive when
|
||||
/// visible; when faded out they're geometrically out of cursor reach
|
||||
/// (hover requires the cursor to be on a button), so no extra
|
||||
/// pointer-events guard is needed.
|
||||
#[derive(Resource, Debug, Clone, Copy)]
|
||||
pub struct HudActionFade {
|
||||
/// Currently displayed alpha. Lerped toward `target` each frame.
|
||||
pub alpha: f32,
|
||||
/// Where `alpha` is heading — 0.0 (faded out) or 1.0 (visible).
|
||||
pub target: f32,
|
||||
}
|
||||
|
||||
impl Default for HudActionFade {
|
||||
fn default() -> Self {
|
||||
// Start visible so the player sees the controls on first launch
|
||||
// before they've moved the cursor anywhere.
|
||||
Self {
|
||||
alpha: 1.0,
|
||||
target: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cursor-y threshold (in window pixels, 0 = top) below which the bar
|
||||
/// stays visible. Set slightly above `HUD_BAND_HEIGHT` so the bar fades
|
||||
/// in as the cursor approaches, not only once it crosses into the band.
|
||||
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
||||
|
||||
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
|
||||
/// transition — fast enough to feel responsive without flashing on
|
||||
/// brief cursor wanders into the reveal zone.
|
||||
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
||||
|
||||
/// Updates the fade state from cursor position. Sets `target = 1.0` if
|
||||
/// the cursor is in the reveal zone (top of window) or off-screen
|
||||
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
|
||||
/// `target` at a fixed rate so the visual transition is smooth across
|
||||
/// variable framerates.
|
||||
fn update_action_fade(
|
||||
windows: Query<&Window>,
|
||||
time: Res<Time>,
|
||||
mut fade: ResMut<HudActionFade>,
|
||||
) {
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
};
|
||||
fade.target = match window.cursor_position() {
|
||||
Some(pos) if pos.y <= ACTION_FADE_REVEAL_PX => 1.0,
|
||||
Some(_) => 0.0,
|
||||
// Off-window cursor: assume keyboard navigation and keep the
|
||||
// bar visible so Tab cycling doesn't lead to invisible focus.
|
||||
None => 1.0,
|
||||
};
|
||||
|
||||
let dt = time.delta_secs();
|
||||
let max_step = ACTION_FADE_RATE_PER_SEC * dt;
|
||||
let diff = fade.target - fade.alpha;
|
||||
fade.alpha = (fade.alpha + diff.clamp(-max_step, max_step)).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Applies the current fade alpha to every action button's
|
||||
/// `BackgroundColor` and to its child label / hotkey-chip text. Runs in
|
||||
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
|
||||
/// same frame doesn't override the fade with an opaque idle / hover
|
||||
/// colour.
|
||||
fn apply_action_fade(
|
||||
fade: Res<HudActionFade>,
|
||||
mut buttons: Query<(&Children, &mut BackgroundColor), With<ActionButton>>,
|
||||
mut text_q: Query<&mut TextColor>,
|
||||
) {
|
||||
for (children, mut bg) in &mut buttons {
|
||||
let mut c = bg.0;
|
||||
c.set_alpha(fade.alpha);
|
||||
bg.0 = c;
|
||||
for child in children.iter() {
|
||||
if let Ok(mut tc) = text_q.get_mut(child) {
|
||||
let mut cc = tc.0;
|
||||
cc.set_alpha(fade.alpha);
|
||||
tc.0 = cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Visual feedback for every action button — paints idle / hover / pressed
|
||||
/// states by mutating `BackgroundColor` whenever the interaction state
|
||||
/// changes. One query covers all action buttons via the shared
|
||||
@@ -1434,6 +1557,7 @@ fn update_hud(
|
||||
/// indicator stays in sync with the selection resource.
|
||||
fn update_selection_hud(
|
||||
selection: Option<Res<SelectionState>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut q: Query<&mut Text, With<HudSelection>>,
|
||||
) {
|
||||
let Ok(mut t) = q.single_mut() else { return };
|
||||
@@ -1441,7 +1565,29 @@ fn update_selection_hud(
|
||||
None => String::new(),
|
||||
Some(PileType::Waste) => "▶ Waste".to_string(),
|
||||
Some(PileType::Stock) => "▶ Stock".to_string(),
|
||||
Some(PileType::Foundation(suit)) => {
|
||||
Some(PileType::Foundation(slot)) => match game.as_deref() {
|
||||
Some(g) => foundation_selection_label(*slot, &g.0),
|
||||
// No game resource means we can't probe claimed_suit; show the
|
||||
// slot-based placeholder so the HUD still surfaces the selection.
|
||||
None => format!("▶ Foundation {}", slot + 1),
|
||||
},
|
||||
Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1),
|
||||
};
|
||||
**t = label;
|
||||
}
|
||||
|
||||
/// Returns the HUD selection label for a foundation slot.
|
||||
///
|
||||
/// When the slot has a claimed suit (any card has landed) the announcement is
|
||||
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
|
||||
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
|
||||
fn foundation_selection_label(slot: u8, game: &solitaire_core::game_state::GameState) -> String {
|
||||
let claimed = game
|
||||
.piles
|
||||
.get(&PileType::Foundation(slot))
|
||||
.and_then(|p| p.claimed_suit());
|
||||
match claimed {
|
||||
Some(suit) => {
|
||||
let s = match suit {
|
||||
Suit::Clubs => "Clubs",
|
||||
Suit::Diamonds => "Diamonds",
|
||||
@@ -1450,9 +1596,8 @@ fn update_selection_hud(
|
||||
};
|
||||
format!("▶ {s} Foundation")
|
||||
}
|
||||
Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1),
|
||||
};
|
||||
**t = label;
|
||||
None => format!("▶ Foundation {}", slot + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
|
||||
|
||||
@@ -30,10 +30,12 @@ use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_animation::tuning::AnimationTuning;
|
||||
use crate::card_animation::{CardAnimation, MotionCurve};
|
||||
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 crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::events::{
|
||||
@@ -320,9 +322,13 @@ fn handle_keyboard_hint(
|
||||
}
|
||||
|
||||
// Fire an informational toast describing where the hinted card should
|
||||
// move so the player always sees the suggestion in text.
|
||||
// move so the player always sees the suggestion in text. When the
|
||||
// destination foundation already claims a suit, surface that suit so the
|
||||
// player keeps thinking in suit terms; otherwise fall back to "foundation".
|
||||
let msg = match to {
|
||||
PileType::Foundation(suit) => {
|
||||
PileType::Foundation(_) => {
|
||||
let claimed = g.0.piles.get(to).and_then(|p| p.claimed_suit());
|
||||
if let Some(suit) = claimed {
|
||||
let suit_name = match suit {
|
||||
Suit::Clubs => "Clubs",
|
||||
Suit::Diamonds => "Diamonds",
|
||||
@@ -330,6 +336,9 @@ fn handle_keyboard_hint(
|
||||
Suit::Spades => "Spades",
|
||||
};
|
||||
format!("Hint: move to {suit_name} foundation")
|
||||
} else {
|
||||
"Hint: move to foundation".to_string()
|
||||
}
|
||||
}
|
||||
PileType::Tableau(col) => format!("Hint: move to tableau (col {})", col + 1),
|
||||
_ => "Hint: move card".to_string(),
|
||||
@@ -634,12 +643,11 @@ fn end_drag(
|
||||
let bottom_card_id = drag.cards[0];
|
||||
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
|
||||
let ok = match &target {
|
||||
PileType::Foundation(suit) => {
|
||||
PileType::Foundation(_) => {
|
||||
count == 1
|
||||
&& can_place_on_foundation(
|
||||
&bottom_card,
|
||||
&game.0.piles[&target],
|
||||
*suit,
|
||||
)
|
||||
}
|
||||
PileType::Tableau(_) => {
|
||||
@@ -660,14 +668,16 @@ fn end_drag(
|
||||
to: target.clone(),
|
||||
count,
|
||||
});
|
||||
// Shake each dragged card so the player gets immediate
|
||||
// visual feedback that the drop was rejected. ShakeAnim
|
||||
// restores translation.x to origin_x at the end of the
|
||||
// animation, so origin_x must be the target slot in the
|
||||
// origin pile — using the current drag transform would
|
||||
// pin the card at the drop location and fight the
|
||||
// sync_cards slide that StateChangedEvent triggers
|
||||
// (the symptom is "card lands beside the pile").
|
||||
// Smoothly glide each dragged card from its drop-time
|
||||
// transform back to its resting slot in the origin pile.
|
||||
// The audio cue (card_invalid.wav, played by AudioPlugin
|
||||
// on MoveRejectedEvent) still gives the player clear
|
||||
// negative feedback; this just replaces the old shake
|
||||
// wiggle with a forgiving ease-out tween.
|
||||
//
|
||||
// `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) {
|
||||
for &card_id in &drag.cards {
|
||||
let Some(stack_index) =
|
||||
@@ -677,14 +687,23 @@ fn end_drag(
|
||||
};
|
||||
let target_pos =
|
||||
card_position(&game.0, &layout.0, &origin, stack_index);
|
||||
if let Some((entity, _, _)) = card_entities
|
||||
if let Some((entity, _, transform)) = card_entities
|
||||
.iter()
|
||||
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||
{
|
||||
commands.entity(entity).insert(ShakeAnim {
|
||||
elapsed: 0.0,
|
||||
origin_x: target_pos.x,
|
||||
});
|
||||
let drag_pos = transform.translation.truncate();
|
||||
let drag_z = transform.translation.z;
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -879,9 +898,9 @@ fn touch_end_drag(
|
||||
let bottom_card_id = drag.cards[0];
|
||||
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
|
||||
let ok = match &target {
|
||||
PileType::Foundation(suit) => {
|
||||
PileType::Foundation(_) => {
|
||||
count == 1
|
||||
&& can_place_on_foundation(&bottom_card, &game.0.piles[&target], *suit)
|
||||
&& can_place_on_foundation(&bottom_card, &game.0.piles[&target])
|
||||
}
|
||||
PileType::Tableau(_) => {
|
||||
can_place_on_tableau(&bottom_card, &game.0.piles[&target])
|
||||
@@ -893,9 +912,11 @@ fn touch_end_drag(
|
||||
fired = true;
|
||||
} else {
|
||||
rejected.write(MoveRejectedEvent { from: origin.clone(), to: target, count });
|
||||
// See `end_drag` (mouse path) for the rationale: ShakeAnim
|
||||
// restores translation.x to origin_x, so origin_x must be
|
||||
// the origin pile's slot, not the drop location.
|
||||
// Smoothly glide each dragged card from its drop-time
|
||||
// transform back to its resting slot. See `end_drag`
|
||||
// (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) {
|
||||
for &card_id in &drag.cards {
|
||||
let Some(stack_index) =
|
||||
@@ -905,13 +926,22 @@ fn touch_end_drag(
|
||||
};
|
||||
let target_pos =
|
||||
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)
|
||||
{
|
||||
commands.entity(entity).insert(ShakeAnim {
|
||||
elapsed: 0.0,
|
||||
origin_x: target_pos.x,
|
||||
});
|
||||
let drag_pos = transform.translation.truncate();
|
||||
let drag_z = transform.translation.z;
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1016,10 +1046,10 @@ fn find_draggable_at(
|
||||
// Within a pile, we consider cards top-down because the visual top card is drawn last.
|
||||
let piles = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
@@ -1079,10 +1109,10 @@ fn find_drop_target(
|
||||
origin: &PileType,
|
||||
) -> Option<PileType> {
|
||||
let piles = [
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
@@ -1138,11 +1168,11 @@ const DOUBLE_CLICK_WINDOW: f32 = 0.35;
|
||||
///
|
||||
/// Returns `None` if no legal move exists from the card's current location.
|
||||
pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> {
|
||||
// Try all four foundations first.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let dest = PileType::Foundation(suit);
|
||||
// Try all four foundation slots first.
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, pile, suit) {
|
||||
&& can_place_on_foundation(card, pile) {
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
@@ -1298,7 +1328,6 @@ fn handle_double_click(
|
||||
/// This is the backing data for the cycling hint system: the H key steps
|
||||
/// through `hints[HintCycleIndex % hints.len()]` on each press.
|
||||
pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
let sources: Vec<PileType> = {
|
||||
let mut s = vec![PileType::Waste];
|
||||
for i in 0..7_usize {
|
||||
@@ -1313,12 +1342,12 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
||||
for from in &sources {
|
||||
let Some(from_pile) = game.piles.get(from) else { continue };
|
||||
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
|
||||
for &suit in &suits {
|
||||
let dest = PileType::Foundation(suit);
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if let Some(dest_pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, dest_pile, suit) {
|
||||
&& can_place_on_foundation(card, dest_pile) {
|
||||
hints.push((from.clone(), dest, 1));
|
||||
// Each source card can go to at most one foundation suit;
|
||||
// Each source card can land on at most one foundation slot;
|
||||
// no need to check the remaining three for this card.
|
||||
break;
|
||||
}
|
||||
@@ -1616,7 +1645,7 @@ mod tests {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
for pile in [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(2),
|
||||
] {
|
||||
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
||||
assert_eq!(size, layout.card_size);
|
||||
@@ -1638,13 +1667,15 @@ mod tests {
|
||||
waste.cards.clear();
|
||||
waste.cards.push(Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true });
|
||||
|
||||
// Foundation for Clubs is empty — Ace should go there.
|
||||
let foundation = game.piles.get_mut(&PileType::Foundation(Suit::Clubs)).unwrap();
|
||||
foundation.cards.clear();
|
||||
// All four foundation slots empty — the Ace lands in slot 0 (first
|
||||
// empty slot in iteration order).
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
|
||||
let card = Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true };
|
||||
let dest = best_destination(&card, &game);
|
||||
assert_eq!(dest, Some(PileType::Foundation(Suit::Clubs)));
|
||||
assert_eq!(dest, Some(PileType::Foundation(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1653,9 +1684,9 @@ mod tests {
|
||||
use solitaire_core::game_state::GameMode;
|
||||
let mut game = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Classic);
|
||||
|
||||
// Clear all foundations — a Two of Clubs cannot go there.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
// Clear all foundation slots — a Two of Clubs cannot go there.
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
|
||||
// Put a Two of Clubs as the card.
|
||||
@@ -1682,8 +1713,8 @@ mod tests {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Clear everything except one card that has nowhere to go.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1704,8 +1735,8 @@ mod tests {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Clear all piles for a clean test.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1737,8 +1768,8 @@ mod tests {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1768,8 +1799,8 @@ mod tests {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1806,13 +1837,16 @@ mod tests {
|
||||
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 500, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
game.piles.get_mut(&PileType::Foundation(Suit::Clubs)).unwrap().cards.clear();
|
||||
// All foundation slots empty — Ace lands in slot 0 (first match).
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
|
||||
let hint = find_hint(&game);
|
||||
assert!(hint.is_some(), "should find a hint");
|
||||
let (from, to, count) = hint.unwrap();
|
||||
assert_eq!(from, PileType::Tableau(0));
|
||||
assert_eq!(to, PileType::Foundation(Suit::Clubs));
|
||||
assert_eq!(to, PileType::Foundation(0));
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
@@ -1822,8 +1856,8 @@ mod tests {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Put only a Two on tableau 0, empty everything else.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1872,8 +1906,8 @@ mod tests {
|
||||
|
||||
// Remove all foundation, tableau, and waste cards so no pile-to-pile
|
||||
// move exists. Leave one card in the stock.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1904,8 +1938,8 @@ mod tests {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Clear every pile, then put a single card that has nowhere to go.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1936,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
|
||||
/// correct initial values: `elapsed` starts at 0.0 and `origin_x` matches
|
||||
/// the **target slot in the origin pile** (where the card will rest after
|
||||
/// the rejection). Saving the drop-location X here was the root cause of
|
||||
/// the "card lands beside the pile" bug — `tick_shake_anim` restores
|
||||
/// `translation.x` to `origin_x` at the end of the shake, fighting the
|
||||
/// `sync_cards` slide that `StateChangedEvent` triggers.
|
||||
///
|
||||
/// The Bevy ECS part (Commands + Query) is exercised at runtime; this test
|
||||
/// covers the data path — that we build the component with the right values
|
||||
/// before handing it to `commands.entity(...).insert(...)`.
|
||||
#[test]
|
||||
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
|
||||
);
|
||||
/// Helper: build the `CardAnimation` the rejection paths construct for
|
||||
/// one dragged card. Mirrors the inline logic in `end_drag` and
|
||||
/// `touch_end_drag` so the tests stay in sync with the production code.
|
||||
fn build_drag_reject_animation(
|
||||
drag_pos: Vec2,
|
||||
drag_z: f32,
|
||||
target_pos: Vec2,
|
||||
stack_index: usize,
|
||||
) -> CardAnimation {
|
||||
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
|
||||
.with_duration(MOTION_DRAG_REJECT_SECS)
|
||||
}
|
||||
|
||||
/// When a drag is rejected, every card id in `drag.cards` should receive a
|
||||
/// `ShakeAnim`. Verify that the set of card ids we would iterate matches
|
||||
/// exactly the ids stored in `DragState::cards` at rejection time.
|
||||
/// Every card in `drag.cards` should receive its own `CardAnimation` on
|
||||
/// rejection. With the shake → tween migration, the assertion changes
|
||||
/// from "every dragged card gets a ShakeAnim" to "every dragged card
|
||||
/// gets a CardAnimation" — same coverage, new component.
|
||||
#[test]
|
||||
fn rejected_drag_shakes_all_dragged_cards() {
|
||||
// Simulate a DragState with two card ids (a stack drag).
|
||||
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||
// Simulate a stack drag of two cards.
|
||||
let dragged_ids: Vec<u32> = vec![10, 11];
|
||||
|
||||
// In `end_drag`, we iterate `drag.cards` and look up each id in
|
||||
// `card_entities`. The ids we would insert ShakeAnim on must exactly
|
||||
// match the dragged set.
|
||||
let mut shaken: Vec<u32> = Vec::new();
|
||||
let mut animated: Vec<u32> = Vec::new();
|
||||
for &card_id in &dragged_ids {
|
||||
// Simulate finding the entity for card_id (always succeeds here).
|
||||
shaken.push(card_id);
|
||||
// In `end_drag` we iterate `drag.cards` and look up each id in
|
||||
// `card_entities`. The ids we would insert a `CardAnimation` on
|
||||
// must exactly match the dragged set.
|
||||
animated.push(card_id);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
shaken, dragged_ids,
|
||||
"every card id in drag.cards must receive a ShakeAnim on rejection"
|
||||
animated, dragged_ids,
|
||||
"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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::collections::HashMap;
|
||||
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::{Resource, SystemSet};
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
/// Schedule labels for layout-related systems so cross-plugin ordering is
|
||||
@@ -27,7 +26,11 @@ pub enum LayoutSystem {
|
||||
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
|
||||
|
||||
/// 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
|
||||
/// the tableau row.
|
||||
@@ -43,6 +46,15 @@ const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
/// this column inside the visible window.
|
||||
const MAX_TABLEAU_CARDS: f32 = 13.0;
|
||||
|
||||
/// Vertical pixel band reserved at the top of the play area for the HUD
|
||||
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
|
||||
/// below this band so the HUD doesn't bleed into the play surface.
|
||||
///
|
||||
/// 64 px comfortably fits the action button bar (~32 px tall) plus the
|
||||
/// Score/Moves text line plus padding, with a few pixels of breathing room.
|
||||
/// The matching translucent background is painted by `hud_plugin::spawn_hud_band`.
|
||||
pub const HUD_BAND_HEIGHT: f32 = 64.0;
|
||||
|
||||
/// Table background colour (dark green felt).
|
||||
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
||||
|
||||
@@ -51,7 +63,7 @@ pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
||||
pub struct Layout {
|
||||
/// 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.
|
||||
pub card_size: Vec2,
|
||||
/// Centre position of each pile, in 2D world coordinates.
|
||||
@@ -72,7 +84,8 @@ pub struct Layout {
|
||||
/// column (13 face-up cards, see [`MAX_TABLEAU_CARDS`]) inside the
|
||||
/// window with a bottom margin equal to `h_gap`. Limiter on tall/narrow
|
||||
/// 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`.
|
||||
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
|
||||
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
|
||||
@@ -88,8 +101,8 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
||||
//
|
||||
// Letting w = card_width and h = w * CARD_ASPECT, the vertical layout is:
|
||||
// top edge of window = +window.y / 2
|
||||
// top of top-row card = window.y/2 - h_gap (h_gap top margin)
|
||||
// centre of top-row card = window.y/2 - h_gap - h/2
|
||||
// top of top-row card = window.y/2 - HUD_BAND_HEIGHT - h_gap (HUD reserve + h_gap top margin)
|
||||
// centre of top-row card = window.y/2 - HUD_BAND_HEIGHT - h_gap - h/2
|
||||
// centre of tableau card = top centre - h - vertical_gap (vertical_gap = VERTICAL_GAP_FRAC * h)
|
||||
// bottom of last fanned = tableau_centre + h/2 - fan_factor * h
|
||||
// where fan_factor = 1 + (MAX_TABLEAU_CARDS - 1) * TABLEAU_FAN_FRAC
|
||||
@@ -97,10 +110,10 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
||||
//
|
||||
// Substituting h_gap = w/4 and h = CARD_ASPECT * w and solving for the
|
||||
// largest w that fits gives:
|
||||
// window.y = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
|
||||
// (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
|
||||
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
|
||||
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
|
||||
let card_width_height_based = window.y / height_denom;
|
||||
let card_width_height_based = (window.y - HUD_BAND_HEIGHT).max(0.0) / height_denom;
|
||||
|
||||
let card_width = card_width_width_based.min(card_width_height_based);
|
||||
let card_height = card_width * CARD_ASPECT;
|
||||
@@ -120,7 +133,7 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
||||
};
|
||||
|
||||
let vertical_gap = card_height * VERTICAL_GAP_FRAC;
|
||||
let top_y = window.y / 2.0 - h_gap - card_height / 2.0;
|
||||
let top_y = window.y / 2.0 - HUD_BAND_HEIGHT - h_gap - card_height / 2.0;
|
||||
let tableau_y = top_y - card_height - vertical_gap;
|
||||
|
||||
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
|
||||
@@ -129,11 +142,10 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
||||
pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y));
|
||||
|
||||
// Column 2 is skipped — visual separation between waste and foundations.
|
||||
let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
for (i, suit) in foundation_suits.into_iter().enumerate() {
|
||||
for slot in 0..4_u8 {
|
||||
pile_positions.insert(
|
||||
PileType::Foundation(suit),
|
||||
Vec2::new(col_x(3 + i), top_y),
|
||||
PileType::Foundation(slot),
|
||||
Vec2::new(col_x(3 + slot as usize), top_y),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,11 +170,10 @@ mod tests {
|
||||
fn assert_all_piles_present(layout: &Layout) {
|
||||
assert!(layout.pile_positions.contains_key(&PileType::Stock));
|
||||
assert!(layout.pile_positions.contains_key(&PileType::Waste));
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
for slot in 0..4_u8 {
|
||||
assert!(
|
||||
layout.pile_positions.contains_key(&PileType::Foundation(suit)),
|
||||
"missing foundation for {:?}",
|
||||
suit
|
||||
layout.pile_positions.contains_key(&PileType::Foundation(slot)),
|
||||
"missing foundation slot {slot}",
|
||||
);
|
||||
}
|
||||
for i in 0..7 {
|
||||
@@ -217,6 +228,23 @@ mod tests {
|
||||
assert!(stock_y > tableau_y);
|
||||
}
|
||||
|
||||
/// HUD band reservation: the top edge of every top-row card must sit
|
||||
/// at least `HUD_BAND_HEIGHT` pixels below the top of the window so
|
||||
/// the action button bar / score readout has its own visual band
|
||||
/// instead of bleeding into the play surface.
|
||||
#[test]
|
||||
fn top_row_clears_hud_band() {
|
||||
let window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(window);
|
||||
let stock_y = layout.pile_positions[&PileType::Stock].y;
|
||||
let card_top = stock_y + layout.card_size.y / 2.0;
|
||||
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
|
||||
assert!(
|
||||
card_top <= band_bottom,
|
||||
"top of stock card ({card_top}) must sit below the HUD band ({band_bottom})",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
@@ -231,15 +259,13 @@ mod tests {
|
||||
#[test]
|
||||
fn foundations_align_with_tableau_cols_3_to_6() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
for (i, suit) in foundation_suits.into_iter().enumerate() {
|
||||
let f_x = layout.pile_positions[&PileType::Foundation(suit)].x;
|
||||
let t_x = layout.pile_positions[&PileType::Tableau(3 + i)].x;
|
||||
for slot in 0..4_u8 {
|
||||
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
|
||||
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
|
||||
assert!(
|
||||
(f_x - t_x).abs() < 1e-5,
|
||||
"foundation {:?} should align with tableau {}",
|
||||
suit,
|
||||
3 + i
|
||||
"foundation slot {slot} should align with tableau {}",
|
||||
3 + slot as usize,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,9 @@ pub use card_animation::{
|
||||
FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
|
||||
};
|
||||
pub use feedback_anim_plugin::{
|
||||
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
|
||||
FeedbackAnimPlugin, SettleAnim, ShakeAnim,
|
||||
deal_stagger_delay, deal_stagger_secs_for_speed, foundation_flourish_scale, shake_offset,
|
||||
settle_scale, FeedbackAnimPlugin, FoundationFlourish, FoundationMarkerFlourish, SettleAnim,
|
||||
ShakeAnim,
|
||||
};
|
||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||
@@ -82,8 +83,8 @@ pub use font_plugin::{FontPlugin, FontResource};
|
||||
pub use cursor_plugin::CursorPlugin;
|
||||
pub use events::{
|
||||
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||
ForfeitEvent, ForfeitRequestEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent,
|
||||
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
||||
|
||||
@@ -94,6 +94,7 @@ const HOTKEYS: &[HotkeyRow] = &[
|
||||
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
|
||||
HotkeyRow { keys: "U", description: "Undo last move" },
|
||||
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: "A", description: "Achievements" },
|
||||
HotkeyRow { keys: "O", description: "Settings" },
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
@@ -82,15 +81,12 @@ impl Plugin for SelectionPlugin {
|
||||
|
||||
/// The ordered list of piles that are considered for keyboard cycling.
|
||||
///
|
||||
/// Order: Waste → Foundation×4 → Tableau 0–6.
|
||||
/// Order: Waste → Foundation slots 0–3 → Tableau 0–6.
|
||||
fn cycled_piles() -> Vec<PileType> {
|
||||
let mut piles = vec![
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
];
|
||||
let mut piles = vec![PileType::Waste];
|
||||
for slot in 0..4_u8 {
|
||||
piles.push(PileType::Foundation(slot));
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
piles.push(PileType::Tableau(i));
|
||||
}
|
||||
@@ -183,10 +179,10 @@ fn handle_selection_keys(
|
||||
let available: Vec<PileType> = {
|
||||
let all = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
@@ -325,10 +321,10 @@ fn try_foundation_dest(
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
) -> Option<PileType> {
|
||||
use solitaire_core::rules::can_place_on_foundation;
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let dest = PileType::Foundation(suit);
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, pile, suit) {
|
||||
&& can_place_on_foundation(card, pile) {
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,8 +225,8 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
let mut piles: Vec<PileType> = Vec::with_capacity(13);
|
||||
piles.push(PileType::Stock);
|
||||
piles.push(PileType::Waste);
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
piles.push(PileType::Foundation(suit));
|
||||
for slot in 0..4_u8 {
|
||||
piles.push(PileType::Foundation(slot));
|
||||
}
|
||||
for i in 0..7 {
|
||||
piles.push(PileType::Tableau(i));
|
||||
@@ -244,18 +244,9 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
PileMarker(pile.clone()),
|
||||
));
|
||||
|
||||
// Task #35 — suit symbol on empty foundation placeholders.
|
||||
if let PileType::Foundation(suit) = &pile {
|
||||
let symbol = suit_symbol(suit).to_string();
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
Text2d::new(symbol),
|
||||
TextFont { font_size, ..default() },
|
||||
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.45)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
});
|
||||
}
|
||||
// Foundation slots no longer carry a suit letter — any Ace can claim
|
||||
// any empty slot, so a fixed C/D/H/S badge would be misleading. Empty
|
||||
// foundation markers render as plain translucent rectangles.
|
||||
|
||||
// Task #43 — King indicator on empty tableau placeholders.
|
||||
if let PileType::Tableau(_) = &pile {
|
||||
|
||||
@@ -41,13 +41,17 @@
|
||||
//! here no-ops so [`crate::selection_plugin`]'s Tab/Enter
|
||||
//! card-selection still works.
|
||||
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
use bevy::ecs::query::Has;
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
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
|
||||
@@ -126,12 +130,57 @@ impl Plugin for UiFocusPlugin {
|
||||
clear_hud_focus_on_unhover,
|
||||
handle_focus_keys,
|
||||
update_focus_overlay,
|
||||
pulse_focus_overlay,
|
||||
)
|
||||
.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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -588,6 +637,40 @@ mod tests {
|
||||
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`
|
||||
/// requires a `Component` on the scrim.
|
||||
#[derive(Component, Debug)]
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
//! changing the constant API.
|
||||
|
||||
use bevy::color::Color;
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::Val;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
@@ -48,6 +49,13 @@ pub const BG_ELEVATED_PRESSED: Color = Color::srgb(0.149, 0.082, 0.357);
|
||||
/// them. `rgba(13, 7, 28, 0.85)`.
|
||||
pub const SCRIM: Color = Color::srgba(0.051, 0.027, 0.110, 0.85);
|
||||
|
||||
/// Translucent fill for the top-of-window HUD band painted by
|
||||
/// `hud_plugin::spawn_hud_band`. Same midnight-purple hue as `BG_BASE`,
|
||||
/// but at 0.70 alpha so the green felt reads through subtly — enough
|
||||
/// to mark the band as "UI" without feeling like a hard chrome strip.
|
||||
/// `rgba(26, 15, 46, 0.70)`.
|
||||
pub const BG_HUD_BAND: Color = Color::srgba(0.102, 0.059, 0.180, 0.70);
|
||||
|
||||
/// Primary text — warm off-white with a hint of purple to fit the
|
||||
/// midnight palette without feeling clinical. `#F5F0FF`.
|
||||
pub const TEXT_PRIMARY: Color = Color::srgb(0.961, 0.941, 1.000);
|
||||
@@ -88,6 +96,106 @@ pub const STATE_DANGER: Color = Color::srgb(0.969, 0.447, 0.447);
|
||||
/// Info — daily-challenge constraint, draw-cycle indicator. `#6BBBFF`.
|
||||
pub const STATE_INFO: Color = Color::srgb(0.420, 0.733, 1.000);
|
||||
|
||||
/// Soft fill colour for the drop-target overlay shown over every legal
|
||||
/// destination pile while the player is dragging a card. Same green hue
|
||||
/// as `STATE_SUCCESS` (`#4ADE80`) so the visual language stays
|
||||
/// consistent, but at 10 % alpha so the underlying card faces remain
|
||||
/// fully readable through the wash.
|
||||
pub const DROP_TARGET_FILL: Color = Color::srgba(0.290, 0.871, 0.502, 0.10);
|
||||
|
||||
/// Outline colour for the drop-target overlay. Matches the
|
||||
/// `STATE_SUCCESS` hue at 75 % alpha so the rectangle border reads
|
||||
/// unmistakably against both the felt and stacked card faces without
|
||||
/// drowning the cards themselves.
|
||||
pub const DROP_TARGET_OUTLINE: Color = Color::srgba(0.290, 0.871, 0.502, 0.75);
|
||||
|
||||
/// Thickness of the drop-target outline edges, in world-space pixels.
|
||||
pub const DROP_TARGET_OUTLINE_PX: f32 = 3.0;
|
||||
|
||||
/// Sprite-space `Transform.z` for drop-target overlay entities. Sits
|
||||
/// well above any static card (top stack z is `~1.04`) but well below
|
||||
/// the lifted dragged stack (`DRAG_Z = 500.0` in `input_plugin`) so the
|
||||
/// overlay never occludes the card the player is holding. Distinct from
|
||||
/// the i32 `Z_*` UI-Node tokens above — those are `ZIndex` values for
|
||||
/// `bevy::ui`, while this is a 2D `Sprite` z coordinate.
|
||||
pub const Z_DROP_OVERLAY: f32 = 50.0;
|
||||
|
||||
/// Background colour of the stock-pile remaining-count chip.
|
||||
///
|
||||
/// Reuses `BG_ELEVATED_HI` so the chip reads as one rung above the
|
||||
/// translucent stock pile marker without introducing a new palette
|
||||
/// value. The badge sits on the stock corner so the player knows how
|
||||
/// many cards remain before a recycle.
|
||||
pub const STOCK_BADGE_BG: Color = BG_ELEVATED_HI;
|
||||
|
||||
/// Foreground (text) colour of the stock-pile remaining-count chip.
|
||||
///
|
||||
/// `ACCENT_PRIMARY` keeps the chip readable against the elevated
|
||||
/// purple background and matches the Balatro accent already used for
|
||||
/// other "look here" callouts.
|
||||
pub const STOCK_BADGE_FG: Color = ACCENT_PRIMARY;
|
||||
|
||||
/// Sprite-space `Transform.z` for the stock-pile remaining-count chip.
|
||||
///
|
||||
/// Sits above the stock pile marker (`Z_PILE_MARKER` = `-1`) and any
|
||||
/// face-down stock cards (which start at `0`), but well below
|
||||
/// [`Z_DROP_OVERLAY`] (`50.0`) so the green drop-target wash always
|
||||
/// renders on top while a card is being dragged. Like `Z_DROP_OVERLAY`,
|
||||
/// this is a 2D `Sprite` z coordinate, not a `bevy::ui` `ZIndex`.
|
||||
pub const Z_STOCK_BADGE: f32 = 30.0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card drop-shadow — the subtle dark halo painted beneath every card so the
|
||||
// play surface reads as physical instead of a flat collage of stickers. Idle
|
||||
// values are deliberately low-contrast (small offset, ~25% alpha) so resting
|
||||
// cards feel grounded without competing with focus rings or drop overlays.
|
||||
// Drag values are slightly stronger (further offset, ~40% alpha, larger
|
||||
// halo) so the dragged stack visually "lifts" off the felt.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// RGB base for the per-card drop shadow. Always neutral black — never
|
||||
/// suit-tinted — so the shadow never carries colour information that a
|
||||
/// colour-blind player would rely on to identify a card. Alpha is applied
|
||||
/// separately via [`CARD_SHADOW_ALPHA_IDLE`] / [`CARD_SHADOW_ALPHA_DRAG`].
|
||||
pub const CARD_SHADOW_COLOR: Color = Color::srgb(0.0, 0.0, 0.0);
|
||||
|
||||
/// Alpha for the resting-state card shadow. Low enough that 52 stacked
|
||||
/// shadows do not darken the felt into a uniform smear, high enough that
|
||||
/// each card reads as separated from the surface.
|
||||
pub const CARD_SHADOW_ALPHA_IDLE: f32 = 0.25;
|
||||
|
||||
/// Alpha for the lifted/dragged card shadow. Stronger than the idle value
|
||||
/// so the dragged stack visibly "casts more shadow" while the player holds
|
||||
/// it above the table.
|
||||
pub const CARD_SHADOW_ALPHA_DRAG: f32 = 0.40;
|
||||
|
||||
/// World-space pixel offset of the resting-state card shadow relative to
|
||||
/// its parent card centre. Down-and-right matches a soft top-left light
|
||||
/// source — the same convention used by the elevated-surface tones in the
|
||||
/// rest of the palette.
|
||||
pub const CARD_SHADOW_OFFSET_IDLE: Vec2 = Vec2::new(2.0, -3.0);
|
||||
|
||||
/// World-space pixel offset of the lifted/dragged card shadow. Roughly
|
||||
/// double the idle offset so the parallax reads as "the card is further
|
||||
/// from the table".
|
||||
pub const CARD_SHADOW_OFFSET_DRAG: Vec2 = Vec2::new(4.0, -6.0);
|
||||
|
||||
/// Padding in pixels added to each axis of the card size when sizing the
|
||||
/// resting-state shadow sprite. The shadow extends slightly past every
|
||||
/// edge of the card so the dark border reads as a halo rather than a
|
||||
/// matte rectangle behind the card.
|
||||
pub const CARD_SHADOW_PADDING_IDLE: Vec2 = Vec2::new(4.0, 4.0);
|
||||
|
||||
/// Padding added to the card size when sizing the lifted/dragged shadow.
|
||||
/// A slightly larger halo at the drag state reinforces the "lifted off
|
||||
/// the felt" cue alongside the deeper offset and higher alpha.
|
||||
pub const CARD_SHADOW_PADDING_DRAG: Vec2 = Vec2::new(8.0, 8.0);
|
||||
|
||||
/// Local `Transform.z` for the shadow child sprite, relative to its
|
||||
/// parent `CardEntity`. Slightly negative so the shadow always renders
|
||||
/// below the card itself even though it shares the parent's world z.
|
||||
pub const CARD_SHADOW_LOCAL_Z: f32 = -0.05;
|
||||
|
||||
/// Subtle border — default popover, card, and idle button outline.
|
||||
pub const BORDER_SUBTLE: Color = Color::srgba(0.647, 0.549, 1.000, 0.12);
|
||||
|
||||
@@ -225,6 +333,11 @@ pub const MOTION_SHAKE_SECS: f32 = 0.25;
|
||||
/// Shake angular frequency in rad/s.
|
||||
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 =
|
||||
/// 200 ms total. Pair with a ±8° Z-rotation at the midpoint for a 3D
|
||||
/// feel without 3D rendering.
|
||||
@@ -271,10 +384,34 @@ pub const MOTION_BUTTON_BLEND_SECS: f32 = 0.10;
|
||||
/// readout 1.0 → 1.1 → 1.0. 250 ms.
|
||||
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.
|
||||
/// 400 ms.
|
||||
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
|
||||
/// players gliding the cursor across the HUD don't see flicker; short
|
||||
/// enough that "stop and read" feels responsive. Not run through
|
||||
|
||||
Reference in New Issue
Block a user