Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 063269c70e | |||
| b126df82b2 | |||
| 655dfde736 | |||
| f712b89fe4 | |||
| f6c916641a | |||
| 95df5421c9 | |||
| fdb6c2ecfe | |||
| 9a3d7f3876 | |||
| c4970b16ea | |||
| 2c72e1fc87 | |||
| efa063fb8f | |||
| 78cf30e906 | |||
| 9a9026e33a | |||
| ab1d098877 | |||
| 160637d1c8 | |||
| 43f13c615e |
+2
-2
@@ -108,5 +108,5 @@ Audio files are MIT-licensed alongside the rest of this project.
|
||||
license.
|
||||
|
||||
If you redistribute Solitaire Quest, you must ship this `CREDITS.md` and the
|
||||
`LICENSE` file alongside the binary so the LGPL and OFL notices remain
|
||||
visible to end users.
|
||||
`LICENSE` file alongside the binary so the MIT (project + hayeah card art)
|
||||
and OFL (FiraMono) notices remain visible to end users.
|
||||
|
||||
Generated
-2
@@ -7643,7 +7643,6 @@ dependencies = [
|
||||
name = "solitaire_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"rand 0.9.4",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
@@ -7723,7 +7722,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
+66
-77
@@ -1,121 +1,110 @@
|
||||
# Solitaire Quest — UX Overhaul Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-01 — Phases 3, 4, and 5 all shipped. Smoke-test bugs closed. v1 release-readiness scope is essentially done; remaining work is the v0.1.0 tag plus desktop packaging.
|
||||
**Last updated:** 2026-05-02 (session 7) — UX iteration round complete: every item from session 6's UX punch list has shipped, plus a font-fallback fix surfaced by a second-machine smoke test. Six commits on top of session 6's `c4970b1`. Direction now opens for the next round — release prep or another UX pass, the player's call.
|
||||
|
||||
## Status at pause
|
||||
|
||||
- **HEAD:** `902560c` — local master is **up to date** with `origin/master`.
|
||||
- **Working tree:** clean.
|
||||
- **HEAD:** `655dfde`. Local master is **3 commits ahead** of `origin/master` (`f6c9166`, `f712b89`, `655dfde` unpushed; `fdb6c2e` and `95df542` already pushed).
|
||||
- **Working tree:** clean. (`CARD_PLAN.md` is untracked but intentionally so — it's a plan doc, not source.)
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||
- **Tests:** **906 passed / 0 failed** across the workspace.
|
||||
- **Tests:** **982 passed / 0 failed** across the workspace (+20 from session 6's 962 baseline).
|
||||
- **Tags on origin:** `v0.9.0`, `v0.10.0`. Stale local-only `v0.1.0` is still safe to `git tag -d v0.1.0`.
|
||||
|
||||
## 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.
|
||||
Session 6's UX punch list was four items. All four shipped today, plus an unrelated font-fallback fix from a second-machine smoke test.
|
||||
|
||||
Polish is essentially complete; the remaining work is tagging v0.1.0 and desktop packaging.
|
||||
The card-theme system, HUD restructure, modal scaffold, and the four big UX feel items (foundations, drop shadows, drop highlights, stock badge) are all in. Direction is open — the deferred release-prep items (`v0.11.0` cut, README/CHANGELOG refresh, desktop packaging) are still on the table, and a fresh round of UX iteration is also available.
|
||||
|
||||
### Design direction (unchanged)
|
||||
|
||||
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
|
||||
- **Palette:** Midnight Purple base + Balatro yellow primary + warm magenta secondary.
|
||||
- See [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for full direction.
|
||||
- See `~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md` (auto-memory; on a different machine, recreate this fresh from the README + ARCHITECTURE.md).
|
||||
|
||||
## Phase 3 (shipped)
|
||||
### Canonical remote
|
||||
|
||||
- `solitaire_engine/src/ui_theme.rs` — every design token: colours, type scale, spacing scale, radius rungs, z-index hierarchy, motion durations.
|
||||
- `solitaire_engine/src/ui_modal.rs` — `spawn_modal` scaffold + button-variant helpers + `paint_modal_buttons` system.
|
||||
- All 12 overlays migrated to the modal scaffold with real Primary/Secondary/Tertiary buttons (no more Y/N debug prompts).
|
||||
- HUD restructured into a 4-tier vertical stack with progressive disclosure.
|
||||
- Animation upgrades: `SmoothSnap` slide curves, scoped settle bounce, deal jitter, win-cascade rotation.
|
||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there. (Earlier sessions used `Rusty_Solitare` — single-i typo — as the repo name; the rename to `Rusty_Solitaire` happened in session 7. Local clone directories may still be named `Rusty_Solitare`; that's just a directory name and works fine.)
|
||||
|
||||
## Phase 4 (shipped 2026-04-30)
|
||||
## Session 7 (shipped 2026-05-02)
|
||||
|
||||
| Area | Commit | What landed |
|
||||
|---|---|---|
|
||||
| Workspace lint | `9bfca92` | Test-only clippy warnings under `--all-targets` resolved. |
|
||||
| App / window | `5f5aba8` | WM_CLASS, centered-on-primary window, panic hook → `crash.log`. |
|
||||
| Modal animation | `71999e1` | `ModalEntering` + ease-out scrim fade and 0.96→1.0 card scale. |
|
||||
| Score feedback | `dcfa976` | `ScorePulse` triangular 1.0→1.1→1.0; floating "+N" for jumps ≥ threshold. |
|
||||
| Hit targets | `b082bd6` | `ICON_BUTTON_PX` 28 → 32; sync status reads "local only". |
|
||||
| Microcopy | `abeb4e5` | Help "Close" → "Done"; final onboarding CTA → "Let's play". |
|
||||
| Empty states | `65d595a` | First-launch em-dash zero-stats grid + welcome line on Profile. |
|
||||
| Leaderboard | `1384365` | Idle/Loaded/Error enum; local-only guard. |
|
||||
| Credits | `fd7fb7b`, `f866299` | CREDITS.md added; README links it. |
|
||||
| Home | `c1bde18` | Home repurposed as Mode Launcher with level-5 lock state. |
|
||||
| Focus rings (Phase 1) | `1278952` | Tab/Shift-Tab/Enter on every modal button; auto-focus primary. |
|
||||
| Focus rings (Phase 2) | `51d3454` | HUD action bar (hover-gated) and Home mode cards. |
|
||||
| Focus rings (Phase 3) | `b78a493` | Settings: icon buttons, swatches, toggles; arrow-key `FocusRow`; auto-scroll. |
|
||||
| Achievement tests | `2e080d0` | Integration coverage for `draw_three_master` and `zen_winner`. |
|
||||
| Microcopy | `0c86cac` | "New game" / "Forfeit" replace "Yes, abandon" / "Yes, forfeit". |
|
||||
| Tooltip infra | `54d3497` | `Tooltip(Cow<'static, str>)` component + hover-delay overlay. |
|
||||
| HUD tooltips | `220e3f0` | 10 readouts + 6 action buttons. |
|
||||
| Settings tooltips | `74597a8` | Volume, toggles, swatches, Sync Now. |
|
||||
| Popover tooltips | `dbe6c60` | Modes and Menu rows. |
|
||||
| Splash | `5d57b67` | Branded splash overlay (300ms fade-in / ~1s hold / 300ms fade-out). |
|
||||
| Doc-rot | `73e210b` | ARCHITECTURE.md `bevy_kira_audio` references → `kira`. |
|
||||
| Doc | `de52c8a`, `60a8036` | Mid-session and end-of-Phase-4 SESSION_HANDOFF refreshes. |
|
||||
| Font fallback | `fdb6c2e` | `shared_fontdb` now `include_bytes!()`s `assets/fonts/main.ttf` (FiraMono) and pins every CSS generic to `"Fira Mono"` so unmatched named families on minimal Linux installs / fresh Wayland sessions / chroots don't drop card rank/suit text. Surfaced when a second-machine pull rendered cards without glyphs. |
|
||||
| Unlock foundations | `95df542` | `PileType::Foundation(Suit)` → `Foundation(u8)` (slot 0..3). `Pile::claimed_suit()` derives the claim from the bottom card — no separate field, no claim-stuck-after-undo class of bugs. `can_place_on_foundation` drops its suit parameter. `next_auto_complete_move` prefers a slot whose claimed suit matches the candidate before falling back to the first empty slot for an Ace. Empty foundation markers render as plain placeholders (no "C/D/H/S"). HUD selection label and hint toast read `claimed_suit()` and fall through to "Foundation N" / "move to foundation" when the slot is empty. Save-format invalidation: `GameState.schema_version` bumped 1 → 2; old `game_state.json` files silently fall through to "fresh game on launch." Stats / progress / achievements / settings live in separate files and are unaffected. 9 new tests. |
|
||||
| Drop overlay | `f6c9166` | The pre-existing `update_drop_highlights` system tinted `PileMarker` sprites green for valid drops, but markers were occluded by stacked cards — invisible during real play. New `update_drop_target_overlays` spawns a soft-fill + 3 px outlined box ABOVE cards for every legal target (full fanned column for tableaux, card-sized for foundations / empty tableaux). `Z_DROP_OVERLAY = 50` sits above static cards but below `DRAG_Z = 500` so the dragged card never gets occluded. Reuses `STATE_SUCCESS` hue. The original marker-tint system is untouched. 3 new tests. |
|
||||
| Drop shadows | `f712b89` | Each `CardEntity` spawns a `CardShadow` child sprite — neutral black at 25 % alpha, sized `card_size + 4 px`, offset `(2, -3)`, local z `-0.05`. `update_card_shadows_on_drag` snaps shadows in `DragState.cards` to a lifted state (40 % alpha, `(4, -6)` offset, `(8, 8)` padding). `resize_cards_in_place` extended to keep shadows cheap on window resize. `update_card_entity`'s `despawn_related` is followed by a fresh `add_card_shadow_child` so flips / theme swaps re-attach shadows. Pure `card_shadow_params(is_dragged)` helper unit-tested. 4 new tests. |
|
||||
| Stock badge | `655dfde` | A small `·N` chip at the top-right corner of the stock pile shows the remaining count. `update_stock_count_badge` spawns a top-level world entity whose `Transform.translation` is recomputed each tick from `LayoutResource`, so window resize / theme swap don't strand it. Hides via `Visibility::Hidden` when the stock empties — the existing `↺` `StockEmptyLabel` takes over and they never co-render. `Z_STOCK_BADGE = 30` sits between cards and `Z_DROP_OVERLAY`. 4 new tests. |
|
||||
|
||||
## Phase 5 (shipped 2026-05-01)
|
||||
## Open punch list — release prep (still deferred unless player chooses now)
|
||||
|
||||
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.
|
||||
1. **Cut `v0.11.0`** — meaningful slice since `v0.10.0`: full card-theme system (CARD_PLAN phases 1–7 + theme picker + hayeah art), HUD overhaul (band + fade), session 6's four bug fixes, and session 7's font fallback + four UX feel wins. (`git tag -d v0.1.0` first to clean up the stale local tag.)
|
||||
2. **README + CHANGELOG refresh** — README was last touched at `a6b8348` before the Settings picker shipped; doesn't mention card themes, the auto-fade, or any of session 7's UX work.
|
||||
3. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo, no remote yet). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
|
||||
|
||||
| Area | Commit | What landed |
|
||||
|---|---|---|
|
||||
| Layout fit | `8dda954` | `card_height` constrained by vertical budget; worst-case 13-card column always fits. |
|
||||
| Resize perf | `1719fda` | In-place sprite/text mutation + 50ms `ResizeThrottle` (was full re-spawn per pixel). |
|
||||
| Resize stall | `59316de` | `PresentMode::AutoNoVsync` eliminates the X11/Wayland vsync stall during drag. |
|
||||
| Window geometry | `6e7705b` | `WindowGeometry` persisted to settings.json; debounced save on resize/move. |
|
||||
| Achievements | `7448225` | Tooltips on rows: reward shown when unlocked, condition + reward when locked, secrets stay cryptic. |
|
||||
| Lint sweep | `4b9d008` | 33 pedantic warnings cleared (`map_unwrap_or`, `uninlined_format_args`, `match_same_arms`). |
|
||||
| Sync tests | `3ef4ecb` | Five client-side round-trip integration tests via in-process axum + mock keyring. |
|
||||
| Splash | `912b08c` | Splash skipped on subsequent launches via existing `first_run_complete` flag. |
|
||||
| Hit test | `902560c` | `card_position` mirrors face-down fan step (0.12) for accurate AABB on tableau columns. |
|
||||
## Open punch list — UX iteration (next-round candidates)
|
||||
|
||||
## Open punch list for v1
|
||||
The session-6 list is exhausted. Candidates for a next round, none formally requested by the player:
|
||||
|
||||
1. **`xCards` upstream URL** in CREDITS.md is intentionally absent (`f866299`). One-line fill-in when the project owner picks a canonical mirror/fork; LGPL notice obligations are already satisfied without it.
|
||||
2. **Tag `v0.1.0`** — workspace builds clean and tests are green; this is the next strategic milestone.
|
||||
3. **Desktop packaging** per ARCHITECTURE.md §17 — Docker compose for the server is documented; desktop client packaging (icon, .ico/.icns, signing, AppImage) is not yet done. Needs artwork and signing certs.
|
||||
- **Animated focus ring** (currently a static overlay; could pulse on focus change).
|
||||
- **Achievement onboarding pass** — show first-time players the achievement panel after their first win.
|
||||
- **Mode-switch keyboard shortcut** from inside the Mode Launcher (today only mouse opens it).
|
||||
- **Runtime aspect-ratio fidelity** — hayeah SVGs are ~1.45 h/w; engine layout assumes 1.4. Cards render ~3 % squashed vertically. Cosmetic.
|
||||
- **Foundation completion celebration** — when a foundation reaches its King, do a small flourish (sparkle, lift, sound). The auto-complete cascade already covers the win moment, but per-foundation closure is currently silent.
|
||||
- **Drag-cancel return animation** — illegal drops snap cards back instantly. A short ease-back tween ("springs back to where it came from") would feel more forgiving.
|
||||
|
||||
### Optional, deferred
|
||||
## Card-theme system (CARD_PLAN.md, fully shipped)
|
||||
|
||||
- Animated focus ring (currently a static overlay; could pulse on focus change).
|
||||
- Achievement onboarding pass — show first-time players the achievement panel after their first win.
|
||||
- Mode-switch keyboard shortcut from inside the Mode Launcher (today only mouse opens it).
|
||||
Seven phases landed across `b8fb3fb` → `924a1e2`. End-to-end:
|
||||
|
||||
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs (MIT) + a midnight-purple `back.svg` (original work).
|
||||
- **User themes** live under `themes://` rooted at `solitaire_engine::assets::user_theme_dir()`. Drop a directory containing `theme.ron` + 53 SVG files; appears in the registry on next launch.
|
||||
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates an archive (20 MB cap, zip-slip rejection, manifest validation, every SVG round-tripped through the rasteriser) and atomically unpacks.
|
||||
- **Picker UI** in Settings → Cosmetic — one chip per registered theme; selection persists to `settings.json` as `selected_theme_id` and propagates to live sprites via `react_to_settings_theme_change` → `sync_card_image_set_with_active_theme` → `StateChangedEvent`.
|
||||
|
||||
## Resume prompt
|
||||
|
||||
```
|
||||
You are a senior Rust + Bevy developer finishing v1 of Solitaire
|
||||
Quest. Working directory: /home/manage/Rusty_Solitare. Branch:
|
||||
master. The polish phase is complete; the remaining work is release
|
||||
prep, not new features.
|
||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||
Working directory: <Rusty_Solitaire clone path on this machine — local
|
||||
directory may still be named Rusty_Solitare from earlier; that's fine>.
|
||||
Branch: master. Direction is OPEN — the session-6 UX punch list is
|
||||
fully shipped. The player will choose between cutting v0.11.0, doing
|
||||
release prep (README/CHANGELOG/packaging), or starting a new UX
|
||||
iteration round.
|
||||
|
||||
State: HEAD=902560c, fully pushed to origin. Working tree clean.
|
||||
State: HEAD=655dfde. Local master is 3 commits ahead of origin
|
||||
(f6c9166, f712b89, 655dfde unpushed; fdb6c2e and 95df542 already
|
||||
pushed). Working tree clean apart from untracked CARD_PLAN.md
|
||||
(intentional).
|
||||
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
||||
Tests: 906 passed / 0 failed.
|
||||
Tests: 982 passed / 0 failed.
|
||||
|
||||
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.)
|
||||
3. ARCHITECTURE.md §15, §17 — platform targets, deployment guide
|
||||
4. ~/.claude/projects/-home-manage-Rusty-Solitare/memory/MEMORY.md
|
||||
— saved feedback / project context
|
||||
3. ARCHITECTURE.md — crate responsibilities + data flow
|
||||
4. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||
— saved feedback / project context (machine-local;
|
||||
may be missing on a fresh machine)
|
||||
|
||||
PUNCH LIST (in priority order):
|
||||
1. Confirm or fill the xCards upstream URL in CREDITS.md (one-line
|
||||
edit; not a release blocker).
|
||||
2. Tag v0.1.0 once the user signs off.
|
||||
3. Desktop packaging: icon hookup, platform bundles (.ico/.icns/
|
||||
AppImage), signing. Needs artwork and certs from the user.
|
||||
DECISION TO ASK THE PLAYER FIRST:
|
||||
A. Push the 3 unpushed commits and cut v0.11.0?
|
||||
B. Skip the tag for now, refresh README + CHANGELOG, then tag?
|
||||
C. Skip release prep entirely and start a new UX iteration round?
|
||||
If C, see the session-7 next-round candidates list (animated
|
||||
focus ring, achievement onboarding, mode-switch keyboard
|
||||
shortcut, aspect-ratio fidelity, foundation completion flourish,
|
||||
drag-cancel return tween).
|
||||
|
||||
WORKFLOW NOTES:
|
||||
- Commits use:
|
||||
git -c user.name=funman300 -c user.email=root@vscode.infinity commit -m "..."
|
||||
git -c user.name=funman300 -c user.email=root@vscode.infinity \
|
||||
commit -m "..."
|
||||
- Sub-agents stage + verify only; orchestrator commits.
|
||||
- Every commit must pass build / clippy / test.
|
||||
- Every commit must pass build / clippy / test before pushing.
|
||||
- Push to GitHub (origin) — that is the canonical remote.
|
||||
|
||||
OPEN AT THE START: ask which punch-list item to start on. Don't pick
|
||||
unilaterally — release-readiness ordering is the user's call.
|
||||
OPEN AT THE START: ask which of A / B / C. Don't pick unilaterally —
|
||||
this is a directional choice, not a tactical one.
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
> Last updated: 2026-04-25
|
||||
> Branch: `master` — pushed to https://git.aleshym.co/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
|
||||
|
||||
---
|
||||
|
||||
@@ -6,6 +6,5 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::card::{Card, Suit};
|
||||
use crate::card::Card;
|
||||
use crate::deck::{deal_klondike, Deck};
|
||||
use crate::error::MoveError;
|
||||
use crate::pile::{Pile, PileType};
|
||||
@@ -9,6 +9,20 @@ use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score
|
||||
|
||||
const MAX_UNDO_STACK: usize = 64;
|
||||
|
||||
/// Save-file schema version for `GameState`. Increment when the on-disk
|
||||
/// representation changes incompatibly so `load_game_state_from` can refuse
|
||||
/// older formats and start the player on a fresh game.
|
||||
///
|
||||
/// History:
|
||||
/// - v1: `Foundation(Suit)` keys.
|
||||
/// - v2 (current): `Foundation(u8)` slot keys; claimed suit derived from the
|
||||
/// bottom card of the pile.
|
||||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
/// Default value for `GameState::schema_version` when deserialising older
|
||||
/// save files that pre-date the field.
|
||||
fn schema_v1() -> u32 { 1 }
|
||||
|
||||
/// Serialize `HashMap<PileType, Pile>` as a `Vec` of `(key, value)` pairs so
|
||||
/// that JSON (which requires string map keys) round-trips correctly.
|
||||
mod pile_map_serde {
|
||||
@@ -98,6 +112,11 @@ pub struct GameState {
|
||||
/// Used by the `comeback` achievement condition.
|
||||
#[serde(default)]
|
||||
pub recycle_count: u32,
|
||||
/// Save-file schema version. Defaults to `1` for older files that pre-date
|
||||
/// the field. The loader refuses any value other than
|
||||
/// [`GAME_STATE_SCHEMA_VERSION`].
|
||||
#[serde(default = "schema_v1")]
|
||||
pub schema_version: u32,
|
||||
undo_stack: VecDeque<StateSnapshot>,
|
||||
}
|
||||
|
||||
@@ -116,8 +135,8 @@ impl GameState {
|
||||
let mut piles: HashMap<PileType, Pile> = HashMap::new();
|
||||
piles.insert(PileType::Stock, stock);
|
||||
piles.insert(PileType::Waste, Pile::new(PileType::Waste));
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
piles.insert(PileType::Foundation(suit), Pile::new(PileType::Foundation(suit)));
|
||||
for slot in 0..4_u8 {
|
||||
piles.insert(PileType::Foundation(slot), Pile::new(PileType::Foundation(slot)));
|
||||
}
|
||||
for (i, pile) in tableau.into_iter().enumerate() {
|
||||
piles.insert(PileType::Tableau(i), pile);
|
||||
@@ -135,6 +154,7 @@ impl GameState {
|
||||
is_auto_completable: false,
|
||||
undo_count: 0,
|
||||
recycle_count: 0,
|
||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||
undo_stack: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
@@ -247,14 +267,14 @@ impl GameState {
|
||||
let bottom_card = from_pile.cards[start].clone();
|
||||
|
||||
match &to {
|
||||
PileType::Foundation(suit) => {
|
||||
PileType::Foundation(_) => {
|
||||
if count != 1 {
|
||||
return Err(MoveError::RuleViolation(
|
||||
"only one card can move to foundation at a time".into(),
|
||||
));
|
||||
}
|
||||
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
|
||||
if !can_place_on_foundation(&bottom_card, dest, *suit) {
|
||||
if !can_place_on_foundation(&bottom_card, dest) {
|
||||
return Err(MoveError::RuleViolation("invalid foundation placement".into()));
|
||||
}
|
||||
}
|
||||
@@ -332,15 +352,13 @@ impl GameState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` when all four foundations each contain 13 cards.
|
||||
/// Returns `true` when all four foundation slots each contain 13 cards.
|
||||
pub fn check_win(&self) -> bool {
|
||||
[Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]
|
||||
.iter()
|
||||
.all(|&suit| {
|
||||
self.piles
|
||||
.get(&PileType::Foundation(suit))
|
||||
.is_some_and(|p| p.cards.len() == 13)
|
||||
})
|
||||
(0..4_u8).all(|slot| {
|
||||
self.piles
|
||||
.get(&PileType::Foundation(slot))
|
||||
.is_some_and(|p| p.cards.len() == 13)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
|
||||
@@ -379,13 +397,34 @@ impl GameState {
|
||||
if !self.is_auto_completable || self.is_won {
|
||||
return None;
|
||||
}
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
for i in 0..7 {
|
||||
let tableau = PileType::Tableau(i);
|
||||
if let Some(card) = self.piles[&tableau].cards.last() {
|
||||
for &suit in &suits {
|
||||
let foundation = PileType::Foundation(suit);
|
||||
if can_place_on_foundation(card, &self.piles[&foundation], suit) {
|
||||
// Prefer the slot that already claims this card's suit so
|
||||
// Aces don't sometimes land in slot 0 and then leave the
|
||||
// matching suit-claimed slot empty.
|
||||
let mut candidate: Option<u8> = None;
|
||||
let mut empty_slot: Option<u8> = None;
|
||||
for slot in 0..4_u8 {
|
||||
let foundation = PileType::Foundation(slot);
|
||||
let pile = &self.piles[&foundation];
|
||||
if pile.cards.is_empty() {
|
||||
if empty_slot.is_none() {
|
||||
empty_slot = Some(slot);
|
||||
}
|
||||
} else if pile.claimed_suit() == Some(card.suit) {
|
||||
candidate = Some(slot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let target_slot = candidate.or_else(|| {
|
||||
// Only fall back to an empty slot if the card is an Ace,
|
||||
// which is the only rank that can claim an empty slot.
|
||||
if card.rank.value() == 1 { empty_slot } else { None }
|
||||
});
|
||||
if let Some(slot) = target_slot {
|
||||
let foundation = PileType::Foundation(slot);
|
||||
if can_place_on_foundation(card, &self.piles[&foundation]) {
|
||||
return Some((tableau, foundation));
|
||||
}
|
||||
}
|
||||
@@ -403,7 +442,7 @@ impl GameState {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card::{Card, Rank};
|
||||
use crate::card::{Card, Rank, Suit};
|
||||
|
||||
fn new_game() -> GameState {
|
||||
GameState::new(42, DrawMode::DrawOne)
|
||||
@@ -434,8 +473,8 @@ mod tests {
|
||||
#[test]
|
||||
fn new_game_foundations_are_empty() {
|
||||
let g = new_game();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
assert!(g.piles[&PileType::Foundation(suit)].cards.is_empty());
|
||||
for slot in 0..4_u8 {
|
||||
assert!(g.piles[&PileType::Foundation(slot)].cards.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,7 +701,7 @@ mod tests {
|
||||
];
|
||||
let result = g.move_cards(
|
||||
PileType::Tableau(0),
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(0),
|
||||
2,
|
||||
);
|
||||
assert!(
|
||||
@@ -706,8 +745,9 @@ mod tests {
|
||||
#[test]
|
||||
fn win_detection_all_foundations_complete() {
|
||||
let mut g = new_game();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let f = g.piles.get_mut(&PileType::Foundation(suit)).unwrap();
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
for (slot, suit) in suits.into_iter().enumerate() {
|
||||
let f = g.piles.get_mut(&PileType::Foundation(slot as u8)).unwrap();
|
||||
f.cards.clear();
|
||||
for rank in [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||
@@ -1039,7 +1079,8 @@ mod tests {
|
||||
|
||||
let mv = g.next_auto_complete_move().expect("should find a move");
|
||||
assert_eq!(mv.0, PileType::Tableau(0));
|
||||
assert_eq!(mv.1, PileType::Foundation(Suit::Clubs));
|
||||
// Slot 0 is the first empty foundation; the Ace lands there.
|
||||
assert_eq!(mv.1, PileType::Foundation(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1049,4 +1090,143 @@ mod tests {
|
||||
g.is_won = true;
|
||||
assert!(g.next_auto_complete_move().is_none());
|
||||
}
|
||||
|
||||
// --- Slot-based foundation behaviour (refactor coverage) ---
|
||||
|
||||
/// Aces land in the first empty slot regardless of suit, and successive
|
||||
/// Aces fan out across slots 0, 1, 2, 3 in deterministic order.
|
||||
#[test]
|
||||
fn any_ace_lands_in_first_empty_foundation() {
|
||||
let mut g = new_game();
|
||||
// Clear stock/waste/tableau so we can hand-construct moves directly.
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
// Place an Ace of Clubs on tableau 0; move it to slot 0.
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||||
// Now place an Ace of Spades on tableau 0 and move it to slot 1.
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(1), 1).unwrap();
|
||||
|
||||
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Clubs));
|
||||
assert_eq!(g.piles[&PileType::Foundation(1)].claimed_suit(), Some(Suit::Spades));
|
||||
}
|
||||
|
||||
/// `Pile::claimed_suit` reads the bottom card's suit on a populated
|
||||
/// foundation slot, regardless of which slot index the pile occupies.
|
||||
#[test]
|
||||
fn claimed_suit_is_derived_from_bottom_card() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 50, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(2), 1).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
g.piles[&PileType::Foundation(2)].claimed_suit(),
|
||||
Some(Suit::Hearts)
|
||||
);
|
||||
}
|
||||
|
||||
/// Undoing the only card from a foundation slot drops the claimed suit;
|
||||
/// the slot then accepts a different Ace.
|
||||
#[test]
|
||||
fn foundation_claim_drops_when_emptied_via_undo() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||||
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Hearts));
|
||||
|
||||
g.undo().unwrap();
|
||||
assert!(g.piles[&PileType::Foundation(0)].cards.is_empty());
|
||||
assert!(g.piles[&PileType::Foundation(0)].claimed_suit().is_none());
|
||||
|
||||
// A different Ace can now claim slot 0.
|
||||
let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.clear();
|
||||
t0.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||||
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Spades));
|
||||
}
|
||||
|
||||
/// Successive Aces from the waste pile distribute across slots 0..=3 in
|
||||
/// order — the player picks the slot, but `move_cards` accepts any
|
||||
/// empty-slot placement for an Ace.
|
||||
#[test]
|
||||
fn multiple_aces_distribute_across_slots() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
let aces = [
|
||||
(Suit::Clubs, 10),
|
||||
(Suit::Diamonds, 11),
|
||||
(Suit::Hearts, 12),
|
||||
(Suit::Spades, 13),
|
||||
];
|
||||
for (slot, (suit, id)) in aces.iter().enumerate() {
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||||
id: *id, suit: *suit, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Waste, PileType::Foundation(slot as u8), 1).unwrap();
|
||||
}
|
||||
for (slot, (suit, _)) in aces.iter().enumerate() {
|
||||
assert_eq!(
|
||||
g.piles[&PileType::Foundation(slot as u8)].claimed_suit(),
|
||||
Some(*suit),
|
||||
"slot {slot} should claim {suit:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-complete prefers the foundation slot whose claimed suit matches
|
||||
/// the candidate card's suit, even if an empty slot exists at a lower
|
||||
/// index.
|
||||
#[test]
|
||||
fn next_auto_complete_move_picks_slot_with_matching_claim() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
// Slot 0 is empty; slot 1 already claims Hearts via Ace of Hearts.
|
||||
g.piles.get_mut(&PileType::Foundation(1)).unwrap().cards.push(Card {
|
||||
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
// Tableau 0 holds the 2 of Hearts to play.
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 2, suit: Suit::Hearts, rank: Rank::Two, face_up: true,
|
||||
});
|
||||
g.is_auto_completable = true;
|
||||
|
||||
let mv = g.next_auto_complete_move().expect("auto-complete must find slot 1");
|
||||
assert_eq!(mv.0, PileType::Tableau(0));
|
||||
assert_eq!(
|
||||
mv.1,
|
||||
PileType::Foundation(1),
|
||||
"must target the Hearts-claimed slot, not the empty slot 0",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ pub enum PileType {
|
||||
Stock,
|
||||
/// The face-up discard pile drawn to.
|
||||
Waste,
|
||||
/// One of the four suit-ordered foundation piles.
|
||||
Foundation(Suit),
|
||||
/// One of the four foundation slots (0..=3). The claimed suit, if any,
|
||||
/// is derived from the bottom card of the pile (always an Ace by
|
||||
/// construction).
|
||||
Foundation(u8),
|
||||
/// One of the seven tableau columns (0–6).
|
||||
Tableau(usize),
|
||||
}
|
||||
@@ -17,7 +19,7 @@ pub enum PileType {
|
||||
/// A named collection of cards in a specific board position.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Pile {
|
||||
/// Which pile this is (Stock, Waste, Foundation suit, or Tableau column).
|
||||
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
|
||||
pub pile_type: PileType,
|
||||
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||||
pub cards: Vec<Card>,
|
||||
@@ -33,6 +35,16 @@ impl Pile {
|
||||
pub fn top(&self) -> Option<&Card> {
|
||||
self.cards.last()
|
||||
}
|
||||
|
||||
/// For foundation piles: returns `Some(suit)` once at least one card has
|
||||
/// landed (the bottom card is always an Ace of the claimed suit).
|
||||
/// Returns `None` for empty foundations or non-foundation piles.
|
||||
pub fn claimed_suit(&self) -> Option<Suit> {
|
||||
match self.pile_type {
|
||||
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -61,12 +73,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_type_foundation_uses_suit() {
|
||||
assert_ne!(PileType::Foundation(Suit::Hearts), PileType::Foundation(Suit::Spades));
|
||||
fn pile_type_foundation_uses_slot_index() {
|
||||
assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_type_tableau_uses_index() {
|
||||
assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_is_none_for_empty_foundation() {
|
||||
let pile = Pile::new(PileType::Foundation(0));
|
||||
assert!(pile.claimed_suit().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_is_none_for_non_foundation() {
|
||||
let mut pile = Pile::new(PileType::Tableau(0));
|
||||
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
|
||||
assert!(pile.claimed_suit().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_returns_bottom_card_suit() {
|
||||
let mut pile = Pile::new(PileType::Foundation(2));
|
||||
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
|
||||
pile.cards.push(Card { id: 1, suit: Suit::Hearts, rank: Rank::Two, face_up: true });
|
||||
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
|
||||
}
|
||||
}
|
||||
|
||||
+37
-26
@@ -1,16 +1,18 @@
|
||||
use crate::card::{Card, Suit};
|
||||
use crate::card::Card;
|
||||
use crate::pile::Pile;
|
||||
|
||||
/// Returns `true` if `card` can be placed on `pile` as the next card in the foundation for `suit`.
|
||||
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
||||
///
|
||||
/// Foundation rules: same suit, Ace starts, each subsequent card is one rank higher.
|
||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile, suit: Suit) -> bool {
|
||||
if card.suit != suit {
|
||||
return false;
|
||||
}
|
||||
/// Foundation rules:
|
||||
/// - When the pile is empty, any Ace is accepted; the placed Ace's suit
|
||||
/// becomes the pile's claimed suit (derived from the bottom card via
|
||||
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
|
||||
/// - When the pile is non-empty, the next card must match the top card's
|
||||
/// suit and be exactly one rank higher.
|
||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
||||
match pile.cards.last() {
|
||||
None => card.rank.value() == 1,
|
||||
Some(top) => card.rank.value() == top.rank.value() + 1,
|
||||
Some(top) => card.suit == top.suit && card.rank.value() == top.rank.value() + 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,37 +47,46 @@ mod tests {
|
||||
// Foundation tests
|
||||
#[test]
|
||||
fn foundation_ace_on_empty_is_valid() {
|
||||
let c = card(Suit::Hearts, Rank::Ace);
|
||||
let p = Pile::new(PileType::Foundation(Suit::Hearts));
|
||||
assert!(can_place_on_foundation(&c, &p, Suit::Hearts));
|
||||
// Every suit's Ace must land on an empty foundation slot regardless of
|
||||
// its slot index; the slot claims the suit only after the Ace lands.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let c = card(suit, Rank::Ace);
|
||||
let p = Pile::new(PileType::Foundation(0));
|
||||
assert!(
|
||||
can_place_on_foundation(&c, &p),
|
||||
"Ace of {suit:?} must land on empty slot 0",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_non_ace_on_empty_is_invalid() {
|
||||
let c = card(Suit::Hearts, Rank::Two);
|
||||
let p = Pile::new(PileType::Foundation(Suit::Hearts));
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Hearts));
|
||||
let p = Pile::new(PileType::Foundation(0));
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_two_on_ace_same_suit_is_valid() {
|
||||
let c = card(Suit::Clubs, Rank::Two);
|
||||
let p = pile_with(PileType::Foundation(Suit::Clubs), vec![card(Suit::Clubs, Rank::Ace)]);
|
||||
assert!(can_place_on_foundation(&c, &p, Suit::Clubs));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
|
||||
assert!(can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_wrong_suit_is_invalid() {
|
||||
let c = card(Suit::Hearts, Rank::Ace);
|
||||
let p = Pile::new(PileType::Foundation(Suit::Spades));
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
|
||||
fn foundation_second_card_must_match_claimed_suit() {
|
||||
// Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected
|
||||
// because the slot's claimed suit is Hearts after the Ace lands.
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Hearts, Rank::Ace)]);
|
||||
let c = card(Suit::Spades, Rank::Two);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_skipping_rank_is_invalid() {
|
||||
let c = card(Suit::Diamonds, Rank::Three);
|
||||
let p = pile_with(PileType::Foundation(Suit::Diamonds), vec![card(Suit::Diamonds, Rank::Ace)]);
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Diamonds));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Diamonds, Rank::Ace)]);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
// Tableau tests
|
||||
@@ -125,16 +136,16 @@ mod tests {
|
||||
fn foundation_king_on_queen_completes_suit() {
|
||||
// The last card placed to complete a foundation is always King on Queen.
|
||||
let c = card(Suit::Spades, Rank::King);
|
||||
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(can_place_on_foundation(&c, &p, Suit::Spades));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_king_wrong_suit_is_invalid() {
|
||||
// King of Hearts cannot go on a Spades foundation even if rank matches.
|
||||
// King of Hearts cannot go on a Spades-claimed foundation even if rank matches.
|
||||
let c = card(Suit::Hearts, Rank::King);
|
||||
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,12 +33,11 @@ pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card::Suit;
|
||||
|
||||
#[test]
|
||||
fn move_to_foundation_scores_ten() {
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(Suit::Hearts)), 10);
|
||||
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(Suit::Clubs)), 10);
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
|
||||
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(0)), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -74,7 +73,7 @@ mod tests {
|
||||
#[test]
|
||||
fn non_waste_to_tableau_scores_zero() {
|
||||
// Foundation → Tableau is impossible in practice but must score 0.
|
||||
assert_eq!(score_move(&PileType::Foundation(Suit::Clubs), &PileType::Tableau(0)), 0);
|
||||
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), 0);
|
||||
// Tableau → Tableau (restack) scores 0.
|
||||
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
|
||||
|
||||
use crate::stats::StatsSnapshot;
|
||||
|
||||
@@ -72,10 +72,21 @@ pub fn game_state_file_path() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
|
||||
/// missing, corrupt, or represents a finished game.
|
||||
/// missing, corrupt, represents a finished game, or carries a save-schema
|
||||
/// version other than [`GAME_STATE_SCHEMA_VERSION`].
|
||||
///
|
||||
/// Schema mismatch is treated as "no save" so a player upgrading across an
|
||||
/// incompatible game-state format change starts fresh instead of seeing a
|
||||
/// half-loaded game (or a deserialiser error). v1 saves with the old
|
||||
/// `Foundation(Suit)` key shape will fail to parse outright; any v1 saves
|
||||
/// that happen to round-trip but report `schema_version: 1` are also rejected
|
||||
/// here.
|
||||
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
|
||||
let data = fs::read(path).ok()?;
|
||||
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
||||
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
|
||||
return None;
|
||||
}
|
||||
if gs.is_won {
|
||||
None
|
||||
} else {
|
||||
@@ -331,4 +342,49 @@ mod tests {
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||
}
|
||||
|
||||
/// Pre-v2 save files used `Foundation(Suit)` keys and either fail to
|
||||
/// parse outright or surface a `schema_version: 1`. Either path must
|
||||
/// produce `None` so the player launches into a fresh game.
|
||||
///
|
||||
/// Sibling assertion: the stats round-trip path is unaffected — only
|
||||
/// the game-state schema bumped.
|
||||
#[test]
|
||||
fn save_format_v1_is_rejected() {
|
||||
let path = gs_path("schema_v1");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// A pared-down v1 JSON literal: foundation pile keys use the old
|
||||
// suit-tagged form and the file omits `schema_version` (so it
|
||||
// deserialises with the default of 1). Even if a future change
|
||||
// makes `Foundation(Suit)` parse-compatible, the schema-version
|
||||
// gate keeps this case rejected.
|
||||
let v1_json = r#"{
|
||||
"piles": [
|
||||
[{"Foundation": "Hearts"}, {"pile_type": {"Foundation": "Hearts"}, "cards": []}]
|
||||
],
|
||||
"draw_mode": "DrawOne",
|
||||
"score": 0,
|
||||
"move_count": 0,
|
||||
"elapsed_seconds": 0,
|
||||
"seed": 42,
|
||||
"is_won": false,
|
||||
"is_auto_completable": false,
|
||||
"undo_count": 0,
|
||||
"undo_stack": []
|
||||
}"#;
|
||||
fs::write(&path, v1_json).expect("write v1 fixture");
|
||||
|
||||
assert!(
|
||||
load_game_state_from(&path).is_none(),
|
||||
"v1 game_state.json must be rejected (parse failure or schema bump)",
|
||||
);
|
||||
|
||||
// Sibling sanity: stats files are independent and still round-trip.
|
||||
let stats_path = tmp_path("schema_unrelated_stats");
|
||||
let _ = fs::remove_file(&stats_path);
|
||||
save_stats_to(&stats_path, &StatsSnapshot::default()).expect("save stats");
|
||||
let loaded = load_stats_from(&stats_path);
|
||||
assert_eq!(loaded, StatsSnapshot::default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
//! loading via `load_with_settings(...)`. The default of 512×768 is a
|
||||
//! safe fallback that fits a typical 2:3 playing card.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use bevy::asset::io::Reader;
|
||||
use bevy::asset::{AssetLoader, LoadContext, RenderAssetUsages};
|
||||
use bevy::image::Image;
|
||||
@@ -27,6 +29,7 @@ use bevy::reflect::TypePath;
|
||||
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use usvg::fontdb;
|
||||
|
||||
/// Per-asset settings consumed by [`SvgLoader::load`].
|
||||
///
|
||||
@@ -102,7 +105,16 @@ impl AssetLoader for SvgLoader {
|
||||
/// thumbnail generators) can rasterise without going through the
|
||||
/// asset graph.
|
||||
pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoaderError> {
|
||||
let opt = usvg::Options::default();
|
||||
let opt = usvg::Options {
|
||||
fontdb: shared_fontdb(),
|
||||
// Default for SVG elements without an explicit `font-family` —
|
||||
// resolved by fontdb's generic-family alias to whatever
|
||||
// sans-serif the system has installed (DejaVu Sans on most
|
||||
// Linux installs, Helvetica on macOS, Arial on Windows).
|
||||
font_family: "sans-serif".to_string(),
|
||||
font_resolver: lenient_font_resolver(),
|
||||
..Default::default()
|
||||
};
|
||||
let tree = usvg::Tree::from_data(svg_bytes, &opt)?;
|
||||
|
||||
let svg_size = tree.size();
|
||||
@@ -140,6 +152,104 @@ pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoader
|
||||
))
|
||||
}
|
||||
|
||||
/// Returns a process-wide font database populated with the OS-installed
|
||||
/// fonts plus the bundled FiraMono-Medium face. Initialised lazily on
|
||||
/// first SVG that references text, then shared (via `Arc`) across every
|
||||
/// subsequent rasterisation.
|
||||
///
|
||||
/// `usvg::Options::default()` ships an empty `fontdb`, so without this
|
||||
/// call any text glyph in an SVG renders with no font match — the
|
||||
/// visible symptom on the bundled hayeah artwork is the "No match for
|
||||
/// Arial font-family" warn spam plus glyphs that fall through to
|
||||
/// whatever shape-only path usvg uses for missing fonts.
|
||||
///
|
||||
/// **Bundled font as last-resort fallback.** Loading only system fonts
|
||||
/// breaks on minimal Linux installs, fresh Wayland sessions, and
|
||||
/// chroots where fontconfig has nothing usable to serve as
|
||||
/// `sans-serif`. The cards on the bundled hayeah theme reference
|
||||
/// `Bitstream Vera Sans` and `Arial` by name — if neither is installed
|
||||
/// AND the resolver's CSS-generic fallbacks (`SansSerif`/`Serif`) also
|
||||
/// don't resolve, the rank/suit text vanishes entirely. Loading the
|
||||
/// project's bundled FiraMono via `include_bytes!()` and pinning it as
|
||||
/// the generic-family target guarantees a working last-resort glyph
|
||||
/// source on every machine. This was the cause of "card font didn't
|
||||
/// carry over" on a fresh second-machine pull.
|
||||
///
|
||||
/// `load_system_fonts` is comparatively expensive (~50–200 ms on a
|
||||
/// typical desktop) so we only pay it once for the lifetime of the
|
||||
/// process, gated by `OnceLock`.
|
||||
fn shared_fontdb() -> Arc<fontdb::Database> {
|
||||
static DB: OnceLock<Arc<fontdb::Database>> = OnceLock::new();
|
||||
DB.get_or_init(|| {
|
||||
let mut db = fontdb::Database::new();
|
||||
db.load_system_fonts();
|
||||
// The bundled FiraMono lives at the workspace root, so the
|
||||
// include_bytes! path goes up three levels from this source
|
||||
// file (assets → src → solitaire_engine → workspace root).
|
||||
db.load_font_data(include_bytes!("../../../assets/fonts/main.ttf").to_vec());
|
||||
// Pin the CSS generics to the bundled face as the resolution
|
||||
// target. Named-family lookups (Bitstream Vera Sans, Arial)
|
||||
// still try the system db first; only when those miss does
|
||||
// the resolver fall through to SansSerif / Serif, and now
|
||||
// those are guaranteed to land on FiraMono.
|
||||
db.set_sans_serif_family("Fira Mono");
|
||||
db.set_serif_family("Fira Mono");
|
||||
db.set_monospace_family("Fira Mono");
|
||||
db.set_cursive_family("Fira Mono");
|
||||
db.set_fantasy_family("Fira Mono");
|
||||
Arc::new(db)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Builds a `usvg::FontResolver` that mirrors the upstream default
|
||||
/// `select_font` but appends the CSS generics `sans-serif` and `serif`
|
||||
/// to every query's family list. The upstream selector only appends
|
||||
/// `serif` and emits a `log::warn!` when its `fontdb.query` returns
|
||||
/// `None`; on systems without the named families requested by the
|
||||
/// SVG (e.g. Arial on Linux), every text node bridges that warn into
|
||||
/// our tracing output. By appending two generics — both resolved via
|
||||
/// fontconfig (or fontdb's built-in defaults) to whatever sans-serif /
|
||||
/// serif the user has installed — we guarantee the query finds *some*
|
||||
/// face, so the warn branch is never taken. The visible behaviour is
|
||||
/// "use the system's default font when the requested one isn't
|
||||
/// installed", which is the intent here.
|
||||
///
|
||||
/// The fallback `select_fallback` is kept as the upstream default —
|
||||
/// per-character fallback (for combining marks, scripts the primary
|
||||
/// face doesn't cover) doesn't have the same warn-spam pathology.
|
||||
fn lenient_font_resolver() -> usvg::FontResolver<'static> {
|
||||
use usvg::{FontFamily, FontResolver};
|
||||
|
||||
usvg::FontResolver {
|
||||
select_font: Box::new(|font, db| {
|
||||
let mut families: Vec<fontdb::Family> = font
|
||||
.families()
|
||||
.iter()
|
||||
.map(|f| match f {
|
||||
FontFamily::Serif => fontdb::Family::Serif,
|
||||
FontFamily::SansSerif => fontdb::Family::SansSerif,
|
||||
FontFamily::Cursive => fontdb::Family::Cursive,
|
||||
FontFamily::Fantasy => fontdb::Family::Fantasy,
|
||||
FontFamily::Monospace => fontdb::Family::Monospace,
|
||||
FontFamily::Named(s) => fontdb::Family::Name(s),
|
||||
})
|
||||
.collect();
|
||||
families.push(fontdb::Family::SansSerif);
|
||||
families.push(fontdb::Family::Serif);
|
||||
|
||||
let query = fontdb::Query {
|
||||
families: &families,
|
||||
weight: fontdb::Weight(font.weight()),
|
||||
stretch: font.stretch().into(),
|
||||
style: font.style().into(),
|
||||
};
|
||||
db.query(&query)
|
||||
}),
|
||||
select_fallback: FontResolver::default_fallback_selector(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -174,6 +284,28 @@ mod tests {
|
||||
assert!(matches!(err, SvgLoaderError::PixmapAlloc(0, 100)));
|
||||
}
|
||||
|
||||
/// SVG with a text node that requests an unlikely-installed family
|
||||
/// ("FontThatProbablyDoesNotExist"). Exercises `lenient_font_resolver`'s
|
||||
/// "fall through to system sans-serif/serif" behaviour: rasterising
|
||||
/// must succeed, never panic, and the test runner's log output must
|
||||
/// not contain `No match for ... font-family.` for the named family.
|
||||
/// Catching the warn directly would require a tracing subscriber; we
|
||||
/// rely on `cargo test`'s default behaviour of capturing stdout/stderr
|
||||
/// and surfacing only failing tests' output, plus visual review of
|
||||
/// the suite's log stream.
|
||||
const TEST_SVG_WITH_TEXT: &[u8] = br##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="200" height="300">
|
||||
<text x="100" y="150" style="font-family:FontThatProbablyDoesNotExist;font-size:32">A</text>
|
||||
</svg>"##;
|
||||
|
||||
#[test]
|
||||
fn rasterizes_svg_with_unmatched_font_family() {
|
||||
let image =
|
||||
rasterize_svg(TEST_SVG_WITH_TEXT, UVec2::new(64, 96)).expect("rasterisation");
|
||||
assert_eq!(image.size().x, 64);
|
||||
assert_eq!(image.size().y, 96);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_svg() {
|
||||
let err = rasterize_svg(b"not actually svg", UVec2::new(64, 96)).unwrap_err();
|
||||
|
||||
@@ -196,7 +196,8 @@ mod tests {
|
||||
// At least one MoveRequestEvent should have been fired.
|
||||
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
|
||||
assert_eq!(fired[0].from, PileType::Tableau(0));
|
||||
assert_eq!(fired[0].to, PileType::Foundation(Suit::Clubs));
|
||||
// First empty foundation slot wins on a fresh nearly-won board.
|
||||
assert_eq!(fired[0].to, PileType::Foundation(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -29,6 +29,12 @@ use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
use crate::table_plugin::PileMarker;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::ui_theme::{
|
||||
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
||||
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
|
||||
CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TYPE_CAPTION, Z_STOCK_BADGE,
|
||||
};
|
||||
|
||||
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
||||
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
@@ -132,6 +138,27 @@ pub struct RightClickHighlightTimer(pub f32);
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StockEmptyLabel;
|
||||
|
||||
/// Marker on the chip-background sprite of the stock-pile remaining-count
|
||||
/// badge.
|
||||
///
|
||||
/// The badge is spawned as a *top-level* world entity (not parented to the
|
||||
/// stock [`PileMarker`]) and its `Transform` is recomputed each frame from
|
||||
/// `LayoutResource` so it tracks the stock pile through window resizes.
|
||||
/// The chip sits in the top-right corner of the stock pile and is hidden
|
||||
/// while the stock is empty — the existing `↺` overlay
|
||||
/// ([`StockEmptyLabel`]) covers the recycle hint instead, so the two
|
||||
/// indicators never render simultaneously.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StockCountBadge;
|
||||
|
||||
/// Marker on the `Text2d` child of [`StockCountBadge`] showing the numeric
|
||||
/// count of cards remaining in the stock pile.
|
||||
///
|
||||
/// Update systems query this component to write the new count in place rather
|
||||
/// than despawning and respawning the text entity each tick.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StockCountBadgeText;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #34 — Card-flip animation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -168,6 +195,72 @@ const FLIP_HALF_SECS: f32 = 0.08;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ShadowEntity;
|
||||
|
||||
/// Marker component for the per-card drop-shadow child sprite.
|
||||
///
|
||||
/// Every `CardEntity` owns exactly one `CardShadow` child whose `Sprite` is a
|
||||
/// neutral-black halo painted slightly down-and-right of the card. Idle state
|
||||
/// uses [`CARD_SHADOW_OFFSET_IDLE`] / [`CARD_SHADOW_ALPHA_IDLE`]; while the
|
||||
/// parent card is being dragged the shadow is pushed to the deeper
|
||||
/// [`CARD_SHADOW_OFFSET_DRAG`] / [`CARD_SHADOW_ALPHA_DRAG`] values so the
|
||||
/// stack reads as "lifted" off the felt.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardShadow;
|
||||
|
||||
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
|
||||
/// shadow given whether its parent card is currently part of the dragged
|
||||
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
|
||||
/// without spinning up a Bevy app.
|
||||
///
|
||||
/// `is_dragged = false` → resting `(IDLE, IDLE, IDLE)`
|
||||
/// `is_dragged = true` → lifted `(DRAG, DRAG, DRAG)`
|
||||
pub fn card_shadow_params(is_dragged: bool) -> (Vec2, Vec2, f32) {
|
||||
if is_dragged {
|
||||
(
|
||||
CARD_SHADOW_OFFSET_DRAG,
|
||||
CARD_SHADOW_PADDING_DRAG,
|
||||
CARD_SHADOW_ALPHA_DRAG,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
CARD_SHADOW_OFFSET_IDLE,
|
||||
CARD_SHADOW_PADDING_IDLE,
|
||||
CARD_SHADOW_ALPHA_IDLE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `Sprite` used for a per-card shadow at the resting state. The
|
||||
/// alpha and size both use the idle tokens; `update_card_shadows_on_drag`
|
||||
/// retunes them at runtime when the parent card joins / leaves the dragged
|
||||
/// stack.
|
||||
fn card_shadow_sprite(card_size: Vec2) -> Sprite {
|
||||
let (_offset, padding, alpha) = card_shadow_params(false);
|
||||
Sprite {
|
||||
color: CARD_SHADOW_COLOR.with_alpha(alpha),
|
||||
custom_size: Some(card_size + padding),
|
||||
..default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `Transform` used for a per-card shadow at the resting state.
|
||||
/// Local — it is parented to the card entity, so positions are relative.
|
||||
fn card_shadow_transform() -> Transform {
|
||||
let (offset, _padding, _alpha) = card_shadow_params(false);
|
||||
Transform::from_xyz(offset.x, offset.y, CARD_SHADOW_LOCAL_Z)
|
||||
}
|
||||
|
||||
/// Spawns a single `CardShadow` child under the given card entity builder.
|
||||
/// Extracted so `spawn_card_entity` and `update_card_entity` can share the
|
||||
/// exact same shadow recipe — we never want one path to drift from the other.
|
||||
fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||
parent.spawn((
|
||||
CardShadow,
|
||||
card_shadow_sprite(card_size),
|
||||
card_shadow_transform(),
|
||||
Visibility::default(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Throttle interval for resize-driven card snap work, in seconds.
|
||||
///
|
||||
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
|
||||
@@ -228,12 +321,14 @@ impl Plugin for CardPlugin {
|
||||
start_flip_anim.after(GameMutation),
|
||||
tick_flip_anim,
|
||||
update_drag_shadow,
|
||||
update_card_shadows_on_drag.after(sync_cards_on_change),
|
||||
tick_hint_highlight,
|
||||
handle_right_click,
|
||||
tick_right_click_highlights,
|
||||
clear_right_click_highlights_on_state_change.after(GameMutation),
|
||||
clear_right_click_highlights_on_pause,
|
||||
update_stock_empty_indicator.after(GameMutation),
|
||||
update_stock_count_badge.after(GameMutation),
|
||||
collect_resize_events.after(LayoutSystem::UpdateOnResize),
|
||||
snap_cards_on_window_resize.after(collect_resize_events),
|
||||
),
|
||||
@@ -436,10 +531,10 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
||||
let piles = [
|
||||
PileType::Stock,
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
@@ -534,6 +629,13 @@ fn spawn_card_entity(
|
||||
Transform::from_xyz(pos.x, pos.y, z),
|
||||
Visibility::default(),
|
||||
));
|
||||
// Every card gets a subtle drop-shadow child so the play surface reads
|
||||
// as physical instead of flat. Spawned in idle state; the drag-tracking
|
||||
// system retunes its offset / alpha when this card joins the dragged
|
||||
// stack.
|
||||
entity.with_children(|b| {
|
||||
add_card_shadow_child(b, layout.card_size);
|
||||
});
|
||||
// When PNG faces are loaded the rank/suit are baked into the image.
|
||||
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
||||
if card_images.is_none() {
|
||||
@@ -593,10 +695,13 @@ fn update_card_entity(
|
||||
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
||||
}
|
||||
|
||||
// Despawn any stale children and re-add the label overlay only when
|
||||
// operating in solid-colour mode (no PNG faces). In image mode the
|
||||
// rank/suit are baked into the PNG, so no Text2d overlay is needed.
|
||||
// Despawn any stale children and re-add the per-card drop shadow plus,
|
||||
// in solid-colour fallback mode, the label overlay. In image mode the
|
||||
// rank/suit are baked into the PNG, so no `Text2d` overlay is needed.
|
||||
commands.entity(entity).despawn_related::<Children>();
|
||||
commands.entity(entity).with_children(|b| {
|
||||
add_card_shadow_child(b, layout.card_size);
|
||||
});
|
||||
if card_images.is_none() {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
@@ -795,6 +900,43 @@ fn update_drag_shadow(
|
||||
}
|
||||
}
|
||||
|
||||
/// Snaps every per-card [`CardShadow`] between its idle and lifted tunings
|
||||
/// based on whether the parent [`CardEntity`] is currently in
|
||||
/// [`DragState::cards`]. Runs every frame; the transition is an instant snap
|
||||
/// (no lerp) — the existing shake / settle feedback already handles motion
|
||||
/// at drag-end, so an additional shadow tween would compete with those cues.
|
||||
///
|
||||
/// The shadow size is rebuilt from the parent card's current `Sprite`
|
||||
/// `custom_size` plus the appropriate padding, so the resize handler does
|
||||
/// not need to pre-tune shadow sizes for the drag state — this system fixes
|
||||
/// the geometry within one frame.
|
||||
fn update_card_shadows_on_drag(
|
||||
drag: Res<DragState>,
|
||||
cards: Query<(&CardEntity, &Sprite, &Children), Without<CardShadow>>,
|
||||
mut shadows: Query<(&mut Sprite, &mut Transform), With<CardShadow>>,
|
||||
) {
|
||||
let dragged: HashSet<u32> = drag.cards.iter().copied().collect();
|
||||
|
||||
for (card_entity, card_sprite, children) in cards.iter() {
|
||||
let is_dragged = dragged.contains(&card_entity.card_id);
|
||||
let (offset, padding, alpha) = card_shadow_params(is_dragged);
|
||||
let Some(card_size) = card_sprite.custom_size else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for child in children.iter() {
|
||||
let Ok((mut shadow_sprite, mut shadow_transform)) = shadows.get_mut(child) else {
|
||||
continue;
|
||||
};
|
||||
shadow_sprite.color = CARD_SHADOW_COLOR.with_alpha(alpha);
|
||||
shadow_sprite.custom_size = Some(card_size + padding);
|
||||
shadow_transform.translation.x = offset.x;
|
||||
shadow_transform.translation.y = offset.y;
|
||||
shadow_transform.translation.z = CARD_SHADOW_LOCAL_Z;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #28 — Hint highlight tick system
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -985,8 +1127,8 @@ fn handle_right_click(
|
||||
let pile_type = &pile_marker.0;
|
||||
let Some(pile) = game.0.piles.get(pile_type) else { continue };
|
||||
let legal = match pile_type {
|
||||
PileType::Foundation(suit) => {
|
||||
can_place_on_foundation(&card, pile, *suit)
|
||||
PileType::Foundation(_) => {
|
||||
can_place_on_foundation(&card, pile)
|
||||
}
|
||||
PileType::Tableau(_) => can_place_on_tableau(&card, pile),
|
||||
_ => false,
|
||||
@@ -1159,6 +1301,159 @@ fn update_stock_empty_indicator(
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stock-pile remaining-count badge
|
||||
//
|
||||
// Shows a small "·N" chip pinned to the top-right corner of the stock pile so
|
||||
// the player can see how many cards remain before the next recycle. The
|
||||
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
|
||||
// the badge hides itself when the stock has zero cards — the two indicators
|
||||
// never render at the same time.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Inset (in pixels) from the top-right corner of the stock pile sprite to
|
||||
/// the centre of the count badge. A small inward offset keeps the chip from
|
||||
/// drifting half-off the card while still reading as "attached" to the
|
||||
/// corner.
|
||||
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
|
||||
|
||||
/// Width / height of the badge background sprite, in world pixels. Sized so
|
||||
/// a 2-digit count (max "24") fits comfortably with `TYPE_CAPTION` text.
|
||||
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(28.0, 16.0);
|
||||
|
||||
/// Returns the count of cards currently in the stock pile.
|
||||
///
|
||||
/// Pure helper extracted so the count source is identical between the spawn
|
||||
/// system, the update system, and the unit tests.
|
||||
fn stock_card_count(game: &GameState) -> usize {
|
||||
game.piles
|
||||
.get(&PileType::Stock)
|
||||
.map_or(0, |p| p.cards.len())
|
||||
}
|
||||
|
||||
/// Returns the world-space `Vec3` for the centre of the stock-count badge,
|
||||
/// given the current `Layout`. The badge sits at the top-right corner of
|
||||
/// the stock pile sprite, inset by [`STOCK_BADGE_INSET`].
|
||||
fn stock_badge_translation(layout: &Layout) -> Vec3 {
|
||||
// Empty layouts don't contain a Stock entry — fall back to origin so
|
||||
// the badge stays in a deterministic spot until the layout is filled.
|
||||
let pile_pos = layout
|
||||
.pile_positions
|
||||
.get(&PileType::Stock)
|
||||
.copied()
|
||||
.unwrap_or(Vec2::ZERO);
|
||||
let half = layout.card_size * 0.5;
|
||||
let x = pile_pos.x + half.x + STOCK_BADGE_INSET.x;
|
||||
let y = pile_pos.y + half.y + STOCK_BADGE_INSET.y;
|
||||
Vec3::new(x, y, Z_STOCK_BADGE)
|
||||
}
|
||||
|
||||
/// Spawns the stock-count badge entity (background sprite + child text)
|
||||
/// into the world. Called once, when the badge does not yet exist.
|
||||
fn spawn_stock_count_badge(
|
||||
commands: &mut Commands,
|
||||
layout: &Layout,
|
||||
font: Option<&Handle<Font>>,
|
||||
count: usize,
|
||||
) {
|
||||
let translation = stock_badge_translation(layout);
|
||||
let visibility = if count == 0 {
|
||||
Visibility::Hidden
|
||||
} else {
|
||||
Visibility::Inherited
|
||||
};
|
||||
let text_font = TextFont {
|
||||
font: font.cloned().unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
StockCountBadge,
|
||||
Sprite {
|
||||
color: STOCK_BADGE_BG,
|
||||
custom_size: Some(STOCK_BADGE_SIZE),
|
||||
..default()
|
||||
},
|
||||
Transform::from_translation(translation),
|
||||
visibility,
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
StockCountBadgeText,
|
||||
Text2d::new(format!("·{count}")),
|
||||
text_font,
|
||||
TextColor(STOCK_BADGE_FG),
|
||||
// Slightly above the chip background so the digits aren't
|
||||
// occluded by the sprite they sit on.
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns the stock-pile remaining-count badge if it does not yet exist,
|
||||
/// and otherwise updates its text and visibility in place.
|
||||
///
|
||||
/// Visibility rule: hidden when the stock is empty (the existing `↺`
|
||||
/// `StockEmptyLabel` overlay covers that state), shown when one or more
|
||||
/// cards remain.
|
||||
///
|
||||
/// Position is recomputed from `LayoutResource` every tick so the badge
|
||||
/// follows the stock pile across `WindowResized` layout updates without
|
||||
/// needing a dedicated resize handler.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn update_stock_count_badge(
|
||||
mut commands: Commands,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
font: Option<Res<FontResource>>,
|
||||
mut badges: Query<(Entity, &mut Transform, &mut Visibility), With<StockCountBadge>>,
|
||||
children: Query<&Children, With<StockCountBadge>>,
|
||||
mut texts: Query<&mut Text2d, With<StockCountBadgeText>>,
|
||||
) {
|
||||
let Some(game) = game else { return };
|
||||
let Some(layout) = layout else { return };
|
||||
|
||||
let count = stock_card_count(&game.0);
|
||||
let translation = stock_badge_translation(&layout.0);
|
||||
let target_visibility = if count == 0 {
|
||||
Visibility::Hidden
|
||||
} else {
|
||||
Visibility::Inherited
|
||||
};
|
||||
|
||||
if badges.is_empty() {
|
||||
spawn_stock_count_badge(
|
||||
&mut commands,
|
||||
&layout.0,
|
||||
font.as_ref().map(|f| &f.0),
|
||||
count,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (entity, mut transform, mut visibility) in badges.iter_mut() {
|
||||
transform.translation = translation;
|
||||
if *visibility != target_visibility {
|
||||
*visibility = target_visibility;
|
||||
}
|
||||
// Update the child text to reflect the latest count. The text node
|
||||
// is created at spawn time, so under normal operation we always
|
||||
// have exactly one child here.
|
||||
if let Ok(badge_children) = children.get(entity) {
|
||||
for child in badge_children.iter() {
|
||||
if let Ok(mut text) = texts.get_mut(child) {
|
||||
let new = format!("·{count}");
|
||||
if text.0 != new {
|
||||
text.0 = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Coalesces every `WindowResized` event arriving this frame into the latest
|
||||
/// pending size on [`ResizeThrottle`].
|
||||
///
|
||||
@@ -1204,7 +1499,7 @@ fn collect_resize_events(
|
||||
/// Scheduled after [`collect_resize_events`] (which itself runs after
|
||||
/// `LayoutSystem::UpdateOnResize`) so `LayoutResource` reflects the latest
|
||||
/// window size before we read it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||
fn snap_cards_on_window_resize(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
@@ -1212,9 +1507,16 @@ fn snap_cards_on_window_resize(
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
entities: Query<(Entity, &CardEntity, &mut Sprite, &mut Transform), Without<CardLabel>>,
|
||||
entities: Query<
|
||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||
(Without<CardLabel>, Without<CardShadow>),
|
||||
>,
|
||||
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite), Without<CardEntity>>,
|
||||
shadow_query: Query<&mut Sprite, (With<CardShadow>, Without<CardEntity>, Without<PileMarker>)>,
|
||||
mut pile_markers: Query<
|
||||
(Entity, &PileMarker, &mut Sprite),
|
||||
(Without<CardEntity>, Without<CardShadow>),
|
||||
>,
|
||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||
) {
|
||||
if throttle.pending.is_none() {
|
||||
@@ -1242,6 +1544,7 @@ fn snap_cards_on_window_resize(
|
||||
card_images.as_deref(),
|
||||
entities,
|
||||
label_query,
|
||||
shadow_query,
|
||||
);
|
||||
|
||||
apply_stock_empty_indicator(
|
||||
@@ -1268,13 +1571,21 @@ fn snap_cards_on_window_resize(
|
||||
///
|
||||
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
|
||||
/// retargeted relative to the previous card-size's position.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn resize_cards_in_place(
|
||||
commands: &mut Commands,
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
card_images: Option<&CardImageSet>,
|
||||
mut entities: Query<(Entity, &CardEntity, &mut Sprite, &mut Transform), Without<CardLabel>>,
|
||||
mut entities: Query<
|
||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||
(Without<CardLabel>, Without<CardShadow>),
|
||||
>,
|
||||
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||
mut shadow_query: Query<
|
||||
&mut Sprite,
|
||||
(With<CardShadow>, Without<CardEntity>, Without<PileMarker>),
|
||||
>,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
let pos_by_id: HashMap<u32, (Vec2, f32)> = positions
|
||||
@@ -1295,6 +1606,27 @@ fn resize_cards_in_place(
|
||||
commands.entity(entity).remove::<CardAnim>();
|
||||
}
|
||||
|
||||
// Resize every per-card shadow halo to match the new card size. Both
|
||||
// idle and drag states scale with the card body, so we preserve the
|
||||
// *current* padding (idle vs drag) by keeping the alpha as-is and only
|
||||
// recomputing the geometry. The drag-tracking system runs every frame
|
||||
// and will retune offset / alpha / padding-mode within one frame if the
|
||||
// drag state diverges from the resized geometry.
|
||||
let idle_padding = CARD_SHADOW_PADDING_IDLE;
|
||||
let drag_padding = CARD_SHADOW_PADDING_DRAG;
|
||||
for mut shadow_sprite in shadow_query.iter_mut() {
|
||||
// Choose padding based on the shadow's current alpha — preserves
|
||||
// a lifted shadow's larger halo across resize without needing to
|
||||
// plumb DragState through the resize handler.
|
||||
let alpha = shadow_sprite.color.alpha();
|
||||
let padding = if alpha >= CARD_SHADOW_ALPHA_DRAG - 0.001 {
|
||||
drag_padding
|
||||
} else {
|
||||
idle_padding
|
||||
};
|
||||
shadow_sprite.custom_size = Some(layout.card_size + padding);
|
||||
}
|
||||
|
||||
// Only the solid-colour fallback path uses CardLabel/Text2d overlays;
|
||||
// when PNG faces are loaded the rank/suit are baked into the image and
|
||||
// there is nothing to resize on the label side.
|
||||
@@ -1926,4 +2258,271 @@ mod tests {
|
||||
(got {after}, expected {expected})"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Per-card drop-shadow — pure helper + spawn / drag-snap regressions.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// `card_shadow_params(false)` returns the IDLE token triple.
|
||||
#[test]
|
||||
fn card_shadow_params_idle_returns_idle_tokens() {
|
||||
let (offset, padding, alpha) = card_shadow_params(false);
|
||||
assert_eq!(offset, CARD_SHADOW_OFFSET_IDLE);
|
||||
assert_eq!(padding, CARD_SHADOW_PADDING_IDLE);
|
||||
assert!((alpha - CARD_SHADOW_ALPHA_IDLE).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
/// `card_shadow_params(true)` returns the DRAG token triple, and each
|
||||
/// drag value differs from its idle counterpart so the player visibly
|
||||
/// sees the lift.
|
||||
#[test]
|
||||
fn card_shadow_params_drag_returns_drag_tokens_and_differs_from_idle() {
|
||||
let (idle_offset, idle_padding, idle_alpha) = card_shadow_params(false);
|
||||
let (drag_offset, drag_padding, drag_alpha) = card_shadow_params(true);
|
||||
|
||||
assert_eq!(drag_offset, CARD_SHADOW_OFFSET_DRAG);
|
||||
assert_eq!(drag_padding, CARD_SHADOW_PADDING_DRAG);
|
||||
assert!((drag_alpha - CARD_SHADOW_ALPHA_DRAG).abs() < f32::EPSILON);
|
||||
|
||||
assert_ne!(idle_offset, drag_offset, "drag offset must differ from idle");
|
||||
assert_ne!(idle_padding, drag_padding, "drag padding must differ from idle");
|
||||
assert!(
|
||||
drag_alpha > idle_alpha,
|
||||
"drag alpha must be stronger than idle (got drag={drag_alpha}, idle={idle_alpha})"
|
||||
);
|
||||
// Drag offset magnitude should be larger than idle so the parallax
|
||||
// reads as "lifted".
|
||||
assert!(
|
||||
drag_offset.length() > idle_offset.length(),
|
||||
"drag offset magnitude ({}) must exceed idle ({}) so the lift is visible",
|
||||
drag_offset.length(),
|
||||
idle_offset.length(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Every spawned `CardEntity` owns exactly one `CardShadow` child.
|
||||
/// Total counts must match: 52 cards → 52 shadows.
|
||||
#[test]
|
||||
fn cards_spawn_with_shadow_child() {
|
||||
let mut app = app();
|
||||
|
||||
let card_count = app
|
||||
.world_mut()
|
||||
.query::<&CardEntity>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(card_count, 52, "fixture should spawn 52 cards");
|
||||
|
||||
let shadow_count = app
|
||||
.world_mut()
|
||||
.query::<&CardShadow>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
shadow_count, 52,
|
||||
"every CardEntity must own exactly one CardShadow child (got {shadow_count})"
|
||||
);
|
||||
|
||||
// Each shadow's parent must be a CardEntity, so the child relation
|
||||
// is wired correctly.
|
||||
let cards: HashSet<bevy::prelude::Entity> = app
|
||||
.world_mut()
|
||||
.query_filtered::<bevy::prelude::Entity, With<CardEntity>>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&ChildOf, With<CardShadow>>();
|
||||
for parent in q.iter(app.world()) {
|
||||
assert!(
|
||||
cards.contains(&parent.parent()),
|
||||
"CardShadow parent {:?} is not a CardEntity",
|
||||
parent.parent()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Driving `DragState.cards` with a card id and ticking the app must
|
||||
/// move that card's shadow to the lifted offset and alpha; cards
|
||||
/// outside the dragged set keep the idle tuning.
|
||||
#[test]
|
||||
fn shadow_offset_increases_during_drag() {
|
||||
let mut app = app();
|
||||
|
||||
// Pick any spawned card id and stage it in DragState.
|
||||
let card_id: u32 = {
|
||||
let mut q = app.world_mut().query::<&CardEntity>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.expect("fixture should spawn at least one CardEntity")
|
||||
.card_id
|
||||
};
|
||||
|
||||
// Pick a *different* card id to act as the negative control —
|
||||
// its shadow must remain at the idle offset.
|
||||
let other_id: u32 = {
|
||||
let mut q = app.world_mut().query::<&CardEntity>();
|
||||
q.iter(app.world())
|
||||
.map(|c| c.card_id)
|
||||
.find(|id| *id != card_id)
|
||||
.expect("fixture should spawn more than one CardEntity")
|
||||
};
|
||||
|
||||
// Stage the drag and run one Update so `update_card_shadows_on_drag`
|
||||
// sees the new DragState.
|
||||
app.world_mut().resource_mut::<DragState>().cards = vec![card_id];
|
||||
app.update();
|
||||
|
||||
// Find the shadow whose parent's CardEntity matches `card_id`.
|
||||
let dragged_shadow_offset = shadow_offset_for_card(&mut app, card_id);
|
||||
let other_shadow_offset = shadow_offset_for_card(&mut app, other_id);
|
||||
|
||||
let drag_off = CARD_SHADOW_OFFSET_DRAG;
|
||||
let idle_off = CARD_SHADOW_OFFSET_IDLE;
|
||||
|
||||
assert!(
|
||||
(dragged_shadow_offset.x - drag_off.x).abs() < 1e-3
|
||||
&& (dragged_shadow_offset.y - drag_off.y).abs() < 1e-3,
|
||||
"dragged shadow offset should match CARD_SHADOW_OFFSET_DRAG \
|
||||
(got {dragged_shadow_offset:?}, expected {drag_off:?})"
|
||||
);
|
||||
assert!(
|
||||
(other_shadow_offset.x - idle_off.x).abs() < 1e-3
|
||||
&& (other_shadow_offset.y - idle_off.y).abs() < 1e-3,
|
||||
"non-dragged shadow offset should remain at CARD_SHADOW_OFFSET_IDLE \
|
||||
(got {other_shadow_offset:?}, expected {idle_off:?})"
|
||||
);
|
||||
|
||||
// Sanity-check: clearing the drag returns the shadow to the idle
|
||||
// offset on the next frame.
|
||||
app.world_mut().resource_mut::<DragState>().clear();
|
||||
app.update();
|
||||
let after_clear = shadow_offset_for_card(&mut app, card_id);
|
||||
assert!(
|
||||
(after_clear.x - idle_off.x).abs() < 1e-3
|
||||
&& (after_clear.y - idle_off.y).abs() < 1e-3,
|
||||
"shadow must snap back to idle offset after drag clears \
|
||||
(got {after_clear:?}, expected {idle_off:?})"
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: given a `card_id`, returns the world-space offset (x, y) of
|
||||
/// its `CardShadow` child relative to the parent card's origin.
|
||||
fn shadow_offset_for_card(app: &mut App, card_id: u32) -> Vec2 {
|
||||
// Map every CardEntity to its (Entity, card_id).
|
||||
let card_entity = {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query::<(bevy::prelude::Entity, &CardEntity)>();
|
||||
q.iter(app.world())
|
||||
.find(|(_, c)| c.card_id == card_id)
|
||||
.map(|(e, _)| e)
|
||||
.expect("card_id not found in spawned CardEntity set")
|
||||
};
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<(&ChildOf, &Transform), With<CardShadow>>();
|
||||
for (parent, transform) in q.iter(app.world()) {
|
||||
if parent.parent() == card_entity {
|
||||
return Vec2::new(transform.translation.x, transform.translation.y);
|
||||
}
|
||||
}
|
||||
panic!("no CardShadow child found for card_id {card_id}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stock-pile remaining-count badge tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Reads the current `Text2d` payload of the single `StockCountBadgeText`
|
||||
/// in the world, panicking if zero or more than one are spawned.
|
||||
fn stock_badge_text(app: &mut App) -> String {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Text2d, With<StockCountBadgeText>>();
|
||||
let texts: Vec<String> = q.iter(app.world()).map(|t| t.0.clone()).collect();
|
||||
assert_eq!(
|
||||
texts.len(),
|
||||
1,
|
||||
"expected exactly one StockCountBadgeText, got {}",
|
||||
texts.len()
|
||||
);
|
||||
texts.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
/// Reads the `Visibility` of the single `StockCountBadge` background sprite.
|
||||
fn stock_badge_visibility(app: &mut App) -> Visibility {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Visibility, With<StockCountBadge>>();
|
||||
let vs: Vec<Visibility> = q.iter(app.world()).copied().collect();
|
||||
assert_eq!(
|
||||
vs.len(),
|
||||
1,
|
||||
"expected exactly one StockCountBadge entity, got {}",
|
||||
vs.len()
|
||||
);
|
||||
vs.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_badge_shows_count_after_startup() {
|
||||
// Fresh Klondike (DrawOne) deals 24 face-down cards into stock — the
|
||||
// canonical starting count. After the first `app.update()` the badge
|
||||
// must exist and read "·24".
|
||||
let mut app = app();
|
||||
// First update inside `app()` runs the spawn path; run one more to
|
||||
// confirm the in-place update path is also stable.
|
||||
app.update();
|
||||
assert_eq!(stock_badge_text(&mut app), "·24");
|
||||
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_badge_hides_when_stock_empty() {
|
||||
// Drain the stock pile to zero cards and assert the badge becomes
|
||||
// hidden, leaving the existing `↺` `StockEmptyLabel` overlay as the
|
||||
// sole indicator (the two never render simultaneously).
|
||||
let mut app = app();
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
|
||||
stock.cards.clear();
|
||||
}
|
||||
}
|
||||
app.update();
|
||||
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Hidden));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_badge_updates_when_stock_count_changes() {
|
||||
// Mutate the stock pile so it holds 23 cards (one fewer than the
|
||||
// initial 24) and assert the badge text follows.
|
||||
let mut app = app();
|
||||
// Sanity-check the starting count.
|
||||
assert_eq!(stock_badge_text(&mut app), "·24");
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
|
||||
let _ = stock.cards.pop();
|
||||
}
|
||||
}
|
||||
app.update();
|
||||
assert_eq!(stock_badge_text(&mut app), "·23");
|
||||
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_card_count_helper_reads_zero_when_pile_missing() {
|
||||
// If the stock pile entry is somehow absent (defensive path), the
|
||||
// helper must return 0 rather than panicking — the badge then
|
||||
// renders as hidden via the count-zero branch in the update system.
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
let mut g_no_stock = g.clone();
|
||||
g_no_stock.piles.remove(&PileType::Stock);
|
||||
assert_eq!(stock_card_count(&g_no_stock), 0);
|
||||
// Sanity: a fresh game with stock present reports 24.
|
||||
assert_eq!(stock_card_count(&g), 24);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,20 @@
|
||||
//! - **Green** if the dragged stack can legally land there.
|
||||
//! - **Default** (nearly transparent white) otherwise.
|
||||
//! The tint is cleared to default the frame the drag ends.
|
||||
//!
|
||||
//! **Drop-target overlays** (`update_drop_target_overlays`)
|
||||
//! Pile markers sit *behind* the card stack, so on a tableau column with
|
||||
//! any cards on it the green tint applied above is fully occluded. To
|
||||
//! make legal targets unmistakable mid-drag, this system spawns a
|
||||
//! translucent green rectangle plus four outline edges over every legal
|
||||
//! destination pile. For tableau columns the overlay covers the full
|
||||
//! visible fan (matching `input_plugin::pile_drop_rect`); for
|
||||
//! foundations and empty tableaux it is card-sized. Overlays are
|
||||
//! despawned the frame the drag ends or whenever the legal-target set
|
||||
//! changes.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
@@ -22,6 +32,9 @@ use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC};
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::table_plugin::PileMarker;
|
||||
use crate::ui_theme::{
|
||||
DROP_TARGET_FILL, DROP_TARGET_OUTLINE, DROP_TARGET_OUTLINE_PX, Z_DROP_OVERLAY,
|
||||
};
|
||||
|
||||
/// Semi-transparent white that `table_plugin` uses for idle pile markers.
|
||||
/// Kept in sync with the `marker_colour` constant there.
|
||||
@@ -30,12 +43,26 @@ const MARKER_DEFAULT: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
/// Green tint applied to pile markers that are valid drop targets during drag.
|
||||
const MARKER_VALID: Color = Color::srgba(0.15, 0.85, 0.25, 0.55);
|
||||
|
||||
/// Marker component on a parent entity that owns one drop-target overlay
|
||||
/// (a translucent fill plus four outline edges as children). The wrapped
|
||||
/// `PileType` identifies which pile this overlay highlights, so test
|
||||
/// queries and the despawn-on-target-change logic can filter by pile.
|
||||
#[derive(Component, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DropTargetOverlay(pub PileType);
|
||||
|
||||
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
|
||||
pub struct CursorPlugin;
|
||||
|
||||
impl Plugin for CursorPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, (update_cursor_icon, update_drop_highlights));
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
update_cursor_icon,
|
||||
update_drop_highlights,
|
||||
update_drop_target_overlays,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,10 +109,10 @@ fn update_cursor_icon(
|
||||
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
|
||||
let piles = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
@@ -158,12 +185,12 @@ fn update_drop_highlights(
|
||||
|
||||
for (marker, mut sprite, _rch) in &mut markers {
|
||||
let valid = match &marker.0 {
|
||||
PileType::Foundation(suit) => {
|
||||
PileType::Foundation(slot) => {
|
||||
if drag_count != 1 {
|
||||
false
|
||||
} else {
|
||||
let pile = game.0.piles.get(&PileType::Foundation(*suit));
|
||||
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p, *suit))
|
||||
let pile = game.0.piles.get(&PileType::Foundation(*slot));
|
||||
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
}
|
||||
PileType::Tableau(idx) => {
|
||||
@@ -176,6 +203,213 @@ fn update_drop_highlights(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drop-target overlay sprites — render in front of cards, unlike the pile
|
||||
// markers above which sit behind the stack.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawns / despawns translucent overlay sprites over every legal drop
|
||||
/// target while a drag is in progress.
|
||||
///
|
||||
/// The overlay is a parent `Sprite` (the soft fill) with four child
|
||||
/// `Sprite`s (top, bottom, left, right edges) that together form the
|
||||
/// outline. A new parent is spawned whenever a target appears in the
|
||||
/// valid set; a parent is despawned (with its children) whenever its
|
||||
/// pile leaves the valid set or the drag ends.
|
||||
///
|
||||
/// Geometry mirrors `input_plugin::pile_drop_rect` exactly so the
|
||||
/// highlighted region matches the actual drop hit-box.
|
||||
fn update_drop_target_overlays(
|
||||
mut commands: Commands,
|
||||
drag: Res<DragState>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
overlays: Query<(Entity, &DropTargetOverlay)>,
|
||||
) {
|
||||
// Drag idle → despawn every existing overlay and exit.
|
||||
if drag.is_idle() {
|
||||
for (entity, _) in &overlays {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let (Some(game), Some(layout)) = (game, layout) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Resolve the bottom card of the dragged stack — same logic as
|
||||
// `update_drop_highlights` so rules can't drift between the marker
|
||||
// tint and the overlay.
|
||||
let Some(&bottom_id) = drag.cards.first() else {
|
||||
return;
|
||||
};
|
||||
let bottom_card = game
|
||||
.0
|
||||
.piles
|
||||
.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == bottom_id)
|
||||
.cloned();
|
||||
let Some(bottom_card) = bottom_card else {
|
||||
return;
|
||||
};
|
||||
let drag_count = drag.cards.len();
|
||||
|
||||
// Iterate the same pile list as `update_drop_highlights`. Stock and
|
||||
// Waste are excluded because they are never legal drop targets.
|
||||
let candidates = [
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
];
|
||||
|
||||
// Compute the new set of valid piles for this frame.
|
||||
let mut valid: Vec<PileType> = Vec::new();
|
||||
for pile in &candidates {
|
||||
let is_valid = match pile {
|
||||
PileType::Foundation(_) => {
|
||||
if drag_count != 1 {
|
||||
false
|
||||
} else {
|
||||
game.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
}
|
||||
PileType::Tableau(_) => game
|
||||
.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.is_some_and(|p| can_place_on_tableau(&bottom_card, p)),
|
||||
_ => false,
|
||||
};
|
||||
// Don't highlight the origin pile — dropping onto the source is
|
||||
// a no-op.
|
||||
if is_valid && drag.origin_pile.as_ref() != Some(pile) {
|
||||
valid.push(pile.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Despawn overlays whose pile is no longer valid.
|
||||
for (entity, marker) in &overlays {
|
||||
if !valid.contains(&marker.0) {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn overlays for piles that are now valid but don't yet have one.
|
||||
let already_overlaid: Vec<PileType> = overlays
|
||||
.iter()
|
||||
.map(|(_, m)| m.0.clone())
|
||||
.filter(|p| valid.contains(p))
|
||||
.collect();
|
||||
|
||||
for pile in valid {
|
||||
if already_overlaid.contains(&pile) {
|
||||
continue;
|
||||
}
|
||||
spawn_drop_target_overlay(&mut commands, &pile, &layout.0, &game.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the `(centre, size)` of the drop-target overlay for a pile.
|
||||
///
|
||||
/// Mirrors `input_plugin::pile_drop_rect` — for tableau columns with two
|
||||
/// or more cards the rectangle extends downward to cover the full fan;
|
||||
/// for everything else it is card-sized. Replicated here rather than
|
||||
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
||||
/// this overlay is the only other consumer.
|
||||
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2, Vec2) {
|
||||
let centre = layout.pile_positions[pile];
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
if card_count > 1 {
|
||||
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
||||
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
||||
let top_edge = centre.y + layout.card_size.y / 2.0;
|
||||
let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0;
|
||||
let span_height = top_edge - bottom_edge;
|
||||
let new_centre_y = (top_edge + bottom_edge) / 2.0;
|
||||
return (
|
||||
Vec2::new(centre.x, new_centre_y),
|
||||
Vec2::new(layout.card_size.x, span_height),
|
||||
);
|
||||
}
|
||||
}
|
||||
(centre, layout.card_size)
|
||||
}
|
||||
|
||||
/// Spawns one overlay parent (fill) plus four edge sprites (outline) at
|
||||
/// the appropriate world position for `pile`.
|
||||
fn spawn_drop_target_overlay(
|
||||
commands: &mut Commands,
|
||||
pile: &PileType,
|
||||
layout: &Layout,
|
||||
game: &GameState,
|
||||
) {
|
||||
let (centre, size) = drop_overlay_rect(pile, layout, game);
|
||||
let edge = DROP_TARGET_OUTLINE_PX;
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_FILL,
|
||||
custom_size: Some(size),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
|
||||
DropTargetOverlay(pile.clone()),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
// Top edge.
|
||||
parent.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_OUTLINE,
|
||||
custom_size: Some(Vec2::new(size.x, edge)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, size.y / 2.0 - edge / 2.0, 0.01),
|
||||
));
|
||||
// Bottom edge.
|
||||
parent.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_OUTLINE,
|
||||
custom_size: Some(Vec2::new(size.x, edge)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, -size.y / 2.0 + edge / 2.0, 0.01),
|
||||
));
|
||||
// Left edge.
|
||||
parent.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_OUTLINE,
|
||||
custom_size: Some(Vec2::new(edge, size.y)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(-size.x / 2.0 + edge / 2.0, 0.0, 0.01),
|
||||
));
|
||||
// Right edge.
|
||||
parent.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_OUTLINE,
|
||||
custom_size: Some(Vec2::new(edge, size.y)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(size.x / 2.0 - edge / 2.0, 0.0, 0.01),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -258,4 +492,159 @@ mod tests {
|
||||
// A cursor far off-screen should never hit anything.
|
||||
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Drop-target overlay tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||
|
||||
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
||||
/// registered, plus the resources the system needs. Callers
|
||||
/// customise `GameStateResource` and `DragState` after construction.
|
||||
fn overlay_test_app(game: GameState) -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.insert_resource(GameStateResource(game))
|
||||
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0))))
|
||||
.insert_resource(DragState::default())
|
||||
.add_systems(Update, update_drop_target_overlays);
|
||||
app
|
||||
}
|
||||
|
||||
/// Replaces the top card of a tableau pile with a fresh face-up
|
||||
/// card. Used to make a specific tableau column accept a chosen
|
||||
/// drag stack.
|
||||
fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) {
|
||||
let pile = game
|
||||
.piles
|
||||
.get_mut(&PileType::Tableau(idx))
|
||||
.expect("tableau pile exists");
|
||||
pile.cards.clear();
|
||||
pile.cards.push(card);
|
||||
}
|
||||
|
||||
/// Inserts a single face-up dragged card into the waste pile and
|
||||
/// configures `DragState` so the overlay system treats it as the
|
||||
/// active drag.
|
||||
fn begin_drag_with(app: &mut App, dragged: Card) {
|
||||
// Place the dragged card on the waste pile (origin).
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let waste = game
|
||||
.0
|
||||
.piles
|
||||
.get_mut(&PileType::Waste)
|
||||
.expect("waste pile exists");
|
||||
waste.cards.clear();
|
||||
waste.cards.push(dragged.clone());
|
||||
}
|
||||
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||
drag.cards = vec![dragged.id];
|
||||
drag.origin_pile = Some(PileType::Waste);
|
||||
drag.committed = true;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlay_spawns_for_valid_tableau_during_drag() {
|
||||
// 5 of Hearts (red, rank 5) on top of Tableau(2)'s 6 of Spades
|
||||
// (black, rank 6) — alternating colour, one rank lower → legal.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card { id: 9001, suit: Suit::Spades, rank: Rank::Six, face_up: true },
|
||||
);
|
||||
let dragged = Card { id: 9002, suit: Suit::Hearts, rank: Rank::Five, face_up: true };
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
|
||||
app.update();
|
||||
|
||||
let overlays: Vec<PileType> = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.map(|o| o.0.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
overlays.contains(&PileType::Tableau(2)),
|
||||
"expected Tableau(2) to be highlighted as a legal drop target, got {overlays:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
|
||||
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
||||
// — same colour family, illegal. Tableau(2) must NOT be
|
||||
// highlighted.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card { id: 9101, suit: Suit::Clubs, rank: Rank::Six, face_up: true },
|
||||
);
|
||||
let dragged = Card { id: 9102, suit: Suit::Spades, rank: Rank::Five, face_up: true };
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
|
||||
app.update();
|
||||
|
||||
let overlays: Vec<PileType> = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.map(|o| o.0.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
!overlays.contains(&PileType::Tableau(2)),
|
||||
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlays_despawn_on_drag_end() {
|
||||
// Set up a scenario that produces at least one valid overlay,
|
||||
// confirm it spawns, then clear the drag and confirm every
|
||||
// overlay is despawned.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card { id: 9201, suit: Suit::Spades, rank: Rank::Six, face_up: true },
|
||||
);
|
||||
let dragged = Card { id: 9202, suit: Suit::Hearts, rank: Rank::Five, face_up: true };
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
app.update();
|
||||
|
||||
let count_during_drag = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
count_during_drag >= 1,
|
||||
"expected ≥1 overlay during drag, got {count_during_drag}"
|
||||
);
|
||||
|
||||
// End the drag — every overlay should despawn next frame.
|
||||
app.world_mut().resource_mut::<DragState>().clear();
|
||||
app.update();
|
||||
|
||||
let count_after_drag = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count_after_drag, 0,
|
||||
"all overlays must despawn when the drag ends"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +479,6 @@ fn handle_undo(
|
||||
/// - Any face-up card on Waste or Tableau piles that can legally move to any
|
||||
/// Foundation or Tableau destination.
|
||||
pub fn has_legal_moves(game: &GameState) -> bool {
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
@@ -490,8 +489,6 @@ pub fn has_legal_moves(game: &GameState) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
|
||||
// Check each playable source pile.
|
||||
let sources: Vec<PileType> = {
|
||||
let mut v = vec![PileType::Waste];
|
||||
@@ -505,11 +502,11 @@ pub fn has_legal_moves(game: &GameState) -> bool {
|
||||
let Some(from_pile) = game.piles.get(from) else { continue };
|
||||
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
|
||||
|
||||
// Check foundations.
|
||||
for &suit in &suits {
|
||||
let dest = PileType::Foundation(suit);
|
||||
// Check foundation slots.
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if let Some(dest_pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, dest_pile, suit) {
|
||||
&& can_place_on_foundation(card, dest_pile) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1116,8 +1113,8 @@ mod tests {
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
|
||||
// Clear all tableau and foundations, put Ace of Clubs on tableau 0.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1139,8 +1136,8 @@ mod tests {
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
|
||||
// Clear all foundations and all tableau.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1234,8 +1231,8 @@ mod tests {
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1273,8 +1270,8 @@ mod tests {
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1340,8 +1337,8 @@ mod tests {
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
|
||||
@@ -16,9 +16,10 @@ use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::layout::HUD_BAND_HEIGHT;
|
||||
use crate::ui_theme::{
|
||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||
BG_ELEVATED_PRESSED, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM,
|
||||
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM,
|
||||
STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||
};
|
||||
@@ -251,7 +252,8 @@ impl Plugin for HudPlugin {
|
||||
.add_message::<ToggleSettingsRequestEvent>()
|
||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||
.init_resource::<PreviousScore>()
|
||||
.add_systems(Startup, (spawn_hud, spawn_action_buttons))
|
||||
.init_resource::<HudActionFade>()
|
||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
||||
.add_systems(Update, update_hud.after(GameMutation))
|
||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||
.add_systems(Update, update_selection_hud)
|
||||
@@ -278,10 +280,44 @@ impl Plugin for HudPlugin {
|
||||
handle_menu_option_click,
|
||||
paint_action_buttons,
|
||||
),
|
||||
);
|
||||
)
|
||||
// Fade lives in `Last` so it always overrides whatever the
|
||||
// hover/paint pass set on `BackgroundColor` this frame.
|
||||
// Otherwise on a hover-state change (`Changed<Interaction>`),
|
||||
// `paint_action_buttons` would clobber the alpha back to 1.0
|
||||
// mid-fade and produce a visible blip.
|
||||
.add_systems(Last, (update_action_fade, apply_action_fade).chain());
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the translucent HUD band that anchors the action buttons
|
||||
/// and primary readouts visually. Sits behind every other HUD element
|
||||
/// (one z-rung below `Z_HUD`) so it reads as the band's "container"
|
||||
/// without intercepting clicks from the buttons it sits under.
|
||||
///
|
||||
/// Width is full-window, height matches `layout::HUD_BAND_HEIGHT` (the
|
||||
/// same constant the card layout reserves at the top), so the band's
|
||||
/// bottom edge lines up exactly with the top edge of the highest
|
||||
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
|
||||
/// alpha, so the green felt reads through subtly.
|
||||
fn spawn_hud_band(mut commands: Commands) {
|
||||
commands.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(0.0),
|
||||
left: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(HUD_BAND_HEIGHT),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_HUD_BAND),
|
||||
// Sit one z-rung below the HUD content so the buttons and text
|
||||
// paint on top, but above the card sprites (which are 2D-world
|
||||
// entities and rendered behind UI regardless).
|
||||
ZIndex(Z_HUD - 1),
|
||||
));
|
||||
}
|
||||
|
||||
/// Spawns the in-game HUD as a 4-tier vertical column anchored to the
|
||||
/// top-left of the play area.
|
||||
///
|
||||
@@ -960,6 +996,93 @@ fn handle_menu_option_click(
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-fade state for the action button bar. The bar fades out when
|
||||
/// the cursor is in the play area (below the HUD band) and back in when
|
||||
/// the cursor approaches the top of the window — same UX as a video
|
||||
/// player's auto-hide controls. Buttons remain fully interactive when
|
||||
/// visible; when faded out they're geometrically out of cursor reach
|
||||
/// (hover requires the cursor to be on a button), so no extra
|
||||
/// pointer-events guard is needed.
|
||||
#[derive(Resource, Debug, Clone, Copy)]
|
||||
pub struct HudActionFade {
|
||||
/// Currently displayed alpha. Lerped toward `target` each frame.
|
||||
pub alpha: f32,
|
||||
/// Where `alpha` is heading — 0.0 (faded out) or 1.0 (visible).
|
||||
pub target: f32,
|
||||
}
|
||||
|
||||
impl Default for HudActionFade {
|
||||
fn default() -> Self {
|
||||
// Start visible so the player sees the controls on first launch
|
||||
// before they've moved the cursor anywhere.
|
||||
Self {
|
||||
alpha: 1.0,
|
||||
target: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cursor-y threshold (in window pixels, 0 = top) below which the bar
|
||||
/// stays visible. Set slightly above `HUD_BAND_HEIGHT` so the bar fades
|
||||
/// in as the cursor approaches, not only once it crosses into the band.
|
||||
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
||||
|
||||
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
|
||||
/// transition — fast enough to feel responsive without flashing on
|
||||
/// brief cursor wanders into the reveal zone.
|
||||
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
||||
|
||||
/// Updates the fade state from cursor position. Sets `target = 1.0` if
|
||||
/// the cursor is in the reveal zone (top of window) or off-screen
|
||||
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
|
||||
/// `target` at a fixed rate so the visual transition is smooth across
|
||||
/// variable framerates.
|
||||
fn update_action_fade(
|
||||
windows: Query<&Window>,
|
||||
time: Res<Time>,
|
||||
mut fade: ResMut<HudActionFade>,
|
||||
) {
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
};
|
||||
fade.target = match window.cursor_position() {
|
||||
Some(pos) if pos.y <= ACTION_FADE_REVEAL_PX => 1.0,
|
||||
Some(_) => 0.0,
|
||||
// Off-window cursor: assume keyboard navigation and keep the
|
||||
// bar visible so Tab cycling doesn't lead to invisible focus.
|
||||
None => 1.0,
|
||||
};
|
||||
|
||||
let dt = time.delta_secs();
|
||||
let max_step = ACTION_FADE_RATE_PER_SEC * dt;
|
||||
let diff = fade.target - fade.alpha;
|
||||
fade.alpha = (fade.alpha + diff.clamp(-max_step, max_step)).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Applies the current fade alpha to every action button's
|
||||
/// `BackgroundColor` and to its child label / hotkey-chip text. Runs in
|
||||
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
|
||||
/// same frame doesn't override the fade with an opaque idle / hover
|
||||
/// colour.
|
||||
fn apply_action_fade(
|
||||
fade: Res<HudActionFade>,
|
||||
mut buttons: Query<(&Children, &mut BackgroundColor), With<ActionButton>>,
|
||||
mut text_q: Query<&mut TextColor>,
|
||||
) {
|
||||
for (children, mut bg) in &mut buttons {
|
||||
let mut c = bg.0;
|
||||
c.set_alpha(fade.alpha);
|
||||
bg.0 = c;
|
||||
for child in children.iter() {
|
||||
if let Ok(mut tc) = text_q.get_mut(child) {
|
||||
let mut cc = tc.0;
|
||||
cc.set_alpha(fade.alpha);
|
||||
tc.0 = cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Visual feedback for every action button — paints idle / hover / pressed
|
||||
/// states by mutating `BackgroundColor` whenever the interaction state
|
||||
/// changes. One query covers all action buttons via the shared
|
||||
@@ -1434,6 +1557,7 @@ fn update_hud(
|
||||
/// indicator stays in sync with the selection resource.
|
||||
fn update_selection_hud(
|
||||
selection: Option<Res<SelectionState>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut q: Query<&mut Text, With<HudSelection>>,
|
||||
) {
|
||||
let Ok(mut t) = q.single_mut() else { return };
|
||||
@@ -1441,7 +1565,29 @@ fn update_selection_hud(
|
||||
None => String::new(),
|
||||
Some(PileType::Waste) => "▶ Waste".to_string(),
|
||||
Some(PileType::Stock) => "▶ Stock".to_string(),
|
||||
Some(PileType::Foundation(suit)) => {
|
||||
Some(PileType::Foundation(slot)) => match game.as_deref() {
|
||||
Some(g) => foundation_selection_label(*slot, &g.0),
|
||||
// No game resource means we can't probe claimed_suit; show the
|
||||
// slot-based placeholder so the HUD still surfaces the selection.
|
||||
None => format!("▶ Foundation {}", slot + 1),
|
||||
},
|
||||
Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1),
|
||||
};
|
||||
**t = label;
|
||||
}
|
||||
|
||||
/// Returns the HUD selection label for a foundation slot.
|
||||
///
|
||||
/// When the slot has a claimed suit (any card has landed) the announcement is
|
||||
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
|
||||
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
|
||||
fn foundation_selection_label(slot: u8, game: &solitaire_core::game_state::GameState) -> String {
|
||||
let claimed = game
|
||||
.piles
|
||||
.get(&PileType::Foundation(slot))
|
||||
.and_then(|p| p.claimed_suit());
|
||||
match claimed {
|
||||
Some(suit) => {
|
||||
let s = match suit {
|
||||
Suit::Clubs => "Clubs",
|
||||
Suit::Diamonds => "Diamonds",
|
||||
@@ -1450,9 +1596,8 @@ fn update_selection_hud(
|
||||
};
|
||||
format!("▶ {s} Foundation")
|
||||
}
|
||||
Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1),
|
||||
};
|
||||
**t = label;
|
||||
None => format!("▶ Foundation {}", slot + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
|
||||
|
||||
@@ -320,16 +320,23 @@ fn handle_keyboard_hint(
|
||||
}
|
||||
|
||||
// Fire an informational toast describing where the hinted card should
|
||||
// move so the player always sees the suggestion in text.
|
||||
// move so the player always sees the suggestion in text. When the
|
||||
// destination foundation already claims a suit, surface that suit so the
|
||||
// player keeps thinking in suit terms; otherwise fall back to "foundation".
|
||||
let msg = match to {
|
||||
PileType::Foundation(suit) => {
|
||||
let suit_name = match suit {
|
||||
Suit::Clubs => "Clubs",
|
||||
Suit::Diamonds => "Diamonds",
|
||||
Suit::Hearts => "Hearts",
|
||||
Suit::Spades => "Spades",
|
||||
};
|
||||
format!("Hint: move to {suit_name} foundation")
|
||||
PileType::Foundation(_) => {
|
||||
let claimed = g.0.piles.get(to).and_then(|p| p.claimed_suit());
|
||||
if let Some(suit) = claimed {
|
||||
let suit_name = match suit {
|
||||
Suit::Clubs => "Clubs",
|
||||
Suit::Diamonds => "Diamonds",
|
||||
Suit::Hearts => "Hearts",
|
||||
Suit::Spades => "Spades",
|
||||
};
|
||||
format!("Hint: move to {suit_name} foundation")
|
||||
} else {
|
||||
"Hint: move to foundation".to_string()
|
||||
}
|
||||
}
|
||||
PileType::Tableau(col) => format!("Hint: move to tableau (col {})", col + 1),
|
||||
_ => "Hint: move card".to_string(),
|
||||
@@ -634,12 +641,11 @@ fn end_drag(
|
||||
let bottom_card_id = drag.cards[0];
|
||||
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
|
||||
let ok = match &target {
|
||||
PileType::Foundation(suit) => {
|
||||
PileType::Foundation(_) => {
|
||||
count == 1
|
||||
&& can_place_on_foundation(
|
||||
&bottom_card,
|
||||
&game.0.piles[&target],
|
||||
*suit,
|
||||
)
|
||||
}
|
||||
PileType::Tableau(_) => {
|
||||
@@ -879,9 +885,9 @@ fn touch_end_drag(
|
||||
let bottom_card_id = drag.cards[0];
|
||||
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
|
||||
let ok = match &target {
|
||||
PileType::Foundation(suit) => {
|
||||
PileType::Foundation(_) => {
|
||||
count == 1
|
||||
&& can_place_on_foundation(&bottom_card, &game.0.piles[&target], *suit)
|
||||
&& can_place_on_foundation(&bottom_card, &game.0.piles[&target])
|
||||
}
|
||||
PileType::Tableau(_) => {
|
||||
can_place_on_tableau(&bottom_card, &game.0.piles[&target])
|
||||
@@ -1016,10 +1022,10 @@ fn find_draggable_at(
|
||||
// Within a pile, we consider cards top-down because the visual top card is drawn last.
|
||||
let piles = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
@@ -1079,10 +1085,10 @@ fn find_drop_target(
|
||||
origin: &PileType,
|
||||
) -> Option<PileType> {
|
||||
let piles = [
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
@@ -1138,11 +1144,11 @@ const DOUBLE_CLICK_WINDOW: f32 = 0.35;
|
||||
///
|
||||
/// Returns `None` if no legal move exists from the card's current location.
|
||||
pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> {
|
||||
// Try all four foundations first.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let dest = PileType::Foundation(suit);
|
||||
// Try all four foundation slots first.
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, pile, suit) {
|
||||
&& can_place_on_foundation(card, pile) {
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
@@ -1298,7 +1304,6 @@ fn handle_double_click(
|
||||
/// This is the backing data for the cycling hint system: the H key steps
|
||||
/// through `hints[HintCycleIndex % hints.len()]` on each press.
|
||||
pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
let sources: Vec<PileType> = {
|
||||
let mut s = vec![PileType::Waste];
|
||||
for i in 0..7_usize {
|
||||
@@ -1313,12 +1318,12 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
||||
for from in &sources {
|
||||
let Some(from_pile) = game.piles.get(from) else { continue };
|
||||
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
|
||||
for &suit in &suits {
|
||||
let dest = PileType::Foundation(suit);
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if let Some(dest_pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, dest_pile, suit) {
|
||||
&& can_place_on_foundation(card, dest_pile) {
|
||||
hints.push((from.clone(), dest, 1));
|
||||
// Each source card can go to at most one foundation suit;
|
||||
// Each source card can land on at most one foundation slot;
|
||||
// no need to check the remaining three for this card.
|
||||
break;
|
||||
}
|
||||
@@ -1616,7 +1621,7 @@ mod tests {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
for pile in [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(2),
|
||||
] {
|
||||
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
||||
assert_eq!(size, layout.card_size);
|
||||
@@ -1638,13 +1643,15 @@ mod tests {
|
||||
waste.cards.clear();
|
||||
waste.cards.push(Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true });
|
||||
|
||||
// Foundation for Clubs is empty — Ace should go there.
|
||||
let foundation = game.piles.get_mut(&PileType::Foundation(Suit::Clubs)).unwrap();
|
||||
foundation.cards.clear();
|
||||
// All four foundation slots empty — the Ace lands in slot 0 (first
|
||||
// empty slot in iteration order).
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
|
||||
let card = Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true };
|
||||
let dest = best_destination(&card, &game);
|
||||
assert_eq!(dest, Some(PileType::Foundation(Suit::Clubs)));
|
||||
assert_eq!(dest, Some(PileType::Foundation(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1653,9 +1660,9 @@ mod tests {
|
||||
use solitaire_core::game_state::GameMode;
|
||||
let mut game = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Classic);
|
||||
|
||||
// Clear all foundations — a Two of Clubs cannot go there.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
// Clear all foundation slots — a Two of Clubs cannot go there.
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
|
||||
// Put a Two of Clubs as the card.
|
||||
@@ -1682,8 +1689,8 @@ mod tests {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Clear everything except one card that has nowhere to go.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1704,8 +1711,8 @@ mod tests {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Clear all piles for a clean test.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1737,8 +1744,8 @@ mod tests {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1768,8 +1775,8 @@ mod tests {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1806,13 +1813,16 @@ mod tests {
|
||||
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 500, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
game.piles.get_mut(&PileType::Foundation(Suit::Clubs)).unwrap().cards.clear();
|
||||
// All foundation slots empty — Ace lands in slot 0 (first match).
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
|
||||
let hint = find_hint(&game);
|
||||
assert!(hint.is_some(), "should find a hint");
|
||||
let (from, to, count) = hint.unwrap();
|
||||
assert_eq!(from, PileType::Tableau(0));
|
||||
assert_eq!(to, PileType::Foundation(Suit::Clubs));
|
||||
assert_eq!(to, PileType::Foundation(0));
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
@@ -1822,8 +1832,8 @@ mod tests {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Put only a Two on tableau 0, empty everything else.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1872,8 +1882,8 @@ mod tests {
|
||||
|
||||
// Remove all foundation, tableau, and waste cards so no pile-to-pile
|
||||
// move exists. Leave one card in the stock.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
@@ -1904,8 +1914,8 @@ mod tests {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Clear every pile, then put a single card that has nowhere to go.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::collections::HashMap;
|
||||
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::{Resource, SystemSet};
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
/// Schedule labels for layout-related systems so cross-plugin ordering is
|
||||
@@ -43,6 +42,15 @@ const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
/// this column inside the visible window.
|
||||
const MAX_TABLEAU_CARDS: f32 = 13.0;
|
||||
|
||||
/// Vertical pixel band reserved at the top of the play area for the HUD
|
||||
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
|
||||
/// below this band so the HUD doesn't bleed into the play surface.
|
||||
///
|
||||
/// 64 px comfortably fits the action button bar (~32 px tall) plus the
|
||||
/// Score/Moves text line plus padding, with a few pixels of breathing room.
|
||||
/// The matching translucent background is painted by `hud_plugin::spawn_hud_band`.
|
||||
pub const HUD_BAND_HEIGHT: f32 = 64.0;
|
||||
|
||||
/// Table background colour (dark green felt).
|
||||
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
||||
|
||||
@@ -88,8 +96,8 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
||||
//
|
||||
// Letting w = card_width and h = w * CARD_ASPECT, the vertical layout is:
|
||||
// top edge of window = +window.y / 2
|
||||
// top of top-row card = window.y/2 - h_gap (h_gap top margin)
|
||||
// centre of top-row card = window.y/2 - h_gap - h/2
|
||||
// top of top-row card = window.y/2 - HUD_BAND_HEIGHT - h_gap (HUD reserve + h_gap top margin)
|
||||
// centre of top-row card = window.y/2 - HUD_BAND_HEIGHT - h_gap - h/2
|
||||
// centre of tableau card = top centre - h - vertical_gap (vertical_gap = VERTICAL_GAP_FRAC * h)
|
||||
// bottom of last fanned = tableau_centre + h/2 - fan_factor * h
|
||||
// where fan_factor = 1 + (MAX_TABLEAU_CARDS - 1) * TABLEAU_FAN_FRAC
|
||||
@@ -97,10 +105,10 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
||||
//
|
||||
// Substituting h_gap = w/4 and h = CARD_ASPECT * w and solving for the
|
||||
// largest w that fits gives:
|
||||
// window.y = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
|
||||
// (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
|
||||
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
|
||||
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
|
||||
let card_width_height_based = window.y / height_denom;
|
||||
let card_width_height_based = (window.y - HUD_BAND_HEIGHT).max(0.0) / height_denom;
|
||||
|
||||
let card_width = card_width_width_based.min(card_width_height_based);
|
||||
let card_height = card_width * CARD_ASPECT;
|
||||
@@ -120,7 +128,7 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
||||
};
|
||||
|
||||
let vertical_gap = card_height * VERTICAL_GAP_FRAC;
|
||||
let top_y = window.y / 2.0 - h_gap - card_height / 2.0;
|
||||
let top_y = window.y / 2.0 - HUD_BAND_HEIGHT - h_gap - card_height / 2.0;
|
||||
let tableau_y = top_y - card_height - vertical_gap;
|
||||
|
||||
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
|
||||
@@ -129,11 +137,10 @@ pub fn compute_layout(window: Vec2) -> Layout {
|
||||
pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y));
|
||||
|
||||
// Column 2 is skipped — visual separation between waste and foundations.
|
||||
let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
for (i, suit) in foundation_suits.into_iter().enumerate() {
|
||||
for slot in 0..4_u8 {
|
||||
pile_positions.insert(
|
||||
PileType::Foundation(suit),
|
||||
Vec2::new(col_x(3 + i), top_y),
|
||||
PileType::Foundation(slot),
|
||||
Vec2::new(col_x(3 + slot as usize), top_y),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,11 +165,10 @@ mod tests {
|
||||
fn assert_all_piles_present(layout: &Layout) {
|
||||
assert!(layout.pile_positions.contains_key(&PileType::Stock));
|
||||
assert!(layout.pile_positions.contains_key(&PileType::Waste));
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
for slot in 0..4_u8 {
|
||||
assert!(
|
||||
layout.pile_positions.contains_key(&PileType::Foundation(suit)),
|
||||
"missing foundation for {:?}",
|
||||
suit
|
||||
layout.pile_positions.contains_key(&PileType::Foundation(slot)),
|
||||
"missing foundation slot {slot}",
|
||||
);
|
||||
}
|
||||
for i in 0..7 {
|
||||
@@ -217,6 +223,23 @@ mod tests {
|
||||
assert!(stock_y > tableau_y);
|
||||
}
|
||||
|
||||
/// HUD band reservation: the top edge of every top-row card must sit
|
||||
/// at least `HUD_BAND_HEIGHT` pixels below the top of the window so
|
||||
/// the action button bar / score readout has its own visual band
|
||||
/// instead of bleeding into the play surface.
|
||||
#[test]
|
||||
fn top_row_clears_hud_band() {
|
||||
let window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(window);
|
||||
let stock_y = layout.pile_positions[&PileType::Stock].y;
|
||||
let card_top = stock_y + layout.card_size.y / 2.0;
|
||||
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
|
||||
assert!(
|
||||
card_top <= band_bottom,
|
||||
"top of stock card ({card_top}) must sit below the HUD band ({band_bottom})",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
@@ -231,15 +254,13 @@ mod tests {
|
||||
#[test]
|
||||
fn foundations_align_with_tableau_cols_3_to_6() {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
for (i, suit) in foundation_suits.into_iter().enumerate() {
|
||||
let f_x = layout.pile_positions[&PileType::Foundation(suit)].x;
|
||||
let t_x = layout.pile_positions[&PileType::Tableau(3 + i)].x;
|
||||
for slot in 0..4_u8 {
|
||||
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
|
||||
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
|
||||
assert!(
|
||||
(f_x - t_x).abs() < 1e-5,
|
||||
"foundation {:?} should align with tableau {}",
|
||||
suit,
|
||||
3 + i
|
||||
"foundation slot {slot} should align with tableau {}",
|
||||
3 + slot as usize,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
@@ -82,15 +81,12 @@ impl Plugin for SelectionPlugin {
|
||||
|
||||
/// The ordered list of piles that are considered for keyboard cycling.
|
||||
///
|
||||
/// Order: Waste → Foundation×4 → Tableau 0–6.
|
||||
/// Order: Waste → Foundation slots 0–3 → Tableau 0–6.
|
||||
fn cycled_piles() -> Vec<PileType> {
|
||||
let mut piles = vec![
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
];
|
||||
let mut piles = vec![PileType::Waste];
|
||||
for slot in 0..4_u8 {
|
||||
piles.push(PileType::Foundation(slot));
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
piles.push(PileType::Tableau(i));
|
||||
}
|
||||
@@ -183,10 +179,10 @@ fn handle_selection_keys(
|
||||
let available: Vec<PileType> = {
|
||||
let all = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Foundation(0),
|
||||
PileType::Foundation(1),
|
||||
PileType::Foundation(2),
|
||||
PileType::Foundation(3),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
@@ -325,10 +321,10 @@ fn try_foundation_dest(
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
) -> Option<PileType> {
|
||||
use solitaire_core::rules::can_place_on_foundation;
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let dest = PileType::Foundation(suit);
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, pile, suit) {
|
||||
&& can_place_on_foundation(card, pile) {
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,10 +248,18 @@ fn push_on_exit(
|
||||
Ok(handle) => handle.block_on(provider.push(&payload)),
|
||||
Err(_) => future::block_on(provider.push(&payload)),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
// Log push failures on exit so they appear in crash/log reports.
|
||||
// We cannot surface them to the UI at this point (game loop is done).
|
||||
warn!("sync push on exit failed: {e}");
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
// `UnsupportedPlatform` is the expected response of
|
||||
// `LocalOnlyProvider`; treat it the same as the pull path does —
|
||||
// no backend configured is not a failure.
|
||||
Err(SyncError::UnsupportedPlatform) => {}
|
||||
Err(e) => {
|
||||
// Log real push failures on exit so they appear in crash/log
|
||||
// reports. We cannot surface them to the UI at this point (game
|
||||
// loop is done).
|
||||
warn!("sync push on exit failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -225,8 +225,8 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
let mut piles: Vec<PileType> = Vec::with_capacity(13);
|
||||
piles.push(PileType::Stock);
|
||||
piles.push(PileType::Waste);
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
piles.push(PileType::Foundation(suit));
|
||||
for slot in 0..4_u8 {
|
||||
piles.push(PileType::Foundation(slot));
|
||||
}
|
||||
for i in 0..7 {
|
||||
piles.push(PileType::Tableau(i));
|
||||
@@ -244,18 +244,9 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
PileMarker(pile.clone()),
|
||||
));
|
||||
|
||||
// Task #35 — suit symbol on empty foundation placeholders.
|
||||
if let PileType::Foundation(suit) = &pile {
|
||||
let symbol = suit_symbol(suit).to_string();
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
Text2d::new(symbol),
|
||||
TextFont { font_size, ..default() },
|
||||
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.45)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
});
|
||||
}
|
||||
// Foundation slots no longer carry a suit letter — any Ace can claim
|
||||
// any empty slot, so a fixed C/D/H/S badge would be misleading. Empty
|
||||
// foundation markers render as plain translucent rectangles.
|
||||
|
||||
// Task #43 — King indicator on empty tableau placeholders.
|
||||
if let PileType::Tableau(_) = &pile {
|
||||
|
||||
@@ -35,9 +35,9 @@ pub enum CardThemeLoaderError {
|
||||
Parse(#[from] ron::error::SpannedError),
|
||||
#[error("manifest validation: {0}")]
|
||||
Validation(#[from] ManifestError),
|
||||
/// `AssetPath::resolve` rejected a manifest-relative path. Almost
|
||||
/// always means the manifest contains an absolute path or a
|
||||
/// surface that includes a custom asset source the manifest
|
||||
/// `AssetPath::resolve_embed` rejected a manifest-relative path.
|
||||
/// Almost always means the manifest contains an absolute path or
|
||||
/// a surface that includes a custom asset source the manifest
|
||||
/// shouldn't be reaching across.
|
||||
#[error("could not resolve asset path: {0}")]
|
||||
PathResolve(#[from] ParseAssetPathError),
|
||||
@@ -73,12 +73,18 @@ impl AssetLoader for CardThemeLoader {
|
||||
// it via `.loader()`.
|
||||
let manifest_path: AssetPath<'static> = load_context.path().clone();
|
||||
|
||||
let back_path = manifest_path.resolve(&path_to_str(&manifest.back))?;
|
||||
// `resolve_embed` is the RFC 1808 sibling-resolution method:
|
||||
// the last segment of the base path (the manifest filename) is
|
||||
// stripped before concatenation, so `themes/foo/theme.ron` +
|
||||
// `hearts_4.svg` resolves to `themes/foo/hearts_4.svg`. Plain
|
||||
// `resolve` would concatenate, giving `themes/foo/theme.ron/hearts_4.svg`,
|
||||
// which is never what manifest-relative references mean.
|
||||
let back_path = manifest_path.resolve_embed(&path_to_str(&manifest.back))?;
|
||||
let face_full: Vec<(CardKey, AssetPath<'static>)> = face_paths
|
||||
.iter()
|
||||
.map(|(k, p)| {
|
||||
manifest_path
|
||||
.resolve(&path_to_str(p))
|
||||
.resolve_embed(&path_to_str(p))
|
||||
.map(|ap| (*k, ap))
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
//! changing the constant API.
|
||||
|
||||
use bevy::color::Color;
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::Val;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
@@ -48,6 +49,13 @@ pub const BG_ELEVATED_PRESSED: Color = Color::srgb(0.149, 0.082, 0.357);
|
||||
/// them. `rgba(13, 7, 28, 0.85)`.
|
||||
pub const SCRIM: Color = Color::srgba(0.051, 0.027, 0.110, 0.85);
|
||||
|
||||
/// Translucent fill for the top-of-window HUD band painted by
|
||||
/// `hud_plugin::spawn_hud_band`. Same midnight-purple hue as `BG_BASE`,
|
||||
/// but at 0.70 alpha so the green felt reads through subtly — enough
|
||||
/// to mark the band as "UI" without feeling like a hard chrome strip.
|
||||
/// `rgba(26, 15, 46, 0.70)`.
|
||||
pub const BG_HUD_BAND: Color = Color::srgba(0.102, 0.059, 0.180, 0.70);
|
||||
|
||||
/// Primary text — warm off-white with a hint of purple to fit the
|
||||
/// midnight palette without feeling clinical. `#F5F0FF`.
|
||||
pub const TEXT_PRIMARY: Color = Color::srgb(0.961, 0.941, 1.000);
|
||||
@@ -88,6 +96,106 @@ pub const STATE_DANGER: Color = Color::srgb(0.969, 0.447, 0.447);
|
||||
/// Info — daily-challenge constraint, draw-cycle indicator. `#6BBBFF`.
|
||||
pub const STATE_INFO: Color = Color::srgb(0.420, 0.733, 1.000);
|
||||
|
||||
/// Soft fill colour for the drop-target overlay shown over every legal
|
||||
/// destination pile while the player is dragging a card. Same green hue
|
||||
/// as `STATE_SUCCESS` (`#4ADE80`) so the visual language stays
|
||||
/// consistent, but at 10 % alpha so the underlying card faces remain
|
||||
/// fully readable through the wash.
|
||||
pub const DROP_TARGET_FILL: Color = Color::srgba(0.290, 0.871, 0.502, 0.10);
|
||||
|
||||
/// Outline colour for the drop-target overlay. Matches the
|
||||
/// `STATE_SUCCESS` hue at 75 % alpha so the rectangle border reads
|
||||
/// unmistakably against both the felt and stacked card faces without
|
||||
/// drowning the cards themselves.
|
||||
pub const DROP_TARGET_OUTLINE: Color = Color::srgba(0.290, 0.871, 0.502, 0.75);
|
||||
|
||||
/// Thickness of the drop-target outline edges, in world-space pixels.
|
||||
pub const DROP_TARGET_OUTLINE_PX: f32 = 3.0;
|
||||
|
||||
/// Sprite-space `Transform.z` for drop-target overlay entities. Sits
|
||||
/// well above any static card (top stack z is `~1.04`) but well below
|
||||
/// the lifted dragged stack (`DRAG_Z = 500.0` in `input_plugin`) so the
|
||||
/// overlay never occludes the card the player is holding. Distinct from
|
||||
/// the i32 `Z_*` UI-Node tokens above — those are `ZIndex` values for
|
||||
/// `bevy::ui`, while this is a 2D `Sprite` z coordinate.
|
||||
pub const Z_DROP_OVERLAY: f32 = 50.0;
|
||||
|
||||
/// Background colour of the stock-pile remaining-count chip.
|
||||
///
|
||||
/// Reuses `BG_ELEVATED_HI` so the chip reads as one rung above the
|
||||
/// translucent stock pile marker without introducing a new palette
|
||||
/// value. The badge sits on the stock corner so the player knows how
|
||||
/// many cards remain before a recycle.
|
||||
pub const STOCK_BADGE_BG: Color = BG_ELEVATED_HI;
|
||||
|
||||
/// Foreground (text) colour of the stock-pile remaining-count chip.
|
||||
///
|
||||
/// `ACCENT_PRIMARY` keeps the chip readable against the elevated
|
||||
/// purple background and matches the Balatro accent already used for
|
||||
/// other "look here" callouts.
|
||||
pub const STOCK_BADGE_FG: Color = ACCENT_PRIMARY;
|
||||
|
||||
/// Sprite-space `Transform.z` for the stock-pile remaining-count chip.
|
||||
///
|
||||
/// Sits above the stock pile marker (`Z_PILE_MARKER` = `-1`) and any
|
||||
/// face-down stock cards (which start at `0`), but well below
|
||||
/// [`Z_DROP_OVERLAY`] (`50.0`) so the green drop-target wash always
|
||||
/// renders on top while a card is being dragged. Like `Z_DROP_OVERLAY`,
|
||||
/// this is a 2D `Sprite` z coordinate, not a `bevy::ui` `ZIndex`.
|
||||
pub const Z_STOCK_BADGE: f32 = 30.0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card drop-shadow — the subtle dark halo painted beneath every card so the
|
||||
// play surface reads as physical instead of a flat collage of stickers. Idle
|
||||
// values are deliberately low-contrast (small offset, ~25% alpha) so resting
|
||||
// cards feel grounded without competing with focus rings or drop overlays.
|
||||
// Drag values are slightly stronger (further offset, ~40% alpha, larger
|
||||
// halo) so the dragged stack visually "lifts" off the felt.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// RGB base for the per-card drop shadow. Always neutral black — never
|
||||
/// suit-tinted — so the shadow never carries colour information that a
|
||||
/// colour-blind player would rely on to identify a card. Alpha is applied
|
||||
/// separately via [`CARD_SHADOW_ALPHA_IDLE`] / [`CARD_SHADOW_ALPHA_DRAG`].
|
||||
pub const CARD_SHADOW_COLOR: Color = Color::srgb(0.0, 0.0, 0.0);
|
||||
|
||||
/// Alpha for the resting-state card shadow. Low enough that 52 stacked
|
||||
/// shadows do not darken the felt into a uniform smear, high enough that
|
||||
/// each card reads as separated from the surface.
|
||||
pub const CARD_SHADOW_ALPHA_IDLE: f32 = 0.25;
|
||||
|
||||
/// Alpha for the lifted/dragged card shadow. Stronger than the idle value
|
||||
/// so the dragged stack visibly "casts more shadow" while the player holds
|
||||
/// it above the table.
|
||||
pub const CARD_SHADOW_ALPHA_DRAG: f32 = 0.40;
|
||||
|
||||
/// World-space pixel offset of the resting-state card shadow relative to
|
||||
/// its parent card centre. Down-and-right matches a soft top-left light
|
||||
/// source — the same convention used by the elevated-surface tones in the
|
||||
/// rest of the palette.
|
||||
pub const CARD_SHADOW_OFFSET_IDLE: Vec2 = Vec2::new(2.0, -3.0);
|
||||
|
||||
/// World-space pixel offset of the lifted/dragged card shadow. Roughly
|
||||
/// double the idle offset so the parallax reads as "the card is further
|
||||
/// from the table".
|
||||
pub const CARD_SHADOW_OFFSET_DRAG: Vec2 = Vec2::new(4.0, -6.0);
|
||||
|
||||
/// Padding in pixels added to each axis of the card size when sizing the
|
||||
/// resting-state shadow sprite. The shadow extends slightly past every
|
||||
/// edge of the card so the dark border reads as a halo rather than a
|
||||
/// matte rectangle behind the card.
|
||||
pub const CARD_SHADOW_PADDING_IDLE: Vec2 = Vec2::new(4.0, 4.0);
|
||||
|
||||
/// Padding added to the card size when sizing the lifted/dragged shadow.
|
||||
/// A slightly larger halo at the drag state reinforces the "lifted off
|
||||
/// the felt" cue alongside the deeper offset and higher alpha.
|
||||
pub const CARD_SHADOW_PADDING_DRAG: Vec2 = Vec2::new(8.0, 8.0);
|
||||
|
||||
/// Local `Transform.z` for the shadow child sprite, relative to its
|
||||
/// parent `CardEntity`. Slightly negative so the shadow always renders
|
||||
/// below the card itself even though it shares the parent's world z.
|
||||
pub const CARD_SHADOW_LOCAL_Z: f32 = -0.05;
|
||||
|
||||
/// Subtle border — default popover, card, and idle button outline.
|
||||
pub const BORDER_SUBTLE: Color = Color::srgba(0.647, 0.549, 1.000, 0.12);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
Reference in New Issue
Block a user