Compare commits

...

16 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
funman300 61d891fb76 docs: CHANGELOG + SESSION_HANDOFF refresh for v0.12.0
CHANGELOG gains a [0.12.0] section covering the second UX iteration
round on top of v0.11.0:
- Foundation completion flourish
- Drag-cancel return tween
- Focus ring breathing
- First-win achievement onboarding toast
- Mode Launcher digit shortcuts
- Card aspect-ratio fix (1.4 → 1.4523)
- Plus the README and CHANGELOG-add docs that rode along

The bottom-of-file compare links thread the new tag into the
existing chain (Unreleased → 0.12.0 → 0.11.0 → ...). Test count
updated to 1007.

SESSION_HANDOFF now distinguishes session 7 round 1 (v0.11.0,
morning) from round 2 (v0.12.0, afternoon) — keeping the audit
trail readable instead of conflating them. The release-prep punch
list collapses to the three tag/push/packaging items; the UX
iteration list opens with six fresh candidates for whoever picks
the next round.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:08:46 +00:00
funman300 7dba772e67 feat(engine): digit shortcuts (1-5) launch modes from inside the Mode Launcher
Pressing M already opens the Home modal (which is the Mode Launcher
post-v0.11) and Tab cycles focus through the cards. The remaining
gap was direct keyboard activation of a specific mode — players had
to tab-and-enter or click. A new modal-scoped digit handler closes
that gap:

  1 → Classic (NewGameRequestEvent)
  2 → Daily Challenge (StartDailyChallengeRequestEvent)
  3 → Zen (StartZenRequestEvent, gated at level 5)
  4 → Challenge (StartChallengeRequestEvent, gated at level 5)
  5 → Time Attack (StartTimeAttackRequestEvent, gated at level 5)

handle_home_digit_keys runs only when HomeScreen exists and short-
circuits otherwise — the digit keys can't accidentally launch a
mode mid-game. Locked modes (level < CHALLENGE_UNLOCK_LEVEL) silent-
no-op rather than firing a toast, mirroring the click-on-locked-card
behaviour without the InfoToastEvent (the click path's toast is the
authoritative "level too low" surface).

The HomePlugin Update tuple is now .chain()ed because the Bevy 0.18
parallel scheduler would otherwise let handle_home_card_click,
handle_home_cancel_button, and the new digit handler all queue a
HomeScreen despawn concurrently — the second buffer apply panics
on the already-despawned entity.

help_plugin gains a new "Mode Launcher (M)" section with the digit
rows and a level-5 unlock note. onboarding's slide-3 hotkey table
gets one new line ("M — Open Mode Launcher (then 1-5 to pick)") so
first-run players see the full path. The help-modal canonical list
now mirrors the onboarding teach.

Four new headless tests pin the contract: Digit1 launches Classic
and closes the modal; Digit3 at level 0 is a no-op (modal stays
open); Digit3 at unlock level launches Zen and closes; digit keys
outside the modal fire no events at all.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:01:41 +00:00
funman300 ca5788f714 feat(engine): one-shot achievement-onboarding toast on first win
After the player's very first win the engine now writes
"First win! Press A to see your achievements." via InfoToastEvent,
then flips a persisted Settings.shown_achievement_onboarding flag so
the cue never re-fires. Mentions the A hotkey by name so the toast
is actionable on its own.

The toast path runs after StatsUpdate so games_won has been
incremented to 1 when the system reads it; .after(GameMutation)
keeps the post-move state visible. Three guards: first win only,
flag was false, GameWonEvent fired this tick.

Persistence mirrors onboarding_plugin's complete_onboarding pattern:
save via save_settings_to with the existing
SettingsStoragePath/Option<&PathBuf> graceful-fallback shape.
Atomic .tmp+rename writes are unchanged.

Settings gains a single bool field with #[serde(default)] so legacy
settings.json files deserialize cleanly to false. The field is
local-only by design — it's about UI teaching for THIS device, not
progression — so SyncPayload and merge logic are untouched.

