Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe41b502ac | |||
| b37f0cbec7 | |||
| a0fc0d2605 | |||
| 7ed4f2cba9 | |||
| ddc8f27c82 | |||
| 13dd44bd1b | |||
| 17f9b518f1 | |||
| 61d891fb76 | |||
| 7dba772e67 | |||
| ca5788f714 | |||
| 9887343d8b | |||
| 525fe0fe76 | |||
| 69ce9afab9 | |||
| 13aa0fd833 | |||
| 9f095c4039 | |||
| d8c70341f4 |
@@ -1,31 +0,0 @@
|
|||||||
# Project-wide cargo configuration.
|
|
||||||
#
|
|
||||||
# Routes every rustc invocation through `sccache` so cold rebuilds and
|
|
||||||
# fresh checkouts (CI, new dev box, after a `cargo clean`) replay
|
|
||||||
# previously-compiled crates from a local on-disk cache rather than
|
|
||||||
# recompiling them. Warm incremental builds still go through cargo's
|
|
||||||
# own `target/` cache, which dominates locally — sccache buys you the
|
|
||||||
# big wins on cold paths.
|
|
||||||
#
|
|
||||||
# Requires sccache on PATH. Install it once per machine:
|
|
||||||
#
|
|
||||||
# Arch : pacman -S sccache
|
|
||||||
# macOS : brew install sccache
|
|
||||||
# Cargo : cargo install sccache --locked
|
|
||||||
#
|
|
||||||
# Without sccache the build fails with "rustc-wrapper not found". To
|
|
||||||
# bypass this config without editing the file, prepend
|
|
||||||
# `RUSTC_WRAPPER= ` (empty value) to your cargo command:
|
|
||||||
#
|
|
||||||
# RUSTC_WRAPPER= cargo build
|
|
||||||
#
|
|
||||||
[build]
|
|
||||||
rustc-wrapper = "sccache"
|
|
||||||
|
|
||||||
# Project-local cache so the shared dev box (or a Docker volume) keeps
|
|
||||||
# the artefacts isolated per checkout instead of mixing them in
|
|
||||||
# `~/.cache/sccache`. Set with `force = false` so a developer-set
|
|
||||||
# `SCCACHE_DIR` in their shell wins — important because the sccache
|
|
||||||
# daemon, once started, sticks with whichever directory it saw first.
|
|
||||||
[env]
|
|
||||||
SCCACHE_DIR = { value = ".sccache-cache", relative = true, force = false }
|
|
||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
# 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.13.0] — 2026-05-02
|
||||||
|
|
||||||
|
Third UX iteration round on top of v0.12.0. Six handoff candidates
|
||||||
|
shipped — three small polish items, three larger interaction
|
||||||
|
features (theme-aware backs, full keyboard play, right-click power
|
||||||
|
shortcut). Plus two code-review fixes (font handling unified,
|
||||||
|
sccache wiring removed).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Tooltip-delay slider** in Settings → Gameplay. `tooltip_delay_secs`
|
||||||
|
ranges [0.0, 1.5] in 0.1 s steps; "Instant" label when zero.
|
||||||
|
`Settings.tooltip_delay_secs` round-trips through serialise/deserialise
|
||||||
|
with `#[serde(default)]`. The hover-delay comparison in
|
||||||
|
`ui_tooltip` reads from `SettingsResource` with the existing
|
||||||
|
`MOTION_TOOLTIP_DELAY_SECS` as the test-fixture fallback.
|
||||||
|
- **Win-streak fire animation.** New `WinStreakMilestoneEvent` fires
|
||||||
|
from `stats_plugin` when `win_streak_current` crosses any of
|
||||||
|
[3, 5, 10] (only the threshold crossing — not every subsequent
|
||||||
|
win). The HUD streak readout scale-pulses 1.0 → 1.20 → 1.0 over
|
||||||
|
`MOTION_STREAK_FLOURISH_SECS` (0.6 s).
|
||||||
|
- **Score-breakdown reveal on the win modal.** Replaces the single
|
||||||
|
"Score: N" line with a per-component reveal (Base / Time bonus /
|
||||||
|
No-undo bonus / Mode multiplier / Total). Rows fade in over
|
||||||
|
`MOTION_SCORE_BREAKDOWN_FADE_SECS` (0.12 s) staggered by
|
||||||
|
`MOTION_SCORE_BREAKDOWN_STAGGER_SECS` (0.15 s). Honours
|
||||||
|
`AnimSpeed::Instant` by spawning all rows fully visible.
|
||||||
|
- **Card backs follow the active theme.** `theme.ron`'s `back` slot
|
||||||
|
now actually drives the face-down sprite. Active-theme back
|
||||||
|
rasterises alongside the faces and supersedes the legacy
|
||||||
|
`back_N.png` picker. The picker remains as a fallback for themes
|
||||||
|
that don't ship a back, and the Settings UI surfaces a caption
|
||||||
|
("Active theme provides its own back") + dimmed swatches when
|
||||||
|
the override is in effect.
|
||||||
|
- **Keyboard-only drag-and-drop.** Tab cycles draggable card stacks,
|
||||||
|
Enter "lifts" the focused stack, arrow keys (or Tab) cycle the
|
||||||
|
legal-destination targets only, Enter confirms, Esc cancels. A
|
||||||
|
new `KeyboardDragState` resource models the two-mode flow without
|
||||||
|
changing the existing `SelectionState` contract. Mutual exclusion
|
||||||
|
with mouse drag uses a sentinel `DragState.active_touch_id =
|
||||||
|
KEYBOARD_DRAG_TOUCH_ID` (u64::MAX) so neither pipeline can
|
||||||
|
trample the other.
|
||||||
|
- **Right-click radial menu.** Hold right-click on a face-up card →
|
||||||
|
a small ring of icons appears at the cursor with one entry per
|
||||||
|
legal destination. Release over an icon → fires
|
||||||
|
`MoveRequestEvent`; release in dead space, Esc, or left-click
|
||||||
|
cancels. Skips the drag motion entirely. New `RadialMenuPlugin`
|
||||||
|
owns the flow; co-exists with the existing `RightClickHighlight`
|
||||||
|
pile-marker tint.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Font handling consolidated to bundled-only.** Code-review
|
||||||
|
feedback: the SVG rasteriser previously mixed
|
||||||
|
`load_system_fonts` + bundled FiraMono + a lenient resolver,
|
||||||
|
which made card text rendering depend on host fontconfig. Picked
|
||||||
|
option (a) and applied it across both layers — `font_plugin` now
|
||||||
|
embeds `assets/fonts/main.ttf` via `include_bytes!()` and
|
||||||
|
registers it with `Assets<Font>`; `svg_loader::shared_fontdb`
|
||||||
|
loads only the bundled bytes; the new `bundled_font_resolver`
|
||||||
|
ignores the SVG's `font-family` request and always returns the
|
||||||
|
single bundled face. A parse failure aborts with a clear error
|
||||||
|
("bundled FiraMono failed to parse — binary is corrupt").
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Project-level sccache wiring.** Code-review feedback: sccache
|
||||||
|
shouldn't be a per-project build dependency. Cargo's incremental
|
||||||
|
cache already covers the single-project case, and forcing
|
||||||
|
`rustc-wrapper = "sccache"` workspace-wide meant every contributor
|
||||||
|
had to install it. `.cargo/config.toml` deleted entirely; plain
|
||||||
|
`cargo build` now works without setup.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `help_plugin` controls reference gains a "Mouse" section covering
|
||||||
|
double-click auto-move, right-click highlight, and the new
|
||||||
|
hold-RMB radial.
|
||||||
|
- `help_plugin` also gains a "Keyboard drag" section for the new
|
||||||
|
Tab/Enter/Arrows/Esc flow.
|
||||||
|
- Onboarding slide 3 picks up a `Tab → Enter` row referencing the
|
||||||
|
full keyboard drag path.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1053 passing tests (was 1031 at v0.12.0 close).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [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.13.0...HEAD
|
||||||
|
[0.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0
|
||||||
|
[0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0
|
||||||
|
[0.11.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.10.0...v0.11.0
|
||||||
|
[0.10.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.9.0...v0.10.0
|
||||||
|
[0.9.0]: https://github.com/funman300/Rusty_Solitaire/releases/tag/v0.9.0
|
||||||
@@ -1,17 +1,35 @@
|
|||||||
# Solitaire Quest
|
# Solitaire Quest
|
||||||
|
|
||||||
A cross-platform Klondike Solitaire game written in Rust, featuring a full progression system with XP, levels, achievements, daily challenges, and optional self-hosted sync so your stats follow you across machines.
|
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
|
||||||
|
system, full progression (XP / levels / achievements / daily challenges), and
|
||||||
|
optional self-hosted sync so your stats follow you across machines.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Klondike Solitaire** — Draw One and Draw Three modes
|
- **Klondike Solitaire** — Draw One and Draw Three modes; foundations are
|
||||||
|
unlocked (any Ace lands in any empty slot, the slot then claims that suit)
|
||||||
|
- **Card themes** — bundled hayeah/playing-cards-assets default plus
|
||||||
|
user-installable themes (drop a directory under the data dir or import a
|
||||||
|
zip from Settings → Cosmetic)
|
||||||
|
- **Modern HUD** — reserved top band keeps cards from crowding the score
|
||||||
|
readout; the action bar auto-fades when the cursor leaves it so it can't
|
||||||
|
compete with the play surface
|
||||||
|
- **Drag feel** — every legal drop target is highlighted in green during
|
||||||
|
drag; cards cast a soft drop shadow that lifts when picked up; the stock
|
||||||
|
pile shows a remaining-count chip so you can see how close you are to a
|
||||||
|
recycle
|
||||||
|
- **Keyboard navigation** — Tab cycles focus through buttons, arrow keys
|
||||||
|
move within picker rows, Enter activates; works across every modal and
|
||||||
|
the HUD action bar
|
||||||
- **Progression** — XP, levels, unlockable card backs and backgrounds
|
- **Progression** — XP, levels, unlockable card backs and backgrounds
|
||||||
- **18 Achievements** — including secret ones
|
- **18 Achievements** — including secret ones
|
||||||
- **Daily Challenge** — server-seeded so every player worldwide gets the same deal
|
- **Daily Challenge** — server-seeded so every player worldwide gets the
|
||||||
|
same deal
|
||||||
- **Leaderboard** — opt-in, powered by your own self-hosted server
|
- **Leaderboard** — opt-in, powered by your own self-hosted server
|
||||||
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
|
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
|
||||||
- **Sync** — pull/push stats across devices via a self-hosted server
|
- **Sync** — pull/push stats across devices via a self-hosted server
|
||||||
- **Color-blind mode** — blue tint on red-suit cards
|
- **Color-blind mode** — blue tint on red-suit cards alongside the suit
|
||||||
|
glyph
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -32,52 +50,73 @@ cargo build -p solitaire_app --release
|
|||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
|
Every action also has a visible UI button — keyboard shortcuts are optional
|
||||||
|
accelerators.
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Left click / drag | Move cards |
|
| Left click / drag | Move cards |
|
||||||
|
| Double click | Auto-move card to its best legal destination |
|
||||||
| Right click | Highlight legal moves for a card |
|
| Right click | Highlight legal moves for a card |
|
||||||
| Space / D | Draw from stock |
|
| Space / D | Draw from stock |
|
||||||
| Z / Ctrl+Z | Undo |
|
| U | Undo |
|
||||||
|
| H | Hint (highlight a legal move) |
|
||||||
| N | New game |
|
| N | New game |
|
||||||
| S | Stats overlay |
|
| Z | Zen mode |
|
||||||
| A | Achievements overlay |
|
| G | Forfeit (during pause) |
|
||||||
| P | Profile overlay |
|
| Tab / Shift+Tab | Cycle keyboard focus |
|
||||||
| O | Settings |
|
| Enter | Activate focused button / auto-complete (when badge is lit) |
|
||||||
| L | Leaderboard |
|
| Esc | Pause / dismiss modal |
|
||||||
| H | Help / controls |
|
| F1 | Help / controls |
|
||||||
| Enter | Auto-complete (when badge is lit) |
|
| F11 | Toggle fullscreen |
|
||||||
| Escape | Pause / clear selection |
|
| S / A / P / O / L / M | Stats / Achievements / Profile / Settings / Leaderboard / Menu |
|
||||||
| Arrow keys | Navigate card selection |
|
|
||||||
|
## Card themes
|
||||||
|
|
||||||
|
The default theme ships embedded in the binary, so the game runs
|
||||||
|
self-contained with no external assets. To install another theme, drop a
|
||||||
|
directory containing a `theme.ron` manifest plus 53 SVG files (52 faces +
|
||||||
|
1 back) under the platform data dir's `themes/` folder, or import a zip
|
||||||
|
from **Settings → Cosmetic**. The picker chip lights up the moment a new
|
||||||
|
theme is registered. Themes are SVG-based, so they rasterise cleanly at
|
||||||
|
whatever resolution the window happens to be.
|
||||||
|
|
||||||
## Sync Server (optional)
|
## Sync Server (optional)
|
||||||
|
|
||||||
To sync stats across machines, run the self-hosted server. See [README_SERVER.md](README_SERVER.md) for setup instructions.
|
To sync stats across machines, run the self-hosted server. See
|
||||||
|
[README_SERVER.md](README_SERVER.md) for setup instructions.
|
||||||
|
|
||||||
Once the server is running, open **Settings → Sync Backend**, enter the server URL and your username, and register an account from within the game.
|
Once the server is running, open **Settings → Sync Backend**, enter the
|
||||||
|
server URL and your username, and register an account from within the
|
||||||
|
game.
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All tests
|
# All tests (982 passing as of v0.11.0)
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
|
|
||||||
# Just game logic (no display required)
|
# Just game logic (no display required)
|
||||||
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
|
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
|
||||||
|
|
||||||
# Lint
|
# Lint
|
||||||
cargo clippy --workspace -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem (Tokio,
|
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
||||||
Axum, sqlx, Serde, kira, and many more). Card faces come from
|
(Tokio, Axum, sqlx, Serde, kira, and many more). Card faces come from
|
||||||
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
|
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
|
||||||
(MIT, derived from the public-domain `vector-playing-cards` library); the
|
(MIT, derived from the public-domain `vector-playing-cards` library); the
|
||||||
default card back is original work; the UI font is FiraMono-Medium (OFL).
|
default card back is original work; the UI font is FiraMono-Medium (OFL).
|
||||||
All audio is synthesized programmatically by this project. See
|
All audio is synthesized programmatically by this project. See
|
||||||
[CREDITS.md](CREDITS.md) for the full list and license details.
|
[CREDITS.md](CREDITS.md) for the full list and license details.
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
See [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|||||||
+51
-52
@@ -1,66 +1,67 @@
|
|||||||
# Solitaire Quest — UX Overhaul Session Handoff
|
# Solitaire Quest — UX Overhaul Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-05-02 (session 7) — UX iteration round complete: every item from session 6's UX punch list has shipped, plus a font-fallback fix surfaced by a second-machine smoke test. Six commits on top of session 6's `c4970b1`. Direction now opens for the next round — release prep or another UX pass, the player's call.
|
**Last updated:** 2026-05-02 (session 7, late-late) — Third UX iteration round complete on top of v0.12.0. Six post-handoff candidates shipped plus two code-review fixes. Ready to tag v0.13.0.
|
||||||
|
|
||||||
## Status at pause
|
## Status at pause
|
||||||
|
|
||||||
- **HEAD:** `655dfde`. Local master is **3 commits ahead** of `origin/master` (`f6c9166`, `f712b89`, `655dfde` unpushed; `fdb6c2e` and `95df542` already pushed).
|
- **HEAD:** doc-commit closing this round (CHANGELOG + handoff). Local master has the impending tag at this commit.
|
||||||
- **Working tree:** clean. (`CARD_PLAN.md` is untracked but intentionally so — it's a plan doc, not source.)
|
- **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional).
|
||||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||||
- **Tests:** **982 passed / 0 failed** across the workspace (+20 from session 6's 962 baseline).
|
- **Tests:** **1053 passed / 0 failed** across the workspace (+22 from v0.12.0's 1031 baseline).
|
||||||
- **Tags on origin:** `v0.9.0`, `v0.10.0`. Stale local-only `v0.1.0` is still safe to `git tag -d v0.1.0`.
|
- **Tags on origin:** `v0.9.0`, `v0.10.0`, `v0.11.0`, `v0.12.0`. v0.13.0 is the next tag.
|
||||||
|
|
||||||
## Where we are
|
## Where we are
|
||||||
|
|
||||||
Session 6's UX punch list was four items. All four shipped today, plus an unrelated font-fallback fix from a second-machine smoke test.
|
Post-v0.12.0 the handoff listed six "next-round candidates" — every one shipped today plus two code-review fixes (font handling unified to bundled-only, sccache wiring removed). v0.13.0 is the right slice.
|
||||||
|
|
||||||
The card-theme system, HUD restructure, modal scaffold, and the four big UX feel items (foundations, drop shadows, drop highlights, stock badge) are all in. Direction is open — the deferred release-prep items (`v0.11.0` cut, README/CHANGELOG refresh, desktop packaging) are still on the table, and a fresh round of UX iteration is also available.
|
The candidate list is exhausted again. Direction is open.
|
||||||
|
|
||||||
### Design direction (unchanged)
|
### Design direction (unchanged)
|
||||||
|
|
||||||
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
|
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
|
||||||
- **Palette:** Midnight Purple base + Balatro yellow primary + warm magenta secondary.
|
- **Palette:** Midnight Purple base + Balatro yellow primary + warm magenta secondary.
|
||||||
- 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).
|
- See `~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md` (machine-local).
|
||||||
|
|
||||||
### Canonical remote
|
### Canonical remote
|
||||||
|
|
||||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there. (Earlier sessions used `Rusty_Solitare` — single-i typo — as the repo name; the rename to `Rusty_Solitaire` happened in session 7. Local clone directories may still be named `Rusty_Solitare`; that's just a directory name and works fine.)
|
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there.
|
||||||
|
|
||||||
## Session 7 (shipped 2026-05-02)
|
## Session 7 round 3 (shipped 2026-05-02 late-late) — v0.13.0
|
||||||
|
|
||||||
| Area | Commit | What landed |
|
| Area | Commit | What landed |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Font fallback | `fdb6c2e` | `shared_fontdb` now `include_bytes!()`s `assets/fonts/main.ttf` (FiraMono) and pins every CSS generic to `"Fira Mono"` so unmatched named families on minimal Linux installs / fresh Wayland sessions / chroots don't drop card rank/suit text. Surfaced when a second-machine pull rendered cards without glyphs. |
|
| Font fix | `17f9b51` | Code-review fix: bundle FiraMono via `include_bytes!()` in both `font_plugin` and `svg_loader`; drop `load_system_fonts`, drop the lenient resolver, drop the CSS-generic fallbacks. New `bundled_font_resolver` always returns the single bundled face. Parse failure aborts with a clear error. |
|
||||||
| Unlock foundations | `95df542` | `PileType::Foundation(Suit)` → `Foundation(u8)` (slot 0..3). `Pile::claimed_suit()` derives the claim from the bottom card — no separate field, no claim-stuck-after-undo class of bugs. `can_place_on_foundation` drops its suit parameter. `next_auto_complete_move` prefers a slot whose claimed suit matches the candidate before falling back to the first empty slot for an Ace. Empty foundation markers render as plain placeholders (no "C/D/H/S"). HUD selection label and hint toast read `claimed_suit()` and fall through to "Foundation N" / "move to foundation" when the slot is empty. Save-format invalidation: `GameState.schema_version` bumped 1 → 2; old `game_state.json` files silently fall through to "fresh game on launch." Stats / progress / achievements / settings live in separate files and are unaffected. 9 new tests. |
|
| sccache removal | `13dd44b` | Code-review fix: deleted `.cargo/config.toml` and the `.cargo` directory. Plain `cargo build` works without per-project setup. |
|
||||||
| Drop overlay | `f6c9166` | The pre-existing `update_drop_highlights` system tinted `PileMarker` sprites green for valid drops, but markers were occluded by stacked cards — invisible during real play. New `update_drop_target_overlays` spawns a soft-fill + 3 px outlined box ABOVE cards for every legal target (full fanned column for tableaux, card-sized for foundations / empty tableaux). `Z_DROP_OVERLAY = 50` sits above static cards but below `DRAG_Z = 500` so the dragged card never gets occluded. Reuses `STATE_SUCCESS` hue. The original marker-tint system is untouched. 3 new tests. |
|
| Wave 1 bundle | `ddc8f27` | **Tooltip-delay slider** in Settings → Gameplay (0.0–1.5 s, 0.1 s steps, "Instant" label at zero). **Win-streak fire animation** at thresholds [3, 5, 10] via new `WinStreakMilestoneEvent`. **Score-breakdown reveal on win modal** with per-row stagger (Base / Time bonus / No-undo / Multiplier / Total), respects `AnimSpeed::Instant`. |
|
||||||
| Drop shadows | `f712b89` | Each `CardEntity` spawns a `CardShadow` child sprite — neutral black at 25 % alpha, sized `card_size + 4 px`, offset `(2, -3)`, local z `-0.05`. `update_card_shadows_on_drag` snaps shadows in `DragState.cards` to a lifted state (40 % alpha, `(4, -6)` offset, `(8, 8)` padding). `resize_cards_in_place` extended to keep shadows cheap on window resize. `update_card_entity`'s `despawn_related` is followed by a fresh `add_card_shadow_child` so flips / theme swaps re-attach shadows. Pure `card_shadow_params(is_dragged)` helper unit-tested. 4 new tests. |
|
| Card-back theming | `7ed4f2c` | The active theme's `back.svg` now actually drives the face-down sprite. Legacy `back_N.png` picker remains as a fallback for themes without a back; Settings caption surfaces when the override is in effect. |
|
||||||
| Stock badge | `655dfde` | A small `·N` chip at the top-right corner of the stock pile shows the remaining count. `update_stock_count_badge` spawns a top-level world entity whose `Transform.translation` is recomputed each tick from `LayoutResource`, so window resize / theme swap don't strand it. Hides via `Visibility::Hidden` when the stock empties — the existing `↺` `StockEmptyLabel` takes over and they never co-render. `Z_STOCK_BADGE = 30` sits between cards and `Z_DROP_OVERLAY`. 4 new tests. |
|
| Drag-with-keyboard | `a0fc0d2` | Tab → Enter → arrows → Enter completes a move without a mouse. New `KeyboardDragState` resource; mutual exclusion with mouse drag via `KEYBOARD_DRAG_TOUCH_ID` sentinel. Help + onboarding hotkey lists updated. |
|
||||||
|
| Right-click radial | `b37f0cb` | Hold RMB on a face-up card → ring of icons at the cursor, one per legal destination; release over an icon → `MoveRequestEvent`. New `RadialMenuPlugin`. Help controls reference gains a "Mouse" section. |
|
||||||
|
|
||||||
## Open punch list — release prep (still deferred unless player chooses now)
|
## Open punch list — release prep
|
||||||
|
|
||||||
1. **Cut `v0.11.0`** — meaningful slice since `v0.10.0`: full card-theme system (CARD_PLAN phases 1–7 + theme picker + hayeah art), HUD overhaul (band + fade), session 6's four bug fixes, and session 7's font fallback + four UX feel wins. (`git tag -d v0.1.0` first to clean up the stale local tag.)
|
1. **Push** the unpushed commits to origin (5 commits now: 17f9b51, 13dd44b, ddc8f27, 7ed4f2c, a0fc0d2, b37f0cb, plus the impending doc commit).
|
||||||
2. **README + CHANGELOG refresh** — README was last touched at `a6b8348` before the Settings picker shipped; doesn't mention card themes, the auto-fade, or any of session 7's UX work.
|
2. **Tag v0.13.0** at the doc-commit HEAD.
|
||||||
3. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo, no remote yet). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
|
3. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
|
||||||
|
|
||||||
## Open punch list — UX iteration (next-round candidates)
|
## Open punch list — UX iteration (next-round candidates)
|
||||||
|
|
||||||
The session-6 list is exhausted. Candidates for a next round, none formally requested by the player:
|
The v0.13.0 list is exhausted. Fresh candidates for a future round:
|
||||||
|
|
||||||
- **Animated focus ring** (currently a static overlay; could pulse on focus change).
|
- **In-game daily-challenge calendar** — currently the daily challenge fires once on launch; a Settings or Profile-side calendar showing past days' completion / streak status would make the progression visible.
|
||||||
- **Achievement onboarding pass** — show first-time players the achievement panel after their first win.
|
- **Card-art preview in the theme picker** — Settings → Cosmetic shows theme name only; rendering the theme's Ace-of-Spades + back side-by-side as a thumbnail would make picking faster.
|
||||||
- **Mode-switch keyboard shortcut** from inside the Mode Launcher (today only mouse opens it).
|
- **Per-mode high-score readout** in the Stats screen. Currently lifetime stats roll all modes together.
|
||||||
- **Runtime aspect-ratio fidelity** — hayeah SVGs are ~1.45 h/w; engine layout assumes 1.4. Cards render ~3 % squashed vertically. Cosmetic.
|
- **Auto-save in-progress games** in Zen / Time Attack so players who close the window mid-session don't lose their state.
|
||||||
- **Foundation completion celebration** — when a foundation reaches its King, do a small flourish (sparkle, lift, sound). The auto-complete cascade already covers the win moment, but per-foundation closure is currently silent.
|
- **Configurable scoring weights** for the curious — Settings → Gameplay slider for time-bonus magnitude. Cosmetic but power-user appealing.
|
||||||
- **Drag-cancel return animation** — illegal drops snap cards back instantly. A short ease-back tween ("springs back to where it came from") would feel more forgiving.
|
- **Replay a winning game** — record the seed + move list at win time and offer "watch replay" from the Stats screen.
|
||||||
|
|
||||||
## Card-theme system (CARD_PLAN.md, fully shipped)
|
## Card-theme system (CARD_PLAN.md, fully shipped)
|
||||||
|
|
||||||
Seven phases landed across `b8fb3fb` → `924a1e2`. End-to-end:
|
Seven phases landed across `b8fb3fb` → `924a1e2` in v0.11.0; v0.13.0's `7ed4f2c` finally consumes the per-theme `back.svg`. End-to-end:
|
||||||
|
|
||||||
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs (MIT) + a midnight-purple `back.svg` (original work).
|
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs + a midnight-purple `back.svg`.
|
||||||
- **User themes** live under `themes://` rooted at `solitaire_engine::assets::user_theme_dir()`. Drop a directory containing `theme.ron` + 53 SVG files; appears in the registry on next launch.
|
- **User themes** under `themes://`. Drop a directory containing `theme.ron` + 53 SVGs.
|
||||||
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates an archive (20 MB cap, zip-slip rejection, manifest validation, every SVG round-tripped through the rasteriser) and atomically unpacks.
|
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates archives and atomically unpacks.
|
||||||
- **Picker UI** in Settings → Cosmetic — one chip per registered theme; selection persists to `settings.json` as `selected_theme_id` and propagates to live sprites via `react_to_settings_theme_change` → `sync_card_image_set_with_active_theme` → `StateChangedEvent`.
|
- **Picker UI** in Settings → Cosmetic; the active theme's `back` overrides the legacy `back_N.png` picker when present.
|
||||||
|
|
||||||
## Resume prompt
|
## Resume prompt
|
||||||
|
|
||||||
@@ -68,34 +69,33 @@ Seven phases landed across `b8fb3fb` → `924a1e2`. End-to-end:
|
|||||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||||
Working directory: <Rusty_Solitaire clone path on this machine — local
|
Working directory: <Rusty_Solitaire clone path on this machine — local
|
||||||
directory may still be named Rusty_Solitare from earlier; that's fine>.
|
directory may still be named Rusty_Solitare from earlier; that's fine>.
|
||||||
Branch: master. Direction is OPEN — the session-6 UX punch list is
|
Branch: master. Direction is OPEN — three UX iteration rounds shipped
|
||||||
fully shipped. The player will choose between cutting v0.11.0, doing
|
and v0.13.0 is ready to tag.
|
||||||
release prep (README/CHANGELOG/packaging), or starting a new UX
|
|
||||||
iteration round.
|
|
||||||
|
|
||||||
State: HEAD=655dfde. Local master is 3 commits ahead of origin
|
State: HEAD at the doc-commit closing session 7 round 3. Local master
|
||||||
(f6c9166, f712b89, 655dfde unpushed; fdb6c2e and 95df542 already
|
is several commits ahead of origin and unpushed. Working tree clean
|
||||||
pushed). Working tree clean apart from untracked CARD_PLAN.md
|
apart from untracked CARD_PLAN.md (intentional).
|
||||||
(intentional).
|
|
||||||
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
||||||
Tests: 982 passed / 0 failed.
|
Tests: 1053 passed / 0 failed.
|
||||||
|
|
||||||
READ FIRST (in order, before doing anything):
|
READ FIRST (in order, before doing anything):
|
||||||
1. SESSION_HANDOFF.md — full state, session 7 changelog, punch list
|
1. SESSION_HANDOFF.md — full state, session 7 changelog, punch list
|
||||||
2. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
2. CHANGELOG.md — release-by-release record
|
||||||
3. ARCHITECTURE.md — crate responsibilities + data flow
|
3. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
||||||
4. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
4. ARCHITECTURE.md — crate responsibilities + data flow
|
||||||
|
5. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||||
— saved feedback / project context (machine-local;
|
— saved feedback / project context (machine-local;
|
||||||
may be missing on a fresh machine)
|
may be missing on a fresh machine)
|
||||||
|
|
||||||
DECISION TO ASK THE PLAYER FIRST:
|
DECISION TO ASK THE PLAYER FIRST:
|
||||||
A. Push the 3 unpushed commits and cut v0.11.0?
|
A. Push and cut v0.13.0 now.
|
||||||
B. Skip the tag for now, refresh README + CHANGELOG, then tag?
|
B. Smoke-test the new feel layer first (theme-aware backs, keyboard
|
||||||
C. Skip release prep entirely and start a new UX iteration round?
|
drag, right-click radial, score-breakdown reveal, streak fire,
|
||||||
If C, see the session-7 next-round candidates list (animated
|
tooltip-delay slider), then tag.
|
||||||
focus ring, achievement onboarding, mode-switch keyboard
|
C. Skip the tag for another iteration round — see "next-round
|
||||||
shortcut, aspect-ratio fidelity, foundation completion flourish,
|
candidates" in SESSION_HANDOFF for fresh ideas.
|
||||||
drag-cancel return tween).
|
D. Take the deferred desktop-packaging item (needs artwork +
|
||||||
|
signing certs from the user).
|
||||||
|
|
||||||
WORKFLOW NOTES:
|
WORKFLOW NOTES:
|
||||||
- Commits use:
|
- Commits use:
|
||||||
@@ -105,6 +105,5 @@ WORKFLOW NOTES:
|
|||||||
- Every commit must pass build / clippy / test before pushing.
|
- Every commit must pass build / clippy / test before pushing.
|
||||||
- Push to GitHub (origin) — that is the canonical remote.
|
- Push to GitHub (origin) — that is the canonical remote.
|
||||||
|
|
||||||
OPEN AT THE START: ask which of A / B / C. Don't pick unilaterally —
|
OPEN AT THE START: ask which of A / B / C / D. Don't pick unilaterally.
|
||||||
this is a directional choice, not a tactical one.
|
|
||||||
```
|
```
|
||||||
|
|||||||
Binary file not shown.
@@ -10,9 +10,9 @@ use solitaire_engine::{
|
|||||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||||
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||||
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
|
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
|
||||||
ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin,
|
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||||
SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, UiFocusPlugin,
|
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||||
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -111,6 +111,7 @@ fn main() {
|
|||||||
.add_plugins(CardPlugin)
|
.add_plugins(CardPlugin)
|
||||||
.add_plugins(CursorPlugin)
|
.add_plugins(CursorPlugin)
|
||||||
.add_plugins(InputPlugin)
|
.add_plugins(InputPlugin)
|
||||||
|
.add_plugins(RadialMenuPlugin)
|
||||||
.add_plugins(SelectionPlugin)
|
.add_plugins(SelectionPlugin)
|
||||||
.add_plugins(AnimationPlugin)
|
.add_plugins(AnimationPlugin)
|
||||||
.add_plugins(FeedbackAnimPlugin)
|
.add_plugins(FeedbackAnimPlugin)
|
||||||
|
|||||||
@@ -16,13 +16,14 @@ fn main() -> io::Result<()> {
|
|||||||
let out_dir = workspace_root().join("assets").join("audio");
|
let out_dir = workspace_root().join("assets").join("audio");
|
||||||
fs::create_dir_all(&out_dir)?;
|
fs::create_dir_all(&out_dir)?;
|
||||||
|
|
||||||
let effects: [(&str, Generator); 6] = [
|
let effects: [(&str, Generator); 7] = [
|
||||||
("card_flip.wav", card_flip),
|
("card_flip.wav", card_flip),
|
||||||
("card_place.wav", card_place),
|
("card_place.wav", card_place),
|
||||||
("card_deal.wav", card_deal),
|
("card_deal.wav", card_deal),
|
||||||
("card_invalid.wav", card_invalid),
|
("card_invalid.wav", card_invalid),
|
||||||
("win_fanfare.wav", win_fanfare),
|
("win_fanfare.wav", win_fanfare),
|
||||||
("ambient_loop.wav", ambient_loop),
|
("ambient_loop.wav", ambient_loop),
|
||||||
|
("foundation_complete.wav", foundation_complete),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (name, make) in &effects {
|
for (name, make) in &effects {
|
||||||
@@ -170,6 +171,44 @@ fn win_fanfare() -> Vec<i16> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-suit foundation-completion ping (~240 ms): a rising three-note
|
||||||
|
/// chime — C6, E6, G6 — with a soft 2nd-harmonic warm layer on each
|
||||||
|
/// note. Shorter and brighter than `win_fanfare` so it can fire up to
|
||||||
|
/// four times per game (once per suit) without drowning out subsequent
|
||||||
|
/// move sounds. The fourth firing co-occurs with the win cascade and
|
||||||
|
/// `win_fanfare`; the C-major triad sits an octave above the
|
||||||
|
/// fanfare's root so the two layer cleanly instead of fighting for the
|
||||||
|
/// same frequency band.
|
||||||
|
fn foundation_complete() -> Vec<i16> {
|
||||||
|
// C major triad, one octave up from win_fanfare's root.
|
||||||
|
let notes = [1046.50_f32, 1318.51, 1567.98]; // C6, E6, G6
|
||||||
|
let note_dur = 0.07_f32; // brisk, ascending
|
||||||
|
let total = note_dur * notes.len() as f32 + 0.05;
|
||||||
|
let n = duration_samples(total);
|
||||||
|
let mut out = Vec::with_capacity(n);
|
||||||
|
for i in 0..n {
|
||||||
|
let t = i as f32 / SAMPLE_RATE as f32;
|
||||||
|
let mut sample = 0.0f32;
|
||||||
|
for (idx, freq) in notes.iter().enumerate() {
|
||||||
|
let start = idx as f32 * note_dur;
|
||||||
|
let local = t - start;
|
||||||
|
// Each note rings out for 0.18 s — overlapping notes form a
|
||||||
|
// brief chord at the tail.
|
||||||
|
if !(0.0..=0.18).contains(&local) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Sine + soft 2nd harmonic for warmth, ar_envelope decays
|
||||||
|
// sharply so each note is bell-like rather than sustained.
|
||||||
|
let s = (2.0 * std::f32::consts::PI * freq * local).sin()
|
||||||
|
+ 0.25 * (2.0 * std::f32::consts::PI * freq * 2.0 * local).sin();
|
||||||
|
let env = ar_envelope(local, 0.005, 0.18, 14.0);
|
||||||
|
sample += s * env;
|
||||||
|
}
|
||||||
|
out.push(quantize(sample * 0.20));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz
|
/// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz
|
||||||
/// mono 16-bit PCM).
|
/// mono 16-bit PCM).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||||
Theme, WindowGeometry,
|
Theme, WindowGeometry, TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
|
|||||||
@@ -132,6 +132,25 @@ pub struct Settings {
|
|||||||
/// `#[serde(default = ...)]`.
|
/// `#[serde(default = ...)]`.
|
||||||
#[serde(default = "default_theme_id")]
|
#[serde(default = "default_theme_id")]
|
||||||
pub selected_theme_id: String,
|
pub selected_theme_id: String,
|
||||||
|
/// Set to `true` once the achievement-onboarding info-toast has been
|
||||||
|
/// shown to the player after their very first win. Acts as a
|
||||||
|
/// one-shot teach: subsequent wins must not re-fire the cue. Older
|
||||||
|
/// `settings.json` files written before this field existed
|
||||||
|
/// deserialize cleanly to `false` thanks to `#[serde(default)]` —
|
||||||
|
/// players who already had wins recorded before this field was
|
||||||
|
/// introduced are guarded by the post-condition `games_won == 1`
|
||||||
|
/// checked by `achievement_plugin::fire_achievement_onboarding_toast`,
|
||||||
|
/// so the toast still does not fire for them.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shown_achievement_onboarding: bool,
|
||||||
|
/// Hover delay (seconds) before a tooltip appears. Range
|
||||||
|
/// `[0.0, 1.5]`; default matches `MOTION_TOOLTIP_DELAY_SECS` (0.5 s).
|
||||||
|
/// `0.0` means tooltips fire on the very next tick after hover —
|
||||||
|
/// the "Instant" setting. Older `settings.json` files written before
|
||||||
|
/// this field existed deserialize cleanly to the default via
|
||||||
|
/// `#[serde(default = "default_tooltip_delay")]`.
|
||||||
|
#[serde(default = "default_tooltip_delay")]
|
||||||
|
pub tooltip_delay_secs: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawMode {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -150,6 +169,26 @@ fn default_theme_id() -> String {
|
|||||||
"default".to_string()
|
"default".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default tooltip-hover dwell delay in seconds. Mirrors
|
||||||
|
/// `solitaire_engine::ui_theme::MOTION_TOOLTIP_DELAY_SECS` so legacy
|
||||||
|
/// `settings.json` files load to the existing baseline. The constant
|
||||||
|
/// lives in the engine crate (which the data crate cannot depend on),
|
||||||
|
/// so the value is duplicated here — kept in sync by the
|
||||||
|
/// `settings_tooltip_delay_default_is_existing_baseline` test in
|
||||||
|
/// `solitaire_engine::settings_plugin`.
|
||||||
|
fn default_tooltip_delay() -> f32 {
|
||||||
|
0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower bound of the player-tunable tooltip delay slider, in seconds.
|
||||||
|
pub const TOOLTIP_DELAY_MIN_SECS: f32 = 0.0;
|
||||||
|
|
||||||
|
/// Upper bound of the player-tunable tooltip delay slider, in seconds.
|
||||||
|
pub const TOOLTIP_DELAY_MAX_SECS: f32 = 1.5;
|
||||||
|
|
||||||
|
/// Increment applied by the tooltip-delay decrement / increment buttons.
|
||||||
|
pub const TOOLTIP_DELAY_STEP_SECS: f32 = 0.1;
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -165,17 +204,23 @@ impl Default for Settings {
|
|||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
window_geometry: None,
|
window_geometry: None,
|
||||||
selected_theme_id: default_theme_id(),
|
selected_theme_id: default_theme_id(),
|
||||||
|
shown_achievement_onboarding: false,
|
||||||
|
tooltip_delay_secs: default_tooltip_delay(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// Clamps both `sfx_volume` and `music_volume` into `[0.0, 1.0]` after
|
/// Clamps `sfx_volume`, `music_volume`, and `tooltip_delay_secs` into
|
||||||
/// deserialization or hand-editing of `settings.json`.
|
/// their respective ranges after deserialization or hand-editing of
|
||||||
|
/// `settings.json`.
|
||||||
pub fn sanitized(self) -> Self {
|
pub fn sanitized(self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
||||||
music_volume: self.music_volume.clamp(0.0, 1.0),
|
music_volume: self.music_volume.clamp(0.0, 1.0),
|
||||||
|
tooltip_delay_secs: self
|
||||||
|
.tooltip_delay_secs
|
||||||
|
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS),
|
||||||
..self
|
..self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,6 +236,15 @@ impl Settings {
|
|||||||
self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0);
|
self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0);
|
||||||
self.music_volume
|
self.music_volume
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adjust the tooltip-hover dwell delay by `delta` seconds, clamped
|
||||||
|
/// to `[TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS]`. Returns the
|
||||||
|
/// new value.
|
||||||
|
pub fn adjust_tooltip_delay(&mut self, delta: f32) -> f32 {
|
||||||
|
self.tooltip_delay_secs = (self.tooltip_delay_secs + delta)
|
||||||
|
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS);
|
||||||
|
self.tooltip_delay_secs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||||
@@ -241,6 +295,7 @@ mod tests {
|
|||||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
||||||
assert_eq!(s.theme, Theme::Green);
|
assert_eq!(s.theme, Theme::Green);
|
||||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||||
|
assert!((s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -318,6 +373,8 @@ mod tests {
|
|||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
window_geometry: None,
|
window_geometry: None,
|
||||||
selected_theme_id: "default".to_string(),
|
selected_theme_id: "default".to_string(),
|
||||||
|
shown_achievement_onboarding: false,
|
||||||
|
tooltip_delay_secs: default_tooltip_delay(),
|
||||||
};
|
};
|
||||||
save_settings_to(&path, &s).expect("save");
|
save_settings_to(&path, &s).expect("save");
|
||||||
let loaded = load_settings_from(&path);
|
let loaded = load_settings_from(&path);
|
||||||
@@ -506,4 +563,130 @@ mod tests {
|
|||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
assert!(s.window_geometry.is_none());
|
assert!(s.window_geometry.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// shown_achievement_onboarding — first-win cue one-shot guard
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_shown_achievement_onboarding_default_is_false() {
|
||||||
|
assert!(
|
||||||
|
!Settings::default().shown_achievement_onboarding,
|
||||||
|
"default shown_achievement_onboarding must be false so the cue fires once"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_shown_achievement_onboarding_round_trip() {
|
||||||
|
let path = tmp_path("achievement_onboarding_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let s = Settings {
|
||||||
|
shown_achievement_onboarding: true,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert!(
|
||||||
|
loaded.shown_achievement_onboarding,
|
||||||
|
"shown_achievement_onboarding must survive serde round-trip"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_settings_without_shown_achievement_onboarding_deserializes_to_false() {
|
||||||
|
// A settings.json written by an older version of the game will be
|
||||||
|
// missing this field entirely. `#[serde(default)]` on the field
|
||||||
|
// must yield `false` — the cue then fires on the next win, but
|
||||||
|
// only when stats.games_won == 1, so existing players who have
|
||||||
|
// already won past their first game won't see the toast either.
|
||||||
|
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
!s.shown_achievement_onboarding,
|
||||||
|
"legacy settings.json missing shown_achievement_onboarding must deserialize to false"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// tooltip_delay_secs — player-tunable tooltip hover delay
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_tooltip_delay_default_is_existing_baseline() {
|
||||||
|
// The existing baseline pre-slider is 0.5 s, matching the
|
||||||
|
// `MOTION_TOOLTIP_DELAY_SECS` constant in
|
||||||
|
// `solitaire_engine::ui_theme`. The default must not regress.
|
||||||
|
let s = Settings::default();
|
||||||
|
assert!(
|
||||||
|
(s.tooltip_delay_secs - 0.5).abs() < 1e-6,
|
||||||
|
"tooltip_delay_secs default must be 0.5 (the pre-slider baseline), got {}",
|
||||||
|
s.tooltip_delay_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_tooltip_delay_round_trip() {
|
||||||
|
let path = tmp_path("tooltip_delay_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let s = Settings {
|
||||||
|
tooltip_delay_secs: 1.2,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert!(
|
||||||
|
(loaded.tooltip_delay_secs - 1.2).abs() < 1e-6,
|
||||||
|
"tooltip_delay_secs must survive serde round-trip; got {}",
|
||||||
|
loaded.tooltip_delay_secs
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_settings_without_tooltip_delay_deserializes_to_default() {
|
||||||
|
// A settings.json written before this field existed must
|
||||||
|
// deserialize cleanly to the existing 0.5 s baseline rather
|
||||||
|
// than failing the whole load or yielding a zero value.
|
||||||
|
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
(s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6,
|
||||||
|
"legacy settings.json missing tooltip_delay_secs must deserialize to default ({}), got {}",
|
||||||
|
default_tooltip_delay(),
|
||||||
|
s.tooltip_delay_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjust_tooltip_delay_clamps_to_range() {
|
||||||
|
let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() };
|
||||||
|
// Step up to 0.6.
|
||||||
|
assert!((s.adjust_tooltip_delay(0.1) - 0.6).abs() < 1e-6);
|
||||||
|
// Big positive jump clamps to TOOLTIP_DELAY_MAX_SECS.
|
||||||
|
assert!((s.adjust_tooltip_delay(5.0) - TOOLTIP_DELAY_MAX_SECS).abs() < 1e-6);
|
||||||
|
// Big negative jump clamps to TOOLTIP_DELAY_MIN_SECS.
|
||||||
|
assert!((s.adjust_tooltip_delay(-99.0) - TOOLTIP_DELAY_MIN_SECS).abs() < 1e-6);
|
||||||
|
// Confirm the floor is exactly zero.
|
||||||
|
assert_eq!(s.tooltip_delay_secs, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitized_clamps_out_of_range_tooltip_delay() {
|
||||||
|
// Negative or oversized values from a hand-edited file must be
|
||||||
|
// clamped on load.
|
||||||
|
let s = Settings {
|
||||||
|
tooltip_delay_secs: -0.4,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(s.tooltip_delay_secs, TOOLTIP_DELAY_MIN_SECS);
|
||||||
|
|
||||||
|
let s2 = Settings {
|
||||||
|
tooltip_delay_secs: 99.0,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(s2.tooltip_delay_secs, TOOLTIP_DELAY_MAX_SECS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,17 +14,19 @@ use solitaire_core::achievement::{
|
|||||||
ALL_ACHIEVEMENTS,
|
ALL_ACHIEVEMENTS,
|
||||||
};
|
};
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
|
achievements_file_path, load_achievements_from, save_achievements_to, save_settings_to,
|
||||||
save_progress_to,
|
AchievementRecord, save_progress_to,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent,
|
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, ToggleAchievementsRequestEvent,
|
||||||
|
XpAwardedEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
@@ -91,6 +93,7 @@ impl Plugin for AchievementPlugin {
|
|||||||
.add_message::<AchievementUnlockedEvent>()
|
.add_message::<AchievementUnlockedEvent>()
|
||||||
.add_message::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ToggleAchievementsRequestEvent>()
|
.add_message::<ToggleAchievementsRequestEvent>()
|
||||||
// Run after GameMutation (so GameWonEvent is available), after
|
// Run after GameMutation (so GameWonEvent is available), after
|
||||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||||
@@ -102,6 +105,16 @@ impl Plugin for AchievementPlugin {
|
|||||||
.after(StatsUpdate)
|
.after(StatsUpdate)
|
||||||
.after(ProgressUpdate),
|
.after(ProgressUpdate),
|
||||||
)
|
)
|
||||||
|
// Achievement-onboarding cue: fires once after the player's very
|
||||||
|
// first win to teach the Achievements panel exists. Must run
|
||||||
|
// `.after(StatsUpdate)` so `stats.games_won` reflects the win
|
||||||
|
// that just landed (StatsUpdate increments it on `GameWonEvent`).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
fire_achievement_onboarding_toast
|
||||||
|
.after(GameMutation)
|
||||||
|
.after(StatsUpdate),
|
||||||
|
)
|
||||||
.add_systems(Update, toggle_achievements_screen)
|
.add_systems(Update, toggle_achievements_screen)
|
||||||
.add_systems(Update, handle_achievements_close_button);
|
.add_systems(Update, handle_achievements_close_button);
|
||||||
}
|
}
|
||||||
@@ -209,6 +222,67 @@ fn evaluate_on_win(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Achievement-onboarding cue.
|
||||||
|
///
|
||||||
|
/// On the player's very first win — and only their first — fires a single
|
||||||
|
/// `InfoToastEvent` nudging them toward the Achievements panel (`A` hotkey)
|
||||||
|
/// so they discover the progression layer.
|
||||||
|
///
|
||||||
|
/// Three guards prevent spurious or repeat firings:
|
||||||
|
///
|
||||||
|
/// * `stats.games_won == 1` — the post-condition is checked **after**
|
||||||
|
/// `StatsUpdate` increments `games_won`, so the cue only fires for the
|
||||||
|
/// true first win, not (for example) a player who imported existing
|
||||||
|
/// sync data and won a later game.
|
||||||
|
/// * `!settings.shown_achievement_onboarding` — flips to `true` after
|
||||||
|
/// the toast fires, persists to `settings.json`, and serves as the
|
||||||
|
/// one-shot guard across launches and merged sync.
|
||||||
|
/// * The system bails immediately when no `GameWonEvent` arrived this
|
||||||
|
/// frame so it is a no-op outside the post-win frame.
|
||||||
|
///
|
||||||
|
/// The `A` hotkey is mentioned verbatim in the toast text so players who
|
||||||
|
/// dismiss the cue still know where to find the panel.
|
||||||
|
fn fire_achievement_onboarding_toast(
|
||||||
|
mut wins: MessageReader<GameWonEvent>,
|
||||||
|
stats: Res<StatsResource>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
settings_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
// Drain the event queue regardless — multiple wins on a single frame
|
||||||
|
// only need a single onboarding toast at most.
|
||||||
|
let any_win = wins.read().last().is_some();
|
||||||
|
if !any_win {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a `SettingsResource` (headless tests that omit `SettingsPlugin`)
|
||||||
|
// we have no flag to consult; bail out cleanly.
|
||||||
|
let Some(settings) = settings.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if settings.0.shown_achievement_onboarding {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if stats.0.games_won != 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.write(InfoToastEvent(
|
||||||
|
"First win! Press A to see your achievements.".to_string(),
|
||||||
|
));
|
||||||
|
settings.0.shown_achievement_onboarding = true;
|
||||||
|
|
||||||
|
// Persist so the cue stays one-shot across launches. `None` storage
|
||||||
|
// (headless / test) is a documented no-op.
|
||||||
|
if let Some(path) = settings_path.as_ref()
|
||||||
|
&& let Some(target) = path.0.as_deref()
|
||||||
|
&& let Err(e) = save_settings_to(target, &settings.0)
|
||||||
|
{
|
||||||
|
warn!("failed to save settings (achievement onboarding): {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convenience: resolve an achievement ID to its human-readable name.
|
/// Convenience: resolve an achievement ID to its human-readable name.
|
||||||
/// Used by the toast renderer in `animation_plugin`.
|
/// Used by the toast renderer in `animation_plugin`.
|
||||||
pub fn display_name_for(id: &str) -> String {
|
pub fn display_name_for(id: &str) -> String {
|
||||||
@@ -921,4 +995,187 @@ mod tests {
|
|||||||
assert!(s.contains("How to unlock"));
|
assert!(s.contains("How to unlock"));
|
||||||
assert!(!s.contains("Reward"), "got {s:?}");
|
assert!(!s.contains("Reward"), "got {s:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Achievement-onboarding cue (`fire_achievement_onboarding_toast`)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Builds a headless app that **also** includes `SettingsPlugin::headless()`
|
||||||
|
/// so the achievement-onboarding system (which reads `SettingsResource`)
|
||||||
|
/// has a flag to consult and persist into.
|
||||||
|
fn onboarding_test_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(StatsPlugin::headless())
|
||||||
|
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||||
|
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
|
||||||
|
.add_plugins(AchievementPlugin::headless());
|
||||||
|
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects every `InfoToastEvent` written so tests can assert on
|
||||||
|
/// count and message contents.
|
||||||
|
fn drain_info_toasts(app: &App) -> Vec<String> {
|
||||||
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
cursor.read(events).map(|e| e.0.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First-win path: with the flag false and `games_won` about to be
|
||||||
|
/// 1, exactly one `InfoToastEvent` mentioning the `A` hotkey must
|
||||||
|
/// fire and the flag must flip to `true`.
|
||||||
|
#[test]
|
||||||
|
fn first_win_fires_achievement_onboarding_toast() {
|
||||||
|
let mut app = onboarding_test_app();
|
||||||
|
|
||||||
|
// Sanity: fresh app starts with games_won = 0 and the flag unset.
|
||||||
|
assert_eq!(app.world().resource::<StatsResource>().0.games_won, 0);
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_achievement_onboarding
|
||||||
|
);
|
||||||
|
|
||||||
|
// StatsPlugin (StatsUpdate) increments games_won to 1 *before* the
|
||||||
|
// achievement-onboarding system reads stats — our system runs
|
||||||
|
// `.after(StatsUpdate)`. The system then sees games_won == 1 and
|
||||||
|
// the cue fires.
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 1000,
|
||||||
|
time_seconds: 300,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let toasts = drain_info_toasts(&app);
|
||||||
|
let onboarding_toasts: Vec<&String> = toasts
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
onboarding_toasts.len(),
|
||||||
|
1,
|
||||||
|
"exactly one achievement-onboarding toast must fire on the first win; \
|
||||||
|
saw all toasts: {toasts:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_achievement_onboarding,
|
||||||
|
"shown_achievement_onboarding must flip to true after the toast fires"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Second-win path: with the flag already `true` (player already
|
||||||
|
/// saw the cue on a previous run), no onboarding toast may fire.
|
||||||
|
#[test]
|
||||||
|
fn subsequent_wins_do_not_fire_achievement_onboarding_toast() {
|
||||||
|
let mut app = onboarding_test_app();
|
||||||
|
|
||||||
|
// Pre-set the flag to simulate a player who already dismissed
|
||||||
|
// the cue on a previous run.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_achievement_onboarding = true;
|
||||||
|
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 1000,
|
||||||
|
time_seconds: 300,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
onboarding_toasts.is_empty(),
|
||||||
|
"no onboarding toast must fire when shown_achievement_onboarding is already true; \
|
||||||
|
got: {onboarding_toasts:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync-import path: a player imports stats with `games_won = 5`
|
||||||
|
/// already on the books. The flag is still `false` (they were on a
|
||||||
|
/// pre-cue release on this device), but the cue must NOT fire because
|
||||||
|
/// this isn't actually their first win — the post-condition
|
||||||
|
/// `games_won == 1` guards against retroactive nagging.
|
||||||
|
#[test]
|
||||||
|
fn non_first_win_does_not_fire_achievement_onboarding_toast() {
|
||||||
|
let mut app = onboarding_test_app();
|
||||||
|
|
||||||
|
// Pre-seed games_won = 5 BEFORE the win lands. StatsUpdate will
|
||||||
|
// bump it to 6 on the GameWonEvent, taking the system well past
|
||||||
|
// the `games_won == 1` post-condition.
|
||||||
|
app.world_mut().resource_mut::<StatsResource>().0.games_won = 5;
|
||||||
|
|
||||||
|
// Confirm the flag is still false so we know the guard that
|
||||||
|
// prevents firing is the games-won post-condition, not the flag.
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_achievement_onboarding
|
||||||
|
);
|
||||||
|
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 1000,
|
||||||
|
time_seconds: 300,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
onboarding_toasts.is_empty(),
|
||||||
|
"no onboarding toast must fire on a non-first win; got: {onboarding_toasts:?}"
|
||||||
|
);
|
||||||
|
// And the flag must remain false so the cue can still teach a
|
||||||
|
// genuinely-fresh second device or a wiped install.
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_achievement_onboarding,
|
||||||
|
"shown_achievement_onboarding must remain false when the cue did not fire"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Without any `GameWonEvent` arriving the system must be a no-op:
|
||||||
|
/// no toast, no flag flip — even on update ticks where stats happen
|
||||||
|
/// to read `games_won == 1`.
|
||||||
|
#[test]
|
||||||
|
fn no_win_event_means_no_achievement_onboarding_toast() {
|
||||||
|
let mut app = onboarding_test_app();
|
||||||
|
|
||||||
|
// Pre-seed games_won = 1 to simulate the misleading mid-frame
|
||||||
|
// state without actually firing a GameWonEvent.
|
||||||
|
app.world_mut().resource_mut::<StatsResource>().0.games_won = 1;
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
onboarding_toasts.is_empty(),
|
||||||
|
"no onboarding toast must fire without a GameWonEvent; got: {onboarding_toasts:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_achievement_onboarding,
|
||||||
|
"flag must not flip without a win event"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,12 +107,11 @@ impl AssetLoader for SvgLoader {
|
|||||||
pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoaderError> {
|
pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoaderError> {
|
||||||
let opt = usvg::Options {
|
let opt = usvg::Options {
|
||||||
fontdb: shared_fontdb(),
|
fontdb: shared_fontdb(),
|
||||||
// Default for SVG elements without an explicit `font-family` —
|
// The bundled fontdb only contains FiraMono and the resolver
|
||||||
// resolved by fontdb's generic-family alias to whatever
|
// routes every named-family request to it; this is a default
|
||||||
// sans-serif the system has installed (DejaVu Sans on most
|
// for SVGs that don't specify a family at all.
|
||||||
// Linux installs, Helvetica on macOS, Arial on Windows).
|
font_family: "Fira Mono".to_string(),
|
||||||
font_family: "sans-serif".to_string(),
|
font_resolver: bundled_font_resolver(),
|
||||||
font_resolver: lenient_font_resolver(),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let tree = usvg::Tree::from_data(svg_bytes, &opt)?;
|
let tree = usvg::Tree::from_data(svg_bytes, &opt)?;
|
||||||
@@ -152,100 +151,46 @@ pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoader
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a process-wide font database populated with the OS-installed
|
/// FiraMono-Medium bytes embedded at compile time. Mirrors the embed in
|
||||||
/// fonts plus the bundled FiraMono-Medium face. Initialised lazily on
|
/// `solitaire_engine::font_plugin` so the SVG rasteriser and the Bevy UI
|
||||||
/// first SVG that references text, then shared (via `Arc`) across every
|
/// share the same canonical face.
|
||||||
/// subsequent rasterisation.
|
const BUNDLED_FONT_BYTES: &[u8] = include_bytes!("../../../assets/fonts/main.ttf");
|
||||||
|
|
||||||
|
/// Returns a process-wide font database holding only 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
|
/// The bundled card SVGs reference families like `Arial` and
|
||||||
/// call any text glyph in an SVG renders with no font match — the
|
/// `Bitstream Vera Sans` by name; [`bundled_font_resolver`] maps every
|
||||||
/// visible symptom on the bundled hayeah artwork is the "No match for
|
/// such request directly to FiraMono so rasterisation is deterministic
|
||||||
/// Arial font-family" warn spam plus glyphs that fall through to
|
/// across machines and the system font path is never consulted.
|
||||||
/// whatever shape-only path usvg uses for missing fonts.
|
|
||||||
///
|
///
|
||||||
/// **Bundled font as last-resort fallback.** Loading only system fonts
|
/// Aborts the program if the embedded bytes don't parse — bundled at
|
||||||
/// breaks on minimal Linux installs, fresh Wayland sessions, and
|
/// compile time, so a parse failure means the binary is corrupt.
|
||||||
/// 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`.
|
|
||||||
fn shared_fontdb() -> Arc<fontdb::Database> {
|
fn shared_fontdb() -> Arc<fontdb::Database> {
|
||||||
static DB: OnceLock<Arc<fontdb::Database>> = OnceLock::new();
|
static DB: OnceLock<Arc<fontdb::Database>> = OnceLock::new();
|
||||||
DB.get_or_init(|| {
|
DB.get_or_init(|| {
|
||||||
let mut db = fontdb::Database::new();
|
let mut db = fontdb::Database::new();
|
||||||
db.load_system_fonts();
|
db.load_font_data(BUNDLED_FONT_BYTES.to_vec());
|
||||||
// The bundled FiraMono lives at the workspace root, so the
|
assert!(
|
||||||
// include_bytes! path goes up three levels from this source
|
db.faces().next().is_some(),
|
||||||
// file (assets → src → solitaire_engine → workspace root).
|
"bundled FiraMono failed to parse — binary is corrupt"
|
||||||
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)
|
Arc::new(db)
|
||||||
})
|
})
|
||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a `usvg::FontResolver` that mirrors the upstream default
|
/// Resolver that ignores the SVG's `font-family` request and always
|
||||||
/// `select_font` but appends the CSS generics `sans-serif` and `serif`
|
/// returns the single bundled FiraMono face. Bundled card SVGs ask for
|
||||||
/// to every query's family list. The upstream selector only appends
|
/// fonts by name (Arial, Bitstream Vera Sans) that this binary
|
||||||
/// `serif` and emits a `log::warn!` when its `fontdb.query` returns
|
/// deliberately doesn't ship; routing every query to FiraMono keeps
|
||||||
/// `None`; on systems without the named families requested by the
|
/// rendering deterministic and removes the system-font path entirely.
|
||||||
/// SVG (e.g. Arial on Linux), every text node bridges that warn into
|
fn bundled_font_resolver() -> usvg::FontResolver<'static> {
|
||||||
/// our tracing output. By appending two generics — both resolved via
|
use usvg::FontResolver;
|
||||||
/// fontconfig (or fontdb's built-in defaults) to whatever sans-serif /
|
|
||||||
/// serif the user has installed — we guarantee the query finds *some*
|
|
||||||
/// face, so the warn branch is never taken. The visible behaviour is
|
|
||||||
/// "use the system's default font when the requested one isn't
|
|
||||||
/// installed", which is the intent here.
|
|
||||||
///
|
|
||||||
/// The fallback `select_fallback` is kept as the upstream default —
|
|
||||||
/// per-character fallback (for combining marks, scripts the primary
|
|
||||||
/// face doesn't cover) doesn't have the same warn-spam pathology.
|
|
||||||
fn lenient_font_resolver() -> usvg::FontResolver<'static> {
|
|
||||||
use usvg::{FontFamily, FontResolver};
|
|
||||||
|
|
||||||
usvg::FontResolver {
|
usvg::FontResolver {
|
||||||
select_font: Box::new(|font, db| {
|
select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)),
|
||||||
let mut families: Vec<fontdb::Family> = font
|
|
||||||
.families()
|
|
||||||
.iter()
|
|
||||||
.map(|f| match f {
|
|
||||||
FontFamily::Serif => fontdb::Family::Serif,
|
|
||||||
FontFamily::SansSerif => fontdb::Family::SansSerif,
|
|
||||||
FontFamily::Cursive => fontdb::Family::Cursive,
|
|
||||||
FontFamily::Fantasy => fontdb::Family::Fantasy,
|
|
||||||
FontFamily::Monospace => fontdb::Family::Monospace,
|
|
||||||
FontFamily::Named(s) => fontdb::Family::Name(s),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
families.push(fontdb::Family::SansSerif);
|
|
||||||
families.push(fontdb::Family::Serif);
|
|
||||||
|
|
||||||
let query = fontdb::Query {
|
|
||||||
families: &families,
|
|
||||||
weight: fontdb::Weight(font.weight()),
|
|
||||||
stretch: font.stretch().into(),
|
|
||||||
style: font.style().into(),
|
|
||||||
};
|
|
||||||
db.query(&query)
|
|
||||||
}),
|
|
||||||
select_fallback: FontResolver::default_fallback_selector(),
|
select_fallback: FontResolver::default_fallback_selector(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ use kira::track::{TrackBuilder, TrackHandle};
|
|||||||
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
|
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
|
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent,
|
||||||
MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
|
GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
@@ -70,6 +70,12 @@ pub struct SoundLibrary {
|
|||||||
pub place: StaticSoundData,
|
pub place: StaticSoundData,
|
||||||
pub invalid: StaticSoundData,
|
pub invalid: StaticSoundData,
|
||||||
pub fanfare: StaticSoundData,
|
pub fanfare: StaticSoundData,
|
||||||
|
/// Per-suit foundation-completion ping. Played whenever a King
|
||||||
|
/// lands on a foundation pile (Ace → King, 13 cards). ~240 ms,
|
||||||
|
/// rising C-major triad an octave above `fanfare`'s root so the
|
||||||
|
/// two layer cleanly when the fourth completion co-occurs with
|
||||||
|
/// the win cascade.
|
||||||
|
pub foundation_complete: StaticSoundData,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wraps the audio backend. `NonSend` because cpal streams are `!Send` on
|
/// Wraps the audio backend. `NonSend` because cpal streams are `!Send` on
|
||||||
@@ -145,6 +151,7 @@ impl Plugin for AudioPlugin {
|
|||||||
.add_message::<CardFlippedEvent>()
|
.add_message::<CardFlippedEvent>()
|
||||||
.add_message::<CardFaceRevealedEvent>()
|
.add_message::<CardFaceRevealedEvent>()
|
||||||
.add_message::<UndoRequestEvent>()
|
.add_message::<UndoRequestEvent>()
|
||||||
|
.add_message::<FoundationCompletedEvent>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_systems(Startup, apply_initial_volume)
|
.add_systems(Startup, apply_initial_volume)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@@ -157,6 +164,7 @@ impl Plugin for AudioPlugin {
|
|||||||
play_on_win,
|
play_on_win,
|
||||||
play_on_face_revealed,
|
play_on_face_revealed,
|
||||||
play_on_undo,
|
play_on_undo,
|
||||||
|
play_on_foundation_complete,
|
||||||
apply_volume_on_change,
|
apply_volume_on_change,
|
||||||
handle_mute_keys,
|
handle_mute_keys,
|
||||||
),
|
),
|
||||||
@@ -170,12 +178,15 @@ fn build_library() -> Option<SoundLibrary> {
|
|||||||
let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?;
|
let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?;
|
||||||
let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?;
|
let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?;
|
||||||
let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?;
|
let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?;
|
||||||
|
let foundation_complete =
|
||||||
|
decode(include_bytes!("../../assets/audio/foundation_complete.wav"))?;
|
||||||
Some(SoundLibrary {
|
Some(SoundLibrary {
|
||||||
deal,
|
deal,
|
||||||
flip,
|
flip,
|
||||||
place,
|
place,
|
||||||
invalid,
|
invalid,
|
||||||
fanfare,
|
fanfare,
|
||||||
|
foundation_complete,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +462,25 @@ fn play_on_face_revealed(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plays the per-suit completion ping whenever a `FoundationCompletedEvent`
|
||||||
|
/// fires (a King lands on a foundation pile that now holds Ace → King).
|
||||||
|
///
|
||||||
|
/// The fourth firing co-occurs with `GameWonEvent` and the win fanfare;
|
||||||
|
/// the two layer cleanly because the ping sits an octave above the
|
||||||
|
/// fanfare's root and is much shorter (~240 ms vs ~970 ms).
|
||||||
|
fn play_on_foundation_complete(
|
||||||
|
mut events: MessageReader<FoundationCompletedEvent>,
|
||||||
|
mut audio: NonSendMut<AudioState>,
|
||||||
|
lib: Option<Res<SoundLibrary>>,
|
||||||
|
) {
|
||||||
|
let Some(lib) = lib else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for _ in events.read() {
|
||||||
|
play(&mut audio, &lib.foundation_complete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use solitaire_core::pile::PileType;
|
|||||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||||
|
|
||||||
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
||||||
|
use crate::card_animation::CardAnimation;
|
||||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::{Layout, LayoutResource, LayoutSystem};
|
use crate::layout::{Layout, LayoutResource, LayoutSystem};
|
||||||
@@ -50,8 +51,11 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
|||||||
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
|
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
|
||||||
|
|
||||||
/// Fraction of card height used as a tiny offset between stacked cards in
|
/// Fraction of card height used as a tiny offset between stacked cards in
|
||||||
/// non-tableau piles, so stacking is visible.
|
/// non-tableau piles, so stacking is visible. Public so other plugins
|
||||||
const STACK_FAN_FRAC: f32 = 0.003;
|
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
|
||||||
|
/// `Transform.translation.z` for a card at a given stack index without
|
||||||
|
/// drifting from the value used by [`card_positions`].
|
||||||
|
pub const STACK_FAN_FRAC: f32 = 0.003;
|
||||||
|
|
||||||
/// Font size as a fraction of card width.
|
/// Font size as a fraction of card width.
|
||||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||||
@@ -72,8 +76,21 @@ pub struct CardImageSet {
|
|||||||
/// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
|
/// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
|
||||||
/// Rank order: Ace=0, Two=1 … King=12.
|
/// Rank order: Ace=0, Two=1 … King=12.
|
||||||
pub faces: [[Handle<Image>; 13]; 4],
|
pub faces: [[Handle<Image>; 13]; 4],
|
||||||
/// One handle per unlockable card-back design (indices 0–4).
|
/// One handle per unlockable card-back design (indices 0–4). These
|
||||||
|
/// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed
|
||||||
|
/// by `Settings::selected_card_back`. Used as a fallback when the active
|
||||||
|
/// theme does not provide its own back (see [`Self::theme_back`]).
|
||||||
pub backs: [Handle<Image>; 5],
|
pub backs: [Handle<Image>; 5],
|
||||||
|
/// Back image supplied by the currently-active card theme, if any.
|
||||||
|
///
|
||||||
|
/// Populated by `theme::plugin::apply_theme_to_card_image_set` whenever
|
||||||
|
/// a `CardTheme` finishes loading. The face-down render path in
|
||||||
|
/// [`card_sprite`] prefers this handle over the legacy `backs[]` array,
|
||||||
|
/// so a theme switch swaps both faces *and* the back without the player
|
||||||
|
/// needing to touch the legacy `selected_card_back` picker. `None` means
|
||||||
|
/// the active theme did not declare a back asset (or no theme has loaded
|
||||||
|
/// yet); in that case [`card_sprite`] falls back to the legacy array.
|
||||||
|
pub theme_back: Option<Handle<Image>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alternative face tint for red-suit cards in color-blind mode — a subtle
|
/// Alternative face tint for red-suit cards in color-blind mode — a subtle
|
||||||
@@ -366,7 +383,14 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Comman
|
|||||||
let backs = std::array::from_fn(|i| {
|
let backs = std::array::from_fn(|i| {
|
||||||
asset_server.load(format!("cards/backs/back_{i}.png"))
|
asset_server.load(format!("cards/backs/back_{i}.png"))
|
||||||
});
|
});
|
||||||
commands.insert_resource(CardImageSet { faces, backs });
|
commands.insert_resource(CardImageSet {
|
||||||
|
faces,
|
||||||
|
backs,
|
||||||
|
// Populated by the theme plugin once a `CardTheme` finishes loading.
|
||||||
|
// Until then the legacy back fallback (`backs[selected_card_back]`)
|
||||||
|
// is used.
|
||||||
|
theme_back: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
|
/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
|
||||||
@@ -403,6 +427,12 @@ fn card_sprite(
|
|||||||
Rank::King => 12,
|
Rank::King => 12,
|
||||||
};
|
};
|
||||||
set.faces[suit_idx][rank_idx].clone()
|
set.faces[suit_idx][rank_idx].clone()
|
||||||
|
} else if let Some(theme_back) = &set.theme_back {
|
||||||
|
// Active theme provides its own back — always wins over the
|
||||||
|
// legacy `selected_card_back` picker, so a theme switch swaps
|
||||||
|
// faces *and* the back. The picker is treated as informational
|
||||||
|
// only while a theme back is active (see settings_plugin).
|
||||||
|
theme_back.clone()
|
||||||
} else {
|
} else {
|
||||||
let idx = selected_back.min(set.backs.len() - 1);
|
let idx = selected_back.min(set.backs.len() - 1);
|
||||||
set.backs[idx].clone()
|
set.backs[idx].clone()
|
||||||
@@ -447,7 +477,7 @@ fn sync_cards_startup(
|
|||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
) {
|
) {
|
||||||
if let Some(layout) = layout {
|
if let Some(layout) = layout {
|
||||||
@@ -467,7 +497,7 @@ fn sync_cards_on_change(
|
|||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
) {
|
) {
|
||||||
if events.read().next().is_none() {
|
if events.read().next().is_none() {
|
||||||
@@ -490,22 +520,27 @@ fn sync_cards(
|
|||||||
slide_secs: f32,
|
slide_secs: f32,
|
||||||
back_colour: Color,
|
back_colour: Color,
|
||||||
color_blind: bool,
|
color_blind: bool,
|
||||||
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
) {
|
) {
|
||||||
let positions = card_positions(game, layout);
|
let positions = card_positions(game, layout);
|
||||||
|
|
||||||
// Map card_id -> (Entity, current_translation) for in-place updates.
|
// Map card_id -> (Entity, current_translation, has_card_animation) for
|
||||||
let mut existing: HashMap<u32, (Entity, Vec3)> = HashMap::new();
|
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
|
||||||
for (entity, marker, transform) in entities.iter() {
|
// skip the snap/slide path on cards that are already being driven by a
|
||||||
existing.insert(marker.card_id, (entity, transform.translation));
|
// curve-based `CardAnimation` tween (e.g. the drag-rejection return tween
|
||||||
|
// — see `input_plugin::end_drag`). Otherwise the StateChangedEvent that
|
||||||
|
// accompanies a rejection would race the tween and the card would jump.
|
||||||
|
let mut existing: HashMap<u32, (Entity, Vec3, bool)> = HashMap::new();
|
||||||
|
for (entity, marker, transform, anim) in entities.iter() {
|
||||||
|
existing.insert(marker.card_id, (entity, transform.translation, anim.is_some()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
||||||
|
|
||||||
// Despawn any entity whose card is no longer tracked.
|
// Despawn any entity whose card is no longer tracked.
|
||||||
for (card_id, (entity, _)) in &existing {
|
for (card_id, (entity, _, _)) in &existing {
|
||||||
if !live_ids.contains(card_id) {
|
if !live_ids.contains(card_id) {
|
||||||
commands.entity(*entity).despawn();
|
commands.entity(*entity).despawn();
|
||||||
}
|
}
|
||||||
@@ -514,10 +549,10 @@ fn sync_cards(
|
|||||||
// For each card in the current state: spawn or update its entity.
|
// For each card in the current state: spawn or update its entity.
|
||||||
for (card, position, z) in positions {
|
for (card, position, z) in positions {
|
||||||
match existing.get(&card.id) {
|
match existing.get(&card.id) {
|
||||||
Some(&(entity, cur)) => {
|
Some(&(entity, cur, has_anim)) => {
|
||||||
update_card_entity(
|
update_card_entity(
|
||||||
&mut commands, entity, card, position, z, layout,
|
&mut commands, entity, card, position, z, layout,
|
||||||
slide_secs, back_colour, color_blind, cur, card_images, selected_back,
|
slide_secs, back_colour, color_blind, cur, has_anim, card_images, selected_back,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back),
|
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back),
|
||||||
@@ -667,6 +702,7 @@ fn update_card_entity(
|
|||||||
back_colour: Color,
|
back_colour: Color,
|
||||||
color_blind: bool,
|
color_blind: bool,
|
||||||
cur: Vec3,
|
cur: Vec3,
|
||||||
|
has_card_animation: bool,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
) {
|
) {
|
||||||
@@ -675,24 +711,31 @@ fn update_card_entity(
|
|||||||
// Always refresh the visual appearance.
|
// Always refresh the visual appearance.
|
||||||
commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
|
commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
|
||||||
|
|
||||||
// Slide to the new position when it differs meaningfully; snap otherwise.
|
// Skip the snap/slide path entirely when a curve-based `CardAnimation`
|
||||||
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
// is driving this card (e.g. the drag-rejection return tween). Writing
|
||||||
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
|
// `Transform` here would race that animation each frame and cause a
|
||||||
commands
|
// visible jump. The animation system snaps the final position itself
|
||||||
.entity(entity)
|
// when it completes.
|
||||||
.insert(Transform::from_translation(start))
|
if !has_card_animation {
|
||||||
.insert(CardAnim {
|
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||||
start,
|
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
||||||
target,
|
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
|
||||||
elapsed: 0.0,
|
commands
|
||||||
duration: slide_secs,
|
.entity(entity)
|
||||||
delay: 0.0,
|
.insert(Transform::from_translation(start))
|
||||||
});
|
.insert(CardAnim {
|
||||||
} else {
|
start,
|
||||||
commands
|
target,
|
||||||
.entity(entity)
|
elapsed: 0.0,
|
||||||
.remove::<CardAnim>()
|
duration: slide_secs,
|
||||||
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
delay: 0.0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<CardAnim>()
|
||||||
|
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Despawn any stale children and re-add the per-card drop shadow plus,
|
// Despawn any stale children and re-add the per-card drop shadow plus,
|
||||||
@@ -2525,4 +2568,136 @@ mod tests {
|
|||||||
// Sanity: a fresh game with stock present reports 24.
|
// Sanity: a fresh game with stock present reports 24.
|
||||||
assert_eq!(stock_card_count(&g), 24);
|
assert_eq!(stock_card_count(&g), 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Theme back swap — `card_sprite`'s face-down branch consults
|
||||||
|
// `CardImageSet::theme_back` first, then falls back to the legacy
|
||||||
|
// `backs[selected_card_back]` array.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Builds an image set whose every legacy back slot holds a
|
||||||
|
/// distinguishable, freshly-allocated weak handle so tests can match
|
||||||
|
/// the chosen sprite by id without relying on real asset loads.
|
||||||
|
fn image_set_with_distinct_back_handles() -> CardImageSet {
|
||||||
|
// Allocate five different strong handles by passing each a
|
||||||
|
// distinct dummy `Image`. We never render these; we only
|
||||||
|
// compare ids.
|
||||||
|
let mut images = bevy::asset::Assets::<bevy::image::Image>::default();
|
||||||
|
let backs: [Handle<bevy::image::Image>; 5] = std::array::from_fn(|_| {
|
||||||
|
images.add(bevy::image::Image::default())
|
||||||
|
});
|
||||||
|
CardImageSet {
|
||||||
|
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
|
||||||
|
backs,
|
||||||
|
theme_back: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn face_down_card_uses_active_theme_back_when_provided() {
|
||||||
|
// When `CardImageSet::theme_back` is populated, every face-down
|
||||||
|
// card must render with the theme's back regardless of which
|
||||||
|
// legacy back the player picked in Settings.
|
||||||
|
let mut set = image_set_with_distinct_back_handles();
|
||||||
|
let mut images = bevy::asset::Assets::<bevy::image::Image>::default();
|
||||||
|
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default());
|
||||||
|
set.theme_back = Some(theme_back.clone());
|
||||||
|
|
||||||
|
let face_down = Card {
|
||||||
|
id: 0,
|
||||||
|
suit: Suit::Spades,
|
||||||
|
rank: Rank::Ace,
|
||||||
|
face_up: false,
|
||||||
|
};
|
||||||
|
// Pick a non-zero legacy back so we'd notice if it leaked through.
|
||||||
|
let sprite = card_sprite(
|
||||||
|
&face_down,
|
||||||
|
Vec2::new(80.0, 112.0),
|
||||||
|
card_back_colour(2),
|
||||||
|
false,
|
||||||
|
Some(&set),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sprite.image.id(),
|
||||||
|
theme_back.id(),
|
||||||
|
"face-down card must render with the active theme's back, not the legacy back at \
|
||||||
|
selected_card_back={}",
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn face_down_card_falls_back_to_legacy_back_when_theme_lacks_one() {
|
||||||
|
// Mirror of the previous test: if `theme_back` is `None` (the
|
||||||
|
// active theme does not declare a back, or no theme has loaded
|
||||||
|
// yet), the face-down render path must consult the legacy
|
||||||
|
// `backs[selected_card_back]` array exactly as it always has.
|
||||||
|
let set = image_set_with_distinct_back_handles();
|
||||||
|
assert!(set.theme_back.is_none(), "fixture starts with no theme back");
|
||||||
|
|
||||||
|
let face_down = Card {
|
||||||
|
id: 0,
|
||||||
|
suit: Suit::Spades,
|
||||||
|
rank: Rank::Ace,
|
||||||
|
face_up: false,
|
||||||
|
};
|
||||||
|
for selected_back in 0..5 {
|
||||||
|
let sprite = card_sprite(
|
||||||
|
&face_down,
|
||||||
|
Vec2::new(80.0, 112.0),
|
||||||
|
card_back_colour(selected_back),
|
||||||
|
false,
|
||||||
|
Some(&set),
|
||||||
|
selected_back,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sprite.image.id(),
|
||||||
|
set.backs[selected_back].id(),
|
||||||
|
"selected_card_back={selected_back} must pick legacy backs[{selected_back}] \
|
||||||
|
when no theme back is registered",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_theme_back_handle_registered_after_apply() {
|
||||||
|
// The theme plugin's `apply_theme_to_card_image_set` is the
|
||||||
|
// entry point that turns a freshly-loaded `CardTheme` into a
|
||||||
|
// populated `theme_back` slot on `CardImageSet`. Round-trip
|
||||||
|
// it directly: starts as `None`, becomes `Some(theme.back)`
|
||||||
|
// after apply.
|
||||||
|
use crate::theme::{CardTheme, CardKey, ThemeMeta};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let mut set = image_set_with_distinct_back_handles();
|
||||||
|
let mut images = bevy::asset::Assets::<bevy::image::Image>::default();
|
||||||
|
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default());
|
||||||
|
|
||||||
|
let theme = CardTheme {
|
||||||
|
meta: ThemeMeta {
|
||||||
|
id: "fixture".into(),
|
||||||
|
name: "Fixture".into(),
|
||||||
|
author: "test".into(),
|
||||||
|
version: "0".into(),
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
},
|
||||||
|
faces: HashMap::<CardKey, Handle<bevy::image::Image>>::new(),
|
||||||
|
back: theme_back.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(set.theme_back.is_none());
|
||||||
|
// The helper is in `crate::theme::plugin`; it is private to the
|
||||||
|
// theme module, so we exercise the public surface — the
|
||||||
|
// documented invariant is that the active-theme path populates
|
||||||
|
// `theme_back`. Mimic the helper here by writing the field
|
||||||
|
// directly, which is what the helper does.
|
||||||
|
set.theme_back = Some(theme.back.clone());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
set.theme_back.as_ref().map(|h| h.id()),
|
||||||
|
Some(theme_back.id()),
|
||||||
|
"after a theme apply the theme_back slot must hold the theme's back handle",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Cross-system events used by the engine's plugins.
|
//! Cross-system events used by the engine's plugins.
|
||||||
|
|
||||||
use bevy::prelude::Message;
|
use bevy::prelude::Message;
|
||||||
|
use solitaire_core::card::Suit;
|
||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
@@ -60,6 +61,48 @@ pub struct GameWonEvent {
|
|||||||
pub time_seconds: u64,
|
pub time_seconds: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fired by `GamePlugin` whenever a successful move lands a card on a
|
||||||
|
/// foundation pile that, after the move, contains all 13 cards of its
|
||||||
|
/// suit (Ace → King). Drives the per-suit completion flourish — a brief
|
||||||
|
/// scale pulse on the King card and a golden tint on the foundation
|
||||||
|
/// pile marker — plus a short audio ping.
|
||||||
|
///
|
||||||
|
/// Fired once per per-suit completion. The fourth completion will
|
||||||
|
/// co-occur with `GameWonEvent` and the win cascade — they layer
|
||||||
|
/// cleanly because the flourish is purely decorative and lives on a
|
||||||
|
/// dedicated marker component.
|
||||||
|
///
|
||||||
|
/// This event is a UI/audio cue only. It does **not** cross
|
||||||
|
/// `solitaire_sync` and is not persisted.
|
||||||
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
|
pub struct FoundationCompletedEvent {
|
||||||
|
/// Foundation pile slot (0..=3) that just reached 13 cards.
|
||||||
|
pub slot: u8,
|
||||||
|
/// The suit of the completed foundation, taken from the bottom card
|
||||||
|
/// (always an Ace by construction).
|
||||||
|
pub suit: Suit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fired by `StatsPlugin` when the player's `win_streak_current`
|
||||||
|
/// crosses one of the milestone thresholds in
|
||||||
|
/// [`crate::ui_theme::STREAK_MILESTONES`] (currently 3, 5, 10).
|
||||||
|
///
|
||||||
|
/// Fires only on the threshold crossing — i.e. when the previous
|
||||||
|
/// streak was below the threshold and the post-win streak is at or
|
||||||
|
/// above it — so subsequent wins past the highest milestone do not
|
||||||
|
/// retrigger the flourish.
|
||||||
|
///
|
||||||
|
/// Drives the HUD streak-milestone flourish (a brief scale pulse on
|
||||||
|
/// the score readout) and an informational toast. UI/audio cue only;
|
||||||
|
/// not persisted, not synchronised.
|
||||||
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
|
pub struct WinStreakMilestoneEvent {
|
||||||
|
/// The new `win_streak_current` value at the moment the
|
||||||
|
/// threshold was crossed. Always equal to a value in
|
||||||
|
/// [`crate::ui_theme::STREAK_MILESTONES`].
|
||||||
|
pub streak: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Fired when a card's face-up state changes during gameplay.
|
/// Fired when a card's face-up state changes during gameplay.
|
||||||
#[derive(Message, Debug, Clone, Copy)]
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
pub struct CardFlippedEvent(pub u32);
|
pub struct CardFlippedEvent(pub u32);
|
||||||
|
|||||||
@@ -48,13 +48,18 @@ use solitaire_data::AnimSpeed;
|
|||||||
use crate::animation_plugin::CardAnim;
|
use crate::animation_plugin::CardAnim;
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
|
NewGameRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
use crate::table_plugin::PileMarker;
|
||||||
|
use crate::ui_theme::{
|
||||||
|
FOUNDATION_FLOURISH_PEAK_SCALE, MOTION_FOUNDATION_FLOURISH_SECS, STATE_SUCCESS,
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared constants
|
// Shared constants
|
||||||
@@ -185,7 +190,8 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
|
|||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Registers the shake, settle, and deal animation systems.
|
/// Registers the shake, settle, deal, and foundation-completion flourish
|
||||||
|
/// animation systems.
|
||||||
pub struct FeedbackAnimPlugin;
|
pub struct FeedbackAnimPlugin;
|
||||||
|
|
||||||
impl Plugin for FeedbackAnimPlugin {
|
impl Plugin for FeedbackAnimPlugin {
|
||||||
@@ -197,6 +203,7 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
.add_message::<DrawRequestEvent>()
|
.add_message::<DrawRequestEvent>()
|
||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
|
.add_message::<FoundationCompletedEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -205,6 +212,8 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
start_settle_anim.after(GameMutation),
|
start_settle_anim.after(GameMutation),
|
||||||
tick_settle_anim,
|
tick_settle_anim,
|
||||||
start_deal_anim.after(GameMutation),
|
start_deal_anim.after(GameMutation),
|
||||||
|
start_foundation_flourish.after(GameMutation),
|
||||||
|
tick_foundation_flourish,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -401,6 +410,204 @@ fn start_deal_anim(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Foundation-completion flourish
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Drives the per-foundation completion flourish on the King card that
|
||||||
|
/// just landed on a foundation pile (Ace → King, 13 cards).
|
||||||
|
///
|
||||||
|
/// Inserted on the King's `CardEntity` when `FoundationCompletedEvent`
|
||||||
|
/// fires; removed once `elapsed >= duration`. Decorative only — does
|
||||||
|
/// not block input or interfere with the win cascade, settle, or hint
|
||||||
|
/// systems (those operate on different markers and read the same
|
||||||
|
/// `Transform.scale` coordinate non-conflictingly because the flourish
|
||||||
|
/// finishes in well under a second).
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct FoundationFlourish {
|
||||||
|
/// Foundation slot (0..=3) this flourish is celebrating.
|
||||||
|
pub foundation_slot: u8,
|
||||||
|
/// Seconds elapsed since the flourish began.
|
||||||
|
pub elapsed: f32,
|
||||||
|
/// Total animation length in seconds.
|
||||||
|
pub duration: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives a brief golden tint on the foundation `PileMarker` whose
|
||||||
|
/// foundation just completed. Stores the marker's original colour so
|
||||||
|
/// it can be restored when the timer expires.
|
||||||
|
///
|
||||||
|
/// Inserted alongside (and concurrent with) `FoundationFlourish` on the
|
||||||
|
/// matching `PileMarker` entity. The system runs independently of the
|
||||||
|
/// existing `HintPileHighlight` so the two never share state — a hint
|
||||||
|
/// landing during a completion flourish (highly unlikely in practice
|
||||||
|
/// since the foundation just completed) won't corrupt either party's
|
||||||
|
/// `original_color` snapshot.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct FoundationMarkerFlourish {
|
||||||
|
/// Seconds elapsed since the tint was applied.
|
||||||
|
pub elapsed: f32,
|
||||||
|
/// Total animation length in seconds.
|
||||||
|
pub duration: f32,
|
||||||
|
/// The pile marker's sprite colour before the tint was applied —
|
||||||
|
/// restored when the timer expires.
|
||||||
|
pub original_color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper for unit tests — returns the per-frame scale factor for
|
||||||
|
/// the foundation flourish at `elapsed_secs` over `duration_secs`.
|
||||||
|
///
|
||||||
|
/// Triangular curve, mirroring `score_pulse_scale` in `hud_plugin`:
|
||||||
|
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
|
||||||
|
/// [`FOUNDATION_FLOURISH_PEAK_SCALE`] (1.15), at `t = 1.0` returns
|
||||||
|
/// `1.0`. Out-of-range values are clamped so the King never freezes
|
||||||
|
/// at a non-1.0 scale on the frame after the flourish ends.
|
||||||
|
///
|
||||||
|
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
|
||||||
|
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
|
||||||
|
/// without dividing by zero.
|
||||||
|
pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
|
||||||
|
if duration_secs <= 0.0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
|
||||||
|
let peak = FOUNDATION_FLOURISH_PEAK_SCALE;
|
||||||
|
if t < 0.5 {
|
||||||
|
// Climb from 1.0 at t=0 to peak at t=0.5.
|
||||||
|
1.0 + (peak - 1.0) * (t / 0.5)
|
||||||
|
} else {
|
||||||
|
// Descend from peak at t=0.5 back to 1.0 at t=1.0.
|
||||||
|
peak - (peak - 1.0) * ((t - 0.5) / 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts `FoundationFlourish` on the King card entity at the
|
||||||
|
/// completed foundation and `FoundationMarkerFlourish` on its
|
||||||
|
/// `PileMarker`. The King is identified as the *top* card of the
|
||||||
|
/// foundation pile after the move — by definition the 13th card,
|
||||||
|
/// always rank King by foundation rules.
|
||||||
|
fn start_foundation_flourish(
|
||||||
|
mut events: MessageReader<FoundationCompletedEvent>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
for ev in events.read() {
|
||||||
|
let pile_type = PileType::Foundation(ev.slot);
|
||||||
|
// Top card of the completed foundation is the King.
|
||||||
|
let Some(king_id) = game
|
||||||
|
.0
|
||||||
|
.piles
|
||||||
|
.get(&pile_type)
|
||||||
|
.and_then(|p| p.cards.last())
|
||||||
|
.map(|c| c.id)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tag the King's card entity.
|
||||||
|
for (entity, card_marker) in card_entities.iter() {
|
||||||
|
if card_marker.card_id == king_id {
|
||||||
|
commands.entity(entity).insert(FoundationFlourish {
|
||||||
|
foundation_slot: ev.slot,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: MOTION_FOUNDATION_FLOURISH_SECS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tint the matching PileMarker. Snapshot the current colour so
|
||||||
|
// tick_foundation_flourish can restore it; if a stale flourish
|
||||||
|
// is somehow still active, reuse its `original_color` so we
|
||||||
|
// don't capture the gold tint as the new "original".
|
||||||
|
for (entity, pile_marker, sprite, existing) in pile_markers.iter_mut() {
|
||||||
|
if pile_marker.0 != pile_type {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let original_color = existing.map_or(sprite.color, |f| f.original_color);
|
||||||
|
commands.entity(entity).insert(FoundationMarkerFlourish {
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: MOTION_FOUNDATION_FLOURISH_SECS,
|
||||||
|
original_color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances both the King's scale pulse and the foundation marker's
|
||||||
|
/// gold tint each frame. Removes both components once their timers
|
||||||
|
/// expire, restoring the King's `Transform.scale` to `Vec3::ONE` and
|
||||||
|
/// the marker's sprite colour to its captured original.
|
||||||
|
///
|
||||||
|
/// Skipped while paused so a player who hits Esc mid-flourish doesn't
|
||||||
|
/// see frozen scaled state (the next unpause tick resumes from the
|
||||||
|
/// stored `elapsed`).
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn tick_foundation_flourish(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut card_anims: Query<(Entity, &mut Transform, &mut FoundationFlourish)>,
|
||||||
|
mut marker_anims: Query<
|
||||||
|
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
|
||||||
|
Without<FoundationFlourish>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
|
||||||
|
// Advance the King's scale pulse.
|
||||||
|
for (entity, mut transform, mut anim) in &mut card_anims {
|
||||||
|
anim.elapsed += dt;
|
||||||
|
if anim.elapsed >= anim.duration {
|
||||||
|
// Restore identity scale so the card sits at its normal size
|
||||||
|
// for the next frame's transform sync.
|
||||||
|
transform.scale = Vec3::ONE;
|
||||||
|
commands.entity(entity).remove::<FoundationFlourish>();
|
||||||
|
} else {
|
||||||
|
let s = foundation_flourish_scale(anim.elapsed, anim.duration);
|
||||||
|
transform.scale = Vec3::new(s, s, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the foundation marker's gold tint. Held flat for the
|
||||||
|
// first half of the duration and faded back to the original colour
|
||||||
|
// over the second half — feels celebratory without bleeding into
|
||||||
|
// the next move's drop-target highlights.
|
||||||
|
for (entity, mut sprite, mut anim) in &mut marker_anims {
|
||||||
|
anim.elapsed += dt;
|
||||||
|
if anim.elapsed >= anim.duration {
|
||||||
|
sprite.color = anim.original_color;
|
||||||
|
commands.entity(entity).remove::<FoundationMarkerFlourish>();
|
||||||
|
} else {
|
||||||
|
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
||||||
|
// Lerp factor: 1.0 (full tint) for the first half, then
|
||||||
|
// ramps down linearly to 0.0 (original colour) by the end.
|
||||||
|
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
|
||||||
|
sprite.color = lerp_color(anim.original_color, STATE_SUCCESS, mix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear interpolation between two `Color`s in sRGB space. Pulled out
|
||||||
|
/// as a small helper so the `tick_foundation_flourish` body stays
|
||||||
|
/// readable; sRGB-space lerping is fine for a brief decorative tint
|
||||||
|
/// (a perceptually-uniform space would be overkill).
|
||||||
|
fn lerp_color(from: Color, to: Color, t: f32) -> Color {
|
||||||
|
let from = from.to_srgba();
|
||||||
|
let to = to.to_srgba();
|
||||||
|
let t = t.clamp(0.0, 1.0);
|
||||||
|
Color::srgba(
|
||||||
|
from.red + (to.red - from.red) * t,
|
||||||
|
from.green + (to.green - from.green) * t,
|
||||||
|
from.blue + (to.blue - from.blue) * t,
|
||||||
|
from.alpha + (to.alpha - from.alpha) * t,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Unit tests (pure functions only — no Bevy world required)
|
// Unit tests (pure functions only — no Bevy world required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -534,6 +741,47 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Foundation-flourish curve tests
|
||||||
|
|
||||||
|
/// Triangular curve must be 1.0 at t=0, peak at t=0.5, and 1.0 at t=1.
|
||||||
|
#[test]
|
||||||
|
fn foundation_flourish_scale_curves_through_one_one_one() {
|
||||||
|
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
|
||||||
|
assert!(
|
||||||
|
(foundation_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
|
||||||
|
"flourish scale at t=0 must be 1.0"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(foundation_flourish_scale(dur / 2.0, dur) - FOUNDATION_FLOURISH_PEAK_SCALE).abs() < 1e-5,
|
||||||
|
"flourish scale at midpoint must be FOUNDATION_FLOURISH_PEAK_SCALE"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(foundation_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
|
||||||
|
"flourish scale at t=duration must return to 1.0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Out-of-range values are clamped, not extrapolated. Important so the
|
||||||
|
/// King never ends up at a non-1.0 scale on the frame after the
|
||||||
|
/// flourish ends (which would race against the despawn / restore step
|
||||||
|
/// in `tick_foundation_flourish`).
|
||||||
|
#[test]
|
||||||
|
fn foundation_flourish_scale_clamps_out_of_range() {
|
||||||
|
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
|
||||||
|
// Negative elapsed clamps to 0 → scale 1.0.
|
||||||
|
assert!((foundation_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
|
||||||
|
// Past-end clamps to t=1 → scale 1.0.
|
||||||
|
assert!((foundation_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
|
||||||
|
/// divides by zero.
|
||||||
|
#[test]
|
||||||
|
fn foundation_flourish_scale_zero_duration_is_one() {
|
||||||
|
assert!((foundation_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
|
||||||
|
assert!((foundation_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deal_stagger_jitter_varies_across_card_ids() {
|
fn deal_stagger_jitter_varies_across_card_ids() {
|
||||||
// 52 cards should produce more than a couple distinct jitter factors;
|
// 52 cards should produce more than a couple distinct jitter factors;
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
// Register FontPlugin in solitaire_engine/src/lib.rs before use.
|
//! Embeds FiraMono-Medium into the binary and exposes it via [`FontResource`].
|
||||||
|
//!
|
||||||
//! Loads FiraMono-Medium via the Bevy `AssetServer` and exposes it via [`FontResource`].
|
//! Bundling rather than runtime-loading guarantees the canonical UI face is
|
||||||
|
//! always available regardless of install or platform. The bytes are
|
||||||
|
//! validated at startup; a parse failure aborts the program with a clear
|
||||||
|
//! error because it means the binary is corrupt.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
/// Holds the project-wide [`Handle<Font>`] loaded at startup.
|
/// FiraMono-Medium bytes embedded at compile time. Single source of truth for
|
||||||
|
/// the project's UI face — `solitaire_engine::assets::svg_loader` embeds the
|
||||||
|
/// same path independently for SVG rasterisation so the two layers can't
|
||||||
|
/// drift.
|
||||||
|
const BUNDLED_FONT_BYTES: &[u8] = include_bytes!("../../assets/fonts/main.ttf");
|
||||||
|
|
||||||
|
/// Holds the project-wide [`Handle<Font>`] registered at startup.
|
||||||
#[derive(Resource)]
|
#[derive(Resource)]
|
||||||
pub struct FontResource(pub Handle<Font>);
|
pub struct FontResource(pub Handle<Font>);
|
||||||
|
|
||||||
/// Loads FiraMono-Medium at startup and inserts [`FontResource`].
|
/// Registers the bundled FiraMono with [`Assets<Font>`] at startup.
|
||||||
pub struct FontPlugin;
|
pub struct FontPlugin;
|
||||||
|
|
||||||
impl Plugin for FontPlugin {
|
impl Plugin for FontPlugin {
|
||||||
@@ -17,11 +26,13 @@ impl Plugin for FontPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_font(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
|
fn load_font(fonts: Option<ResMut<Assets<Font>>>, mut commands: Commands) {
|
||||||
let Some(asset_server) = asset_server else {
|
// Headless test fixtures use MinimalPlugins (no AssetPlugin → no
|
||||||
// AssetServer absent (e.g. MinimalPlugins in tests) — insert default.
|
// Assets<Font>). FontPlugin in that context is a no-op — consumers
|
||||||
commands.insert_resource(FontResource(Handle::default()));
|
// already query `Option<Res<FontResource>>` and degrade cleanly.
|
||||||
return;
|
let Some(mut fonts) = fonts else { return };
|
||||||
};
|
let font = Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec())
|
||||||
commands.insert_resource(FontResource(asset_server.load("fonts/main.ttf")));
|
.expect("bundled FiraMono failed to parse — binary is corrupt");
|
||||||
|
let handle = fonts.add(font);
|
||||||
|
commands.insert_resource(FontResource(handle));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ use solitaire_data::{delete_game_state_at, game_state_file_path, load_game_state
|
|||||||
save_game_state_to};
|
save_game_state_to};
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent,
|
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
|
||||||
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
||||||
@@ -86,6 +86,7 @@ impl Plugin for GamePlugin {
|
|||||||
.add_message::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_message::<crate::events::CardFlippedEvent>()
|
.add_message::<crate::events::CardFlippedEvent>()
|
||||||
.add_message::<crate::events::AchievementUnlockedEvent>()
|
.add_message::<crate::events::AchievementUnlockedEvent>()
|
||||||
|
.add_message::<FoundationCompletedEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -398,14 +399,18 @@ fn handle_draw(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_move(
|
fn handle_move(
|
||||||
mut moves: MessageReader<MoveRequestEvent>,
|
mut moves: MessageReader<MoveRequestEvent>,
|
||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut changed: MessageWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
mut won: MessageWriter<GameWonEvent>,
|
mut won: MessageWriter<GameWonEvent>,
|
||||||
mut flipped: MessageWriter<crate::events::CardFlippedEvent>,
|
mut flipped: MessageWriter<crate::events::CardFlippedEvent>,
|
||||||
|
mut foundation_done: MessageWriter<FoundationCompletedEvent>,
|
||||||
path: Option<Res<GameStatePath>>,
|
path: Option<Res<GameStatePath>>,
|
||||||
) {
|
) {
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
for ev in moves.read() {
|
for ev in moves.read() {
|
||||||
let was_won = game.0.is_won;
|
let was_won = game.0.is_won;
|
||||||
// Identify the card that will be exposed (and may flip face-up) by the move.
|
// Identify the card that will be exposed (and may flip face-up) by the move.
|
||||||
@@ -429,6 +434,19 @@ fn handle_move(
|
|||||||
{
|
{
|
||||||
flipped.write(crate::events::CardFlippedEvent(fid));
|
flipped.write(crate::events::CardFlippedEvent(fid));
|
||||||
}
|
}
|
||||||
|
// If this move landed on a foundation pile and that pile is
|
||||||
|
// now complete (Ace → King, 13 cards), fire the per-suit
|
||||||
|
// flourish event. Drives a brief decorative scale-pulse on
|
||||||
|
// the King + a golden tint on the foundation marker plus a
|
||||||
|
// short audio ping. Purely a UI / audio cue — does not
|
||||||
|
// cross `solitaire_sync` and is not persisted.
|
||||||
|
if let PileType::Foundation(slot) = ev.to
|
||||||
|
&& let Some(pile) = game.0.piles.get(&ev.to)
|
||||||
|
&& pile.cards.len() == 13
|
||||||
|
&& let Some(suit) = pile.claimed_suit()
|
||||||
|
{
|
||||||
|
foundation_done.write(FoundationCompletedEvent { slot, suit });
|
||||||
|
}
|
||||||
changed.write(StateChangedEvent);
|
changed.write(StateChangedEvent);
|
||||||
if !was_won && game.0.is_won {
|
if !was_won && game.0.is_won {
|
||||||
won.write(GameWonEvent {
|
won.write(GameWonEvent {
|
||||||
@@ -1407,6 +1425,196 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Foundation-completion flourish — FoundationCompletedEvent firing logic
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Helper: prefill `Foundation(slot)` with Ace through Queen of `suit`
|
||||||
|
/// (12 cards, all face-up) and place the King of `suit` on
|
||||||
|
/// `Tableau(0)` so a single `MoveRequestEvent` can complete the
|
||||||
|
/// foundation.
|
||||||
|
fn seed_foundation_with_ace_through_queen(
|
||||||
|
app: &mut App,
|
||||||
|
slot: u8,
|
||||||
|
suit: solitaire_core::card::Suit,
|
||||||
|
) {
|
||||||
|
use solitaire_core::card::{Card, Rank};
|
||||||
|
|
||||||
|
let ranks = [
|
||||||
|
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
|
||||||
|
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen,
|
||||||
|
];
|
||||||
|
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
|
let foundation = gs
|
||||||
|
.0
|
||||||
|
.piles
|
||||||
|
.get_mut(&PileType::Foundation(slot))
|
||||||
|
.expect("foundation slot must exist");
|
||||||
|
foundation.cards.clear();
|
||||||
|
for (i, &rank) in ranks.iter().enumerate() {
|
||||||
|
foundation.cards.push(Card {
|
||||||
|
id: 5_000 + i as u32 + (slot as u32) * 100,
|
||||||
|
suit,
|
||||||
|
rank,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Put the King on Tableau(0) so a single move can complete it.
|
||||||
|
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||||
|
t0.cards.clear();
|
||||||
|
t0.cards.push(Card {
|
||||||
|
id: 6_000 + (slot as u32),
|
||||||
|
suit,
|
||||||
|
rank: Rank::King,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reading helper: collect every `FoundationCompletedEvent` written
|
||||||
|
/// during the most recent `update()` so the test body can assert
|
||||||
|
/// against count, slot, and suit.
|
||||||
|
fn drain_foundation_events(app: &App) -> Vec<FoundationCompletedEvent> {
|
||||||
|
let events = app
|
||||||
|
.world()
|
||||||
|
.resource::<Messages<FoundationCompletedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
cursor.read(events).copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a King lands on a foundation that already holds Ace through
|
||||||
|
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
|
||||||
|
/// the matching slot + suit.
|
||||||
|
#[test]
|
||||||
|
fn foundation_completed_event_fires_when_king_lands() {
|
||||||
|
use solitaire_core::card::Suit;
|
||||||
|
|
||||||
|
let mut app = test_app(1);
|
||||||
|
seed_foundation_with_ace_through_queen(&mut app, 2, Suit::Hearts);
|
||||||
|
|
||||||
|
app.world_mut().write_message(MoveRequestEvent {
|
||||||
|
from: PileType::Tableau(0),
|
||||||
|
to: PileType::Foundation(2),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let fired = drain_foundation_events(&app);
|
||||||
|
assert_eq!(
|
||||||
|
fired.len(),
|
||||||
|
1,
|
||||||
|
"exactly one FoundationCompletedEvent must fire when the 13th card lands"
|
||||||
|
);
|
||||||
|
assert_eq!(fired[0].slot, 2, "event slot must match the destination slot");
|
||||||
|
assert_eq!(fired[0].suit, Suit::Hearts, "event suit must match the foundation suit");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moving a card to a tableau pile must never produce a
|
||||||
|
/// `FoundationCompletedEvent`, even if the source tableau happened
|
||||||
|
/// to have been a King.
|
||||||
|
#[test]
|
||||||
|
fn foundation_completed_event_does_not_fire_for_non_foundation_moves() {
|
||||||
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
|
|
||||||
|
let mut app = test_app(1);
|
||||||
|
// Reset the world: clear stock + waste so a draw isn't possible,
|
||||||
|
// empty all tableaux + foundations, then place a face-up King of
|
||||||
|
// Spades on Tableau(0). Tableau(1) is empty, so the King can move
|
||||||
|
// there legally.
|
||||||
|
{
|
||||||
|
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
|
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
for i in 0..7_usize {
|
||||||
|
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||||
|
id: 7_000,
|
||||||
|
suit: Suit::Spades,
|
||||||
|
rank: Rank::King,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.world_mut().write_message(MoveRequestEvent {
|
||||||
|
from: PileType::Tableau(0),
|
||||||
|
to: PileType::Tableau(1),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let fired = drain_foundation_events(&app);
|
||||||
|
assert!(
|
||||||
|
fired.is_empty(),
|
||||||
|
"FoundationCompletedEvent must not fire for non-foundation moves; got {fired:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At 12 cards on a foundation (Ace–Jack on the pile, Queen in
|
||||||
|
/// flight), the event must NOT fire — the flourish is only for the
|
||||||
|
/// final 13th completion.
|
||||||
|
#[test]
|
||||||
|
fn foundation_completed_event_does_not_fire_at_12_cards() {
|
||||||
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
|
|
||||||
|
let mut app = test_app(1);
|
||||||
|
let suit = Suit::Diamonds;
|
||||||
|
let slot: u8 = 1;
|
||||||
|
// Pre-fill foundation with Ace through Jack (11 cards).
|
||||||
|
let pre_ranks = [
|
||||||
|
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
|
||||||
|
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack,
|
||||||
|
];
|
||||||
|
{
|
||||||
|
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
|
let foundation = gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap();
|
||||||
|
foundation.cards.clear();
|
||||||
|
for (i, &rank) in pre_ranks.iter().enumerate() {
|
||||||
|
foundation.cards.push(Card {
|
||||||
|
id: 8_000 + i as u32,
|
||||||
|
suit,
|
||||||
|
rank,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Queen on Tableau(0) so a single move pushes the foundation
|
||||||
|
// count to exactly 12 (still below the completion threshold).
|
||||||
|
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||||
|
t0.cards.clear();
|
||||||
|
t0.cards.push(Card {
|
||||||
|
id: 8_900,
|
||||||
|
suit,
|
||||||
|
rank: Rank::Queen,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.world_mut().write_message(MoveRequestEvent {
|
||||||
|
from: PileType::Tableau(0),
|
||||||
|
to: PileType::Foundation(slot),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Sanity: the move actually landed (foundation has 12 cards now).
|
||||||
|
let foundation_len = app
|
||||||
|
.world()
|
||||||
|
.resource::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.piles[&PileType::Foundation(slot)]
|
||||||
|
.cards
|
||||||
|
.len();
|
||||||
|
assert_eq!(foundation_len, 12, "Queen must have landed on the foundation");
|
||||||
|
|
||||||
|
let fired = drain_foundation_events(&app);
|
||||||
|
assert!(
|
||||||
|
fired.is_empty(),
|
||||||
|
"FoundationCompletedEvent must not fire at 12 cards; got {fired:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A successful undo must NOT fire an `InfoToastEvent`.
|
/// A successful undo must NOT fire an `InfoToastEvent`.
|
||||||
#[test]
|
#[test]
|
||||||
fn undo_after_draw_does_not_fire_info_toast() {
|
fn undo_after_draw_does_not_fire_info_toast() {
|
||||||
|
|||||||
@@ -94,6 +94,28 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
|
|||||||
ControlRow { keys: "Click stock", description: "Draw" },
|
ControlRow { keys: "Click stock", description: "Draw" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
ControlSection {
|
||||||
|
title: "Mouse",
|
||||||
|
rows: &[
|
||||||
|
ControlRow { keys: "Double-click", description: "Auto-move card to its best destination" },
|
||||||
|
ControlRow { keys: "Right-click", description: "Highlight legal destinations briefly" },
|
||||||
|
ControlRow {
|
||||||
|
keys: "Hold RMB",
|
||||||
|
description: "Open radial menu — release over an icon to quick-drop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ControlSection {
|
||||||
|
title: "Keyboard drag",
|
||||||
|
rows: &[
|
||||||
|
ControlRow { keys: "Tab", description: "Focus next draggable card" },
|
||||||
|
ControlRow { keys: "Enter", description: "Lift focused card (then arrows pick where)" },
|
||||||
|
ControlRow { keys: "Arrows / Tab", description: "Cycle legal destinations while lifted" },
|
||||||
|
ControlRow { keys: "Enter", description: "Drop the lifted cards on the focused pile" },
|
||||||
|
ControlRow { keys: "Esc", description: "Cancel lift (Esc again clears focus)" },
|
||||||
|
ControlRow { keys: "Space", description: "Auto-move focused card (foundation first)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
ControlSection {
|
ControlSection {
|
||||||
title: "New Game",
|
title: "New Game",
|
||||||
rows: &[
|
rows: &[
|
||||||
@@ -104,6 +126,16 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
|
|||||||
ControlRow { keys: "T", description: "Start a Time Attack session (level 5+)" },
|
ControlRow { keys: "T", description: "Start a Time Attack session (level 5+)" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
ControlSection {
|
||||||
|
title: "Mode Launcher (M)",
|
||||||
|
rows: &[
|
||||||
|
ControlRow { keys: "1", description: "Launch Classic" },
|
||||||
|
ControlRow { keys: "2", description: "Launch Daily Challenge" },
|
||||||
|
ControlRow { keys: "3", description: "Launch Zen (level 5+)" },
|
||||||
|
ControlRow { keys: "4", description: "Launch Challenge (level 5+)" },
|
||||||
|
ControlRow { keys: "5", description: "Launch Time Attack (level 5+)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
ControlSection {
|
ControlSection {
|
||||||
title: "Overlays",
|
title: "Overlays",
|
||||||
rows: &[
|
rows: &[
|
||||||
|
|||||||
@@ -135,6 +135,14 @@ impl Plugin for HomePlugin {
|
|||||||
.add_message::<StartTimeAttackRequestEvent>()
|
.add_message::<StartTimeAttackRequestEvent>()
|
||||||
.add_message::<StartDailyChallengeRequestEvent>()
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
|
// `.chain()` because several systems (M-toggle, card click,
|
||||||
|
// cancel button, digit-key shortcut) all read the
|
||||||
|
// `HomeScreen` entity and may queue a despawn on it in the
|
||||||
|
// same tick. Bevy's parallel scheduler would otherwise let
|
||||||
|
// two of them run simultaneously and double-despawn the
|
||||||
|
// entity, panicking when the second command buffer is
|
||||||
|
// applied. Chaining serialises these systems and keeps the
|
||||||
|
// despawn deterministic.
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -142,7 +150,9 @@ impl Plugin for HomePlugin {
|
|||||||
attach_focusable_to_home_mode_cards,
|
attach_focusable_to_home_mode_cards,
|
||||||
handle_home_card_click,
|
handle_home_card_click,
|
||||||
handle_home_cancel_button,
|
handle_home_cancel_button,
|
||||||
),
|
handle_home_digit_keys,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +261,98 @@ fn handle_home_cancel_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Digit-key shortcuts (1-5) — modal-scoped
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Maps a [`KeyCode::Digit1`]..[`KeyCode::Digit5`] press to the matching
|
||||||
|
/// [`HomeMode`]. Returns `None` for any other key. Kept as a small free
|
||||||
|
/// function so the keyboard handler reads as a clean dispatch table and so
|
||||||
|
/// the mapping is easy to unit-test in isolation.
|
||||||
|
fn digit_to_home_mode(key: KeyCode) -> Option<HomeMode> {
|
||||||
|
match key {
|
||||||
|
KeyCode::Digit1 => Some(HomeMode::Classic),
|
||||||
|
KeyCode::Digit2 => Some(HomeMode::Daily),
|
||||||
|
KeyCode::Digit3 => Some(HomeMode::Zen),
|
||||||
|
KeyCode::Digit4 => Some(HomeMode::Challenge),
|
||||||
|
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct keyboard activation of a specific mode while the Mode Launcher
|
||||||
|
/// modal is open. Mirrors the click-handler dispatch in
|
||||||
|
/// [`handle_home_card_click`]: pressing `1` launches Classic, `2` launches
|
||||||
|
/// the Daily Challenge, and `3`/`4`/`5` launch Zen / Challenge / Time
|
||||||
|
/// Attack respectively when the player has reached
|
||||||
|
/// [`CHALLENGE_UNLOCK_LEVEL`].
|
||||||
|
///
|
||||||
|
/// The shortcut is **modal-scoped** — when no [`HomeScreen`] exists the
|
||||||
|
/// system returns immediately, so digit keys can never accidentally launch
|
||||||
|
/// a mode mid-game. Pressing a digit for a locked mode is a no-op (matches
|
||||||
|
/// the click-on-locked-card behaviour) and leaves the modal open so the
|
||||||
|
/// player can pick another mode.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn handle_home_digit_keys(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
|
mut zen: MessageWriter<StartZenRequestEvent>,
|
||||||
|
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||||
|
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
||||||
|
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
||||||
|
) {
|
||||||
|
// Modal-scoped: do nothing when the Mode Launcher isn't open.
|
||||||
|
if screens.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(mode) = [
|
||||||
|
KeyCode::Digit1,
|
||||||
|
KeyCode::Digit2,
|
||||||
|
KeyCode::Digit3,
|
||||||
|
KeyCode::Digit4,
|
||||||
|
KeyCode::Digit5,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.find(|k| keys.just_pressed(*k))
|
||||||
|
.and_then(digit_to_home_mode) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
||||||
|
if !mode.is_unlocked(level) {
|
||||||
|
// Locked mode: no-op, modal stays open.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match mode {
|
||||||
|
HomeMode::Classic => {
|
||||||
|
new_game.write(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
HomeMode::Daily => {
|
||||||
|
daily.write(StartDailyChallengeRequestEvent);
|
||||||
|
}
|
||||||
|
HomeMode::Zen => {
|
||||||
|
zen.write(StartZenRequestEvent);
|
||||||
|
}
|
||||||
|
HomeMode::Challenge => {
|
||||||
|
challenge.write(StartChallengeRequestEvent);
|
||||||
|
}
|
||||||
|
HomeMode::TimeAttack => {
|
||||||
|
time_attack.write(StartTimeAttackRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the modal after dispatching the launch event — same shape as
|
||||||
|
// the click handler.
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Spawn helpers
|
// Spawn helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -873,4 +975,191 @@ mod tests {
|
|||||||
"no card may be Disabled when the player is at the unlock level"
|
"no card may be Disabled when the player is at the unlock level"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Digit-key shortcuts (1-5) — modal-scoped direct mode launch
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Press a key and clear the input afterwards so the next `update()`
|
||||||
|
/// doesn't re-fire `just_pressed`. Mirrors the open_home() pattern but
|
||||||
|
/// for an arbitrary key (the M-press helper releases & clears KeyM,
|
||||||
|
/// which is also what we need here for Digit keys).
|
||||||
|
fn press_and_clear(app: &mut App, key: KeyCode) {
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.press(key);
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(key);
|
||||||
|
input.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn digit1_in_home_modal_starts_classic_and_closes_modal() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let _ = open_home(&mut app);
|
||||||
|
|
||||||
|
// Drain any pre-existing NewGameRequestEvent so the assertion
|
||||||
|
// only sees the digit-key driven write.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<NewGameRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
|
||||||
|
press_and_clear(&mut app, KeyCode::Digit1);
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
|
assert_eq!(
|
||||||
|
fired.len(),
|
||||||
|
1,
|
||||||
|
"exactly one NewGameRequestEvent must fire for Digit1"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&HomeScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
"Home modal must close after launching Classic via Digit1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn digit3_at_level_zero_is_a_noop() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Default level is 0 — Zen is locked.
|
||||||
|
let _ = open_home(&mut app);
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<StartZenRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
|
||||||
|
press_and_clear(&mut app, KeyCode::Digit3);
|
||||||
|
|
||||||
|
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
|
||||||
|
let mut zc = zen.get_cursor();
|
||||||
|
assert!(
|
||||||
|
zc.read(zen).next().is_none(),
|
||||||
|
"Digit3 at level 0 must not fire StartZenRequestEvent"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&HomeScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"Home modal must remain open after a locked-mode digit press"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn digit3_at_unlock_level_starts_zen_and_closes_modal() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Bump the player to the unlock level *before* opening the modal
|
||||||
|
// so the Mode Launcher is in its unlocked state.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ProgressResource>()
|
||||||
|
.0
|
||||||
|
.level = CHALLENGE_UNLOCK_LEVEL;
|
||||||
|
let _ = open_home(&mut app);
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<StartZenRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
|
||||||
|
press_and_clear(&mut app, KeyCode::Digit3);
|
||||||
|
|
||||||
|
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
|
||||||
|
let mut zc = zen.get_cursor();
|
||||||
|
assert_eq!(
|
||||||
|
zc.read(zen).count(),
|
||||||
|
1,
|
||||||
|
"Digit3 at unlock level must fire exactly one StartZenRequestEvent"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&HomeScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
"Home modal must close after launching Zen via Digit3"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn digit_keys_outside_home_modal_are_noop() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Modal is NOT open. Bump level so Zen would otherwise be allowed
|
||||||
|
// — this isolates the modal-scope guard from the unlock check.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ProgressResource>()
|
||||||
|
.0
|
||||||
|
.level = CHALLENGE_UNLOCK_LEVEL;
|
||||||
|
|
||||||
|
// Drain any pre-existing events.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<NewGameRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<StartZenRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<StartChallengeRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<StartTimeAttackRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<StartDailyChallengeRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
|
||||||
|
// Press every digit 1-5 in turn — none should trigger a launch.
|
||||||
|
for key in [
|
||||||
|
KeyCode::Digit1,
|
||||||
|
KeyCode::Digit2,
|
||||||
|
KeyCode::Digit3,
|
||||||
|
KeyCode::Digit4,
|
||||||
|
KeyCode::Digit5,
|
||||||
|
] {
|
||||||
|
press_and_clear(&mut app, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
let mut nc = new_game.get_cursor();
|
||||||
|
assert!(
|
||||||
|
nc.read(new_game).next().is_none(),
|
||||||
|
"Digit keys with no modal open must not fire NewGameRequestEvent"
|
||||||
|
);
|
||||||
|
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
|
||||||
|
let mut zc = zen.get_cursor();
|
||||||
|
assert!(
|
||||||
|
zc.read(zen).next().is_none(),
|
||||||
|
"Digit keys with no modal open must not fire StartZenRequestEvent"
|
||||||
|
);
|
||||||
|
let chal = app.world().resource::<Messages<StartChallengeRequestEvent>>();
|
||||||
|
let mut cc = chal.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cc.read(chal).next().is_none(),
|
||||||
|
"Digit keys with no modal open must not fire StartChallengeRequestEvent"
|
||||||
|
);
|
||||||
|
let ta = app.world().resource::<Messages<StartTimeAttackRequestEvent>>();
|
||||||
|
let mut tc = ta.get_cursor();
|
||||||
|
assert!(
|
||||||
|
tc.read(ta).next().is_none(),
|
||||||
|
"Digit keys with no modal open must not fire StartTimeAttackRequestEvent"
|
||||||
|
);
|
||||||
|
let daily = app.world().resource::<Messages<StartDailyChallengeRequestEvent>>();
|
||||||
|
let mut dc = daily.get_cursor();
|
||||||
|
assert!(
|
||||||
|
dc.read(daily).next().is_none(),
|
||||||
|
"Digit keys with no modal open must not fire StartDailyChallengeRequestEvent"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,17 @@ use crate::settings_plugin::SettingsResource;
|
|||||||
use crate::layout::HUD_BAND_HEIGHT;
|
use crate::layout::HUD_BAND_HEIGHT;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM,
|
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS,
|
||||||
STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY, TEXT_SECONDARY,
|
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
|
||||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||||
|
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
};
|
};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||||
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
||||||
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
|
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
|
||||||
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
|
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
|
||||||
UndoRequestEvent,
|
UndoRequestEvent, WinStreakMilestoneEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
@@ -130,6 +131,51 @@ pub struct ScoreFloater {
|
|||||||
pub duration: f32,
|
pub duration: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drives the streak-milestone flourish: scales the [`HudScore`] text
|
||||||
|
/// from `1.0 → STREAK_FLOURISH_PEAK_SCALE → 1.0` over
|
||||||
|
/// [`MOTION_STREAK_FLOURISH_SECS`] (scaled by
|
||||||
|
/// [`AnimSpeed`](solitaire_data::AnimSpeed)) and tints it
|
||||||
|
/// [`ACCENT_SECONDARY`] for the same window before restoring the
|
||||||
|
/// original colour.
|
||||||
|
///
|
||||||
|
/// The streak readout currently lives in the Stats overlay (press
|
||||||
|
/// `S`) — there is no always-on HUD streak counter — so the flourish
|
||||||
|
/// piggybacks on the score readout, which is the most prominent
|
||||||
|
/// always-visible HUD number. Mirrors the `FoundationFlourish`
|
||||||
|
/// pattern: triangular scale curve, fixed duration, restores state
|
||||||
|
/// when the timer expires.
|
||||||
|
///
|
||||||
|
/// Inserted on `HudScore` entities by `start_streak_flourish` when a
|
||||||
|
/// `WinStreakMilestoneEvent` fires; removed once `elapsed >=
|
||||||
|
/// duration` so the readout returns to its rest state for the next
|
||||||
|
/// frame's transform sync.
|
||||||
|
///
|
||||||
|
/// Coexists with [`ScorePulse`]: the streak flourish lives on a
|
||||||
|
/// dedicated marker so a streak-crossing win that also ticks the
|
||||||
|
/// score (every win does) doesn't have the two animations stomp on
|
||||||
|
/// each other's `Transform.scale` writes — the streak flourish runs
|
||||||
|
/// in a `Without<ScorePulse>` query so only the loudest of the two
|
||||||
|
/// celebrations is active at a time.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct StreakFlourish {
|
||||||
|
/// The streak milestone that triggered this flourish (3, 5, 10).
|
||||||
|
/// Carried for diagnostic logging only — the visual is identical
|
||||||
|
/// for every threshold so play-testing can decide later whether
|
||||||
|
/// to differentiate.
|
||||||
|
pub streak: u32,
|
||||||
|
/// Seconds elapsed since the flourish began.
|
||||||
|
pub elapsed: f32,
|
||||||
|
/// Total animation length in seconds. Zero under
|
||||||
|
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) — the system
|
||||||
|
/// snaps the scale back to 1.0 on the first tick so no half-state
|
||||||
|
/// is ever shown.
|
||||||
|
pub duration: f32,
|
||||||
|
/// The score readout's colour before the flourish began —
|
||||||
|
/// restored when the timer expires so the readout returns to its
|
||||||
|
/// resting `TEXT_PRIMARY` (or whatever it was) tint.
|
||||||
|
pub original_color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
/// Tracks the score from the previous frame so the HUD can detect
|
/// Tracks the score from the previous frame so the HUD can detect
|
||||||
/// changes without a `ScoreChangedEvent`. The plugin wires this to the
|
/// changes without a `ScoreChangedEvent`. The plugin wires this to the
|
||||||
/// pulse + floater systems on every `Update`.
|
/// pulse + floater systems on every `Update`.
|
||||||
@@ -251,6 +297,7 @@ impl Plugin for HudPlugin {
|
|||||||
.add_message::<ToggleProfileRequestEvent>()
|
.add_message::<ToggleProfileRequestEvent>()
|
||||||
.add_message::<ToggleSettingsRequestEvent>()
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||||
|
.add_message::<WinStreakMilestoneEvent>()
|
||||||
.init_resource::<PreviousScore>()
|
.init_resource::<PreviousScore>()
|
||||||
.init_resource::<HudActionFade>()
|
.init_resource::<HudActionFade>()
|
||||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
||||||
@@ -267,6 +314,12 @@ impl Plugin for HudPlugin {
|
|||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.after(GameMutation),
|
||||||
)
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(start_streak_flourish, advance_streak_flourish)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -1285,6 +1338,148 @@ fn advance_score_floater(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Streak-milestone flourish
|
||||||
|
//
|
||||||
|
// Per the 2026-04-30 UX overhaul plan, the foundation flourish is the per-suit
|
||||||
|
// completion celebration; the streak flourish is its lifetime equivalent —
|
||||||
|
// when the player's `win_streak_current` crosses 3, 5, or 10, the HUD score
|
||||||
|
// readout pulses larger than a normal score-change pulse and tints magenta
|
||||||
|
// (`ACCENT_SECONDARY`) before snapping back to its resting state.
|
||||||
|
//
|
||||||
|
// Why the score readout: there is no always-on streak number on the HUD
|
||||||
|
// today (the readout lives in the Stats overlay), and the score is the
|
||||||
|
// most prominent always-visible HUD figure. The accompanying `InfoToastEvent`
|
||||||
|
// fired by `stats_plugin` carries the explicit "Win streak: N!" text so a
|
||||||
|
// player who isn't watching the score still sees the celebration land.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pure helper for unit tests — returns the per-frame scale factor for
|
||||||
|
/// the streak flourish at `elapsed_secs` over `duration_secs`.
|
||||||
|
///
|
||||||
|
/// Triangular curve, mirroring [`foundation_flourish_scale`](crate::feedback_anim_plugin::foundation_flourish_scale):
|
||||||
|
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
|
||||||
|
/// [`STREAK_FLOURISH_PEAK_SCALE`], at `t = 1.0` returns `1.0`.
|
||||||
|
/// Out-of-range values are clamped so the score readout 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 streak_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 = STREAK_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 a [`StreakFlourish`] on every [`HudScore`] entity when a
|
||||||
|
/// [`WinStreakMilestoneEvent`] fires. Captures the readout's current
|
||||||
|
/// `TextColor` so `advance_streak_flourish` can restore it when the
|
||||||
|
/// timer expires; reuses any existing flourish's `original_color` so
|
||||||
|
/// re-entering the system mid-flourish doesn't snapshot the magenta
|
||||||
|
/// tint as the new "original".
|
||||||
|
///
|
||||||
|
/// Removes any concurrent [`ScorePulse`] from the same entity so the
|
||||||
|
/// flourish takes over the scale slot cleanly — score pulses last
|
||||||
|
/// 250 ms, the flourish 600 ms, and the streak crossing always
|
||||||
|
/// coincides with a positive score delta, so the flourish is the
|
||||||
|
/// louder of the two celebrations.
|
||||||
|
fn start_streak_flourish(
|
||||||
|
mut events: MessageReader<WinStreakMilestoneEvent>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
score_q: Query<(Entity, &TextColor, Option<&StreakFlourish>), With<HudScore>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let Some(latest) = events.read().last() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let speed = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.0.animation_speed)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let duration = scaled_duration(MOTION_STREAK_FLOURISH_SECS, speed);
|
||||||
|
for (entity, color, existing) in &score_q {
|
||||||
|
let original_color = existing.map_or(color.0, |f| f.original_color);
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<ScorePulse>()
|
||||||
|
.insert(StreakFlourish {
|
||||||
|
streak: latest.streak,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration,
|
||||||
|
original_color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every [`StreakFlourish`], scaling its entity's `Transform`
|
||||||
|
/// using [`streak_flourish_scale`] and lerping the `TextColor` toward
|
||||||
|
/// [`ACCENT_SECONDARY`] for the first half then back to the captured
|
||||||
|
/// `original_color`. Removes the component once `elapsed >= duration`
|
||||||
|
/// (or immediately under [`AnimSpeed::Instant`](solitaire_data::AnimSpeed)
|
||||||
|
/// where duration is 0) and pins the scale back to 1.0 / restores the
|
||||||
|
/// original colour so no half-state is ever shown.
|
||||||
|
///
|
||||||
|
/// Filtered with `Without<ScorePulse>` so the streak flourish never
|
||||||
|
/// races a score pulse for the same `Transform.scale` slot —
|
||||||
|
/// `start_streak_flourish` strips any concurrent `ScorePulse` from the
|
||||||
|
/// score entity before this system runs, so the filter is purely a
|
||||||
|
/// belt-and-braces invariant.
|
||||||
|
fn advance_streak_flourish(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut q: Query<
|
||||||
|
(Entity, &mut StreakFlourish, &mut Transform, &mut TextColor),
|
||||||
|
Without<ScorePulse>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut anim, mut transform, mut color) in &mut q {
|
||||||
|
let t = if anim.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
anim.elapsed += dt;
|
||||||
|
(anim.elapsed / anim.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
let scale = streak_flourish_scale(anim.elapsed, anim.duration);
|
||||||
|
transform.scale = Vec3::new(scale, scale, 1.0);
|
||||||
|
// Tint mix: full magenta at t=0..=0.5, fades back to the
|
||||||
|
// original colour over t=0.5..=1.0.
|
||||||
|
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
|
||||||
|
color.0 = lerp_text_color(anim.original_color, ACCENT_SECONDARY, mix);
|
||||||
|
if t >= 1.0 {
|
||||||
|
transform.scale = Vec3::ONE;
|
||||||
|
color.0 = anim.original_color;
|
||||||
|
commands.entity(entity).remove::<StreakFlourish>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sRGB-space linear interpolation between two `Color`s — small local
|
||||||
|
/// helper so `advance_streak_flourish` stays readable. sRGB-space
|
||||||
|
/// lerping is fine for a brief decorative tint (a perceptually-uniform
|
||||||
|
/// space would be overkill).
|
||||||
|
fn lerp_text_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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
fn update_hud(
|
fn update_hud(
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
@@ -2091,6 +2286,45 @@ mod tests {
|
|||||||
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
|
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Streak flourish curve must be 1.0 at t=0, peak at t=0.5, and
|
||||||
|
/// return to 1.0 at t=duration. Mirrors the `foundation_flourish_scale`
|
||||||
|
/// curve test — the two animations share a triangular shape so a
|
||||||
|
/// future tweak that desyncs them shows up here.
|
||||||
|
#[test]
|
||||||
|
fn streak_flourish_scale_curves_through_one_one_one() {
|
||||||
|
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||||
|
assert!(
|
||||||
|
(streak_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
|
||||||
|
"streak flourish scale at t=0 must be 1.0",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(streak_flourish_scale(dur / 2.0, dur) - STREAK_FLOURISH_PEAK_SCALE).abs() < 1e-5,
|
||||||
|
"streak flourish scale at midpoint must be STREAK_FLOURISH_PEAK_SCALE",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(streak_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
|
||||||
|
"streak flourish scale at t=duration must return to 1.0",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Out-of-range values are clamped, not extrapolated. Matches the
|
||||||
|
/// foundation flourish's clamp behaviour so the score readout never
|
||||||
|
/// freezes at a non-1.0 scale on the frame after the flourish ends.
|
||||||
|
#[test]
|
||||||
|
fn streak_flourish_scale_clamps_out_of_range() {
|
||||||
|
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||||
|
assert!((streak_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
|
||||||
|
assert!((streak_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 streak_flourish_scale_zero_duration_is_one() {
|
||||||
|
assert!((streak_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
|
||||||
|
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Phase 2: keyboard focus ring — HUD action bar
|
// Phase 2: keyboard focus ring — HUD action bar
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ use solitaire_core::pile::PileType;
|
|||||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||||
|
|
||||||
use crate::card_animation::tuning::AnimationTuning;
|
use crate::card_animation::tuning::AnimationTuning;
|
||||||
|
use crate::card_animation::{CardAnimation, MotionCurve};
|
||||||
use crate::card_plugin::{
|
use crate::card_plugin::{
|
||||||
CardEntity, HintHighlight, HintHighlightTimer, TABLEAU_FACEDOWN_FAN_FRAC, TABLEAU_FAN_FRAC,
|
CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC,
|
||||||
|
TABLEAU_FAN_FRAC,
|
||||||
};
|
};
|
||||||
use crate::feedback_anim_plugin::ShakeAnim;
|
use crate::ui_theme::MOTION_DRAG_REJECT_SECS;
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
@@ -666,14 +668,16 @@ fn end_drag(
|
|||||||
to: target.clone(),
|
to: target.clone(),
|
||||||
count,
|
count,
|
||||||
});
|
});
|
||||||
// Shake each dragged card so the player gets immediate
|
// Smoothly glide each dragged card from its drop-time
|
||||||
// visual feedback that the drop was rejected. ShakeAnim
|
// transform back to its resting slot in the origin pile.
|
||||||
// restores translation.x to origin_x at the end of the
|
// The audio cue (card_invalid.wav, played by AudioPlugin
|
||||||
// animation, so origin_x must be the target slot in the
|
// on MoveRejectedEvent) still gives the player clear
|
||||||
// origin pile — using the current drag transform would
|
// negative feedback; this just replaces the old shake
|
||||||
// pin the card at the drop location and fight the
|
// wiggle with a forgiving ease-out tween.
|
||||||
// sync_cards slide that StateChangedEvent triggers
|
//
|
||||||
// (the symptom is "card lands beside the pile").
|
// `update_card_entity` skips its own snap/slide while a
|
||||||
|
// `CardAnimation` is present, so the StateChangedEvent
|
||||||
|
// that fires below does not fight this tween.
|
||||||
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
||||||
for &card_id in &drag.cards {
|
for &card_id in &drag.cards {
|
||||||
let Some(stack_index) =
|
let Some(stack_index) =
|
||||||
@@ -683,14 +687,23 @@ fn end_drag(
|
|||||||
};
|
};
|
||||||
let target_pos =
|
let target_pos =
|
||||||
card_position(&game.0, &layout.0, &origin, stack_index);
|
card_position(&game.0, &layout.0, &origin, stack_index);
|
||||||
if let Some((entity, _, _)) = card_entities
|
if let Some((entity, _, transform)) = card_entities
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, ce, _)| ce.card_id == card_id)
|
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||||
{
|
{
|
||||||
commands.entity(entity).insert(ShakeAnim {
|
let drag_pos = transform.translation.truncate();
|
||||||
elapsed: 0.0,
|
let drag_z = transform.translation.z;
|
||||||
origin_x: target_pos.x,
|
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
});
|
commands.entity(entity).insert(
|
||||||
|
CardAnimation::slide(
|
||||||
|
drag_pos,
|
||||||
|
drag_z,
|
||||||
|
target_pos,
|
||||||
|
end_z,
|
||||||
|
MotionCurve::Responsive,
|
||||||
|
)
|
||||||
|
.with_duration(MOTION_DRAG_REJECT_SECS),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -899,9 +912,11 @@ fn touch_end_drag(
|
|||||||
fired = true;
|
fired = true;
|
||||||
} else {
|
} else {
|
||||||
rejected.write(MoveRejectedEvent { from: origin.clone(), to: target, count });
|
rejected.write(MoveRejectedEvent { from: origin.clone(), to: target, count });
|
||||||
// See `end_drag` (mouse path) for the rationale: ShakeAnim
|
// Smoothly glide each dragged card from its drop-time
|
||||||
// restores translation.x to origin_x, so origin_x must be
|
// transform back to its resting slot. See `end_drag`
|
||||||
// the origin pile's slot, not the drop location.
|
// (mouse path) for the full rationale; the touch path
|
||||||
|
// mirrors it exactly so finger and mouse rejection
|
||||||
|
// feel identical.
|
||||||
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
||||||
for &card_id in &drag.cards {
|
for &card_id in &drag.cards {
|
||||||
let Some(stack_index) =
|
let Some(stack_index) =
|
||||||
@@ -911,13 +926,22 @@ fn touch_end_drag(
|
|||||||
};
|
};
|
||||||
let target_pos =
|
let target_pos =
|
||||||
card_position(&game.0, &layout.0, &origin, stack_index);
|
card_position(&game.0, &layout.0, &origin, stack_index);
|
||||||
if let Some((entity, _, _)) =
|
if let Some((entity, _, transform)) =
|
||||||
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
|
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
|
||||||
{
|
{
|
||||||
commands.entity(entity).insert(ShakeAnim {
|
let drag_pos = transform.translation.truncate();
|
||||||
elapsed: 0.0,
|
let drag_z = transform.translation.z;
|
||||||
origin_x: target_pos.x,
|
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
});
|
commands.entity(entity).insert(
|
||||||
|
CardAnimation::slide(
|
||||||
|
drag_pos,
|
||||||
|
drag_z,
|
||||||
|
target_pos,
|
||||||
|
end_z,
|
||||||
|
MotionCurve::Responsive,
|
||||||
|
)
|
||||||
|
.with_duration(MOTION_DRAG_REJECT_SECS),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1946,71 +1970,159 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Task #57 — ShakeAnim insertion on rejected drag
|
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||||
|
// `ShakeAnim` on the dragged cards. The audio cue
|
||||||
|
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
||||||
|
// visual response on the dragged cards swapped from a horizontal wiggle
|
||||||
|
// to a smooth ease-out glide back to the origin pile.
|
||||||
|
//
|
||||||
|
// These tests build the component values exactly as `end_drag` and
|
||||||
|
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
|
||||||
|
// shaped correctly. Driving `end_drag` end-to-end requires a real window
|
||||||
|
// and mouse-button input, so we exercise the data path the same way the
|
||||||
|
// legacy `ShakeAnim` tests did.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/// Verifies that `ShakeAnim` constructed for a rejected drag has the
|
/// Helper: build the `CardAnimation` the rejection paths construct for
|
||||||
/// correct initial values: `elapsed` starts at 0.0 and `origin_x` matches
|
/// one dragged card. Mirrors the inline logic in `end_drag` and
|
||||||
/// the **target slot in the origin pile** (where the card will rest after
|
/// `touch_end_drag` so the tests stay in sync with the production code.
|
||||||
/// the rejection). Saving the drop-location X here was the root cause of
|
fn build_drag_reject_animation(
|
||||||
/// the "card lands beside the pile" bug — `tick_shake_anim` restores
|
drag_pos: Vec2,
|
||||||
/// `translation.x` to `origin_x` at the end of the shake, fighting the
|
drag_z: f32,
|
||||||
/// `sync_cards` slide that `StateChangedEvent` triggers.
|
target_pos: Vec2,
|
||||||
///
|
stack_index: usize,
|
||||||
/// The Bevy ECS part (Commands + Query) is exercised at runtime; this test
|
) -> CardAnimation {
|
||||||
/// covers the data path — that we build the component with the right values
|
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
/// before handing it to `commands.entity(...).insert(...)`.
|
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
|
||||||
#[test]
|
.with_duration(MOTION_DRAG_REJECT_SECS)
|
||||||
fn shake_anim_for_rejected_drag_has_correct_initial_values() {
|
|
||||||
use crate::feedback_anim_plugin::ShakeAnim;
|
|
||||||
|
|
||||||
// Simulate the X coordinate of the card's slot in its origin pile —
|
|
||||||
// computed by `card_position(game, layout, &origin, stack_index)` at
|
|
||||||
// rejection time, not the drop-location transform X.
|
|
||||||
let target_slot_x = 123.5_f32;
|
|
||||||
|
|
||||||
// This mirrors the ShakeAnim construction in `end_drag` and
|
|
||||||
// `touch_end_drag` after the bugfix: origin_x is the origin pile's
|
|
||||||
// slot X, so the shake ends with the card at its correct resting
|
|
||||||
// position.
|
|
||||||
let anim = ShakeAnim {
|
|
||||||
elapsed: 0.0,
|
|
||||||
origin_x: target_slot_x,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
anim.elapsed, 0.0,
|
|
||||||
"ShakeAnim must start with elapsed=0.0 so the animation plays from the beginning"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(anim.origin_x - target_slot_x).abs() < 1e-6,
|
|
||||||
"ShakeAnim origin_x must match the origin pile slot's X (where the \
|
|
||||||
card belongs after rejection), not the drop-location transform X. \
|
|
||||||
Expected {target_slot_x}, got {}",
|
|
||||||
anim.origin_x
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When a drag is rejected, every card id in `drag.cards` should receive a
|
/// Every card in `drag.cards` should receive its own `CardAnimation` on
|
||||||
/// `ShakeAnim`. Verify that the set of card ids we would iterate matches
|
/// rejection. With the shake → tween migration, the assertion changes
|
||||||
/// exactly the ids stored in `DragState::cards` at rejection time.
|
/// from "every dragged card gets a ShakeAnim" to "every dragged card
|
||||||
|
/// gets a CardAnimation" — same coverage, new component.
|
||||||
#[test]
|
#[test]
|
||||||
fn rejected_drag_shakes_all_dragged_cards() {
|
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||||
// Simulate a DragState with two card ids (a stack drag).
|
// Simulate a stack drag of two cards.
|
||||||
let dragged_ids: Vec<u32> = vec![10, 11];
|
let dragged_ids: Vec<u32> = vec![10, 11];
|
||||||
|
|
||||||
// In `end_drag`, we iterate `drag.cards` and look up each id in
|
let mut animated: Vec<u32> = Vec::new();
|
||||||
// `card_entities`. The ids we would insert ShakeAnim on must exactly
|
|
||||||
// match the dragged set.
|
|
||||||
let mut shaken: Vec<u32> = Vec::new();
|
|
||||||
for &card_id in &dragged_ids {
|
for &card_id in &dragged_ids {
|
||||||
// Simulate finding the entity for card_id (always succeeds here).
|
// In `end_drag` we iterate `drag.cards` and look up each id in
|
||||||
shaken.push(card_id);
|
// `card_entities`. The ids we would insert a `CardAnimation` on
|
||||||
|
// must exactly match the dragged set.
|
||||||
|
animated.push(card_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
shaken, dragged_ids,
|
animated, dragged_ids,
|
||||||
"every card id in drag.cards must receive a ShakeAnim on rejection"
|
"every card id in drag.cards must receive a CardAnimation on rejection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `end` field of the inserted tween must equal the card's resting
|
||||||
|
/// slot in its origin pile — the position the card belongs at after a
|
||||||
|
/// rejected drop. Without this, the tween would glide to the wrong spot
|
||||||
|
/// and `sync_cards` would have to fight it back.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_targets_origin_resting_position() {
|
||||||
|
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
||||||
|
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
||||||
|
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(anim.end - target_pos).length() < 1e-6,
|
||||||
|
"CardAnimation.end must match the origin slot's resting position. \
|
||||||
|
Expected {target_pos:?}, got {:?}",
|
||||||
|
anim.end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `start` field of the inserted tween must equal the card's
|
||||||
|
/// drop-time transform position — i.e. wherever the cursor or finger
|
||||||
|
/// released the card. This is what makes the glide feel like a
|
||||||
|
/// continuous return rather than a teleport-then-shake.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_starts_from_drag_position() {
|
||||||
|
let drag_pos = Vec2::new(640.0, 200.0);
|
||||||
|
let target_pos = Vec2::new(80.0, -120.0);
|
||||||
|
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(anim.start - drag_pos).length() < 1e-6,
|
||||||
|
"CardAnimation.start must match the drop-time transform position \
|
||||||
|
(where the cursor released). Expected {drag_pos:?}, got {:?}",
|
||||||
|
anim.start
|
||||||
|
);
|
||||||
|
// And the start must be visibly distinct from the origin slot — the
|
||||||
|
// whole point of the tween is that it visibly travels.
|
||||||
|
assert!(
|
||||||
|
(anim.start - anim.end).length() > 1.0,
|
||||||
|
"rejected drag should travel a visible distance, got start={:?} end={:?}",
|
||||||
|
anim.start,
|
||||||
|
anim.end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tween duration is taken from the project-wide motion token so
|
||||||
|
/// designers can retune the feel from one place. Keeps the constant and
|
||||||
|
/// the call site honest.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_uses_correct_duration() {
|
||||||
|
let anim = build_drag_reject_animation(
|
||||||
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
||||||
|
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
||||||
|
({MOTION_DRAG_REJECT_SECS}), got {}",
|
||||||
|
anim.duration
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The curve must be a no-overshoot ease-out so the card decelerates
|
||||||
|
/// cleanly into its rest position — overshoot on a rejection feels
|
||||||
|
/// jittery rather than forgiving.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_uses_responsive_curve() {
|
||||||
|
let anim = build_drag_reject_animation(
|
||||||
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
anim.curve,
|
||||||
|
MotionCurve::Responsive,
|
||||||
|
"drag-rejection tween must use Responsive (quintic ease-out) \
|
||||||
|
so the card snaps back without bouncing past the slot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `start_z` of the tween must equal the card's drop-time z
|
||||||
|
/// (`DRAG_Z`) so the card stays above the rest of the table while it
|
||||||
|
/// travels home, then settles at the correct resting z.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
|
||||||
|
let stack_index = 2_usize;
|
||||||
|
let anim = build_drag_reject_animation(
|
||||||
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
stack_index,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(anim.start_z - DRAG_Z).abs() < 1e-6,
|
||||||
|
"tween must start at DRAG_Z so the card stays on top during the glide"
|
||||||
|
);
|
||||||
|
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
|
assert!(
|
||||||
|
(anim.end_z - expected_end_z).abs() < 1e-6,
|
||||||
|
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
|
||||||
|
anim.end_z
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ pub enum LayoutSystem {
|
|||||||
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
|
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
|
||||||
|
|
||||||
/// Aspect ratio (height / width) of a standard playing card.
|
/// Aspect ratio (height / width) of a standard playing card.
|
||||||
const CARD_ASPECT: f32 = 1.4;
|
///
|
||||||
|
/// Matches the bundled hayeah/playing-cards-assets SVG dimensions
|
||||||
|
/// (167.087 × 242.667 → 1.4523). Pre-v0.11 the constant was 1.4,
|
||||||
|
/// which rendered the cards ~3.6 % squashed vertically.
|
||||||
|
const CARD_ASPECT: f32 = 1.4523;
|
||||||
|
|
||||||
/// Fraction of card height used as vertical padding between the top row and
|
/// Fraction of card height used as vertical padding between the top row and
|
||||||
/// the tableau row.
|
/// the tableau row.
|
||||||
@@ -59,7 +63,7 @@ pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
|||||||
pub struct Layout {
|
pub struct Layout {
|
||||||
/// Width and height of a single card, in world units (Bevy 2D world-space).
|
/// Width and height of a single card, in world units (Bevy 2D world-space).
|
||||||
///
|
///
|
||||||
/// `x` is the card width; `y` is the card height (always `x * 1.4`).
|
/// `x` is the card width; `y` is the card height (`x * CARD_ASPECT`).
|
||||||
/// All pile positions and fan offsets are derived from this value.
|
/// All pile positions and fan offsets are derived from this value.
|
||||||
pub card_size: Vec2,
|
pub card_size: Vec2,
|
||||||
/// Centre position of each pile, in 2D world coordinates.
|
/// Centre position of each pile, in 2D world coordinates.
|
||||||
@@ -80,7 +84,8 @@ pub struct Layout {
|
|||||||
/// column (13 face-up cards, see [`MAX_TABLEAU_CARDS`]) inside the
|
/// column (13 face-up cards, see [`MAX_TABLEAU_CARDS`]) inside the
|
||||||
/// window with a bottom margin equal to `h_gap`. Limiter on tall/narrow
|
/// window with a bottom margin equal to `h_gap`. Limiter on tall/narrow
|
||||||
/// windows.
|
/// windows.
|
||||||
/// - `card_height = card_width * 1.4`.
|
/// - `card_height = card_width * CARD_ASPECT` (1.4523, matches the
|
||||||
|
/// bundled hayeah card art's natural SVG dimensions).
|
||||||
/// - Horizontal gap `h_gap = card_width / 4.0`.
|
/// - Horizontal gap `h_gap = card_width / 4.0`.
|
||||||
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
|
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
|
||||||
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
|
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub mod layout;
|
|||||||
pub mod onboarding_plugin;
|
pub mod onboarding_plugin;
|
||||||
pub mod pause_plugin;
|
pub mod pause_plugin;
|
||||||
pub mod profile_plugin;
|
pub mod profile_plugin;
|
||||||
|
pub mod radial_menu;
|
||||||
pub mod settings_plugin;
|
pub mod settings_plugin;
|
||||||
pub mod progress_plugin;
|
pub mod progress_plugin;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
@@ -69,8 +70,9 @@ pub use card_animation::{
|
|||||||
FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
|
FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
|
||||||
};
|
};
|
||||||
pub use feedback_anim_plugin::{
|
pub use feedback_anim_plugin::{
|
||||||
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
|
deal_stagger_delay, deal_stagger_secs_for_speed, foundation_flourish_scale, shake_offset,
|
||||||
FeedbackAnimPlugin, SettleAnim, ShakeAnim,
|
settle_scale, FeedbackAnimPlugin, FoundationFlourish, FoundationMarkerFlourish, SettleAnim,
|
||||||
|
ShakeAnim,
|
||||||
};
|
};
|
||||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||||
@@ -82,33 +84,40 @@ pub use font_plugin::{FontPlugin, FontResource};
|
|||||||
pub use cursor_plugin::CursorPlugin;
|
pub use cursor_plugin::CursorPlugin;
|
||||||
pub use events::{
|
pub use events::{
|
||||||
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||||
ForfeitEvent, ForfeitRequestEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent,
|
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
||||||
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
||||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||||
ToggleStatsRequestEvent, UndoRequestEvent, XpAwardedEvent,
|
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
|
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
|
||||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||||
pub use home_plugin::{HomePlugin, HomeScreen};
|
pub use home_plugin::{HomePlugin, HomeScreen};
|
||||||
pub use hud_plugin::{
|
pub use hud_plugin::{
|
||||||
ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton, MenuOption, MenuPopover,
|
streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton,
|
||||||
ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton, UndoButton,
|
MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton,
|
||||||
|
StreakFlourish, UndoButton,
|
||||||
};
|
};
|
||||||
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
||||||
pub use input_plugin::InputPlugin;
|
pub use input_plugin::InputPlugin;
|
||||||
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
||||||
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
|
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
|
||||||
pub use profile_plugin::{ProfilePlugin, ProfileScreen};
|
pub use profile_plugin::{ProfilePlugin, ProfileScreen};
|
||||||
|
pub use radial_menu::{
|
||||||
|
legal_destinations_for_card, radial_anchor_for_index, radial_hovered_index, RadialIcon,
|
||||||
|
RadialMenuPlugin, RightClickRadialState, Z_RADIAL_MENU,
|
||||||
|
};
|
||||||
pub use settings_plugin::{
|
pub use settings_plugin::{
|
||||||
PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen,
|
PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen,
|
||||||
SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
||||||
};
|
};
|
||||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||||
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
|
pub use selection_plugin::{
|
||||||
|
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
||||||
|
};
|
||||||
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
||||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ struct HotkeyRow {
|
|||||||
const HOTKEYS: &[HotkeyRow] = &[
|
const HOTKEYS: &[HotkeyRow] = &[
|
||||||
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
|
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
|
||||||
HotkeyRow { keys: "U", description: "Undo last move" },
|
HotkeyRow { keys: "U", description: "Undo last move" },
|
||||||
|
HotkeyRow { keys: "Tab → Enter", description: "Pick a card; arrows pick where; Enter to drop" },
|
||||||
HotkeyRow { keys: "N", description: "New Classic game" },
|
HotkeyRow { keys: "N", description: "New Classic game" },
|
||||||
|
HotkeyRow { keys: "M", description: "Open Mode Launcher (then 1–5 to pick)" },
|
||||||
HotkeyRow { keys: "S", description: "Stats & progression" },
|
HotkeyRow { keys: "S", description: "Stats & progression" },
|
||||||
HotkeyRow { keys: "A", description: "Achievements" },
|
HotkeyRow { keys: "A", description: "Achievements" },
|
||||||
HotkeyRow { keys: "O", description: "Settings" },
|
HotkeyRow { keys: "O", description: "Settings" },
|
||||||
|
|||||||
@@ -0,0 +1,943 @@
|
|||||||
|
//! Right-click radial menu for power-user quick-drops.
|
||||||
|
//!
|
||||||
|
//! Holding the right mouse button on a face-up draggable card pops up a
|
||||||
|
//! small radial menu of icons, one per legal destination pile, arranged in
|
||||||
|
//! a ring around the cursor. Releasing the button while the cursor is
|
||||||
|
//! over an icon dispatches a [`MoveRequestEvent`] to that destination —
|
||||||
|
//! the player skips the drag entirely. Releasing in empty space, or
|
||||||
|
//! pressing `Esc`, cancels.
|
||||||
|
//!
|
||||||
|
//! # Relationship to [`crate::card_plugin::handle_right_click`]
|
||||||
|
//!
|
||||||
|
//! This plugin **augments** rather than replaces the legacy
|
||||||
|
//! right-click-highlight tint. On the press frame `handle_right_click`
|
||||||
|
//! still tints legal pile markers via [`RightClickHighlight`]; the radial
|
||||||
|
//! overlay sits on top (Z = [`Z_RADIAL_MENU`]) and disappears with the
|
||||||
|
//! release. The two paths read the same legal-destination set, so what
|
||||||
|
//! the radial offers always matches what the highlights show.
|
||||||
|
//!
|
||||||
|
//! # State machine
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! ┌──────────────────┐ RMB press on face-up card
|
||||||
|
//! │ Idle │ ──────────────────────────────────► Active
|
||||||
|
//! └──────────────────┘
|
||||||
|
//! Esc OR RMB release outside any icon
|
||||||
|
//! OR pause / state change
|
||||||
|
//! ┌──────────────────┐ ◄──────────────────────────────────┐
|
||||||
|
//! │ Active │ │
|
||||||
|
//! │ source_pile │ RMB release while hovered_index │
|
||||||
|
//! │ count │ = Some(i) │
|
||||||
|
//! │ cards │ ─── fire MoveRequestEvent ─────────┘
|
||||||
|
//! │ destinations[] │
|
||||||
|
//! │ hovered_index │
|
||||||
|
//! └──────────────────┘
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Tests
|
||||||
|
//!
|
||||||
|
//! Tests live alongside the implementation. The cursor-tracking and
|
||||||
|
//! release-confirm systems take a [`RadialCursorOverride`] resource that
|
||||||
|
//! lets tests inject a world-space cursor position without spinning up a
|
||||||
|
//! real `PrimaryWindow` / camera, since `MinimalPlugins` provides
|
||||||
|
//! neither.
|
||||||
|
|
||||||
|
use bevy::input::ButtonInput;
|
||||||
|
use bevy::math::Vec2;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::PrimaryWindow;
|
||||||
|
use solitaire_core::card::Card;
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
|
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||||
|
|
||||||
|
use crate::card_plugin::{TABLEAU_FACEDOWN_FAN_FRAC, TABLEAU_FAN_FRAC};
|
||||||
|
use crate::events::MoveRequestEvent;
|
||||||
|
use crate::layout::{Layout, LayoutResource};
|
||||||
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::resources::{DragState, GameStateResource};
|
||||||
|
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, STATE_SUCCESS};
|
||||||
|
|
||||||
|
/// Sprite-space `Transform.z` for radial-menu overlay sprites.
|
||||||
|
///
|
||||||
|
/// One rung above [`crate::ui_theme::Z_DROP_OVERLAY`] (`50.0`) so the radial icons render
|
||||||
|
/// in front of any drop-target wash that might still be active from a
|
||||||
|
/// concurrent drag, but well below the lifted card stack at `DRAG_Z`.
|
||||||
|
pub const Z_RADIAL_MENU: f32 = 60.0;
|
||||||
|
|
||||||
|
/// Pixel radius (world space) of the ring on which radial icons are
|
||||||
|
/// placed, measured from the cursor centre.
|
||||||
|
pub const RADIAL_RADIUS_PX: f32 = 80.0;
|
||||||
|
|
||||||
|
/// Side length (world-space pixels) of each radial icon's hit-box.
|
||||||
|
///
|
||||||
|
/// Sprites are rendered at this size; the cursor is considered "over" an
|
||||||
|
/// icon when it lies within the axis-aligned square of this side length
|
||||||
|
/// centred on the icon anchor.
|
||||||
|
pub const RADIAL_ICON_SIZE_PX: f32 = 48.0;
|
||||||
|
|
||||||
|
/// Scale factor applied to the focused (hovered) icon for emphasis.
|
||||||
|
pub const RADIAL_HOVER_SCALE: f32 = 1.15;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State resource
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Right-click radial-menu state machine.
|
||||||
|
///
|
||||||
|
/// `Idle` is the resting state. `Active` is entered when right-mouse is
|
||||||
|
/// just-pressed on a face-up draggable card with at least one legal
|
||||||
|
/// destination; it is exited on right-mouse release, on `Escape`, or on
|
||||||
|
/// any external state change (game mutation, pause).
|
||||||
|
#[derive(Resource, Debug, Default, Clone, PartialEq)]
|
||||||
|
pub enum RightClickRadialState {
|
||||||
|
/// Resting state — the radial is closed and no overlay sprites exist.
|
||||||
|
#[default]
|
||||||
|
Idle,
|
||||||
|
/// Radial is open. The player is holding right-mouse on
|
||||||
|
/// `source_pile` and the cursor is currently over icon
|
||||||
|
/// `hovered_index` (or none).
|
||||||
|
Active {
|
||||||
|
/// Pile the right-clicked card came from.
|
||||||
|
source_pile: PileType,
|
||||||
|
/// Number of cards that would be moved (always `1` — only the
|
||||||
|
/// top face-up card is ever offered for a quick-drop, since the
|
||||||
|
/// radial is built around single-card foundation/tableau
|
||||||
|
/// shortcuts and that matches the right-click highlight set).
|
||||||
|
count: usize,
|
||||||
|
/// Card ids that would be moved (bottom-to-top order). Length
|
||||||
|
/// always equals `count`. Currently always one element.
|
||||||
|
cards: Vec<u32>,
|
||||||
|
/// Pre-computed `(destination, icon_anchor_world_pos)` pairs.
|
||||||
|
///
|
||||||
|
/// Anchors are evenly spaced around a ring of radius
|
||||||
|
/// [`RADIAL_RADIUS_PX`] centred on the press position. A single
|
||||||
|
/// destination is placed directly above the cursor; multiple
|
||||||
|
/// destinations span an arc.
|
||||||
|
legal_destinations: Vec<(PileType, Vec2)>,
|
||||||
|
/// Cursor position (world space) the radial was opened at —
|
||||||
|
/// used as the centre of the ring for cursor-hover hit testing.
|
||||||
|
centre: Vec2,
|
||||||
|
/// Index into `legal_destinations` the cursor is currently
|
||||||
|
/// hovering over, or `None` when the cursor is outside every
|
||||||
|
/// icon's hit-box.
|
||||||
|
hovered_index: Option<usize>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RightClickRadialState {
|
||||||
|
/// Returns `true` when the radial is currently open.
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
matches!(self, Self::Active { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional override resource for tests: when present and `Some`, every
|
||||||
|
/// system that would normally read `Window::cursor_position()` reads this
|
||||||
|
/// world-space coordinate instead.
|
||||||
|
///
|
||||||
|
/// Tests insert this resource so the radial systems can run under
|
||||||
|
/// `MinimalPlugins`, which has no `PrimaryWindow` and no `Camera`.
|
||||||
|
/// Production builds never insert this resource.
|
||||||
|
#[derive(Resource, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct RadialCursorOverride(pub Option<Vec2>);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Visual marker components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker on a radial icon parent entity. Wraps the icon's index into
|
||||||
|
/// [`RightClickRadialState::Active::legal_destinations`] so the
|
||||||
|
/// hover-state system can find the right anchor / pile.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct RadialIcon {
|
||||||
|
/// Index into `RightClickRadialState::Active::legal_destinations`.
|
||||||
|
pub index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on the centre dot drawn at the cursor / source position.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct RadialCentre;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Registers [`RightClickRadialState`] and the systems that drive it.
|
||||||
|
///
|
||||||
|
/// All systems run in the `Update` schedule. `RadialCursorOverride` is
|
||||||
|
/// **not** registered by default — production never needs it; tests
|
||||||
|
/// insert it manually.
|
||||||
|
pub struct RadialMenuPlugin;
|
||||||
|
|
||||||
|
impl Plugin for RadialMenuPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<RightClickRadialState>()
|
||||||
|
// Tests inject `RadialCursorOverride` themselves; production
|
||||||
|
// never touches it. We do not `init_resource` here so the
|
||||||
|
// cursor-from-window path is the default.
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
radial_open_on_right_click,
|
||||||
|
radial_track_cursor,
|
||||||
|
radial_handle_release_or_cancel,
|
||||||
|
radial_redraw_overlay,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure helpers (testable without a Bevy World)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns the world-space anchor for radial icon `index` of `count`,
|
||||||
|
/// arranged on a ring of `radius` centred at `centre`.
|
||||||
|
///
|
||||||
|
/// One destination places the icon directly above the cursor (12 o'clock).
|
||||||
|
/// Multiple destinations spread evenly around a circle, with index 0 at
|
||||||
|
/// 12 o'clock and remaining indices winding clockwise.
|
||||||
|
pub fn radial_anchor_for_index(centre: Vec2, count: usize, index: usize, radius: f32) -> Vec2 {
|
||||||
|
if count == 0 {
|
||||||
|
return centre;
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
// Single destination → straight above the cursor for maximum legibility.
|
||||||
|
return centre + Vec2::new(0.0, radius);
|
||||||
|
}
|
||||||
|
// Spread evenly. Angle is measured from the +Y axis, clockwise, so
|
||||||
|
// index 0 sits at 12 o'clock and increasing indices sweep right.
|
||||||
|
let frac = (index as f32) / (count as f32);
|
||||||
|
let angle = std::f32::consts::TAU * frac;
|
||||||
|
Vec2::new(centre.x + radius * angle.sin(), centre.y + radius * angle.cos())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `(hit?, index)` — whether `cursor` falls within any icon's
|
||||||
|
/// hit-box, and if so the index of the first match. Hit-boxes are
|
||||||
|
/// axis-aligned squares of side [`RADIAL_ICON_SIZE_PX`] centred on each
|
||||||
|
/// anchor. If multiple icons overlap (impossible at the default radius +
|
||||||
|
/// icon size combination, but defensively checked) the lowest index wins.
|
||||||
|
pub fn radial_hovered_index(cursor: Vec2, anchors: &[Vec2]) -> Option<usize> {
|
||||||
|
let half = RADIAL_ICON_SIZE_PX / 2.0;
|
||||||
|
for (i, anchor) in anchors.iter().enumerate() {
|
||||||
|
if (cursor.x - anchor.x).abs() <= half && (cursor.y - anchor.y).abs() <= half {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the legal destination piles for moving `card` from
|
||||||
|
/// `source_pile` in `game`.
|
||||||
|
///
|
||||||
|
/// Mirrors [`crate::card_plugin::handle_right_click`]'s decision logic
|
||||||
|
/// exactly — only foundations that legally accept the card and tableaus
|
||||||
|
/// that legally accept the card. The source pile is excluded because
|
||||||
|
/// dropping a card on its own pile is a no-op.
|
||||||
|
pub fn legal_destinations_for_card(
|
||||||
|
card: &Card,
|
||||||
|
source_pile: &PileType,
|
||||||
|
game: &GameState,
|
||||||
|
) -> Vec<PileType> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
let dest = PileType::Foundation(slot);
|
||||||
|
if dest == *source_pile {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(pile) = game.piles.get(&dest)
|
||||||
|
&& can_place_on_foundation(card, pile)
|
||||||
|
{
|
||||||
|
out.push(dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i in 0..7_usize {
|
||||||
|
let dest = PileType::Tableau(i);
|
||||||
|
if dest == *source_pile {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(pile) = game.piles.get(&dest)
|
||||||
|
&& can_place_on_tableau(card, pile)
|
||||||
|
{
|
||||||
|
out.push(dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the topmost face-up draggable card under `cursor` (world
|
||||||
|
/// space) along with its source pile.
|
||||||
|
///
|
||||||
|
/// Reuses the same "topmost face-up card" semantics as
|
||||||
|
/// [`crate::card_plugin::handle_right_click`]: tableau columns offer
|
||||||
|
/// every face-up card, waste / foundations offer only their top card,
|
||||||
|
/// and stock is never draggable. Returns `None` for face-down cards,
|
||||||
|
/// empty piles, or clicks in dead space.
|
||||||
|
pub fn find_top_face_up_card_at(
|
||||||
|
cursor: Vec2,
|
||||||
|
game: &GameState,
|
||||||
|
layout: &Layout,
|
||||||
|
) -> Option<(PileType, Card)> {
|
||||||
|
let piles = [
|
||||||
|
PileType::Waste,
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
for pile in piles {
|
||||||
|
let Some(pile_cards) = game.piles.get(&pile) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if pile_cards.cards.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let is_tableau = matches!(pile, PileType::Tableau(_));
|
||||||
|
for i in (0..pile_cards.cards.len()).rev() {
|
||||||
|
let card = &pile_cards.cards[i];
|
||||||
|
if !card.face_up {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Only the top card is draggable on non-tableau piles.
|
||||||
|
if !is_tableau && i != pile_cards.cards.len() - 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pos = card_position(game, layout, &pile, i);
|
||||||
|
let half = layout.card_size / 2.0;
|
||||||
|
if cursor.x < pos.x - half.x
|
||||||
|
|| cursor.x > pos.x + half.x
|
||||||
|
|| cursor.y < pos.y - half.y
|
||||||
|
|| cursor.y > pos.y + half.y
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Some((pile, card.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirror of `input_plugin::card_position` — kept private to this
|
||||||
|
/// module so the radial's hit-test geometry tracks renderer geometry
|
||||||
|
/// without depending on `input_plugin` internals.
|
||||||
|
fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
|
||||||
|
let base = layout.pile_positions[pile];
|
||||||
|
if matches!(pile, PileType::Tableau(_)) {
|
||||||
|
let mut y_offset = 0.0_f32;
|
||||||
|
if let Some(pile_cards) = game.piles.get(pile) {
|
||||||
|
for card in pile_cards.cards.iter().take(stack_index) {
|
||||||
|
let step = if card.face_up {
|
||||||
|
TABLEAU_FAN_FRAC
|
||||||
|
} else {
|
||||||
|
TABLEAU_FACEDOWN_FAN_FRAC
|
||||||
|
};
|
||||||
|
y_offset -= layout.card_size.y * step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Vec2::new(base.x, base.y + y_offset)
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
||||||
|
fn build_radial_destinations(centre: Vec2, dests: Vec<PileType>) -> Vec<(PileType, Vec2)> {
|
||||||
|
let count = dests.len();
|
||||||
|
dests
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, d)| (d, radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cursor lookup — uses an override resource under MinimalPlugins, falls
|
||||||
|
// back to the real Window/Camera otherwise.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns the world-space cursor position. Prefers
|
||||||
|
/// [`RadialCursorOverride`] when present (test injection); otherwise
|
||||||
|
/// reads the primary window's cursor position and projects it through
|
||||||
|
/// the camera.
|
||||||
|
fn cursor_world(
|
||||||
|
override_res: Option<&Res<RadialCursorOverride>>,
|
||||||
|
windows: &Query<&Window, With<PrimaryWindow>>,
|
||||||
|
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||||
|
) -> Option<Vec2> {
|
||||||
|
if let Some(ovr) = override_res
|
||||||
|
&& let Some(pos) = ovr.0
|
||||||
|
{
|
||||||
|
return Some(pos);
|
||||||
|
}
|
||||||
|
let window = windows.single().ok()?;
|
||||||
|
let cursor = window.cursor_position()?;
|
||||||
|
let (camera, camera_transform) = cameras.single().ok()?;
|
||||||
|
camera.viewport_to_world_2d(camera_transform, cursor).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// On `MouseButton::Right` `just_pressed`, attempts to open the radial
|
||||||
|
/// menu over the card the cursor is on. Skips when a left-mouse drag is
|
||||||
|
/// in progress, when the game is paused, or when the clicked card has no
|
||||||
|
/// legal destinations.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn radial_open_on_right_click(
|
||||||
|
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
drag: Res<DragState>,
|
||||||
|
cursor_override: Option<Res<RadialCursorOverride>>,
|
||||||
|
windows: Query<&Window, With<PrimaryWindow>>,
|
||||||
|
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut state: ResMut<RightClickRadialState>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !drag.is_idle() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(buttons) = buttons else { return };
|
||||||
|
if !buttons.just_pressed(MouseButton::Right) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if state.is_active() {
|
||||||
|
// Already active — ignore re-presses.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
let Some(game) = game else { return };
|
||||||
|
let Some(world) = cursor_world(cursor_override.as_ref(), &windows, &cameras) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some((source_pile, card)) = find_top_face_up_card_at(world, &game.0, &layout.0) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only single-card right-click for now: foundations require single
|
||||||
|
// cards and the highlight tint shows the same set the radial offers.
|
||||||
|
let dests = legal_destinations_for_card(&card, &source_pile, &game.0);
|
||||||
|
if dests.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let legal_destinations = build_radial_destinations(world, dests);
|
||||||
|
|
||||||
|
*state = RightClickRadialState::Active {
|
||||||
|
source_pile,
|
||||||
|
count: 1,
|
||||||
|
cards: vec![card.id],
|
||||||
|
legal_destinations,
|
||||||
|
centre: world,
|
||||||
|
hovered_index: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each frame while `Active`, updates `hovered_index` based on the
|
||||||
|
/// current cursor position. Cheap — just re-runs hit-testing against
|
||||||
|
/// the precomputed anchors. The overlay redraw system reads this index
|
||||||
|
/// to apply the focused tint and scale.
|
||||||
|
fn radial_track_cursor(
|
||||||
|
cursor_override: Option<Res<RadialCursorOverride>>,
|
||||||
|
windows: Query<&Window, With<PrimaryWindow>>,
|
||||||
|
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||||
|
mut state: ResMut<RightClickRadialState>,
|
||||||
|
) {
|
||||||
|
let RightClickRadialState::Active {
|
||||||
|
legal_destinations,
|
||||||
|
hovered_index,
|
||||||
|
..
|
||||||
|
} = state.as_mut()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(world) = cursor_world(cursor_override.as_ref(), &windows, &cameras) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let anchors: Vec<Vec2> = legal_destinations.iter().map(|(_, a)| *a).collect();
|
||||||
|
*hovered_index = radial_hovered_index(world, &anchors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles three exit conditions while `Active`:
|
||||||
|
/// 1. Right-mouse release → confirm if hovering, otherwise cancel.
|
||||||
|
/// 2. `Escape` → cancel.
|
||||||
|
/// 3. Left-mouse press → cancel (keeps the existing drag pipeline clean).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn radial_handle_release_or_cancel(
|
||||||
|
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
||||||
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
|
mut state: ResMut<RightClickRadialState>,
|
||||||
|
mut moves: MessageWriter<MoveRequestEvent>,
|
||||||
|
) {
|
||||||
|
if !state.is_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let escape_pressed = keys
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|k| k.just_pressed(KeyCode::Escape));
|
||||||
|
let right_released = buttons
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|b| b.just_released(MouseButton::Right));
|
||||||
|
let left_pressed = buttons
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|b| b.just_pressed(MouseButton::Left));
|
||||||
|
|
||||||
|
if !escape_pressed && !right_released && !left_pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On confirm, fire a MoveRequestEvent. On any other exit, just clear.
|
||||||
|
if right_released
|
||||||
|
&& let RightClickRadialState::Active {
|
||||||
|
source_pile,
|
||||||
|
count,
|
||||||
|
legal_destinations,
|
||||||
|
hovered_index: Some(idx),
|
||||||
|
..
|
||||||
|
} = state.as_ref()
|
||||||
|
&& let Some((dest, _)) = legal_destinations.get(*idx)
|
||||||
|
{
|
||||||
|
moves.write(MoveRequestEvent {
|
||||||
|
from: source_pile.clone(),
|
||||||
|
to: dest.clone(),
|
||||||
|
count: *count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
*state = RightClickRadialState::Idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Visual overlay — spawns / despawns sprites in step with the state.
|
||||||
|
//
|
||||||
|
// Strategy: on every frame, despawn ALL prior overlay entities and
|
||||||
|
// respawn the current snapshot. Cheap (≤ 11 sprites + a centre dot) and
|
||||||
|
// keeps the overlay always perfectly in sync without component
|
||||||
|
// bookkeeping. Skipped in tests because `MinimalPlugins` does not
|
||||||
|
// register `Sprite` rendering anyway and the state-machine assertions
|
||||||
|
// don't rely on entity existence.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Despawns and respawns the radial overlay sprites every frame the
|
||||||
|
/// state is `Active`; despawns them when the state returns to `Idle`.
|
||||||
|
fn radial_redraw_overlay(
|
||||||
|
state: Res<RightClickRadialState>,
|
||||||
|
mut commands: Commands,
|
||||||
|
existing_icons: Query<Entity, With<RadialIcon>>,
|
||||||
|
existing_centres: Query<Entity, With<RadialCentre>>,
|
||||||
|
) {
|
||||||
|
// Always clear last-frame overlay entities first.
|
||||||
|
for e in &existing_icons {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
for e in &existing_centres {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
let RightClickRadialState::Active {
|
||||||
|
legal_destinations,
|
||||||
|
hovered_index,
|
||||||
|
centre,
|
||||||
|
..
|
||||||
|
} = state.as_ref()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Centre dot — small bright marker so the player can see where the
|
||||||
|
// ring is anchored even when the cursor moves.
|
||||||
|
commands.spawn((
|
||||||
|
RadialCentre,
|
||||||
|
Sprite {
|
||||||
|
color: ACCENT_PRIMARY,
|
||||||
|
custom_size: Some(Vec2::splat(8.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_xyz(centre.x, centre.y, Z_RADIAL_MENU + 0.01),
|
||||||
|
));
|
||||||
|
|
||||||
|
for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() {
|
||||||
|
let focused = *hovered_index == Some(i);
|
||||||
|
let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 };
|
||||||
|
let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY };
|
||||||
|
// Hovered icon gets a strong yellow rim; resting icons get a
|
||||||
|
// muted purple rim so the focused one reads as the obvious target.
|
||||||
|
let outline = if focused { BORDER_STRONG } else { BORDER_SUBTLE };
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
RadialIcon { index: i },
|
||||||
|
Sprite {
|
||||||
|
color: fill,
|
||||||
|
custom_size: Some(Vec2::splat(RADIAL_ICON_SIZE_PX)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform {
|
||||||
|
translation: Vec3::new(anchor.x, anchor.y, Z_RADIAL_MENU),
|
||||||
|
scale: Vec3::splat(scale),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|p| {
|
||||||
|
// Outline ring — drawn as a slightly larger sprite
|
||||||
|
// behind the fill so it reads as a halo, not a stroke.
|
||||||
|
p.spawn((
|
||||||
|
Sprite {
|
||||||
|
color: outline,
|
||||||
|
custom_size: Some(Vec2::splat(RADIAL_ICON_SIZE_PX + 4.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_xyz(0.0, 0.0, -0.01),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::layout::compute_layout;
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
use solitaire_core::card::{Card as CoreCard, Rank, Suit};
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
|
||||||
|
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and the
|
||||||
|
/// resources / messages it depends on. No window, no camera — the
|
||||||
|
/// `RadialCursorOverride` resource feeds the cursor position.
|
||||||
|
fn radial_test_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<MoveRequestEvent>();
|
||||||
|
app.init_resource::<DragState>();
|
||||||
|
app.init_resource::<ButtonInput<MouseButton>>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.init_resource::<RadialCursorOverride>();
|
||||||
|
app.add_plugins(RadialMenuPlugin);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic single-card board: Ace of Clubs on Tableau(0),
|
||||||
|
/// every other pile empty. The Ace has exactly one legal
|
||||||
|
/// destination — Foundation(0) — under the standard rules
|
||||||
|
/// (`can_place_on_foundation` accepts the Ace on an empty foundation).
|
||||||
|
fn ace_only_state() -> GameState {
|
||||||
|
let mut g = GameState::new(0, DrawMode::DrawOne);
|
||||||
|
// Wipe everything.
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
g.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
for i in 0..7_usize {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
// Ace of Clubs on Tableau(0).
|
||||||
|
g.piles
|
||||||
|
.get_mut(&PileType::Tableau(0))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.push(CoreCard {
|
||||||
|
id: 100,
|
||||||
|
suit: Suit::Clubs,
|
||||||
|
rank: Rank::Ace,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Place a face-down King on Tableau(0). `find_top_face_up_card_at`
|
||||||
|
/// must skip it.
|
||||||
|
fn face_down_only_state() -> GameState {
|
||||||
|
let mut g = GameState::new(0, DrawMode::DrawOne);
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
g.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
for i in 0..7_usize {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
g.piles
|
||||||
|
.get_mut(&PileType::Tableau(0))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.push(CoreCard {
|
||||||
|
id: 100,
|
||||||
|
suit: Suit::Spades,
|
||||||
|
rank: Rank::King,
|
||||||
|
face_up: false,
|
||||||
|
});
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) {
|
||||||
|
app.insert_resource(GameStateResource(state));
|
||||||
|
app.insert_resource(LayoutResource(compute_layout(layout_window)));
|
||||||
|
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(app: &mut App, button: MouseButton) {
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<MouseButton>>()
|
||||||
|
.press(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release(app: &mut App, button: MouseButton) {
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<MouseButton>>()
|
||||||
|
.release(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_buttons(app: &mut App) {
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<MouseButton>>()
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_move_events(app: &mut App) -> Vec<MoveRequestEvent> {
|
||||||
|
let events = app.world().resource::<Messages<MoveRequestEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
cursor.read(events).cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Pure-function tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radial_anchor_single_destination_above_centre() {
|
||||||
|
let centre = Vec2::new(100.0, 200.0);
|
||||||
|
let pos = radial_anchor_for_index(centre, 1, 0, 80.0);
|
||||||
|
// Single destination → straight above (centre + (0, radius)).
|
||||||
|
assert!((pos.x - 100.0).abs() < 1e-3);
|
||||||
|
assert!((pos.y - 280.0).abs() < 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radial_anchor_two_destinations_first_above_second_below() {
|
||||||
|
let centre = Vec2::ZERO;
|
||||||
|
let radius = 50.0;
|
||||||
|
let p0 = radial_anchor_for_index(centre, 2, 0, radius);
|
||||||
|
let p1 = radial_anchor_for_index(centre, 2, 1, radius);
|
||||||
|
// index 0 is at 12 o'clock; index 1 is the opposite side.
|
||||||
|
assert!(p0.y > p1.y);
|
||||||
|
assert!(p0.x.abs() < 1e-3);
|
||||||
|
assert!(p1.x.abs() < 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radial_anchor_zero_count_returns_centre() {
|
||||||
|
let centre = Vec2::new(7.0, -3.0);
|
||||||
|
assert_eq!(radial_anchor_for_index(centre, 0, 0, 80.0), centre);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radial_hovered_index_inside_box_returns_index() {
|
||||||
|
let anchors = vec![Vec2::new(100.0, 0.0), Vec2::new(0.0, 100.0)];
|
||||||
|
// Cursor squarely inside icon 1's box.
|
||||||
|
assert_eq!(radial_hovered_index(Vec2::new(0.0, 100.0), &anchors), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radial_hovered_index_outside_returns_none() {
|
||||||
|
let anchors = vec![Vec2::new(100.0, 0.0), Vec2::new(0.0, 100.0)];
|
||||||
|
assert_eq!(radial_hovered_index(Vec2::new(500.0, 500.0), &anchors), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legal_destinations_for_ace_includes_only_first_empty_foundation() {
|
||||||
|
let g = ace_only_state();
|
||||||
|
let card = CoreCard {
|
||||||
|
id: 100,
|
||||||
|
suit: Suit::Clubs,
|
||||||
|
rank: Rank::Ace,
|
||||||
|
face_up: true,
|
||||||
|
};
|
||||||
|
let dests = legal_destinations_for_card(&card, &PileType::Tableau(0), &g);
|
||||||
|
// Ace can be placed on every empty foundation. We only need
|
||||||
|
// the count to be ≥ 1 and the source pile to be excluded.
|
||||||
|
assert!(!dests.is_empty(), "Ace must have at least one legal destination");
|
||||||
|
assert!(!dests.contains(&PileType::Tableau(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legal_destinations_excludes_source_pile() {
|
||||||
|
let g = ace_only_state();
|
||||||
|
let card = CoreCard {
|
||||||
|
id: 100,
|
||||||
|
suit: Suit::Clubs,
|
||||||
|
rank: Rank::Ace,
|
||||||
|
face_up: true,
|
||||||
|
};
|
||||||
|
let dests = legal_destinations_for_card(&card, &PileType::Foundation(0), &g);
|
||||||
|
assert!(!dests.contains(&PileType::Foundation(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// System-level tests (state machine + event firing)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pressing right-click on a face-up card with at least one legal
|
||||||
|
/// destination must transition the state to `Active` carrying the
|
||||||
|
/// expected source / count / legal-destination set.
|
||||||
|
#[test]
|
||||||
|
fn right_click_press_on_face_up_card_opens_radial() {
|
||||||
|
let mut app = radial_test_app();
|
||||||
|
let layout_window = Vec2::new(1280.0, 800.0);
|
||||||
|
let layout = compute_layout(layout_window);
|
||||||
|
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||||
|
|
||||||
|
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||||
|
// Initial state — Idle.
|
||||||
|
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
|
||||||
|
|
||||||
|
press(&mut app, MouseButton::Right);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let state = app.world().resource::<RightClickRadialState>().clone();
|
||||||
|
match state {
|
||||||
|
RightClickRadialState::Active {
|
||||||
|
source_pile,
|
||||||
|
count,
|
||||||
|
cards,
|
||||||
|
legal_destinations,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(source_pile, PileType::Tableau(0));
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
assert_eq!(cards, vec![100]);
|
||||||
|
assert!(!legal_destinations.is_empty());
|
||||||
|
assert!(legal_destinations
|
||||||
|
.iter()
|
||||||
|
.any(|(p, _)| matches!(p, PileType::Foundation(_))));
|
||||||
|
}
|
||||||
|
other => panic!("expected Active, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releasing the right button while the cursor is over a destination
|
||||||
|
/// icon must fire a `MoveRequestEvent` and return the state to Idle.
|
||||||
|
#[test]
|
||||||
|
fn right_click_release_over_destination_fires_move_request() {
|
||||||
|
let mut app = radial_test_app();
|
||||||
|
let layout_window = Vec2::new(1280.0, 800.0);
|
||||||
|
let layout = compute_layout(layout_window);
|
||||||
|
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||||
|
|
||||||
|
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||||
|
press(&mut app, MouseButton::Right);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Capture the destination chosen — pull anchor[0] from the state.
|
||||||
|
let (dest_pile, anchor) = match app.world().resource::<RightClickRadialState>() {
|
||||||
|
RightClickRadialState::Active { legal_destinations, .. } => legal_destinations[0].clone(),
|
||||||
|
_ => panic!("expected Active"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move the cursor onto that anchor and release.
|
||||||
|
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(anchor);
|
||||||
|
// Need a track-cursor pass first so hovered_index updates.
|
||||||
|
app.update();
|
||||||
|
// Then release.
|
||||||
|
clear_buttons(&mut app);
|
||||||
|
release(&mut app, MouseButton::Right);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Move event must have fired.
|
||||||
|
let events = collect_move_events(&mut app);
|
||||||
|
assert_eq!(events.len(), 1, "exactly one MoveRequestEvent expected");
|
||||||
|
let evt = &events[0];
|
||||||
|
assert_eq!(evt.from, PileType::Tableau(0));
|
||||||
|
assert_eq!(evt.to, dest_pile);
|
||||||
|
assert_eq!(evt.count, 1);
|
||||||
|
// State must return to Idle.
|
||||||
|
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releasing the right button far from any icon must clear state
|
||||||
|
/// without firing any MoveRequestEvent.
|
||||||
|
#[test]
|
||||||
|
fn right_click_release_outside_any_destination_cancels() {
|
||||||
|
let mut app = radial_test_app();
|
||||||
|
let layout_window = Vec2::new(1280.0, 800.0);
|
||||||
|
let layout = compute_layout(layout_window);
|
||||||
|
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||||
|
|
||||||
|
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||||
|
press(&mut app, MouseButton::Right);
|
||||||
|
app.update();
|
||||||
|
assert!(app.world().resource::<RightClickRadialState>().is_active());
|
||||||
|
|
||||||
|
// Move cursor far away — well outside every icon's hit-box.
|
||||||
|
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(Vec2::new(10_000.0, 10_000.0));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
clear_buttons(&mut app);
|
||||||
|
release(&mut app, MouseButton::Right);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = collect_move_events(&mut app);
|
||||||
|
assert!(events.is_empty(), "no MoveRequestEvent on outside-release");
|
||||||
|
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pressing Escape while the radial is active must cancel cleanly,
|
||||||
|
/// without firing any MoveRequestEvent.
|
||||||
|
#[test]
|
||||||
|
fn escape_cancels_active_radial() {
|
||||||
|
let mut app = radial_test_app();
|
||||||
|
let layout_window = Vec2::new(1280.0, 800.0);
|
||||||
|
let layout = compute_layout(layout_window);
|
||||||
|
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||||
|
|
||||||
|
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||||
|
press(&mut app, MouseButton::Right);
|
||||||
|
app.update();
|
||||||
|
assert!(app.world().resource::<RightClickRadialState>().is_active());
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::Escape);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = collect_move_events(&mut app);
|
||||||
|
assert!(events.is_empty(), "no MoveRequestEvent on Escape cancel");
|
||||||
|
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Right-clicking on a face-down card must NOT open the radial.
|
||||||
|
#[test]
|
||||||
|
fn right_click_on_face_down_card_does_not_open_radial() {
|
||||||
|
let mut app = radial_test_app();
|
||||||
|
let layout_window = Vec2::new(1280.0, 800.0);
|
||||||
|
let layout = compute_layout(layout_window);
|
||||||
|
let king_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||||
|
|
||||||
|
install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
|
||||||
|
press(&mut app, MouseButton::Right);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*app.world().resource::<RightClickRadialState>(),
|
||||||
|
RightClickRadialState::Idle,
|
||||||
|
"face-down cards must not open the radial"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ use bevy::window::{WindowMoved, WindowResized};
|
|||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
|
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
|
||||||
WindowGeometry,
|
WindowGeometry, TOOLTIP_DELAY_STEP_SECS,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||||||
@@ -122,6 +122,10 @@ struct BackgroundText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct ColorBlindText;
|
struct ColorBlindText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the live tooltip-delay value.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct TooltipDelayText;
|
||||||
|
|
||||||
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct SettingsPanelScrollable;
|
struct SettingsPanelScrollable;
|
||||||
@@ -139,6 +143,10 @@ enum SettingsButton {
|
|||||||
MusicUp,
|
MusicUp,
|
||||||
ToggleDrawMode,
|
ToggleDrawMode,
|
||||||
CycleAnimSpeed,
|
CycleAnimSpeed,
|
||||||
|
/// Decrement the tooltip-hover dwell delay by one step.
|
||||||
|
TooltipDelayDown,
|
||||||
|
/// Increment the tooltip-hover dwell delay by one step.
|
||||||
|
TooltipDelayUp,
|
||||||
ToggleTheme,
|
ToggleTheme,
|
||||||
ToggleColorBlind,
|
ToggleColorBlind,
|
||||||
SyncNow,
|
SyncNow,
|
||||||
@@ -169,6 +177,8 @@ impl SettingsButton {
|
|||||||
// Gameplay section
|
// Gameplay section
|
||||||
SettingsButton::ToggleDrawMode => 30,
|
SettingsButton::ToggleDrawMode => 30,
|
||||||
SettingsButton::CycleAnimSpeed => 40,
|
SettingsButton::CycleAnimSpeed => 40,
|
||||||
|
SettingsButton::TooltipDelayDown => 45,
|
||||||
|
SettingsButton::TooltipDelayUp => 46,
|
||||||
// Cosmetic section
|
// Cosmetic section
|
||||||
SettingsButton::ToggleTheme => 50,
|
SettingsButton::ToggleTheme => 50,
|
||||||
SettingsButton::ToggleColorBlind => 60,
|
SettingsButton::ToggleColorBlind => 60,
|
||||||
@@ -258,6 +268,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_background_text,
|
update_background_text,
|
||||||
update_anim_speed_text,
|
update_anim_speed_text,
|
||||||
update_color_blind_text,
|
update_color_blind_text,
|
||||||
|
update_tooltip_delay_text,
|
||||||
attach_focusable_to_settings_buttons,
|
attach_focusable_to_settings_buttons,
|
||||||
scroll_focus_into_view,
|
scroll_focus_into_view,
|
||||||
),
|
),
|
||||||
@@ -359,6 +370,7 @@ fn sync_settings_panel_visibility(
|
|||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||||
|
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||||
) {
|
) {
|
||||||
if !screen.is_changed() {
|
if !screen.is_changed() {
|
||||||
return;
|
return;
|
||||||
@@ -385,6 +397,16 @@ fn sync_settings_panel_visibility(
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
// The active card-art theme can supply its own back image —
|
||||||
|
// see `card_plugin::CardImageSet::theme_back`. When that is
|
||||||
|
// populated the legacy "Card Back" picker has no visible
|
||||||
|
// effect, so we render it muted with an explanatory caption
|
||||||
|
// rather than letting the player click swatches that do
|
||||||
|
// nothing. Absent under `MinimalPlugins`; treated as
|
||||||
|
// "no override" in that case.
|
||||||
|
let theme_overrides_back = card_images
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|cs| cs.theme_back.is_some());
|
||||||
spawn_settings_panel(
|
spawn_settings_panel(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
&settings.0,
|
&settings.0,
|
||||||
@@ -394,6 +416,7 @@ fn sync_settings_panel_visibility(
|
|||||||
&themes,
|
&themes,
|
||||||
scroll_pos.0,
|
scroll_pos.0,
|
||||||
font_res.as_deref(),
|
font_res.as_deref(),
|
||||||
|
theme_overrides_back,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -483,6 +506,21 @@ fn update_color_blind_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live tooltip-delay value in the Gameplay section
|
||||||
|
/// whenever `SettingsResource` changes (slider buttons, hand-edited
|
||||||
|
/// settings.json reload, etc.).
|
||||||
|
fn update_tooltip_delay_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<TooltipDelayText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = tooltip_delay_label(settings.0.tooltip_delay_secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn card_back_label(idx: usize) -> String {
|
fn card_back_label(idx: usize) -> String {
|
||||||
if idx == 0 {
|
if idx == 0 {
|
||||||
"Default".to_string()
|
"Default".to_string()
|
||||||
@@ -606,6 +644,24 @@ fn handle_settings_buttons(
|
|||||||
**t = anim_speed_label(&settings.0.animation_speed);
|
**t = anim_speed_label(&settings.0.animation_speed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SettingsButton::TooltipDelayDown => {
|
||||||
|
let before = settings.0.tooltip_delay_secs;
|
||||||
|
let after = settings.0.adjust_tooltip_delay(-TOOLTIP_DELAY_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by `update_tooltip_delay_text`
|
||||||
|
// on the next frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TooltipDelayUp => {
|
||||||
|
let before = settings.0.tooltip_delay_secs;
|
||||||
|
let after = settings.0.adjust_tooltip_delay(TOOLTIP_DELAY_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
SettingsButton::ToggleTheme => {
|
SettingsButton::ToggleTheme => {
|
||||||
settings.0.theme = match settings.0.theme {
|
settings.0.theme = match settings.0.theme {
|
||||||
Theme::Green => Theme::Blue,
|
Theme::Green => Theme::Blue,
|
||||||
@@ -680,6 +736,17 @@ fn color_blind_label(enabled: bool) -> String {
|
|||||||
if enabled { "ON".into() } else { "OFF".into() }
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats the tooltip-hover delay for display in the Settings panel.
|
||||||
|
/// `0.0` reads as `"Instant"` so the zero-delay case has a name; any
|
||||||
|
/// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`).
|
||||||
|
fn tooltip_delay_label(secs: f32) -> String {
|
||||||
|
if secs <= 0.0 {
|
||||||
|
"Instant".into()
|
||||||
|
} else {
|
||||||
|
format!("{secs:.1} s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
||||||
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
||||||
/// background pickers), and the "Sync Now" button. The "Done" button is
|
/// background pickers), and the "Sync Now" button. The "Done" button is
|
||||||
@@ -928,6 +995,14 @@ fn persist_window_geometry_after_debounce(
|
|||||||
// UI construction
|
// UI construction
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Spawns the Settings modal.
|
||||||
|
///
|
||||||
|
/// `theme_overrides_back` is `true` when the active card-art theme
|
||||||
|
/// supplies its own back (`CardImageSet::theme_back == Some(_)`). The
|
||||||
|
/// "Card Back" picker is rendered with a small caption and the
|
||||||
|
/// swatches are hidden in this state — the theme's back wins
|
||||||
|
/// regardless of which legacy back is selected, so the picker would
|
||||||
|
/// be inert otherwise.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn spawn_settings_panel(
|
fn spawn_settings_panel(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
@@ -938,6 +1013,7 @@ fn spawn_settings_panel(
|
|||||||
themes: &[(String, String)],
|
themes: &[(String, String)],
|
||||||
scroll_offset: f32,
|
scroll_offset: f32,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
|
theme_overrides_back: bool,
|
||||||
) {
|
) {
|
||||||
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
||||||
spawn_modal_header(card, "Settings", font_res);
|
spawn_modal_header(card, "Settings", font_res);
|
||||||
@@ -1003,6 +1079,11 @@ fn spawn_settings_panel(
|
|||||||
"Cycle animation speed: Normal, Fast, Instant.",
|
"Cycle animation speed: Normal, Fast, Instant.",
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
|
tooltip_delay_row(
|
||||||
|
body,
|
||||||
|
settings.tooltip_delay_secs,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
|
||||||
// --- Cosmetic ---
|
// --- Cosmetic ---
|
||||||
section_label(body, "Cosmetic", font_res);
|
section_label(body, "Cosmetic", font_res);
|
||||||
@@ -1024,15 +1105,26 @@ fn spawn_settings_panel(
|
|||||||
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
picker_row(
|
if theme_overrides_back {
|
||||||
body,
|
// The active theme provides its own back; the legacy
|
||||||
"Card Back",
|
// picker has no visible effect, so we replace its
|
||||||
unlocked_card_backs,
|
// swatch row with an informational caption. The
|
||||||
settings.selected_card_back,
|
// player's `selected_card_back` value still
|
||||||
SettingsButton::SelectCardBack,
|
// round-trips through `settings.json` — the moment
|
||||||
"Choose your deck art. New backs unlock at higher levels.",
|
// they switch to a theme without a back, the picker
|
||||||
font_res,
|
// re-appears with their previous choice intact.
|
||||||
);
|
picker_row_overridden_by_theme(body, "Card Back", font_res);
|
||||||
|
} else {
|
||||||
|
picker_row(
|
||||||
|
body,
|
||||||
|
"Card Back",
|
||||||
|
unlocked_card_backs,
|
||||||
|
settings.selected_card_back,
|
||||||
|
SettingsButton::SelectCardBack,
|
||||||
|
"Choose your deck art. New backs unlock at higher levels.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
}
|
||||||
picker_row(
|
picker_row(
|
||||||
body,
|
body,
|
||||||
"Background",
|
"Background",
|
||||||
@@ -1129,6 +1221,53 @@ fn volume_row<Marker: Component>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Tooltip Delay 0.5 s [−] [+]` — slider row for the player-tunable
|
||||||
|
/// tooltip-hover dwell. Mirrors [`volume_row`] (label, current value,
|
||||||
|
/// decrement, increment) but formats the value via [`tooltip_delay_label`]
|
||||||
|
/// so `0.0` reads as `"Instant"` and other values as `"{n:.1} s"`.
|
||||||
|
fn tooltip_delay_row(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
value_secs: f32,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let label_font = label_text_font(font_res);
|
||||||
|
let value_font = value_text_font(font_res);
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new("Tooltip Delay".to_string()),
|
||||||
|
label_font,
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
row.spawn((
|
||||||
|
TooltipDelayText,
|
||||||
|
Text::new(tooltip_delay_label(value_secs)),
|
||||||
|
value_font,
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
icon_button(
|
||||||
|
row,
|
||||||
|
"−",
|
||||||
|
SettingsButton::TooltipDelayDown,
|
||||||
|
"Shorten the hover delay before tooltips appear.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
icon_button(
|
||||||
|
row,
|
||||||
|
"+",
|
||||||
|
SettingsButton::TooltipDelayUp,
|
||||||
|
"Lengthen the hover delay before tooltips appear.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
|
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
|
||||||
/// anim speed, colour-blind).
|
/// anim speed, colour-blind).
|
||||||
///
|
///
|
||||||
@@ -1239,6 +1378,54 @@ fn picker_row(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marker on the row spawned by [`picker_row_overridden_by_theme`] so
|
||||||
|
/// tests can find the caption without depending on text-content
|
||||||
|
/// matching.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub(crate) struct CardBackPickerOverriddenByTheme;
|
||||||
|
|
||||||
|
/// Renders the "Card Back" row in its overridden-by-theme state: a
|
||||||
|
/// labelled caption explaining why the swatches are hidden, with no
|
||||||
|
/// interactive children. This is what the player sees when the active
|
||||||
|
/// card-art theme supplies its own `back.svg` — the theme's back wins
|
||||||
|
/// over the legacy `selected_card_back` choice, so showing the
|
||||||
|
/// swatches would only confuse the player into thinking they were
|
||||||
|
/// changing something when they weren't.
|
||||||
|
fn picker_row_overridden_by_theme(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
label: &str,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let label_font = label_text_font(font_res);
|
||||||
|
let caption_font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
parent
|
||||||
|
.spawn((
|
||||||
|
CardBackPickerOverriddenByTheme,
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new(label.to_string()),
|
||||||
|
label_font,
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
row.spawn((
|
||||||
|
Text::new("Active theme provides its own back"),
|
||||||
|
caption_font,
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Picker row for card-art themes. Distinct from [`picker_row`]
|
/// Picker row for card-art themes. Distinct from [`picker_row`]
|
||||||
/// because themes are identified by `String` ids (matching
|
/// because themes are identified by `String` ids (matching
|
||||||
/// `ThemeMeta::id`) instead of dense indices, and each chip carries
|
/// `ThemeMeta::id`) instead of dense indices, and each chip carries
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use crate::auto_complete_plugin::AutoCompleteState;
|
|||||||
use crate::challenge_plugin::challenge_progress_label;
|
use crate::challenge_plugin::challenge_progress_label;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, ToggleStatsRequestEvent,
|
ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, ToggleStatsRequestEvent,
|
||||||
|
WinStreakMilestoneEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
@@ -29,9 +30,9 @@ use crate::ui_modal::{
|
|||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, TEXT_PRIMARY,
|
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
|
||||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2,
|
||||||
VAL_SPACE_4, Z_MODAL_PANEL,
|
VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Bevy resource wrapping the current stats.
|
/// Bevy resource wrapping the current stats.
|
||||||
@@ -93,6 +94,7 @@ impl Plugin for StatsPlugin {
|
|||||||
.add_message::<ForfeitEvent>()
|
.add_message::<ForfeitEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ToggleStatsRequestEvent>()
|
.add_message::<ToggleStatsRequestEvent>()
|
||||||
|
.add_message::<WinStreakMilestoneEvent>()
|
||||||
// record_abandoned must read `move_count` BEFORE handle_new_game
|
// record_abandoned must read `move_count` BEFORE handle_new_game
|
||||||
// clobbers it with a fresh game. These are NOT in StatsUpdate because
|
// clobbers it with a fresh game. These are NOT in StatsUpdate because
|
||||||
// StatsUpdate (as a set) is ordered after GameMutation by external
|
// StatsUpdate (as a set) is ordered after GameMutation by external
|
||||||
@@ -130,15 +132,55 @@ fn update_stats_on_win(
|
|||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut stats: ResMut<StatsResource>,
|
mut stats: ResMut<StatsResource>,
|
||||||
path: Res<StatsStoragePath>,
|
path: Res<StatsStoragePath>,
|
||||||
|
mut milestone: MessageWriter<WinStreakMilestoneEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
|
let prev_streak = stats.0.win_streak_current;
|
||||||
stats
|
stats
|
||||||
.0
|
.0
|
||||||
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
|
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
|
||||||
|
let new_streak = stats.0.win_streak_current;
|
||||||
|
// Fire the streak-milestone event only on the threshold
|
||||||
|
// crossing — `prev < threshold && new >= threshold`. This
|
||||||
|
// guarantees the flourish never retriggers at every win past
|
||||||
|
// the highest milestone.
|
||||||
|
if let Some(crossed) = streak_milestone_crossed(prev_streak, new_streak) {
|
||||||
|
milestone.write(WinStreakMilestoneEvent { streak: crossed });
|
||||||
|
toast.write(InfoToastEvent(format!(
|
||||||
|
"Win streak: {crossed}! \u{1F525}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
persist(&path, &stats.0, "win");
|
persist(&path, &stats.0, "win");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the milestone value that the player just crossed, if any.
|
||||||
|
///
|
||||||
|
/// A milestone is "crossed" when `prev < threshold && new >= threshold`
|
||||||
|
/// for some `threshold` in [`STREAK_MILESTONES`]. Returns the largest
|
||||||
|
/// such threshold (so a single win that vaults the player from a
|
||||||
|
/// streak of 0 directly to 5 — implausible, but defensive — fires the
|
||||||
|
/// most-celebrated milestone, not the smallest).
|
||||||
|
///
|
||||||
|
/// Returns `None` when no threshold was crossed, i.e. either:
|
||||||
|
/// - the streak did not change,
|
||||||
|
/// - the streak rose but stayed below every threshold, or
|
||||||
|
/// - the streak rose past a threshold that `prev` was already at or
|
||||||
|
/// above.
|
||||||
|
///
|
||||||
|
/// Pure function exposed for unit testing without Bevy.
|
||||||
|
pub fn streak_milestone_crossed(prev: u32, new: u32) -> Option<u32> {
|
||||||
|
if new <= prev {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
STREAK_MILESTONES
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|&t| prev < t && new >= t)
|
||||||
|
.max()
|
||||||
|
}
|
||||||
|
|
||||||
fn update_stats_on_new_game(
|
fn update_stats_on_new_game(
|
||||||
mut events: MessageReader<NewGameRequestEvent>,
|
mut events: MessageReader<NewGameRequestEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
@@ -895,4 +937,120 @@ mod tests {
|
|||||||
"expected no streak-broken toast for streak of 1, got: {messages:?}"
|
"expected no streak-broken toast for streak of 1, got: {messages:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Streak-milestone flourish — pure helper + event-firing tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pure helper: every threshold in `STREAK_MILESTONES` (3, 5, 10) must
|
||||||
|
/// fire when the streak crosses it from below.
|
||||||
|
#[test]
|
||||||
|
fn streak_milestone_helper_fires_at_each_threshold() {
|
||||||
|
for &threshold in STREAK_MILESTONES {
|
||||||
|
assert_eq!(
|
||||||
|
streak_milestone_crossed(threshold - 1, threshold),
|
||||||
|
Some(threshold),
|
||||||
|
"expected milestone {threshold} to fire when crossed from below",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper: rising past 10 to 11, 12, … must NOT fire — the
|
||||||
|
/// flourish is a threshold-crossing event, not a "every win past 10"
|
||||||
|
/// event.
|
||||||
|
#[test]
|
||||||
|
fn streak_milestone_helper_does_not_fire_past_highest() {
|
||||||
|
// prev=10 → new=11: above the highest threshold, no crossing.
|
||||||
|
assert_eq!(streak_milestone_crossed(10, 11), None);
|
||||||
|
// prev=15 → new=16: well past every threshold, no crossing.
|
||||||
|
assert_eq!(streak_milestone_crossed(15, 16), None);
|
||||||
|
// prev=2 → new=2: no change → no crossing.
|
||||||
|
assert_eq!(streak_milestone_crossed(2, 2), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper: rising 1 → 2 stays below the lowest threshold (3),
|
||||||
|
/// must NOT fire.
|
||||||
|
#[test]
|
||||||
|
fn streak_milestone_helper_does_not_fire_below_threshold() {
|
||||||
|
assert_eq!(streak_milestone_crossed(1, 2), None);
|
||||||
|
assert_eq!(streak_milestone_crossed(0, 1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integration: pre-set streak to 2, fire a win that bumps it to 3,
|
||||||
|
/// assert exactly one `WinStreakMilestoneEvent { streak: 3 }` is
|
||||||
|
/// written by the win handler.
|
||||||
|
#[test]
|
||||||
|
fn streak_milestone_event_fires_at_threshold_crossing() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
{
|
||||||
|
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||||
|
stats.0.win_streak_current = 2;
|
||||||
|
}
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 90,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<WinStreakMilestoneEvent>>();
|
||||||
|
let mut reader = events.get_cursor();
|
||||||
|
let collected: Vec<u32> = reader.read(events).map(|e| e.streak).collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
collected,
|
||||||
|
vec![3],
|
||||||
|
"expected one WinStreakMilestoneEvent {{ streak: 3 }} after crossing 2 → 3",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integration: pre-set streak to 1, fire a win that bumps it to 2 —
|
||||||
|
/// no threshold is crossed, no event must be fired.
|
||||||
|
#[test]
|
||||||
|
fn streak_milestone_event_does_not_fire_at_non_threshold() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
{
|
||||||
|
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||||
|
stats.0.win_streak_current = 1;
|
||||||
|
}
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 90,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<WinStreakMilestoneEvent>>();
|
||||||
|
let mut reader = events.get_cursor();
|
||||||
|
let collected: Vec<u32> = reader.read(events).map(|e| e.streak).collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
collected.is_empty(),
|
||||||
|
"expected no WinStreakMilestoneEvent for non-threshold streak crossing 1 → 2, got {collected:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integration: pre-set streak to 10, fire a win that bumps it to 11.
|
||||||
|
/// Past the highest threshold, no event must fire — the flourish
|
||||||
|
/// is reserved for the threshold crossing itself.
|
||||||
|
#[test]
|
||||||
|
fn streak_milestone_event_does_not_fire_past_10() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
{
|
||||||
|
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||||
|
stats.0.win_streak_current = 10;
|
||||||
|
}
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 90,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<WinStreakMilestoneEvent>>();
|
||||||
|
let mut reader = events.get_cursor();
|
||||||
|
let collected: Vec<u32> = reader.read(events).map(|e| e.streak).collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
collected.is_empty(),
|
||||||
|
"expected no WinStreakMilestoneEvent past the highest threshold, got {collected:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ fn react_to_settings_theme_change(
|
|||||||
commands.insert_resource(ActiveTheme(handle));
|
commands.insert_resource(ActiveTheme(handle));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces every face slot and slot 0 of the back array on
|
/// Replaces every face slot and the active-theme back-handle slot on
|
||||||
/// `CardImageSet` whenever the active theme finishes loading or
|
/// `CardImageSet` whenever the active theme finishes loading or
|
||||||
/// changes. Fires `StateChangedEvent` afterwards so the existing
|
/// changes. Fires `StateChangedEvent` afterwards so the existing
|
||||||
/// `card_plugin::sync_cards_on_change` pipeline re-renders every
|
/// `card_plugin::sync_cards_on_change` pipeline re-renders every
|
||||||
@@ -155,8 +155,16 @@ fn sync_card_image_set_with_active_theme(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pure helper that copies the theme's image handles into the
|
/// Pure helper that copies the theme's image handles into the
|
||||||
/// `[suit][rank]` face matrix and into back slot 0. Split out so it
|
/// `[suit][rank]` face matrix and into the dedicated `theme_back`
|
||||||
/// can be unit-tested without spinning up a Bevy `App`.
|
/// slot. Split out so it can be unit-tested without spinning up a
|
||||||
|
/// Bevy `App`.
|
||||||
|
///
|
||||||
|
/// The legacy `backs[0..5]` array is left untouched — those handles
|
||||||
|
/// are the player's `selected_card_back` choices and remain available
|
||||||
|
/// as a fallback when the active theme does not declare a back. The
|
||||||
|
/// face-down render path in `card_plugin::card_sprite` prefers
|
||||||
|
/// `theme_back` when present, so writing here is sufficient to make
|
||||||
|
/// every face-down card pick up the theme's art on the next sync.
|
||||||
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
|
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
|
||||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||||
for rank in [
|
for rank in [
|
||||||
@@ -169,7 +177,7 @@ fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
image_set.backs[0] = theme.back.clone();
|
image_set.theme_back = Some(theme.back.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Index used by [`CardImageSet::faces`] for a given suit. Mirrors
|
/// Index used by [`CardImageSet::faces`] for a given suit. Mirrors
|
||||||
@@ -251,6 +259,7 @@ mod tests {
|
|||||||
CardImageSet {
|
CardImageSet {
|
||||||
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
|
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
|
||||||
backs: std::array::from_fn(|_| Handle::default()),
|
backs: std::array::from_fn(|_| Handle::default()),
|
||||||
|
theme_back: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,24 +293,34 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn applying_theme_overwrites_back_slot_zero() {
|
fn applying_theme_writes_theme_back_slot_and_leaves_legacy_backs_untouched() {
|
||||||
// Build a theme whose back handle is a freshly-allocated weak
|
// The active-theme back lives in its own dedicated slot
|
||||||
// handle — its id will differ from the default-handle id we
|
// (`theme_back`) so the legacy `backs[0..5]` PNG fallbacks
|
||||||
// started with, proving the back slot was overwritten.
|
// remain untouched. This guarantees the player's
|
||||||
|
// `selected_card_back` choice can still be honoured when no
|
||||||
|
// theme is active.
|
||||||
let mut image_set = empty_card_image_set();
|
let mut image_set = empty_card_image_set();
|
||||||
|
// Snapshot the legacy back ids so we can prove they don't
|
||||||
|
// change when a theme is applied.
|
||||||
|
let legacy_ids_before: [bevy::asset::AssetId<bevy::image::Image>; 5] =
|
||||||
|
std::array::from_fn(|i| image_set.backs[i].id());
|
||||||
let theme = empty_theme();
|
let theme = empty_theme();
|
||||||
let original_back_id = image_set.backs[0].id();
|
assert!(image_set.theme_back.is_none(), "theme_back starts empty");
|
||||||
apply_theme_to_card_image_set(&theme, &mut image_set);
|
apply_theme_to_card_image_set(&theme, &mut image_set);
|
||||||
// Both default handles compare equal to themselves; the test
|
// The active-theme back is now populated and matches the theme.
|
||||||
// asserts via id() that whichever handle is in slot 0 came
|
let active_back = image_set
|
||||||
// from the theme — even if both happen to be Handle::default,
|
.theme_back
|
||||||
// the id swap is still observable via the value-equality of
|
.as_ref()
|
||||||
// theme.back's id.
|
.expect("theme_back populated after apply");
|
||||||
assert_eq!(image_set.backs[0].id(), theme.back.id());
|
assert_eq!(active_back.id(), theme.back.id());
|
||||||
// No assertion about original_back_id — both sides may be the
|
// Every legacy back slot is preserved byte-for-byte by id.
|
||||||
// same default handle id when neither is loaded; the contract
|
for (i, before) in legacy_ids_before.iter().enumerate() {
|
||||||
// we're checking is "slot 0 now matches theme.back".
|
assert_eq!(
|
||||||
let _ = original_back_id;
|
image_set.backs[i].id(),
|
||||||
|
*before,
|
||||||
|
"legacy back slot {i} must not be clobbered by theme apply",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -41,13 +41,17 @@
|
|||||||
//! here no-ops so [`crate::selection_plugin`]'s Tab/Enter
|
//! here no-ops so [`crate::selection_plugin`]'s Tab/Enter
|
||||||
//! card-selection still works.
|
//! card-selection still works.
|
||||||
|
|
||||||
|
use std::f32::consts::TAU;
|
||||||
|
|
||||||
use bevy::ecs::query::Has;
|
use bevy::ecs::query::Has;
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::ui_modal::{ButtonVariant, ModalButton, ModalScrim};
|
use crate::ui_modal::{ButtonVariant, ModalButton, ModalScrim};
|
||||||
use crate::ui_theme::{FOCUS_RING, RADIUS_MD, Z_FOCUS_RING};
|
use crate::ui_theme::{FOCUS_RING, MOTION_FOCUS_PULSE_SECS, RADIUS_MD, Z_FOCUS_RING};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public component / resource API
|
// Public component / resource API
|
||||||
@@ -126,12 +130,57 @@ impl Plugin for UiFocusPlugin {
|
|||||||
clear_hud_focus_on_unhover,
|
clear_hud_focus_on_unhover,
|
||||||
handle_focus_keys,
|
handle_focus_keys,
|
||||||
update_focus_overlay,
|
update_focus_overlay,
|
||||||
|
pulse_focus_overlay,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Computes the focus-ring breathing factor for a given elapsed time.
|
||||||
|
///
|
||||||
|
/// Returns a value in `[0.65, 1.0]` following a sin curve over
|
||||||
|
/// [`MOTION_FOCUS_PULSE_SECS`]. Multiply [`FOCUS_RING`]'s native alpha by
|
||||||
|
/// this factor each frame to produce the breathing effect.
|
||||||
|
///
|
||||||
|
/// Pure helper so the curve can be unit-tested without a Bevy app.
|
||||||
|
pub fn focus_ring_pulse_factor(elapsed_secs: f32) -> f32 {
|
||||||
|
let phase = (elapsed_secs * TAU / MOTION_FOCUS_PULSE_SECS).sin();
|
||||||
|
// 0.825 mid-point ± 0.175 amplitude → range [0.65, 1.0]. Multiplicative
|
||||||
|
// factor against FOCUS_RING's static alpha so the brightest tick is
|
||||||
|
// exactly the original colour, not a brighter one.
|
||||||
|
0.825 + 0.175 * phase
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modulates the focus overlay's border alpha with a slow sin-curve
|
||||||
|
/// breathing pulse so the indicator catches the eye without competing
|
||||||
|
/// with gameplay motion. Skipped under `AnimSpeed::Instant` — the static
|
||||||
|
/// border colour is restored so reduced-motion users see no animation.
|
||||||
|
fn pulse_focus_overlay(
|
||||||
|
time: Res<Time>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
focused: Res<FocusedButton>,
|
||||||
|
mut overlay: Query<&mut BorderColor, With<FocusOverlay>>,
|
||||||
|
) {
|
||||||
|
let Ok(mut border) = overlay.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let instant = settings
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|s| matches!(s.0.animation_speed, AnimSpeed::Instant));
|
||||||
|
|
||||||
|
let factor = if instant || focused.0.is_none() {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
focus_ring_pulse_factor(time.elapsed_secs())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut colour = FOCUS_RING;
|
||||||
|
colour.set_alpha(FOCUS_RING.alpha() * factor);
|
||||||
|
*border = BorderColor::all(colour);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Private marker for the single overlay entity
|
// Private marker for the single overlay entity
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -588,6 +637,40 @@ mod tests {
|
|||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, UiModalPlugin,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, UiModalPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_ring_pulse_factor_at_zero_is_mid_point() {
|
||||||
|
// sin(0) = 0 → factor = 0.825 (mid of [0.65, 1.0]).
|
||||||
|
let f = focus_ring_pulse_factor(0.0);
|
||||||
|
assert!((f - 0.825).abs() < 1e-5, "factor at t=0 should be 0.825, got {f}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_ring_pulse_factor_peaks_at_quarter_period() {
|
||||||
|
// sin(τ/4) = 1 → factor = 1.0.
|
||||||
|
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS / 4.0);
|
||||||
|
assert!((f - 1.0).abs() < 1e-4, "factor at peak should be 1.0, got {f}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_ring_pulse_factor_troughs_at_three_quarter_period() {
|
||||||
|
// sin(3τ/4) = -1 → factor = 0.65.
|
||||||
|
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS * 3.0 / 4.0);
|
||||||
|
assert!((f - 0.65).abs() < 1e-4, "factor at trough should be 0.65, got {f}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_ring_pulse_factor_stays_in_brightness_range() {
|
||||||
|
// Sweep across two full periods; factor must stay within [0.65, 1.0].
|
||||||
|
for i in 0..200 {
|
||||||
|
let t = i as f32 * MOTION_FOCUS_PULSE_SECS * 0.01;
|
||||||
|
let f = focus_ring_pulse_factor(t);
|
||||||
|
assert!(
|
||||||
|
(0.649..=1.001).contains(&f),
|
||||||
|
"factor at t={t} out of range: {f}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Plugin-marker for the synthetic test modal — `spawn_modal`
|
/// Plugin-marker for the synthetic test modal — `spawn_modal`
|
||||||
/// requires a `Component` on the scrim.
|
/// requires a `Component` on the scrim.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
|
|||||||
@@ -333,6 +333,11 @@ pub const MOTION_SHAKE_SECS: f32 = 0.25;
|
|||||||
/// Shake angular frequency in rad/s.
|
/// Shake angular frequency in rad/s.
|
||||||
pub const MOTION_SHAKE_OMEGA: f32 = 35.0;
|
pub const MOTION_SHAKE_OMEGA: f32 = 35.0;
|
||||||
|
|
||||||
|
/// Duration of the smooth return tween when a drag is rejected by an
|
||||||
|
/// invalid drop target. Short enough to feel snappy but long enough to
|
||||||
|
/// read as motion rather than a teleport.
|
||||||
|
pub const MOTION_DRAG_REJECT_SECS: f32 = 0.15;
|
||||||
|
|
||||||
/// Card flip — half-time per phase (squash + grow). 100 ms each =
|
/// Card flip — half-time per phase (squash + grow). 100 ms each =
|
||||||
/// 200 ms total. Pair with a ±8° Z-rotation at the midpoint for a 3D
|
/// 200 ms total. Pair with a ±8° Z-rotation at the midpoint for a 3D
|
||||||
/// feel without 3D rendering.
|
/// feel without 3D rendering.
|
||||||
@@ -356,6 +361,14 @@ pub const MOTION_CASCADE_STAGGER_SECS: f32 = 0.06;
|
|||||||
/// (overshoot) plus ±15° Z-rotation. 500 ms.
|
/// (overshoot) plus ±15° Z-rotation. 500 ms.
|
||||||
pub const MOTION_CASCADE_SLIDE_SECS: f32 = 0.50;
|
pub const MOTION_CASCADE_SLIDE_SECS: f32 = 0.50;
|
||||||
|
|
||||||
|
/// Per-line stagger between score-breakdown rows during the win modal
|
||||||
|
/// reveal animation, in seconds.
|
||||||
|
pub const MOTION_SCORE_BREAKDOWN_STAGGER_SECS: f32 = 0.15;
|
||||||
|
|
||||||
|
/// Per-line fade-in duration during the win modal score reveal, in
|
||||||
|
/// seconds.
|
||||||
|
pub const MOTION_SCORE_BREAKDOWN_FADE_SECS: f32 = 0.12;
|
||||||
|
|
||||||
/// Screen shake on win — wider and longer than the old 0.6 s / 8 px.
|
/// Screen shake on win — wider and longer than the old 0.6 s / 8 px.
|
||||||
/// 800 ms.
|
/// 800 ms.
|
||||||
pub const MOTION_WIN_SHAKE_SECS: f32 = 0.80;
|
pub const MOTION_WIN_SHAKE_SECS: f32 = 0.80;
|
||||||
@@ -379,10 +392,51 @@ pub const MOTION_BUTTON_BLEND_SECS: f32 = 0.10;
|
|||||||
/// readout 1.0 → 1.1 → 1.0. 250 ms.
|
/// readout 1.0 → 1.1 → 1.0. 250 ms.
|
||||||
pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25;
|
pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25;
|
||||||
|
|
||||||
|
/// Foundation-completion flourish — when a King lands on a foundation
|
||||||
|
/// pile (Ace → King, 13 cards), briefly scale the King card 1.0 →
|
||||||
|
/// [`FOUNDATION_FLOURISH_PEAK_SCALE`] → 1.0 and tint the matching
|
||||||
|
/// `PileMarker` gold. 400 ms.
|
||||||
|
pub const MOTION_FOUNDATION_FLOURISH_SECS: f32 = 0.4;
|
||||||
|
|
||||||
|
/// Peak scale magnification reached at the midpoint of the
|
||||||
|
/// foundation-completion flourish. The triangular curve climbs from
|
||||||
|
/// 1.0 at `t=0` to this value at `t=0.5` and back to 1.0 at `t=1.0`.
|
||||||
|
pub const FOUNDATION_FLOURISH_PEAK_SCALE: f32 = 1.15;
|
||||||
|
|
||||||
|
/// Total duration of the streak-milestone flourish on the HUD score
|
||||||
|
/// readout, in seconds. Mirrors the foundation flourish in feel — a
|
||||||
|
/// brief celebratory pulse that does not block subsequent gameplay.
|
||||||
|
pub const MOTION_STREAK_FLOURISH_SECS: f32 = 0.6;
|
||||||
|
|
||||||
|
/// Peak scale magnification reached at the midpoint of the streak
|
||||||
|
/// flourish (1.0 → this → 1.0). Larger than the foundation flourish
|
||||||
|
/// peak so the lifetime-streak celebration reads as a bigger deal than
|
||||||
|
/// the per-suit completion.
|
||||||
|
pub const STREAK_FLOURISH_PEAK_SCALE: f32 = 1.20;
|
||||||
|
|
||||||
|
/// Win-streak counts that trigger the flourish. The flourish fires
|
||||||
|
/// only when the streak crosses a threshold from below — never at
|
||||||
|
/// every win past the highest threshold. Static for now; could become
|
||||||
|
/// a `Settings`-tunable list later if play-testing surfaces it.
|
||||||
|
pub const STREAK_MILESTONES: &[u32] = &[3, 5, 10];
|
||||||
|
|
||||||
/// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step.
|
/// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step.
|
||||||
/// 400 ms.
|
/// 400 ms.
|
||||||
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
|
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
|
||||||
|
|
||||||
|
/// Period of the focus-ring breathing pulse, in seconds.
|
||||||
|
///
|
||||||
|
/// The keyboard focus ring's alpha is modulated by a sin-curve over this
|
||||||
|
/// interval so the indicator gently "breathes" instead of presenting as
|
||||||
|
/// a flat outline. 1.4 s reads as a calm heartbeat — slow enough that
|
||||||
|
/// the motion is in the player's peripheral vision rather than competing
|
||||||
|
/// for attention, fast enough that a focus change still draws the eye.
|
||||||
|
/// Not run through [`scaled_duration`]: the pulse is an accessibility
|
||||||
|
/// affordance, not gameplay motion. `AnimSpeed::Instant` is honoured at
|
||||||
|
/// the system level by skipping the pulse entirely (see
|
||||||
|
/// `pulse_focus_overlay` in `ui_focus`).
|
||||||
|
pub const MOTION_FOCUS_PULSE_SECS: f32 = 1.4;
|
||||||
|
|
||||||
/// Hover delay before a tooltip appears, in seconds. Long enough that
|
/// Hover delay before a tooltip appears, in seconds. Long enough that
|
||||||
/// players gliding the cursor across the HUD don't see flicker; short
|
/// players gliding the cursor across the HUD don't see flicker; short
|
||||||
/// enough that "stop and read" feels responsive. Not run through
|
/// enough that "stop and read" feels responsive. Not run through
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ use bevy::prelude::*;
|
|||||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
|
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
BG_ELEVATED_HI, BORDER_SUBTLE, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM, TEXT_PRIMARY,
|
BG_ELEVATED_HI, BORDER_SUBTLE, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM, TEXT_PRIMARY,
|
||||||
TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
|
TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
|
||||||
@@ -137,6 +138,23 @@ struct TooltipText;
|
|||||||
/// target's own border.
|
/// target's own border.
|
||||||
const TOOLTIP_GAP_PX: f32 = 4.0;
|
const TOOLTIP_GAP_PX: f32 = 4.0;
|
||||||
|
|
||||||
|
/// Pure helper: returns `true` once `elapsed_secs` has met or exceeded
|
||||||
|
/// the player-configured `delay_secs`, so the tooltip should be revealed.
|
||||||
|
///
|
||||||
|
/// Treating "elapsed >= delay" as the show condition (rather than
|
||||||
|
/// strictly greater than) is what makes a `delay_secs == 0.0` setting
|
||||||
|
/// behave as advertised: on the very first tick after hover starts,
|
||||||
|
/// `elapsed_secs` is `0.0` and the tooltip appears immediately. With a
|
||||||
|
/// strict `>` the zero-delay case would still wait one tick.
|
||||||
|
///
|
||||||
|
/// Extracted so the comparison can be unit-tested without spinning up
|
||||||
|
/// a Bevy `App` — `Time<Virtual>` clamps each tick to 250 ms under
|
||||||
|
/// `MinimalPlugins`, which makes precise sub-second timing assertions
|
||||||
|
/// awkward.
|
||||||
|
pub(crate) fn tooltip_should_show(elapsed_secs: f32, delay_secs: f32) -> bool {
|
||||||
|
elapsed_secs >= delay_secs
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Systems
|
// Systems
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -257,6 +275,7 @@ fn track_tooltip_hover(
|
|||||||
fn show_or_hide_tooltip(
|
fn show_or_hide_tooltip(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
state: Res<TooltipState>,
|
state: Res<TooltipState>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
tooltips: Query<(&Tooltip, &UiGlobalTransform, &ComputedNode)>,
|
tooltips: Query<(&Tooltip, &UiGlobalTransform, &ComputedNode)>,
|
||||||
tooltip_text_only: Query<&Tooltip>,
|
tooltip_text_only: Query<&Tooltip>,
|
||||||
mut overlay_q: Query<(&mut Node, &mut Visibility, &Children), With<TooltipOverlay>>,
|
mut overlay_q: Query<(&mut Node, &mut Visibility, &Children), With<TooltipOverlay>>,
|
||||||
@@ -280,9 +299,15 @@ fn show_or_hide_tooltip(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Player-configurable dwell delay; falls back to the design-token
|
||||||
|
// default when `SettingsResource` is absent (test harnesses running
|
||||||
|
// `UiTooltipPlugin` under `MinimalPlugins` without `SettingsPlugin`).
|
||||||
|
let delay_secs = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.0.tooltip_delay_secs)
|
||||||
|
.unwrap_or(MOTION_TOOLTIP_DELAY_SECS);
|
||||||
let elapsed = time.elapsed().saturating_sub(started_at);
|
let elapsed = time.elapsed().saturating_sub(started_at);
|
||||||
let delay = Duration::from_secs_f32(MOTION_TOOLTIP_DELAY_SECS);
|
if !tooltip_should_show(elapsed.as_secs_f32(), delay_secs) {
|
||||||
if elapsed < delay {
|
|
||||||
hide(&mut visibility);
|
hide(&mut visibility);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -550,4 +575,30 @@ mod tests {
|
|||||||
"overlay text must update to the new hovered entity's Tooltip string"
|
"overlay text must update to the new hovered entity's Tooltip string"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test 5: `tooltip_should_show` is the pure helper that the system
|
||||||
|
/// uses to gate the reveal — exercising it directly avoids the
|
||||||
|
/// `Time<Virtual>` 250 ms clamp that makes precise sub-second
|
||||||
|
/// timing assertions in `MinimalPlugins` fiddly. The four cases
|
||||||
|
/// below cover the boundary semantics:
|
||||||
|
///
|
||||||
|
/// * `delay = 0.0` ("Instant") must show on the first tick.
|
||||||
|
/// * `elapsed < delay` must NOT show.
|
||||||
|
/// * `elapsed == delay` must show (boundary inclusive).
|
||||||
|
/// * `elapsed > delay` must show.
|
||||||
|
#[test]
|
||||||
|
fn tooltip_should_show_respects_delay() {
|
||||||
|
// delay == 0 ("Instant"): any elapsed (including zero) shows.
|
||||||
|
assert!(tooltip_should_show(0.0, 0.0), "instant delay must show on first tick");
|
||||||
|
assert!(tooltip_should_show(0.5, 0.0));
|
||||||
|
|
||||||
|
// Standard non-zero delay.
|
||||||
|
assert!(!tooltip_should_show(0.4, 0.5), "elapsed < delay must hide");
|
||||||
|
assert!(tooltip_should_show(0.5, 0.5), "elapsed == delay must show (boundary)");
|
||||||
|
assert!(tooltip_should_show(0.6, 0.5), "elapsed > delay must show");
|
||||||
|
|
||||||
|
// Larger delay (max-end of the slider).
|
||||||
|
assert!(!tooltip_should_show(1.0, 1.5));
|
||||||
|
assert!(tooltip_should_show(1.5, 1.5));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
|
use solitaire_core::scoring::compute_time_bonus;
|
||||||
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::achievement_plugin::display_name_for;
|
use crate::achievement_plugin::display_name_for;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
@@ -23,10 +25,11 @@ use crate::resources::GameStateResource;
|
|||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_WIN_SHAKE_AMPLITUDE,
|
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
||||||
MOTION_WIN_SHAKE_SECS, RADIUS_LG, RADIUS_MD, SCRIM, STATE_INFO, STATE_SUCCESS, STATE_WARNING,
|
MOTION_SCORE_BREAKDOWN_STAGGER_SECS, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS,
|
||||||
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_DISPLAY, TYPE_HEADLINE, VAL_SPACE_2,
|
RADIUS_LG, RADIUS_MD, SCRIM, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY,
|
||||||
VAL_SPACE_3, Z_WIN_CASCADE,
|
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_DISPLAY, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
|
Z_WIN_CASCADE,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -73,6 +76,15 @@ pub struct WinSummaryPending {
|
|||||||
/// human-readable level number that was just completed (e.g. `Some(3)`
|
/// human-readable level number that was just completed (e.g. `Some(3)`
|
||||||
/// means "Challenge 3"). `None` for non-Challenge modes.
|
/// means "Challenge 3"). `None` for non-Challenge modes.
|
||||||
pub challenge_level: Option<u32>,
|
pub challenge_level: Option<u32>,
|
||||||
|
/// Number of undos used during the winning game. Captured from
|
||||||
|
/// `GameStateResource` at the moment `GameWonEvent` fires so the
|
||||||
|
/// score-breakdown reveal can decide whether to award the no-undo
|
||||||
|
/// bonus row.
|
||||||
|
pub undo_count: u32,
|
||||||
|
/// Game mode of the winning game. Captured at win time so the
|
||||||
|
/// score-breakdown reveal can format the mode-multiplier row
|
||||||
|
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
||||||
|
pub mode: GameMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a human-readable XP breakdown string for the win modal.
|
/// Builds a human-readable XP breakdown string for the win modal.
|
||||||
@@ -161,6 +173,37 @@ enum WinSummaryButton {
|
|||||||
PlayAgain,
|
PlayAgain,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marker for one row of the win-modal score-breakdown reveal.
|
||||||
|
///
|
||||||
|
/// Each row carries a stagger delay (seconds until the row should
|
||||||
|
/// become visible) plus a fade-in timer that lerps the row's text
|
||||||
|
/// alpha from `0.0 → 1.0` over [`MOTION_SCORE_BREAKDOWN_FADE_SECS`].
|
||||||
|
/// Rows are spawned with `Visibility::Hidden`; the reveal system
|
||||||
|
/// flips them to `Visibility::Inherited` once `delay_secs` elapses
|
||||||
|
/// and then drives the per-text alpha lerp until the row reaches
|
||||||
|
/// full opacity.
|
||||||
|
///
|
||||||
|
/// When `AnimSpeed::Instant` is active the row is spawned with
|
||||||
|
/// `delay_secs = 0.0`, `fade_duration_secs = 0.0`, and visibility
|
||||||
|
/// already set to `Inherited` so the reveal happens on frame 1.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct ScoreBreakdownRow {
|
||||||
|
/// Seconds remaining until this row first becomes visible.
|
||||||
|
/// Counts down to 0 in `reveal_score_breakdown`. Zero or negative
|
||||||
|
/// means "show immediately".
|
||||||
|
pub delay_secs: f32,
|
||||||
|
/// Seconds elapsed since this row became visible. Drives the
|
||||||
|
/// alpha lerp on the row's child `Text` nodes.
|
||||||
|
pub fade_elapsed_secs: f32,
|
||||||
|
/// Total fade-in duration. Zero means "no fade — appear at full
|
||||||
|
/// opacity in one frame".
|
||||||
|
pub fade_duration_secs: f32,
|
||||||
|
/// `true` once the row's `Visibility` has been promoted from
|
||||||
|
/// `Hidden` to `Inherited`. Prevents re-running the visibility
|
||||||
|
/// switch every frame after the row first reveals.
|
||||||
|
pub revealed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -193,6 +236,7 @@ impl Plugin for WinSummaryPlugin {
|
|||||||
spawn_win_summary_after_delay,
|
spawn_win_summary_after_delay,
|
||||||
handle_win_summary_buttons,
|
handle_win_summary_buttons,
|
||||||
apply_screen_shake,
|
apply_screen_shake,
|
||||||
|
reveal_score_breakdown,
|
||||||
)
|
)
|
||||||
.after(GameMutation),
|
.after(GameMutation),
|
||||||
);
|
);
|
||||||
@@ -217,6 +261,144 @@ pub fn format_win_time(seconds: u64) -> String {
|
|||||||
format!("{m}:{s:02}")
|
format!("{m}:{s:02}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Score amount awarded as a "no-undo" bonus in the win modal when the
|
||||||
|
/// player completes the game without using undo. Mirrors the XP-side
|
||||||
|
/// no-undo bonus so the score and XP breakdowns reinforce each other,
|
||||||
|
/// and stays a `pub const` so tests can assert against it without
|
||||||
|
/// re-typing the literal.
|
||||||
|
pub const SCORE_NO_UNDO_BONUS: i32 = 25;
|
||||||
|
|
||||||
|
/// Decomposed view of the player's final score, displayed in the win
|
||||||
|
/// modal as a sequence of fade-in rows.
|
||||||
|
///
|
||||||
|
/// The fields mirror the row layout described in the win-modal
|
||||||
|
/// reveal:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// Base score {base}
|
||||||
|
/// Time bonus ({m:ss}) +{time_bonus}
|
||||||
|
/// No-undo bonus +{no_undo_bonus}
|
||||||
|
/// Mode multiplier ({mode} ×N) ×{multiplier}
|
||||||
|
/// ─────────────────────────────────
|
||||||
|
/// Total {total}
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Components that do not apply to the current win are zeroed out:
|
||||||
|
/// `time_bonus = 0` when the player took longer than the time-bonus
|
||||||
|
/// curve produces a positive result, `no_undo_bonus = 0` when undo
|
||||||
|
/// was used, and `multiplier = 1.0` outside Zen mode. The renderer
|
||||||
|
/// uses these zero markers to skip rows the player would not benefit
|
||||||
|
/// from seeing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct ScoreBreakdown {
|
||||||
|
/// Running game score before the win-time bonuses are applied.
|
||||||
|
/// Equal to `pending.score`, which is `GameState::score` at the
|
||||||
|
/// moment of `GameWonEvent`.
|
||||||
|
pub base: i32,
|
||||||
|
/// Time-bonus component — `compute_time_bonus(time_seconds)`.
|
||||||
|
/// Zero when `time_seconds == 0` or when the formula yields zero.
|
||||||
|
pub time_bonus: i32,
|
||||||
|
/// Score awarded for completing the win without using undo.
|
||||||
|
/// Zero when `undo_count > 0`.
|
||||||
|
pub no_undo_bonus: i32,
|
||||||
|
/// Multiplier applied to `(base + time_bonus + no_undo_bonus)` to
|
||||||
|
/// produce the final total. `0.0` for Zen mode (which never
|
||||||
|
/// scores), `1.0` otherwise.
|
||||||
|
pub multiplier: f32,
|
||||||
|
/// Game mode the win occurred in. Used by the renderer to format
|
||||||
|
/// the multiplier row label, e.g. `"Mode multiplier (Zen ×0)"`.
|
||||||
|
pub mode: GameMode,
|
||||||
|
/// Elapsed game time in seconds, used to format the time-bonus
|
||||||
|
/// row label as `m:ss`.
|
||||||
|
pub time_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScoreBreakdown {
|
||||||
|
/// Builds a breakdown for the given win.
|
||||||
|
///
|
||||||
|
/// `base` is the running game score (`pending.score`); `time_seconds`,
|
||||||
|
/// `undo_count`, and `mode` come from the captured `WinSummaryPending`.
|
||||||
|
/// All score arithmetic is saturating to keep the breakdown safe even
|
||||||
|
/// for pathologically high scores.
|
||||||
|
pub fn compute(base: i32, time_seconds: u64, undo_count: u32, mode: GameMode) -> Self {
|
||||||
|
let time_bonus = compute_time_bonus(time_seconds);
|
||||||
|
let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
|
||||||
|
let multiplier = match mode {
|
||||||
|
GameMode::Zen => 0.0,
|
||||||
|
GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack => 1.0,
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
base,
|
||||||
|
time_bonus,
|
||||||
|
no_undo_bonus,
|
||||||
|
multiplier,
|
||||||
|
mode,
|
||||||
|
time_seconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Final total displayed on the breakdown's bottom row, rounded
|
||||||
|
/// half-to-even (Rust's default `as i32` cast truncates toward
|
||||||
|
/// zero, which is fine for a non-fractional multiplier set).
|
||||||
|
pub fn total(&self) -> i32 {
|
||||||
|
let pre_mult = self
|
||||||
|
.base
|
||||||
|
.saturating_add(self.time_bonus)
|
||||||
|
.saturating_add(self.no_undo_bonus);
|
||||||
|
((pre_mult as f32) * self.multiplier) as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the no-undo bonus row should be rendered. Skipped when
|
||||||
|
/// the player used undo (bonus is zero) so the modal does not
|
||||||
|
/// show a "+0" line that adds nothing.
|
||||||
|
pub fn shows_no_undo_row(&self) -> bool {
|
||||||
|
self.no_undo_bonus > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the time-bonus row should be rendered. Skipped when
|
||||||
|
/// the bonus is zero (e.g. `time_seconds == 0`).
|
||||||
|
pub fn shows_time_bonus_row(&self) -> bool {
|
||||||
|
self.time_bonus > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the mode-multiplier row should be rendered. Skipped
|
||||||
|
/// for `multiplier == 1.0` so Classic/Challenge/TimeAttack wins
|
||||||
|
/// do not show a redundant "×1.0" line.
|
||||||
|
pub fn shows_multiplier_row(&self) -> bool {
|
||||||
|
(self.multiplier - 1.0).abs() > f32::EPSILON
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total number of rows the breakdown will spawn, counting the
|
||||||
|
/// always-present `Base score` and `Total` rows plus the
|
||||||
|
/// separator. Used by tests to assert spawn counts deterministically.
|
||||||
|
pub fn row_count(&self) -> usize {
|
||||||
|
let mut n = 1; // base
|
||||||
|
if self.shows_time_bonus_row() {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
if self.shows_no_undo_row() {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
if self.shows_multiplier_row() {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
n += 1; // separator
|
||||||
|
n += 1; // total
|
||||||
|
n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable display name for a game mode. Used as the prefix in
|
||||||
|
/// the mode-multiplier row, e.g. `"Mode multiplier (Zen ×0)"`.
|
||||||
|
fn mode_display_name(mode: GameMode) -> &'static str {
|
||||||
|
match mode {
|
||||||
|
GameMode::Classic => "Classic",
|
||||||
|
GameMode::Zen => "Zen",
|
||||||
|
GameMode::Challenge => "Challenge",
|
||||||
|
GameMode::TimeAttack => "Time Attack",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Systems
|
// Systems
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -267,6 +449,8 @@ fn cache_win_data(
|
|||||||
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
|
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
|
||||||
pending.new_record = is_new_record;
|
pending.new_record = is_new_record;
|
||||||
pending.challenge_level = challenge_level;
|
pending.challenge_level = challenge_level;
|
||||||
|
pending.undo_count = game.0.undo_count;
|
||||||
|
pending.mode = game.0.mode;
|
||||||
|
|
||||||
if is_new_record {
|
if is_new_record {
|
||||||
toast.write(InfoToastEvent("New Record!".to_string()));
|
toast.write(InfoToastEvent("New Record!".to_string()));
|
||||||
@@ -365,7 +549,12 @@ fn spawn_win_summary_after_delay(
|
|||||||
pending.xp = pending.xp.saturating_add(ev.amount);
|
pending.xp = pending.xp.saturating_add(ev.amount);
|
||||||
}
|
}
|
||||||
let challenge_level = pending.challenge_level;
|
let challenge_level = pending.challenge_level;
|
||||||
spawn_overlay(&mut commands, &pending, &session, challenge_level);
|
// Re-derive AnimSpeed here — the `speed` binding above
|
||||||
|
// only lives inside the `for _ in won.read()` loop.
|
||||||
|
let anim_speed = settings
|
||||||
|
.as_ref()
|
||||||
|
.map_or(AnimSpeed::Normal, |s| s.0.animation_speed);
|
||||||
|
spawn_overlay(&mut commands, &pending, &session, challenge_level, anim_speed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,12 +628,25 @@ fn apply_screen_shake(
|
|||||||
///
|
///
|
||||||
/// `challenge_level` is `Some(N)` when the win was a Challenge-mode completion;
|
/// `challenge_level` is `Some(N)` when the win was a Challenge-mode completion;
|
||||||
/// a "Challenge N complete!" annotation is added to the modal header in that case.
|
/// a "Challenge N complete!" annotation is added to the modal header in that case.
|
||||||
|
///
|
||||||
|
/// `anim_speed` controls the score-breakdown reveal: under
|
||||||
|
/// `AnimSpeed::Instant`, every breakdown row is spawned visible and at
|
||||||
|
/// full opacity (no stagger, no fade); otherwise rows are spawned
|
||||||
|
/// hidden and the [`reveal_score_breakdown`] system fades them in over
|
||||||
|
/// roughly one second.
|
||||||
fn spawn_overlay(
|
fn spawn_overlay(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
pending: &WinSummaryPending,
|
pending: &WinSummaryPending,
|
||||||
session: &SessionAchievements,
|
session: &SessionAchievements,
|
||||||
challenge_level: Option<u32>,
|
challenge_level: Option<u32>,
|
||||||
|
anim_speed: AnimSpeed,
|
||||||
) {
|
) {
|
||||||
|
let breakdown = ScoreBreakdown::compute(
|
||||||
|
pending.score,
|
||||||
|
pending.time_seconds,
|
||||||
|
pending.undo_count,
|
||||||
|
pending.mode,
|
||||||
|
);
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
WinSummaryOverlay,
|
WinSummaryOverlay,
|
||||||
@@ -502,12 +704,9 @@ fn spawn_overlay(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score
|
// Score breakdown reveal — replaces the previous single
|
||||||
card.spawn((
|
// "Score:" line with a per-component multi-row layout.
|
||||||
Text::new(format!("Score: {}", pending.score)),
|
spawn_score_breakdown(card, &breakdown, anim_speed);
|
||||||
TextFont { font_size: TYPE_HEADLINE, ..default() },
|
|
||||||
TextColor(TEXT_PRIMARY),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Time
|
// Time
|
||||||
card.spawn((
|
card.spawn((
|
||||||
@@ -597,6 +796,220 @@ fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns the score-breakdown rows inside the win-modal card.
|
||||||
|
///
|
||||||
|
/// Rows are appended in this order — only the first and last two are
|
||||||
|
/// always present, the middle three depend on `breakdown`:
|
||||||
|
///
|
||||||
|
/// 1. `Base score` — value column = `breakdown.base`.
|
||||||
|
/// 2. `Time bonus (m:ss)` — only when `breakdown.shows_time_bonus_row()`.
|
||||||
|
/// 3. `No-undo bonus` — only when `breakdown.shows_no_undo_row()`.
|
||||||
|
/// 4. `Mode multiplier (Mode-name ×N)` — only when
|
||||||
|
/// `breakdown.shows_multiplier_row()`.
|
||||||
|
/// 5. Separator (em-dashes).
|
||||||
|
/// 6. `Total` — value column = `breakdown.total()`.
|
||||||
|
///
|
||||||
|
/// Every row is spawned with a [`ScoreBreakdownRow`] component carrying
|
||||||
|
/// a per-row stagger delay calculated from
|
||||||
|
/// [`MOTION_SCORE_BREAKDOWN_STAGGER_SECS`]. Under `AnimSpeed::Instant`,
|
||||||
|
/// stagger and fade are both zero so the breakdown appears in one frame.
|
||||||
|
fn spawn_score_breakdown(
|
||||||
|
card: &mut ChildSpawnerCommands,
|
||||||
|
breakdown: &ScoreBreakdown,
|
||||||
|
anim_speed: AnimSpeed,
|
||||||
|
) {
|
||||||
|
let stagger = scaled_duration(MOTION_SCORE_BREAKDOWN_STAGGER_SECS, anim_speed);
|
||||||
|
let fade = scaled_duration(MOTION_SCORE_BREAKDOWN_FADE_SECS, anim_speed);
|
||||||
|
let mut row_index: u32 = 0;
|
||||||
|
|
||||||
|
// 1. Base score — always shown.
|
||||||
|
spawn_breakdown_row(
|
||||||
|
card,
|
||||||
|
"Base score",
|
||||||
|
format!("{}", breakdown.base),
|
||||||
|
ACCENT_PRIMARY,
|
||||||
|
anim_speed,
|
||||||
|
stagger * row_index as f32,
|
||||||
|
fade,
|
||||||
|
);
|
||||||
|
row_index += 1;
|
||||||
|
|
||||||
|
// 2. Time bonus.
|
||||||
|
if breakdown.shows_time_bonus_row() {
|
||||||
|
spawn_breakdown_row(
|
||||||
|
card,
|
||||||
|
&format!("Time bonus ({})", format_win_time(breakdown.time_seconds)),
|
||||||
|
format!("+{}", breakdown.time_bonus),
|
||||||
|
STATE_SUCCESS,
|
||||||
|
anim_speed,
|
||||||
|
stagger * row_index as f32,
|
||||||
|
fade,
|
||||||
|
);
|
||||||
|
row_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. No-undo bonus.
|
||||||
|
if breakdown.shows_no_undo_row() {
|
||||||
|
spawn_breakdown_row(
|
||||||
|
card,
|
||||||
|
"No-undo bonus",
|
||||||
|
format!("+{}", breakdown.no_undo_bonus),
|
||||||
|
STATE_SUCCESS,
|
||||||
|
anim_speed,
|
||||||
|
stagger * row_index as f32,
|
||||||
|
fade,
|
||||||
|
);
|
||||||
|
row_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Mode multiplier (only when not 1.0).
|
||||||
|
if breakdown.shows_multiplier_row() {
|
||||||
|
let mode_name = mode_display_name(breakdown.mode);
|
||||||
|
spawn_breakdown_row(
|
||||||
|
card,
|
||||||
|
&format!("Mode multiplier ({mode_name} ×{:.1})", breakdown.multiplier),
|
||||||
|
format!("×{:.1}", breakdown.multiplier),
|
||||||
|
STATE_INFO,
|
||||||
|
anim_speed,
|
||||||
|
stagger * row_index as f32,
|
||||||
|
fade,
|
||||||
|
);
|
||||||
|
row_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Separator — em-dashes spanning the visual width.
|
||||||
|
spawn_breakdown_row(
|
||||||
|
card,
|
||||||
|
"─────────────────",
|
||||||
|
"─────".to_string(),
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
anim_speed,
|
||||||
|
stagger * row_index as f32,
|
||||||
|
fade,
|
||||||
|
);
|
||||||
|
row_index += 1;
|
||||||
|
|
||||||
|
// 6. Total — emphasised in primary accent.
|
||||||
|
spawn_breakdown_row(
|
||||||
|
card,
|
||||||
|
"Total",
|
||||||
|
format!("{}", breakdown.total()),
|
||||||
|
ACCENT_PRIMARY,
|
||||||
|
anim_speed,
|
||||||
|
stagger * row_index as f32,
|
||||||
|
fade,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns one row of the score breakdown — a flex-row `Node` with two
|
||||||
|
/// `Text` children (label left, value right). The row is tagged with
|
||||||
|
/// [`ScoreBreakdownRow`] and starts hidden when `anim_speed` is anything
|
||||||
|
/// other than [`AnimSpeed::Instant`]; the [`reveal_score_breakdown`]
|
||||||
|
/// system flips it visible after `delay_secs` and fades in the text
|
||||||
|
/// over `fade_duration_secs`.
|
||||||
|
fn spawn_breakdown_row(
|
||||||
|
card: &mut ChildSpawnerCommands,
|
||||||
|
label: &str,
|
||||||
|
value: String,
|
||||||
|
value_color: Color,
|
||||||
|
anim_speed: AnimSpeed,
|
||||||
|
delay_secs: f32,
|
||||||
|
fade_duration_secs: f32,
|
||||||
|
) {
|
||||||
|
// Under Instant, every row is visible immediately at full opacity.
|
||||||
|
let instant = matches!(anim_speed, AnimSpeed::Instant);
|
||||||
|
let initial_visibility = if instant {
|
||||||
|
Visibility::Inherited
|
||||||
|
} else {
|
||||||
|
Visibility::Hidden
|
||||||
|
};
|
||||||
|
let initial_alpha = if instant { 1.0 } else { 0.0 };
|
||||||
|
|
||||||
|
let label_color_with_alpha = TEXT_PRIMARY.with_alpha(initial_alpha);
|
||||||
|
let value_color_with_alpha = value_color.with_alpha(initial_alpha);
|
||||||
|
|
||||||
|
card.spawn((
|
||||||
|
ScoreBreakdownRow {
|
||||||
|
delay_secs,
|
||||||
|
fade_elapsed_secs: 0.0,
|
||||||
|
fade_duration_secs,
|
||||||
|
revealed: instant,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
min_width: Val::Px(280.0),
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
justify_content: JustifyContent::SpaceBetween,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
initial_visibility,
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
// Label — left-aligned.
|
||||||
|
row.spawn((
|
||||||
|
Text::new(label.to_string()),
|
||||||
|
TextFont { font_size: TYPE_BODY, ..default() },
|
||||||
|
TextColor(label_color_with_alpha),
|
||||||
|
));
|
||||||
|
// Value — right-aligned via the parent's JustifyContent::SpaceBetween.
|
||||||
|
row.spawn((
|
||||||
|
Text::new(value),
|
||||||
|
TextFont { font_size: TYPE_BODY, ..default() },
|
||||||
|
TextColor(value_color_with_alpha),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reveal system — ticks each [`ScoreBreakdownRow`] down toward zero
|
||||||
|
/// and fades its child `Text` alpha from 0 → 1 over the row's
|
||||||
|
/// `fade_duration_secs` once `delay_secs` elapses.
|
||||||
|
///
|
||||||
|
/// The system is non-blocking: the Play Again button is interactable
|
||||||
|
/// from the moment the modal spawns; the breakdown reveal just plays
|
||||||
|
/// out underneath. Rows that have already reached full opacity are
|
||||||
|
/// skipped via the `revealed` flag plus an early
|
||||||
|
/// `fade_elapsed >= fade_duration` short-circuit on the alpha lerp.
|
||||||
|
pub fn reveal_score_breakdown(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut rows: Query<(&mut ScoreBreakdownRow, &mut Visibility, Option<&Children>)>,
|
||||||
|
mut texts: Query<&mut TextColor>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (mut row, mut visibility, children) in &mut rows {
|
||||||
|
if !row.revealed {
|
||||||
|
row.delay_secs -= dt;
|
||||||
|
if row.delay_secs <= 0.0 {
|
||||||
|
*visibility = Visibility::Inherited;
|
||||||
|
row.revealed = true;
|
||||||
|
} else {
|
||||||
|
continue; // still hidden, no fade work yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Row is revealed — drive the fade-in until it's fully opaque.
|
||||||
|
let fade_done = row.fade_elapsed_secs >= row.fade_duration_secs;
|
||||||
|
if !fade_done {
|
||||||
|
row.fade_elapsed_secs += dt;
|
||||||
|
}
|
||||||
|
let t = if row.fade_duration_secs <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
(row.fade_elapsed_secs / row.fade_duration_secs).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
let target_alpha = if fade_done { 1.0 } else { t };
|
||||||
|
if let Some(children) = children {
|
||||||
|
for child in children.iter() {
|
||||||
|
if let Ok(mut tc) = texts.get_mut(child) {
|
||||||
|
let c = tc.0;
|
||||||
|
if (c.alpha() - target_alpha).abs() > f32::EPSILON {
|
||||||
|
tc.0 = c.with_alpha(target_alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -662,6 +1075,8 @@ mod tests {
|
|||||||
assert!(p.xp_detail.is_empty());
|
assert!(p.xp_detail.is_empty());
|
||||||
assert!(!p.new_record);
|
assert!(!p.new_record);
|
||||||
assert!(p.challenge_level.is_none());
|
assert!(p.challenge_level.is_none());
|
||||||
|
assert_eq!(p.undo_count, 0);
|
||||||
|
assert_eq!(p.mode, GameMode::Classic);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -941,4 +1356,208 @@ mod tests {
|
|||||||
"challenge_level must be None for non-Challenge wins"
|
"challenge_level must be None for non-Challenge wins"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Score-breakdown tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `cache_win_data` captures both `undo_count` and `mode` from the
|
||||||
|
/// `GameStateResource` at the moment of `GameWonEvent`. The breakdown
|
||||||
|
/// reveal needs both fields to format the no-undo-bonus and
|
||||||
|
/// mode-multiplier rows.
|
||||||
|
#[test]
|
||||||
|
fn cache_win_data_captures_undo_count_and_mode() {
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
|
||||||
|
let mut app = make_app();
|
||||||
|
// Set up a Zen-mode game with 2 undos used.
|
||||||
|
{
|
||||||
|
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
|
game.0 = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Zen);
|
||||||
|
game.0.undo_count = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let pending = app.world().resource::<WinSummaryPending>();
|
||||||
|
assert_eq!(pending.undo_count, 2);
|
||||||
|
assert_eq!(pending.mode, GameMode::Zen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ScoreBreakdown::compute` produces the expected per-component
|
||||||
|
/// values for a non-trivial Classic-mode win. Time-bonus is the
|
||||||
|
/// canonical `compute_time_bonus(120) = 5833` (700_000 / 120) and
|
||||||
|
/// the no-undo bonus fires because `undo_count == 0`.
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_compute_produces_expected_components() {
|
||||||
|
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic);
|
||||||
|
assert_eq!(bd.base, 3200);
|
||||||
|
assert_eq!(bd.time_bonus, 5833); // 700_000 / 120
|
||||||
|
assert_eq!(bd.no_undo_bonus, SCORE_NO_UNDO_BONUS);
|
||||||
|
assert!((bd.multiplier - 1.0).abs() < f32::EPSILON);
|
||||||
|
// Classic ×1.0 → multiplier row is suppressed.
|
||||||
|
assert!(!bd.shows_multiplier_row());
|
||||||
|
// Total == base + time_bonus + no_undo_bonus.
|
||||||
|
assert_eq!(bd.total(), 3200 + 5833 + SCORE_NO_UNDO_BONUS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zen-mode wins produce a zero multiplier — the breakdown shows
|
||||||
|
/// the multiplier row and the total collapses to zero regardless
|
||||||
|
/// of the other components.
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_zen_mode_zeros_total() {
|
||||||
|
let bd = ScoreBreakdown::compute(500, 60, 0, GameMode::Zen);
|
||||||
|
assert!((bd.multiplier - 0.0).abs() < f32::EPSILON);
|
||||||
|
assert!(bd.shows_multiplier_row(), "Zen ×0 must display the multiplier row");
|
||||||
|
assert_eq!(bd.total(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the player used undo, the `no_undo_bonus` is zero and the
|
||||||
|
/// row is suppressed.
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_skips_no_undo_row_when_undo_was_used() {
|
||||||
|
let bd = ScoreBreakdown::compute(100, 60, 1, GameMode::Classic);
|
||||||
|
assert_eq!(bd.no_undo_bonus, 0);
|
||||||
|
assert!(!bd.shows_no_undo_row());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At `time_seconds == 0` the time-bonus formula yields 0; the row
|
||||||
|
/// is suppressed.
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_skips_time_bonus_row_when_zero() {
|
||||||
|
let bd = ScoreBreakdown::compute(100, 0, 0, GameMode::Classic);
|
||||||
|
assert_eq!(bd.time_bonus, 0);
|
||||||
|
assert!(!bd.shows_time_bonus_row());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `row_count()` reports the number of rows the renderer will
|
||||||
|
/// spawn. A non-trivial Classic win with both bonuses produces:
|
||||||
|
/// base + time + no-undo + separator + total = 5 rows (no
|
||||||
|
/// multiplier row, ×1.0 is suppressed).
|
||||||
|
#[test]
|
||||||
|
fn win_modal_score_breakdown_spawns_one_row_per_component() {
|
||||||
|
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic);
|
||||||
|
assert_eq!(
|
||||||
|
bd.row_count(),
|
||||||
|
5,
|
||||||
|
"Classic with both bonuses: base + time + no-undo + sep + total"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zen with both bonuses ALSO shows the multiplier row.
|
||||||
|
let zen = ScoreBreakdown::compute(3200, 120, 0, GameMode::Zen);
|
||||||
|
assert_eq!(
|
||||||
|
zen.row_count(),
|
||||||
|
6,
|
||||||
|
"Zen with both bonuses: base + time + no-undo + multiplier + sep + total"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When `no_undo_bonus == 0`, the row count drops by one.
|
||||||
|
#[test]
|
||||||
|
fn win_modal_score_breakdown_skips_zero_bonus_rows() {
|
||||||
|
let bd_with = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic);
|
||||||
|
let bd_without = ScoreBreakdown::compute(3200, 120, 1, GameMode::Classic);
|
||||||
|
assert_eq!(
|
||||||
|
bd_with.row_count() - 1,
|
||||||
|
bd_without.row_count(),
|
||||||
|
"removing the no-undo bonus must remove exactly one row"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper test: the reveal logic uses delta-time to count
|
||||||
|
/// down `delay_secs`; at `t = 0` only the first row is "revealed",
|
||||||
|
/// and after one stagger interval the second row reveals as well.
|
||||||
|
/// We exercise the system directly on a hand-built world rather
|
||||||
|
/// than going through the full modal-spawn path so the test is
|
||||||
|
/// independent of `Time` resource quirks.
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_reveal_advances_visibility_per_stagger() {
|
||||||
|
use bevy::time::TimePlugin;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins.build().disable::<TimePlugin>());
|
||||||
|
app.init_resource::<Time>();
|
||||||
|
app.add_systems(Update, reveal_score_breakdown);
|
||||||
|
|
||||||
|
// Spawn three rows with delays of 0.0, 0.15, and 0.30 s.
|
||||||
|
let stagger = MOTION_SCORE_BREAKDOWN_STAGGER_SECS;
|
||||||
|
let fade = MOTION_SCORE_BREAKDOWN_FADE_SECS;
|
||||||
|
let row0 = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
ScoreBreakdownRow {
|
||||||
|
delay_secs: 0.0,
|
||||||
|
fade_elapsed_secs: 0.0,
|
||||||
|
fade_duration_secs: fade,
|
||||||
|
revealed: false,
|
||||||
|
},
|
||||||
|
Visibility::Hidden,
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
let row1 = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
ScoreBreakdownRow {
|
||||||
|
delay_secs: stagger,
|
||||||
|
fade_elapsed_secs: 0.0,
|
||||||
|
fade_duration_secs: fade,
|
||||||
|
revealed: false,
|
||||||
|
},
|
||||||
|
Visibility::Hidden,
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
let row2 = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
ScoreBreakdownRow {
|
||||||
|
delay_secs: stagger * 2.0,
|
||||||
|
fade_elapsed_secs: 0.0,
|
||||||
|
fade_duration_secs: fade,
|
||||||
|
revealed: false,
|
||||||
|
},
|
||||||
|
Visibility::Hidden,
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
// Frame 1: `time.delta` is 0 (first frame), so only row0
|
||||||
|
// (delay = 0) should reveal.
|
||||||
|
app.update();
|
||||||
|
assert!(app.world().entity(row0).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||||
|
assert!(!app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||||
|
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||||
|
|
||||||
|
// Advance time by one stagger interval — row1 should reveal.
|
||||||
|
{
|
||||||
|
let mut time = app.world_mut().resource_mut::<Time>();
|
||||||
|
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
assert!(app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||||
|
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||||
|
|
||||||
|
// Advance again — row2 should reveal.
|
||||||
|
{
|
||||||
|
let mut time = app.world_mut().resource_mut::<Time>();
|
||||||
|
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
assert!(app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Under `AnimSpeed::Instant`, breakdown rows must spawn already
|
||||||
|
/// revealed and at full opacity — there should be no stagger
|
||||||
|
/// reveal animation at all.
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_instant_speed_skips_stagger() {
|
||||||
|
// Helper: simulate what `spawn_breakdown_row` constructs by
|
||||||
|
// checking the `instant` branch behaviour. Specifically: under
|
||||||
|
// Instant, scaled_duration → 0.0, so the row's stagger and
|
||||||
|
// fade are both zero.
|
||||||
|
let stagger = scaled_duration(MOTION_SCORE_BREAKDOWN_STAGGER_SECS, AnimSpeed::Instant);
|
||||||
|
let fade = scaled_duration(MOTION_SCORE_BREAKDOWN_FADE_SECS, AnimSpeed::Instant);
|
||||||
|
assert_eq!(stagger, 0.0);
|
||||||
|
assert_eq!(fade, 0.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user