Compare commits

...

7 Commits

Author SHA1 Message Date
funman300 fe41b502ac docs: CHANGELOG + SESSION_HANDOFF refresh for v0.13.0
CHANGELOG gains a [0.13.0] section covering the third UX iteration
round on top of v0.12.0:
- Tooltip-delay slider, streak fire, score-breakdown reveal
- Card backs follow active theme
- Drag-with-keyboard
- Right-click radial menu
Plus two code-review fixes (Removed: sccache wiring, Fixed: bundled-
only font handling).

The bottom-of-file compare links thread the new tag into the
existing chain. Test count updated to 1053.

SESSION_HANDOFF gains a "Session 7 round 3" table summarising the
six commits and rolls the punch list forward — UX candidate list
exhausted again, fresh six-item list seeded for a future round
(daily-challenge calendar, theme-picker thumbnails, per-mode high
scores, in-progress auto-save for Zen/Time Attack, configurable
scoring weights, win replays). Resume prompt asks A/B/C/D about
push, smoke-test, next round, or packaging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:43:38 +00:00
funman300 b37f0cbec7 feat(engine): right-click radial menu for quick-drop without dragging
Power-user shortcut: hold right-click on a face-up card, a small
ring of icons appears around the cursor with one entry per legal
destination, release over an icon to fire MoveRequestEvent. Skips
the drag motion entirely while preserving the existing
RightClickHighlight tint on the actual pile markers.

A new RadialMenuPlugin owns the flow. RightClickRadialState is a
two-state enum (Idle / Active) carrying the source pile, lifted
cards, pre-computed legal destinations + their world anchors, the
ring centre, and the currently hovered icon index. Four chained
systems handle press → cursor track → release/cancel → redraw, in
that order so a single-tick test can't observe a half-state.

Mutual exclusion with the left-button mouse drag is implicit —
RadialMenuPlugin only listens to MouseButton::Right while the
existing drag pipeline only listens to Left. RightClickHighlight
co-exists at a lower z (50) than the radial overlay (Z_RADIAL_MENU
= 60), so the brief pile-marker tint reads as the same set of legal
destinations the radial offers.

Cancel paths: release the right button outside any icon, press Esc,
or press the left button. All three reset state to Idle without
dispatching a move.

Visual: a centre dot at the press location plus N icons at radius
80 px around it. For one destination the icon sits at 12 o'clock;
for N icons they spread evenly clockwise. Hovered icon scales to
1.15× and tints STATE_SUCCESS so the focused choice is unambiguous.

Twelve new tests pin the contract — five system-level (open on
press over face-up card, release over destination fires move event,
release in dead space cancels, Esc cancels, face-down doesn't
open), seven on the pure helpers (radial_anchor_for_index,
radial_hovered_index, legal_destinations_for_card). Tests inject
cursor positions through a RadialCursorOverride resource so they
work under MinimalPlugins where there's no PrimaryWindow or Camera.

help_plugin's controls reference gains a new "Mouse" section
covering double-click auto-move, right-click highlight, and the
new "Hold RMB" radial. Onboarding slide 3 is intentionally left
keyboard-only — the radial is a power-user discovery, not a
first-run teach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:40:48 +00:00
funman300 a0fc0d2605 feat(engine): keyboard-only drag-and-drop via Tab → Enter → arrows → Enter
Players can now complete an entire game without a mouse. Tab cycles
the keyboard cursor across draggable card stacks, Enter "lifts" the
focused stack into a destination-pick mode, arrow keys (or Tab)
cycle through the legal targets only, and Enter confirms the move.
Esc cancels — single-press in Lifted reverts to source-pick keeping
focus, second-press clears the source selection entirely.

A new KeyboardDragState resource models the two-mode flow without
touching SelectionState's existing source-pick contract:

  Idle                         (Tab/Enter/auto-move via SelectionState)
  Lifted {
      source_pile, count, cards,
      legal_destinations,      pre-computed at lift time via
      destination_index,       can_place_on_foundation/_tableau
  }

Mutual exclusion with mouse drag is sentinel-based: keyboard lift
sets DragState.active_touch_id = u64::MAX (KEYBOARD_DRAG_TOUCH_ID),
existing mouse handlers in input_plugin already short-circuit when
active_touch_id is Some, and the cleanup path only clears DragState
when the sentinel is present so the mouse path is never stomped.
Conversely keyboard input is suppressed when a real mouse/touch
drag is active.

The visual lift reuses the existing drag z-lift and shadow path so
the keyboard-lifted stack reads the same as a mouse-lifted one;
update_selection_highlight gains a green destination indicator on
the focused legal target while Lifted.

help_plugin's canonical hotkey list grows a "Keyboard drag"
section (Tab/Enter/Arrows/Esc/Space) and onboarding slide 3 picks
up a "Tab → Enter" entry so first-run players see the full path.

Seven new headless tests pin the contract: Tab cycles to first
draggable pile, Enter lifts the stack, arrow keys cycle only legal
destinations, Enter with destination fires MoveRequestEvent and
clears state, Esc reverts to source-pick, mouse-drag-active
suppresses keyboard input, double-Esc clears source selection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:10:41 +00:00
funman300 7ed4f2cba9 feat(engine): card backs follow active theme
Themes already shipped a back.svg in their manifest but card_plugin
ignored it — face-down cards always rendered with the legacy
back_N.png picker, so swapping themes only swapped the faces. Now
the active theme's back rasterises alongside its faces and feeds
into the face-down sprite path; the legacy back_N.png picker remains
the fallback when a theme doesn't ship its own back (e.g. a
user-imported theme that only redefines faces).

theme/plugin.rs caches the active theme's back Handle<Image> in the
ActiveTheme resource on theme-load and theme-switch. card_plugin's
face-down branch reads ActiveTheme first; missing theme back →
legacy back_N.png path indexed by Settings.selected_card_back.

Settings → Cosmetic's card-back picker section gains a caption
("Active theme provides its own back") that surfaces when the
override is in effect, plus the swatch row dims to communicate the
read-only state. Settings file format unchanged — selected_card_back
still round-trips and only takes effect when the theme leaves the
back undefined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:08:17 +00:00
funman300 ddc8f27c82 feat(engine): UX iteration round — tooltip slider, streak fire, score breakdown
Three small UX improvements bundled because they share ui_theme token
edits.

