Compare commits

...

19 Commits

Author SHA1 Message Date
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
funman300 063269c70e docs: update repo URL references to corrected Rusty_Solitaire spelling
The GitHub repo was renamed from Rusty_Solitare to Rusty_Solitaire
(adding the missing 'i'). The local origin remote has been updated
via `git remote set-url`; this commit updates the three doc
references that hardcoded the old URL.

SESSION_HANDOFF.md's "Canonical remote" section now names the new
URL and explains the rename for future readers, including the note
that local clone directories may still be named Rusty_Solitare —
that's a local-only name and works fine, only the GitHub repo URL
changed.

docs/SESSION_HANDOFF.md (older snapshot, unchanged otherwise) gets
its single URL line corrected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:36:06 +00:00
funman300 b126df82b2 docs: refresh SESSION_HANDOFF for session 7 UX-iteration round complete
Session 6 closed with a four-item UX punch list (unlock foundations,
drop shadows, drop-target highlights, stock badge). All four shipped
in session 7, plus an unrelated font-fallback fix surfaced by a
second-machine smoke test that landed before the UX work.

Refreshes the doc to reflect:
- HEAD: 655dfde, 3 commits ahead of origin
- 982 tests pass (was 962)
- Session 7 changelog table summarising the five commits
- UX punch-list entirely closed; release-prep items still on the
  table but un-deferred (player gets a directional choice next session)
- New "next-round candidates" UX list (animated focus ring,
  achievement onboarding, mode-switch keyboard shortcut, aspect-ratio
  fidelity, foundation completion flourish, drag-cancel tween)
- Resume prompt asks A/B/C: tag v0.11.0, README/CHANGELOG first, or
  start a new UX round

Length 120 → 109 (-11) by trimming the spent priority list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:33:42 +00:00
funman300 655dfde736 feat(engine): stock-pile remaining-count badge
Players were recycling the stock blind — there's no in-world
indicator of how many cards are left before the recycle. A small
"·N" chip now sits at the top-right corner of the stock pile,
showing the remaining count.

The badge is a top-level world entity whose Transform.translation is
recomputed each tick from the live LayoutResource (so window resizes
and theme switches don't strand it), parented to neither the
PileMarker nor any card. update_stock_count_badge spawns the entity
on the first frame, then on every subsequent frame reads the stock
pile's card count, writes the formatted text into the child Text2d,
and toggles Visibility::Hidden when the count drops to zero — the
same state where StockEmptyLabel's existing ↺ icon takes over, so
the two never co-render.

Z_STOCK_BADGE = 30 sits above stock cards (z ≈ 1) and below
Z_DROP_OVERLAY = 50, so the badge stays visible during normal play
but green drop-target washes still cover it while a card is being
dragged. Card drop shadows live at negative local z relative to
each card and don't compete with the badge plane.

Tokens (STOCK_BADGE_BG, STOCK_BADGE_FG, Z_STOCK_BADGE) were already
present in ui_theme from prior work; this commit only wires them up.
The chip itself is 28×16 px, rendered with TYPE_CAPTION text in
ACCENT_PRIMARY against BG_ELEVATED_HI.

Four new tests pin the contract: badge shows "·24" on a fresh deal,
hides when the stock empties, updates as the count drops, and the
stock_card_count helper reports 0 when the pile is missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:31:15 +00:00
funman300 f712b89fe4 feat(engine): drop shadows on cards with lifted state during drag
Cards previously read as flat stickers on the felt — no separation
cue, no sense the play surface had any depth. Each CardEntity now
spawns a CardShadow child sprite: neutral black at 25 % alpha, sized
to card_size + 4 px halo, offset (2, -3) and rendered at local z
-0.05 so it sits behind its card.

Cards in the active drag set switch to a lifted shadow: alpha 40 %,
offset (4, -6), padding (8, 8). update_card_shadows_on_drag runs
every Update and snaps each shadow to the right state based on
DragState membership — no lerp, no animation cost. The pure
card_shadow_params(is_dragged) helper is unit-tested for the four
parameter values.

resize_cards_in_place gains a third query for shadows so the
in-place resize keeps shadows cheap (no Sprite regeneration); the
shadow's current alpha is read to preserve idle vs lifted padding
across a resize. update_card_entity's despawn_related call is
followed by a fresh add_card_shadow_child so the shadow re-attaches
when the card is repainted (face flip, settings change, theme
swap). The pre-existing bulk drag-shadow under the whole lifted
stack is untouched — per-card shadows complement it.

All shadow values flow through eight new ui_theme tokens
(CARD_SHADOW_COLOR, alphas, offsets, paddings, local z) so the
visual is tunable in one place. Color is neutral black so the
shadows don't conflict with color-blind mode's red/blue suit tints.

Four new tests pin the contract: shadow params for idle and drag
states, every CardEntity spawns with exactly one CardShadow child,
and dragging shifts only the dragged shadow's offset while leaving
unrelated shadows on the idle offset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:21:28 +00:00
funman300 f6c916641a feat(engine): visible drop-target overlay during drag
The existing update_drop_highlights system tinted PileMarker sprites
green for valid drops, but the marker is a card-sized rectangle that
sits behind the stack. Once a tableau column had any cards on it the
marker was occluded and the highlight effectively invisible — the
handoff's "drops feel guess-y because there's no preview" point.

A new update_drop_target_overlays system spawns an overlay above every
legal target during drag: a soft DROP_TARGET_FILL rectangle sized to
the pile's actual visible footprint (full fanned column for tableaux,
card-sized for foundations and empty tableaux) plus four thin
DROP_TARGET_OUTLINE edges forming a 3 px border. Z_DROP_OVERLAY = 50
sits above static cards (z ~1) but below the dragged stack (DRAG_Z =
500), so the overlay never occludes the card the player is holding.

The valid-target enumeration mirrors update_drop_highlights exactly so
the rules can't drift, and pile geometry mirrors input_plugin's
pile_drop_rect. The original marker-tint system is untouched; it still
does its job for empty-pile placeholders. The overlay layer is purely
additive — running alongside, not replacing.

Token values reuse the existing STATE_SUCCESS hue (#4ADE80) at 10%
fill / 75% outline so the overlay green matches the rest of the
success-signal palette (foundation completion, sync OK, etc.).

Three headless tests pin the contract: overlay spawns for valid
tableau drops, doesn't spawn for invalid destinations, and despawns
the moment the drag ends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:33:22 +00:00
funman300 95df5421c9 feat(core): unlock foundations — Foundation(u8) slots, suit derived from contents
Standard Klondike behaviour: any Ace can land in any empty foundation,
and that slot then claims the suit until the pile empties. The
previous PileType::Foundation(Suit) variant pre-assigned each of the
four foundations to a fixed suit ("C / D / H / S" placeholders) and
rejected mismatched Aces — non-standard and (per the smoke-test
feedback) confusing.

Replaces the variant payload with a slot index Foundation(u8) (0..=3)
and derives the claimed suit from the bottom card via a new
Pile::claimed_suit() method. The bottom card is, by construction,
the Ace that established the claim; using it directly eliminates an
entire class of "stuck claim after undo" bugs that a separate
claimed_suit field would have introduced.

can_place_on_foundation drops its suit parameter — the rule reduces
to "empty pile accepts any Ace; non-empty pile accepts the next
rank up of the bottom card's suit." Iteration sites across
input_plugin, cursor_plugin, selection_plugin, card_plugin,
auto_complete_plugin, game_plugin, layout, and hud_plugin all swap
the four-suit list for `(0..4u8).map(PileType::Foundation)`.

next_auto_complete_move now prefers a slot whose claimed_suit matches
the candidate card before falling back to the first empty slot for
an Ace — so the same suit consistently auto-targets the same slot
across the whole game, matching player expectations.

The HUD selection label and the hint toast read claimed_suit() and
fall back to "Foundation N" / "move to foundation" only when the
slot is empty. Empty foundation pile markers no longer render the
suit-letter children — they're plain translucent rectangles, matching
empty tableau placeholders.

Save-format invalidation: GameState gains a schema_version field
(serde-default to 1 for back-compat parsing of old files), the
constant is bumped to 2, and load_game_state_from rejects mismatched
schemas. Old in-progress saves silently fall through to "fresh game
on launch" — the user accepted this loss given the mechanic change.
Stats / progress / achievements / settings live in separate files,
contain no PileType data, and are unaffected.

9 new tests pin the contract:
- Pile::claimed_suit returns None for empty / non-foundation, Some
  for non-empty foundation
- Any Ace lands in the first empty foundation; successive Aces
  distribute across slots 0..3
- Claim drops when the slot is emptied via undo
- Auto-complete picks the slot with a matching claim, not the first
  empty slot
- A v1-format game_state.json is rejected; sibling stats save/load
  is unaffected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:17:17 +00:00
funman300 fdb6c2ecfe fix(engine): bundle FiraMono into SVG fontdb as last-resort fallback
The hayeah card SVGs reference Bitstream Vera Sans and Arial by name.
The lenient FontResolver from efa063f appends Family::SansSerif and
Family::Serif so unmatched named families fall through to whatever
the system serves under those CSS generics — which works on machines
with a normal fontconfig setup, and silently fails on minimal Linux
installs, fresh Wayland sessions, or chroots where the generic
aliases don't resolve to anything either. The visible symptom on the
player's second machine was "card font didn't carry over": rank and
suit glyphs vanished from the cards because every lookup path hit a
None.

shared_fontdb now also include_bytes!()s the bundled
assets/fonts/main.ttf into the fontdb after load_system_fonts, and
pins each CSS generic (sans-serif, serif, monospace, cursive,
fantasy) to "Fira Mono". Named-family lookups still prefer the
system db first when those families exist, so machines with a normal
font setup behave identically; only when SansSerif/Serif fall through
does the resolver land on FiraMono — guaranteed present because it's
embedded in the binary.

The bundled font is ~170 KB; the binary already include_bytes!()s the
six audio WAVs and the embedded card-theme SVGs, so this fits the
existing self-contained-binary policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:41:35 +00:00
funman300 9a3d7f3876 docs: refresh SESSION_HANDOFF for session 6 + UX-iteration direction
Captures today's six commits (theme loader fix, exit-warn silence, two
font-warn rounds, HUD band, action fade), updates HEAD/test counts,
records that the player redirected from "cut v0.11.0 / package" to
"keep iterating on UX," and lists the new four-item UX punch list
(unlock foundations, drop shadows, drop highlighting, stock badge).

Resume prompt is rewritten so a fresh agent on a different machine
picks up cleanly: notes GitHub is the canonical remote (Gitea drift
caused commits to silently miss the alex machine earlier in session),
flags that the in-progress save format will invalidate when (1)
lands, and explicitly defers the release-prep items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:24:09 +00:00
funman300 c4970b16ea feat(engine): auto-fade HUD action buttons when cursor leaves the band
Player request: the Menu / Undo / Pause / Help / Modes / New Game
buttons stay visible during play even when the player isn't looking
at them. Fade them out when the cursor is in the play area, fade
back in when it returns to the top of the window.

Implementation mirrors video-player auto-hide UX:
- HudActionFade resource holds (alpha, target). Default both 1.0 so
  the bar starts visible on first launch.
- update_action_fade reads cursor.y each frame, sets target to 1.0
  when the cursor is in the top reveal zone (HUD_BAND_HEIGHT + 32 px)
  or off-window (keyboard navigation), 0.0 otherwise. Lerps alpha
  toward target at 6/sec ≈ 167 ms per full transition.
- apply_action_fade overrides BackgroundColor + child TextColor on
  every ActionButton. Runs in Last so a hover-state change in the
  same frame doesn't blip back to opaque mid-fade.

No interactivity guard needed: hover requires the cursor to be on a
button, and a faded button is geometrically out of reach (cursor must
re-enter the reveal zone, which is exactly the trigger that fades
the bar back in).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:08:39 +00:00
funman300 2c72e1fc87 feat(engine): reserve top band for HUD so it stops crowding the cards
Player report: the action button bar (Menu / Undo / Pause / Help /
Modes / New Game) and Score / Moves / Timer text were sharing the
same vertical band as the stock + foundation row, with no visual
separation. The HUD read as part of the play surface.

Two-part fix:

1. layout.rs reserves HUD_BAND_HEIGHT (64 px) at the top of the
   window. Card-grid math takes that off the available vertical
   budget so cards still fit; top_y shifts down by the same amount.
   New layout test pins the reservation. Existing
   worst_case_tableau_fits_vertically tests verify the height-budget
   arithmetic still holds.

2. hud_plugin.rs spawns a translucent purple band (BG_HUD_BAND, new
   token in ui_theme.rs at the BG_BASE hue with 0.70 alpha) filling
   that reserved zone. Z-index sits one rung below Z_HUD so action
   buttons paint on top while the band reads as their container. The
   band's bottom edge lines up with the top edge of the highest
   playable card, so the buttons feel anchored to a "tools strip"
   rather than floating in the play area.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:57:51 +00:00