Seven new tests pin the contract: default value is false, field
round-trips through save/load, legacy JSON without the field
deserializes to false, first win fires the toast and flips the
flag, subsequent wins are silent, the fifth win on a synced device
is silent (won't fire when games_won has been bumped via sync), and
no win event means no toast.

Toast duration is the existing animation_plugin
QUEUED_TOAST_SECS = 2.5 s — InfoToastEvent is a tuple struct with
no duration parameter, so the agent kept the existing event shape
rather than expanding it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:01:18 +00:00
funman300 9887343d8b feat(engine): focus ring breathes at 1.4 s — gentle pulse instead of flat
The keyboard focus ring rendered as a static yellow outline. A new
pulse_focus_overlay system modulates the overlay's BorderColor alpha
with a sin curve over MOTION_FOCUS_PULSE_SECS (1.4 s), breathing the
visible alpha between 0.65× and 1.0× of FOCUS_RING's native value.
The motion is slow enough to read as a calm heartbeat in peripheral
vision rather than a competing animation, and a focus change still
draws the eye because the ring re-attaches at full brightness on
the next pulse cycle.

The pulse honours AnimSpeed::Instant by reading SettingsResource
and skipping the modulation entirely (static FOCUS_RING colour) for
reduced-motion users — matches the convention used elsewhere for
animation gating.

A pure focus_ring_pulse_factor(elapsed_secs) helper is unit-tested
for the curve shape: 0.825 at t=0 (mid-point), 1.0 at the
quarter-period peak, 0.65 at the three-quarter-period trough, and a
sweep across two full periods stays within the [0.65, 1.0] range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:40:40 +00:00
funman300 525fe0fe76 feat(engine): drag-cancel return tween — smooth ease-out instead of shake
Illegal drops previously snapped each dragged card to its origin
slot and ran a horizontal ShakeAnim wiggle for negative feedback —
which read as punitive on every misclick. The rejection now plays
a 150 ms quintic ease-out glide from the drop location back to the
resting slot. The audio cue (card_invalid.wav) still fires so the
player gets clear "no" feedback; the visual is just gentler.

Both rejection paths in input_plugin (mouse end_drag and touch
end_drag) construct a CardAnimation::slide(drag_pos → target_pos)
with MotionCurve::Responsive — the curve module's own docs
recommend Responsive specifically for invalid snap-back because its
zero overshoot reads forgiving rather than jittery.

card_plugin's update_card_entity gates its snap path on
CardAnimation absence so the StateChangedEvent that follows a
rejection no longer fights the in-flight tween. Mirrors how
resize_cards_in_place already drops in-flight tweens during a
window resize.

ShakeAnim itself stays in feedback_anim_plugin — the right-click
invalid-target and double-click in-place rejection paths still use
it because there's no movement to interpolate, just a "no" wiggle.
Only the drag-rejection path swaps to the smooth tween.

Six new rejection-tween tests pin the contract: CardAnimation is
inserted on every dragged card, start/end positions and z values
match the drag-to-resting transition, duration matches the new
MOTION_DRAG_REJECT_SECS token, and the curve is Responsive. The
two legacy ShakeAnim drag-rejection tests are removed since their
contract is intentionally inverted by this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:34:12 +00:00
funman300 69ce9afab9 feat(engine): foundation completion flourish — King-on-foundation celebration
Now that foundations are unlocked and "completing" one is a real
moment (rather than a foregone conclusion based on suit assignment),
each Ace-through-King run gets its own small celebration when the
King lands.

Three layers fire on a single FoundationCompletedEvent emitted by
game_plugin's handle_move when a successful move leaves a
PileType::Foundation pile holding 13 cards:

1. King card scale-pulse via a new FoundationFlourish component.
   Triangular curve 1.0 → 1.15 → 1.0 over MOTION_FOUNDATION_FLOURISH
   _SECS (0.4s) — same shape as the existing ScorePulse so the feel
   matches.
2. Pile-marker tint flourish via FoundationMarkerFlourish — the
   foundation marker's sprite colour lerps to STATE_SUCCESS for the
   first half of the duration then fades back. Reuses the existing
   success-signal palette; no new colour token.
3. Audio cue: foundation_complete.wav, a synthesised C6→E6→G6 triad
   with 2nd-harmonic warmth and AR decay (~240 ms). Sits an octave
   above win_fanfare's root so the layered fourth-completion + win
   cascade reads cleanly. Generated via solitaire_assetgen's
   foundation_complete() function and embedded via include_bytes!().

The visual systems run .after(GameMutation) so the post-move pile
state is visible when the King is identified. Both flourish
components remove themselves once elapsed time exceeds duration —
no animation queue or scheduler integration needed.

Pure foundation_flourish_scale(elapsed, duration) helper is
unit-tested for the curve, edge clamps, and zero-duration safety.
Three integration tests on the firing logic verify the event fires
exactly once when a King completes a foundation, doesn't fire for
non-foundation moves, and doesn't fire when the foundation is at 12
cards.

The fourth completion still co-occurs with the win cascade — the
two layer cleanly because the flourish's scale is on the King card
sprite while the cascade is a screen-shake + per-card rotation, and
the foundation_complete ping is a higher octave than the win
fanfare's root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:19:50 +00:00
funman300 13aa0fd833 fix(engine): match CARD_ASPECT to hayeah SVG dimensions (1.4 → 1.4523)
Cards rendered ~3.6 % squashed vertically because layout.rs assumed a
1.4 height/width ratio while the bundled hayeah/playing-cards-assets
SVGs are natively 167.087 × 242.667 (= 1.4523). The mismatch meant
every face was scaled to fit a too-short box; pip arrangements and
court-card art read slightly compressed.

Bumps CARD_ASPECT to 1.4523 to match the SVG. The vertical-budget
math in compute_layout (the height-based card_width candidate) uses
CARD_ASPECT algebraically, so the tableau-fits-on-screen check
adapts automatically — slightly smaller cards on aspect-ratio-tight
windows, no visible regression on standard 16:9.

Doc comments referencing the old 1.4 literal updated to point at
CARD_ASPECT instead so this can't drift again.

All 982 tests pass — the existing layout/test sentinel
(card_size.y / card_size.x - CARD_ASPECT) used the constant by name
and adapted for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:03:12 +00:00
funman300 9f095c4039 docs: add CHANGELOG.md covering v0.9.0 through v0.11.0
The CHANGELOG didn't exist; v0.11.0 felt too meaty to land without one
and starting from v0.10.0+ would have made the file feel rootless. The
format follows Keep a Changelog 1.1.0 with the standard Added /
Changed / Fixed / Removed sections per release plus a Migration block
when relevant.

v0.11.0 (2026-05-02) — full coverage of the card-theme system, HUD
overhaul, drag-feel polish (drop overlay, drop shadows, stock count
badge, unlocked foundations), the FiraMono fontdb fix, and the
schema-version bump that invalidates pre-v2 game_state.json saves on
launch. 982 tests, zero clippy.

v0.10.0 (2026-04-29) — PNG art pipeline, Bevy 0.15 → 0.18 migration,
kira 0.9 → 0.12 migration, Rust edition 2024 + MSRV 1.95, custom
font, JWT-secret-at-startup fix, SmartIpKeyExtractor, MessageReader
touch-input fix.

v0.9.0 (2026-04-28) — initial public-tagged release: workspace
structure, modal scaffold, design-token system, four-tier HUD,
progression, sync server, splash, focus rings, tooltips,
achievement integration tests, all the foundation work that
predates the card-theme rewrite.

README gains a Changelog section linking to the new file.

The bottom-of-file compare links use the corrected
github.com/funman300/Rusty_Solitaire URL so the rendered page on
GitHub auto-generates the correct diff views once the tags are
pushed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:59:13 +00:00
funman300 d8c70341f4 docs: refresh README for v0.11.0 — card themes, HUD overhaul, drag feel
The README hadn't been touched since before the card-theme system
landed and was missing every UX feel improvement from v0.11.0.
Anyone discovering the repo on the GitHub release page would have
seen pre-theme copy.

Features list now covers card themes (bundled default + user
zip-installable), the modern HUD (reserved band + action-bar
auto-fade), and the four drag-feel improvements (drop highlights,
drop shadows, stock count badge, unlocked foundations).

Controls table fixes three real discrepancies: Undo is U not
Z/Ctrl+Z (the README inverted the bindings), Help is F1 not H, and
Z actually toggles Zen mode. Adds the previously undocumented Tab /
Shift+Tab focus cycle, Enter activation, F11 fullscreen, double-
click to auto-move, and the G forfeit shortcut. Notes that every
action is also a visible UI button so the keyboard list is
optional-accelerator only — matches the project's UI-first rule.

Adds a small Card Themes section explaining how to install a theme
(drop a directory or zip-import via Settings → Cosmetic) without
diving into SVG technicals.

Test count updated to 982 to reflect v0.11.0 baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:56:24 +00:00
33 changed files with 5467 additions and 486 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 }
+320
View File
@@ -0,0 +1,320 @@
# Changelog
All notable changes to Solitaire Quest are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
_Nothing yet._
## [0.13.0] — 2026-05-02
Third UX iteration round on top of v0.12.0. Six handoff candidates
shipped — three small polish items, three larger interaction
features (theme-aware backs, full keyboard play, right-click power
shortcut). Plus two code-review fixes (font handling unified,
sccache wiring removed).
### Added
- **Tooltip-delay slider** in Settings → Gameplay. `tooltip_delay_secs`
ranges [0.0, 1.5] in 0.1 s steps; "Instant" label when zero.
`Settings.tooltip_delay_secs` round-trips through serialise/deserialise
with `#[serde(default)]`. The hover-delay comparison in
`ui_tooltip` reads from `SettingsResource` with the existing
`MOTION_TOOLTIP_DELAY_SECS` as the test-fixture fallback.
- **Win-streak fire animation.** New `WinStreakMilestoneEvent` fires
from `stats_plugin` when `win_streak_current` crosses any of
[3, 5, 10] (only the threshold crossing — not every subsequent
win). The HUD streak readout scale-pulses 1.0 → 1.20 → 1.0 over
`MOTION_STREAK_FLOURISH_SECS` (0.6 s).
- **Score-breakdown reveal on the win modal.** Replaces the single
"Score: N" line with a per-component reveal (Base / Time bonus /
No-undo bonus / Mode multiplier / Total). Rows fade in over
`MOTION_SCORE_BREAKDOWN_FADE_SECS` (0.12 s) staggered by
`MOTION_SCORE_BREAKDOWN_STAGGER_SECS` (0.15 s). Honours
`AnimSpeed::Instant` by spawning all rows fully visible.
- **Card backs follow the active theme.** `theme.ron`'s `back` slot
now actually drives the face-down sprite. Active-theme back
rasterises alongside the faces and supersedes the legacy
`back_N.png` picker. The picker remains as a fallback for themes
that don't ship a back, and the Settings UI surfaces a caption
("Active theme provides its own back") + dimmed swatches when
the override is in effect.
- **Keyboard-only drag-and-drop.** Tab cycles draggable card stacks,
Enter "lifts" the focused stack, arrow keys (or Tab) cycle the
legal-destination targets only, Enter confirms, Esc cancels. A
new `KeyboardDragState` resource models the two-mode flow without
changing the existing `SelectionState` contract. Mutual exclusion
with mouse drag uses a sentinel `DragState.active_touch_id =
KEYBOARD_DRAG_TOUCH_ID` (u64::MAX) so neither pipeline can
trample the other.
- **Right-click radial menu.** Hold right-click on a face-up card →
a small ring of icons appears at the cursor with one entry per
legal destination. Release over an icon → fires
`MoveRequestEvent`; release in dead space, Esc, or left-click
cancels. Skips the drag motion entirely. New `RadialMenuPlugin`
owns the flow; co-exists with the existing `RightClickHighlight`
pile-marker tint.
### Fixed
- **Font handling consolidated to bundled-only.** Code-review
feedback: the SVG rasteriser previously mixed
`load_system_fonts` + bundled FiraMono + a lenient resolver,
which made card text rendering depend on host fontconfig. Picked
option (a) and applied it across both layers — `font_plugin` now
embeds `assets/fonts/main.ttf` via `include_bytes!()` and
registers it with `Assets<Font>`; `svg_loader::shared_fontdb`
loads only the bundled bytes; the new `bundled_font_resolver`
ignores the SVG's `font-family` request and always returns the
single bundled face. A parse failure aborts with a clear error
("bundled FiraMono failed to parse — binary is corrupt").
### Removed
- **Project-level sccache wiring.** Code-review feedback: sccache
shouldn't be a per-project build dependency. Cargo's incremental
cache already covers the single-project case, and forcing
`rustc-wrapper = "sccache"` workspace-wide meant every contributor
had to install it. `.cargo/config.toml` deleted entirely; plain
`cargo build` now works without setup.
### Documentation
- `help_plugin` controls reference gains a "Mouse" section covering
double-click auto-move, right-click highlight, and the new
hold-RMB radial.
- `help_plugin` also gains a "Keyboard drag" section for the new
Tab/Enter/Arrows/Esc flow.
- Onboarding slide 3 picks up a `Tab → Enter` row referencing the
full keyboard drag path.
### Stats
- 1053 passing tests (was 1031 at v0.12.0 close).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.12.0] — 2026-05-02
UX feel polish round on top of v0.11.0. Six small-but-tangible
improvements that make the play surface feel more responsive,
forgiving, and discoverable, plus the doc refresh that should have
ridden along with v0.11.0.
### Added
- **Foundation completion flourish.** When a King lands on a
foundation (Ace-through-King for that suit), a brief celebration
fires: King card scale-pulses 1.0 → 1.15 → 1.0 over 0.4 s, the
foundation marker tints `STATE_SUCCESS` for the first half then
fades, and a synthesised C6→E6→G6 bell ping plays (~240 ms,
octave above `win_fanfare`'s root so the fourth completion + win
cascade layer cleanly). New `FoundationCompletedEvent { slot,
suit }` carries the trigger so future systems can hook in.
- **Drag-cancel return tween.** Illegal drops glide each dragged
card back to its origin slot over 150 ms with a quintic ease-out
curve (`MotionCurve::Responsive`, zero overshoot — reads forgiving
rather than jittery). The audio cue (`card_invalid.wav`) still
fires for negative feedback. Right-click and double-click invalid
paths still use `ShakeAnim` since there's no motion to interpolate.
- **Focus ring breathing.** The keyboard focus ring's alpha modulates
with a 1.4 s sin curve over [0.65, 1.0] of its native value so the
indicator catches the eye on focus changes without competing with
gameplay. Honours `AnimSpeed::Instant` by reverting to the static
outline for reduced-motion users.
- **First-win achievement onboarding toast.** After the player's
very first win, a one-shot info toast surfaces "First win! Press
A to see your achievements." `Settings.shown_achievement_onboarding`
persists the seen state so the cue never re-fires (legacy
`settings.json` files load to `false` via `#[serde(default)]`).
- **Mode Launcher digit shortcuts.** Pressing M opens the Home modal
(the Mode Launcher); inside it, pressing 15 launches each mode
directly without needing Tab + Enter. Locked modes (Zen, Challenge,
Time Attack at level < 5) are silent no-ops. Modal-scoped — digit
keys outside the launcher fire nothing.
### Fixed
- **Card aspect ratio matches hayeah SVGs.** `CARD_ASPECT` 1.4 →
1.4523 to match the bundled artwork's natural 167.087 × 242.667
dimensions. Cards previously rendered ~3.6 % vertically squashed.
The vertical-budget math in `compute_layout` uses `CARD_ASPECT`
algebraically so the worst-case-tableau-fits-on-screen guarantee
adapts automatically.
### Documentation
- **README refresh** with v0.11.0+ features (card themes, HUD
overhaul, drag feel, unlocked foundations) and a corrected controls
table — the previous table inverted Z/U for undo and listed H for
help when F1 is the binding.
- **CHANGELOG.md** added (this file), covering v0.9.0v0.12.0 with
Keep a Changelog 1.1.0 conventions.
### Stats
- 1007 passing tests (was 982 at v0.11.0).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.11.0] — 2026-05-02
The biggest release since 0.10.0. Headline threads: a runtime card-theme
system, an HUD restructure that reclaims the play surface, and a round of
UX feel polish surfaced by smoke testing.
### Added
- **Runtime card-theme system** (CARD_PLAN phases 17).
- Bundled default theme ships in the binary via `embedded://` — 52
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
SVGs (MIT) plus a midnight-purple `back.svg` as original work.
- User themes live under `themes://` rooted at `user_theme_dir()`. Drop
a directory containing `theme.ron` + 53 SVGs and the registry picks
it up on next launch.
- Importer at `solitaire_engine::theme::import_theme(zip)` validates
archives (20 MB cap, zip-slip rejection, manifest validation, every
SVG round-tripped through the rasteriser) and atomically unpacks.
- Picker UI in **Settings → Cosmetic**; selection persists as
`selected_theme_id` and propagates to live sprites.
- **Reserved HUD top band** (64 px) so cards no longer crowd the score
readout or action buttons; layout's `top_y` shifts down accordingly.
- **Action-bar auto-fade** — buttons fade out when the cursor leaves the
band, fade back in when it returns. Lerp at ~167 ms.
- **Visible drop-target overlay during drag** — a soft fill plus 3 px
outline drawn ABOVE stacked cards for every legal target (full fanned
column for tableaux, card-sized for foundations and empty tableaux).
Replaces the previously invisible pile-marker tint.
- **Card drop shadows** — every card casts a neutral 25 % black shadow
with a 4 px halo; cards in the active drag set switch to a lifted
shadow (40 % alpha, larger offset, bigger halo).
- **Stock remaining-count badge** — small `·N` chip at the top-right of
the stock pile so the player can see how close they are to a recycle.
Hides when the stock empties.
### Changed
- **Foundations are unlocked.** `PileType::Foundation(Suit)`
`Foundation(u8)` (slot 0..3). The claimed suit is derived from the
bottom card via `Pile::claimed_suit()` — no separate field, no
claim-stuck-after-undo bugs. Any Ace lands in any empty slot, and the
slot then claims that suit. `next_auto_complete_move` prefers a
claim-matched slot before falling back to the first empty slot for
Aces. Empty foundation markers render as plain placeholders (no
"C/D/H/S").
- **HUD selection label** and **hint toast** read `claimed_suit()` and
fall through to "Foundation N" / "move to foundation" only when the
slot is empty.
### Fixed
- **`shared_fontdb` now bundles FiraMono.** The hayeah SVGs reference
`Bitstream Vera Sans` and `Arial` by name. On minimal Linux installs
/ fresh Wayland sessions / chroots where neither is installed AND the
CSS-generic aliases don't resolve, card rank/suit text vanished. The
bundled font is loaded into fontdb and pinned as every CSS generic's
target so the resolver always lands on something real. Surfaced when
a second-machine pull rendered cards without glyphs.
- **Theme asset path resolution** — `AssetPath::resolve` (concatenates)
`resolve_embed` (RFC 1808 sibling resolution). Was producing paths
like `…/theme.ron/hearts_4.svg` and failing to load every face SVG.
- **Sync exit log spam** — `push_on_exit` silently no-ops on
`LocalOnlyProvider`'s `UnsupportedPlatform` instead of warn-spamming
every shutdown.
- **usvg font-substitution warn spam** — custom `FontResolver.select_font`
appends `Family::SansSerif` and `Family::Serif` to every query so
unmatched named families silently fall through.
### Migration
- **In-progress saves invalidated.** `GameState.schema_version` bumped
1 → 2; pre-v2 `game_state.json` files silently fall through to "fresh
game on launch." Stats, progress, achievements, and settings live in
separate files and are unaffected.
### Stats
- 982 passing tests (was 819 at v0.10.0).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.10.0] — 2026-04-29
PNG art pipeline plus a major dependency pass. The first release where
the binary shipped with bundled artwork.
### Added
- **52 individual card face PNGs** generated via `solitaire_assetgen`.
- **Custom font** (FiraMono-Medium) loaded via `AssetServer` at startup
through the new `FontPlugin`.
- **Card backs and backgrounds** upgraded to 120×168 with richer
patterns.
- **Ambient audio loop** wired through the kira mixer.
- **Arch Linux PKGBUILDs** for the game client and sync server (under
the separate `solitaire-quest-pkgbuild` directory).
- **Workspace README, CI workflow, migration guide.**
### Changed
- **Bevy 0.15 → 0.18** workspace migration.
- **kira 0.9 → 0.12** audio backend migration.
- **Edition 2024**, MSRV pinned to **Rust 1.95**.
- **rand 0.9** upgrade.
- **Card rendering** moved from `Text2d` overlay to PNG-backed
`Sprite` with face/back atlases; `Text2d` retained as a headless
fallback when `CardImageSet` is absent (tests under MinimalPlugins).
- **Asset pipeline** switched from `include_bytes!()` for PNGs/TTFs to
runtime `AssetServer::load()` so artwork can be swapped without a
recompile. Audio remains embedded.
- **Removed Google Play Games Services sync backend** — redundant with
the self-hosted server.
### Fixed
- **Server JWT secret** loaded at startup (was lazy, surfaced as
intermittent 500s).
- **Daily-challenge race** in the server's seed-generation path.
- **Rate limiter** switched to `SmartIpKeyExtractor` so the limit
applies per real client IP rather than per upstream proxy.
- **Touch input** uses `MessageReader<TouchInput>` (Bevy 0.18 rename).
- **Sync push/pull races** in async task scheduling.
- **Hot-path allocations** reduced in card-rendering systems.
- **Conflict report coverage** added for sync merge edge cases.
### Stats
- 819 passing tests at tag time.
## [0.9.0] — 2026-04-28
Initial public-tagged release. Established the workspace structure
(`solitaire_core` / `_sync` / `_data` / `_engine` / `_server` / `_app` /
`_assetgen`), the modal scaffold via `ui_modal`, the design-token system
in `ui_theme`, and the four-tier HUD layout. Foundations were
suit-locked at this point; cards rendered as `Text2d` rank/suit overlays
with no PNG artwork yet.
### Added
- Klondike core (Draw One / Draw Three modes).
- Progression system (XP, levels, 18 achievements, daily challenge,
weekly goals, special modes at level 5).
- Self-hosted sync server (Axum + SQLite + JWT auth).
- All 12 overlay screens migrated to the `ui_modal` scaffold with real
Primary/Secondary/Tertiary buttons.
- Animation upgrades: `SmoothSnap` slide curves, scoped settle bounce,
deal jitter, win-cascade rotation.
- Splash screen, focus rings (Phases 13), tooltips infrastructure +
HUD/Settings/popover applications, achievement integration tests,
destructive-confirm verb unification, leaderboard error/idle states,
first-launch empty-state polish, hit-target accessibility fix,
CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
client-side sync round-trip integration tests.
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...HEAD
[0.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0
[0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0
[0.11.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.10.0...v0.11.0
[0.10.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.9.0...v0.10.0
[0.9.0]: https://github.com/funman300/Rusty_Solitaire/releases/tag/v0.9.0
+59 -20
View File
@@ -1,17 +1,35 @@
# Solitaire Quest
A cross-platform Klondike Solitaire game written in Rust, featuring a full progression system with XP, levels, achievements, daily challenges, and optional self-hosted sync so your stats follow you across machines.
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
system, full progression (XP / levels / achievements / daily challenges), and
optional self-hosted sync so your stats follow you across machines.
## Features
- **Klondike Solitaire** — Draw One and Draw Three modes
- **Klondike Solitaire** — Draw One and Draw Three modes; foundations are
unlocked (any Ace lands in any empty slot, the slot then claims that suit)
- **Card themes** — bundled hayeah/playing-cards-assets default plus
user-installable themes (drop a directory under the data dir or import a
zip from Settings → Cosmetic)
- **Modern HUD** — reserved top band keeps cards from crowding the score
readout; the action bar auto-fades when the cursor leaves it so it can't
compete with the play surface
- **Drag feel** — every legal drop target is highlighted in green during
drag; cards cast a soft drop shadow that lifts when picked up; the stock
pile shows a remaining-count chip so you can see how close you are to a
recycle
- **Keyboard navigation** — Tab cycles focus through buttons, arrow keys
move within picker rows, Enter activates; works across every modal and
the HUD action bar
- **Progression** — XP, levels, unlockable card backs and backgrounds
- **18 Achievements** — including secret ones
- **Daily Challenge** — server-seeded so every player worldwide gets the same deal
- **Daily Challenge** — server-seeded so every player worldwide gets the
same deal
- **Leaderboard** — opt-in, powered by your own self-hosted server
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
- **Sync** — pull/push stats across devices via a self-hosted server
- **Color-blind mode** — blue tint on red-suit cards
- **Color-blind mode** — blue tint on red-suit cards alongside the suit
glyph
## Building
@@ -32,52 +50,73 @@ cargo build -p solitaire_app --release
## Controls
Every action also has a visible UI button — keyboard shortcuts are optional
accelerators.
| Key | Action |
|---|---|
| Left click / drag | Move cards |
| Double click | Auto-move card to its best legal destination |
| Right click | Highlight legal moves for a card |
| Space / D | Draw from stock |
| Z / Ctrl+Z | Undo |
| U | Undo |
| H | Hint (highlight a legal move) |
| N | New game |
| S | Stats overlay |
| A | Achievements overlay |
| P | Profile overlay |
| O | Settings |
| L | Leaderboard |
| H | Help / controls |
| Enter | Auto-complete (when badge is lit) |
| Escape | Pause / clear selection |
| Arrow keys | Navigate card selection |
| Z | Zen mode |
| G | Forfeit (during pause) |
| Tab / Shift+Tab | Cycle keyboard focus |
| Enter | Activate focused button / auto-complete (when badge is lit) |
| Esc | Pause / dismiss modal |
| F1 | Help / controls |
| F11 | Toggle fullscreen |
| S / A / P / O / L / M | Stats / Achievements / Profile / Settings / Leaderboard / Menu |
## Card themes
The default theme ships embedded in the binary, so the game runs
self-contained with no external assets. To install another theme, drop a
directory containing a `theme.ron` manifest plus 53 SVG files (52 faces +
1 back) under the platform data dir's `themes/` folder, or import a zip
from **Settings → Cosmetic**. The picker chip lights up the moment a new
theme is registered. Themes are SVG-based, so they rasterise cleanly at
whatever resolution the window happens to be.
## Sync Server (optional)
To sync stats across machines, run the self-hosted server. See [README_SERVER.md](README_SERVER.md) for setup instructions.
To sync stats across machines, run the self-hosted server. See
[README_SERVER.md](README_SERVER.md) for setup instructions.
Once the server is running, open **Settings → Sync Backend**, enter the server URL and your username, and register an account from within the game.
Once the server is running, open **Settings → Sync Backend**, enter the
server URL and your username, and register an account from within the
game.
## Running Tests
```bash
# All tests
# All tests (982 passing as of v0.11.0)
cargo test --workspace
# Just game logic (no display required)
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
# Lint
cargo clippy --workspace -- -D warnings
cargo clippy --workspace --all-targets -- -D warnings
```
## Credits
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem (Tokio,
Axum, sqlx, Serde, kira, and many more). Card faces come from
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
(Tokio, Axum, sqlx, Serde, kira, and many more). Card faces come from
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
(MIT, derived from the public-domain `vector-playing-cards` library); the
default card back is original work; the UI font is FiraMono-Medium (OFL).
All audio is synthesized programmatically by this project. See
[CREDITS.md](CREDITS.md) for the full list and license details.
## Changelog
See [CHANGELOG.md](CHANGELOG.md).
## License
MIT — see [LICENSE](LICENSE).
+51 -52
View File
@@ -1,66 +1,67 @@
# Solitaire Quest — UX Overhaul Session Handoff
**Last updated:** 2026-05-02 (session 7) — UX iteration round complete: every item from session 6's UX punch list has shipped, plus a font-fallback fix surfaced by a second-machine smoke test. Six commits on top of session 6's `c4970b1`. Direction now opens for the next round — release prep or another UX pass, the player's call.
**Last updated:** 2026-05-02 (session 7, late-late) — Third UX iteration round complete on top of v0.12.0. Six post-handoff candidates shipped plus two code-review fixes. Ready to tag v0.13.0.
## Status at pause
- **HEAD:** `655dfde`. Local master is **3 commits ahead** of `origin/master` (`f6c9166`, `f712b89`, `655dfde` unpushed; `fdb6c2e` and `95df542` already pushed).
- **Working tree:** clean. (`CARD_PLAN.md` is untracked but intentionally so — it's a plan doc, not source.)
- **HEAD:** doc-commit closing this round (CHANGELOG + handoff). Local master has the impending tag at this commit.
- **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional).
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
- **Tests:** **982 passed / 0 failed** across the workspace (+20 from session 6's 962 baseline).
- **Tags on origin:** `v0.9.0`, `v0.10.0`. Stale local-only `v0.1.0` is still safe to `git tag -d v0.1.0`.
- **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`, `v0.12.0`. v0.13.0 is the next tag.
## Where we are
Session 6's UX punch list was four items. All four shipped today, plus an unrelated font-fallback fix from a second-machine smoke test.
Post-v0.12.0 the handoff listed six "next-round candidates" — every one shipped today plus two code-review fixes (font handling unified to bundled-only, sccache wiring removed). v0.13.0 is the right slice.
The card-theme system, HUD restructure, modal scaffold, and the four big UX feel items (foundations, drop shadows, drop highlights, stock badge) are all in. Direction is open — the deferred release-prep items (`v0.11.0` cut, README/CHANGELOG refresh, desktop packaging) are still on the table, and a fresh round of UX iteration is also available.
The candidate list is exhausted again. Direction is open.
### Design direction (unchanged)
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
- **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
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there. (Earlier sessions used `Rusty_Solitare` — single-i typo — as the repo name; the rename to `Rusty_Solitaire` happened in session 7. Local clone directories may still be named `Rusty_Solitare`; that's just a directory name and works fine.)
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there.
## Session 7 (shipped 2026-05-02)
## Session 7 round 3 (shipped 2026-05-02 late-late) — v0.13.0
| Area | Commit | What landed |
|---|---|---|
| Font fallback | `fdb6c2e` | `shared_fontdb` now `include_bytes!()`s `assets/fonts/main.ttf` (FiraMono) and pins every CSS generic to `"Fira Mono"` so unmatched named families on minimal Linux installs / fresh Wayland sessions / chroots don't drop card rank/suit text. Surfaced when a second-machine pull rendered cards without glyphs. |
| Unlock foundations | `95df542` | `PileType::Foundation(Suit)``Foundation(u8)` (slot 0..3). `Pile::claimed_suit()` derives the claim from the bottom card — no separate field, no claim-stuck-after-undo class of bugs. `can_place_on_foundation` drops its suit parameter. `next_auto_complete_move` prefers a slot whose claimed suit matches the candidate before falling back to the first empty slot for an Ace. Empty foundation markers render as plain placeholders (no "C/D/H/S"). HUD selection label and hint toast read `claimed_suit()` and fall through to "Foundation N" / "move to foundation" when the slot is empty. Save-format invalidation: `GameState.schema_version` bumped 1 → 2; old `game_state.json` files silently fall through to "fresh game on launch." Stats / progress / achievements / settings live in separate files and are unaffected. 9 new tests. |
| Drop overlay | `f6c9166` | The pre-existing `update_drop_highlights` system tinted `PileMarker` sprites green for valid drops, but markers were occluded by stacked cards — invisible during real play. New `update_drop_target_overlays` spawns a soft-fill + 3 px outlined box ABOVE cards for every legal target (full fanned column for tableaux, card-sized for foundations / empty tableaux). `Z_DROP_OVERLAY = 50` sits above static cards but below `DRAG_Z = 500` so the dragged card never gets occluded. Reuses `STATE_SUCCESS` hue. The original marker-tint system is untouched. 3 new tests. |
| Drop shadows | `f712b89` | Each `CardEntity` spawns a `CardShadow` child sprite — neutral black at 25 % alpha, sized `card_size + 4 px`, offset `(2, -3)`, local z `-0.05`. `update_card_shadows_on_drag` snaps shadows in `DragState.cards` to a lifted state (40 % alpha, `(4, -6)` offset, `(8, 8)` padding). `resize_cards_in_place` extended to keep shadows cheap on window resize. `update_card_entity`'s `despawn_related` is followed by a fresh `add_card_shadow_child` so flips / theme swaps re-attach shadows. Pure `card_shadow_params(is_dragged)` helper unit-tested. 4 new tests. |
| Stock badge | `655dfde` | A small `·N` chip at the top-right corner of the stock pile shows the remaining count. `update_stock_count_badge` spawns a top-level world entity whose `Transform.translation` is recomputed each tick from `LayoutResource`, so window resize / theme swap don't strand it. Hides via `Visibility::Hidden` when the stock empties — the existing `↺` `StockEmptyLabel` takes over and they never co-render. `Z_STOCK_BADGE = 30` sits between cards and `Z_DROP_OVERLAY`. 4 new tests. |
| 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. |
| sccache removal | `13dd44b` | Code-review fix: deleted `.cargo/config.toml` and the `.cargo` directory. Plain `cargo build` works without per-project setup. |
| 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`. |
| 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. |
| Drag-with-keyboard | `a0fc0d2` | Tab → Enter → arrows → Enter completes a move without a mouse. New `KeyboardDragState` resource; mutual exclusion with mouse drag via `KEYBOARD_DRAG_TOUCH_ID` sentinel. Help + onboarding hotkey lists updated. |
| Right-click radial | `b37f0cb` | Hold RMB on a face-up card → ring of icons at the cursor, one per legal destination; release over an icon → `MoveRequestEvent`. New `RadialMenuPlugin`. Help controls reference gains a "Mouse" section. |
## Open punch list — release prep (still deferred unless player chooses now)
## Open punch list — release prep
1. **Cut `v0.11.0`** — meaningful slice since `v0.10.0`: full card-theme system (CARD_PLAN phases 17 + theme picker + hayeah art), HUD overhaul (band + fade), session 6's four bug fixes, and session 7's font fallback + four UX feel wins. (`git tag -d v0.1.0` first to clean up the stale local tag.)
2. **README + CHANGELOG refresh** — README was last touched at `a6b8348` before the Settings picker shipped; doesn't mention card themes, the auto-fade, or any of session 7's UX work.
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.
1. **Push** the unpushed commits to origin (5 commits now: 17f9b51, 13dd44b, ddc8f27, 7ed4f2c, a0fc0d2, b37f0cb, plus the impending doc commit).
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). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
## Open punch list — UX iteration (next-round candidates)
The session-6 list is exhausted. Candidates for a next round, none formally requested by the player:
The v0.13.0 list is exhausted. Fresh candidates for a future round:
- **Animated focus ring** (currently a static overlay; could pulse on focus change).
- **Achievement onboarding pass** — show first-time players the achievement panel after their first win.
- **Mode-switch keyboard shortcut** from inside the Mode Launcher (today only mouse opens it).
- **Runtime aspect-ratio fidelity** — hayeah SVGs are ~1.45 h/w; engine layout assumes 1.4. Cards render ~3 % squashed vertically. Cosmetic.
- **Foundation completion celebration** — when a foundation reaches its King, do a small flourish (sparkle, lift, sound). The auto-complete cascade already covers the win moment, but per-foundation closure is currently silent.
- **Drag-cancel return animation** — illegal drops snap cards back instantly. A short ease-back tween ("springs back to where it came from") would feel more forgiving.
- **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.
- **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.
- **Per-mode high-score readout** in the Stats screen. Currently lifetime stats roll all modes together.
- **Auto-save in-progress games** in Zen / Time Attack so players who close the window mid-session don't lose their state.
- **Configurable scoring weights** for the curious — Settings → Gameplay slider for time-bonus magnitude. Cosmetic but power-user appealing.
- **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)
Seven phases landed across `b8fb3fb``924a1e2`. End-to-end:
Seven phases landed across `b8fb3fb``924a1e2` in v0.11.0; v0.13.0's `7ed4f2c` finally consumes the per-theme `back.svg`. End-to-end:
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs (MIT) + a midnight-purple `back.svg` (original work).
- **User themes** live under `themes://` rooted at `solitaire_engine::assets::user_theme_dir()`. Drop a directory containing `theme.ron` + 53 SVG files; appears in the registry on next launch.
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates an archive (20 MB cap, zip-slip rejection, manifest validation, every SVG round-tripped through the rasteriser) and atomically unpacks.
- **Picker UI** in Settings → Cosmetic — one chip per registered theme; selection persists to `settings.json` as `selected_theme_id` and propagates to live sprites via `react_to_settings_theme_change` `sync_card_image_set_with_active_theme``StateChangedEvent`.
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs + a midnight-purple `back.svg`.
- **User themes** under `themes://`. Drop a directory containing `theme.ron` + 53 SVGs.
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates archives and atomically unpacks.
- **Picker UI** in Settings → Cosmetic; the active theme's `back` overrides the legacy `back_N.png` picker when present.
## Resume prompt
@@ -68,34 +69,33 @@ Seven phases landed across `b8fb3fb` → `924a1e2`. End-to-end:
You are a senior Rust + Bevy developer working on Solitaire Quest.
Working directory: <Rusty_Solitaire clone path on this machine — local
directory may still be named Rusty_Solitare from earlier; that's fine>.
Branch: master. Direction is OPEN — the session-6 UX punch list is
fully shipped. The player will choose between cutting v0.11.0, doing
release prep (README/CHANGELOG/packaging), or starting a new UX
iteration round.
Branch: master. Direction is OPEN — three UX iteration rounds shipped
and v0.13.0 is ready to tag.
State: HEAD=655dfde. Local master is 3 commits ahead of origin
(f6c9166, f712b89, 655dfde unpushed; fdb6c2e and 95df542 already
pushed). Working tree clean apart from untracked CARD_PLAN.md
(intentional).
State: HEAD at the doc-commit closing session 7 round 3. Local master
is several commits ahead of origin and unpushed. Working tree clean
apart from untracked CARD_PLAN.md (intentional).
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 982 passed / 0 failed.
Tests: 1053 passed / 0 failed.
READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — full state, session 7 changelog, punch list
2. CLAUDE.md — hard rules (UI-first, no panics, etc.)
3. ARCHITECTURE.md — crate responsibilities + data flow
4. ~/.claude/projects/<this-project>/memory/MEMORY.md
2. CHANGELOG.md — release-by-release record
3. CLAUDE.md — hard rules (UI-first, no panics, etc.)
4. ARCHITECTURE.md — crate responsibilities + data flow
5. ~/.claude/projects/<this-project>/memory/MEMORY.md
— saved feedback / project context (machine-local;
may be missing on a fresh machine)
DECISION TO ASK THE PLAYER FIRST:
A. Push the 3 unpushed commits and cut v0.11.0?
B. Skip the tag for now, refresh README + CHANGELOG, then tag?
C. Skip release prep entirely and start a new UX iteration round?
If C, see the session-7 next-round candidates list (animated
focus ring, achievement onboarding, mode-switch keyboard
shortcut, aspect-ratio fidelity, foundation completion flourish,
drag-cancel return tween).
A. Push and cut v0.13.0 now.
B. Smoke-test the new feel layer first (theme-aware backs, keyboard
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
candidates" in SESSION_HANDOFF for fresh ideas.
D. Take the deferred desktop-packaging item (needs artwork +
signing certs from the user).
WORKFLOW NOTES:
- Commits use:
@@ -105,6 +105,5 @@ WORKFLOW NOTES:
- Every commit must pass build / clippy / test before pushing.
- Push to GitHub (origin) — that is the canonical remote.
OPEN AT THE START: ask which of A / B / C. Don't pick unilaterally
this is a directional choice, not a tactical one.
OPEN AT THE START: ask which of A / B / C / D. Don't pick unilaterally.
```
Binary file not shown.
+4 -3
View File
@@ -10,9 +10,9 @@ use solitaire_engine::{
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin,
SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, UiFocusPlugin,
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
};
fn main() {
@@ -111,6 +111,7 @@ fn main() {
.add_plugins(CardPlugin)
.add_plugins(CursorPlugin)
.add_plugins(InputPlugin)
.add_plugins(RadialMenuPlugin)
.add_plugins(SelectionPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(FeedbackAnimPlugin)
+40 -1
View File
@@ -16,13 +16,14 @@ fn main() -> io::Result<()> {
let out_dir = workspace_root().join("assets").join("audio");
fs::create_dir_all(&out_dir)?;
let effects: [(&str, Generator); 6] = [
let effects: [(&str, Generator); 7] = [
("card_flip.wav", card_flip),
("card_place.wav", card_place),
("card_deal.wav", card_deal),
("card_invalid.wav", card_invalid),
("win_fanfare.wav", win_fanfare),
("ambient_loop.wav", ambient_loop),
("foundation_complete.wav", foundation_complete),
];
for (name, make) in &effects {
@@ -170,6 +171,44 @@ fn win_fanfare() -> Vec<i16> {
out
}
/// Per-suit foundation-completion ping (~240 ms): a rising three-note
/// chime — C6, E6, G6 — with a soft 2nd-harmonic warm layer on each
/// note. Shorter and brighter than `win_fanfare` so it can fire up to
/// four times per game (once per suit) without drowning out subsequent
/// move sounds. The fourth firing co-occurs with the win cascade and
/// `win_fanfare`; the C-major triad sits an octave above the
/// fanfare's root so the two layer cleanly instead of fighting for the
/// same frequency band.
fn foundation_complete() -> Vec<i16> {
// C major triad, one octave up from win_fanfare's root.
let notes = [1046.50_f32, 1318.51, 1567.98]; // C6, E6, G6
let note_dur = 0.07_f32; // brisk, ascending
let total = note_dur * notes.len() as f32 + 0.05;
let n = duration_samples(total);
let mut out = Vec::with_capacity(n);
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
let mut sample = 0.0f32;
for (idx, freq) in notes.iter().enumerate() {
let start = idx as f32 * note_dur;
let local = t - start;
// Each note rings out for 0.18 s — overlapping notes form a
// brief chord at the tail.
if !(0.0..=0.18).contains(&local) {
continue;
}
// Sine + soft 2nd harmonic for warmth, ar_envelope decays
// sharply so each note is bell-like rather than sustained.
let s = (2.0 * std::f32::consts::PI * freq * local).sin()
+ 0.25 * (2.0 * std::f32::consts::PI * freq * 2.0 * local).sin();
let env = ar_envelope(local, 0.005, 0.18, 14.0);
sample += s * env;
}
out.push(quantize(sample * 0.20));
}
out
}
/// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz
/// mono 16-bit PCM).
///
+1 -1
View File
@@ -126,7 +126,7 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
pub mod settings;
pub use settings::{
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;
+185 -2
View File
@@ -132,6 +132,25 @@ pub struct Settings {
/// `#[serde(default = ...)]`.
#[serde(default = "default_theme_id")]
pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been
/// shown to the player after their very first win. Acts as a
/// one-shot teach: subsequent wins must not re-fire the cue. Older
/// `settings.json` files written before this field existed
/// deserialize cleanly to `false` thanks to `#[serde(default)]` —
/// players who already had wins recorded before this field was
/// introduced are guarded by the post-condition `games_won == 1`
/// checked by `achievement_plugin::fire_achievement_onboarding_toast`,
/// so the toast still does not fire for them.
#[serde(default)]
pub shown_achievement_onboarding: bool,
/// 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 {
@@ -150,6 +169,26 @@ fn default_theme_id() -> 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 {
fn default() -> Self {
Self {
@@ -165,17 +204,23 @@ impl Default for Settings {
color_blind_mode: false,
window_geometry: None,
selected_theme_id: default_theme_id(),
shown_achievement_onboarding: false,
tooltip_delay_secs: default_tooltip_delay(),
}
}
}
impl Settings {
/// Clamps both `sfx_volume` and `music_volume` into `[0.0, 1.0]` after
/// deserialization or hand-editing of `settings.json`.
/// Clamps `sfx_volume`, `music_volume`, and `tooltip_delay_secs` into
/// their respective ranges after deserialization or hand-editing of
/// `settings.json`.
pub fn sanitized(self) -> Self {
Self {
sfx_volume: self.sfx_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
}
}
@@ -191,6 +236,15 @@ impl Settings {
self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0);
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
@@ -241,6 +295,7 @@ mod tests {
assert_eq!(s.animation_speed, AnimSpeed::Normal);
assert_eq!(s.theme, Theme::Green);
assert_eq!(s.sync_backend, SyncBackend::Local);
assert!((s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6);
}
#[test]
@@ -318,6 +373,8 @@ mod tests {
color_blind_mode: false,
window_geometry: None,
selected_theme_id: "default".to_string(),
shown_achievement_onboarding: false,
tooltip_delay_secs: default_tooltip_delay(),
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
@@ -506,4 +563,130 @@ mod tests {
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(s.window_geometry.is_none());
}
// -----------------------------------------------------------------------
// shown_achievement_onboarding — first-win cue one-shot guard
// -----------------------------------------------------------------------
#[test]
fn settings_shown_achievement_onboarding_default_is_false() {
assert!(
!Settings::default().shown_achievement_onboarding,
"default shown_achievement_onboarding must be false so the cue fires once"
);
}
#[test]
fn settings_shown_achievement_onboarding_round_trip() {
let path = tmp_path("achievement_onboarding_round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
shown_achievement_onboarding: true,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert!(
loaded.shown_achievement_onboarding,
"shown_achievement_onboarding must survive serde round-trip"
);
let _ = fs::remove_file(&path);
}
#[test]
fn legacy_settings_without_shown_achievement_onboarding_deserializes_to_false() {
// A settings.json written by an older version of the game will be
// missing this field entirely. `#[serde(default)]` on the field
// must yield `false` — the cue then fires on the next win, but
// only when stats.games_won == 1, so existing players who have
// already won past their first game won't see the toast either.
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(
!s.shown_achievement_onboarding,
"legacy settings.json missing shown_achievement_onboarding must deserialize to false"
);
}
// -----------------------------------------------------------------------
// 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);
}
}
+260 -3
View File
@@ -14,17 +14,19 @@ use solitaire_core::achievement::{
ALL_ACHIEVEMENTS,
};
use solitaire_data::{
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
save_progress_to,
achievements_file_path, load_achievements_from, save_achievements_to, save_settings_to,
AchievementRecord, save_progress_to,
};
use crate::events::{
AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent,
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, ToggleAchievementsRequestEvent,
XpAwardedEvent,
};
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
@@ -91,6 +93,7 @@ impl Plugin for AchievementPlugin {
.add_message::<AchievementUnlockedEvent>()
.add_message::<GameWonEvent>()
.add_message::<XpAwardedEvent>()
.add_message::<InfoToastEvent>()
.add_message::<ToggleAchievementsRequestEvent>()
// Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
@@ -102,6 +105,16 @@ impl Plugin for AchievementPlugin {
.after(StatsUpdate)
.after(ProgressUpdate),
)
// Achievement-onboarding cue: fires once after the player's very
// first win to teach the Achievements panel exists. Must run
// `.after(StatsUpdate)` so `stats.games_won` reflects the win
// that just landed (StatsUpdate increments it on `GameWonEvent`).
.add_systems(
Update,
fire_achievement_onboarding_toast
.after(GameMutation)
.after(StatsUpdate),
)
.add_systems(Update, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button);
}
@@ -209,6 +222,67 @@ fn evaluate_on_win(
}
}
/// Achievement-onboarding cue.
///
/// On the player's very first win — and only their first — fires a single
/// `InfoToastEvent` nudging them toward the Achievements panel (`A` hotkey)
/// so they discover the progression layer.
///
/// Three guards prevent spurious or repeat firings:
///
/// * `stats.games_won == 1` — the post-condition is checked **after**
/// `StatsUpdate` increments `games_won`, so the cue only fires for the
/// true first win, not (for example) a player who imported existing
/// sync data and won a later game.
/// * `!settings.shown_achievement_onboarding` — flips to `true` after
/// the toast fires, persists to `settings.json`, and serves as the
/// one-shot guard across launches and merged sync.
/// * The system bails immediately when no `GameWonEvent` arrived this
/// frame so it is a no-op outside the post-win frame.
///
/// The `A` hotkey is mentioned verbatim in the toast text so players who
/// dismiss the cue still know where to find the panel.
fn fire_achievement_onboarding_toast(
mut wins: MessageReader<GameWonEvent>,
stats: Res<StatsResource>,
mut settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
mut toast: MessageWriter<InfoToastEvent>,
) {
// Drain the event queue regardless — multiple wins on a single frame
// only need a single onboarding toast at most.
let any_win = wins.read().last().is_some();
if !any_win {
return;
}
// Without a `SettingsResource` (headless tests that omit `SettingsPlugin`)
// we have no flag to consult; bail out cleanly.
let Some(settings) = settings.as_mut() else {
return;
};
if settings.0.shown_achievement_onboarding {
return;
}
if stats.0.games_won != 1 {
return;
}
toast.write(InfoToastEvent(
"First win! Press A to see your achievements.".to_string(),
));
settings.0.shown_achievement_onboarding = true;
// Persist so the cue stays one-shot across launches. `None` storage
// (headless / test) is a documented no-op.
if let Some(path) = settings_path.as_ref()
&& let Some(target) = path.0.as_deref()
&& let Err(e) = save_settings_to(target, &settings.0)
{
warn!("failed to save settings (achievement onboarding): {e}");
}
}
/// Convenience: resolve an achievement ID to its human-readable name.
/// Used by the toast renderer in `animation_plugin`.
pub fn display_name_for(id: &str) -> String {
@@ -921,4 +995,187 @@ mod tests {
assert!(s.contains("How to unlock"));
assert!(!s.contains("Reward"), "got {s:?}");
}
// -----------------------------------------------------------------------
// Achievement-onboarding cue (`fire_achievement_onboarding_toast`)
// -----------------------------------------------------------------------
/// Builds a headless app that **also** includes `SettingsPlugin::headless()`
/// so the achievement-onboarding system (which reads `SettingsResource`)
/// has a flag to consult and persist into.
fn onboarding_test_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(StatsPlugin::headless())
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
.add_plugins(AchievementPlugin::headless());
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.update();
app
}
/// Collects every `InfoToastEvent` written so tests can assert on
/// count and message contents.
fn drain_info_toasts(app: &App) -> Vec<String> {
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
cursor.read(events).map(|e| e.0.clone()).collect()
}
/// First-win path: with the flag false and `games_won` about to be
/// 1, exactly one `InfoToastEvent` mentioning the `A` hotkey must
/// fire and the flag must flip to `true`.
#[test]
fn first_win_fires_achievement_onboarding_toast() {
let mut app = onboarding_test_app();
// Sanity: fresh app starts with games_won = 0 and the flag unset.
assert_eq!(app.world().resource::<StatsResource>().0.games_won, 0);
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding
);
// StatsPlugin (StatsUpdate) increments games_won to 1 *before* the
// achievement-onboarding system reads stats — our system runs
// `.after(StatsUpdate)`. The system then sees games_won == 1 and
// the cue fires.
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let toasts = drain_info_toasts(&app);
let onboarding_toasts: Vec<&String> = toasts
.iter()
.filter(|t| t.contains("Press A") && t.contains("achievements"))
.collect();
assert_eq!(
onboarding_toasts.len(),
1,
"exactly one achievement-onboarding toast must fire on the first win; \
saw all toasts: {toasts:?}"
);
assert!(
app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding,
"shown_achievement_onboarding must flip to true after the toast fires"
);
}
/// Second-win path: with the flag already `true` (player already
/// saw the cue on a previous run), no onboarding toast may fire.
#[test]
fn subsequent_wins_do_not_fire_achievement_onboarding_toast() {
let mut app = onboarding_test_app();
// Pre-set the flag to simulate a player who already dismissed
// the cue on a previous run.
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.shown_achievement_onboarding = true;
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
.into_iter()
.filter(|t| t.contains("Press A") && t.contains("achievements"))
.collect();
assert!(
onboarding_toasts.is_empty(),
"no onboarding toast must fire when shown_achievement_onboarding is already true; \
got: {onboarding_toasts:?}"
);
}
/// Sync-import path: a player imports stats with `games_won = 5`
/// already on the books. The flag is still `false` (they were on a
/// pre-cue release on this device), but the cue must NOT fire because
/// this isn't actually their first win — the post-condition
/// `games_won == 1` guards against retroactive nagging.
#[test]
fn non_first_win_does_not_fire_achievement_onboarding_toast() {
let mut app = onboarding_test_app();
// Pre-seed games_won = 5 BEFORE the win lands. StatsUpdate will
// bump it to 6 on the GameWonEvent, taking the system well past
// the `games_won == 1` post-condition.
app.world_mut().resource_mut::<StatsResource>().0.games_won = 5;
// Confirm the flag is still false so we know the guard that
// prevents firing is the games-won post-condition, not the flag.
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding
);
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
.into_iter()
.filter(|t| t.contains("Press A") && t.contains("achievements"))
.collect();
assert!(
onboarding_toasts.is_empty(),
"no onboarding toast must fire on a non-first win; got: {onboarding_toasts:?}"
);
// And the flag must remain false so the cue can still teach a
// genuinely-fresh second device or a wiped install.
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding,
"shown_achievement_onboarding must remain false when the cue did not fire"
);
}
/// Without any `GameWonEvent` arriving the system must be a no-op:
/// no toast, no flag flip — even on update ticks where stats happen
/// to read `games_won == 1`.
#[test]
fn no_win_event_means_no_achievement_onboarding_toast() {
let mut app = onboarding_test_app();
// Pre-seed games_won = 1 to simulate the misleading mid-frame
// state without actually firing a GameWonEvent.
app.world_mut().resource_mut::<StatsResource>().0.games_won = 1;
app.update();
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
.into_iter()
.filter(|t| t.contains("Press A") && t.contains("achievements"))
.collect();
assert!(
onboarding_toasts.is_empty(),
"no onboarding toast must fire without a GameWonEvent; got: {onboarding_toasts:?}"
);
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding,
"flag must not flip without a win event"
);
}
}
+32 -87
View File
@@ -107,12 +107,11 @@ impl AssetLoader for SvgLoader {
pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoaderError> {
let opt = usvg::Options {
fontdb: shared_fontdb(),
// Default for SVG elements without an explicit `font-family` —
// resolved by fontdb's generic-family alias to whatever
// sans-serif the system has installed (DejaVu Sans on most
// Linux installs, Helvetica on macOS, Arial on Windows).
font_family: "sans-serif".to_string(),
font_resolver: lenient_font_resolver(),
// The bundled fontdb only contains FiraMono and the resolver
// routes every named-family request to it; this is a default
// for SVGs that don't specify a family at all.
font_family: "Fira Mono".to_string(),
font_resolver: bundled_font_resolver(),
..Default::default()
};
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
/// fonts plus the bundled FiraMono-Medium face. Initialised lazily on
/// first SVG that references text, then shared (via `Arc`) across every
/// subsequent rasterisation.
/// FiraMono-Medium bytes embedded at compile time. Mirrors the embed in
/// `solitaire_engine::font_plugin` so the SVG rasteriser and the Bevy UI
/// share the same canonical face.
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
/// call any text glyph in an SVG renders with no font match — the
/// visible symptom on the bundled hayeah artwork is the "No match for
/// Arial font-family" warn spam plus glyphs that fall through to
/// whatever shape-only path usvg uses for missing fonts.
/// The bundled card SVGs reference families like `Arial` and
/// `Bitstream Vera Sans` by name; [`bundled_font_resolver`] maps every
/// such request directly to FiraMono so rasterisation is deterministic
/// across machines and the system font path is never consulted.
///
/// **Bundled font as last-resort fallback.** Loading only system fonts
/// breaks on minimal Linux installs, fresh Wayland sessions, and
/// chroots where fontconfig has nothing usable to serve as
/// `sans-serif`. The cards on the bundled hayeah theme reference
/// `Bitstream Vera Sans` and `Arial` by name — if neither is installed
/// AND the resolver's CSS-generic fallbacks (`SansSerif`/`Serif`) also
/// don't resolve, the rank/suit text vanishes entirely. Loading the
/// project's bundled FiraMono via `include_bytes!()` and pinning it as
/// the generic-family target guarantees a working last-resort glyph
/// source on every machine. This was the cause of "card font didn't
/// carry over" on a fresh second-machine pull.
///
/// `load_system_fonts` is comparatively expensive (~50200 ms on a
/// typical desktop) so we only pay it once for the lifetime of the
/// process, gated by `OnceLock`.
/// Aborts the program if the embedded bytes don't parse — bundled at
/// compile time, so a parse failure means the binary is corrupt.
fn shared_fontdb() -> Arc<fontdb::Database> {
static DB: OnceLock<Arc<fontdb::Database>> = OnceLock::new();
DB.get_or_init(|| {
let mut db = fontdb::Database::new();
db.load_system_fonts();
// The bundled FiraMono lives at the workspace root, so the
// include_bytes! path goes up three levels from this source
// file (assets → src → solitaire_engine → workspace root).
db.load_font_data(include_bytes!("../../../assets/fonts/main.ttf").to_vec());
// Pin the CSS generics to the bundled face as the resolution
// target. Named-family lookups (Bitstream Vera Sans, Arial)
// still try the system db first; only when those miss does
// the resolver fall through to SansSerif / Serif, and now
// those are guaranteed to land on FiraMono.
db.set_sans_serif_family("Fira Mono");
db.set_serif_family("Fira Mono");
db.set_monospace_family("Fira Mono");
db.set_cursive_family("Fira Mono");
db.set_fantasy_family("Fira Mono");
db.load_font_data(BUNDLED_FONT_BYTES.to_vec());
assert!(
db.faces().next().is_some(),
"bundled FiraMono failed to parse — binary is corrupt"
);
Arc::new(db)
})
.clone()
}
/// Builds a `usvg::FontResolver` that mirrors the upstream default
/// `select_font` but appends the CSS generics `sans-serif` and `serif`
/// to every query's family list. The upstream selector only appends
/// `serif` and emits a `log::warn!` when its `fontdb.query` returns
/// `None`; on systems without the named families requested by the
/// SVG (e.g. Arial on Linux), every text node bridges that warn into
/// our tracing output. By appending two generics — both resolved via
/// 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};
/// Resolver that ignores the SVG's `font-family` request and always
/// returns the single bundled FiraMono face. Bundled card SVGs ask for
/// fonts by name (Arial, Bitstream Vera Sans) that this binary
/// deliberately doesn't ship; routing every query to FiraMono keeps
/// rendering deterministic and removes the system-font path entirely.
fn bundled_font_resolver() -> usvg::FontResolver<'static> {
use usvg::FontResolver;
usvg::FontResolver {
select_font: Box::new(|font, db| {
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_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)),
select_fallback: FontResolver::default_fallback_selector(),
}
}
+32 -2
View File
@@ -28,8 +28,8 @@ use kira::track::{TrackBuilder, TrackHandle};
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
use crate::events::{
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent,
GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
};
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
@@ -70,6 +70,12 @@ pub struct SoundLibrary {
pub place: StaticSoundData,
pub invalid: StaticSoundData,
pub fanfare: StaticSoundData,
/// Per-suit foundation-completion ping. Played whenever a King
/// lands on a foundation pile (Ace → King, 13 cards). ~240 ms,
/// rising C-major triad an octave above `fanfare`'s root so the
/// two layer cleanly when the fourth completion co-occurs with
/// the win cascade.
pub foundation_complete: StaticSoundData,
}
/// Wraps the audio backend. `NonSend` because cpal streams are `!Send` on
@@ -145,6 +151,7 @@ impl Plugin for AudioPlugin {
.add_message::<CardFlippedEvent>()
.add_message::<CardFaceRevealedEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<SettingsChangedEvent>()
.add_systems(Startup, apply_initial_volume)
.add_systems(
@@ -157,6 +164,7 @@ impl Plugin for AudioPlugin {
play_on_win,
play_on_face_revealed,
play_on_undo,
play_on_foundation_complete,
apply_volume_on_change,
handle_mute_keys,
),
@@ -170,12 +178,15 @@ fn build_library() -> Option<SoundLibrary> {
let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?;
let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?;
let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?;
let foundation_complete =
decode(include_bytes!("../../assets/audio/foundation_complete.wav"))?;
Some(SoundLibrary {
deal,
flip,
place,
invalid,
fanfare,
foundation_complete,
})
}
@@ -451,6 +462,25 @@ fn play_on_face_revealed(
}
}
/// Plays the per-suit completion ping whenever a `FoundationCompletedEvent`
/// fires (a King lands on a foundation pile that now holds Ace → King).
///
/// The fourth firing co-occurs with `GameWonEvent` and the win fanfare;
/// the two layer cleanly because the ping sits an octave above the
/// fanfare's root and is much shorter (~240 ms vs ~970 ms).
fn play_on_foundation_complete(
mut events: MessageReader<FoundationCompletedEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
let Some(lib) = lib else {
return;
};
for _ in events.read() {
play(&mut audio, &lib.foundation_complete);
}
}
#[cfg(test)]
mod tests {
use super::*;
+207 -32
View File
@@ -22,6 +22,7 @@ use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::layout::{Layout, LayoutResource, LayoutSystem};
@@ -50,8 +51,11 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
/// Fraction of card height used as a tiny offset between stacked cards in
/// non-tableau piles, so stacking is visible.
const STACK_FAN_FRAC: f32 = 0.003;
/// non-tableau piles, so stacking is visible. Public so other plugins
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
/// `Transform.translation.z` for a card at a given stack index without
/// drifting from the value used by [`card_positions`].
pub const STACK_FAN_FRAC: f32 = 0.003;
/// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28;
@@ -72,8 +76,21 @@ pub struct CardImageSet {
/// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
/// Rank order: Ace=0, Two=1 … King=12.
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],
/// 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
@@ -366,7 +383,14 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Comman
let backs = std::array::from_fn(|i| {
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
@@ -403,6 +427,12 @@ fn card_sprite(
Rank::King => 12,
};
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 {
let idx = selected_back.min(set.backs.len() - 1);
set.backs[idx].clone()
@@ -447,7 +477,7 @@ fn sync_cards_startup(
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform)>,
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
card_images: Option<Res<CardImageSet>>,
) {
if let Some(layout) = layout {
@@ -467,7 +497,7 @@ fn sync_cards_on_change(
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform)>,
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
card_images: Option<Res<CardImageSet>>,
) {
if events.read().next().is_none() {
@@ -490,22 +520,27 @@ fn sync_cards(
slide_secs: f32,
back_colour: Color,
color_blind: bool,
entities: &Query<(Entity, &CardEntity, &Transform)>,
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
card_images: Option<&CardImageSet>,
selected_back: usize,
) {
let positions = card_positions(game, layout);
// Map card_id -> (Entity, current_translation) for in-place updates.
let mut existing: HashMap<u32, (Entity, Vec3)> = HashMap::new();
for (entity, marker, transform) in entities.iter() {
existing.insert(marker.card_id, (entity, transform.translation));
// Map card_id -> (Entity, current_translation, has_card_animation) for
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
// skip the snap/slide path on cards that are already being driven by a
// curve-based `CardAnimation` tween (e.g. the drag-rejection return tween
// — see `input_plugin::end_drag`). Otherwise the StateChangedEvent that
// accompanies a rejection would race the tween and the card would jump.
let mut existing: HashMap<u32, (Entity, Vec3, bool)> = HashMap::new();
for (entity, marker, transform, anim) in entities.iter() {
existing.insert(marker.card_id, (entity, transform.translation, anim.is_some()));
}
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
// Despawn any entity whose card is no longer tracked.
for (card_id, (entity, _)) in &existing {
for (card_id, (entity, _, _)) in &existing {
if !live_ids.contains(card_id) {
commands.entity(*entity).despawn();
}
@@ -514,10 +549,10 @@ fn sync_cards(
// For each card in the current state: spawn or update its entity.
for (card, position, z) in positions {
match existing.get(&card.id) {
Some(&(entity, cur)) => {
Some(&(entity, cur, has_anim)) => {
update_card_entity(
&mut commands, entity, card, position, z, layout,
slide_secs, back_colour, color_blind, cur, card_images, selected_back,
slide_secs, back_colour, color_blind, cur, has_anim, card_images, selected_back,
)
}
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back),
@@ -667,6 +702,7 @@ fn update_card_entity(
back_colour: Color,
color_blind: bool,
cur: Vec3,
has_card_animation: bool,
card_images: Option<&CardImageSet>,
selected_back: usize,
) {
@@ -675,24 +711,31 @@ fn update_card_entity(
// Always refresh the visual appearance.
commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
// Slide to the new position when it differs meaningfully; snap otherwise.
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
commands
.entity(entity)
.insert(Transform::from_translation(start))
.insert(CardAnim {
start,
target,
elapsed: 0.0,
duration: slide_secs,
delay: 0.0,
});
} else {
commands
.entity(entity)
.remove::<CardAnim>()
.insert(Transform::from_xyz(pos.x, pos.y, z));
// Skip the snap/slide path entirely when a curve-based `CardAnimation`
// is driving this card (e.g. the drag-rejection return tween). Writing
// `Transform` here would race that animation each frame and cause a
// visible jump. The animation system snaps the final position itself
// when it completes.
if !has_card_animation {
// Slide to the new position when it differs meaningfully; snap otherwise.
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
commands
.entity(entity)
.insert(Transform::from_translation(start))
.insert(CardAnim {
start,
target,
elapsed: 0.0,
duration: slide_secs,
delay: 0.0,
});
} else {
commands
.entity(entity)
.remove::<CardAnim>()
.insert(Transform::from_xyz(pos.x, pos.y, z));
}
}
// Despawn any stale children and re-add the per-card drop shadow plus,
@@ -2525,4 +2568,136 @@ mod tests {
// Sanity: a fresh game with stock present reports 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",
);
}
}
+43
View File
@@ -1,6 +1,7 @@
//! Cross-system events used by the engine's plugins.
use bevy::prelude::Message;
use solitaire_core::card::Suit;
use solitaire_core::game_state::GameMode;
use solitaire_core::pile::PileType;
use solitaire_data::AchievementRecord;
@@ -60,6 +61,48 @@ pub struct GameWonEvent {
pub time_seconds: u64,
}
/// Fired by `GamePlugin` whenever a successful move lands a card on a
/// foundation pile that, after the move, contains all 13 cards of its
/// suit (Ace → King). Drives the per-suit completion flourish — a brief
/// scale pulse on the King card and a golden tint on the foundation
/// pile marker — plus a short audio ping.
///
/// Fired once per per-suit completion. The fourth completion will
/// co-occur with `GameWonEvent` and the win cascade — they layer
/// cleanly because the flourish is purely decorative and lives on a
/// dedicated marker component.
///
/// This event is a UI/audio cue only. It does **not** cross
/// `solitaire_sync` and is not persisted.
#[derive(Message, Debug, Clone, Copy)]
pub struct FoundationCompletedEvent {
/// Foundation pile slot (0..=3) that just reached 13 cards.
pub slot: u8,
/// The suit of the completed foundation, taken from the bottom card
/// (always an Ace by construction).
pub suit: Suit,
}
/// Fired 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.
#[derive(Message, Debug, Clone, Copy)]
pub struct CardFlippedEvent(pub u32);
+250 -2
View File
@@ -48,13 +48,18 @@ use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim;
use crate::card_plugin::CardEntity;
use crate::events::{
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameRequestEvent,
};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource;
use crate::table_plugin::PileMarker;
use crate::ui_theme::{
FOUNDATION_FLOURISH_PEAK_SCALE, MOTION_FOUNDATION_FLOURISH_SECS, STATE_SUCCESS,
};
// ---------------------------------------------------------------------------
// Shared constants
@@ -185,7 +190,8 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
// Plugin
// ---------------------------------------------------------------------------
/// Registers the shake, settle, and deal animation systems.
/// Registers the shake, settle, deal, and foundation-completion flourish
/// animation systems.
pub struct FeedbackAnimPlugin;
impl Plugin for FeedbackAnimPlugin {
@@ -197,6 +203,7 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<DrawRequestEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<FoundationCompletedEvent>()
.add_systems(
Update,
(
@@ -205,6 +212,8 @@ impl Plugin for FeedbackAnimPlugin {
start_settle_anim.after(GameMutation),
tick_settle_anim,
start_deal_anim.after(GameMutation),
start_foundation_flourish.after(GameMutation),
tick_foundation_flourish,
),
);
}
@@ -401,6 +410,204 @@ fn start_deal_anim(
}
}
// ---------------------------------------------------------------------------
// Foundation-completion flourish
// ---------------------------------------------------------------------------
/// Drives the per-foundation completion flourish on the King card that
/// just landed on a foundation pile (Ace → King, 13 cards).
///
/// Inserted on the King's `CardEntity` when `FoundationCompletedEvent`
/// fires; removed once `elapsed >= duration`. Decorative only — does
/// not block input or interfere with the win cascade, settle, or hint
/// systems (those operate on different markers and read the same
/// `Transform.scale` coordinate non-conflictingly because the flourish
/// finishes in well under a second).
#[derive(Component, Debug, Clone, Copy)]
pub struct FoundationFlourish {
/// Foundation slot (0..=3) this flourish is celebrating.
pub foundation_slot: u8,
/// Seconds elapsed since the flourish began.
pub elapsed: f32,
/// Total animation length in seconds.
pub duration: f32,
}
/// Drives a brief golden tint on the foundation `PileMarker` whose
/// foundation just completed. Stores the marker's original colour so
/// it can be restored when the timer expires.
///
/// Inserted alongside (and concurrent with) `FoundationFlourish` on the
/// matching `PileMarker` entity. The system runs independently of the
/// existing `HintPileHighlight` so the two never share state — a hint
/// landing during a completion flourish (highly unlikely in practice
/// since the foundation just completed) won't corrupt either party's
/// `original_color` snapshot.
#[derive(Component, Debug, Clone, Copy)]
pub struct FoundationMarkerFlourish {
/// Seconds elapsed since the tint was applied.
pub elapsed: f32,
/// Total animation length in seconds.
pub duration: f32,
/// The pile marker's sprite colour before the tint was applied —
/// restored when the timer expires.
pub original_color: Color,
}
/// Pure helper for unit tests — returns the per-frame scale factor for
/// the foundation flourish at `elapsed_secs` over `duration_secs`.
///
/// Triangular curve, mirroring `score_pulse_scale` in `hud_plugin`:
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
/// [`FOUNDATION_FLOURISH_PEAK_SCALE`] (1.15), at `t = 1.0` returns
/// `1.0`. Out-of-range values are clamped so the King never freezes
/// at a non-1.0 scale on the frame after the flourish ends.
///
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
/// without dividing by zero.
pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
if duration_secs <= 0.0 {
return 1.0;
}
let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
let peak = FOUNDATION_FLOURISH_PEAK_SCALE;
if t < 0.5 {
// Climb from 1.0 at t=0 to peak at t=0.5.
1.0 + (peak - 1.0) * (t / 0.5)
} else {
// Descend from peak at t=0.5 back to 1.0 at t=1.0.
peak - (peak - 1.0) * ((t - 0.5) / 0.5)
}
}
/// Inserts `FoundationFlourish` on the King card entity at the
/// completed foundation and `FoundationMarkerFlourish` on its
/// `PileMarker`. The King is identified as the *top* card of the
/// foundation pile after the move — by definition the 13th card,
/// always rank King by foundation rules.
fn start_foundation_flourish(
mut events: MessageReader<FoundationCompletedEvent>,
game: Res<GameStateResource>,
card_entities: Query<(Entity, &CardEntity)>,
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
mut commands: Commands,
) {
for ev in events.read() {
let pile_type = PileType::Foundation(ev.slot);
// Top card of the completed foundation is the King.
let Some(king_id) = game
.0
.piles
.get(&pile_type)
.and_then(|p| p.cards.last())
.map(|c| c.id)
else {
continue;
};
// Tag the King's card entity.
for (entity, card_marker) in card_entities.iter() {
if card_marker.card_id == king_id {
commands.entity(entity).insert(FoundationFlourish {
foundation_slot: ev.slot,
elapsed: 0.0,
duration: MOTION_FOUNDATION_FLOURISH_SECS,
});
}
}
// Tint the matching PileMarker. Snapshot the current colour so
// tick_foundation_flourish can restore it; if a stale flourish
// is somehow still active, reuse its `original_color` so we
// don't capture the gold tint as the new "original".
for (entity, pile_marker, sprite, existing) in pile_markers.iter_mut() {
if pile_marker.0 != pile_type {
continue;
}
let original_color = existing.map_or(sprite.color, |f| f.original_color);
commands.entity(entity).insert(FoundationMarkerFlourish {
elapsed: 0.0,
duration: MOTION_FOUNDATION_FLOURISH_SECS,
original_color,
});
}
}
}
/// Advances both the King's scale pulse and the foundation marker's
/// gold tint each frame. Removes both components once their timers
/// expire, restoring the King's `Transform.scale` to `Vec3::ONE` and
/// the marker's sprite colour to its captured original.
///
/// Skipped while paused so a player who hits Esc mid-flourish doesn't
/// see frozen scaled state (the next unpause tick resumes from the
/// stored `elapsed`).
#[allow(clippy::type_complexity)]
fn tick_foundation_flourish(
mut commands: Commands,
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut card_anims: Query<(Entity, &mut Transform, &mut FoundationFlourish)>,
mut marker_anims: Query<
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
Without<FoundationFlourish>,
>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
// Advance the King's scale pulse.
for (entity, mut transform, mut anim) in &mut card_anims {
anim.elapsed += dt;
if anim.elapsed >= anim.duration {
// Restore identity scale so the card sits at its normal size
// for the next frame's transform sync.
transform.scale = Vec3::ONE;
commands.entity(entity).remove::<FoundationFlourish>();
} else {
let s = foundation_flourish_scale(anim.elapsed, anim.duration);
transform.scale = Vec3::new(s, s, 1.0);
}
}
// Advance the foundation marker's gold tint. Held flat for the
// first half of the duration and faded back to the original colour
// over the second half — feels celebratory without bleeding into
// the next move's drop-target highlights.
for (entity, mut sprite, mut anim) in &mut marker_anims {
anim.elapsed += dt;
if anim.elapsed >= anim.duration {
sprite.color = anim.original_color;
commands.entity(entity).remove::<FoundationMarkerFlourish>();
} else {
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
// Lerp factor: 1.0 (full tint) for the first half, then
// ramps down linearly to 0.0 (original colour) by the end.
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
sprite.color = lerp_color(anim.original_color, STATE_SUCCESS, mix);
}
}
}
/// Linear interpolation between two `Color`s in sRGB space. Pulled out
/// as a small helper so the `tick_foundation_flourish` body stays
/// readable; sRGB-space lerping is fine for a brief decorative tint
/// (a perceptually-uniform space would be overkill).
fn lerp_color(from: Color, to: Color, t: f32) -> Color {
let from = from.to_srgba();
let to = to.to_srgba();
let t = t.clamp(0.0, 1.0);
Color::srgba(
from.red + (to.red - from.red) * t,
from.green + (to.green - from.green) * t,
from.blue + (to.blue - from.blue) * t,
from.alpha + (to.alpha - from.alpha) * t,
)
}
// ---------------------------------------------------------------------------
// Unit tests (pure functions only — no Bevy world required)
// ---------------------------------------------------------------------------
@@ -534,6 +741,47 @@ mod tests {
}
}
// Foundation-flourish curve tests
/// Triangular curve must be 1.0 at t=0, peak at t=0.5, and 1.0 at t=1.
#[test]
fn foundation_flourish_scale_curves_through_one_one_one() {
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
assert!(
(foundation_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
"flourish scale at t=0 must be 1.0"
);
assert!(
(foundation_flourish_scale(dur / 2.0, dur) - FOUNDATION_FLOURISH_PEAK_SCALE).abs() < 1e-5,
"flourish scale at midpoint must be FOUNDATION_FLOURISH_PEAK_SCALE"
);
assert!(
(foundation_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
"flourish scale at t=duration must return to 1.0"
);
}
/// Out-of-range values are clamped, not extrapolated. Important so the
/// King never ends up at a non-1.0 scale on the frame after the
/// flourish ends (which would race against the despawn / restore step
/// in `tick_foundation_flourish`).
#[test]
fn foundation_flourish_scale_clamps_out_of_range() {
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
// Negative elapsed clamps to 0 → scale 1.0.
assert!((foundation_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
// Past-end clamps to t=1 → scale 1.0.
assert!((foundation_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
}
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
/// divides by zero.
#[test]
fn foundation_flourish_scale_zero_duration_is_one() {
assert!((foundation_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
assert!((foundation_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
}
#[test]
fn deal_stagger_jitter_varies_across_card_ids() {
// 52 cards should produce more than a couple distinct jitter factors;
+23 -12
View File
@@ -1,14 +1,23 @@
// Register FontPlugin in solitaire_engine/src/lib.rs before use.
//! Loads FiraMono-Medium via the Bevy `AssetServer` and exposes it via [`FontResource`].
//! Embeds FiraMono-Medium into the binary 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::*;
/// 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)]
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;
impl Plugin for FontPlugin {
@@ -17,11 +26,13 @@ impl Plugin for FontPlugin {
}
}
fn load_font(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
let Some(asset_server) = asset_server else {
// AssetServer absent (e.g. MinimalPlugins in tests) — insert default.
commands.insert_resource(FontResource(Handle::default()));
return;
};
commands.insert_resource(FontResource(asset_server.load("fonts/main.ttf")));
fn load_font(fonts: Option<ResMut<Assets<Font>>>, mut commands: Commands) {
// Headless test fixtures use MinimalPlugins (no AssetPlugin → no
// Assets<Font>). FontPlugin in that context is a no-op — consumers
// already query `Option<Res<FontResource>>` and degrade cleanly.
let Some(mut fonts) = fonts else { return };
let font = Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec())
.expect("bundled FiraMono failed to parse — binary is corrupt");
let handle = fonts.add(font);
commands.insert_resource(FontResource(handle));
}
+210 -2
View File
@@ -15,8 +15,8 @@ use solitaire_data::{delete_game_state_at, game_state_file_path, load_game_state
save_game_state_to};
use crate::events::{
CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent,
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
@@ -86,6 +86,7 @@ impl Plugin for GamePlugin {
.add_message::<GameWonEvent>()
.add_message::<crate::events::CardFlippedEvent>()
.add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>()
.add_systems(
Update,
@@ -398,14 +399,18 @@ fn handle_draw(
}
}
#[allow(clippy::too_many_arguments)]
fn handle_move(
mut moves: MessageReader<MoveRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: MessageWriter<StateChangedEvent>,
mut won: MessageWriter<GameWonEvent>,
mut flipped: MessageWriter<crate::events::CardFlippedEvent>,
mut foundation_done: MessageWriter<FoundationCompletedEvent>,
path: Option<Res<GameStatePath>>,
) {
use solitaire_core::pile::PileType;
for ev in moves.read() {
let was_won = game.0.is_won;
// Identify the card that will be exposed (and may flip face-up) by the move.
@@ -429,6 +434,19 @@ fn handle_move(
{
flipped.write(crate::events::CardFlippedEvent(fid));
}
// If this move landed on a foundation pile and that pile is
// now complete (Ace → King, 13 cards), fire the per-suit
// flourish event. Drives a brief decorative scale-pulse on
// the King + a golden tint on the foundation marker plus a
// short audio ping. Purely a UI / audio cue — does not
// cross `solitaire_sync` and is not persisted.
if let PileType::Foundation(slot) = ev.to
&& let Some(pile) = game.0.piles.get(&ev.to)
&& pile.cards.len() == 13
&& let Some(suit) = pile.claimed_suit()
{
foundation_done.write(FoundationCompletedEvent { slot, suit });
}
changed.write(StateChangedEvent);
if !was_won && game.0.is_won {
won.write(GameWonEvent {
@@ -1407,6 +1425,196 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// Foundation-completion flourish — FoundationCompletedEvent firing logic
// -----------------------------------------------------------------------
/// Helper: prefill `Foundation(slot)` with Ace through Queen of `suit`
/// (12 cards, all face-up) and place the King of `suit` on
/// `Tableau(0)` so a single `MoveRequestEvent` can complete the
/// foundation.
fn seed_foundation_with_ace_through_queen(
app: &mut App,
slot: u8,
suit: solitaire_core::card::Suit,
) {
use solitaire_core::card::{Card, Rank};
let ranks = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen,
];
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
let foundation = gs
.0
.piles
.get_mut(&PileType::Foundation(slot))
.expect("foundation slot must exist");
foundation.cards.clear();
for (i, &rank) in ranks.iter().enumerate() {
foundation.cards.push(Card {
id: 5_000 + i as u32 + (slot as u32) * 100,
suit,
rank,
face_up: true,
});
}
// Put the King on Tableau(0) so a single move can complete it.
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.clear();
t0.cards.push(Card {
id: 6_000 + (slot as u32),
suit,
rank: Rank::King,
face_up: true,
});
}
/// Reading helper: collect every `FoundationCompletedEvent` written
/// during the most recent `update()` so the test body can assert
/// against count, slot, and suit.
fn drain_foundation_events(app: &App) -> Vec<FoundationCompletedEvent> {
let events = app
.world()
.resource::<Messages<FoundationCompletedEvent>>();
let mut cursor = events.get_cursor();
cursor.read(events).copied().collect()
}
/// When a King lands on a foundation that already holds Ace through
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
/// the matching slot + suit.
#[test]
fn foundation_completed_event_fires_when_king_lands() {
use solitaire_core::card::Suit;
let mut app = test_app(1);
seed_foundation_with_ace_through_queen(&mut app, 2, Suit::Hearts);
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Foundation(2),
count: 1,
});
app.update();
let fired = drain_foundation_events(&app);
assert_eq!(
fired.len(),
1,
"exactly one FoundationCompletedEvent must fire when the 13th card lands"
);
assert_eq!(fired[0].slot, 2, "event slot must match the destination slot");
assert_eq!(fired[0].suit, Suit::Hearts, "event suit must match the foundation suit");
}
/// Moving a card to a tableau pile must never produce a
/// `FoundationCompletedEvent`, even if the source tableau happened
/// to have been a King.
#[test]
fn foundation_completed_event_does_not_fire_for_non_foundation_moves() {
use solitaire_core::card::{Card, Rank, Suit};
let mut app = test_app(1);
// Reset the world: clear stock + waste so a draw isn't possible,
// empty all tableaux + foundations, then place a face-up King of
// Spades on Tableau(0). Tableau(1) is empty, so the King can move
// there legally.
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 7_000,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
});
}
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
count: 1,
});
app.update();
let fired = drain_foundation_events(&app);
assert!(
fired.is_empty(),
"FoundationCompletedEvent must not fire for non-foundation moves; got {fired:?}"
);
}
/// At 12 cards on a foundation (AceJack on the pile, Queen in
/// flight), the event must NOT fire — the flourish is only for the
/// final 13th completion.
#[test]
fn foundation_completed_event_does_not_fire_at_12_cards() {
use solitaire_core::card::{Card, Rank, Suit};
let mut app = test_app(1);
let suit = Suit::Diamonds;
let slot: u8 = 1;
// Pre-fill foundation with Ace through Jack (11 cards).
let pre_ranks = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack,
];
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
let foundation = gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap();
foundation.cards.clear();
for (i, &rank) in pre_ranks.iter().enumerate() {
foundation.cards.push(Card {
id: 8_000 + i as u32,
suit,
rank,
face_up: true,
});
}
// Queen on Tableau(0) so a single move pushes the foundation
// count to exactly 12 (still below the completion threshold).
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.clear();
t0.cards.push(Card {
id: 8_900,
suit,
rank: Rank::Queen,
face_up: true,
});
}
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Foundation(slot),
count: 1,
});
app.update();
// Sanity: the move actually landed (foundation has 12 cards now).
let foundation_len = app
.world()
.resource::<GameStateResource>()
.0
.piles[&PileType::Foundation(slot)]
.cards
.len();
assert_eq!(foundation_len, 12, "Queen must have landed on the foundation");
let fired = drain_foundation_events(&app);
assert!(
fired.is_empty(),
"FoundationCompletedEvent must not fire at 12 cards; got {fired:?}"
);
}
/// A successful undo must NOT fire an `InfoToastEvent`.
#[test]
fn undo_after_draw_does_not_fire_info_toast() {
+32
View File
@@ -94,6 +94,28 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
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 {
title: "New Game",
rows: &[
@@ -104,6 +126,16 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlRow { keys: "T", description: "Start a Time Attack session (level 5+)" },
],
},
ControlSection {
title: "Mode Launcher (M)",
rows: &[
ControlRow { keys: "1", description: "Launch Classic" },
ControlRow { keys: "2", description: "Launch Daily Challenge" },
ControlRow { keys: "3", description: "Launch Zen (level 5+)" },
ControlRow { keys: "4", description: "Launch Challenge (level 5+)" },
ControlRow { keys: "5", description: "Launch Time Attack (level 5+)" },
],
},
ControlSection {
title: "Overlays",
rows: &[
+290 -1
View File
@@ -135,6 +135,14 @@ impl Plugin for HomePlugin {
.add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<InfoToastEvent>()
// `.chain()` because several systems (M-toggle, card click,
// cancel button, digit-key shortcut) all read the
// `HomeScreen` entity and may queue a despawn on it in the
// same tick. Bevy's parallel scheduler would otherwise let
// two of them run simultaneously and double-despawn the
// entity, panicking when the second command buffer is
// applied. Chaining serialises these systems and keeps the
// despawn deterministic.
.add_systems(
Update,
(
@@ -142,7 +150,9 @@ impl Plugin for HomePlugin {
attach_focusable_to_home_mode_cards,
handle_home_card_click,
handle_home_cancel_button,
),
handle_home_digit_keys,
)
.chain(),
);
}
}
@@ -251,6 +261,98 @@ fn handle_home_cancel_button(
}
}
// ---------------------------------------------------------------------------
// Digit-key shortcuts (1-5) — modal-scoped
// ---------------------------------------------------------------------------
/// Maps a [`KeyCode::Digit1`]..[`KeyCode::Digit5`] press to the matching
/// [`HomeMode`]. Returns `None` for any other key. Kept as a small free
/// function so the keyboard handler reads as a clean dispatch table and so
/// the mapping is easy to unit-test in isolation.
fn digit_to_home_mode(key: KeyCode) -> Option<HomeMode> {
match key {
KeyCode::Digit1 => Some(HomeMode::Classic),
KeyCode::Digit2 => Some(HomeMode::Daily),
KeyCode::Digit3 => Some(HomeMode::Zen),
KeyCode::Digit4 => Some(HomeMode::Challenge),
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
_ => None,
}
}
/// Direct keyboard activation of a specific mode while the Mode Launcher
/// modal is open. Mirrors the click-handler dispatch in
/// [`handle_home_card_click`]: pressing `1` launches Classic, `2` launches
/// the Daily Challenge, and `3`/`4`/`5` launch Zen / Challenge / Time
/// Attack respectively when the player has reached
/// [`CHALLENGE_UNLOCK_LEVEL`].
///
/// The shortcut is **modal-scoped** — when no [`HomeScreen`] exists the
/// system returns immediately, so digit keys can never accidentally launch
/// a mode mid-game. Pressing a digit for a locked mode is a no-op (matches
/// the click-on-locked-card behaviour) and leaves the modal open so the
/// player can pick another mode.
#[allow(clippy::too_many_arguments)]
fn handle_home_digit_keys(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
progress: Option<Res<ProgressResource>>,
screens: Query<Entity, With<HomeScreen>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut zen: MessageWriter<StartZenRequestEvent>,
mut challenge: MessageWriter<StartChallengeRequestEvent>,
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
) {
// Modal-scoped: do nothing when the Mode Launcher isn't open.
if screens.is_empty() {
return;
}
let Some(mode) = [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
]
.into_iter()
.find(|k| keys.just_pressed(*k))
.and_then(digit_to_home_mode) else {
return;
};
let level = progress.as_ref().map_or(0, |p| p.0.level);
if !mode.is_unlocked(level) {
// Locked mode: no-op, modal stays open.
return;
}
match mode {
HomeMode::Classic => {
new_game.write(NewGameRequestEvent::default());
}
HomeMode::Daily => {
daily.write(StartDailyChallengeRequestEvent);
}
HomeMode::Zen => {
zen.write(StartZenRequestEvent);
}
HomeMode::Challenge => {
challenge.write(StartChallengeRequestEvent);
}
HomeMode::TimeAttack => {
time_attack.write(StartTimeAttackRequestEvent);
}
}
// Close the modal after dispatching the launch event — same shape as
// the click handler.
for entity in &screens {
commands.entity(entity).despawn();
}
}
// ---------------------------------------------------------------------------
// Spawn helpers
// ---------------------------------------------------------------------------
@@ -873,4 +975,191 @@ mod tests {
"no card may be Disabled when the player is at the unlock level"
);
}
// -----------------------------------------------------------------------
// Digit-key shortcuts (1-5) — modal-scoped direct mode launch
// -----------------------------------------------------------------------
/// Press a key and clear the input afterwards so the next `update()`
/// doesn't re-fire `just_pressed`. Mirrors the open_home() pattern but
/// for an arbitrary key (the M-press helper releases & clears KeyM,
/// which is also what we need here for Digit keys).
fn press_and_clear(app: &mut App, key: KeyCode) {
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.press(key);
}
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
}
}
#[test]
fn digit1_in_home_modal_starts_classic_and_closes_modal() {
let mut app = headless_app();
let _ = open_home(&mut app);
// Drain any pre-existing NewGameRequestEvent so the assertion
// only sees the digit-key driven write.
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
press_and_clear(&mut app, KeyCode::Digit1);
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(
fired.len(),
1,
"exactly one NewGameRequestEvent must fire for Digit1"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Classic via Digit1"
);
}
#[test]
fn digit3_at_level_zero_is_a_noop() {
let mut app = headless_app();
// Default level is 0 — Zen is locked.
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
press_and_clear(&mut app, KeyCode::Digit3);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert!(
zc.read(zen).next().is_none(),
"Digit3 at level 0 must not fire StartZenRequestEvent"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
1,
"Home modal must remain open after a locked-mode digit press"
);
}
#[test]
fn digit3_at_unlock_level_starts_zen_and_closes_modal() {
let mut app = headless_app();
// Bump the player to the unlock level *before* opening the modal
// so the Mode Launcher is in its unlocked state.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
press_and_clear(&mut app, KeyCode::Digit3);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert_eq!(
zc.read(zen).count(),
1,
"Digit3 at unlock level must fire exactly one StartZenRequestEvent"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Zen via Digit3"
);
}
#[test]
fn digit_keys_outside_home_modal_are_noop() {
let mut app = headless_app();
// Modal is NOT open. Bump level so Zen would otherwise be allowed
// — this isolates the modal-scope guard from the unlock check.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
// Drain any pre-existing events.
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartChallengeRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartTimeAttackRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartDailyChallengeRequestEvent>>()
.clear();
// Press every digit 1-5 in turn — none should trigger a launch.
for key in [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
] {
press_and_clear(&mut app, key);
}
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut nc = new_game.get_cursor();
assert!(
nc.read(new_game).next().is_none(),
"Digit keys with no modal open must not fire NewGameRequestEvent"
);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert!(
zc.read(zen).next().is_none(),
"Digit keys with no modal open must not fire StartZenRequestEvent"
);
let chal = app.world().resource::<Messages<StartChallengeRequestEvent>>();
let mut cc = chal.get_cursor();
assert!(
cc.read(chal).next().is_none(),
"Digit keys with no modal open must not fire StartChallengeRequestEvent"
);
let ta = app.world().resource::<Messages<StartTimeAttackRequestEvent>>();
let mut tc = ta.get_cursor();
assert!(
tc.read(ta).next().is_none(),
"Digit keys with no modal open must not fire StartTimeAttackRequestEvent"
);
let daily = app.world().resource::<Messages<StartDailyChallengeRequestEvent>>();
let mut dc = daily.get_cursor();
assert!(
dc.read(daily).next().is_none(),
"Digit keys with no modal open must not fire StartDailyChallengeRequestEvent"
);
}
}
+238 -4
View File
@@ -19,16 +19,17 @@ use crate::settings_plugin::SettingsResource;
use crate::layout::HUD_BAND_HEIGHT;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM,
STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY, TEXT_SECONDARY,
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS,
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
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::{
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
UndoRequestEvent,
UndoRequestEvent, WinStreakMilestoneEvent,
};
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
@@ -130,6 +131,51 @@ pub struct ScoreFloater {
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
/// changes without a `ScoreChangedEvent`. The plugin wires this to the
/// pulse + floater systems on every `Update`.
@@ -251,6 +297,7 @@ impl Plugin for HudPlugin {
.add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<ToggleLeaderboardRequestEvent>()
.add_message::<WinStreakMilestoneEvent>()
.init_resource::<PreviousScore>()
.init_resource::<HudActionFade>()
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
@@ -267,6 +314,12 @@ impl Plugin for HudPlugin {
.chain()
.after(GameMutation),
)
.add_systems(
Update,
(start_streak_flourish, advance_streak_flourish)
.chain()
.after(GameMutation),
)
.add_systems(
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)]
fn update_hud(
game: Res<GameStateResource>,
@@ -2091,6 +2286,45 @@ mod tests {
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
// -----------------------------------------------------------------------
+189 -77
View File
@@ -30,10 +30,12 @@ use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_animation::tuning::AnimationTuning;
use crate::card_animation::{CardAnimation, MotionCurve};
use crate::card_plugin::{
CardEntity, HintHighlight, HintHighlightTimer, TABLEAU_FACEDOWN_FAN_FRAC, TABLEAU_FAN_FRAC,
CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC,
TABLEAU_FAN_FRAC,
};
use crate::feedback_anim_plugin::ShakeAnim;
use crate::ui_theme::MOTION_DRAG_REJECT_SECS;
use solitaire_core::game_state::DrawMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
@@ -666,14 +668,16 @@ fn end_drag(
to: target.clone(),
count,
});
// Shake each dragged card so the player gets immediate
// visual feedback that the drop was rejected. ShakeAnim
// restores translation.x to origin_x at the end of the
// animation, so origin_x must be the target slot in the
// origin pile — using the current drag transform would
// pin the card at the drop location and fight the
// sync_cards slide that StateChangedEvent triggers
// (the symptom is "card lands beside the pile").
// Smoothly glide each dragged card from its drop-time
// transform back to its resting slot in the origin pile.
// The audio cue (card_invalid.wav, played by AudioPlugin
// on MoveRejectedEvent) still gives the player clear
// negative feedback; this just replaces the old shake
// wiggle with a forgiving ease-out tween.
//
// `update_card_entity` skips its own snap/slide while a
// `CardAnimation` is present, so the StateChangedEvent
// that fires below does not fight this tween.
if let Some(origin_pile) = game.0.piles.get(&origin) {
for &card_id in &drag.cards {
let Some(stack_index) =
@@ -683,14 +687,23 @@ fn end_drag(
};
let target_pos =
card_position(&game.0, &layout.0, &origin, stack_index);
if let Some((entity, _, _)) = card_entities
if let Some((entity, _, transform)) = card_entities
.iter()
.find(|(_, ce, _)| ce.card_id == card_id)
{
commands.entity(entity).insert(ShakeAnim {
elapsed: 0.0,
origin_x: target_pos.x,
});
let drag_pos = transform.translation.truncate();
let drag_z = transform.translation.z;
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
commands.entity(entity).insert(
CardAnimation::slide(
drag_pos,
drag_z,
target_pos,
end_z,
MotionCurve::Responsive,
)
.with_duration(MOTION_DRAG_REJECT_SECS),
);
}
}
}
@@ -899,9 +912,11 @@ fn touch_end_drag(
fired = true;
} else {
rejected.write(MoveRejectedEvent { from: origin.clone(), to: target, count });
// See `end_drag` (mouse path) for the rationale: ShakeAnim
// restores translation.x to origin_x, so origin_x must be
// the origin pile's slot, not the drop location.
// Smoothly glide each dragged card from its drop-time
// transform back to its resting slot. See `end_drag`
// (mouse path) for the full rationale; the touch path
// mirrors it exactly so finger and mouse rejection
// feel identical.
if let Some(origin_pile) = game.0.piles.get(&origin) {
for &card_id in &drag.cards {
let Some(stack_index) =
@@ -911,13 +926,22 @@ fn touch_end_drag(
};
let target_pos =
card_position(&game.0, &layout.0, &origin, stack_index);
if let Some((entity, _, _)) =
if let Some((entity, _, transform)) =
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
{
commands.entity(entity).insert(ShakeAnim {
elapsed: 0.0,
origin_x: target_pos.x,
});
let drag_pos = transform.translation.truncate();
let drag_z = transform.translation.z;
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
commands.entity(entity).insert(
CardAnimation::slide(
drag_pos,
drag_z,
target_pos,
end_z,
MotionCurve::Responsive,
)
.with_duration(MOTION_DRAG_REJECT_SECS),
);
}
}
}
@@ -1946,71 +1970,159 @@ mod tests {
}
// -----------------------------------------------------------------------
// Task #57 — ShakeAnim insertion on rejected drag
// Drag-rejection return tween — `CardAnimation` replaces the legacy
// `ShakeAnim` on the dragged cards. The audio cue
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
// visual response on the dragged cards swapped from a horizontal wiggle
// to a smooth ease-out glide back to the origin pile.
//
// These tests build the component values exactly as `end_drag` and
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
// shaped correctly. Driving `end_drag` end-to-end requires a real window
// and mouse-button input, so we exercise the data path the same way the
// legacy `ShakeAnim` tests did.
// -----------------------------------------------------------------------
/// Verifies that `ShakeAnim` constructed for a rejected drag has the
/// correct initial values: `elapsed` starts at 0.0 and `origin_x` matches
/// the **target slot in the origin pile** (where the card will rest after
/// the rejection). Saving the drop-location X here was the root cause of
/// the "card lands beside the pile" bug — `tick_shake_anim` restores
/// `translation.x` to `origin_x` at the end of the shake, fighting the
/// `sync_cards` slide that `StateChangedEvent` triggers.
///
/// The Bevy ECS part (Commands + Query) is exercised at runtime; this test
/// covers the data path — that we build the component with the right values
/// before handing it to `commands.entity(...).insert(...)`.
#[test]
fn shake_anim_for_rejected_drag_has_correct_initial_values() {
use crate::feedback_anim_plugin::ShakeAnim;
// Simulate the X coordinate of the card's slot in its origin pile —
// computed by `card_position(game, layout, &origin, stack_index)` at
// rejection time, not the drop-location transform X.
let target_slot_x = 123.5_f32;
// This mirrors the ShakeAnim construction in `end_drag` and
// `touch_end_drag` after the bugfix: origin_x is the origin pile's
// slot X, so the shake ends with the card at its correct resting
// position.
let anim = ShakeAnim {
elapsed: 0.0,
origin_x: target_slot_x,
};
assert_eq!(
anim.elapsed, 0.0,
"ShakeAnim must start with elapsed=0.0 so the animation plays from the beginning"
);
assert!(
(anim.origin_x - target_slot_x).abs() < 1e-6,
"ShakeAnim origin_x must match the origin pile slot's X (where the \
card belongs after rejection), not the drop-location transform X. \
Expected {target_slot_x}, got {}",
anim.origin_x
);
/// Helper: build the `CardAnimation` the rejection paths construct for
/// one dragged card. Mirrors the inline logic in `end_drag` and
/// `touch_end_drag` so the tests stay in sync with the production code.
fn build_drag_reject_animation(
drag_pos: Vec2,
drag_z: f32,
target_pos: Vec2,
stack_index: usize,
) -> CardAnimation {
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
.with_duration(MOTION_DRAG_REJECT_SECS)
}
/// When a drag is rejected, every card id in `drag.cards` should receive a
/// `ShakeAnim`. Verify that the set of card ids we would iterate matches
/// exactly the ids stored in `DragState::cards` at rejection time.
/// Every card in `drag.cards` should receive its own `CardAnimation` on
/// rejection. With the shake → tween migration, the assertion changes
/// from "every dragged card gets a ShakeAnim" to "every dragged card
/// gets a CardAnimation" — same coverage, new component.
#[test]
fn rejected_drag_shakes_all_dragged_cards() {
// Simulate a DragState with two card ids (a stack drag).
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
// Simulate a stack drag of two cards.
let dragged_ids: Vec<u32> = vec![10, 11];
// In `end_drag`, we iterate `drag.cards` and look up each id in
// `card_entities`. The ids we would insert ShakeAnim on must exactly
// match the dragged set.
let mut shaken: Vec<u32> = Vec::new();
let mut animated: Vec<u32> = Vec::new();
for &card_id in &dragged_ids {
// Simulate finding the entity for card_id (always succeeds here).
shaken.push(card_id);
// In `end_drag` we iterate `drag.cards` and look up each id in
// `card_entities`. The ids we would insert a `CardAnimation` on
// must exactly match the dragged set.
animated.push(card_id);
}
assert_eq!(
shaken, dragged_ids,
"every card id in drag.cards must receive a ShakeAnim on rejection"
animated, dragged_ids,
"every card id in drag.cards must receive a CardAnimation on rejection"
);
}
/// The `end` field of the inserted tween must equal the card's resting
/// slot in its origin pile — the position the card belongs at after a
/// rejected drop. Without this, the tween would glide to the wrong spot
/// and `sync_cards` would have to fight it back.
#[test]
fn rejected_drag_animation_targets_origin_resting_position() {
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
assert!(
(anim.end - target_pos).length() < 1e-6,
"CardAnimation.end must match the origin slot's resting position. \
Expected {target_pos:?}, got {:?}",
anim.end
);
}
/// The `start` field of the inserted tween must equal the card's
/// drop-time transform position — i.e. wherever the cursor or finger
/// released the card. This is what makes the glide feel like a
/// continuous return rather than a teleport-then-shake.
#[test]
fn rejected_drag_animation_starts_from_drag_position() {
let drag_pos = Vec2::new(640.0, 200.0);
let target_pos = Vec2::new(80.0, -120.0);
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
assert!(
(anim.start - drag_pos).length() < 1e-6,
"CardAnimation.start must match the drop-time transform position \
(where the cursor released). Expected {drag_pos:?}, got {:?}",
anim.start
);
// And the start must be visibly distinct from the origin slot — the
// whole point of the tween is that it visibly travels.
assert!(
(anim.start - anim.end).length() > 1.0,
"rejected drag should travel a visible distance, got start={:?} end={:?}",
anim.start,
anim.end
);
}
/// The tween duration is taken from the project-wide motion token so
/// designers can retune the feel from one place. Keeps the constant and
/// the call site honest.
#[test]
fn rejected_drag_animation_uses_correct_duration() {
let anim = build_drag_reject_animation(
Vec2::new(640.0, 200.0),
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert!(
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
({MOTION_DRAG_REJECT_SECS}), got {}",
anim.duration
);
}
/// The curve must be a no-overshoot ease-out so the card decelerates
/// cleanly into its rest position — overshoot on a rejection feels
/// jittery rather than forgiving.
#[test]
fn rejected_drag_animation_uses_responsive_curve() {
let anim = build_drag_reject_animation(
Vec2::new(640.0, 200.0),
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert_eq!(
anim.curve,
MotionCurve::Responsive,
"drag-rejection tween must use Responsive (quintic ease-out) \
so the card snaps back without bouncing past the slot"
);
}
/// The `start_z` of the tween must equal the card's drop-time z
/// (`DRAG_Z`) so the card stays above the rest of the table while it
/// travels home, then settles at the correct resting z.
#[test]
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
let stack_index = 2_usize;
let anim = build_drag_reject_animation(
Vec2::new(640.0, 200.0),
DRAG_Z,
Vec2::new(80.0, -120.0),
stack_index,
);
assert!(
(anim.start_z - DRAG_Z).abs() < 1e-6,
"tween must start at DRAG_Z so the card stays on top during the glide"
);
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
assert!(
(anim.end_z - expected_end_z).abs() < 1e-6,
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
anim.end_z
);
}
}
+8 -3
View File
@@ -26,7 +26,11 @@ pub enum LayoutSystem {
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
/// Aspect ratio (height / width) of a standard playing card.
const CARD_ASPECT: f32 = 1.4;
///
/// Matches the bundled hayeah/playing-cards-assets SVG dimensions
/// (167.087 × 242.667 → 1.4523). Pre-v0.11 the constant was 1.4,
/// which rendered the cards ~3.6 % squashed vertically.
const CARD_ASPECT: f32 = 1.4523;
/// Fraction of card height used as vertical padding between the top row and
/// the tableau row.
@@ -59,7 +63,7 @@ pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
pub struct Layout {
/// Width and height of a single card, in world units (Bevy 2D world-space).
///
/// `x` is the card width; `y` is the card height (always `x * 1.4`).
/// `x` is the card width; `y` is the card height (`x * CARD_ASPECT`).
/// All pile positions and fan offsets are derived from this value.
pub card_size: Vec2,
/// Centre position of each pile, in 2D world coordinates.
@@ -80,7 +84,8 @@ pub struct Layout {
/// column (13 face-up cards, see [`MAX_TABLEAU_CARDS`]) inside the
/// window with a bottom margin equal to `h_gap`. Limiter on tall/narrow
/// windows.
/// - `card_height = card_width * 1.4`.
/// - `card_height = card_width * CARD_ASPECT` (1.4523, matches the
/// bundled hayeah card art's natural SVG dimensions).
/// - Horizontal gap `h_gap = card_width / 4.0`.
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
+17 -8
View File
@@ -23,6 +23,7 @@ pub mod layout;
pub mod onboarding_plugin;
pub mod pause_plugin;
pub mod profile_plugin;
pub mod radial_menu;
pub mod settings_plugin;
pub mod progress_plugin;
pub mod resources;
@@ -69,8 +70,9 @@ pub use card_animation::{
FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
};
pub use feedback_anim_plugin::{
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
FeedbackAnimPlugin, SettleAnim, ShakeAnim,
deal_stagger_delay, deal_stagger_secs_for_speed, foundation_flourish_scale, shake_offset,
settle_scale, FeedbackAnimPlugin, FoundationFlourish, FoundationMarkerFlourish, SettleAnim,
ShakeAnim,
};
pub use auto_complete_plugin::AutoCompletePlugin;
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
@@ -82,33 +84,40 @@ pub use font_plugin::{FontPlugin, FontResource};
pub use cursor_plugin::CursorPlugin;
pub use events::{
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
ForfeitEvent, ForfeitRequestEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent,
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
ToggleStatsRequestEvent, UndoRequestEvent, XpAwardedEvent,
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent,
};
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
pub use help_plugin::{HelpPlugin, HelpScreen};
pub use home_plugin::{HomePlugin, HomeScreen};
pub use hud_plugin::{
ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton, MenuOption, MenuPopover,
ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton, UndoButton,
streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton,
MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton,
StreakFlourish, UndoButton,
};
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
pub use input_plugin::InputPlugin;
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
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::{
PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen,
SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS,
};
pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
pub use selection_plugin::{
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
};
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
@@ -93,7 +93,9 @@ struct HotkeyRow {
const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
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: "M", description: "Open Mode Launcher (then 15 to pick)" },
HotkeyRow { keys: "S", description: "Stats & progression" },
HotkeyRow { keys: "A", description: "Achievements" },
HotkeyRow { keys: "O", description: "Settings" },
+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"
);
}
}
File diff suppressed because it is too large Load Diff
+197 -10
View File
@@ -18,7 +18,7 @@ use bevy::window::{WindowMoved, WindowResized};
use solitaire_core::game_state::DrawMode;
use solitaire_data::{
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
WindowGeometry,
WindowGeometry, TOOLTIP_DELAY_STEP_SECS,
};
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
@@ -122,6 +122,10 @@ struct BackgroundText;
#[derive(Component, Debug)]
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.
#[derive(Component, Debug)]
struct SettingsPanelScrollable;
@@ -139,6 +143,10 @@ enum SettingsButton {
MusicUp,
ToggleDrawMode,
CycleAnimSpeed,
/// Decrement the tooltip-hover dwell delay by one step.
TooltipDelayDown,
/// Increment the tooltip-hover dwell delay by one step.
TooltipDelayUp,
ToggleTheme,
ToggleColorBlind,
SyncNow,
@@ -169,6 +177,8 @@ impl SettingsButton {
// Gameplay section
SettingsButton::ToggleDrawMode => 30,
SettingsButton::CycleAnimSpeed => 40,
SettingsButton::TooltipDelayDown => 45,
SettingsButton::TooltipDelayUp => 46,
// Cosmetic section
SettingsButton::ToggleTheme => 50,
SettingsButton::ToggleColorBlind => 60,
@@ -258,6 +268,7 @@ impl Plugin for SettingsPlugin {
update_background_text,
update_anim_speed_text,
update_color_blind_text,
update_tooltip_delay_text,
attach_focusable_to_settings_buttons,
scroll_focus_into_view,
),
@@ -359,6 +370,7 @@ fn sync_settings_panel_visibility(
progress: Option<Res<ProgressResource>>,
font_res: Option<Res<FontResource>>,
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
) {
if !screen.is_changed() {
return;
@@ -385,6 +397,16 @@ fn sync_settings_panel_visibility(
.collect()
})
.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(
&mut commands,
&settings.0,
@@ -394,6 +416,7 @@ fn sync_settings_panel_visibility(
&themes,
scroll_pos.0,
font_res.as_deref(),
theme_overrides_back,
);
}
} 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 {
if idx == 0 {
"Default".to_string()
@@ -606,6 +644,24 @@ fn handle_settings_buttons(
**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 => {
settings.0.theme = match settings.0.theme {
Theme::Green => Theme::Blue,
@@ -680,6 +736,17 @@ fn color_blind_label(enabled: bool) -> String {
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
/// buttons (volume +/, toggle, cycle), swatch buttons (card-back,
/// background pickers), and the "Sync Now" button. The "Done" button is
@@ -928,6 +995,14 @@ fn persist_window_geometry_after_debounce(
// 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)]
fn spawn_settings_panel(
commands: &mut Commands,
@@ -938,6 +1013,7 @@ fn spawn_settings_panel(
themes: &[(String, String)],
scroll_offset: f32,
font_res: Option<&FontResource>,
theme_overrides_back: bool,
) {
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Settings", font_res);
@@ -1003,6 +1079,11 @@ fn spawn_settings_panel(
"Cycle animation speed: Normal, Fast, Instant.",
font_res,
);
tooltip_delay_row(
body,
settings.tooltip_delay_secs,
font_res,
);
// --- Cosmetic ---
section_label(body, "Cosmetic", font_res);
@@ -1024,15 +1105,26 @@ fn spawn_settings_panel(
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
font_res,
);
picker_row(
body,
"Card Back",
unlocked_card_backs,
settings.selected_card_back,
SettingsButton::SelectCardBack,
"Choose your deck art. New backs unlock at higher levels.",
font_res,
);
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(
body,
"Card Back",
unlocked_card_backs,
settings.selected_card_back,
SettingsButton::SelectCardBack,
"Choose your deck art. New backs unlock at higher levels.",
font_res,
);
}
picker_row(
body,
"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,
/// 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`]
/// because themes are identified by `String` ids (matching
/// `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::events::{
ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, ToggleStatsRequestEvent,
WinStreakMilestoneEvent,
};
use crate::game_plugin::GameMutation;
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,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
VAL_SPACE_4, Z_MODAL_PANEL,
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2,
VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
};
/// Bevy resource wrapping the current stats.
@@ -93,6 +94,7 @@ impl Plugin for StatsPlugin {
.add_message::<ForfeitEvent>()
.add_message::<InfoToastEvent>()
.add_message::<ToggleStatsRequestEvent>()
.add_message::<WinStreakMilestoneEvent>()
// record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game. These are NOT in StatsUpdate because
// StatsUpdate (as a set) is ordered after GameMutation by external
@@ -130,15 +132,55 @@ fn update_stats_on_win(
game: Res<GameStateResource>,
mut stats: ResMut<StatsResource>,
path: Res<StatsStoragePath>,
mut milestone: MessageWriter<WinStreakMilestoneEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
for ev in events.read() {
let prev_streak = stats.0.win_streak_current;
stats
.0
.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");
}
}
/// 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(
mut events: MessageReader<NewGameRequestEvent>,
game: Res<GameStateResource>,
@@ -895,4 +937,120 @@ mod tests {
"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));
}
/// 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
/// changes. Fires `StateChangedEvent` afterwards so the existing
/// `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
/// `[suit][rank]` face matrix and into back slot 0. Split out so it
/// can be unit-tested without spinning up a Bevy `App`.
/// `[suit][rank]` face matrix and into the dedicated `theme_back`
/// 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) {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
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
@@ -251,6 +259,7 @@ mod tests {
CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs: std::array::from_fn(|_| Handle::default()),
theme_back: None,
}
}
@@ -284,24 +293,34 @@ mod tests {
}
#[test]
fn applying_theme_overwrites_back_slot_zero() {
// Build a theme whose back handle is a freshly-allocated weak
// handle — its id will differ from the default-handle id we
// started with, proving the back slot was overwritten.
fn applying_theme_writes_theme_back_slot_and_leaves_legacy_backs_untouched() {
// The active-theme back lives in its own dedicated slot
// (`theme_back`) so the legacy `backs[0..5]` PNG fallbacks
// 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();
// 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 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);
// Both default handles compare equal to themselves; the test
// asserts via id() that whichever handle is in slot 0 came
// from the theme — even if both happen to be Handle::default,
// the id swap is still observable via the value-equality of
// theme.back's id.
assert_eq!(image_set.backs[0].id(), theme.back.id());
// No assertion about original_back_id — both sides may be the
// same default handle id when neither is loaded; the contract
// we're checking is "slot 0 now matches theme.back".
let _ = original_back_id;
// The active-theme back is now populated and matches the theme.
let active_back = image_set
.theme_back
.as_ref()
.expect("theme_back populated after apply");
assert_eq!(active_back.id(), theme.back.id());
// Every legacy back slot is preserved byte-for-byte by id.
for (i, before) in legacy_ids_before.iter().enumerate() {
assert_eq!(
image_set.backs[i].id(),
*before,
"legacy back slot {i} must not be clobbered by theme apply",
);
}
}
#[test]
+84 -1
View File
@@ -41,13 +41,17 @@
//! here no-ops so [`crate::selection_plugin`]'s Tab/Enter
//! card-selection still works.
use std::f32::consts::TAU;
use bevy::ecs::query::Has;
use bevy::input::ButtonInput;
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use solitaire_data::AnimSpeed;
use crate::settings_plugin::SettingsResource;
use crate::ui_modal::{ButtonVariant, ModalButton, ModalScrim};
use crate::ui_theme::{FOCUS_RING, RADIUS_MD, Z_FOCUS_RING};
use crate::ui_theme::{FOCUS_RING, MOTION_FOCUS_PULSE_SECS, RADIUS_MD, Z_FOCUS_RING};
// ---------------------------------------------------------------------------
// Public component / resource API
@@ -126,12 +130,57 @@ impl Plugin for UiFocusPlugin {
clear_hud_focus_on_unhover,
handle_focus_keys,
update_focus_overlay,
pulse_focus_overlay,
)
.chain(),
);
}
}
/// Computes the focus-ring breathing factor for a given elapsed time.
///
/// Returns a value in `[0.65, 1.0]` following a sin curve over
/// [`MOTION_FOCUS_PULSE_SECS`]. Multiply [`FOCUS_RING`]'s native alpha by
/// this factor each frame to produce the breathing effect.
///
/// Pure helper so the curve can be unit-tested without a Bevy app.
pub fn focus_ring_pulse_factor(elapsed_secs: f32) -> f32 {
let phase = (elapsed_secs * TAU / MOTION_FOCUS_PULSE_SECS).sin();
// 0.825 mid-point ± 0.175 amplitude → range [0.65, 1.0]. Multiplicative
// factor against FOCUS_RING's static alpha so the brightest tick is
// exactly the original colour, not a brighter one.
0.825 + 0.175 * phase
}
/// Modulates the focus overlay's border alpha with a slow sin-curve
/// breathing pulse so the indicator catches the eye without competing
/// with gameplay motion. Skipped under `AnimSpeed::Instant` — the static
/// border colour is restored so reduced-motion users see no animation.
fn pulse_focus_overlay(
time: Res<Time>,
settings: Option<Res<SettingsResource>>,
focused: Res<FocusedButton>,
mut overlay: Query<&mut BorderColor, With<FocusOverlay>>,
) {
let Ok(mut border) = overlay.single_mut() else {
return;
};
let instant = settings
.as_deref()
.is_some_and(|s| matches!(s.0.animation_speed, AnimSpeed::Instant));
let factor = if instant || focused.0.is_none() {
1.0
} else {
focus_ring_pulse_factor(time.elapsed_secs())
};
let mut colour = FOCUS_RING;
colour.set_alpha(FOCUS_RING.alpha() * factor);
*border = BorderColor::all(colour);
}
// ---------------------------------------------------------------------------
// Private marker for the single overlay entity
// ---------------------------------------------------------------------------
@@ -588,6 +637,40 @@ mod tests {
spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, UiModalPlugin,
};
#[test]
fn focus_ring_pulse_factor_at_zero_is_mid_point() {
// sin(0) = 0 → factor = 0.825 (mid of [0.65, 1.0]).
let f = focus_ring_pulse_factor(0.0);
assert!((f - 0.825).abs() < 1e-5, "factor at t=0 should be 0.825, got {f}");
}
#[test]
fn focus_ring_pulse_factor_peaks_at_quarter_period() {
// sin(τ/4) = 1 → factor = 1.0.
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS / 4.0);
assert!((f - 1.0).abs() < 1e-4, "factor at peak should be 1.0, got {f}");
}
#[test]
fn focus_ring_pulse_factor_troughs_at_three_quarter_period() {
// sin(3τ/4) = -1 → factor = 0.65.
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS * 3.0 / 4.0);
assert!((f - 0.65).abs() < 1e-4, "factor at trough should be 0.65, got {f}");
}
#[test]
fn focus_ring_pulse_factor_stays_in_brightness_range() {
// Sweep across two full periods; factor must stay within [0.65, 1.0].
for i in 0..200 {
let t = i as f32 * MOTION_FOCUS_PULSE_SECS * 0.01;
let f = focus_ring_pulse_factor(t);
assert!(
(0.649..=1.001).contains(&f),
"factor at t={t} out of range: {f}"
);
}
}
/// Plugin-marker for the synthetic test modal — `spawn_modal`
/// requires a `Component` on the scrim.
#[derive(Component, Debug)]
+54
View File
@@ -333,6 +333,11 @@ pub const MOTION_SHAKE_SECS: f32 = 0.25;
/// Shake angular frequency in rad/s.
pub const MOTION_SHAKE_OMEGA: f32 = 35.0;
/// Duration of the smooth return tween when a drag is rejected by an
/// invalid drop target. Short enough to feel snappy but long enough to
/// read as motion rather than a teleport.
pub const MOTION_DRAG_REJECT_SECS: f32 = 0.15;
/// Card flip — half-time per phase (squash + grow). 100 ms each =
/// 200 ms total. Pair with a ±8° Z-rotation at the midpoint for a 3D
/// feel without 3D rendering.
@@ -356,6 +361,14 @@ pub const MOTION_CASCADE_STAGGER_SECS: f32 = 0.06;
/// (overshoot) plus ±15° Z-rotation. 500 ms.
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.
/// 800 ms.
pub const MOTION_WIN_SHAKE_SECS: f32 = 0.80;
@@ -379,10 +392,51 @@ pub const MOTION_BUTTON_BLEND_SECS: f32 = 0.10;
/// readout 1.0 → 1.1 → 1.0. 250 ms.
pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25;
/// Foundation-completion flourish — when a King lands on a foundation
/// pile (Ace → King, 13 cards), briefly scale the King card 1.0 →
/// [`FOUNDATION_FLOURISH_PEAK_SCALE`] → 1.0 and tint the matching
/// `PileMarker` gold. 400 ms.
pub const MOTION_FOUNDATION_FLOURISH_SECS: f32 = 0.4;
/// Peak scale magnification reached at the midpoint of the
/// foundation-completion flourish. The triangular curve climbs from
/// 1.0 at `t=0` to this value at `t=0.5` and back to 1.0 at `t=1.0`.
pub const FOUNDATION_FLOURISH_PEAK_SCALE: f32 = 1.15;
/// Total duration of the streak-milestone flourish on the HUD score
/// readout, in seconds. Mirrors the foundation flourish in feel — a
/// brief celebratory pulse that does not block subsequent gameplay.
pub const MOTION_STREAK_FLOURISH_SECS: f32 = 0.6;
/// Peak scale magnification reached at the midpoint of the streak
/// flourish (1.0 → this → 1.0). Larger than the foundation flourish
/// peak so the lifetime-streak celebration reads as a bigger deal than
/// the per-suit completion.
pub const STREAK_FLOURISH_PEAK_SCALE: f32 = 1.20;
/// Win-streak counts that trigger the flourish. The flourish fires
/// only when the streak crosses a threshold from below — never at
/// every win past the highest threshold. Static for now; could become
/// a `Settings`-tunable list later if play-testing surfaces it.
pub const STREAK_MILESTONES: &[u32] = &[3, 5, 10];
/// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step.
/// 400 ms.
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
/// Period of the focus-ring breathing pulse, in seconds.
///
/// The keyboard focus ring's alpha is modulated by a sin-curve over this
/// interval so the indicator gently "breathes" instead of presenting as
/// a flat outline. 1.4 s reads as a calm heartbeat — slow enough that
/// the motion is in the player's peripheral vision rather than competing
/// for attention, fast enough that a focus change still draws the eye.
/// Not run through [`scaled_duration`]: the pulse is an accessibility
/// affordance, not gameplay motion. `AnimSpeed::Instant` is honoured at
/// the system level by skipping the pulse entirely (see
/// `pulse_focus_overlay` in `ui_focus`).
pub const MOTION_FOCUS_PULSE_SECS: f32 = 1.4;
/// Hover delay before a tooltip appears, in seconds. Long enough that
/// players gliding the cursor across the HUD don't see flicker; short
/// enough that "stop and read" feels responsive. Not run through
+53 -2
View File
@@ -34,6 +34,7 @@ use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use crate::font_plugin::FontResource;
use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{
BG_ELEVATED_HI, BORDER_SUBTLE, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM, TEXT_PRIMARY,
TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
@@ -137,6 +138,23 @@ struct TooltipText;
/// target's own border.
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
// ---------------------------------------------------------------------------
@@ -257,6 +275,7 @@ fn track_tooltip_hover(
fn show_or_hide_tooltip(
time: Res<Time>,
state: Res<TooltipState>,
settings: Option<Res<SettingsResource>>,
tooltips: Query<(&Tooltip, &UiGlobalTransform, &ComputedNode)>,
tooltip_text_only: Query<&Tooltip>,
mut overlay_q: Query<(&mut Node, &mut Visibility, &Children), With<TooltipOverlay>>,
@@ -280,9 +299,15 @@ fn show_or_hide_tooltip(
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 delay = Duration::from_secs_f32(MOTION_TOOLTIP_DELAY_SECS);
if elapsed < delay {
if !tooltip_should_show(elapsed.as_secs_f32(), delay_secs) {
hide(&mut visibility);
return;
}
@@ -550,4 +575,30 @@ mod tests {
"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 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::events::{
@@ -23,10 +25,11 @@ use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_WIN_SHAKE_AMPLITUDE,
MOTION_WIN_SHAKE_SECS, RADIUS_LG, RADIUS_MD, SCRIM, STATE_INFO, STATE_SUCCESS, STATE_WARNING,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_DISPLAY, TYPE_HEADLINE, VAL_SPACE_2,
VAL_SPACE_3, Z_WIN_CASCADE,
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
MOTION_SCORE_BREAKDOWN_STAGGER_SECS, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS,
RADIUS_LG, RADIUS_MD, SCRIM, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY,
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)`
/// means "Challenge 3"). `None` for non-Challenge modes.
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.
@@ -161,6 +173,37 @@ enum WinSummaryButton {
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
// ---------------------------------------------------------------------------
@@ -193,6 +236,7 @@ impl Plugin for WinSummaryPlugin {
spawn_win_summary_after_delay,
handle_win_summary_buttons,
apply_screen_shake,
reveal_score_breakdown,
)
.after(GameMutation),
);
@@ -217,6 +261,144 @@ pub fn format_win_time(seconds: u64) -> String {
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
// ---------------------------------------------------------------------------
@@ -267,6 +449,8 @@ fn cache_win_data(
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
pending.new_record = is_new_record;
pending.challenge_level = challenge_level;
pending.undo_count = game.0.undo_count;
pending.mode = game.0.mode;
if is_new_record {
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);
}
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;
/// 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(
commands: &mut Commands,
pending: &WinSummaryPending,
session: &SessionAchievements,
challenge_level: Option<u32>,
anim_speed: AnimSpeed,
) {
let breakdown = ScoreBreakdown::compute(
pending.score,
pending.time_seconds,
pending.undo_count,
pending.mode,
);
commands
.spawn((
WinSummaryOverlay,
@@ -502,12 +704,9 @@ fn spawn_overlay(
));
}
// Score
card.spawn((
Text::new(format!("Score: {}", pending.score)),
TextFont { font_size: TYPE_HEADLINE, ..default() },
TextColor(TEXT_PRIMARY),
));
// Score breakdown reveal — replaces the previous single
// "Score:" line with a per-component multi-row layout.
spawn_score_breakdown(card, &breakdown, anim_speed);
// Time
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
// ---------------------------------------------------------------------------
@@ -662,6 +1075,8 @@ mod tests {
assert!(p.xp_detail.is_empty());
assert!(!p.new_record);
assert!(p.challenge_level.is_none());
assert_eq!(p.undo_count, 0);
assert_eq!(p.mode, GameMode::Classic);
}
#[test]
@@ -941,4 +1356,208 @@ mod tests {
"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);
}
}