Tooltip-delay slider in Settings → Gameplay
- Settings.tooltip_delay_secs (f32, #[serde(default)] = 0.5) tunable
  via "−" / "+" icon buttons next to a value readout. Range
  [TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS] = [0.0, 1.5] in
  TOOLTIP_DELAY_STEP_SECS (0.1) increments. "Instant" label when
  value is 0; "{n:.1} s" otherwise.
- ui_tooltip's hover-delay comparison reads from SettingsResource
  with MOTION_TOOLTIP_DELAY_SECS as the fallback when the resource
  is absent (test path). New tooltip_should_show(elapsed, delay)
  pure helper covers the boundary cases.
- adjust_tooltip_delay clamps; sanitized() carries the clamp through
  load. Five round-trip / default / legacy-deserialise tests.

Win-streak milestone fire animation
- New WinStreakMilestoneEvent { streak: u32 } fired from stats_plugin
  when win_streak_current crosses any of [3, 5, 10] (only the
  threshold crossing — not every subsequent win). HUD streak readout
  scale-pulses 1.0 → 1.20 → 1.0 over MOTION_STREAK_FLOURISH_SECS
  (0.6 s) on receipt; mirrors the foundation-flourish curve shape.
- Three threshold-crossing tests pin the firing contract.

Score-breakdown reveal on the win modal
- Win modal body replaces the single "Score: N" line with a
  per-component reveal: Base score, Time bonus (m:ss), No-undo
  bonus, Mode multiplier, separator, Total. Rows fade in over
  MOTION_SCORE_BREAKDOWN_FADE_SECS (0.12 s) staggered by
  MOTION_SCORE_BREAKDOWN_STAGGER_SECS (0.15 s) so the math reads as
  it animates. Skipped rows: zero time bonus, undo-tainted no-undo
  bonus, multiplier == 1.0.
- Honours AnimSpeed::Instant: rows spawn fully visible, no stagger.
- New ScoreBreakdown::compute helper sources base from
  GameWonEvent.score, time bonus from
  solitaire_core::scoring::compute_time_bonus, no-undo from a +25
  constant when undo_count == 0, mode multiplier from GameMode (Zen
  zeros the total). 9 new tests cover the math and the reveal
  cadence.

Test count net: +25 across the workspace (1007 → 1031).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:34:53 +00:00
funman300 13dd44bd1b chore: remove project-level sccache rustc-wrapper config
Code-review feedback: sccache shouldn't be a per-project build
dependency. Cargo's incremental cache already covers what sccache
offers for a single project, and forcing rustc-wrapper = "sccache"
project-wide means every contributor has to install sccache or
prepend RUSTC_WRAPPER= to bypass the wrapper.

.cargo/config.toml only existed to wire sccache and pin SCCACHE_DIR
to a project-local cache. Removing the file entirely so plain
`cargo build` works without any extra setup. The .cargo directory
is empty after the deletion and removed too. .gitignore's
/.sccache-cache line is harmless cruft and stays — players who
already have a populated .sccache-cache directory keep it ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:34:20 +00:00
funman300 17f9b518f1 fix(engine): bundle fonts only and drop system-font fallback
Code-review feedback: the SVG rasteriser mixed three font-resolution
layers (load_system_fonts + bundled FiraMono + lenient resolver
appending CSS generics), which made card text rendering depend on
which fonts the host machine happened to have. The Bevy UI face
loaded separately at runtime via AssetServer. Picking option (a)
from the review and applying it consistently: bundle FiraMono via
include_bytes!() in BOTH layers, no system fallback anywhere.

solitaire_engine/src/font_plugin.rs now embeds main.ttf at compile
time and registers it with Assets<Font>. A parse failure aborts
with "bundled FiraMono failed to parse — binary is corrupt"; the
MinimalPlugins early-return stays as a "this plugin doesn't apply
in headless tests" check (consumers query Option<Res<FontResource>>
and degrade cleanly), not a production fallback.

solitaire_engine/src/assets/svg_loader.rs drops load_system_fonts
entirely, drops the lenient_font_resolver, and drops the five
set_*_family pins. The new bundled_font_resolver ignores the SVG's
font-family request and always returns the single bundled face —
the bundled card SVGs reference Arial / Bitstream Vera Sans by name
and we deliberately don't ship those, so routing every query to
FiraMono keeps rasterisation deterministic. shared_fontdb asserts
the embedded bytes parsed.