32 changed files with 3685 additions and 477 deletions
+231
View File
@@ -0,0 +1,231 @@
# Changelog
All notable changes to Solitaire Quest are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
_Nothing yet._
## [0.12.0] — 2026-05-02
UX feel polish round on top of v0.11.0. Six small-but-tangible
improvements that make the play surface feel more responsive,
forgiving, and discoverable, plus the doc refresh that should have
ridden along with v0.11.0.
### Added
- **Foundation completion flourish.** When a King lands on a
foundation (Ace-through-King for that suit), a brief celebration
fires: King card scale-pulses 1.0 → 1.15 → 1.0 over 0.4 s, the
foundation marker tints `STATE_SUCCESS` for the first half then
fades, and a synthesised C6→E6→G6 bell ping plays (~240 ms,
octave above `win_fanfare`'s root so the fourth completion + win
cascade layer cleanly). New `FoundationCompletedEvent { slot,
suit }` carries the trigger so future systems can hook in.
- **Drag-cancel return tween.** Illegal drops glide each dragged
card back to its origin slot over 150 ms with a quintic ease-out
curve (`MotionCurve::Responsive`, zero overshoot — reads forgiving
rather than jittery). The audio cue (`card_invalid.wav`) still
fires for negative feedback. Right-click and double-click invalid
paths still use `ShakeAnim` since there's no motion to interpolate.
- **Focus ring breathing.** The keyboard focus ring's alpha modulates
with a 1.4 s sin curve over [0.65, 1.0] of its native value so the
indicator catches the eye on focus changes without competing with
gameplay. Honours `AnimSpeed::Instant` by reverting to the static
outline for reduced-motion users.
- **First-win achievement onboarding toast.** After the player's
very first win, a one-shot info toast surfaces "First win! Press
A to see your achievements." `Settings.shown_achievement_onboarding`
persists the seen state so the cue never re-fires (legacy
`settings.json` files load to `false` via `#[serde(default)]`).
- **Mode Launcher digit shortcuts.** Pressing M opens the Home modal
(the Mode Launcher); inside it, pressing 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.12.0...HEAD
[0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0
[0.11.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.10.0...v0.11.0
[0.10.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.9.0...v0.10.0
[0.9.0]: https://github.com/funman300/Rusty_Solitaire/releases/tag/v0.9.0
+59 -20
View File
@@ -1,17 +1,35 @@
# Solitaire Quest # Solitaire Quest
A cross-platform Klondike Solitaire game written in Rust, featuring a full progression system with XP, levels, achievements, daily challenges, and optional self-hosted sync so your stats follow you across machines. A cross-platform Klondike Solitaire game written in Rust, with a card-theme
system, full progression (XP / levels / achievements / daily challenges), and
optional self-hosted sync so your stats follow you across machines.
## Features ## Features
- **Klondike Solitaire** — Draw One and Draw Three modes - **Klondike Solitaire** — Draw One and Draw Three modes; foundations are
unlocked (any Ace lands in any empty slot, the slot then claims that suit)
- **Card themes** — bundled hayeah/playing-cards-assets default plus
user-installable themes (drop a directory under the data dir or import a
zip from Settings → Cosmetic)
- **Modern HUD** — reserved top band keeps cards from crowding the score
readout; the action bar auto-fades when the cursor leaves it so it can't
compete with the play surface
- **Drag feel** — every legal drop target is highlighted in green during
drag; cards cast a soft drop shadow that lifts when picked up; the stock
pile shows a remaining-count chip so you can see how close you are to a
recycle
- **Keyboard navigation** — Tab cycles focus through buttons, arrow keys
move within picker rows, Enter activates; works across every modal and
the HUD action bar
- **Progression** — XP, levels, unlockable card backs and backgrounds - **Progression** — XP, levels, unlockable card backs and backgrounds
- **18 Achievements** — including secret ones - **18 Achievements** — including secret ones
- **Daily Challenge** — server-seeded so every player worldwide gets the same deal - **Daily Challenge** — server-seeded so every player worldwide gets the
same deal
- **Leaderboard** — opt-in, powered by your own self-hosted server - **Leaderboard** — opt-in, powered by your own self-hosted server
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge - **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
- **Sync** — pull/push stats across devices via a self-hosted server - **Sync** — pull/push stats across devices via a self-hosted server
- **Color-blind mode** — blue tint on red-suit cards - **Color-blind mode** — blue tint on red-suit cards alongside the suit
glyph
## Building ## Building
@@ -32,52 +50,73 @@ cargo build -p solitaire_app --release
## Controls ## Controls
Every action also has a visible UI button — keyboard shortcuts are optional
accelerators.
| Key | Action | | Key | Action |
|---|---| |---|---|
| Left click / drag | Move cards | | Left click / drag | Move cards |
| Double click | Auto-move card to its best legal destination |
| Right click | Highlight legal moves for a card | | Right click | Highlight legal moves for a card |
| Space / D | Draw from stock | | Space / D | Draw from stock |
| Z / Ctrl+Z | Undo | | U | Undo |
| H | Hint (highlight a legal move) |
| N | New game | | N | New game |
| S | Stats overlay | | Z | Zen mode |
| A | Achievements overlay | | G | Forfeit (during pause) |
| P | Profile overlay | | Tab / Shift+Tab | Cycle keyboard focus |
| O | Settings | | Enter | Activate focused button / auto-complete (when badge is lit) |
| L | Leaderboard | | Esc | Pause / dismiss modal |
| H | Help / controls | | F1 | Help / controls |
| Enter | Auto-complete (when badge is lit) | | F11 | Toggle fullscreen |
| Escape | Pause / clear selection | | S / A / P / O / L / M | Stats / Achievements / Profile / Settings / Leaderboard / Menu |
| Arrow keys | Navigate card selection |
## Card themes
The default theme ships embedded in the binary, so the game runs
self-contained with no external assets. To install another theme, drop a
directory containing a `theme.ron` manifest plus 53 SVG files (52 faces +
1 back) under the platform data dir's `themes/` folder, or import a zip
from **Settings → Cosmetic**. The picker chip lights up the moment a new
theme is registered. Themes are SVG-based, so they rasterise cleanly at
whatever resolution the window happens to be.
## Sync Server (optional) ## Sync Server (optional)
To sync stats across machines, run the self-hosted server. See [README_SERVER.md](README_SERVER.md) for setup instructions. To sync stats across machines, run the self-hosted server. See
[README_SERVER.md](README_SERVER.md) for setup instructions.
Once the server is running, open **Settings → Sync Backend**, enter the server URL and your username, and register an account from within the game. Once the server is running, open **Settings → Sync Backend**, enter the
server URL and your username, and register an account from within the
game.
## Running Tests ## Running Tests
```bash ```bash
# All tests # All tests (982 passing as of v0.11.0)
cargo test --workspace cargo test --workspace
# Just game logic (no display required) # Just game logic (no display required)
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
# Lint # Lint
cargo clippy --workspace -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
``` ```
## Credits ## Credits
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem (Tokio, Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
Axum, sqlx, Serde, kira, and many more). Card faces come from (Tokio, Axum, sqlx, Serde, kira, and many more). Card faces come from
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets) [hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
(MIT, derived from the public-domain `vector-playing-cards` library); the (MIT, derived from the public-domain `vector-playing-cards` library); the
default card back is original work; the UI font is FiraMono-Medium (OFL). default card back is original work; the UI font is FiraMono-Medium (OFL).
All audio is synthesized programmatically by this project. See All audio is synthesized programmatically by this project. See
[CREDITS.md](CREDITS.md) for the full list and license details. [CREDITS.md](CREDITS.md) for the full list and license details.
## Changelog
See [CHANGELOG.md](CHANGELOG.md).
## License ## License
MIT — see [LICENSE](LICENSE). MIT — see [LICENSE](LICENSE).
+74 -110
View File
@@ -1,159 +1,123 @@
# Solitaire Quest — UX Overhaul Session Handoff # Solitaire Quest — UX Overhaul Session Handoff
**Last updated:** 2026-05-01 — Phases 3, 4, 5 + the seven CARD_PLAN phases all shipped. v0.1.0 tagged locally. Bundled card art + runtime SVG theme system + in-Settings theme picker all live. Remaining work is desktop packaging and a player-side smoke test of the new theme. **Last updated:** 2026-05-02 (session 7, late) — Second UX iteration round complete. Six small UX feel items shipped on top of v0.11.0 and the README/CHANGELOG refresh that should have ridden along. Ready to tag v0.12.0.
## Status at pause ## Status at pause
- **HEAD:** `924a1e2`. v0.1.0 tag created locally (push pending interactive credentials). - **HEAD:** `7dba772` plus the impending CHANGELOG/handoff doc commits. Local master is **3 commits ahead** of `origin/master` (`9887343`, `ca5788f`, `7dba772` unpushed); doc commits to follow.
- **Working tree:** clean after the post-Phase cleanup pass. - **Working tree:** clean apart from this doc + CHANGELOG, both intentional.
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean. - **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
- **Tests:** **960 passed / 0 failed / 9 ignored** across the workspace. - **Tests:** **1007 passed / 0 failed** across the workspace (+25 from session 7 morning's 982 baseline).
- **Tags on origin:** `v0.9.0`, `v0.10.0`, `v0.11.0`. Local has `v0.11.0` too. v0.12.0 is the next tag.
## Where we are ## Where we are
Phase 3 (design tokens + modal scaffold) and Phase 4 (release polish) shipped earlier. Phase 5 — running the binary end-to-end and fixing what broke — landed nine more commits today: a layout fit fix so tableau columns stop spilling off-screen, a three-pronged resize-lag fix, persisted window geometry, splash skip on subsequent launches, achievement tooltips, a code-quality sweep, client-side sync round-trip tests, and a hit-test fix so dragging a card no longer requires aiming for the bottom strip. v0.11.0 shipped the headline structural changes (card themes, HUD overhaul, four UX feel wins, font fallback). The second UX round — six smaller items — is also done now. v0.12.0 is the right slice for them; together with the README refresh and CHANGELOG add it makes a clean release.
Polish is essentially complete; the remaining work is tagging v0.1.0 and desktop packaging. The post-v0.11.0 UX candidate list is exhausted. Direction is open again.
### Design direction (unchanged) ### Design direction (unchanged)
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions. - **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
- **Palette:** Midnight Purple base + Balatro yellow primary + warm magenta secondary. - **Palette:** Midnight Purple base + Balatro yellow primary + warm magenta secondary.
- See [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for full direction. - See `~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md` (auto-memory; on a different machine, recreate this fresh from the README + ARCHITECTURE.md).
## Phase 3 (shipped) ### Canonical remote
- `solitaire_engine/src/ui_theme.rs` — every design token: colours, type scale, spacing scale, radius rungs, z-index hierarchy, motion durations. `github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there. (Local clone directories may still be named `Rusty_Solitare`; that's just a directory name and works fine.)
- `solitaire_engine/src/ui_modal.rs``spawn_modal` scaffold + button-variant helpers + `paint_modal_buttons` system.
- All 12 overlays migrated to the modal scaffold with real Primary/Secondary/Tertiary buttons (no more Y/N debug prompts).
- HUD restructured into a 4-tier vertical stack with progressive disclosure.
- Animation upgrades: `SmoothSnap` slide curves, scoped settle bounce, deal jitter, win-cascade rotation.
## Phase 4 (shipped 2026-04-30) ## Session 7 round 1 (shipped 2026-05-02 morning) — v0.11.0
| Area | Commit | What landed | | Area | Commit | What landed |
|---|---|---| |---|---|---|
| Workspace lint | `9bfca92` | Test-only clippy warnings under `--all-targets` resolved. | | Font fallback | `fdb6c2e` | FiraMono bundled into the SVG fontdb so cards render rank/suit text on machines without Bitstream Vera Sans / Arial. Surfaced when a second-machine pull lost glyphs. |
| App / window | `5f5aba8` | WM_CLASS, centered-on-primary window, panic hook → `crash.log`. | | Unlock foundations | `95df542` | `PileType::Foundation(Suit)``Foundation(u8)` with claim derived from the bottom card. Save schema 1 → 2; pre-v2 saves silently fall through to fresh game. |
| Modal animation | `71999e1` | `ModalEntering` + ease-out scrim fade and 0.96→1.0 card scale. | | Drop overlay | `f6c9166` | Soft fill + 3 px outline drawn ABOVE stacked cards for every legal target during drag. Replaces the hidden pile-marker tint. |
| Score feedback | `dcfa976` | `ScorePulse` triangular 1.0→1.1→1.0; floating "+N" for jumps ≥ threshold. | | Drop shadows | `f712b89` | Each card casts a 25 % black shadow; lifts to 40 % with bigger offset/halo while in the active drag set. |
| Hit targets | `b082bd6` | `ICON_BUTTON_PX` 28 → 32; sync status reads "local only". | | Stock badge | `655dfde` | "·N" chip at the top-right of the stock so players can see how close they are to a recycle. Hides at zero. |
| Microcopy | `abeb4e5` | Help "Close" → "Done"; final onboarding CTA → "Let's play". |
| Empty states | `65d595a` | First-launch em-dash zero-stats grid + welcome line on Profile. |
| Leaderboard | `1384365` | Idle/Loaded/Error enum; local-only guard. |
| Credits | `fd7fb7b`, `f866299` | CREDITS.md added; README links it. |
| Home | `c1bde18` | Home repurposed as Mode Launcher with level-5 lock state. |
| Focus rings (Phase 1) | `1278952` | Tab/Shift-Tab/Enter on every modal button; auto-focus primary. |
| Focus rings (Phase 2) | `51d3454` | HUD action bar (hover-gated) and Home mode cards. |
| Focus rings (Phase 3) | `b78a493` | Settings: icon buttons, swatches, toggles; arrow-key `FocusRow`; auto-scroll. |
| Achievement tests | `2e080d0` | Integration coverage for `draw_three_master` and `zen_winner`. |
| Microcopy | `0c86cac` | "New game" / "Forfeit" replace "Yes, abandon" / "Yes, forfeit". |
| Tooltip infra | `54d3497` | `Tooltip(Cow<'static, str>)` component + hover-delay overlay. |
| HUD tooltips | `220e3f0` | 10 readouts + 6 action buttons. |
| Settings tooltips | `74597a8` | Volume, toggles, swatches, Sync Now. |
| Popover tooltips | `dbe6c60` | Modes and Menu rows. |
| Splash | `5d57b67` | Branded splash overlay (300ms fade-in / ~1s hold / 300ms fade-out). |
| Doc-rot | `73e210b` | ARCHITECTURE.md `bevy_kira_audio` references → `kira`. |
| Doc | `de52c8a`, `60a8036` | Mid-session and end-of-Phase-4 SESSION_HANDOFF refreshes. |
## Phase 5 (shipped 2026-05-01) Tagged as `v0.11.0` (commit `063269c` plus URL refresh).
Smoke test surfaced three issues: window-resize lag, tableau columns clipped below viewport, hit-target offset on cards. All fixed, plus four bonus polish items. ## Session 7 round 2 (shipped 2026-05-02 afternoon) — v0.12.0
| Area | Commit | What landed | | Area | Commit | What landed |
|---|---|---| |---|---|---|
| Layout fit | `8dda954` | `card_height` constrained by vertical budget; worst-case 13-card column always fits. | | Aspect ratio | `13aa0fd` | `CARD_ASPECT` 1.4 → 1.4523 to match hayeah SVG dimensions; cards no longer ~3.6 % squashed. Vertical-budget math adapts via the constant. |
| Resize perf | `1719fda` | In-place sprite/text mutation + 50ms `ResizeThrottle` (was full re-spawn per pixel). | | Foundation flourish | `69ce9af` | King-on-foundation celebration: scale-pulse on the King, marker tints `STATE_SUCCESS`, synthesised C6→E6→G6 bell ping (~240 ms). New `FoundationCompletedEvent`. |
| Resize stall | `59316de` | `PresentMode::AutoNoVsync` eliminates the X11/Wayland vsync stall during drag. | | Drag-cancel tween | `525fe0f` | Illegal drops glide each card back to its origin over 150 ms with a quintic ease-out (Responsive curve, zero overshoot). Audio cue still fires. ShakeAnim retained for non-drag rejection paths. |
| Window geometry | `6e7705b` | `WindowGeometry` persisted to settings.json; debounced save on resize/move. | | Focus pulse | `9887343` | Focus ring breathes at 1.4 s sin period over [0.65, 1.0] of native alpha. Static under `AnimSpeed::Instant`. |
| Achievements | `7448225` | Tooltips on rows: reward shown when unlocked, condition + reward when locked, secrets stay cryptic. | | Achievement onboarding | `ca5788f` | First-win toast "First win! Press A to see your achievements." plus persisted `shown_achievement_onboarding` flag so the cue fires exactly once. |
| Lint sweep | `4b9d008` | 33 pedantic warnings cleared (`map_unwrap_or`, `uninlined_format_args`, `match_same_arms`). | | Mode Launcher shortcuts | `7dba772` | Digit 15 inside the Mode Launcher launches Classic / Daily / Zen / Challenge / TimeAttack. Locked modes silent no-op. Modal-scoped. |
| Sync tests | `3ef4ecb` | Five client-side round-trip integration tests via in-process axum + mock keyring. | | Docs (rode along) | `d8c7034`, `9f095c4` | README refresh for v0.11.0 features and corrected controls table; CHANGELOG.md added covering v0.9.0v0.11.0. |
| Splash | `912b08c` | Splash skipped on subsequent launches via existing `first_run_complete` flag. |
| Hit test | `902560c` | `card_position` mirrors face-down fan step (0.12) for accurate AABB on tableau columns. |
## Open punch list for v1 The first three items in this round (`13aa0fd`, `69ce9af`, `525fe0f`) shipped before the v0.11.0 tag's commit window closed; treating them as v0.12.0 since v0.11.0 was already cut at `063269c`.
1. **Player smoke-test of the new theme system.** Launch ## Open punch list — release prep
`cargo run -p solitaire_app --features bevy/dynamic_linking` and
confirm: (a) hayeah card faces render correctly, (b) the
midnight-purple `back.svg` shows on face-down cards, (c) the
"Card Theme" picker appears in Settings → Cosmetic with at least
the "Default" chip, (d) clicking the chip is a no-op (already
selected) without errors.
2. **Push the v0.1.0 tag**`git push origin v0.1.0` once you're
happy with the smoke-test outcome. Tag exists locally; not yet on
origin.
3. **Desktop packaging** per ARCHITECTURE.md §17. The Arch PKGBUILD
exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo,
no remote yet — `git remote add origin <URL>` and push to your
gitea / AUR when ready). Still pending: app icon, macOS .icns +
notarisation cert, Windows .ico + Authenticode cert, AppImage
recipe.
### Optional, deferred 1. **Tag v0.12.0** — meaningful slice since v0.11.0: six UX feel items + the README/CHANGELOG refresh. Tag at the doc-commit HEAD that closes this round.
2. **Push to origin** — three-plus commits unpushed.
3. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo, no remote yet). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
- Animated focus ring (currently a static overlay; could pulse on focus change). ## Open punch list — UX iteration (next-round candidates)
- 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). The v0.12.0 list is exhausted. Candidates for a future round:
- Runtime aspect-ratio fidelity for the bundled hayeah cards: the SVG
source is ~1.45 height/width while the engine layout assumes 1.4. - **Card-back theme support** — the current theme system swaps face SVGs but not the back. Players asked for animated backs in passing.
Cards display ~3% squashed vertically; either widen the layout or - **Streak fire animation** in the HUD when win-streak crosses 3, 5, 10. Foundation flourish suggests the per-suit completion pattern; streak milestones are the lifetime equivalent.
letterbox the SVGs to match. Cosmetic-only; not blocking. - **Score-breakdown reveal** at win — show base / time-bonus / no-undo bonus / mode multiplier as the score animates up. Currently the win modal just shows the final number.
- **Right-click radial menu** for power users: hold right-click on a card → quick-drop options without dragging.
- **Drag-with-keyboard** — Tab to a card, Enter to "lift", arrow keys to choose destination, Enter to drop. Keyboard-only completion of a game.
- **Settings: tooltip-delay slider** so power users can disable the 0.5 s hover delay. Cheap.
## Card-theme system (CARD_PLAN.md, fully shipped) ## Card-theme system (CARD_PLAN.md, fully shipped)
Seven phases landed across `b8fb3fb``924a1e2`. End-to-end flow: Seven phases landed across `b8fb3fb``924a1e2` in v0.11.0:
- **Bundled default theme** ships in the binary via `embedded://` - **Bundled default theme** ships in the binary via `embedded://` 52 hayeah/playing-cards-assets SVGs (MIT) + a midnight-purple `back.svg` (original work).
52 hayeah/playing-cards-assets SVGs (MIT) + a midnight-purple - **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.
`back.svg` (original work). - **Importer** at `solitaire_engine::theme::import_theme(zip)` validates an archive and atomically unpacks.
- **User themes** live under `themes://` rooted at - **Picker UI** in Settings → Cosmetic.
`solitaire_engine::assets::user_theme_dir()`. Drop a directory
containing a valid `theme.ron` + 53 SVG files there and it
appears in the registry on next launch.
- **Importer** at `solitaire_engine::theme::import_theme(zip)`
validates an archive (20 MB cap, zip-slip rejection, manifest
validation, every referenced SVG round-tripped through the
rasteriser) and atomically unpacks it into the user themes dir.
- **Picker UI** in Settings → Cosmetic offers one chip per
registered theme; selection persists to `settings.json` as
`selected_theme_id` and propagates to live card sprites via
`react_to_settings_theme_change`
`sync_card_image_set_with_active_theme``StateChangedEvent`.
## Resume prompt ## Resume prompt
``` ```
You are a senior Rust + Bevy developer finishing v1 of Solitaire You are a senior Rust + Bevy developer working on Solitaire Quest.
Quest. Working directory: /home/manage/Rusty_Solitare. Branch: Working directory: <Rusty_Solitaire clone path on this machine — local
master. The polish phase is complete; the remaining work is release directory may still be named Rusty_Solitare from earlier; that's fine>.
prep, not new features. Branch: master. Direction is OPEN — both UX iteration rounds shipped
and v0.12.0 is ready to tag.
State: HEAD=902560c, fully pushed to origin. Working tree clean. State: HEAD at the doc-commit closing session 7 round 2. Local master
is several commits ahead of origin and unpushed. Working tree clean
apart from untracked CARD_PLAN.md (intentional).
Build: cargo clippy --workspace --all-targets -- -D warnings clean. Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 906 passed / 0 failed. Tests: 1007 passed / 0 failed.
READ FIRST (in order, before doing anything): READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — full state and punch list 1. SESSION_HANDOFF.md — full state, session 7 changelog, punch list
2. CLAUDE.md — hard rules (UI-first, no panics, etc.) 2. CHANGELOG.md — release-by-release record
3. ARCHITECTURE.md §15, §17 — platform targets, deployment guide 3. CLAUDE.md — hard rules (UI-first, no panics, etc.)
4. ~/.claude/projects/-home-manage-Rusty-Solitare/memory/MEMORY.md 4. ARCHITECTURE.md — crate responsibilities + data flow
— saved feedback / project context 5. ~/.claude/projects/<this-project>/memory/MEMORY.md
— saved feedback / project context (machine-local;
may be missing on a fresh machine)
PUNCH LIST (in priority order): DECISION TO ASK THE PLAYER FIRST:
1. Confirm or fill the xCards upstream URL in CREDITS.md (one-line A. Push the unpushed commits and cut v0.12.0 now.
edit; not a release blocker). B. Smoke-test the new feel layer first (foundation flourish, drag
2. Tag v0.1.0 once the user signs off. tween, focus pulse, mode digits), then tag.
3. Desktop packaging: icon hookup, platform bundles (.ico/.icns/ C. Skip the tag for another iteration round — see "next-round
AppImage), signing. Needs artwork and certs from the user. candidates" in SESSION_HANDOFF for ideas.
D. Take the deferred desktop-packaging item (needs artwork +
signing certs from the user).
WORKFLOW NOTES: WORKFLOW NOTES:
- Commits use: - Commits use:
git -c user.name=funman300 -c user.email=root@vscode.infinity commit -m "..." git -c user.name=funman300 -c user.email=root@vscode.infinity \
commit -m "..."
- Sub-agents stage + verify only; orchestrator commits. - Sub-agents stage + verify only; orchestrator commits.
- Every commit must pass build / clippy / test. - Every commit must pass build / clippy / test before pushing.
- Push to GitHub (origin) — that is the canonical remote.
OPEN AT THE START: ask which punch-list item to start on. Don't pick OPEN AT THE START: ask which of A / B / C / D. Don't pick unilaterally.
unilaterally — release-readiness ordering is the user's call.
``` ```
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
# Solitaire Quest — Session Handoff # Solitaire Quest — Session Handoff
> Last updated: 2026-04-25 > Last updated: 2026-04-25
> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitare.git > Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitaire.git
> Test count: **242 passing** (83 core + 60 data + 99 engine), `cargo clippy --workspace -- -D warnings` clean > Test count: **242 passing** (83 core + 60 data + 99 engine), `cargo clippy --workspace -- -D warnings` clean
--- ---
+40 -1
View File
@@ -16,13 +16,14 @@ fn main() -> io::Result<()> {
let out_dir = workspace_root().join("assets").join("audio"); let out_dir = workspace_root().join("assets").join("audio");
fs::create_dir_all(&out_dir)?; fs::create_dir_all(&out_dir)?;
let effects: [(&str, Generator); 6] = [ let effects: [(&str, Generator); 7] = [
("card_flip.wav", card_flip), ("card_flip.wav", card_flip),
("card_place.wav", card_place), ("card_place.wav", card_place),
("card_deal.wav", card_deal), ("card_deal.wav", card_deal),
("card_invalid.wav", card_invalid), ("card_invalid.wav", card_invalid),
("win_fanfare.wav", win_fanfare), ("win_fanfare.wav", win_fanfare),
("ambient_loop.wav", ambient_loop), ("ambient_loop.wav", ambient_loop),
("foundation_complete.wav", foundation_complete),
]; ];
for (name, make) in &effects { for (name, make) in &effects {
@@ -170,6 +171,44 @@ fn win_fanfare() -> Vec<i16> {
out out
} }
/// Per-suit foundation-completion ping (~240 ms): a rising three-note
/// chime — C6, E6, G6 — with a soft 2nd-harmonic warm layer on each
/// note. Shorter and brighter than `win_fanfare` so it can fire up to
/// four times per game (once per suit) without drowning out subsequent
/// move sounds. The fourth firing co-occurs with the win cascade and
/// `win_fanfare`; the C-major triad sits an octave above the
/// fanfare's root so the two layer cleanly instead of fighting for the
/// same frequency band.
fn foundation_complete() -> Vec<i16> {
// C major triad, one octave up from win_fanfare's root.
let notes = [1046.50_f32, 1318.51, 1567.98]; // C6, E6, G6
let note_dur = 0.07_f32; // brisk, ascending
let total = note_dur * notes.len() as f32 + 0.05;
let n = duration_samples(total);
let mut out = Vec::with_capacity(n);
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
let mut sample = 0.0f32;
for (idx, freq) in notes.iter().enumerate() {
let start = idx as f32 * note_dur;
let local = t - start;
// Each note rings out for 0.18 s — overlapping notes form a
// brief chord at the tail.
if !(0.0..=0.18).contains(&local) {
continue;
}
// Sine + soft 2nd harmonic for warmth, ar_envelope decays
// sharply so each note is bell-like rather than sustained.
let s = (2.0 * std::f32::consts::PI * freq * local).sin()
+ 0.25 * (2.0 * std::f32::consts::PI * freq * 2.0 * local).sin();
let env = ar_envelope(local, 0.005, 0.18, 14.0);
sample += s * env;
}
out.push(quantize(sample * 0.20));
}
out
}
/// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz /// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz
/// mono 16-bit PCM). /// mono 16-bit PCM).
/// ///
+201 -21
View File
@@ -1,6 +1,6 @@
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::card::{Card, Suit}; use crate::card::Card;
use crate::deck::{deal_klondike, Deck}; use crate::deck::{deal_klondike, Deck};
use crate::error::MoveError; use crate::error::MoveError;
use crate::pile::{Pile, PileType}; use crate::pile::{Pile, PileType};
@@ -9,6 +9,20 @@ use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score
const MAX_UNDO_STACK: usize = 64; const MAX_UNDO_STACK: usize = 64;
/// Save-file schema version for `GameState`. Increment when the on-disk
/// representation changes incompatibly so `load_game_state_from` can refuse
/// older formats and start the player on a fresh game.
///
/// History:
/// - v1: `Foundation(Suit)` keys.
/// - v2 (current): `Foundation(u8)` slot keys; claimed suit derived from the
/// bottom card of the pile.
pub const GAME_STATE_SCHEMA_VERSION: u32 = 2;
/// Default value for `GameState::schema_version` when deserialising older
/// save files that pre-date the field.
fn schema_v1() -> u32 { 1 }
/// Serialize `HashMap<PileType, Pile>` as a `Vec` of `(key, value)` pairs so /// Serialize `HashMap<PileType, Pile>` as a `Vec` of `(key, value)` pairs so
/// that JSON (which requires string map keys) round-trips correctly. /// that JSON (which requires string map keys) round-trips correctly.
mod pile_map_serde { mod pile_map_serde {
@@ -98,6 +112,11 @@ pub struct GameState {
/// Used by the `comeback` achievement condition. /// Used by the `comeback` achievement condition.
#[serde(default)] #[serde(default)]
pub recycle_count: u32, pub recycle_count: u32,
/// Save-file schema version. Defaults to `1` for older files that pre-date
/// the field. The loader refuses any value other than
/// [`GAME_STATE_SCHEMA_VERSION`].
#[serde(default = "schema_v1")]
pub schema_version: u32,
undo_stack: VecDeque<StateSnapshot>, undo_stack: VecDeque<StateSnapshot>,
} }
@@ -116,8 +135,8 @@ impl GameState {
let mut piles: HashMap<PileType, Pile> = HashMap::new(); let mut piles: HashMap<PileType, Pile> = HashMap::new();
piles.insert(PileType::Stock, stock); piles.insert(PileType::Stock, stock);
piles.insert(PileType::Waste, Pile::new(PileType::Waste)); piles.insert(PileType::Waste, Pile::new(PileType::Waste));
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
piles.insert(PileType::Foundation(suit), Pile::new(PileType::Foundation(suit))); piles.insert(PileType::Foundation(slot), Pile::new(PileType::Foundation(slot)));
} }
for (i, pile) in tableau.into_iter().enumerate() { for (i, pile) in tableau.into_iter().enumerate() {
piles.insert(PileType::Tableau(i), pile); piles.insert(PileType::Tableau(i), pile);
@@ -135,6 +154,7 @@ impl GameState {
is_auto_completable: false, is_auto_completable: false,
undo_count: 0, undo_count: 0,
recycle_count: 0, recycle_count: 0,
schema_version: GAME_STATE_SCHEMA_VERSION,
undo_stack: VecDeque::new(), undo_stack: VecDeque::new(),
} }
} }
@@ -247,14 +267,14 @@ impl GameState {
let bottom_card = from_pile.cards[start].clone(); let bottom_card = from_pile.cards[start].clone();
match &to { match &to {
PileType::Foundation(suit) => { PileType::Foundation(_) => {
if count != 1 { if count != 1 {
return Err(MoveError::RuleViolation( return Err(MoveError::RuleViolation(
"only one card can move to foundation at a time".into(), "only one card can move to foundation at a time".into(),
)); ));
} }
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?; let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
if !can_place_on_foundation(&bottom_card, dest, *suit) { if !can_place_on_foundation(&bottom_card, dest) {
return Err(MoveError::RuleViolation("invalid foundation placement".into())); return Err(MoveError::RuleViolation("invalid foundation placement".into()));
} }
} }
@@ -332,13 +352,11 @@ impl GameState {
Ok(()) Ok(())
} }
/// Returns `true` when all four foundations each contain 13 cards. /// Returns `true` when all four foundation slots each contain 13 cards.
pub fn check_win(&self) -> bool { pub fn check_win(&self) -> bool {
[Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] (0..4_u8).all(|slot| {
.iter()
.all(|&suit| {
self.piles self.piles
.get(&PileType::Foundation(suit)) .get(&PileType::Foundation(slot))
.is_some_and(|p| p.cards.len() == 13) .is_some_and(|p| p.cards.len() == 13)
}) })
} }
@@ -379,13 +397,34 @@ impl GameState {
if !self.is_auto_completable || self.is_won { if !self.is_auto_completable || self.is_won {
return None; return None;
} }
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
for i in 0..7 { for i in 0..7 {
let tableau = PileType::Tableau(i); let tableau = PileType::Tableau(i);
if let Some(card) = self.piles[&tableau].cards.last() { if let Some(card) = self.piles[&tableau].cards.last() {
for &suit in &suits { // Prefer the slot that already claims this card's suit so
let foundation = PileType::Foundation(suit); // Aces don't sometimes land in slot 0 and then leave the
if can_place_on_foundation(card, &self.piles[&foundation], suit) { // matching suit-claimed slot empty.
let mut candidate: Option<u8> = None;
let mut empty_slot: Option<u8> = None;
for slot in 0..4_u8 {
let foundation = PileType::Foundation(slot);
let pile = &self.piles[&foundation];
if pile.cards.is_empty() {
if empty_slot.is_none() {
empty_slot = Some(slot);
}
} else if pile.claimed_suit() == Some(card.suit) {
candidate = Some(slot);
break;
}
}
let target_slot = candidate.or_else(|| {
// Only fall back to an empty slot if the card is an Ace,
// which is the only rank that can claim an empty slot.
if card.rank.value() == 1 { empty_slot } else { None }
});
if let Some(slot) = target_slot {
let foundation = PileType::Foundation(slot);
if can_place_on_foundation(card, &self.piles[&foundation]) {
return Some((tableau, foundation)); return Some((tableau, foundation));
} }
} }
@@ -403,7 +442,7 @@ impl GameState {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::card::{Card, Rank}; use crate::card::{Card, Rank, Suit};
fn new_game() -> GameState { fn new_game() -> GameState {
GameState::new(42, DrawMode::DrawOne) GameState::new(42, DrawMode::DrawOne)
@@ -434,8 +473,8 @@ mod tests {
#[test] #[test]
fn new_game_foundations_are_empty() { fn new_game_foundations_are_empty() {
let g = new_game(); let g = new_game();
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
assert!(g.piles[&PileType::Foundation(suit)].cards.is_empty()); assert!(g.piles[&PileType::Foundation(slot)].cards.is_empty());
} }
} }
@@ -662,7 +701,7 @@ mod tests {
]; ];
let result = g.move_cards( let result = g.move_cards(
PileType::Tableau(0), PileType::Tableau(0),
PileType::Foundation(Suit::Clubs), PileType::Foundation(0),
2, 2,
); );
assert!( assert!(
@@ -706,8 +745,9 @@ mod tests {
#[test] #[test]
fn win_detection_all_foundations_complete() { fn win_detection_all_foundations_complete() {
let mut g = new_game(); let mut g = new_game();
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let f = g.piles.get_mut(&PileType::Foundation(suit)).unwrap(); for (slot, suit) in suits.into_iter().enumerate() {
let f = g.piles.get_mut(&PileType::Foundation(slot as u8)).unwrap();
f.cards.clear(); f.cards.clear();
for rank in [ for rank in [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
@@ -1039,7 +1079,8 @@ mod tests {
let mv = g.next_auto_complete_move().expect("should find a move"); let mv = g.next_auto_complete_move().expect("should find a move");
assert_eq!(mv.0, PileType::Tableau(0)); assert_eq!(mv.0, PileType::Tableau(0));
assert_eq!(mv.1, PileType::Foundation(Suit::Clubs)); // Slot 0 is the first empty foundation; the Ace lands there.
assert_eq!(mv.1, PileType::Foundation(0));
} }
#[test] #[test]
@@ -1049,4 +1090,143 @@ mod tests {
g.is_won = true; g.is_won = true;
assert!(g.next_auto_complete_move().is_none()); assert!(g.next_auto_complete_move().is_none());
} }
// --- Slot-based foundation behaviour (refactor coverage) ---
/// Aces land in the first empty slot regardless of suit, and successive
/// Aces fan out across slots 0, 1, 2, 3 in deterministic order.
#[test]
fn any_ace_lands_in_first_empty_foundation() {
let mut g = new_game();
// Clear stock/waste/tableau so we can hand-construct moves directly.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Place an Ace of Clubs on tableau 0; move it to slot 0.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
});
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
// Now place an Ace of Spades on tableau 0 and move it to slot 1.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true,
});
g.move_cards(PileType::Tableau(0), PileType::Foundation(1), 1).unwrap();
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Clubs));
assert_eq!(g.piles[&PileType::Foundation(1)].claimed_suit(), Some(Suit::Spades));
}
/// `Pile::claimed_suit` reads the bottom card's suit on a populated
/// foundation slot, regardless of which slot index the pile occupies.
#[test]
fn claimed_suit_is_derived_from_bottom_card() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 50, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
});
g.move_cards(PileType::Tableau(0), PileType::Foundation(2), 1).unwrap();
assert_eq!(
g.piles[&PileType::Foundation(2)].claimed_suit(),
Some(Suit::Hearts)
);
}
/// Undoing the only card from a foundation slot drops the claimed suit;
/// the slot then accepts a different Ace.
#[test]
fn foundation_claim_drops_when_emptied_via_undo() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
});
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Hearts));
g.undo().unwrap();
assert!(g.piles[&PileType::Foundation(0)].cards.is_empty());
assert!(g.piles[&PileType::Foundation(0)].claimed_suit().is_none());
// A different Ace can now claim slot 0.
let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.clear();
t0.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Spades));
}
/// Successive Aces from the waste pile distribute across slots 0..=3 in
/// order — the player picks the slot, but `move_cards` accepts any
/// empty-slot placement for an Ace.
#[test]
fn multiple_aces_distribute_across_slots() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
let aces = [
(Suit::Clubs, 10),
(Suit::Diamonds, 11),
(Suit::Hearts, 12),
(Suit::Spades, 13),
];
for (slot, (suit, id)) in aces.iter().enumerate() {
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
id: *id, suit: *suit, rank: Rank::Ace, face_up: true,
});
g.move_cards(PileType::Waste, PileType::Foundation(slot as u8), 1).unwrap();
}
for (slot, (suit, _)) in aces.iter().enumerate() {
assert_eq!(
g.piles[&PileType::Foundation(slot as u8)].claimed_suit(),
Some(*suit),
"slot {slot} should claim {suit:?}",
);
}
}
/// Auto-complete prefers the foundation slot whose claimed suit matches
/// the candidate card's suit, even if an empty slot exists at a lower
/// index.
#[test]
fn next_auto_complete_move_picks_slot_with_matching_claim() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Slot 0 is empty; slot 1 already claims Hearts via Ace of Hearts.
g.piles.get_mut(&PileType::Foundation(1)).unwrap().cards.push(Card {
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
});
// Tableau 0 holds the 2 of Hearts to play.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 2, suit: Suit::Hearts, rank: Rank::Two, face_up: true,
});
g.is_auto_completable = true;
let mv = g.next_auto_complete_move().expect("auto-complete must find slot 1");
assert_eq!(mv.0, PileType::Tableau(0));
assert_eq!(
mv.1,
PileType::Foundation(1),
"must target the Hearts-claimed slot, not the empty slot 0",
);
}
} }
+38 -5
View File
@@ -8,8 +8,10 @@ pub enum PileType {
Stock, Stock,
/// The face-up discard pile drawn to. /// The face-up discard pile drawn to.
Waste, Waste,
/// One of the four suit-ordered foundation piles. /// One of the four foundation slots (0..=3). The claimed suit, if any,
Foundation(Suit), /// is derived from the bottom card of the pile (always an Ace by
/// construction).
Foundation(u8),
/// One of the seven tableau columns (06). /// One of the seven tableau columns (06).
Tableau(usize), Tableau(usize),
} }
@@ -17,7 +19,7 @@ pub enum PileType {
/// A named collection of cards in a specific board position. /// A named collection of cards in a specific board position.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pile { pub struct Pile {
/// Which pile this is (Stock, Waste, Foundation suit, or Tableau column). /// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
pub pile_type: PileType, pub pile_type: PileType,
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card. /// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
pub cards: Vec<Card>, pub cards: Vec<Card>,
@@ -33,6 +35,16 @@ impl Pile {
pub fn top(&self) -> Option<&Card> { pub fn top(&self) -> Option<&Card> {
self.cards.last() self.cards.last()
} }
/// For foundation piles: returns `Some(suit)` once at least one card has
/// landed (the bottom card is always an Ace of the claimed suit).
/// Returns `None` for empty foundations or non-foundation piles.
pub fn claimed_suit(&self) -> Option<Suit> {
match self.pile_type {
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
_ => None,
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -61,12 +73,33 @@ mod tests {
} }
#[test] #[test]
fn pile_type_foundation_uses_suit() { fn pile_type_foundation_uses_slot_index() {
assert_ne!(PileType::Foundation(Suit::Hearts), PileType::Foundation(Suit::Spades)); assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
} }
#[test] #[test]
fn pile_type_tableau_uses_index() { fn pile_type_tableau_uses_index() {
assert_ne!(PileType::Tableau(0), PileType::Tableau(6)); assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
} }
#[test]
fn claimed_suit_is_none_for_empty_foundation() {
let pile = Pile::new(PileType::Foundation(0));
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_is_none_for_non_foundation() {
let mut pile = Pile::new(PileType::Tableau(0));
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_returns_bottom_card_suit() {
let mut pile = Pile::new(PileType::Foundation(2));
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
pile.cards.push(Card { id: 1, suit: Suit::Hearts, rank: Rank::Two, face_up: true });
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
}
} }
+37 -26
View File
@@ -1,16 +1,18 @@
use crate::card::{Card, Suit}; use crate::card::Card;
use crate::pile::Pile; use crate::pile::Pile;
/// Returns `true` if `card` can be placed on `pile` as the next card in the foundation for `suit`. /// Returns `true` if `card` can be placed on the foundation `pile`.
/// ///
/// Foundation rules: same suit, Ace starts, each subsequent card is one rank higher. /// Foundation rules:
pub fn can_place_on_foundation(card: &Card, pile: &Pile, suit: Suit) -> bool { /// - When the pile is empty, any Ace is accepted; the placed Ace's suit
if card.suit != suit { /// becomes the pile's claimed suit (derived from the bottom card via
return false; /// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
} /// - When the pile is non-empty, the next card must match the top card's
/// suit and be exactly one rank higher.
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
match pile.cards.last() { match pile.cards.last() {
None => card.rank.value() == 1, None => card.rank.value() == 1,
Some(top) => card.rank.value() == top.rank.value() + 1, Some(top) => card.suit == top.suit && card.rank.value() == top.rank.value() + 1,
} }
} }
@@ -45,37 +47,46 @@ mod tests {
// Foundation tests // Foundation tests
#[test] #[test]
fn foundation_ace_on_empty_is_valid() { fn foundation_ace_on_empty_is_valid() {
let c = card(Suit::Hearts, Rank::Ace); // Every suit's Ace must land on an empty foundation slot regardless of
let p = Pile::new(PileType::Foundation(Suit::Hearts)); // its slot index; the slot claims the suit only after the Ace lands.
assert!(can_place_on_foundation(&c, &p, Suit::Hearts)); for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
let c = card(suit, Rank::Ace);
let p = Pile::new(PileType::Foundation(0));
assert!(
can_place_on_foundation(&c, &p),
"Ace of {suit:?} must land on empty slot 0",
);
}
} }
#[test] #[test]
fn foundation_non_ace_on_empty_is_invalid() { fn foundation_non_ace_on_empty_is_invalid() {
let c = card(Suit::Hearts, Rank::Two); let c = card(Suit::Hearts, Rank::Two);
let p = Pile::new(PileType::Foundation(Suit::Hearts)); let p = Pile::new(PileType::Foundation(0));
assert!(!can_place_on_foundation(&c, &p, Suit::Hearts)); assert!(!can_place_on_foundation(&c, &p));
} }
#[test] #[test]
fn foundation_two_on_ace_same_suit_is_valid() { fn foundation_two_on_ace_same_suit_is_valid() {
let c = card(Suit::Clubs, Rank::Two); let c = card(Suit::Clubs, Rank::Two);
let p = pile_with(PileType::Foundation(Suit::Clubs), vec![card(Suit::Clubs, Rank::Ace)]); let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
assert!(can_place_on_foundation(&c, &p, Suit::Clubs)); assert!(can_place_on_foundation(&c, &p));
} }
#[test] #[test]
fn foundation_wrong_suit_is_invalid() { fn foundation_second_card_must_match_claimed_suit() {
let c = card(Suit::Hearts, Rank::Ace); // Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected
let p = Pile::new(PileType::Foundation(Suit::Spades)); // because the slot's claimed suit is Hearts after the Ace lands.
assert!(!can_place_on_foundation(&c, &p, Suit::Spades)); let p = pile_with(PileType::Foundation(0), vec![card(Suit::Hearts, Rank::Ace)]);
let c = card(Suit::Spades, Rank::Two);
assert!(!can_place_on_foundation(&c, &p));
} }
#[test] #[test]
fn foundation_skipping_rank_is_invalid() { fn foundation_skipping_rank_is_invalid() {
let c = card(Suit::Diamonds, Rank::Three); let c = card(Suit::Diamonds, Rank::Three);
let p = pile_with(PileType::Foundation(Suit::Diamonds), vec![card(Suit::Diamonds, Rank::Ace)]); let p = pile_with(PileType::Foundation(0), vec![card(Suit::Diamonds, Rank::Ace)]);
assert!(!can_place_on_foundation(&c, &p, Suit::Diamonds)); assert!(!can_place_on_foundation(&c, &p));
} }
// Tableau tests // Tableau tests
@@ -125,16 +136,16 @@ mod tests {
fn foundation_king_on_queen_completes_suit() { fn foundation_king_on_queen_completes_suit() {
// The last card placed to complete a foundation is always King on Queen. // The last card placed to complete a foundation is always King on Queen.
let c = card(Suit::Spades, Rank::King); let c = card(Suit::Spades, Rank::King);
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]); let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
assert!(can_place_on_foundation(&c, &p, Suit::Spades)); assert!(can_place_on_foundation(&c, &p));
} }
#[test] #[test]
fn foundation_king_wrong_suit_is_invalid() { fn foundation_king_wrong_suit_is_invalid() {
// King of Hearts cannot go on a Spades foundation even if rank matches. // King of Hearts cannot go on a Spades-claimed foundation even if rank matches.
let c = card(Suit::Hearts, Rank::King); let c = card(Suit::Hearts, Rank::King);
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]); let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
assert!(!can_place_on_foundation(&c, &p, Suit::Spades)); assert!(!can_place_on_foundation(&c, &p));
} }
#[test] #[test]
+3 -4
View File
@@ -33,12 +33,11 @@ pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::card::Suit;
#[test] #[test]
fn move_to_foundation_scores_ten() { fn move_to_foundation_scores_ten() {
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(Suit::Hearts)), 10); assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(Suit::Clubs)), 10); assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(0)), 10);
} }
#[test] #[test]
@@ -74,7 +73,7 @@ mod tests {
#[test] #[test]
fn non_waste_to_tableau_scores_zero() { fn non_waste_to_tableau_scores_zero() {
// Foundation → Tableau is impossible in practice but must score 0. // Foundation → Tableau is impossible in practice but must score 0.
assert_eq!(score_move(&PileType::Foundation(Suit::Clubs), &PileType::Tableau(0)), 0); assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), 0);
// Tableau → Tableau (restack) scores 0. // Tableau → Tableau (restack) scores 0.
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0); assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
} }
+57
View File
@@ -132,6 +132,17 @@ pub struct Settings {
/// `#[serde(default = ...)]`. /// `#[serde(default = ...)]`.
#[serde(default = "default_theme_id")] #[serde(default = "default_theme_id")]
pub selected_theme_id: String, pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been
/// shown to the player after their very first win. Acts as a
/// one-shot teach: subsequent wins must not re-fire the cue. Older
/// `settings.json` files written before this field existed
/// deserialize cleanly to `false` thanks to `#[serde(default)]` —
/// players who already had wins recorded before this field was
/// introduced are guarded by the post-condition `games_won == 1`
/// checked by `achievement_plugin::fire_achievement_onboarding_toast`,
/// so the toast still does not fire for them.
#[serde(default)]
pub shown_achievement_onboarding: bool,
} }
fn default_draw_mode() -> DrawMode { fn default_draw_mode() -> DrawMode {
@@ -165,6 +176,7 @@ impl Default for Settings {
color_blind_mode: false, color_blind_mode: false,
window_geometry: None, window_geometry: None,
selected_theme_id: default_theme_id(), selected_theme_id: default_theme_id(),
shown_achievement_onboarding: false,
} }
} }
} }
@@ -318,6 +330,7 @@ mod tests {
color_blind_mode: false, color_blind_mode: false,
window_geometry: None, window_geometry: None,
selected_theme_id: "default".to_string(), selected_theme_id: "default".to_string(),
shown_achievement_onboarding: false,
}; };
save_settings_to(&path, &s).expect("save"); save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path); let loaded = load_settings_from(&path);
@@ -506,4 +519,48 @@ mod tests {
let s: Settings = serde_json::from_slice(json).unwrap_or_default(); let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(s.window_geometry.is_none()); assert!(s.window_geometry.is_none());
} }
// -----------------------------------------------------------------------
// shown_achievement_onboarding — first-win cue one-shot guard
// -----------------------------------------------------------------------
#[test]
fn settings_shown_achievement_onboarding_default_is_false() {
assert!(
!Settings::default().shown_achievement_onboarding,
"default shown_achievement_onboarding must be false so the cue fires once"
);
}
#[test]
fn settings_shown_achievement_onboarding_round_trip() {
let path = tmp_path("achievement_onboarding_round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
shown_achievement_onboarding: true,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert!(
loaded.shown_achievement_onboarding,
"shown_achievement_onboarding must survive serde round-trip"
);
let _ = fs::remove_file(&path);
}
#[test]
fn legacy_settings_without_shown_achievement_onboarding_deserializes_to_false() {
// A settings.json written by an older version of the game will be
// missing this field entirely. `#[serde(default)]` on the field
// must yield `false` — the cue then fires on the next win, but
// only when stats.games_won == 1, so existing players who have
// already won past their first game won't see the toast either.
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(
!s.shown_achievement_onboarding,
"legacy settings.json missing shown_achievement_onboarding must deserialize to false"
);
}
} }
+58 -2
View File
@@ -7,7 +7,7 @@ use std::fs;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use solitaire_core::game_state::GameState; use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
use crate::stats::StatsSnapshot; use crate::stats::StatsSnapshot;
@@ -72,10 +72,21 @@ pub fn game_state_file_path() -> Option<PathBuf> {
} }
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is /// Load an in-progress `GameState` from `path`. Returns `None` if the file is
/// missing, corrupt, or represents a finished game. /// missing, corrupt, represents a finished game, or carries a save-schema
/// version other than [`GAME_STATE_SCHEMA_VERSION`].
///
/// Schema mismatch is treated as "no save" so a player upgrading across an
/// incompatible game-state format change starts fresh instead of seeing a
/// half-loaded game (or a deserialiser error). v1 saves with the old
/// `Foundation(Suit)` key shape will fail to parse outright; any v1 saves
/// that happen to round-trip but report `schema_version: 1` are also rejected
/// here.
pub fn load_game_state_from(path: &Path) -> Option<GameState> { pub fn load_game_state_from(path: &Path) -> Option<GameState> {
let data = fs::read(path).ok()?; let data = fs::read(path).ok()?;
let gs: GameState = serde_json::from_slice(&data).ok()?; let gs: GameState = serde_json::from_slice(&data).ok()?;
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
return None;
}
if gs.is_won { if gs.is_won {
None None
} else { } else {
@@ -331,4 +342,49 @@ mod tests {
let tmp = path.with_extension("json.tmp"); let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists(), ".tmp must be cleaned up after rename"); assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
} }
/// Pre-v2 save files used `Foundation(Suit)` keys and either fail to
/// parse outright or surface a `schema_version: 1`. Either path must
/// produce `None` so the player launches into a fresh game.
///
/// Sibling assertion: the stats round-trip path is unaffected — only
/// the game-state schema bumped.
#[test]
fn save_format_v1_is_rejected() {
let path = gs_path("schema_v1");
let _ = fs::remove_file(&path);
// A pared-down v1 JSON literal: foundation pile keys use the old
// suit-tagged form and the file omits `schema_version` (so it
// deserialises with the default of 1). Even if a future change
// makes `Foundation(Suit)` parse-compatible, the schema-version
// gate keeps this case rejected.
let v1_json = r#"{
"piles": [
[{"Foundation": "Hearts"}, {"pile_type": {"Foundation": "Hearts"}, "cards": []}]
],
"draw_mode": "DrawOne",
"score": 0,
"move_count": 0,
"elapsed_seconds": 0,
"seed": 42,
"is_won": false,
"is_auto_completable": false,
"undo_count": 0,
"undo_stack": []
}"#;
fs::write(&path, v1_json).expect("write v1 fixture");
assert!(
load_game_state_from(&path).is_none(),
"v1 game_state.json must be rejected (parse failure or schema bump)",
);
// Sibling sanity: stats files are independent and still round-trip.
let stats_path = tmp_path("schema_unrelated_stats");
let _ = fs::remove_file(&stats_path);
save_stats_to(&stats_path, &StatsSnapshot::default()).expect("save stats");
let loaded = load_stats_from(&stats_path);
assert_eq!(loaded, StatsSnapshot::default());
}
} }
+260 -3
View File
@@ -14,17 +14,19 @@ use solitaire_core::achievement::{
ALL_ACHIEVEMENTS, ALL_ACHIEVEMENTS,
}; };
use solitaire_data::{ use solitaire_data::{
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord, achievements_file_path, load_achievements_from, save_achievements_to, save_settings_to,
save_progress_to, AchievementRecord, save_progress_to,
}; };
use crate::events::{ use crate::events::{
AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent, AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, ToggleAchievementsRequestEvent,
XpAwardedEvent,
}; };
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate}; use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
@@ -91,6 +93,7 @@ impl Plugin for AchievementPlugin {
.add_message::<AchievementUnlockedEvent>() .add_message::<AchievementUnlockedEvent>()
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.add_message::<InfoToastEvent>()
.add_message::<ToggleAchievementsRequestEvent>() .add_message::<ToggleAchievementsRequestEvent>()
// Run after GameMutation (so GameWonEvent is available), after // Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate // StatsUpdate (so stats reflect this win), and after ProgressUpdate
@@ -102,6 +105,16 @@ impl Plugin for AchievementPlugin {
.after(StatsUpdate) .after(StatsUpdate)
.after(ProgressUpdate), .after(ProgressUpdate),
) )
// Achievement-onboarding cue: fires once after the player's very
// first win to teach the Achievements panel exists. Must run
// `.after(StatsUpdate)` so `stats.games_won` reflects the win
// that just landed (StatsUpdate increments it on `GameWonEvent`).
.add_systems(
Update,
fire_achievement_onboarding_toast
.after(GameMutation)
.after(StatsUpdate),
)
.add_systems(Update, toggle_achievements_screen) .add_systems(Update, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button); .add_systems(Update, handle_achievements_close_button);
} }
@@ -209,6 +222,67 @@ fn evaluate_on_win(
} }
} }
/// Achievement-onboarding cue.
///
/// On the player's very first win — and only their first — fires a single
/// `InfoToastEvent` nudging them toward the Achievements panel (`A` hotkey)
/// so they discover the progression layer.
///
/// Three guards prevent spurious or repeat firings:
///
/// * `stats.games_won == 1` — the post-condition is checked **after**
/// `StatsUpdate` increments `games_won`, so the cue only fires for the
/// true first win, not (for example) a player who imported existing
/// sync data and won a later game.
/// * `!settings.shown_achievement_onboarding` — flips to `true` after
/// the toast fires, persists to `settings.json`, and serves as the
/// one-shot guard across launches and merged sync.
/// * The system bails immediately when no `GameWonEvent` arrived this
/// frame so it is a no-op outside the post-win frame.
///
/// The `A` hotkey is mentioned verbatim in the toast text so players who
/// dismiss the cue still know where to find the panel.
fn fire_achievement_onboarding_toast(
mut wins: MessageReader<GameWonEvent>,
stats: Res<StatsResource>,
mut settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
mut toast: MessageWriter<InfoToastEvent>,
) {
// Drain the event queue regardless — multiple wins on a single frame
// only need a single onboarding toast at most.
let any_win = wins.read().last().is_some();
if !any_win {
return;
}
// Without a `SettingsResource` (headless tests that omit `SettingsPlugin`)
// we have no flag to consult; bail out cleanly.
let Some(settings) = settings.as_mut() else {
return;
};
if settings.0.shown_achievement_onboarding {
return;
}
if stats.0.games_won != 1 {
return;
}
toast.write(InfoToastEvent(
"First win! Press A to see your achievements.".to_string(),
));
settings.0.shown_achievement_onboarding = true;
// Persist so the cue stays one-shot across launches. `None` storage
// (headless / test) is a documented no-op.
if let Some(path) = settings_path.as_ref()
&& let Some(target) = path.0.as_deref()
&& let Err(e) = save_settings_to(target, &settings.0)
{
warn!("failed to save settings (achievement onboarding): {e}");
}
}
/// Convenience: resolve an achievement ID to its human-readable name. /// Convenience: resolve an achievement ID to its human-readable name.
/// Used by the toast renderer in `animation_plugin`. /// Used by the toast renderer in `animation_plugin`.
pub fn display_name_for(id: &str) -> String { pub fn display_name_for(id: &str) -> String {
@@ -921,4 +995,187 @@ mod tests {
assert!(s.contains("How to unlock")); assert!(s.contains("How to unlock"));
assert!(!s.contains("Reward"), "got {s:?}"); assert!(!s.contains("Reward"), "got {s:?}");
} }
// -----------------------------------------------------------------------
// Achievement-onboarding cue (`fire_achievement_onboarding_toast`)
// -----------------------------------------------------------------------
/// Builds a headless app that **also** includes `SettingsPlugin::headless()`
/// so the achievement-onboarding system (which reads `SettingsResource`)
/// has a flag to consult and persist into.
fn onboarding_test_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(StatsPlugin::headless())
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
.add_plugins(AchievementPlugin::headless());
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.update();
app
}
/// Collects every `InfoToastEvent` written so tests can assert on
/// count and message contents.
fn drain_info_toasts(app: &App) -> Vec<String> {
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
cursor.read(events).map(|e| e.0.clone()).collect()
}
/// First-win path: with the flag false and `games_won` about to be
/// 1, exactly one `InfoToastEvent` mentioning the `A` hotkey must
/// fire and the flag must flip to `true`.
#[test]
fn first_win_fires_achievement_onboarding_toast() {
let mut app = onboarding_test_app();
// Sanity: fresh app starts with games_won = 0 and the flag unset.
assert_eq!(app.world().resource::<StatsResource>().0.games_won, 0);
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding
);
// StatsPlugin (StatsUpdate) increments games_won to 1 *before* the
// achievement-onboarding system reads stats — our system runs
// `.after(StatsUpdate)`. The system then sees games_won == 1 and
// the cue fires.
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let toasts = drain_info_toasts(&app);
let onboarding_toasts: Vec<&String> = toasts
.iter()
.filter(|t| t.contains("Press A") && t.contains("achievements"))
.collect();
assert_eq!(
onboarding_toasts.len(),
1,
"exactly one achievement-onboarding toast must fire on the first win; \
saw all toasts: {toasts:?}"
);
assert!(
app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding,
"shown_achievement_onboarding must flip to true after the toast fires"
);
}
/// Second-win path: with the flag already `true` (player already
/// saw the cue on a previous run), no onboarding toast may fire.
#[test]
fn subsequent_wins_do_not_fire_achievement_onboarding_toast() {
let mut app = onboarding_test_app();
// Pre-set the flag to simulate a player who already dismissed
// the cue on a previous run.
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.shown_achievement_onboarding = true;
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
.into_iter()
.filter(|t| t.contains("Press A") && t.contains("achievements"))
.collect();
assert!(
onboarding_toasts.is_empty(),
"no onboarding toast must fire when shown_achievement_onboarding is already true; \
got: {onboarding_toasts:?}"
);
}
/// Sync-import path: a player imports stats with `games_won = 5`
/// already on the books. The flag is still `false` (they were on a
/// pre-cue release on this device), but the cue must NOT fire because
/// this isn't actually their first win — the post-condition
/// `games_won == 1` guards against retroactive nagging.
#[test]
fn non_first_win_does_not_fire_achievement_onboarding_toast() {
let mut app = onboarding_test_app();
// Pre-seed games_won = 5 BEFORE the win lands. StatsUpdate will
// bump it to 6 on the GameWonEvent, taking the system well past
// the `games_won == 1` post-condition.
app.world_mut().resource_mut::<StatsResource>().0.games_won = 5;
// Confirm the flag is still false so we know the guard that
// prevents firing is the games-won post-condition, not the flag.
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding
);
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
.into_iter()
.filter(|t| t.contains("Press A") && t.contains("achievements"))
.collect();
assert!(
onboarding_toasts.is_empty(),
"no onboarding toast must fire on a non-first win; got: {onboarding_toasts:?}"
);
// And the flag must remain false so the cue can still teach a
// genuinely-fresh second device or a wiped install.
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding,
"shown_achievement_onboarding must remain false when the cue did not fire"
);
}
/// Without any `GameWonEvent` arriving the system must be a no-op:
/// no toast, no flag flip — even on update ticks where stats happen
/// to read `games_won == 1`.
#[test]
fn no_win_event_means_no_achievement_onboarding_toast() {
let mut app = onboarding_test_app();
// Pre-seed games_won = 1 to simulate the misleading mid-frame
// state without actually firing a GameWonEvent.
app.world_mut().resource_mut::<StatsResource>().0.games_won = 1;
app.update();
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
.into_iter()
.filter(|t| t.contains("Press A") && t.contains("achievements"))
.collect();
assert!(
onboarding_toasts.is_empty(),
"no onboarding toast must fire without a GameWonEvent; got: {onboarding_toasts:?}"
);
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding,
"flag must not flip without a win event"
);
}
} }
+36 -7
View File
@@ -153,13 +153,28 @@ pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoader
} }
/// Returns a process-wide font database populated with the OS-installed /// Returns a process-wide font database populated with the OS-installed
/// fonts the user has available. Initialised lazily on first SVG that /// fonts plus the bundled FiraMono-Medium face. Initialised lazily on
/// references text, then shared (via `Arc`) across every subsequent /// first SVG that references text, then shared (via `Arc`) across every
/// rasterisation. `usvg::Options::default()` ships an empty `fontdb`, /// subsequent rasterisation.
/// 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 /// `usvg::Options::default()` ships an empty `fontdb`, so without this
/// "No match for Arial font-family" warn spam plus glyphs that fall /// call any text glyph in an SVG renders with no font match — the
/// through to whatever shape-only path usvg uses for missing fonts. /// visible symptom on the bundled hayeah artwork is the "No match for
/// Arial font-family" warn spam plus glyphs that fall through to
/// whatever shape-only path usvg uses for missing fonts.
///
/// **Bundled font as last-resort fallback.** Loading only system fonts
/// breaks on minimal Linux installs, fresh Wayland sessions, and
/// chroots where fontconfig has nothing usable to serve as
/// `sans-serif`. The cards on the bundled hayeah theme reference
/// `Bitstream Vera Sans` and `Arial` by name — if neither is installed
/// AND the resolver's CSS-generic fallbacks (`SansSerif`/`Serif`) also
/// don't resolve, the rank/suit text vanishes entirely. Loading the
/// project's bundled FiraMono via `include_bytes!()` and pinning it as
/// the generic-family target guarantees a working last-resort glyph
/// source on every machine. This was the cause of "card font didn't
/// carry over" on a fresh second-machine pull.
///
/// `load_system_fonts` is comparatively expensive (~50200 ms on a /// `load_system_fonts` is comparatively expensive (~50200 ms on a
/// typical desktop) so we only pay it once for the lifetime of the /// typical desktop) so we only pay it once for the lifetime of the
/// process, gated by `OnceLock`. /// process, gated by `OnceLock`.
@@ -168,6 +183,20 @@ fn shared_fontdb() -> Arc<fontdb::Database> {
DB.get_or_init(|| { DB.get_or_init(|| {
let mut db = fontdb::Database::new(); let mut db = fontdb::Database::new();
db.load_system_fonts(); db.load_system_fonts();
// The bundled FiraMono lives at the workspace root, so the
// include_bytes! path goes up three levels from this source
// file (assets → src → solitaire_engine → workspace root).
db.load_font_data(include_bytes!("../../../assets/fonts/main.ttf").to_vec());
// Pin the CSS generics to the bundled face as the resolution
// target. Named-family lookups (Bitstream Vera Sans, Arial)
// still try the system db first; only when those miss does
// the resolver fall through to SansSerif / Serif, and now
// those are guaranteed to land on FiraMono.
db.set_sans_serif_family("Fira Mono");
db.set_serif_family("Fira Mono");
db.set_monospace_family("Fira Mono");
db.set_cursive_family("Fira Mono");
db.set_fantasy_family("Fira Mono");
Arc::new(db) Arc::new(db)
}) })
.clone() .clone()
+32 -2
View File
@@ -28,8 +28,8 @@ use kira::track::{TrackBuilder, TrackHandle};
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value}; use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
use crate::events::{ use crate::events::{
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent,
MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
}; };
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
@@ -70,6 +70,12 @@ pub struct SoundLibrary {
pub place: StaticSoundData, pub place: StaticSoundData,
pub invalid: StaticSoundData, pub invalid: StaticSoundData,
pub fanfare: StaticSoundData, pub fanfare: StaticSoundData,
/// Per-suit foundation-completion ping. Played whenever a King
/// lands on a foundation pile (Ace → King, 13 cards). ~240 ms,
/// rising C-major triad an octave above `fanfare`'s root so the
/// two layer cleanly when the fourth completion co-occurs with
/// the win cascade.
pub foundation_complete: StaticSoundData,
} }
/// Wraps the audio backend. `NonSend` because cpal streams are `!Send` on /// Wraps the audio backend. `NonSend` because cpal streams are `!Send` on
@@ -145,6 +151,7 @@ impl Plugin for AudioPlugin {
.add_message::<CardFlippedEvent>() .add_message::<CardFlippedEvent>()
.add_message::<CardFaceRevealedEvent>() .add_message::<CardFaceRevealedEvent>()
.add_message::<UndoRequestEvent>() .add_message::<UndoRequestEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<SettingsChangedEvent>() .add_message::<SettingsChangedEvent>()
.add_systems(Startup, apply_initial_volume) .add_systems(Startup, apply_initial_volume)
.add_systems( .add_systems(
@@ -157,6 +164,7 @@ impl Plugin for AudioPlugin {
play_on_win, play_on_win,
play_on_face_revealed, play_on_face_revealed,
play_on_undo, play_on_undo,
play_on_foundation_complete,
apply_volume_on_change, apply_volume_on_change,
handle_mute_keys, handle_mute_keys,
), ),
@@ -170,12 +178,15 @@ fn build_library() -> Option<SoundLibrary> {
let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?; let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?;
let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?; let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?;
let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?; let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?;
let foundation_complete =
decode(include_bytes!("../../assets/audio/foundation_complete.wav"))?;
Some(SoundLibrary { Some(SoundLibrary {
deal, deal,
flip, flip,
place, place,
invalid, invalid,
fanfare, fanfare,
foundation_complete,
}) })
} }
@@ -451,6 +462,25 @@ fn play_on_face_revealed(
} }
} }
/// Plays the per-suit completion ping whenever a `FoundationCompletedEvent`
/// fires (a King lands on a foundation pile that now holds Ace → King).
///
/// The fourth firing co-occurs with `GameWonEvent` and the win fanfare;
/// the two layer cleanly because the ping sits an octave above the
/// fanfare's root and is much shorter (~240 ms vs ~970 ms).
fn play_on_foundation_complete(
mut events: MessageReader<FoundationCompletedEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
let Some(lib) = lib else {
return;
};
for _ in events.read() {
play(&mut audio, &lib.foundation_complete);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+2 -1
View File
@@ -196,7 +196,8 @@ mod tests {
// At least one MoveRequestEvent should have been fired. // At least one MoveRequestEvent should have been fired.
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent"); assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
assert_eq!(fired[0].from, PileType::Tableau(0)); assert_eq!(fired[0].from, PileType::Tableau(0));
assert_eq!(fired[0].to, PileType::Foundation(Suit::Clubs)); // First empty foundation slot wins on a fresh nearly-won board.
assert_eq!(fired[0].to, PileType::Foundation(0));
} }
#[test] #[test]
+641 -25
View File
@@ -22,6 +22,7 @@ use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration}; use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent}; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::{Layout, LayoutResource, LayoutSystem}; use crate::layout::{Layout, LayoutResource, LayoutSystem};
@@ -29,6 +30,12 @@ use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource}; use crate::resources::{DragState, GameStateResource};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::table_plugin::PileMarker; use crate::table_plugin::PileMarker;
use crate::font_plugin::FontResource;
use crate::ui_theme::{
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TYPE_CAPTION, Z_STOCK_BADGE,
};
/// Fraction of card height used as vertical offset between face-up tableau cards. /// Fraction of card height used as vertical offset between face-up tableau cards.
pub const TABLEAU_FAN_FRAC: f32 = 0.25; pub const TABLEAU_FAN_FRAC: f32 = 0.25;
@@ -44,8 +51,11 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12; pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
/// Fraction of card height used as a tiny offset between stacked cards in /// Fraction of card height used as a tiny offset between stacked cards in
/// non-tableau piles, so stacking is visible. /// non-tableau piles, so stacking is visible. Public so other plugins
const STACK_FAN_FRAC: f32 = 0.003; /// (e.g. input_plugin's drag-rejection tween) can compute the resting
/// `Transform.translation.z` for a card at a given stack index without
/// drifting from the value used by [`card_positions`].
pub const STACK_FAN_FRAC: f32 = 0.003;
/// Font size as a fraction of card width. /// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28; const FONT_SIZE_FRAC: f32 = 0.28;
@@ -132,6 +142,27 @@ pub struct RightClickHighlightTimer(pub f32);
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct StockEmptyLabel; pub struct StockEmptyLabel;
/// Marker on the chip-background sprite of the stock-pile remaining-count
/// badge.
///
/// The badge is spawned as a *top-level* world entity (not parented to the
/// stock [`PileMarker`]) and its `Transform` is recomputed each frame from
/// `LayoutResource` so it tracks the stock pile through window resizes.
/// The chip sits in the top-right corner of the stock pile and is hidden
/// while the stock is empty — the existing `↺` overlay
/// ([`StockEmptyLabel`]) covers the recycle hint instead, so the two
/// indicators never render simultaneously.
#[derive(Component, Debug)]
pub struct StockCountBadge;
/// Marker on the `Text2d` child of [`StockCountBadge`] showing the numeric
/// count of cards remaining in the stock pile.
///
/// Update systems query this component to write the new count in place rather
/// than despawning and respawning the text entity each tick.
#[derive(Component, Debug)]
pub struct StockCountBadgeText;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #34 — Card-flip animation // Task #34 — Card-flip animation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -168,6 +199,72 @@ const FLIP_HALF_SECS: f32 = 0.08;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ShadowEntity; pub struct ShadowEntity;
/// Marker component for the per-card drop-shadow child sprite.
///
/// Every `CardEntity` owns exactly one `CardShadow` child whose `Sprite` is a
/// neutral-black halo painted slightly down-and-right of the card. Idle state
/// uses [`CARD_SHADOW_OFFSET_IDLE`] / [`CARD_SHADOW_ALPHA_IDLE`]; while the
/// parent card is being dragged the shadow is pushed to the deeper
/// [`CARD_SHADOW_OFFSET_DRAG`] / [`CARD_SHADOW_ALPHA_DRAG`] values so the
/// stack reads as "lifted" off the felt.
#[derive(Component, Debug)]
pub struct CardShadow;
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
/// shadow given whether its parent card is currently part of the dragged
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
/// without spinning up a Bevy app.
///
/// `is_dragged = false` → resting `(IDLE, IDLE, IDLE)`
/// `is_dragged = true` → lifted `(DRAG, DRAG, DRAG)`
pub fn card_shadow_params(is_dragged: bool) -> (Vec2, Vec2, f32) {
if is_dragged {
(
CARD_SHADOW_OFFSET_DRAG,
CARD_SHADOW_PADDING_DRAG,
CARD_SHADOW_ALPHA_DRAG,
)
} else {
(
CARD_SHADOW_OFFSET_IDLE,
CARD_SHADOW_PADDING_IDLE,
CARD_SHADOW_ALPHA_IDLE,
)
}
}
/// Builds the `Sprite` used for a per-card shadow at the resting state. The
/// alpha and size both use the idle tokens; `update_card_shadows_on_drag`
/// retunes them at runtime when the parent card joins / leaves the dragged
/// stack.
fn card_shadow_sprite(card_size: Vec2) -> Sprite {
let (_offset, padding, alpha) = card_shadow_params(false);
Sprite {
color: CARD_SHADOW_COLOR.with_alpha(alpha),
custom_size: Some(card_size + padding),
..default()
}
}
/// Builds the `Transform` used for a per-card shadow at the resting state.
/// Local — it is parented to the card entity, so positions are relative.
fn card_shadow_transform() -> Transform {
let (offset, _padding, _alpha) = card_shadow_params(false);
Transform::from_xyz(offset.x, offset.y, CARD_SHADOW_LOCAL_Z)
}
/// Spawns a single `CardShadow` child under the given card entity builder.
/// Extracted so `spawn_card_entity` and `update_card_entity` can share the
/// exact same shadow recipe — we never want one path to drift from the other.
fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
parent.spawn((
CardShadow,
card_shadow_sprite(card_size),
card_shadow_transform(),
Visibility::default(),
));
}
/// Throttle interval for resize-driven card snap work, in seconds. /// Throttle interval for resize-driven card snap work, in seconds.
/// ///
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can /// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
@@ -228,12 +325,14 @@ impl Plugin for CardPlugin {
start_flip_anim.after(GameMutation), start_flip_anim.after(GameMutation),
tick_flip_anim, tick_flip_anim,
update_drag_shadow, update_drag_shadow,
update_card_shadows_on_drag.after(sync_cards_on_change),
tick_hint_highlight, tick_hint_highlight,
handle_right_click, handle_right_click,
tick_right_click_highlights, tick_right_click_highlights,
clear_right_click_highlights_on_state_change.after(GameMutation), clear_right_click_highlights_on_state_change.after(GameMutation),
clear_right_click_highlights_on_pause, clear_right_click_highlights_on_pause,
update_stock_empty_indicator.after(GameMutation), update_stock_empty_indicator.after(GameMutation),
update_stock_count_badge.after(GameMutation),
collect_resize_events.after(LayoutSystem::UpdateOnResize), collect_resize_events.after(LayoutSystem::UpdateOnResize),
snap_cards_on_window_resize.after(collect_resize_events), snap_cards_on_window_resize.after(collect_resize_events),
), ),
@@ -352,7 +451,7 @@ fn sync_cards_startup(
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>, slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform)>, entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
card_images: Option<Res<CardImageSet>>, card_images: Option<Res<CardImageSet>>,
) { ) {
if let Some(layout) = layout { if let Some(layout) = layout {
@@ -372,7 +471,7 @@ fn sync_cards_on_change(
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>, slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform)>, entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
card_images: Option<Res<CardImageSet>>, card_images: Option<Res<CardImageSet>>,
) { ) {
if events.read().next().is_none() { if events.read().next().is_none() {
@@ -395,22 +494,27 @@ fn sync_cards(
slide_secs: f32, slide_secs: f32,
back_colour: Color, back_colour: Color,
color_blind: bool, color_blind: bool,
entities: &Query<(Entity, &CardEntity, &Transform)>, entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
card_images: Option<&CardImageSet>, card_images: Option<&CardImageSet>,
selected_back: usize, selected_back: usize,
) { ) {
let positions = card_positions(game, layout); let positions = card_positions(game, layout);
// Map card_id -> (Entity, current_translation) for in-place updates. // Map card_id -> (Entity, current_translation, has_card_animation) for
let mut existing: HashMap<u32, (Entity, Vec3)> = HashMap::new(); // in-place updates. The `has_card_animation` flag lets `update_card_entity`
for (entity, marker, transform) in entities.iter() { // skip the snap/slide path on cards that are already being driven by a
existing.insert(marker.card_id, (entity, transform.translation)); // curve-based `CardAnimation` tween (e.g. the drag-rejection return tween
// — see `input_plugin::end_drag`). Otherwise the StateChangedEvent that
// accompanies a rejection would race the tween and the card would jump.
let mut existing: HashMap<u32, (Entity, Vec3, bool)> = HashMap::new();
for (entity, marker, transform, anim) in entities.iter() {
existing.insert(marker.card_id, (entity, transform.translation, anim.is_some()));
} }
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect(); let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
// Despawn any entity whose card is no longer tracked. // Despawn any entity whose card is no longer tracked.
for (card_id, (entity, _)) in &existing { for (card_id, (entity, _, _)) in &existing {
if !live_ids.contains(card_id) { if !live_ids.contains(card_id) {
commands.entity(*entity).despawn(); commands.entity(*entity).despawn();
} }
@@ -419,10 +523,10 @@ fn sync_cards(
// For each card in the current state: spawn or update its entity. // For each card in the current state: spawn or update its entity.
for (card, position, z) in positions { for (card, position, z) in positions {
match existing.get(&card.id) { match existing.get(&card.id) {
Some(&(entity, cur)) => { Some(&(entity, cur, has_anim)) => {
update_card_entity( update_card_entity(
&mut commands, entity, card, position, z, layout, &mut commands, entity, card, position, z, layout,
slide_secs, back_colour, color_blind, cur, card_images, selected_back, slide_secs, back_colour, color_blind, cur, has_anim, card_images, selected_back,
) )
} }
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back), None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back),
@@ -436,10 +540,10 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
let piles = [ let piles = [
PileType::Stock, PileType::Stock,
PileType::Waste, PileType::Waste,
PileType::Foundation(Suit::Clubs), PileType::Foundation(0),
PileType::Foundation(Suit::Diamonds), PileType::Foundation(1),
PileType::Foundation(Suit::Hearts), PileType::Foundation(2),
PileType::Foundation(Suit::Spades), PileType::Foundation(3),
PileType::Tableau(0), PileType::Tableau(0),
PileType::Tableau(1), PileType::Tableau(1),
PileType::Tableau(2), PileType::Tableau(2),
@@ -534,6 +638,13 @@ fn spawn_card_entity(
Transform::from_xyz(pos.x, pos.y, z), Transform::from_xyz(pos.x, pos.y, z),
Visibility::default(), Visibility::default(),
)); ));
// Every card gets a subtle drop-shadow child so the play surface reads
// as physical instead of flat. Spawned in idle state; the drag-tracking
// system retunes its offset / alpha when this card joins the dragged
// stack.
entity.with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
// When PNG faces are loaded the rank/suit are baked into the image. // When PNG faces are loaded the rank/suit are baked into the image.
// Only spawn the Text2d overlay in the solid-colour fallback (tests). // Only spawn the Text2d overlay in the solid-colour fallback (tests).
if card_images.is_none() { if card_images.is_none() {
@@ -565,6 +676,7 @@ fn update_card_entity(
back_colour: Color, back_colour: Color,
color_blind: bool, color_blind: bool,
cur: Vec3, cur: Vec3,
has_card_animation: bool,
card_images: Option<&CardImageSet>, card_images: Option<&CardImageSet>,
selected_back: usize, selected_back: usize,
) { ) {
@@ -573,6 +685,12 @@ fn update_card_entity(
// Always refresh the visual appearance. // Always refresh the visual appearance.
commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back)); commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
// 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. // Slide to the new position when it differs meaningfully; snap otherwise.
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 { if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
@@ -592,11 +710,15 @@ fn update_card_entity(
.remove::<CardAnim>() .remove::<CardAnim>()
.insert(Transform::from_xyz(pos.x, pos.y, z)); .insert(Transform::from_xyz(pos.x, pos.y, z));
} }
}
// Despawn any stale children and re-add the label overlay only when // Despawn any stale children and re-add the per-card drop shadow plus,
// operating in solid-colour mode (no PNG faces). In image mode the // in solid-colour fallback mode, the label overlay. In image mode the
// rank/suit are baked into the PNG, so no Text2d overlay is needed. // rank/suit are baked into the PNG, so no `Text2d` overlay is needed.
commands.entity(entity).despawn_related::<Children>(); commands.entity(entity).despawn_related::<Children>();
commands.entity(entity).with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
if card_images.is_none() { if card_images.is_none() {
commands.entity(entity).with_children(|b| { commands.entity(entity).with_children(|b| {
b.spawn(( b.spawn((
@@ -795,6 +917,43 @@ fn update_drag_shadow(
} }
} }
/// Snaps every per-card [`CardShadow`] between its idle and lifted tunings
/// based on whether the parent [`CardEntity`] is currently in
/// [`DragState::cards`]. Runs every frame; the transition is an instant snap
/// (no lerp) — the existing shake / settle feedback already handles motion
/// at drag-end, so an additional shadow tween would compete with those cues.
///
/// The shadow size is rebuilt from the parent card's current `Sprite`
/// `custom_size` plus the appropriate padding, so the resize handler does
/// not need to pre-tune shadow sizes for the drag state — this system fixes
/// the geometry within one frame.
fn update_card_shadows_on_drag(
drag: Res<DragState>,
cards: Query<(&CardEntity, &Sprite, &Children), Without<CardShadow>>,
mut shadows: Query<(&mut Sprite, &mut Transform), With<CardShadow>>,
) {
let dragged: HashSet<u32> = drag.cards.iter().copied().collect();
for (card_entity, card_sprite, children) in cards.iter() {
let is_dragged = dragged.contains(&card_entity.card_id);
let (offset, padding, alpha) = card_shadow_params(is_dragged);
let Some(card_size) = card_sprite.custom_size else {
continue;
};
for child in children.iter() {
let Ok((mut shadow_sprite, mut shadow_transform)) = shadows.get_mut(child) else {
continue;
};
shadow_sprite.color = CARD_SHADOW_COLOR.with_alpha(alpha);
shadow_sprite.custom_size = Some(card_size + padding);
shadow_transform.translation.x = offset.x;
shadow_transform.translation.y = offset.y;
shadow_transform.translation.z = CARD_SHADOW_LOCAL_Z;
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #28 — Hint highlight tick system // Task #28 — Hint highlight tick system
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -985,8 +1144,8 @@ fn handle_right_click(
let pile_type = &pile_marker.0; let pile_type = &pile_marker.0;
let Some(pile) = game.0.piles.get(pile_type) else { continue }; let Some(pile) = game.0.piles.get(pile_type) else { continue };
let legal = match pile_type { let legal = match pile_type {
PileType::Foundation(suit) => { PileType::Foundation(_) => {
can_place_on_foundation(&card, pile, *suit) can_place_on_foundation(&card, pile)
} }
PileType::Tableau(_) => can_place_on_tableau(&card, pile), PileType::Tableau(_) => can_place_on_tableau(&card, pile),
_ => false, _ => false,
@@ -1159,6 +1318,159 @@ fn update_stock_empty_indicator(
); );
} }
// ---------------------------------------------------------------------------
// Stock-pile remaining-count badge
//
// Shows a small "·N" chip pinned to the top-right corner of the stock pile so
// the player can see how many cards remain before the next recycle. The
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
// the badge hides itself when the stock has zero cards — the two indicators
// never render at the same time.
// ---------------------------------------------------------------------------
/// Inset (in pixels) from the top-right corner of the stock pile sprite to
/// the centre of the count badge. A small inward offset keeps the chip from
/// drifting half-off the card while still reading as "attached" to the
/// corner.
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
/// Width / height of the badge background sprite, in world pixels. Sized so
/// a 2-digit count (max "24") fits comfortably with `TYPE_CAPTION` text.
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(28.0, 16.0);
/// Returns the count of cards currently in the stock pile.
///
/// Pure helper extracted so the count source is identical between the spawn
/// system, the update system, and the unit tests.
fn stock_card_count(game: &GameState) -> usize {
game.piles
.get(&PileType::Stock)
.map_or(0, |p| p.cards.len())
}
/// Returns the world-space `Vec3` for the centre of the stock-count badge,
/// given the current `Layout`. The badge sits at the top-right corner of
/// the stock pile sprite, inset by [`STOCK_BADGE_INSET`].
fn stock_badge_translation(layout: &Layout) -> Vec3 {
// Empty layouts don't contain a Stock entry — fall back to origin so
// the badge stays in a deterministic spot until the layout is filled.
let pile_pos = layout
.pile_positions
.get(&PileType::Stock)
.copied()
.unwrap_or(Vec2::ZERO);
let half = layout.card_size * 0.5;
let x = pile_pos.x + half.x + STOCK_BADGE_INSET.x;
let y = pile_pos.y + half.y + STOCK_BADGE_INSET.y;
Vec3::new(x, y, Z_STOCK_BADGE)
}
/// Spawns the stock-count badge entity (background sprite + child text)
/// into the world. Called once, when the badge does not yet exist.
fn spawn_stock_count_badge(
commands: &mut Commands,
layout: &Layout,
font: Option<&Handle<Font>>,
count: usize,
) {
let translation = stock_badge_translation(layout);
let visibility = if count == 0 {
Visibility::Hidden
} else {
Visibility::Inherited
};
let text_font = TextFont {
font: font.cloned().unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
commands
.spawn((
StockCountBadge,
Sprite {
color: STOCK_BADGE_BG,
custom_size: Some(STOCK_BADGE_SIZE),
..default()
},
Transform::from_translation(translation),
visibility,
))
.with_children(|b| {
b.spawn((
StockCountBadgeText,
Text2d::new(format!("·{count}")),
text_font,
TextColor(STOCK_BADGE_FG),
// Slightly above the chip background so the digits aren't
// occluded by the sprite they sit on.
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
}
/// Spawns the stock-pile remaining-count badge if it does not yet exist,
/// and otherwise updates its text and visibility in place.
///
/// Visibility rule: hidden when the stock is empty (the existing `↺`
/// `StockEmptyLabel` overlay covers that state), shown when one or more
/// cards remain.
///
/// Position is recomputed from `LayoutResource` every tick so the badge
/// follows the stock pile across `WindowResized` layout updates without
/// needing a dedicated resize handler.
#[allow(clippy::too_many_arguments)]
fn update_stock_count_badge(
mut commands: Commands,
game: Option<Res<GameStateResource>>,
layout: Option<Res<LayoutResource>>,
font: Option<Res<FontResource>>,
mut badges: Query<(Entity, &mut Transform, &mut Visibility), With<StockCountBadge>>,
children: Query<&Children, With<StockCountBadge>>,
mut texts: Query<&mut Text2d, With<StockCountBadgeText>>,
) {
let Some(game) = game else { return };
let Some(layout) = layout else { return };
let count = stock_card_count(&game.0);
let translation = stock_badge_translation(&layout.0);
let target_visibility = if count == 0 {
Visibility::Hidden
} else {
Visibility::Inherited
};
if badges.is_empty() {
spawn_stock_count_badge(
&mut commands,
&layout.0,
font.as_ref().map(|f| &f.0),
count,
);
return;
}
for (entity, mut transform, mut visibility) in badges.iter_mut() {
transform.translation = translation;
if *visibility != target_visibility {
*visibility = target_visibility;
}
// Update the child text to reflect the latest count. The text node
// is created at spawn time, so under normal operation we always
// have exactly one child here.
if let Ok(badge_children) = children.get(entity) {
for child in badge_children.iter() {
if let Ok(mut text) = texts.get_mut(child) {
let new = format!("·{count}");
if text.0 != new {
text.0 = new;
}
}
}
}
}
}
/// Coalesces every `WindowResized` event arriving this frame into the latest /// Coalesces every `WindowResized` event arriving this frame into the latest
/// pending size on [`ResizeThrottle`]. /// pending size on [`ResizeThrottle`].
/// ///
@@ -1204,7 +1516,7 @@ fn collect_resize_events(
/// Scheduled after [`collect_resize_events`] (which itself runs after /// Scheduled after [`collect_resize_events`] (which itself runs after
/// `LayoutSystem::UpdateOnResize`) so `LayoutResource` reflects the latest /// `LayoutSystem::UpdateOnResize`) so `LayoutResource` reflects the latest
/// window size before we read it. /// window size before we read it.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn snap_cards_on_window_resize( fn snap_cards_on_window_resize(
mut commands: Commands, mut commands: Commands,
time: Res<Time>, time: Res<Time>,
@@ -1212,9 +1524,16 @@ fn snap_cards_on_window_resize(
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
card_images: Option<Res<CardImageSet>>, card_images: Option<Res<CardImageSet>>,
entities: Query<(Entity, &CardEntity, &mut Sprite, &mut Transform), Without<CardLabel>>, entities: Query<
(Entity, &CardEntity, &mut Sprite, &mut Transform),
(Without<CardLabel>, Without<CardShadow>),
>,
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>, label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite), Without<CardEntity>>, shadow_query: Query<&mut Sprite, (With<CardShadow>, Without<CardEntity>, Without<PileMarker>)>,
mut pile_markers: Query<
(Entity, &PileMarker, &mut Sprite),
(Without<CardEntity>, Without<CardShadow>),
>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>, label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) { ) {
if throttle.pending.is_none() { if throttle.pending.is_none() {
@@ -1242,6 +1561,7 @@ fn snap_cards_on_window_resize(
card_images.as_deref(), card_images.as_deref(),
entities, entities,
label_query, label_query,
shadow_query,
); );
apply_stock_empty_indicator( apply_stock_empty_indicator(
@@ -1268,13 +1588,21 @@ fn snap_cards_on_window_resize(
/// ///
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not /// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
/// retargeted relative to the previous card-size's position. /// retargeted relative to the previous card-size's position.
#[allow(clippy::type_complexity)]
fn resize_cards_in_place( fn resize_cards_in_place(
commands: &mut Commands, commands: &mut Commands,
game: &GameState, game: &GameState,
layout: &Layout, layout: &Layout,
card_images: Option<&CardImageSet>, card_images: Option<&CardImageSet>,
mut entities: Query<(Entity, &CardEntity, &mut Sprite, &mut Transform), Without<CardLabel>>, mut entities: Query<
(Entity, &CardEntity, &mut Sprite, &mut Transform),
(Without<CardLabel>, Without<CardShadow>),
>,
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>, mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
mut shadow_query: Query<
&mut Sprite,
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>),
>,
) { ) {
let positions = card_positions(game, layout); let positions = card_positions(game, layout);
let pos_by_id: HashMap<u32, (Vec2, f32)> = positions let pos_by_id: HashMap<u32, (Vec2, f32)> = positions
@@ -1295,6 +1623,27 @@ fn resize_cards_in_place(
commands.entity(entity).remove::<CardAnim>(); commands.entity(entity).remove::<CardAnim>();
} }
// Resize every per-card shadow halo to match the new card size. Both
// idle and drag states scale with the card body, so we preserve the
// *current* padding (idle vs drag) by keeping the alpha as-is and only
// recomputing the geometry. The drag-tracking system runs every frame
// and will retune offset / alpha / padding-mode within one frame if the
// drag state diverges from the resized geometry.
let idle_padding = CARD_SHADOW_PADDING_IDLE;
let drag_padding = CARD_SHADOW_PADDING_DRAG;
for mut shadow_sprite in shadow_query.iter_mut() {
// Choose padding based on the shadow's current alpha — preserves
// a lifted shadow's larger halo across resize without needing to
// plumb DragState through the resize handler.
let alpha = shadow_sprite.color.alpha();
let padding = if alpha >= CARD_SHADOW_ALPHA_DRAG - 0.001 {
drag_padding
} else {
idle_padding
};
shadow_sprite.custom_size = Some(layout.card_size + padding);
}
// Only the solid-colour fallback path uses CardLabel/Text2d overlays; // Only the solid-colour fallback path uses CardLabel/Text2d overlays;
// when PNG faces are loaded the rank/suit are baked into the image and // when PNG faces are loaded the rank/suit are baked into the image and
// there is nothing to resize on the label side. // there is nothing to resize on the label side.
@@ -1926,4 +2275,271 @@ mod tests {
(got {after}, expected {expected})" (got {after}, expected {expected})"
); );
} }
// -----------------------------------------------------------------------
// Per-card drop-shadow — pure helper + spawn / drag-snap regressions.
// -----------------------------------------------------------------------
/// `card_shadow_params(false)` returns the IDLE token triple.
#[test]
fn card_shadow_params_idle_returns_idle_tokens() {
let (offset, padding, alpha) = card_shadow_params(false);
assert_eq!(offset, CARD_SHADOW_OFFSET_IDLE);
assert_eq!(padding, CARD_SHADOW_PADDING_IDLE);
assert!((alpha - CARD_SHADOW_ALPHA_IDLE).abs() < f32::EPSILON);
}
/// `card_shadow_params(true)` returns the DRAG token triple, and each
/// drag value differs from its idle counterpart so the player visibly
/// sees the lift.
#[test]
fn card_shadow_params_drag_returns_drag_tokens_and_differs_from_idle() {
let (idle_offset, idle_padding, idle_alpha) = card_shadow_params(false);
let (drag_offset, drag_padding, drag_alpha) = card_shadow_params(true);
assert_eq!(drag_offset, CARD_SHADOW_OFFSET_DRAG);
assert_eq!(drag_padding, CARD_SHADOW_PADDING_DRAG);
assert!((drag_alpha - CARD_SHADOW_ALPHA_DRAG).abs() < f32::EPSILON);
assert_ne!(idle_offset, drag_offset, "drag offset must differ from idle");
assert_ne!(idle_padding, drag_padding, "drag padding must differ from idle");
assert!(
drag_alpha > idle_alpha,
"drag alpha must be stronger than idle (got drag={drag_alpha}, idle={idle_alpha})"
);
// Drag offset magnitude should be larger than idle so the parallax
// reads as "lifted".
assert!(
drag_offset.length() > idle_offset.length(),
"drag offset magnitude ({}) must exceed idle ({}) so the lift is visible",
drag_offset.length(),
idle_offset.length(),
);
}
/// Every spawned `CardEntity` owns exactly one `CardShadow` child.
/// Total counts must match: 52 cards → 52 shadows.
#[test]
fn cards_spawn_with_shadow_child() {
let mut app = app();
let card_count = app
.world_mut()
.query::<&CardEntity>()
.iter(app.world())
.count();
assert_eq!(card_count, 52, "fixture should spawn 52 cards");
let shadow_count = app
.world_mut()
.query::<&CardShadow>()
.iter(app.world())
.count();
assert_eq!(
shadow_count, 52,
"every CardEntity must own exactly one CardShadow child (got {shadow_count})"
);
// Each shadow's parent must be a CardEntity, so the child relation
// is wired correctly.
let cards: HashSet<bevy::prelude::Entity> = app
.world_mut()
.query_filtered::<bevy::prelude::Entity, With<CardEntity>>()
.iter(app.world())
.collect();
let mut q = app
.world_mut()
.query_filtered::<&ChildOf, With<CardShadow>>();
for parent in q.iter(app.world()) {
assert!(
cards.contains(&parent.parent()),
"CardShadow parent {:?} is not a CardEntity",
parent.parent()
);
}
}
/// Driving `DragState.cards` with a card id and ticking the app must
/// move that card's shadow to the lifted offset and alpha; cards
/// outside the dragged set keep the idle tuning.
#[test]
fn shadow_offset_increases_during_drag() {
let mut app = app();
// Pick any spawned card id and stage it in DragState.
let card_id: u32 = {
let mut q = app.world_mut().query::<&CardEntity>();
q.iter(app.world())
.next()
.expect("fixture should spawn at least one CardEntity")
.card_id
};
// Pick a *different* card id to act as the negative control —
// its shadow must remain at the idle offset.
let other_id: u32 = {
let mut q = app.world_mut().query::<&CardEntity>();
q.iter(app.world())
.map(|c| c.card_id)
.find(|id| *id != card_id)
.expect("fixture should spawn more than one CardEntity")
};
// Stage the drag and run one Update so `update_card_shadows_on_drag`
// sees the new DragState.
app.world_mut().resource_mut::<DragState>().cards = vec![card_id];
app.update();
// Find the shadow whose parent's CardEntity matches `card_id`.
let dragged_shadow_offset = shadow_offset_for_card(&mut app, card_id);
let other_shadow_offset = shadow_offset_for_card(&mut app, other_id);
let drag_off = CARD_SHADOW_OFFSET_DRAG;
let idle_off = CARD_SHADOW_OFFSET_IDLE;
assert!(
(dragged_shadow_offset.x - drag_off.x).abs() < 1e-3
&& (dragged_shadow_offset.y - drag_off.y).abs() < 1e-3,
"dragged shadow offset should match CARD_SHADOW_OFFSET_DRAG \
(got {dragged_shadow_offset:?}, expected {drag_off:?})"
);
assert!(
(other_shadow_offset.x - idle_off.x).abs() < 1e-3
&& (other_shadow_offset.y - idle_off.y).abs() < 1e-3,
"non-dragged shadow offset should remain at CARD_SHADOW_OFFSET_IDLE \
(got {other_shadow_offset:?}, expected {idle_off:?})"
);
// Sanity-check: clearing the drag returns the shadow to the idle
// offset on the next frame.
app.world_mut().resource_mut::<DragState>().clear();
app.update();
let after_clear = shadow_offset_for_card(&mut app, card_id);
assert!(
(after_clear.x - idle_off.x).abs() < 1e-3
&& (after_clear.y - idle_off.y).abs() < 1e-3,
"shadow must snap back to idle offset after drag clears \
(got {after_clear:?}, expected {idle_off:?})"
);
}
/// Helper: given a `card_id`, returns the world-space offset (x, y) of
/// its `CardShadow` child relative to the parent card's origin.
fn shadow_offset_for_card(app: &mut App, card_id: u32) -> Vec2 {
// Map every CardEntity to its (Entity, card_id).
let card_entity = {
let mut q = app
.world_mut()
.query::<(bevy::prelude::Entity, &CardEntity)>();
q.iter(app.world())
.find(|(_, c)| c.card_id == card_id)
.map(|(e, _)| e)
.expect("card_id not found in spawned CardEntity set")
};
let mut q = app
.world_mut()
.query_filtered::<(&ChildOf, &Transform), With<CardShadow>>();
for (parent, transform) in q.iter(app.world()) {
if parent.parent() == card_entity {
return Vec2::new(transform.translation.x, transform.translation.y);
}
}
panic!("no CardShadow child found for card_id {card_id}");
}
// -----------------------------------------------------------------------
// Stock-pile remaining-count badge tests
// -----------------------------------------------------------------------
/// Reads the current `Text2d` payload of the single `StockCountBadgeText`
/// in the world, panicking if zero or more than one are spawned.
fn stock_badge_text(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Text2d, With<StockCountBadgeText>>();
let texts: Vec<String> = q.iter(app.world()).map(|t| t.0.clone()).collect();
assert_eq!(
texts.len(),
1,
"expected exactly one StockCountBadgeText, got {}",
texts.len()
);
texts.into_iter().next().unwrap()
}
/// Reads the `Visibility` of the single `StockCountBadge` background sprite.
fn stock_badge_visibility(app: &mut App) -> Visibility {
let mut q = app
.world_mut()
.query_filtered::<&Visibility, With<StockCountBadge>>();
let vs: Vec<Visibility> = q.iter(app.world()).copied().collect();
assert_eq!(
vs.len(),
1,
"expected exactly one StockCountBadge entity, got {}",
vs.len()
);
vs.into_iter().next().unwrap()
}
#[test]
fn stock_badge_shows_count_after_startup() {
// Fresh Klondike (DrawOne) deals 24 face-down cards into stock — the
// canonical starting count. After the first `app.update()` the badge
// must exist and read "·24".
let mut app = app();
// First update inside `app()` runs the spawn path; run one more to
// confirm the in-place update path is also stable.
app.update();
assert_eq!(stock_badge_text(&mut app), "·24");
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
}
#[test]
fn stock_badge_hides_when_stock_empty() {
// Drain the stock pile to zero cards and assert the badge becomes
// hidden, leaving the existing `↺` `StockEmptyLabel` overlay as the
// sole indicator (the two never render simultaneously).
let mut app = app();
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
stock.cards.clear();
}
}
app.update();
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Hidden));
}
#[test]
fn stock_badge_updates_when_stock_count_changes() {
// Mutate the stock pile so it holds 23 cards (one fewer than the
// initial 24) and assert the badge text follows.
let mut app = app();
// Sanity-check the starting count.
assert_eq!(stock_badge_text(&mut app), "·24");
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
let _ = stock.cards.pop();
}
}
app.update();
assert_eq!(stock_badge_text(&mut app), "·23");
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
}
#[test]
fn stock_card_count_helper_reads_zero_when_pile_missing() {
// If the stock pile entry is somehow absent (defensive path), the
// helper must return 0 rather than panicking — the badge then
// renders as hidden via the count-zero branch in the update system.
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let mut g_no_stock = g.clone();
g_no_stock.piles.remove(&PileType::Stock);
assert_eq!(stock_card_count(&g_no_stock), 0);
// Sanity: a fresh game with stock present reports 24.
assert_eq!(stock_card_count(&g), 24);
}
} }
+398 -9
View File
@@ -10,10 +10,20 @@
//! - **Green** if the dragged stack can legally land there. //! - **Green** if the dragged stack can legally land there.
//! - **Default** (nearly transparent white) otherwise. //! - **Default** (nearly transparent white) otherwise.
//! The tint is cleared to default the frame the drag ends. //! The tint is cleared to default the frame the drag ends.
//!
//! **Drop-target overlays** (`update_drop_target_overlays`)
//! Pile markers sit *behind* the card stack, so on a tableau column with
//! any cards on it the green tint applied above is fully occluded. To
//! make legal targets unmistakable mid-drag, this system spawns a
//! translucent green rectangle plus four outline edges over every legal
//! destination pile. For tableau columns the overlay covers the full
//! visible fan (matching `input_plugin::pile_drop_rect`); for
//! foundations and empty tableaux it is card-sized. Overlays are
//! despawned the frame the drag ends or whenever the legal-target set
//! changes.
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::card::Suit;
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
@@ -22,6 +32,9 @@ use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC};
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
use crate::resources::{DragState, GameStateResource}; use crate::resources::{DragState, GameStateResource};
use crate::table_plugin::PileMarker; use crate::table_plugin::PileMarker;
use crate::ui_theme::{
DROP_TARGET_FILL, DROP_TARGET_OUTLINE, DROP_TARGET_OUTLINE_PX, Z_DROP_OVERLAY,
};
/// Semi-transparent white that `table_plugin` uses for idle pile markers. /// Semi-transparent white that `table_plugin` uses for idle pile markers.
/// Kept in sync with the `marker_colour` constant there. /// Kept in sync with the `marker_colour` constant there.
@@ -30,12 +43,26 @@ const MARKER_DEFAULT: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Green tint applied to pile markers that are valid drop targets during drag. /// Green tint applied to pile markers that are valid drop targets during drag.
const MARKER_VALID: Color = Color::srgba(0.15, 0.85, 0.25, 0.55); const MARKER_VALID: Color = Color::srgba(0.15, 0.85, 0.25, 0.55);
/// Marker component on a parent entity that owns one drop-target overlay
/// (a translucent fill plus four outline edges as children). The wrapped
/// `PileType` identifies which pile this overlay highlights, so test
/// queries and the despawn-on-target-change logic can filter by pile.
#[derive(Component, Debug, Clone, PartialEq, Eq)]
pub struct DropTargetOverlay(pub PileType);
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress. /// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
pub struct CursorPlugin; pub struct CursorPlugin;
impl Plugin for CursorPlugin { impl Plugin for CursorPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(Update, (update_cursor_icon, update_drop_highlights)); app.add_systems(
Update,
(
update_cursor_icon,
update_drop_highlights,
update_drop_target_overlays,
),
);
} }
} }
@@ -82,10 +109,10 @@ fn update_cursor_icon(
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool { fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
let piles = [ let piles = [
PileType::Waste, PileType::Waste,
PileType::Foundation(Suit::Clubs), PileType::Foundation(0),
PileType::Foundation(Suit::Diamonds), PileType::Foundation(1),
PileType::Foundation(Suit::Hearts), PileType::Foundation(2),
PileType::Foundation(Suit::Spades), PileType::Foundation(3),
PileType::Tableau(0), PileType::Tableau(0),
PileType::Tableau(1), PileType::Tableau(1),
PileType::Tableau(2), PileType::Tableau(2),
@@ -158,12 +185,12 @@ fn update_drop_highlights(
for (marker, mut sprite, _rch) in &mut markers { for (marker, mut sprite, _rch) in &mut markers {
let valid = match &marker.0 { let valid = match &marker.0 {
PileType::Foundation(suit) => { PileType::Foundation(slot) => {
if drag_count != 1 { if drag_count != 1 {
false false
} else { } else {
let pile = game.0.piles.get(&PileType::Foundation(*suit)); let pile = game.0.piles.get(&PileType::Foundation(*slot));
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p, *suit)) pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
} }
} }
PileType::Tableau(idx) => { PileType::Tableau(idx) => {
@@ -176,6 +203,213 @@ fn update_drop_highlights(
} }
} }
// ---------------------------------------------------------------------------
// Drop-target overlay sprites — render in front of cards, unlike the pile
// markers above which sit behind the stack.
// ---------------------------------------------------------------------------
/// Spawns / despawns translucent overlay sprites over every legal drop
/// target while a drag is in progress.
///
/// The overlay is a parent `Sprite` (the soft fill) with four child
/// `Sprite`s (top, bottom, left, right edges) that together form the
/// outline. A new parent is spawned whenever a target appears in the
/// valid set; a parent is despawned (with its children) whenever its
/// pile leaves the valid set or the drag ends.
///
/// Geometry mirrors `input_plugin::pile_drop_rect` exactly so the
/// highlighted region matches the actual drop hit-box.
fn update_drop_target_overlays(
mut commands: Commands,
drag: Res<DragState>,
game: Option<Res<GameStateResource>>,
layout: Option<Res<LayoutResource>>,
overlays: Query<(Entity, &DropTargetOverlay)>,
) {
// Drag idle → despawn every existing overlay and exit.
if drag.is_idle() {
for (entity, _) in &overlays {
commands.entity(entity).despawn();
}
return;
}
let (Some(game), Some(layout)) = (game, layout) else {
return;
};
// Resolve the bottom card of the dragged stack — same logic as
// `update_drop_highlights` so rules can't drift between the marker
// tint and the overlay.
let Some(&bottom_id) = drag.cards.first() else {
return;
};
let bottom_card = game
.0
.piles
.values()
.flat_map(|p| p.cards.iter())
.find(|c| c.id == bottom_id)
.cloned();
let Some(bottom_card) = bottom_card else {
return;
};
let drag_count = drag.cards.len();
// Iterate the same pile list as `update_drop_highlights`. Stock and
// Waste are excluded because they are never legal drop targets.
let candidates = [
PileType::Foundation(0),
PileType::Foundation(1),
PileType::Foundation(2),
PileType::Foundation(3),
PileType::Tableau(0),
PileType::Tableau(1),
PileType::Tableau(2),
PileType::Tableau(3),
PileType::Tableau(4),
PileType::Tableau(5),
PileType::Tableau(6),
];
// Compute the new set of valid piles for this frame.
let mut valid: Vec<PileType> = Vec::new();
for pile in &candidates {
let is_valid = match pile {
PileType::Foundation(_) => {
if drag_count != 1 {
false
} else {
game.0
.piles
.get(pile)
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
}
}
PileType::Tableau(_) => game
.0
.piles
.get(pile)
.is_some_and(|p| can_place_on_tableau(&bottom_card, p)),
_ => false,
};
// Don't highlight the origin pile — dropping onto the source is
// a no-op.
if is_valid && drag.origin_pile.as_ref() != Some(pile) {
valid.push(pile.clone());
}
}
// Despawn overlays whose pile is no longer valid.
for (entity, marker) in &overlays {
if !valid.contains(&marker.0) {
commands.entity(entity).despawn();
}
}
// Spawn overlays for piles that are now valid but don't yet have one.
let already_overlaid: Vec<PileType> = overlays
.iter()
.map(|(_, m)| m.0.clone())
.filter(|p| valid.contains(p))
.collect();
for pile in valid {
if already_overlaid.contains(&pile) {
continue;
}
spawn_drop_target_overlay(&mut commands, &pile, &layout.0, &game.0);
}
}
/// Computes the `(centre, size)` of the drop-target overlay for a pile.
///
/// Mirrors `input_plugin::pile_drop_rect` — for tableau columns with two
/// or more cards the rectangle extends downward to cover the full fan;
/// for everything else it is card-sized. Replicated here rather than
/// imported because `pile_drop_rect` is private to `input_plugin` and
/// this overlay is the only other consumer.
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2, Vec2) {
let centre = layout.pile_positions[pile];
if matches!(pile, PileType::Tableau(_)) {
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
if card_count > 1 {
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
let top_edge = centre.y + layout.card_size.y / 2.0;
let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0;
let span_height = top_edge - bottom_edge;
let new_centre_y = (top_edge + bottom_edge) / 2.0;
return (
Vec2::new(centre.x, new_centre_y),
Vec2::new(layout.card_size.x, span_height),
);
}
}
(centre, layout.card_size)
}
/// Spawns one overlay parent (fill) plus four edge sprites (outline) at
/// the appropriate world position for `pile`.
fn spawn_drop_target_overlay(
commands: &mut Commands,
pile: &PileType,
layout: &Layout,
game: &GameState,
) {
let (centre, size) = drop_overlay_rect(pile, layout, game);
let edge = DROP_TARGET_OUTLINE_PX;
commands
.spawn((
Sprite {
color: DROP_TARGET_FILL,
custom_size: Some(size),
..default()
},
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
DropTargetOverlay(pile.clone()),
))
.with_children(|parent| {
// Top edge.
parent.spawn((
Sprite {
color: DROP_TARGET_OUTLINE,
custom_size: Some(Vec2::new(size.x, edge)),
..default()
},
Transform::from_xyz(0.0, size.y / 2.0 - edge / 2.0, 0.01),
));
// Bottom edge.
parent.spawn((
Sprite {
color: DROP_TARGET_OUTLINE,
custom_size: Some(Vec2::new(size.x, edge)),
..default()
},
Transform::from_xyz(0.0, -size.y / 2.0 + edge / 2.0, 0.01),
));
// Left edge.
parent.spawn((
Sprite {
color: DROP_TARGET_OUTLINE,
custom_size: Some(Vec2::new(edge, size.y)),
..default()
},
Transform::from_xyz(-size.x / 2.0 + edge / 2.0, 0.0, 0.01),
));
// Right edge.
parent.spawn((
Sprite {
color: DROP_TARGET_OUTLINE,
custom_size: Some(Vec2::new(edge, size.y)),
..default()
},
Transform::from_xyz(size.x / 2.0 - edge / 2.0, 0.0, 0.01),
));
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared helpers // Shared helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -258,4 +492,159 @@ mod tests {
// A cursor far off-screen should never hit anything. // A cursor far off-screen should never hit anything.
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout)); assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
} }
// -----------------------------------------------------------------------
// Drop-target overlay tests
// -----------------------------------------------------------------------
use crate::layout::compute_layout;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
/// Builds an `App` with `MinimalPlugins` and the overlay system
/// registered, plus the resources the system needs. Callers
/// customise `GameStateResource` and `DragState` after construction.
fn overlay_test_app(game: GameState) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(GameStateResource(game))
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0))))
.insert_resource(DragState::default())
.add_systems(Update, update_drop_target_overlays);
app
}
/// Replaces the top card of a tableau pile with a fresh face-up
/// card. Used to make a specific tableau column accept a chosen
/// drag stack.
fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) {
let pile = game
.piles
.get_mut(&PileType::Tableau(idx))
.expect("tableau pile exists");
pile.cards.clear();
pile.cards.push(card);
}
/// Inserts a single face-up dragged card into the waste pile and
/// configures `DragState` so the overlay system treats it as the
/// active drag.
fn begin_drag_with(app: &mut App, dragged: Card) {
// Place the dragged card on the waste pile (origin).
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
let waste = game
.0
.piles
.get_mut(&PileType::Waste)
.expect("waste pile exists");
waste.cards.clear();
waste.cards.push(dragged.clone());
}
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![dragged.id];
drag.origin_pile = Some(PileType::Waste);
drag.committed = true;
}
#[test]
fn drop_target_overlay_spawns_for_valid_tableau_during_drag() {
// 5 of Hearts (red, rank 5) on top of Tableau(2)'s 6 of Spades
// (black, rank 6) — alternating colour, one rank lower → legal.
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
set_tableau_top(
&mut game,
2,
Card { id: 9001, suit: Suit::Spades, rank: Rank::Six, face_up: true },
);
let dragged = Card { id: 9002, suit: Suit::Hearts, rank: Rank::Five, face_up: true };
let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
app.update();
let overlays: Vec<PileType> = app
.world_mut()
.query::<&DropTargetOverlay>()
.iter(app.world())
.map(|o| o.0.clone())
.collect();
assert!(
overlays.contains(&PileType::Tableau(2)),
"expected Tableau(2) to be highlighted as a legal drop target, got {overlays:?}"
);
}
#[test]
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
// — same colour family, illegal. Tableau(2) must NOT be
// highlighted.
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
set_tableau_top(
&mut game,
2,
Card { id: 9101, suit: Suit::Clubs, rank: Rank::Six, face_up: true },
);
let dragged = Card { id: 9102, suit: Suit::Spades, rank: Rank::Five, face_up: true };
let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
app.update();
let overlays: Vec<PileType> = app
.world_mut()
.query::<&DropTargetOverlay>()
.iter(app.world())
.map(|o| o.0.clone())
.collect();
assert!(
!overlays.contains(&PileType::Tableau(2)),
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
);
}
#[test]
fn drop_target_overlays_despawn_on_drag_end() {
// Set up a scenario that produces at least one valid overlay,
// confirm it spawns, then clear the drag and confirm every
// overlay is despawned.
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
set_tableau_top(
&mut game,
2,
Card { id: 9201, suit: Suit::Spades, rank: Rank::Six, face_up: true },
);
let dragged = Card { id: 9202, suit: Suit::Hearts, rank: Rank::Five, face_up: true };
let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
app.update();
let count_during_drag = app
.world_mut()
.query::<&DropTargetOverlay>()
.iter(app.world())
.count();
assert!(
count_during_drag >= 1,
"expected ≥1 overlay during drag, got {count_during_drag}"
);
// End the drag — every overlay should despawn next frame.
app.world_mut().resource_mut::<DragState>().clear();
app.update();
let count_after_drag = app
.world_mut()
.query::<&DropTargetOverlay>()
.iter(app.world())
.count();
assert_eq!(
count_after_drag, 0,
"all overlays must despawn when the drag ends"
);
}
} }
+23
View File
@@ -1,6 +1,7 @@
//! Cross-system events used by the engine's plugins. //! Cross-system events used by the engine's plugins.
use bevy::prelude::Message; use bevy::prelude::Message;
use solitaire_core::card::Suit;
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_data::AchievementRecord; use solitaire_data::AchievementRecord;
@@ -60,6 +61,28 @@ pub struct GameWonEvent {
pub time_seconds: u64, pub time_seconds: u64,
} }
/// Fired by `GamePlugin` whenever a successful move lands a card on a
/// foundation pile that, after the move, contains all 13 cards of its
/// suit (Ace → King). Drives the per-suit completion flourish — a brief
/// scale pulse on the King card and a golden tint on the foundation
/// pile marker — plus a short audio ping.
///
/// Fired once per per-suit completion. The fourth completion will
/// co-occur with `GameWonEvent` and the win cascade — they layer
/// cleanly because the flourish is purely decorative and lives on a
/// dedicated marker component.
///
/// This event is a UI/audio cue only. It does **not** cross
/// `solitaire_sync` and is not persisted.
#[derive(Message, Debug, Clone, Copy)]
pub struct FoundationCompletedEvent {
/// Foundation pile slot (0..=3) that just reached 13 cards.
pub slot: u8,
/// The suit of the completed foundation, taken from the bottom card
/// (always an Ace by construction).
pub suit: Suit,
}
/// Fired when a card's face-up state changes during gameplay. /// Fired when a card's face-up state changes during gameplay.
#[derive(Message, Debug, Clone, Copy)] #[derive(Message, Debug, Clone, Copy)]
pub struct CardFlippedEvent(pub u32); pub struct CardFlippedEvent(pub u32);
+250 -2
View File
@@ -48,13 +48,18 @@ use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim; use crate::animation_plugin::CardAnim;
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::events::{ use crate::events::{
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameRequestEvent,
}; };
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
use crate::table_plugin::PileMarker;
use crate::ui_theme::{
FOUNDATION_FLOURISH_PEAK_SCALE, MOTION_FOUNDATION_FLOURISH_SECS, STATE_SUCCESS,
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared constants // Shared constants
@@ -185,7 +190,8 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
// Plugin // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Registers the shake, settle, and deal animation systems. /// Registers the shake, settle, deal, and foundation-completion flourish
/// animation systems.
pub struct FeedbackAnimPlugin; pub struct FeedbackAnimPlugin;
impl Plugin for FeedbackAnimPlugin { impl Plugin for FeedbackAnimPlugin {
@@ -197,6 +203,7 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<DrawRequestEvent>() .add_message::<DrawRequestEvent>()
.add_message::<MoveRejectedEvent>() .add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<FoundationCompletedEvent>()
.add_systems( .add_systems(
Update, Update,
( (
@@ -205,6 +212,8 @@ impl Plugin for FeedbackAnimPlugin {
start_settle_anim.after(GameMutation), start_settle_anim.after(GameMutation),
tick_settle_anim, tick_settle_anim,
start_deal_anim.after(GameMutation), start_deal_anim.after(GameMutation),
start_foundation_flourish.after(GameMutation),
tick_foundation_flourish,
), ),
); );
} }
@@ -401,6 +410,204 @@ fn start_deal_anim(
} }
} }
// ---------------------------------------------------------------------------
// Foundation-completion flourish
// ---------------------------------------------------------------------------
/// Drives the per-foundation completion flourish on the King card that
/// just landed on a foundation pile (Ace → King, 13 cards).
///
/// Inserted on the King's `CardEntity` when `FoundationCompletedEvent`
/// fires; removed once `elapsed >= duration`. Decorative only — does
/// not block input or interfere with the win cascade, settle, or hint
/// systems (those operate on different markers and read the same
/// `Transform.scale` coordinate non-conflictingly because the flourish
/// finishes in well under a second).
#[derive(Component, Debug, Clone, Copy)]
pub struct FoundationFlourish {
/// Foundation slot (0..=3) this flourish is celebrating.
pub foundation_slot: u8,
/// Seconds elapsed since the flourish began.
pub elapsed: f32,
/// Total animation length in seconds.
pub duration: f32,
}
/// Drives a brief golden tint on the foundation `PileMarker` whose
/// foundation just completed. Stores the marker's original colour so
/// it can be restored when the timer expires.
///
/// Inserted alongside (and concurrent with) `FoundationFlourish` on the
/// matching `PileMarker` entity. The system runs independently of the
/// existing `HintPileHighlight` so the two never share state — a hint
/// landing during a completion flourish (highly unlikely in practice
/// since the foundation just completed) won't corrupt either party's
/// `original_color` snapshot.
#[derive(Component, Debug, Clone, Copy)]
pub struct FoundationMarkerFlourish {
/// Seconds elapsed since the tint was applied.
pub elapsed: f32,
/// Total animation length in seconds.
pub duration: f32,
/// The pile marker's sprite colour before the tint was applied —
/// restored when the timer expires.
pub original_color: Color,
}
/// Pure helper for unit tests — returns the per-frame scale factor for
/// the foundation flourish at `elapsed_secs` over `duration_secs`.
///
/// Triangular curve, mirroring `score_pulse_scale` in `hud_plugin`:
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
/// [`FOUNDATION_FLOURISH_PEAK_SCALE`] (1.15), at `t = 1.0` returns
/// `1.0`. Out-of-range values are clamped so the King never freezes
/// at a non-1.0 scale on the frame after the flourish ends.
///
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
/// without dividing by zero.
pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
if duration_secs <= 0.0 {
return 1.0;
}
let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
let peak = FOUNDATION_FLOURISH_PEAK_SCALE;
if t < 0.5 {
// Climb from 1.0 at t=0 to peak at t=0.5.
1.0 + (peak - 1.0) * (t / 0.5)
} else {
// Descend from peak at t=0.5 back to 1.0 at t=1.0.
peak - (peak - 1.0) * ((t - 0.5) / 0.5)
}
}
/// Inserts `FoundationFlourish` on the King card entity at the
/// completed foundation and `FoundationMarkerFlourish` on its
/// `PileMarker`. The King is identified as the *top* card of the
/// foundation pile after the move — by definition the 13th card,
/// always rank King by foundation rules.
fn start_foundation_flourish(
mut events: MessageReader<FoundationCompletedEvent>,
game: Res<GameStateResource>,
card_entities: Query<(Entity, &CardEntity)>,
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
mut commands: Commands,
) {
for ev in events.read() {
let pile_type = PileType::Foundation(ev.slot);
// Top card of the completed foundation is the King.
let Some(king_id) = game
.0
.piles
.get(&pile_type)
.and_then(|p| p.cards.last())
.map(|c| c.id)
else {
continue;
};
// Tag the King's card entity.
for (entity, card_marker) in card_entities.iter() {
if card_marker.card_id == king_id {
commands.entity(entity).insert(FoundationFlourish {
foundation_slot: ev.slot,
elapsed: 0.0,
duration: MOTION_FOUNDATION_FLOURISH_SECS,
});
}
}
// Tint the matching PileMarker. Snapshot the current colour so
// tick_foundation_flourish can restore it; if a stale flourish
// is somehow still active, reuse its `original_color` so we
// don't capture the gold tint as the new "original".
for (entity, pile_marker, sprite, existing) in pile_markers.iter_mut() {
if pile_marker.0 != pile_type {
continue;
}
let original_color = existing.map_or(sprite.color, |f| f.original_color);
commands.entity(entity).insert(FoundationMarkerFlourish {
elapsed: 0.0,
duration: MOTION_FOUNDATION_FLOURISH_SECS,
original_color,
});
}
}
}
/// Advances both the King's scale pulse and the foundation marker's
/// gold tint each frame. Removes both components once their timers
/// expire, restoring the King's `Transform.scale` to `Vec3::ONE` and
/// the marker's sprite colour to its captured original.
///
/// Skipped while paused so a player who hits Esc mid-flourish doesn't
/// see frozen scaled state (the next unpause tick resumes from the
/// stored `elapsed`).
#[allow(clippy::type_complexity)]
fn tick_foundation_flourish(
mut commands: Commands,
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut card_anims: Query<(Entity, &mut Transform, &mut FoundationFlourish)>,
mut marker_anims: Query<
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
Without<FoundationFlourish>,
>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
// Advance the King's scale pulse.
for (entity, mut transform, mut anim) in &mut card_anims {
anim.elapsed += dt;
if anim.elapsed >= anim.duration {
// Restore identity scale so the card sits at its normal size
// for the next frame's transform sync.
transform.scale = Vec3::ONE;
commands.entity(entity).remove::<FoundationFlourish>();
} else {
let s = foundation_flourish_scale(anim.elapsed, anim.duration);
transform.scale = Vec3::new(s, s, 1.0);
}
}
// Advance the foundation marker's gold tint. Held flat for the
// first half of the duration and faded back to the original colour
// over the second half — feels celebratory without bleeding into
// the next move's drop-target highlights.
for (entity, mut sprite, mut anim) in &mut marker_anims {
anim.elapsed += dt;
if anim.elapsed >= anim.duration {
sprite.color = anim.original_color;
commands.entity(entity).remove::<FoundationMarkerFlourish>();
} else {
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
// Lerp factor: 1.0 (full tint) for the first half, then
// ramps down linearly to 0.0 (original colour) by the end.
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
sprite.color = lerp_color(anim.original_color, STATE_SUCCESS, mix);
}
}
}
/// Linear interpolation between two `Color`s in sRGB space. Pulled out
/// as a small helper so the `tick_foundation_flourish` body stays
/// readable; sRGB-space lerping is fine for a brief decorative tint
/// (a perceptually-uniform space would be overkill).
fn lerp_color(from: Color, to: Color, t: f32) -> Color {
let from = from.to_srgba();
let to = to.to_srgba();
let t = t.clamp(0.0, 1.0);
Color::srgba(
from.red + (to.red - from.red) * t,
from.green + (to.green - from.green) * t,
from.blue + (to.blue - from.blue) * t,
from.alpha + (to.alpha - from.alpha) * t,
)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Unit tests (pure functions only — no Bevy world required) // Unit tests (pure functions only — no Bevy world required)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -534,6 +741,47 @@ mod tests {
} }
} }
// Foundation-flourish curve tests
/// Triangular curve must be 1.0 at t=0, peak at t=0.5, and 1.0 at t=1.
#[test]
fn foundation_flourish_scale_curves_through_one_one_one() {
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
assert!(
(foundation_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
"flourish scale at t=0 must be 1.0"
);
assert!(
(foundation_flourish_scale(dur / 2.0, dur) - FOUNDATION_FLOURISH_PEAK_SCALE).abs() < 1e-5,
"flourish scale at midpoint must be FOUNDATION_FLOURISH_PEAK_SCALE"
);
assert!(
(foundation_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
"flourish scale at t=duration must return to 1.0"
);
}
/// Out-of-range values are clamped, not extrapolated. Important so the
/// King never ends up at a non-1.0 scale on the frame after the
/// flourish ends (which would race against the despawn / restore step
/// in `tick_foundation_flourish`).
#[test]
fn foundation_flourish_scale_clamps_out_of_range() {
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
// Negative elapsed clamps to 0 → scale 1.0.
assert!((foundation_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
// Past-end clamps to t=1 → scale 1.0.
assert!((foundation_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
}
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
/// divides by zero.
#[test]
fn foundation_flourish_scale_zero_duration_is_one() {
assert!((foundation_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
assert!((foundation_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
}
#[test] #[test]
fn deal_stagger_jitter_varies_across_card_ids() { fn deal_stagger_jitter_varies_across_card_ids() {
// 52 cards should produce more than a couple distinct jitter factors; // 52 cards should produce more than a couple distinct jitter factors;
+224 -19
View File
@@ -15,8 +15,8 @@ use solitaire_data::{delete_game_state_at, game_state_file_path, load_game_state
save_game_state_to}; save_game_state_to};
use crate::events::{ use crate::events::{
CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent, CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
}; };
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::resources::{DragState, GameStateResource, SyncStatusResource}; use crate::resources::{DragState, GameStateResource, SyncStatusResource};
@@ -86,6 +86,7 @@ impl Plugin for GamePlugin {
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<crate::events::CardFlippedEvent>() .add_message::<crate::events::CardFlippedEvent>()
.add_message::<crate::events::AchievementUnlockedEvent>() .add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_systems( .add_systems(
Update, Update,
@@ -398,14 +399,18 @@ fn handle_draw(
} }
} }
#[allow(clippy::too_many_arguments)]
fn handle_move( fn handle_move(
mut moves: MessageReader<MoveRequestEvent>, mut moves: MessageReader<MoveRequestEvent>,
mut game: ResMut<GameStateResource>, mut game: ResMut<GameStateResource>,
mut changed: MessageWriter<StateChangedEvent>, mut changed: MessageWriter<StateChangedEvent>,
mut won: MessageWriter<GameWonEvent>, mut won: MessageWriter<GameWonEvent>,
mut flipped: MessageWriter<crate::events::CardFlippedEvent>, mut flipped: MessageWriter<crate::events::CardFlippedEvent>,
mut foundation_done: MessageWriter<FoundationCompletedEvent>,
path: Option<Res<GameStatePath>>, path: Option<Res<GameStatePath>>,
) { ) {
use solitaire_core::pile::PileType;
for ev in moves.read() { for ev in moves.read() {
let was_won = game.0.is_won; let was_won = game.0.is_won;
// Identify the card that will be exposed (and may flip face-up) by the move. // Identify the card that will be exposed (and may flip face-up) by the move.
@@ -429,6 +434,19 @@ fn handle_move(
{ {
flipped.write(crate::events::CardFlippedEvent(fid)); flipped.write(crate::events::CardFlippedEvent(fid));
} }
// If this move landed on a foundation pile and that pile is
// now complete (Ace → King, 13 cards), fire the per-suit
// flourish event. Drives a brief decorative scale-pulse on
// the King + a golden tint on the foundation marker plus a
// short audio ping. Purely a UI / audio cue — does not
// cross `solitaire_sync` and is not persisted.
if let PileType::Foundation(slot) = ev.to
&& let Some(pile) = game.0.piles.get(&ev.to)
&& pile.cards.len() == 13
&& let Some(suit) = pile.claimed_suit()
{
foundation_done.write(FoundationCompletedEvent { slot, suit });
}
changed.write(StateChangedEvent); changed.write(StateChangedEvent);
if !was_won && game.0.is_won { if !was_won && game.0.is_won {
won.write(GameWonEvent { won.write(GameWonEvent {
@@ -479,7 +497,6 @@ fn handle_undo(
/// - Any face-up card on Waste or Tableau piles that can legally move to any /// - Any face-up card on Waste or Tableau piles that can legally move to any
/// Foundation or Tableau destination. /// Foundation or Tableau destination.
pub fn has_legal_moves(game: &GameState) -> bool { pub fn has_legal_moves(game: &GameState) -> bool {
use solitaire_core::card::Suit;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
@@ -490,8 +507,6 @@ pub fn has_legal_moves(game: &GameState) -> bool {
return true; return true;
} }
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
// Check each playable source pile. // Check each playable source pile.
let sources: Vec<PileType> = { let sources: Vec<PileType> = {
let mut v = vec![PileType::Waste]; let mut v = vec![PileType::Waste];
@@ -505,11 +520,11 @@ pub fn has_legal_moves(game: &GameState) -> bool {
let Some(from_pile) = game.piles.get(from) else { continue }; let Some(from_pile) = game.piles.get(from) else { continue };
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue }; let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
// Check foundations. // Check foundation slots.
for &suit in &suits { for slot in 0..4_u8 {
let dest = PileType::Foundation(suit); let dest = PileType::Foundation(slot);
if let Some(dest_pile) = game.piles.get(&dest) if let Some(dest_pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, dest_pile, suit) { && can_place_on_foundation(card, dest_pile) {
return true; return true;
} }
} }
@@ -1116,8 +1131,8 @@ mod tests {
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Clear all tableau and foundations, put Ace of Clubs on tableau 0. // Clear all tableau and foundations, put Ace of Clubs on tableau 0.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1139,8 +1154,8 @@ mod tests {
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Clear all foundations and all tableau. // Clear all foundations and all tableau.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1234,8 +1249,8 @@ mod tests {
let mut gs = app.world_mut().resource_mut::<GameStateResource>(); 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::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1273,8 +1288,8 @@ mod tests {
let mut gs = app.world_mut().resource_mut::<GameStateResource>(); 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::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1340,8 +1355,8 @@ mod tests {
let mut gs = app.world_mut().resource_mut::<GameStateResource>(); 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::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1410,6 +1425,196 @@ mod tests {
); );
} }
// -----------------------------------------------------------------------
// Foundation-completion flourish — FoundationCompletedEvent firing logic
// -----------------------------------------------------------------------
/// Helper: prefill `Foundation(slot)` with Ace through Queen of `suit`
/// (12 cards, all face-up) and place the King of `suit` on
/// `Tableau(0)` so a single `MoveRequestEvent` can complete the
/// foundation.
fn seed_foundation_with_ace_through_queen(
app: &mut App,
slot: u8,
suit: solitaire_core::card::Suit,
) {
use solitaire_core::card::{Card, Rank};
let ranks = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen,
];
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
let foundation = gs
.0
.piles
.get_mut(&PileType::Foundation(slot))
.expect("foundation slot must exist");
foundation.cards.clear();
for (i, &rank) in ranks.iter().enumerate() {
foundation.cards.push(Card {
id: 5_000 + i as u32 + (slot as u32) * 100,
suit,
rank,
face_up: true,
});
}
// Put the King on Tableau(0) so a single move can complete it.
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.clear();
t0.cards.push(Card {
id: 6_000 + (slot as u32),
suit,
rank: Rank::King,
face_up: true,
});
}
/// Reading helper: collect every `FoundationCompletedEvent` written
/// during the most recent `update()` so the test body can assert
/// against count, slot, and suit.
fn drain_foundation_events(app: &App) -> Vec<FoundationCompletedEvent> {
let events = app
.world()
.resource::<Messages<FoundationCompletedEvent>>();
let mut cursor = events.get_cursor();
cursor.read(events).copied().collect()
}
/// When a King lands on a foundation that already holds Ace through
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
/// the matching slot + suit.
#[test]
fn foundation_completed_event_fires_when_king_lands() {
use solitaire_core::card::Suit;
let mut app = test_app(1);
seed_foundation_with_ace_through_queen(&mut app, 2, Suit::Hearts);
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Foundation(2),
count: 1,
});
app.update();
let fired = drain_foundation_events(&app);
assert_eq!(
fired.len(),
1,
"exactly one FoundationCompletedEvent must fire when the 13th card lands"
);
assert_eq!(fired[0].slot, 2, "event slot must match the destination slot");
assert_eq!(fired[0].suit, Suit::Hearts, "event suit must match the foundation suit");
}
/// Moving a card to a tableau pile must never produce a
/// `FoundationCompletedEvent`, even if the source tableau happened
/// to have been a King.
#[test]
fn foundation_completed_event_does_not_fire_for_non_foundation_moves() {
use solitaire_core::card::{Card, Rank, Suit};
let mut app = test_app(1);
// Reset the world: clear stock + waste so a draw isn't possible,
// empty all tableaux + foundations, then place a face-up King of
// Spades on Tableau(0). Tableau(1) is empty, so the King can move
// there legally.
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 7_000,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
});
}
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
count: 1,
});
app.update();
let fired = drain_foundation_events(&app);
assert!(
fired.is_empty(),
"FoundationCompletedEvent must not fire for non-foundation moves; got {fired:?}"
);
}
/// At 12 cards on a foundation (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`. /// A successful undo must NOT fire an `InfoToastEvent`.
#[test] #[test]
fn undo_after_draw_does_not_fire_info_toast() { fn undo_after_draw_does_not_fire_info_toast() {
+10
View File
@@ -104,6 +104,16 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlRow { keys: "T", description: "Start a Time Attack session (level 5+)" }, ControlRow { keys: "T", description: "Start a Time Attack session (level 5+)" },
], ],
}, },
ControlSection {
title: "Mode Launcher (M)",
rows: &[
ControlRow { keys: "1", description: "Launch Classic" },
ControlRow { keys: "2", description: "Launch Daily Challenge" },
ControlRow { keys: "3", description: "Launch Zen (level 5+)" },
ControlRow { keys: "4", description: "Launch Challenge (level 5+)" },
ControlRow { keys: "5", description: "Launch Time Attack (level 5+)" },
],
},
ControlSection { ControlSection {
title: "Overlays", title: "Overlays",
rows: &[ rows: &[
+290 -1
View File
@@ -135,6 +135,14 @@ impl Plugin for HomePlugin {
.add_message::<StartTimeAttackRequestEvent>() .add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>() .add_message::<StartDailyChallengeRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
// `.chain()` because several systems (M-toggle, card click,
// cancel button, digit-key shortcut) all read the
// `HomeScreen` entity and may queue a despawn on it in the
// same tick. Bevy's parallel scheduler would otherwise let
// two of them run simultaneously and double-despawn the
// entity, panicking when the second command buffer is
// applied. Chaining serialises these systems and keeps the
// despawn deterministic.
.add_systems( .add_systems(
Update, Update,
( (
@@ -142,7 +150,9 @@ impl Plugin for HomePlugin {
attach_focusable_to_home_mode_cards, attach_focusable_to_home_mode_cards,
handle_home_card_click, handle_home_card_click,
handle_home_cancel_button, handle_home_cancel_button,
), handle_home_digit_keys,
)
.chain(),
); );
} }
} }
@@ -251,6 +261,98 @@ fn handle_home_cancel_button(
} }
} }
// ---------------------------------------------------------------------------
// Digit-key shortcuts (1-5) — modal-scoped
// ---------------------------------------------------------------------------
/// Maps a [`KeyCode::Digit1`]..[`KeyCode::Digit5`] press to the matching
/// [`HomeMode`]. Returns `None` for any other key. Kept as a small free
/// function so the keyboard handler reads as a clean dispatch table and so
/// the mapping is easy to unit-test in isolation.
fn digit_to_home_mode(key: KeyCode) -> Option<HomeMode> {
match key {
KeyCode::Digit1 => Some(HomeMode::Classic),
KeyCode::Digit2 => Some(HomeMode::Daily),
KeyCode::Digit3 => Some(HomeMode::Zen),
KeyCode::Digit4 => Some(HomeMode::Challenge),
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
_ => None,
}
}
/// Direct keyboard activation of a specific mode while the Mode Launcher
/// modal is open. Mirrors the click-handler dispatch in
/// [`handle_home_card_click`]: pressing `1` launches Classic, `2` launches
/// the Daily Challenge, and `3`/`4`/`5` launch Zen / Challenge / Time
/// Attack respectively when the player has reached
/// [`CHALLENGE_UNLOCK_LEVEL`].
///
/// The shortcut is **modal-scoped** — when no [`HomeScreen`] exists the
/// system returns immediately, so digit keys can never accidentally launch
/// a mode mid-game. Pressing a digit for a locked mode is a no-op (matches
/// the click-on-locked-card behaviour) and leaves the modal open so the
/// player can pick another mode.
#[allow(clippy::too_many_arguments)]
fn handle_home_digit_keys(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
progress: Option<Res<ProgressResource>>,
screens: Query<Entity, With<HomeScreen>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut zen: MessageWriter<StartZenRequestEvent>,
mut challenge: MessageWriter<StartChallengeRequestEvent>,
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
) {
// Modal-scoped: do nothing when the Mode Launcher isn't open.
if screens.is_empty() {
return;
}
let Some(mode) = [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
]
.into_iter()
.find(|k| keys.just_pressed(*k))
.and_then(digit_to_home_mode) else {
return;
};
let level = progress.as_ref().map_or(0, |p| p.0.level);
if !mode.is_unlocked(level) {
// Locked mode: no-op, modal stays open.
return;
}
match mode {
HomeMode::Classic => {
new_game.write(NewGameRequestEvent::default());
}
HomeMode::Daily => {
daily.write(StartDailyChallengeRequestEvent);
}
HomeMode::Zen => {
zen.write(StartZenRequestEvent);
}
HomeMode::Challenge => {
challenge.write(StartChallengeRequestEvent);
}
HomeMode::TimeAttack => {
time_attack.write(StartTimeAttackRequestEvent);
}
}
// Close the modal after dispatching the launch event — same shape as
// the click handler.
for entity in &screens {
commands.entity(entity).despawn();
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Spawn helpers // Spawn helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -873,4 +975,191 @@ mod tests {
"no card may be Disabled when the player is at the unlock level" "no card may be Disabled when the player is at the unlock level"
); );
} }
// -----------------------------------------------------------------------
// Digit-key shortcuts (1-5) — modal-scoped direct mode launch
// -----------------------------------------------------------------------
/// Press a key and clear the input afterwards so the next `update()`
/// doesn't re-fire `just_pressed`. Mirrors the open_home() pattern but
/// for an arbitrary key (the M-press helper releases & clears KeyM,
/// which is also what we need here for Digit keys).
fn press_and_clear(app: &mut App, key: KeyCode) {
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.press(key);
}
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
}
}
#[test]
fn digit1_in_home_modal_starts_classic_and_closes_modal() {
let mut app = headless_app();
let _ = open_home(&mut app);
// Drain any pre-existing NewGameRequestEvent so the assertion
// only sees the digit-key driven write.
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
press_and_clear(&mut app, KeyCode::Digit1);
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(
fired.len(),
1,
"exactly one NewGameRequestEvent must fire for Digit1"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Classic via Digit1"
);
}
#[test]
fn digit3_at_level_zero_is_a_noop() {
let mut app = headless_app();
// Default level is 0 — Zen is locked.
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
press_and_clear(&mut app, KeyCode::Digit3);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert!(
zc.read(zen).next().is_none(),
"Digit3 at level 0 must not fire StartZenRequestEvent"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
1,
"Home modal must remain open after a locked-mode digit press"
);
}
#[test]
fn digit3_at_unlock_level_starts_zen_and_closes_modal() {
let mut app = headless_app();
// Bump the player to the unlock level *before* opening the modal
// so the Mode Launcher is in its unlocked state.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
press_and_clear(&mut app, KeyCode::Digit3);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert_eq!(
zc.read(zen).count(),
1,
"Digit3 at unlock level must fire exactly one StartZenRequestEvent"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Zen via Digit3"
);
}
#[test]
fn digit_keys_outside_home_modal_are_noop() {
let mut app = headless_app();
// Modal is NOT open. Bump level so Zen would otherwise be allowed
// — this isolates the modal-scope guard from the unlock check.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
// Drain any pre-existing events.
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartChallengeRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartTimeAttackRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartDailyChallengeRequestEvent>>()
.clear();
// Press every digit 1-5 in turn — none should trigger a launch.
for key in [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
] {
press_and_clear(&mut app, key);
}
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut nc = new_game.get_cursor();
assert!(
nc.read(new_game).next().is_none(),
"Digit keys with no modal open must not fire NewGameRequestEvent"
);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert!(
zc.read(zen).next().is_none(),
"Digit keys with no modal open must not fire StartZenRequestEvent"
);
let chal = app.world().resource::<Messages<StartChallengeRequestEvent>>();
let mut cc = chal.get_cursor();
assert!(
cc.read(chal).next().is_none(),
"Digit keys with no modal open must not fire StartChallengeRequestEvent"
);
let ta = app.world().resource::<Messages<StartTimeAttackRequestEvent>>();
let mut tc = ta.get_cursor();
assert!(
tc.read(ta).next().is_none(),
"Digit keys with no modal open must not fire StartTimeAttackRequestEvent"
);
let daily = app.world().resource::<Messages<StartDailyChallengeRequestEvent>>();
let mut dc = daily.get_cursor();
assert!(
dc.read(daily).next().is_none(),
"Digit keys with no modal open must not fire StartDailyChallengeRequestEvent"
);
}
} }
+152 -7
View File
@@ -16,9 +16,10 @@ use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource; use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
use crate::layout::HUD_BAND_HEIGHT;
use crate::ui_theme::{ use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM, BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM,
STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY, TEXT_SECONDARY, 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, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
}; };
@@ -251,7 +252,8 @@ impl Plugin for HudPlugin {
.add_message::<ToggleSettingsRequestEvent>() .add_message::<ToggleSettingsRequestEvent>()
.add_message::<ToggleLeaderboardRequestEvent>() .add_message::<ToggleLeaderboardRequestEvent>()
.init_resource::<PreviousScore>() .init_resource::<PreviousScore>()
.add_systems(Startup, (spawn_hud, spawn_action_buttons)) .init_resource::<HudActionFade>()
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
.add_systems(Update, update_hud.after(GameMutation)) .add_systems(Update, update_hud.after(GameMutation))
.add_systems(Update, announce_auto_complete.after(GameMutation)) .add_systems(Update, announce_auto_complete.after(GameMutation))
.add_systems(Update, update_selection_hud) .add_systems(Update, update_selection_hud)
@@ -278,10 +280,44 @@ impl Plugin for HudPlugin {
handle_menu_option_click, handle_menu_option_click,
paint_action_buttons, paint_action_buttons,
), ),
); )
// Fade lives in `Last` so it always overrides whatever the
// hover/paint pass set on `BackgroundColor` this frame.
// Otherwise on a hover-state change (`Changed<Interaction>`),
// `paint_action_buttons` would clobber the alpha back to 1.0
// mid-fade and produce a visible blip.
.add_systems(Last, (update_action_fade, apply_action_fade).chain());
} }
} }
/// Spawns the translucent HUD band that anchors the action buttons
/// and primary readouts visually. Sits behind every other HUD element
/// (one z-rung below `Z_HUD`) so it reads as the band's "container"
/// without intercepting clicks from the buttons it sits under.
///
/// Width is full-window, height matches `layout::HUD_BAND_HEIGHT` (the
/// same constant the card layout reserves at the top), so the band's
/// bottom edge lines up exactly with the top edge of the highest
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
/// alpha, so the green felt reads through subtly.
fn spawn_hud_band(mut commands: Commands) {
commands.spawn((
Node {
position_type: PositionType::Absolute,
top: Val::Px(0.0),
left: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(HUD_BAND_HEIGHT),
..default()
},
BackgroundColor(BG_HUD_BAND),
// Sit one z-rung below the HUD content so the buttons and text
// paint on top, but above the card sprites (which are 2D-world
// entities and rendered behind UI regardless).
ZIndex(Z_HUD - 1),
));
}
/// Spawns the in-game HUD as a 4-tier vertical column anchored to the /// Spawns the in-game HUD as a 4-tier vertical column anchored to the
/// top-left of the play area. /// top-left of the play area.
/// ///
@@ -960,6 +996,93 @@ fn handle_menu_option_click(
} }
} }
/// Auto-fade state for the action button bar. The bar fades out when
/// the cursor is in the play area (below the HUD band) and back in when
/// the cursor approaches the top of the window — same UX as a video
/// player's auto-hide controls. Buttons remain fully interactive when
/// visible; when faded out they're geometrically out of cursor reach
/// (hover requires the cursor to be on a button), so no extra
/// pointer-events guard is needed.
#[derive(Resource, Debug, Clone, Copy)]
pub struct HudActionFade {
/// Currently displayed alpha. Lerped toward `target` each frame.
pub alpha: f32,
/// Where `alpha` is heading — 0.0 (faded out) or 1.0 (visible).
pub target: f32,
}
impl Default for HudActionFade {
fn default() -> Self {
// Start visible so the player sees the controls on first launch
// before they've moved the cursor anywhere.
Self {
alpha: 1.0,
target: 1.0,
}
}
}
/// Cursor-y threshold (in window pixels, 0 = top) below which the bar
/// stays visible. Set slightly above `HUD_BAND_HEIGHT` so the bar fades
/// in as the cursor approaches, not only once it crosses into the band.
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
/// transition — fast enough to feel responsive without flashing on
/// brief cursor wanders into the reveal zone.
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// Updates the fade state from cursor position. Sets `target = 1.0` if
/// the cursor is in the reveal zone (top of window) or off-screen
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
/// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates.
fn update_action_fade(
windows: Query<&Window>,
time: Res<Time>,
mut fade: ResMut<HudActionFade>,
) {
let Ok(window) = windows.single() else {
return;
};
fade.target = match window.cursor_position() {
Some(pos) if pos.y <= ACTION_FADE_REVEAL_PX => 1.0,
Some(_) => 0.0,
// Off-window cursor: assume keyboard navigation and keep the
// bar visible so Tab cycling doesn't lead to invisible focus.
None => 1.0,
};
let dt = time.delta_secs();
let max_step = ACTION_FADE_RATE_PER_SEC * dt;
let diff = fade.target - fade.alpha;
fade.alpha = (fade.alpha + diff.clamp(-max_step, max_step)).clamp(0.0, 1.0);
}
/// Applies the current fade alpha to every action button's
/// `BackgroundColor` and to its child label / hotkey-chip text. Runs in
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
/// same frame doesn't override the fade with an opaque idle / hover
/// colour.
fn apply_action_fade(
fade: Res<HudActionFade>,
mut buttons: Query<(&Children, &mut BackgroundColor), With<ActionButton>>,
mut text_q: Query<&mut TextColor>,
) {
for (children, mut bg) in &mut buttons {
let mut c = bg.0;
c.set_alpha(fade.alpha);
bg.0 = c;
for child in children.iter() {
if let Ok(mut tc) = text_q.get_mut(child) {
let mut cc = tc.0;
cc.set_alpha(fade.alpha);
tc.0 = cc;
}
}
}
}
/// Visual feedback for every action button — paints idle / hover / pressed /// Visual feedback for every action button — paints idle / hover / pressed
/// states by mutating `BackgroundColor` whenever the interaction state /// states by mutating `BackgroundColor` whenever the interaction state
/// changes. One query covers all action buttons via the shared /// changes. One query covers all action buttons via the shared
@@ -1434,6 +1557,7 @@ fn update_hud(
/// indicator stays in sync with the selection resource. /// indicator stays in sync with the selection resource.
fn update_selection_hud( fn update_selection_hud(
selection: Option<Res<SelectionState>>, selection: Option<Res<SelectionState>>,
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<HudSelection>>, mut q: Query<&mut Text, With<HudSelection>>,
) { ) {
let Ok(mut t) = q.single_mut() else { return }; let Ok(mut t) = q.single_mut() else { return };
@@ -1441,7 +1565,29 @@ fn update_selection_hud(
None => String::new(), None => String::new(),
Some(PileType::Waste) => "▶ Waste".to_string(), Some(PileType::Waste) => "▶ Waste".to_string(),
Some(PileType::Stock) => "▶ Stock".to_string(), Some(PileType::Stock) => "▶ Stock".to_string(),
Some(PileType::Foundation(suit)) => { Some(PileType::Foundation(slot)) => match game.as_deref() {
Some(g) => foundation_selection_label(*slot, &g.0),
// No game resource means we can't probe claimed_suit; show the
// slot-based placeholder so the HUD still surfaces the selection.
None => format!("▶ Foundation {}", slot + 1),
},
Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1),
};
**t = label;
}
/// Returns the HUD selection label for a foundation slot.
///
/// When the slot has a claimed suit (any card has landed) the announcement is
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
fn foundation_selection_label(slot: u8, game: &solitaire_core::game_state::GameState) -> String {
let claimed = game
.piles
.get(&PileType::Foundation(slot))
.and_then(|p| p.claimed_suit());
match claimed {
Some(suit) => {
let s = match suit { let s = match suit {
Suit::Clubs => "Clubs", Suit::Clubs => "Clubs",
Suit::Diamonds => "Diamonds", Suit::Diamonds => "Diamonds",
@@ -1450,9 +1596,8 @@ fn update_selection_hud(
}; };
format!("{s} Foundation") format!("{s} Foundation")
} }
Some(PileType::Tableau(idx)) => format!("Column {}", idx + 1), None => format!("Foundation {}", slot + 1),
}; }
**t = label;
} }
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time /// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
+246 -124
View File
@@ -30,10 +30,12 @@ use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_animation::tuning::AnimationTuning; use crate::card_animation::tuning::AnimationTuning;
use crate::card_animation::{CardAnimation, MotionCurve};
use crate::card_plugin::{ use crate::card_plugin::{
CardEntity, HintHighlight, HintHighlightTimer, TABLEAU_FACEDOWN_FAN_FRAC, TABLEAU_FAN_FRAC, CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC,
TABLEAU_FAN_FRAC,
}; };
use crate::feedback_anim_plugin::ShakeAnim; use crate::ui_theme::MOTION_DRAG_REJECT_SECS;
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::DrawMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{ use crate::events::{
@@ -320,9 +322,13 @@ fn handle_keyboard_hint(
} }
// Fire an informational toast describing where the hinted card should // Fire an informational toast describing where the hinted card should
// move so the player always sees the suggestion in text. // move so the player always sees the suggestion in text. When the
// destination foundation already claims a suit, surface that suit so the
// player keeps thinking in suit terms; otherwise fall back to "foundation".
let msg = match to { let msg = match to {
PileType::Foundation(suit) => { PileType::Foundation(_) => {
let claimed = g.0.piles.get(to).and_then(|p| p.claimed_suit());
if let Some(suit) = claimed {
let suit_name = match suit { let suit_name = match suit {
Suit::Clubs => "Clubs", Suit::Clubs => "Clubs",
Suit::Diamonds => "Diamonds", Suit::Diamonds => "Diamonds",
@@ -330,6 +336,9 @@ fn handle_keyboard_hint(
Suit::Spades => "Spades", Suit::Spades => "Spades",
}; };
format!("Hint: move to {suit_name} foundation") format!("Hint: move to {suit_name} foundation")
} else {
"Hint: move to foundation".to_string()
}
} }
PileType::Tableau(col) => format!("Hint: move to tableau (col {})", col + 1), PileType::Tableau(col) => format!("Hint: move to tableau (col {})", col + 1),
_ => "Hint: move card".to_string(), _ => "Hint: move card".to_string(),
@@ -634,12 +643,11 @@ fn end_drag(
let bottom_card_id = drag.cards[0]; let bottom_card_id = drag.cards[0];
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) { if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
let ok = match &target { let ok = match &target {
PileType::Foundation(suit) => { PileType::Foundation(_) => {
count == 1 count == 1
&& can_place_on_foundation( && can_place_on_foundation(
&bottom_card, &bottom_card,
&game.0.piles[&target], &game.0.piles[&target],
*suit,
) )
} }
PileType::Tableau(_) => { PileType::Tableau(_) => {
@@ -660,14 +668,16 @@ fn end_drag(
to: target.clone(), to: target.clone(),
count, count,
}); });
// Shake each dragged card so the player gets immediate // Smoothly glide each dragged card from its drop-time
// visual feedback that the drop was rejected. ShakeAnim // transform back to its resting slot in the origin pile.
// restores translation.x to origin_x at the end of the // The audio cue (card_invalid.wav, played by AudioPlugin
// animation, so origin_x must be the target slot in the // on MoveRejectedEvent) still gives the player clear
// origin pile — using the current drag transform would // negative feedback; this just replaces the old shake
// pin the card at the drop location and fight the // wiggle with a forgiving ease-out tween.
// sync_cards slide that StateChangedEvent triggers //
// (the symptom is "card lands beside the pile"). // `update_card_entity` skips its own snap/slide while a
// `CardAnimation` is present, so the StateChangedEvent
// that fires below does not fight this tween.
if let Some(origin_pile) = game.0.piles.get(&origin) { if let Some(origin_pile) = game.0.piles.get(&origin) {
for &card_id in &drag.cards { for &card_id in &drag.cards {
let Some(stack_index) = let Some(stack_index) =
@@ -677,14 +687,23 @@ fn end_drag(
}; };
let target_pos = let target_pos =
card_position(&game.0, &layout.0, &origin, stack_index); card_position(&game.0, &layout.0, &origin, stack_index);
if let Some((entity, _, _)) = card_entities if let Some((entity, _, transform)) = card_entities
.iter() .iter()
.find(|(_, ce, _)| ce.card_id == card_id) .find(|(_, ce, _)| ce.card_id == card_id)
{ {
commands.entity(entity).insert(ShakeAnim { let drag_pos = transform.translation.truncate();
elapsed: 0.0, let drag_z = transform.translation.z;
origin_x: target_pos.x, let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
}); commands.entity(entity).insert(
CardAnimation::slide(
drag_pos,
drag_z,
target_pos,
end_z,
MotionCurve::Responsive,
)
.with_duration(MOTION_DRAG_REJECT_SECS),
);
} }
} }
} }
@@ -879,9 +898,9 @@ fn touch_end_drag(
let bottom_card_id = drag.cards[0]; let bottom_card_id = drag.cards[0];
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) { if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
let ok = match &target { let ok = match &target {
PileType::Foundation(suit) => { PileType::Foundation(_) => {
count == 1 count == 1
&& can_place_on_foundation(&bottom_card, &game.0.piles[&target], *suit) && can_place_on_foundation(&bottom_card, &game.0.piles[&target])
} }
PileType::Tableau(_) => { PileType::Tableau(_) => {
can_place_on_tableau(&bottom_card, &game.0.piles[&target]) can_place_on_tableau(&bottom_card, &game.0.piles[&target])
@@ -893,9 +912,11 @@ fn touch_end_drag(
fired = true; fired = true;
} else { } else {
rejected.write(MoveRejectedEvent { from: origin.clone(), to: target, count }); rejected.write(MoveRejectedEvent { from: origin.clone(), to: target, count });
// See `end_drag` (mouse path) for the rationale: ShakeAnim // Smoothly glide each dragged card from its drop-time
// restores translation.x to origin_x, so origin_x must be // transform back to its resting slot. See `end_drag`
// the origin pile's slot, not the drop location. // (mouse path) for the full rationale; the touch path
// mirrors it exactly so finger and mouse rejection
// feel identical.
if let Some(origin_pile) = game.0.piles.get(&origin) { if let Some(origin_pile) = game.0.piles.get(&origin) {
for &card_id in &drag.cards { for &card_id in &drag.cards {
let Some(stack_index) = let Some(stack_index) =
@@ -905,13 +926,22 @@ fn touch_end_drag(
}; };
let target_pos = let target_pos =
card_position(&game.0, &layout.0, &origin, stack_index); card_position(&game.0, &layout.0, &origin, stack_index);
if let Some((entity, _, _)) = if let Some((entity, _, transform)) =
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id) card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
{ {
commands.entity(entity).insert(ShakeAnim { let drag_pos = transform.translation.truncate();
elapsed: 0.0, let drag_z = transform.translation.z;
origin_x: target_pos.x, let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
}); commands.entity(entity).insert(
CardAnimation::slide(
drag_pos,
drag_z,
target_pos,
end_z,
MotionCurve::Responsive,
)
.with_duration(MOTION_DRAG_REJECT_SECS),
);
} }
} }
} }
@@ -1016,10 +1046,10 @@ fn find_draggable_at(
// Within a pile, we consider cards top-down because the visual top card is drawn last. // Within a pile, we consider cards top-down because the visual top card is drawn last.
let piles = [ let piles = [
PileType::Waste, PileType::Waste,
PileType::Foundation(Suit::Clubs), PileType::Foundation(0),
PileType::Foundation(Suit::Diamonds), PileType::Foundation(1),
PileType::Foundation(Suit::Hearts), PileType::Foundation(2),
PileType::Foundation(Suit::Spades), PileType::Foundation(3),
PileType::Tableau(0), PileType::Tableau(0),
PileType::Tableau(1), PileType::Tableau(1),
PileType::Tableau(2), PileType::Tableau(2),
@@ -1079,10 +1109,10 @@ fn find_drop_target(
origin: &PileType, origin: &PileType,
) -> Option<PileType> { ) -> Option<PileType> {
let piles = [ let piles = [
PileType::Foundation(Suit::Clubs), PileType::Foundation(0),
PileType::Foundation(Suit::Diamonds), PileType::Foundation(1),
PileType::Foundation(Suit::Hearts), PileType::Foundation(2),
PileType::Foundation(Suit::Spades), PileType::Foundation(3),
PileType::Tableau(0), PileType::Tableau(0),
PileType::Tableau(1), PileType::Tableau(1),
PileType::Tableau(2), PileType::Tableau(2),
@@ -1138,11 +1168,11 @@ const DOUBLE_CLICK_WINDOW: f32 = 0.35;
/// ///
/// Returns `None` if no legal move exists from the card's current location. /// Returns `None` if no legal move exists from the card's current location.
pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> { pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> {
// Try all four foundations first. // Try all four foundation slots first.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
let dest = PileType::Foundation(suit); let dest = PileType::Foundation(slot);
if let Some(pile) = game.piles.get(&dest) if let Some(pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, pile, suit) { && can_place_on_foundation(card, pile) {
return Some(dest); return Some(dest);
} }
} }
@@ -1298,7 +1328,6 @@ fn handle_double_click(
/// This is the backing data for the cycling hint system: the H key steps /// This is the backing data for the cycling hint system: the H key steps
/// through `hints[HintCycleIndex % hints.len()]` on each press. /// through `hints[HintCycleIndex % hints.len()]` on each press.
pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> { pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let sources: Vec<PileType> = { let sources: Vec<PileType> = {
let mut s = vec![PileType::Waste]; let mut s = vec![PileType::Waste];
for i in 0..7_usize { for i in 0..7_usize {
@@ -1313,12 +1342,12 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
for from in &sources { for from in &sources {
let Some(from_pile) = game.piles.get(from) else { continue }; let Some(from_pile) = game.piles.get(from) else { continue };
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue }; let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
for &suit in &suits { for slot in 0..4_u8 {
let dest = PileType::Foundation(suit); let dest = PileType::Foundation(slot);
if let Some(dest_pile) = game.piles.get(&dest) if let Some(dest_pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, dest_pile, suit) { && can_place_on_foundation(card, dest_pile) {
hints.push((from.clone(), dest, 1)); hints.push((from.clone(), dest, 1));
// Each source card can go to at most one foundation suit; // Each source card can land on at most one foundation slot;
// no need to check the remaining three for this card. // no need to check the remaining three for this card.
break; break;
} }
@@ -1616,7 +1645,7 @@ mod tests {
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0));
for pile in [ for pile in [
PileType::Waste, PileType::Waste,
PileType::Foundation(Suit::Hearts), PileType::Foundation(2),
] { ] {
let (_, size) = pile_drop_rect(&pile, &layout, &game); let (_, size) = pile_drop_rect(&pile, &layout, &game);
assert_eq!(size, layout.card_size); assert_eq!(size, layout.card_size);
@@ -1638,13 +1667,15 @@ mod tests {
waste.cards.clear(); waste.cards.clear();
waste.cards.push(Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true }); waste.cards.push(Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true });
// Foundation for Clubs is empty — Ace should go there. // All four foundation slots empty — the Ace lands in slot 0 (first
let foundation = game.piles.get_mut(&PileType::Foundation(Suit::Clubs)).unwrap(); // empty slot in iteration order).
foundation.cards.clear(); for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
let card = Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true }; let card = Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true };
let dest = best_destination(&card, &game); let dest = best_destination(&card, &game);
assert_eq!(dest, Some(PileType::Foundation(Suit::Clubs))); assert_eq!(dest, Some(PileType::Foundation(0)));
} }
#[test] #[test]
@@ -1653,9 +1684,9 @@ mod tests {
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
let mut game = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Classic); let mut game = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Classic);
// Clear all foundations — a Two of Clubs cannot go there. // Clear all foundation slots — a Two of Clubs cannot go there.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
// Put a Two of Clubs as the card. // Put a Two of Clubs as the card.
@@ -1682,8 +1713,8 @@ mod tests {
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
// Clear everything except one card that has nowhere to go. // Clear everything except one card that has nowhere to go.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1704,8 +1735,8 @@ mod tests {
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
// Clear all piles for a clean test. // Clear all piles for a clean test.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1737,8 +1768,8 @@ mod tests {
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1768,8 +1799,8 @@ mod tests {
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1806,13 +1837,16 @@ mod tests {
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 500, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, id: 500, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
}); });
game.piles.get_mut(&PileType::Foundation(Suit::Clubs)).unwrap().cards.clear(); // All foundation slots empty — Ace lands in slot 0 (first match).
for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
}
let hint = find_hint(&game); let hint = find_hint(&game);
assert!(hint.is_some(), "should find a hint"); assert!(hint.is_some(), "should find a hint");
let (from, to, count) = hint.unwrap(); let (from, to, count) = hint.unwrap();
assert_eq!(from, PileType::Tableau(0)); assert_eq!(from, PileType::Tableau(0));
assert_eq!(to, PileType::Foundation(Suit::Clubs)); assert_eq!(to, PileType::Foundation(0));
assert_eq!(count, 1); assert_eq!(count, 1);
} }
@@ -1822,8 +1856,8 @@ mod tests {
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
// Put only a Two on tableau 0, empty everything else. // Put only a Two on tableau 0, empty everything else.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1872,8 +1906,8 @@ mod tests {
// Remove all foundation, tableau, and waste cards so no pile-to-pile // Remove all foundation, tableau, and waste cards so no pile-to-pile
// move exists. Leave one card in the stock. // move exists. Leave one card in the stock.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1904,8 +1938,8 @@ mod tests {
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
// Clear every pile, then put a single card that has nowhere to go. // Clear every pile, then put a single card that has nowhere to go.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1936,71 +1970,159 @@ mod tests {
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Task #57 — ShakeAnim insertion on rejected drag // Drag-rejection return tween — `CardAnimation` replaces the legacy
// `ShakeAnim` on the dragged cards. The audio cue
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
// visual response on the dragged cards swapped from a horizontal wiggle
// to a smooth ease-out glide back to the origin pile.
//
// These tests build the component values exactly as `end_drag` and
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
// shaped correctly. Driving `end_drag` end-to-end requires a real window
// and mouse-button input, so we exercise the data path the same way the
// legacy `ShakeAnim` tests did.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// Verifies that `ShakeAnim` constructed for a rejected drag has the /// Helper: build the `CardAnimation` the rejection paths construct for
/// correct initial values: `elapsed` starts at 0.0 and `origin_x` matches /// one dragged card. Mirrors the inline logic in `end_drag` and
/// the **target slot in the origin pile** (where the card will rest after /// `touch_end_drag` so the tests stay in sync with the production code.
/// the rejection). Saving the drop-location X here was the root cause of fn build_drag_reject_animation(
/// the "card lands beside the pile" bug — `tick_shake_anim` restores drag_pos: Vec2,
/// `translation.x` to `origin_x` at the end of the shake, fighting the drag_z: f32,
/// `sync_cards` slide that `StateChangedEvent` triggers. target_pos: Vec2,
/// stack_index: usize,
/// The Bevy ECS part (Commands + Query) is exercised at runtime; this test ) -> CardAnimation {
/// covers the data path — that we build the component with the right values let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
/// before handing it to `commands.entity(...).insert(...)`. CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
#[test] .with_duration(MOTION_DRAG_REJECT_SECS)
fn shake_anim_for_rejected_drag_has_correct_initial_values() {
use crate::feedback_anim_plugin::ShakeAnim;
// Simulate the X coordinate of the card's slot in its origin pile —
// computed by `card_position(game, layout, &origin, stack_index)` at
// rejection time, not the drop-location transform X.
let target_slot_x = 123.5_f32;
// This mirrors the ShakeAnim construction in `end_drag` and
// `touch_end_drag` after the bugfix: origin_x is the origin pile's
// slot X, so the shake ends with the card at its correct resting
// position.
let anim = ShakeAnim {
elapsed: 0.0,
origin_x: target_slot_x,
};
assert_eq!(
anim.elapsed, 0.0,
"ShakeAnim must start with elapsed=0.0 so the animation plays from the beginning"
);
assert!(
(anim.origin_x - target_slot_x).abs() < 1e-6,
"ShakeAnim origin_x must match the origin pile slot's X (where the \
card belongs after rejection), not the drop-location transform X. \
Expected {target_slot_x}, got {}",
anim.origin_x
);
} }
/// When a drag is rejected, every card id in `drag.cards` should receive a /// Every card in `drag.cards` should receive its own `CardAnimation` on
/// `ShakeAnim`. Verify that the set of card ids we would iterate matches /// rejection. With the shake → tween migration, the assertion changes
/// exactly the ids stored in `DragState::cards` at rejection time. /// from "every dragged card gets a ShakeAnim" to "every dragged card
/// gets a CardAnimation" — same coverage, new component.
#[test] #[test]
fn rejected_drag_shakes_all_dragged_cards() { fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
// Simulate a DragState with two card ids (a stack drag). // Simulate a stack drag of two cards.
let dragged_ids: Vec<u32> = vec![10, 11]; let dragged_ids: Vec<u32> = vec![10, 11];
// In `end_drag`, we iterate `drag.cards` and look up each id in let mut animated: Vec<u32> = Vec::new();
// `card_entities`. The ids we would insert ShakeAnim on must exactly
// match the dragged set.
let mut shaken: Vec<u32> = Vec::new();
for &card_id in &dragged_ids { for &card_id in &dragged_ids {
// Simulate finding the entity for card_id (always succeeds here). // In `end_drag` we iterate `drag.cards` and look up each id in
shaken.push(card_id); // `card_entities`. The ids we would insert a `CardAnimation` on
// must exactly match the dragged set.
animated.push(card_id);
} }
assert_eq!( assert_eq!(
shaken, dragged_ids, animated, dragged_ids,
"every card id in drag.cards must receive a ShakeAnim on rejection" "every card id in drag.cards must receive a CardAnimation on rejection"
);
}
/// The `end` field of the inserted tween must equal the card's resting
/// slot in its origin pile — the position the card belongs at after a
/// rejected drop. Without this, the tween would glide to the wrong spot
/// and `sync_cards` would have to fight it back.
#[test]
fn rejected_drag_animation_targets_origin_resting_position() {
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
assert!(
(anim.end - target_pos).length() < 1e-6,
"CardAnimation.end must match the origin slot's resting position. \
Expected {target_pos:?}, got {:?}",
anim.end
);
}
/// The `start` field of the inserted tween must equal the card's
/// drop-time transform position — i.e. wherever the cursor or finger
/// released the card. This is what makes the glide feel like a
/// continuous return rather than a teleport-then-shake.
#[test]
fn rejected_drag_animation_starts_from_drag_position() {
let drag_pos = Vec2::new(640.0, 200.0);
let target_pos = Vec2::new(80.0, -120.0);
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
assert!(
(anim.start - drag_pos).length() < 1e-6,
"CardAnimation.start must match the drop-time transform position \
(where the cursor released). Expected {drag_pos:?}, got {:?}",
anim.start
);
// And the start must be visibly distinct from the origin slot — the
// whole point of the tween is that it visibly travels.
assert!(
(anim.start - anim.end).length() > 1.0,
"rejected drag should travel a visible distance, got start={:?} end={:?}",
anim.start,
anim.end
);
}
/// The tween duration is taken from the project-wide motion token so
/// designers can retune the feel from one place. Keeps the constant and
/// the call site honest.
#[test]
fn rejected_drag_animation_uses_correct_duration() {
let anim = build_drag_reject_animation(
Vec2::new(640.0, 200.0),
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert!(
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
({MOTION_DRAG_REJECT_SECS}), got {}",
anim.duration
);
}
/// The curve must be a no-overshoot ease-out so the card decelerates
/// cleanly into its rest position — overshoot on a rejection feels
/// jittery rather than forgiving.
#[test]
fn rejected_drag_animation_uses_responsive_curve() {
let anim = build_drag_reject_animation(
Vec2::new(640.0, 200.0),
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert_eq!(
anim.curve,
MotionCurve::Responsive,
"drag-rejection tween must use Responsive (quintic ease-out) \
so the card snaps back without bouncing past the slot"
);
}
/// The `start_z` of the tween must equal the card's drop-time z
/// (`DRAG_Z`) so the card stays above the rest of the table while it
/// travels home, then settles at the correct resting z.
#[test]
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
let stack_index = 2_usize;
let anim = build_drag_reject_animation(
Vec2::new(640.0, 200.0),
DRAG_Z,
Vec2::new(80.0, -120.0),
stack_index,
);
assert!(
(anim.start_z - DRAG_Z).abs() < 1e-6,
"tween must start at DRAG_Z so the card stays on top during the glide"
);
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
assert!(
(anim.end_z - expected_end_z).abs() < 1e-6,
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
anim.end_z
); );
} }
} }
+50 -24
View File
@@ -7,7 +7,6 @@ use std::collections::HashMap;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::{Resource, SystemSet}; use bevy::prelude::{Resource, SystemSet};
use solitaire_core::card::Suit;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
/// Schedule labels for layout-related systems so cross-plugin ordering is /// Schedule labels for layout-related systems so cross-plugin ordering is
@@ -27,7 +26,11 @@ pub enum LayoutSystem {
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0); pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
/// Aspect ratio (height / width) of a standard playing card. /// Aspect ratio (height / width) of a standard playing card.
const CARD_ASPECT: f32 = 1.4; ///
/// Matches the bundled hayeah/playing-cards-assets SVG dimensions
/// (167.087 × 242.667 → 1.4523). Pre-v0.11 the constant was 1.4,
/// which rendered the cards ~3.6 % squashed vertically.
const CARD_ASPECT: f32 = 1.4523;
/// Fraction of card height used as vertical padding between the top row and /// Fraction of card height used as vertical padding between the top row and
/// the tableau row. /// the tableau row.
@@ -43,6 +46,15 @@ const TABLEAU_FAN_FRAC: f32 = 0.25;
/// this column inside the visible window. /// this column inside the visible window.
const MAX_TABLEAU_CARDS: f32 = 13.0; const MAX_TABLEAU_CARDS: f32 = 13.0;
/// Vertical pixel band reserved at the top of the play area for the HUD
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
/// below this band so the HUD doesn't bleed into the play surface.
///
/// 64 px comfortably fits the action button bar (~32 px tall) plus the
/// Score/Moves text line plus padding, with a few pixels of breathing room.
/// The matching translucent background is painted by `hud_plugin::spawn_hud_band`.
pub const HUD_BAND_HEIGHT: f32 = 64.0;
/// Table background colour (dark green felt). /// Table background colour (dark green felt).
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196]; pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
@@ -51,7 +63,7 @@ pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
pub struct Layout { pub struct Layout {
/// Width and height of a single card, in world units (Bevy 2D world-space). /// Width and height of a single card, in world units (Bevy 2D world-space).
/// ///
/// `x` is the card width; `y` is the card height (always `x * 1.4`). /// `x` is the card width; `y` is the card height (`x * CARD_ASPECT`).
/// All pile positions and fan offsets are derived from this value. /// All pile positions and fan offsets are derived from this value.
pub card_size: Vec2, pub card_size: Vec2,
/// Centre position of each pile, in 2D world coordinates. /// Centre position of each pile, in 2D world coordinates.
@@ -72,7 +84,8 @@ pub struct Layout {
/// column (13 face-up cards, see [`MAX_TABLEAU_CARDS`]) inside the /// column (13 face-up cards, see [`MAX_TABLEAU_CARDS`]) inside the
/// window with a bottom margin equal to `h_gap`. Limiter on tall/narrow /// window with a bottom margin equal to `h_gap`. Limiter on tall/narrow
/// windows. /// windows.
/// - `card_height = card_width * 1.4`. /// - `card_height = card_width * CARD_ASPECT` (1.4523, matches the
/// bundled hayeah card art's natural SVG dimensions).
/// - Horizontal gap `h_gap = card_width / 4.0`. /// - Horizontal gap `h_gap = card_width / 4.0`.
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns /// - Top row (stock, waste, 4 foundations) aligns with tableau columns
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the /// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
@@ -88,8 +101,8 @@ pub fn compute_layout(window: Vec2) -> Layout {
// //
// Letting w = card_width and h = w * CARD_ASPECT, the vertical layout is: // Letting w = card_width and h = w * CARD_ASPECT, the vertical layout is:
// top edge of window = +window.y / 2 // top edge of window = +window.y / 2
// top of top-row card = window.y/2 - h_gap (h_gap top margin) // top of top-row card = window.y/2 - HUD_BAND_HEIGHT - h_gap (HUD reserve + h_gap top margin)
// centre of top-row card = window.y/2 - h_gap - h/2 // centre of top-row card = window.y/2 - HUD_BAND_HEIGHT - h_gap - h/2
// centre of tableau card = top centre - h - vertical_gap (vertical_gap = VERTICAL_GAP_FRAC * h) // centre of tableau card = top centre - h - vertical_gap (vertical_gap = VERTICAL_GAP_FRAC * h)
// bottom of last fanned = tableau_centre + h/2 - fan_factor * h // bottom of last fanned = tableau_centre + h/2 - fan_factor * h
// where fan_factor = 1 + (MAX_TABLEAU_CARDS - 1) * TABLEAU_FAN_FRAC // where fan_factor = 1 + (MAX_TABLEAU_CARDS - 1) * TABLEAU_FAN_FRAC
@@ -97,10 +110,10 @@ pub fn compute_layout(window: Vec2) -> Layout {
// //
// Substituting h_gap = w/4 and h = CARD_ASPECT * w and solving for the // Substituting h_gap = w/4 and h = CARD_ASPECT * w and solving for the
// largest w that fits gives: // largest w that fits gives:
// window.y = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT) // (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC; let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT; let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
let card_width_height_based = window.y / height_denom; let card_width_height_based = (window.y - HUD_BAND_HEIGHT).max(0.0) / height_denom;
let card_width = card_width_width_based.min(card_width_height_based); let card_width = card_width_width_based.min(card_width_height_based);
let card_height = card_width * CARD_ASPECT; let card_height = card_width * CARD_ASPECT;
@@ -120,7 +133,7 @@ pub fn compute_layout(window: Vec2) -> Layout {
}; };
let vertical_gap = card_height * VERTICAL_GAP_FRAC; let vertical_gap = card_height * VERTICAL_GAP_FRAC;
let top_y = window.y / 2.0 - h_gap - card_height / 2.0; let top_y = window.y / 2.0 - HUD_BAND_HEIGHT - h_gap - card_height / 2.0;
let tableau_y = top_y - card_height - vertical_gap; let tableau_y = top_y - card_height - vertical_gap;
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13); let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
@@ -129,11 +142,10 @@ pub fn compute_layout(window: Vec2) -> Layout {
pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y)); pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y));
// Column 2 is skipped — visual separation between waste and foundations. // Column 2 is skipped — visual separation between waste and foundations.
let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; for slot in 0..4_u8 {
for (i, suit) in foundation_suits.into_iter().enumerate() {
pile_positions.insert( pile_positions.insert(
PileType::Foundation(suit), PileType::Foundation(slot),
Vec2::new(col_x(3 + i), top_y), Vec2::new(col_x(3 + slot as usize), top_y),
); );
} }
@@ -158,11 +170,10 @@ mod tests {
fn assert_all_piles_present(layout: &Layout) { fn assert_all_piles_present(layout: &Layout) {
assert!(layout.pile_positions.contains_key(&PileType::Stock)); assert!(layout.pile_positions.contains_key(&PileType::Stock));
assert!(layout.pile_positions.contains_key(&PileType::Waste)); assert!(layout.pile_positions.contains_key(&PileType::Waste));
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
assert!( assert!(
layout.pile_positions.contains_key(&PileType::Foundation(suit)), layout.pile_positions.contains_key(&PileType::Foundation(slot)),
"missing foundation for {:?}", "missing foundation slot {slot}",
suit
); );
} }
for i in 0..7 { for i in 0..7 {
@@ -217,6 +228,23 @@ mod tests {
assert!(stock_y > tableau_y); assert!(stock_y > tableau_y);
} }
/// HUD band reservation: the top edge of every top-row card must sit
/// at least `HUD_BAND_HEIGHT` pixels below the top of the window so
/// the action button bar / score readout has its own visual band
/// instead of bleeding into the play surface.
#[test]
fn top_row_clears_hud_band() {
let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window);
let stock_y = layout.pile_positions[&PileType::Stock].y;
let card_top = stock_y + layout.card_size.y / 2.0;
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
assert!(
card_top <= band_bottom,
"top of stock card ({card_top}) must sit below the HUD band ({band_bottom})",
);
}
#[test] #[test]
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() { fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0));
@@ -231,15 +259,13 @@ mod tests {
#[test] #[test]
fn foundations_align_with_tableau_cols_3_to_6() { fn foundations_align_with_tableau_cols_3_to_6() {
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0));
let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; for slot in 0..4_u8 {
for (i, suit) in foundation_suits.into_iter().enumerate() { let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
let f_x = layout.pile_positions[&PileType::Foundation(suit)].x; let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
let t_x = layout.pile_positions[&PileType::Tableau(3 + i)].x;
assert!( assert!(
(f_x - t_x).abs() < 1e-5, (f_x - t_x).abs() < 1e-5,
"foundation {:?} should align with tableau {}", "foundation slot {slot} should align with tableau {}",
suit, 3 + slot as usize,
3 + i
); );
} }
} }
+5 -4
View File
@@ -69,8 +69,9 @@ pub use card_animation::{
FrameTimeDiagnostics, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
}; };
pub use feedback_anim_plugin::{ pub use feedback_anim_plugin::{
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale, deal_stagger_delay, deal_stagger_secs_for_speed, foundation_flourish_scale, shake_offset,
FeedbackAnimPlugin, SettleAnim, ShakeAnim, settle_scale, FeedbackAnimPlugin, FoundationFlourish, FoundationMarkerFlourish, SettleAnim,
ShakeAnim,
}; };
pub use auto_complete_plugin::AutoCompletePlugin; pub use auto_complete_plugin::AutoCompletePlugin;
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary}; pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
@@ -82,8 +83,8 @@ pub use font_plugin::{FontPlugin, FontResource};
pub use cursor_plugin::CursorPlugin; pub use cursor_plugin::CursorPlugin;
pub use events::{ pub use events::{
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
ForfeitEvent, ForfeitRequestEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent, ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent, StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
@@ -94,6 +94,7 @@ const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow { keys: "D / Space", description: "Draw from stock" }, HotkeyRow { keys: "D / Space", description: "Draw from stock" },
HotkeyRow { keys: "U", description: "Undo last move" }, HotkeyRow { keys: "U", description: "Undo last move" },
HotkeyRow { keys: "N", description: "New Classic game" }, HotkeyRow { keys: "N", description: "New Classic game" },
HotkeyRow { keys: "M", description: "Open Mode Launcher (then 15 to pick)" },
HotkeyRow { keys: "S", description: "Stats & progression" }, HotkeyRow { keys: "S", description: "Stats & progression" },
HotkeyRow { keys: "A", description: "Achievements" }, HotkeyRow { keys: "A", description: "Achievements" },
HotkeyRow { keys: "O", description: "Settings" }, HotkeyRow { keys: "O", description: "Settings" },
+12 -16
View File
@@ -18,7 +18,6 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::card::Suit;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
@@ -82,15 +81,12 @@ impl Plugin for SelectionPlugin {
/// The ordered list of piles that are considered for keyboard cycling. /// The ordered list of piles that are considered for keyboard cycling.
/// ///
/// Order: Waste → Foundation×4 → Tableau 06. /// Order: Waste → Foundation slots 03 → Tableau 06.
fn cycled_piles() -> Vec<PileType> { fn cycled_piles() -> Vec<PileType> {
let mut piles = vec![ let mut piles = vec![PileType::Waste];
PileType::Waste, for slot in 0..4_u8 {
PileType::Foundation(Suit::Clubs), piles.push(PileType::Foundation(slot));
PileType::Foundation(Suit::Diamonds), }
PileType::Foundation(Suit::Hearts),
PileType::Foundation(Suit::Spades),
];
for i in 0..7_usize { for i in 0..7_usize {
piles.push(PileType::Tableau(i)); piles.push(PileType::Tableau(i));
} }
@@ -183,10 +179,10 @@ fn handle_selection_keys(
let available: Vec<PileType> = { let available: Vec<PileType> = {
let all = [ let all = [
PileType::Waste, PileType::Waste,
PileType::Foundation(Suit::Clubs), PileType::Foundation(0),
PileType::Foundation(Suit::Diamonds), PileType::Foundation(1),
PileType::Foundation(Suit::Hearts), PileType::Foundation(2),
PileType::Foundation(Suit::Spades), PileType::Foundation(3),
PileType::Tableau(0), PileType::Tableau(0),
PileType::Tableau(1), PileType::Tableau(1),
PileType::Tableau(2), PileType::Tableau(2),
@@ -325,10 +321,10 @@ fn try_foundation_dest(
game: &solitaire_core::game_state::GameState, game: &solitaire_core::game_state::GameState,
) -> Option<PileType> { ) -> Option<PileType> {
use solitaire_core::rules::can_place_on_foundation; use solitaire_core::rules::can_place_on_foundation;
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
let dest = PileType::Foundation(suit); let dest = PileType::Foundation(slot);
if let Some(pile) = game.piles.get(&dest) if let Some(pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, pile, suit) { && can_place_on_foundation(card, pile) {
return Some(dest); return Some(dest);
} }
} }
+5 -14
View File
@@ -225,8 +225,8 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
let mut piles: Vec<PileType> = Vec::with_capacity(13); let mut piles: Vec<PileType> = Vec::with_capacity(13);
piles.push(PileType::Stock); piles.push(PileType::Stock);
piles.push(PileType::Waste); piles.push(PileType::Waste);
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
piles.push(PileType::Foundation(suit)); piles.push(PileType::Foundation(slot));
} }
for i in 0..7 { for i in 0..7 {
piles.push(PileType::Tableau(i)); piles.push(PileType::Tableau(i));
@@ -244,18 +244,9 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
PileMarker(pile.clone()), PileMarker(pile.clone()),
)); ));
// Task #35 — suit symbol on empty foundation placeholders. // Foundation slots no longer carry a suit letter — any Ace can claim
if let PileType::Foundation(suit) = &pile { // any empty slot, so a fixed C/D/H/S badge would be misleading. Empty
let symbol = suit_symbol(suit).to_string(); // foundation markers render as plain translucent rectangles.
entity.with_children(|b| {
b.spawn((
Text2d::new(symbol),
TextFont { font_size, ..default() },
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.45)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
}
// Task #43 — King indicator on empty tableau placeholders. // Task #43 — King indicator on empty tableau placeholders.
if let PileType::Tableau(_) = &pile { if let PileType::Tableau(_) = &pile {
+84 -1
View File
@@ -41,13 +41,17 @@
//! here no-ops so [`crate::selection_plugin`]'s Tab/Enter //! here no-ops so [`crate::selection_plugin`]'s Tab/Enter
//! card-selection still works. //! card-selection still works.
use std::f32::consts::TAU;
use bevy::ecs::query::Has; use bevy::ecs::query::Has;
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform}; use bevy::ui::{ComputedNode, UiGlobalTransform};
use solitaire_data::AnimSpeed;
use crate::settings_plugin::SettingsResource;
use crate::ui_modal::{ButtonVariant, ModalButton, ModalScrim}; use crate::ui_modal::{ButtonVariant, ModalButton, ModalScrim};
use crate::ui_theme::{FOCUS_RING, RADIUS_MD, Z_FOCUS_RING}; use crate::ui_theme::{FOCUS_RING, MOTION_FOCUS_PULSE_SECS, RADIUS_MD, Z_FOCUS_RING};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public component / resource API // Public component / resource API
@@ -126,12 +130,57 @@ impl Plugin for UiFocusPlugin {
clear_hud_focus_on_unhover, clear_hud_focus_on_unhover,
handle_focus_keys, handle_focus_keys,
update_focus_overlay, update_focus_overlay,
pulse_focus_overlay,
) )
.chain(), .chain(),
); );
} }
} }
/// Computes the focus-ring breathing factor for a given elapsed time.
///
/// Returns a value in `[0.65, 1.0]` following a sin curve over
/// [`MOTION_FOCUS_PULSE_SECS`]. Multiply [`FOCUS_RING`]'s native alpha by
/// this factor each frame to produce the breathing effect.
///
/// Pure helper so the curve can be unit-tested without a Bevy app.
pub fn focus_ring_pulse_factor(elapsed_secs: f32) -> f32 {
let phase = (elapsed_secs * TAU / MOTION_FOCUS_PULSE_SECS).sin();
// 0.825 mid-point ± 0.175 amplitude → range [0.65, 1.0]. Multiplicative
// factor against FOCUS_RING's static alpha so the brightest tick is
// exactly the original colour, not a brighter one.
0.825 + 0.175 * phase
}
/// Modulates the focus overlay's border alpha with a slow sin-curve
/// breathing pulse so the indicator catches the eye without competing
/// with gameplay motion. Skipped under `AnimSpeed::Instant` — the static
/// border colour is restored so reduced-motion users see no animation.
fn pulse_focus_overlay(
time: Res<Time>,
settings: Option<Res<SettingsResource>>,
focused: Res<FocusedButton>,
mut overlay: Query<&mut BorderColor, With<FocusOverlay>>,
) {
let Ok(mut border) = overlay.single_mut() else {
return;
};
let instant = settings
.as_deref()
.is_some_and(|s| matches!(s.0.animation_speed, AnimSpeed::Instant));
let factor = if instant || focused.0.is_none() {
1.0
} else {
focus_ring_pulse_factor(time.elapsed_secs())
};
let mut colour = FOCUS_RING;
colour.set_alpha(FOCUS_RING.alpha() * factor);
*border = BorderColor::all(colour);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Private marker for the single overlay entity // Private marker for the single overlay entity
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -588,6 +637,40 @@ mod tests {
spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, UiModalPlugin, spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, UiModalPlugin,
}; };
#[test]
fn focus_ring_pulse_factor_at_zero_is_mid_point() {
// sin(0) = 0 → factor = 0.825 (mid of [0.65, 1.0]).
let f = focus_ring_pulse_factor(0.0);
assert!((f - 0.825).abs() < 1e-5, "factor at t=0 should be 0.825, got {f}");
}
#[test]
fn focus_ring_pulse_factor_peaks_at_quarter_period() {
// sin(τ/4) = 1 → factor = 1.0.
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS / 4.0);
assert!((f - 1.0).abs() < 1e-4, "factor at peak should be 1.0, got {f}");
}
#[test]
fn focus_ring_pulse_factor_troughs_at_three_quarter_period() {
// sin(3τ/4) = -1 → factor = 0.65.
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS * 3.0 / 4.0);
assert!((f - 0.65).abs() < 1e-4, "factor at trough should be 0.65, got {f}");
}
#[test]
fn focus_ring_pulse_factor_stays_in_brightness_range() {
// Sweep across two full periods; factor must stay within [0.65, 1.0].
for i in 0..200 {
let t = i as f32 * MOTION_FOCUS_PULSE_SECS * 0.01;
let f = focus_ring_pulse_factor(t);
assert!(
(0.649..=1.001).contains(&f),
"factor at t={t} out of range: {f}"
);
}
}
/// Plugin-marker for the synthetic test modal — `spawn_modal` /// Plugin-marker for the synthetic test modal — `spawn_modal`
/// requires a `Component` on the scrim. /// requires a `Component` on the scrim.
#[derive(Component, Debug)] #[derive(Component, Debug)]
+137
View File
@@ -16,6 +16,7 @@
//! changing the constant API. //! changing the constant API.
use bevy::color::Color; use bevy::color::Color;
use bevy::math::Vec2;
use bevy::prelude::Val; use bevy::prelude::Val;
use solitaire_data::AnimSpeed; use solitaire_data::AnimSpeed;
@@ -48,6 +49,13 @@ pub const BG_ELEVATED_PRESSED: Color = Color::srgb(0.149, 0.082, 0.357);
/// them. `rgba(13, 7, 28, 0.85)`. /// them. `rgba(13, 7, 28, 0.85)`.
pub const SCRIM: Color = Color::srgba(0.051, 0.027, 0.110, 0.85); pub const SCRIM: Color = Color::srgba(0.051, 0.027, 0.110, 0.85);
/// Translucent fill for the top-of-window HUD band painted by
/// `hud_plugin::spawn_hud_band`. Same midnight-purple hue as `BG_BASE`,
/// but at 0.70 alpha so the green felt reads through subtly — enough
/// to mark the band as "UI" without feeling like a hard chrome strip.
/// `rgba(26, 15, 46, 0.70)`.
pub const BG_HUD_BAND: Color = Color::srgba(0.102, 0.059, 0.180, 0.70);
/// Primary text — warm off-white with a hint of purple to fit the /// Primary text — warm off-white with a hint of purple to fit the
/// midnight palette without feeling clinical. `#F5F0FF`. /// midnight palette without feeling clinical. `#F5F0FF`.
pub const TEXT_PRIMARY: Color = Color::srgb(0.961, 0.941, 1.000); pub const TEXT_PRIMARY: Color = Color::srgb(0.961, 0.941, 1.000);
@@ -88,6 +96,106 @@ pub const STATE_DANGER: Color = Color::srgb(0.969, 0.447, 0.447);
/// Info — daily-challenge constraint, draw-cycle indicator. `#6BBBFF`. /// Info — daily-challenge constraint, draw-cycle indicator. `#6BBBFF`.
pub const STATE_INFO: Color = Color::srgb(0.420, 0.733, 1.000); pub const STATE_INFO: Color = Color::srgb(0.420, 0.733, 1.000);
/// Soft fill colour for the drop-target overlay shown over every legal
/// destination pile while the player is dragging a card. Same green hue
/// as `STATE_SUCCESS` (`#4ADE80`) so the visual language stays
/// consistent, but at 10 % alpha so the underlying card faces remain
/// fully readable through the wash.
pub const DROP_TARGET_FILL: Color = Color::srgba(0.290, 0.871, 0.502, 0.10);
/// Outline colour for the drop-target overlay. Matches the
/// `STATE_SUCCESS` hue at 75 % alpha so the rectangle border reads
/// unmistakably against both the felt and stacked card faces without
/// drowning the cards themselves.
pub const DROP_TARGET_OUTLINE: Color = Color::srgba(0.290, 0.871, 0.502, 0.75);
/// Thickness of the drop-target outline edges, in world-space pixels.
pub const DROP_TARGET_OUTLINE_PX: f32 = 3.0;
/// Sprite-space `Transform.z` for drop-target overlay entities. Sits
/// well above any static card (top stack z is `~1.04`) but well below
/// the lifted dragged stack (`DRAG_Z = 500.0` in `input_plugin`) so the
/// overlay never occludes the card the player is holding. Distinct from
/// the i32 `Z_*` UI-Node tokens above — those are `ZIndex` values for
/// `bevy::ui`, while this is a 2D `Sprite` z coordinate.
pub const Z_DROP_OVERLAY: f32 = 50.0;
/// Background colour of the stock-pile remaining-count chip.
///
/// Reuses `BG_ELEVATED_HI` so the chip reads as one rung above the
/// translucent stock pile marker without introducing a new palette
/// value. The badge sits on the stock corner so the player knows how
/// many cards remain before a recycle.
pub const STOCK_BADGE_BG: Color = BG_ELEVATED_HI;
/// Foreground (text) colour of the stock-pile remaining-count chip.
///
/// `ACCENT_PRIMARY` keeps the chip readable against the elevated
/// purple background and matches the Balatro accent already used for
/// other "look here" callouts.
pub const STOCK_BADGE_FG: Color = ACCENT_PRIMARY;
/// Sprite-space `Transform.z` for the stock-pile remaining-count chip.
///
/// Sits above the stock pile marker (`Z_PILE_MARKER` = `-1`) and any
/// face-down stock cards (which start at `0`), but well below
/// [`Z_DROP_OVERLAY`] (`50.0`) so the green drop-target wash always
/// renders on top while a card is being dragged. Like `Z_DROP_OVERLAY`,
/// this is a 2D `Sprite` z coordinate, not a `bevy::ui` `ZIndex`.
pub const Z_STOCK_BADGE: f32 = 30.0;
// ---------------------------------------------------------------------------
// Card drop-shadow — the subtle dark halo painted beneath every card so the
// play surface reads as physical instead of a flat collage of stickers. Idle
// values are deliberately low-contrast (small offset, ~25% alpha) so resting
// cards feel grounded without competing with focus rings or drop overlays.
// Drag values are slightly stronger (further offset, ~40% alpha, larger
// halo) so the dragged stack visually "lifts" off the felt.
// ---------------------------------------------------------------------------
/// RGB base for the per-card drop shadow. Always neutral black — never
/// suit-tinted — so the shadow never carries colour information that a
/// colour-blind player would rely on to identify a card. Alpha is applied
/// separately via [`CARD_SHADOW_ALPHA_IDLE`] / [`CARD_SHADOW_ALPHA_DRAG`].
pub const CARD_SHADOW_COLOR: Color = Color::srgb(0.0, 0.0, 0.0);
/// Alpha for the resting-state card shadow. Low enough that 52 stacked
/// shadows do not darken the felt into a uniform smear, high enough that
/// each card reads as separated from the surface.
pub const CARD_SHADOW_ALPHA_IDLE: f32 = 0.25;
/// Alpha for the lifted/dragged card shadow. Stronger than the idle value
/// so the dragged stack visibly "casts more shadow" while the player holds
/// it above the table.
pub const CARD_SHADOW_ALPHA_DRAG: f32 = 0.40;
/// World-space pixel offset of the resting-state card shadow relative to
/// its parent card centre. Down-and-right matches a soft top-left light
/// source — the same convention used by the elevated-surface tones in the
/// rest of the palette.
pub const CARD_SHADOW_OFFSET_IDLE: Vec2 = Vec2::new(2.0, -3.0);
/// World-space pixel offset of the lifted/dragged card shadow. Roughly
/// double the idle offset so the parallax reads as "the card is further
/// from the table".
pub const CARD_SHADOW_OFFSET_DRAG: Vec2 = Vec2::new(4.0, -6.0);
/// Padding in pixels added to each axis of the card size when sizing the
/// resting-state shadow sprite. The shadow extends slightly past every
/// edge of the card so the dark border reads as a halo rather than a
/// matte rectangle behind the card.
pub const CARD_SHADOW_PADDING_IDLE: Vec2 = Vec2::new(4.0, 4.0);
/// Padding added to the card size when sizing the lifted/dragged shadow.
/// A slightly larger halo at the drag state reinforces the "lifted off
/// the felt" cue alongside the deeper offset and higher alpha.
pub const CARD_SHADOW_PADDING_DRAG: Vec2 = Vec2::new(8.0, 8.0);
/// Local `Transform.z` for the shadow child sprite, relative to its
/// parent `CardEntity`. Slightly negative so the shadow always renders
/// below the card itself even though it shares the parent's world z.
pub const CARD_SHADOW_LOCAL_Z: f32 = -0.05;
/// Subtle border — default popover, card, and idle button outline. /// Subtle border — default popover, card, and idle button outline.
pub const BORDER_SUBTLE: Color = Color::srgba(0.647, 0.549, 1.000, 0.12); pub const BORDER_SUBTLE: Color = Color::srgba(0.647, 0.549, 1.000, 0.12);
@@ -225,6 +333,11 @@ pub const MOTION_SHAKE_SECS: f32 = 0.25;
/// Shake angular frequency in rad/s. /// Shake angular frequency in rad/s.
pub const MOTION_SHAKE_OMEGA: f32 = 35.0; pub const MOTION_SHAKE_OMEGA: f32 = 35.0;
/// Duration of the smooth return tween when a drag is rejected by an
/// invalid drop target. Short enough to feel snappy but long enough to
/// read as motion rather than a teleport.
pub const MOTION_DRAG_REJECT_SECS: f32 = 0.15;
/// Card flip — half-time per phase (squash + grow). 100 ms each = /// Card flip — half-time per phase (squash + grow). 100 ms each =
/// 200 ms total. Pair with a ±8° Z-rotation at the midpoint for a 3D /// 200 ms total. Pair with a ±8° Z-rotation at the midpoint for a 3D
/// feel without 3D rendering. /// feel without 3D rendering.
@@ -271,10 +384,34 @@ pub const MOTION_BUTTON_BLEND_SECS: f32 = 0.10;
/// readout 1.0 → 1.1 → 1.0. 250 ms. /// readout 1.0 → 1.1 → 1.0. 250 ms.
pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25; pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25;
/// Foundation-completion flourish — when a King lands on a foundation
/// pile (Ace → King, 13 cards), briefly scale the King card 1.0 →
/// [`FOUNDATION_FLOURISH_PEAK_SCALE`] → 1.0 and tint the matching
/// `PileMarker` gold. 400 ms.
pub const MOTION_FOUNDATION_FLOURISH_SECS: f32 = 0.4;
/// Peak scale magnification reached at the midpoint of the
/// foundation-completion flourish. The triangular curve climbs from
/// 1.0 at `t=0` to this value at `t=0.5` and back to 1.0 at `t=1.0`.
pub const FOUNDATION_FLOURISH_PEAK_SCALE: f32 = 1.15;
/// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step. /// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step.
/// 400 ms. /// 400 ms.
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40; pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
/// Period of the focus-ring breathing pulse, in seconds.
///
/// The keyboard focus ring's alpha is modulated by a sin-curve over this
/// interval so the indicator gently "breathes" instead of presenting as
/// a flat outline. 1.4 s reads as a calm heartbeat — slow enough that
/// the motion is in the player's peripheral vision rather than competing
/// for attention, fast enough that a focus change still draws the eye.
/// Not run through [`scaled_duration`]: the pulse is an accessibility
/// affordance, not gameplay motion. `AnimSpeed::Instant` is honoured at
/// the system level by skipping the pulse entirely (see
/// `pulse_focus_overlay` in `ui_focus`).
pub const MOTION_FOCUS_PULSE_SECS: f32 = 1.4;
/// Hover delay before a tooltip appears, in seconds. Long enough that /// Hover delay before a tooltip appears, in seconds. Long enough that
/// players gliding the cursor across the HUD don't see flicker; short /// players gliding the cursor across the HUD don't see flicker; short
/// enough that "stop and read" feels responsive. Not run through /// enough that "stop and read" feels responsive. Not run through