The two layers now embed the same path
(assets/fonts/main.ttf) independently, so they can't drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:33:54 +00:00
22 changed files with 3632 additions and 343 deletions
-31
View File
@@ -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 }
+90 -1
View File
@@ -8,6 +8,94 @@ project follows [Semantic Versioning](https://semver.org/).
_Nothing yet._ _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 ## [0.12.0] — 2026-05-02
UX feel polish round on top of v0.11.0. Six small-but-tangible UX feel polish round on top of v0.11.0. Six small-but-tangible
@@ -224,7 +312,8 @@ with no PNG artwork yet.
CREDITS.md, persistent window geometry, mode-launcher Home repurpose, CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
client-side sync round-trip integration tests. client-side sync round-trip integration tests.
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...HEAD [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.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.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.10.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.9.0...v0.10.0
+40 -54
View File
@@ -1,82 +1,67 @@
# Solitaire Quest — UX Overhaul Session Handoff # Solitaire Quest — UX Overhaul Session Handoff
**Last updated:** 2026-05-02 (session 7, late) — Second UX iteration round complete. Six small UX feel items shipped on top of v0.11.0 and the README/CHANGELOG refresh that should have ridden along. Ready to tag v0.12.0. **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:** `7dba772` plus the impending CHANGELOG/handoff doc commits. Local master is **3 commits ahead** of `origin/master` (`9887343`, `ca5788f`, `7dba772` unpushed); doc commits to follow. - **HEAD:** doc-commit closing this round (CHANGELOG + handoff). Local master has the impending tag at this commit.
- **Working tree:** clean apart from this doc + CHANGELOG, both intentional. - **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:** **1007 passed / 0 failed** across the workspace (+25 from session 7 morning's 982 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`, `v0.11.0`. Local has `v0.11.0` too. v0.12.0 is the next tag. - **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
v0.11.0 shipped the headline structural changes (card themes, HUD overhaul, four UX feel wins, font fallback). The second UX round — six smaller items — is also done now. v0.12.0 is the right slice for them; together with the README refresh and CHANGELOG add it makes a clean release. 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 post-v0.11.0 UX candidate list is exhausted. Direction is open again. 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. (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 round 1 (shipped 2026-05-02 morning) — v0.11.0 ## Session 7 round 3 (shipped 2026-05-02 late-late) — v0.13.0
| Area | Commit | What landed | | Area | Commit | What landed |
|---|---|---| |---|---|---|
| Font fallback | `fdb6c2e` | FiraMono bundled into the SVG fontdb so cards render rank/suit text on machines without Bitstream Vera Sans / Arial. Surfaced when a second-machine pull lost glyphs. | | 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)` with claim derived from the bottom card. Save schema 1 → 2; pre-v2 saves silently fall through to fresh game. | | 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` | Soft fill + 3 px outline drawn ABOVE stacked cards for every legal target during drag. Replaces the hidden pile-marker tint. | | Wave 1 bundle | `ddc8f27` | **Tooltip-delay slider** in Settings → Gameplay (0.01.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 card casts a 25 % black shadow; lifts to 40 % with bigger offset/halo while in the active drag set. | | 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` | "·N" chip at the top-right of the stock so players can see how close they are to a recycle. Hides at zero. | | 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. |
Tagged as `v0.11.0` (commit `063269c` plus URL refresh).
## Session 7 round 2 (shipped 2026-05-02 afternoon) — v0.12.0
| Area | Commit | What landed |
|---|---|---|
| Aspect ratio | `13aa0fd` | `CARD_ASPECT` 1.4 → 1.4523 to match hayeah SVG dimensions; cards no longer ~3.6 % squashed. Vertical-budget math adapts via the constant. |
| Foundation flourish | `69ce9af` | King-on-foundation celebration: scale-pulse on the King, marker tints `STATE_SUCCESS`, synthesised C6→E6→G6 bell ping (~240 ms). New `FoundationCompletedEvent`. |
| Drag-cancel tween | `525fe0f` | Illegal drops glide each card back to its origin over 150 ms with a quintic ease-out (Responsive curve, zero overshoot). Audio cue still fires. ShakeAnim retained for non-drag rejection paths. |
| Focus pulse | `9887343` | Focus ring breathes at 1.4 s sin period over [0.65, 1.0] of native alpha. Static under `AnimSpeed::Instant`. |
| Achievement onboarding | `ca5788f` | First-win toast "First win! Press A to see your achievements." plus persisted `shown_achievement_onboarding` flag so the cue fires exactly once. |
| Mode Launcher shortcuts | `7dba772` | Digit 15 inside the Mode Launcher launches Classic / Daily / Zen / Challenge / TimeAttack. Locked modes silent no-op. Modal-scoped. |
| Docs (rode along) | `d8c7034`, `9f095c4` | README refresh for v0.11.0 features and corrected controls table; CHANGELOG.md added covering v0.9.0v0.11.0. |
The first three items in this round (`13aa0fd`, `69ce9af`, `525fe0f`) shipped before the v0.11.0 tag's commit window closed; treating them as v0.12.0 since v0.11.0 was already cut at `063269c`.
## Open punch list — release prep ## Open punch list — release prep
1. **Tag v0.12.0** — meaningful slice since v0.11.0: six UX feel items + the README/CHANGELOG refresh. Tag at the doc-commit HEAD that closes this round. 1. **Push** the unpushed commits to origin (5 commits now: 17f9b51, 13dd44b, ddc8f27, 7ed4f2c, a0fc0d2, b37f0cb, plus the impending doc commit).
2. **Push to origin** three-plus commits unpushed. 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 v0.12.0 list is exhausted. Candidates for a future round: The v0.13.0 list is exhausted. Fresh candidates for a future round:
- **Card-back theme support** — the current theme system swaps face SVGs but not the back. Players asked for animated backs in passing. - **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.
- **Streak fire animation** in the HUD when win-streak crosses 3, 5, 10. Foundation flourish suggests the per-suit completion pattern; streak milestones are the lifetime equivalent. - **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.
- **Score-breakdown reveal** at win — show base / time-bonus / no-undo bonus / mode multiplier as the score animates up. Currently the win modal just shows the final number. - **Per-mode high-score readout** in the Stats screen. Currently lifetime stats roll all modes together.
- **Right-click radial menu** for power users: hold right-click on a card → quick-drop options without dragging. - **Auto-save in-progress games** in Zen / Time Attack so players who close the window mid-session don't lose their state.
- **Drag-with-keyboard** — Tab to a card, Enter to "lift", arrow keys to choose destination, Enter to drop. Keyboard-only completion of a game. - **Configurable scoring weights** for the curious — Settings → Gameplay slider for time-bonus magnitude. Cosmetic but power-user appealing.
- **Settings: tooltip-delay slider** so power users can disable the 0.5 s hover delay. Cheap. - **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` in v0.11.0: 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 and atomically unpacks. - **Importer** at `solitaire_engine::theme::import_theme(zip)` validates archives and atomically unpacks.
- **Picker UI** in Settings → Cosmetic. - **Picker UI** in Settings → Cosmetic; the active theme's `back` overrides the legacy `back_N.png` picker when present.
## Resume prompt ## Resume prompt
@@ -84,14 +69,14 @@ Seven phases landed across `b8fb3fb` → `924a1e2` in v0.11.0:
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 — both UX iteration rounds shipped Branch: master. Direction is OPEN — three UX iteration rounds shipped
and v0.12.0 is ready to tag. and v0.13.0 is ready to tag.
State: HEAD at the doc-commit closing session 7 round 2. Local master State: HEAD at the doc-commit closing session 7 round 3. Local master
is several commits ahead of origin and unpushed. Working tree clean is several commits ahead of origin and unpushed. Working tree clean
apart from untracked CARD_PLAN.md (intentional). 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: 1007 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
@@ -103,11 +88,12 @@ READ FIRST (in order, before doing anything):
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 unpushed commits and cut v0.12.0 now. A. Push and cut v0.13.0 now.
B. Smoke-test the new feel layer first (foundation flourish, drag B. Smoke-test the new feel layer first (theme-aware backs, keyboard
tween, focus pulse, mode digits), then tag. drag, right-click radial, score-breakdown reveal, streak fire,
tooltip-delay slider), then tag.
C. Skip the tag for another iteration round — see "next-round C. Skip the tag for another iteration round — see "next-round
candidates" in SESSION_HANDOFF for ideas. candidates" in SESSION_HANDOFF for fresh ideas.
D. Take the deferred desktop-packaging item (needs artwork + D. Take the deferred desktop-packaging item (needs artwork +
signing certs from the user). signing certs from the user).
+4 -3
View File
@@ -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)
+1 -1
View File
@@ -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;
+128 -2
View File
@@ -143,6 +143,14 @@ pub struct Settings {
/// so the toast still does not fire for them. /// so the toast still does not fire for them.
#[serde(default)] #[serde(default)]
pub shown_achievement_onboarding: bool, 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 {
@@ -161,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 {
@@ -177,17 +205,22 @@ impl Default for Settings {
window_geometry: None, window_geometry: None,
selected_theme_id: default_theme_id(), selected_theme_id: default_theme_id(),
shown_achievement_onboarding: false, 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
} }
} }
@@ -203,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
@@ -253,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]
@@ -331,6 +374,7 @@ mod tests {
window_geometry: None, window_geometry: None,
selected_theme_id: "default".to_string(), selected_theme_id: "default".to_string(),
shown_achievement_onboarding: false, 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);
@@ -563,4 +607,86 @@ mod tests {
"legacy settings.json missing shown_achievement_onboarding must deserialize to false" "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);
}
} }
+32 -87
View File
@@ -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 (~50200 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(),
} }
} }
+160 -2
View File
@@ -76,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 04). /// One handle per unlockable card-back design (indices 04). 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
@@ -370,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
@@ -407,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()
@@ -2542,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",
);
}
} }
+20
View File
@@ -83,6 +83,26 @@ pub struct FoundationCompletedEvent {
pub suit: Suit, 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);
+23 -12
View File
@@ -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));
} }
+22
View File
@@ -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: &[
+238 -4
View File
@@ -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
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+12 -4
View File
@@ -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;
@@ -89,27 +90,34 @@ pub use events::{
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,6 +93,7 @@ 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 15 to pick)" }, HotkeyRow { keys: "M", description: "Open Mode Launcher (then 15 to pick)" },
HotkeyRow { keys: "S", description: "Stats & progression" }, HotkeyRow { keys: "S", description: "Stats & progression" },
+943
View File
@@ -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"
);
}
}
+778 -61
View File
@@ -1,24 +1,45 @@
//! Keyboard-driven card selection (Task #68). //! Keyboard-driven card selection and full keyboard drag-and-drop.
//! //!
//! Pressing `Tab` cycles through piles that have a face-up draggable top card. //! ## Two-mode flow
//! Pressing `Enter` or `Space` fires a [`MoveRequestEvent`] to the best
//! available destination using the following priority order, then clears the
//! selection:
//! //!
//! 1. Move the top card to its best foundation (count = 1). //! Selection works as a small state machine across two resources:
//! 2. Move the full face-up run from the selected tableau pile to the best
//! tableau destination (count = run length). Single-card stacks from
//! non-tableau piles fall back to [`best_destination`] for tableau targets.
//! //!
//! Pressing `Escape` clears the selection without moving. //! 1. [`SelectionState`] tracks the *source-pick* mode. `Tab` / `Shift+Tab`
//! cycles a focus through piles that have a face-up draggable top card.
//! The focused card is decorated with a cyan [`SelectionHighlight`].
//! //!
//! The selected card is highlighted by a cyan [`SelectionHighlight`] outline //! 2. [`KeyboardDragState`] tracks the *destination-pick* mode. Pressing
//! sprite parented to the selected card entity. The highlight is despawned when //! `Enter` while a pile is focused enters
//! the selection is cleared. //! [`KeyboardDragState::Lifted`] — the cards are visually "lifted" by
//! populating [`crate::resources::DragState`] (cards / origin_pile /
//! cursor_offset / origin_z / `active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID)`
//! sentinel so mouse handlers ignore the keyboard-driven drag), and the
//! arrow keys (or `Tab` / `Shift+Tab`) cycle through *legal* destination
//! piles only. A second `Enter` confirms the move; `Esc` cancels back to
//! source-pick mode.
//!
//! ## Mutual exclusion with mouse drag
//!
//! While a mouse drag is in progress (`DragState` non-empty *and* not the
//! keyboard sentinel) all keyboard input is ignored. Conversely, while the
//! keyboard drag is active, mouse handlers in `input_plugin` short-circuit
//! because they check `DragState.is_idle()` before starting a new drag and
//! the mouse-up / drag-update systems explicitly skip `DragState` entries
//! whose `active_touch_id.is_some()`.
//!
//! ## Why a separate resource
//!
//! Keeping the lift state out of `SelectionState` lets `Esc` cancel the
//! lift without losing the source focus — a single Esc reverts to
//! source-pick, a second Esc clears the source focus. It also lets HUD
//! widgets that already read `SelectionState::selected_pile` keep working
//! unchanged whether the player is in source-pick or destination-pick mode.
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent}; use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
@@ -26,7 +47,7 @@ use crate::game_plugin::GameMutation;
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack}; use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
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::{DragState, GameStateResource};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public types // Public types
@@ -41,6 +62,70 @@ pub struct SelectionState {
pub selected_pile: Option<PileType>, pub selected_pile: Option<PileType>,
} }
/// Sentinel value used in [`crate::resources::DragState::active_touch_id`]
/// to mark a `DragState` populated by the keyboard-drag flow rather than a
/// real mouse or touch drag.
///
/// Mouse handlers in `input_plugin` already skip `DragState` entries whose
/// `active_touch_id.is_some()`, so this value provides clean mutual
/// exclusion without changing `DragState`'s shape.
pub const KEYBOARD_DRAG_TOUCH_ID: u64 = u64::MAX;
/// Two-state machine for the keyboard drag flow. `Idle` is the resting
/// state; while `Lifted`, the player is choosing a destination pile with
/// the arrow keys.
///
/// See the [module-level docs](self) for the full state machine.
#[derive(Resource, Debug, Default, Clone, PartialEq, Eq)]
pub enum KeyboardDragState {
/// No keyboard drag in progress. `Tab` / `Enter` operate on
/// [`SelectionState`].
#[default]
Idle,
/// Source pile is lifted; arrow keys / `Tab` cycle through
/// `legal_destinations` and `Enter` fires the move.
Lifted {
/// Pile the cards were lifted from.
source_pile: PileType,
/// Number of cards lifted (1 for waste / foundation, full face-up
/// run length for a tableau column).
count: usize,
/// Card ids being lifted, in the same bottom-to-top order
/// `DragState.cards` expects.
cards: Vec<u32>,
/// Pre-computed list of piles the lifted stack can legally be
/// placed on. Always at least one entry while in this variant —
/// if no legal destinations exist the state machine refuses to
/// enter `Lifted` in the first place.
legal_destinations: Vec<PileType>,
/// Cursor into `legal_destinations`. Always `< legal_destinations.len()`.
destination_index: usize,
},
}
impl KeyboardDragState {
/// Returns the currently focused destination pile while [`Lifted`], or
/// `None` while [`Idle`].
///
/// [`Lifted`]: KeyboardDragState::Lifted
/// [`Idle`]: KeyboardDragState::Idle
pub fn focused_destination(&self) -> Option<&PileType> {
match self {
Self::Idle => None,
Self::Lifted {
legal_destinations,
destination_index,
..
} => legal_destinations.get(*destination_index),
}
}
/// Returns `true` when the keyboard drag is in the `Lifted` state.
pub fn is_lifted(&self) -> bool {
matches!(self, Self::Lifted { .. })
}
}
/// System set label for the key-handling system. /// System set label for the key-handling system.
/// ///
/// `PausePlugin` registers `toggle_pause` before this set so it can read /// `PausePlugin` registers `toggle_pause` before this set so it can read
@@ -62,6 +147,7 @@ pub struct SelectionPlugin;
impl Plugin for SelectionPlugin { impl Plugin for SelectionPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<SelectionState>() app.init_resource::<SelectionState>()
.init_resource::<KeyboardDragState>()
.add_systems( .add_systems(
Update, Update,
( (
@@ -161,13 +247,33 @@ fn did_wrap(
// Systems // Systems
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Handles Tab / Enter / Space / Escape for keyboard card selection. /// Handles `Tab` / `Enter` / `Space` / arrow keys / `Escape` for keyboard
/// card selection and keyboard drag-and-drop.
///
/// Source-pick mode (`KeyboardDragState::Idle`):
/// - `Tab` / `Shift+Tab` cycles `SelectionState` through draggable piles.
/// - `Enter` lifts the focused pile into `KeyboardDragState::Lifted`.
/// - `Space` is the legacy auto-move accelerator (foundation-first, then
/// best tableau target). Preserved so power users keep their muscle
/// memory; the new lift-and-pick flow is what `Enter` does.
/// - `Esc` clears `SelectionState`.
///
/// Destination-pick mode (`KeyboardDragState::Lifted`):
/// - `ArrowRight` / `ArrowDown` / `Tab` advance to the next legal
/// destination, wrapping at the end.
/// - `ArrowLeft` / `ArrowUp` / `Shift+Tab` move to the previous legal
/// destination.
/// - `Enter` confirms — fires `MoveRequestEvent` and returns to `Idle`.
/// - `Esc` cancels — clears the `DragState` and returns to source-pick
/// mode with `SelectionState` intact.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn handle_selection_keys( fn handle_selection_keys(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
mut selection: ResMut<SelectionState>, mut selection: ResMut<SelectionState>,
mut kbd_drag: ResMut<KeyboardDragState>,
mut drag: ResMut<DragState>,
mut moves: MessageWriter<MoveRequestEvent>, mut moves: MessageWriter<MoveRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>, mut info_toast: MessageWriter<InfoToastEvent>,
) { ) {
@@ -175,6 +281,84 @@ fn handle_selection_keys(
return; return;
} }
// Mutual exclusion with mouse drag — if a real mouse / touch drag is
// running, swallow keyboard input. The keyboard-driven lift uses the
// sentinel `active_touch_id`, so only that case may proceed.
if !drag.is_idle() && drag.active_touch_id != Some(KEYBOARD_DRAG_TOUCH_ID) {
return;
}
// ---------------------------------------------------------------------
// Lifted (destination-pick) mode.
// ---------------------------------------------------------------------
if let KeyboardDragState::Lifted {
source_pile,
count,
cards: _,
legal_destinations,
destination_index,
} = &mut *kbd_drag
{
let shift_held =
keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
// Cycle destinations forward / backward.
let advance = keys.just_pressed(KeyCode::ArrowRight)
|| keys.just_pressed(KeyCode::ArrowDown)
|| (keys.just_pressed(KeyCode::Tab) && !shift_held);
let retreat = keys.just_pressed(KeyCode::ArrowLeft)
|| keys.just_pressed(KeyCode::ArrowUp)
|| (keys.just_pressed(KeyCode::Tab) && shift_held);
if advance {
let n = legal_destinations.len();
if n > 0 {
*destination_index = (*destination_index + 1) % n;
}
return;
}
if retreat {
let n = legal_destinations.len();
if n > 0 {
*destination_index = (*destination_index + n - 1) % n;
}
return;
}
// Confirm — fire MoveRequestEvent.
if keys.just_pressed(KeyCode::Enter) {
if let Some(dest) = legal_destinations.get(*destination_index).cloned() {
moves.write(MoveRequestEvent {
from: source_pile.clone(),
to: dest,
count: *count,
});
}
// Whether or not we fired, leave Lifted: a subsequent
// `StateChangedEvent` will also reset us via
// `clear_selection_on_state_change`, but explicit reset is
// cleaner and lets the state-change clear handle the
// SelectionState side.
*kbd_drag = KeyboardDragState::Idle;
drag.clear();
return;
}
// Cancel back to source-pick mode — keep SelectionState focused.
if keys.just_pressed(KeyCode::Escape) {
*kbd_drag = KeyboardDragState::Idle;
drag.clear();
return;
}
// No other keys do anything while lifted.
return;
}
// ---------------------------------------------------------------------
// Idle (source-pick) mode.
// ---------------------------------------------------------------------
// Build the list of piles that currently have a face-up draggable top card. // Build the list of piles that currently have a face-up draggable top card.
let available: Vec<PileType> = { let available: Vec<PileType> = {
let all = [ let all = [
@@ -222,15 +406,10 @@ fn handle_selection_keys(
return; return;
} }
// Enter / Space — execute move for the selected pile's top card (or full // Space — legacy auto-move accelerator. Foundation-first, then best
// face-up run when the source is a tableau column). // tableau stack target. Preserved so the muscle memory built around
// // `Tab` → `Space` keeps working; `Enter` is now the lift trigger.
// Priority: if keys.just_pressed(KeyCode::Space)
// 1. Foundation move — always count = 1.
// 2. Tableau stack move — count = full face-up run length from the source.
let activate =
keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::Space);
if activate
&& let Some(ref pile) = selection.selected_pile.clone() && let Some(ref pile) = selection.selected_pile.clone()
&& let Some(card) = game && let Some(card) = game
.0 .0
@@ -239,9 +418,8 @@ fn handle_selection_keys(
.and_then(|p| p.cards.last()) .and_then(|p| p.cards.last())
.filter(|c| c.face_up) .filter(|c| c.face_up)
{ {
// --- Priority 1: foundation move (single card) --- // Priority 1: foundation move (single card).
let foundation_dest = try_foundation_dest(card, &game.0); if let Some(dest) = try_foundation_dest(card, &game.0) {
if let Some(dest) = foundation_dest {
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: pile.clone(), from: pile.clone(),
to: dest, to: dest,
@@ -250,15 +428,11 @@ fn handle_selection_keys(
selection.selected_pile = None; selection.selected_pile = None;
return; return;
} }
// Priority 2: tableau stack move.
// --- Priority 2: tableau stack move --- let run_len = face_up_run_len(
// Count the full contiguous face-up run in the source pile. game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice()),
let run_len = face_up_run_len(game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice())); );
let bottom_card = game let bottom_card = game.0.piles.get(pile).and_then(|p| {
.0
.piles
.get(pile)
.and_then(|p| {
let start = p.cards.len().saturating_sub(run_len); let start = p.cards.len().saturating_sub(run_len);
p.cards.get(start) p.cards.get(start)
}); });
@@ -274,10 +448,7 @@ fn handle_selection_keys(
selection.selected_pile = None; selection.selected_pile = None;
return; return;
} }
// Fallback for non-tableau sources.
// --- Fallback: single-card move to any destination ---
// Covers non-tableau sources (Waste, Foundation) that have no
// stack-move logic.
if let Some(dest) = best_destination(card, &game.0) { if let Some(dest) = best_destination(card, &game.0) {
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: pile.clone(), from: pile.clone(),
@@ -286,7 +457,108 @@ fn handle_selection_keys(
}); });
selection.selected_pile = None; selection.selected_pile = None;
} }
return;
} }
// Enter — lift the focused pile into destination-pick mode.
if keys.just_pressed(KeyCode::Enter)
&& let Some(ref source) = selection.selected_pile.clone()
{
let Some(pile_cards) = game.0.piles.get(source) else {
return;
};
// Determine the lift range: tableau lifts the full face-up run, all
// other sources lift only the top card.
let run_len = face_up_run_len(pile_cards.cards.as_slice());
let count = if matches!(source, PileType::Tableau(_)) {
run_len.max(1)
} else {
1
};
if pile_cards.cards.is_empty() {
return;
}
let start = pile_cards.cards.len().saturating_sub(count);
let lifted_cards: Vec<u32> =
pile_cards.cards[start..].iter().map(|c| c.id).collect();
let Some(bottom) = pile_cards.cards.get(start) else {
return;
};
let legal = legal_destinations_for(bottom, source, &game.0, count);
if legal.is_empty() {
info_toast.write(InfoToastEvent(
"No legal moves for this card".to_string(),
));
return;
}
// Populate `DragState` with the keyboard sentinel so the existing
// mouse-drag systems treat this as "not their drag".
drag.cards = lifted_cards.clone();
drag.origin_pile = Some(source.clone());
drag.cursor_offset = Vec2::ZERO;
drag.origin_z = 1.0;
drag.press_pos = Vec2::ZERO;
drag.committed = false;
drag.active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID);
*kbd_drag = KeyboardDragState::Lifted {
source_pile: source.clone(),
count,
cards: lifted_cards,
legal_destinations: legal,
destination_index: 0,
};
}
}
// ---------------------------------------------------------------------------
// Legal-destination enumeration
// ---------------------------------------------------------------------------
/// Enumerate every pile that the lifted stack rooted at `bottom` can be
/// legally placed on, excluding the source pile itself.
///
/// Foundations are returned first (in slot order 0..4), then tableau
/// columns (in column order 0..7). Foundations only accept single-card
/// stacks, matching the existing rules engine.
///
/// The order is deliberate: the first entry is the most "obvious" target
/// (the lowest foundation or column number) which becomes the default
/// destination after a lift. Players who want a different column simply
/// press the right-arrow key once or twice.
pub(crate) fn legal_destinations_for(
bottom: &solitaire_core::card::Card,
source: &PileType,
game: &GameState,
stack_count: usize,
) -> Vec<PileType> {
let mut out = Vec::new();
if stack_count == 1 {
for slot in 0..4_u8 {
let dest = PileType::Foundation(slot);
if &dest == source {
continue;
}
if let Some(pile) = game.piles.get(&dest)
&& can_place_on_foundation(bottom, pile)
{
out.push(dest);
}
}
}
for i in 0..7_usize {
let dest = PileType::Tableau(i);
if &dest == source {
continue;
}
if let Some(pile) = game.piles.get(&dest)
&& can_place_on_tableau(bottom, pile)
{
out.push(dest);
}
}
out
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -336,22 +608,44 @@ fn try_foundation_dest(
/// Without this, an undo or a rejected move could leave `selected_pile` /// Without this, an undo or a rejected move could leave `selected_pile`
/// pointing at a pile whose top card changed, causing the highlight to /// pointing at a pile whose top card changed, causing the highlight to
/// trail a different card than the player expects. /// trail a different card than the player expects.
///
/// Also resets [`KeyboardDragState`] back to `Idle` and clears any
/// keyboard-driven [`DragState`] population — the lifted cards have just
/// moved (or been undone) so the cached `legal_destinations` are stale.
fn clear_selection_on_state_change( fn clear_selection_on_state_change(
mut state_events: MessageReader<StateChangedEvent>, mut state_events: MessageReader<StateChangedEvent>,
mut selection: ResMut<SelectionState>, mut selection: ResMut<SelectionState>,
mut kbd_drag: ResMut<KeyboardDragState>,
mut drag: ResMut<DragState>,
) { ) {
if state_events.read().next().is_some() { if state_events.read().next().is_some() {
selection.selected_pile = None; selection.selected_pile = None;
if matches!(*kbd_drag, KeyboardDragState::Lifted { .. }) {
*kbd_drag = KeyboardDragState::Idle;
// Only clear DragState if it's the keyboard sentinel — never
// stomp a real mouse / touch drag.
if drag.active_touch_id == Some(KEYBOARD_DRAG_TOUCH_ID) {
drag.clear();
}
}
} }
} }
/// Maintains the `SelectionHighlight` outline sprite. /// Maintains the `SelectionHighlight` outline sprite.
/// ///
/// When a pile is selected, a cyan sprite is placed at the selected card's /// When a pile is selected (source-pick mode), a cyan sprite is placed
/// position. When the selection is cleared the highlight entity is despawned. /// at the selected card's position. While
/// [`KeyboardDragState::Lifted`] the source highlight tints gold and a
/// second highlight follows the focused destination's top card — visually
/// telling the player "these cards will move to that pile when you press
/// Enter".
///
/// All highlights are despawned and respawned every frame so an undo /
/// rejected move can never leave a stale outline behind.
fn update_selection_highlight( fn update_selection_highlight(
mut commands: Commands, mut commands: Commands,
selection: Res<SelectionState>, selection: Res<SelectionState>,
kbd_drag: Res<KeyboardDragState>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
card_entities: Query<(Entity, &CardEntity)>, card_entities: Query<(Entity, &CardEntity)>,
@@ -361,40 +655,90 @@ fn update_selection_highlight(
for entity in &highlights { for entity in &highlights {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
let Some(ref pile) = selection.selected_pile else {
return;
};
let Some(layout) = layout else { let Some(layout) = layout else {
return; return;
}; };
let Some(card) = game let card_size = layout.0.card_size;
.0
.piles // Choose colours per mode: cyan in source-pick, gold while lifted.
let lifted = kbd_drag.is_lifted();
let source_color = if lifted {
Color::srgba(1.0, 0.84, 0.0, 0.6)
} else {
Color::srgba(0.0, 1.0, 1.0, 0.5)
};
let dest_color = Color::srgba(0.0, 1.0, 0.4, 0.6);
// Resolve the source pile from KeyboardDragState (when lifted) or
// SelectionState (otherwise). Lifted takes precedence so the gold
// outline follows the actual lifted cards.
let source_pile: Option<PileType> = match &*kbd_drag {
KeyboardDragState::Lifted { source_pile, .. } => Some(source_pile.clone()),
KeyboardDragState::Idle => selection.selected_pile.clone(),
};
if let Some(ref pile) = source_pile
&& let Some(card) = top_face_up_card(pile, &game.0)
{
spawn_highlight_on_card(
&mut commands,
&card_entities,
card.id,
card_size,
source_color,
);
}
// Destination highlight while lifted.
if let Some(dest) = kbd_drag.focused_destination() {
// For non-empty piles, anchor on the top card. For empty piles
// (e.g. an empty tableau column), no card exists to anchor to;
// skip — the source highlight already conveys that the player is
// in destination-pick mode and the focused index is observable
// via the resource.
if let Some(card) = top_face_up_card(dest, &game.0) {
spawn_highlight_on_card(
&mut commands,
&card_entities,
card.id,
card_size,
dest_color,
);
}
}
}
/// Returns the top face-up card on `pile`, or `None` if the pile is
/// empty or its top card is face-down.
fn top_face_up_card<'a>(
pile: &PileType,
game: &'a GameState,
) -> Option<&'a solitaire_core::card::Card> {
game.piles
.get(pile) .get(pile)
.and_then(|p| p.cards.last()) .and_then(|p| p.cards.last())
.filter(|c| c.face_up) .filter(|c| c.face_up)
else { }
return;
};
let card_id = card.id; /// Spawn a `SelectionHighlight` sprite as a child of the entity carrying
let card_size = layout.0.card_size; /// the matching `CardEntity::card_id`. No-op if no entity matches.
fn spawn_highlight_on_card(
// Find the entity for the selected card so we can read its position. commands: &mut Commands,
for (entity, card_entity) in &card_entities { card_entities: &Query<(Entity, &CardEntity)>,
card_id: u32,
card_size: Vec2,
color: Color,
) {
for (entity, card_entity) in card_entities {
if card_entity.card_id == card_id { if card_entity.card_id == card_id {
// Spawn the highlight as a child of the card entity so it moves
// with it automatically.
commands.entity(entity).with_children(|b| { commands.entity(entity).with_children(|b| {
b.spawn(( b.spawn((
SelectionHighlight, SelectionHighlight,
Sprite { Sprite {
color: Color::srgba(0.0, 1.0, 1.0, 0.5), color,
custom_size: Some(card_size + Vec2::splat(4.0)), custom_size: Some(card_size + Vec2::splat(4.0)),
..default() ..default()
}, },
// Slightly behind the card face so text labels are still visible.
Transform::from_xyz(0.0, 0.0, -0.01), Transform::from_xyz(0.0, 0.0, -0.01),
Visibility::default(), Visibility::default(),
)); ));
@@ -552,4 +896,377 @@ mod tests {
]; ];
assert_eq!(face_up_run_len(&cards), 1); assert_eq!(face_up_run_len(&cards), 1);
} }
// -----------------------------------------------------------------------
// Keyboard drag-and-drop — full integration tests
//
// Each test runs a `MinimalPlugins` Bevy app with `SelectionPlugin` and
// builds a deterministic `GameState` so the legal-destination ordering
// is predictable without depending on the deal RNG.
// -----------------------------------------------------------------------
use bevy::ecs::message::Messages;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
/// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
/// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` /
/// `InfoToastEvent` channels are registered manually so the plugin's
/// systems compile and run.
fn drag_test_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<MoveRequestEvent>();
app.add_message::<StateChangedEvent>();
app.add_message::<InfoToastEvent>();
app.init_resource::<DragState>();
app.init_resource::<ButtonInput<KeyCode>>();
app.add_plugins(SelectionPlugin);
app
}
/// Build a tableau-only board with deterministic top cards so the
/// keyboard-cycle order is predictable.
///
/// Layout:
/// - Tableau(0): 5♣ face-up (red destinations: 4♥ on T1 face-up below)
/// - Tableau(1): 6♥ face-up
/// - Tableau(2): 6♦ face-up
/// - Tableau(3..7): empty
/// - Stock / Waste / Foundations: empty
///
/// 5♣ on T0 can legally go to either 6♥ on T1 or 6♦ on T2 (both red,
/// rank one higher). It cannot go to a foundation (Foundation needs
/// Ace first). It cannot go to an empty tableau (only Kings).
/// Empty tableaus T3..T6 only accept Kings, so they are filtered out.
fn deterministic_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne);
// Clear stock, waste, all tableaus.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Place test cards.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 100,
suit: Suit::Clubs,
rank: Rank::Five,
face_up: true,
});
g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards.push(Card {
id: 101,
suit: Suit::Hearts,
rank: Rank::Six,
face_up: true,
});
g.piles.get_mut(&PileType::Tableau(2)).unwrap().cards.push(Card {
id: 102,
suit: Suit::Diamonds,
rank: Rank::Six,
face_up: true,
});
g
}
fn install_state(app: &mut App, state: GameState) {
app.insert_resource(GameStateResource(state));
}
fn press_key(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
input.press(key);
}
fn clear_input(app: &mut App) {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.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()
}
/// Test 1 — Tab in idle state cycles to the first draggable pile.
///
/// On the deterministic board, the first draggable pile in cycle order
/// is `Tableau(0)` (the 5♣).
#[test]
fn tab_in_idle_cycles_to_first_draggable_pile() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
// Initial state: nothing selected, KeyboardDragState::Idle.
assert!(app.world().resource::<SelectionState>().selected_pile.is_none());
assert_eq!(*app.world().resource::<KeyboardDragState>(), KeyboardDragState::Idle);
press_key(&mut app, KeyCode::Tab);
app.update();
let selected = app.world().resource::<SelectionState>().selected_pile.clone();
// The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(PileType::Tableau(0)));
assert_eq!(*app.world().resource::<KeyboardDragState>(), KeyboardDragState::Idle);
}
/// Test 2 — Enter while a source is selected lifts the stack.
///
/// `DragState.cards` must be populated with the lifted card ids and the
/// keyboard sentinel must be set.
#[test]
fn enter_in_source_selected_lifts_the_stack() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
// Manually focus Tableau(0) so we don't depend on Tab.
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
// Assert KeyboardDragState is Lifted with the right metadata.
let kbd = app.world().resource::<KeyboardDragState>().clone();
match kbd {
KeyboardDragState::Lifted {
source_pile,
count,
cards,
legal_destinations,
destination_index,
} => {
assert_eq!(source_pile, PileType::Tableau(0));
assert_eq!(count, 1);
assert_eq!(cards, vec![100]);
assert!(
!legal_destinations.is_empty(),
"lifted stack must have at least one legal destination"
);
assert_eq!(destination_index, 0);
}
other => panic!("expected Lifted, got {other:?}"),
}
// DragState must mirror the lifted cards and carry the keyboard sentinel.
let drag = app.world().resource::<DragState>();
assert_eq!(drag.cards, vec![100]);
assert_eq!(drag.origin_pile, Some(PileType::Tableau(0)));
assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID));
}
/// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations
/// only (foundations and tableaus that pass `can_place_on_*`), and
/// wrap at the end of the list.
#[test]
fn arrow_in_lifted_cycles_legal_destinations_only() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
// Capture the destination list. For the deterministic state the 5♣
// (black) can land on 6♥ (T1) or 6♦ (T2) — both red, rank one
// higher. Verify that the destinations are exactly those tableaus
// (in cycle order T1 then T2).
let initial_dests: Vec<PileType> = match app.world().resource::<KeyboardDragState>() {
KeyboardDragState::Lifted { legal_destinations, .. } => legal_destinations.clone(),
_ => panic!("expected Lifted"),
};
assert_eq!(
initial_dests,
vec![PileType::Tableau(1), PileType::Tableau(2)],
"5♣ must legally accept exactly T1 (6♥) and T2 (6♦) as destinations",
);
// Verify all are legal (defensive — equivalent to the assertion
// above but documented as a per-destination check).
for dest in &initial_dests {
let bottom_card = Card {
id: 100,
suit: Suit::Clubs,
rank: Rank::Five,
face_up: true,
};
let pile = app.world().resource::<GameStateResource>().0.piles.get(dest).unwrap().clone();
assert!(
can_place_on_tableau(&bottom_card, &pile),
"destination {dest:?} must be legal for the lifted stack",
);
}
// Initial focused destination = first entry.
assert_eq!(
app.world().resource::<KeyboardDragState>().focused_destination(),
Some(&PileType::Tableau(1)),
);
// ArrowRight → next.
clear_input(&mut app);
press_key(&mut app, KeyCode::ArrowRight);
app.update();
assert_eq!(
app.world().resource::<KeyboardDragState>().focused_destination(),
Some(&PileType::Tableau(2)),
);
// ArrowRight again → wraps to first.
clear_input(&mut app);
press_key(&mut app, KeyCode::ArrowRight);
app.update();
assert_eq!(
app.world().resource::<KeyboardDragState>().focused_destination(),
Some(&PileType::Tableau(1)),
"destination index must wrap back to 0 after exhausting the list",
);
}
/// Test 4 — Enter while `Lifted` with a destination focused fires
/// exactly one `MoveRequestEvent` and resets the state machine to
/// `Idle` with `DragState` cleared.
#[test]
fn enter_in_lifted_with_destination_fires_move_request_event() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
// Sanity: lifted with a focused destination.
assert!(app.world().resource::<KeyboardDragState>().is_lifted());
let expected_dest = app
.world()
.resource::<KeyboardDragState>()
.focused_destination()
.cloned()
.expect("must have a focused destination after lift");
// Confirm with Enter.
clear_input(&mut app);
press_key(&mut app, KeyCode::Enter);
app.update();
let events = collect_move_events(&mut app);
assert_eq!(events.len(), 1, "exactly one MoveRequestEvent must fire");
assert_eq!(events[0].from, PileType::Tableau(0));
assert_eq!(events[0].to, expected_dest);
assert_eq!(events[0].count, 1);
// State machine resets.
assert_eq!(
*app.world().resource::<KeyboardDragState>(),
KeyboardDragState::Idle,
"Enter on lifted must return state machine to Idle",
);
assert!(
app.world().resource::<DragState>().is_idle(),
"DragState must be cleared after confirming the move",
);
}
/// Test 5 — Esc while `Lifted` cancels back to source-selected with
/// `SelectionState` intact and `DragState` cleared.
#[test]
fn escape_in_lifted_returns_to_source_selected() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
assert!(app.world().resource::<KeyboardDragState>().is_lifted());
// Esc cancels.
clear_input(&mut app);
press_key(&mut app, KeyCode::Escape);
app.update();
assert_eq!(
*app.world().resource::<KeyboardDragState>(),
KeyboardDragState::Idle,
"Esc on lifted must return state machine to Idle",
);
assert_eq!(
app.world().resource::<SelectionState>().selected_pile,
Some(PileType::Tableau(0)),
"Esc on lifted must keep SelectionState intact (source-pick mode)",
);
assert!(
app.world().resource::<DragState>().is_idle(),
"DragState must be cleared after cancelling the lift",
);
}
/// Mouse drag in progress (non-keyboard `active_touch_id`) must
/// suppress keyboard input — pressing Tab while a real mouse drag is
/// running must not change `SelectionState`.
#[test]
fn keyboard_input_ignored_while_mouse_drag_active() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
// Simulate a real mouse drag by populating DragState without the
// keyboard sentinel.
{
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![100];
drag.origin_pile = Some(PileType::Tableau(0));
drag.committed = true;
drag.active_touch_id = None;
}
let before = app.world().resource::<SelectionState>().selected_pile.clone();
press_key(&mut app, KeyCode::Tab);
app.update();
let after = app.world().resource::<SelectionState>().selected_pile.clone();
assert_eq!(
before, after,
"Tab must not change SelectionState while a mouse drag is in progress",
);
}
/// Esc on a lifted state with no prior state-change does NOT clear
/// `SelectionState`. A second Esc (now that the state is Idle) does.
#[test]
fn double_escape_clears_source_selection() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
clear_input(&mut app);
press_key(&mut app, KeyCode::Escape);
app.update();
assert_eq!(
app.world().resource::<SelectionState>().selected_pile,
Some(PileType::Tableau(0)),
"first Esc only cancels the lift",
);
clear_input(&mut app);
press_key(&mut app, KeyCode::Escape);
app.update();
assert!(
app.world().resource::<SelectionState>().selected_pile.is_none(),
"second Esc clears the source selection",
);
}
} }
+188 -1
View File
@@ -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,6 +1105,16 @@ 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,
); );
if theme_overrides_back {
// The active theme provides its own back; the legacy
// picker has no visible effect, so we replace its
// swatch row with an informational caption. The
// player's `selected_card_back` value still
// round-trips through `settings.json` — the moment
// they switch to a theme without a back, the picker
// re-appears with their previous choice intact.
picker_row_overridden_by_theme(body, "Card Back", font_res);
} else {
picker_row( picker_row(
body, body,
"Card Back", "Card Back",
@@ -1033,6 +1124,7 @@ fn spawn_settings_panel(
"Choose your deck art. New backs unlock at higher levels.", "Choose your deck art. New backs unlock at higher levels.",
font_res, 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
+161 -3
View File
@@ -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:?}",
);
}
} }
+38 -19
View File
@@ -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]
+25
View File
@@ -361,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;
@@ -395,6 +403,23 @@ pub const MOTION_FOUNDATION_FLOURISH_SECS: f32 = 0.4;
/// 1.0 at `t=0` to this value at `t=0.5` and back to 1.0 at `t=1.0`. /// 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; 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;
+53 -2
View File
@@ -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));
}
} }
+630 -11
View File
@@ -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);
}
} }