Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41a009a693 | |||
| fa7f98ac52 | |||
| 9891ae4ba3 | |||
| cdcaddaabe | |||
| d752870007 | |||
| 1d1543e4bc | |||
| 651f4060e6 | |||
| a1376075bd | |||
| ceec4fc486 | |||
| 0d477ac9fd | |||
| 4b51e50203 | |||
| f2d2119db5 | |||
| 59424a370c | |||
| fb8b2ac684 | |||
| 690e1d2ad6 | |||
| 35516d31f6 | |||
| 9b065e5ac6 | |||
| e1b8766e15 | |||
| 67c150bd7b | |||
| aa2a021712 | |||
| 6037596cc0 | |||
| d7ffb16df5 | |||
| b57db017d3 | |||
| 0b3140ad6d | |||
| e41def8c89 | |||
| aad8bb9c83 | |||
| 55c235b55f | |||
| 21ec03b157 | |||
| 17e3112502 | |||
| de4751115f | |||
| 9ff48ace5b | |||
| 91b7605b9f | |||
| 42d90b199c | |||
| 3e11e9e79a | |||
| bfcd05fbb5 | |||
| c497c3193c | |||
| 9aa0dd23b1 | |||
| d065d49fe7 | |||
| c30b04ec72 | |||
| 40d6e0ab17 | |||
| 9fe650fa20 | |||
| b73d246b4c | |||
| ae40a1db7a | |||
| b7c3a4996f | |||
| d48b9489db | |||
| 08b006ff30 | |||
| 17e0737a10 | |||
| dd63261999 | |||
| 93660c2217 | |||
| 56e2e6f151 | |||
| cc635328be | |||
| a4bc063497 | |||
| 540869c851 | |||
| bdac754b26 | |||
| f863d85c35 | |||
| 3c7a0eb4fb | |||
| d489e7a31b | |||
| f2f30c8002 | |||
| a49a340a30 | |||
| 27cdf78ce0 | |||
| faa6c5efc4 | |||
| 487b99bbc9 | |||
| 53e3b816cf | |||
| 87275bf340 | |||
| 56647d7f0d | |||
| cbf2483028 | |||
| a54201e97b | |||
| 48e412177c | |||
| cd54ce1bb0 | |||
| 7a3032b74c | |||
| 89699a8a86 | |||
| 70165da103 | |||
| 8a5fa8751c | |||
| bf660df971 | |||
| 13a8a012ee | |||
| 02ababa65f | |||
| 9c36b49729 | |||
| 8e90574437 | |||
| 95fcdad5d2 |
@@ -47,11 +47,10 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
|
||||
### Design Principles
|
||||
|
||||
- **Offline first.** The local file is always the source of truth. Sync is additive, never destructive.
|
||||
- **Pure core.** All game logic lives in a dependency-free Rust crate with no Bevy, no network, and no I/O. This keeps it fully unit-testable and portable.
|
||||
- **No panics in game logic.** Every state transition returns `Result<_, MoveError>`. Panics are only acceptable in startup/configuration code.
|
||||
- **One language, one repo.** The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace.
|
||||
- **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s.
|
||||
- **UI-first interaction.** Every player-triggered action — new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, etc. — must be reachable from a visible UI control. Keyboard shortcuts exist only as optional accelerators for power users; they are never the sole entry point. A player using only mouse or touch must be able to perform every action. New gameplay features ship with the UI control alongside the system that backs it.
|
||||
|
||||
Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enforced by CLAUDE.md §2.1, §2.3, and §3.3 respectively — those are the canonical statements; this file describes the design that motivates them.
|
||||
|
||||
---
|
||||
|
||||
@@ -716,11 +715,14 @@ pub struct AchievementDef {
|
||||
| `speed_and_skill` | ??? | Win < 90s without undo | Yes | Card back #4 |
|
||||
| `comeback` | ??? | Win after 3+ stock recycles | Yes | Background #4 |
|
||||
| `zen_winner` | ??? | Win in Zen Mode | Yes | Badge |
|
||||
| `cinephile` | Cinephile | Watch a saved replay all the way through | No | — |
|
||||
|
||||
### Evaluation Timing
|
||||
|
||||
Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently.
|
||||
|
||||
A small number of achievements are *event-driven* rather than condition-driven: their `AchievementDef::condition` always returns `false` and their unlock is written from a dedicated observer system instead. `cinephile` is the canonical example — it unlocks when `ReplayPlaybackState` transitions from `Playing` to `Completed` (a saved replay watched to its natural end). The Stop button transitions `Playing → Inactive` directly without entering `Completed`, so manual aborts do not unlock the achievement.
|
||||
|
||||
---
|
||||
|
||||
## 12. Progression System
|
||||
|
||||
@@ -6,7 +6,709 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
_Nothing yet._
|
||||
No threads in flight. v0.20.0 cut on 2026-05-07; CHANGELOG accumulates
|
||||
the next cycle here.
|
||||
|
||||
## [0.20.0] — 2026-05-07
|
||||
|
||||
Two through-lines closed: a full **Android port** (build target,
|
||||
first 54 MB APK, JNI-free per-app persistence shim) and the
|
||||
**Terminal visual-identity port** that replaces the prior
|
||||
Premium-Solitaire palette across every UI surface. The Android
|
||||
arc opened in `fb8b2ac` (compile + APK), continued in `4b51e50`
|
||||
(`solitaire_data::data_dir` shim closing the CLAUDE.md §10
|
||||
`dirs::data_dir() = None` pitfall), and is functional end-to-end
|
||||
on a real device — though the runtime artwork is still the legacy
|
||||
white-card palette, and JNI ClipboardManager / keyring bridges
|
||||
remain stubbed (matching v0.19.0's documented fallback behaviour).
|
||||
The Terminal port lands as a top-down stack: the `ui_theme` token
|
||||
API in `0d477ac` is load-bearing, and the rest of the cycle is
|
||||
downstream applications (modal scaffold, gameplay-feedback,
|
||||
toasts, table / card chrome, splash cursor, hint-highlight
|
||||
pairing). The card faces and suit-pip palette are deliberately
|
||||
NOT migrated — those track PNG artwork that hasn't been
|
||||
regenerated yet, and swapping the fallback constants ahead of the
|
||||
artwork would mix two visual systems on any code path where
|
||||
image loading fails.
|
||||
|
||||
The 24 Stitch-rendered mockups in `docs/ui-mockups/` are now
|
||||
in-tree (`fa7f98a`); future plugin work should diff against the
|
||||
matching mockup before touching pixels.
|
||||
|
||||
Two threads from v0.19.0's punch list also closed in this cycle:
|
||||
the pull-failure test flake (`67c150b`), the Settings opt-out for
|
||||
the smart-default window sizer (`e1b8766`), and the share-link
|
||||
discoverability surfacing (`9b065e5`). The remaining v0.19.0
|
||||
candidate — the app-icon round — stays open.
|
||||
|
||||
### Added
|
||||
|
||||
- **`ui_theme` Terminal design-token system** (`0d477ac`). Single
|
||||
source of truth for the engine's visual identity:
|
||||
base16-eighties palette (cyan primary CTA, lime/lavender/gold/
|
||||
teal/pink semantic accents), 5-rung type scale, 7-rung 4-multiple
|
||||
spacing scale, 3-step radius, 14-rung z-index hierarchy, full
|
||||
motion budget, and four invariant-pinning unit tests. Every
|
||||
downstream port commit in this cycle reads from this module —
|
||||
swapping the palette is now a one-file edit, not a hunt across
|
||||
~50 plugin files. Card-shadow alphas pinned to 0 (Terminal
|
||||
achieves depth via 1px borders + tonal layering, no
|
||||
`box-shadow`); the rendering path is left intact so a future
|
||||
palette can re-enable shadows without touching consumers.
|
||||
- **`ToastVariant` enum + Terminal toast styling** (`a137607`).
|
||||
Toasts now follow `docs/ui-mockups/design-system.md`: opaque
|
||||
`BG_ELEVATED` fill, 1px accent border keyed off
|
||||
`Info` / `Warning` / `Error` / `Celebration` variants, 18px
|
||||
monospaced caption (`TYPE_BODY_LG`), bottom-anchored. All ten
|
||||
call sites pass their semantic variant: achievement / level-up
|
||||
/ XP / daily / weekly / challenge → Celebration (lavender);
|
||||
goal-announcement / time-attack / settings volume / auto-complete
|
||||
→ Info (teal). Two regression tests pin variant→border mapping
|
||||
to the design tokens and require all four borders to be visually
|
||||
distinct. Queued and immediate toasts use slightly different
|
||||
bottom anchors (6 % vs. 14 %) so a celebration toast spawned
|
||||
alongside a queued info banner layers above it.
|
||||
- **Terminal cursor block on the splash overlay** (`cdcadda`).
|
||||
The launch splash now renders the design system's signature
|
||||
`▌` cyan (`ACCENT_PRIMARY`) glyph (96 px, hand-tuned literal)
|
||||
above the wordmark, matching `docs/ui-mockups/splash-mobile.html`.
|
||||
Cursor fades on the same per-frame alpha schedule as the title
|
||||
and subtitle so the brand beat still dissolves as a single
|
||||
layer. Did *not* pull in the mockup's full boot-loader treatment
|
||||
(scanline overlay, ✓ check log, progress bar, ROOT@SOLITAIRE
|
||||
prompt) — those are aesthetic features warranting their own
|
||||
commit.
|
||||
- **Terminal design-system spec + 24-mockup library** (`fa7f98a`).
|
||||
`docs/ui-mockups/design-system.md` (palette, type scale, spacing
|
||||
scale, motion budget, component library, accessibility notes —
|
||||
color-blind toggle, high-contrast mode, glyph differentiation,
|
||||
canonical `"Terminal"` card-back theme) and 24 Stitch-rendered
|
||||
mockups (HTML + PNG): 12 redesigned existing screens, 1 desktop
|
||||
home variant, 2 onboarding steps, and 9 missing-plugin screens
|
||||
(splash, challenge, time-attack, weekly-goals, leaderboard,
|
||||
sync, level-up, replay, radial-menu). The spec the rest of this
|
||||
cycle ports against; future plugin work diffs here before
|
||||
touching pixels.
|
||||
- **Android build target — first working APK** (`fb8b2ac`).
|
||||
`cargo apk build -p solitaire_app --target x86_64-linux-android`
|
||||
now produces a 54 MB debug-signed APK at
|
||||
`target/debug/apk/solitaire-quest.apk`. Five gating points
|
||||
resolved end-to-end:
|
||||
- **`solitaire_app` split into bin + lib.** cargo-apk needs a
|
||||
`cdylib` to bundle as `libmain.so`; pure-bin crates panic
|
||||
with "Bin is not compatible with Cdylib". `src/lib.rs`
|
||||
carries the ECS bootstrap as `pub fn run`; `src/main.rs` is
|
||||
a 3-line shim that delegates for the desktop path.
|
||||
- **`[package.metadata.android]`** pins target SDK 34 / min
|
||||
SDK 26 and points `assets = "../assets"` at the workspace
|
||||
asset directory so desktop and APK share one set.
|
||||
- **Workspace `bevy` features** add `android-native-activity`
|
||||
(target-gated inside bevy_internal — desktop builds compile
|
||||
it out). Pairs with cargo-apk's NativeActivity wrapper.
|
||||
- **`arboard` target-gated** to `cfg(not(target_os =
|
||||
"android"))`. The crate has no Android backend; cargo apk
|
||||
fails with E0433 on `platform::Clipboard` if left
|
||||
unconditional. Stats's "Copy share link" surfaces an
|
||||
informational toast on Android until JNI ClipboardManager
|
||||
lands in the Phase-Android round.
|
||||
- **`keyring` + `keyring-core` target-gated.** Bionic doesn't
|
||||
expose `libc::__errno_location` so the transitive
|
||||
`rpassword` won't compile. `auth_tokens` ships an Android
|
||||
stub returning `KeychainUnavailable` for every call —
|
||||
matches the existing fallback for a Linux box without
|
||||
Secret Service.
|
||||
- Cosmetic: cargo-apk panics post-sign when it tries to also
|
||||
wrap the bin target. The APK on disk is unaffected;
|
||||
`cargo apk build --lib` is the small workaround.
|
||||
- **Android developer setup + build runbook** (`59424a3`).
|
||||
Captures Debian 13 toolchain install (JDK 21, unzip, SDK
|
||||
licence prompts), the `cargo apk build` invocation, the
|
||||
cosmetic post-sign panic workaround, and a what-is-wired-vs-
|
||||
stubbed table for the android target. Runnable on a fresh
|
||||
clone — no machine-local context required.
|
||||
- **F3-toggleable FPS / frame-time overlay** (`690e1d2`).
|
||||
`DiagnosticsHudPlugin` wraps Bevy's `FrameTimeDiagnosticsPlugin`
|
||||
and renders a corner readout the developer toggles with F3.
|
||||
Hidden by default; F3 is not gated by pause / modal state.
|
||||
Reads `smoothed()` so the cell isn't a per-frame jittery
|
||||
scoreboard. Format: `FPS NN \u{2022} M.MM ms`. Anchored
|
||||
top-right at `z = Z_SPLASH + 100` above every modal / toast /
|
||||
splash. Update system bails when hidden so the
|
||||
diagnostic-store lookup is free when nobody's looking.
|
||||
- **"Smart window size" Settings toggle** (`e1b8766`). Gameplay
|
||||
section gains an opt-out toggle for v0.19.0's
|
||||
`apply_smart_default_window_size` system. New
|
||||
`Settings::disable_smart_default_size: bool` with
|
||||
`#[serde(default)]` so legacy `settings.json` files load to
|
||||
the shipped behaviour (smart sizer enabled). `solitaire_app::main`
|
||||
reads the flag once at startup and skips the system's
|
||||
registration when set. Saved window geometry still wins over
|
||||
both branches; tooltip on the row makes that explicit.
|
||||
- **"Shareable" badge on the Latest-win caption** (`9b065e5`).
|
||||
The Stats overlay's Latest-win caption now appends
|
||||
`\u{2022} Shareable` when the displayed replay carries a
|
||||
populated `share_url`. Players can see at a glance whether the
|
||||
Copy share link button will produce a URL or surface the
|
||||
upload-prerequisite toast.
|
||||
- **Help overlay covers M / P / Win-Summary-Enter** (`35516d3`).
|
||||
Three new rows in the Overlays section: M (Home / Mode
|
||||
launcher), P (Profile), and the Enter accelerator that
|
||||
dismisses the Win Summary modal. Three post-v0.18 entries
|
||||
that had drifted out of the cheat sheet are now listed.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Gameplay-feedback colours route through Terminal state
|
||||
tokens** (`ceec4fc`). Selection-highlight tints in
|
||||
`selection_plugin` and the valid-drop marker tint in
|
||||
`cursor_plugin` were hand-tuned RGB literals. Migrated to
|
||||
semantic state tokens: keyboard-drag picking source →
|
||||
`ACCENT_PRIMARY` (cyan focus); keyboard-drag lifted source →
|
||||
`STATE_WARNING` (gold attention); destination → `STATE_SUCCESS`
|
||||
(lime valid-move); `cursor_plugin::MARKER_VALID` →
|
||||
`STATE_SUCCESS` at 0.55 α with a tracking test pinning its RGB
|
||||
to the token. Three stale doc comments in `ui_modal` corrected
|
||||
("loud yellow CTA" / "magenta secondary accent" → cyan /
|
||||
lavender to match the actual token values).
|
||||
- **`table_plugin` chrome migration to Terminal tokens** (`651f406`).
|
||||
`marker_colour` promoted to module-level `pub const
|
||||
PILE_MARKER_DEFAULT_COLOUR` so `cursor_plugin::MARKER_DEFAULT`
|
||||
imports the const directly — replaces the prior
|
||||
duplicated literal kept in sync only by doc comment with a
|
||||
compile-enforced invariant. The empty-tableau "K" placeholder
|
||||
text now uses `TEXT_PRIMARY` at 0.35 α; `HINT_PILE_HIGHLIGHT_COLOUR`
|
||||
retuned from bright `srgb(1.0, 0.85, 0.1)` to the `STATE_WARNING`
|
||||
token (`#ddb26f`) with a tracking test, and the existing "is
|
||||
gold" character test loosened to fit the muted Terminal gold
|
||||
while still rejecting non-warm colours.
|
||||
- **`card_plugin` chrome migration to Terminal tokens** (`d752870`).
|
||||
Drag-elevation shadow now sources its colour from
|
||||
`CARD_SHADOW_COLOR` + `CARD_SHADOW_ALPHA_DRAG` so the Terminal
|
||||
"no box-shadow" policy disables the stack shadow in lockstep
|
||||
with the per-card shadows. `RIGHT_CLICK_HIGHLIGHT_COLOUR`
|
||||
retuned from raw green to `STATE_SUCCESS` at 0.6 α with a
|
||||
tracking test. The duplicated `PILE_MARKER_DEFAULT_COLOUR`
|
||||
const dropped — this plugin now imports the promoted const
|
||||
from `table_plugin`. Stock recycle "↺" text moved from raw
|
||||
white-at-0.7-α to `TEXT_PRIMARY.with_alpha(0.7)`. Card-face /
|
||||
suit / card-back palette constants were intentionally NOT
|
||||
migrated (the runtime path renders PNG artwork that's still on
|
||||
the previous "white card" palette).
|
||||
- **Hint-source card tint matches the destination pile**
|
||||
(`9891ae4`). `input_plugin`'s hint-source card tint moved from
|
||||
raw bright-yellow `srgba(1.0, 1.0, 0.4, 1.0)` to `STATE_WARNING`,
|
||||
so the source card and the destination pile (which already uses
|
||||
`STATE_WARNING` via `HINT_PILE_HIGHLIGHT_COLOUR`) wear the same
|
||||
attention colour as a coherent pair.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`solitaire_data::data_dir` shim closes the Android persistence
|
||||
gap** (`4b51e50`). `dirs::data_dir()` returns `None` on Android,
|
||||
which silently disabled every persistence path (settings, stats,
|
||||
achievements, replays, game-state, time-attack sessions, user
|
||||
themes). New `solitaire_data::platform::data_dir()` shim falls
|
||||
through to `dirs::data_dir()` on desktop and returns the per-app
|
||||
sandbox at `/data/data/com.solitairequest.app/files` on Android
|
||||
— no JNI needed, since the package id is pinned in
|
||||
`[package.metadata.android]`. Six call sites across
|
||||
`solitaire_data` plus `solitaire_engine/assets/user_dir.rs`
|
||||
migrated. CLAUDE.md §10 already flagged this as a known
|
||||
pitfall; the shim pays it down at the one chokepoint instead
|
||||
of per feature.
|
||||
- **`card_shadow_params` test aligned with Terminal "no shadow"
|
||||
intent** (`1d1543e`). The Terminal token system pinned both
|
||||
`CARD_SHADOW_ALPHA_IDLE` and `CARD_SHADOW_ALPHA_DRAG` to 0.0,
|
||||
which made the prior `drag_alpha > idle_alpha` assertion fail
|
||||
(`0 > 0` is false). Loosened to `drag_alpha >= idle_alpha`
|
||||
with a comment naming the new invariant: under Terminal both
|
||||
are 0; under any future palette that re-enables shadows, drag
|
||||
still must not be weaker than idle. The useful regression-guard
|
||||
(catching an accidental swap of the two constants) is preserved.
|
||||
- **`pull_failure_sets_error_status` test flake** (`67c150b`).
|
||||
The fixed 5-update budget was the last test still subject to
|
||||
the AsyncComputeTaskPool starvation mode that v0.19.0's
|
||||
auto-save fix already cleared. Replaced with a wall-clock-
|
||||
bounded loop (5-second deadline, `std::thread::yield_now`
|
||||
between iterations) that exits as soon as the status flips.
|
||||
Mirrors the auto-save flake fix shape.
|
||||
|
||||
### Stats
|
||||
|
||||
- **1176 passing tests / 0 failing** across the workspace
|
||||
(six new tests this cycle: four `ui_theme` invariant guards
|
||||
for the type / spacing / z-index scales + `scaled_duration`,
|
||||
one toast-variant-border-mapping pair, and four palette-
|
||||
tracking guards on `MARKER_VALID` / `HINT_PILE_HIGHLIGHT_COLOUR`
|
||||
/ `RIGHT_CLICK_HIGHLIGHT_COLOUR` / toast-border distinctness).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.19.0] — 2026-05-06
|
||||
|
||||
Closes the v0.18.0 punch list (items B and D — async hint and
|
||||
persistent replay share URLs), expands desktop platform fit
|
||||
(Wayland session support + monitor-aware default window size for
|
||||
HiDPI / 4K displays), polishes the win-celebration and
|
||||
double-click animation paths, and clears two test-flake
|
||||
contributors. A short-lived "Rusty Pixel" pixel-art card theme
|
||||
was prototyped and reverted in the same window — the engine
|
||||
plumbing it touched (`pixel_art` field on `ThemeMeta`, PNG
|
||||
manifest face support, second `embedded://` theme channel) was
|
||||
fully reverted and is not part of this release.
|
||||
|
||||
### Changed
|
||||
|
||||
- **H-key hint runs on `AsyncComputeTaskPool`** (`3e11e9e`). The
|
||||
synchronous `try_solve_from_state` call on every H press is gone;
|
||||
`handle_keyboard_hint` now spawns a task whose result the new
|
||||
`pending_hint::poll_pending_hint_task` system surfaces one frame
|
||||
later. New `PendingHintTask` resource carries the in-flight handle
|
||||
plus `move_count_at_spawn` for staleness detection;
|
||||
`drop_pending_hint_on_state_change` cancels the task whenever the
|
||||
game state shifts; `PendingHintTask::spawn` implements
|
||||
cancel-on-replace so two quick H presses keep at most one task in
|
||||
flight. Mirrors the v0.18.0 `PendingNewGameSeed` template.
|
||||
`emit_hint_visuals` and `find_heuristic_hint` are extracted as
|
||||
`pub` helpers so the polling system can call them.
|
||||
- **Persistent replay share URLs** (`42d90b1`). v0.18.0's
|
||||
`LastSharedReplayUrl` was an in-memory resource wiped on quit —
|
||||
the player had to share within the session of the win.
|
||||
`solitaire_data::Replay` now carries a `share_url: Option<String>`
|
||||
field with `#[serde(default)]` (no `REPLAY_SCHEMA_VERSION` bump
|
||||
needed; older `replays.json` files load unchanged with `share_url
|
||||
== None` on every entry). `poll_replay_upload_result` writes the
|
||||
resolved URL into `replays[0].share_url` and persists the updated
|
||||
history via `save_replay_history_to`. The Stats overlay's
|
||||
"Copy share link" button reads from
|
||||
`history.0.replays[selected.0].share_url`, so the Prev/Next
|
||||
selector's currently-displayed replay drives the clipboard
|
||||
contents — each historical win keeps its own URL.
|
||||
`LastSharedReplayUrl` removed (its role is now subsumed by the
|
||||
`share_url` field on the replay record).
|
||||
|
||||
### Added
|
||||
|
||||
- **Wayland session support** (`b57db01`). The workspace
|
||||
`Cargo.toml` Bevy feature list now enables `wayland` alongside
|
||||
`x11`. winit prefers Wayland when `WAYLAND_DISPLAY` is set on the
|
||||
session, falling back to X11 when it isn't. Pre-fix, a Wayland
|
||||
desktop environment fell through to XWayland, rendering the
|
||||
game inside an X11 frame stitched into the Wayland compositor.
|
||||
Post-fix, the game opens as a native Wayland surface. Costs a
|
||||
few hundred KB of binary for the libwayland-client bindings;
|
||||
cross-distro friendly because winit dlopen-probes the libraries
|
||||
rather than hard-linking them.
|
||||
- **Monitor-relative default window size** (`b57db01`). On launches
|
||||
with no saved geometry, the new
|
||||
`apply_smart_default_window_size` Update system queries
|
||||
`Monitor` (with the `PrimaryMonitor` marker) and resizes the
|
||||
primary window to ~70 % of the monitor's *logical* size on the
|
||||
first frame. Before, every fresh launch opened at 1280×800
|
||||
regardless of monitor; on a 4K monitor that's a comparatively
|
||||
tiny window in one corner. Logical size already accounts for
|
||||
the OS's HiDPI scale factor, so a Retina display reporting
|
||||
scale_factor 2.0 yields the same physical inches as a 1080p
|
||||
display reporting 1.0. Skipped entirely when saved geometry was
|
||||
applied — the player's chosen size always wins.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Duplicate "You Win" toast on game-won** (`55c235b`). The
|
||||
post-win UI was firing two celebration surfaces: a 4-second
|
||||
toast banner ("You Win! Score: X Time: Y") on top of the
|
||||
`win_summary_plugin`'s "You Won!" modal. In screenshots the
|
||||
toast banner was partially clipped behind the modal card,
|
||||
peeking out on either side. The toast predated the modal and is
|
||||
strictly subsumed by it; removed. The cards-fly-off cascade
|
||||
animation (`MotionCurve::Expressive` per-card rotation drift)
|
||||
is unchanged — that's the visual celebration, distinct from
|
||||
the textual celebration the modal owns. `WIN_TOAST_SECS` const
|
||||
removed.
|
||||
- **Double-click on a single card with no destination now plays
|
||||
the reject animation** (`d7ffb16`). `handle_double_click` only
|
||||
fired `MoveRejectedEvent` for multi-card stacks with no
|
||||
destination; a double-click on a single card whose top didn't
|
||||
fit any foundation or tableau slot produced zero feedback —
|
||||
no `card_invalid.wav`, no source-pile shake. Both priorities'
|
||||
failure paths now converge on a single rejection at the end of
|
||||
the double-click branch, so single-card and stack misses get
|
||||
the same feedback shape as drag-and-drop rejections.
|
||||
- **Double-click move animation no longer plays twice**
|
||||
(`6037596`). On a successful double-click, the slide-to-
|
||||
destination animation rendered twice — once from the move's
|
||||
`StateChangedEvent` landing, then again from the release's
|
||||
`end_drag` firing a redundant `StateChangedEvent` mid-slide.
|
||||
`sync_cards_on_change` saw the card mid-CardAnim (`cur ≠
|
||||
target`) and replaced the in-flight tween with a fresh one
|
||||
starting at the mid-position, visibly restarting the slide. The
|
||||
defensive `StateChangedEvent` write in `end_drag`'s
|
||||
uncommitted-drag branch is removed; `start_drag` only mutates
|
||||
`DragState` (never card transforms), so an uncommitted drag
|
||||
has no visual side effect to undo. The committed-drag branch
|
||||
keeps its `StateChangedEvent` since real drag snap-backs do
|
||||
need a resync.
|
||||
- **`auto_save_writes_after_30_seconds` test flake** (`91b7605`).
|
||||
The test's single-frame `app.update()` was sensitive to
|
||||
first-frame `Time::delta_secs()` variance under heavy parallel
|
||||
cargo-test load, and to production-disk
|
||||
`~/.local/share/solitaire_quest/game_state.json` state leaking
|
||||
into the test world via `GamePlugin::build`'s load path.
|
||||
`test_app` now resets `PendingRestoredGame(None)` after plugin
|
||||
build (preventing the dev machine's saved-game state from
|
||||
tripping the auto-save guard) and the test re-arms the timer in
|
||||
a small bounded loop until the file appears (robust against
|
||||
first-frame Time variance). No production-code change.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1170 passing tests (was 1166 at v0.18.0 close — net +4 from
|
||||
the persistent share URL backwards-compat test, the three
|
||||
async-hint tests, minus the dropped synchronous hint tests).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.18.0] — 2026-05-06
|
||||
|
||||
The launch-experience round. The engine used to drop the player on a
|
||||
silent default Classic deal whether they had unfinished work or not;
|
||||
v0.18.0 replaces that with two stacked decision points — a Restore
|
||||
prompt for in-progress saves, then an MSSC-style Home / mode picker
|
||||
that surfaces Daily / Zen / Challenge / Time Attack as picture tiles
|
||||
with live stats. The same round closes the last solver-on-main-thread
|
||||
hot path (winnable-only seed selection moves to
|
||||
`AsyncComputeTaskPool`), wires "Copy share link" into Stats, lights a
|
||||
"Won before" HUD chip on re-deals of beaten seeds, and tidies the
|
||||
unified-3.0 rule set across CLAUDE.md / CLAUDE_SPEC.md /
|
||||
CLAUDE_WORKFLOW.md / CLAUDE_PROMPT_PACK.md.
|
||||
|
||||
### Added
|
||||
|
||||
- **Restore prompt on launch** (`3c7a0eb`). When `game_state.json`
|
||||
holds an in-progress game (`move_count > 0`, not won), the engine
|
||||
now seeds `GameStateResource` with a fresh deal and holds the saved
|
||||
game in a new `PendingRestoredGame` resource. After the splash
|
||||
clears, a "Welcome back" modal offers **Continue** (Enter / C /
|
||||
click) or **New game** (N / click). Fresh-deal saves
|
||||
(`move_count == 0`) skip the prompt and load directly.
|
||||
- **Save preservation while the prompt is unanswered** (`f863d85`).
|
||||
Both `save_game_state_on_exit` and `auto_save_game_state` consult
|
||||
`PendingRestoredGame` first: if it still holds a pending saved
|
||||
game, that's what gets persisted (or the auto-save is skipped),
|
||||
so exiting before answering the prompt no longer overwrites the
|
||||
meaningful save with the placeholder fresh deal.
|
||||
- **Home / mode picker auto-shows on launch** (`dd63261`). The mode
|
||||
picker was only reachable via **M** during gameplay; players who
|
||||
hadn't discovered the hotkey never saw the Daily / Zen / Challenge
|
||||
/ Time Attack entry points after the splash cleared. `HomePlugin`
|
||||
gains an `auto_show_on_launch` flag (default true) and a
|
||||
one-shot `LaunchHomeShown` gate. Skips when the Restore prompt is
|
||||
on screen so Welcome-back still takes precedence.
|
||||
- **MSSC-style Home picker — header / chips / score chips / draw
|
||||
mode** (`ae40a1d`). Player-stats header strip (Level / XP /
|
||||
Lifetime Score, compact-formatted as `1.2M` / `12.3K` / `1,234`)
|
||||
acts as a clickable shortcut to Profile. Draw-mode chip row above
|
||||
the mode cards lets the player flip Draw 1 / Draw 3 from the
|
||||
picker itself; persists `settings.json` and respawns the modal so
|
||||
the active state repaints cleanly. Per-mode best-score / streak
|
||||
chips on each card; hidden on a 0 best so a fresh profile doesn't
|
||||
read "Best 0" everywhere.
|
||||
- **Today's Event callout on the Daily card** (`b73d246`). "Today,
|
||||
May 6" date line plus the server-fetched goal (when SyncPlugin is
|
||||
wired). Once today's daily is recorded as completed, the date
|
||||
flips to `Today, May 6 • Done` in `ACCENT_PRIMARY` so the picker
|
||||
reads as a reward state rather than a TODO.
|
||||
- **Picture-tile mode cards** (`9fe650f` + glyph-picking follow-ups
|
||||
`40d6e0a`, `c30b04e`, `d065d49`). Mode cards become a wrapping
|
||||
2-up grid (`FlexWrap::Wrap`, tiles 48 % wide, `min_height: 180px`)
|
||||
with a centred Unicode-glyph centrepiece per tile. Final glyph set
|
||||
picked from FiraMono-Medium's actual coverage: ♣ Classic, ◆ Daily,
|
||||
○ Zen, ▲ Challenge, → TimeAttack. `ACCENT_PRIMARY` when the mode is
|
||||
unlocked, `TEXT_DISABLED` when locked. Centrepiece is a `Text` node
|
||||
for now — when real per-mode artwork lands, swap to `Image` without
|
||||
touching tile layout, focus order, or chip rendering.
|
||||
- **Solver-vetted seed selection on `AsyncComputeTaskPool`**
|
||||
(`d489e7a`). Closes the worst-case 6 s UI stall on a New Game
|
||||
click with "Winnable deals only" enabled. New `PendingNewGameSeed`
|
||||
resource holds the in-flight `Task<u64>` plus the original
|
||||
request's `mode` / `confirmed` flags. `poll_pending_new_game_seed`
|
||||
runs `.before(GameMutation)` and replays a synthetic
|
||||
`NewGameRequestEvent` once the task resolves — the player sees no
|
||||
extra-frame visual lag. Cancel-on-replace: a fresh
|
||||
`NewGameRequestEvent` while a task is in flight drops the old
|
||||
task, letting Bevy's `Task` Drop cancel cooperatively at the next
|
||||
await point.
|
||||
- **"Won before" HUD indicator** (`bdac754`). When the current
|
||||
deal's `(seed, draw_mode, mode)` triple matches an entry in the
|
||||
rolling `ReplayHistory`, the HUD's tier-2 context row shows
|
||||
**✓ Won before** in `STATE_SUCCESS`. Cleared on win (the on-screen
|
||||
victory cue is enough) and on first-time deals. New
|
||||
`HudWonPreviously` marker driven by a separate
|
||||
`update_won_previously` system; gracefully no-ops in headless
|
||||
tests that don't load `StatsPlugin`.
|
||||
- **"Copy share link" Stats button** (`540869c`). End-to-end replay
|
||||
sharing on a server-backed sync backend:
|
||||
`sync_plugin::push_replay_on_win` spawns the upload on
|
||||
`AsyncComputeTaskPool` and stores the handle in
|
||||
`PendingReplayUpload` (drops any in-flight predecessor — the most
|
||||
recent win is what the player wants the link for);
|
||||
`poll_replay_upload_result` writes `<server>/replays/<id>` to
|
||||
`LastSharedReplayUrl` on success; the Stats overlay's action bar
|
||||
gains a button that writes the URL to the OS clipboard via
|
||||
`arboard` and surfaces a "Copied: \<url\>" toast. URL is in-memory
|
||||
only — sharing must happen within the session of the win.
|
||||
- **Empty-state copy + onboarding hints** (`56e2e6f`). Leaderboard
|
||||
empty state: two-tier "Be the first on the leaderboard." headline
|
||||
+ body invite. Achievements panel: first-launch hint above the
|
||||
grid until the first unlock. Volume hotkeys (`[` / `]`) now emit
|
||||
an `InfoToastEvent` with the new percentage so off-panel
|
||||
adjustments give visible feedback (previously silent).
|
||||
- **Enter dismisses the Win Summary and starts a fresh deal**
|
||||
(`17e0737`). The post-win modal's "Play Again" was click-only;
|
||||
keyboard-only players had to reach for the mouse to leave the
|
||||
celebration screen. The button label gains a trailing return-key
|
||||
glyph so the keyboard path is discoverable on first sight.
|
||||
- **`N` opens the real Confirm/Cancel modal** (`93660c2`). The old
|
||||
"Press N again" double-tap pattern was a UI-first violation (only
|
||||
continuation was another keystroke). `N` now fires
|
||||
`NewGameRequestEvent::default()` directly; `handle_new_game`'s
|
||||
active-game check spawns the existing `ConfirmNewGameScreen`. The
|
||||
HUD button already routed through the same modal — keyboard and
|
||||
mouse paths are unified. `Shift+N` keeps the keyboard power-user
|
||||
bypass (`confirmed: true`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Settings row layout** (`a4bc063`). All five
|
||||
slider/toggle row helpers (volume × 2, tooltip delay, time-bonus
|
||||
multiplier, replay-move interval, generic toggle) restructured to
|
||||
a label-spacer-cluster layout (`width: 100%`, label gets
|
||||
`flex-grow: 1`, controls cluster sits flush right). Stable across
|
||||
varying value-text widths ("0.80" → "1.00", "Instant" vs "1.5 s")
|
||||
and narrow windows.
|
||||
- **Docs adopt the unified-3.0 rule set** (`f2f30c8`). `CLAUDE.md`
|
||||
grows from a 114-line pointer doc to a 571-line rulebook (hard
|
||||
global constraints §2, engine rules §3, asset rules §4, code
|
||||
standards §5, build + verification §6, git workflow §7, the ASK
|
||||
BEFORE list §8, Context Injection System §14). New companions:
|
||||
`CLAUDE_SPEC.md` (formal architecture spec — crate dependency
|
||||
graph, data ownership, state-machine invariants, sync merge /
|
||||
server contracts, validation checklist),
|
||||
`CLAUDE_WORKFLOW.md` (two-agent Builder/Guardian pipeline with
|
||||
hard-fail patterns), `CLAUDE_PROMPT_PACK.md` (task-type
|
||||
templates). Three duplicate rule passages removed across
|
||||
`CLAUDE_SPEC.md` and `ARCHITECTURE.md`.
|
||||
- **Test discipline pruning** (`a49a340`). Removed 43 low-value
|
||||
tests across `solitaire_data` and `solitaire_core` (default-value
|
||||
tests, serde-derive round-trips on plain structs, single-field
|
||||
clamp tests, near-duplicates, constant-equals-itself tests). None
|
||||
pinned a behaviour contract or a regression on a real bug. Future
|
||||
agent briefs request tests for behaviour contracts or real-bug
|
||||
regressions, not a count of N.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Esc on a modal no longer opens Pause underneath** (`08b006f`).
|
||||
A single Esc press on Confirm New Game / Restore / Home /
|
||||
Onboarding / Settings used to both close the modal and spawn the
|
||||
Pause overlay on top in the same frame. `toggle_pause` now skips
|
||||
when any non-Pause `ModalScrim` is in the world; the HUD-button
|
||||
path is gated too. The four modal queries are bundled into a
|
||||
`PauseModalQueries` `SystemParam` to stay under Bevy's
|
||||
16-parameter cap.
|
||||
- **Esc dismisses Home / accepts the Restore-prompt default**
|
||||
(`d48b948`). Both screens previously ignored Esc, leaving the
|
||||
player no keyboard-only escape after the previous fix. Home: Esc
|
||||
behaves like Cancel (despawns the modal, keeps the underlying
|
||||
default deal). Restore: Esc maps to Continue (preserves the saved
|
||||
game, matching how the primary action already advertises Enter).
|
||||
- **Esc dismisses the topmost modal when Profile stacks on Home**
|
||||
(`9aa0dd2`). Clicking the Home header chip opens Profile on top
|
||||
of Home; Esc used to close Home (because
|
||||
`handle_home_cancel_button` fired with no awareness of layered
|
||||
modals) and leave Profile orphaned over the game.
|
||||
`profile_plugin` now splits P/button (toggle) from Esc
|
||||
(close-only); `handle_home_cancel_button` skips its Esc branch
|
||||
when any other `ModalScrim` exists.
|
||||
- **Restore-prompt resolution suppresses Home auto-show**
|
||||
(`b7c3a49`). Resolving the Welcome-back prompt cleared
|
||||
`PendingRestoredGame` and despawned the modal, but the
|
||||
launch-time Home auto-show then fired the next frame and stacked
|
||||
itself over the player's chosen path. `LaunchHomeShown` becomes
|
||||
`pub` so `handle_restore_prompt` flips it to `true` after either
|
||||
resolution; **M** still re-opens the picker on demand.
|
||||
- **Game timers freeze while the Home picker is up** (`c497c31`).
|
||||
The HUD's elapsed-time counter ticked from the moment the default
|
||||
Classic deal landed at startup, even though the auto-show Home
|
||||
picker was still up — the player saw "0:11" before they had
|
||||
chosen a mode. `tick_elapsed_time` and `advance_time_attack` now
|
||||
also gate on the absence of `HomeScreen`, mirroring their
|
||||
existing `PausedResource` check.
|
||||
- **Popover rows stay visible regardless of action-bar fade**
|
||||
(`cc63532`). Opening Modes / Menu showed a solid dark-purple
|
||||
block in the top-right with no readable content — the action-bar
|
||||
auto-fade was matching the popover rows by their shared
|
||||
`ActionButton` marker and dropping their alpha to the
|
||||
cursor-position-based fade value (typically 0). New `PopoverRow`
|
||||
marker on rows in `spawn_modes_popover` / `spawn_menu_popover`;
|
||||
`apply_action_fade` excludes them via `Without<PopoverRow>`.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1166 passing tests (was 1208 at v0.17.0 close — 43 net removals
|
||||
from the test-discipline prune plus 1 net-new test from the
|
||||
async-seed work, no behaviour regressions).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.17.0] — 2026-05-06
|
||||
|
||||
A short follow-up round on top of v0.16.0: the H-key hint is no
|
||||
longer a heuristic guess but the actual best first move suggested by
|
||||
the v0.15.0 solver, and the in-engine replay player now has a
|
||||
player-tunable playback rate.
|
||||
|
||||
### Added
|
||||
|
||||
- **Replay-rate slider** in Settings → Gameplay. Tunes
|
||||
`replay_move_interval_secs` from 0.10 s to 1.00 s in 0.05 s steps;
|
||||
default 0.45 s. `tick_replay_playback` reads the value from
|
||||
`SettingsResource` per frame so the slider takes effect on the
|
||||
next playback tick — no restart required.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Solver-driven hints.** Pressing **H** used to surface a
|
||||
heuristic-best move (foundation moves preferred, then
|
||||
tableau-to-tableau by depth-of-flip-revealed). It now asks the
|
||||
v0.15.0 solver for the actual provably-best first move via the
|
||||
new `solitaire_core::solver::try_solve_with_first_move` /
|
||||
`try_solve_from_state` APIs. When the solver returns inconclusive
|
||||
(rare deals where the bound runs out before a result), the old
|
||||
heuristic remains the fallback. Median 2 ms per H press.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1208 passing tests (was 1196 at v0.16.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.16.0] — 2026-05-06
|
||||
|
||||
A modal-feel polish round. Every overlay screen now scrolls when its
|
||||
content overflows the 800×600 minimum window, every clickable button
|
||||
shows a hand cursor on hover, keyboard focus lands on the primary
|
||||
button on the same frame the modal opens, and read-only modals
|
||||
dismiss when the player clicks the scrim outside the card.
|
||||
|
||||
### Added
|
||||
|
||||
- **Pointer cursor on hover** for every interactive `Button` entity
|
||||
(modal buttons, HUD action bar, mode-launcher cards, settings
|
||||
toggles, Stats selectors). `update_cursor_icon` gains a fourth
|
||||
branch sitting between Grabbing (active drag) and Grab
|
||||
(draggable card hover): when no drag is active and any
|
||||
`Interaction::Hovered`/`Pressed` button is detected, the window
|
||||
cursor swaps to `SystemCursorIcon::Pointer`. A pure
|
||||
`pick_cursor_icon` helper makes the priority logic
|
||||
unit-testable.
|
||||
- **Click-outside-to-dismiss** for the six read-only modals: Stats,
|
||||
Achievements, Help, Profile, Leaderboard, Home. New
|
||||
`ScrimDismissible` marker on `ModalScrim` opts a modal in;
|
||||
`dismiss_modal_on_scrim_click` runs in `Update`, despawns the
|
||||
topmost dismissible scrim on a left-mouse press whose cursor
|
||||
lands on the scrim and outside every `ModalCard`. Bevy's
|
||||
hierarchy despawn cascades to the card and children.
|
||||
Settings, Onboarding, Pause, Forfeit confirm, and Confirm New
|
||||
Game intentionally don't opt in — they carry unsaved or
|
||||
destructive state.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Modal content scrolls when it overflows** (Achievements, Help,
|
||||
Stats, Profile, Leaderboard). Each modal's body Node now
|
||||
carries `Overflow::scroll_y()` plus a `max_height` constraint
|
||||
(`Val::Vh(70.0)` for most, `Val::Vh(50.0)` for the
|
||||
leaderboard's variable-length ranking section) and a marker
|
||||
component (`AchievementsScrollable`, `HelpScrollable`,
|
||||
`StatsScrollable`, `ProfileScrollable`,
|
||||
`LeaderboardScrollable`). A sibling `scroll_*_panel` system
|
||||
per modal routes `MouseWheel` events into the body's
|
||||
`ScrollPosition`. Mirrors the existing `SettingsPanelScrollable`
|
||||
pattern. Home modal intentionally not scrolled — its five
|
||||
mode cards + Cancel are sized to fit at 800×600 by design.
|
||||
- **Modal focus arrives on the same frame the modal opens.**
|
||||
Previously `attach_focusable_to_modal_buttons` and
|
||||
`auto_focus_on_modal_open` ran in `Update` alongside arbitrary
|
||||
click-handlers that spawn modals; with no ordering edge,
|
||||
Bevy's deferred `Commands` queued the new entities but the
|
||||
attach system couldn't see them on the same tick. Both systems
|
||||
moved to `PostUpdate` so the schedule boundary itself supplies
|
||||
the sync point — `FocusedButton` is always populated before
|
||||
`app.update()` returns. The very next Tab/Enter press lands on
|
||||
a populated resource instead of wasting itself moving focus
|
||||
from None to the primary.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1196 passing tests (was 1178 at v0.15.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.15.0] — 2026-05-02
|
||||
|
||||
In-engine replay playback, the Klondike solver + "Winnable deals
|
||||
only" toggle, a 19th achievement, rolling replay history, and a
|
||||
significant build-time / binary-size win from disabling Bevy's
|
||||
default audio stack.
|
||||
|
||||
### Added
|
||||
|
||||
- **In-engine replay playback** for the Stats overlay's Watch Replay
|
||||
button. New `ReplayPlaybackPlugin` runs a state machine
|
||||
(Inactive / Playing / Completed) that resets the live game to the
|
||||
recorded deal and ticks through `replay.moves` at
|
||||
`REPLAY_MOVE_INTERVAL_SECS` (0.45 s) firing the canonical
|
||||
`MoveRequestEvent` / `DrawRequestEvent` per recorded move.
|
||||
Recording is suppressed during playback so replays don't re-record
|
||||
themselves.
|
||||
- **Replay overlay banner** (`ReplayOverlayPlugin`) anchored to the
|
||||
top of the window during playback. Shows "Replay" label, "Move N
|
||||
of M" progress, and a Stop button. Z-order leaves modals
|
||||
(Settings, Pause, Help) free to render on top so the player can
|
||||
adjust audio mid-replay.
|
||||
- **Rolling replay history** at `<data_dir>/replays.json` capped at
|
||||
8 entries. Replaces the single-slot `latest_replay.json` (legacy
|
||||
file is migrated forward on first launch via
|
||||
`migrate_legacy_latest_replay`). Stats overlay gains a Prev / Next
|
||||
selector and a "Replay N / M" caption so the player can revisit
|
||||
older wins.
|
||||
- **"Cinephile" achievement** (#19). Unlocks the first time
|
||||
`ReplayPlaybackState` transitions Playing → Completed (i.e. the
|
||||
replay played out to its end without the player pressing Stop).
|
||||
Stop transitions Playing → Inactive directly so it doesn't count.
|
||||
- **Klondike solver** in `solitaire_core::solver`. Iterative-DFS
|
||||
with memoisation on a 64-bit canonical state hash, two budget
|
||||
knobs (move_budget + state_budget) for pathological cases, and a
|
||||
three-state `SolverResult` (Winnable / Unwinnable / Inconclusive).
|
||||
Median solve time 2 ms; pathological inconclusives cap near
|
||||
120 ms. Pure logic — `solitaire_core` keeps no Bevy or I/O.
|
||||
- **"Winnable deals only" toggle** in Settings → Gameplay (default
|
||||
off). When on, `handle_new_game` walks seed N, N+1, N+2, …
|
||||
through `try_solve` until it finds Winnable or Inconclusive,
|
||||
capped at `SOLVER_DEAL_RETRY_CAP` (50) attempts. Daily
|
||||
challenges, replays, and explicit-seed requests bypass the
|
||||
solver — only random Classic deals are gated.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Bevy default-feature trim** (`bevy = { default-features = false,
|
||||
features = [...] }` in workspace Cargo.toml) drops 51 transitive
|
||||
crates including the `bevy_audio` → rodio → cpal 0.15 + symphonia
|
||||
chain that the project doesn't use (kira handles audio directly).
|
||||
The retained feature list is curated to exactly what the engine
|
||||
uses; `solitaire_wasm` is unaffected because it doesn't depend on
|
||||
bevy.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1178 passing tests (was 1134 at v0.14.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.14.0] — 2026-05-02
|
||||
|
||||
@@ -405,7 +1107,9 @@ with no PNG artwork yet.
|
||||
CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
|
||||
client-side sync round-trip integration tests.
|
||||
|
||||
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...HEAD
|
||||
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.16.0...HEAD
|
||||
[0.16.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.15.0...v0.16.0
|
||||
[0.15.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...v0.15.0
|
||||
[0.14.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...v0.14.0
|
||||
[0.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0
|
||||
[0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0
|
||||
|
||||
@@ -1,114 +1,571 @@
|
||||
# Solitaire Quest — Claude Code Instructions
|
||||
# CLAUDE.md
|
||||
|
||||
See @ARCHITECTURE.md for full project design, crate responsibilities, data models, and API reference.
|
||||
version: unified-3.0
|
||||
|
||||
---
|
||||
|
||||
## Project Layout
|
||||
# 0. Role of This File
|
||||
|
||||
```text
|
||||
solitaire_core/ # Pure Rust game logic — NO Bevy, NO network, NO I/O
|
||||
solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
|
||||
solitaire_data/ # Persistence + SyncProvider trait + server client
|
||||
solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||
solitaire_server/ # Axum sync server binary
|
||||
solitaire_app/ # Thin binary entry point
|
||||
assets/ # Source assets — embedded at compile time via include_bytes!()
|
||||
This document defines:
|
||||
|
||||
* **Execution rules (what Claude must do)**
|
||||
* **System constraints (what Claude must never violate)**
|
||||
* **Operational architecture (how code is structured)**
|
||||
|
||||
For full system design details:
|
||||
→ `ARCHITECTURE.md` (authoritative source of truth)
|
||||
|
||||
This file overrides all conversational assumptions.
|
||||
|
||||
---
|
||||
|
||||
# 1. System Architecture (Authoritative Mapping)
|
||||
|
||||
## 1.1 Crates
|
||||
|
||||
```text id="crate_map"
|
||||
solitaire_core/ # PURE logic (no IO, no Bevy, deterministic)
|
||||
solitaire_sync/ # Shared API + merge logic
|
||||
solitaire_data/ # Persistence + sync client
|
||||
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
||||
solitaire_server/ # Axum backend (optional sync layer)
|
||||
solitaire_app/ # Entry binary
|
||||
assets/ # Runtime assets (except audio)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build & Test Commands
|
||||
## 1.2 Architecture Source of Truth
|
||||
|
||||
```bash
|
||||
# Dev run (fast compile via dynamic linking)
|
||||
cargo run -p solitaire_app --features bevy/dynamic_linking
|
||||
* Full system design: `ARCHITECTURE.md`
|
||||
* This file NEVER redefines system design
|
||||
* This file ONLY enforces behavior
|
||||
|
||||
# Release build
|
||||
cargo build --workspace --release
|
||||
---
|
||||
|
||||
# All tests — MUST pass before any commit
|
||||
# 2. Hard Global Constraints (NON-NEGOTIABLE)
|
||||
|
||||
These override all other instructions.
|
||||
|
||||
## 2.1 Core Determinism
|
||||
|
||||
* `solitaire_core` MUST:
|
||||
|
||||
* be deterministic
|
||||
* be side-effect free
|
||||
* never depend on Bevy / IO / async
|
||||
|
||||
---
|
||||
|
||||
## 2.2 Sync Isolation
|
||||
|
||||
* `solitaire_sync`:
|
||||
|
||||
* no Bevy
|
||||
* no IO
|
||||
* no engine dependencies
|
||||
* merge logic must be pure functions only
|
||||
|
||||
---
|
||||
|
||||
## 2.3 Error Policy
|
||||
|
||||
* NO `unwrap()`
|
||||
* NO `panic!()` in runtime/game logic
|
||||
* All state transitions:
|
||||
|
||||
```rust id="err_model"
|
||||
Result<T, MoveError>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.4 Threading Rules
|
||||
|
||||
* Sync must run on `AsyncComputeTaskPool`
|
||||
* NEVER block Bevy main thread
|
||||
|
||||
---
|
||||
|
||||
## 2.5 Persistence Rules
|
||||
|
||||
* atomic writes only:
|
||||
|
||||
* write `.tmp`
|
||||
* rename atomically
|
||||
* no partial state writes allowed
|
||||
|
||||
---
|
||||
|
||||
## 2.6 Security Rules
|
||||
|
||||
* credentials ONLY via `keyring`
|
||||
* NEVER store secrets in:
|
||||
|
||||
* files
|
||||
* logs
|
||||
* source code
|
||||
|
||||
---
|
||||
|
||||
## 2.7 Sync System Rules
|
||||
|
||||
* All sync backends implement:
|
||||
|
||||
```rust id="sync_trait"
|
||||
trait SyncProvider
|
||||
```
|
||||
|
||||
* `SyncPlugin` MUST be backend-agnostic
|
||||
* NEVER match on backend inside ECS systems
|
||||
|
||||
---
|
||||
|
||||
# 3. Engine Rules (Bevy Layer)
|
||||
|
||||
## 3.1 ECS Design
|
||||
|
||||
* systems = single responsibility
|
||||
* communication = Events only
|
||||
* shared state = Resources only
|
||||
* per-entity state = Components only
|
||||
|
||||
---
|
||||
|
||||
## 3.2 Game State Authority
|
||||
|
||||
* ONLY `GameStateResource` can mutate game state
|
||||
* UI systems MUST NOT directly modify core logic
|
||||
|
||||
---
|
||||
|
||||
## 3.3 UI-First Constraint (CRITICAL)
|
||||
|
||||
Every player action MUST:
|
||||
|
||||
* have a visible UI control
|
||||
* NOT rely solely on keyboard shortcuts
|
||||
|
||||
Keyboard shortcuts are:
|
||||
→ optional accelerators only
|
||||
|
||||
---
|
||||
|
||||
## 3.4 Layout System
|
||||
|
||||
* recompute on `WindowResized`
|
||||
* no fixed resolution assumptions
|
||||
|
||||
---
|
||||
|
||||
# 4. Asset System Rules
|
||||
|
||||
## 4.1 Runtime Assets (AssetServer)
|
||||
|
||||
Loaded via:
|
||||
|
||||
* `CardImageSet`
|
||||
* `BackgroundImageSet`
|
||||
* `FontResource`
|
||||
|
||||
Includes:
|
||||
|
||||
* cards
|
||||
* backgrounds
|
||||
* fonts
|
||||
|
||||
---
|
||||
|
||||
## 4.2 Embedded Assets
|
||||
|
||||
Only audio:
|
||||
|
||||
```text id="audio_rule"
|
||||
include_bytes!()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4.3 Test Compatibility Rule
|
||||
|
||||
All asset loaders MUST accept:
|
||||
|
||||
```rust id="asset_fallback"
|
||||
Option<Res<AssetServer>>
|
||||
```
|
||||
|
||||
Must degrade gracefully under `MinimalPlugins`.
|
||||
|
||||
---
|
||||
|
||||
# 5. Code Standards
|
||||
|
||||
## 5.1 Error Handling
|
||||
|
||||
* use `thiserror`
|
||||
* no `Box<dyn Error>` in libraries
|
||||
|
||||
---
|
||||
|
||||
## 5.2 Public API Rules
|
||||
|
||||
* prefer `Into<T>` over concrete types
|
||||
* all public items require doc comments
|
||||
|
||||
---
|
||||
|
||||
## 5.3 Derive Order
|
||||
|
||||
```rust id="derive_order"
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.4 Performance Rules
|
||||
|
||||
* NO `clone()` in hot paths
|
||||
* profile before optimizing
|
||||
|
||||
---
|
||||
|
||||
## 5.5 SQL Rules
|
||||
|
||||
* ONLY `sqlx::query!`
|
||||
* NO raw SQL strings
|
||||
|
||||
---
|
||||
|
||||
# 6. Build & Verification Rules
|
||||
|
||||
These are mandatory before ANY commit.
|
||||
|
||||
```bash id="build_rules"
|
||||
cargo test --workspace
|
||||
|
||||
# Lint — MUST pass clean (zero warnings)
|
||||
cargo clippy --workspace -- -D warnings
|
||||
|
||||
# Run sync server locally
|
||||
cargo run -p solitaire_server
|
||||
|
||||
# Check a single crate
|
||||
cargo test -p solitaire_core
|
||||
cargo clippy -p solitaire_core -- -D warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hard Rules
|
||||
# 7. Git Workflow Rules
|
||||
|
||||
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
|
||||
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
|
||||
- Audio assets are embedded at compile time using `include_bytes!()` in `audio_plugin.rs`.
|
||||
- Card faces (52 PNGs in `assets/cards/faces/`), card backs (`assets/cards/backs/back_N.png`), board backgrounds (`assets/backgrounds/bg_N.png`), and the UI font (`assets/fonts/main.ttf`) are loaded at runtime via `AssetServer::load()` and stored as `Handle<Image>`/`Handle<Font>` in the `CardImageSet`, `BackgroundImageSet`, and `FontResource` resources. The `assets/` directory must ship alongside the binary.
|
||||
- Asset-loading systems take `Option<Res<AssetServer>>` so they degrade cleanly under `MinimalPlugins` (tests). When `CardImageSet` is absent, `card_plugin` falls back to a `Text2d` rank+suit overlay; when `BackgroundImageSet` is absent, the board falls back to a solid colour.
|
||||
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
|
||||
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
|
||||
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
|
||||
- All sync backends implement the `SyncProvider` trait. The `SyncPlugin` is backend-agnostic — never `match` on `SyncBackend` inside a Bevy system.
|
||||
- `cargo clippy --workspace -- -D warnings` must pass clean after every change.
|
||||
- `cargo test --workspace` must pass after every change.
|
||||
## Commit format
|
||||
|
||||
```text id="commit_fmt"
|
||||
type(scope): description
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
* feat(core): add draw-three rules
|
||||
* fix(engine): correct drag z-order
|
||||
* test(core): undo boundary cases
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
## Commit conditions
|
||||
|
||||
- Use `thiserror` for error types. Never `Box<dyn Error>` in library crates.
|
||||
- Prefer `Into<T>` over concrete types in public API function parameters.
|
||||
- All public items must have doc comments (`///`). Private items: comment only when non-obvious.
|
||||
- Derive order convention: `#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]`
|
||||
- Bevy systems: one responsibility per system. Use `Events` for cross-system communication, never shared mutable state.
|
||||
- SQL queries: use `sqlx::query!` macros (compile-time checked), not raw string queries.
|
||||
- No `clone()` calls in hot paths (game loop systems). Profile before optimising elsewhere.
|
||||
* tests must pass
|
||||
* clippy must be clean
|
||||
|
||||
NEVER commit otherwise
|
||||
|
||||
---
|
||||
|
||||
## Bevy Conventions
|
||||
# 8. Change Control (ASK BEFORE DOING)
|
||||
|
||||
- One `Plugin` per major feature: `CardPlugin`, `AudioPlugin`, `AchievementPlugin`, `UIPlugin`, `SyncPlugin`.
|
||||
- Resources own shared state. Events communicate between systems. Components own per-entity data.
|
||||
- All UI screens are built with Bevy UI (`bevy::ui`). Never mix UI layout and game logic in the same system.
|
||||
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
|
||||
- **UI-first.** Every player-triggered action (new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, switch mode, etc.) must be reachable from a visible UI control. Keyboard shortcuts are optional accelerators — never the sole entry point. New gameplay features ship with the UI control alongside the system that backs it; do not merge a feature that is keyboard-only.
|
||||
Claude must request confirmation before:
|
||||
|
||||
* adding dependencies
|
||||
* modifying `solitaire_sync`
|
||||
* changing DB schema
|
||||
* introducing `unsafe`
|
||||
* changing merge strategy
|
||||
|
||||
---
|
||||
|
||||
## Git Workflow
|
||||
# 9. System Mental Model (IMPORTANT)
|
||||
|
||||
- Commit after each passing phase, not after every file change.
|
||||
- Commit message format: `type(scope): description`
|
||||
- `feat(core): add draw-three mode validation`
|
||||
- `fix(engine): card z-order during drag`
|
||||
- `test(core): undo stack boundary conditions`
|
||||
- `chore(server): add sqlx migration 002`
|
||||
- Never commit with failing tests or clippy warnings.
|
||||
- Never commit secrets, `.env` files, or `*.db` files.
|
||||
```text id="mental_model"
|
||||
Core (rules + deterministic logic)
|
||||
↓
|
||||
Engine (Bevy orchestration)
|
||||
↓
|
||||
Data layer (persistence + sync)
|
||||
↓
|
||||
Server (optional external system)
|
||||
```
|
||||
|
||||
Core is always the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Ask Before Doing
|
||||
# 10. Known Platform Pitfalls
|
||||
|
||||
- Adding a new crate dependency (discuss alternatives first).
|
||||
- Changing a type in `solitaire_sync` (breaking change on both client and server).
|
||||
- Altering the database schema (requires a new sqlx migration).
|
||||
- Introducing `unsafe` code anywhere.
|
||||
- Changing the merge strategy in `solitaire_sync::merge()`.
|
||||
Must always be handled explicitly:
|
||||
|
||||
* Bevy `Time` uses `f32`
|
||||
* `sqlx::migrate!()` path is crate-relative
|
||||
* `dirs::data_dir()` may return `None`
|
||||
* Linux may lack keyring backend
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
# 11. Forbidden Patterns
|
||||
|
||||
> Add entries here when Claude makes a mistake so it isn't repeated.
|
||||
* game logic inside Bevy systems
|
||||
* duplication across crates
|
||||
* blocking async calls in ECS
|
||||
* insecure credential storage
|
||||
* bypassing core logic layer
|
||||
|
||||
- Bevy's `Time` resource uses `f32` seconds; convert to `u64` only when writing to `StatsSnapshot`.
|
||||
- `sqlx::migrate!()` macro path is relative to the crate root, not the workspace root.
|
||||
- `keyring` on Linux requires a running secret service (e.g. GNOME Keyring or KWallet) — handle `Error::NoStorageAccess` gracefully and fall back to prompting the user.
|
||||
- `dirs::data_dir()` returns `None` on some minimal Linux environments — always handle the `None` case explicitly, do not unwrap.
|
||||
---
|
||||
|
||||
# 12. Execution Rules for Claude
|
||||
|
||||
When generating code:
|
||||
|
||||
1. respect crate boundaries
|
||||
2. minimize diff size
|
||||
3. do not expand scope
|
||||
4. follow existing patterns
|
||||
5. preserve invariants
|
||||
|
||||
If unclear:
|
||||
→ ask before acting
|
||||
|
||||
---
|
||||
|
||||
# 13. Relationship to ARCHITECTURE.md
|
||||
|
||||
| File | Role |
|
||||
| --------------- | ------------------------- |
|
||||
| CLAUDE.md | execution + constraints |
|
||||
| ARCHITECTURE.md | system design truth |
|
||||
| Both combined | full system understanding |
|
||||
|
||||
---
|
||||
# 14. Context Injection System (AUTOMATIC SCOPE FILTER)
|
||||
|
||||
## 14.1 Purpose
|
||||
|
||||
Before generating any response, Claude MUST construct a **minimal relevant context set**.
|
||||
|
||||
This prevents:
|
||||
|
||||
* architectural drift
|
||||
* irrelevant spec loading
|
||||
* over-engineering
|
||||
* cross-crate confusion
|
||||
|
||||
---
|
||||
|
||||
## 14.2 Input Classification Step (MANDATORY)
|
||||
|
||||
Every request MUST be classified into exactly one task type:
|
||||
|
||||
```text id="task_types"
|
||||
feature
|
||||
bugfix
|
||||
refactor
|
||||
system_design
|
||||
bevy_system
|
||||
core_logic
|
||||
sync
|
||||
optimization
|
||||
test
|
||||
debug
|
||||
```
|
||||
|
||||
If uncertain → ask clarification.
|
||||
|
||||
---
|
||||
|
||||
## 14.3 Context Selection Engine
|
||||
|
||||
After classification, Claude MUST include ONLY the relevant sections below.
|
||||
|
||||
---
|
||||
|
||||
## 14.4 Context Map (CORE RULESET)
|
||||
|
||||
### feature
|
||||
|
||||
Include:
|
||||
|
||||
* §2 Hard Global Constraints
|
||||
* §3 Engine Rules
|
||||
* ARCHITECTURE.md (crate of target feature only)
|
||||
* relevant data models (GameState, SyncPayload if needed)
|
||||
|
||||
---
|
||||
|
||||
### bugfix
|
||||
|
||||
Include:
|
||||
|
||||
* §2 Hard Global Constraints
|
||||
* §5 Code Standards
|
||||
* affected crate boundaries
|
||||
* relevant system (engine/core/sync only)
|
||||
|
||||
---
|
||||
|
||||
### refactor
|
||||
|
||||
Include:
|
||||
|
||||
* §3 Engine Rules
|
||||
* §5 Code Standards
|
||||
* §11 Forbidden Patterns
|
||||
* target crate boundaries
|
||||
|
||||
---
|
||||
|
||||
### system_design
|
||||
|
||||
Include:
|
||||
|
||||
* ARCHITECTURE.md (FULL)
|
||||
* §9 Mental Model
|
||||
* §1 System Architecture Mapping
|
||||
|
||||
---
|
||||
|
||||
### core_logic
|
||||
|
||||
Include:
|
||||
|
||||
* solitaire_core rules only
|
||||
* GameState model
|
||||
* MoveError model
|
||||
* §2.1–2.3 constraints
|
||||
|
||||
---
|
||||
|
||||
### bevy_system
|
||||
|
||||
Include:
|
||||
|
||||
* §3 Engine Rules
|
||||
* ECS rules (Events/Resources/Components)
|
||||
* UI-first constraint
|
||||
* relevant plugin system only
|
||||
|
||||
---
|
||||
|
||||
### sync
|
||||
|
||||
Include:
|
||||
|
||||
* SyncProvider trait
|
||||
* merge strategy rules
|
||||
* solitaire_sync models
|
||||
* §2.6 Sync Rules
|
||||
|
||||
---
|
||||
|
||||
### optimization
|
||||
|
||||
Include:
|
||||
|
||||
* target crate only
|
||||
* §5.4 Performance Rules
|
||||
* hot path constraints
|
||||
|
||||
---
|
||||
|
||||
### test
|
||||
|
||||
Include:
|
||||
|
||||
* §6 Build Rules
|
||||
* relevant module
|
||||
* expected invariants
|
||||
|
||||
---
|
||||
|
||||
### debug
|
||||
|
||||
Include:
|
||||
|
||||
* target file/module only
|
||||
* §2.3 Error Policy
|
||||
* runtime assumptions relevant to failure
|
||||
|
||||
---
|
||||
|
||||
## 14.5 Context Compression Rules
|
||||
|
||||
Claude MUST obey:
|
||||
|
||||
* never include full ARCHITECTURE.md unless system_design
|
||||
* max 2 crates per response unless explicitly required
|
||||
* prefer function-level context over file-level context
|
||||
* exclude unrelated plugins/systems
|
||||
|
||||
---
|
||||
|
||||
## 14.6 Context Priority Order
|
||||
|
||||
When space is limited:
|
||||
|
||||
1. Hard Constraints (§2)
|
||||
2. Target crate rules
|
||||
3. Data models
|
||||
4. Only then: architecture snippets
|
||||
|
||||
---
|
||||
|
||||
## 14.7 “No Context Pollution” Rule
|
||||
|
||||
Claude must NOT include:
|
||||
|
||||
* unrelated crates
|
||||
* unrelated plugins
|
||||
* unused data models
|
||||
* full architecture dumps
|
||||
* speculative systems
|
||||
|
||||
---
|
||||
|
||||
## 14.8 Self-Check Before Execution
|
||||
|
||||
Before writing code, Claude MUST verify:
|
||||
|
||||
* [ ] Is only relevant context included?
|
||||
* [ ] Is at least one hard constraint present?
|
||||
* [ ] Am I touching more than one crate unnecessarily?
|
||||
* [ ] Am I duplicating ARCHITECTURE.md content?
|
||||
|
||||
If any fail → revise context selection.
|
||||
|
||||
---
|
||||
|
||||
## 14.9 Injection Output Format (Internal Model)
|
||||
|
||||
Claude should behave as if it constructed:
|
||||
|
||||
```text id="ctx_format"
|
||||
[SELECTED TASK TYPE]
|
||||
|
||||
[MINIMAL REQUIRED RULES]
|
||||
|
||||
[MINIMAL ARCHITECTURE SLICES]
|
||||
|
||||
[RELEVANT MODELS]
|
||||
|
||||
[REQUEST]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14.10 Relationship to ARCHITECTURE.md
|
||||
|
||||
* ARCHITECTURE.md = source of truth
|
||||
* CLAUDE.md = execution constraints
|
||||
* THIS SECTION = filtering layer between them
|
||||
|
||||
---
|
||||
|
||||
# END CONTEXT INJECTION SYSTEM
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
# CLAUDE_PROMPT_PACK.md
|
||||
|
||||
version: 1.0
|
||||
|
||||
---
|
||||
|
||||
# 0. GLOBAL INSTRUCTION (prepend to every prompt)
|
||||
|
||||
```
|
||||
You must follow CLAUDE_SPEC.md strictly.
|
||||
|
||||
Rules:
|
||||
- Do not expand scope beyond what is defined
|
||||
- Do not refactor unrelated code
|
||||
- Do not introduce new dependencies
|
||||
- Prefer minimal, surgical changes
|
||||
- Use existing patterns in the codebase
|
||||
- Return minimal diffs or changed functions only
|
||||
|
||||
Before writing code:
|
||||
1. List relevant constraints from CLAUDE_SPEC.md
|
||||
2. Identify risks
|
||||
3. Then implement
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 1. FEATURE IMPLEMENTATION
|
||||
|
||||
```
|
||||
# TASK: Feature Implementation
|
||||
|
||||
feature: "<name>"
|
||||
|
||||
goal:
|
||||
"<clear outcome>"
|
||||
|
||||
scope:
|
||||
crates: []
|
||||
systems: []
|
||||
files: []
|
||||
|
||||
non_goals:
|
||||
- ""
|
||||
|
||||
constraints:
|
||||
- must follow CLAUDE_SPEC.md
|
||||
- event-driven architecture required
|
||||
- no blocking operations
|
||||
- no cross-crate leakage
|
||||
|
||||
acceptance_criteria:
|
||||
- ""
|
||||
- ""
|
||||
|
||||
edge_cases:
|
||||
- ""
|
||||
|
||||
---
|
||||
|
||||
## Required Patterns
|
||||
|
||||
Use this pattern for systems:
|
||||
<PASTE EXISTING SYSTEM SNIPPET HERE>
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
intent:
|
||||
plan:
|
||||
constraints_used:
|
||||
risks:
|
||||
|
||||
code_changes:
|
||||
(minimal diffs only)
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 2. BUGFIX
|
||||
|
||||
```
|
||||
# TASK: Bug Fix
|
||||
|
||||
bug_description:
|
||||
"<what is broken>"
|
||||
|
||||
expected_behavior:
|
||||
"<correct behavior>"
|
||||
|
||||
root_cause_hint (optional):
|
||||
""
|
||||
|
||||
scope:
|
||||
crates: []
|
||||
files: []
|
||||
|
||||
constraints:
|
||||
- minimal fix only
|
||||
- no refactors unless required
|
||||
- must add regression protection if applicable
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
1. Identify root cause
|
||||
2. Fix it minimally
|
||||
3. Preserve all invariants
|
||||
4. Do not change unrelated logic
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
root_cause:
|
||||
fix_strategy:
|
||||
|
||||
code_changes:
|
||||
(minimal diff)
|
||||
|
||||
regression_test (only if high-value):
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 3. REFACTOR
|
||||
|
||||
```
|
||||
# TASK: Refactor
|
||||
|
||||
target:
|
||||
"<what is being improved>"
|
||||
|
||||
goal:
|
||||
"<what improves>"
|
||||
|
||||
scope:
|
||||
crates: []
|
||||
files: []
|
||||
|
||||
non_goals:
|
||||
- no behavior changes
|
||||
- no new features
|
||||
|
||||
constraints:
|
||||
- must preserve behavior exactly
|
||||
- must respect crate boundaries
|
||||
- must not duplicate logic
|
||||
|
||||
---
|
||||
|
||||
## Refactor Type
|
||||
|
||||
- [ ] simplify logic
|
||||
- [ ] reduce duplication
|
||||
- [ ] improve readability
|
||||
- [ ] performance (non-invasive)
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
issues_found:
|
||||
|
||||
refactor_plan:
|
||||
|
||||
code_changes:
|
||||
(diff only)
|
||||
|
||||
verification:
|
||||
- behavior unchanged: yes/no
|
||||
- invariants preserved: yes/no
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 4. SYSTEM DESIGN (NEW FEATURE)
|
||||
|
||||
```
|
||||
# TASK: System Design
|
||||
|
||||
feature:
|
||||
"<name>"
|
||||
|
||||
goal:
|
||||
"<what problem it solves>"
|
||||
|
||||
constraints:
|
||||
- must fit existing architecture
|
||||
- must follow plugin + event model
|
||||
- must not violate crate boundaries
|
||||
|
||||
---
|
||||
|
||||
## Required Output
|
||||
|
||||
design:
|
||||
|
||||
components:
|
||||
- plugins:
|
||||
- systems:
|
||||
- events:
|
||||
- resources:
|
||||
|
||||
data_flow:
|
||||
(step-by-step)
|
||||
|
||||
integration_points:
|
||||
- where it connects to existing systems
|
||||
|
||||
risks:
|
||||
- ""
|
||||
|
||||
tradeoffs:
|
||||
- ""
|
||||
|
||||
---
|
||||
|
||||
## DO NOT
|
||||
|
||||
- write full implementation
|
||||
- modify unrelated systems
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 5. NEW BEVY SYSTEM
|
||||
|
||||
```
|
||||
# TASK: Add Bevy System
|
||||
|
||||
system_name:
|
||||
""
|
||||
|
||||
trigger:
|
||||
(event or condition)
|
||||
|
||||
reads:
|
||||
[Resources]
|
||||
|
||||
writes:
|
||||
[Resources]
|
||||
|
||||
emits:
|
||||
[Events]
|
||||
|
||||
constraints:
|
||||
- must be event-driven
|
||||
- must not directly mutate unrelated state
|
||||
- must be single responsibility
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
system_signature:
|
||||
|
||||
implementation:
|
||||
(code only)
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 6. CORE LOGIC FUNCTION (solitaire_core)
|
||||
|
||||
```
|
||||
# TASK: Core Logic Implementation
|
||||
|
||||
function:
|
||||
"<name>"
|
||||
|
||||
goal:
|
||||
"<what it does>"
|
||||
|
||||
rules:
|
||||
- no IO
|
||||
- no async
|
||||
- no Bevy
|
||||
- deterministic
|
||||
|
||||
invariants:
|
||||
- ""
|
||||
- ""
|
||||
|
||||
errors:
|
||||
- ""
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
constraints_checked:
|
||||
|
||||
implementation:
|
||||
(code only)
|
||||
|
||||
edge_case_handling:
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 7. SYNC / MERGE LOGIC
|
||||
|
||||
```
|
||||
# TASK: Sync Logic
|
||||
|
||||
goal:
|
||||
"<what is being merged or synced>"
|
||||
|
||||
constraints:
|
||||
- must be deterministic
|
||||
- must be idempotent
|
||||
- must be lossless
|
||||
- must not delete data
|
||||
|
||||
rules:
|
||||
- counters → max
|
||||
- times → min
|
||||
- collections → union
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
|
||||
merge_logic:
|
||||
|
||||
code_changes:
|
||||
|
||||
invariants_verified:
|
||||
- deterministic
|
||||
- idempotent
|
||||
- lossless
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 8. PERFORMANCE OPTIMIZATION
|
||||
|
||||
```
|
||||
# TASK: Optimization
|
||||
|
||||
target:
|
||||
"<what is slow>"
|
||||
|
||||
constraints:CLAUDE_WORKFLOW.md
|
||||
- no behavior change
|
||||
- no architecture change
|
||||
- minimal code changes
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
bottleneck:
|
||||
|
||||
optimization_strategy:
|
||||
|
||||
code_changes:
|
||||
|
||||
impact_estimate:
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 9. TEST GENERATION (STRICT MODE)
|
||||
|
||||
```
|
||||
# TASK: Test Generation
|
||||
|
||||
target:
|
||||
"<function/system>"
|
||||
|
||||
reason:
|
||||
- bugfix | complex logic | invariant protection
|
||||
|
||||
constraints:
|
||||
- no redundant tests
|
||||
- must test real behavior
|
||||
- must fail if logic breaks
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
test_cases:
|
||||
- ""
|
||||
|
||||
test_code:
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 10. DEBUGGING / INVESTIGATION
|
||||
|
||||
```
|
||||
# TASK: Debug
|
||||
|
||||
problem:
|
||||
"<symptom>"
|
||||
|
||||
context:
|
||||
"<relevant code or system>"
|
||||
|
||||
---
|
||||
|
||||
## Required Steps
|
||||
|
||||
1. List possible causes
|
||||
2. Narrow down most likely
|
||||
3. Suggest verification steps
|
||||
4. Provide minimal fix
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
hypotheses:
|
||||
|
||||
most_likely:
|
||||
|
||||
verification_steps:
|
||||
|
||||
fix:
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 11. HARD CONSTRAINT OVERRIDE (RARE)
|
||||
|
||||
```
|
||||
# TASK: Exception Handling
|
||||
|
||||
reason:
|
||||
"<why constraints must be bent>"
|
||||
|
||||
requested_exception:
|
||||
"<rule being broken>"
|
||||
|
||||
justification:
|
||||
"<why unavoidable>"
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
|
||||
alternatives_considered:
|
||||
|
||||
final_decision:
|
||||
|
||||
risk:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 12. STOP CONDITIONS (always append)
|
||||
|
||||
```
|
||||
Stop when:
|
||||
- acceptance criteria are met
|
||||
- code is minimal and correct
|
||||
|
||||
Do NOT:
|
||||
- expand scope
|
||||
- refactor unrelated code
|
||||
- optimize prematurely
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# END
|
||||
@@ -0,0 +1,292 @@
|
||||
# CLAUDE_SPEC.md
|
||||
|
||||
version: 1.0
|
||||
|
||||
---
|
||||
|
||||
## 0. Global Rules
|
||||
|
||||
(Core determinism, panic policy, and event-driven engine constraints live in CLAUDE.md §2.1, §2.3, §3.1. Listed here only when they add information CLAUDE.md doesn't carry.)
|
||||
|
||||
rules:
|
||||
|
||||
* id: single_source_of_truth
|
||||
description: "GameStateResource is the only mutable game state in runtime"
|
||||
|
||||
* id: sync_is_additive
|
||||
description: "Remote data must never destructively overwrite local data"
|
||||
|
||||
---
|
||||
|
||||
## 1. Crate Graph
|
||||
|
||||
crates:
|
||||
solitaire_core:
|
||||
depends_on: [rand, serde, chrono]
|
||||
forbidden_deps: [bevy, reqwest, tokio, std::fs]
|
||||
|
||||
solitaire_sync:
|
||||
depends_on: [serde, serde_json, uuid, chrono]
|
||||
role: "shared_types"
|
||||
|
||||
solitaire_data:
|
||||
depends_on: [solitaire_core, solitaire_sync, reqwest, tokio, keyring]
|
||||
role: "persistence_and_sync"
|
||||
|
||||
solitaire_engine:
|
||||
depends_on: [bevy, kira, solitaire_core, solitaire_data]
|
||||
role: "runtime_engine"
|
||||
|
||||
solitaire_server:
|
||||
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
|
||||
role: "backend"
|
||||
|
||||
solitaire_app:
|
||||
depends_on: [solitaire_engine]
|
||||
role: "entrypoint"
|
||||
|
||||
---
|
||||
|
||||
## 2. Data Ownership
|
||||
|
||||
ownership:
|
||||
GameState:
|
||||
owner: solitaire_core
|
||||
mutable_in: solitaire_engine
|
||||
access_pattern: "via GameStateResource only"
|
||||
|
||||
StatsSnapshot:
|
||||
owner: solitaire_data
|
||||
|
||||
PlayerProgress:
|
||||
owner: solitaire_data
|
||||
|
||||
AchievementRecord:
|
||||
owner: solitaire_data
|
||||
|
||||
SyncPayload:
|
||||
owner: solitaire_sync
|
||||
|
||||
---
|
||||
|
||||
## 3. State Transitions
|
||||
|
||||
state_machine:
|
||||
GameState:
|
||||
transitions:
|
||||
- action: move_cards
|
||||
returns: Result<GameState, MoveError>
|
||||
|
||||
```
|
||||
- action: draw
|
||||
returns: Result<GameState, MoveError>
|
||||
|
||||
- action: undo
|
||||
returns: Result<GameState, MoveError>
|
||||
|
||||
invariants:
|
||||
- "52 cards always exist"
|
||||
- "no duplicate card IDs"
|
||||
- "all cards belong to exactly one pile"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Event System
|
||||
|
||||
events:
|
||||
|
||||
input:
|
||||
- MoveRequestEvent
|
||||
- DrawRequestEvent
|
||||
- UndoRequestEvent
|
||||
- NewGameRequestEvent
|
||||
|
||||
state:
|
||||
- StateChangedEvent
|
||||
- GameWonEvent
|
||||
|
||||
meta:
|
||||
- AchievementUnlockedEvent
|
||||
- SyncCompleteEvent
|
||||
|
||||
rules:
|
||||
|
||||
* "Input events trigger core logic"
|
||||
* "Core logic emits state events"
|
||||
* "UI reacts to state events only"
|
||||
|
||||
---
|
||||
|
||||
## 5. Sync Contract
|
||||
|
||||
sync:
|
||||
|
||||
provider_trait:
|
||||
methods:
|
||||
- pull() -> SyncPayload
|
||||
- push(payload) -> SyncResponse
|
||||
|
||||
guarantees:
|
||||
- "non-blocking during gameplay"
|
||||
- "blocking allowed on exit only"
|
||||
|
||||
merge:
|
||||
rules:
|
||||
counters: "max"
|
||||
best_times: "min"
|
||||
collections: "union"
|
||||
achievements: "never removed"
|
||||
|
||||
```
|
||||
properties:
|
||||
- deterministic
|
||||
- idempotent
|
||||
- lossless
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Persistence
|
||||
|
||||
storage:
|
||||
|
||||
format: json
|
||||
|
||||
files:
|
||||
- stats.json
|
||||
- progress.json
|
||||
- achievements.json
|
||||
- settings.json
|
||||
- game_state.json
|
||||
|
||||
guarantees:
|
||||
- atomic_write: true
|
||||
- crash_safe: true
|
||||
|
||||
---
|
||||
|
||||
## 7. Engine Rules
|
||||
|
||||
engine:
|
||||
|
||||
mutation_rules:
|
||||
- "Only GameLogicSystem mutates GameState"
|
||||
- "UI systems are read-only"
|
||||
|
||||
threading:
|
||||
- "sync runs on AsyncComputeTaskPool"
|
||||
- "main thread must never block"
|
||||
|
||||
plugins:
|
||||
pattern: "feature_isolation"
|
||||
communication: "events"
|
||||
|
||||
---
|
||||
|
||||
## 8. Server Contract
|
||||
|
||||
server:
|
||||
|
||||
auth:
|
||||
method: jwt
|
||||
access_expiry: 24h
|
||||
refresh_expiry: 30d
|
||||
|
||||
endpoints:
|
||||
- POST /api/auth/register
|
||||
- POST /api/auth/login
|
||||
- GET /api/sync/pull
|
||||
- POST /api/sync/push
|
||||
|
||||
limits:
|
||||
payload_max: 1MB
|
||||
rate_limit: "10 req/min auth routes"
|
||||
|
||||
---
|
||||
|
||||
## 9. Achievement System
|
||||
|
||||
achievements:
|
||||
|
||||
definition_location: solitaire_core
|
||||
state_location: solitaire_data
|
||||
|
||||
types:
|
||||
- condition_based
|
||||
- event_driven
|
||||
|
||||
rule:
|
||||
- "achievements cannot be revoked"
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing Rules
|
||||
|
||||
testing:
|
||||
|
||||
philosophy:
|
||||
- "test real failures"
|
||||
- "avoid redundant tests"
|
||||
|
||||
required_coverage:
|
||||
solitaire_core:
|
||||
- move_validation
|
||||
- undo_integrity
|
||||
- win_detection
|
||||
|
||||
```
|
||||
solitaire_sync:
|
||||
- merge_correctness
|
||||
- idempotency
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Prohibited Patterns
|
||||
|
||||
(See CLAUDE.md §11 for the canonical forbidden-patterns list.)
|
||||
|
||||
---
|
||||
|
||||
## 12. Extension Points
|
||||
|
||||
extensibility:
|
||||
|
||||
sync_backends:
|
||||
pattern: "implement SyncProvider"
|
||||
|
||||
game_modes:
|
||||
location: solitaire_core::GameMode
|
||||
|
||||
plugins:
|
||||
rule: "new feature = new plugin"
|
||||
|
||||
---
|
||||
|
||||
## 13. Validation Checklist (for Claude)
|
||||
|
||||
validation:
|
||||
|
||||
* check: "crate dependency rules respected"
|
||||
* check: "no panics in core"
|
||||
* check: "events used for cross-system communication"
|
||||
* check: "GameState mutations centralized"
|
||||
* check: "merge function properties preserved"
|
||||
* check: "no blocking operations in main loop"
|
||||
|
||||
---
|
||||
|
||||
## 14. Mental Model
|
||||
|
||||
model:
|
||||
|
||||
layers:
|
||||
- core
|
||||
- engine
|
||||
- data
|
||||
- server
|
||||
|
||||
flow:
|
||||
- input -> engine -> core -> engine -> ui
|
||||
- data <-> sync <-> server
|
||||
@@ -0,0 +1,335 @@
|
||||
# CLAUDE_WORKFLOW.md
|
||||
|
||||
version: 1.0
|
||||
|
||||
---
|
||||
|
||||
## 0. Overview
|
||||
|
||||
This workflow defines a **two-agent system**:
|
||||
|
||||
* **Builder Agent** → writes and modifies code
|
||||
* **Guardian Agent** → enforces architecture + rejects invalid changes
|
||||
|
||||
No code is considered valid unless it passes Guardian validation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Roles
|
||||
|
||||
### 1.1 Builder Agent
|
||||
|
||||
role: "code_generation"
|
||||
|
||||
responsibilities:
|
||||
|
||||
* implement features
|
||||
* refactor code
|
||||
* generate tests (only when justified)
|
||||
* follow CLAUDE_SPEC.md
|
||||
|
||||
constraints:
|
||||
|
||||
* cannot bypass validation
|
||||
* must declare intent before writing code
|
||||
|
||||
output_contract:
|
||||
must_produce:
|
||||
- change_summary
|
||||
- files_modified
|
||||
- reasoning (short)
|
||||
- code_diff
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Guardian Agent
|
||||
|
||||
role: "architecture_enforcement"
|
||||
|
||||
responsibilities:
|
||||
|
||||
* validate against CLAUDE_SPEC.md
|
||||
* detect violations
|
||||
* reject or approve changes
|
||||
* suggest minimal fixes (not full rewrites)
|
||||
|
||||
constraints:
|
||||
|
||||
* no feature implementation
|
||||
* no large rewrites
|
||||
* must be deterministic
|
||||
|
||||
output_contract:
|
||||
must_produce:
|
||||
- status: APPROVED | REJECTED
|
||||
- violations[]
|
||||
- required_fixes[]
|
||||
- optional_improvements[]
|
||||
|
||||
---
|
||||
|
||||
## 2. Workflow Pipeline
|
||||
|
||||
```text
|
||||
User Request
|
||||
↓
|
||||
Builder Agent (proposal + code)
|
||||
↓
|
||||
Guardian Agent (validation)
|
||||
↓
|
||||
IF approved → commit
|
||||
IF rejected → feedback → Builder retry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Builder Protocol
|
||||
|
||||
### Step 1 — Intent Declaration
|
||||
|
||||
Builder MUST start with:
|
||||
|
||||
```yaml
|
||||
intent:
|
||||
feature: "<name>"
|
||||
crates_touched: []
|
||||
systems_affected: []
|
||||
risk_level: low|medium|high
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — Plan
|
||||
|
||||
```yaml
|
||||
plan:
|
||||
- step: "..."
|
||||
- step: "..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — Implementation
|
||||
|
||||
* Only modify declared crates
|
||||
* Follow ownership rules
|
||||
* Use events for cross-system communication
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — Output
|
||||
|
||||
```yaml
|
||||
change_summary: "..."
|
||||
|
||||
files_modified:
|
||||
- path: ...
|
||||
change: "..."
|
||||
|
||||
violations_self_check:
|
||||
- none | list
|
||||
|
||||
notes: "short reasoning"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Guardian Protocol
|
||||
|
||||
### Step 1 — Spec Validation
|
||||
|
||||
Check against:
|
||||
|
||||
* crate boundaries
|
||||
* mutation rules
|
||||
* event system usage
|
||||
* sync guarantees
|
||||
* forbidden patterns
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — Invariant Validation
|
||||
|
||||
Must verify:
|
||||
|
||||
* GameState invariants preserved
|
||||
* no new panic paths
|
||||
* no blocking calls in engine
|
||||
* merge properties unchanged
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — Output Decision
|
||||
|
||||
#### APPROVED
|
||||
|
||||
```yaml
|
||||
status: APPROVED
|
||||
|
||||
notes:
|
||||
- "no violations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### REJECTED
|
||||
|
||||
```yaml
|
||||
status: REJECTED
|
||||
|
||||
violations:
|
||||
- id: core_purity_violation
|
||||
file: "solitaire_core/src/..."
|
||||
reason: "uses std::fs"
|
||||
|
||||
required_fixes:
|
||||
- "move IO to solitaire_data"
|
||||
|
||||
optional_improvements:
|
||||
- "simplify event naming"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Enforcement Rules
|
||||
|
||||
### Hard Fail (automatic rejection)
|
||||
|
||||
* core crate uses IO / Bevy / network
|
||||
* GameState mutated outside GameLogicSystem
|
||||
* blocking async on main thread
|
||||
* duplicate logic across crates
|
||||
* merge function altered incorrectly
|
||||
|
||||
---
|
||||
|
||||
### Soft Fail (allowed but flagged)
|
||||
|
||||
* unnecessary complexity
|
||||
* redundant tests
|
||||
* minor architectural drift
|
||||
|
||||
---
|
||||
|
||||
## 6. Iteration Loop
|
||||
|
||||
Max attempts per task: **3**
|
||||
|
||||
```text
|
||||
Attempt 1 → Reject → Fix
|
||||
Attempt 2 → Reject → Fix
|
||||
Attempt 3 → Final decision
|
||||
```
|
||||
|
||||
If still failing:
|
||||
→ escalate to user
|
||||
|
||||
---
|
||||
|
||||
## 7. Diff Strategy
|
||||
|
||||
Builder MUST produce:
|
||||
|
||||
* minimal diffs
|
||||
* no unrelated refactors
|
||||
* no formatting-only changes
|
||||
|
||||
---
|
||||
|
||||
## 8. Test Strategy Integration
|
||||
|
||||
Builder rules:
|
||||
|
||||
* only add tests if:
|
||||
|
||||
* fixing a bug
|
||||
* protecting complex logic
|
||||
* validating invariants
|
||||
|
||||
Guardian rejects:
|
||||
|
||||
* redundant tests
|
||||
* no-op tests
|
||||
|
||||
---
|
||||
|
||||
## 9. Optional Extensions
|
||||
|
||||
### 9.1 Third Agent (Optimizer)
|
||||
|
||||
role: performance + cleanup
|
||||
|
||||
runs AFTER approval:
|
||||
|
||||
* reduce allocations
|
||||
* simplify logic
|
||||
* improve ECS scheduling
|
||||
|
||||
---
|
||||
|
||||
### 9.2 CI Integration
|
||||
|
||||
Pipeline:
|
||||
|
||||
```text
|
||||
Builder → Guardian → cargo check → clippy → tests
|
||||
```
|
||||
|
||||
Guardian runs BEFORE compilation to catch structural issues early.
|
||||
|
||||
---
|
||||
|
||||
## 10. Example Interaction
|
||||
|
||||
### Builder
|
||||
|
||||
```yaml
|
||||
intent:
|
||||
feature: "undo stack limit fix"
|
||||
crates_touched: [solitaire_core]
|
||||
risk_level: low
|
||||
```
|
||||
|
||||
```yaml
|
||||
change_summary: "limit undo stack to 64 entries"
|
||||
|
||||
files_modified:
|
||||
- solitaire_core/src/game_state.rs
|
||||
|
||||
notes: "prevents unbounded memory growth"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Guardian
|
||||
|
||||
```yaml
|
||||
status: APPROVED
|
||||
|
||||
notes:
|
||||
- "respects core constraints"
|
||||
- "no invariant violations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Mental Model
|
||||
|
||||
* Builder = **creative**
|
||||
* Guardian = **strict**
|
||||
|
||||
Builder explores
|
||||
Guardian enforces
|
||||
|
||||
Neither replaces the other.
|
||||
|
||||
---
|
||||
|
||||
## 12. Success Criteria
|
||||
|
||||
System is working if:
|
||||
|
||||
* architectural violations go to ~0
|
||||
* code stays consistent across features
|
||||
* refactors become safe
|
||||
* complexity grows sub-linearly
|
||||
@@ -30,13 +30,65 @@ dirs = "6"
|
||||
keyring = "4"
|
||||
keyring-core = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||
arboard = { version = "3", default-features = false }
|
||||
|
||||
solitaire_core = { path = "solitaire_core" }
|
||||
solitaire_sync = { path = "solitaire_sync" }
|
||||
solitaire_data = { path = "solitaire_data" }
|
||||
solitaire_engine = { path = "solitaire_engine" }
|
||||
|
||||
bevy = "0.18"
|
||||
# Bevy with `default-features = false` to avoid the unused
|
||||
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
||||
# Audio is handled directly by `kira` in `audio_plugin.rs`, so the
|
||||
# `bevy_audio` feature is intentionally omitted. The features below
|
||||
# enumerate every leaf of the standard `2d` + `ui` meta-features that
|
||||
# we actually use; new features should only be added with a
|
||||
# corresponding use site.
|
||||
bevy = { version = "0.18", default-features = false, features = [
|
||||
# default_app
|
||||
"async_executor",
|
||||
"bevy_asset",
|
||||
"bevy_input_focus",
|
||||
"bevy_log",
|
||||
"bevy_state",
|
||||
"bevy_window",
|
||||
"custom_cursor",
|
||||
"reflect_auto_register",
|
||||
# default_platform (desktop subset)
|
||||
"std",
|
||||
"bevy_winit",
|
||||
"default_font",
|
||||
"multi_threaded",
|
||||
# winit prefers Wayland when WAYLAND_DISPLAY is set on the
|
||||
# session and falls through to X11 otherwise. Without `wayland`,
|
||||
# winit-on-Wayland-session falls back to XWayland which renders
|
||||
# the game in an X11 frame inside the Wayland compositor.
|
||||
"wayland",
|
||||
"x11",
|
||||
# Android: NativeActivity glue. The feature is target-gated inside
|
||||
# bevy_internal — desktop builds compile it out, so leaving it on
|
||||
# the always-on list is harmless on Linux/macOS/Windows. Pairs with
|
||||
# cargo-apk's NativeActivity wrapper (cargo-apk 0.10+ uses this by
|
||||
# default). Switch to `android-game-activity` later if we want
|
||||
# AndroidX GameActivity for Google Play Games integration.
|
||||
"android-native-activity",
|
||||
# common_api
|
||||
"bevy_color",
|
||||
"bevy_image",
|
||||
"bevy_mesh",
|
||||
"bevy_shader",
|
||||
"bevy_text",
|
||||
"png",
|
||||
# 2d rendering
|
||||
"bevy_camera",
|
||||
"bevy_render",
|
||||
"bevy_core_pipeline",
|
||||
"bevy_sprite",
|
||||
"bevy_sprite_render",
|
||||
# UI rendering
|
||||
"bevy_ui",
|
||||
"bevy_ui_render",
|
||||
] }
|
||||
kira = "0.12"
|
||||
|
||||
# SVG rasterisation pipeline for the runtime card-theme system.
|
||||
|
||||
@@ -22,7 +22,7 @@ optional self-hosted sync so your stats follow you across machines.
|
||||
move within picker rows, Enter activates; works across every modal and
|
||||
the HUD action bar
|
||||
- **Progression** — XP, levels, unlockable card backs and backgrounds
|
||||
- **18 Achievements** — including secret ones
|
||||
- **19 Achievements** — including secret ones
|
||||
- **Daily Challenge** — server-seeded so every player worldwide gets the
|
||||
same deal
|
||||
- **Leaderboard** — opt-in, powered by your own self-hosted server
|
||||
|
||||
@@ -1,140 +1,282 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-02 (session 9, post-v0.14.0 release prep) — v0.14.0 cut. The Quat bug fixes, the rest of the v0.13.0 candidate list, and the entire replay → upload → web-viewer pipeline are all bundled in this release. Direction now opens for the next round.
|
||||
**Last updated:** 2026-05-07 — v0.20.0 cut. Two through-lines closed
|
||||
in this cycle: a full **Terminal visual-identity port** (token system
|
||||
in `ui_theme` plus downstream chrome migrations across modal scaffold,
|
||||
gameplay-feedback, toasts, and the table / card / splash surfaces)
|
||||
and the **Android persistence shim** that closes the
|
||||
`dirs::data_dir() = None` pitfall flagged in CLAUDE.md §10. The
|
||||
Android *build* target landed earlier in the cycle (`fb8b2ac`); this
|
||||
session paid down the persistence half so a real APK can survive a
|
||||
cold start. The 24 Stitch-rendered mockups are now in-tree under
|
||||
`docs/ui-mockups/`; future plugin work diffs against the matching
|
||||
mockup before touching pixels.
|
||||
|
||||
## Status at pause
|
||||
|
||||
- **HEAD on origin:** v0.14.0's tag commit (CHANGELOG + handoff refresh).
|
||||
- **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional).
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||
- **Tests:** **1134 passed / 0 failed** across the workspace.
|
||||
- **Tags on origin:** `v0.9.0`, `v0.10.0`, `v0.11.0`, `v0.12.0`, `v0.13.0`, `v0.14.0`.
|
||||
- **HEAD on origin:** the v0.20.0 docs commit (the one that lands
|
||||
this file + CHANGELOG cut). Tag not yet pushed; cut whenever
|
||||
feels right.
|
||||
- **Working tree:** clean apart from the still-untracked `artwork/`
|
||||
directory (intentional — the card PNGs there are mid-flight for
|
||||
the Terminal aesthetic and committing now would freeze a
|
||||
transitional state).
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
clean.
|
||||
- **Tests:** **1176 passing / 0 failing** across the workspace.
|
||||
Six new tests this cycle: four `ui_theme` invariant guards
|
||||
(type / spacing / z-index scales + `scaled_duration`), one
|
||||
toast-variant-border-mapping pair, and four palette-tracking
|
||||
guards on `MARKER_VALID` / `HINT_PILE_HIGHLIGHT_COLOUR` /
|
||||
`RIGHT_CLICK_HIGHLIGHT_COLOUR` / toast-border distinctness. No
|
||||
known flakes.
|
||||
- **Tags on origin:** `v0.9.0` through `v0.19.0`. v0.20.0 not yet
|
||||
tagged.
|
||||
|
||||
## Where we are
|
||||
## What shipped in v0.20.0
|
||||
|
||||
v0.14.0 is the largest release since the card-theme system. Three threads land together:
|
||||
### Terminal visual-identity port
|
||||
|
||||
1. **The remaining v0.13.0-era UX candidates** — theme thumbnails, daily-challenge calendar, Time Attack auto-save, per-mode bests, time-bonus multiplier slider.
|
||||
2. **Quat smoke-test bug fixes** — multi-card move validation, softlock detection, deal-tween information leak.
|
||||
3. **The replay pipeline** — record on win, persist to disk, upload to server, view in browser via a new `solitaire_wasm` crate. The biggest single feature since the card-theme system.
|
||||
Top-down stack — every commit downstream of the token system
|
||||
reads from it, so swapping the palette is now a one-file edit:
|
||||
|
||||
The card-flight web animations and replay E2E test coverage close out the pipeline.
|
||||
- **`ui_theme` token system** (`0d477ac`). base16-eighties
|
||||
palette, 5-rung type scale, 7-rung 4-multiple spacing scale,
|
||||
3-step radius, 14-rung z-index hierarchy, full motion budget,
|
||||
4 invariant-pinning unit tests. Card-shadow alphas pinned to 0
|
||||
(Terminal achieves depth via 1px borders + tonal layering).
|
||||
- **Modal scaffold already on tokens** — `ui_modal` was ported
|
||||
in the same commit's wake; three stale "loud yellow" /
|
||||
"magenta secondary" doc comments fixed.
|
||||
- **Gameplay feedback → semantic state tokens** (`ceec4fc`).
|
||||
Selection / valid-drop tints route through `ACCENT_PRIMARY` /
|
||||
`STATE_WARNING` / `STATE_SUCCESS`.
|
||||
- **Toasts** (`a137607`). New `ToastVariant` enum
|
||||
(Info / Warning / Error / Celebration); opaque `BG_ELEVATED`
|
||||
+ 1px accent border + bottom-anchor. All ten call sites pass
|
||||
their semantic variant.
|
||||
- **`table_plugin` chrome** (`651f406`).
|
||||
`PILE_MARKER_DEFAULT_COLOUR` promoted; `cursor_plugin` imports
|
||||
it, replacing a "kept in sync" doc comment with a compile-
|
||||
enforced invariant. `HINT_PILE_HIGHLIGHT_COLOUR` →
|
||||
`STATE_WARNING`.
|
||||
- **`card_plugin` chrome** (`d752870`). Drag-elevation shadow
|
||||
routes through `CARD_SHADOW_*` tokens. `RIGHT_CLICK_HIGHLIGHT_COLOUR`
|
||||
→ `STATE_SUCCESS`. Stock recycle "↺" text → `TEXT_PRIMARY @ 0.7α`.
|
||||
Card-face / suit / card-back palette intentionally NOT migrated
|
||||
(artwork dependency — see open-list item below).
|
||||
- **Splash cursor** (`cdcadda`). The signature `▌` cyan glyph
|
||||
(96 px) added above the wordmark, matching the spec.
|
||||
- **Hint-source / dest pairing** (`9891ae4`). `input_plugin`'s
|
||||
source-card tint now matches the destination pile's
|
||||
`STATE_WARNING`.
|
||||
- **Design system + 24-mockup library** (`fa7f98a`).
|
||||
`docs/ui-mockups/design-system.md` + 24 Stitch mockups (HTML +
|
||||
PNG) covering every screen plus 9 missing-plugin surfaces.
|
||||
- **`card_shadow_params` test aligned** (`1d1543e`). Drag-vs-
|
||||
idle shadow assertion loosened to `>=` to accept the Terminal
|
||||
"no shadow" intent without losing the regression-guard.
|
||||
|
||||
### Design direction (unchanged)
|
||||
### Android persistence
|
||||
|
||||
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
|
||||
- **Palette:** Midnight Purple base + Balatro yellow primary + warm magenta secondary.
|
||||
- See `~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md` (machine-local).
|
||||
- **`solitaire_data::data_dir` shim** (`4b51e50`). New
|
||||
`solitaire_data::platform::data_dir()` falls through to
|
||||
`dirs::data_dir()` on desktop and returns the per-app sandbox
|
||||
at `/data/data/com.solitairequest.app/files` on Android — no
|
||||
JNI needed (package id pinned in `[package.metadata.android]`).
|
||||
Six `solitaire_data` callsites + `solitaire_engine/assets/user_dir.rs`
|
||||
migrated. Settings, stats, achievements, replays, game-state,
|
||||
time-attack sessions, and user themes now persist on Android.
|
||||
|
||||
### Canonical remote
|
||||
### Inherited from earlier in the cycle (pre-session)
|
||||
|
||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there.
|
||||
|
||||
## Session 8 + 9 (shipped 2026-05-02) — v0.14.0
|
||||
|
||||
### v0.13.0-era UX candidates (had landed but missed v0.13.0's tag)
|
||||
|
||||
| Area | Commit | What landed |
|
||||
|---|---|---|
|
||||
| Theme thumbnails | `ba527de` | Each Settings → Cosmetic theme chip renders an Ace + back preview pair via `rasterize_svg`. Cached per theme. Missing-SVG themes show a transparent placeholder rather than crashing. |
|
||||
| Daily-challenge calendar | `1a10476` | 14-dot horizontal calendar in the Profile modal. Today is ringed, completed days fill `STATE_SUCCESS`, missed days fill `BG_ELEVATED`. Caption: "Current streak: N · Longest: M". `PlayerProgress` gains `daily_challenge_history` (capped at 365) and `daily_challenge_longest_streak`. |
|
||||
| Time Attack auto-save | `0001432` | New sibling `time_attack_session.json` next to `game_state.json`. Atomic .tmp + rename. 30 s auto-save while active + on `AppExit`. Sessions whose 10-min window expired in real time while the app was closed are discarded on load. |
|
||||
| Per-mode bests | `3984231` | StatsSnapshot gains six `#[serde(default)]` fields (Classic / Zen / Challenge × best_score + fastest_win_seconds). Stats screen renders a "Per-mode bests" section. Lifetime totals continue to roll all modes together. |
|
||||
| Time-bonus slider | `89c51ab` | Settings → Gameplay slider 0.0–2.0, default 1.0, "Off" at zero. Multiplies the time-bonus shown in the win modal. Cosmetic only — does NOT affect achievement unlock thresholds. |
|
||||
|
||||
### Quat smoke-test bug fixes
|
||||
|
||||
| Area | Commit | What landed |
|
||||
|---|---|---|
|
||||
| Move validation (#1) | `f1aeb24` | `solitaire_core::rules::is_valid_tableau_sequence(&[Card]) -> bool` checks every adjacent pair in a moved stack descends one rank with alternating colour. Wired into `move_cards`. Closes the bug where any multi-card lift could be dropped as long as the bottom landed legally. |
|
||||
| Deal-tween leak (#4) | `3eabc14` | New-game snaps every card sprite to the stock pile position before writing `StateChangedEvent`, so all 52 cards animate from a single deck point during the deal. Previously sprites started from previous-game positions, briefly revealing the prior deal. |
|
||||
| Softlock detection (#2) | `2716472` | `has_legal_moves` rewritten: walks every potential move source (every stock card, every waste card, the face-up top of every tableau column) against every foundation and every tableau. Previous heuristic returned `true` whenever stock had cards, hiding genuine softlocks. `GameOverScreen` now actually fires for true softlocks. |
|
||||
| End-game screen (#3) | — | Resolved as downstream of #2. The pre-existing `GameOverScreen` and `WinSummaryOverlay` already cover the close-out paths; the softlock screen just never spawned because the old `has_legal_moves` lied. |
|
||||
|
||||
### Replay pipeline (the major feature)
|
||||
|
||||
| Area | Commit | What landed |
|
||||
|---|---|---|
|
||||
| Replay storage | `42535f5` | `solitaire_data::replay::Replay` (seed + draw_mode + mode + score + time + recorded date + ordered move list) and atomic save/load helpers under `<data_dir>/latest_replay.json`. Schema v1; `load` returns None for any other version. |
|
||||
| Engine recording | `57d1c58` | `RecordingReplay` resource + `ReplayPath` settings. Every successful `MoveRequestEvent` / `DrawRequestEvent` appends to recording; `GameWonEvent` freezes the recording into a `Replay` and persists. Undo intentionally not recorded. New game clears the recording. |
|
||||
| Stats button | `d9f36bf` | Stats overlay surfaces a "Latest win:" caption + "Watch replay" button. Loads from disk via `LatestReplayResource`. (Full in-engine playback deferred — button currently fires an `InfoToastEvent` describing the replay.) |
|
||||
| Server upload + fetch | `93182fa` | `POST /api/replays` accepts a `Replay` JSON; `GET /api/replays/:id` returns it. JWT-gated. SQL migration for the new `replays` table. |
|
||||
| Engine sync | `23c9704` | Engine uploads winning replays automatically when the player has cloud sync configured. Re-uses the existing JWT/refresh-token flow. |
|
||||
| WASM crate | `5bed43e` | New workspace member `solitaire_wasm` compiles replay-relevant `solitaire_core` types to WebAssembly so a browser can re-execute a replay client-side. `wasm-bindgen` glue. |
|
||||
| Web viewer | `07b8ecd` | `GET /replays/:id` returns HTML + CSS + the wasm bundle. Browser fetches the replay JSON, rasterises a deal from the seed, and animates the recorded moves. |
|
||||
| E2E coverage | `3081505` | Server tests covering the full upload → fetch round-trip via `axum::test`. |
|
||||
| Web flight anim | `1fcd032` | Card-flight tweens on the web side so the browser viewer reads as a real game replay rather than a static dump. |
|
||||
- Android build target + APK (`fb8b2ac`), runbook (`59424a3`),
|
||||
F3 FPS overlay (`690e1d2`), Smart Window Size opt-out
|
||||
(`e1b8766`), Shareable badge (`9b065e5`), Help cheat-sheet
|
||||
M/P/Enter rows (`35516d3`), `pull_failure_sets_error_status`
|
||||
flake fix (`67c150b`).
|
||||
|
||||
## Open punch list
|
||||
|
||||
### Release prep
|
||||
1. **Smoke-test on the alex machine** after pulling — confirm Quat's three bug fixes hold up in real gameplay, and try the new replay button + web viewer end-to-end.
|
||||
2. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
|
||||
### Phase Android (build + persistence shipped; runtime gaps remain)
|
||||
|
||||
### UX iteration (next-round candidates)
|
||||
- **APK launch verification on AVD / device.** `adb install` then
|
||||
`adb logcat` against the `bevy_test` AVD or an x86_64 device.
|
||||
The build works and persistence is wired, but no end-to-end
|
||||
device run has been logged. Shakes out runtime bugs the build +
|
||||
unit tests can't catch.
|
||||
- **JNI ClipboardManager bridge.** Replaces the Android stub for
|
||||
the Stats "Copy share link" toast. `arboard` doesn't ship an
|
||||
Android backend; small custom JNI call.
|
||||
- **Android Keystore for credentials.** `keyring` is target-gated
|
||||
to a stub returning `KeychainUnavailable`; replace with Android
|
||||
Keystore via JNI when sync auth ships on mobile.
|
||||
- **Google Play Games (gpgs) integration.** Listed as a
|
||||
Phase-Android target since Phase 1; now unblocked by the build
|
||||
target.
|
||||
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
|
||||
panic doesn't affect the APK on disk but produces noisy stderr.
|
||||
Either upstream a cargo-apk fix or document `--lib` as
|
||||
canonical in the runbook.
|
||||
|
||||
- **Solver-at-deal toggle** (Quat investigation #1, still deferred): add a Settings → Gameplay toggle "Winnable deals only" rather than baking solver-only into every deal. Lightest middle ground.
|
||||
- **Disable Bevy's default audio feature** (Quat investigation #2, still deferred): one-line `default-features = false` swap on the workspace `bevy =` line, re-enable explicitly the features the engine uses (`render`, `bevy_winit`, `2d`, `bevy_window`, `png`, `bevy_text`, `bevy_ui`, `bevy_log`, `bevy_asset`, `default_font`, `bevy_state`). Drops ~50 transitive crates including the rodio + symphonia stack the project doesn't use (kira handles audio).
|
||||
- **In-engine replay playback** — promote the "Watch replay" button from a stub toast to a real playback overlay that re-runs the recorded moves with `CardAnimation` tweens. The wasm crate already proves the playback math; the in-engine version reuses the same execute logic against the live game state.
|
||||
- **Per-replay history** — currently single-slot at `latest_replay.json`. A "best replay per mode" bucket or a recent-N rolling list would let players revisit notable wins.
|
||||
- **Solver-driven hint system** — extend the existing hint toggle so a deal-time solver provides higher-quality hints (currently a heuristic). Requires the solver from the toggle work above.
|
||||
- **Achievement: "won via replay path"** — track when a player wins a deal whose previously-saved replay also won the same deal. Mostly fun; trivial scope.
|
||||
### Visual-identity follow-ups (opened by v0.20.0's port)
|
||||
|
||||
## Card-theme system (CARD_PLAN.md, fully shipped)
|
||||
- **Card-face / suit / card-back artwork regeneration.** The
|
||||
Terminal spec calls for dark `#1a1a1a` cards with light suit
|
||||
pips (pink for hearts/diamonds, foreground gray for spades/
|
||||
clubs); the runtime path still renders the legacy white-card
|
||||
PNG artwork. The fallback constants in `card_plugin`
|
||||
(`CARD_FACE_COLOUR`, `RED_SUIT_COLOUR`, `BLACK_SUIT_COLOUR`,
|
||||
`CARD_FACE_COLOUR_RED_CBM`, `card_back_colour` palette) are
|
||||
intentionally unmigrated and should swap in lockstep with the
|
||||
artwork. Largest visible payoff remaining in the visual-
|
||||
identity arc.
|
||||
- **Splash boot-loader richness.** The mockup
|
||||
(`docs/ui-mockups/splash-mobile.html`) calls for a scanline
|
||||
overlay, ✓ lime check log lines, pulsing cursor, ROOT@SOLITAIRE
|
||||
prompt, and a loading bar — none of which v0.20.0's
|
||||
cursor-glyph-only port pulled in. Aesthetic feature, its own
|
||||
commit.
|
||||
- **Replay-overlay redesign.** The mockup
|
||||
(`docs/ui-mockups/replay-overlay-mobile.html`) envisions a
|
||||
much richer surface (terminal `▌replay.tsx` header, move log
|
||||
scroll, MOVE 47/87 chip, WIN MOVE callout, status bar) versus
|
||||
the current top banner. Aesthetic feature.
|
||||
- **Toast Warning / Error variants.** The new `ToastVariant`
|
||||
enum has slots for `Warning` (gold) and `Error` (pink) but no
|
||||
in-engine event uses them yet (the four current toast events
|
||||
all map to Info or Celebration). Wire when a warning- or
|
||||
error-flavoured toast event materialises.
|
||||
|
||||
Seven phases landed across `b8fb3fb` → `924a1e2` in v0.11.0; v0.13.0's `7ed4f2c` consumes the per-theme `back.svg`; v0.14.0's `ba527de` adds preview thumbnails. End-to-end:
|
||||
### Carried forward from v0.19.0
|
||||
|
||||
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs + a midnight-purple `back.svg`.
|
||||
- **User themes** under `themes://`. Drop a directory containing `theme.ron` + 53 SVGs.
|
||||
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates archives and atomically unpacks.
|
||||
- **Picker UI** in Settings → Cosmetic; thumbnails + the active theme's `back` override the legacy `back_N.png` picker when present.
|
||||
- **App icon round.** `Window::icon` not yet wired; no
|
||||
`.icns` / `.ico` / Linux hicolor PNG hierarchy. The 11-size
|
||||
icon export the v0.19 handoff referenced is *not* currently
|
||||
in `artwork/` (current `artwork/` holds the reverted Rusty
|
||||
Pixel card PNGs and is intentionally untracked); icon-export
|
||||
needs to be re-run before this item can be picked up.
|
||||
Half-day task once the PNGs are back in place. No cert
|
||||
dependency.
|
||||
|
||||
### Other small candidates
|
||||
|
||||
- **Prev/Next selector chips spawn site.** v0.19.0's `9b065e5`
|
||||
noted Prev/Next markers exist in `stats_plugin` but no spawn
|
||||
site renders them today — the Shareable badge therefore lands
|
||||
on the single-replay caption. If/when Prev/Next is plumbed,
|
||||
the badge will need to follow.
|
||||
- **Toast queue / immediate unification.** The two toast paths
|
||||
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
|
||||
for fire-and-forget) now share visual treatment but remain
|
||||
separate functions because they serve different temporal
|
||||
needs (sequential vs. parallel). If overlap becomes a UX
|
||||
issue, merge into one queue with priority lanes.
|
||||
|
||||
### Process notes
|
||||
|
||||
- **Token-port pattern.** v0.20.0's chrome-migration commits
|
||||
set a reusable shape for "centralized design system applied
|
||||
across N plugins":
|
||||
1. Constants module (`ui_theme.rs`) is the source of truth.
|
||||
2. Const sites that can't call `Alpha::with_alpha` (not yet
|
||||
`const` on stable) use a literal RGB matching the token,
|
||||
with a unit test pinning the RGB to the token (e.g.
|
||||
`MARKER_VALID`, `HINT_PILE_HIGHLIGHT_COLOUR`,
|
||||
`RIGHT_CLICK_HIGHLIGHT_COLOUR`).
|
||||
3. Cross-plugin duplication (e.g. `MARKER_DEFAULT` ↔
|
||||
`PILE_MARKER_DEFAULT_COLOUR`) collapses to a single
|
||||
promoted const re-exported from one plugin and imported
|
||||
by the other — replaces "kept in sync" doc comments with a
|
||||
compile-time invariant.
|
||||
4. Domain colours (suit pips, card faces, lerp helpers) stay
|
||||
as literals with a comment naming the rationale; only UI
|
||||
chrome routes through tokens.
|
||||
- **Audit before migrating wide.** Before touching any plugin,
|
||||
grep for the literal pattern (`Color::srgb\(|Color::srgba\(|
|
||||
Color::WHITE|Color::BLACK`) and classify each hit as domain
|
||||
vs. chrome. Most plugins after the modal scaffold port turned
|
||||
out to be 100 % token-correct already; the audit prevents
|
||||
wasted churn.
|
||||
|
||||
### Canonical remote
|
||||
|
||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
|
||||
Always push there.
|
||||
|
||||
### Design direction (now Terminal — base16-eighties)
|
||||
|
||||
- **Tone:** retro-terminal / synthwave — flat depth (no box-shadows),
|
||||
monospaced-forward typography (JetBrains Mono / FiraMono), tight
|
||||
16 px edge margins, 8 px card radius.
|
||||
- **Palette:** near-black surface ramp (`#151515` / `#202020` / `#2a2a2a`
|
||||
/ `#353535`), cyan primary CTA (`#6fc2ef`), lime success
|
||||
(`#acc267`), gold warning (`#ddb26f`), pink error / suit-red
|
||||
(`#fb9fb1`), lavender celebration (`#e1a3ee`), teal info
|
||||
(`#12cfc0`).
|
||||
- **Two-color suits.** Red = `#fb9fb1`, black = `#d0d0d0`. Outlined
|
||||
glyphs for diamonds & clubs are *always on*; the Settings
|
||||
"color-blind mode" toggle only swaps red → cyan.
|
||||
|
||||
(Was: Midnight Purple base + Balatro yellow primary + warm magenta.
|
||||
Replaced this cycle.)
|
||||
|
||||
## Resume prompt
|
||||
|
||||
```
|
||||
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 — v0.14.0 just shipped covering the
|
||||
Quat bug fixes, the v0.13.0 candidate tail, and the entire
|
||||
replay-pipeline feature.
|
||||
Working directory: <Rusty_Solitaire clone path on this machine>.
|
||||
Branch: master. v0.20.0 just cut on 2026-05-07; CHANGELOG's new
|
||||
[Unreleased] section is empty pending the next cycle's threads.
|
||||
|
||||
State: HEAD at v0.14.0. Working tree clean apart from untracked
|
||||
CARD_PLAN.md (intentional).
|
||||
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
||||
Tests: 1134 passed / 0 failed.
|
||||
State: HEAD on the v0.20.0 docs commit. Tag not pushed yet — last
|
||||
pushed tag is v0.19.0. Working tree clean apart from the
|
||||
intentionally-untracked `artwork/`.
|
||||
|
||||
READ FIRST (in order, before doing anything):
|
||||
1. SESSION_HANDOFF.md — v0.14.0 changelog + open punch list
|
||||
2. CHANGELOG.md — release-by-release record
|
||||
3. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
||||
4. ARCHITECTURE.md — crate responsibilities + data flow
|
||||
5. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||
— saved feedback / project context (machine-local;
|
||||
may be missing on a fresh machine)
|
||||
1. SESSION_HANDOFF.md — this file
|
||||
2. CHANGELOG.md — [0.20.0] section is the most recent cut
|
||||
3. CLAUDE.md — unified-3.0 rule set
|
||||
4. CLAUDE_SPEC.md — formal architecture spec
|
||||
5. ARCHITECTURE.md — crate responsibilities + data flow
|
||||
6. docs/ui-mockups/ — design system + 24-mockup library
|
||||
(Terminal aesthetic — landed in fa7f98a)
|
||||
7. docs/android/* — Android setup + build runbook
|
||||
8. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||
— saved feedback / project context
|
||||
(machine-local; may be missing on a
|
||||
fresh machine)
|
||||
|
||||
DECISION TO ASK THE PLAYER FIRST:
|
||||
A. Smoke-test v0.14.0 on the alex machine first to confirm the
|
||||
three Quat bug fixes hold up in real gameplay and the replay
|
||||
pipeline works end-to-end (record → upload → web viewer).
|
||||
B. Take the deferred Bevy-audio-feature trim (Quat investigation
|
||||
#2) — one-line workspace edit, ~50 fewer transitive crates.
|
||||
C. Take the deferred solver toggle (Quat investigation #1): add
|
||||
"Winnable deals only" Settings toggle. Larger.
|
||||
D. Promote the in-engine "Watch replay" button to real playback.
|
||||
E. Pick from the remaining "next-round candidates" in this doc.
|
||||
F. Take the deferred desktop-packaging item (needs artwork +
|
||||
signing certs from the user).
|
||||
A. Push v0.20.0 tag — `git tag v0.20.0 && git push --tags`. If
|
||||
the player wants the cut formalised before any new work.
|
||||
B. APK launch verification — `adb install` + `adb logcat` on
|
||||
bevy_test AVD or an x86_64 device. Now that persistence is
|
||||
wired (4b51e50), shake out remaining runtime bugs.
|
||||
C. Card-face artwork regeneration — generate Terminal-aesthetic
|
||||
card PNGs (dark face, light suit pips), then migrate
|
||||
CARD_FACE_COLOUR / RED_SUIT_COLOUR / BLACK_SUIT_COLOUR /
|
||||
CARD_FACE_COLOUR_RED_CBM in lockstep. Largest visible
|
||||
payoff remaining in the visual-identity arc.
|
||||
D. Splash boot-loader richness — port the scanline overlay,
|
||||
✓ check log, pulsing cursor, ROOT@SOLITAIRE prompt, and
|
||||
loading bar from docs/ui-mockups/splash-mobile.html. Pure
|
||||
polish; no behavioural change.
|
||||
E. App icon round — re-run artwork/Icon Export.html (the
|
||||
export PNGs are not currently in `artwork/`), then wire
|
||||
Window::icon + generate .icns / .ico. Half-day task. No
|
||||
cert dependency.
|
||||
F. JNI ClipboardManager / Keystore bridge — replaces the
|
||||
Android stubs for Stats clipboard share + sync auth.
|
||||
|
||||
WORKFLOW NOTES:
|
||||
- Commits use:
|
||||
git -c user.name=funman300 -c user.email=root@vscode.infinity \
|
||||
commit -m "..."
|
||||
- When attributing playtester feedback in commits/docs, use "Quat"
|
||||
not "Rhys" (saved feedback memory).
|
||||
- Use the system git config (already correct).
|
||||
- When attributing playtester feedback in commits/docs, use
|
||||
"Quat" not "Rhys" (saved feedback memory).
|
||||
- Sub-agents stage + verify only; orchestrator commits.
|
||||
- Every commit must pass build / clippy / test before pushing.
|
||||
- Push to GitHub (origin) — that is the canonical remote.
|
||||
- Push to GitHub (origin) — gh auth setup-git wired on
|
||||
primary dev box; verify on laptop before first push.
|
||||
|
||||
OPEN AT THE START: ask which of A–F. Don't pick unilaterally.
|
||||
```
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
# Android build — developer setup
|
||||
|
||||
This doc captures the toolchain install + build invocation for the
|
||||
Android target. Steps are runnable on a fresh Debian 13 (trixie) box;
|
||||
later sections document what's known to compile, what's stubbed, and
|
||||
the next milestones.
|
||||
|
||||
> **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB
|
||||
> debug-signed `solitaire-quest.apk` for `x86_64-linux-android`. Has
|
||||
> NOT yet been verified to launch on a device or emulator — that's
|
||||
> the next milestone.
|
||||
|
||||
---
|
||||
|
||||
## 1. Toolchain install (Debian 13 / trixie)
|
||||
|
||||
Run as one block. Will pull ~15-20 GB of disk between APT, the SDK,
|
||||
the NDK, the system image, and Rust target sysroots. Requires sudo.
|
||||
|
||||
```bash
|
||||
# 1. JDK 21 (Android tooling needs JDK 17+; Debian 13 default is 21).
|
||||
sudo apt update && sudo apt install -y openjdk-21-jdk-headless unzip wget
|
||||
|
||||
# 2. SDK directory + Google's cmdline-tools bootstrap.
|
||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
wget -O /tmp/cmdline-tools.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/cmdline-tools.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
rm /tmp/cmdline-tools.zip
|
||||
|
||||
# 3. Persist env vars.
|
||||
{
|
||||
echo ''
|
||||
echo '# Android dev'
|
||||
echo 'export ANDROID_HOME="$HOME/Android/Sdk"'
|
||||
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.3.11579264"'
|
||||
echo 'export JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"'
|
||||
echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"'
|
||||
} >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# 4. Accept SDK licences (interactive prompts answered by `yes |`).
|
||||
yes | sdkmanager --licenses
|
||||
|
||||
# 5. Platform packages — ~5 GB.
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-34" \
|
||||
"build-tools;34.0.0" \
|
||||
"ndk;26.3.11579264" \
|
||||
"emulator" \
|
||||
"system-images;android-34;google_apis;x86_64"
|
||||
|
||||
# 6. AVD for testing (one-time).
|
||||
echo no | avdmanager create avd \
|
||||
-n bevy_test \
|
||||
-k "system-images;android-34;google_apis;x86_64" \
|
||||
-d pixel_7
|
||||
|
||||
# 7. Rust cross-compile targets.
|
||||
rustup target add \
|
||||
aarch64-linux-android \
|
||||
armv7-linux-androideabi \
|
||||
x86_64-linux-android \
|
||||
i686-linux-android
|
||||
|
||||
# 8. cargo-apk.
|
||||
cargo install cargo-apk
|
||||
```
|
||||
|
||||
Sanity:
|
||||
|
||||
```bash
|
||||
java --version | head -1 # openjdk 21.0.x
|
||||
adb --version | head -1 # 35.x or higher
|
||||
sdkmanager --list_installed | head # build-tools, emulator, ndk, platforms, system-images
|
||||
avdmanager list avd | head # bevy_test
|
||||
rustup target list --installed | grep android # 4 targets
|
||||
cargo apk --help | head -5
|
||||
```
|
||||
|
||||
If `sdkmanager --version` errors with `JAVA_HOME is not set`, the env
|
||||
section in step 3 didn't apply to your shell — `source ~/.bashrc`
|
||||
again or open a new terminal.
|
||||
|
||||
### Optional: emulator runtime libs
|
||||
|
||||
The Android emulator is dynamically linked against X11/GL/audio. If
|
||||
`emulator -list-avds` works but `emulator -avd bevy_test` complains
|
||||
about `libX11.so.6`, install:
|
||||
|
||||
```bash
|
||||
sudo apt install -y \
|
||||
libx11-6 libxcursor1 libxrandr2 libxi6 libxinerama1 libxxf86vm1 \
|
||||
libgl1 libnss3 libpulse0 libxcomposite1
|
||||
```
|
||||
|
||||
Headless emulator launch:
|
||||
|
||||
```bash
|
||||
emulator -avd bevy_test -no-window -gpu swiftshader_indirect &
|
||||
adb wait-for-device && adb devices
|
||||
# Stop later:
|
||||
# adb -s emulator-5554 emu kill
|
||||
```
|
||||
|
||||
Headless + software rendering is fine for "does it boot" smoke tests
|
||||
but useless for perf measurement — use a physical Pixel-class device
|
||||
over USB for real numbers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Build the APK
|
||||
|
||||
```bash
|
||||
cargo apk build -p solitaire_app --target x86_64-linux-android
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
target/debug/apk/solitaire-quest.apk
|
||||
```
|
||||
|
||||
Targets shipped via `[package.metadata.android].build_targets` in
|
||||
`solitaire_app/Cargo.toml`:
|
||||
|
||||
| Target | Use |
|
||||
|--------|-----|
|
||||
| `aarch64-linux-android` | Real phones (modern 64-bit ARM) |
|
||||
| `armv7-linux-androideabi` | Older 32-bit ARM phones |
|
||||
| `x86_64-linux-android` | The `bevy_test` AVD on this dev box |
|
||||
|
||||
Build any of them with `--target <triple>`.
|
||||
|
||||
### Known cosmetic warning
|
||||
|
||||
After the APK is signed cargo-apk panics with:
|
||||
|
||||
```
|
||||
thread 'main' panicked: Bin is not compatible with Cdylib
|
||||
```
|
||||
|
||||
This happens AFTER the APK is on disk and signed. cargo-apk is
|
||||
trying to also wrap the desktop `[[bin]]` target. The APK is still
|
||||
valid. Work around with `--lib`:
|
||||
|
||||
```bash
|
||||
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
|
||||
```
|
||||
|
||||
(Permanent fix to come — likely a `[[bin]] required-features = ["desktop"]`
|
||||
gate so cargo-apk skips the bin target on Android.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Install + run
|
||||
|
||||
Physical device:
|
||||
|
||||
```bash
|
||||
adb devices # confirm connection
|
||||
adb install target/debug/apk/solitaire-quest.apk
|
||||
adb shell am start -n com.solitairequest.app/android.app.NativeActivity
|
||||
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
|
||||
```
|
||||
|
||||
Emulator:
|
||||
|
||||
```bash
|
||||
emulator -avd bevy_test -no-window -gpu swiftshader_indirect &
|
||||
adb wait-for-device
|
||||
adb install target/debug/apk/solitaire-quest.apk
|
||||
# ... same start + logcat steps as above.
|
||||
```
|
||||
|
||||
If `adb install` errors with `INSTALL_FAILED_NO_MATCHING_ABIS`, the
|
||||
emulator is x86_64 but the APK was built for arm — rebuild with the
|
||||
`x86_64-linux-android` target, or add an x86_64 system image to the
|
||||
AVD.
|
||||
|
||||
---
|
||||
|
||||
## 4. What's wired vs. what's stubbed
|
||||
|
||||
The first build pass (commit `fb8b2ac`) gates four desktop-only
|
||||
crates / call sites so the workspace cross-compiles. Each gate is
|
||||
documented at its call site.
|
||||
|
||||
| Surface | Desktop | Android |
|
||||
|---------|---------|---------|
|
||||
| Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) |
|
||||
| Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline |
|
||||
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Stub returning `KeychainUnavailable`; sync requires fresh login each launch |
|
||||
| App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity |
|
||||
|
||||
What's NOT yet ported / not yet measured:
|
||||
|
||||
- `dirs::data_dir()` returns `None` on Android. Callers in
|
||||
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
|
||||
`achievements.rs`, `settings.rs` all need an Android-aware
|
||||
helper (likely `/data/data/com.solitairequest.app/files`).
|
||||
- Touch UX pass — hit-target sizes, modal scaling on small screens,
|
||||
app lifecycle (suspend / resume), font scaling.
|
||||
- Android Keystore via JNI for `auth_tokens`.
|
||||
- JNI ClipboardManager for share links.
|
||||
- Google Play Games sign-in (the `solitaire_gpgs` crate referenced
|
||||
in older docs doesn't yet exist).
|
||||
|
||||
---
|
||||
|
||||
## 5. Iteration loop
|
||||
|
||||
```bash
|
||||
# Edit code…
|
||||
cargo build -p solitaire_app # desktop sanity
|
||||
cargo clippy --workspace --all-targets -- -D warnings # gate
|
||||
cargo test --workspace # gate
|
||||
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
|
||||
adb install -r target/debug/apk/solitaire-quest.apk # `-r` reinstalls
|
||||
adb logcat -c && adb shell am start -n com.solitairequest.app/android.app.NativeActivity
|
||||
adb logcat | grep -iE "RustStdoutStderr|solitaire"
|
||||
```
|
||||
|
||||
`adb logcat` is the canonical way to see Bevy / Rust panic output —
|
||||
they end up in the `RustStdoutStderr` tag.
|
||||
@@ -0,0 +1,293 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Rusty Solitaire - Achievements</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Inter:wght@400;500;700&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"on-tertiary-container": "#683476",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"error": "#fb9fb1",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"background": "#101417",
|
||||
"error-container": "#93000a",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"inverse-primary": "#00668a",
|
||||
"highlight-valid": "#acc267",
|
||||
"suit-red": "#fb9fb1",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"on-secondary": "#293500",
|
||||
"on-primary-container": "#004f6c",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"on-surface": "#e0e3e6",
|
||||
"outline-variant": "#3f484e",
|
||||
"on-background": "#e0e3e6",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"info": "#12cfc0",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"warning": "#ddb26f",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"surface": "#151515",
|
||||
"surface-container-highest": "#313538",
|
||||
"outline": "#505050",
|
||||
"on-primary": "#003549",
|
||||
"on-error-container": "#ffdad6",
|
||||
"surface-variant": "#313538",
|
||||
"on-error": "#690005",
|
||||
"suit-black": "#d0d0d0",
|
||||
"primary": "#a1dcff",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"surface-bright": "#363a3d",
|
||||
"on-tertiary": "#4c195b",
|
||||
"surface-dim": "#101417",
|
||||
"primary-container": "#6fc2ef",
|
||||
"tertiary": "#f7c3ff",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"surface-container-high": "#272a2d",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"surface-container": "#1c2023",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"secondary-container": "#435401",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"secondary": "#bad073",
|
||||
"surface-container-low": "#181c1f"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"stack-overlap": "2rem",
|
||||
"gutter-card": "0.375rem",
|
||||
"touch-target-min": "48dp",
|
||||
"margin-edge": "1rem",
|
||||
"action-bar-height": "64px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
body {
|
||||
background-color: #151515;
|
||||
color: #e0e3e6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.scanline {
|
||||
background: linear-gradient(to bottom, transparent 50%, rgba(0,0,0,0.1) 50%);
|
||||
background-size: 100% 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-body-md text-body-md overflow-x-hidden pb-[action-bar-height]">
|
||||
<!-- Status Bar -->
|
||||
<header class="fixed top-0 w-full h-[32px] bg-[#202020] flex items-center justify-between px-margin-edge z-[60] border-b border-outline-variant">
|
||||
<div class="flex items-center gap-2 font-label-caps text-on-surface">
|
||||
<span class="text-primary">▌</span>achievements.json
|
||||
</div>
|
||||
<div class="font-label-caps text-[#a0a0a0]">
|
||||
8/19 UNLOCKED
|
||||
</div>
|
||||
</header>
|
||||
<!-- Top App Bar (Shared Component Reference) -->
|
||||
<nav class="fixed top-[32px] w-full h-[64px] bg-surface flex items-center justify-between px-margin-edge z-50 border-b border-outline-variant">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-primary" data-icon="terminal">terminal</span>
|
||||
<h1 class="font-headline text-[20px] text-primary uppercase tracking-widest">Rusty Solitaire</h1>
|
||||
</div>
|
||||
<button class="w-10 h-10 flex items-center justify-center hover:bg-surface-container-highest transition-colors">
|
||||
<span class="material-symbols-outlined text-on-surface-variant" data-icon="settings">settings</span>
|
||||
</button>
|
||||
</nav>
|
||||
<main class="mt-[112px] px-margin-edge">
|
||||
<!-- Hero Progress Card -->
|
||||
<section class="w-full h-[100px] bg-[#202020] border border-[#353535] rounded-lg p-4 mb-6">
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<span class="font-label-caps text-[10px] text-[#a0a0a0]">PROGRESS</span>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-headline text-[28px] font-bold text-[#d0d0d0]">8/19</span>
|
||||
<span class="font-label-caps text-[14px] text-highlight-celebration">(42%)</span>
|
||||
</div>
|
||||
<div class="w-full h-[4px] bg-[#353535] rounded-full overflow-hidden mt-1">
|
||||
<div class="h-full bg-highlight-celebration" style="width: 42%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Filter Chip Row -->
|
||||
<section class="flex gap-2 mb-6 overflow-x-auto no-scrollbar">
|
||||
<button class="h-[32px] px-3 flex items-center justify-center border border-[#6fc2ef] text-[#6fc2ef] rounded-[4px] font-label-caps text-[11px]">
|
||||
[ ALL ]
|
||||
</button>
|
||||
<button class="h-[32px] px-3 flex items-center justify-center border border-outline text-[#a0a0a0] rounded-[4px] font-label-caps text-[11px] hover:border-primary hover:text-primary transition-colors">
|
||||
UNLOCKED
|
||||
</button>
|
||||
<button class="h-[32px] px-3 flex items-center justify-center border border-outline text-[#a0a0a0] rounded-[4px] font-label-caps text-[11px] hover:border-primary hover:text-primary transition-colors">
|
||||
LOCKED
|
||||
</button>
|
||||
<button class="h-[32px] px-3 flex items-center justify-center border border-outline text-[#a0a0a0] rounded-[4px] font-label-caps text-[11px] hover:border-primary hover:text-primary transition-colors">
|
||||
SECRET
|
||||
</button>
|
||||
</section>
|
||||
<!-- Achievements Grid -->
|
||||
<section class="grid grid-cols-2 gap-3 mb-10">
|
||||
<!-- FIRST WIN -->
|
||||
<div class="h-[100px] bg-[#202020] border border-[#353535] p-3 flex flex-col justify-between rounded-sm">
|
||||
<div class="w-8 h-8 rounded-full bg-[#6fc2ef] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#151515] text-[20px]" data-icon="emoji_events" style="font-variation-settings: 'FILL' 1;">emoji_events</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#d0d0d0] leading-none mb-1">FIRST WIN</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#a0a0a0] leading-tight">Win your first game</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SPEED DEMON -->
|
||||
<div class="h-[100px] bg-[#202020] border border-highlight-celebration p-3 flex flex-col justify-between rounded-sm relative">
|
||||
<div class="absolute inset-0 border border-highlight-celebration opacity-20 pointer-events-none"></div>
|
||||
<div class="w-8 h-8 rounded-full bg-[#6fc2ef] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#151515] text-[20px]" data-icon="speed" style="font-variation-settings: 'FILL' 1;">speed</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#d0d0d0] leading-none mb-1">SPEED DEMON</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#a0a0a0] leading-tight">Win in under 3:00</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- STREAK 10 -->
|
||||
<div class="h-[100px] bg-[#202020] border border-[#353535] p-3 flex flex-col justify-between rounded-sm">
|
||||
<div class="w-8 h-8 rounded-full bg-[#6fc2ef] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#151515] text-[20px]" data-icon="bolt" style="font-variation-settings: 'FILL' 1;">bolt</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#d0d0d0] leading-none mb-1">STREAK 10</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#a0a0a0] leading-tight">10 wins in a row</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- DAILY DEFENDER -->
|
||||
<div class="h-[100px] bg-[#202020] border border-[#353535] p-3 flex flex-col justify-between rounded-sm">
|
||||
<div class="w-8 h-8 rounded-full bg-[#6fc2ef] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#151515] text-[20px]" data-icon="calendar_today" style="font-variation-settings: 'FILL' 1;">calendar_today</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#d0d0d0] leading-none mb-1">DAILY DEFENDER</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#a0a0a0] leading-tight">Complete 7 daily seeds</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- PERFECTIONIST (LOCKED) -->
|
||||
<div class="h-[100px] bg-[#0d0d0d] border border-[#353535] p-3 flex flex-col justify-between rounded-sm opacity-80">
|
||||
<div class="w-8 h-8 rounded-full border border-[#505050] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#505050] text-[20px]" data-icon="undo">undo</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#505050] leading-none mb-1">PERFECTIONIST</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#353535] leading-tight">Win without using undo</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- CHALLENGE BEATEN (LOCKED) -->
|
||||
<div class="h-[100px] bg-[#0d0d0d] border border-[#353535] p-3 flex flex-col justify-between rounded-sm opacity-80">
|
||||
<div class="w-8 h-8 rounded-full border border-[#505050] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#505050] text-[20px]" data-icon="military_tech">military_tech</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#505050] leading-none mb-1">CHALLENGE BEATEN</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#353535] leading-tight">Complete CHALLENGE mode</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SECRET (LOCKED) -->
|
||||
<div class="h-[100px] bg-[#0d0d0d] border border-[#353535] p-3 flex flex-col justify-between rounded-sm opacity-80">
|
||||
<div class="w-8 h-8 rounded-full border border-[#505050] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#505050] text-[20px]" data-icon="help_outline">help_outline</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#505050] leading-none mb-1">????</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#353535] leading-tight">SECRET · Hidden until unlocked</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- PAR HUNTER (LOCKED) -->
|
||||
<div class="h-[100px] bg-[#0d0d0d] border border-[#353535] p-3 flex flex-col justify-between rounded-sm opacity-80">
|
||||
<div class="w-8 h-8 rounded-full border border-[#505050] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#505050] text-[20px]" data-icon="golf_course">golf_course</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#505050] leading-none mb-1">PAR HUNTER</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#353535] leading-tight">Beat par on 50 games</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- Footer Status -->
|
||||
<footer class="fixed bottom-[action-bar-height] w-full h-[24px] bg-background border-t border-outline-variant flex items-center justify-between px-margin-edge z-40 text-[10px] font-label-caps">
|
||||
<div class="flex items-center">
|
||||
<span class="text-primary mr-1">▌</span>
|
||||
<span class="text-on-surface-variant">NORMAL</span>
|
||||
<span class="mx-2 text-outline">│</span>
|
||||
<span class="text-on-surface-variant">achievements</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div><span class="text-[#a0a0a0]">[F]</span> <span class="text-[#505050]">filter</span></div>
|
||||
<div><span class="text-[#a0a0a0]">[/]</span> <span class="text-[#505050]">search</span></div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- Bottom Navigation Bar (Shared Component Reference) -->
|
||||
<nav class="fixed bottom-0 w-full h-action-bar-height bg-surface-container flex justify-around items-center px-margin-edge z-50 border-t border-outline-variant">
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:text-primary hover:bg-surface-container-highest transition-colors active:scale-95">
|
||||
<span class="material-symbols-outlined" data-icon="power_settings_new">power_settings_new</span>
|
||||
<span class="font-label-caps text-[10px] mt-1">[Q] QUIT</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:text-primary hover:bg-surface-container-highest transition-colors active:scale-95">
|
||||
<span class="material-symbols-outlined" data-icon="add_box">add_box</span>
|
||||
<span class="font-label-caps text-[10px] mt-1">[N] NEW</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:text-primary hover:bg-surface-container-highest transition-colors active:scale-95">
|
||||
<span class="material-symbols-outlined" data-icon="undo">undo</span>
|
||||
<span class="font-label-caps text-[10px] mt-1">[U] UNDO</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:text-primary hover:bg-surface-container-highest transition-colors active:scale-95">
|
||||
<span class="material-symbols-outlined" data-icon="lightbulb">lightbulb</span>
|
||||
<span class="font-label-caps text-[10px] mt-1">[H] HINT</span>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- CRT Overlay Effect (Visual Decoration) -->
|
||||
<div class="fixed inset-0 pointer-events-none z-[100] opacity-[0.03] scanline"></div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,219 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=390, height=844, initial-scale=1.0" name="viewport"/>
|
||||
<title>Challenge Mode Menu</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"error-container": "#93000a",
|
||||
"tertiary": "#f7c3ff",
|
||||
"on-primary-container": "#004f6c",
|
||||
"on-surface": "#e0e3e6",
|
||||
"surface-dim": "#101417",
|
||||
"surface-container-high": "#272a2d",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"secondary-container": "#435401",
|
||||
"suit-red": "#fb9fb1",
|
||||
"on-error": "#690005",
|
||||
"surface-container-low": "#181c1f",
|
||||
"surface-variant": "#313538",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"primary-container": "#6fc2ef",
|
||||
"background": "#101417",
|
||||
"primary": "#a1dcff",
|
||||
"outline": "#505050",
|
||||
"suit-black": "#d0d0d0",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"surface-container": "#202020",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"outline-variant": "#3f484e",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"error": "#fb9fb1",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"highlight-valid": "#acc267",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"on-error-container": "#ffdad6",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"on-secondary": "#293500",
|
||||
"on-tertiary": "#4c195b",
|
||||
"on-background": "#e0e3e6",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"secondary": "#bad073",
|
||||
"inverse-primary": "#00668a",
|
||||
"surface-bright": "#363a3d",
|
||||
"surface": "#151515",
|
||||
"on-tertiary-container": "#683476",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"warning": "#ddb26f",
|
||||
"info": "#12cfc0",
|
||||
"surface-container-highest": "#313538",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"on-primary": "#003549"
|
||||
},
|
||||
"fontFamily": {
|
||||
"mono": ["JetBrains Mono", "monospace"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
body {
|
||||
background-color: #101417;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.retro-scanline {
|
||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.03));
|
||||
background-size: 100% 2px, 3px 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex items-center justify-center min-h-screen text-on-background overflow-hidden">
|
||||
<!-- Mobile Container (390x844) -->
|
||||
<div class="relative w-[390px] h-[844px] bg-background flex flex-col overflow-hidden border border-outline-variant">
|
||||
<!-- Status Bar -->
|
||||
<div class="h-[32px] bg-surface-container flex items-center justify-between px-4 text-[11px] font-mono border-b border-outline-variant shrink-0">
|
||||
<span class="text-suit-black">▌challenge.tsx</span>
|
||||
<span class="text-[#a0a0a0]">LV 12 · UNLOCKED</span>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
<header class="h-[80px] px-margin-edge flex flex-col justify-center border-b border-outline-variant shrink-0">
|
||||
<h1 class="text-[24px] font-bold leading-tight text-suit-black">CHALLENGE MODE</h1>
|
||||
<p class="text-[12px] text-[#a0a0a0] mt-1">Curated puzzles · Beat par for bonus XP</p>
|
||||
</header>
|
||||
<!-- Stats Row -->
|
||||
<div class="mx-margin-edge mt-4 bg-surface-container rounded-[4px] p-3 flex items-center justify-between border border-outline-variant shrink-0">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-[14px] font-bold text-suit-black">DONE 8/24</span>
|
||||
<span class="text-[14px] font-bold text-highlight-celebration">(33%)</span>
|
||||
</div>
|
||||
<span class="text-outline-variant text-[14px]">│</span>
|
||||
<div class="text-[14px] font-bold text-suit-black">BEST AVG 03:42</div>
|
||||
<span class="text-outline-variant text-[14px]">│</span>
|
||||
<div class="text-[14px] font-bold text-highlight-valid">+1240 XP</div>
|
||||
</div>
|
||||
<!-- Scrollable List Area -->
|
||||
<div class="flex-1 overflow-y-auto px-margin-edge pt-4 space-y-3 pb-6">
|
||||
<!-- Card 1 -->
|
||||
<div class="h-[80px] bg-surface-container border border-outline rounded-[4px] flex relative overflow-hidden">
|
||||
<div class="w-[6px] h-full bg-warning"></div>
|
||||
<div class="flex-1 flex items-center justify-between px-4">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[14px] font-bold text-suit-black">DEEP STACK</span>
|
||||
<span class="text-[12px] text-on-surface-variant">Win with 0 stock · ★★★☆☆</span>
|
||||
</div>
|
||||
<div class="bg-highlight-valid px-2 py-1 rounded-[2px] text-background text-[11px] font-bold">
|
||||
✓ DONE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card 2 -->
|
||||
<div class="h-[80px] bg-surface-container border border-primary rounded-[4px] flex relative overflow-hidden">
|
||||
<div class="w-[6px] h-full bg-highlight-valid"></div>
|
||||
<div class="flex-1 flex items-center justify-between px-4">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[14px] font-bold text-suit-black">SPEED RUN</span>
|
||||
<span class="text-[12px] text-on-surface-variant">Win under 2:30 · ★★☆☆☆</span>
|
||||
</div>
|
||||
<div class="bg-primary px-2 py-1 rounded-[2px] text-background text-[11px] font-bold">
|
||||
▶ ACTIVE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card 3 -->
|
||||
<div class="h-[80px] bg-surface-container border border-outline rounded-[4px] flex relative overflow-hidden">
|
||||
<div class="w-[6px] h-full bg-suit-red"></div>
|
||||
<div class="flex-1 flex items-center justify-between px-4">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[14px] font-bold text-suit-black">NO UNDO</span>
|
||||
<span class="text-[12px] text-on-surface-variant">Win without undo · ★★★★☆</span>
|
||||
</div>
|
||||
<div class="bg-primary px-2 py-1 rounded-[2px] text-background text-[11px] font-bold">
|
||||
▶ ACTIVE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card 4 -->
|
||||
<div class="h-[80px] bg-surface-container border border-outline rounded-[4px] flex relative overflow-hidden">
|
||||
<div class="w-[6px] h-full bg-info"></div>
|
||||
<div class="flex-1 flex items-center justify-between px-4">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[14px] font-bold text-suit-black">FOUR SUITS</span>
|
||||
<span class="text-[12px] text-on-surface-variant">1 card per suit · ★☆☆☆☆</span>
|
||||
</div>
|
||||
<div class="bg-highlight-valid px-2 py-1 rounded-[2px] text-background text-[11px] font-bold">
|
||||
✓ DONE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card 5 (Locked) -->
|
||||
<div class="h-[80px] bg-surface-container border border-outline-variant rounded-[4px] flex relative overflow-hidden opacity-60">
|
||||
<div class="w-[6px] h-full bg-highlight-celebration"></div>
|
||||
<div class="flex-1 flex items-center justify-between px-4">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[14px] font-bold text-suit-black">PERFECT RUN</span>
|
||||
<span class="text-[12px] text-on-surface-variant">Below par moves · ★★★★★</span>
|
||||
</div>
|
||||
<div class="bg-outline px-2 py-1 rounded-[2px] text-on-surface text-[11px] font-bold">
|
||||
🔒 LOCKED
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filler Graphic for retro feel -->
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<div class="h-[1px] flex-1 bg-outline-variant"></div>
|
||||
<span class="px-4 text-[10px] text-outline text-label-caps">END OF LIST</span>
|
||||
<div class="h-[1px] flex-1 bg-outline-variant"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Shared Component: Terminal Context (Used as Footer) -->
|
||||
<div class="h-[24px] bg-surface px-4 flex items-center justify-between text-[10px] font-mono border-t border-outline-variant shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-primary">▌ NORMAL</span>
|
||||
<span class="text-outline">│</span>
|
||||
<span class="text-on-surface-variant uppercase tracking-widest">challenge</span>
|
||||
</div>
|
||||
<div class="text-[#a0a0a0] flex items-center gap-3">
|
||||
<span>[ENTER] select</span>
|
||||
<span>[F] filter</span>
|
||||
<span class="text-suit-red">[ESC] back</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Retro Scanline Overlay -->
|
||||
<div class="absolute inset-0 retro-scanline z-50"></div>
|
||||
</div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 67 KiB |
@@ -0,0 +1,258 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=390, height=844, initial-scale=1.0" name="viewport"/>
|
||||
<title>Rusty Solitaire - Daily Challenge</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Inter:wght@400;500;600&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
body {
|
||||
background-color: #101417;
|
||||
color: #e0e3e6;
|
||||
font-family: 'Inter', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.scanline-bg {
|
||||
background: linear-gradient(to bottom, transparent 50%, rgba(26, 26, 26, 0.5) 50%);
|
||||
background-size: 100% 4px;
|
||||
}
|
||||
</style>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"on-error": "#690005",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"tertiary": "#f7c3ff",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"primary-container": "#6fc2ef",
|
||||
"surface-dim": "#101417",
|
||||
"surface-variant": "#313538",
|
||||
"on-error-container": "#ffdad6",
|
||||
"warning": "#ddb26f",
|
||||
"on-surface": "#e0e3e6",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"error-container": "#93000a",
|
||||
"on-tertiary": "#4c195b",
|
||||
"info": "#12cfc0",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"primary": "#a1dcff",
|
||||
"on-primary": "#003549",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"highlight-valid": "#acc267",
|
||||
"surface-container-low": "#181c1f",
|
||||
"surface-container": "#1c2023",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"secondary-container": "#435401",
|
||||
"error": "#fb9fb1",
|
||||
"surface": "#151515",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"outline": "#505050",
|
||||
"surface-container-highest": "#313538",
|
||||
"on-secondary": "#293500",
|
||||
"on-primary-container": "#004f6c",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"background": "#101417",
|
||||
"surface-container-high": "#272a2d",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"suit-red": "#fb9fb1",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"outline-variant": "#3f484e",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"inverse-primary": "#00668a",
|
||||
"surface-bright": "#363a3d",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"on-background": "#e0e3e6",
|
||||
"on-tertiary-container": "#683476",
|
||||
"suit-black": "#d0d0d0",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"secondary": "#bad073",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"highlight-celebration": "#e1a3ee"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"gutter-card": "0.375rem",
|
||||
"stack-overlap": "2rem",
|
||||
"margin-edge": "1rem",
|
||||
"action-bar-height": "64px",
|
||||
"touch-target-min": "48dp"
|
||||
},
|
||||
"fontFamily": {
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"headline": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex flex-col min-h-screen max-w-[390px] mx-auto overflow-hidden shadow-2xl border-x border-outline">
|
||||
<!-- 1. Status Bar -->
|
||||
<div class="h-[32px] bg-[#202020] flex items-center justify-between px-margin-edge border-b border-outline">
|
||||
<span class="font-hud-timer text-[12px] text-on-surface-variant">▌daily/2024-127.json</span>
|
||||
<div class="bg-warning/10 border border-warning px-2 py-0.5 rounded-sm">
|
||||
<span class="font-hud-timer text-[11px] text-warning font-bold tracking-tighter">EXPIRES 11:42:30</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Main Content Canvas -->
|
||||
<main class="flex-1 p-margin-edge space-y-4 overflow-y-auto pb-8">
|
||||
<!-- 2. Header Card -->
|
||||
<section class="h-[130px] bg-[#1a1a1a] border border-[#353535] rounded-lg p-4 flex flex-col justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-headline font-bold text-[24px] text-suit-black leading-none">MAY 07 · 2026</span>
|
||||
<span class="font-headline font-extrabold text-[32px] text-highlight-valid -tracking-[0.01em] leading-tight">#2024-127</span>
|
||||
</div>
|
||||
<span class="font-label-caps text-[11px] text-on-surface-variant/70">DRAW-3 · DIFFICULTY ★★★☆☆ · PAR 04:30</span>
|
||||
</section>
|
||||
<!-- 3. Primary CTA -->
|
||||
<button class="w-full h-[64px] bg-primary-container text-surface font-headline font-bold text-[14px] uppercase tracking-wider rounded-lg active:scale-95 transition-transform duration-80 flex items-center justify-center gap-2">
|
||||
<span class="material-symbols-outlined text-[18px]">play_arrow</span>
|
||||
ATTEMPT TODAY'S SEED
|
||||
</button>
|
||||
<!-- 4. Your Attempts Card -->
|
||||
<section class="h-[96px] bg-[#202020] rounded-lg p-4 flex flex-col justify-between">
|
||||
<span class="font-label-caps text-[11px] text-on-surface-variant/60 uppercase">YOUR ATTEMPTS</span>
|
||||
<div class="flex justify-between items-end">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-hud-score text-[16px] text-suit-black">BEST 04:12</span>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="bg-warning text-surface text-[10px] font-bold px-1.5 py-0.5 rounded-sm">WIN</span>
|
||||
<span class="font-label-caps text-[11px] text-warning">RANK 17/2843</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-hud-timer text-[13px] text-error mb-1">LAST: FAILED at move 47</span>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 5. Leaderboard Card -->
|
||||
<section class="bg-[#202020] rounded-lg p-4 flex flex-col flex-grow">
|
||||
<span class="font-label-caps text-[11px] text-on-surface-variant/60 uppercase mb-4">TOP TODAY · 2,843 PLAYERS</span>
|
||||
<div class="space-y-0 divide-y divide-[#353535]">
|
||||
<!-- Row 1 -->
|
||||
<div class="h-[32px] flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 flex items-center justify-center bg-warning text-surface text-[10px] font-bold rounded-full">01</span>
|
||||
<span class="font-hud-timer text-[14px]">swift_jaguar</span>
|
||||
</div>
|
||||
<span class="font-hud-timer text-[14px] text-on-surface-variant">02:47</span>
|
||||
</div>
|
||||
<!-- Row 2 -->
|
||||
<div class="h-[32px] flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 flex items-center justify-center bg-[#a0a0a0] text-surface text-[10px] font-bold rounded-full">02</span>
|
||||
<span class="font-hud-timer text-[14px]">base16_fan</span>
|
||||
</div>
|
||||
<span class="font-hud-timer text-[14px] text-on-surface-variant">03:12</span>
|
||||
</div>
|
||||
<!-- Row 3 -->
|
||||
<div class="h-[32px] flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 flex items-center justify-center bg-[#7a5d3b] text-surface text-[10px] font-bold rounded-full">03</span>
|
||||
<span class="font-hud-timer text-[14px]">cli_player</span>
|
||||
</div>
|
||||
<span class="font-hud-timer text-[14px] text-on-surface-variant">03:54</span>
|
||||
</div>
|
||||
<!-- Row 4 -->
|
||||
<div class="h-[32px] flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 flex items-center justify-center bg-[#353535] text-on-surface-variant text-[10px] font-bold rounded-full">04</span>
|
||||
<span class="font-hud-timer text-[14px]">tablejockey</span>
|
||||
</div>
|
||||
<span class="font-hud-timer text-[14px] text-on-surface-variant">04:01</span>
|
||||
</div>
|
||||
<!-- Row 5 -->
|
||||
<div class="h-[32px] flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 flex items-center justify-center bg-[#353535] text-on-surface-variant text-[10px] font-bold rounded-full">05</span>
|
||||
<span class="font-hud-timer text-[14px]">vim_motions</span>
|
||||
</div>
|
||||
<span class="font-hud-timer text-[14px] text-on-surface-variant">04:05</span>
|
||||
</div>
|
||||
<!-- Row 17 (YOU) -->
|
||||
<div class="h-[36px] flex items-center justify-between bg-primary-container/10 -mx-4 px-4 border-y border-primary-container/20">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 flex items-center justify-center bg-primary-container text-surface text-[10px] font-bold rounded-full">17</span>
|
||||
<span class="font-hud-timer text-[14px] text-primary-container font-bold">(YOU) anonymous</span>
|
||||
</div>
|
||||
<span class="font-hud-timer text-[14px] text-primary-container font-bold">04:12</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex-1 border-t border-[#353535] pt-4 flex flex-col items-center justify-center opacity-30 select-none">
|
||||
<span class="material-symbols-outlined text-[48px]">terminal</span>
|
||||
<span class="font-label-caps text-[10px] mt-2">END OF VISIBLE LOG</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- 6. Footer Navigation -->
|
||||
<footer class="h-[24px] bg-background border-t border-outline flex items-center justify-between px-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-label-caps text-[10px] text-on-surface-variant">▌ NORMAL │ daily</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-label-caps text-[9px] text-on-surface-variant/70"><span class="text-primary-container">[ENTER]</span> attempt</span>
|
||||
<span class="font-label-caps text-[9px] text-on-surface-variant/70"><span class="text-primary-container">[L]</span> full leaderboard</span>
|
||||
<span class="font-label-caps text-[9px] text-on-surface-variant/70"><span class="text-primary-container">[ESC]</span> back</span>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- Shared Component Shell Rendering Logic -->
|
||||
<header class="w-full top-0 sticky bg-background border-b border-outline flex justify-between items-center px-margin-edge h-action-bar-height hidden">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-primary">terminal</span>
|
||||
<h1 class="font-headline text-headline text-primary uppercase tracking-widest">RUSTY SOLITAIRE</h1>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-on-surface-variant hover:text-primary transition-colors duration-120 cursor-pointer">settings</span>
|
||||
</header>
|
||||
<nav class="fixed bottom-0 w-full h-action-bar-height z-50 bg-surface-container border-t border-outline flex justify-around items-center px-2 hidden">
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant hover:bg-surface-container-highest transition-colors duration-120 cursor-pointer w-full h-full">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
<span class="font-label-caps text-label-caps">DEAL</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant hover:bg-surface-container-highest transition-colors duration-120 cursor-pointer w-full h-full">
|
||||
<span class="material-symbols-outlined">undo</span>
|
||||
<span class="font-label-caps text-label-caps">UNDO</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant hover:bg-surface-container-highest transition-colors duration-120 cursor-pointer w-full h-full">
|
||||
<span class="material-symbols-outlined">lightbulb</span>
|
||||
<span class="font-label-caps text-label-caps">HINT</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-primary dark:text-primary-fixed-dim hover:bg-surface-container-highest transition-colors duration-120 cursor-pointer w-full h-full">
|
||||
<span class="material-symbols-outlined">menu</span>
|
||||
<span class="font-label-caps text-label-caps">MENU</span>
|
||||
</div>
|
||||
</nav>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,278 @@
|
||||
---
|
||||
name: Terminal
|
||||
colors:
|
||||
surface: '#151515'
|
||||
surface-dim: '#0d0d0d'
|
||||
surface-bright: '#2a2a2a'
|
||||
surface-container-lowest: '#0a0a0a'
|
||||
surface-container-low: '#1a1a1a'
|
||||
surface-container: '#202020'
|
||||
surface-container-high: '#2a2a2a'
|
||||
surface-container-highest: '#353535'
|
||||
on-surface: '#d0d0d0'
|
||||
on-surface-variant: '#a0a0a0'
|
||||
inverse-surface: '#d0d0d0'
|
||||
inverse-on-surface: '#151515'
|
||||
outline: '#505050'
|
||||
outline-variant: '#353535'
|
||||
surface-tint: '#6fc2ef'
|
||||
primary: '#6fc2ef'
|
||||
on-primary: '#151515'
|
||||
primary-container: '#1f3a4a'
|
||||
on-primary-container: '#a8dcf5'
|
||||
inverse-primary: '#0e6e99'
|
||||
secondary: '#acc267'
|
||||
on-secondary: '#151515'
|
||||
secondary-container: '#2a3320'
|
||||
on-secondary-container: '#c5d585'
|
||||
tertiary: '#e1a3ee'
|
||||
on-tertiary: '#151515'
|
||||
tertiary-container: '#3a2a40'
|
||||
on-tertiary-container: '#eec3f5'
|
||||
error: '#fb9fb1'
|
||||
on-error: '#151515'
|
||||
error-container: '#4a2530'
|
||||
on-error-container: '#fdc3ce'
|
||||
background: '#151515'
|
||||
on-background: '#d0d0d0'
|
||||
surface-variant: '#353535'
|
||||
suit-red: '#fb9fb1'
|
||||
suit-black: '#d0d0d0'
|
||||
suit-red-cb: '#6fc2ef'
|
||||
highlight-valid: '#acc267'
|
||||
highlight-celebration: '#e1a3ee'
|
||||
highlight-warning: '#ddb26f'
|
||||
highlight-info: '#12cfc0'
|
||||
typography:
|
||||
hud-score:
|
||||
fontFamily: JetBrains Mono
|
||||
fontSize: 24px
|
||||
fontWeight: '700'
|
||||
lineHeight: 32px
|
||||
letterSpacing: '-0.02em'
|
||||
hud-timer:
|
||||
fontFamily: JetBrains Mono
|
||||
fontSize: 16px
|
||||
fontWeight: '400'
|
||||
lineHeight: 24px
|
||||
card-rank:
|
||||
fontFamily: JetBrains Mono
|
||||
fontSize: 18px
|
||||
fontWeight: '700'
|
||||
lineHeight: 18px
|
||||
body-md:
|
||||
fontFamily: Inter
|
||||
fontSize: 16px
|
||||
fontWeight: '400'
|
||||
lineHeight: 24px
|
||||
label-caps:
|
||||
fontFamily: JetBrains Mono
|
||||
fontSize: 12px
|
||||
fontWeight: '500'
|
||||
lineHeight: 16px
|
||||
letterSpacing: '0.08em'
|
||||
headline:
|
||||
fontFamily: JetBrains Mono
|
||||
fontSize: 28px
|
||||
fontWeight: '700'
|
||||
lineHeight: 32px
|
||||
letterSpacing: '-0.01em'
|
||||
rounded:
|
||||
sm: 0.125rem
|
||||
DEFAULT: 0.25rem
|
||||
md: 0.5rem
|
||||
lg: 0.75rem
|
||||
xl: 1rem
|
||||
full: 9999px
|
||||
spacing:
|
||||
margin-edge: 1rem
|
||||
gutter-card: 0.375rem
|
||||
stack-overlap: 2rem
|
||||
touch-target-min: 48dp
|
||||
---
|
||||
|
||||
## Brand & Style
|
||||
|
||||
The "Terminal" design system replaces the previous "Premium Solitaire" calm-indie aesthetic with a **retro-terminal / synthwave** identity. The intent is the visual confidence of a well-tuned terminal emulator (think Berkeley Mono dotfiles, base16-eighties, CRT phosphor): monospaced, dense, legible, snappy. It is *not* casino-glitz, *not* skeuomorphic felt, and *not* whimsical.
|
||||
|
||||
The personality is **technical, deliberate, slightly playful**. Cards are flat with thin colored strokes; the HUD reads like a status bar; modals look like terminal panes. Motion is short and snap-easing — no bouncy springs. Long-session calm is preserved by keeping the chroma low and reserving saturated accents for *meaning* (CTAs, feedback, celebrations) rather than decoration.
|
||||
|
||||
Influences: base16-eighties (Chris Kempson), Berkeley Mono, Vim/Neovim status lines, the iA Writer aesthetic, classic CRT phosphor with no chromatic aberration.
|
||||
|
||||
## Palette
|
||||
|
||||
The palette is base16-eighties — a 16-slot terminal palette where indices 00–07 form a monochrome ramp and 08–0F provide saturated accents. We map base16 slots to Material Design 3 token roles below.
|
||||
|
||||
### Source palette (base16-eighties)
|
||||
|
||||
| Slot | Hex | Role |
|
||||
|---|---|---|
|
||||
| base00 | `#151515` | background |
|
||||
| base01 | `#202020` | surface-container |
|
||||
| base02 | `#303030` | line-highlight (subtle) |
|
||||
| base03 | `#505050` | outline / muted text |
|
||||
| base04 | `#b0b0b0` | secondary text |
|
||||
| base05 | `#d0d0d0` | foreground / on-surface |
|
||||
| base06 | `#e0e0e0` | bright text |
|
||||
| base07 | `#f5f5f5` | brightest highlight |
|
||||
| base08 | `#fb9fb1` | red — used for `error`, `suit-red` |
|
||||
| base09 | `#ddb26f` | orange — used for warning chips |
|
||||
| base0A | `#acc267` | yellow/lime — used for `highlight-valid` (drag targets, valid moves) |
|
||||
| base0B | `#12cfc0` | green/teal — used for `highlight-info` (toasts, neutral status) |
|
||||
| base0C | `#6fc2ef` | cyan/sky — primary CTA, focus ring, `selection`, `suit-red-cb` (color-blind tinted red) |
|
||||
| base0D | `#6fc2ef` | (alias) |
|
||||
| base0E | `#e1a3ee` | violet — used for celebration (level-up, achievement unlock) |
|
||||
| base0F | `#fb9fb1` | (alias) |
|
||||
|
||||
### Semantic assignments
|
||||
|
||||
- **CTA / Primary action**: cyan `#6fc2ef`. Reserved for "Play," "New Game," "Save," "Resume," and the focus ring on selected cards. Never used decoratively.
|
||||
- **Valid-move / drag-target highlight**: lime `#acc267`. Reserved for in-game feedback only. Never appears in chrome.
|
||||
- **Celebration**: lavender `#e1a3ee`. Used for level-up flashes, achievement unlock cards, and the daily-streak chip when the streak is active. Quiet otherwise.
|
||||
- **Warning / soft alert**: gold `#ddb26f`. Used for "challenge expires in N minutes" chips, sync-pending status, and the daily-seed countdown.
|
||||
- **Info**: teal `#12cfc0`. Used for neutral system toasts and the sync-connected indicator.
|
||||
- **Error**: pink `#fb9fb1`. Used for sync conflict, server unreachable, invalid move shake.
|
||||
|
||||
## Suit Colors
|
||||
|
||||
**Two-color traditional mapping**, with mandatory color-blind support:
|
||||
|
||||
| Suit | Default | Color-blind mode | Glyph differentiation |
|
||||
|---|---|---|---|
|
||||
| Hearts | `#fb9fb1` (pink) | `#6fc2ef` (cyan) | Solid filled glyph |
|
||||
| Diamonds | `#fb9fb1` (pink) | `#6fc2ef` (cyan) | **Outlined glyph (1.5px stroke)** |
|
||||
| Spades | `#d0d0d0` (foreground) | `#d0d0d0` | Solid filled glyph |
|
||||
| Clubs | `#d0d0d0` (foreground) | `#d0d0d0` | **Outlined glyph (1.5px stroke)** |
|
||||
|
||||
The outlined-glyph treatment is the **primary** differentiation mechanism. Color is supplementary. This means a player viewing the game on a monochrome display, or with severe red-green deficiency, can still distinguish all four suits without context. This is a hard requirement, not an optional setting.
|
||||
|
||||
The "color-blind mode" toggle in Settings only swaps red→cyan; it does not turn the outlined glyphs on or off, because outlined glyphs are always on.
|
||||
|
||||
## Typography
|
||||
|
||||
**Monospace-forward, dual-font system.**
|
||||
|
||||
- **JetBrains Mono** is used for: HUD (score, timer, moves), card rank/value text, all labels, all headlines, all numerals anywhere in the app, and any chip-style component. This is the dominant face.
|
||||
- **Inter** is used only for: long-form body copy (Help screen, Settings descriptions, achievement tooltips, onboarding copy). It is the *exception*, not the default.
|
||||
|
||||
Weights: 400 regular, 500 medium for labels, 700 bold for HUD numbers and headlines. No 600 / no italics anywhere — the terminal aesthetic doesn't have them.
|
||||
|
||||
Letter spacing: tight (`-0.02em`) on HUD score for visual mass; wide (`+0.08em`) on uppercase labels for readability at 12px. Body uses default (0).
|
||||
|
||||
HUD numbers must use **tabular figures** (`font-feature-settings: 'tnum'`) so the timer and score don't reflow as digits change.
|
||||
|
||||
## Layout & Spacing
|
||||
|
||||
Optimized for **Android portrait, 390×844 (Pixel 6 baseline), API 34**.
|
||||
|
||||
- **Margins**: 16px (1rem) edge safety margin. *Tighter than the previous system's 24px.* Eighties palettes are dense by nature; over-padding fights the aesthetic.
|
||||
- **Tableau**: 7-column layout, 32px (2rem) vertical card overlap. Tighter than before to fit a longer cascade on phone screens.
|
||||
- **HUD position**: top of screen, in the system safe area. Bottom 64px holds the action bar (Undo / Hint / New Game / Auto-complete). Action bar is **always visible** in-game — no hover-fade — because there is no hover on touch.
|
||||
- **Touch target minimum**: 48dp on all interactive elements. Cards in the tableau may be smaller visually but use a 48dp invisible hit area centered on the visible glyph.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
Depth is created through **tonal layering and 1px outlines**, not blur shadows. (Synthwave-flat, not Material-soft.)
|
||||
|
||||
- **Level 0 (Background)**: the `#151515` base canvas.
|
||||
- **Level 1 (Tableau slots, empty piles)**: 1px dashed outline in `#353535`. Empty foundations show a faint suit glyph at 12% opacity inside the outline.
|
||||
- **Level 2 (Cards at rest)**: solid `#1a1a1a` fill, 1px solid border in the suit color (so the suit is detectable at a glance even if the card is partially obscured).
|
||||
- **Level 3 (Active / dragged card)**: same border, but glow effect: 0 0 12px of `#6fc2ef` at 40% opacity. **No scale transform** — flatness preserved. Z-index lifts above siblings.
|
||||
- **Modals**: full-screen with backdrop `#151515` at 95% opacity (just enough to dim the table without blurring it). Modal panel is `#202020` with a 1px `#505050` border — like a terminal pane.
|
||||
- **Toasts**: bottom of screen, `#202020` fill, 1px border in the toast's accent color (info=teal, warning=gold, error=pink, celebration=lavender). 16px monospaced caption.
|
||||
|
||||
No `box-shadow` is used anywhere. **All depth is achieved with borders and tonal value.** This is a hard constraint.
|
||||
|
||||
## Shapes
|
||||
|
||||
The shape language is **soft-rounded but tight**:
|
||||
|
||||
- **Cards**: `rounded-md` (8px) — slightly less rounded than the previous system's 16px to read more "technical."
|
||||
- **Buttons / chips / inputs**: `rounded` (4px) default, `rounded-sm` (2px) for the smallest chips.
|
||||
- **Modals / sheets**: `rounded-lg` (12px).
|
||||
- **Avatars / circular indicators**: `rounded-full`.
|
||||
- **Card-back pattern corners**: matches the card's `rounded-md`.
|
||||
|
||||
Selection highlights use a **2px inset stroke** in `#6fc2ef` following the host shape's corner radius. Never an outer stroke — the outer stroke is reserved for the suit-color hairline.
|
||||
|
||||
## Motion
|
||||
|
||||
**Snappy, no spring.** All transitions use `ease-out` with a 120ms duration unless specified.
|
||||
|
||||
- Card lift (start drag): 80ms.
|
||||
- Card place (drop): 120ms with a 16ms holdframe (no bounce).
|
||||
- Modal enter: 200ms ease-out, fade + 8px translate-up.
|
||||
- Modal exit: 120ms ease-in, fade only.
|
||||
- Selection ring appear: 80ms.
|
||||
- Win-summary stat reveal: 60ms each, staggered 40ms.
|
||||
- HUD number tick: instant (no transition) — terminal counters don't ease.
|
||||
|
||||
**Optional CRT effect**: a 1-frame scanline sweep across the screen on game-state transitions (start, win, restart). User-toggleable in Settings. Off by default.
|
||||
|
||||
## Components
|
||||
|
||||
### Game Cards
|
||||
|
||||
Flat face design.
|
||||
- Background: `#1a1a1a`
|
||||
- Border: 1px solid in suit color (pink for hearts/diamonds, foreground gray for spades/clubs)
|
||||
- Top-left: rank in JetBrains Mono Bold 18px + small suit glyph (10px)
|
||||
- Bottom-right: large suit glyph (32px), rotated 180°
|
||||
- Corner radius: 8px
|
||||
- Suit differentiation: hearts and spades have **filled** glyphs; diamonds and clubs have **outlined** glyphs (1.5px stroke)
|
||||
|
||||
### Card Back ("Terminal" theme)
|
||||
|
||||
- Theme name: `"Terminal"`
|
||||
- Author: `"Rusty Solitaire"`
|
||||
- Background: `#151515`
|
||||
- Pattern: horizontal scanlines at 2px pitch in `#1a1a1a` (1px line, 1px gap), full bleed
|
||||
- Border: 1px solid `#353535`
|
||||
- Top-left badge: a 12×16px solid `#6fc2ef` block (the "terminal cursor"), 6px from the corner
|
||||
- Bottom-right monogram: the characters `▌RS` in JetBrains Mono 12px, color `#505050`, 6px from the corner
|
||||
- Corner radius: 8px (matches face)
|
||||
|
||||
### Primary Buttons
|
||||
|
||||
Solid `#6fc2ef` fill, `#151515` text, JetBrains Mono Medium 14px uppercase with `+0.08em` tracking. 4px corner radius. Pressed state: darken to `#5aa9d4`. Disabled: `#353535` fill, `#505050` text.
|
||||
|
||||
### Secondary Buttons
|
||||
|
||||
Transparent fill, 1px `#505050` border, `#d0d0d0` text. Hover/press: border becomes `#6fc2ef`, text becomes `#6fc2ef`.
|
||||
|
||||
### HUD Chips
|
||||
|
||||
`#202020` fill, no border, 4px radius. Monospaced 16px text. Score chip pulses to `#acc267` for 200ms when score increases.
|
||||
|
||||
### Drag Targets
|
||||
|
||||
When a card is being dragged over a valid pile, the pile's empty-slot dashed outline becomes:
|
||||
- Solid 1px in `#acc267`
|
||||
- Plus a 0 0 8px outer glow in `#acc267` at 30% opacity
|
||||
|
||||
This is the *only* place glow effects appear in the system.
|
||||
|
||||
### Modals
|
||||
|
||||
Full-screen backdrop at 95% opacity. Centered panel: `#202020` fill, 1px `#505050` border, 12px corner radius. Title bar shows the screen name in monospaced 14px, color `#a0a0a0`, with a single `▌` cursor character prefix to reinforce the terminal pane motif.
|
||||
|
||||
### Navigation Bar
|
||||
|
||||
Fixed at the bottom of in-game screens. Height: 64px. `#202020` fill, 1px top border in `#353535`. Four icon buttons: Undo / Hint / New / Auto-complete. Icons: 24px, 1.5px stroke weight, color `#d0d0d0`. Active/pressed: icon color `#6fc2ef`.
|
||||
|
||||
### Status / Sync Indicator
|
||||
|
||||
Top-right corner of the HUD: a 6px circular dot.
|
||||
- Connected & synced: `#12cfc0`
|
||||
- Pending: `#ddb26f` (pulsing 1.5s)
|
||||
- Error: `#fb9fb1` (steady)
|
||||
- Offline: `#505050`
|
||||
|
||||
## Accessibility
|
||||
|
||||
1. **Color-blind mode** (Settings → Gameplay): swaps red suits' default `#fb9fb1` for `#6fc2ef`. Outlined-glyph differentiation remains active in *all* modes.
|
||||
2. **High-contrast mode** (Settings → Gameplay): boosts on-surface from `#d0d0d0` to `#f5f5f5`, outline from `#505050` to `#a0a0a0`, suit-red from `#fb9fb1` to `#ff8aa0`.
|
||||
3. **Reduce-motion mode** (Settings → Gameplay): disables card-lift transition (instant z-lift), disables CRT scanline effect, disables the warning-chip pulse animation.
|
||||
4. **Tabular figures** are mandatory for any number that updates live (timer, score, moves) so they don't reflow.
|
||||
5. **Touch targets** are 48dp minimum even when the visual element is smaller.
|
||||
6. **Text contrast**: all body text on background passes WCAG AA at minimum (`#d0d0d0` on `#151515` = 9.5:1; `#a0a0a0` on `#151515` = 5.7:1).
|
||||
@@ -0,0 +1,253 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.outlined-glyph {
|
||||
-webkit-text-stroke: 1.5px currentColor;
|
||||
color: transparent;
|
||||
}
|
||||
.scanline-pattern {
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
#1a1a1a,
|
||||
#1a1a1a 2px,
|
||||
#151515 2px,
|
||||
#151515 4px
|
||||
);
|
||||
}
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"surface": "#151515",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"warning": "#ddb26f",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"on-background": "#e0e3e6",
|
||||
"on-primary-container": "#004f6c",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"on-surface": "#e0e3e6",
|
||||
"error": "#fb9fb1",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"inverse-primary": "#00668a",
|
||||
"surface-container-high": "#272a2d",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"surface-bright": "#363a3d",
|
||||
"on-primary": "#003549",
|
||||
"on-tertiary": "#4c195b",
|
||||
"error-container": "#93000a",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"surface-container": "#202020",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"surface-container-highest": "#313538",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"highlight-valid": "#acc267",
|
||||
"primary": "#a1dcff",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"on-error-container": "#ffdad6",
|
||||
"secondary": "#bad073",
|
||||
"on-tertiary": "#293500",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"on-error": "#690005",
|
||||
"info": "#12cfc0",
|
||||
"suit-red": "#fb9fb1",
|
||||
"surface-dim": "#101417",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"background": "#101417",
|
||||
"secondary-container": "#435401",
|
||||
"surface-variant": "#313538",
|
||||
"outline-variant": "#3f484e",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"suit-black": "#d0d0d0"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"touch-target-min": "48px",
|
||||
"margin-edge": "1rem",
|
||||
"action-bar-height": "64px",
|
||||
"stack-overlap": "2rem",
|
||||
"gutter-card": "0.375rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface text-on-surface font-body-md overflow-hidden selection:bg-primary selection:text-surface">
|
||||
<!-- TopAppBar -->
|
||||
<header class="fixed top-0 w-full flex justify-between items-center px-margin-edge h-[56px] bg-surface-container border-b border-outline dark:border-outline z-50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-primary">terminal</span>
|
||||
<h1 class="font-hud-score text-[18px] text-primary">solitaire.sh</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-[6px] h-[6px] rounded-full bg-info"></div>
|
||||
<span class="material-symbols-outlined text-on-surface-variant">settings</span>
|
||||
</div>
|
||||
</header>
|
||||
<!-- HUD Band -->
|
||||
<div class="fixed top-[56px] left-0 w-full h-[56px] bg-surface-container border-b border-outline-variant flex items-center justify-around px-margin-edge z-40">
|
||||
<div class="bg-surface p-1 px-3 rounded flex flex-col items-center">
|
||||
<span class="font-label-caps text-[10px] text-on-surface-variant">SCORE</span>
|
||||
<span class="font-hud-score text-primary tabular-nums">247</span>
|
||||
</div>
|
||||
<div class="bg-surface p-1 px-3 rounded flex flex-col items-center border border-outline">
|
||||
<span class="font-label-caps text-[10px] text-on-surface-variant">TIME</span>
|
||||
<span class="font-hud-timer text-on-surface tabular-nums">12:34</span>
|
||||
</div>
|
||||
<div class="bg-surface p-1 px-3 rounded flex flex-col items-center">
|
||||
<span class="font-label-caps text-[10px] text-on-surface-variant">MOVES</span>
|
||||
<span class="font-hud-score text-secondary tabular-nums">87</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Main Game Table -->
|
||||
<main class="pt-[124px] px-margin-edge h-screen w-full relative">
|
||||
<!-- Top Row: Stock, Waste, Foundations -->
|
||||
<div class="grid grid-cols-7 gap-gutter-card h-[110px]">
|
||||
<!-- Stock -->
|
||||
<div class="relative w-full h-full rounded-xl border border-outline-variant bg-surface overflow-hidden scanline-pattern">
|
||||
<div class="absolute top-1 left-1 w-3 h-4 bg-suit-red-cb"></div>
|
||||
<div class="absolute bottom-1 right-1 font-label-caps text-[8px] text-suit-black">▌RS</div>
|
||||
<div class="absolute bottom-[-16px] left-0 w-full text-center font-label-caps text-[10px] text-on-surface-variant">STOCK · 18</div>
|
||||
</div>
|
||||
<!-- Waste -->
|
||||
<div class="relative w-full h-full rounded-xl border border-suit-red bg-[#1a1a1a] flex flex-col justify-between p-1.5">
|
||||
<div class="font-card-rank text-suit-red leading-none">10<br/><span class="font-normal">♥</span></div>
|
||||
<div class="self-end text-[32px] font-card-rank text-suit-red rotate-180">♥</div>
|
||||
</div>
|
||||
<!-- Empty Gap -->
|
||||
<div></div>
|
||||
<!-- Foundation S -->
|
||||
<div class="relative w-full h-full rounded-xl border border-dashed border-outline-variant flex items-center justify-center">
|
||||
<span class="text-on-surface-variant opacity-20 text-[32px] font-card-rank">♠</span>
|
||||
</div>
|
||||
<!-- Foundation H -->
|
||||
<div class="relative w-full h-full rounded-xl border border-suit-red bg-[#1a1a1a] flex flex-col justify-between p-1.5">
|
||||
<div class="font-card-rank text-suit-red leading-none">2<br/><span class="font-normal">♥</span></div>
|
||||
<div class="self-end text-[32px] font-card-rank text-suit-red rotate-180">♥</div>
|
||||
</div>
|
||||
<!-- Foundation C -->
|
||||
<div class="relative w-full h-full rounded-xl border border-dashed border-outline-variant flex items-center justify-center">
|
||||
<span class="text-on-surface-variant opacity-20 text-[32px] font-card-rank outlined-glyph">♣</span>
|
||||
</div>
|
||||
<!-- Foundation D -->
|
||||
<div class="relative w-full h-full rounded-xl border border-dashed border-outline-variant flex items-center justify-center">
|
||||
<span class="text-on-surface-variant opacity-20 text-[32px] font-card-rank outlined-glyph">♦</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tableau -->
|
||||
<div class="mt-8 grid grid-cols-7 gap-gutter-card items-start relative h-[400px]">
|
||||
<!-- Col 1 -->
|
||||
<div class="relative w-full h-full">
|
||||
<div class="w-full h-[96px] rounded-xl border border-suit-black bg-[#1a1a1a] p-1.5">
|
||||
<div class="font-card-rank text-suit-black leading-none">K<br/><span class="font-normal">♠</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Col 2 -->
|
||||
<div class="relative w-full h-full">
|
||||
<div class="w-full h-[96px] rounded-xl border border-outline bg-[#1a1a1a] scanline-pattern"></div>
|
||||
<div class="absolute top-[32px] w-full h-[96px] rounded-xl border border-suit-red bg-[#1a1a1a] p-1.5">
|
||||
<div class="font-card-rank text-suit-red leading-none">Q<br/><span class="font-normal">♥</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Col 3 -->
|
||||
<div class="relative w-full h-full">
|
||||
<div class="w-full h-[96px] rounded-xl border border-outline bg-[#1a1a1a] scanline-pattern"></div>
|
||||
<div class="absolute top-[32px] w-full h-[96px] rounded-xl border border-outline bg-[#1a1a1a] scanline-pattern"></div>
|
||||
<div class="absolute top-[64px] w-full h-[96px] rounded-xl border border-suit-red bg-[#1a1a1a] p-1.5">
|
||||
<div class="font-card-rank text-suit-red leading-none">10<br/><span class="font-normal outlined-glyph">♦</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Col 4 -->
|
||||
<div class="relative w-full h-full">
|
||||
<div class="w-full h-[96px] rounded-xl border border-outline bg-[#1a1a1a] scanline-pattern"></div>
|
||||
<div class="w-full h-[96px] rounded-xl border border-outline bg-[#1a1a1a] scanline-pattern absolute top-[32px]"></div>
|
||||
<!-- Valid Drop Target Glow -->
|
||||
<div class="absolute top-[64px] w-full h-[96px] rounded-xl border border-suit-black bg-[#1a1a1a] p-1.5 ring-4 ring-highlight-valid/30">
|
||||
<div class="font-card-rank text-suit-black leading-none">9<br/><span class="font-normal">♠</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Col 5, 6 (Empty/Filler) -->
|
||||
<div class="relative w-full"></div>
|
||||
<div class="relative w-full"></div>
|
||||
<!-- Col 7 -->
|
||||
<div class="relative w-full">
|
||||
<!-- Original Position Placeholder -->
|
||||
<div class="w-full h-[96px] rounded-xl border border-dashed border-outline"></div>
|
||||
<!-- Being Dragged Card -->
|
||||
<div class="absolute top-[-20px] left-[30px] w-full h-[96px] rounded-xl border border-suit-red bg-[#1a1a1a] p-1.5 shadow-[0_0_20px_rgba(111,194,239,0.4)] z-50 ring-1 ring-primary/40">
|
||||
<div class="font-card-rank text-suit-red leading-none">4<br/><span class="font-normal outlined-glyph">♦</span></div>
|
||||
<div class="absolute bottom-1 right-1 text-[24px] font-card-rank text-suit-red rotate-180 outlined-glyph">♦</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- BottomNavBar / Action Bar -->
|
||||
<nav class="fixed bottom-0 left-0 w-full h-action-bar-height bg-surface-container border-t border-outline-variant flex justify-around items-center px-margin-edge z-50">
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-info transition-colors duration-120 active:opacity-80">
|
||||
<span class="material-symbols-outlined" data-icon="menu">menu</span>
|
||||
<span class="font-label-caps text-[10px] mt-1">[ESC] MENU</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-info font-bold active:opacity-80">
|
||||
<span class="material-symbols-outlined" data-icon="undo">undo</span>
|
||||
<span class="font-label-caps text-[10px] mt-1">[U] UNDO</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-info transition-colors duration-120 active:opacity-80">
|
||||
<span class="material-symbols-outlined" data-icon="lightbulb">lightbulb</span>
|
||||
<span class="font-label-caps text-[10px] mt-1">[H] HINT</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-info transition-colors duration-120 active:opacity-80">
|
||||
<span class="material-symbols-outlined" data-icon="add_box">add_box</span>
|
||||
<span class="font-label-caps text-[10px] mt-1">[N] NEW</span>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- Drag & CRT Overlay (Visual Decoration) -->
|
||||
<div class="pointer-events-none fixed inset-0 z-[100] opacity-[0.03] scanline-pattern mix-blend-overlay"></div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,200 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"surface-container-low": "#181c1f",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"secondary": "#bad073",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"on-error-container": "#ffdad6",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"secondary-container": "#435401",
|
||||
"background": "#101417",
|
||||
"surface-variant": "#313538",
|
||||
"on-primary-container": "#004f6c",
|
||||
"highlight-valid": "#acc267",
|
||||
"outline-variant": "#3f484e",
|
||||
"on-background": "#e0e3e6",
|
||||
"surface-bright": "#363a3d",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"surface-dim": "#101417",
|
||||
"on-surface": "#e0e3e6",
|
||||
"info": "#12cfc0",
|
||||
"on-secondary": "#293500",
|
||||
"suit-red": "#fb9fb1",
|
||||
"error": "#fb9fb1",
|
||||
"error-container": "#93000a",
|
||||
"surface-container": "#202020",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"warning": "#ddb26f",
|
||||
"tertiary": "#f7c3ff",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"primary-container": "#6fc2ef",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"on-tertiary": "#4c195b",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"surface-container-highest": "#313538",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"surface-container-high": "#272a2d",
|
||||
"primary": "#a1dcff",
|
||||
"suit-black": "#d0d0d0",
|
||||
"on-tertiary-container": "#683476",
|
||||
"on-error": "#690005",
|
||||
"inverse-primary": "#00668a",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"outline": "#505050",
|
||||
"on-primary": "#003549",
|
||||
"surface": "#151515"
|
||||
},
|
||||
"fontFamily": {
|
||||
"jetbrains": ["JetBrains Mono", "monospace"],
|
||||
"inter": ["Inter", "sans-serif"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
vertical-align: middle;
|
||||
font-size: 16px;
|
||||
}
|
||||
.tabular-nums { font-variant-numeric: tabular-nums; }
|
||||
body { background-color: #151515; }
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex items-center justify-center min-h-screen p-4">
|
||||
<!-- Mobile Container (390x844) -->
|
||||
<div class="w-[390px] h-[844px] bg-surface flex flex-col overflow-hidden relative border border-outline/20">
|
||||
<!-- 1. Status Bar -->
|
||||
<header class="h-[32px] bg-surface-container flex items-center justify-between px-4 shrink-0">
|
||||
<span class="font-jetbrains text-[12px] font-bold text-suit-black tracking-tight">▌rusty-solitaire(1) · MAN PAGE</span>
|
||||
<button class="font-jetbrains text-[12px] font-bold text-suit-black/60 hover:text-primary transition-colors">× CLOSE</button>
|
||||
</header>
|
||||
<!-- 2. Heading Band -->
|
||||
<div class="h-[120px] px-4 pt-10 pb-4 shrink-0">
|
||||
<h1 class="font-jetbrains font-bold text-[24px] text-suit-black leading-none mb-1">GESTURES & SHORTCUTS</h1>
|
||||
<p class="font-inter text-[13px] text-on-surface-variant/80">Touch gestures and keyboard equivalents.</p>
|
||||
</div>
|
||||
<!-- Scrollable Content Section -->
|
||||
<main class="flex-1 overflow-y-auto px-4 pb-8 space-y-6">
|
||||
<!-- 3a. TOUCH GESTURES -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-jetbrains text-[11px] font-medium tracking-widest text-on-surface-variant/60 uppercase">TOUCH GESTURES</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Row 1 -->
|
||||
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
|
||||
<div class="w-[40%] flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-suit-black" data-icon="square">square</span>
|
||||
<span class="font-jetbrains text-[13px] font-medium text-suit-black uppercase">TAP card</span>
|
||||
</div>
|
||||
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Select / unselect for move</div>
|
||||
</div>
|
||||
<!-- Row 2 -->
|
||||
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
|
||||
<div class="w-[40%] flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-suit-black" data-icon="east">east</span>
|
||||
<span class="font-jetbrains text-[13px] font-medium text-suit-black uppercase">DRAG stack</span>
|
||||
</div>
|
||||
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Move with translucent ghost preview</div>
|
||||
</div>
|
||||
<!-- Row 3 -->
|
||||
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
|
||||
<div class="w-[40%] flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-suit-black" data-icon="double_arrow">double_arrow</span>
|
||||
<span class="font-jetbrains text-[13px] font-medium text-suit-black uppercase">DOUBLE-TAP</span>
|
||||
</div>
|
||||
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Auto-send to best foundation</div>
|
||||
</div>
|
||||
<!-- Row 4 -->
|
||||
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
|
||||
<div class="w-[40%] flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-suit-black" data-icon="touch_app">touch_app</span>
|
||||
<span class="font-jetbrains text-[13px] font-medium text-suit-black uppercase">LONG-PRESS</span>
|
||||
</div>
|
||||
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Highlight all legal moves for card</div>
|
||||
</div>
|
||||
<!-- Row 5 -->
|
||||
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
|
||||
<div class="w-[40%] flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-suit-black" data-icon="south">south</span>
|
||||
<span class="font-jetbrains text-[13px] font-medium text-suit-black uppercase">SWIPE DOWN</span>
|
||||
</div>
|
||||
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Reveal hidden action bar</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 3b. KEYBOARD SHORTCUTS -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-jetbrains text-[11px] font-medium tracking-widest text-on-surface-variant/60 uppercase">KEYBOARD SHORTCUTS</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Row 1 -->
|
||||
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
|
||||
<div class="w-[40%] font-jetbrains text-[13px] font-medium text-suit-black uppercase">[U]</div>
|
||||
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Undo last move</div>
|
||||
</div>
|
||||
<!-- Row 2 -->
|
||||
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
|
||||
<div class="w-[40%] font-jetbrains text-[13px] font-medium text-suit-black uppercase">[H]</div>
|
||||
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Show hint</div>
|
||||
</div>
|
||||
<!-- Row 3 -->
|
||||
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
|
||||
<div class="w-[40%] font-jetbrains text-[13px] font-medium text-suit-black uppercase">[N]</div>
|
||||
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">New game</div>
|
||||
</div>
|
||||
<!-- Row 4 -->
|
||||
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
|
||||
<div class="w-[40%] font-jetbrains text-[13px] font-medium text-suit-black uppercase">[A]</div>
|
||||
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Auto-complete (when possible)</div>
|
||||
</div>
|
||||
<!-- Row 5 -->
|
||||
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
|
||||
<div class="w-[40%] font-jetbrains text-[13px] font-medium text-suit-black uppercase">[ESC]</div>
|
||||
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Pause / back</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- 4. Footer -->
|
||||
<footer class="h-[24px] bg-surface-container border-t border-outline/20 flex items-center justify-between px-2 shrink-0">
|
||||
<div class="font-jetbrains text-[10px] text-suit-black">
|
||||
<span class="opacity-80">▌ NORMAL │ help</span>
|
||||
</div>
|
||||
<div class="font-jetbrains text-[10px] uppercase tracking-wider flex items-center gap-1">
|
||||
<span class="text-outline">PRESS</span>
|
||||
<span class="text-on-surface-variant">[ESC]</span>
|
||||
<span class="text-outline">OR TAP</span>
|
||||
<span class="text-on-surface-variant">×</span>
|
||||
<span class="text-outline">TO RETURN</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,343 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>RS_TERMINAL_OS - Rusty Solitaire</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Inter:wght@400;500&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
font-size: 18px;
|
||||
}
|
||||
body {
|
||||
background-color: #151515;
|
||||
color: #d0d0d0;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scanline {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: rgba(26, 26, 26, 0.5);
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #151515;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #353535;
|
||||
}
|
||||
</style>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"on-tertiary-container": "#683476",
|
||||
"surface-dim": "#101417",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"on-error": "#690005",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"on-tertiary": "#4c195b",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"outline-variant": "#3f484e",
|
||||
"tertiary": "#f7c3ff",
|
||||
"surface": "#151515",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"background": "#101417",
|
||||
"surface-container": "#202020",
|
||||
"primary-container": "#6fc2ef",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"on-surface": "#d0d0d0",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"on-error-container": "#ffdad6",
|
||||
"surface-container-low": "#181c1f",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"on-background": "#e0e3e6",
|
||||
"secondary-container": "#435401",
|
||||
"error": "#fb9fb1",
|
||||
"info": "#12cfc0",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"warning": "#ddb26f",
|
||||
"inverse-primary": "#00668a",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"suit-black": "#d0d0d0",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"on-secondary": "#293500",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"surface-container-highest": "#313538",
|
||||
"error-container": "#93000a",
|
||||
"surface-container-high": "#272a2d",
|
||||
"on-primary-container": "#004f6c",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"on-primary": "#003549",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"highlight-valid": "#acc267",
|
||||
"surface-variant": "#313538",
|
||||
"secondary": "#bad073",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"outline": "#505050",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"primary": "#a1dcff",
|
||||
"surface-bright": "#363a3d",
|
||||
"suit-red": "#fb9fb1"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0px",
|
||||
"lg": "0px",
|
||||
"xl": "0px",
|
||||
"full": "0px"
|
||||
},
|
||||
"spacing": {
|
||||
"stack-overlap": "2rem",
|
||||
"touch-target-min": "48px",
|
||||
"margin-edge": "1rem",
|
||||
"gutter-card": "0.375rem",
|
||||
"action-bar-height": "64px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"label-caps": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-surface text-on-surface h-screen flex flex-col antialiased">
|
||||
<!-- TOP BAR (32px) -->
|
||||
<header class="h-8 bg-surface-container border-b border-outline flex items-center justify-between px-4 z-50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-primary-container font-bold">▌</span>
|
||||
<h1 class="font-headline text-[14px] font-bold tracking-tight text-on-surface">RS_TERMINAL_OS</h1>
|
||||
</div>
|
||||
<nav class="flex gap-4 font-label-caps text-[12px] uppercase tracking-widest">
|
||||
<span class="text-primary-container">[ HOME ]</span>
|
||||
<span class="text-on-surface-variant hover:text-primary transition-colors cursor-pointer">· PLAY</span>
|
||||
<span class="text-on-surface-variant hover:text-primary transition-colors cursor-pointer">· STATS</span>
|
||||
<span class="text-on-surface-variant hover:text-primary transition-colors cursor-pointer">· SETTINGS</span>
|
||||
</nav>
|
||||
<div class="flex items-center gap-3 font-label-caps text-[11px] text-on-surface-variant">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>LV 12</span>
|
||||
<span class="text-outline">|</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>XP 320/500</span>
|
||||
<div class="w-[60px] h-1 bg-surface-container-highest">
|
||||
<div class="h-full bg-primary-container w-[64%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-outline">|</span>
|
||||
<div class="flex items-center gap-1 text-info">
|
||||
<span class="w-2 h-2 rounded-full bg-info"></span>
|
||||
<span class="uppercase">Synced</span>
|
||||
</div>
|
||||
<span class="text-outline">|</span>
|
||||
<span class="text-outline">v0.20.0</span>
|
||||
</div>
|
||||
</header>
|
||||
<!-- MAIN CONTENT AREA -->
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<!-- LEFT PANE (40%) -->
|
||||
<section class="w-[40%] border-r border-outline flex flex-col p-8 gap-8 overflow-y-auto custom-scrollbar">
|
||||
<div class="space-y-1">
|
||||
<p class="text-outline font-label-caps text-xs">▌play.tsx</p>
|
||||
<h2 class="font-headline text-[32px] font-bold text-on-surface leading-none uppercase">Ready to play?</h2>
|
||||
<p class="text-on-surface-variant font-label-caps text-sm tracking-wide">RESUME · 12:34 ELAPSED · DRAW-3</p>
|
||||
</div>
|
||||
<button class="w-full h-24 bg-primary-container text-surface font-headline text-[24px] font-bold flex items-center justify-center gap-4 hover:brightness-110 active:scale-[0.98] transition-all">
|
||||
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
|
||||
CONTINUE GAME
|
||||
</button>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<button class="h-12 border border-outline bg-transparent text-on-surface font-label-caps text-sm hover:border-primary-container hover:text-primary-container transition-all flex items-center justify-center gap-2">
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
NEW GAME
|
||||
</button>
|
||||
<button class="h-12 border border-outline bg-transparent text-on-surface font-label-caps text-sm hover:border-primary-container hover:text-primary-container transition-all flex items-center justify-center gap-2">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
RESTART RUN
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<p class="text-outline font-label-caps text-xs uppercase tracking-widest">Game Modes</p>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<!-- Zen -->
|
||||
<div class="aspect-square border border-outline flex flex-col items-center justify-center gap-2 hover:bg-surface-container transition-colors cursor-pointer group">
|
||||
<span class="material-symbols-outlined text-outline group-hover:text-primary-container">spa</span>
|
||||
<span class="font-label-caps text-[10px] uppercase">Zen</span>
|
||||
</div>
|
||||
<!-- Time Attack -->
|
||||
<div class="aspect-square border border-outline flex flex-col items-center justify-center gap-2 hover:bg-surface-container transition-colors cursor-pointer group">
|
||||
<span class="material-symbols-outlined text-outline group-hover:text-primary-container">timer</span>
|
||||
<span class="font-label-caps text-[10px] uppercase text-center">Time<br/>Attack</span>
|
||||
</div>
|
||||
<!-- Locked Challenge -->
|
||||
<div class="aspect-square bg-[#0d0d0d] border border-outline/30 flex flex-col items-center justify-center gap-2 relative opacity-60">
|
||||
<span class="material-symbols-outlined text-outline">lock</span>
|
||||
<span class="font-label-caps text-[10px] uppercase">Challenge</span>
|
||||
<div class="absolute -top-2 -right-2 bg-warning text-surface px-1 py-0.5 text-[8px] font-bold">LV 5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- VISUAL DECORATION (IMAGE PLACEHOLDER) -->
|
||||
<div class="mt-auto pt-8">
|
||||
<div class="w-full h-40 border border-outline overflow-hidden">
|
||||
<img class="w-full h-full object-cover opacity-40 grayscale hover:grayscale-0 transition-all duration-700" data-alt="A dark, high-contrast digital art piece showing an abstract terminal interface with glowing cyan scanlines and retro-futuristic grid patterns. The composition is geometric and minimalist, following a synthwave aesthetic with deep black backgrounds and crisp cyan light elements. The lighting is moody and artificial, suggesting a high-performance computer screen in a dimly lit server room. Professional, sharp-edged UI design style." src="https://lh3.googleusercontent.com/aida-public/AB6AXuAet8SrRWSacZfwd8ISRQdDC7CDGixBwRnPAVMmMcjbifq1jnHSzCGWgSSL6YPSRfCkLNWr91BxTzV4zigGjMBLlk7rCLo5I7X7F6ydinDrKJVqZkRbvHJeSo90BPANoQwZtzPvhKXVEA9C2DbBaj8KPR4ObCo24Mj25NXPvGNThOE-3BSpuU6MPC-hrUMPVCPJpZnJdI_OmSz8mT021vjTxFERN12S1PFOzXKmNUDleoTDIat-8UifyKmKg4eKilecrBW6sFqaBw"/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- CENTER PANE (30%) -->
|
||||
<section class="w-[30%] border-r border-outline flex flex-col p-8 gap-8 overflow-y-auto custom-scrollbar">
|
||||
<div class="space-y-1">
|
||||
<p class="text-outline font-label-caps text-xs">▌daily.json</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-headline text-[18px] font-bold text-on-surface">MAY 07 · 2026</h3>
|
||||
<span class="bg-warning/20 text-warning px-2 py-1 text-[10px] font-bold border border-warning/40">EXPIRES 11:42:30</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-container p-6 border border-outline space-y-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-on-surface-variant font-label-caps text-[10px] uppercase tracking-tighter">Current Seed</p>
|
||||
<p class="font-headline text-[24px] font-extrabold text-highlight-valid">#2024-127</p>
|
||||
</div>
|
||||
<button class="w-full py-3 bg-primary-container text-surface font-label-caps text-xs font-bold uppercase tracking-widest hover:brightness-110 active:scale-95 transition-all">
|
||||
▶ Attempt Today
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<p class="text-outline font-label-caps text-xs uppercase tracking-widest">Global Standings</p>
|
||||
<div class="space-y-1 text-xs font-label-caps">
|
||||
<div class="flex justify-between py-2 border-b border-outline/30 text-highlight-valid">
|
||||
<span>01 │ swift_jaguar</span>
|
||||
<span>02:47</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-outline/30 text-on-surface-variant">
|
||||
<span>02 │ pixel_drifter</span>
|
||||
<span>03:12</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-outline/30 text-on-surface-variant">
|
||||
<span>03 │ null_ptr</span>
|
||||
<span>03:15</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-outline/30 text-on-surface-variant">
|
||||
<span>04 │ core_dump_88</span>
|
||||
<span>03:44</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 text-primary-container bg-primary-container/10 px-2 -mx-2">
|
||||
<span>12 │ YOU (anon)</span>
|
||||
<span>--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- RIGHT PANE (30%) -->
|
||||
<section class="w-[30%] flex flex-col p-8 gap-8 overflow-y-auto custom-scrollbar">
|
||||
<div class="space-y-1">
|
||||
<p class="text-outline font-label-caps text-xs">▌stats.log</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="border border-outline p-4 space-y-1">
|
||||
<p class="text-on-surface-variant text-[10px] uppercase">Games</p>
|
||||
<p class="font-hud-score text-[28px] text-on-surface">247</p>
|
||||
</div>
|
||||
<div class="border border-outline p-4 space-y-1 text-highlight-valid">
|
||||
<p class="text-on-surface-variant text-[10px] uppercase">Win Rate</p>
|
||||
<p class="font-hud-score text-[28px]">61%</p>
|
||||
</div>
|
||||
<div class="border border-outline p-4 space-y-1">
|
||||
<p class="text-on-surface-variant text-[10px] uppercase">Best Time</p>
|
||||
<p class="font-hud-score text-[28px]">01:54</p>
|
||||
</div>
|
||||
<div class="border border-outline p-4 space-y-1 text-primary-container">
|
||||
<p class="text-on-surface-variant text-[10px] uppercase">Streak</p>
|
||||
<p class="font-hud-score text-[28px]">7</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<p class="text-outline font-label-caps text-xs uppercase tracking-widest">Achievements (8/19)</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- Filled Cyan Dots -->
|
||||
<div class="w-3 h-3 bg-primary-container"></div>
|
||||
<div class="w-3 h-3 bg-primary-container"></div>
|
||||
<div class="w-3 h-3 bg-primary-container"></div>
|
||||
<div class="w-3 h-3 bg-primary-container"></div>
|
||||
<div class="w-3 h-3 bg-primary-container"></div>
|
||||
<div class="w-3 h-3 bg-primary-container"></div>
|
||||
<div class="w-3 h-3 bg-primary-container"></div>
|
||||
<div class="w-3 h-3 bg-primary-container"></div>
|
||||
<!-- Empty Dots -->
|
||||
<div class="w-3 h-3 border border-outline"></div>
|
||||
<div class="w-3 h-3 border border-outline"></div>
|
||||
<div class="w-3 h-3 border border-outline"></div>
|
||||
<div class="w-3 h-3 border border-outline"></div>
|
||||
<div class="w-3 h-3 border border-outline"></div>
|
||||
<div class="w-3 h-3 border border-outline"></div>
|
||||
<div class="w-3 h-3 border border-outline"></div>
|
||||
<div class="w-3 h-3 border border-outline"></div>
|
||||
<div class="w-3 h-3 border border-outline"></div>
|
||||
<div class="w-3 h-3 border border-outline"></div>
|
||||
<div class="w-3 h-3 border border-outline"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto border border-outline bg-surface-container p-4 flex items-center justify-between hover:border-primary-container transition-colors cursor-pointer group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary-container text-surface flex items-center justify-center font-bold text-lg">RS</div>
|
||||
<div class="space-y-0.5">
|
||||
<p class="text-on-surface font-bold text-xs">anonymous@local</p>
|
||||
<p class="text-on-surface-variant text-[10px]">Session: Active</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-primary-container group-hover:translate-x-1 transition-transform">arrow_forward</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- BOTTOM BAR (24px) -->
|
||||
<footer class="h-6 bg-surface-container border-t border-outline flex items-center justify-between px-4 text-[10px] font-label-caps">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-primary-container">▌ NORMAL</span>
|
||||
<span class="text-outline">│</span>
|
||||
<span class="text-on-surface-variant">~/rusty-solitaire/home</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-on-surface-variant">
|
||||
<div class="flex items-center gap-1"><span class="text-primary-container">[SPACE]</span> play</div>
|
||||
<div class="flex items-center gap-1"><span class="text-primary-container">[D]</span> daily</div>
|
||||
<div class="flex items-center gap-1"><span class="text-primary-container">[S]</span> settings</div>
|
||||
<div class="flex items-center gap-1"><span class="text-primary-container">[?]</span> help</div>
|
||||
</div>
|
||||
<div class="text-outline">
|
||||
2026-05-07 17:42 EDT
|
||||
</div>
|
||||
</footer>
|
||||
<!-- GLOBAL SCANLINE EFFECT -->
|
||||
<div class="fixed inset-0 pointer-events-none z-[100] overflow-hidden opacity-10">
|
||||
<div class="absolute inset-0" style="background: repeating-linear-gradient(0deg, #151515, #151515 2px, #202020 4px);"></div>
|
||||
</div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,225 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Rusty Solitaire - Main Menu</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"outline": "#505050",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"suit-black": "#d0d0d0",
|
||||
"surface-container-high": "#272a2d",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"on-tertiary-container": "#683476",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"background": "#101417",
|
||||
"primary-container": "#6fc2ef",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"surface-container-low": "#181c1f",
|
||||
"on-surface": "#d0d0d0",
|
||||
"primary": "#a1dcff",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"secondary-container": "#435401",
|
||||
"inverse-primary": "#00668a",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"surface-bright": "#363a3d",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"warning": "#ddb26f",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"suit-red": "#fb9fb1",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"info": "#12cfc0",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"error": "#fb9fb1",
|
||||
"surface-variant": "#313538",
|
||||
"on-error": "#690005",
|
||||
"surface": "#151515",
|
||||
"surface-container": "#202020",
|
||||
"on-primary-container": "#004f6c",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"on-secondary": "#293500",
|
||||
"error-container": "#93000a",
|
||||
"secondary": "#bad073",
|
||||
"tertiary": "#f7c3ff",
|
||||
"outline-variant": "#3f484e",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"surface-container-highest": "#313538",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"on-error-container": "#ffdad6",
|
||||
"on-primary": "#003549",
|
||||
"on-background": "#e0e3e6",
|
||||
"surface-dim": "#101417",
|
||||
"on-tertiary": "#4c195b",
|
||||
"highlight-valid": "#acc267"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"margin-edge": "1rem",
|
||||
"touch-target-min": "48px",
|
||||
"stack-overlap": "2rem",
|
||||
"action-bar-height": "64px",
|
||||
"gutter-card": "0.375rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"card-rank": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"hud-timer": ["16px", { "lineHeight": "24px", "fontWeight": "400" }],
|
||||
"headline": ["28px", { "lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700" }],
|
||||
"label-caps": ["12px", { "lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500" }],
|
||||
"hud-score": ["24px", { "lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700" }],
|
||||
"body-md": ["16px", { "lineHeight": "24px", "fontWeight": "400" }],
|
||||
"card-rank": ["18px", { "lineHeight": "18px", "fontWeight": "700" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.scanline {
|
||||
background: linear-gradient(to bottom, rgba(255,255,255,0), rgba(255,255,255,0) 50%, rgba(0,0,0,0.1) 50%, rgba(0,0,0,0.1));
|
||||
background-size: 100% 4px;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface text-on-surface font-hud-timer min-h-screen flex flex-col relative overflow-hidden">
|
||||
<!-- Subtle CRT scanline overlay -->
|
||||
<div class="absolute inset-0 pointer-events-none scanline opacity-20 z-0"></div>
|
||||
<!-- Status Bar Zone -->
|
||||
<div class="h-6 w-full flex justify-end items-center px-margin-edge pt-2 z-10 relative">
|
||||
<div class="w-2 h-2 rounded-full bg-info"></div>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
<header class="px-margin-edge pt-4 pb-6 flex justify-between items-center z-10 relative">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-headline text-headline text-on-surface">▌RUSTY SOLITAIRE</span>
|
||||
<div class="w-2 h-6 bg-primary-container inline-block ml-1 animate-pulse"></div>
|
||||
</div>
|
||||
<div class="bg-surface-container px-3 py-1 flex items-center gap-2 border border-outline">
|
||||
<span class="font-label-caps text-label-caps text-on-surface">LV 12</span>
|
||||
<div class="w-2 h-2 rounded-full bg-highlight-celebration"></div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Main Content Canvas -->
|
||||
<main class="flex-1 px-margin-edge flex flex-col gap-8 z-10 relative pb-24 overflow-y-auto">
|
||||
<!-- XP Section -->
|
||||
<section class="flex flex-col gap-2">
|
||||
<div class="w-full h-1 bg-surface-container border border-outline relative">
|
||||
<div class="absolute top-0 left-0 h-full bg-primary-container w-[64%]"></div>
|
||||
</div>
|
||||
<div class="font-label-caps text-label-caps text-on-surface-variant text-right">
|
||||
320 / 500 XP
|
||||
</div>
|
||||
</section>
|
||||
<!-- Primary Action -->
|
||||
<section class="flex flex-col gap-2">
|
||||
<button class="w-full h-[56px] bg-primary-container text-surface flex items-center justify-center gap-2 hover:bg-surface-tint transition-colors duration-120">
|
||||
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
|
||||
<span class="font-label-caps text-[14px] uppercase tracking-widest font-bold">PLAY</span>
|
||||
</button>
|
||||
<div class="font-label-caps text-label-caps text-on-surface-variant text-center">
|
||||
RESUME LAST GAME · 12:34 ELAPSED
|
||||
</div>
|
||||
</section>
|
||||
<!-- Daily Challenge Tile -->
|
||||
<section>
|
||||
<div class="bg-surface-container border border-outline p-4 flex justify-between items-center hover:bg-surface-container-high transition-colors cursor-pointer group">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-label-caps text-label-caps text-primary">DAILY CHALLENGE</span>
|
||||
<span class="font-body-md text-body-md text-on-surface">DRAW-3 · SEED #2024-127</span>
|
||||
<div class="inline-flex">
|
||||
<span class="bg-surface px-2 py-0.5 border border-warning text-warning font-label-caps text-[10px]">EXPIRES 11:42:30</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-primary group-hover:translate-x-1 transition-transform">chevron_right</span>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Special Modes Grid -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<h2 class="font-label-caps text-label-caps text-on-surface-variant">SPECIAL MODES</h2>
|
||||
<div class="grid grid-cols-3 gap-gutter-card">
|
||||
<!-- ZEN -->
|
||||
<button class="aspect-square bg-surface border border-outline flex flex-col items-center justify-center gap-2 hover:border-primary hover:text-primary transition-colors text-on-surface">
|
||||
<span class="material-symbols-outlined text-[32px]">self_improvement</span>
|
||||
<span class="font-label-caps text-label-caps">ZEN</span>
|
||||
</button>
|
||||
<!-- TIME ATTACK -->
|
||||
<button class="aspect-square bg-surface border border-outline flex flex-col items-center justify-center gap-2 hover:border-primary hover:text-primary transition-colors text-on-surface">
|
||||
<span class="material-symbols-outlined text-[32px]">timer</span>
|
||||
<span class="font-label-caps text-label-caps">TIME ATTACK</span>
|
||||
</button>
|
||||
<!-- CHALLENGE (Locked) -->
|
||||
<button class="aspect-square bg-[#0d0d0d] border border-surface-container-high flex flex-col items-center justify-center gap-2 text-on-surface-variant opacity-75 cursor-not-allowed relative">
|
||||
<span class="material-symbols-outlined text-[32px]">lock</span>
|
||||
<span class="font-label-caps text-label-caps">CHALLENGE</span>
|
||||
<div class="absolute top-2 right-2 bg-surface px-1 py-0.5 border border-warning text-warning font-label-caps text-[10px]">
|
||||
LV 5
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Secondary Nav Grid -->
|
||||
<section class="grid grid-cols-2 gap-y-4 gap-x-6 pb-6">
|
||||
<button class="flex items-center gap-3 h-[56px] border-l-2 border-outline pl-3 hover:border-primary hover:text-primary transition-colors text-on-surface justify-start">
|
||||
<span class="material-symbols-outlined">bar_chart</span>
|
||||
<span class="font-label-caps text-label-caps">STATS</span>
|
||||
</button>
|
||||
<button class="flex items-center gap-3 h-[56px] border-l-2 border-outline pl-3 hover:border-primary hover:text-primary transition-colors text-on-surface justify-start relative">
|
||||
<span class="material-symbols-outlined">emoji_events</span>
|
||||
<span class="font-label-caps text-label-caps">ACHIEVEMENTS</span>
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-highlight-celebration"></div>
|
||||
</button>
|
||||
<button class="flex items-center gap-3 h-[56px] border-l-2 border-outline pl-3 hover:border-primary hover:text-primary transition-colors text-on-surface justify-start">
|
||||
<span class="material-symbols-outlined">format_list_numbered</span>
|
||||
<span class="font-label-caps text-label-caps">LEADERBOARD</span>
|
||||
</button>
|
||||
<button class="flex items-center gap-3 h-[56px] border-l-2 border-outline pl-3 hover:border-primary hover:text-primary transition-colors text-on-surface justify-start">
|
||||
<span class="material-symbols-outlined">account_circle</span>
|
||||
<span class="font-label-caps text-label-caps">PROFILE</span>
|
||||
</button>
|
||||
</section>
|
||||
<!-- Footer Links -->
|
||||
<footer class="flex flex-col items-center gap-4 mt-auto">
|
||||
<div class="flex items-center gap-4 font-label-caps text-label-caps text-primary cursor-pointer hover:text-surface-tint">
|
||||
<span>SETTINGS</span>
|
||||
<span class="text-on-surface-variant">·</span>
|
||||
<span>HELP</span>
|
||||
</div>
|
||||
<div class="font-label-caps text-[10px] text-on-surface-variant text-center opacity-60">
|
||||
v0.20.0 — TERMINAL THEME · BUILD 2026.05
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,315 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Rusty Solitaire - Leaderboard</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Inter:wght@400;500&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
body {
|
||||
background-color: #151515;
|
||||
color: #e0e3e6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.scanline-overlay {
|
||||
background: linear-gradient(to bottom, rgba(21, 21, 21, 0) 50%, rgba(26, 26, 26, 0.2) 50%);
|
||||
background-size: 100% 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.terminal-glow {
|
||||
box-shadow: 0 0 10px rgba(111, 194, 239, 0.1);
|
||||
}
|
||||
</style>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"outline": "#505050",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"secondary-container": "#435401",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"primary": "#a1dcff",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"on-error": "#690005",
|
||||
"inverse-primary": "#00668a",
|
||||
"surface-container": "#202020",
|
||||
"highlight-valid": "#acc267",
|
||||
"suit-black": "#d0d0d0",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"on-secondary": "#293500",
|
||||
"on-surface": "#e0e3e6",
|
||||
"on-tertiary-container": "#683476",
|
||||
"secondary": "#bad073",
|
||||
"surface-bright": "#363a3d",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"surface-variant": "#313538",
|
||||
"suit-red": "#fb9fb1",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"surface-container-low": "#181c1f",
|
||||
"surface": "#151515",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"on-primary": "#003549",
|
||||
"primary-container": "#6fc2ef",
|
||||
"on-background": "#e0e3e6",
|
||||
"tertiary": "#f7c3ff",
|
||||
"surface-container-highest": "#313538",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"info": "#12cfc0",
|
||||
"error": "#fb9fb1",
|
||||
"warning": "#ddb26f",
|
||||
"on-primary-container": "#004f6c",
|
||||
"surface-container-high": "#272a2d",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"error-container": "#93000a",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"on-tertiary": "#4c195b",
|
||||
"background": "#101417",
|
||||
"on-error-container": "#ffdad6",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"outline-variant": "#3f484e",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"surface-dim": "#101417",
|
||||
"on-primary-fixed-variant": "#004c69"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"touch-target-min": "48px",
|
||||
"action-bar-height": "64px",
|
||||
"stack-overlap": "2rem",
|
||||
"gutter-card": "0.375rem",
|
||||
"margin-edge": "1rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-body-md overflow-hidden h-[844px] w-[390px] mx-auto relative border-x border-outline/20">
|
||||
<div class="scanline-overlay absolute inset-0 z-0"></div>
|
||||
<!-- Top AppBar (Identity Anchor) -->
|
||||
<header class="fixed top-0 w-full h-action-bar-height z-50 flex items-center px-margin-edge justify-between bg-surface dark:bg-surface text-primary dark:text-primary border-b border-outline dark:border-outline">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-primary">terminal</span>
|
||||
<h1 class="font-headline text-headline text-primary dark:text-primary uppercase tracking-tighter">Rusty Solitaire</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="material-symbols-outlined text-on-surface-variant hover:bg-surface-variant transition-colors duration-120 p-2 rounded-lg cursor-pointer">sync</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="pt-[64px] h-[calc(100%-64px)] flex flex-col z-10 relative">
|
||||
<!-- Pseudo Status Bar -->
|
||||
<div class="h-[32px] bg-surface-container flex items-center justify-between px-4 font-label-caps text-[10px] tracking-tight">
|
||||
<div class="text-[#a0a0a0]">▌leaderboard.tsx</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-info"></span>
|
||||
<span class="text-on-surface-variant">SYNCED</span>
|
||||
</span>
|
||||
<span class="text-outline">v0.20.0</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab Strip -->
|
||||
<nav class="h-[40px] bg-[#1a1a1a] border-b border-[#353535] flex items-center">
|
||||
<div class="flex-1 flex flex-col items-center justify-center relative">
|
||||
<span class="font-label-caps text-[11px] text-[#6fc2ef]">[ TODAY ]</span>
|
||||
<div class="absolute bottom-0 w-full h-[2px] bg-[#6fc2ef]"></div>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<span class="font-label-caps text-[11px] text-[#a0a0a0]">WEEK</span>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<span class="font-label-caps text-[11px] text-[#a0a0a0]">ALL-TIME</span>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<span class="font-label-caps text-[11px] text-[#a0a0a0]">FRIENDS</span>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="flex-1 overflow-y-auto px-margin-edge pt-4 space-y-4 pb-[88px]">
|
||||
<!-- Hero Podium Card -->
|
||||
<section class="h-[120px] bg-surface-container border border-[#353535] rounded-lg p-2 flex flex-col justify-between">
|
||||
<div class="font-label-caps text-[10px] text-[#a0a0a0]">TOP 3 · TODAY</div>
|
||||
<div class="flex gap-2 items-end justify-between flex-1 mt-1">
|
||||
<!-- 2nd -->
|
||||
<div class="flex-1 border border-[#a0a0a0] h-full rounded flex flex-col items-center justify-center relative py-1">
|
||||
<span class="font-card-rank text-[16px] text-[#a0a0a0]">02</span>
|
||||
<span class="text-[9px] font-mono text-[#d0d0d0] truncate w-full text-center px-1">base16_fan</span>
|
||||
<span class="text-[10px] font-mono text-[#a0a0a0]">03:12</span>
|
||||
</div>
|
||||
<!-- 1st -->
|
||||
<div class="flex-[1.2] border border-warning h-[110%] mb-[-2px] rounded-lg bg-surface flex flex-col items-center justify-center relative py-1 terminal-glow">
|
||||
<span class="absolute top-1 right-1 text-warning material-symbols-outlined text-[14px]">star</span>
|
||||
<span class="font-card-rank text-[24px] text-warning leading-none">01</span>
|
||||
<span class="text-[11px] font-mono text-[#d0d0d0] font-bold truncate w-full text-center px-1">swift_jaguar</span>
|
||||
<span class="text-[12px] font-mono text-[#d0d0d0]">02:47</span>
|
||||
</div>
|
||||
<!-- 3rd -->
|
||||
<div class="flex-1 border border-[#7a5d3b] h-full rounded flex flex-col items-center justify-center relative py-1">
|
||||
<span class="font-card-rank text-[16px] text-[#7a5d3b]">03</span>
|
||||
<span class="text-[9px] font-mono text-[#d0d0d0] truncate w-full text-center px-1">cli_player</span>
|
||||
<span class="text-[10px] font-mono text-[#a0a0a0]">03:54</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Search/Filter Row -->
|
||||
<div class="flex items-center gap-2 h-[40px]">
|
||||
<div class="px-3 h-8 border border-outline rounded flex items-center justify-center bg-surface-container-low">
|
||||
<span class="font-label-caps text-[10px] text-[#6fc2ef]">[ ALL TIMES ]</span>
|
||||
</div>
|
||||
<div class="flex-1 h-8 border border-outline rounded flex items-center px-2 bg-surface gap-2">
|
||||
<span class="font-mono text-[12px] text-outline">/ search players</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Leaderboard List -->
|
||||
<div class="space-y-0.5 font-mono text-[12px]">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between px-2 pb-1 border-b border-outline/20 text-outline text-[10px] uppercase font-bold tracking-widest">
|
||||
<span>Rank & User</span>
|
||||
<span>Time</span>
|
||||
</div>
|
||||
<!-- Rank 04 -->
|
||||
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
|
||||
<div class="flex gap-4">
|
||||
<span class="text-[#a0a0a0] w-8">004</span>
|
||||
<span class="text-on-surface">tablejockey</span>
|
||||
</div>
|
||||
<span class="text-[#a0a0a0]">04:01</span>
|
||||
</div>
|
||||
<!-- Rank 05 -->
|
||||
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
|
||||
<div class="flex gap-4">
|
||||
<span class="text-[#a0a0a0] w-8">005</span>
|
||||
<span class="text-on-surface">vim_motions</span>
|
||||
</div>
|
||||
<span class="text-[#a0a0a0]">04:05</span>
|
||||
</div>
|
||||
<!-- Rank 06 -->
|
||||
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
|
||||
<div class="flex gap-4">
|
||||
<span class="text-[#a0a0a0] w-8">006</span>
|
||||
<span class="text-on-surface">tmux_lover</span>
|
||||
</div>
|
||||
<span class="text-[#a0a0a0]">04:18</span>
|
||||
</div>
|
||||
<!-- Rank 07 -->
|
||||
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
|
||||
<div class="flex gap-4">
|
||||
<span class="text-[#a0a0a0] w-8">007</span>
|
||||
<span class="text-on-surface">nvim_dotfiles</span>
|
||||
</div>
|
||||
<span class="text-[#a0a0a0]">04:23</span>
|
||||
</div>
|
||||
<!-- Rank 08 -->
|
||||
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
|
||||
<div class="flex gap-4">
|
||||
<span class="text-[#a0a0a0] w-8">008</span>
|
||||
<span class="text-on-surface">dark_theme</span>
|
||||
</div>
|
||||
<span class="text-[#a0a0a0]">04:31</span>
|
||||
</div>
|
||||
<!-- Spacer for truncated view -->
|
||||
<div class="flex justify-center py-2 text-outline/30 tracking-[1em]">...</div>
|
||||
<!-- YOU (Rank 17) -->
|
||||
<div class="flex items-center justify-between px-2 py-2 bg-[#1f3a4a]/30 border border-[#6fc2ef]/40 rounded-sm">
|
||||
<div class="flex gap-4">
|
||||
<span class="text-[#6fc2ef] w-8 font-bold">▶ 017</span>
|
||||
<span class="text-[#6fc2ef] font-bold">anonymous (YOU)</span>
|
||||
</div>
|
||||
<span class="text-[#6fc2ef] font-bold">04:12</span>
|
||||
</div>
|
||||
<!-- Rank 18 -->
|
||||
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
|
||||
<div class="flex gap-4">
|
||||
<span class="text-[#a0a0a0] w-8">018</span>
|
||||
<span class="text-on-surface">bash_brawler</span>
|
||||
</div>
|
||||
<span class="text-[#a0a0a0]">05:01</span>
|
||||
</div>
|
||||
<!-- Rank 19 -->
|
||||
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
|
||||
<div class="flex gap-4">
|
||||
<span class="text-[#a0a0a0] w-8">019</span>
|
||||
<span class="text-on-surface">curl_master</span>
|
||||
</div>
|
||||
<span class="text-[#a0a0a0]">05:14</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- CLI Style Footer -->
|
||||
<footer class="fixed bottom-0 w-full h-[24px] bg-[#202020] border-t border-[#353535] px-2 flex items-center justify-between font-mono text-[9px] z-50">
|
||||
<div class="text-[#a0a0a0]">
|
||||
<span class="text-info font-bold">▌</span> NORMAL │ leaderboard
|
||||
</div>
|
||||
<div class="text-[#a0a0a0] flex gap-3">
|
||||
<span>[1-4] tab</span>
|
||||
<span>[/] search</span>
|
||||
<span>[ESC] back</span>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- Shared Component: BottomNavBar -->
|
||||
<nav class="fixed bottom-[24px] w-full h-action-bar-height z-50 flex justify-around items-center bg-surface-container dark:bg-surface-container border-t border-outline dark:border-outline">
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant p-2 hover:text-primary dark:hover:text-primary transition-all duration-120 ease-linear active:bg-surface-container-highest">
|
||||
<span class="material-symbols-outlined">playing_cards</span>
|
||||
<span class="font-label-caps text-label-caps">DEAL [F1]</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant p-2 hover:text-primary dark:hover:text-primary transition-all duration-120 ease-linear active:bg-surface-container-highest">
|
||||
<span class="material-symbols-outlined">undo</span>
|
||||
<span class="font-label-caps text-label-caps">UNDO [Z]</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant p-2 hover:text-primary dark:hover:text-primary transition-all duration-120 ease-linear active:bg-surface-container-highest">
|
||||
<span class="material-symbols-outlined">lightbulb</span>
|
||||
<span class="font-label-caps text-label-caps">HINT [H]</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center bg-primary-container dark:bg-primary-container text-on-primary-container dark:text-on-primary-container rounded-none p-2 transition-all duration-120 ease-linear active:bg-surface-container-highest">
|
||||
<span class="material-symbols-outlined">analytics</span>
|
||||
<span class="font-label-caps text-label-caps">STATS [S]</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant p-2 hover:text-primary dark:hover:text-primary transition-all duration-120 ease-linear active:bg-surface-container-highest">
|
||||
<span class="material-symbols-outlined">menu</span>
|
||||
<span class="font-label-caps text-label-caps">MENU [ESC]</span>
|
||||
</button>
|
||||
</nav>
|
||||
</main>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,259 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>ROOT@SOLITAIRE:~ | LEVEL UP</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.scanline-overlay {
|
||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06));
|
||||
background-size: 100% 2px, 3px 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.card-glow {
|
||||
box-shadow: 0 0 24px rgba(225, 163, 238, 0.25);
|
||||
}
|
||||
</style>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"secondary-container": "#435401",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"background": "#101417",
|
||||
"on-primary": "#003549",
|
||||
"info": "#12cfc0",
|
||||
"surface-container-highest": "#313538",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"on-secondary": "#293500",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"surface-container-low": "#181c1f",
|
||||
"surface-container-high": "#272a2d",
|
||||
"secondary": "#bad073",
|
||||
"outline-variant": "#3f484e",
|
||||
"on-surface": "#e0e3e6",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"on-tertiary": "#4c195b",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"on-tertiary-container": "#683476",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"inverse-primary": "#00668a",
|
||||
"primary-container": "#6fc2ef",
|
||||
"surface-container": "#1c2023",
|
||||
"on-background": "#e0e3e6",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"surface-dim": "#101417",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"tertiary": "#f7c3ff",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"error": "#fb9fb1",
|
||||
"error-container": "#93000a",
|
||||
"surface-bright": "#363a3d",
|
||||
"on-primary-container": "#004f6c",
|
||||
"warning": "#ddb26f",
|
||||
"surface": "#151515",
|
||||
"suit-black": "#d0d0d0",
|
||||
"highlight-valid": "#acc267",
|
||||
"outline": "#505050",
|
||||
"surface-variant": "#313538",
|
||||
"on-error-container": "#ffdad6",
|
||||
"on-error": "#690005",
|
||||
"primary": "#a1dcff",
|
||||
"suit-red": "#fb9fb1"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"gutter-card": "0.375rem",
|
||||
"action-bar-height": "64px",
|
||||
"stack-overlap": "2rem",
|
||||
"margin-edge": "1rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-background font-body-md overflow-hidden h-screen select-none">
|
||||
<!-- Top App Bar -->
|
||||
<header class="fixed top-0 w-full z-50 flex justify-between items-center px-margin-edge h-action-bar-height bg-background dark:bg-background border-b border-outline-variant dark:border-outline-variant">
|
||||
<div class="font-headline text-headline text-primary dark:text-primary uppercase tracking-tighter">ROOT@SOLITAIRE:~</div>
|
||||
<div class="flex gap-4">
|
||||
<span class="material-symbols-outlined text-primary" data-icon="memory">memory</span>
|
||||
<span class="material-symbols-outlined text-primary" data-icon="settings_ethernet">settings_ethernet</span>
|
||||
<span class="material-symbols-outlined text-primary" data-icon="wifi_tethering">wifi_tethering</span>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Main Tableau (Dimmed Background) -->
|
||||
<main class="pt-24 px-4 flex flex-col gap-8 opacity-20 filter grayscale">
|
||||
<!-- HUD Chips -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="bg-surface-container p-3 flex flex-col">
|
||||
<span class="font-label-caps text-label-caps text-on-surface-variant uppercase">SCORE</span>
|
||||
<span class="font-hud-score text-hud-score text-primary">04,820</span>
|
||||
</div>
|
||||
<div class="bg-surface-container p-3 flex flex-col items-end">
|
||||
<span class="font-label-caps text-label-caps text-on-surface-variant uppercase">TIMER</span>
|
||||
<span class="font-hud-timer text-hud-timer text-on-surface">04:12</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Foundation & Stock -->
|
||||
<div class="flex gap-gutter-card justify-between">
|
||||
<div class="flex gap-gutter-card">
|
||||
<div class="w-[64px] h-[88px] border border-dashed border-outline rounded-DEFAULT"></div>
|
||||
<div class="w-[64px] h-[88px] bg-surface border border-outline rounded-DEFAULT relative overflow-hidden">
|
||||
<div class="absolute inset-0 scanline-overlay"></div>
|
||||
<div class="absolute top-2 left-2 w-3 h-4 bg-suit-red-cb"></div>
|
||||
<div class="absolute bottom-2 right-2 font-card-rank text-[12px] text-on-surface">▌RS</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-gutter-card">
|
||||
<div class="w-[64px] h-[88px] border border-outline rounded-DEFAULT bg-surface"></div>
|
||||
<div class="w-[64px] h-[88px] border border-outline rounded-DEFAULT bg-surface"></div>
|
||||
<div class="w-[64px] h-[88px] border border-outline rounded-DEFAULT bg-surface"></div>
|
||||
<div class="w-[64px] h-[88px] border border-outline rounded-DEFAULT bg-surface"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Cascades -->
|
||||
<div class="grid grid-cols-7 gap-gutter-card">
|
||||
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
|
||||
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
|
||||
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
|
||||
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
|
||||
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
|
||||
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
|
||||
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- CELEBRATION OVERLAY SCREEM -->
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-surface/95 backdrop-blur-sm">
|
||||
<!-- Celebration Card -->
|
||||
<div class="w-[340px] h-[480px] bg-[#202020] border border-highlight-celebration rounded-xl card-glow flex flex-col overflow-hidden relative">
|
||||
<!-- Title Bar -->
|
||||
<div class="h-[28px] bg-[#1a1a1a] border-b border-outline flex items-center px-4 shrink-0">
|
||||
<span class="text-primary mr-1">▌</span>
|
||||
<span class="font-headline text-[12px] text-[#a0a0a0]">level-up.tsx</span>
|
||||
</div>
|
||||
<!-- Content Area -->
|
||||
<div class="flex-grow p-6 flex flex-col">
|
||||
<!-- Hero Band -->
|
||||
<div class="text-center mb-6">
|
||||
<div class="font-headline text-[14px] text-highlight-celebration uppercase tracking-[0.08em] mb-2">▲ LEVEL UP</div>
|
||||
<div class="flex items-baseline justify-center gap-2">
|
||||
<span class="font-headline font-bold text-[96px] text-suit-black leading-none tracking-tighter">13</span>
|
||||
<div class="flex flex-col items-start">
|
||||
<span class="font-label-caps text-[11px] text-outline uppercase">FROM 12</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 font-headline font-medium text-[13px] text-highlight-celebration tracking-[0.08em]">█ NEW PERKS UNLOCKED</div>
|
||||
</div>
|
||||
<!-- Perks List -->
|
||||
<div class="space-y-2 mb-6">
|
||||
<!-- Item 1 -->
|
||||
<div class="h-[48px] bg-[#1a1a1a] rounded-[4px] px-3 flex items-center justify-between">
|
||||
<span class="font-headline text-[13px] text-suit-black">▢ +1 daily challenge slot</span>
|
||||
<span class="bg-highlight-celebration/10 text-highlight-celebration px-2 py-0.5 rounded-full font-label-caps text-[10px] border border-highlight-celebration/30">NEW</span>
|
||||
</div>
|
||||
<!-- Item 2 -->
|
||||
<div class="h-[48px] bg-[#1a1a1a] rounded-[4px] px-3 flex items-center justify-between">
|
||||
<span class="font-headline text-[13px] text-suit-black">▢ Background: Forest</span>
|
||||
<span class="bg-highlight-valid/10 text-highlight-valid px-2 py-0.5 rounded-full font-label-caps text-[10px] border border-highlight-valid/30">UNLOCKED</span>
|
||||
</div>
|
||||
<!-- Item 3 -->
|
||||
<div class="h-[48px] bg-[#1a1a1a] rounded-[4px] px-3 flex items-center justify-between">
|
||||
<span class="font-headline text-[13px] text-suit-black">▢ Card-back: Stripes</span>
|
||||
<span class="bg-highlight-valid/10 text-highlight-valid px-2 py-0.5 rounded-full font-label-caps text-[10px] border border-highlight-valid/30">UNLOCKED</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- XP Recap -->
|
||||
<div class="h-[48px] bg-[#1a1a1a] rounded-[4px] px-4 flex items-center justify-between mt-auto">
|
||||
<span class="font-headline text-[12px] text-[#a0a0a0]">XP</span>
|
||||
<span class="font-headline font-bold text-[14px] text-highlight-valid uppercase">+200 XP THIS LEVEL</span>
|
||||
<div class="w-[60px] h-[4px] bg-outline-variant rounded-full overflow-hidden">
|
||||
<div class="w-[0%] h-full bg-suit-red-cb"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Action Button -->
|
||||
<button class="h-[56px] w-full bg-suit-red-cb flex items-center justify-center gap-2 hover:opacity-90 active:opacity-75 transition-opacity">
|
||||
<span class="font-headline font-bold text-[14px] text-background tracking-wider">▶ CONTINUE</span>
|
||||
</button>
|
||||
<!-- Scanline layer inside card -->
|
||||
<div class="absolute inset-0 scanline-overlay opacity-20 pointer-events-none"></div>
|
||||
</div>
|
||||
<!-- Caption -->
|
||||
<div class="absolute bottom-8 w-full text-center">
|
||||
<span class="font-body-md text-[11px] text-[#a0a0a0] uppercase tracking-widest opacity-60">Tap anywhere to dismiss</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom Nav Bar -->
|
||||
<nav class="fixed bottom-0 w-full z-50 flex justify-between items-center h-action-bar-height bg-surface-container dark:bg-surface-container border-t border-outline dark:border-outline">
|
||||
<div class="flex flex-col items-center justify-center bg-primary text-background p-2 w-1/5 h-full transition-colors font-label-caps text-label-caps uppercase tracking-widest">
|
||||
<span class="material-symbols-outlined" data-icon="videogame_asset">videogame_asset</span>
|
||||
<span>NORMAL</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 w-1/5 h-full hover:text-primary transition-colors font-label-caps text-label-caps uppercase tracking-widest">
|
||||
<span class="material-symbols-outlined" data-icon="edit">edit</span>
|
||||
<span>INSERT</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 w-1/5 h-full hover:text-primary transition-colors font-label-caps text-label-caps uppercase tracking-widest">
|
||||
<span class="material-symbols-outlined" data-icon="visibility">visibility</span>
|
||||
<span>VISUAL</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 w-1/5 h-full hover:text-primary transition-colors font-label-caps text-label-caps uppercase tracking-widest">
|
||||
<span class="material-symbols-outlined" data-icon="auto_fix_high">auto_fix_high</span>
|
||||
<span>SOLVE</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 w-1/5 h-full hover:text-primary transition-colors font-label-caps text-label-caps uppercase tracking-widest">
|
||||
<span class="material-symbols-outlined" data-icon="power_settings_new">power_settings_new</span>
|
||||
<span>QUIT</span>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Persistent Background Overlay (CRT Effect) -->
|
||||
<div class="fixed inset-0 pointer-events-none z-[200] scanline-overlay opacity-30"></div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,206 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Inter:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"tertiary": "#f7c3ff",
|
||||
"surface": "#151515",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"surface-variant": "#313538",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"primary": "#a1dcff",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"on-error-container": "#ffdad6",
|
||||
"on-tertiary-container": "#683476",
|
||||
"secondary": "#bad073",
|
||||
"surface-dim": "#101417",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"on-tertiary": "#4c195b",
|
||||
"surface-container-high": "#272a2d",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"surface-container": "#202020",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"background": "#101417",
|
||||
"suit-red": "#fb9fb1",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"error": "#fb9fb1",
|
||||
"on-surface": "#d0d0d0",
|
||||
"info": "#12cfc0",
|
||||
"secondary-container": "#435401",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"surface-bright": "#363a3d",
|
||||
"outline-variant": "#3f484e",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"warning": "#ddb26f",
|
||||
"surface-container-highest": "#313538",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"highlight-valid": "#acc267",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"on-error": "#690005",
|
||||
"on-primary-container": "#004f6c",
|
||||
"suit-black": "#d0d0d0",
|
||||
"inverse-primary": "#00668a",
|
||||
"surface-container-low": "#181c1f",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"on-secondary": "#293500",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"outline": "#505050",
|
||||
"error-container": "#93000a",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"primary-container": "#6fc2ef",
|
||||
"on-background": "#e0e3e6",
|
||||
"on-primary": "#003549",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"primary-fixed-dim": "#7ed0fe"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"margin-edge": "1rem",
|
||||
"gutter-card": "0.375rem",
|
||||
"touch-target-min": "48dp",
|
||||
"stack-overlap": "2rem",
|
||||
"action-bar-height": "64px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
body {
|
||||
background-color: #151515;
|
||||
color: #d0d0d0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.card-scanline {
|
||||
background: linear-gradient(rgba(21, 21, 21, 0) 50%, rgba(26, 26, 26, 1) 50%);
|
||||
background-size: 100% 4px;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex items-center justify-center min-h-screen">
|
||||
<!-- Mobile Canvas (390x844 simulated) -->
|
||||
<main class="w-[390px] h-[844px] bg-surface relative overflow-hidden flex flex-col">
|
||||
<!-- Status Bar -->
|
||||
<header class="h-8 bg-surface-container flex items-center justify-between px-margin-edge border-b border-outline-variant">
|
||||
<div class="font-hud-timer text-[11px] text-primary tracking-tight">▌onboard/01-draw.tsx</div>
|
||||
<div class="font-hud-timer text-[11px] text-on-surface-variant font-bold">STEP 1 OF 3</div>
|
||||
</header>
|
||||
<!-- Content Canvas -->
|
||||
<div class="flex-1 overflow-y-auto pb-action-bar-height">
|
||||
<!-- Hero Section -->
|
||||
<section class="h-[140px] flex flex-col items-center justify-center mt-8">
|
||||
<div class="w-8 h-12 bg-primary animate-pulse mb-2"></div>
|
||||
<h1 class="font-headline text-headline text-on-surface uppercase tracking-tighter">
|
||||
WELCOME <span class="text-primary">▌_</span>
|
||||
</h1>
|
||||
</section>
|
||||
<!-- Headline -->
|
||||
<section class="px-margin-edge mt-4 text-center">
|
||||
<h2 class="font-headline text-[22px] leading-tight text-on-surface mb-1">CHOOSE A DRAW MODE</h2>
|
||||
<p class="font-body-md text-[12px] text-on-surface-variant">You can change this any time in Settings.</p>
|
||||
</section>
|
||||
<!-- Choice Cards -->
|
||||
<section class="px-margin-edge mt-8 space-y-4">
|
||||
<!-- DRAW-3 Card -->
|
||||
<div class="h-[120px] bg-surface-container border border-primary p-4 relative flex items-start gap-4">
|
||||
<div class="absolute top-0 right-0 bg-primary px-2 py-0.5">
|
||||
<span class="font-label-caps text-[10px] text-surface font-bold">RECOMMENDED</span>
|
||||
</div>
|
||||
<div class="w-12 h-16 flex items-center justify-center border border-outline-variant bg-surface-dim">
|
||||
<span class="material-symbols-outlined text-primary" data-icon="filter_3">filter_3</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-headline text-[16px] text-primary mb-1">DRAW-3 (CLASSIC)</h3>
|
||||
<p class="font-hud-timer text-[12px] leading-snug text-on-surface-variant">
|
||||
Cycle 3 cards at a time. Standard solitaire rules for a tactical challenge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- DRAW-1 Card -->
|
||||
<div class="h-[120px] bg-surface-container border border-outline-variant p-4 flex items-start gap-4">
|
||||
<div class="w-12 h-16 flex items-center justify-center border border-outline-variant bg-surface-dim">
|
||||
<span class="material-symbols-outlined text-on-surface-variant" data-icon="filter_1">filter_1</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-headline text-[16px] text-on-surface mb-1">DRAW-1 (EASY)</h3>
|
||||
<p class="font-hud-timer text-[12px] leading-snug text-on-surface-variant">
|
||||
Cycle one card at a time. More winnable, faster pace, perfect for quick sessions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Step Indicator -->
|
||||
<section class="mt-12 flex flex-col items-center">
|
||||
<div class="flex gap-2 mb-2">
|
||||
<div class="w-8 h-1.5 bg-primary"></div>
|
||||
<div class="w-8 h-1.5 bg-outline-variant"></div>
|
||||
<div class="w-8 h-1.5 bg-outline-variant"></div>
|
||||
</div>
|
||||
<div class="font-hud-timer text-[12px] flex gap-4">
|
||||
<span class="text-primary font-bold">[1]</span>
|
||||
<span class="text-outline-variant">[2]</span>
|
||||
<span class="text-outline-variant">[3]</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Bottom Action Bar -->
|
||||
<footer class="h-action-bar-height bg-surface-container border-t border-outline flex items-center justify-between px-margin-edge fixed bottom-0 w-[390px] z-50">
|
||||
<!-- Back Button (Disabled/Muted) -->
|
||||
<button class="w-[48%] h-12 border border-outline-variant flex items-center justify-center gap-2 opacity-40 cursor-not-allowed">
|
||||
<span class="material-symbols-outlined text-outline-variant text-[18px]" data-icon="arrow_back">arrow_back</span>
|
||||
<span class="font-label-caps text-outline-variant">BACK</span>
|
||||
</button>
|
||||
<!-- Next Button -->
|
||||
<button class="w-[48%] h-12 bg-primary flex items-center justify-center gap-2 active:opacity-80 transition-opacity">
|
||||
<span class="font-headline text-[14px] text-surface font-bold uppercase tracking-widest">NEXT</span>
|
||||
<span class="material-symbols-outlined text-surface text-[18px]" data-icon="arrow_forward">arrow_forward</span>
|
||||
</button>
|
||||
</footer>
|
||||
<!-- Terminal Overlay (Faint scanlines for atmosphere) -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-[0.03] card-scanline z-[100]"></div>
|
||||
</main>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,211 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"outline-variant": "#3f484e",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"surface-bright": "#363a3d",
|
||||
"secondary-container": "#435401",
|
||||
"info": "#12cfc0",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"surface-container": "#202020",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"on-surface": "#d0d0d0",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"error": "#fb9fb1",
|
||||
"background": "#101417",
|
||||
"suit-red": "#fb9fb1",
|
||||
"surface-dim": "#101417",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"on-tertiary-container": "#683476",
|
||||
"secondary": "#bad073",
|
||||
"primary": "#a1dcff",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"on-error-container": "#ffdad6",
|
||||
"surface-container-high": "#272a2d",
|
||||
"on-tertiary": "#4c195b",
|
||||
"tertiary": "#f7c3ff",
|
||||
"surface": "#151515",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"surface-variant": "#313538",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"on-primary": "#003549",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"outline": "#505050",
|
||||
"on-secondary": "#293500",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"primary-container": "#6fc2ef",
|
||||
"on-background": "#e0e3e6",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"error-container": "#93000a",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"surface-container-low": "#181c1f",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"on-primary-container": "#004f6c",
|
||||
"suit-black": "#d0d0d0",
|
||||
"inverse-primary": "#00668a",
|
||||
"on-error": "#690005",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"surface-container-highest": "#313538",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"highlight-valid": "#acc267",
|
||||
"warning": "#ddb26f",
|
||||
"tertiary-container": "#e1a3ee"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"margin-edge": "1rem",
|
||||
"gutter-card": "0.375rem",
|
||||
"touch-target-min": "48dp",
|
||||
"stack-overlap": "2rem",
|
||||
"action-bar-height": "64px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"]
|
||||
},
|
||||
"fontSize": {
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.scanline-bg {
|
||||
background: linear-gradient(to bottom, #1a1a1a 1px, transparent 1px);
|
||||
background-size: 100% 2px;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-surface font-body-md select-none overflow-hidden h-screen flex flex-col">
|
||||
<!-- Top Navigation Bar -->
|
||||
<header class="fixed top-0 w-full bg-background z-50 border-b border-outline flex justify-between items-center px-margin-edge h-action-bar-height">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-primary" data-icon="terminal">terminal</span>
|
||||
<span class="font-headline text-headline text-primary uppercase tracking-tighter text-sm md:text-base">▌onboard/03-demo.tsx</span>
|
||||
</div>
|
||||
<div class="font-label-caps text-label-caps text-on-surface-variant">STEP 3 OF 3</div>
|
||||
</header>
|
||||
<main class="flex-1 mt-[64px] mb-[64px] flex flex-col items-center px-margin-edge pt-6 space-y-6 overflow-y-auto">
|
||||
<!-- Header Text -->
|
||||
<div class="w-full text-center space-y-2">
|
||||
<h1 class="font-headline text-headline text-on-surface">TRY IT OUT</h1>
|
||||
<p class="font-body-md text-on-surface-variant max-w-xs mx-auto">Tap a face-up card to auto-move it to the best legal pile.</p>
|
||||
</div>
|
||||
<!-- Demo Panel -->
|
||||
<div class="w-full max-w-sm bg-surface border border-outline p-6 rounded-lg relative overflow-hidden">
|
||||
<!-- Subtle scanline background effect for "terminal" pane feel -->
|
||||
<div class="absolute inset-0 scanline-bg opacity-10 pointer-events-none"></div>
|
||||
<div class="relative z-10 flex flex-col items-center">
|
||||
<!-- Foundation Slot -->
|
||||
<div class="w-20 h-28 border border-dashed border-outline-variant flex items-center justify-center mb-12">
|
||||
<span class="material-symbols-outlined text-outline-variant opacity-40 text-4xl" data-icon="spades">playing_cards</span>
|
||||
</div>
|
||||
<!-- Path Indicator (The Arrow) -->
|
||||
<div class="absolute top-[84px] left-1/2 -translate-x-1/2 flex flex-col items-center">
|
||||
<div class="font-label-caps text-secondary text-[10px] mb-1">MOVES HERE</div>
|
||||
<span class="material-symbols-outlined text-secondary text-3xl font-bold" data-icon="arrow_upward">arrow_upward</span>
|
||||
</div>
|
||||
<!-- Mini-Cards Row -->
|
||||
<div class="flex gap-gutter-card">
|
||||
<!-- A-Spades (Target) -->
|
||||
<div class="w-20 h-28 bg-surface border-2 border-primary rounded flex flex-col justify-between p-2 relative ring-1 ring-primary ring-offset-2 ring-offset-surface">
|
||||
<div class="font-card-rank text-card-rank text-suit-black">A</div>
|
||||
<span class="material-symbols-outlined self-end text-3xl text-suit-black" data-icon="spades" style="font-variation-settings: 'FILL' 1;">playing_cards</span>
|
||||
<!-- Pulse Icon -->
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-primary text-4xl opacity-80" data-icon="touch_app">touch_app</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- K-Hearts -->
|
||||
<div class="w-20 h-28 bg-surface border border-suit-red rounded flex flex-col justify-between p-2 opacity-50">
|
||||
<div class="font-card-rank text-card-rank text-suit-red">K</div>
|
||||
<span class="material-symbols-outlined self-end text-3xl text-suit-red" data-icon="favorite" style="font-variation-settings: 'FILL' 1;">favorite</span>
|
||||
</div>
|
||||
<!-- Q-Clubs -->
|
||||
<div class="w-20 h-28 bg-surface border border-outline rounded flex flex-col justify-between p-2 opacity-50">
|
||||
<div class="font-card-rank text-card-rank text-on-surface">Q</div>
|
||||
<span class="material-symbols-outlined self-end text-3xl text-on-surface" data-icon="clubs">groups</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- CLI Prompt -->
|
||||
<div class="w-full max-w-sm flex items-center gap-2 font-label-caps text-on-surface py-2">
|
||||
<span class="text-primary">▌</span>
|
||||
<span class="tracking-widest">TAP THE A♠ TO CONTINUE</span>
|
||||
<span class="w-3 h-5 bg-primary animate-pulse"></span>
|
||||
</div>
|
||||
<!-- Feature List -->
|
||||
<div class="w-full max-w-sm space-y-3 pt-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-secondary text-sm" data-icon="check_circle" style="font-variation-settings: 'FILL' 1;">check_circle</span>
|
||||
<span class="font-label-caps text-label-caps">TAP TO AUTO-MOVE</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-secondary text-sm" data-icon="check_circle" style="font-variation-settings: 'FILL' 1;">check_circle</span>
|
||||
<span class="font-label-caps text-label-caps">DRAG TO TARGET PILE</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-secondary text-sm" data-icon="check_circle" style="font-variation-settings: 'FILL' 1;">check_circle</span>
|
||||
<span class="font-label-caps text-label-caps">DOUBLE-TAP TO FOUNDATION</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Step Indicators -->
|
||||
<div class="flex gap-2 py-4">
|
||||
<div class="w-8 h-1 bg-primary"></div>
|
||||
<div class="w-8 h-1 bg-primary"></div>
|
||||
<div class="w-8 h-1 bg-primary"></div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Bottom Action Bar -->
|
||||
<footer class="fixed bottom-0 w-full h-[64px] bg-surface-container border-t border-outline flex items-center px-margin-edge justify-between z-50">
|
||||
<button class="px-6 py-2 border border-outline text-on-surface-variant font-label-caps text-label-caps transition-colors duration-120 active:bg-surface-bright flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-sm" data-icon="arrow_back">arrow_back</span>
|
||||
BACK
|
||||
</button>
|
||||
<button class="px-6 py-2 bg-primary text-on-primary font-label-caps text-label-caps transition-colors duration-120 active:bg-primary-container flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-sm" data-icon="play_arrow" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
|
||||
START PLAYING
|
||||
</button>
|
||||
</footer>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,218 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=390, height=844, initial-scale=1.0" name="viewport"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Inter:wght@400;500;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"highlight-valid": "#acc267",
|
||||
"outline": "#505050",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"error-container": "#93000a",
|
||||
"surface-container": "#202020",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"tertiary": "#f7c3ff",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"info": "#12cfc0",
|
||||
"on-tertiary": "#4c195b",
|
||||
"secondary-container": "#435401",
|
||||
"surface": "#151515",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"outline-variant": "#3f484e",
|
||||
"suit-red": "#fb9fb1",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"error": "#fb9fb1",
|
||||
"primary-container": "#6fc2ef",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"on-surface": "#e0e3e6",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"on-primary": "#003549",
|
||||
"on-secondary": "#293500",
|
||||
"on-primary-container": "#004f6c",
|
||||
"secondary": "#bad073",
|
||||
"surface-container-highest": "#313538",
|
||||
"primary": "#a1dcff",
|
||||
"surface-container-low": "#181c1f",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"warning": "#ddb26f",
|
||||
"suit-black": "#d0d0d0",
|
||||
"surface-variant": "#313538",
|
||||
"on-tertiary-container": "#683476",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"on-background": "#e0e3e6",
|
||||
"surface-bright": "#363a3d",
|
||||
"on-error": "#690005",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"background": "#101417",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"on-error-container": "#ffdad6",
|
||||
"inverse-primary": "#00668a",
|
||||
"surface-dim": "#101417",
|
||||
"surface-container-high": "#272a2d",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"cyan-terminal": "#6fc2ef"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"touch-target-min": "48dp",
|
||||
"stack-overlap": "2rem",
|
||||
"gutter-card": "0.375rem",
|
||||
"action-bar-height": "64px",
|
||||
"margin-edge": "1rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["JetBrains Mono"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.scanline-pattern {
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
#151515,
|
||||
#151515 2px,
|
||||
#1a1a1a 2px,
|
||||
#1a1a1a 4px
|
||||
);
|
||||
}
|
||||
.checker-pattern {
|
||||
background-color: #ffffff;
|
||||
background-image:
|
||||
linear-gradient(45deg, #004c69 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #004c69 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #004c69 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #004c69 75%);
|
||||
background-size: 8px 8px;
|
||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
||||
}
|
||||
.stripe-pattern {
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
#fb9fb1,
|
||||
#fb9fb1 4px,
|
||||
#151515 4px,
|
||||
#151515 8px
|
||||
);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface text-on-surface min-h-screen flex flex-col items-center overflow-hidden selection:bg-cyan-terminal selection:text-surface">
|
||||
<!-- 1. Status Bar -->
|
||||
<header class="w-full h-8 bg-surface-container flex items-center justify-between px-4 border-b border-outline-variant">
|
||||
<span class="font-label-caps text-[12px] text-on-surface uppercase tracking-tight">▌onboard/02-theme.tsx</span>
|
||||
<span class="font-label-caps text-[12px] text-[#a0a0a0] uppercase tracking-widest">STEP 2 OF 3</span>
|
||||
</header>
|
||||
<!-- 2. Hero Illustration Band -->
|
||||
<section class="w-full flex flex-col items-center pt-8 pb-4">
|
||||
<div class="h-[100px] flex items-center justify-center relative">
|
||||
<span class="text-cyan-terminal font-headline text-[48px] mr-4 select-none">▌</span>
|
||||
<div class="flex -space-x-4">
|
||||
<div class="w-[24px] h-[34px] border border-outline bg-surface scanline-pattern transform -rotate-12 translate-y-2"></div>
|
||||
<div class="w-[24px] h-[34px] border border-outline bg-surface checker-pattern transform rotate-0 z-10"></div>
|
||||
<div class="w-[24px] h-[34px] border border-outline bg-surface stripe-pattern transform rotate-12 translate-y-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="font-headline text-[28px] font-bold text-suit-black tracking-tight leading-none">PICK YOUR DECK</h2>
|
||||
</section>
|
||||
<!-- 3. Headline & Description -->
|
||||
<section class="w-full px-margin-edge text-center mb-6">
|
||||
<h3 class="font-headline text-[22px] font-bold text-suit-black mb-1">CHOOSE A CARD-BACK</h3>
|
||||
<p class="font-body-md text-[12px] text-[#a0a0a0] leading-tight">
|
||||
You can swap or import more themes from Settings later.
|
||||
</p>
|
||||
</section>
|
||||
<!-- 4. Theme Selection Grid -->
|
||||
<main class="w-full px-margin-edge grid grid-cols-3 gap-2 flex-grow max-h-[220px]">
|
||||
<!-- Tile 1: Terminal (Active) -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-full aspect-[110/150] bg-surface-container border-2 border-cyan-terminal rounded-lg p-3 relative flex items-center justify-center overflow-hidden">
|
||||
<div class="w-full h-full scanline-pattern border border-outline-variant relative">
|
||||
<div class="absolute top-1 left-1 w-2 h-3 bg-cyan-terminal"></div>
|
||||
<div class="absolute bottom-1 right-1 font-headline text-[10px] text-on-surface opacity-50">▌RS</div>
|
||||
</div>
|
||||
<div class="absolute top-1 right-1 bg-cyan-terminal text-surface w-4 h-4 flex items-center justify-center rounded-full">
|
||||
<span class="material-symbols-outlined text-[12px] font-bold">check</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="mt-2 font-label-caps text-[12px] font-bold text-suit-black tracking-widest uppercase">TERMINAL</span>
|
||||
</div>
|
||||
<!-- Tile 2: Classic -->
|
||||
<div class="flex flex-col items-center opacity-70">
|
||||
<div class="w-full aspect-[110/150] bg-surface-container border border-outline rounded-lg p-3 relative flex items-center justify-center overflow-hidden">
|
||||
<div class="w-full h-full checker-pattern border border-outline-variant"></div>
|
||||
</div>
|
||||
<span class="mt-2 font-label-caps text-[12px] font-bold text-suit-black tracking-widest uppercase">CLASSIC</span>
|
||||
</div>
|
||||
<!-- Tile 3: Stripes -->
|
||||
<div class="flex flex-col items-center opacity-70">
|
||||
<div class="w-full aspect-[110/150] bg-surface-container border border-outline rounded-lg p-3 relative flex items-center justify-center overflow-hidden">
|
||||
<div class="w-full h-full stripe-pattern border border-outline-variant"></div>
|
||||
</div>
|
||||
<span class="mt-2 font-label-caps text-[12px] font-bold text-suit-black tracking-widest uppercase">STRIPES</span>
|
||||
</div>
|
||||
</main>
|
||||
<!-- 5. More Info -->
|
||||
<div class="w-full text-center mt-4">
|
||||
<span class="font-label-caps text-[11px] font-medium text-[#a0a0a0] tracking-widest">
|
||||
<span class="text-cyan-terminal">+</span> MORE IN SETTINGS
|
||||
</span>
|
||||
</div>
|
||||
<!-- 6. Step Indicator -->
|
||||
<section class="w-full flex flex-col items-center mt-6">
|
||||
<div class="flex gap-1 h-2 mb-2">
|
||||
<div class="w-8 h-1 bg-cyan-terminal rounded-full"></div>
|
||||
<div class="w-8 h-1 bg-cyan-terminal rounded-full"></div>
|
||||
<div class="w-8 h-1 bg-outline rounded-full"></div>
|
||||
</div>
|
||||
<div class="font-headline text-[12px] font-medium tracking-[0.2em]">
|
||||
<span class="text-cyan-terminal">[1]</span>
|
||||
<span class="text-cyan-terminal">[2]</span>
|
||||
<span class="text-outline">[3]</span>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 7. Bottom Action Bar -->
|
||||
<footer class="fixed bottom-0 w-full h-[64px] bg-surface-container flex items-center justify-between px-margin-edge z-50">
|
||||
<button class="w-[48%] h-12 border border-outline bg-transparent text-suit-black font-label-caps text-[13px] font-medium uppercase rounded-lg active:bg-surface-variant transition-colors">
|
||||
← BACK
|
||||
</button>
|
||||
<button class="w-[48%] h-12 bg-cyan-terminal text-surface font-label-caps text-[14px] font-bold uppercase rounded-lg active:opacity-80 transition-opacity">
|
||||
NEXT →
|
||||
</button>
|
||||
</footer>
|
||||
<!-- Image descriptive data for the model (hidden visually) -->
|
||||
<div class="hidden" data-alt="A detailed user interface screen for a retro-terminal themed solitaire game called Rusty Solitaire. The background is a deep black with cyan and gray accents. In the center, a card theme selection grid displays three different card back designs: a scanline pattern, a checker pattern, and a striped pattern. The visual style is crisp, technical, and uses monospaced typography to evoke a command-line interface or professional developer environment. The mood is minimalist, efficient, and technologically nostalgic."></div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,212 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Rouge Solitaire - Pause</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700;800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"warning": "#ddb26f",
|
||||
"surface-container-low": "#181c1f",
|
||||
"surface-container": "#1c2023",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"outline-variant": "#3f484e",
|
||||
"on-tertiary-container": "#683476",
|
||||
"surface-container-high": "#272a2d",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"surface-bright": "#363a3d",
|
||||
"outline": "#505050",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"tertiary": "#f7c3ff",
|
||||
"on-surface": "#e0e3e6",
|
||||
"secondary": "#bad073",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"info": "#12cfc0",
|
||||
"primary": "#a1dcff",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"background": "#101417",
|
||||
"surface-container-highest": "#313538",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"highlight-valid": "#acc267",
|
||||
"inverse-primary": "#00668a",
|
||||
"surface-dim": "#101417",
|
||||
"error": "#fb9fb1",
|
||||
"on-error": "#690005",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"suit-red": "#fb9fb1",
|
||||
"suit-black": "#d0d0d0",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"on-error-container": "#ffdad6",
|
||||
"on-primary": "#003549",
|
||||
"surface": "#151515",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"on-secondary": "#293500",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"on-tertiary": "#4c195b",
|
||||
"error-container": "#93000a",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"on-primary-container": "#004f6c",
|
||||
"primary-container": "#6fc2ef",
|
||||
"on-background": "#e0e3e6",
|
||||
"surface-variant": "#313538",
|
||||
"secondary-container": "#435401",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"tertiary-container": "#e1a3ee"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"touch-target-min": "48dp",
|
||||
"action-bar-height": "64px",
|
||||
"stack-overlap": "2rem",
|
||||
"margin-edge": "1rem",
|
||||
"gutter-card": "0.375rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"body-md": ["Inter"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"label-caps": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.scanline {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 50%,
|
||||
rgba(0, 0, 0, 0.05) 50%
|
||||
);
|
||||
background-size: 100% 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-surface font-body-md overflow-hidden antialiased">
|
||||
<!-- Background Tableau (Simulated by Dimmed Image Overlay) -->
|
||||
<div class="fixed inset-0 z-0">
|
||||
<img alt="Game Tableau Background" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDJSHHDQ5Y5qul5C_xabnOSM9aS3uxcWSTk47AOHrS_KIlQi0Ur7YhtL0BomjEWTDc8vRLpytWeG4kf5xgxBzpORahTtsWyXOUPsVRg6_H_qp0QjM6DDo57rOPwjU6TFdfK3Pi7cO9rg-xnUSSu1wu29WyKVwSWDDaA5cZ4QN_9L81YMTCTMKAwDTGsY3eGsj1b1i1X2CdF211aepkhmX8xf4bnV35WSB3QuYxUwlPct0Met7iLFf-AGBeizhK6IAboW5u-Wpg8Ag"/>
|
||||
<!-- 95% Opacity Scrim -->
|
||||
<div class="absolute inset-0 bg-surface opacity-95"></div>
|
||||
<!-- Scanline Overlay for Texture -->
|
||||
<div class="absolute inset-0 scanline"></div>
|
||||
</div>
|
||||
<!-- Modal Container -->
|
||||
<div class="fixed inset-0 z-10 flex items-center justify-center p-margin-edge">
|
||||
<!-- Modal Panel -->
|
||||
<div class="w-[330px] h-[480px] bg-[#202020] border border-outline rounded-xl flex flex-col overflow-hidden">
|
||||
<!-- Title Bar -->
|
||||
<div class="h-[28px] bg-[#1a1a1a] border-b border-[#353535] px-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-primary-container font-headline text-[12px] leading-none mt-px">▌</span>
|
||||
<span class="font-headline text-[12px] text-[#a0a0a0] leading-none">pause.tsx</span>
|
||||
</div>
|
||||
<button class="flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[16px] text-[#505050]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Content Canvas -->
|
||||
<div class="flex-1 flex flex-col items-center pt-8 px-4">
|
||||
<!-- Headline -->
|
||||
<h1 class="font-headline text-[24px] font-bold text-[#d0d0d0] tracking-tight text-center">
|
||||
GAME PAUSED
|
||||
</h1>
|
||||
<!-- Subline -->
|
||||
<p class="font-headline text-[12px] text-[#a0a0a0] mt-1 text-center">
|
||||
12:34 ELAPSED · 87 MOVES · DRAW-3
|
||||
</p>
|
||||
<!-- Mini-Stat Chips -->
|
||||
<div class="flex gap-2 mt-4 justify-center">
|
||||
<div class="bg-[#1a1a1a] border border-[#353535] rounded-sm px-2 py-1 flex flex-col items-center">
|
||||
<span class="font-headline text-[11px] text-[#d0d0d0]">SCORE 247</span>
|
||||
</div>
|
||||
<div class="bg-[#1a1a1a] border border-[#353535] rounded-sm px-2 py-1 flex flex-col items-center">
|
||||
<span class="font-headline text-[11px] text-[#d0d0d0]">STOCK 18</span>
|
||||
</div>
|
||||
<div class="bg-[#1a1a1a] border border-[#353535] rounded-sm px-2 py-1 flex flex-col items-center">
|
||||
<span class="font-headline text-[11px] text-[#d0d0d0]">MOVES 87</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Action Buttons Cluster -->
|
||||
<div class="w-full mt-6 space-y-3">
|
||||
<!-- Primary CTA -->
|
||||
<button class="w-full h-[48px] bg-primary-container text-surface flex items-center justify-center rounded-lg active:scale-95 transition-transform duration-75">
|
||||
<span class="font-headline text-[14px] font-bold tracking-[0.08em] uppercase">▶ RESUME GAME</span>
|
||||
</button>
|
||||
<!-- Secondary Buttons -->
|
||||
<button class="w-full h-[48px] bg-transparent border border-outline text-[#d0d0d0] flex items-center justify-center rounded-lg hover:border-primary-container hover:text-primary-container transition-colors duration-120">
|
||||
<span class="font-headline text-[13px] font-medium tracking-[0.08em] uppercase">↻ RESTART</span>
|
||||
</button>
|
||||
<button class="w-full h-[48px] bg-transparent border border-outline text-[#d0d0d0] flex items-center justify-center rounded-lg hover:border-primary-container hover:text-primary-container transition-colors duration-120">
|
||||
<span class="font-headline text-[13px] font-medium tracking-[0.08em] uppercase">✕ FORFEIT</span>
|
||||
</button>
|
||||
<button class="w-full h-[48px] bg-transparent border border-outline text-[#d0d0d0] flex items-center justify-center rounded-lg hover:border-primary-container hover:text-primary-container transition-colors duration-120">
|
||||
<span class="font-headline text-[13px] font-medium tracking-[0.08em] uppercase">⌂ QUIT TO MENU</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer Status Line -->
|
||||
<div class="h-[24px] border-t border-[#353535] px-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-primary-container font-headline text-[11px]">▌</span>
|
||||
<span class="font-headline text-[11px] text-[#a0a0a0]">NORMAL</span>
|
||||
<span class="text-[#505050] text-[11px]">│</span>
|
||||
<span class="font-headline text-[11px] text-[#a0a0a0]">pause</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 font-headline text-[11px]">
|
||||
<span class="text-[#a0a0a0]">[ESC]</span>
|
||||
<span class="text-[#505050]">resume</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hidden Navigation Shell (Suppressed due to Task-Focused Modal Context) -->
|
||||
<!-- But included visually as per the brand anchor hierarchy for TopAppBar identity if it were visible -->
|
||||
<header class="hidden fixed top-0 w-full h-action-bar-height flex items-center justify-between px-margin-edge w-full bg-background border-b border-outline-variant">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-primary">terminal</span>
|
||||
<span class="font-headline text-headline text-primary uppercase tracking-tighter">▌ROUGE_SOLITAIRE</span>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Bottom Nav Suppression Logic: Not rendered to prioritize the focus canvas -->
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,274 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;700&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
/* CRT Scanline Overlay Effect */
|
||||
.crt-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.03));
|
||||
background-size: 100% 3px, 3px 100%;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #151515;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #505050;
|
||||
}
|
||||
</style>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"surface-container-low": "#181c1f",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"on-error": "#690005",
|
||||
"suit-black": "#d0d0d0",
|
||||
"inverse-primary": "#00668a",
|
||||
"on-primary-container": "#004f6c",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"highlight-valid": "#acc267",
|
||||
"surface-container-highest": "#313538",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"warning": "#ddb26f",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"on-primary": "#003549",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"primary-container": "#6fc2ef",
|
||||
"on-background": "#e0e3e6",
|
||||
"error-container": "#93000a",
|
||||
"outline": "#505050",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"on-surface": "#e0e3e6",
|
||||
"on-secondary": "#293500",
|
||||
"surface-container-high": "#272a2d",
|
||||
"on-tertiary": "#4c195b",
|
||||
"secondary": "#bad073",
|
||||
"on-tertiary-container": "#683476",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"surface-dim": "#101417",
|
||||
"primary": "#a1dcff",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"on-error-container": "#ffdad6",
|
||||
"surface-variant": "#313538",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"surface": "#151515",
|
||||
"tertiary": "#f7c3ff",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"surface-bright": "#363a3d",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"outline-variant": "#3f484e",
|
||||
"info": "#12cfc0",
|
||||
"secondary-container": "#435401",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"error": "#fb9fb1",
|
||||
"suit-red": "#fb9fb1",
|
||||
"background": "#101417",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"surface-container": "#202020"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"stack-overlap": "2rem",
|
||||
"action-bar-height": "64px",
|
||||
"touch-target-min": "48dp",
|
||||
"gutter-card": "0.375rem",
|
||||
"margin-edge": "1rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["JetBrains Mono"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"card-rank": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-background font-body-md selection:bg-primary selection:text-background overflow-x-hidden">
|
||||
<div class="crt-overlay"></div>
|
||||
<!-- Status Bar -->
|
||||
<header class="bg-surface-container h-[32px] w-full flex items-center justify-between px-margin-edge z-50">
|
||||
<div class="font-headline text-[12px] text-on-surface-variant tracking-wider">
|
||||
▌profile.tsx
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-info"></span>
|
||||
<span class="font-label-caps text-[10px] text-on-surface">● SYNCED</span>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Main Canvas -->
|
||||
<main class="flex-1 overflow-y-auto pb-24">
|
||||
<!-- Profile Header -->
|
||||
<section class="h-[120px] bg-surface-container border-b border-outline-variant flex items-center px-margin-edge gap-4">
|
||||
<div class="w-[64px] h-[64px] bg-[#1a1a1a] border border-outline flex items-center justify-center shrink-0">
|
||||
<span class="font-headline text-[28px] text-primary-container">RS</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 overflow-hidden">
|
||||
<h1 class="font-headline text-[18px] text-on-surface truncate">anonymous@local</h1>
|
||||
<p class="font-label-caps text-on-surface-variant text-[10px]">MEMBER SINCE 2026-04-22</p>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<span class="px-2 py-0.5 bg-[#1a1a1a] font-label-caps text-[10px] text-suit-black">247 GAMES</span>
|
||||
<span class="px-2 py-0.5 bg-[#1a1a1a] font-label-caps text-[10px] text-suit-black">61% WR</span>
|
||||
<span class="px-2 py-0.5 bg-[#1a1a1a] font-label-caps text-[10px] text-suit-black">12 STREAK</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Level/XP Section -->
|
||||
<section class="p-margin-edge bg-surface-container border-b border-outline-variant">
|
||||
<div class="flex justify-between items-baseline mb-2">
|
||||
<span class="font-headline text-[24px] text-on-surface">LEVEL 12</span>
|
||||
<span class="font-hud-timer text-on-surface-variant">320/500 XP</span>
|
||||
</div>
|
||||
<div class="h-3 w-full bg-[#353535] relative overflow-hidden">
|
||||
<div class="h-full bg-primary-container" style="width: 64%;"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-3">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-highlight-celebration"></span>
|
||||
<span class="font-label-caps text-[10px] text-on-surface-variant">180 XP TO LEVEL 13</span>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Unlocked Cards -->
|
||||
<section class="mt-6">
|
||||
<h2 class="px-margin-edge font-headline text-[14px] text-on-surface mb-4">▌ unlocked.cards</h2>
|
||||
<div class="flex overflow-x-auto gap-4 px-margin-edge pb-4 custom-scrollbar">
|
||||
<!-- Terminal (Active) -->
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<div class="w-[60px] h-[84px] bg-surface-container-low border-2 border-primary-container relative flex items-center justify-center p-1">
|
||||
<div class="w-full h-full bg-[#151515] overflow-hidden flex flex-col p-1">
|
||||
<div class="w-2 h-2.5 bg-primary-container mb-auto"></div>
|
||||
<div class="self-end text-[8px] font-headline text-on-surface-variant opacity-50">▌RS</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-label-caps text-[9px] text-primary-container text-center">ACTIVE</span>
|
||||
</div>
|
||||
<!-- Classic -->
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<div class="w-[60px] h-[84px] bg-white border border-outline relative p-1">
|
||||
<div class="w-full h-full border border-red-200 bg-red-50 opacity-20"></div>
|
||||
</div>
|
||||
<span class="font-label-caps text-[9px] text-on-surface-variant text-center opacity-50">TAP TO USE</span>
|
||||
</div>
|
||||
<!-- Stripes -->
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<div class="w-[60px] h-[84px] bg-surface-container border border-outline p-1">
|
||||
<div class="w-full h-full bg-gradient-to-br from-secondary-container via-surface to-secondary-container opacity-40"></div>
|
||||
</div>
|
||||
<span class="font-label-caps text-[9px] text-on-surface-variant text-center opacity-50">TAP TO USE</span>
|
||||
</div>
|
||||
<!-- Polka -->
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<div class="w-[60px] h-[84px] bg-surface-container border border-outline p-1 overflow-hidden relative">
|
||||
<div class="w-full h-full opacity-30" style="background-image: radial-gradient(#505050 1px, transparent 0); background-size: 6px 6px;"></div>
|
||||
</div>
|
||||
<span class="font-label-caps text-[9px] text-on-surface-variant text-center opacity-50">TAP TO USE</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Unlocked Backgrounds -->
|
||||
<section class="mt-6">
|
||||
<h2 class="px-margin-edge font-headline text-[14px] text-on-surface mb-4">▌ unlocked.backgrounds</h2>
|
||||
<div class="flex overflow-x-auto gap-4 px-margin-edge pb-4 custom-scrollbar">
|
||||
<!-- Default (Active) -->
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<div class="w-[80px] h-[56px] bg-[#151515] border-2 border-primary-container"></div>
|
||||
<span class="font-label-caps text-[9px] text-primary-container text-center">ACTIVE</span>
|
||||
</div>
|
||||
<!-- Forest -->
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<div class="w-[80px] h-[56px] bg-[#0d160d] border border-outline"></div>
|
||||
<span class="font-label-caps text-[9px] text-on-surface-variant text-center opacity-50">FOREST</span>
|
||||
</div>
|
||||
<!-- Slate -->
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<div class="w-[80px] h-[56px] bg-[#1c2128] border border-outline"></div>
|
||||
<span class="font-label-caps text-[9px] text-on-surface-variant text-center opacity-50">SLATE</span>
|
||||
</div>
|
||||
<!-- Midnight -->
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<div class="w-[80px] h-[56px] bg-[#09090b] border border-outline"></div>
|
||||
<span class="font-label-caps text-[9px] text-on-surface-variant text-center opacity-50">MIDNIGHT</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Sign-in Card -->
|
||||
<section class="mt-8 px-margin-edge">
|
||||
<button class="w-full h-[64px] bg-surface-container border border-dashed border-outline flex items-center justify-between px-6 hover:bg-surface-variant transition-colors group">
|
||||
<span class="font-label-caps text-on-surface-variant tracking-widest">+ SIGN IN TO SYNC PROGRESS</span>
|
||||
<span class="material-symbols-outlined text-primary-container group-hover:translate-x-1 transition-transform">arrow_forward</span>
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
<!-- TopAppBar (from Shared Components - as Terminal Header) -->
|
||||
<div class="fixed top-[32px] left-0 w-full z-40 bg-background border-b border-outline-variant flex items-center justify-between px-margin-edge h-action-bar-height">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-primary">terminal</span>
|
||||
<span class="font-headline text-headline text-primary tracking-tighter">~/root/usr/settings</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<button class="p-2 hover:bg-surface-variant text-primary transition-colors">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- BottomNavBar (from Shared Components - as Terminal Footer) -->
|
||||
<nav class="fixed bottom-0 left-0 w-full z-50 h-[24px] bg-surface-container-lowest border-t border-outline-variant flex justify-between items-center px-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-label-caps text-[10px] text-on-surface-variant">▌ NORMAL │ profile</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="flex items-center gap-1 group">
|
||||
<span class="font-label-caps text-[10px] text-on-surface-variant group-hover:text-primary">[ESC] back</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Decorative CRT Scanline overlay line -->
|
||||
<div class="fixed top-0 left-0 w-full h-[1px] bg-primary opacity-20 pointer-events-none animate-pulse"></div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,271 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"surface-variant": "#313538",
|
||||
"surface-dim": "#101417",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"surface-bright": "#363a3d",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"secondary-container": "#435401",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"surface-container-highest": "#313538",
|
||||
"outline-variant": "#3f484e",
|
||||
"error": "#fb9fb1",
|
||||
"surface-container": "#202020",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"outline": "#505050",
|
||||
"on-secondary": "#293500",
|
||||
"suit-red": "#fb9fb1",
|
||||
"inverse-primary": "#00668a",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"warning": "#ddb26f",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"info": "#12cfc0",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"highlight-valid": "#acc267",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"on-tertiary": "#4c195b",
|
||||
"background": "#101417",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"suit-black": "#d0d0d0",
|
||||
"on-error-container": "#ffdad6",
|
||||
"on-surface": "#d0d0d0",
|
||||
"primary": "#a1dcff",
|
||||
"error-container": "#93000a",
|
||||
"secondary": "#bad073",
|
||||
"surface": "#151515",
|
||||
"primary-container": "#6fc2ef",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"on-primary": "#003549",
|
||||
"surface-container-low": "#181c1f",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"on-tertiary-container": "#683476",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"surface-container-high": "#272a2d",
|
||||
"on-error": "#690005",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"tertiary": "#f7c3ff",
|
||||
"on-background": "#e0e3e6",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"on-primary-container": "#004f6c",
|
||||
"inverse-surface": "#e0e3e6"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"action-bar-height": "64px",
|
||||
"stack-overlap": "2rem",
|
||||
"gutter-card": "0.375rem",
|
||||
"touch-target-min": "48dp",
|
||||
"margin-edge": "1rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.radial-segment {
|
||||
clip-path: polygon(50% 50%, 100% 0, 100% 100%);
|
||||
}
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.scanline-bg {
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(26, 26, 26, 1) 0px,
|
||||
rgba(26, 26, 26, 1) 2px,
|
||||
rgba(21, 21, 21, 1) 2px,
|
||||
rgba(21, 21, 21, 1) 4px
|
||||
);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface font-body-md text-on-surface select-none overflow-hidden h-screen w-screen flex flex-col">
|
||||
<!-- Underlying Game Tableau (Dimmed Background) -->
|
||||
<main class="relative flex-grow opacity-30 grid grid-cols-7 gap-2 p-margin-edge pointer-events-none">
|
||||
<!-- Top row: Foundation/Stock -->
|
||||
<div class="col-span-1 aspect-[2/3] border border-dashed border-outline-variant bg-surface-container flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-outline-variant">terminal</span>
|
||||
</div>
|
||||
<div class="col-span-1 aspect-[2/3] border border-dashed border-outline-variant"></div>
|
||||
<div class="col-span-1"></div>
|
||||
<div class="col-span-1 aspect-[2/3] border border-outline border-suit-red-cb flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-suit-red-cb">favorite</span>
|
||||
</div>
|
||||
<div class="col-span-1 aspect-[2/3] border border-outline border-suit-black flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-suit-black">backspace</span>
|
||||
</div>
|
||||
<div class="col-span-1 aspect-[2/3] border border-outline border-suit-red-cb flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-suit-red-cb">diamond</span>
|
||||
</div>
|
||||
<div class="col-span-1 aspect-[2/3] border border-outline border-suit-black flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-suit-black">spa</span>
|
||||
</div>
|
||||
<!-- Tableau piles -->
|
||||
<div class="col-span-7 grid grid-cols-7 gap-2 mt-4">
|
||||
<div class="space-y-[-120%]">
|
||||
<div class="aspect-[2/3] bg-surface-container-high border border-outline p-1 flex flex-col justify-between">
|
||||
<span class="font-card-rank text-card-rank text-suit-red-cb">K</span>
|
||||
<span class="material-symbols-outlined self-end text-3xl rotate-180 text-suit-red-cb">diamond</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-[-120%]">
|
||||
<div class="aspect-[2/3] scanline-bg border border-outline relative">
|
||||
<div class="absolute top-1 left-1 w-3 h-4 bg-primary"></div>
|
||||
<span class="absolute bottom-1 right-1 font-label-caps text-[10px] text-primary">▌RS</span>
|
||||
</div>
|
||||
<div class="aspect-[2/3] bg-surface-container-high border border-outline p-1 flex flex-col justify-between">
|
||||
<span class="font-card-rank text-card-rank text-suit-black">Q</span>
|
||||
<span class="material-symbols-outlined self-end text-3xl rotate-180 text-suit-black" style="font-variation-settings: 'FILL' 1;">spa</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-[-120%]">
|
||||
<div class="aspect-[2/3] scanline-bg border border-outline"></div>
|
||||
<div class="aspect-[2/3] scanline-bg border border-outline"></div>
|
||||
<div class="aspect-[2/3] bg-surface-container-high border border-outline p-1 flex flex-col justify-between">
|
||||
<span class="font-card-rank text-card-rank text-suit-red-cb">J</span>
|
||||
<span class="material-symbols-outlined self-end text-3xl rotate-180 text-suit-red-cb" style="font-variation-settings: 'FILL' 1;">favorite</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- More stacks... omitted for brevity as background -->
|
||||
</div>
|
||||
</main>
|
||||
<!-- Radial Menu Overlay -->
|
||||
<div class="fixed inset-0 z-50 bg-[#151515]/70 flex items-center justify-center overflow-hidden">
|
||||
<div class="relative w-[280px] h-[280px] flex items-center justify-center">
|
||||
<!-- Outer Circular Ring Shell -->
|
||||
<div class="absolute inset-0 rounded-full border border-outline bg-surface-container overflow-hidden">
|
||||
<!-- SVG Segments Construction -->
|
||||
<svg class="w-full h-full transform -rotate-22.5" viewbox="0 0 100 100">
|
||||
<!-- Slice 1 (UNDO) - Top / 12:00 -->
|
||||
<!-- Active state: bg-primary-container/15, stroke-primary -->
|
||||
<path d="M 50 50 L 50 0 A 50 50 0 0 1 85.35 14.65 Z" fill="#6fc2ef26" stroke="#6fc2ef" stroke-width="0.5" transform="rotate(-22.5, 50, 50)"></path>
|
||||
<!-- Slice 2 (REDO) -->
|
||||
<path d="M 50 50 L 85.35 14.65 A 50 50 0 0 1 100 50 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
|
||||
<!-- Slice 3 (HINT) -->
|
||||
<path d="M 50 50 L 100 50 A 50 50 0 0 1 85.35 85.35 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
|
||||
<!-- Slice 4 (AUTO) -->
|
||||
<path d="M 50 50 L 85.35 85.35 A 50 50 0 0 1 50 100 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
|
||||
<!-- Slice 5 (NEW) -->
|
||||
<path d="M 50 50 L 50 100 A 50 50 0 0 1 14.65 85.35 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
|
||||
<!-- Slice 6 (PAUSE) -->
|
||||
<path d="M 50 50 L 14.65 85.35 A 50 50 0 0 1 0 50 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
|
||||
<!-- Slice 7 (STATS) -->
|
||||
<path d="M 50 50 L 0 50 A 50 50 0 0 1 14.65 14.65 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
|
||||
<!-- Slice 8 (SETTINGS) -->
|
||||
<path d="M 50 50 L 14.65 14.65 A 50 50 0 0 1 50 0 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Labels and Icons Overlay -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<!-- 12:00 UNDO (ACTIVE) -->
|
||||
<div class="absolute top-[12%] left-1/2 -translate-x-1/2 flex flex-col items-center text-primary">
|
||||
<span class="material-symbols-outlined text-[24px]" data-icon="undo">undo</span>
|
||||
<span class="font-label-caps text-[11px] mt-1">UNDO</span>
|
||||
</div>
|
||||
<!-- 1:30 REDO -->
|
||||
<div class="absolute top-[22%] right-[12%] flex flex-col items-center text-on-surface">
|
||||
<span class="material-symbols-outlined text-[24px]" data-icon="redo">redo</span>
|
||||
<span class="font-label-caps text-[11px] mt-1">REDO</span>
|
||||
</div>
|
||||
<!-- 3:00 HINT -->
|
||||
<div class="absolute top-1/2 right-[8%] -translate-y-1/2 flex flex-col items-center text-on-surface">
|
||||
<span class="material-symbols-outlined text-[24px]" data-icon="lightbulb">lightbulb</span>
|
||||
<span class="font-label-caps text-[11px] mt-1">HINT</span>
|
||||
</div>
|
||||
<!-- 4:30 AUTO -->
|
||||
<div class="absolute bottom-[22%] right-[12%] flex flex-col items-center text-on-surface">
|
||||
<span class="material-symbols-outlined text-[24px]" data-icon="double_arrow">double_arrow</span>
|
||||
<span class="font-label-caps text-[11px] mt-1">AUTO</span>
|
||||
</div>
|
||||
<!-- 6:00 NEW -->
|
||||
<div class="absolute bottom-[12%] left-1/2 -translate-x-1/2 flex flex-col items-center text-on-surface">
|
||||
<span class="material-symbols-outlined text-[24px]" data-icon="add">add</span>
|
||||
<span class="font-label-caps text-[11px] mt-1">NEW</span>
|
||||
</div>
|
||||
<!-- 7:30 PAUSE -->
|
||||
<div class="absolute bottom-[22%] left-[12%] flex flex-col items-center text-on-surface">
|
||||
<span class="material-symbols-outlined text-[24px]" data-icon="pause">pause</span>
|
||||
<span class="font-label-caps text-[11px] mt-1">PAUSE</span>
|
||||
</div>
|
||||
<!-- 9:00 STATS -->
|
||||
<div class="absolute top-1/2 left-[8%] -translate-y-1/2 flex flex-col items-center text-on-surface">
|
||||
<span class="material-symbols-outlined text-[24px]" data-icon="bar_chart">bar_chart</span>
|
||||
<span class="font-label-caps text-[11px] mt-1">STATS</span>
|
||||
</div>
|
||||
<!-- 10:30 SETTINGS -->
|
||||
<div class="absolute top-[22%] left-[12%] flex flex-col items-center text-on-surface">
|
||||
<span class="material-symbols-outlined text-[24px]" data-icon="settings">settings</span>
|
||||
<span class="font-label-caps text-[11px] mt-1">SETTINGS</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Inner Hole -->
|
||||
<div class="absolute w-20 h-20 rounded-full bg-surface-container border border-outline-variant flex flex-col items-center justify-center z-10">
|
||||
<div class="font-headline text-[32px] text-primary leading-none">▌</div>
|
||||
<div class="font-label-caps text-[10px] text-on-surface-variant tracking-widest mt-1">RADIAL</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Instructions (Bottom Floating) -->
|
||||
<div class="absolute bottom-12 left-0 w-full flex flex-col items-center gap-4">
|
||||
<div class="font-label-caps text-[12px] text-on-surface-variant tracking-wider">
|
||||
DRAG TO SELECT · RELEASE TO ACTIVATE
|
||||
</div>
|
||||
<!-- Status Line (Vim style) -->
|
||||
<div class="w-full h-8 bg-surface-container border-t border-outline-variant flex items-center justify-center">
|
||||
<span class="font-label-caps text-[11px] text-on-surface-variant">
|
||||
<span class="text-primary">▌</span> NORMAL │ radial · 1/8 selected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hidden image for standard requirement compliance, though not visually used in this specific overlay task -->
|
||||
<div class="hidden">
|
||||
<img data-alt="A macro shot of a vintage terminal screen displaying green computer code and technical data. The lighting is low-key, with a soft glow emanating from the screen, highlighting the CRT scanlines and subtle reflections. The aesthetic is purely technical and retro-futuristic, focusing on precision and high-contrast digital artifacts. Deep blacks and vibrant green neon tones dominate the color palette, evoking a high-performance system environment." src="https://lh3.googleusercontent.com/aida-public/AB6AXuAQuJUCOQev_BN72KyX0c-ylmW3DMZD-gOUlylYo3w1SrSpGnvorMvSUwe5oGPAgBgc050cCowC8f1QaxHEDN-DUkyCynOLhzrZHXyCJh2ebCWd6x1quLQwp0ffwbHsZW1-J2zAMuUydMNpEVmpHFQDij0yjVg6lxc6JdsC0etMoAWMhb61S3HUoDffSl-Q23N8Oc77r3dSf6kLFKAMAJCbXFz4nTaJKCKAwtMs62pLr6fd1jzMZrItH43RaO28uzMzvnGGZj3Miw"/>
|
||||
</div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,284 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Solitaire Replay Overlay</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"on-background": "#e0e3e6",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"surface-container": "#202020",
|
||||
"primary": "#a1dcff",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"outline": "#505050",
|
||||
"suit-black": "#d0d0d0",
|
||||
"secondary": "#bad073",
|
||||
"on-surface": "#d0d0d0",
|
||||
"on-primary": "#003549",
|
||||
"on-error-container": "#ffdad6",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"surface-bright": "#363a3d",
|
||||
"surface-variant": "#313538",
|
||||
"secondary-container": "#435401",
|
||||
"surface-container-highest": "#313538",
|
||||
"surface-container-low": "#181c1f",
|
||||
"primary-container": "#6fc2ef",
|
||||
"on-error": "#690005",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"on-tertiary-container": "#683476",
|
||||
"suit-red": "#fb9fb1",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"on-primary-container": "#004f6c",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"warning": "#ddb26f",
|
||||
"on-secondary": "#293500",
|
||||
"info": "#12cfc0",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"background": "#101417",
|
||||
"surface-container-high": "#272a2d",
|
||||
"surface-dim": "#101417",
|
||||
"surface": "#151515",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"error-container": "#93000a",
|
||||
"tertiary": "#f7c3ff",
|
||||
"inverse-primary": "#00668a",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"highlight-valid": "#acc267",
|
||||
"outline-variant": "#3f484e",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"error": "#fb9fb1",
|
||||
"on-tertiary": "#4c195b",
|
||||
"on-secondary-container": "#b2c86d"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"touch-target-min": "48dp",
|
||||
"stack-overlap": "2rem",
|
||||
"margin-edge": "1rem",
|
||||
"action-bar-height": "64px",
|
||||
"gutter-card": "0.375rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.card-back-pattern {
|
||||
background-color: #151515;
|
||||
background-image: repeating-linear-gradient(0deg, transparent, transparent 2px, #1a1a1a 2px, #1a1a1a 4px);
|
||||
}
|
||||
/* Custom mechanical transition style */
|
||||
.mechanical-transition {
|
||||
transition: all 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface text-on-surface font-body-md select-none overflow-hidden flex flex-col h-[844px] w-[390px] mx-auto border-x border-outline-variant">
|
||||
<!-- Status Bar -->
|
||||
<header class="h-8 bg-surface-container flex items-center justify-between px-4 border-b border-outline-variant flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-primary font-headline text-[14px]">▌replay.tsx</span>
|
||||
</div>
|
||||
<div class="flex items-center text-on-surface-variant font-label-caps text-[10px] tracking-wider uppercase">
|
||||
GAME #2024-127 · 87 MOVES
|
||||
</div>
|
||||
</header>
|
||||
<!-- Game Peek Band (Tableau) -->
|
||||
<main class="h-[240px] relative bg-background overflow-hidden border-b border-outline-variant">
|
||||
<!-- 7-Column Tableau (Dimmed 50%) -->
|
||||
<div class="absolute inset-0 opacity-50 flex justify-around p-2 gap-1">
|
||||
<!-- Tableau Columns 1-7 -->
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<div class="w-full aspect-[2/3] border border-dashed border-outline-variant mb-1"></div>
|
||||
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline"></div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline"></div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline"></div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline"></div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline"></div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline"></div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline relative">
|
||||
<!-- Central Focused Card (Move 47) -->
|
||||
<div class="absolute inset-0 z-20 opacity-100">
|
||||
<!-- Shadow-less highlight using glow outline -->
|
||||
<div class="w-full h-full bg-[#1a1a1a] border border-suit-red-cb ring-2 ring-suit-red-cb/40 flex flex-col justify-between p-1">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-card-rank text-card-rank text-suit-red-cb">4</span>
|
||||
<span class="material-symbols-outlined text-[14px] text-suit-red-cb" data-icon="diamond">diamond</span>
|
||||
</div>
|
||||
<div class="self-end rotate-180 flex flex-col">
|
||||
<span class="font-card-rank text-card-rank text-suit-red-cb">4</span>
|
||||
<span class="material-symbols-outlined text-[14px] text-suit-red-cb" data-icon="diamond">diamond</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Move Chip -->
|
||||
<div class="absolute -top-6 left-1/2 -translate-x-1/2 bg-suit-red-cb px-2 py-0.5 rounded-sm">
|
||||
<span class="text-surface font-label-caps text-[9px] font-bold">MOVE 47/87</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Playback Toolbar -->
|
||||
<div class="h-16 bg-surface-container flex items-center justify-between px-4 border-b border-outline-variant">
|
||||
<!-- Left: Timer -->
|
||||
<div class="flex flex-col">
|
||||
<span class="text-on-surface font-hud-timer text-[18px] font-bold leading-none">00:42</span>
|
||||
<span class="text-[11px] text-[#a0a0a0] font-label-caps tracking-tighter">/ 02:18</span>
|
||||
</div>
|
||||
<!-- Center: Controls -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="material-symbols-outlined text-on-surface-variant hover:text-primary mechanical-transition" data-icon="skip_previous">skip_previous</button>
|
||||
<button class="material-symbols-outlined text-on-surface-variant hover:text-primary mechanical-transition" data-icon="arrow_left">arrow_left</button>
|
||||
<button class="material-symbols-outlined text-suit-red-cb text-[32px] mechanical-transition" data-icon="play_arrow">play_arrow</button>
|
||||
<button class="material-symbols-outlined text-on-surface-variant hover:text-primary mechanical-transition" data-icon="arrow_right">arrow_right</button>
|
||||
<button class="material-symbols-outlined text-on-surface-variant hover:text-primary mechanical-transition" data-icon="skip_next">skip_next</button>
|
||||
</div>
|
||||
<!-- Right: Speed -->
|
||||
<div class="flex items-center bg-surface-variant border border-outline px-2 py-1 gap-1">
|
||||
<span class="font-label-caps text-[14px] font-bold text-on-surface">1.0x</span>
|
||||
<span class="material-symbols-outlined text-[16px] text-on-surface-variant" data-icon="unfold_more">unfold_more</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Scrub Bar Area -->
|
||||
<div class="px-margin-edge pt-6 pb-8 bg-surface-container-low border-b border-outline-variant">
|
||||
<div class="relative w-full h-1 bg-outline rounded-full">
|
||||
<!-- Cyan Progress Track -->
|
||||
<div class="absolute left-0 top-0 h-full bg-suit-red-cb" style="width: 54%;"></div>
|
||||
<!-- Notches & Labels -->
|
||||
<div class="absolute inset-0 flex justify-between">
|
||||
<div class="relative">
|
||||
<div class="w-[1px] h-3 bg-outline -mt-1"></div>
|
||||
<span class="absolute -bottom-5 left-1/2 -translate-x-1/2 text-[9px] font-label-caps text-outline">0%</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="w-[1px] h-3 bg-outline -mt-1"></div>
|
||||
<span class="absolute -bottom-5 left-1/2 -translate-x-1/2 text-[9px] font-label-caps text-outline">25%</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="w-[1px] h-3 bg-outline -mt-1"></div>
|
||||
<span class="absolute -bottom-5 left-1/2 -translate-x-1/2 text-[9px] font-label-caps text-outline">50%</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="w-[1px] h-3 bg-outline -mt-1"></div>
|
||||
<span class="absolute -bottom-5 left-1/2 -translate-x-1/2 text-[9px] font-label-caps text-outline">75%</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="w-[1px] h-3 bg-outline -mt-1"></div>
|
||||
<span class="absolute -bottom-5 left-1/2 -translate-x-1/2 text-[9px] font-label-caps text-outline">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Current Marker (54%) -->
|
||||
<div class="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-suit-red-cb border border-surface" style="left: 54%;"></div>
|
||||
<div class="absolute -top-4 left-[54%] -translate-x-1/2 text-[10px] text-suit-red-cb font-label-caps font-bold">47/87</div>
|
||||
<!-- Win Marker (72%) -->
|
||||
<div class="absolute top-0 w-[2px] h-3 bg-highlight-valid -translate-y-1" style="left: 72%;"></div>
|
||||
<div class="absolute -bottom-5 left-[72%] -translate-x-1/2 text-[8px] text-highlight-valid font-label-caps font-bold whitespace-nowrap">WIN MOVE</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Move Log Card -->
|
||||
<section class="flex-1 bg-surface-container p-4 overflow-y-auto">
|
||||
<h3 class="font-label-caps text-label-caps text-on-surface-variant mb-4 flex items-center gap-2">
|
||||
<span class="w-1.5 h-3 bg-primary block"></span>
|
||||
MOVE LOG · 47/87
|
||||
</h3>
|
||||
<div class="flex flex-col font-label-caps text-[12px]">
|
||||
<!-- Log Rows -->
|
||||
<div class="flex items-center h-6 px-2 text-[#a0a0a0] border-b border-outline-variant/30">
|
||||
<span class="w-8">44 |</span>
|
||||
<span>5♥ → tableau col 3</span>
|
||||
</div>
|
||||
<div class="flex items-center h-6 px-2 text-[#a0a0a0] border-b border-outline-variant/30">
|
||||
<span class="w-8">45 |</span>
|
||||
<span>8♣ → tableau col 1</span>
|
||||
</div>
|
||||
<div class="flex items-center h-6 px-2 text-[#a0a0a0] border-b border-outline-variant/30">
|
||||
<span class="w-8">46 |</span>
|
||||
<span>stock cycle</span>
|
||||
</div>
|
||||
<!-- Highlighted Active Move -->
|
||||
<div class="flex items-center h-6 px-2 bg-suit-red-cb text-surface-container font-bold">
|
||||
<span class="w-8">▶ 47 |</span>
|
||||
<span>4♦ → 5♣ on col 7</span>
|
||||
</div>
|
||||
<div class="flex items-center h-6 px-2 text-on-surface border-b border-outline-variant/30">
|
||||
<span class="w-8">48 |</span>
|
||||
<span class="material-symbols-outlined text-[14px] align-middle mr-1" data-icon="foundation">foundation</span> A♠ → foundation
|
||||
</div>
|
||||
<div class="flex items-center h-6 px-2 text-on-surface border-b border-outline-variant/30">
|
||||
<span class="w-8">49 |</span>
|
||||
<span class="material-symbols-outlined text-[14px] align-middle mr-1" data-icon="foundation">foundation</span> 2♠ → foundation
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Footer -->
|
||||
<footer class="h-6 bg-surface-container flex items-center justify-between px-4 border-t border-outline-variant flex-shrink-0 text-[10px] font-label-caps tracking-wider">
|
||||
<div class="text-on-surface-variant">
|
||||
<span class="text-primary">▌</span> NORMAL │ replay
|
||||
</div>
|
||||
<div class="text-on-surface-variant opacity-70">
|
||||
[SPACE] play · [← →] scrub · [ESC]
|
||||
</div>
|
||||
</footer>
|
||||
<!-- Overlay Background (For visualization of depth) -->
|
||||
<div class="fixed inset-0 pointer-events-none border-[16px] border-surface-dim/40 z-50"></div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,258 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"on-secondary": "#293500",
|
||||
"surface-container": "#1c2023",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"suit-black": "#d0d0d0",
|
||||
"on-background": "#e0e3e6",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"on-tertiary": "#4c195b",
|
||||
"on-error": "#690005",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"on-primary-container": "#004f6c",
|
||||
"on-error-container": "#ffdad6",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"on-surface": "#e0e3e6",
|
||||
"secondary-container": "#435401",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"suit-red": "#fb9fb1",
|
||||
"tertiary": "#f7c3ff",
|
||||
"surface-dim": "#101417",
|
||||
"background": "#101417",
|
||||
"error": "#fb9fb1",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"on-primary": "#003549",
|
||||
"on-tertiary-container": "#683476",
|
||||
"surface-container-low": "#181c1f",
|
||||
"warning": "#ddb26f",
|
||||
"surface-variant": "#313538",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"primary": "#a1dcff",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"surface": "#151515",
|
||||
"secondary": "#bad073",
|
||||
"outline-variant": "#3f484e",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"highlight-valid": "#acc267",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"surface-bright": "#363a3d",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"primary-container": "#6fc2ef",
|
||||
"surface-container-highest": "#313538",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"surface-container-high": "#272a2d",
|
||||
"inverse-primary": "#00668a",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"outline": "#505050",
|
||||
"info": "#12cfc0",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"error-container": "#93000a"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"gutter-card": "0.375rem",
|
||||
"stack-overlap": "2rem",
|
||||
"margin-edge": "1rem",
|
||||
"touch-target-min": "48dp",
|
||||
"action-bar-height": "64px",
|
||||
"top-action-bar-height": "32px",
|
||||
"bottom-action-bar-height": "24px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"body-md": ["Inter"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
}
|
||||
.card-back-pattern {
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
#151515,
|
||||
#151515 2px,
|
||||
#1a1a1a 2px,
|
||||
#1a1a1a 4px
|
||||
);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-background font-body-md selection:bg-primary selection:text-on-primary-container overflow-hidden flex flex-col h-[844px] w-[390px] mx-auto border-x border-outline-variant">
|
||||
<!-- Status Bar / TopAppBar Logic -->
|
||||
<header class="bg-[#202020] h-8 flex items-center justify-between px-4 w-full z-50">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-primary font-headline text-[12px]">▌</span>
|
||||
<span class="text-on-surface font-label-caps text-[12px]">settings.toml</span>
|
||||
</div>
|
||||
<div class="text-outline font-label-caps text-[12px]">v0.20.0</div>
|
||||
</header>
|
||||
<!-- NavigationDrawer (Tab Strip Mapping) -->
|
||||
<nav class="bg-[#1a1a1a] h-10 border-b border-[#353535] flex items-center w-full">
|
||||
<!-- COSMETIC Tab (Active) -->
|
||||
<div class="h-full flex-1 flex flex-col items-center justify-center relative">
|
||||
<span class="text-primary font-label-caps text-[12px] px-2">[ COSMETIC ]</span>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-[2px] bg-primary"></div>
|
||||
</div>
|
||||
<!-- GAMEPLAY Tab -->
|
||||
<div class="h-full flex-1 flex items-center justify-center">
|
||||
<span class="text-[#a0a0a0] font-label-caps text-[12px]">GAMEPLAY</span>
|
||||
</div>
|
||||
<!-- SYNC Tab -->
|
||||
<div class="h-full flex-1 flex items-center justify-center">
|
||||
<span class="text-[#a0a0a0] font-label-caps text-[12px]">SYNC</span>
|
||||
</div>
|
||||
<!-- AUDIO Tab -->
|
||||
<div class="h-full flex-1 flex items-center justify-center">
|
||||
<span class="text-[#a0a0a0] font-label-caps text-[12px]">AUDIO</span>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Content Area (Canvas) -->
|
||||
<main class="flex-1 bg-[#151515] p-margin-edge overflow-y-auto space-y-2">
|
||||
<!-- row: card_theme -->
|
||||
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[#a0a0a0] font-label-caps text-[11px]">card_theme</span>
|
||||
<span class="text-suit-black font-label-caps text-[14px]">Terminal</span>
|
||||
</div>
|
||||
<span class="text-primary material-symbols-outlined" data-icon="arrow_forward">arrow_forward</span>
|
||||
</div>
|
||||
<!-- row: background -->
|
||||
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[#a0a0a0] font-label-caps text-[11px]">background</span>
|
||||
<span class="text-suit-black font-label-caps text-[14px]">Solid #151515</span>
|
||||
</div>
|
||||
<span class="text-primary material-symbols-outlined" data-icon="arrow_forward">arrow_forward</span>
|
||||
</div>
|
||||
<!-- row: card_back -->
|
||||
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[#a0a0a0] font-label-caps text-[11px]">card_back</span>
|
||||
<span class="text-suit-black font-label-caps text-[14px]">Terminal</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-4 h-6 border border-outline card-back-pattern relative overflow-hidden">
|
||||
<div class="absolute top-0.5 left-0.5 w-1.5 h-2 bg-primary"></div>
|
||||
</div>
|
||||
<span class="text-primary material-symbols-outlined" data-icon="arrow_forward">arrow_forward</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- row: color_blind_mode -->
|
||||
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[#a0a0a0] font-label-caps text-[11px]">color_blind_mode</span>
|
||||
<span class="text-suit-black font-label-caps text-[14px]">false</span>
|
||||
</div>
|
||||
<!-- Toggle OFF -->
|
||||
<div class="w-10 h-5 bg-[#202020] border border-outline rounded-full relative">
|
||||
<div class="absolute left-0.5 top-0.5 w-3.5 h-3.5 bg-outline rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- row: high_contrast -->
|
||||
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[#a0a0a0] font-label-caps text-[11px]">high_contrast</span>
|
||||
<span class="text-suit-black font-label-caps text-[14px]">false</span>
|
||||
</div>
|
||||
<!-- Toggle OFF -->
|
||||
<div class="w-10 h-5 bg-[#202020] border border-outline rounded-full relative">
|
||||
<div class="absolute left-0.5 top-0.5 w-3.5 h-3.5 bg-outline rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- row: reduce_motion -->
|
||||
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[#a0a0a0] font-label-caps text-[11px]">reduce_motion</span>
|
||||
<span class="text-suit-black font-label-caps text-[14px]">true</span>
|
||||
</div>
|
||||
<!-- Toggle ON -->
|
||||
<div class="w-10 h-5 bg-[#1f3a4a] border border-primary/50 rounded-full relative">
|
||||
<div class="absolute right-0.5 top-0.5 w-3.5 h-3.5 bg-primary-container rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- row: crt_scanline_effect -->
|
||||
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[#a0a0a0] font-label-caps text-[11px]">crt_scanline_effect</span>
|
||||
<span class="text-suit-black font-label-caps text-[14px]">false</span>
|
||||
</div>
|
||||
<!-- Toggle OFF -->
|
||||
<div class="w-10 h-5 bg-[#202020] border border-outline rounded-full relative">
|
||||
<div class="absolute left-0.5 top-0.5 w-3.5 h-3.5 bg-outline rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- BottomNavBar (Footer Logic) -->
|
||||
<footer class="h-6 bg-[#202020] border-t border-[#353535] flex items-center justify-between px-4 w-full z-50">
|
||||
<div class="flex items-center gap-1 font-label-caps text-[11px]">
|
||||
<span class="text-primary">▌</span>
|
||||
<span class="text-suit-black">NORMAL</span>
|
||||
<span class="text-outline">│</span>
|
||||
<span class="text-on-surface">settings</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 font-label-caps text-[11px]">
|
||||
<div class="flex items-center">
|
||||
<span class="text-[#a0a0a0]">[1-4]</span>
|
||||
<span class="text-outline ml-1">tab</span>
|
||||
</div>
|
||||
<span class="text-outline">·</span>
|
||||
<div class="flex items-center">
|
||||
<span class="text-[#a0a0a0]">[ESC]</span>
|
||||
<span class="text-outline ml-1">back</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Rusty Solitaire - Terminal Edition</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;700&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"surface-dim": "#101417",
|
||||
"on-error": "#690005",
|
||||
"inverse-primary": "#00668a",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"surface-variant": "#313538",
|
||||
"on-background": "#e0e3e6",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"surface-bright": "#363a3d",
|
||||
"on-primary-container": "#004f6c",
|
||||
"on-surface": "#d0d0d0",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"on-tertiary-container": "#683476",
|
||||
"surface-container-high": "#272a2d",
|
||||
"error": "#fb9fb1",
|
||||
"highlight-valid": "#acc267",
|
||||
"primary-container": "#6fc2ef",
|
||||
"on-secondary": "#293500",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"outline-variant": "#3f484e",
|
||||
"secondary-container": "#435401",
|
||||
"background": "#101417",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"suit-black": "#d0d0d0",
|
||||
"surface-container": "#1c2023",
|
||||
"on-primary": "#003549",
|
||||
"primary": "#a1dcff",
|
||||
"error-container": "#93000a",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"info": "#12cfc0",
|
||||
"warning": "#ddb26f",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"on-tertiary": "#4c195b",
|
||||
"surface": "#151515",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"surface-container-highest": "#313538",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"suit-red": "#fb9fb1",
|
||||
"surface-container-low": "#181c1f",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"on-error-container": "#ffdad6",
|
||||
"tertiary": "#f7c3ff",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"outline": "#505050",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"secondary": "#bad073",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"boot-cyan": "#a1dcff",
|
||||
"boot-lime": "#acc267"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"stack-overlap": "2rem",
|
||||
"action-bar-height": "64px",
|
||||
"margin-edge": "1rem",
|
||||
"gutter-card": "0.375rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #151515;
|
||||
color: #d0d0d0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.cyan-halo {
|
||||
box-shadow: 0 0 40px 4px rgba(161, 220, 255, 0.15);
|
||||
}
|
||||
.scanlines {
|
||||
background: linear-gradient(to bottom, rgba(21, 21, 21, 0) 50%, rgba(0, 0, 0, 0.2) 50%);
|
||||
background-size: 100% 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex flex-col items-center justify-between font-body-md w-[390px] h-[844px] mx-auto relative overflow-hidden">
|
||||
<div class="absolute inset-0 scanlines opacity-30 z-10"></div>
|
||||
<!-- Top Safe Area (Blank) -->
|
||||
<div class="h-10 w-full"></div>
|
||||
<!-- Main Content Stack -->
|
||||
<main class="flex-1 w-full flex flex-col items-center z-20">
|
||||
<!-- Header Section (~30% Top) -->
|
||||
<div class="mt-[10%] flex flex-col items-center">
|
||||
<div class="w-24 h-24 flex items-center justify-center relative mb-4">
|
||||
<div class="text-[96px] text-boot-cyan font-headline cyan-halo leading-none">▌</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<h1 class="font-headline text-[32px] font-bold text-on-surface tracking-tight mb-1">RUSTY SOLITAIRE</h1>
|
||||
<div class="w-48 h-[1px] bg-outline-variant"></div>
|
||||
<span class="font-label-caps text-[12px] text-outline mt-2 tracking-[0.1em] uppercase">TERMINAL EDITION</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Terminal Boot Log (~60% Top Position) -->
|
||||
<div class="mt-48 w-[70%] flex flex-col items-start font-hud-timer text-[11px] space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-boot-lime">✓</span>
|
||||
<span class="text-outline">assets loaded</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-boot-lime">✓</span>
|
||||
<span class="text-outline">theme: terminal</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-boot-lime">✓</span>
|
||||
<span class="text-outline">progress restored</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 mt-1">
|
||||
<span class="text-on-surface-variant">▌ ready_</span>
|
||||
<span class="w-[6px] h-3 bg-boot-cyan animate-pulse"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress Bar (~75% Top Position) -->
|
||||
<div class="mt-20 w-full px-8">
|
||||
<div class="w-full h-[1px] bg-surface-container-highest relative">
|
||||
<div class="absolute top-0 left-0 h-full bg-boot-cyan w-full"></div>
|
||||
</div>
|
||||
<div class="w-full text-right mt-2">
|
||||
<span class="font-label-caps text-[10px] text-outline tracking-wider uppercase">DONE · 247 ASSETS</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Bottom Strip & Footer -->
|
||||
<footer class="w-full flex flex-col items-center z-20 pb-10">
|
||||
<div class="flex flex-col items-center mb-8">
|
||||
<span class="font-label-caps text-[9px] text-outline mb-3 tracking-widest uppercase">BASE16-EIGHTIES</span>
|
||||
<div class="flex gap-1.5">
|
||||
<div class="w-3 h-3 bg-[#fb9fb1]"></div>
|
||||
<div class="w-3 h-3 bg-[#ddb26f]"></div>
|
||||
<div class="w-3 h-3 bg-[#acc267]"></div>
|
||||
<div class="w-3 h-3 bg-[#12cfc0]"></div>
|
||||
<div class="w-3 h-3 bg-[#6fc2ef]"></div>
|
||||
<div class="w-3 h-3 bg-[#e1a3ee]"></div>
|
||||
<div class="w-3 h-3 bg-[#d0d0d0]"></div>
|
||||
<div class="w-3 h-3 bg-[#505050]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-hud-timer text-[11px] text-outline">
|
||||
v0.20.0
|
||||
</div>
|
||||
</footer>
|
||||
<!-- Top App Bar Content (Hidden as per requirement, but following Shared Components Logic) -->
|
||||
<div class="hidden">
|
||||
<header class="fixed top-0 w-full z-50 flex justify-between items-center px-margin-edge h-action-bar-height border-b border-outline-variant bg-background">
|
||||
<div class="font-headline text-headline text-primary uppercase tracking-tighter">ROOT@SOLITAIRE:~</div>
|
||||
<div class="flex gap-4 text-primary">
|
||||
<span class="material-symbols-outlined">memory</span>
|
||||
<span class="material-symbols-outlined">settings_ethernet</span>
|
||||
<span class="material-symbols-outlined">wifi_tethering</span>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
<!-- Placeholder for data-alt requirements even if visual images are minimal -->
|
||||
<div class="hidden">
|
||||
<img data-alt="A cinematic, low-angle digital render of a retro-terminal interface appearing on a high-end mobile display in a dark room. The screen glows with a soft cyan light, illuminating a minimalist layout with sharp, 1px white lines and a large block cursor symbol. The atmosphere is quiet, technical, and high-performance, reflecting a dark-mode synthwave aesthetic with muted grays and vibrant cyan accents. The focus is on the crisp, high-density typography and the mechanical precision of the digital environment." src="https://lh3.googleusercontent.com/aida-public/AB6AXuBeZymuyGd_-VJr-zgC8p08qBLD4pk0WyRtxuIhVT5kY6cc3y_qSkZ-P_EYYwKIliGysN5rDgqbCsXLxksfslVnB4nj4BYktu4d5EAKi1zEQ8t8MId17UzIgKujbGqebDo0FWO51Snqxt9AvrjX_afEsvACaaeAyIfTKgoAB8MBOUnanIre26Y1tNTftn1y9jxKfrXgi9eCYiJn6zoiaRmNmdLwo_s7RenmSlloPdIURVb3KKHKaBZHldPaStbWcyMYNR877R6O_A"/>
|
||||
</div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,279 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
/* CRT Scanline effect overlay */
|
||||
.crt-overlay::before {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.02), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.02));
|
||||
z-index: 100;
|
||||
background-size: 100% 2px, 3px 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"tertiary": "#f7c3ff",
|
||||
"surface-container": "#202020",
|
||||
"primary-container": "#6fc2ef",
|
||||
"surface": "#151515",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"info": "#12cfc0",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"suit-red": "#fb9fb1",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"on-background": "#e0e3e6",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"suit-black": "#d0d0d0",
|
||||
"on-primary": "#003549",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"primary": "#a1dcff",
|
||||
"on-primary-container": "#004f6c",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"surface-dim": "#101417",
|
||||
"on-tertiary-container": "#683476",
|
||||
"inverse-primary": "#00668a",
|
||||
"background": "#101417",
|
||||
"on-surface": "#d0d0d0",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"on-error": "#690005",
|
||||
"surface-container-highest": "#313538",
|
||||
"outline-variant": "#3f484e",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"warning": "#ddb26f",
|
||||
"surface-variant": "#313538",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"outline": "#505050",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"surface-container-high": "#272a2d",
|
||||
"secondary": "#bad073",
|
||||
"error": "#fb9fb1",
|
||||
"highlight-valid": "#acc267",
|
||||
"surface-container-low": "#181c1f",
|
||||
"on-secondary": "#293500",
|
||||
"surface-bright": "#363a3d",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"on-error-container": "#ffdad6",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"on-tertiary": "#4c195b",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"secondary-container": "#435401",
|
||||
"error-container": "#93000a",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"on-secondary-fixed-variant": "#3c4d00"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"touch-target-min": "48dp",
|
||||
"stack-overlap": "2rem",
|
||||
"margin-edge": "1rem",
|
||||
"action-bar-height": "64px",
|
||||
"gutter-card": "0.375rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"label-caps": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface text-on-surface font-body-md min-h-screen selection:bg-primary-container selection:text-on-primary-container">
|
||||
<!-- Top Navigation Shell -->
|
||||
<header class="fixed top-0 w-full bg-background z-50 border-b border-outline flex justify-between items-center px-margin-edge h-action-bar-height">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-primary" data-icon="terminal">terminal</span>
|
||||
<h1 class="font-headline text-headline text-primary uppercase tracking-tighter">STATISTICS.LOG</h1>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<button class="material-symbols-outlined text-on-surface-variant hover:bg-surface-container-highest transition-colors duration-120 p-2 rounded" data-icon="settings_input_component">settings_input_component</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="pt-action-bar-height pb-[88px] crt-overlay">
|
||||
<!-- Status Bar (Emulated Retro Style) -->
|
||||
<div class="h-[32px] bg-surface-container flex items-center justify-between px-margin-edge text-[11px] font-hud-timer">
|
||||
<span class="text-on-surface">▌stats.log</span>
|
||||
<span class="text-on-surface-variant">247 GAMES TRACKED</span>
|
||||
</div>
|
||||
<!-- Sub-tab Strip -->
|
||||
<nav class="h-[40px] bg-[#1a1a1a] border-b border-outline flex items-center px-margin-edge overflow-x-auto whitespace-nowrap scrollbar-hide">
|
||||
<div class="flex items-center h-full gap-4">
|
||||
<button class="h-full px-1 flex flex-col justify-center font-label-caps text-[11px] text-primary-container border-b-2 border-primary-container">
|
||||
[ OVERVIEW ]
|
||||
</button>
|
||||
<button class="h-full px-1 flex flex-col justify-center font-label-caps text-[11px] text-on-surface-variant hover:text-primary transition-colors">
|
||||
DRAW-1
|
||||
</button>
|
||||
<button class="h-full px-1 flex flex-col justify-center font-label-caps text-[11px] text-on-surface-variant hover:text-primary transition-colors">
|
||||
DRAW-3
|
||||
</button>
|
||||
<button class="h-full px-1 flex flex-col justify-center font-label-caps text-[11px] text-on-surface-variant hover:text-primary transition-colors">
|
||||
DAILY
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Main Content Grid -->
|
||||
<section class="p-4 flex flex-col gap-4">
|
||||
<!-- Hero Stat Card -->
|
||||
<div class="h-24 bg-surface-container border border-outline rounded-lg p-4 flex justify-between items-center">
|
||||
<div>
|
||||
<span class="font-headline text-[48px] text-on-surface">61%</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-label-caps text-[11px] text-on-surface-variant">WIN RATE</p>
|
||||
<p class="font-hud-timer text-[13px] text-highlight-valid">▲ +3% vs last 30</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 2x2 Grid -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="h-[88px] bg-surface-container border border-outline rounded-lg p-3 flex flex-col justify-between">
|
||||
<p class="font-label-caps text-[11px] text-on-surface-variant">GAMES</p>
|
||||
<p class="font-hud-score text-hud-score">247</p>
|
||||
</div>
|
||||
<div class="h-[88px] bg-surface-container border border-outline rounded-lg p-3 flex flex-col justify-between">
|
||||
<p class="font-label-caps text-[11px] text-on-surface-variant">WINS</p>
|
||||
<p class="font-hud-score text-hud-score">151</p>
|
||||
</div>
|
||||
<div class="h-[88px] bg-surface-container border border-outline rounded-lg p-3 flex flex-col justify-between">
|
||||
<p class="font-label-caps text-[11px] text-on-surface-variant">BEST TIME</p>
|
||||
<p class="font-hud-score text-hud-score text-primary">01:54</p>
|
||||
</div>
|
||||
<div class="h-[88px] bg-surface-container border border-outline rounded-lg p-3 flex flex-col justify-between">
|
||||
<p class="font-label-caps text-[11px] text-on-surface-variant">STREAK</p>
|
||||
<p class="font-hud-score text-hud-score">12</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sparkline Card -->
|
||||
<div class="bg-surface-container border border-outline rounded-lg p-4 h-[200px] flex flex-col">
|
||||
<p class="font-label-caps text-[11px] text-on-surface-variant mb-4">WIN RATE · LAST 30 DAYS</p>
|
||||
<div class="relative flex-1 flex items-end justify-between border-b border-outline pb-1">
|
||||
<!-- Y-Axis Labels -->
|
||||
<span class="absolute top-0 left-0 text-[9px] font-hud-timer text-outline">100%</span>
|
||||
<span class="absolute bottom-1 left-0 text-[9px] font-hud-timer text-outline">0%</span>
|
||||
<!-- Pixel Bar Chart -->
|
||||
<div class="flex items-end justify-between w-full h-[80%] gap-[2px]">
|
||||
<!-- Generated bars with upward trend -->
|
||||
<div class="bg-primary-container w-full" style="height: 35%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 30%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 40%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 38%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 45%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 42%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 50%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 48%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 55%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 52%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 58%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 60%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 55%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 62%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 65%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 63%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 68%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 70%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 65%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 72%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 75%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 78%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 74%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 80%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 82%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 85%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 88%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 86%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 90%"></div>
|
||||
<div class="bg-primary-container w-full" style="height: 95%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-1">
|
||||
<span class="text-[9px] font-hud-timer text-outline uppercase">30d ago</span>
|
||||
<span class="text-[9px] font-hud-timer text-outline uppercase">today</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Draw-Mode Split Card -->
|
||||
<div class="h-20 bg-surface-container border border-outline rounded-lg p-4 flex flex-col justify-between">
|
||||
<p class="font-label-caps text-[11px] text-on-surface-variant">DRAW MODE SPLIT</p>
|
||||
<div class="h-3 w-full flex rounded-sm overflow-hidden bg-outline">
|
||||
<div class="h-full bg-highlight-valid" style="width: 60%"></div>
|
||||
<div class="h-full bg-primary-container" style="width: 40%"></div>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-hud-timer text-[11px] text-highlight-valid">DRAW-1 · 60%</span>
|
||||
<span class="font-hud-timer text-[11px] text-primary-container">DRAW-3 · 40%</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Retro Footer Strip -->
|
||||
<footer class="h-[24px] px-margin-edge flex items-center justify-between font-hud-timer text-[10px] text-outline mt-2">
|
||||
<div>▌ NORMAL │ stats</div>
|
||||
<div>[1-4] view</div>
|
||||
</footer>
|
||||
</main>
|
||||
<!-- Bottom Navigation Shell -->
|
||||
<nav class="fixed bottom-0 w-full z-50 h-[64px] bg-surface-container border-t border-outline flex justify-around items-center">
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant pt-1 hover:text-primary transition-colors duration-120">
|
||||
<span class="material-symbols-outlined" data-icon="playing_cards">playing_cards</span>
|
||||
<span class="font-label-caps text-label-caps">DECK</span>
|
||||
</div>
|
||||
<!-- ACTIVE TAB: STATS -->
|
||||
<div class="flex flex-col items-center justify-center text-primary border-t-2 border-primary pt-1 hover:text-primary transition-colors duration-120">
|
||||
<span class="material-symbols-outlined" data-icon="query_stats" style="font-variation-settings: 'FILL' 1;">query_stats</span>
|
||||
<span class="font-label-caps text-label-caps">STATS</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant pt-1 hover:text-primary transition-colors duration-120">
|
||||
<span class="material-symbols-outlined" data-icon="history_edu">history_edu</span>
|
||||
<span class="font-label-caps text-label-caps">LOGS</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant pt-1 hover:text-primary transition-colors duration-120">
|
||||
<span class="material-symbols-outlined" data-icon="terminal">terminal</span>
|
||||
<span class="font-label-caps text-label-caps">SYS</span>
|
||||
</div>
|
||||
</nav>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,262 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Rusty Solitaire - Sync Progress</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"surface-container-low": "#181c1f",
|
||||
"highlight-valid": "#acc267",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"on-tertiary": "#4c195b",
|
||||
"suit-black": "#d0d0d0",
|
||||
"surface-container": "#1c2023",
|
||||
"warning": "#ddb26f",
|
||||
"outline": "#505050",
|
||||
"error-container": "#93000a",
|
||||
"on-secondary": "#293500",
|
||||
"surface-dim": "#101417",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"secondary": "#bad073",
|
||||
"on-surface": "#e0e3e6",
|
||||
"primary": "#a1dcff",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"on-tertiary-container": "#683476",
|
||||
"inverse-primary": "#00668a",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"primary-container": "#6fc2ef",
|
||||
"on-primary-container": "#004f6c",
|
||||
"secondary-container": "#435401",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"surface-container-high": "#272a2d",
|
||||
"on-background": "#e0e3e6",
|
||||
"on-primary": "#003549",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"surface": "#151515",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"suit-red": "#fb9fb1",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"surface-variant": "#313538",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"outline-variant": "#3f484e",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"info": "#12cfc0",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"surface-container-highest": "#313538",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"on-error": "#690005",
|
||||
"background": "#101417",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"error": "#fb9fb1",
|
||||
"tertiary": "#f7c3ff",
|
||||
"on-error-container": "#ffdad6",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"surface-bright": "#363a3d"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"touch-target-min": "48dp",
|
||||
"gutter-card": "0.375rem",
|
||||
"action-bar-height": "64px",
|
||||
"stack-overlap": "2rem",
|
||||
"margin-edge": "1rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"]
|
||||
},
|
||||
"fontSize": {
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.scanline {
|
||||
background: linear-gradient(to bottom, transparent 50%, rgba(0, 0, 0, 0.1) 50%);
|
||||
background-size: 100% 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface text-on-surface font-body-md min-h-screen flex flex-col overflow-x-hidden selection:bg-primary-container selection:text-on-primary-container">
|
||||
<!-- 1. Status Bar (Top) -->
|
||||
<nav class="h-8 bg-[#202020] flex items-center justify-between px-4 border-b border-outline-variant z-50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-headline text-[13px] tracking-tight text-on-surface-variant">▌sync.config</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-warning animate-pulse"></span>
|
||||
<span class="font-label-caps text-[11px] text-warning">PENDING</span>
|
||||
</div>
|
||||
<span class="font-label-caps text-[11px] text-outline">v0.20.0</span>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Shared TopAppBar Component -->
|
||||
<header class="bg-surface flex justify-between items-center w-full px-margin-edge h-action-bar-height max-w-full border-b border-outline-variant">
|
||||
<div class="font-headline text-headline text-primary tracking-tighter uppercase">▌RS_TERMINAL_OS</div>
|
||||
<div class="hidden md:flex gap-6 items-center">
|
||||
<a class="font-label-caps text-label-caps uppercase tracking-widest text-on-surface-variant hover:text-primary transition-colors duration-120" href="#">PLAY</a>
|
||||
<a class="font-label-caps text-label-caps uppercase tracking-widest text-on-surface-variant hover:text-primary transition-colors duration-120" href="#">DAILY</a>
|
||||
<a class="font-label-caps text-label-caps uppercase tracking-widest text-primary border-b-2 border-primary pb-1" href="#">STATS</a>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<span class="material-symbols-outlined text-primary cursor-pointer">account_circle</span>
|
||||
<span class="material-symbols-outlined text-primary cursor-pointer">sync</span>
|
||||
<span class="material-symbols-outlined text-primary cursor-pointer">settings</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-grow p-4 md:p-8 max-w-3xl mx-auto w-full space-y-6">
|
||||
<!-- 2. Header Area -->
|
||||
<div class="h-20 flex flex-col justify-center border-l-2 border-outline-variant pl-4">
|
||||
<h1 class="font-headline text-[24px] font-bold text-suit-black leading-tight">SYNC PROGRESS</h1>
|
||||
<p class="font-body-md text-[12px] text-[#a0a0a0]">Connect to a server to sync games across devices.</p>
|
||||
</div>
|
||||
<!-- 3. Status Card -->
|
||||
<section class="bg-[#202020] rounded-[4px] p-4 border-l-[4px] border-warning flex flex-col gap-1">
|
||||
<h2 class="font-label-caps text-[10px] text-[#a0a0a0] uppercase tracking-widest">STATUS</h2>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-headline text-[16px] font-bold text-[#a0a0a0]">○ NOT SIGNED IN</span>
|
||||
<span class="text-[11px] text-outline font-medium">Local progress only · Last attempt: never</span>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 4. Login Form Card -->
|
||||
<section class="bg-[#202020] border border-outline-variant rounded-[4px] p-4 flex flex-col gap-5">
|
||||
<div class="flex items-center gap-2 border-b border-outline-variant pb-2 mb-1">
|
||||
<span class="font-headline text-[12px] text-[#a0a0a0] flex items-center">
|
||||
▌ AUTH.toml <span class="ml-1 w-1.5 h-3 bg-primary-container animate-pulse"></span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Server URL -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="font-label-caps text-[10px] text-[#a0a0a0] uppercase tracking-widest">server_url</label>
|
||||
<div class="h-12 bg-[#1a1a1a] border border-outline-variant rounded-[2px] flex items-center px-4">
|
||||
<span class="font-headline text-[13px] text-suit-black">https://sync.rusty-solitaire.app</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Email -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="font-label-caps text-[10px] text-[#a0a0a0] uppercase tracking-widest">email</label>
|
||||
<div class="h-12 bg-[#1a1a1a] border border-outline-variant rounded-[2px] flex items-center px-4">
|
||||
<span class="font-headline text-[13px] text-outline">/ user@example.com</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Passcode -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="font-label-caps text-[10px] text-[#a0a0a0] uppercase tracking-widest">passcode</label>
|
||||
<div class="h-12 bg-[#1a1a1a] border border-outline-variant rounded-[2px] flex items-center justify-between px-4">
|
||||
<span class="font-headline text-[13px] text-outline">•••••••• (12 chars)</span>
|
||||
<span class="material-symbols-outlined text-outline cursor-pointer text-[18px]">visibility</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 5. Form Actions -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<button class="h-12 bg-primary-container text-surface flex items-center justify-center rounded-[2px] font-headline text-[14px] font-bold active:scale-95 transition-transform">
|
||||
▶ SIGN IN
|
||||
</button>
|
||||
<button class="h-12 border border-outline text-suit-black flex items-center justify-center rounded-[2px] font-headline text-[13px] font-medium hover:border-primary-container hover:text-primary-container transition-all">
|
||||
+ CREATE ACCOUNT
|
||||
</button>
|
||||
</div>
|
||||
<!-- 6. Recent History Panel -->
|
||||
<section class="bg-[#202020] rounded-[4px] p-4 flex flex-col gap-3">
|
||||
<h2 class="font-label-caps text-[12px] text-[#a0a0a0] uppercase tracking-widest">RECENT</h2>
|
||||
<div class="space-y-2 border-l border-outline-variant pl-4">
|
||||
<div class="font-headline text-[11px] text-[#a0a0a0] flex items-center gap-2">
|
||||
<span class="text-outline">2026-05-07 17:38</span>
|
||||
<span class="text-outline">·</span>
|
||||
<span>○ no auth</span>
|
||||
<span class="text-outline">·</span>
|
||||
<span>skip</span>
|
||||
</div>
|
||||
<div class="font-headline text-[11px] text-[#a0a0a0] flex items-center gap-2">
|
||||
<span class="text-outline">2026-05-07 14:12</span>
|
||||
<span class="text-outline">·</span>
|
||||
<span>○ no auth</span>
|
||||
<span class="text-outline">·</span>
|
||||
<span>skip</span>
|
||||
</div>
|
||||
<div class="font-headline text-[11px] text-[#a0a0a0] flex items-center gap-2">
|
||||
<span class="text-outline">2026-05-06 09:01</span>
|
||||
<span class="text-outline">·</span>
|
||||
<span class="text-highlight-valid">✓ synced 12 games</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- 7. Footer -->
|
||||
<footer class="h-10 bg-surface border-t border-outline-variant flex items-center justify-between px-4 fixed bottom-0 w-full z-50">
|
||||
<div class="flex items-center gap-2 font-headline text-[11px]">
|
||||
<span class="text-primary-container">▌ NORMAL</span>
|
||||
<span class="text-outline">│</span>
|
||||
<span class="text-on-surface-variant">sync</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 font-headline text-[11px] text-outline uppercase tracking-wider">
|
||||
<span>[ENTER] <span class="text-on-surface-variant">sign in</span></span>
|
||||
<span>[ESC] <span class="text-on-surface-variant">cancel</span></span>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- Shared BottomNavBar Component (Mobile Only) -->
|
||||
<nav class="md:hidden fixed bottom-10 w-full z-50 flex justify-around items-center h-action-bar-height px-margin-edge bg-surface-container border-t border-outline-variant">
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:bg-surface-bright transition-all duration-120">
|
||||
<span class="material-symbols-outlined" data-icon="videogame_asset">videogame_asset</span>
|
||||
<span class="font-label-caps text-[10px] uppercase tracking-widest mt-1">F1_NEW_GAME</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:bg-surface-bright transition-all duration-120">
|
||||
<span class="material-symbols-outlined" data-icon="event_upcoming">event_upcoming</span>
|
||||
<span class="font-label-caps text-[10px] uppercase tracking-widest mt-1">F2_CHALLENGE</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-primary dark:text-primary-fixed-dim bg-surface-container-highest rounded-none p-2 transition-all duration-120 scale-95">
|
||||
<span class="material-symbols-outlined" data-icon="query_stats">query_stats</span>
|
||||
<span class="font-label-caps text-[10px] uppercase tracking-widest mt-1">F5_STATS</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:bg-surface-bright transition-all duration-120">
|
||||
<span class="material-symbols-outlined" data-icon="power_settings_new">power_settings_new</span>
|
||||
<span class="font-label-caps text-[10px] uppercase tracking-widest mt-1">ESC_EXIT</span>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Decorative Screen Texture -->
|
||||
<div class="fixed inset-0 pointer-events-none opacity-[0.03] scanline z-[100]"></div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,250 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Inter:wght@400;500;600&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<style>.material-symbols-outlined {
|
||||
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24
|
||||
}
|
||||
.scanline-pattern {
|
||||
background: repeating-linear-gradient(0deg, #1a1a1a, #1a1a1a 2px, #151515 2px, #151515 4px)
|
||||
}
|
||||
.checker-pattern {
|
||||
background-color: #001e2c;
|
||||
background-image: radial-gradient(#a1dcff 10%, transparent 10%), radial-gradient(#a1dcff 10%, transparent 10%);
|
||||
background-size: 8px 8px;
|
||||
background-position: 0 0, 4px 4px
|
||||
}
|
||||
.stripe-pattern {
|
||||
background: repeating-linear-gradient(0deg, #fb9fb1, #fb9fb1 4px, #202020 4px, #202020 8px)
|
||||
}
|
||||
.polka-pattern {
|
||||
background-color: #001e2c;
|
||||
background-image: radial-gradient(#e0e3e6 15%, transparent 15%);
|
||||
background-size: 12px 12px
|
||||
}
|
||||
.vintage-pattern {
|
||||
background-color: #d5ec8c;
|
||||
background-image: url(https://lh3.googleusercontent.com/aida-public/AB6AXuD8S9vQTpEh-DjYtjB5CUHqi2CO326ZEjVVLJoOqG1AA6b92NZ6ctGoD4yZHKV7oHJnSFdvp3z3Wei9zfTI2EGAdrQfHxFYJ-h1DaeiZQY3vTa5khIQ83Sf-bjz2xiudHsjs3RyhSKC5bHv2c8_9t6YjepJdQnJa4GelCetFEs_agpN6u2IfMS1M9RrGxGKLl4K18fj0Pg3BW8IptX_ladhVFR5Hk8F0Reu5WHY8eQt1Nr-p9NNXl-w3C9Jz0uGSxi_Wb7R771lgQ);
|
||||
opacity: 0.8
|
||||
}</style>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"surface-variant": "#313538",
|
||||
"on-error-container": "#ffdad6",
|
||||
"warning": "#ddb26f",
|
||||
"on-surface": "#e0e3e6",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"on-error": "#690005",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"primary-container": "#6fc2ef",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"tertiary": "#f7c3ff",
|
||||
"surface-dim": "#101417",
|
||||
"surface-container-low": "#181c1f",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"surface-container": "#1c2023",
|
||||
"secondary-container": "#435401",
|
||||
"error": "#fb9fb1",
|
||||
"on-tertiary": "#4c195b",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"error-container": "#93000a",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"info": "#12cfc0",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"highlight-valid": "#acc267",
|
||||
"on-primary": "#003549",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"primary": "#a1dcff",
|
||||
"surface-container-high": "#272a2d",
|
||||
"background": "#101417",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"suit-red": "#fb9fb1",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"surface": "#151515",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"outline": "#505050",
|
||||
"on-secondary": "#293500",
|
||||
"surface-container-highest": "#313538",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"on-primary-container": "#004f6c",
|
||||
"suit-black": "#d0d0d0",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"on-tertiary-container": "#683476",
|
||||
"secondary": "#bad073",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"surface-bright": "#363a3d",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"inverse-primary": "#00668a",
|
||||
"outline-variant": "#3f484e",
|
||||
"on-background": "#e0e3e6",
|
||||
"tertiary-container": "#e1a3ee"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"stack-overlap": "2rem",
|
||||
"gutter-card": "0.375rem",
|
||||
"touch-target-min": "48dp",
|
||||
"action-bar-height": "64px",
|
||||
"margin-edge": "1rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"]
|
||||
},
|
||||
"fontSize": {
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-surface selection:bg-primary-container selection:text-on-primary-container min-h-screen flex flex-col font-body-md overflow-x-hidden">
|
||||
<!-- TopAppBar Semantic Shell -->
|
||||
<header class="fixed top-0 w-full z-50 flex justify-between items-center h-[32px] px-margin-edge bg-surface-container border-b border-outline-variant">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-[16px] text-primary" data-icon="terminal">terminal</span>
|
||||
<span class="font-headline text-[14px] font-bold text-on-surface">▌theme.picker</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity">
|
||||
<span class="font-label-caps text-[12px] text-on-surface-variant uppercase">× CLOSE</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="mt-[32px] mb-[64px] flex-grow flex flex-col px-margin-edge py-6">
|
||||
<!-- Header Section -->
|
||||
<div class="flex justify-between items-start mb-8">
|
||||
<div class="flex-1">
|
||||
<h1 class="font-headline text-[24px] font-bold text-on-surface mb-1">CARD THEMES</h1>
|
||||
<p class="font-body-md text-[13px] text-on-surface-variant max-w-[280px]">Choose a card-face theme. Imported themes appear at the bottom.</p>
|
||||
</div>
|
||||
<div class="bg-surface-container-high px-2 py-1 border border-outline-variant">
|
||||
<span class="font-label-caps text-[11px] text-primary">5 INSTALLED</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Theme Grid -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Active Theme: Terminal -->
|
||||
<div class="relative flex flex-col bg-surface border-2 border-primary-container">
|
||||
<div class="aspect-[2.5/3.5] w-full p-2 overflow-hidden flex items-center justify-center bg-background">
|
||||
<!-- Card Preview -->
|
||||
<div class="w-24 h-36 bg-surface border border-primary-container scanline-pattern relative">
|
||||
<div class="absolute top-1 left-1 w-3 h-4 bg-primary-container"></div>
|
||||
<div class="absolute bottom-1 right-1 font-headline text-[10px] text-on-surface">▌RS</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 bg-surface-container-high border-t border-primary-container">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="font-headline text-[14px] font-bold truncate">Terminal</span>
|
||||
<span class="font-label-caps text-[10px] text-primary-container">✓ ACTIVE</span>
|
||||
</div>
|
||||
<span class="font-body-md text-[11px] text-on-surface-variant">by Rusty Solitaire</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Theme: Classic -->
|
||||
<div class="relative flex flex-col bg-surface border border-outline">
|
||||
<div class="aspect-[2.5/3.5] w-full p-2 overflow-hidden flex items-center justify-center bg-background">
|
||||
<div class="w-24 h-36 bg-surface border border-outline checker-pattern"></div>
|
||||
</div>
|
||||
<div class="p-3 bg-surface-container-low border-t border-outline">
|
||||
<span class="font-headline text-[14px] font-bold block truncate">Classic</span>
|
||||
<span class="font-body-md text-[11px] text-on-surface-variant">by Rusty Solitaire</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Theme: Stripes -->
|
||||
<div class="relative flex flex-col bg-surface border border-outline">
|
||||
<div class="aspect-[2.5/3.5] w-full p-2 overflow-hidden flex items-center justify-center bg-background">
|
||||
<div class="w-24 h-36 bg-surface border border-outline stripe-pattern"></div>
|
||||
</div>
|
||||
<div class="p-3 bg-surface-container-low border-t border-outline">
|
||||
<span class="font-headline text-[14px] font-bold block truncate">Stripes</span>
|
||||
<span class="font-body-md text-[11px] text-on-surface-variant">by hayeah</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Theme: Polka -->
|
||||
<div class="relative flex flex-col bg-surface border border-outline">
|
||||
<div class="aspect-[2.5/3.5] w-full p-2 overflow-hidden flex items-center justify-center bg-background">
|
||||
<div class="w-24 h-36 bg-surface border border-outline polka-pattern"></div>
|
||||
</div>
|
||||
<div class="p-3 bg-surface-container-low border-t border-outline">
|
||||
<span class="font-headline text-[14px] font-bold block truncate">Polka</span>
|
||||
<span class="font-body-md text-[11px] text-on-surface-variant">by hayeah</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Theme: Vintage -->
|
||||
<div class="relative flex flex-col bg-surface border border-outline">
|
||||
<div class="aspect-[2.5/3.5] w-full p-2 overflow-hidden flex items-center justify-center bg-background">
|
||||
<div class="w-24 h-36 bg-surface border border-outline vintage-pattern"></div>
|
||||
</div>
|
||||
<div class="p-3 bg-surface-container-low border-t border-outline">
|
||||
<span class="font-headline text-[14px] font-bold block truncate">Vintage</span>
|
||||
<span class="font-body-md text-[11px] text-on-surface-variant">by hayeah</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Import Theme -->
|
||||
<div class="relative flex flex-col bg-surface border-2 border-dashed border-outline-variant hover:border-primary-container transition-colors cursor-pointer">
|
||||
<div class="aspect-[2.5/3.5] w-full flex flex-col items-center justify-center gap-3">
|
||||
<span class="material-symbols-outlined text-[32px] text-primary-container" data-icon="add">add</span>
|
||||
<span class="font-label-caps text-[10px] text-on-surface-variant tracking-widest text-center px-4">IMPORT FROM .ZIP</span>
|
||||
</div>
|
||||
<div class="p-3 bg-surface-container-low border-t border-outline-variant">
|
||||
<span class="font-headline text-[14px] font-bold block truncate">+ IMPORT THEME</span>
|
||||
<span class="font-body-md text-[11px] opacity-0">spacer</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- BottomNavBar Semantic Shell -->
|
||||
<footer class="fixed bottom-0 left-0 w-full h-[64px] z-50 bg-surface-container border-t border-outline-variant flex justify-between items-center px-margin-edge pb-safe">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-headline text-[14px] font-bold text-on-surface">▌ NORMAL</span>
|
||||
<span class="text-on-surface-variant">│</span>
|
||||
<span class="font-label-caps text-[12px] text-on-surface-variant">theme</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center">
|
||||
<span class="text-on-surface-variant font-label-caps text-[11px]">[ENTER]</span>
|
||||
<span class="text-on-surface-variant font-body-md text-[11px] ml-1">activate</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-on-surface-variant font-label-caps text-[11px]">[I]</span>
|
||||
<span class="text-on-surface-variant font-body-md text-[11px] ml-1">import</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-on-surface-variant font-label-caps text-[11px]">[ESC]</span>
|
||||
<span class="text-on-surface-variant font-body-md text-[11px] ml-1">back</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,215 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Rusty Solitaire - Time Attack Configuration</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Inter:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"surface-tint": "#7ed0fe",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"primary-container": "#6fc2ef",
|
||||
"surface-container-high": "#272a2d",
|
||||
"tertiary": "#f7c3ff",
|
||||
"suit-black": "#d0d0d0",
|
||||
"secondary-container": "#435401",
|
||||
"on-error-container": "#ffdad6",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"surface-dim": "#101417",
|
||||
"on-primary-container": "#004f6c",
|
||||
"info": "#12cfc0",
|
||||
"outline-variant": "#3f484e",
|
||||
"surface": "#151515",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"surface-container-low": "#181c1f",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"inverse-primary": "#00668a",
|
||||
"suit-red": "#fb9fb1",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"on-surface": "#d0d0d0",
|
||||
"on-secondary": "#293500",
|
||||
"error-container": "#93000a",
|
||||
"highlight-valid": "#acc267",
|
||||
"surface-container-highest": "#313538",
|
||||
"primary": "#a1dcff",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"error": "#fb9fb1",
|
||||
"outline": "#505050",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"surface-container": "#202020",
|
||||
"on-primary": "#003549",
|
||||
"on-error": "#690005",
|
||||
"warning": "#ddb26f",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"on-background": "#e0e3e6",
|
||||
"on-tertiary": "#4c195b",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"background": "#101417",
|
||||
"surface-variant": "#313538",
|
||||
"secondary": "#bad073",
|
||||
"on-tertiary-container": "#683476",
|
||||
"surface-bright": "#363a3d",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"primary-fixed": "#c4e7ff"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"margin-edge": "1rem",
|
||||
"touch-target-min": "48px",
|
||||
"stack-overlap": "2rem",
|
||||
"gutter-card": "0.375rem",
|
||||
"action-bar-height": "64px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"hud-timer": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #151515; color: #d0d0d0; }
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.scanline {
|
||||
background: linear-gradient(to bottom, rgba(255,255,255,0) 50%, rgba(0,0,0,0.1) 50%);
|
||||
background-size: 100% 4px;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex items-center justify-center min-h-screen">
|
||||
<!-- Mobile Container (390x844) -->
|
||||
<div class="w-[390px] h-[844px] bg-surface relative flex flex-col overflow-hidden border border-outline-variant">
|
||||
<!-- STATUS BAR -->
|
||||
<header class="h-8 bg-surface-container flex items-center justify-between px-4 z-10">
|
||||
<span class="font-headline text-[12px] tracking-tight text-primary">▌time-attack.tsx</span>
|
||||
<span class="font-label-caps text-[10px] text-on-surface-variant">MODE · TIMED</span>
|
||||
</header>
|
||||
<!-- TOP APP BAR (from JSON) -->
|
||||
<nav class="flex justify-between items-center w-full px-margin-edge h-action-bar-height max-w-full bg-surface text-primary font-headline text-headline font-bold border-b border-outline-variant">
|
||||
<div class="font-headline text-headline text-primary tracking-tighter uppercase">▌RS_TERMINAL_OS</div>
|
||||
<div class="flex gap-4">
|
||||
<span class="material-symbols-outlined cursor-pointer hover:text-primary-fixed transition-colors duration-120">account_circle</span>
|
||||
<span class="material-symbols-outlined cursor-pointer hover:text-primary-fixed transition-colors duration-120">sync</span>
|
||||
<span class="material-symbols-outlined cursor-pointer hover:text-primary-fixed transition-colors duration-120">settings</span>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- MAIN CONTENT -->
|
||||
<main class="flex-1 px-4 py-4 flex flex-col gap-4 overflow-y-auto">
|
||||
<!-- HERO BAND -->
|
||||
<section class="flex flex-col items-center justify-center h-[100px] text-center">
|
||||
<h1 class="font-headline text-[32px] font-bold tracking-tighter text-on-surface uppercase">TIME ATTACK</h1>
|
||||
<p class="font-body-md text-[12px] text-on-surface-variant max-w-[280px]">Race the clock. The faster you finish, the higher your score.</p>
|
||||
</section>
|
||||
<!-- TIMER DISPLAY -->
|
||||
<section class="w-full h-[120px] bg-surface-dim border border-outline-variant flex flex-col items-center justify-center relative overflow-hidden">
|
||||
<div class="absolute inset-0 scanline opacity-10 pointer-events-none"></div>
|
||||
<div class="font-headline text-[64px] font-bold tracking-tight text-primary tabular-nums leading-none">05:00</div>
|
||||
<div class="font-label-caps text-[11px] uppercase tracking-[0.2em] text-on-surface-variant mt-2">MINUTES</div>
|
||||
</section>
|
||||
<!-- DURATION PICKER -->
|
||||
<section class="grid grid-cols-4 gap-px bg-outline-variant border border-outline-variant">
|
||||
<button class="h-12 bg-surface-container font-label-caps text-[12px] text-on-surface hover:bg-surface-bright transition-all">1 MIN</button>
|
||||
<button class="h-12 bg-surface-container font-label-caps text-[12px] text-on-surface hover:bg-surface-bright transition-all">3 MIN</button>
|
||||
<button class="h-12 bg-primary text-on-primary font-bold font-label-caps text-[12px]">5 MIN</button>
|
||||
<button class="h-12 bg-surface-container font-label-caps text-[12px] text-on-surface hover:bg-surface-bright transition-all">10 MIN</button>
|
||||
</section>
|
||||
<!-- RULES CARD -->
|
||||
<section class="bg-surface-container h-20 p-3 border border-outline-variant flex flex-col justify-between">
|
||||
<div class="font-label-caps text-[10px] text-on-surface-variant uppercase tracking-widest">RULES</div>
|
||||
<div class="font-headline text-[12px] text-on-surface flex items-center gap-2">
|
||||
<span class="w-1 h-1 bg-primary"></span> DRAW-3
|
||||
<span class="w-1 h-1 bg-primary ml-2"></span> NO HINT PENALTY
|
||||
<span class="w-1 h-1 bg-primary ml-2"></span> +50 XP / WIN
|
||||
</div>
|
||||
</section>
|
||||
<!-- BEST RUN CARD -->
|
||||
<section class="bg-surface-container h-16 p-3 border border-outline-variant flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<div class="font-label-caps text-[10px] text-on-surface-variant uppercase tracking-widest">PERSONAL BEST · 5 MIN</div>
|
||||
<div class="font-headline text-[24px] font-bold text-on-surface leading-tight">02:47 <span class="text-[12px] font-normal text-on-surface-variant">WIN</span></div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end">
|
||||
<div class="font-label-caps text-[10px] text-on-surface-variant">GLOBAL RANK 142</div>
|
||||
<div class="bg-warning text-on-surface text-[9px] px-1.5 py-0.5 font-bold mt-1">TOP 5%</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- PRIMARY CTA -->
|
||||
<section class="mt-auto pt-4">
|
||||
<button class="w-full h-20 bg-primary text-on-primary flex flex-col items-center justify-center transition-all active:scale-[0.98] duration-80">
|
||||
<div class="font-headline text-[18px] font-extrabold uppercase tracking-[0.2em] flex items-center gap-2">
|
||||
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">play_arrow</span> BEGIN COUNTDOWN
|
||||
</div>
|
||||
</button>
|
||||
<p class="font-body-md text-[11px] text-on-surface-variant text-center mt-3">Game starts after a 3-second countdown.</p>
|
||||
</section>
|
||||
</main>
|
||||
<!-- FOOTER -->
|
||||
<footer class="h-6 bg-surface-container-lowest flex items-center justify-between px-4 border-t border-outline-variant">
|
||||
<span class="font-headline text-[10px] text-on-surface-variant">▌ NORMAL │ time-attack</span>
|
||||
<span class="font-label-caps text-[9px] text-on-surface-variant uppercase">[ENTER] begin · [ESC] back</span>
|
||||
</footer>
|
||||
<!-- BOTTOM NAV BAR (from JSON) -->
|
||||
<nav class="fixed bottom-0 w-[390px] z-50 flex justify-around items-center h-action-bar-height px-margin-edge bg-surface-container border-t border-outline-variant">
|
||||
<div class="flex flex-col items-center justify-center text-primary bg-surface-container-highest rounded-none p-2 transition-transform duration-80 active:scale-95">
|
||||
<span class="material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">videogame_asset</span>
|
||||
<span class="font-label-caps text-label-caps uppercase tracking-widest">F1_NEW_GAME</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:bg-surface-bright transition-all duration-120">
|
||||
<span class="material-symbols-outlined">event_upcoming</span>
|
||||
<span class="font-label-caps text-label-caps uppercase tracking-widest">F2_CHALLENGE</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:bg-surface-bright transition-all duration-120">
|
||||
<span class="material-symbols-outlined">query_stats</span>
|
||||
<span class="font-label-caps text-label-caps uppercase tracking-widest">F5_STATS</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:bg-surface-bright transition-all duration-120">
|
||||
<span class="material-symbols-outlined">power_settings_new</span>
|
||||
<span class="font-label-caps text-label-caps uppercase tracking-widest">ESC_EXIT</span>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- PADDING FOR FIXED NAV -->
|
||||
<div class="h-action-bar-height"></div>
|
||||
</div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,265 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Weekly Goals - Rusty Solitaire</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"surface-container-high": "#272a2d",
|
||||
"on-primary": "#003549",
|
||||
"highlight-valid": "#acc267",
|
||||
"primary-container": "#6fc2ef",
|
||||
"on-error-container": "#ffdad6",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"outline": "#505050",
|
||||
"on-primary-container": "#004f6c",
|
||||
"on-background": "#e0e3e6",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"error": "#fb9fb1",
|
||||
"suit-black": "#d0d0d0",
|
||||
"secondary": "#bad073",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"surface-container": "#202020",
|
||||
"primary": "#a1dcff",
|
||||
"error-container": "#93000a",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"surface-container-highest": "#313538",
|
||||
"surface-dim": "#101417",
|
||||
"suit-red": "#fb9fb1",
|
||||
"warning": "#ddb26f",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"on-tertiary-container": "#683476",
|
||||
"on-surface": "#e0e3e6",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"info": "#12cfc0",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"surface-variant": "#313538",
|
||||
"inverse-primary": "#00668a",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"secondary-container": "#435401",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"surface-container-low": "#181c1f",
|
||||
"background": "#101417",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"surface-bright": "#363a3d",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"on-secondary": "#293500",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"on-tertiary": "#4c195b",
|
||||
"tertiary": "#f7c3ff",
|
||||
"outline-variant": "#3f484e",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"surface": "#151515",
|
||||
"on-error": "#690005"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"margin-edge": "1rem",
|
||||
"touch-target-min": "48dp",
|
||||
"gutter-card": "0.375rem",
|
||||
"stack-overlap": "2rem",
|
||||
"action-bar-height": "64px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tabular-nums { font-variant-numeric: tabular-nums; }
|
||||
::-webkit-scrollbar { width: 4px; }
|
||||
::-webkit-scrollbar-track { background: #151515; }
|
||||
::-webkit-scrollbar-thumb { background: #353535; border-radius: 2px; }
|
||||
|
||||
/* Scanline Overlay Effect */
|
||||
.crt-overlay {
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; bottom: 0; right: 0;
|
||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.03));
|
||||
background-size: 100% 3px, 3px 100%;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-surface font-body-md selection:bg-primary-container selection:text-on-primary-container overflow-hidden h-screen flex flex-col">
|
||||
<!-- Top Status Bar -->
|
||||
<div class="h-[32px] bg-surface flex items-center justify-between px-margin-edge z-50 shrink-0">
|
||||
<div class="font-label-caps text-[12px] text-[#a0a0a0] flex items-center">
|
||||
<span class="mr-1">▌</span>weekly-goals.json
|
||||
</div>
|
||||
<div class="bg-warning/10 text-warning px-2 py-0.5 rounded-sm flex items-center gap-1.5">
|
||||
<span class="material-symbols-outlined text-[14px]">timer</span>
|
||||
<span class="font-headline text-[10px] font-bold tabular-nums tracking-wider uppercase">RESETS IN 2D 14H</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Main Header Band -->
|
||||
<header class="h-[80px] px-margin-edge flex flex-col justify-center border-b border-outline/20 shrink-0">
|
||||
<h1 class="font-headline text-[24px] font-bold text-suit-black tracking-tight leading-none">WEEKLY GOALS</h1>
|
||||
<p class="font-body-md text-[12px] text-[#a0a0a0] mt-1">Complete goals before reset to claim XP and rewards.</p>
|
||||
</header>
|
||||
<!-- Main Content Canvas -->
|
||||
<main class="flex-1 overflow-y-auto px-margin-edge py-4 space-y-4">
|
||||
<!-- Overall Progress Card -->
|
||||
<section class="h-[80px] bg-surface-container border border-outline/30 rounded-[4px] p-4 flex flex-col justify-between">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-label-caps text-[10px] text-[#a0a0a0] tracking-widest uppercase">OVERALL · 3/5</span>
|
||||
<span class="font-headline text-[12px] text-highlight-celebration tabular-nums">(60%)</span>
|
||||
</div>
|
||||
<div class="w-full h-[6px] bg-[#353535] rounded-full overflow-hidden">
|
||||
<div class="h-full bg-highlight-celebration w-[60%]"></div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<div class="font-label-caps text-[11px] text-highlight-valid flex items-center gap-1">
|
||||
<span class="material-symbols-outlined text-[14px]">stars</span>
|
||||
+220 XP CLAIMED
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Goal List -->
|
||||
<div class="space-y-2 pb-16">
|
||||
<!-- Goal 1: COMPLETED -->
|
||||
<div class="h-[88px] bg-surface-container border border-outline/30 rounded-[4px] p-3 flex flex-col justify-between">
|
||||
<div class="flex justify-between items-start">
|
||||
<h3 class="font-headline text-[14px] font-bold text-suit-black">PLAY 10 GAMES</h3>
|
||||
<div class="bg-highlight-valid/10 text-highlight-valid px-2 py-0.5 rounded-sm font-label-caps text-[11px]">+50 XP</div>
|
||||
</div>
|
||||
<div class="w-full h-[4px] bg-[#353535] rounded-full">
|
||||
<div class="h-full bg-highlight-valid w-full"></div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center font-label-caps text-[10px]">
|
||||
<span class="text-[#a0a0a0]">10/10 GAMES</span>
|
||||
<span class="text-highlight-valid flex items-center gap-1">✓ CLAIMED</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Goal 2: COMPLETED -->
|
||||
<div class="h-[88px] bg-surface-container border border-outline/30 rounded-[4px] p-3 flex flex-col justify-between">
|
||||
<div class="flex justify-between items-start">
|
||||
<h3 class="font-headline text-[14px] font-bold text-suit-black">WIN 5 DAILY SEEDS</h3>
|
||||
<div class="bg-highlight-celebration/10 text-highlight-celebration px-2 py-0.5 rounded-sm font-label-caps text-[11px]">+100 XP</div>
|
||||
</div>
|
||||
<div class="w-full h-[4px] bg-[#353535] rounded-full">
|
||||
<div class="h-full bg-highlight-valid w-full"></div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center font-label-caps text-[10px]">
|
||||
<span class="text-[#a0a0a0]">5/5 DONE</span>
|
||||
<span class="text-highlight-valid flex items-center gap-1">✓ CLAIMED</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Goal 3: IN PROGRESS -->
|
||||
<div class="h-[88px] bg-surface-container border border-outline/30 rounded-[4px] p-3 flex flex-col justify-between border-l-2 border-l-primary-container">
|
||||
<div class="flex justify-between items-start">
|
||||
<h3 class="font-headline text-[14px] font-bold text-suit-black">WIN UNDER 4:00 (3 TIMES)</h3>
|
||||
<div class="bg-highlight-valid/10 text-highlight-valid px-2 py-0.5 rounded-sm font-label-caps text-[11px]">+75 XP</div>
|
||||
</div>
|
||||
<div class="w-full h-[4px] bg-[#353535] rounded-full">
|
||||
<div class="h-full bg-primary-container w-[66%]"></div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center font-label-caps text-[10px]">
|
||||
<span class="text-[#a0a0a0]">2/3</span>
|
||||
<span class="text-primary-container flex items-center gap-1">▶ IN PROGRESS</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Goal 4: NOT STARTED -->
|
||||
<div class="h-[88px] bg-surface-container border border-outline/30 rounded-[4px] p-3 flex flex-col justify-between opacity-70">
|
||||
<div class="flex justify-between items-start">
|
||||
<h3 class="font-headline text-[14px] font-bold text-suit-black">PERFECT GAME (NO UNDO)</h3>
|
||||
<div class="bg-warning/10 text-warning px-2 py-0.5 rounded-sm font-label-caps text-[11px]">+150 XP</div>
|
||||
</div>
|
||||
<div class="w-full h-[4px] bg-[#353535] rounded-full">
|
||||
<div class="h-full bg-outline w-0"></div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center font-label-caps text-[10px]">
|
||||
<span class="text-[#a0a0a0]">0/1</span>
|
||||
<span class="text-outline flex items-center gap-1">○ NOT STARTED</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Goal 5: IN PROGRESS -->
|
||||
<div class="h-[88px] bg-surface-container border border-outline/30 rounded-[4px] p-3 flex flex-col justify-between border-l-2 border-l-primary-container">
|
||||
<div class="flex justify-between items-start">
|
||||
<h3 class="font-headline text-[14px] font-bold text-suit-black">STREAK OF 5 WINS</h3>
|
||||
<div class="bg-highlight-valid/10 text-highlight-valid px-2 py-0.5 rounded-sm font-label-caps text-[11px]">+50 XP</div>
|
||||
</div>
|
||||
<div class="w-full h-[4px] bg-[#353535] rounded-full">
|
||||
<div class="h-full bg-primary-container w-[60%]"></div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center font-label-caps text-[10px]">
|
||||
<span class="text-[#a0a0a0]">3/5</span>
|
||||
<span class="text-primary-container flex items-center gap-1">▶ IN PROGRESS</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Navigation Shell from JSON -->
|
||||
<nav class="fixed bottom-0 left-0 w-full z-50 flex justify-around items-center px-margin-edge bg-surface-container border-t border-outline-variant h-action-bar-height">
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary transition-colors duration-120 active:scale-95 transition-transform duration-80">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary transition-colors duration-120 active:scale-95 transition-transform duration-80">
|
||||
<span class="material-symbols-outlined">undo</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-primary active:scale-95 transition-transform duration-80">
|
||||
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">style</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary transition-colors duration-120 active:scale-95 transition-transform duration-80">
|
||||
<span class="material-symbols-outlined">help_outline</span>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- Bottom Terminal Status Footer -->
|
||||
<footer class="fixed bottom-[64px] left-0 w-full h-[24px] bg-surface flex items-center justify-between px-margin-edge z-50 border-t border-outline/10">
|
||||
<div class="font-label-caps text-[10px] text-[#a0a0a0]">
|
||||
<span class="text-primary">▌</span> NORMAL │ weekly
|
||||
</div>
|
||||
<div class="font-label-caps text-[10px] flex gap-3">
|
||||
<span class="text-[#505050]"><span class="text-[#a0a0a0]">[C]</span> claim all</span>
|
||||
<span class="text-[#505050]"><span class="text-[#a0a0a0]">[ESC]</span> back</span>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- CRT Overlay -->
|
||||
<div class="crt-overlay"></div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,200 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>ROOT@SOLITAIRE:~ | Win Summary</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"on-tertiary": "#4c195b",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"surface-container-highest": "#313538",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"on-secondary": "#293500",
|
||||
"on-background": "#e0e3e6",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"warning": "#ddb26f",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"background": "#101417",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"info": "#12cfc0",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"surface-container": "#202020",
|
||||
"secondary": "#bad073",
|
||||
"outline": "#505050",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"secondary-container": "#435401",
|
||||
"inverse-primary": "#00668a",
|
||||
"surface": "#151515",
|
||||
"on-error-container": "#ffdad6",
|
||||
"error-container": "#93000a",
|
||||
"surface-bright": "#363a3d",
|
||||
"surface-dim": "#101417",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"surface-container-high": "#272a2d",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"on-tertiary-container": "#683476",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"on-primary-container": "#004f6c",
|
||||
"on-error": "#690005",
|
||||
"on-surface": "#e0e3e6",
|
||||
"surface-variant": "#313538",
|
||||
"highlight-valid": "#acc267",
|
||||
"primary": "#a1dcff"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"margin-edge": "1rem",
|
||||
"action-bar-height": "64px",
|
||||
"gutter-card": "0.375rem",
|
||||
"touch-target-min": "48dp",
|
||||
"stack-overlap": "2rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"body-md": ["Inter"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.crt-scanlines {
|
||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.02), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.02));
|
||||
background-size: 100% 2px, 3px 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.lavender-glow {
|
||||
text-shadow: 0 0 16px rgba(225, 163, 238, 0.3);
|
||||
}
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-background font-body-md selection:bg-primary-container selection:text-on-primary-container min-h-screen flex flex-col relative overflow-hidden">
|
||||
<!-- CRT Overlay -->
|
||||
<div class="fixed inset-0 crt-scanlines z-[100] opacity-30"></div>
|
||||
<!-- Status Bar (Emulating TopAppBar context) -->
|
||||
<header class="h-[32px] bg-surface-container flex items-center justify-between px-margin-edge z-50 border-b border-outline-variant">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-label-caps text-[11px] text-on-surface">▌win.tsx</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-info"></span>
|
||||
<span class="font-label-caps text-[11px] text-info uppercase">Synced</span>
|
||||
</div>
|
||||
<span class="font-label-caps text-[11px] text-outline uppercase tracking-tight">v0.20.0</span>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Content Canvas -->
|
||||
<main class="flex-1 flex flex-col px-margin-edge pt-12 pb-8 gap-8 relative z-10">
|
||||
<!-- Hero Band -->
|
||||
<section class="flex flex-col items-center text-center space-y-2">
|
||||
<h1 class="font-headline text-[48px] leading-tight text-highlight-celebration uppercase lavender-glow tracking-tighter">
|
||||
█ COMPLETE
|
||||
</h1>
|
||||
<p class="font-label-caps text-[12px] text-outline-variant tracking-[0.2em]">
|
||||
GAME #2024-127 · DRAW-3
|
||||
</p>
|
||||
</section>
|
||||
<!-- Stats Card -->
|
||||
<section class="bg-surface-container border border-outline-variant rounded-lg overflow-hidden p-6 grid grid-cols-2 gap-y-8 gap-x-4">
|
||||
<div class="space-y-1">
|
||||
<span class="block font-label-caps text-outline uppercase text-[10px]">Final Score</span>
|
||||
<span class="block font-hud-score text-on-background">1,024</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<span class="block font-label-caps text-outline uppercase text-[10px]">Time</span>
|
||||
<span class="block font-hud-timer text-on-background">12:34</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<span class="block font-label-caps text-outline uppercase text-[10px]">Moves</span>
|
||||
<span class="block font-hud-timer text-on-background">87</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<span class="block font-label-caps text-outline uppercase text-[10px]">Par Delta</span>
|
||||
<span class="block font-hud-timer text-highlight-valid">−13</span>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Achievement Card -->
|
||||
<section class="bg-surface-container border-l-2 border-highlight-celebration rounded-r-lg p-4 flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<span class="font-label-caps text-highlight-celebration text-[10px] flex items-center gap-1">
|
||||
<span class="text-[8px]">▲</span> ACHIEVEMENT UNLOCKED
|
||||
</span>
|
||||
<p class="font-label-caps text-suit-black uppercase tracking-wider text-[14px]">FIRST DAILY WIN</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 rounded-full border border-outline-variant flex items-center justify-center bg-surface-container-low">
|
||||
<span class="material-symbols-outlined text-highlight-celebration text-2xl" data-icon="military_tech">military_tech</span>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Action Buttons -->
|
||||
<div class="mt-auto space-y-3">
|
||||
<button class="w-full h-[56px] bg-info text-surface font-label-caps text-[14px] font-bold tracking-widest flex items-center justify-center gap-2 hover:opacity-90 active:opacity-80 transition-all uppercase">
|
||||
<span class="text-lg">▶</span> New Game
|
||||
</button>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button class="h-[48px] border border-outline text-on-surface-variant font-label-caps text-[12px] flex items-center justify-center gap-2 hover:border-info hover:text-info transition-colors uppercase">
|
||||
<span class="text-sm">↺</span> Replay Seed
|
||||
</button>
|
||||
<button class="h-[48px] border border-outline text-on-surface-variant font-label-caps text-[12px] flex items-center justify-center gap-2 hover:border-info hover:text-info transition-colors uppercase">
|
||||
<span class="text-sm">⌂</span> Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Footer Keys -->
|
||||
<footer class="h-action-bar-height flex flex-col items-center justify-center px-margin-edge border-t border-outline-variant bg-surface-container-low">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-label-caps text-outline-variant">[ S ]</span>
|
||||
<span class="font-label-caps text-outline uppercase text-[10px]">share screenshot</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-label-caps text-outline-variant">[ X ]</span>
|
||||
<span class="font-label-caps text-outline uppercase text-[10px]">copy seed</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- Bottom Nav Suppression Logic: This is a task-focused confirmation screen, so the global BottomNavBar from JSON is suppressed to focus on primary actions. -->
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -8,8 +8,68 @@ edition.workspace = true
|
||||
name = "solitaire_app"
|
||||
path = "src/main.rs"
|
||||
|
||||
# `cdylib` is what cargo-apk packages into `libsolitaire_app.so` for
|
||||
# Android — the activity dlopens the shared object and calls into it.
|
||||
# `rlib` lets the bin target above link the library normally on
|
||||
# desktop. Both produce the same code; only the linkage form differs.
|
||||
[lib]
|
||||
name = "solitaire_app"
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
bevy = { workspace = true }
|
||||
solitaire_engine = { workspace = true }
|
||||
solitaire_data = { workspace = true }
|
||||
|
||||
# `keyring`'s default-store init only matters on platforms with a
|
||||
# real keychain backend (Linux Secret Service, macOS Keychain,
|
||||
# Windows Credential Store). The crate also pulls `rpassword`
|
||||
# transitively, which uses `libc::__errno_location` — a symbol
|
||||
# Android's bionic doesn't expose. Target-gating keeps
|
||||
# `cargo apk build` viable; the call site in `lib.rs` has its own
|
||||
# `cfg(not(target_os = "android"))` guard so the desktop init path
|
||||
# is unchanged.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
keyring = { workspace = true }
|
||||
|
||||
# --- Android packaging metadata (read by `cargo-apk`) -------------------
|
||||
#
|
||||
# Pinning these values inside the repo means a contributor running
|
||||
# `cargo apk build -p solitaire_app --target x86_64-linux-android`
|
||||
# does not need to install whatever SDK version cargo-apk happens to
|
||||
# default to today. The numbers track the SDK we install in the dev
|
||||
# setup script: target SDK 34 (Android 14, current Play Store target),
|
||||
# min SDK 26 (Android 8, the lowest Bevy 0.18 supports cleanly with
|
||||
# the wgpu / GLES path).
|
||||
#
|
||||
# Asset path is `../assets` so the same directory the desktop build
|
||||
# already uses ships into the APK without copy-tree gymnastics.
|
||||
# `apk_name` keeps the output filename predictable across machines.
|
||||
[package.metadata.android]
|
||||
package = "com.solitairequest.app"
|
||||
apk_name = "solitaire-quest"
|
||||
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
|
||||
assets = "../assets"
|
||||
# No `runtime_libs` — we don't ship any precompiled .so files,
|
||||
# the entire app is pure Rust + Bevy. cargo-apk would try to
|
||||
# resolve `runtime_libs/<arch>/` if set, and fail on a non-existent
|
||||
# arch directory under our package.
|
||||
strip = "strip"
|
||||
|
||||
[package.metadata.android.sdk]
|
||||
target_sdk_version = 34
|
||||
min_sdk_version = 26
|
||||
|
||||
[[package.metadata.android.uses_feature]]
|
||||
name = "android.hardware.touchscreen"
|
||||
required = true
|
||||
|
||||
[[package.metadata.android.uses_permission]]
|
||||
name = "android.permission.INTERNET"
|
||||
|
||||
[package.metadata.android.application]
|
||||
label = "Solitaire Quest"
|
||||
# `debuggable` defaults to false on release builds; cargo-apk flips it
|
||||
# automatically for debug profiles. Leaving the field unset keeps the
|
||||
# default behaviour.
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Library entry point for `solitaire_app`.
|
||||
//!
|
||||
//! The app is a `cdylib + bin` hybrid: desktop builds run through the
|
||||
//! `bin` target's [`main`](crate::main_desktop) shim; Android builds
|
||||
//! load this `cdylib` via NativeActivity / GameActivity, which calls
|
||||
//! into the platform's own `main` glue. Both paths converge on
|
||||
//! [`run`], so the ECS bootstrap is single-sourced.
|
||||
//!
|
||||
//! Why split this out: cargo-apk requires the package to expose a
|
||||
//! `cdylib` library target — the Android activity dlopens
|
||||
//! `libsolitaire_app.so` and calls into it. A bin-only crate panics
|
||||
//! at build time with `Bin is not compatible with Cdylib`. The split
|
||||
//! keeps the desktop `cargo run -p solitaire_app` flow unchanged
|
||||
//! while making `cargo apk build -p solitaire_app` viable.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{
|
||||
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
|
||||
};
|
||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||
use solitaire_engine::{
|
||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
|
||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
||||
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
|
||||
/// App entry point — builds and runs the Bevy app.
|
||||
///
|
||||
/// Called from both the desktop `bin` target's `main` shim and (on
|
||||
/// Android) the platform's NativeActivity / GameActivity glue.
|
||||
pub fn run() {
|
||||
// Install a panic hook that writes a crash log next to the save files
|
||||
// before re-running the default hook (so stderr still gets the message
|
||||
// and any debugger attached still sees the panic).
|
||||
install_crash_log_hook();
|
||||
|
||||
// Initialise the platform keyring store before any token operations.
|
||||
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||
// If the platform has no OS keyring (e.g. a headless CI box), keyring
|
||||
// operations will fail gracefully with TokenError::KeychainUnavailable.
|
||||
//
|
||||
// Android: `keyring` isn't compiled in (its `rpassword` transitive
|
||||
// pulls a libc symbol Android's bionic doesn't expose). `auth_tokens`
|
||||
// ships an Android stub that returns KeychainUnavailable for every
|
||||
// call — the runtime behaviour is "session login required each launch"
|
||||
// until we wire Android Keystore via JNI in the Phase-Android round.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
if let Err(e) = keyring::use_native_store(true) {
|
||||
eprintln!(
|
||||
"warn: could not initialise OS keyring ({e}); \
|
||||
server sync login will be unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
// Load settings before building the app so we can construct the right
|
||||
// sync provider. Falls back to defaults if no settings file exists yet.
|
||||
let settings: Settings = settings_file_path()
|
||||
.map(|p| load_settings_from(&p))
|
||||
.unwrap_or_default();
|
||||
let sync_provider = provider_for_backend(&settings.sync_backend);
|
||||
|
||||
// Restore the previous window geometry if the player has one saved.
|
||||
// Otherwise open at the platform default (1280×800, centred on the
|
||||
// primary monitor) — `apply_smart_default_window_size` will resize
|
||||
// up to a monitor-relative target on the first frame so HiDPI / 4K
|
||||
// sessions don't end up with a comparatively tiny window.
|
||||
let had_saved_geometry = settings.window_geometry.is_some();
|
||||
let (window_resolution, window_position) = match settings.window_geometry {
|
||||
Some(geom) => (
|
||||
(geom.width, geom.height).into(),
|
||||
WindowPosition::At(IVec2::new(geom.x, geom.y)),
|
||||
),
|
||||
None => (
|
||||
(1280u32, 800u32).into(),
|
||||
WindowPosition::Centered(MonitorSelection::Primary),
|
||||
),
|
||||
};
|
||||
|
||||
let mut app = App::new();
|
||||
|
||||
// The card-theme system's `themes://` asset source must be
|
||||
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
|
||||
// because that plugin freezes the asset-source list at build
|
||||
// time. The matching `AssetSourcesPlugin` (added below) finishes
|
||||
// the wiring after `DefaultPlugins` by populating the embedded
|
||||
// default theme into Bevy's `EmbeddedAssetRegistry`.
|
||||
register_theme_asset_sources(&mut app);
|
||||
|
||||
app
|
||||
.add_plugins(
|
||||
DefaultPlugins
|
||||
.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: "Solitaire Quest".into(),
|
||||
// X11/Wayland WM_CLASS so taskbar managers group
|
||||
// multiple windows of this app correctly.
|
||||
name: Some("solitaire-quest".into()),
|
||||
resolution: window_resolution,
|
||||
position: window_position,
|
||||
// AutoNoVsync prefers Mailbox (triple-buffered) and
|
||||
// falls back to Immediate, eliminating the vsync stall
|
||||
// that AutoVsync produces during continuous window
|
||||
// resize on X11 / Wayland. The game's frame budget is
|
||||
// small enough that a few stray dropped frames from
|
||||
// disabling vsync are imperceptible.
|
||||
present_mode: PresentMode::AutoNoVsync,
|
||||
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||
min_width: 800.0,
|
||||
min_height: 600.0,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
})
|
||||
// The `assets/` directory lives at the workspace root, but
|
||||
// Bevy resolves `AssetPlugin::file_path` relative to the
|
||||
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
|
||||
// Point one level up so `cargo run -p solitaire_app` finds
|
||||
// card faces, backs, backgrounds, and the UI font.
|
||||
.set(bevy::asset::AssetPlugin {
|
||||
file_path: "../assets".to_string(),
|
||||
..default()
|
||||
}),
|
||||
)
|
||||
.add_plugins(AssetSourcesPlugin)
|
||||
.add_plugins(ThemePlugin)
|
||||
.add_plugins(ThemeRegistryPlugin)
|
||||
.add_plugins(FontPlugin)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(CardPlugin)
|
||||
.add_plugins(CursorPlugin)
|
||||
.add_plugins(InputPlugin)
|
||||
.add_plugins(RadialMenuPlugin)
|
||||
.add_plugins(SelectionPlugin)
|
||||
.add_plugins(AnimationPlugin)
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(ReplayPlaybackPlugin)
|
||||
.add_plugins(ReplayOverlayPlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
.add_plugins(ProgressPlugin::default())
|
||||
.add_plugins(AchievementPlugin::default())
|
||||
.add_plugins(DailyChallengePlugin)
|
||||
.add_plugins(WeeklyGoalsPlugin)
|
||||
.add_plugins(ChallengePlugin)
|
||||
.add_plugins(TimeAttackPlugin)
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
.add_plugins(AudioPlugin)
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(LeaderboardPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(UiTooltipPlugin)
|
||||
.add_plugins(SplashPlugin)
|
||||
.add_plugins(DiagnosticsHudPlugin);
|
||||
|
||||
// Smart default window sizing: when no saved geometry was loaded,
|
||||
// resize the freshly-opened 1280×800 window to ~70 % of the primary
|
||||
// monitor's logical size on the first frame. Without this, a 4K
|
||||
// monitor opens the same 1280×800 window that a 1080p monitor
|
||||
// does — visually tiny relative to screen. Skipped entirely when
|
||||
// saved geometry was applied; the player's preference always wins.
|
||||
//
|
||||
// Players who specifically want the literal 1280×800 baseline on
|
||||
// every fresh launch can flip `disable_smart_default_size` in
|
||||
// Settings to opt out. The flag is checked once at startup; a
|
||||
// mid-session change applies on the next launch.
|
||||
if !had_saved_geometry && !settings.disable_smart_default_size {
|
||||
app.add_systems(Update, apply_smart_default_window_size);
|
||||
}
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
/// One-shot Update system that runs only on launches without saved
|
||||
/// window geometry. Resizes the primary window to a fraction of the
|
||||
/// primary monitor's *logical* size — bigger monitors get bigger
|
||||
/// windows automatically. Logical size already accounts for the OS's
|
||||
/// HiDPI scale factor, so a 2880×1800 Retina display reporting
|
||||
/// scale_factor 2.0 yields a 1440×900 logical size and a 1008×630
|
||||
/// target window — same physical inches as a 1920×1080 monitor with
|
||||
/// scale_factor 1.0 yielding 1344×756.
|
||||
///
|
||||
/// Uses `Local<bool>` to make itself one-shot rather than introducing
|
||||
/// a dedicated resource. The Update tick is necessary because Bevy
|
||||
/// populates the `Monitor` entities asynchronously after winit's
|
||||
/// Resumed event fires; they may not exist on the first Startup pass.
|
||||
fn apply_smart_default_window_size(
|
||||
mut applied: Local<bool>,
|
||||
monitors: Query<&Monitor, With<PrimaryMonitor>>,
|
||||
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
||||
) {
|
||||
if *applied {
|
||||
return;
|
||||
}
|
||||
let Ok(monitor) = monitors.single() else {
|
||||
// Primary monitor not yet spawned by bevy_winit. Try again
|
||||
// next frame; the cost is one early-exit per tick until
|
||||
// monitors arrive (typically frame 1 or 2).
|
||||
return;
|
||||
};
|
||||
let Ok(mut window) = windows.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let scale = monitor.scale_factor as f32;
|
||||
if scale <= 0.0 {
|
||||
// Defensive: a zero or negative scale factor would NaN the
|
||||
// arithmetic below. Bail and accept the default size.
|
||||
*applied = true;
|
||||
return;
|
||||
}
|
||||
let logical_w = monitor.physical_width as f32 / scale;
|
||||
let logical_h = monitor.physical_height as f32 / scale;
|
||||
|
||||
// Target 70 % of monitor in each dimension, clamped to the
|
||||
// existing 800×600 minimum and the monitor's own logical size
|
||||
// (so we never request a window larger than the screen).
|
||||
let target_w = (logical_w * 0.7).clamp(800.0, logical_w);
|
||||
let target_h = (logical_h * 0.7).clamp(600.0, logical_h);
|
||||
|
||||
// Resize only when the change is meaningful — at exactly 1280×800
|
||||
// on a 1920×1080 monitor the new target is 1344×756 (only ~5 %
|
||||
// wider), worth the resize; at the same default on an 800×600
|
||||
// monitor the clamp pins us at 800×600 and we shouldn't resize.
|
||||
let curr_w = window.resolution.width();
|
||||
let curr_h = window.resolution.height();
|
||||
if (curr_w - target_w).abs() > 8.0 || (curr_h - target_h).abs() > 8.0 {
|
||||
window.resolution.set(target_w, target_h);
|
||||
}
|
||||
*applied = true;
|
||||
}
|
||||
|
||||
/// Wraps the default panic hook with one that also appends a crash log
|
||||
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||
/// still runs afterwards, so stderr output and debugger integration are
|
||||
/// unchanged. If the data directory is unavailable, the wrapper silently
|
||||
/// falls through — the default hook handles output either way.
|
||||
fn install_crash_log_hook() {
|
||||
let crash_log_path = settings_file_path().and_then(|p| {
|
||||
p.parent()
|
||||
.map(|parent| parent.join("crash.log"))
|
||||
});
|
||||
let default_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if let Some(path) = crash_log_path.as_ref()
|
||||
&& let Ok(mut file) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
{
|
||||
// Plain unix-seconds timestamp keeps the format trivially
|
||||
// parseable and avoids pulling in chrono just for this.
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
|
||||
}
|
||||
default_hook(info);
|
||||
}));
|
||||
}
|
||||
@@ -1,172 +1,9 @@
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||
use solitaire_engine::{
|
||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
|
||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
//! Desktop entry point for `solitaire_app`.
|
||||
//!
|
||||
//! The body of the app lives in `lib.rs` so cargo-apk can package the
|
||||
//! same code into an Android `cdylib`. This shim is the desktop /
|
||||
//! `cargo run` path — it just delegates to [`solitaire_app::run`].
|
||||
|
||||
fn main() {
|
||||
// Install a panic hook that writes a crash log next to the save files
|
||||
// before re-running the default hook (so stderr still gets the message
|
||||
// and any debugger attached still sees the panic).
|
||||
install_crash_log_hook();
|
||||
|
||||
// Initialise the platform keyring store before any token operations.
|
||||
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||
// If the platform has no OS keyring (e.g. a headless CI box), keyring
|
||||
// operations will fail gracefully with TokenError::KeychainUnavailable.
|
||||
if let Err(e) = keyring::use_native_store(true) {
|
||||
eprintln!(
|
||||
"warn: could not initialise OS keyring ({e}); \
|
||||
server sync login will be unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
// Load settings before building the app so we can construct the right
|
||||
// sync provider. Falls back to defaults if no settings file exists yet.
|
||||
let settings: Settings = settings_file_path()
|
||||
.map(|p| load_settings_from(&p))
|
||||
.unwrap_or_default();
|
||||
let sync_provider = provider_for_backend(&settings.sync_backend);
|
||||
|
||||
// Restore the previous window geometry if the player has one saved.
|
||||
// Otherwise open at the platform default (1280×800, centred on the
|
||||
// primary monitor). The window_geometry field is None on first run
|
||||
// and after upgrading from a build that didn't persist geometry.
|
||||
let (window_resolution, window_position) = match settings.window_geometry {
|
||||
Some(geom) => (
|
||||
(geom.width, geom.height).into(),
|
||||
WindowPosition::At(IVec2::new(geom.x, geom.y)),
|
||||
),
|
||||
None => (
|
||||
(1280u32, 800u32).into(),
|
||||
WindowPosition::Centered(MonitorSelection::Primary),
|
||||
),
|
||||
};
|
||||
|
||||
let mut app = App::new();
|
||||
|
||||
// The card-theme system's `themes://` asset source must be
|
||||
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
|
||||
// because that plugin freezes the asset-source list at build
|
||||
// time. The matching `AssetSourcesPlugin` (added below) finishes
|
||||
// the wiring after `DefaultPlugins` by populating the embedded
|
||||
// default theme into Bevy's `EmbeddedAssetRegistry`.
|
||||
register_theme_asset_sources(&mut app);
|
||||
|
||||
app
|
||||
.add_plugins(
|
||||
DefaultPlugins
|
||||
.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: "Solitaire Quest".into(),
|
||||
// X11/Wayland WM_CLASS so taskbar managers group
|
||||
// multiple windows of this app correctly.
|
||||
name: Some("solitaire-quest".into()),
|
||||
resolution: window_resolution,
|
||||
position: window_position,
|
||||
// AutoNoVsync prefers Mailbox (triple-buffered) and
|
||||
// falls back to Immediate, eliminating the vsync stall
|
||||
// that AutoVsync produces during continuous window
|
||||
// resize on X11 / Wayland. The game's frame budget is
|
||||
// small enough that a few stray dropped frames from
|
||||
// disabling vsync are imperceptible.
|
||||
present_mode: PresentMode::AutoNoVsync,
|
||||
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||
min_width: 800.0,
|
||||
min_height: 600.0,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
})
|
||||
// The `assets/` directory lives at the workspace root, but
|
||||
// Bevy resolves `AssetPlugin::file_path` relative to the
|
||||
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
|
||||
// Point one level up so `cargo run -p solitaire_app` finds
|
||||
// card faces, backs, backgrounds, and the UI font.
|
||||
.set(bevy::asset::AssetPlugin {
|
||||
file_path: "../assets".to_string(),
|
||||
..default()
|
||||
}),
|
||||
)
|
||||
.add_plugins(AssetSourcesPlugin)
|
||||
.add_plugins(ThemePlugin)
|
||||
.add_plugins(ThemeRegistryPlugin)
|
||||
.add_plugins(FontPlugin)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(CardPlugin)
|
||||
.add_plugins(CursorPlugin)
|
||||
.add_plugins(InputPlugin)
|
||||
.add_plugins(RadialMenuPlugin)
|
||||
.add_plugins(SelectionPlugin)
|
||||
.add_plugins(AnimationPlugin)
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
.add_plugins(ProgressPlugin::default())
|
||||
.add_plugins(AchievementPlugin::default())
|
||||
.add_plugins(DailyChallengePlugin)
|
||||
.add_plugins(WeeklyGoalsPlugin)
|
||||
.add_plugins(ChallengePlugin)
|
||||
.add_plugins(TimeAttackPlugin)
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin)
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
.add_plugins(AudioPlugin)
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(LeaderboardPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(UiTooltipPlugin)
|
||||
.add_plugins(SplashPlugin)
|
||||
.run();
|
||||
}
|
||||
|
||||
/// Wraps the default panic hook with one that also appends a crash log
|
||||
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||
/// still runs afterwards, so stderr output and debugger integration are
|
||||
/// unchanged. If the data directory is unavailable, the wrapper silently
|
||||
/// falls through — the default hook handles output either way.
|
||||
fn install_crash_log_hook() {
|
||||
let crash_log_path = settings_file_path().and_then(|p| {
|
||||
p.parent()
|
||||
.map(|parent| parent.join("crash.log"))
|
||||
});
|
||||
let default_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if let Some(path) = crash_log_path.as_ref()
|
||||
&& let Ok(mut file) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
{
|
||||
// Plain unix-seconds timestamp keeps the format trivially
|
||||
// parseable and avoids pulling in chrono just for this.
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
|
||||
}
|
||||
default_hook(info);
|
||||
}));
|
||||
solitaire_app::run();
|
||||
}
|
||||
|
||||
@@ -140,6 +140,16 @@ fn comeback(c: &AchievementContext) -> bool {
|
||||
fn zen_winner(c: &AchievementContext) -> bool {
|
||||
c.last_win_is_zen
|
||||
}
|
||||
/// Cinephile is event-driven: it unlocks when the engine observes a
|
||||
/// `ReplayPlaybackState` transition from `Playing` to `Completed`, not on
|
||||
/// any field of [`AchievementContext`]. The condition predicate therefore
|
||||
/// always returns false so [`check_achievements`] never unlocks it from a
|
||||
/// `GameWonEvent` / `StateChangedEvent` cycle — the unlock is driven by
|
||||
/// `AchievementUnlockedEvent` written directly from the engine's
|
||||
/// replay-playback observer.
|
||||
fn cinephile_never(_c: &AchievementContext) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// All currently-evaluable achievements. Order is stable so persistence files
|
||||
/// remain readable across versions (new achievements append).
|
||||
@@ -288,6 +298,18 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
||||
reward: Some(Reward::Badge),
|
||||
condition: zen_winner,
|
||||
},
|
||||
AchievementDef {
|
||||
id: "cinephile",
|
||||
name: "Cinephile",
|
||||
description: "Watch a saved replay all the way through",
|
||||
secret: false,
|
||||
reward: None,
|
||||
// Event-driven unlock: the engine's replay-playback observer fires
|
||||
// `AchievementUnlockedEvent("cinephile")` directly on a Playing →
|
||||
// Completed transition. `cinephile_never` keeps the condition path
|
||||
// a no-op so a `GameWonEvent` evaluation cycle cannot unlock it.
|
||||
condition: cinephile_never,
|
||||
},
|
||||
];
|
||||
|
||||
/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
|
||||
@@ -721,6 +743,31 @@ mod tests {
|
||||
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cinephile_achievement_in_canonical_list() {
|
||||
let def = achievement_by_id("cinephile").expect("cinephile must be registered");
|
||||
assert_eq!(def.id, "cinephile");
|
||||
assert_eq!(def.name, "Cinephile");
|
||||
assert!(!def.secret, "cinephile is not a secret achievement");
|
||||
// Event-driven: the predicate is a sentinel that always returns
|
||||
// false. `check_achievements` must never unlock cinephile from a
|
||||
// GameWonEvent context, even one that satisfies every other gate.
|
||||
let mut c = ctx();
|
||||
c.games_won = 1;
|
||||
c.win_streak_current = 999;
|
||||
c.last_win_time_seconds = 1;
|
||||
c.last_win_used_undo = false;
|
||||
c.best_single_score = 99_999;
|
||||
c.lifetime_score = u64::MAX;
|
||||
c.last_win_is_zen = true;
|
||||
c.last_win_recycle_count = 99;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(
|
||||
!ids.contains(&"cinephile"),
|
||||
"cinephile must never unlock via condition evaluation; got {ids:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfectionist_score_well_above_threshold_still_passes() {
|
||||
let mut c = ctx();
|
||||
|
||||
@@ -77,16 +77,6 @@ pub struct Card {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rank_value_ace_is_one() {
|
||||
assert_eq!(Rank::Ace.value(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_value_king_is_thirteen() {
|
||||
assert_eq!(Rank::King.value(), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_values_are_sequential() {
|
||||
let ranks = [
|
||||
@@ -100,26 +90,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_red_is_diamonds_and_hearts() {
|
||||
assert!(Suit::Diamonds.is_red());
|
||||
assert!(Suit::Hearts.is_red());
|
||||
assert!(!Suit::Clubs.is_red());
|
||||
assert!(!Suit::Spades.is_red());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_black_is_clubs_and_spades() {
|
||||
assert!(Suit::Clubs.is_black());
|
||||
assert!(Suit::Spades.is_black());
|
||||
assert!(!Suit::Diamonds.is_black());
|
||||
assert!(!Suit::Hearts.is_black());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_up_field_reflects_construction() {
|
||||
let card = Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: false };
|
||||
assert!(!card.face_up);
|
||||
let card2 = Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true };
|
||||
assert!(card2.face_up);
|
||||
fn suit_red_and_black_are_complementary() {
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
assert_ne!(suit.is_red(), suit.is_black(), "{suit:?} must be exactly one of red/black");
|
||||
}
|
||||
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
||||
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -815,11 +815,6 @@ mod tests {
|
||||
assert!(g.undo_stack_len() <= 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_count_starts_at_zero() {
|
||||
assert_eq!(new_game().undo_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_count_increments_on_each_undo() {
|
||||
let mut g = new_game();
|
||||
@@ -900,11 +895,6 @@ mod tests {
|
||||
assert_eq!(g.score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_mode_default_is_classic_via_default_trait() {
|
||||
assert_eq!(GameMode::default(), GameMode::Classic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_mode_field_persists_through_construction() {
|
||||
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
|
||||
@@ -956,12 +946,6 @@ mod tests {
|
||||
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_attack_score_starts_at_zero() {
|
||||
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack);
|
||||
assert_eq!(g.score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_attack_draw_three_combination() {
|
||||
// TimeAttack + DrawThree is a valid combination; verify construction.
|
||||
|
||||
@@ -6,3 +6,4 @@ pub mod game_state;
|
||||
pub mod pile;
|
||||
pub mod rules;
|
||||
pub mod scoring;
|
||||
pub mod solver;
|
||||
|
||||
@@ -13,10 +13,19 @@ chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
keyring-core = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
# `keyring-core` is the typed Entry/Error API used by
|
||||
# `auth_tokens`. The crate's own dependency tree pulls in
|
||||
# `rpassword` which uses `libc::__errno_location` — a symbol the
|
||||
# Android NDK doesn't expose (`__errno` lives at a different path
|
||||
# on bionic). On Android `auth_tokens` falls back to a stub
|
||||
# implementation that always returns `KeychainUnavailable`; the
|
||||
# real backend lands when we wire Android Keystore via JNI.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
keyring-core = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
solitaire_server = { path = "../solitaire_server" }
|
||||
solitaire_sync = { workspace = true }
|
||||
|
||||
@@ -15,7 +15,7 @@ const FILE_NAME: &str = "achievements.json";
|
||||
|
||||
/// Platform-specific default path for `achievements.json`.
|
||||
pub fn achievements_file_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load achievements from an explicit path. Returns `Vec::new()` if the file
|
||||
|
||||
@@ -14,8 +14,19 @@
|
||||
//! the Bevy `App`). If no default store is set, all operations in this module
|
||||
//! will return [`TokenError::KeychainUnavailable`].
|
||||
//!
|
||||
//! # Android stub
|
||||
//!
|
||||
//! `keyring-core` cannot compile for the android target (its `rpassword`
|
||||
//! transitive dep uses `libc::__errno_location`, which Android's bionic
|
||||
//! doesn't expose). On Android every function in this module returns
|
||||
//! [`TokenError::KeychainUnavailable`] so callers can detect the fallback
|
||||
//! the same way they handle a Linux box without Secret Service. The
|
||||
//! real Android backend will arrive in the Phase-Android round when we
|
||||
//! wire Android Keystore via JNI.
|
||||
//!
|
||||
//! # Note: no unit tests — requires live OS keychain.
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use keyring_core::Entry;
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -34,9 +45,11 @@ pub enum TokenError {
|
||||
}
|
||||
|
||||
/// Service name used to namespace all keychain entries for this application.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const SERVICE: &str = "solitaire_quest_server";
|
||||
|
||||
/// Map a `keyring_core::Error` to the appropriate `TokenError`.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
|
||||
let msg = err.to_string();
|
||||
match err {
|
||||
@@ -51,6 +64,7 @@ fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
|
||||
/// Store the access and refresh tokens for `username` in the OS keychain.
|
||||
///
|
||||
/// Any previously stored tokens for that username are overwritten.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn store_tokens(
|
||||
username: &str,
|
||||
access_token: &str,
|
||||
@@ -72,6 +86,7 @@ pub fn store_tokens(
|
||||
/// Load the stored access token for `username` from the OS keychain.
|
||||
///
|
||||
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
||||
Entry::new(SERVICE, &format!("{username}_access"))
|
||||
.map_err(|e| map_keyring_err(e, username))?
|
||||
@@ -82,6 +97,7 @@ pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
||||
/// Load the stored refresh token for `username` from the OS keychain.
|
||||
///
|
||||
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||
Entry::new(SERVICE, &format!("{username}_refresh"))
|
||||
.map_err(|e| map_keyring_err(e, username))?
|
||||
@@ -93,6 +109,7 @@ pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||
///
|
||||
/// Intended to be called on logout or account deletion. Missing entries are
|
||||
/// silently ignored (the tokens are already gone, which is the desired state).
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||
match Entry::new(SERVICE, &format!("{username}_access"))
|
||||
.map_err(|e| map_keyring_err(e, username))?
|
||||
@@ -112,3 +129,37 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Android stub — same public API, always returns KeychainUnavailable.
|
||||
// Lets `sync_client::*` compile unchanged on Android; the runtime
|
||||
// effect is "session login required every launch", same as a Linux
|
||||
// box without Secret Service.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
const ANDROID_STUB_MSG: &str = "android stub: keychain not yet wired (Phase-Android task)";
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn store_tokens(
|
||||
_username: &str,
|
||||
_access_token: &str,
|
||||
_refresh_token: &str,
|
||||
) -> Result<(), TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn load_access_token(_username: &str) -> Result<String, TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn load_refresh_token(_username: &str) -> Result<String, TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
|
||||
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
|
||||
}
|
||||
|
||||
@@ -90,9 +90,4 @@ mod tests {
|
||||
seeds.dedup();
|
||||
assert_eq!(seeds.len(), len_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_count_matches_seed_list_length() {
|
||||
assert_eq!(challenge_count() as usize, CHALLENGE_SEEDS.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,13 +56,13 @@ pub trait SyncProvider: Send + Sync {
|
||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||
Ok(())
|
||||
}
|
||||
/// Upload a winning replay to the backend so it's available for web
|
||||
/// playback at `<server>/replays/<id>`. Default returns
|
||||
/// `UnsupportedPlatform` so backends without a server (e.g.
|
||||
/// `LocalOnlyProvider`) are silently no-op'd by the engine's
|
||||
/// push-on-win system, matching the same pattern `pull` / `push`
|
||||
/// follow.
|
||||
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<(), SyncError> {
|
||||
/// Upload a winning replay to the backend. On success, returns the
|
||||
/// shareable web URL the player can copy to their clipboard
|
||||
/// (`<server>/replays/<id>`). Default returns `UnsupportedPlatform`
|
||||
/// so backends without a server (e.g. `LocalOnlyProvider`) are
|
||||
/// silently no-op'd by the engine's push-on-win system, matching
|
||||
/// the same pattern `pull` / `push` follow.
|
||||
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
||||
Err(SyncError::UnsupportedPlatform)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||
(**self).delete_account().await
|
||||
}
|
||||
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<(), SyncError> {
|
||||
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
||||
(**self).push_replay(replay).await
|
||||
}
|
||||
}
|
||||
@@ -141,9 +141,10 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||
Theme, WindowGeometry, TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN,
|
||||
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS,
|
||||
TOOLTIP_DELAY_STEP_SECS,
|
||||
Theme, WindowGeometry, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
|
||||
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
|
||||
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
|
||||
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
||||
};
|
||||
|
||||
pub mod auth_tokens;
|
||||
@@ -155,7 +156,13 @@ pub mod sync_client;
|
||||
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
|
||||
|
||||
pub mod replay;
|
||||
#[allow(deprecated)]
|
||||
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
|
||||
pub use replay::{
|
||||
latest_replay_path, load_latest_replay_from, save_latest_replay_to, Replay, ReplayMove,
|
||||
REPLAY_SCHEMA_VERSION,
|
||||
append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay,
|
||||
replay_history_path, save_replay_history_to, Replay, ReplayHistory, ReplayMove,
|
||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
|
||||
};
|
||||
|
||||
pub mod platform;
|
||||
pub use platform::data_dir;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Per-platform resolution of the per-user data directory.
|
||||
//!
|
||||
//! The rest of `solitaire_data` (settings, stats, achievements,
|
||||
//! replays, progress, game state) and the engine's user-themes
|
||||
//! discovery all need a base path under which to nest
|
||||
//! `solitaire_quest/<file>`. On desktop the right answer is
|
||||
//! `dirs::data_dir()` (which resolves to platform-appropriate
|
||||
//! locations: `~/.local/share` on Linux, `~/Library/Application
|
||||
//! Support` on macOS, `%APPDATA%` on Windows). On Android the
|
||||
//! `dirs` crate returns `None`, which would silently disable
|
||||
//! every persistence path — settings, stats, replays, the lot.
|
||||
//!
|
||||
//! [`data_dir`] is a thin shim that returns the right base path
|
||||
//! per target. Callers continue to append
|
||||
//! `solitaire_quest/<file>` themselves, so the on-disk layout is
|
||||
//! identical across platforms (the per-app Android sandbox makes
|
||||
//! the extra `solitaire_quest/` segment harmless, and a `tar`
|
||||
//! export from one platform deserialises cleanly on another).
|
||||
//!
|
||||
//! # Why hardcode on Android?
|
||||
//!
|
||||
//! The "proper" Android answer is JNI: call back into Java to
|
||||
//! invoke `Activity.getFilesDir()`. That requires plumbing an
|
||||
//! `AndroidApp` context through Bevy's startup hooks and a
|
||||
//! per-call JNI bridge — meaningfully more code than the
|
||||
//! sandbox-guaranteed `/data/data/<package>/files` path. The
|
||||
//! package name `com.solitairequest.app` is fixed at compile
|
||||
//! time in `solitaire_app/Cargo.toml`'s
|
||||
//! `[package.metadata.android]` block, so a hardcoded path is
|
||||
//! safe until that ever changes (at which point this constant
|
||||
//! moves with it).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Hardcoded per-app private files directory on Android.
|
||||
///
|
||||
/// Matches `[package.metadata.android]` in `solitaire_app/Cargo.toml`.
|
||||
/// The Android sandbox guarantees this path exists, is writable,
|
||||
/// and is private to the app — no JNI needed. Update both this
|
||||
/// constant and the Cargo metadata together if the package id
|
||||
/// ever changes.
|
||||
#[cfg(target_os = "android")]
|
||||
const ANDROID_APP_FILES_DIR: &str = "/data/data/com.solitairequest.app/files";
|
||||
|
||||
/// Returns the per-user data directory for the current target,
|
||||
/// or `None` if the platform doesn't expose one (rare; usually
|
||||
/// indicates a broken `$HOME` or `$XDG_*` configuration on a
|
||||
/// minimal Linux container).
|
||||
///
|
||||
/// Callers append `solitaire_quest/<file>` themselves. See the
|
||||
/// module-level doc comment for the per-platform behaviour and
|
||||
/// why Android uses a hardcoded path.
|
||||
pub fn data_dir() -> Option<PathBuf> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
Some(PathBuf::from(ANDROID_APP_FILES_DIR))
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
dirs::data_dir()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// On every supported desktop target the OS reports a usable
|
||||
/// data directory. This test only runs on desktop because the
|
||||
/// Android branch returns a fixed string regardless of host
|
||||
/// state, and asserting on a fixed string is a tautology.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
|
||||
#[test]
|
||||
fn data_dir_returns_some_on_desktop_targets() {
|
||||
let dir = data_dir().expect("desktop targets must report a data dir");
|
||||
assert!(
|
||||
dir.is_absolute(),
|
||||
"data_dir() must return an absolute path on desktop, got {dir:?}",
|
||||
);
|
||||
}
|
||||
|
||||
/// On Android the hardcoded path matches the package id pinned
|
||||
/// in `solitaire_app/Cargo.toml`'s `[package.metadata.android]`.
|
||||
/// If a future change rotates that id, this test fails loudly
|
||||
/// so the path constant moves with it.
|
||||
#[cfg(target_os = "android")]
|
||||
#[test]
|
||||
fn data_dir_returns_sandbox_path_on_android() {
|
||||
let dir = data_dir().expect("android must report a data dir");
|
||||
assert_eq!(dir, PathBuf::from("/data/data/com.solitairequest.app/files"));
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
||||
|
||||
/// Platform-specific default path for `progress.json`.
|
||||
pub fn progress_file_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load progress from an explicit path. Returns `default()` if missing/corrupt.
|
||||
@@ -162,21 +162,6 @@ mod tests {
|
||||
|
||||
// --- Persistence ---
|
||||
|
||||
#[test]
|
||||
fn round_trip_save_and_load() {
|
||||
let path = tmp_path("round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let mut p = PlayerProgress::default();
|
||||
p.add_xp(1234);
|
||||
p.unlocked_card_backs.push(2);
|
||||
save_progress_to(&path, &p).expect("save");
|
||||
let loaded = load_progress_from(&path);
|
||||
assert_eq!(loaded.total_xp, 1234);
|
||||
assert_eq!(loaded.level, p.level);
|
||||
assert!(loaded.unlocked_card_backs.contains(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_missing_file_returns_default() {
|
||||
let path = tmp_path("missing_xyz");
|
||||
|
||||
@@ -31,6 +31,34 @@ use solitaire_core::pile::PileType;
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||
|
||||
/// Maximum number of recent winning replays the rolling history retains.
|
||||
///
|
||||
/// When [`append_replay_to_history`] pushes a fresh entry past this cap,
|
||||
/// the oldest entry is dropped so the file never grows unbounded. The
|
||||
/// player can revisit any of the last [`REPLAY_HISTORY_CAP`] wins from
|
||||
/// the Stats overlay's replay selector — older wins age out silently.
|
||||
pub const REPLAY_HISTORY_CAP: usize = 8;
|
||||
|
||||
/// Save-file schema version for [`ReplayHistory`]. Bump when the on-disk
|
||||
/// shape of the wrapper changes incompatibly so [`load_replay_history_from`]
|
||||
/// returns `None` for older files (the player simply sees an empty
|
||||
/// history rather than a half-loaded broken one). Bumping
|
||||
/// [`REPLAY_SCHEMA_VERSION`] independently invalidates individual
|
||||
/// [`Replay`] payloads inside an otherwise-current history.
|
||||
///
|
||||
/// History:
|
||||
/// - v1 (current): initial release of the rolling history wrapper.
|
||||
pub const REPLAY_HISTORY_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Default value for [`ReplayHistory::schema_version`] when deserialising
|
||||
/// files that pre-date the field. Any value other than
|
||||
/// [`REPLAY_HISTORY_SCHEMA_VERSION`] causes [`load_replay_history_from`]
|
||||
/// to return `None`.
|
||||
fn history_schema_v0() -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
/// Save-file schema version for [`Replay`]. Increment when the on-disk
|
||||
/// representation changes incompatibly so [`load_latest_replay_from`] can
|
||||
@@ -110,6 +138,15 @@ pub struct Replay {
|
||||
/// Ordered move list. Each entry is what the player did, replayable
|
||||
/// against a fresh `GameState` constructed from the seed.
|
||||
pub moves: Vec<ReplayMove>,
|
||||
/// Public share URL for this replay on the active sync backend, set
|
||||
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
||||
/// task resolves. `None` when the player won on a local-only
|
||||
/// backend, the upload failed, or the replay pre-dates v0.19.0
|
||||
/// share-link persistence. `#[serde(default)]` keeps older
|
||||
/// `replays.json` files loadable without bumping
|
||||
/// [`REPLAY_SCHEMA_VERSION`].
|
||||
#[serde(default)]
|
||||
pub share_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Replay {
|
||||
@@ -134,14 +171,80 @@ impl Replay {
|
||||
final_score,
|
||||
recorded_at,
|
||||
moves,
|
||||
share_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolling history of the player's most recent winning replays.
|
||||
///
|
||||
/// Stored as a single JSON file at
|
||||
/// `<data_dir>/solitaire_quest/replays.json` (see
|
||||
/// [`replay_history_path`]). Capped at [`REPLAY_HISTORY_CAP`] entries —
|
||||
/// when [`append_replay_to_history`] pushes past the cap, the oldest
|
||||
/// entry is dropped so the file never grows unbounded.
|
||||
///
|
||||
/// `replays[0]` is always the most recent win; the Stats overlay's
|
||||
/// replay selector defaults to that entry and surfaces the older
|
||||
/// entries behind a small chooser so the player can revisit a memorable
|
||||
/// game even after a more recent win.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReplayHistory {
|
||||
/// Schema version. See [`REPLAY_HISTORY_SCHEMA_VERSION`].
|
||||
#[serde(default = "history_schema_v0")]
|
||||
pub schema_version: u32,
|
||||
/// Most recent first. Capped at [`REPLAY_HISTORY_CAP`] entries —
|
||||
/// older entries drop off when the cap is hit.
|
||||
pub replays: Vec<Replay>,
|
||||
}
|
||||
|
||||
impl Default for ReplayHistory {
|
||||
/// An empty history at the current schema version. Used by callers
|
||||
/// that need a starting point before the first winning replay has
|
||||
/// ever been recorded.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReplayHistory {
|
||||
/// Returns the most recent replay (`replays[0]`), or `None` when the
|
||||
/// history is empty. Convenience used by the Stats overlay's default
|
||||
/// selector position.
|
||||
pub fn most_recent(&self) -> Option<&Replay> {
|
||||
self.replays.first()
|
||||
}
|
||||
|
||||
/// Returns the number of replays currently retained.
|
||||
pub fn len(&self) -> usize {
|
||||
self.replays.len()
|
||||
}
|
||||
|
||||
/// Returns `true` when no replays have been recorded yet.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.replays.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `latest_replay.json`, or `None`
|
||||
/// if `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||
/// if `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||
#[deprecated(
|
||||
note = "single-slot replay storage replaced by the rolling history at \
|
||||
replay_history_path(); kept for the one-shot legacy migration \
|
||||
in migrate_legacy_latest_replay"
|
||||
)]
|
||||
pub fn latest_replay_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `replays.json`, the rolling
|
||||
/// history file, or `None` if `crate::data_dir()` is unavailable (e.g.
|
||||
/// minimal Linux containers).
|
||||
pub fn replay_history_path() -> Option<PathBuf> {
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
|
||||
@@ -149,6 +252,11 @@ pub fn latest_replay_path() -> Option<PathBuf> {
|
||||
///
|
||||
/// Overwrites any existing replay — only the most recent winning replay
|
||||
/// is retained on disk.
|
||||
#[deprecated(
|
||||
note = "single-slot replay storage replaced by the rolling history; \
|
||||
use append_replay_to_history instead. Kept for the one-shot \
|
||||
legacy migration."
|
||||
)]
|
||||
pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
@@ -168,6 +276,11 @@ pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
|
||||
/// "No replay recorded yet" caption rather than a half-loaded broken
|
||||
/// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every
|
||||
/// older save without further migration code.
|
||||
#[deprecated(
|
||||
note = "single-slot replay storage replaced by the rolling history; \
|
||||
use load_replay_history_from instead. Kept for the one-shot \
|
||||
legacy migration."
|
||||
)]
|
||||
pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
|
||||
let data = fs::read(path).ok()?;
|
||||
let replay: Replay = serde_json::from_slice(&data).ok()?;
|
||||
@@ -177,7 +290,124 @@ pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
|
||||
Some(replay)
|
||||
}
|
||||
|
||||
/// Save a [`ReplayHistory`] atomically to `path` using the standard
|
||||
/// `.tmp` → rename contract.
|
||||
///
|
||||
/// The on-disk encoding is pretty-printed JSON; the file is intended to
|
||||
/// be small (≤ [`REPLAY_HISTORY_CAP`] entries, each carrying a few
|
||||
/// hundred move records at most) so the readability tradeoff is fine.
|
||||
pub fn save_replay_history_to(path: &Path, history: &ReplayHistory) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(history).map_err(io::Error::other)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, json.as_bytes())?;
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a [`ReplayHistory`] from `path`, returning `None` when the file
|
||||
/// is missing, corrupt, or carries a [`schema_version`](ReplayHistory::schema_version)
|
||||
/// other than [`REPLAY_HISTORY_SCHEMA_VERSION`].
|
||||
///
|
||||
/// Individual [`Replay`] entries inside an otherwise-current history are
|
||||
/// filtered to only those carrying [`REPLAY_SCHEMA_VERSION`] — older
|
||||
/// entries are silently dropped so a future bump of the inner replay
|
||||
/// schema does not corrupt the wrapper.
|
||||
pub fn load_replay_history_from(path: &Path) -> Option<ReplayHistory> {
|
||||
let data = fs::read(path).ok()?;
|
||||
let history: ReplayHistory = serde_json::from_slice(&data).ok()?;
|
||||
if history.schema_version != REPLAY_HISTORY_SCHEMA_VERSION {
|
||||
return None;
|
||||
}
|
||||
let filtered: Vec<Replay> = history
|
||||
.replays
|
||||
.into_iter()
|
||||
.filter(|r| r.schema_version == REPLAY_SCHEMA_VERSION)
|
||||
.collect();
|
||||
Some(ReplayHistory {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: filtered,
|
||||
})
|
||||
}
|
||||
|
||||
/// Append `replay` to the front of the rolling history at `path`,
|
||||
/// dropping the oldest entry once [`REPLAY_HISTORY_CAP`] is exceeded,
|
||||
/// and persist the updated history atomically.
|
||||
///
|
||||
/// If `path` has no existing history (missing file, corrupt, or
|
||||
/// schema-mismatched) a fresh [`ReplayHistory::default`] is used as the
|
||||
/// starting point so the new replay is always saved. The returned
|
||||
/// [`ReplayHistory`] is the exact value written to disk so callers can
|
||||
/// update an in-memory mirror (e.g. the Stats overlay's
|
||||
/// `ReplayHistoryResource`) without a follow-up `load`.
|
||||
pub fn append_replay_to_history(
|
||||
path: &Path,
|
||||
replay: Replay,
|
||||
) -> io::Result<ReplayHistory> {
|
||||
let mut history = load_replay_history_from(path).unwrap_or_default();
|
||||
// Most recent first. Reserve the front slot; pop the oldest if we
|
||||
// exceed the cap so the file never grows unbounded.
|
||||
history.replays.insert(0, replay);
|
||||
if history.replays.len() > REPLAY_HISTORY_CAP {
|
||||
history.replays.truncate(REPLAY_HISTORY_CAP);
|
||||
}
|
||||
save_replay_history_to(path, &history)?;
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
/// One-shot migration from the legacy single-slot
|
||||
/// `latest_replay.json` file to the rolling [`ReplayHistory`] stored at
|
||||
/// `history_path`.
|
||||
///
|
||||
/// Behaviour matrix:
|
||||
/// - `history_path` already exists → no-op (the rolling history wins).
|
||||
/// - `history_path` is absent and `latest_path` is absent → no-op.
|
||||
/// - `history_path` is absent and `latest_path` exists with a valid
|
||||
/// replay → seed a fresh history with that one replay and write it.
|
||||
/// - `history_path` is absent and `latest_path` exists but is corrupt /
|
||||
/// schema-mismatched → write an empty history (we know the player is
|
||||
/// on the new build and shouldn't keep being prompted to migrate).
|
||||
///
|
||||
/// The legacy `latest_replay.json` file is intentionally NOT deleted by
|
||||
/// this helper — keep it for one release as a safety net so a player
|
||||
/// rolling back to the previous build doesn't lose their last winning
|
||||
/// replay. The deletion is planned for the release after this one.
|
||||
pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
||||
if history_path.exists() {
|
||||
// Rolling history is authoritative once it exists.
|
||||
return;
|
||||
}
|
||||
if !latest_path.exists() {
|
||||
return;
|
||||
}
|
||||
// Use the deprecated loader directly — the migration is the one
|
||||
// place we still consult the legacy file shape on purpose.
|
||||
#[allow(deprecated)]
|
||||
let legacy = load_latest_replay_from(latest_path);
|
||||
let history = match legacy {
|
||||
Some(replay) => ReplayHistory {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: vec![replay],
|
||||
},
|
||||
None => ReplayHistory::default(),
|
||||
};
|
||||
if let Err(e) = save_replay_history_to(history_path, &history) {
|
||||
// Migration failure is non-fatal: on the next launch we'll just
|
||||
// try again. We log to stderr rather than panic so headless
|
||||
// tests stay quiet.
|
||||
eprintln!(
|
||||
"replay: failed to migrate legacy latest_replay.json into rolling history: {e}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// The legacy single-slot tests still exercise `save_latest_replay_to` /
|
||||
// `load_latest_replay_from` on purpose — they're the round-trip
|
||||
// guardrails for the migration source format.
|
||||
#[allow(deprecated)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
@@ -261,6 +491,34 @@ mod tests {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Backwards-compat: a `Replay` record persisted before v0.19.0
|
||||
/// share-link persistence carries no `share_url` field on disk.
|
||||
/// `#[serde(default)]` must let it deserialise cleanly with
|
||||
/// `share_url == None`, so existing players don't see their
|
||||
/// rolling history wiped on the v0.19.0 update.
|
||||
#[test]
|
||||
fn replay_loads_when_share_url_field_is_absent() {
|
||||
let pre_v019_json = format!(
|
||||
r#"{{
|
||||
"schema_version": {schema},
|
||||
"seed": 1,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 60,
|
||||
"final_score": 100,
|
||||
"recorded_at": "2025-01-01",
|
||||
"moves": []
|
||||
}}"#,
|
||||
schema = REPLAY_SCHEMA_VERSION,
|
||||
);
|
||||
let parsed: Replay = serde_json::from_str(&pre_v019_json)
|
||||
.expect("pre-v0.19.0 replay JSON must still deserialise");
|
||||
assert!(
|
||||
parsed.share_url.is_none(),
|
||||
"missing share_url field must default to None",
|
||||
);
|
||||
}
|
||||
|
||||
/// Atomic-write contract — `.tmp` must not be left behind after
|
||||
/// `save_latest_replay_to` returns. Mirrors the same check that
|
||||
/// guards `save_game_state_to` in `storage.rs`.
|
||||
@@ -294,4 +552,189 @@ mod tests {
|
||||
assert!(load_latest_replay_from(&path).is_none());
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ReplayHistory — rolling list of recent wins
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Build a [`Replay`] whose `final_score` carries `id` so tests can
|
||||
/// assert ordering / identity without writing a deep equality match.
|
||||
fn replay_with_id(id: i32) -> Replay {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||
Replay::new(
|
||||
id as u64,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
60,
|
||||
id,
|
||||
date,
|
||||
vec![ReplayMove::StockClick],
|
||||
)
|
||||
}
|
||||
|
||||
/// Pushing past [`REPLAY_HISTORY_CAP`] must drop the oldest entries —
|
||||
/// the on-disk file (and the in-memory mirror returned by the helper)
|
||||
/// stays bounded so the user's data dir never grows unbounded.
|
||||
#[test]
|
||||
fn append_replay_to_history_caps_at_eight() {
|
||||
let path = tmp_path("history_cap");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let mut last_returned = ReplayHistory::default();
|
||||
for i in 0..10 {
|
||||
last_returned = append_replay_to_history(&path, replay_with_id(i))
|
||||
.expect("append must succeed");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
last_returned.replays.len(),
|
||||
REPLAY_HISTORY_CAP,
|
||||
"history must be capped at REPLAY_HISTORY_CAP entries",
|
||||
);
|
||||
// The most recent ten pushes were ids 0..=9; ids 9, 8, ..., 2
|
||||
// survive (newest first), ids 0 and 1 aged out.
|
||||
let ids: Vec<i32> = last_returned.replays.iter().map(|r| r.final_score).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![9, 8, 7, 6, 5, 4, 3, 2],
|
||||
"newest entries must survive, oldest must age out",
|
||||
);
|
||||
|
||||
// The on-disk file must agree with the returned in-memory copy.
|
||||
let loaded = load_replay_history_from(&path).expect("load must succeed");
|
||||
assert_eq!(loaded, last_returned, "disk must mirror returned history");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// `append_replay_to_history` must place new entries at index 0 so
|
||||
/// the Stats overlay's default selector (most recent) lands on the
|
||||
/// just-saved replay.
|
||||
#[test]
|
||||
fn append_replay_inserts_at_front() {
|
||||
let path = tmp_path("history_front");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
append_replay_to_history(&path, replay_with_id(1)).expect("append 1");
|
||||
append_replay_to_history(&path, replay_with_id(2)).expect("append 2");
|
||||
let history = append_replay_to_history(&path, replay_with_id(3)).expect("append 3");
|
||||
|
||||
let ids: Vec<i32> = history.replays.iter().map(|r| r.final_score).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![3, 2, 1],
|
||||
"history must be reverse-chronological (newest first)",
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// On first launch with the new code, a pre-existing
|
||||
/// `latest_replay.json` must seed the new rolling history so the
|
||||
/// player doesn't lose their last winning replay across the upgrade.
|
||||
#[test]
|
||||
fn legacy_latest_replay_migrates_to_history_on_first_launch() {
|
||||
let latest = tmp_path("legacy_migrate_latest");
|
||||
let history = tmp_path("legacy_migrate_history");
|
||||
let _ = fs::remove_file(&latest);
|
||||
let _ = fs::remove_file(&history);
|
||||
|
||||
// Seed the legacy file with a real replay.
|
||||
let legacy_replay = sample_replay();
|
||||
save_latest_replay_to(&latest, &legacy_replay).expect("seed legacy");
|
||||
assert!(!history.exists(), "history file must not exist pre-migration");
|
||||
|
||||
migrate_legacy_latest_replay(&latest, &history);
|
||||
|
||||
assert!(history.exists(), "migration must create the history file");
|
||||
let loaded = load_replay_history_from(&history)
|
||||
.expect("post-migration history must load");
|
||||
assert_eq!(loaded.replays.len(), 1, "history must hold exactly the legacy entry");
|
||||
assert_eq!(loaded.replays[0], legacy_replay, "entry must equal the legacy replay");
|
||||
// Legacy file is intentionally retained for one release as a
|
||||
// safety net — see `migrate_legacy_latest_replay` doc comment.
|
||||
assert!(latest.exists(), "legacy file must NOT be deleted by migration");
|
||||
|
||||
let _ = fs::remove_file(&latest);
|
||||
let _ = fs::remove_file(&history);
|
||||
}
|
||||
|
||||
/// When the rolling history file already exists, the migration must
|
||||
/// be a no-op — we never want to overwrite the player's accumulated
|
||||
/// history with a stale single-slot legacy entry.
|
||||
#[test]
|
||||
fn migrate_is_noop_when_history_already_exists() {
|
||||
let latest = tmp_path("legacy_noop_latest");
|
||||
let history = tmp_path("legacy_noop_history");
|
||||
let _ = fs::remove_file(&latest);
|
||||
let _ = fs::remove_file(&history);
|
||||
|
||||
save_latest_replay_to(&latest, &sample_replay()).expect("seed legacy");
|
||||
let pre_existing = ReplayHistory {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: vec![replay_with_id(42)],
|
||||
};
|
||||
save_replay_history_to(&history, &pre_existing).expect("seed history");
|
||||
|
||||
migrate_legacy_latest_replay(&latest, &history);
|
||||
|
||||
let loaded = load_replay_history_from(&history).expect("load");
|
||||
assert_eq!(loaded, pre_existing, "existing history must not be overwritten");
|
||||
|
||||
let _ = fs::remove_file(&latest);
|
||||
let _ = fs::remove_file(&history);
|
||||
}
|
||||
|
||||
/// A populated [`ReplayHistory`] must round-trip byte-identically
|
||||
/// through `save_replay_history_to` / `load_replay_history_from`.
|
||||
#[test]
|
||||
fn replay_history_round_trips_through_save_and_load() {
|
||||
let path = tmp_path("history_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let history = ReplayHistory {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: vec![replay_with_id(7), replay_with_id(3), sample_replay()],
|
||||
};
|
||||
save_replay_history_to(&path, &history).expect("save");
|
||||
let loaded = load_replay_history_from(&path).expect("load");
|
||||
assert_eq!(loaded, history, "round-trip must preserve every field");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A file written by an older history schema must be rejected so the
|
||||
/// player sees a clean empty history rather than a half-loaded one.
|
||||
#[test]
|
||||
fn replay_history_legacy_schema_version_falls_through_to_none() {
|
||||
let path = tmp_path("history_legacy_schema");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// No `schema_version` key → defaults to 0 via `history_schema_v0()`.
|
||||
let v0_json = r#"{
|
||||
"replays": []
|
||||
}"#;
|
||||
fs::write(&path, v0_json).expect("write v0 fixture");
|
||||
|
||||
assert!(
|
||||
load_replay_history_from(&path).is_none(),
|
||||
"v0 history must be rejected (schema gate)",
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Atomic-write contract for the rolling history — `.tmp` must not be
|
||||
/// left behind after `save_replay_history_to` returns.
|
||||
#[test]
|
||||
fn replay_history_save_is_atomic() {
|
||||
let path = tmp_path("history_atomic");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
save_replay_history_to(&path, &ReplayHistory::default()).expect("save");
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,46 @@ pub struct Settings {
|
||||
/// `#[serde(default = "default_time_bonus_multiplier")]`.
|
||||
#[serde(default = "default_time_bonus_multiplier")]
|
||||
pub time_bonus_multiplier: f32,
|
||||
/// When `true`, the engine rejects new-game deals the
|
||||
/// [`solitaire_core::solver`] cannot prove winnable, retrying
|
||||
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
|
||||
/// giving up and using the last tried seed. Off by default —
|
||||
/// the solver adds a few hundred milliseconds of latency on the
|
||||
/// pathological deals that hit the budget cap, and not every
|
||||
/// player wants to wait. Older `settings.json` files written
|
||||
/// before this field existed deserialize cleanly to `false` via
|
||||
/// `#[serde(default)]`.
|
||||
///
|
||||
/// Scope: only random-seed Classic-mode deals are filtered.
|
||||
/// Daily challenges, replays, and explicit-seed requests skip the
|
||||
/// solver retry loop — see `solitaire_engine::handle_new_game`.
|
||||
#[serde(default)]
|
||||
pub winnable_deals_only: bool,
|
||||
/// When `true`, suppresses the launch-time
|
||||
/// `apply_smart_default_window_size` system: the window opens at
|
||||
/// the literal `(1280, 800)` default instead of resizing to ~70 %
|
||||
/// of the primary monitor's logical size on the first frame. For
|
||||
/// players who specifically prefer the 1280×800 baseline on every
|
||||
/// fresh launch (i.e. installs without saved geometry).
|
||||
///
|
||||
/// Older `settings.json` files written before this field existed
|
||||
/// deserialize cleanly to `false` via `#[serde(default)]`, which
|
||||
/// preserves the smart-default behaviour shipped in v0.19.0.
|
||||
/// Saved-geometry launches are unaffected by this flag — the
|
||||
/// player's last window size always wins.
|
||||
#[serde(default)]
|
||||
pub disable_smart_default_size: bool,
|
||||
/// Per-move duration during replay playback, in seconds. Range
|
||||
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`;
|
||||
/// default mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||
/// (0.45 s/move) so existing playback behaviour is unchanged for
|
||||
/// players who never touch the slider. Smaller values scrub
|
||||
/// faster through the recorded move list. Older `settings.json`
|
||||
/// files written before this field existed deserialize cleanly to
|
||||
/// the default via
|
||||
/// `#[serde(default = "default_replay_move_interval_secs")]`.
|
||||
#[serde(default = "default_replay_move_interval_secs")]
|
||||
pub replay_move_interval_secs: f32,
|
||||
}
|
||||
|
||||
fn default_draw_mode() -> DrawMode {
|
||||
@@ -223,6 +263,44 @@ fn default_time_bonus_multiplier() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// Default per-move duration during replay playback, in seconds.
|
||||
/// Mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||
/// so legacy `settings.json` files load to the existing baseline and
|
||||
/// playback feels identical for players who never touch the slider.
|
||||
/// The constant is duplicated across the data and engine crates
|
||||
/// because `solitaire_data` cannot depend on the engine crate — keep
|
||||
/// the two values in sync when adjusting either.
|
||||
fn default_replay_move_interval_secs() -> f32 {
|
||||
0.45
|
||||
}
|
||||
|
||||
/// Lower bound of the player-tunable replay-playback per-move interval,
|
||||
/// in seconds. Below this the cards barely register visually before
|
||||
/// the next move fires; the cap keeps the playback legible.
|
||||
pub const REPLAY_MOVE_INTERVAL_MIN_SECS: f32 = 0.10;
|
||||
|
||||
/// Upper bound of the player-tunable replay-playback per-move interval,
|
||||
/// in seconds. One second per move is a comfortable upper limit for
|
||||
/// players who want to study a recorded game frame by frame.
|
||||
pub const REPLAY_MOVE_INTERVAL_MAX_SECS: f32 = 1.00;
|
||||
|
||||
/// Increment applied by the replay-playback decrement / increment
|
||||
/// buttons. 0.05 s gives 19 stops between MIN and MAX — fine-grained
|
||||
/// enough to land on any "round" speed (0.10 s, 0.25 s, 0.45 s, etc.)
|
||||
/// without making the slider feel stuck on the same value.
|
||||
pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
|
||||
|
||||
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
||||
/// is willing to attempt before giving up and accepting the latest
|
||||
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
||||
/// every retry comes back [`SolverResult::Unwinnable`] (which would
|
||||
/// be very unusual) we'd rather hand the player a possibly-unwinnable
|
||||
/// deal than spin forever on the main thread.
|
||||
///
|
||||
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
|
||||
/// the upper bound on UI freeze when the toggle is on.
|
||||
pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -241,14 +319,18 @@ impl Default for Settings {
|
||||
shown_achievement_onboarding: false,
|
||||
tooltip_delay_secs: default_tooltip_delay(),
|
||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||
winnable_deals_only: false,
|
||||
disable_smart_default_size: false,
|
||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`, and
|
||||
/// `time_bonus_multiplier` into their respective ranges after
|
||||
/// deserialization or hand-editing of `settings.json`.
|
||||
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`,
|
||||
/// `time_bonus_multiplier`, and `replay_move_interval_secs` into
|
||||
/// their respective ranges after deserialization or hand-editing of
|
||||
/// `settings.json`.
|
||||
pub fn sanitized(self) -> Self {
|
||||
Self {
|
||||
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
||||
@@ -259,6 +341,9 @@ impl Settings {
|
||||
time_bonus_multiplier: self
|
||||
.time_bonus_multiplier
|
||||
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX),
|
||||
replay_move_interval_secs: self
|
||||
.replay_move_interval_secs
|
||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
||||
..self
|
||||
}
|
||||
}
|
||||
@@ -297,12 +382,27 @@ impl Settings {
|
||||
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
|
||||
self.time_bonus_multiplier
|
||||
}
|
||||
|
||||
/// Adjust the replay-playback per-move interval by `delta`
|
||||
/// seconds, clamped to
|
||||
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`.
|
||||
/// The result is rounded to two decimal places so the readout
|
||||
/// stays clean across repeated `±` clicks at the 0.05 s step
|
||||
/// (avoids float drift like `0.45000003`). Returns the new value.
|
||||
pub fn adjust_replay_move_interval(&mut self, delta: f32) -> f32 {
|
||||
let raw = (self.replay_move_interval_secs + delta)
|
||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS);
|
||||
// Round to 2 decimal places — the slider step is 0.05, so this
|
||||
// collapses any FP drift introduced by repeated additions.
|
||||
self.replay_move_interval_secs = (raw * 100.0).round() / 100.0;
|
||||
self.replay_move_interval_secs
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||
/// `dirs::data_dir()` is unavailable.
|
||||
/// the platform's data directory is unavailable.
|
||||
pub fn settings_file_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load settings from an explicit path. Returns `Settings::default()` if the
|
||||
@@ -337,19 +437,6 @@ mod tests {
|
||||
env::temp_dir().join(format!("solitaire_settings_test_{name}.json"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_reasonable() {
|
||||
let s = Settings::default();
|
||||
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
|
||||
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
||||
assert!(!s.first_run_complete);
|
||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
||||
assert_eq!(s.theme, Theme::Green);
|
||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||
assert!((s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_sfx_volume_clamps() {
|
||||
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
|
||||
@@ -382,75 +469,6 @@ mod tests {
|
||||
assert!(s.first_run_complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_clamps_music_volume() {
|
||||
let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized();
|
||||
assert_eq!(s.music_volume, 1.0);
|
||||
|
||||
let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized();
|
||||
assert_eq!(s2.music_volume, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_save_and_load() {
|
||||
let path = tmp_path("round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
sfx_volume: 0.42,
|
||||
first_run_complete: true,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_save_and_load_full_settings() {
|
||||
let path = tmp_path("round_trip_full");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
draw_mode: DrawMode::DrawThree,
|
||||
sfx_volume: 0.3,
|
||||
music_volume: 0.7,
|
||||
animation_speed: AnimSpeed::Fast,
|
||||
theme: Theme::Dark,
|
||||
sync_backend: SyncBackend::SolitaireServer {
|
||||
url: "https://example.com".to_string(),
|
||||
username: "testuser".to_string(),
|
||||
},
|
||||
selected_card_back: 0,
|
||||
selected_background: 0,
|
||||
first_run_complete: true,
|
||||
color_blind_mode: false,
|
||||
window_geometry: None,
|
||||
selected_theme_id: "default".to_string(),
|
||||
shown_achievement_onboarding: false,
|
||||
tooltip_delay_secs: default_tooltip_delay(),
|
||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_non_default_cosmetic_selections() {
|
||||
// selected_card_back and selected_background must survive save→load with
|
||||
// non-zero values — zero is the default and not a meaningful regression check.
|
||||
let path = tmp_path("cosmetic_selections");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
selected_card_back: 3,
|
||||
selected_background: 2,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded.selected_card_back, 3);
|
||||
assert_eq!(loaded.selected_background, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_missing_file_returns_default() {
|
||||
let path = tmp_path("missing_xyz");
|
||||
@@ -467,250 +485,6 @@ mod tests {
|
||||
assert_eq!(s, Settings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_old_format_uses_defaults_for_new_fields() {
|
||||
// Simulate a settings.json written by an older version that only had
|
||||
// sfx_volume and first_run_complete.
|
||||
let path = tmp_path("old_format");
|
||||
fs::write(
|
||||
&path,
|
||||
br#"{ "sfx_volume": 0.6, "first_run_complete": true }"#,
|
||||
)
|
||||
.expect("write");
|
||||
let s = load_settings_from(&path);
|
||||
assert!((s.sfx_volume - 0.6).abs() < 1e-6);
|
||||
assert!(s.first_run_complete);
|
||||
// New fields should fall back to their defaults.
|
||||
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
||||
assert_eq!(s.theme, Theme::Green);
|
||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
||||
assert_eq!(s.selected_card_back, 0, "cosmetic card-back must default to 0 on old format");
|
||||
assert_eq!(s.selected_background, 0, "cosmetic background must default to 0 on old format");
|
||||
assert!(!s.color_blind_mode, "color_blind_mode must default to false on old format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_blind_mode_defaults_to_false_when_field_absent() {
|
||||
// Simulate a JSON file that has no color_blind_mode field.
|
||||
let json = br#"{ "sfx_volume": 0.7 }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
assert!(!s.color_blind_mode, "color_blind_mode must be false when absent from JSON");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_blind_mode_round_trips() {
|
||||
let path = tmp_path("color_blind");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
color_blind_mode: true,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert!(loaded.color_blind_mode, "color_blind_mode must survive a save/load round-trip");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #62 — selected_card_back
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_card_back_default_is_zero() {
|
||||
assert_eq!(Settings::default().selected_card_back, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_card_back_serializes_round_trip() {
|
||||
let path = tmp_path("card_back_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
selected_card_back: 2,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded.selected_card_back, 2, "selected_card_back must survive serde round-trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #63 — selected_background
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_background_default_is_zero() {
|
||||
assert_eq!(Settings::default().selected_background, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_background_serializes_round_trip() {
|
||||
let path = tmp_path("background_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
selected_background: 3,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// window_geometry — persisted window size/position
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_window_geometry_default_is_none() {
|
||||
assert!(
|
||||
Settings::default().window_geometry.is_none(),
|
||||
"default window_geometry must be None so first launch uses platform defaults"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_with_window_geometry_round_trip() {
|
||||
let path = tmp_path("window_geometry_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let geom = WindowGeometry {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
x: 120,
|
||||
y: 80,
|
||||
};
|
||||
let s = Settings {
|
||||
window_geometry: Some(geom),
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(
|
||||
loaded.window_geometry,
|
||||
Some(geom),
|
||||
"window_geometry must survive serde round-trip"
|
||||
);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_without_window_geometry_deserializes_to_none() {
|
||||
// A settings.json written by an older version of the game will be
|
||||
// missing this field entirely. `#[serde(default)]` on the field
|
||||
// must yield `None` rather than failing the whole deserialise.
|
||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
assert!(
|
||||
s.window_geometry.is_none(),
|
||||
"legacy settings.json missing window_geometry must deserialize to None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_geometry_explicit_null_deserializes_to_none() {
|
||||
// An explicit `"window_geometry": null` is also valid input that
|
||||
// must yield None — keeps tooling that hand-edits the file safe.
|
||||
let json = br#"{ "window_geometry": null }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
assert!(s.window_geometry.is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// shown_achievement_onboarding — first-win cue one-shot guard
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_shown_achievement_onboarding_default_is_false() {
|
||||
assert!(
|
||||
!Settings::default().shown_achievement_onboarding,
|
||||
"default shown_achievement_onboarding must be false so the cue fires once"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_shown_achievement_onboarding_round_trip() {
|
||||
let path = tmp_path("achievement_onboarding_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
shown_achievement_onboarding: true,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert!(
|
||||
loaded.shown_achievement_onboarding,
|
||||
"shown_achievement_onboarding must survive serde round-trip"
|
||||
);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_without_shown_achievement_onboarding_deserializes_to_false() {
|
||||
// A settings.json written by an older version of the game will be
|
||||
// missing this field entirely. `#[serde(default)]` on the field
|
||||
// must yield `false` — the cue then fires on the next win, but
|
||||
// only when stats.games_won == 1, so existing players who have
|
||||
// already won past their first game won't see the toast either.
|
||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
assert!(
|
||||
!s.shown_achievement_onboarding,
|
||||
"legacy settings.json missing shown_achievement_onboarding must deserialize to false"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// tooltip_delay_secs — player-tunable tooltip hover delay
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_tooltip_delay_default_is_existing_baseline() {
|
||||
// The existing baseline pre-slider is 0.5 s, matching the
|
||||
// `MOTION_TOOLTIP_DELAY_SECS` constant in
|
||||
// `solitaire_engine::ui_theme`. The default must not regress.
|
||||
let s = Settings::default();
|
||||
assert!(
|
||||
(s.tooltip_delay_secs - 0.5).abs() < 1e-6,
|
||||
"tooltip_delay_secs default must be 0.5 (the pre-slider baseline), got {}",
|
||||
s.tooltip_delay_secs
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_tooltip_delay_round_trip() {
|
||||
let path = tmp_path("tooltip_delay_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
tooltip_delay_secs: 1.2,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert!(
|
||||
(loaded.tooltip_delay_secs - 1.2).abs() < 1e-6,
|
||||
"tooltip_delay_secs must survive serde round-trip; got {}",
|
||||
loaded.tooltip_delay_secs
|
||||
);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_without_tooltip_delay_deserializes_to_default() {
|
||||
// A settings.json written before this field existed must
|
||||
// deserialize cleanly to the existing 0.5 s baseline rather
|
||||
// than failing the whole load or yielding a zero value.
|
||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
assert!(
|
||||
(s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6,
|
||||
"legacy settings.json missing tooltip_delay_secs must deserialize to default ({}), got {}",
|
||||
default_tooltip_delay(),
|
||||
s.tooltip_delay_secs
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_tooltip_delay_clamps_to_range() {
|
||||
let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() };
|
||||
@@ -724,90 +498,6 @@ mod tests {
|
||||
assert_eq!(s.tooltip_delay_secs, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_clamps_out_of_range_tooltip_delay() {
|
||||
// Negative or oversized values from a hand-edited file must be
|
||||
// clamped on load.
|
||||
let s = Settings {
|
||||
tooltip_delay_secs: -0.4,
|
||||
..Settings::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(s.tooltip_delay_secs, TOOLTIP_DELAY_MIN_SECS);
|
||||
|
||||
let s2 = Settings {
|
||||
tooltip_delay_secs: 99.0,
|
||||
..Settings::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(s2.tooltip_delay_secs, TOOLTIP_DELAY_MAX_SECS);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// time_bonus_multiplier — cosmetic win-modal time-bonus weight
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_time_bonus_multiplier_default_is_one() {
|
||||
let s = Settings::default();
|
||||
assert!(
|
||||
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
|
||||
"default time_bonus_multiplier must be 1.0 (no change to displayed bonus), got {}",
|
||||
s.time_bonus_multiplier
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_time_bonus_multiplier_round_trip() {
|
||||
let path = tmp_path("time_bonus_multiplier_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
time_bonus_multiplier: 1.5,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert!(
|
||||
(loaded.time_bonus_multiplier - 1.5).abs() < 1e-6,
|
||||
"time_bonus_multiplier must survive serde round-trip; got {}",
|
||||
loaded.time_bonus_multiplier
|
||||
);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_without_time_bonus_multiplier_deserializes_to_one() {
|
||||
// A settings.json written before this field existed must
|
||||
// deserialize cleanly to the existing 1.0 baseline so old
|
||||
// players see no change to their win-modal bonuses.
|
||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
assert!(
|
||||
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
|
||||
"legacy settings.json missing time_bonus_multiplier must deserialize to 1.0, got {}",
|
||||
s.time_bonus_multiplier
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_time_bonus_multiplier_clamps_to_range() {
|
||||
// Negative or oversized values from a hand-edited file must be
|
||||
// clamped on load.
|
||||
let s = Settings {
|
||||
time_bonus_multiplier: -0.5,
|
||||
..Settings::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(s.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MIN);
|
||||
|
||||
let s2 = Settings {
|
||||
time_bonus_multiplier: 99.0,
|
||||
..Settings::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(s2.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
|
||||
let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() };
|
||||
@@ -835,4 +525,31 @@ mod tests {
|
||||
s2.time_bonus_multiplier
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_replay_move_interval_clamps_and_rounds() {
|
||||
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
|
||||
// Step down to 0.40.
|
||||
assert!((s.adjust_replay_move_interval(-0.05) - 0.40).abs() < 1e-6);
|
||||
// Big positive jump clamps to MAX.
|
||||
assert!(
|
||||
(s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6
|
||||
);
|
||||
// Big negative jump clamps to MIN.
|
||||
assert!(
|
||||
(s.adjust_replay_move_interval(-99.0) - REPLAY_MOVE_INTERVAL_MIN_SECS).abs() < 1e-6
|
||||
);
|
||||
|
||||
// Repeated 0.05 steps must not drift past the 0.05 grid.
|
||||
let mut s2 = Settings { replay_move_interval_secs: 0.10, ..Default::default() };
|
||||
for _ in 0..6 {
|
||||
s2.adjust_replay_move_interval(0.05);
|
||||
}
|
||||
// After six +0.05 steps from 0.10, value should be exactly 0.40 (2 decimals).
|
||||
assert!(
|
||||
(s2.replay_move_interval_secs - 0.40).abs() < 1e-6,
|
||||
"rounding should pin repeated 0.05 steps to the decimal grid, got {}",
|
||||
s2.replay_move_interval_secs
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ const GAME_STATE_FILE_NAME: &str = "game_state.json";
|
||||
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
|
||||
|
||||
/// Returns the platform-specific path to `stats.json`, or `None` if
|
||||
/// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||
/// `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||
pub fn stats_file_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
|
||||
@@ -69,9 +69,9 @@ pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns the platform-specific path to `game_state.json`, or `None` if
|
||||
/// `dirs::data_dir()` is unavailable.
|
||||
/// `crate::data_dir()` is unavailable.
|
||||
pub fn game_state_file_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
|
||||
@@ -129,7 +129,7 @@ pub fn delete_game_state_at(path: &Path) -> io::Result<()> {
|
||||
/// in an atomic save. Safe to call on startup; missing or unreadable entries
|
||||
/// are silently skipped.
|
||||
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
|
||||
let dir = match dirs::data_dir() {
|
||||
let dir = match crate::data_dir() {
|
||||
Some(d) => d.join(APP_DIR_NAME),
|
||||
None => return Ok(()),
|
||||
};
|
||||
@@ -179,9 +179,9 @@ pub struct TimeAttackSession {
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `time_attack_session.json`, or
|
||||
/// `None` if `dirs::data_dir()` is unavailable.
|
||||
/// `None` if `crate::data_dir()` is unavailable.
|
||||
pub fn time_attack_session_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
|
||||
|
||||
@@ -358,13 +358,12 @@ impl SyncProvider for SolitaireServerClient {
|
||||
extract_leaderboard_body(resp).await
|
||||
}
|
||||
|
||||
/// Upload a winning replay to `POST /api/replays`. Mirrors the
|
||||
/// `push` auth flow: 401 triggers a token refresh and one retry.
|
||||
/// Non-success statuses are surfaced as the relevant `SyncError`
|
||||
/// variant so the engine's push-on-win system can downgrade
|
||||
/// network/auth failures into a quiet log without aborting the
|
||||
/// game flow.
|
||||
async fn push_replay(&self, replay: &Replay) -> Result<(), SyncError> {
|
||||
/// Upload a winning replay to `POST /api/replays`. On success the
|
||||
/// server returns `{ "id": "<uuid>" }`; this method composes that
|
||||
/// id with the configured base URL into the player-shareable
|
||||
/// `<base>/replays/<id>` link and returns it. Mirrors the `push`
|
||||
/// auth flow: 401 triggers a token refresh and one retry.
|
||||
async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
|
||||
let token = self.access_token()?;
|
||||
let url = format!("{}/api/replays", self.base_url);
|
||||
|
||||
@@ -388,22 +387,38 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
return check_replay_status(resp.status());
|
||||
return self.share_url_from_response(resp).await;
|
||||
}
|
||||
|
||||
check_replay_status(resp.status())
|
||||
self.share_url_from_response(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
fn check_replay_status(status: reqwest::StatusCode) -> Result<(), SyncError> {
|
||||
if status.is_success() {
|
||||
Ok(())
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED
|
||||
|| status == reqwest::StatusCode::FORBIDDEN
|
||||
{
|
||||
Err(SyncError::Auth(format!("server returned {status}")))
|
||||
} else {
|
||||
Err(SyncError::Network(format!("server returned {status}")))
|
||||
impl SolitaireServerClient {
|
||||
/// Pulled out of `push_replay` so both the first attempt and the
|
||||
/// post-401-retry attempt go through the same parse path.
|
||||
async fn share_url_from_response(
|
||||
&self,
|
||||
resp: reqwest::Response,
|
||||
) -> Result<String, SyncError> {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(if status == reqwest::StatusCode::UNAUTHORIZED
|
||||
|| status == reqwest::StatusCode::FORBIDDEN
|
||||
{
|
||||
SyncError::Auth(format!("server returned {status}"))
|
||||
} else {
|
||||
SyncError::Network(format!("server returned {status}"))
|
||||
});
|
||||
}
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||
let id = body["id"].as_str().ok_or_else(|| {
|
||||
SyncError::Serialization("upload response missing `id`".into())
|
||||
})?;
|
||||
Ok(format!("{}/replays/{}", self.base_url, id))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,16 @@ ron = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
# `arboard` provides clipboard access for the Stats overlay's
|
||||
# "Copy share link" button. The crate has no Android backend
|
||||
# (its `platform::Clipboard` module is unimplemented for the
|
||||
# android target — `cargo apk build` fails with E0433 if this is
|
||||
# left unconditional). On Android the same button surfaces an
|
||||
# informational toast instead; see
|
||||
# `stats_plugin::handle_copy_share_link_button`.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
arboard = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use chrono::{Local, Timelike, Utc};
|
||||
use solitaire_core::achievement::{
|
||||
@@ -25,11 +26,13 @@ use crate::events::{
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
@@ -47,6 +50,19 @@ pub struct AchievementsScreen;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct AchievementRow;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Achievements modal.
|
||||
///
|
||||
/// The Achievements list can grow to ~19 rows which overflows the modal at
|
||||
/// the 800x600 minimum window. This marker tags the inner container that
|
||||
/// carries `Overflow::scroll_y()` plus a `max_height` constraint so the
|
||||
/// content scrolls instead of clipping. Mirrors the
|
||||
/// `SettingsPanelScrollable` pattern in `settings_plugin`.
|
||||
///
|
||||
/// `scroll_achievements_panel` reads this marker to route mouse-wheel
|
||||
/// events into the body's `ScrollPosition`.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct AchievementsScrollable;
|
||||
|
||||
/// All per-player achievement records (one per known achievement).
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
||||
@@ -95,6 +111,11 @@ impl Plugin for AchievementPlugin {
|
||||
.add_message::<XpAwardedEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ToggleAchievementsRequestEvent>()
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the
|
||||
// achievements-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
// Run after GameMutation (so GameWonEvent is available), after
|
||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||
// (so daily_challenge_streak is up to date for daily_devotee).
|
||||
@@ -116,7 +137,13 @@ impl Plugin for AchievementPlugin {
|
||||
.after(StatsUpdate),
|
||||
)
|
||||
.add_systems(Update, toggle_achievements_screen)
|
||||
.add_systems(Update, handle_achievements_close_button);
|
||||
.add_systems(Update, handle_achievements_close_button)
|
||||
.add_systems(Update, scroll_achievements_panel)
|
||||
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
||||
// `cinephile` the first time playback runs to natural completion.
|
||||
// Reads the resource via `Option<Res<_>>` so headless tests that
|
||||
// omit `ReplayPlaybackPlugin` still build.
|
||||
.add_systems(Update, evaluate_cinephile_on_replay_completion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +249,66 @@ fn evaluate_on_win(
|
||||
}
|
||||
}
|
||||
|
||||
/// Cinephile unlock observer.
|
||||
///
|
||||
/// Watches [`ReplayPlaybackState`] and unlocks the `cinephile` achievement
|
||||
/// the first time the resource transitions from `Playing` to `Completed` —
|
||||
/// i.e. the player watched a saved replay all the way through. The Stop
|
||||
/// button transitions `Playing` → `Inactive` directly (never via
|
||||
/// `Completed`), so manual aborts do not trigger the unlock.
|
||||
///
|
||||
/// Idempotent: once the record is unlocked, subsequent Playing → Completed
|
||||
/// transitions are a no-op (no extra `AchievementUnlockedEvent`, no extra
|
||||
/// disk write). The transition itself is debounced by tracking the
|
||||
/// previous frame's `is_playing()` state in a `Local<bool>` — without
|
||||
/// this, a freshly-spawned `Completed` state would re-fire each frame
|
||||
/// during the linger window.
|
||||
///
|
||||
/// Reads `ReplayPlaybackState` via `Option<Res<_>>` so achievement tests
|
||||
/// that omit `ReplayPlaybackPlugin` still build cleanly.
|
||||
fn evaluate_cinephile_on_replay_completion(
|
||||
state: Option<Res<ReplayPlaybackState>>,
|
||||
// `Local` collides with `chrono::Local` imported at the top of this
|
||||
// module — fully qualify so the Bevy system parameter resolves
|
||||
// correctly.
|
||||
mut last_was_playing: bevy::prelude::Local<bool>,
|
||||
mut achievements: ResMut<AchievementsResource>,
|
||||
mut unlocks: MessageWriter<AchievementUnlockedEvent>,
|
||||
path: Res<AchievementsStoragePath>,
|
||||
) {
|
||||
let Some(state) = state else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Detect the Playing → Completed transition: was playing last frame,
|
||||
// is now completed. Direct Playing → Inactive (Stop button) does not
|
||||
// satisfy this guard because it never enters `Completed`.
|
||||
let now_playing = state.is_playing();
|
||||
let now_completed = state.is_completed();
|
||||
let just_completed = *last_was_playing && now_completed;
|
||||
*last_was_playing = now_playing;
|
||||
|
||||
if !just_completed {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(record) = achievements.0.iter_mut().find(|r| r.id == "cinephile") else {
|
||||
return;
|
||||
};
|
||||
if record.unlocked {
|
||||
return;
|
||||
}
|
||||
record.unlock(Utc::now());
|
||||
record.reward_granted = true;
|
||||
unlocks.write(AchievementUnlockedEvent(record.clone()));
|
||||
|
||||
if let Some(target) = &path.0
|
||||
&& let Err(e) = save_achievements_to(target, &achievements.0)
|
||||
{
|
||||
warn!("failed to save achievements after cinephile unlock: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Achievement-onboarding cue.
|
||||
///
|
||||
/// On the player's very first win — and only their first — fires a single
|
||||
@@ -329,6 +416,38 @@ fn handle_achievements_close_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes mouse-wheel events into the Achievements modal's scrollable body
|
||||
/// while the panel is open.
|
||||
///
|
||||
/// `offset_y` increases downward (0 = top). Scrolling down (`ev.y < 0`) adds
|
||||
/// to the offset; scrolling up subtracts. Clamped to >= 0 so the viewport
|
||||
/// never scrolls past the top. Mirrors `scroll_settings_panel` in
|
||||
/// `settings_plugin`. The query is empty when no `AchievementsScrollable`
|
||||
/// is in the world (modal closed) so this is a no-op outside the open
|
||||
/// state without an explicit gate resource.
|
||||
fn scroll_achievements_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<AchievementsScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
scroll_evr.clear();
|
||||
return;
|
||||
}
|
||||
let delta_y: f32 = scroll_evr
|
||||
.read()
|
||||
.map(|ev| match ev.unit {
|
||||
MouseScrollUnit::Line => ev.y * 50.0,
|
||||
MouseScrollUnit::Pixel => ev.y,
|
||||
})
|
||||
.sum();
|
||||
if delta_y == 0.0 {
|
||||
return;
|
||||
}
|
||||
for mut sp in scrollables.iter_mut() {
|
||||
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_achievements_screen(
|
||||
commands: &mut Commands,
|
||||
records: &[AchievementRecord],
|
||||
@@ -355,79 +474,119 @@ fn spawn_achievements_screen(
|
||||
..default()
|
||||
};
|
||||
|
||||
spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
||||
let any_unlocked = records.iter().any(|r| r.unlocked);
|
||||
|
||||
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, header, font_res);
|
||||
|
||||
// Achievement rows — unlocked first, then locked alphabetical.
|
||||
let mut sorted: Vec<_> = records.iter().collect();
|
||||
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
|
||||
|
||||
for record in &sorted {
|
||||
let def = achievement_by_id(&record.id);
|
||||
let (name, description) = def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
|
||||
|
||||
// Hide secret locked achievements so they remain a surprise.
|
||||
let is_secret = def.is_some_and(|d| d.secret);
|
||||
if is_secret && !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (name_color, desc_color, prefix) = if record.unlocked {
|
||||
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
|
||||
} else {
|
||||
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
|
||||
};
|
||||
|
||||
let tooltip_text = tooltip_for_row(record.unlocked, def);
|
||||
|
||||
// First-time hint — shown until the player has unlocked anything.
|
||||
// The list itself describes individual rewards, but a top-level
|
||||
// explanation gives newer players context for the otherwise dense
|
||||
// greyed-out grid.
|
||||
if !any_unlocked {
|
||||
card.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
Text::new(
|
||||
"Complete games and try new modes to unlock achievements and rewards.",
|
||||
),
|
||||
TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
AchievementRow,
|
||||
Tooltip::new(tooltip_text),
|
||||
))
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new(format!("{prefix}{name}")),
|
||||
font_name.clone(),
|
||||
TextColor(name_color),
|
||||
));
|
||||
if !description.is_empty() {
|
||||
row.spawn((
|
||||
Text::new(format!(" {description}")),
|
||||
font_desc.clone(),
|
||||
TextColor(desc_color),
|
||||
));
|
||||
}
|
||||
if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) {
|
||||
row.spawn((
|
||||
Text::new(format!(" Reward: {reward_str}")),
|
||||
font_meta.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
}
|
||||
if let Some(date) = record.unlock_date {
|
||||
row.spawn((
|
||||
Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))),
|
||||
font_meta.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// Subtle row separator — keeps the long list scannable.
|
||||
card.spawn((
|
||||
Node {
|
||||
height: Val::Px(1.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BORDER_SUBTLE),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
|
||||
// Scrollable body — the achievements list grows to ~19 rows which
|
||||
// overflows the modal on the 800x600 minimum window. Wrapping the
|
||||
// row list in an `Overflow::scroll_y()` Node with a constrained
|
||||
// `max_height` keeps every row reachable. The Done button below
|
||||
// sits outside the scroll so it's always one click away. Mirrors
|
||||
// the `SettingsPanelScrollable` pattern.
|
||||
card.spawn((
|
||||
AchievementsScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
max_height: Val::Vh(70.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
// Achievement rows — unlocked first, then locked alphabetical.
|
||||
let mut sorted: Vec<_> = records.iter().collect();
|
||||
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
|
||||
|
||||
for record in &sorted {
|
||||
let def = achievement_by_id(&record.id);
|
||||
let (name, description) =
|
||||
def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
|
||||
|
||||
// Hide secret locked achievements so they remain a surprise.
|
||||
let is_secret = def.is_some_and(|d| d.secret);
|
||||
if is_secret && !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (name_color, desc_color, prefix) = if record.unlocked {
|
||||
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
|
||||
} else {
|
||||
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
|
||||
};
|
||||
|
||||
let tooltip_text = tooltip_for_row(record.unlocked, def);
|
||||
|
||||
body.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
..default()
|
||||
},
|
||||
AchievementRow,
|
||||
Tooltip::new(tooltip_text),
|
||||
))
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new(format!("{prefix}{name}")),
|
||||
font_name.clone(),
|
||||
TextColor(name_color),
|
||||
));
|
||||
if !description.is_empty() {
|
||||
row.spawn((
|
||||
Text::new(format!(" {description}")),
|
||||
font_desc.clone(),
|
||||
TextColor(desc_color),
|
||||
));
|
||||
}
|
||||
if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) {
|
||||
row.spawn((
|
||||
Text::new(format!(" Reward: {reward_str}")),
|
||||
font_meta.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
}
|
||||
if let Some(date) = record.unlock_date {
|
||||
row.spawn((
|
||||
Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))),
|
||||
font_meta.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// Subtle row separator — keeps the long list scannable.
|
||||
body.spawn((
|
||||
Node {
|
||||
height: Val::Px(1.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BORDER_SUBTLE),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
@@ -439,6 +598,9 @@ fn spawn_achievements_screen(
|
||||
);
|
||||
});
|
||||
});
|
||||
// Achievements is a read-only list — clicking the scrim outside
|
||||
// the card dismisses alongside the existing A / Done paths.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
fn format_reward(reward: Reward) -> String {
|
||||
@@ -829,6 +991,64 @@ mod tests {
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Scrollable body
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Spawning the modal must place exactly one `AchievementsScrollable`
|
||||
/// marker in the world so the row list scrolls instead of clipping at
|
||||
/// the 800x600 minimum window.
|
||||
#[test]
|
||||
fn achievements_modal_body_is_scrollable() {
|
||||
let mut app = headless_app();
|
||||
press(&mut app, KeyCode::KeyA);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&AchievementsScrollable>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Achievements modal must spawn exactly one AchievementsScrollable body"
|
||||
);
|
||||
}
|
||||
|
||||
/// The scrollable body must constrain its `max_height` so the modal
|
||||
/// actually engages scrolling on tall content. Without this the inner
|
||||
/// flex column would expand to fit every row and `Overflow::scroll_y`
|
||||
/// would have nothing to clip.
|
||||
#[test]
|
||||
fn achievements_modal_body_has_max_height() {
|
||||
let mut app = headless_app();
|
||||
press(&mut app, KeyCode::KeyA);
|
||||
app.update();
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<AchievementsScrollable>>();
|
||||
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
||||
assert_eq!(nodes.len(), 1, "expected exactly one scrollable body");
|
||||
let node = nodes[0];
|
||||
|
||||
// `Val::Auto` is the default; assert the body's `max_height` was
|
||||
// explicitly set to something else so scroll engages.
|
||||
assert_ne!(
|
||||
node.max_height,
|
||||
Val::Auto,
|
||||
"scrollable body must set a non-default max_height; got {:?}",
|
||||
node.max_height
|
||||
);
|
||||
// And the overflow axis must be y-scroll.
|
||||
assert_eq!(
|
||||
node.overflow,
|
||||
Overflow::scroll_y(),
|
||||
"scrollable body must use Overflow::scroll_y(); got {:?}",
|
||||
node.overflow
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// format_reward
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1149,9 +1369,215 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Without any `GameWonEvent` arriving the system must be a no-op:
|
||||
/// no toast, no flag flip — even on update ticks where stats happen
|
||||
/// to read `games_won == 1`.
|
||||
// -----------------------------------------------------------------------
|
||||
// Cinephile (event-driven via ReplayPlaybackState)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
|
||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
|
||||
/// by hand. The achievement plugin's cinephile observer reads it via
|
||||
/// `Option<Res<_>>` so the absence of the playback plugin is safe.
|
||||
fn cinephile_app() -> App {
|
||||
let mut app = headless_app();
|
||||
app.init_resource::<ReplayPlaybackState>();
|
||||
app
|
||||
}
|
||||
|
||||
fn dummy_replay() -> Replay {
|
||||
Replay::new(
|
||||
1,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick],
|
||||
)
|
||||
}
|
||||
|
||||
fn cinephile_unlocked(app: &App) -> bool {
|
||||
app.world()
|
||||
.resource::<AchievementsResource>()
|
||||
.0
|
||||
.iter()
|
||||
.find(|r| r.id == "cinephile")
|
||||
.map(|r| r.unlocked)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn cinephile_unlocks_emitted(app: &App) -> usize {
|
||||
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
cursor
|
||||
.read(events)
|
||||
.filter(|e| e.0.id == "cinephile")
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The cinephile record must be seeded on plugin init like every other
|
||||
/// achievement, so the observer can find and mutate it later.
|
||||
#[test]
|
||||
fn cinephile_record_seeded_by_plugin() {
|
||||
let app = cinephile_app();
|
||||
let records = &app.world().resource::<AchievementsResource>().0;
|
||||
assert!(
|
||||
records.iter().any(|r| r.id == "cinephile" && !r.unlocked),
|
||||
"cinephile record must be seeded as locked",
|
||||
);
|
||||
}
|
||||
|
||||
/// Drive Inactive → Playing → Completed and assert the cinephile
|
||||
/// achievement unlocks and exactly one `AchievementUnlockedEvent` is
|
||||
/// emitted.
|
||||
#[test]
|
||||
fn cinephile_unlocks_on_replay_completion() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
// Frame 1: enter Playing. The observer's first sample sees
|
||||
// `last_was_playing = false` and `now_playing = true`.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
};
|
||||
app.update();
|
||||
assert!(
|
||||
!cinephile_unlocked(&app),
|
||||
"Playing alone must not unlock cinephile",
|
||||
);
|
||||
|
||||
// Frame 2: transition to Completed. The observer must detect
|
||||
// `last_was_playing = true && now_completed = true` and unlock.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
cinephile_unlocked(&app),
|
||||
"cinephile must unlock on Playing → Completed transition",
|
||||
);
|
||||
assert_eq!(
|
||||
cinephile_unlocks_emitted(&app),
|
||||
1,
|
||||
"exactly one AchievementUnlockedEvent must fire for cinephile",
|
||||
);
|
||||
}
|
||||
|
||||
/// Stop button transitions Playing → Inactive directly (not via
|
||||
/// Completed). Drive that path and assert no cinephile unlock.
|
||||
#[test]
|
||||
fn cinephile_does_not_unlock_on_stop_button_abort() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
};
|
||||
app.update();
|
||||
|
||||
// Direct Playing → Inactive — the path the Stop button takes via
|
||||
// `stop_replay_playback`. Must not unlock cinephile.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Inactive;
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
!cinephile_unlocked(&app),
|
||||
"Stop button (Playing → Inactive) must not unlock cinephile",
|
||||
);
|
||||
assert_eq!(
|
||||
cinephile_unlocks_emitted(&app),
|
||||
0,
|
||||
"no AchievementUnlockedEvent for cinephile on a Stop transition",
|
||||
);
|
||||
}
|
||||
|
||||
/// A second Playing → Completed cycle on an already-unlocked record
|
||||
/// must be idempotent: no additional `AchievementUnlockedEvent`.
|
||||
#[test]
|
||||
fn cinephile_does_not_double_fire() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
// First completion cycle to unlock.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
assert!(cinephile_unlocked(&app), "precondition: first cycle must unlock");
|
||||
|
||||
// Drain the event queue so the next assertion doesn't double-count
|
||||
// the legitimate first-time unlock event.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<AchievementUnlockedEvent>>()
|
||||
.clear();
|
||||
|
||||
// Second cycle: Inactive → Playing → Completed once more.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Inactive;
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
cinephile_unlocks_emitted(&app),
|
||||
0,
|
||||
"cinephile must not re-fire on a second Playing → Completed cycle",
|
||||
);
|
||||
}
|
||||
|
||||
/// `Completed` lingers across multiple frames before the auto-clear
|
||||
/// transitions back to `Inactive`. The observer must fire exactly
|
||||
/// once during that linger window — not once per frame.
|
||||
#[test]
|
||||
fn cinephile_fires_once_across_completed_linger() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
// Stay in Completed for a few more frames as the real auto-clear
|
||||
// does. Each subsequent frame the resource is still `Completed`
|
||||
// but the observer has already counted this transition.
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
cinephile_unlocks_emitted(&app),
|
||||
1,
|
||||
"cinephile must fire exactly once across the Completed linger window",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_win_event_means_no_achievement_onboarding_toast() {
|
||||
let mut app = onboarding_test_app();
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
||||
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
||||
use crate::events::{InfoToastEvent, NewGameConfirmEvent, XpAwardedEvent};
|
||||
use crate::events::{InfoToastEvent, XpAwardedEvent};
|
||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
@@ -30,8 +30,9 @@ use crate::progress_plugin::LevelUpEvent;
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||
use crate::ui_theme::{
|
||||
scaled_duration, ACCENT_PRIMARY, MOTION_CASCADE_SLIDE_SECS, MOTION_CASCADE_STAGGER_SECS,
|
||||
MOTION_SLIDE_SECS, TEXT_PRIMARY, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_TOAST,
|
||||
scaled_duration, ACCENT_SECONDARY, BG_ELEVATED, MOTION_CASCADE_SLIDE_SECS,
|
||||
MOTION_CASCADE_STAGGER_SECS, MOTION_SLIDE_SECS, RADIUS_MD, STATE_DANGER, STATE_INFO,
|
||||
STATE_WARNING, TEXT_PRIMARY, TYPE_BODY_LG, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_TOAST,
|
||||
};
|
||||
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
|
||||
|
||||
@@ -61,7 +62,6 @@ fn anim_speed_to_secs(speed: &AnimSpeed) -> f32 {
|
||||
scaled_duration(MOTION_SLIDE_SECS, *speed)
|
||||
}
|
||||
|
||||
const WIN_TOAST_SECS: f32 = 4.0;
|
||||
const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
|
||||
const LEVELUP_TOAST_SECS: f32 = 3.0;
|
||||
const DAILY_TOAST_SECS: f32 = 3.0;
|
||||
@@ -161,7 +161,6 @@ impl Plugin for AnimationPlugin {
|
||||
.add_message::<TimeAttackEndedEvent>()
|
||||
.add_message::<ChallengeAdvancedEvent>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<NewGameConfirmEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<XpAwardedEvent>()
|
||||
.init_resource::<EffectiveSlideDuration>()
|
||||
@@ -183,7 +182,6 @@ impl Plugin for AnimationPlugin {
|
||||
handle_challenge_toast,
|
||||
handle_settings_toast,
|
||||
handle_auto_complete_toast,
|
||||
handle_new_game_confirm_toast,
|
||||
handle_xp_awarded_toast,
|
||||
tick_toasts,
|
||||
(enqueue_toasts, drive_toast_display).chain(),
|
||||
@@ -268,9 +266,15 @@ fn handle_win_cascade(
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
) {
|
||||
let Some(ev) = events.read().next() else {
|
||||
// Drain the event reader; the cascade visual is the only thing
|
||||
// this system contributes — the post-win "You Won!" modal
|
||||
// (`win_summary_plugin`) consumes the same `GameWonEvent` and
|
||||
// carries score / time / achievements / XP itself, so a duplicate
|
||||
// toast saying "You Win! Score X Time Y" rendered behind the modal
|
||||
// in earlier builds. Removed.
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
let margin = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
|
||||
|
||||
@@ -286,11 +290,6 @@ fn handle_win_cascade(
|
||||
Vec3::new(-margin, 0.0, 300.0),
|
||||
];
|
||||
|
||||
let m = ev.time_seconds / 60;
|
||||
let s = ev.time_seconds % 60;
|
||||
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
|
||||
spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS);
|
||||
|
||||
let step = settings
|
||||
.as_ref()
|
||||
.map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed));
|
||||
@@ -341,6 +340,7 @@ fn handle_achievement_toast(
|
||||
&mut commands,
|
||||
format!("Achievement: {}", display_name_for(&ev.0.id)),
|
||||
ACHIEVEMENT_TOAST_SECS,
|
||||
ToastVariant::Celebration,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -351,6 +351,7 @@ fn handle_levelup_toast(mut commands: Commands, mut events: MessageReader<LevelU
|
||||
&mut commands,
|
||||
format!("Level Up! → {}", ev.new_level),
|
||||
LEVELUP_TOAST_SECS,
|
||||
ToastVariant::Celebration,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -360,7 +361,12 @@ fn handle_daily_goal_announcement_toast(
|
||||
mut events: MessageReader<DailyGoalAnnouncementEvent>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
spawn_toast(&mut commands, format!("Goal: {}", ev.0), DAILY_TOAST_SECS);
|
||||
spawn_toast(
|
||||
&mut commands,
|
||||
format!("Goal: {}", ev.0),
|
||||
DAILY_TOAST_SECS,
|
||||
ToastVariant::Info,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +379,7 @@ fn handle_daily_toast(
|
||||
&mut commands,
|
||||
format!("Daily Challenge Complete! (Streak: {})", ev.streak),
|
||||
DAILY_TOAST_SECS,
|
||||
ToastVariant::Celebration,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -386,6 +393,7 @@ fn handle_weekly_toast(
|
||||
&mut commands,
|
||||
format!("Weekly Goal: {}", ev.description),
|
||||
WEEKLY_TOAST_SECS,
|
||||
ToastVariant::Celebration,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -399,6 +407,7 @@ fn handle_time_attack_toast(
|
||||
&mut commands,
|
||||
format!("Time Attack: {} win{}", ev.wins, if ev.wins == 1 { "" } else { "s" }),
|
||||
TIME_ATTACK_TOAST_SECS,
|
||||
ToastVariant::Info,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -412,6 +421,7 @@ fn handle_challenge_toast(
|
||||
&mut commands,
|
||||
format!("Challenge {} cleared!", ev.previous_index.saturating_add(1)),
|
||||
CHALLENGE_TOAST_SECS,
|
||||
ToastVariant::Celebration,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -431,11 +441,21 @@ fn handle_settings_toast(
|
||||
*last_music = Some(music);
|
||||
if sfx_changed {
|
||||
let pct = (sfx * 100.0).round() as i32;
|
||||
spawn_toast(&mut commands, format!("SFX: {pct}%"), VOLUME_TOAST_SECS);
|
||||
spawn_toast(
|
||||
&mut commands,
|
||||
format!("SFX: {pct}%"),
|
||||
VOLUME_TOAST_SECS,
|
||||
ToastVariant::Info,
|
||||
);
|
||||
}
|
||||
if music_changed {
|
||||
let pct = (music * 100.0).round() as i32;
|
||||
spawn_toast(&mut commands, format!("Music: {pct}%"), VOLUME_TOAST_SECS);
|
||||
spawn_toast(
|
||||
&mut commands,
|
||||
format!("Music: {pct}%"),
|
||||
VOLUME_TOAST_SECS,
|
||||
ToastVariant::Info,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,7 +471,12 @@ fn handle_auto_complete_toast(
|
||||
if s.active {
|
||||
if !*shown {
|
||||
*shown = true;
|
||||
spawn_toast(&mut commands, "Auto-completing…".to_string(), 2.0);
|
||||
spawn_toast(
|
||||
&mut commands,
|
||||
"Auto-completing…".to_string(),
|
||||
2.0,
|
||||
ToastVariant::Info,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
*shown = false;
|
||||
@@ -459,15 +484,6 @@ fn handle_auto_complete_toast(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_new_game_confirm_toast(
|
||||
mut commands: Commands,
|
||||
mut events: MessageReader<NewGameConfirmEvent>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
spawn_toast(&mut commands, "Press N again to start a new game".to_string(), 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads every incoming `InfoToastEvent` and appends its text to `ToastQueue`.
|
||||
///
|
||||
/// This is the first half of the two-system toast queue (Task #67). The queue
|
||||
@@ -524,37 +540,72 @@ fn drive_toast_display(
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a centered top-of-screen `ToastEntity` for the queued toast system.
|
||||
/// Visual variant of a toast — drives the 1px border accent per the
|
||||
/// design-system toast spec
|
||||
/// (`docs/ui-mockups/design-system.md` → "Toasts").
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToastVariant {
|
||||
/// Neutral system message — teal border. Default for `InfoToastEvent`,
|
||||
/// settings volume notifications, and the auto-complete announcement.
|
||||
Info,
|
||||
/// Caution / penalty — gold border. Currently unused by an in-engine
|
||||
/// event; kept so future warning-flavoured toasts have a slot.
|
||||
#[allow(dead_code)]
|
||||
Warning,
|
||||
/// Failure / rejected action — pink border. Currently unused; kept so
|
||||
/// future error-flavoured toasts have a slot.
|
||||
#[allow(dead_code)]
|
||||
Error,
|
||||
/// Reward / milestone — lavender border. Used for XP awards,
|
||||
/// achievement unlocks, level-ups, daily/weekly/challenge completions.
|
||||
Celebration,
|
||||
}
|
||||
|
||||
impl ToastVariant {
|
||||
/// Returns the 1px border accent for this variant per the design
|
||||
/// system. Single source of truth — `spawn_toast` and
|
||||
/// `spawn_queued_toast` both consume it so a future palette swap
|
||||
/// only has to touch the token, never every call site.
|
||||
fn border_color(self) -> Color {
|
||||
match self {
|
||||
ToastVariant::Info => STATE_INFO,
|
||||
ToastVariant::Warning => STATE_WARNING,
|
||||
ToastVariant::Error => STATE_DANGER,
|
||||
ToastVariant::Celebration => ACCENT_SECONDARY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a bottom-anchored `ToastEntity` for the queued toast system.
|
||||
///
|
||||
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
|
||||
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
||||
/// Variants other than `Info` belong on the immediate-fire path
|
||||
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
||||
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
||||
commands
|
||||
.spawn((
|
||||
ToastEntity,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(15.0),
|
||||
top: Val::Percent(8.0),
|
||||
width: Val::Percent(70.0),
|
||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.60)),
|
||||
ZIndex(Z_TOAST),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(message),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
})
|
||||
.id()
|
||||
spawn_toast_node(
|
||||
commands,
|
||||
ToastEntity,
|
||||
message,
|
||||
ToastVariant::Info,
|
||||
// Slightly taller anchor than the immediate-fire path so a
|
||||
// queued info banner doesn't collide with a celebration toast
|
||||
// fired in the same frame.
|
||||
Val::Percent(6.0),
|
||||
Val::Percent(15.0),
|
||||
Val::Percent(70.0),
|
||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||
)
|
||||
}
|
||||
|
||||
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
||||
for ev in events.read() {
|
||||
spawn_toast(&mut commands, format!("+{} XP", ev.amount), 3.0);
|
||||
spawn_toast(
|
||||
&mut commands,
|
||||
format!("+{} XP", ev.amount),
|
||||
3.0,
|
||||
ToastVariant::Celebration,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,33 +631,88 @@ fn tick_toasts(
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_toast(commands: &mut Commands, message: String, duration_secs: f32) {
|
||||
/// Spawns a bottom-anchored fire-and-forget toast that despawns after
|
||||
/// `duration_secs`. The `variant` selects the 1px accent border color
|
||||
/// per the design-system toast spec.
|
||||
fn spawn_toast(
|
||||
commands: &mut Commands,
|
||||
message: String,
|
||||
duration_secs: f32,
|
||||
variant: ToastVariant,
|
||||
) {
|
||||
spawn_toast_node(
|
||||
commands,
|
||||
(ToastOverlay, ToastTimer(duration_secs)),
|
||||
message,
|
||||
variant,
|
||||
// Sits above the queued banner so a celebration toast spawned
|
||||
// alongside a queued info message remains readable.
|
||||
Val::Percent(14.0),
|
||||
Val::Percent(25.0),
|
||||
Val::Percent(50.0),
|
||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
||||
);
|
||||
}
|
||||
|
||||
/// Common toast-spawn primitive used by both the queued and the
|
||||
/// fire-and-forget paths. Centralizes the design-system contract so a
|
||||
/// future spec change (e.g. a different border thickness) is a
|
||||
/// one-line edit.
|
||||
///
|
||||
/// The Terminal toast spec from `design-system.md`:
|
||||
/// - Opaque [`BG_ELEVATED`] fill (no translucent dim).
|
||||
/// - 1px border in the variant's accent color.
|
||||
/// - [`TYPE_BODY_LG`] (18px) `TEXT_PRIMARY` caption — the spec calls
|
||||
/// for 16px, but the engine type scale only carries 14/18/26/40/...
|
||||
/// rungs; 18 is the closest rung that preserves the scale invariants
|
||||
/// tested in `ui_theme::tests`.
|
||||
/// - [`RADIUS_MD`] corners.
|
||||
/// - Bottom-anchored absolute position; `bottom_pct` differs between
|
||||
/// queued and immediate paths so they layer instead of overlap.
|
||||
// The 8-argument signature is intentional — these are the per-toast
|
||||
// layout values that genuinely differ between the queued and fire-and-
|
||||
// forget call sites. A struct wrapper would just rename the same data.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_toast_node<B: Bundle>(
|
||||
commands: &mut Commands,
|
||||
bundle: B,
|
||||
message: String,
|
||||
variant: ToastVariant,
|
||||
bottom_pct: Val,
|
||||
left_pct: Val,
|
||||
width_pct: Val,
|
||||
padding: UiRect,
|
||||
) -> Entity {
|
||||
commands
|
||||
.spawn((
|
||||
ToastOverlay,
|
||||
ToastTimer(duration_secs),
|
||||
bundle,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(25.0),
|
||||
top: Val::Percent(42.0),
|
||||
width: Val::Percent(50.0),
|
||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
||||
left: left_pct,
|
||||
bottom: bottom_pct,
|
||||
width: width_pct,
|
||||
padding,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.72)),
|
||||
BackgroundColor(BG_ELEVATED),
|
||||
BorderColor::all(variant.border_color()),
|
||||
ZIndex(Z_TOAST),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(message),
|
||||
TextFont {
|
||||
font_size: 32.0,
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
});
|
||||
})
|
||||
.id()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -714,6 +820,41 @@ mod tests {
|
||||
assert!(anim_speed_to_secs(&AnimSpeed::Fast) < anim_speed_to_secs(&AnimSpeed::Normal));
|
||||
}
|
||||
|
||||
/// Pin every `ToastVariant` to its design-system border colour.
|
||||
/// A future palette swap that touches `STATE_INFO`, `STATE_WARNING`,
|
||||
/// `STATE_DANGER`, or `ACCENT_SECONDARY` flows through this mapping
|
||||
/// automatically; this test guards against accidental remappings.
|
||||
#[test]
|
||||
fn toast_variant_border_colors_match_design_tokens() {
|
||||
assert_eq!(ToastVariant::Info.border_color(), STATE_INFO);
|
||||
assert_eq!(ToastVariant::Warning.border_color(), STATE_WARNING);
|
||||
assert_eq!(ToastVariant::Error.border_color(), STATE_DANGER);
|
||||
assert_eq!(ToastVariant::Celebration.border_color(), ACCENT_SECONDARY);
|
||||
}
|
||||
|
||||
/// Every `ToastVariant` resolves to a unique border colour so a
|
||||
/// careless rebinding (e.g. accidentally setting `Warning` to the
|
||||
/// same hue as `Info`) fails loudly. Pure check — does not run a
|
||||
/// Bevy app.
|
||||
#[test]
|
||||
fn toast_variant_border_colors_are_distinct() {
|
||||
let colors = [
|
||||
ToastVariant::Info.border_color(),
|
||||
ToastVariant::Warning.border_color(),
|
||||
ToastVariant::Error.border_color(),
|
||||
ToastVariant::Celebration.border_color(),
|
||||
];
|
||||
for i in 0..colors.len() {
|
||||
for j in (i + 1)..colors.len() {
|
||||
assert_ne!(
|
||||
format!("{:?}", colors[i]),
|
||||
format!("{:?}", colors[j]),
|
||||
"variants {i} and {j} resolved to the same border colour",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anim_speed_instant_is_zero() {
|
||||
assert_eq!(anim_speed_to_secs(&AnimSpeed::Instant), 0.0);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
//! Per-platform resolution of the user-themes directory.
|
||||
//!
|
||||
//! The path is determined exactly once and exposed via
|
||||
//! [`user_theme_dir`]. On desktop platforms it is derived from
|
||||
//! `dirs::data_dir()` (matching the rest of the project's
|
||||
//! per-app-storage convention); on mobile it must be supplied by the
|
||||
//! platform entry point via [`set_user_theme_dir`] before any code
|
||||
//! that needs the path executes — there is deliberately no silent
|
||||
//! fallback because mobile sandboxing makes any guess we'd hard-code
|
||||
//! wrong.
|
||||
//! [`user_theme_dir`]. The base directory comes from
|
||||
//! [`solitaire_data::data_dir`] (desktop: `dirs::data_dir()`;
|
||||
//! Android: the hardcoded `/data/data/<package>/files` sandbox
|
||||
//! path). Mobile entry points may still override the path via
|
||||
//! [`set_user_theme_dir`] when they need to point at a non-default
|
||||
//! location (e.g. tests, custom AssetManager wiring).
|
||||
//!
|
||||
//! # Why panic instead of returning Result?
|
||||
//!
|
||||
@@ -35,17 +34,18 @@ const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
/// Sub-folder under [`APP_DIR_NAME`] dedicated to user themes.
|
||||
const THEME_DIR_NAME: &str = "themes";
|
||||
|
||||
/// Sets the user-themes directory at runtime — mobile-only API.
|
||||
/// Sets the user-themes directory at runtime — escape hatch for
|
||||
/// embedders or tests that need to override the platform default.
|
||||
///
|
||||
/// Returns `Err` containing the rejected path if the override has
|
||||
/// already been set. The first caller wins and subsequent calls are
|
||||
/// silently a no-op-with-feedback so a mis-configured embedder can't
|
||||
/// flip the path mid-session.
|
||||
///
|
||||
/// On desktop platforms this is functional but unnecessary —
|
||||
/// [`user_theme_dir`] derives the path from `dirs::data_dir` directly
|
||||
/// and ignores the override. Setting it on desktop is harmless but
|
||||
/// nearly always a sign of confusion.
|
||||
/// Mostly unnecessary now that [`solitaire_data::data_dir`] handles
|
||||
/// every supported target — the override is kept for tests and for
|
||||
/// embedders that want a non-default location (e.g. a sandboxed
|
||||
/// AssetManager root on a future iOS port).
|
||||
pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
|
||||
USER_THEME_DIR_OVERRIDE.set(path)
|
||||
}
|
||||
@@ -55,16 +55,10 @@ pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics on:
|
||||
///
|
||||
/// - Desktop, if `dirs::data_dir()` returns `None` (rare; usually
|
||||
/// indicates a broken `$HOME` or `$XDG_*` configuration).
|
||||
/// - Mobile, if no entry point has called [`set_user_theme_dir`] yet.
|
||||
/// - Any other target, where the embedder is required to supply the
|
||||
/// path manually.
|
||||
///
|
||||
/// The panic message names the missing piece so the failure is
|
||||
/// immediately actionable.
|
||||
/// Panics if [`solitaire_data::data_dir`] returns `None`, which on
|
||||
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration.
|
||||
/// Android always returns `Some`. The panic message names the
|
||||
/// supported workaround ([`set_user_theme_dir`]).
|
||||
pub fn user_theme_dir() -> PathBuf {
|
||||
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
|
||||
return p.clone();
|
||||
@@ -79,45 +73,23 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
|
||||
data_dir.join(APP_DIR_NAME).join(THEME_DIR_NAME)
|
||||
}
|
||||
|
||||
/// Per-target-os resolution of the platform's data dir. Split out so
|
||||
/// mobile branches can grow without disturbing desktop behaviour.
|
||||
/// Per-target-os resolution of the platform's data dir. Delegates
|
||||
/// to [`solitaire_data::data_dir`] which encapsulates the
|
||||
/// per-target shape (desktop: `dirs::data_dir()`; android: the
|
||||
/// hardcoded `/data/data/<package>/files` sandbox path). Panics
|
||||
/// only when the underlying resolver returns `None`, which on
|
||||
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration —
|
||||
/// the panic message names the supported workaround.
|
||||
fn detected_platform_data_dir() -> PathBuf {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
|
||||
{
|
||||
dirs::data_dir().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"user_theme_dir(): platform data directory is unavailable. \
|
||||
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
||||
the OS reported no Application Support / AppData path. \
|
||||
As a workaround call solitaire_engine::assets::user_dir::\
|
||||
set_user_theme_dir() before App::run()."
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
{
|
||||
solitaire_data::data_dir().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"user_theme_dir(): mobile entry point must call \
|
||||
solitaire_engine::assets::user_dir::set_user_theme_dir() \
|
||||
before App::run() — there is no platform default."
|
||||
"user_theme_dir(): platform data directory is unavailable. \
|
||||
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
||||
the OS reported no Application Support / AppData path. \
|
||||
As a workaround call solitaire_engine::assets::user_dir::\
|
||||
set_user_theme_dir() before App::run()."
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "windows",
|
||||
target_os = "android",
|
||||
target_os = "ios"
|
||||
)))]
|
||||
{
|
||||
panic!(
|
||||
"user_theme_dir(): unsupported platform; call \
|
||||
solitaire_engine::assets::user_dir::set_user_theme_dir() \
|
||||
from your entry point before App::run()."
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -140,14 +112,16 @@ mod tests {
|
||||
assert_eq!(dir, PathBuf::from("solitaire_quest/themes"));
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
|
||||
#[test]
|
||||
fn detected_data_dir_yields_a_path_with_a_parent() {
|
||||
// On every supported desktop platform the OS reports a
|
||||
// user-writable data directory; the test machine already has
|
||||
// one for `dirs::data_dir()` to discover. We don't pin the
|
||||
// exact value because it depends on the user's $HOME, but it
|
||||
// must at least be a non-empty path with a parent component.
|
||||
// On every supported target the platform resolver
|
||||
// (`solitaire_data::data_dir`) returns a usable directory:
|
||||
// desktop targets via `dirs::data_dir()` (the test machine
|
||||
// already has a `$HOME` for it to discover), Android via
|
||||
// the hardcoded `/data/data/<package>/files` sandbox path.
|
||||
// We don't pin the exact value because it depends on the
|
||||
// user's `$HOME` on desktop, but it must at least be a
|
||||
// non-empty path with a parent component.
|
||||
let dir = detected_platform_data_dir();
|
||||
assert!(dir.parent().is_some(), "data dir {dir:?} should be absolute");
|
||||
}
|
||||
|
||||
@@ -29,12 +29,13 @@ use crate::layout::{Layout, LayoutResource, LayoutSystem};
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
use crate::table_plugin::PileMarker;
|
||||
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
|
||||
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,
|
||||
CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_CAPTION,
|
||||
Z_STOCK_BADGE,
|
||||
};
|
||||
|
||||
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
||||
@@ -925,12 +926,17 @@ fn update_drag_shadow(
|
||||
commands.entity(e).insert(Transform::from_translation(shadow_pos));
|
||||
}
|
||||
None => {
|
||||
// Spawn a new shadow sprite.
|
||||
// Spawn a new shadow sprite. Alpha tracks the per-card
|
||||
// CARD_SHADOW_ALPHA_DRAG token so the Terminal palette's
|
||||
// "no box-shadow" policy disables this stack shadow in
|
||||
// lockstep with the per-card shadows. Re-enabling shadows
|
||||
// is then a one-line change in `ui_theme`, not a hunt
|
||||
// through plugin code.
|
||||
let e = commands
|
||||
.spawn((
|
||||
ShadowEntity,
|
||||
Sprite {
|
||||
color: Color::srgba(0.0, 0.0, 0.0, 0.35),
|
||||
color: CARD_SHADOW_COLOR.with_alpha(CARD_SHADOW_ALPHA_DRAG),
|
||||
custom_size: Some(Vec2::new(card_w + 8.0, card_h + 8.0)),
|
||||
..default()
|
||||
},
|
||||
@@ -1024,11 +1030,13 @@ fn tick_hint_highlight(
|
||||
// Task #46 — Right-click legal destination highlights
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Color applied to a `PileMarker` sprite when it is a legal destination for
|
||||
/// the right-clicked card.
|
||||
const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.2, 0.8, 0.2, 0.6);
|
||||
/// Restored color for `PileMarker` sprites when the highlight is cleared.
|
||||
const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
/// Lime tint applied to a `PileMarker` sprite when it is a legal
|
||||
/// destination for the right-clicked card. Same RGB as the design-
|
||||
/// system [`STATE_SUCCESS`] token at 60% alpha. Spelled as a literal
|
||||
/// because `Alpha::with_alpha` is not yet a `const` trait method on
|
||||
/// stable; the tracking test below pins the RGB to `STATE_SUCCESS`
|
||||
/// so a palette swap can't drift the two apart silently.
|
||||
const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.675, 0.761, 0.404, 0.6);
|
||||
|
||||
/// Counts down `RightClickHighlightTimer` each frame and clears the highlight
|
||||
/// when the timer expires.
|
||||
@@ -1238,11 +1246,16 @@ fn find_top_card_at(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
|
||||
/// to signal to the player that there are no more cards to draw.
|
||||
/// to signal to the player that there are no more cards to draw. Pure white
|
||||
/// at 0.4 alpha — a deliberate brightness-boost over the default marker so
|
||||
/// the "empty" state is more visible, not less. Not derived from a palette
|
||||
/// token: this is a sprite tint, not chrome colour.
|
||||
const STOCK_EMPTY_DIM_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.4);
|
||||
|
||||
/// Sprite colour applied to the stock `PileMarker` when cards remain in stock.
|
||||
const STOCK_NORMAL_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
/// Sprite colour applied to the stock `PileMarker` when cards remain in
|
||||
/// stock. Aliased to [`PILE_MARKER_DEFAULT_COLOUR`] so it tracks the rest
|
||||
/// of the engine's idle pile-marker tint automatically.
|
||||
const STOCK_NORMAL_COLOUR: Color = PILE_MARKER_DEFAULT_COLOUR;
|
||||
|
||||
/// Shared logic for updating the stock pile marker's dim state and "↺" label.
|
||||
///
|
||||
@@ -1283,7 +1296,7 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
|
||||
StockEmptyLabel,
|
||||
Text2d::new("↺"),
|
||||
TextFont { font_size, ..default() },
|
||||
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.7)),
|
||||
TextColor(TEXT_PRIMARY.with_alpha(0.7)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
});
|
||||
@@ -2329,9 +2342,15 @@ mod tests {
|
||||
|
||||
assert_ne!(idle_offset, drag_offset, "drag offset must differ from idle");
|
||||
assert_ne!(idle_padding, drag_padding, "drag padding must differ from idle");
|
||||
// Under the Terminal design system both alphas are pinned to 0
|
||||
// (depth comes from 1px borders + tonal layering, no `box-shadow`).
|
||||
// The invariant we still enforce is "drag never weaker than idle"
|
||||
// — so an accidental swap of the two constants fails loudly,
|
||||
// and a future palette that re-enables shadows still has to keep
|
||||
// the lift cue stronger than the rest state.
|
||||
assert!(
|
||||
drag_alpha > idle_alpha,
|
||||
"drag alpha must be stronger than idle (got drag={drag_alpha}, idle={idle_alpha})"
|
||||
drag_alpha >= idle_alpha,
|
||||
"drag alpha must not be weaker than idle (got drag={drag_alpha}, idle={idle_alpha})"
|
||||
);
|
||||
// Drag offset magnitude should be larger than idle so the parallax
|
||||
// reads as "lifted".
|
||||
@@ -2700,4 +2719,20 @@ mod tests {
|
||||
"after a theme apply the theme_back slot must hold the theme's back handle",
|
||||
);
|
||||
}
|
||||
|
||||
/// `RIGHT_CLICK_HIGHLIGHT_COLOUR` is spelled as a literal because
|
||||
/// `Alpha::with_alpha` is not a `const` trait method on stable.
|
||||
/// This test pins its RGB to the design-system `STATE_SUCCESS`
|
||||
/// token so a future palette swap that updates the token but
|
||||
/// forgets the right-click highlight fails loudly here.
|
||||
#[test]
|
||||
fn right_click_highlight_rgb_tracks_state_success_token() {
|
||||
use crate::ui_theme::STATE_SUCCESS;
|
||||
let highlight = RIGHT_CLICK_HIGHLIGHT_COLOUR.to_srgba();
|
||||
let success = STATE_SUCCESS.to_srgba();
|
||||
assert!((highlight.red - success.red).abs() < 1e-6);
|
||||
assert!((highlight.green - success.green).abs() < 1e-6);
|
||||
assert!((highlight.blue - success.blue).abs() < 1e-6);
|
||||
assert!((highlight.alpha - 0.6).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,19 @@
|
||||
//!
|
||||
//! **Cursor icons** (`update_cursor_icon`)
|
||||
//! - Cards are being dragged → `Grabbing` (closed hand)
|
||||
//! - A UI `Button` entity is hovered (and no drag in progress) → `Pointer`
|
||||
//! (the hand-with-extended-index-finger icon). This telegraphs
|
||||
//! clickability for every modal button, HUD action, mode-launcher
|
||||
//! card, settings toggle, etc.
|
||||
//! - Cursor hovers over a face-up draggable card → `Grab` (open hand)
|
||||
//! - Otherwise → `Default` (arrow)
|
||||
//!
|
||||
//! Priority order: dragging > button-hover > card-hover > default. A
|
||||
//! button-overlapping-a-card edge case favours `Pointer` because UI
|
||||
//! elements take precedence over world-space cards; in practice
|
||||
//! buttons are always on UI nodes and cards are sprites, so they
|
||||
//! cannot occupy the same hit region simultaneously.
|
||||
//!
|
||||
//! **Drop-target highlights** (`update_drop_highlights`)
|
||||
//! While a drag is in progress every `PileMarker` sprite is tinted:
|
||||
//! - **Green** if the dragged stack can legally land there.
|
||||
@@ -31,17 +41,28 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
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::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
|
||||
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.
|
||||
const MARKER_DEFAULT: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
/// Idle pile-marker tint — re-exported from `table_plugin` so the
|
||||
/// "valid drop" toggle in this plugin and the marker spawn in
|
||||
/// `table_plugin` cannot drift apart. Was previously a duplicated
|
||||
/// literal kept in sync via doc comment.
|
||||
const MARKER_DEFAULT: Color = PILE_MARKER_DEFAULT_COLOUR;
|
||||
|
||||
/// 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);
|
||||
/// Lime tint applied to pile markers that are valid drop targets during
|
||||
/// a drag. Same RGB as the design-system [`STATE_SUCCESS`] token at 55%
|
||||
/// alpha, so the in-game "this is a legal target" colour stays
|
||||
/// consistent with foundation-completion flourishes and other
|
||||
/// valid-move signals. Spelled as a literal because `Alpha::with_alpha`
|
||||
/// is not yet a `const` trait method on stable; the tracking test
|
||||
/// below pins the RGB to `STATE_SUCCESS` so a palette swap can't drift
|
||||
/// the two apart silently. Distinct from [`DROP_TARGET_FILL`] (10%
|
||||
/// alpha) because the marker sprite is thin and would otherwise wash
|
||||
/// out at a similar opacity.
|
||||
const MARKER_VALID: Color = Color::srgba(0.675, 0.761, 0.404, 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
|
||||
@@ -70,6 +91,31 @@ impl Plugin for CursorPlugin {
|
||||
// #31 — Cursor icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pure decision function for the cursor icon, separated from the Bevy
|
||||
/// system so it can be unit-tested without `PrimaryWindow` /
|
||||
/// `Camera` / `Time` plumbing.
|
||||
///
|
||||
/// Priority order (highest first):
|
||||
/// 1. `is_dragging` → `Grabbing`
|
||||
/// 2. `any_button_hovered` → `Pointer`
|
||||
/// 3. `any_card_hovered` → `Grab`
|
||||
/// 4. otherwise → `Default`
|
||||
fn pick_cursor_icon(
|
||||
is_dragging: bool,
|
||||
any_button_hovered: bool,
|
||||
any_card_hovered: bool,
|
||||
) -> SystemCursorIcon {
|
||||
if is_dragging {
|
||||
SystemCursorIcon::Grabbing
|
||||
} else if any_button_hovered {
|
||||
SystemCursorIcon::Pointer
|
||||
} else if any_card_hovered {
|
||||
SystemCursorIcon::Grab
|
||||
} else {
|
||||
SystemCursorIcon::Default
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the primary-window cursor icon based on drag state and hover.
|
||||
fn update_cursor_icon(
|
||||
drag: Res<DragState>,
|
||||
@@ -77,32 +123,39 @@ fn update_cursor_icon(
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
button_q: Query<&Interaction, With<Button>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let Ok((win_entity, window)) = windows.single() else { return };
|
||||
|
||||
if !drag.is_idle() {
|
||||
commands
|
||||
.entity(win_entity)
|
||||
.insert(CursorIcon::from(SystemCursorIcon::Grabbing));
|
||||
return;
|
||||
}
|
||||
let is_dragging = !drag.is_idle();
|
||||
|
||||
let hovering = (|| {
|
||||
let cursor = window.cursor_position()?;
|
||||
let (camera, cam_xf) = cameras.single().ok()?;
|
||||
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
|
||||
let layout = layout.as_ref()?.0.clone();
|
||||
let game = game.as_ref()?;
|
||||
Some(cursor_over_draggable(world, &game.0, &layout))
|
||||
})()
|
||||
.unwrap_or(false);
|
||||
// A UI button is "hovered" if any `Button` entity has its
|
||||
// `Interaction` set to `Hovered` or `Pressed`. We include
|
||||
// `Pressed` so the pointer icon stays visible while a click is
|
||||
// being held, matching browser behaviour.
|
||||
let any_button_hovered = button_q
|
||||
.iter()
|
||||
.any(|i| matches!(i, Interaction::Hovered | Interaction::Pressed));
|
||||
|
||||
commands.entity(win_entity).insert(CursorIcon::from(if hovering {
|
||||
SystemCursorIcon::Grab
|
||||
let any_card_hovered = if is_dragging || any_button_hovered {
|
||||
// No need to do the world-space hit test when a higher
|
||||
// priority branch already wins.
|
||||
false
|
||||
} else {
|
||||
SystemCursorIcon::Default
|
||||
}));
|
||||
(|| {
|
||||
let cursor = window.cursor_position()?;
|
||||
let (camera, cam_xf) = cameras.single().ok()?;
|
||||
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
|
||||
let layout = layout.as_ref()?.0.clone();
|
||||
let game = game.as_ref()?;
|
||||
Some(cursor_over_draggable(world, &game.0, &layout))
|
||||
})()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
let icon = pick_cursor_icon(is_dragging, any_button_hovered, any_card_hovered);
|
||||
commands.entity(win_entity).insert(CursorIcon::from(icon));
|
||||
}
|
||||
|
||||
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
|
||||
@@ -482,6 +535,69 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_valid_rgb_tracks_state_success_token() {
|
||||
// `MARKER_VALID` is spelled as a literal because
|
||||
// `Alpha::with_alpha` is not a `const` trait method on stable.
|
||||
// This test pins its RGB to `STATE_SUCCESS` so a future
|
||||
// palette swap that updates the token but forgets the marker
|
||||
// fails loudly here.
|
||||
use crate::ui_theme::STATE_SUCCESS;
|
||||
let valid = MARKER_VALID.to_srgba();
|
||||
let success = STATE_SUCCESS.to_srgba();
|
||||
assert!((valid.red - success.red).abs() < 1e-6);
|
||||
assert!((valid.green - success.green).abs() < 1e-6);
|
||||
assert!((valid.blue - success.blue).abs() < 1e-6);
|
||||
assert!((valid.alpha - 0.55).abs() < 1e-6);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// pick_cursor_icon priority-order tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cursor_picks_grabbing_when_dragging_overrides_button_hover() {
|
||||
// Dragging always wins regardless of button or card hover state.
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(true, true, true),
|
||||
SystemCursorIcon::Grabbing
|
||||
));
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(true, false, false),
|
||||
SystemCursorIcon::Grabbing
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_picks_pointer_when_button_hovered_and_no_drag() {
|
||||
// Button hover beats card hover when not dragging.
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(false, true, false),
|
||||
SystemCursorIcon::Pointer
|
||||
));
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(false, true, true),
|
||||
SystemCursorIcon::Pointer
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_picks_grab_when_card_hovered_and_no_button() {
|
||||
// Card hover wins only when no drag and no button-hover.
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(false, false, true),
|
||||
SystemCursorIcon::Grab
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_picks_default_when_nothing_hovered() {
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(false, false, false),
|
||||
SystemCursorIcon::Default
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_over_draggable_returns_false_for_empty_game() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Optional on-screen FPS / frame-time overlay.
|
||||
//!
|
||||
//! Wraps Bevy's [`FrameTimeDiagnosticsPlugin`] and renders a tiny
|
||||
//! corner readout that the developer (or a curious player) can toggle
|
||||
//! with `F3`. Hidden by default — production builds ship the plugin
|
||||
//! but the overlay starts invisible, so the production HUD is never
|
||||
//! cluttered unless explicitly summoned.
|
||||
//!
|
||||
//! Why this exists: with an Android port on the roadmap, "feels
|
||||
//! slow" became a real risk to plan around. A togglable FPS / frame-
|
||||
//! time pair gives us a numeric baseline we can quote across desktop
|
||||
//! and mobile, instead of optimising on vibes.
|
||||
//!
|
||||
//! ## Display contract
|
||||
//!
|
||||
//! When visible, the overlay reads `"FPS NN \u{2022} M.MM ms"` in a
|
||||
//! small monospaced cell, anchored top-right. Both numbers are the
|
||||
//! `smoothed()` value (Bevy's exponential moving average) — peak
|
||||
//! and worst-case readings would jitter the text every frame, which
|
||||
//! is harder to glance at than a smoothed reading.
|
||||
//!
|
||||
//! ## Hotkey scope
|
||||
//!
|
||||
//! `F3` is a global, gameplay-blockable toggle: the system reads
|
||||
//! `ButtonInput<KeyCode>` directly and ignores the rest of the modal
|
||||
//! / pause stack. The overlay is informational and shouldn't depend
|
||||
//! on game state.
|
||||
|
||||
use bevy::diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::ui_theme::Z_SPLASH;
|
||||
|
||||
/// Z-index for the diagnostics HUD — above every modal / toast /
|
||||
/// splash layer so a developer can always see the readout, no matter
|
||||
/// what overlay is up.
|
||||
const Z_DIAGNOSTICS_HUD: i32 = Z_SPLASH + 100;
|
||||
|
||||
/// Width-stable font size for the readout. Hand-tuned literal — the
|
||||
/// HUD is a developer affordance and uses its own sizing rather than
|
||||
/// borrowing a typography token whose meaning may drift.
|
||||
const DIAGNOSTICS_FONT_SIZE: f32 = 12.0;
|
||||
|
||||
/// Background alpha for the readout cell. Translucent so the HUD
|
||||
/// doesn't fully obscure whatever's behind it but stays legible.
|
||||
const DIAGNOSTICS_BG_ALPHA: f32 = 0.7;
|
||||
|
||||
/// Wires the FPS / frame-time HUD overlay.
|
||||
///
|
||||
/// Adds [`FrameTimeDiagnosticsPlugin`] (no-op if already added — the
|
||||
/// plugin's `Plugin::build` is idempotent on duplicate registration
|
||||
/// in our codebase since no other site adds it). Spawns the HUD
|
||||
/// hidden, registers the toggle handler, and wires the per-frame
|
||||
/// text refresh.
|
||||
pub struct DiagnosticsHudPlugin;
|
||||
|
||||
impl Plugin for DiagnosticsHudPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins(FrameTimeDiagnosticsPlugin::default())
|
||||
.init_resource::<DiagnosticsHudVisible>()
|
||||
.add_systems(Startup, spawn_diagnostics_hud)
|
||||
.add_systems(
|
||||
Update,
|
||||
(toggle_diagnostics_hud, update_diagnostics_hud).chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks whether the overlay is currently visible. Flipped by the
|
||||
/// `F3` toggle; defaults to hidden so production launches start clean.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
struct DiagnosticsHudVisible(bool);
|
||||
|
||||
/// Marker on the overlay's root Node — used to flip `Visibility`.
|
||||
#[derive(Component, Debug)]
|
||||
struct DiagnosticsHudRoot;
|
||||
|
||||
/// Marker on the readout `Text` node — used by the per-frame refresh
|
||||
/// system to find the right text to overwrite.
|
||||
#[derive(Component, Debug)]
|
||||
struct DiagnosticsHudText;
|
||||
|
||||
/// Spawns the (initially-hidden) overlay at startup. Anchored
|
||||
/// top-right with absolute positioning so it never participates in
|
||||
/// the rest of the UI flex tree.
|
||||
fn spawn_diagnostics_hud(mut commands: Commands, font_res: Option<Res<FontResource>>) {
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let bg = Color::srgba(0.0, 0.0, 0.0, DIAGNOSTICS_BG_ALPHA);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
DiagnosticsHudRoot,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(8.0),
|
||||
right: Val::Px(8.0),
|
||||
padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(bg),
|
||||
Visibility::Hidden,
|
||||
GlobalZIndex(Z_DIAGNOSTICS_HUD),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
parent.spawn((
|
||||
DiagnosticsHudText,
|
||||
Text::new("FPS \u{2014}"),
|
||||
TextFont {
|
||||
font: font_handle,
|
||||
font_size: DIAGNOSTICS_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// `F3` flips the visible flag and the overlay's `Visibility`. Reads
|
||||
/// the keyboard input directly so it isn't gated by pause / modal
|
||||
/// state — diagnostics should always be reachable.
|
||||
fn toggle_diagnostics_hud(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut visible: ResMut<DiagnosticsHudVisible>,
|
||||
mut roots: Query<&mut Visibility, With<DiagnosticsHudRoot>>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::F3) {
|
||||
return;
|
||||
}
|
||||
visible.0 = !visible.0;
|
||||
let target = if visible.0 {
|
||||
Visibility::Visible
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
for mut v in &mut roots {
|
||||
*v = target;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the smoothed FPS + frame-time diagnostics each frame and
|
||||
/// rewrites the readout text. Skipped while the overlay is hidden so
|
||||
/// we don't pay the diagnostic-store lookup or the text mutation
|
||||
/// when nobody's looking.
|
||||
fn update_diagnostics_hud(
|
||||
diagnostics: Res<DiagnosticsStore>,
|
||||
visible: Res<DiagnosticsHudVisible>,
|
||||
mut texts: Query<&mut Text, With<DiagnosticsHudText>>,
|
||||
) {
|
||||
if !visible.0 {
|
||||
return;
|
||||
}
|
||||
let fps = diagnostics
|
||||
.get(&FrameTimeDiagnosticsPlugin::FPS)
|
||||
.and_then(|d| d.smoothed())
|
||||
.unwrap_or(0.0);
|
||||
let frame_time_ms = diagnostics
|
||||
.get(&FrameTimeDiagnosticsPlugin::FRAME_TIME)
|
||||
.and_then(|d| d.smoothed())
|
||||
.unwrap_or(0.0);
|
||||
for mut text in &mut texts {
|
||||
**text = format!("FPS {fps:.0} \u{2022} {frame_time_ms:.2} ms");
|
||||
}
|
||||
}
|
||||
@@ -207,13 +207,6 @@ pub struct ToggleLeaderboardRequestEvent;
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
|
||||
|
||||
/// Fired by `InputPlugin` when N is pressed while a game is in progress
|
||||
/// but confirmation has not yet been received. The animation plugin shows
|
||||
/// a "Press N again to confirm" toast. A second N press within the
|
||||
/// confirmation window sends `NewGameRequestEvent`.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct NewGameConfirmEvent;
|
||||
|
||||
/// Generic informational toast message. Any system can fire this to display
|
||||
/// a short string to the player, e.g. "Locked — reach level 5".
|
||||
#[derive(Message, Debug, Clone)]
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
//!
|
||||
//! # Task #69 — Animated card deal on new game start
|
||||
//!
|
||||
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`) or
|
||||
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
|
||||
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`),
|
||||
//! `start_deal_anim` reads `LayoutResource` and
|
||||
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
|
||||
//! pile's position to its current (final) position with a per-card stagger
|
||||
//! derived from the current `AnimSpeed` setting plus a deterministic ±10 %
|
||||
|
||||
@@ -10,11 +10,18 @@ use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use chrono::Utc;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_data::{delete_game_state_at, game_state_file_path, latest_replay_path,
|
||||
load_game_state_from, save_game_state_to, save_latest_replay_to, Replay, ReplayMove};
|
||||
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
|
||||
use solitaire_data::{
|
||||
append_replay_to_history, delete_game_state_at, game_state_file_path, load_game_state_from,
|
||||
migrate_legacy_latest_replay, replay_history_path, save_game_state_to, Replay, ReplayMove,
|
||||
SOLVER_DEAL_RETRY_CAP,
|
||||
};
|
||||
#[allow(deprecated)]
|
||||
use solitaire_data::latest_replay_path;
|
||||
|
||||
use crate::events::{
|
||||
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
|
||||
@@ -54,10 +61,44 @@ pub struct GameMutation;
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct GameStatePath(pub Option<PathBuf>);
|
||||
|
||||
/// Persistence path for the most recent winning replay. `None` disables I/O.
|
||||
/// Persistence path for the rolling [`solitaire_data::ReplayHistory`]
|
||||
/// file (`replays.json`). `None` disables I/O — used by tests and on
|
||||
/// minimal Linux containers without `dirs::data_dir()`.
|
||||
///
|
||||
/// Each `GameWonEvent` appends the freshly-frozen [`Replay`] to the
|
||||
/// history at this path via
|
||||
/// [`solitaire_data::append_replay_to_history`], capping at
|
||||
/// [`solitaire_data::REPLAY_HISTORY_CAP`] so the file never grows
|
||||
/// unbounded.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct ReplayPath(pub Option<PathBuf>);
|
||||
|
||||
/// Holds the saved-on-disk in-progress game between plugin build and
|
||||
/// the player's answer to the "Continue or start a new game?" prompt.
|
||||
///
|
||||
/// Some(game) at startup means a previously-saved game existed and had
|
||||
/// real moves on it. The restore-prompt modal swaps it into
|
||||
/// `GameStateResource` if the player picks Continue, or drops it (and
|
||||
/// lets `handle_new_game` clean up the disk file) on New Game. None for
|
||||
/// first-launch installs and for save files that contain a fresh deal
|
||||
/// with no moves yet — there's nothing meaningful to "continue" there.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct PendingRestoredGame(pub Option<GameState>);
|
||||
|
||||
/// Marker on the "Welcome back — Continue or start a new game?" modal
|
||||
/// scrim. Despawning the scrim cascades to the card and children, so a
|
||||
/// single `commands.entity(scrim).despawn()` tears the modal down.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct RestorePromptScreen;
|
||||
|
||||
/// Marker on the modal's primary "Continue" button.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct RestoreContinueButton;
|
||||
|
||||
/// Marker on the modal's secondary "New game" button.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct RestoreNewGameButton;
|
||||
|
||||
/// In-memory accumulator for [`ReplayMove`] entries during the current
|
||||
/// game. Cleared on every new-game start; frozen into a [`Replay`] and
|
||||
/// flushed to disk by [`record_replay_on_win`] when the player wins.
|
||||
@@ -95,16 +136,57 @@ impl GamePlugin {
|
||||
impl Plugin for GamePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let path = game_state_file_path();
|
||||
// Restore any saved in-progress game, falling back to a fresh deal.
|
||||
let initial_state = path
|
||||
.as_deref()
|
||||
.and_then(load_game_state_from)
|
||||
.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne));
|
||||
// Try to load any saved in-progress game. We don't want to
|
||||
// silently restore a half-played game on launch — the player
|
||||
// should get to decide between continuing and starting fresh.
|
||||
// So: if there IS a saved game with progress and it isn't
|
||||
// already won, hold it in `PendingRestoredGame` and let the
|
||||
// restore-prompt modal swap it into `GameStateResource` if
|
||||
// the player picks Continue. Otherwise put it directly into
|
||||
// `GameStateResource` (existing behaviour for un-played /
|
||||
// won deals which there's nothing to ask about).
|
||||
let saved = path.as_deref().and_then(load_game_state_from);
|
||||
let prompt_worthy = saved
|
||||
.as_ref()
|
||||
.is_some_and(|g| g.move_count > 0 && !g.is_won);
|
||||
let (initial_state, pending_restore) = if prompt_worthy {
|
||||
(
|
||||
GameState::new(seed_from_system_time(), DrawMode::DrawOne),
|
||||
saved,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
saved.unwrap_or_else(|| {
|
||||
GameState::new(seed_from_system_time(), DrawMode::DrawOne)
|
||||
}),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
// One-shot migration from the legacy single-slot
|
||||
// `latest_replay.json` to the rolling history at `replays.json`.
|
||||
// Runs at plugin construction so the player's last winning
|
||||
// replay from a pre-history build is the first entry of the
|
||||
// new history file. The legacy file is intentionally left in
|
||||
// place for one release as a safety net (see
|
||||
// `migrate_legacy_latest_replay` doc comment).
|
||||
let history_path = replay_history_path();
|
||||
if let (Some(legacy), Some(history)) =
|
||||
(
|
||||
#[allow(deprecated)]
|
||||
latest_replay_path(),
|
||||
history_path.as_ref(),
|
||||
)
|
||||
{
|
||||
migrate_legacy_latest_replay(&legacy, history);
|
||||
}
|
||||
|
||||
app.insert_resource(GameStateResource(initial_state))
|
||||
.insert_resource(GameStatePath(path))
|
||||
.insert_resource(ReplayPath(latest_replay_path()))
|
||||
.insert_resource(ReplayPath(history_path))
|
||||
.insert_resource(PendingRestoredGame(pending_restore))
|
||||
.init_resource::<RecordingReplay>()
|
||||
.init_resource::<PendingNewGameSeed>()
|
||||
.init_resource::<DragState>()
|
||||
.init_resource::<SyncStatusResource>()
|
||||
.add_message::<MoveRequestEvent>()
|
||||
@@ -118,6 +200,10 @@ impl Plugin for GamePlugin {
|
||||
.add_message::<crate::events::AchievementUnlockedEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
poll_pending_new_game_seed.before(GameMutation),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -135,6 +221,11 @@ impl Plugin for GamePlugin {
|
||||
.add_systems(Update, handle_confirm_button_input.after(GameMutation))
|
||||
.add_systems(Update, handle_game_over_input.after(GameMutation))
|
||||
.add_systems(Update, handle_game_over_button_input.after(GameMutation))
|
||||
// Restore prompt: spawn the modal once the splash is gone,
|
||||
// route Continue / New Game intents back into the existing
|
||||
// GameMutation flow.
|
||||
.add_systems(Update, spawn_restore_prompt_if_pending)
|
||||
.add_systems(Update, handle_restore_prompt.before(GameMutation))
|
||||
.init_resource::<AutoSaveTimer>()
|
||||
.add_systems(Update, tick_elapsed_time)
|
||||
.add_systems(Update, auto_save_game_state)
|
||||
@@ -161,16 +252,20 @@ pub fn advance_elapsed(
|
||||
}
|
||||
|
||||
/// Increment `GameState.elapsed_seconds` once per real-world second while
|
||||
/// the game is in progress (not won) and not paused. Stops counting on
|
||||
/// win so the final time reflects how long the player took to solve the
|
||||
/// deal; stops while the pause overlay is open.
|
||||
/// the game is in progress (not won), not paused, and the launch /
|
||||
/// mode-picker Home modal isn't covering the board. Stops counting on
|
||||
/// win so the final time reflects how long the player took to solve
|
||||
/// the deal; stops while the pause overlay is open; stops while Home
|
||||
/// is up so the timer doesn't tick under the picker before the player
|
||||
/// has actually committed to a deal.
|
||||
fn tick_elapsed_time(
|
||||
time: Res<Time>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut accumulator: Local<f32>,
|
||||
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
let is_won = game.0.is_won;
|
||||
@@ -188,6 +283,95 @@ fn seed_from_system_time() -> u64 {
|
||||
.map_or(0, |d| d.as_nanos() as u64)
|
||||
}
|
||||
|
||||
/// Walks forward from `initial_seed` (incrementing by 1 with wrapping
|
||||
/// arithmetic) until the [`solitaire_core::solver`] returns a verdict
|
||||
/// the engine accepts as winnable, or until [`SOLVER_DEAL_RETRY_CAP`]
|
||||
/// attempts have elapsed.
|
||||
///
|
||||
/// The solver classifies each deal as one of three verdicts:
|
||||
/// - [`SolverResult::Winnable`] — provably solvable; accept.
|
||||
/// - [`SolverResult::Inconclusive`] — budget exceeded, no proof
|
||||
/// either way; accept (we treat "we don't know" as winnable so
|
||||
/// the toggle never silently drops a player into the retry cap).
|
||||
/// - [`SolverResult::Unwinnable`] — provably dead; try the next seed.
|
||||
///
|
||||
/// If every seed in the retry window is `Unwinnable` (extremely
|
||||
/// unlikely on real inputs), the function returns the *last* tried
|
||||
/// seed so the player still gets a deal — better a possibly-unwinnable
|
||||
/// hand than an infinite loop.
|
||||
///
|
||||
/// In-flight async work for "Winnable deals only" seed selection.
|
||||
///
|
||||
/// `handle_new_game` writes here when it needs the solver to vet a deal;
|
||||
/// `poll_pending_new_game_seed` reads from here, polls the task, and
|
||||
/// re-emits a `NewGameRequestEvent` with the chosen seed once the task
|
||||
/// completes. The desktop client's UI never blocks on the worst-case
|
||||
/// 50 × ~120 ms solver runs that can pile up on pathological deals.
|
||||
///
|
||||
/// At most one task is ever in flight: a fresh new-game request while
|
||||
/// a previous task is still running drops the previous task (Bevy's
|
||||
/// `Task` `Drop` cancels it cooperatively at the next await point) and
|
||||
/// queues the new one.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PendingNewGameSeed {
|
||||
/// `Some` while a solver-vetted seed is being computed.
|
||||
inner: Option<PendingSeedTask>,
|
||||
}
|
||||
|
||||
/// One in-flight winnable-seed search plus the request fields that
|
||||
/// would have flowed through `handle_new_game` synchronously. The
|
||||
/// poll system replays them on a synthetic `NewGameRequestEvent` once
|
||||
/// the task completes — `seed: Some(...)` skips the solver branch on
|
||||
/// the second pass so we don't loop.
|
||||
struct PendingSeedTask {
|
||||
handle: Task<u64>,
|
||||
mode: Option<GameMode>,
|
||||
confirmed: bool,
|
||||
}
|
||||
|
||||
/// Update system: poll the in-flight winnable-seed search. When the
|
||||
/// task resolves, emit a synthetic `NewGameRequestEvent` carrying the
|
||||
/// chosen seed. Ordered `.before(GameMutation)` so `handle_new_game`
|
||||
/// picks up the synthetic event on the same frame, completing the
|
||||
/// new-game flow without a one-frame visual lag.
|
||||
fn poll_pending_new_game_seed(
|
||||
mut pending: ResMut<PendingNewGameSeed>,
|
||||
mut new_game_writer: MessageWriter<NewGameRequestEvent>,
|
||||
) {
|
||||
let Some(p) = pending.inner.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(seed) = future::block_on(future::poll_once(&mut p.handle)) else {
|
||||
return;
|
||||
};
|
||||
let mode = p.mode;
|
||||
let confirmed = p.confirmed;
|
||||
pending.inner = None;
|
||||
new_game_writer.write(NewGameRequestEvent {
|
||||
seed: Some(seed),
|
||||
mode,
|
||||
confirmed,
|
||||
});
|
||||
}
|
||||
|
||||
/// Pure helper extracted for testability — `new_game_with_solver_*`
|
||||
/// engine tests in the same file exercise this path.
|
||||
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: &DrawMode) -> u64 {
|
||||
let cfg = SolverConfig::default();
|
||||
let mut seed = initial_seed;
|
||||
for _ in 0..SOLVER_DEAL_RETRY_CAP {
|
||||
match try_solve(seed, draw_mode.clone(), &cfg) {
|
||||
SolverResult::Winnable | SolverResult::Inconclusive => return seed,
|
||||
SolverResult::Unwinnable => {
|
||||
seed = seed.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Retry cap exhausted — accept the latest tried seed rather than
|
||||
// recurring forever.
|
||||
seed
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_new_game(
|
||||
mut commands: Commands,
|
||||
@@ -195,6 +379,7 @@ fn handle_new_game(
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: MessageWriter<StateChangedEvent>,
|
||||
mut recording: ResMut<RecordingReplay>,
|
||||
mut pending_seed: ResMut<PendingNewGameSeed>,
|
||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
@@ -229,7 +414,14 @@ fn handle_new_game(
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
|
||||
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
|
||||
// Drop any in-flight winnable-seed search now that we've
|
||||
// committed to acting on a new request. Its result was for
|
||||
// the previous user intent — the new request supersedes it
|
||||
// regardless of which branch we take below (synchronous
|
||||
// explicit-seed deal vs. another async solver search).
|
||||
pending_seed.inner = None;
|
||||
|
||||
let initial_seed = ev.seed.unwrap_or_else(seed_from_system_time);
|
||||
// Prefer the draw mode from Settings when starting a fresh game.
|
||||
// Fall back to the current game's draw mode in headless/test contexts
|
||||
// where SettingsPlugin is not installed.
|
||||
@@ -237,7 +429,43 @@ fn handle_new_game(
|
||||
.as_ref()
|
||||
.map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone());
|
||||
let mode = ev.mode.unwrap_or(game.0.mode);
|
||||
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
|
||||
|
||||
// Solver-backed retry: when the player has opted in to
|
||||
// "Winnable deals only" AND this is a random Classic deal
|
||||
// (no caller-supplied seed), reject deals the solver can
|
||||
// prove unwinnable and try the next seed. Capped at
|
||||
// [`SOLVER_DEAL_RETRY_CAP`] so a pathological run can't
|
||||
// hang the main thread — if every attempt is rejected we
|
||||
// fall through to the latest tried seed.
|
||||
//
|
||||
// **Scope** — the retry deliberately skips:
|
||||
// - Daily challenges and challenge-mode seeds (caller passes
|
||||
// `ev.seed = Some(...)` so the player gets the same deal as
|
||||
// everyone else).
|
||||
// - Replays (the replay's own seed is authoritative).
|
||||
// - Any other explicit seed request — the player asked for
|
||||
// that seed; honour it.
|
||||
let winnable_only = settings
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.0.winnable_deals_only);
|
||||
if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
|
||||
let dm = draw_mode.clone();
|
||||
let task = AsyncComputeTaskPool::get()
|
||||
.spawn(async move { choose_winnable_seed(initial_seed, &dm) });
|
||||
pending_seed.inner = Some(PendingSeedTask {
|
||||
handle: task,
|
||||
mode: ev.mode,
|
||||
confirmed: ev.confirmed,
|
||||
});
|
||||
// Skip the rest of the new-game flow; the polling system
|
||||
// will re-emit a synthetic event with a chosen seed once
|
||||
// the task resolves.
|
||||
continue;
|
||||
}
|
||||
|
||||
let chosen_seed = initial_seed;
|
||||
|
||||
game.0 = GameState::new_with_mode(chosen_seed, draw_mode, mode);
|
||||
// Reset the in-flight replay buffer — a fresh deal starts with
|
||||
// an empty move list. The previously saved replay on disk
|
||||
// (latest_replay.json) is preserved until the player wins again.
|
||||
@@ -291,6 +519,132 @@ pub struct ConfirmNoButton;
|
||||
/// and "No (N)" — those were not real Button entities, so the player
|
||||
/// had no hover / press feedback and the modal felt like a debug panel
|
||||
/// (the user's smoke-test "#2 complaint").
|
||||
/// Update-schedule system: once the splash overlay is gone and there's
|
||||
/// a pending restored game waiting for the player's answer, spawn the
|
||||
/// "Welcome back — Continue or start a new game?" modal. Idempotent —
|
||||
/// the existing `RestorePromptScreen` query gates against duplicate
|
||||
/// spawns if Update fires before the player clicks.
|
||||
fn spawn_restore_prompt_if_pending(
|
||||
mut commands: Commands,
|
||||
pending: Res<PendingRestoredGame>,
|
||||
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
|
||||
existing: Query<(), With<RestorePromptScreen>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if pending.0.is_none() || !splash.is_empty() || !existing.is_empty() {
|
||||
return;
|
||||
}
|
||||
spawn_modal(
|
||||
&mut commands,
|
||||
RestorePromptScreen,
|
||||
ui_theme::Z_MODAL_PANEL,
|
||||
|card| {
|
||||
spawn_modal_header(card, "Welcome back", font_res.as_deref());
|
||||
spawn_modal_body_text(
|
||||
card,
|
||||
"You have an in-progress game. Continue where you left off, or start a new one?",
|
||||
ui_theme::TEXT_SECONDARY,
|
||||
font_res.as_deref(),
|
||||
);
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
RestoreNewGameButton,
|
||||
"New game",
|
||||
Some("N"),
|
||||
ButtonVariant::Secondary,
|
||||
font_res.as_deref(),
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
RestoreContinueButton,
|
||||
"Continue",
|
||||
Some("Enter"),
|
||||
ButtonVariant::Primary,
|
||||
font_res.as_deref(),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Click handlers + keyboard shortcuts for the restore prompt.
|
||||
///
|
||||
/// Continue (Enter / C) — swaps the saved game into `GameStateResource`
|
||||
/// and writes a `StateChangedEvent` so card sprites resync to the
|
||||
/// restored layout.
|
||||
/// New game (N) — drops the saved game and writes
|
||||
/// `NewGameRequestEvent { confirmed: true }`. The existing
|
||||
/// `handle_new_game` flow takes over: deletes `game_state.json`, deals
|
||||
/// a fresh game, fires `StateChangedEvent`. `confirmed: true` skips
|
||||
/// the abandon-current-game confirm dialog (the player has already
|
||||
/// confirmed by clicking New game here).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_restore_prompt(
|
||||
mut commands: Commands,
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
screens: Query<Entity, With<RestorePromptScreen>>,
|
||||
continue_buttons: Query<&Interaction, (With<RestoreContinueButton>, Changed<Interaction>)>,
|
||||
new_game_buttons: Query<&Interaction, (With<RestoreNewGameButton>, Changed<Interaction>)>,
|
||||
mut pending: ResMut<PendingRestoredGame>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: MessageWriter<StateChangedEvent>,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
mut launch_home_shown: Option<ResMut<crate::home_plugin::LaunchHomeShown>>,
|
||||
) {
|
||||
if screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Esc maps to Continue rather than New Game so a stray dismiss
|
||||
// press preserves the saved game — the data-preserving default is
|
||||
// the safer fallback when a player hits Esc reflexively to "close
|
||||
// this dialog" without reading it.
|
||||
let key_continue = keys.as_ref().is_some_and(|k| {
|
||||
k.just_pressed(KeyCode::Enter)
|
||||
|| k.just_pressed(KeyCode::KeyC)
|
||||
|| k.just_pressed(KeyCode::Escape)
|
||||
});
|
||||
let key_new = keys.as_ref().is_some_and(|k| k.just_pressed(KeyCode::KeyN));
|
||||
let click_continue = continue_buttons
|
||||
.iter()
|
||||
.any(|i| *i == Interaction::Pressed);
|
||||
let click_new = new_game_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
|
||||
let resolved = if key_continue || click_continue {
|
||||
if let Some(restored) = pending.0.take() {
|
||||
game.0 = restored;
|
||||
changed.write(StateChangedEvent);
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
true
|
||||
} else if key_new || click_new {
|
||||
pending.0 = None;
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
new_game.write(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: None,
|
||||
confirmed: true,
|
||||
});
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// The player has just made an explicit launch-time choice (continue
|
||||
// saved game, or start a fresh deal). Suppress the launch-time Home
|
||||
// auto-show so it doesn't pop on top of the resolution they picked.
|
||||
// `M` still re-opens the picker on demand.
|
||||
if resolved
|
||||
&& let Some(ref mut shown) = launch_home_shown
|
||||
{
|
||||
shown.0 = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_confirm_dialog(
|
||||
commands: &mut Commands,
|
||||
original_request: NewGameRequestEvent,
|
||||
@@ -557,14 +911,15 @@ fn handle_undo(
|
||||
|
||||
/// On every `GameWonEvent`, freeze the in-flight [`RecordingReplay`] into
|
||||
/// a [`Replay`] tagged with the deal seed/mode, the win's score and
|
||||
/// elapsed time, and today's date — then persist it atomically to
|
||||
/// `<data_dir>/solitaire_quest/latest_replay.json` (or to whichever path
|
||||
/// `ReplayPath` carries; tests inject a temp path).
|
||||
/// elapsed time, and today's date — then append it to the rolling
|
||||
/// [`solitaire_data::ReplayHistory`] at the path `ReplayPath` carries
|
||||
/// (tests inject a temp path).
|
||||
///
|
||||
/// Only the most recent winning replay is retained — the existing file is
|
||||
/// overwritten. The recording buffer is left intact after the win so a
|
||||
/// subsequent state-change does not erase the move list before the save
|
||||
/// completes; it gets cleared on the next `NewGameRequestEvent`.
|
||||
/// The history is capped at [`solitaire_data::REPLAY_HISTORY_CAP`]
|
||||
/// entries; older wins age out automatically when the cap is hit. The
|
||||
/// recording buffer is left intact after the win so a subsequent
|
||||
/// state-change does not erase the move list before the save completes;
|
||||
/// it gets cleared on the next `NewGameRequestEvent`.
|
||||
pub fn record_replay_on_win(
|
||||
mut wins: MessageReader<GameWonEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
@@ -597,8 +952,8 @@ pub fn record_replay_on_win(
|
||||
// to inspect it without going through the disk.
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = save_latest_replay_to(p, &replay) {
|
||||
warn!("replay: failed to save winning replay: {e}");
|
||||
if let Err(e) = append_replay_to_history(p, replay) {
|
||||
warn!("replay: failed to append winning replay to history: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -843,9 +1198,17 @@ fn auto_save_game_state(
|
||||
path: Option<Res<GameStatePath>>,
|
||||
mut timer: ResMut<AutoSaveTimer>,
|
||||
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||
pending: Res<PendingRestoredGame>,
|
||||
) {
|
||||
// Don't save if paused, game is won, or no moves have been made yet.
|
||||
if paused.is_some_and(|p| p.0) || game.0.is_won || game.0.move_count == 0 {
|
||||
// Don't save if paused, game is won, no moves have been made yet,
|
||||
// or there's a pending restore the player hasn't answered — saving
|
||||
// the fresh-deal placeholder we seeded GameStateResource with at
|
||||
// startup would clobber the real saved game on disk.
|
||||
if paused.is_some_and(|p| p.0)
|
||||
|| game.0.is_won
|
||||
|| game.0.move_count == 0
|
||||
|| pending.0.is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
timer.0 += time.delta_secs();
|
||||
@@ -862,17 +1225,25 @@ fn auto_save_game_state(
|
||||
/// player can resume where they left off. Won games are not saved (the
|
||||
/// `save_game_state_to` helper skips them). Blocking on exit is acceptable
|
||||
/// because the game loop is already shutting down.
|
||||
///
|
||||
/// Special case: when `PendingRestoredGame` still holds a saved game the
|
||||
/// player never answered the restore prompt for, write THAT to disk
|
||||
/// instead of the live `GameStateResource`. Otherwise we'd clobber a
|
||||
/// real saved game with the fresh-deal placeholder we seeded
|
||||
/// `GameStateResource` with at startup.
|
||||
fn save_game_state_on_exit(
|
||||
mut exit_events: MessageReader<AppExit>,
|
||||
game: Res<GameStateResource>,
|
||||
path: Res<GameStatePath>,
|
||||
pending: Res<PendingRestoredGame>,
|
||||
) {
|
||||
if exit_events.is_empty() {
|
||||
return;
|
||||
}
|
||||
exit_events.clear();
|
||||
let Some(p) = path.0.as_deref() else { return };
|
||||
if let Err(e) = save_game_state_to(p, &game.0) {
|
||||
let to_save = pending.0.as_ref().unwrap_or(&game.0);
|
||||
if let Err(e) = save_game_state_to(p, to_save) {
|
||||
warn!("game_state: failed to save on exit: {e}");
|
||||
}
|
||||
}
|
||||
@@ -893,6 +1264,14 @@ mod tests {
|
||||
// plugin's build path; clearing them keeps tests self-contained.
|
||||
app.insert_resource(GameStatePath(None));
|
||||
app.insert_resource(ReplayPath(None));
|
||||
// Force `PendingRestoredGame` empty so production saved-game
|
||||
// state on the dev machine's disk (loaded by `GamePlugin::build`)
|
||||
// can't leak into per-test world state and trip the
|
||||
// `pending.0.is_some()` guard in `auto_save_game_state` /
|
||||
// `save_game_state_on_exit`. Without this clear, an
|
||||
// unrelated `~/.local/share/solitaire_quest/game_state.json`
|
||||
// would silently disable the auto-save path under test.
|
||||
app.insert_resource(PendingRestoredGame(None));
|
||||
// Override the system-time seed with a known value.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
@@ -1145,6 +1524,16 @@ mod tests {
|
||||
}
|
||||
|
||||
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
|
||||
///
|
||||
/// The timer is pre-seeded just past the threshold and the test
|
||||
/// re-arms it before each `app.update()` in a small bounded loop:
|
||||
/// under `MinimalPlugins` the first frame's `Time::delta_secs()`
|
||||
/// can be 0.0 (or, under heavy parallel cargo-test load, large
|
||||
/// enough that the pre-seeded margin is consumed by it), so a
|
||||
/// single-frame check is fragile. Looping until the file appears
|
||||
/// (or hitting the bound) makes the test robust against
|
||||
/// first-frame Time variance without changing the underlying
|
||||
/// behaviour contract.
|
||||
#[test]
|
||||
fn auto_save_writes_after_30_seconds() {
|
||||
use solitaire_data::load_game_state_from;
|
||||
@@ -1160,10 +1549,18 @@ mod tests {
|
||||
.0
|
||||
.move_count = 1;
|
||||
|
||||
// Pre-seed the timer just past the threshold. The system will trigger
|
||||
// on the very next update() without needing to control Time::delta_secs().
|
||||
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 0.1));
|
||||
app.update();
|
||||
// Re-arm the timer past the threshold every frame and pump
|
||||
// updates until the save fires. Caps at 16 iterations — a
|
||||
// healthy run hits it on the first or second frame; the cap
|
||||
// prevents an infinite loop if a future regression skips
|
||||
// the save unconditionally.
|
||||
for _ in 0..16 {
|
||||
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 1.0));
|
||||
app.update();
|
||||
if path.exists() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(path.exists(), "auto-save file must exist after timer crosses threshold");
|
||||
let loaded = load_game_state_from(&path).expect("file must be loadable");
|
||||
@@ -1946,11 +2343,13 @@ mod tests {
|
||||
}
|
||||
|
||||
/// On `GameWonEvent`, the recording is frozen into a `Replay` and
|
||||
/// persisted. We point `ReplayPath` at a temp file, fake a win, and
|
||||
/// load the file back to assert the metadata + move list match.
|
||||
/// appended to the rolling [`solitaire_data::ReplayHistory`]. We
|
||||
/// point `ReplayPath` at a temp file, fake a win, and load the
|
||||
/// history back to assert the just-saved entry sits at the front
|
||||
/// with the metadata + move list intact.
|
||||
#[test]
|
||||
fn replay_recording_freezes_into_replay_on_game_won() {
|
||||
use solitaire_data::load_latest_replay_from;
|
||||
use solitaire_data::load_replay_history_from;
|
||||
|
||||
let path = std::env::temp_dir().join("engine_test_replay_freeze.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
@@ -1978,8 +2377,14 @@ mod tests {
|
||||
});
|
||||
app.update();
|
||||
|
||||
let loaded = load_latest_replay_from(&path)
|
||||
let history = load_replay_history_from(&path)
|
||||
.expect("a winning replay must be persisted to ReplayPath");
|
||||
assert_eq!(
|
||||
history.replays.len(),
|
||||
1,
|
||||
"fresh history must contain exactly the just-recorded win",
|
||||
);
|
||||
let loaded = &history.replays[0];
|
||||
assert_eq!(loaded.seed, 7654, "seed must match the live game state");
|
||||
assert_eq!(loaded.draw_mode, DrawMode::DrawOne, "draw_mode must be captured");
|
||||
assert_eq!(loaded.final_score, 4321, "final_score must come from the win event");
|
||||
@@ -1998,6 +2403,53 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Successive `GameWonEvent`s must accumulate in the rolling
|
||||
/// history rather than overwriting one another. Pre-cap, every win
|
||||
/// joins the front of `history.replays`.
|
||||
#[test]
|
||||
fn replay_recording_appends_to_history_across_wins() {
|
||||
use solitaire_data::load_replay_history_from;
|
||||
|
||||
let path = std::env::temp_dir().join("engine_test_replay_history_append.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let mut app = test_app(11);
|
||||
app.insert_resource(ReplayPath(Some(path.clone())));
|
||||
|
||||
// First win.
|
||||
{
|
||||
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
|
||||
recording.moves.clear();
|
||||
recording.moves.push(ReplayMove::StockClick);
|
||||
}
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 100,
|
||||
time_seconds: 60,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// Second win — different score so we can distinguish.
|
||||
{
|
||||
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
|
||||
recording.moves.clear();
|
||||
recording.moves.push(ReplayMove::StockClick);
|
||||
recording.moves.push(ReplayMove::StockClick);
|
||||
}
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 200,
|
||||
time_seconds: 120,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let history = load_replay_history_from(&path).expect("history must exist");
|
||||
assert_eq!(history.replays.len(), 2, "both wins must be retained");
|
||||
// Newest first — second win lands at index 0.
|
||||
assert_eq!(history.replays[0].final_score, 200);
|
||||
assert_eq!(history.replays[1].final_score, 100);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// `GameWonEvent` with an empty recording must NOT touch disk.
|
||||
/// Without this guard, parallel-plugin tests that synthesise
|
||||
/// win events for XP / streak / weekly-goal logic (without
|
||||
@@ -2022,4 +2474,261 @@ mod tests {
|
||||
"no replay must be written when recording is empty",
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Solver-backed "Winnable deals only" toggle
|
||||
//
|
||||
// Exercises [`choose_winnable_seed`] and the wiring inside
|
||||
// `handle_new_game` that consults [`Settings::winnable_deals_only`].
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Inject a `SettingsResource` with the given `winnable_deals_only`
|
||||
/// flag. The handle_new_game system already reads this resource via
|
||||
/// `Option<Res<...>>`, so no `SettingsPlugin` boot is needed.
|
||||
fn insert_settings(app: &mut App, winnable_deals_only: bool) {
|
||||
let settings = solitaire_data::Settings {
|
||||
winnable_deals_only,
|
||||
..solitaire_data::Settings::default()
|
||||
};
|
||||
app.insert_resource(crate::settings_plugin::SettingsResource(settings));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_with_solver_toggle_off_uses_requested_seed() {
|
||||
// Toggle off — the engine must use the seed it was handed and
|
||||
// never invoke the solver. Seed 999 is just an arbitrary
|
||||
// deterministic seed; the test asserts the resulting deal
|
||||
// matches `GameState::new(999, DrawOne)`.
|
||||
let mut app = test_app(1);
|
||||
insert_settings(&mut app, false);
|
||||
|
||||
app.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: Some(999),
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let actual_seed = app.world().resource::<GameStateResource>().0.seed;
|
||||
assert_eq!(
|
||||
actual_seed, 999,
|
||||
"with solver toggle off, the requested seed must be honoured exactly"
|
||||
);
|
||||
// Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte.
|
||||
let expected = GameState::new(999, DrawMode::DrawOne);
|
||||
for i in 0..7 {
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.piles[&PileType::Tableau(i)].cards,
|
||||
expected.piles[&PileType::Tableau(i)].cards,
|
||||
"tableau column {i} must match the unfiltered seed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_with_solver_toggle_off_random_seed_path() {
|
||||
// When seed is None and toggle is off, the engine uses a
|
||||
// system-time seed and skips the solver. We can't pin the
|
||||
// exact seed, but we can assert the seed is *not* the
|
||||
// sentinel zero (which would only happen if SystemTime is
|
||||
// before the epoch — practically impossible), AND that no
|
||||
// resource has been mutated to suggest the solver ran.
|
||||
// The strongest assertion is "the move runs to completion
|
||||
// without panicking", which the .update() call covers.
|
||||
let mut app = test_app(1);
|
||||
insert_settings(&mut app, false);
|
||||
|
||||
app.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// Game state was reseeded — move_count is 0 on the new game.
|
||||
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_with_solver_toggle_on_skips_solver_for_specific_seed() {
|
||||
// Even with the toggle on, an *explicit* seed must be honoured:
|
||||
// daily challenges, replay seeding, and challenge-mode all
|
||||
// pass `Some(seed)` and must never be retried.
|
||||
let mut app = test_app(1);
|
||||
insert_settings(&mut app, true);
|
||||
|
||||
app.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: Some(123),
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
});
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.seed,
|
||||
123,
|
||||
"explicit-seed requests must skip the solver retry loop",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choose_winnable_seed_skips_unwinnable_seed() {
|
||||
// Seed 394 was identified by the offline scan
|
||||
// (`solver::tests::find_unwinnable`) as the only Unwinnable
|
||||
// seed in 0..500 under the default solver budget. Seed 395
|
||||
// resolves as Inconclusive — the engine treats Inconclusive
|
||||
// as winnable (see `choose_winnable_seed` doc), so the
|
||||
// helper must return 395 when started at 394.
|
||||
let chosen = choose_winnable_seed(394, &DrawMode::DrawOne);
|
||||
assert_eq!(
|
||||
chosen, 395,
|
||||
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_with_solver_toggle_on_retries_until_winnable() {
|
||||
// End-to-end: with the toggle on, fire a NewGameRequestEvent
|
||||
// with seed=None and *manually pre-seed* the system-time
|
||||
// path by clearing the GameStateResource so handle_new_game
|
||||
// takes the random branch. We can't easily inject the
|
||||
// system-time seed here, so we exercise the helper via a
|
||||
// separate call and assert the *resource* receives the
|
||||
// post-retry seed when the helper would have rejected.
|
||||
//
|
||||
// We test the integration by setting up an alternative
|
||||
// scenario: pass `seed: Some(394)` with toggle on. Our
|
||||
// implementation already documents that explicit seeds skip
|
||||
// the retry, so this *won't* trigger retry. The cleaner
|
||||
// integration is captured in `choose_winnable_seed_skips_*`.
|
||||
// Here we verify the default-seed path doesn't crash when
|
||||
// toggle is on — exercising the live solver call inside
|
||||
// handle_new_game without depending on the solver picking
|
||||
// a specific seed.
|
||||
let mut app = test_app(1);
|
||||
insert_settings(&mut app, true);
|
||||
|
||||
app.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// The chosen seed is non-deterministic (system time),
|
||||
// but the new game must have been started cleanly:
|
||||
// move_count back to 0, undo stack empty.
|
||||
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.undo_stack_len(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// Async-solver flow: a winnable-only request with no explicit
|
||||
/// seed must populate `PendingNewGameSeed` on the same frame the
|
||||
/// request fires (no main-thread stall waiting on the solver),
|
||||
/// and subsequent updates must clear the pending state and
|
||||
/// produce a new GameState.
|
||||
///
|
||||
/// Drives multiple `app.update()` calls because the polling
|
||||
/// system needs at least one tick after spawn to observe the
|
||||
/// task as ready and re-emit the synthetic event.
|
||||
#[test]
|
||||
fn winnable_seed_search_runs_async_and_completes_eventually() {
|
||||
let mut app = test_app(394);
|
||||
insert_settings(&mut app, true);
|
||||
|
||||
app.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
});
|
||||
// First update: handle_new_game spawns the solver task and
|
||||
// returns. The GameStateResource is unchanged on this tick —
|
||||
// the player's previous game is still on screen, so the UI
|
||||
// doesn't visually stall.
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<PendingNewGameSeed>().inner.is_some(),
|
||||
"first frame should have an in-flight solver task",
|
||||
);
|
||||
|
||||
// Pump frames until the polling system observes the task as
|
||||
// ready and re-emits the synthetic event. AsyncComputeTaskPool
|
||||
// is a shared pool across the whole `cargo test` run — when
|
||||
// dozens of tests execute in parallel the pool can take a
|
||||
// while to actually schedule our future. The yield_now() lets
|
||||
// the pool's worker threads make progress between our polls
|
||||
// without burning wall-clock time.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
while app.world().resource::<PendingNewGameSeed>().inner.is_some() {
|
||||
app.update();
|
||||
std::thread::yield_now();
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
app.world().resource::<PendingNewGameSeed>().inner.is_none(),
|
||||
"solver task should have completed within 15 s wall-clock",
|
||||
);
|
||||
// New game completed: a fresh deal carries 0 moves.
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.move_count,
|
||||
0,
|
||||
"completed new game must be in fresh-deal state",
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel-on-replace: a winnable-only request that arrives while
|
||||
/// a previous solver task is in flight must drop the previous
|
||||
/// task and queue the new one. The most recently-fired request
|
||||
/// is the one whose seed wins, regardless of which task started
|
||||
/// first.
|
||||
#[test]
|
||||
fn winnable_seed_search_drops_in_flight_task_on_new_request() {
|
||||
let mut app = test_app(394);
|
||||
insert_settings(&mut app, true);
|
||||
|
||||
// Fire the first request; first update spawns the task.
|
||||
app.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
});
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<PendingNewGameSeed>().inner.is_some(),
|
||||
"first request should be in flight",
|
||||
);
|
||||
|
||||
// Fire a SECOND request with an explicit seed before the
|
||||
// first task can complete. handle_new_game's `pending.inner =
|
||||
// None` line must drop the in-flight task; the explicit-seed
|
||||
// branch then bypasses the solver entirely. After this tick
|
||||
// the GameStateResource carries seed 12345, not whatever the
|
||||
// solver would have picked for the first request.
|
||||
app.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: Some(12345),
|
||||
mode: None,
|
||||
confirmed: true,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// Drive a few more ticks to drain any stragglers.
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
assert!(
|
||||
app.world().resource::<PendingNewGameSeed>().inner.is_none(),
|
||||
"explicit-seed request must have cancelled the in-flight task",
|
||||
);
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.seed,
|
||||
12345,
|
||||
"explicit-seed request takes precedence over the dropped solver task",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
//! is an optional accelerator. Listed shortcuts are grouped by intent —
|
||||
//! gameplay, modes, and overlays.
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::events::HelpRequestEvent;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||
@@ -24,6 +26,16 @@ pub struct HelpScreen;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HelpCloseButton;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Help modal.
|
||||
///
|
||||
/// The controls reference is six sections totalling ~28 rows, which
|
||||
/// overflows the modal on the 800x600 minimum window. This marker tags
|
||||
/// the inner container that carries `Overflow::scroll_y()` plus a
|
||||
/// `max_height` constraint so every row stays reachable. Mirrors the
|
||||
/// `SettingsPanelScrollable` pattern.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HelpScrollable;
|
||||
|
||||
/// Spawns and despawns the help / controls overlay shown when the player
|
||||
/// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture
|
||||
/// guides live here.
|
||||
@@ -32,7 +44,14 @@ pub struct HelpPlugin;
|
||||
impl Plugin for HelpPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_message::<HelpRequestEvent>()
|
||||
.add_systems(Update, (toggle_help_screen, handle_help_close_button));
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the help-scroll
|
||||
// system also runs cleanly under `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(toggle_help_screen, handle_help_close_button, scroll_help_panel),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +90,32 @@ fn handle_help_close_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes mouse-wheel events into the Help modal's scrollable body while
|
||||
/// the panel is open. No-op when no `HelpScrollable` exists in the world
|
||||
/// (modal closed). Mirrors `scroll_settings_panel`.
|
||||
fn scroll_help_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<HelpScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
scroll_evr.clear();
|
||||
return;
|
||||
}
|
||||
let delta_y: f32 = scroll_evr
|
||||
.read()
|
||||
.map(|ev| match ev.unit {
|
||||
MouseScrollUnit::Line => ev.y * 50.0,
|
||||
MouseScrollUnit::Pixel => ev.y,
|
||||
})
|
||||
.sum();
|
||||
if delta_y == 0.0 {
|
||||
return;
|
||||
}
|
||||
for mut sp in scrollables.iter_mut() {
|
||||
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each entry in the controls reference table.
|
||||
struct ControlRow {
|
||||
keys: &'static str,
|
||||
@@ -139,6 +184,8 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
|
||||
ControlSection {
|
||||
title: "Overlays",
|
||||
rows: &[
|
||||
ControlRow { keys: "M", description: "Mode launcher (Home)" },
|
||||
ControlRow { keys: "P", description: "Profile" },
|
||||
ControlRow { keys: "S", description: "Stats & progression" },
|
||||
ControlRow { keys: "A", description: "Achievements" },
|
||||
ControlRow { keys: "L", description: "Leaderboard" },
|
||||
@@ -147,6 +194,7 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
|
||||
ControlRow { keys: "F11", description: "Toggle fullscreen" },
|
||||
ControlRow { keys: "Esc", description: "Pause / resume" },
|
||||
ControlRow { keys: "[ / ]", description: "SFX volume down / up" },
|
||||
ControlRow { keys: "Enter", description: "Play Again (on the Win Summary)" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -165,62 +213,80 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
..default()
|
||||
};
|
||||
|
||||
spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| {
|
||||
let scrim = spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Controls", font_res);
|
||||
|
||||
for section in CONTROL_SECTIONS {
|
||||
// Section title in muted text — distinguishes from row content.
|
||||
card.spawn((
|
||||
Text::new(section.title),
|
||||
font_section.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
// Scrollable body — the controls reference is six sections totalling
|
||||
// ~28 rows, which overflows the modal on the 800x600 minimum
|
||||
// window. Wrapping in an `Overflow::scroll_y()` Node with a
|
||||
// constrained `max_height` keeps every row reachable; the Done
|
||||
// button below stays fixed outside the scroll.
|
||||
card.spawn((
|
||||
HelpScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
max_height: Val::Vh(70.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
for section in CONTROL_SECTIONS {
|
||||
// Section title in muted text — distinguishes from row content.
|
||||
body.spawn((
|
||||
Text::new(section.title),
|
||||
font_section.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
// Each row is a flex-row: kbd-style chip + description.
|
||||
for row in section.rows {
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|line| {
|
||||
// The hotkey rendered as a small chip with a border —
|
||||
// visual cue that it's a key reference, not part of
|
||||
// the description text.
|
||||
line.spawn((
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||
min_width: Val::Px(64.0),
|
||||
justify_content: JustifyContent::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
..default()
|
||||
},
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|chip| {
|
||||
chip.spawn((
|
||||
Text::new(row.keys),
|
||||
font_kbd.clone(),
|
||||
// Each row is a flex-row: kbd-style chip + description.
|
||||
for row in section.rows {
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|line| {
|
||||
// The hotkey rendered as a small chip with a border —
|
||||
// visual cue that it's a key reference, not part of
|
||||
// the description text.
|
||||
line.spawn((
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||
min_width: Val::Px(64.0),
|
||||
justify_content: JustifyContent::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
..default()
|
||||
},
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|chip| {
|
||||
chip.spawn((
|
||||
Text::new(row.keys),
|
||||
font_kbd.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
});
|
||||
line.spawn((
|
||||
Text::new(row.description),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
});
|
||||
line.spawn((
|
||||
Text::new(row.description),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
|
||||
// Section spacer — small empty box. Keeps each section
|
||||
// visually grouped.
|
||||
body.spawn(Node {
|
||||
height: Val::Px(SPACE_2),
|
||||
..default()
|
||||
});
|
||||
}
|
||||
|
||||
// Section spacer — small empty box. Keeps each section
|
||||
// visually grouped.
|
||||
card.spawn(Node {
|
||||
height: Val::Px(SPACE_2),
|
||||
..default()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
@@ -233,6 +299,9 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
);
|
||||
});
|
||||
});
|
||||
// Help is read-only — clicking the scrim outside the card dismisses
|
||||
// alongside the existing F1 / Esc / Done paths.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -264,6 +333,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_modal_body_is_scrollable() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::F1);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&HelpScrollable>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Help modal must spawn exactly one HelpScrollable body"
|
||||
);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<HelpScrollable>>();
|
||||
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
||||
assert_ne!(
|
||||
nodes[0].max_height,
|
||||
Val::Auto,
|
||||
"scrollable body must set a non-default max_height"
|
||||
);
|
||||
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_f1_twice_closes_help_screen() {
|
||||
let mut app = headless_app();
|
||||
|
||||
@@ -13,24 +13,34 @@
|
||||
//! [`InfoToastEvent`] explaining the gate but does not launch the mode
|
||||
//! or close the overlay.
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_data::save_settings_to;
|
||||
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::events::{
|
||||
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||
ToggleProfileRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::settings_plugin::{
|
||||
SettingsChangedEvent, SettingsResource, SettingsStoragePath,
|
||||
};
|
||||
use crate::stats_plugin::StatsResource;
|
||||
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD, STATE_INFO,
|
||||
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
|
||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD,
|
||||
STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||
TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -46,6 +56,31 @@ pub struct HomeScreen;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HomeCancelButton;
|
||||
|
||||
/// Marker on the player-stats chip strip at the top of the Home modal.
|
||||
/// Clicking the strip opens the Profile overlay so the player can drill
|
||||
/// into level / XP / cosmetics without first dismissing Home.
|
||||
#[derive(Component, Debug)]
|
||||
struct HomeProfileChip;
|
||||
|
||||
/// Marker on the "Draw 1" toggle button inside the Home modal's
|
||||
/// draw-mode row. Clicking flips `Settings.draw_mode` to `DrawOne` and
|
||||
/// fires `SettingsChangedEvent` so audio / UI dependents react.
|
||||
#[derive(Component, Debug)]
|
||||
struct HomeDrawOneButton;
|
||||
|
||||
/// Marker on the "Draw 3" toggle button inside the Home modal's
|
||||
/// draw-mode row. Mirror of [`HomeDrawOneButton`] for `DrawThree`.
|
||||
#[derive(Component, Debug)]
|
||||
struct HomeDrawThreeButton;
|
||||
|
||||
/// Marker on the scrollable inner Node containing the player chips,
|
||||
/// draw-mode row, and tile grid. Wrapping these in a scrollable
|
||||
/// container keeps the modal usable on small viewports — without it,
|
||||
/// the 3-row tile stack pushes the Cancel button off the bottom of
|
||||
/// the screen on 800x600 hardware. Mirrors `SettingsPanelScrollable`.
|
||||
#[derive(Component, Debug)]
|
||||
struct HomeScrollable;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private mode-card data shape
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -86,6 +121,38 @@ impl HomeMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unicode glyph rendered as the picture-tile centrepiece. Stand-in
|
||||
/// for real per-mode artwork — chosen for one-glyph-tells-the-mode
|
||||
/// readability rather than visual fidelity. Swap to `Image` nodes
|
||||
/// when art lands; the rest of the tile layout doesn't change.
|
||||
///
|
||||
/// Picks are constrained to **card suits** (U+2660-2666) and basic
|
||||
/// **Geometric Shapes** (U+25xx) — the two ranges the bundled
|
||||
/// FiraMono-Medium face actually covers. Earlier choices in
|
||||
/// Dingbats (★ ❀ ✦) and Misc Symbols (⌚) rendered as
|
||||
/// missing-glyph rectangles because FiraMono's coverage there is
|
||||
/// minimal.
|
||||
fn glyph(self) -> &'static str {
|
||||
match self {
|
||||
// Black club — card suit, the obvious solitaire mark.
|
||||
HomeMode::Classic => "\u{2663}",
|
||||
// Black diamond — Geometric Shapes; reads as the day's gem.
|
||||
HomeMode::Daily => "\u{25C6}",
|
||||
// White circle — Geometric Shapes; reads as the Zen enso.
|
||||
HomeMode::Zen => "\u{25CB}",
|
||||
// Black up-pointing triangle — Geometric Shapes; reads as
|
||||
// a mountain / a step up in difficulty.
|
||||
HomeMode::Challenge => "\u{25B2}",
|
||||
// Rightwards arrow — Arrows block (U+2190-21FF), a core
|
||||
// range every dev-oriented monospace font (FiraMono
|
||||
// included) ships. Reads as "go / fast-forward" for the
|
||||
// timed mode. Earlier ▶ (U+25B6) did not render; FiraMono
|
||||
// ships ▲ (up triangle) but evidently not the sideways
|
||||
// siblings.
|
||||
HomeMode::TimeAttack => "\u{2192}",
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyboard accelerator that dispatches the same launch event,
|
||||
/// shown in a small chip on the card.
|
||||
fn hotkey(self) -> &'static str {
|
||||
@@ -114,27 +181,69 @@ impl HomeMode {
|
||||
#[derive(Component, Debug)]
|
||||
struct HomeModeCard(HomeMode);
|
||||
|
||||
/// Tracks whether the launch-time Home modal has already been auto-shown
|
||||
/// for this app session. Flipped to `true` by [`spawn_home_on_launch`]
|
||||
/// the first time it spawns the modal, so the auto-show is one-shot per
|
||||
/// process — subsequent dismissals (Cancel / mode pick) don't trigger
|
||||
/// a respawn, but the player can still re-open the picker with `M`.
|
||||
///
|
||||
/// Other plugins (e.g. `game_plugin`'s restore-prompt handler) can flip
|
||||
/// the flag manually to suppress the launch auto-show when the player
|
||||
/// has already made a launch-time choice through a different surface.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct LaunchHomeShown(pub bool);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers the M-key toggle, the mode-card click handler, and the
|
||||
/// Cancel-button handler.
|
||||
pub struct HomePlugin;
|
||||
///
|
||||
/// `auto_show_on_launch` (default true) controls whether the picker
|
||||
/// auto-spawns once the splash clears at app start. Headless tests use
|
||||
/// [`HomePlugin::headless`] to opt out so each test starts with no
|
||||
/// modal in the world.
|
||||
pub struct HomePlugin {
|
||||
auto_show_on_launch: bool,
|
||||
}
|
||||
|
||||
impl Default for HomePlugin {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_show_on_launch: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HomePlugin {
|
||||
/// Test-only constructor that disables the launch-time auto-show.
|
||||
/// `MinimalPlugins` test setups don't include a splash, so the
|
||||
/// gating system would otherwise fire on the first tick and
|
||||
/// pre-spawn the modal that every test asserts is absent.
|
||||
pub fn headless() -> Self {
|
||||
Self {
|
||||
auto_show_on_launch: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for HomePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// Be defensive about message registration so HomePlugin works
|
||||
// standalone in tests (the actual handlers live in
|
||||
// input_plugin / challenge_plugin / time_attack_plugin /
|
||||
// daily_challenge_plugin, but those plugins might not be
|
||||
// installed in a tightly-scoped headless app).
|
||||
app.add_message::<NewGameRequestEvent>()
|
||||
// Pre-mark the auto-show as already done in headless mode so the
|
||||
// gating system is a permanent no-op for tests.
|
||||
app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch))
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<StartZenRequestEvent>()
|
||||
.add_message::<StartChallengeRequestEvent>()
|
||||
.add_message::<StartTimeAttackRequestEvent>()
|
||||
.add_message::<StartDailyChallengeRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ToggleProfileRequestEvent>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
// Defensively register MouseWheel so `scroll_home_panel`
|
||||
// runs cleanly under MinimalPlugins headless tests too.
|
||||
.add_message::<MouseWheel>()
|
||||
// `.chain()` because several systems (M-toggle, card click,
|
||||
// cancel button, digit-key shortcut) all read the
|
||||
// `HomeScreen` entity and may queue a despawn on it in the
|
||||
@@ -146,25 +255,92 @@ impl Plugin for HomePlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
spawn_home_on_launch,
|
||||
toggle_home_screen,
|
||||
attach_focusable_to_home_mode_cards,
|
||||
handle_home_card_click,
|
||||
handle_home_cancel_button,
|
||||
handle_home_profile_chip,
|
||||
handle_home_draw_mode_buttons,
|
||||
handle_home_digit_keys,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
)
|
||||
.add_systems(Update, scroll_home_panel);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-show on launch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Auto-spawns the Home / mode-picker modal once per app session, so
|
||||
/// the player lands on a deliberate "what mode do I want to play"
|
||||
/// screen instead of the default Classic deal.
|
||||
///
|
||||
/// Gated on the launch-time UI being clear:
|
||||
///
|
||||
/// * `SplashRoot` must be gone — the splash owns the foreground during
|
||||
/// the brand beat and the home modal appearing under it would feel
|
||||
/// like a flash of half-rendered UI.
|
||||
/// * `RestorePromptScreen` must not be open and `PendingRestoredGame`
|
||||
/// must be empty — when the player has a saved in-progress game the
|
||||
/// restore prompt takes precedence; the home picker would compete
|
||||
/// with it for attention.
|
||||
/// * `HomeScreen` must not already exist (defensive — e.g. the player
|
||||
/// pressed `M` between ticks).
|
||||
/// * `LaunchHomeShown` flips to `true` after the first spawn so this
|
||||
/// system becomes a no-op for the rest of the session. Cancelling
|
||||
/// the modal therefore goes to the underlying default deal rather
|
||||
/// than respawning the picker.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_home_on_launch(
|
||||
mut commands: Commands,
|
||||
mut shown: ResMut<LaunchHomeShown>,
|
||||
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
|
||||
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
|
||||
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
|
||||
existing: Query<(), With<HomeScreen>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if shown.0
|
||||
|| !splash.is_empty()
|
||||
|| !restore_prompts.is_empty()
|
||||
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some())
|
||||
|| !existing.is_empty()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
spawn_home_screen(
|
||||
&mut commands,
|
||||
build_home_context(
|
||||
progress.as_deref(),
|
||||
stats.as_deref(),
|
||||
settings.as_deref(),
|
||||
daily.as_deref(),
|
||||
font_res.as_deref(),
|
||||
),
|
||||
);
|
||||
shown.0 = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M-key toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn toggle_home_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
) {
|
||||
@@ -174,8 +350,54 @@ fn toggle_home_screen(
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
||||
spawn_home_screen(&mut commands, level, font_res.as_deref());
|
||||
spawn_home_screen(
|
||||
&mut commands,
|
||||
build_home_context(
|
||||
progress.as_deref(),
|
||||
stats.as_deref(),
|
||||
settings.as_deref(),
|
||||
daily.as_deref(),
|
||||
font_res.as_deref(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a [`HomeContext`] from the live resources the Home modal
|
||||
/// reads. Falls back to safe defaults when a resource is missing
|
||||
/// (typical for `MinimalPlugins` headless tests that don't install
|
||||
/// every contributor plugin).
|
||||
fn build_home_context<'a>(
|
||||
progress: Option<&ProgressResource>,
|
||||
stats: Option<&StatsResource>,
|
||||
settings: Option<&SettingsResource>,
|
||||
daily: Option<&DailyChallengeResource>,
|
||||
font_res: Option<&'a FontResource>,
|
||||
) -> HomeContext<'a> {
|
||||
let daily_today = daily.map(|d| {
|
||||
let completed_today = progress
|
||||
.and_then(|p| p.0.daily_challenge_last_completed)
|
||||
.is_some_and(|d_last| d_last == d.date);
|
||||
DailyToday {
|
||||
date_label: d.date.format("%b %-d").to_string(),
|
||||
goal: d.goal_description.clone(),
|
||||
completed_today,
|
||||
}
|
||||
});
|
||||
|
||||
HomeContext {
|
||||
level: progress.map_or(0, |p| p.0.level),
|
||||
total_xp: progress.map_or(0, |p| p.0.total_xp),
|
||||
daily_streak: progress.map_or(0, |p| p.0.daily_challenge_streak),
|
||||
lifetime_score: stats.map_or(0, |s| s.0.lifetime_score),
|
||||
classic_best: stats.map_or(0, |s| s.0.classic_best_score),
|
||||
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
||||
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
||||
daily_today,
|
||||
draw_mode: settings
|
||||
.map(|s| s.0.draw_mode.clone())
|
||||
.unwrap_or(DrawMode::DrawOne),
|
||||
font_res,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,10 +472,22 @@ fn handle_home_card_click(
|
||||
|
||||
fn handle_home_cancel_button(
|
||||
mut commands: Commands,
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
cancel_buttons: Query<&Interaction, (With<HomeCancelButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||
) {
|
||||
if !cancel_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
if screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
let click = cancel_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape));
|
||||
// Esc only closes Home when it is the *topmost* modal. With Profile
|
||||
// (or any other ModalScrim) layered on top, the topmost owns the
|
||||
// dismissal — without this gate a single Esc closed the back
|
||||
// modal (Home) and left the front modal orphaned.
|
||||
let esc_targets_home = esc && other_modal_scrims.is_empty();
|
||||
if !click && !esc_targets_home {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
@@ -261,6 +495,115 @@ fn handle_home_cancel_button(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header chip + draw-mode button handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Routes mouse-wheel events into the Home modal's scrollable body
|
||||
/// while the modal is open. No-op when no `HomeScrollable` exists in
|
||||
/// the world (modal closed). Mirrors `scroll_settings_panel` and
|
||||
/// `scroll_leaderboard_panel`.
|
||||
fn scroll_home_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<HomeScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
scroll_evr.clear();
|
||||
return;
|
||||
}
|
||||
let delta_y: f32 = scroll_evr
|
||||
.read()
|
||||
.map(|ev| match ev.unit {
|
||||
MouseScrollUnit::Line => ev.y * 50.0,
|
||||
MouseScrollUnit::Pixel => ev.y,
|
||||
})
|
||||
.sum();
|
||||
if delta_y == 0.0 {
|
||||
return;
|
||||
}
|
||||
for mut sp in scrollables.iter_mut() {
|
||||
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Click on the player-stats header chip → fire
|
||||
/// [`ToggleProfileRequestEvent`] so the Profile overlay opens on top
|
||||
/// of Home. Closing Profile (`P` / `Esc`) returns the player to the
|
||||
/// Home picker without losing their context.
|
||||
fn handle_home_profile_chip(
|
||||
chips: Query<&Interaction, (With<HomeProfileChip>, Changed<Interaction>)>,
|
||||
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||
) {
|
||||
if chips.iter().any(|i| *i == Interaction::Pressed) {
|
||||
profile.write(ToggleProfileRequestEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/// Click on a draw-mode chip — flip `Settings.draw_mode`, persist,
|
||||
/// fire `SettingsChangedEvent`, and respawn the Home modal so the
|
||||
/// active-chip styling reflects the new state. Repaint by full
|
||||
/// rebuild keeps the helper code small (no per-entity colour
|
||||
/// surgery) and the modal is light enough to respawn cleanly.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_home_draw_mode_buttons(
|
||||
mut commands: Commands,
|
||||
one_buttons: Query<&Interaction, (With<HomeDrawOneButton>, Changed<Interaction>)>,
|
||||
three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
mut settings: Option<ResMut<SettingsResource>>,
|
||||
storage_path: Option<Res<SettingsStoragePath>>,
|
||||
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
if !want_one && !want_three {
|
||||
return;
|
||||
}
|
||||
let Some(settings) = settings.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let target = if want_one {
|
||||
DrawMode::DrawOne
|
||||
} else {
|
||||
DrawMode::DrawThree
|
||||
};
|
||||
if settings.0.draw_mode == target {
|
||||
return; // already in this mode — avoid a redundant respawn.
|
||||
}
|
||||
settings.0.draw_mode = target;
|
||||
if let Some(p) = storage_path
|
||||
&& let Some(path) = p.0.as_deref()
|
||||
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||
{
|
||||
warn!("home: failed to persist draw-mode change: {e}");
|
||||
}
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
|
||||
// Repaint by despawn + respawn so the chip styling and any
|
||||
// dependent labels (none today, but Phase B may surface a
|
||||
// "Standard (Draw 1)" caption like MSSC) reflect the new state.
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
spawn_home_screen(
|
||||
&mut commands,
|
||||
build_home_context(
|
||||
progress.as_deref(),
|
||||
stats.as_deref(),
|
||||
Some(settings),
|
||||
daily.as_deref(),
|
||||
font_res.as_deref(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Digit-key shortcuts (1-5) — modal-scoped
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -357,20 +700,95 @@ fn handle_home_digit_keys(
|
||||
// Spawn helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawns the Home modal with five mode cards plus a Cancel button.
|
||||
fn spawn_home_screen(commands: &mut Commands, level: u32, font_res: Option<&FontResource>) {
|
||||
spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
|
||||
/// Bundles the data the Home modal needs to render the new
|
||||
/// MSSC-inspired header chips, per-mode score chips, and draw-mode
|
||||
/// row. Built fresh by the two call sites (`spawn_home_on_launch`
|
||||
/// and `toggle_home_screen`) from the live progress / stats /
|
||||
/// settings resources, with sensible defaults when a resource is
|
||||
/// missing under `MinimalPlugins` headless tests.
|
||||
struct HomeContext<'a> {
|
||||
level: u32,
|
||||
total_xp: u64,
|
||||
lifetime_score: u64,
|
||||
classic_best: u32,
|
||||
zen_best: u32,
|
||||
challenge_best: u32,
|
||||
daily_streak: u32,
|
||||
daily_today: Option<DailyToday>,
|
||||
draw_mode: DrawMode,
|
||||
font_res: Option<&'a FontResource>,
|
||||
}
|
||||
|
||||
/// Today's daily-challenge metadata as the Home picker needs it. Only
|
||||
/// populated when both [`DailyChallengeResource`] is present (the
|
||||
/// plugin is wired) and we have something useful to show — otherwise
|
||||
/// the Daily card falls back to its baseline description without a
|
||||
/// dated callout.
|
||||
struct DailyToday {
|
||||
/// Short calendar label, e.g. `"May 6"`. Always populated.
|
||||
date_label: String,
|
||||
/// Server-supplied goal copy ("Win in under 5 minutes"). `None`
|
||||
/// when no server backend is wired or the fetch hasn't returned.
|
||||
goal: Option<String>,
|
||||
/// `true` when the player has already recorded today's daily.
|
||||
/// Surfaces a "Done" badge so the picker reads as reward-state
|
||||
/// rather than "you still owe today's run".
|
||||
completed_today: bool,
|
||||
}
|
||||
|
||||
/// Spawns the Home modal with the player-stats header strip, draw-mode
|
||||
/// row, five mode cards, and a Cancel button.
|
||||
fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) {
|
||||
let HomeContext { font_res, .. } = ctx;
|
||||
let scrim = spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Choose a Mode", font_res);
|
||||
|
||||
for mode in [
|
||||
HomeMode::Classic,
|
||||
HomeMode::Daily,
|
||||
HomeMode::Zen,
|
||||
HomeMode::Challenge,
|
||||
HomeMode::TimeAttack,
|
||||
] {
|
||||
spawn_mode_card(card, mode, level, font_res);
|
||||
}
|
||||
// Scrollable middle — chips + draw row + tile grid. Constrained
|
||||
// to 70vh so the modal fits on small viewports (the 5-tile
|
||||
// grid alone is ~540 px). Cancel button sits outside this
|
||||
// node so it's always one click away.
|
||||
card.spawn((
|
||||
HomeScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_3,
|
||||
width: Val::Percent(100.0),
|
||||
max_height: Val::Vh(70.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
spawn_home_header_chips(body, &ctx);
|
||||
spawn_draw_mode_row(body, &ctx);
|
||||
|
||||
// Mode tiles in a wrapping 2-column grid. Each tile takes 48%
|
||||
// of the row so column_gap fits comfortably; the 5 modes wrap
|
||||
// to a third row of one tile, which we leave left-aligned —
|
||||
// the asymmetry matches MSSC's "Daily Challenges / Today's
|
||||
// Event" half-cell on the right of their grid and keeps the
|
||||
// visual rhythm.
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
row_gap: VAL_SPACE_3,
|
||||
column_gap: VAL_SPACE_3,
|
||||
width: Val::Percent(100.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|grid| {
|
||||
for mode in [
|
||||
HomeMode::Classic,
|
||||
HomeMode::Daily,
|
||||
HomeMode::Zen,
|
||||
HomeMode::Challenge,
|
||||
HomeMode::TimeAttack,
|
||||
] {
|
||||
spawn_mode_card(grid, mode, &ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
@@ -383,6 +801,190 @@ fn spawn_home_screen(commands: &mut Commands, level: u32, font_res: Option<&Font
|
||||
);
|
||||
});
|
||||
});
|
||||
// Home is read-only — opt into click-outside-to-dismiss.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
/// Player-stats chip strip — Level, XP, Lifetime Score. Clickable as a
|
||||
/// whole to open the Profile overlay (mirrors the MSSC top-right
|
||||
/// avatar+rewards corner that surfaces level + premium status). Falls
|
||||
/// back to plain Text in headless contexts where `Button` interaction
|
||||
/// isn't driven by the input pipeline anyway.
|
||||
fn spawn_home_header_chips(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
|
||||
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_label = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
let font_value = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
|
||||
parent
|
||||
.spawn((
|
||||
HomeProfileChip,
|
||||
Button,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::SpaceBetween,
|
||||
column_gap: VAL_SPACE_2,
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
width: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|row| {
|
||||
for (label, value) in [
|
||||
("Level".to_string(), format_compact(ctx.level as u64)),
|
||||
("XP".to_string(), format_compact(ctx.total_xp)),
|
||||
("Score".to_string(), format_compact(ctx.lifetime_score)),
|
||||
] {
|
||||
row.spawn(Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: VAL_SPACE_1,
|
||||
..default()
|
||||
})
|
||||
.with_children(|col| {
|
||||
col.spawn((
|
||||
Text::new(label),
|
||||
font_label.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
col.spawn((
|
||||
Text::new(value),
|
||||
font_value.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw-mode row — "Draw 1" / "Draw 3" toggle. Affects the next Classic
|
||||
/// deal (the Settings value the new-game flow reads). Surfacing it on
|
||||
/// the Home modal keeps the per-game choice one tap away rather than
|
||||
/// buried in Settings, mirroring the dropdown MSSC puts on its
|
||||
/// difficulty picker.
|
||||
fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
|
||||
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_label = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
let font_btn = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
|
||||
let active_one = matches!(ctx.draw_mode, DrawMode::DrawOne);
|
||||
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new("Draw mode"),
|
||||
font_label.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
spawn_draw_mode_chip::<HomeDrawOneButton>(
|
||||
row,
|
||||
HomeDrawOneButton,
|
||||
"Draw 1",
|
||||
active_one,
|
||||
&font_btn,
|
||||
);
|
||||
spawn_draw_mode_chip::<HomeDrawThreeButton>(
|
||||
row,
|
||||
HomeDrawThreeButton,
|
||||
"Draw 3",
|
||||
!active_one,
|
||||
&font_btn,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_draw_mode_chip<M: Component>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
marker: M,
|
||||
label: &str,
|
||||
active: bool,
|
||||
font: &TextFont,
|
||||
) {
|
||||
let (bg, fg) = if active {
|
||||
(ACCENT_PRIMARY, BG_ELEVATED)
|
||||
} else {
|
||||
(BG_ELEVATED_HI, TEXT_PRIMARY)
|
||||
};
|
||||
parent
|
||||
.spawn((
|
||||
marker,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(bg),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|c| {
|
||||
c.spawn((Text::new(label.to_string()), font.clone(), TextColor(fg)));
|
||||
});
|
||||
}
|
||||
|
||||
/// Compact decimal formatter: `1234567` → `"1.2M"`, `12345` → `"12.3K"`,
|
||||
/// otherwise the raw number with thousands separators. Keeps chip text
|
||||
/// short enough to fit a 3-up header strip without wrapping.
|
||||
fn format_compact(n: u64) -> String {
|
||||
if n >= 1_000_000 {
|
||||
format!("{:.1}M", n as f64 / 1_000_000.0)
|
||||
} else if n >= 10_000 {
|
||||
format!("{:.1}K", n as f64 / 1_000.0)
|
||||
} else if n >= 1_000 {
|
||||
let (high, low) = (n / 1_000, n % 1_000);
|
||||
format!("{high},{low:03}")
|
||||
} else {
|
||||
n.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-mode score / streak chip text. `None` for modes where no
|
||||
/// per-mode best exists yet (Time Attack uses session scoring; modes
|
||||
/// with `0` recorded mean "no win yet" and we hide the chip rather
|
||||
/// than show a 0).
|
||||
fn score_chip_text_for(mode: HomeMode, ctx: &HomeContext<'_>) -> Option<String> {
|
||||
match mode {
|
||||
HomeMode::Classic if ctx.classic_best > 0 => {
|
||||
Some(format!("Best {}", format_compact(ctx.classic_best as u64)))
|
||||
}
|
||||
HomeMode::Zen if ctx.zen_best > 0 => {
|
||||
Some(format!("Best {}", format_compact(ctx.zen_best as u64)))
|
||||
}
|
||||
HomeMode::Challenge if ctx.challenge_best > 0 => {
|
||||
Some(format!("Best {}", format_compact(ctx.challenge_best as u64)))
|
||||
}
|
||||
HomeMode::Daily if ctx.daily_streak > 0 => {
|
||||
Some(format!("Streak {}", ctx.daily_streak))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab-walk order for each mode card, matching the visual top-to-bottom
|
||||
@@ -456,9 +1058,11 @@ fn attach_focusable_to_home_mode_cards(
|
||||
fn spawn_mode_card(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
mode: HomeMode,
|
||||
level: u32,
|
||||
font_res: Option<&FontResource>,
|
||||
ctx: &HomeContext<'_>,
|
||||
) {
|
||||
let level = ctx.level;
|
||||
let font_res = ctx.font_res;
|
||||
let score_chip = score_chip_text_for(mode, ctx);
|
||||
let unlocked = mode.is_unlocked(level);
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_title = TextFont {
|
||||
@@ -472,10 +1076,17 @@ fn spawn_mode_card(
|
||||
..default()
|
||||
};
|
||||
let font_chip = TextFont {
|
||||
font: font_handle,
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
// Glyph rendered at display size — Unicode emoji standing in for
|
||||
// the per-mode artwork. Centred at the top of the tile.
|
||||
let font_glyph = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_DISPLAY,
|
||||
..default()
|
||||
};
|
||||
|
||||
// Locked cards mute their text to communicate the disabled state at
|
||||
// a glance; the explicit "Unlocks at level N" caption underneath
|
||||
@@ -483,6 +1094,7 @@ fn spawn_mode_card(
|
||||
let title_color = if unlocked { TEXT_PRIMARY } else { TEXT_DISABLED };
|
||||
let desc_color = if unlocked { TEXT_SECONDARY } else { TEXT_DISABLED };
|
||||
let border_color = if unlocked { BORDER_SUBTLE } else { BORDER_STRONG };
|
||||
let glyph_color = if unlocked { ACCENT_PRIMARY } else { TEXT_DISABLED };
|
||||
|
||||
parent
|
||||
.spawn((
|
||||
@@ -493,9 +1105,13 @@ fn spawn_mode_card(
|
||||
Button,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
row_gap: VAL_SPACE_2,
|
||||
padding: UiRect::all(VAL_SPACE_3),
|
||||
width: Val::Percent(100.0),
|
||||
// 48% per tile + the row's column_gap = a clean 2-up
|
||||
// grid that wraps to a single tile on the third row.
|
||||
width: Val::Percent(48.0),
|
||||
min_height: Val::Px(180.0),
|
||||
align_items: AlignItems::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
..default()
|
||||
@@ -504,12 +1120,20 @@ fn spawn_mode_card(
|
||||
BorderColor::all(border_color),
|
||||
))
|
||||
.with_children(|c| {
|
||||
// Centerpiece glyph — placeholder for real per-mode art.
|
||||
c.spawn((
|
||||
Text::new(mode.glyph().to_string()),
|
||||
font_glyph.clone(),
|
||||
TextColor(glyph_color),
|
||||
));
|
||||
|
||||
// Title row — title text on the left, hotkey chip on the right.
|
||||
c.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::SpaceBetween,
|
||||
column_gap: VAL_SPACE_3,
|
||||
width: Val::Percent(100.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
@@ -559,6 +1183,59 @@ fn spawn_mode_card(
|
||||
TextColor(desc_color),
|
||||
));
|
||||
|
||||
// Per-mode score / streak chip — populated only when the
|
||||
// player has data for this mode. Hidden on a 0 best so a
|
||||
// fresh profile doesn't show "Best 0" everywhere.
|
||||
if let Some(text) = score_chip.clone()
|
||||
&& unlocked
|
||||
{
|
||||
c.spawn((
|
||||
Text::new(text),
|
||||
font_chip.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
Node {
|
||||
margin: UiRect::top(VAL_SPACE_1),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// Daily-only "Today's Event" caption — date, optional
|
||||
// server goal, and a "Done" badge once the player has
|
||||
// already recorded today's completion. Only renders for
|
||||
// the Daily card when DailyChallengeResource is present.
|
||||
if matches!(mode, HomeMode::Daily)
|
||||
&& unlocked
|
||||
&& let Some(today) = ctx.daily_today.as_ref()
|
||||
{
|
||||
let date_text = if today.completed_today {
|
||||
format!("Today, {} \u{2022} Done", today.date_label)
|
||||
} else {
|
||||
format!("Today, {}", today.date_label)
|
||||
};
|
||||
let date_color = if today.completed_today {
|
||||
ACCENT_PRIMARY
|
||||
} else {
|
||||
STATE_INFO
|
||||
};
|
||||
c.spawn((
|
||||
Text::new(date_text),
|
||||
font_chip.clone(),
|
||||
TextColor(date_color),
|
||||
Node {
|
||||
margin: UiRect::top(VAL_SPACE_1),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
if let Some(goal) = today.goal.as_ref() {
|
||||
c.spawn((
|
||||
Text::new(format!("Goal: {goal}")),
|
||||
font_chip.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Locked footnote — explicit copy so the gate is unambiguous.
|
||||
if !unlocked {
|
||||
c.spawn((
|
||||
@@ -599,7 +1276,7 @@ mod tests {
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(ProgressPlugin::headless())
|
||||
.add_plugins(HomePlugin);
|
||||
.add_plugins(HomePlugin::headless());
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
@@ -889,7 +1566,7 @@ mod tests {
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(ProgressPlugin::headless())
|
||||
.add_plugins(HomePlugin);
|
||||
.add_plugins(HomePlugin::headless());
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
|
||||
@@ -62,6 +62,18 @@ pub struct HudMode;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudChallenge;
|
||||
|
||||
/// Marker on the "won this deal before" indicator text node.
|
||||
///
|
||||
/// Displays `"✓ Won before"` when the current deal's seed + draw_mode +
|
||||
/// mode triple matches one of the entries in `ReplayHistoryResource`.
|
||||
/// Empty string otherwise (including won games — the score readout
|
||||
/// already conveys the win on the active deal). Only meaningful for
|
||||
/// Classic / Zen / Challenge — daily-challenge and time-attack seeds
|
||||
/// are filtered out implicitly because their replay entries always
|
||||
/// carry a different mode tag.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudWonPreviously;
|
||||
|
||||
/// Marker on the undo-count text node.
|
||||
///
|
||||
/// Shows how many undos have been used this game. Displayed in amber when
|
||||
@@ -194,6 +206,16 @@ pub const SCORE_FLOATER_THRESHOLD: i32 = 50;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ActionButton;
|
||||
|
||||
/// Marker on rows inside a popover panel ([`ModesPopover`] or
|
||||
/// [`MenuPopover`]). Popover rows already carry `ActionButton` so the
|
||||
/// hover/press paint path applies to them, but the auto-fade applied
|
||||
/// to the top-level action bar must NOT also fade these rows — the
|
||||
/// popover only renders when the player has explicitly opened it, so
|
||||
/// its content should always be at full opacity. `apply_action_fade`
|
||||
/// excludes entities with this marker via `Without<PopoverRow>`.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct PopoverRow;
|
||||
|
||||
/// Marker on the "New Game" action button anchored top-right of the play
|
||||
/// area. Click fires [`NewGameRequestEvent`]; the existing
|
||||
/// `ConfirmNewGameScreen` modal handles confirmation when a game is in
|
||||
@@ -302,6 +324,7 @@ impl Plugin for HudPlugin {
|
||||
.init_resource::<HudActionFade>()
|
||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
||||
.add_systems(Update, update_hud.after(GameMutation))
|
||||
.add_systems(Update, update_won_previously.after(GameMutation))
|
||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||
.add_systems(Update, update_selection_hud)
|
||||
.add_systems(
|
||||
@@ -481,6 +504,15 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
font_body.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
t2.spawn((
|
||||
HudWonPreviously,
|
||||
Tooltip::new(
|
||||
"You've won this deal before. Same seed in your replay history.",
|
||||
),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
});
|
||||
|
||||
// Tier 3 — penalty / bonus. Undos and Recycles share the
|
||||
@@ -834,6 +866,7 @@ fn spawn_modes_popover(
|
||||
.spawn((
|
||||
option,
|
||||
ActionButton,
|
||||
PopoverRow,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
Node {
|
||||
@@ -987,6 +1020,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
.spawn((
|
||||
option,
|
||||
ActionButton,
|
||||
PopoverRow,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
Node {
|
||||
@@ -1117,9 +1151,20 @@ fn update_action_fade(
|
||||
/// `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.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn apply_action_fade(
|
||||
fade: Res<HudActionFade>,
|
||||
mut buttons: Query<(&Children, &mut BackgroundColor), With<ActionButton>>,
|
||||
// Excludes `PopoverRow` so the auto-fade only applies to the
|
||||
// top-level action bar buttons. Popover rows live inside an
|
||||
// explicitly-opened dropdown panel and need to stay visible
|
||||
// regardless of the bar's fade state — without the exclusion
|
||||
// the rows fade to invisible while the popover container stays
|
||||
// visible, leaving a solid background block with no readable
|
||||
// content.
|
||||
mut buttons: Query<
|
||||
(&Children, &mut BackgroundColor),
|
||||
(With<ActionButton>, Without<PopoverRow>),
|
||||
>,
|
||||
mut text_q: Query<&mut TextColor>,
|
||||
) {
|
||||
for (children, mut bg) in &mut buttons {
|
||||
@@ -1480,6 +1525,42 @@ fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
|
||||
)
|
||||
}
|
||||
|
||||
/// Sets the [`HudWonPreviously`] text to "✓ Won before" whenever the
|
||||
/// current deal's seed + draw_mode + mode triple matches an entry in
|
||||
/// the rolling [`ReplayHistory`]. Cleared while the active game is won
|
||||
/// (the on-screen "Game won!" cue already conveys victory) and on
|
||||
/// fresh deals the player hasn't won before.
|
||||
///
|
||||
/// Lives in its own system rather than `update_hud` to keep this
|
||||
/// orthogonal: `update_hud`'s query disambiguation is already busy
|
||||
/// enough; threading another marker through every Without filter
|
||||
/// would touch ~10 unrelated queries for no benefit.
|
||||
fn update_won_previously(
|
||||
game: Res<GameStateResource>,
|
||||
// Optional because the HUD plugin's headless tests run without
|
||||
// `StatsPlugin` and therefore without this resource. With the
|
||||
// resource absent there's no history to compare against; the
|
||||
// indicator just stays empty.
|
||||
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
||||
mut q: Query<&mut Text, With<HudWonPreviously>>,
|
||||
) {
|
||||
let Ok(mut text) = q.single_mut() else {
|
||||
return;
|
||||
};
|
||||
let won_before = !game.0.is_won
|
||||
&& history.as_ref().is_some_and(|h| {
|
||||
h.0.replays.iter().any(|r| {
|
||||
r.seed == game.0.seed
|
||||
&& r.draw_mode == game.0.draw_mode
|
||||
&& r.mode == game.0.mode
|
||||
})
|
||||
});
|
||||
let next = if won_before { "\u{2713} Won before" } else { "" };
|
||||
if text.0 != next {
|
||||
text.0 = next.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn update_hud(
|
||||
game: Res<GameStateResource>,
|
||||
|
||||
@@ -35,15 +35,15 @@ use crate::card_plugin::{
|
||||
CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC,
|
||||
TABLEAU_FAN_FRAC,
|
||||
};
|
||||
use crate::ui_theme::MOTION_DRAG_REJECT_SECS;
|
||||
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_WARNING};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::events::{
|
||||
DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
|
||||
MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, StartZenRequestEvent,
|
||||
StateChangedEvent, UndoRequestEvent,
|
||||
MoveRequestEvent, NewGameRequestEvent, StartZenRequestEvent, StateChangedEvent,
|
||||
UndoRequestEvent,
|
||||
};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::game_plugin::{ConfirmNewGameScreen, GameMutation, RestorePromptScreen};
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
@@ -54,21 +54,15 @@ use crate::time_attack_plugin::TimeAttackResource;
|
||||
/// Z-depth used for cards while being dragged — above all resting cards.
|
||||
const DRAG_Z: f32 = 500.0;
|
||||
|
||||
/// Shared countdown state for the new-game double-press confirmation
|
||||
/// flow.
|
||||
/// Solver budgets used by the H-key hint system.
|
||||
///
|
||||
/// Using a resource (instead of `Local`) lets the keyboard sub-systems
|
||||
/// share the same countdown state without needing to pass values
|
||||
/// between them. Forfeit no longer has a keyboard countdown — `G` now
|
||||
/// fires `ForfeitRequestEvent` and `PausePlugin` shows a real
|
||||
/// `ForfeitConfirmScreen` modal.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
struct KeyboardConfirmState {
|
||||
/// Seconds remaining in the new-game confirmation window (> 0 while open).
|
||||
new_game_countdown: f32,
|
||||
/// True while we are waiting for the second N press to confirm a new game.
|
||||
new_game_pending: bool,
|
||||
}
|
||||
/// Wraps `solitaire_core::solver::SolverConfig` as a Bevy resource so
|
||||
/// tests can inject tighter budgets to exercise the heuristic-fallback
|
||||
/// path. Production initialises this to `SolverConfig::default()` (100k
|
||||
/// move / 200k state budgets, the same numbers the new-game retry loop
|
||||
/// uses).
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct HintSolverConfig(pub solitaire_core::solver::SolverConfig);
|
||||
|
||||
/// Registers keyboard, mouse, and touch input systems.
|
||||
///
|
||||
@@ -89,8 +83,8 @@ pub struct InputPlugin;
|
||||
impl Plugin for InputPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<HintCycleIndex>()
|
||||
.init_resource::<KeyboardConfirmState>()
|
||||
.add_message::<NewGameConfirmEvent>()
|
||||
.init_resource::<HintSolverConfig>()
|
||||
.init_resource::<crate::pending_hint::PendingHintTask>()
|
||||
.add_message::<StartZenRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ForfeitRequestEvent>()
|
||||
@@ -116,13 +110,21 @@ impl Plugin for InputPlugin {
|
||||
.chain(),
|
||||
)
|
||||
.add_systems(Update, handle_fullscreen)
|
||||
.add_systems(Update, reset_hint_cycle_on_state_change);
|
||||
.add_systems(Update, reset_hint_cycle_on_state_change)
|
||||
// Async hint pipeline: state-change drop runs before the
|
||||
// poll system so a move applied this frame cancels any
|
||||
// in-flight task before its result can be surfaced.
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
crate::pending_hint::drop_pending_hint_on_state_change,
|
||||
crate::pending_hint::poll_pending_hint_task,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds after the first N press during which a second N confirms new game.
|
||||
const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
|
||||
|
||||
/// Bundles the event writers needed by the core keyboard handler.
|
||||
///
|
||||
/// Keeping these in a [`SystemParam`] avoids hitting Bevy's 16-parameter limit.
|
||||
@@ -130,43 +132,39 @@ const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
|
||||
struct CoreKeyboardMessages<'w> {
|
||||
undo: MessageWriter<'w, UndoRequestEvent>,
|
||||
new_game: MessageWriter<'w, NewGameRequestEvent>,
|
||||
confirm_event: MessageWriter<'w, NewGameConfirmEvent>,
|
||||
info_toast: MessageWriter<'w, InfoToastEvent>,
|
||||
draw: MessageWriter<'w, DrawRequestEvent>,
|
||||
}
|
||||
|
||||
/// Handles the core keyboard shortcuts: U (undo), N (new game + confirmation
|
||||
/// window), Z (zen mode), D / Space (draw), and ticks down the new-game
|
||||
/// confirmation countdown each frame.
|
||||
/// Handles the core keyboard shortcuts: U (undo), N (new game), Z (zen mode),
|
||||
/// D / Space (draw).
|
||||
///
|
||||
/// `N` fires `NewGameRequestEvent` straight through; the existing
|
||||
/// `handle_new_game` flow shows the `ConfirmNewGameScreen` modal when
|
||||
/// the current game is in progress, so a single press surfaces a real
|
||||
/// Confirm / Cancel UI instead of a "press N again" toast. `Shift+N`
|
||||
/// keeps the keyboard power-user bypass by setting `confirmed: true`.
|
||||
///
|
||||
/// While the confirm modal or the restore prompt is already open, the
|
||||
/// system skips the N branch so those modals' own input handlers can
|
||||
/// process N (cancel / start-new-game) without us re-firing a request
|
||||
/// the same frame.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_keyboard_core(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
time: Res<Time>,
|
||||
mut confirm: ResMut<KeyboardConfirmState>,
|
||||
mut ev: CoreKeyboardMessages<'_>,
|
||||
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
||||
selection: Option<Res<SelectionState>>,
|
||||
mut zen_requests: MessageReader<StartZenRequestEvent>,
|
||||
confirm_screens: Query<(), With<ConfirmNewGameScreen>>,
|
||||
restore_prompts: Query<(), With<RestorePromptScreen>>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tick down the new-game confirmation window each frame.
|
||||
if confirm.new_game_countdown > 0.0 {
|
||||
confirm.new_game_countdown -= time.delta_secs();
|
||||
if confirm.new_game_countdown <= 0.0 {
|
||||
confirm.new_game_countdown = 0.0;
|
||||
if confirm.new_game_pending {
|
||||
confirm.new_game_pending = false;
|
||||
ev.info_toast.write(InfoToastEvent("New game cancelled".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if keys.just_pressed(KeyCode::KeyU) {
|
||||
ev.undo.write(UndoRequestEvent);
|
||||
}
|
||||
@@ -183,27 +181,24 @@ fn handle_keyboard_core(
|
||||
mode: Some(solitaire_core::game_state::GameMode::Classic),
|
||||
confirmed: false,
|
||||
});
|
||||
confirm.new_game_countdown = 0.0;
|
||||
return;
|
||||
}
|
||||
|
||||
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
|
||||
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||
if shift_held || !active_game {
|
||||
// Shift+N or no active game — start immediately, no confirmation.
|
||||
ev.new_game.write(NewGameRequestEvent::default());
|
||||
confirm.new_game_countdown = 0.0;
|
||||
confirm.new_game_pending = false;
|
||||
} else if confirm.new_game_countdown > 0.0 {
|
||||
// Second press within the window — confirmed.
|
||||
ev.new_game.write(NewGameRequestEvent::default());
|
||||
confirm.new_game_countdown = 0.0;
|
||||
confirm.new_game_pending = false;
|
||||
// The confirm modal and restore prompt own N while they're up —
|
||||
// they cancel / accept respectively. Skipping here prevents us
|
||||
// from firing a fresh request the same frame those modals close.
|
||||
if !confirm_screens.is_empty() || !restore_prompts.is_empty() {
|
||||
// intentional: defer to those modals' input handlers.
|
||||
} else {
|
||||
// First press on an active game — require confirmation.
|
||||
confirm.new_game_countdown = NEW_GAME_CONFIRM_WINDOW;
|
||||
confirm.new_game_pending = true;
|
||||
ev.confirm_event.write(NewGameConfirmEvent);
|
||||
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||
ev.new_game.write(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: None,
|
||||
// Shift+N skips the confirm modal for keyboard power-users;
|
||||
// bare N falls through `handle_new_game`'s active-game check
|
||||
// and shows the modal when a game is in progress.
|
||||
confirmed: shift_held,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,22 +231,29 @@ fn handle_keyboard_core(
|
||||
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
|
||||
}
|
||||
|
||||
/// Handles the H key: cycles through all available hints, highlighting the
|
||||
/// source card yellow for 2 s and showing a descriptive toast.
|
||||
/// Handles the H key: spawn an async solver task on
|
||||
/// `AsyncComputeTaskPool` whose result `pending_hint::poll_pending_hint_task`
|
||||
/// turns into hint visuals one frame later.
|
||||
///
|
||||
/// The hint index wraps around once all hints have been cycled through. When no
|
||||
/// moves are available a "No hints available" toast is shown instead.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Median solve time is ~2 ms but pathological positions can hit the
|
||||
/// `SolverConfig::default()` cap at ~120 ms; running synchronously
|
||||
/// (the v0.17.0 behaviour) blocked the main thread on the same frame
|
||||
/// the player pressed H. Cancel-on-replace lives in
|
||||
/// `PendingHintTask::spawn` — a fresh H press while a previous task
|
||||
/// is in flight drops the previous task's handle.
|
||||
///
|
||||
/// Special-cases: when the game is already won, surface a "Game won!"
|
||||
/// toast instead of asking the solver. The poll system handles the
|
||||
/// "no legal moves" toast on the heuristic fallback path so the
|
||||
/// handler here only needs to dispatch.
|
||||
fn handle_keyboard_hint(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||
mut commands: Commands,
|
||||
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
solver_config: Res<HintSolverConfig>,
|
||||
mut pending_hint: ResMut<crate::pending_hint::PendingHintTask>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
mut hint_visual: MessageWriter<HintVisualEvent>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
@@ -269,23 +271,51 @@ fn handle_keyboard_hint(
|
||||
|
||||
let Some(_layout_res) = layout else { return };
|
||||
|
||||
let hints = all_hints(&g.0);
|
||||
if hints.is_empty() {
|
||||
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
||||
return;
|
||||
}
|
||||
pending_hint.spawn(g.0.clone(), solver_config.0);
|
||||
}
|
||||
|
||||
// Pick the hint at the current cycle index (wrapping) and advance.
|
||||
/// Heuristic hint helper used by `pending_hint::poll_pending_hint_task`
|
||||
/// when the solver returns `Inconclusive` or `Unwinnable`.
|
||||
///
|
||||
/// Picks the hint at `HintCycleIndex % hints.len()` (wrapping) and
|
||||
/// advances the index so successive H presses on a stuck position
|
||||
/// cycle through every legal move. Returns `None` when no legal move
|
||||
/// exists at all — the caller surfaces a "No hints available" toast.
|
||||
pub fn find_heuristic_hint(
|
||||
game: &GameState,
|
||||
hint_cycle: &mut HintCycleIndex,
|
||||
) -> Option<(PileType, PileType)> {
|
||||
let hints = all_hints(game);
|
||||
if hints.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let idx = hint_cycle.0 % hints.len();
|
||||
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
|
||||
let (from, to, _count) = &hints[idx];
|
||||
let (from, to, _count) = hints[idx].clone();
|
||||
Some((from, to))
|
||||
}
|
||||
|
||||
/// Apply the visual + toast effects for a single chosen hint move.
|
||||
///
|
||||
/// Shared between the solver-driven and heuristic-driven hint paths so
|
||||
/// both produce identical player-facing feedback. Called from
|
||||
/// `pending_hint::poll_pending_hint_task` once the async solver task
|
||||
/// resolves.
|
||||
pub fn emit_hint_visuals(
|
||||
game: &GameState,
|
||||
from: &PileType,
|
||||
to: &PileType,
|
||||
commands: &mut Commands,
|
||||
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
info_toast: &mut MessageWriter<InfoToastEvent>,
|
||||
hint_visual: &mut MessageWriter<HintVisualEvent>,
|
||||
) {
|
||||
// When the hint points at the stock (draw suggestion) there is no
|
||||
// face-up card to highlight — show a toast instead.
|
||||
// If the stock is empty, pressing D will recycle the waste rather
|
||||
// than draw a card, so the toast text must reflect that.
|
||||
if *from == PileType::Stock {
|
||||
let stock_empty = g.0.piles
|
||||
let stock_empty = game.piles
|
||||
.get(&PileType::Stock)
|
||||
.is_some_and(|p| p.cards.is_empty());
|
||||
let msg = if stock_empty {
|
||||
@@ -298,15 +328,18 @@ fn handle_keyboard_hint(
|
||||
}
|
||||
|
||||
// Find the top face-up card in the source pile and highlight it.
|
||||
let top_card_id = g.0.piles.get(from)
|
||||
let top_card_id = game.piles.get(from)
|
||||
.and_then(|p| p.cards.last().filter(|c| c.face_up))
|
||||
.map(|c| c.id);
|
||||
if let Some(card_id) = top_card_id {
|
||||
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
|
||||
if card_entity.card_id == card_id {
|
||||
// Tint the card gold without replacing the Sprite (which would
|
||||
// discard the image handle set by CardImageSet).
|
||||
sprite.color = Color::srgba(1.0, 1.0, 0.4, 1.0);
|
||||
// discard the image handle set by CardImageSet). Uses the
|
||||
// design-system `STATE_WARNING` token so the source-card
|
||||
// tint matches the destination pile highlight, both of
|
||||
// which signal "look here" for the hint.
|
||||
sprite.color = STATE_WARNING;
|
||||
commands.entity(entity)
|
||||
.insert(HintHighlight { remaining: 2.0 })
|
||||
.insert(HintHighlightTimer(2.0));
|
||||
@@ -327,7 +360,7 @@ fn handle_keyboard_hint(
|
||||
// player keeps thinking in suit terms; otherwise fall back to "foundation".
|
||||
let msg = match to {
|
||||
PileType::Foundation(_) => {
|
||||
let claimed = g.0.piles.get(to).and_then(|p| p.claimed_suit());
|
||||
let claimed = game.piles.get(to).and_then(|p| p.claimed_suit());
|
||||
if let Some(suit) = claimed {
|
||||
let suit_name = match suit {
|
||||
Suit::Clubs => "Clubs",
|
||||
@@ -614,10 +647,23 @@ fn end_drag(
|
||||
}
|
||||
|
||||
// If the drag was never committed (user tapped without moving far enough),
|
||||
// treat it as a click: just cancel the pending drag and resync card positions.
|
||||
// treat it as a click: cancel the pending drag and exit. We deliberately
|
||||
// do NOT fire `StateChangedEvent` here — `start_drag` only mutates the
|
||||
// `DragState` resource on press, never card transforms, so an uncommitted
|
||||
// drag has no visual side effect to undo.
|
||||
//
|
||||
// Firing one would race a CardAnim that's already in flight on the same
|
||||
// card. Specifically: on a successful double-click, `handle_double_click`
|
||||
// fires `MoveRequestEvent`, `start_drag` picks the card up the same
|
||||
// frame (uncommitted), and `handle_move` queues a `StateChangedEvent` →
|
||||
// `sync_cards_on_change` starts a slide animation. When the player
|
||||
// releases the button mid-slide, `end_drag` would fire a second
|
||||
// `StateChangedEvent`, `sync_cards_on_change` would see the card mid-
|
||||
// animation (`cur != target`), and replace the in-flight CardAnim with
|
||||
// a fresh one — restarting the slide and reading on screen as the move
|
||||
// animation playing twice.
|
||||
if !drag.committed {
|
||||
drag.clear();
|
||||
changed.write(StateChangedEvent);
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else {
|
||||
@@ -1280,32 +1326,37 @@ fn handle_double_click(
|
||||
// Priority 2: if the player clicked the base of a multi-card face-up
|
||||
// stack (card_ids.len() > 1), try moving the whole stack to another
|
||||
// tableau column.
|
||||
if card_ids.len() > 1 {
|
||||
let Some(bottom_card) = game.0.piles.get(&pile)
|
||||
.and_then(|p| p.cards.get(stack_index)) else { return };
|
||||
if let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||
if card_ids.len() > 1
|
||||
&& let Some(bottom_card) = game.0.piles.get(&pile)
|
||||
.and_then(|p| p.cards.get(stack_index))
|
||||
&& let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||
bottom_card,
|
||||
&pile,
|
||||
&game.0,
|
||||
card_ids.len(),
|
||||
) {
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile,
|
||||
to: dest,
|
||||
count,
|
||||
});
|
||||
} else {
|
||||
// No legal destination for the stack — play the invalid-move
|
||||
// sound and shake the source pile cards as feedback.
|
||||
// `MoveRejectedEvent` with `from == to` routes the shake to
|
||||
// the source pile (which `start_shake_anim` reads from `ev.to`).
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: pile.clone(),
|
||||
to: pile,
|
||||
count: card_ids.len(),
|
||||
});
|
||||
}
|
||||
)
|
||||
{
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile,
|
||||
to: dest,
|
||||
count,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Both priorities failed — play the invalid-move sound and shake
|
||||
// the source pile as feedback. `MoveRejectedEvent` with
|
||||
// `from == to` routes the shake to the source pile (which
|
||||
// `start_shake_anim` reads from `ev.to`). Pre-fix, this branch
|
||||
// only fired for multi-card stacks, so a double-click on a
|
||||
// single card with no legal destination did nothing — no
|
||||
// sound, no shake. Now both single-card and stack misses get
|
||||
// the same feedback.
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: pile.clone(),
|
||||
to: pile,
|
||||
count: card_ids.len(),
|
||||
});
|
||||
} else {
|
||||
// Single click — record the time.
|
||||
last_click.insert(top_card_id, now);
|
||||
@@ -1960,15 +2011,6 @@ mod tests {
|
||||
assert!(hints.is_empty(), "no hint should exist when the game is truly stuck");
|
||||
}
|
||||
|
||||
/// Const-assert that `NEW_GAME_CONFIRM_WINDOW` is positive so the
|
||||
/// confirmation countdown actually opens on the first N press.
|
||||
///
|
||||
/// Mirrors the existing `forfeit_confirm_window_is_positive` test.
|
||||
#[test]
|
||||
fn new_game_confirm_window_is_positive() {
|
||||
const { assert!(NEW_GAME_CONFIRM_WINDOW > 0.0, "NEW_GAME_CONFIRM_WINDOW must be > 0"); }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||
// `ShakeAnim` on the dragged cards. The audio cue
|
||||
@@ -2125,5 +2167,53 @@ mod tests {
|
||||
anim.end_z
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hint system — async port (v0.18.0+)
|
||||
//
|
||||
// `handle_keyboard_hint` no longer runs the solver inline; it
|
||||
// spawns an `AsyncComputeTaskPool` task whose result the polling
|
||||
// system in `pending_hint` turns into hint visuals one frame
|
||||
// later. The behaviour contract this section pins is "pressing H
|
||||
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
|
||||
// covered end-to-end in `pending_hint::tests`.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pressing H on a non-paused, non-won game with a live
|
||||
/// `GameStateResource` + `LayoutResource` must populate
|
||||
/// `PendingHintTask`. The polling system, exercised in
|
||||
/// `pending_hint::tests`, drives the result to a visual event.
|
||||
#[test]
|
||||
fn pressing_h_spawns_pending_hint_task() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_message::<InfoToastEvent>();
|
||||
app.add_message::<HintVisualEvent>();
|
||||
app.init_resource::<HintCycleIndex>();
|
||||
app.init_resource::<HintSolverConfig>();
|
||||
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.insert_resource(crate::layout::LayoutResource(
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0)),
|
||||
));
|
||||
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
|
||||
app.add_systems(Update, handle_keyboard_hint);
|
||||
|
||||
// Simulate the H key being pressed this frame.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyH);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyH);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<crate::pending_hint::PendingHintTask>()
|
||||
.is_pending(),
|
||||
"pressing H must spawn an async hint task",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
|
||||
//! the panel shows "Not available" immediately.
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use solitaire_data::settings::SyncBackend;
|
||||
@@ -20,10 +21,11 @@ use crate::settings_plugin::SettingsResource;
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -66,6 +68,18 @@ struct LeaderboardFetchTask(Option<Task<Result<Vec<LeaderboardEntry>, String>>>)
|
||||
#[derive(Component, Debug)]
|
||||
pub struct LeaderboardScreen;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Leaderboard modal.
|
||||
///
|
||||
/// The leaderboard caps at the top 10 entries today, but rendering the
|
||||
/// caption + opt-in/opt-out row + 10 data rows on the 800x600 minimum
|
||||
/// window is right at the edge of overflowing — long display names or
|
||||
/// future row-count expansion would cut off entries below the fold.
|
||||
/// Wrapping the data section in an `Overflow::scroll_y()` Node with a
|
||||
/// constrained `max_height` keeps every row reachable. Mirrors the
|
||||
/// `SettingsPanelScrollable` pattern.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct LeaderboardScrollable;
|
||||
|
||||
/// Marker on the "Opt In" button inside the leaderboard panel.
|
||||
#[derive(Component, Debug)]
|
||||
struct LeaderboardOptInButton;
|
||||
@@ -98,6 +112,11 @@ impl Plugin for LeaderboardPlugin {
|
||||
.init_resource::<OptInTask>()
|
||||
.init_resource::<OptOutTask>()
|
||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the
|
||||
// leaderboard-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -112,7 +131,8 @@ impl Plugin for LeaderboardPlugin {
|
||||
poll_opt_out_task,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
)
|
||||
.add_systems(Update, scroll_leaderboard_panel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +242,33 @@ fn update_leaderboard_panel(
|
||||
}
|
||||
|
||||
/// Click handler for the modal's "Done" button — despawns the overlay.
|
||||
/// Routes mouse-wheel events into the Leaderboard modal's scrollable
|
||||
/// data body while the panel is open. No-op when no
|
||||
/// `LeaderboardScrollable` exists in the world (modal closed). Mirrors
|
||||
/// `scroll_settings_panel`.
|
||||
fn scroll_leaderboard_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<LeaderboardScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
scroll_evr.clear();
|
||||
return;
|
||||
}
|
||||
let delta_y: f32 = scroll_evr
|
||||
.read()
|
||||
.map(|ev| match ev.unit {
|
||||
MouseScrollUnit::Line => ev.y * 50.0,
|
||||
MouseScrollUnit::Pixel => ev.y,
|
||||
})
|
||||
.sum();
|
||||
if delta_y == 0.0 {
|
||||
return;
|
||||
}
|
||||
for mut sp in scrollables.iter_mut() {
|
||||
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_leaderboard_close_button(
|
||||
mut commands: Commands,
|
||||
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
|
||||
@@ -346,7 +393,7 @@ fn spawn_leaderboard_screen(
|
||||
remote_available: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
|
||||
let scrim = spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Leaderboard", font_res);
|
||||
|
||||
// Subhead — what the screen does + what the buttons control.
|
||||
@@ -420,76 +467,99 @@ fn spawn_leaderboard_screen(
|
||||
BackgroundColor(BORDER_SUBTLE),
|
||||
));
|
||||
|
||||
match data {
|
||||
LeaderboardResource::Idle => {
|
||||
card.spawn((
|
||||
Text::new("Fetching\u{2026}"),
|
||||
font_status.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Error(_) => {
|
||||
card.spawn((
|
||||
Text::new("Couldn't reach the leaderboard. Try again later."),
|
||||
font_status.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
||||
card.spawn((
|
||||
Text::new("No entries yet \u{2014} sync and opt in to appear here."),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) => {
|
||||
// Column headers
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_4,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
header_cell(row, "#", 30.0, &font_header);
|
||||
header_cell(row, "Player", 160.0, &font_header);
|
||||
header_cell(row, "Best Score", 100.0, &font_header);
|
||||
header_cell(row, "Fastest Win", 110.0, &font_header);
|
||||
});
|
||||
|
||||
let mut sorted = rows.to_vec();
|
||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0)));
|
||||
|
||||
for (i, entry) in sorted.iter().take(10).enumerate() {
|
||||
// Top three get accent treatments to highlight the
|
||||
// podium without leaning on hand-picked metallic
|
||||
// colours that sit outside the token system.
|
||||
let rank_color = match i {
|
||||
0 => ACCENT_PRIMARY, // Balatro yellow for #1
|
||||
1 | 2 => TEXT_PRIMARY,
|
||||
_ => TEXT_SECONDARY,
|
||||
};
|
||||
|
||||
let time_str = entry
|
||||
.best_time_secs
|
||||
.map_or_else(|| "-".to_string(), format_secs);
|
||||
let score_str = entry
|
||||
.best_score
|
||||
.map_or_else(|| "-".to_string(), |s| s.to_string());
|
||||
|
||||
card.spawn(Node {
|
||||
// Scrollable data section — caps at top 10 rows today, but on the
|
||||
// 800x600 minimum window the header + caption + opt-in row + 10
|
||||
// entries crowds the modal. Wrapping in `Overflow::scroll_y()`
|
||||
// with a `max_height` keeps every entry reachable and survives
|
||||
// any future expansion of the row cap.
|
||||
card.spawn((
|
||||
LeaderboardScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
max_height: Val::Vh(50.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
match data {
|
||||
LeaderboardResource::Idle => {
|
||||
body.spawn((
|
||||
Text::new("Fetching\u{2026}"),
|
||||
font_status.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Error(_) => {
|
||||
body.spawn((
|
||||
Text::new("Couldn't reach the leaderboard. Try again later."),
|
||||
font_status.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
||||
body.spawn((
|
||||
Text::new("Be the first on the leaderboard."),
|
||||
font_status.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
body.spawn((
|
||||
Text::new("Win a game and opt in to appear here."),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) => {
|
||||
// Column headers
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_4,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row);
|
||||
data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row);
|
||||
data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row);
|
||||
data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row);
|
||||
header_cell(row, "#", 30.0, &font_header);
|
||||
header_cell(row, "Player", 160.0, &font_header);
|
||||
header_cell(row, "Best Score", 100.0, &font_header);
|
||||
header_cell(row, "Fastest Win", 110.0, &font_header);
|
||||
});
|
||||
|
||||
let mut sorted = rows.to_vec();
|
||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0)));
|
||||
|
||||
for (i, entry) in sorted.iter().take(10).enumerate() {
|
||||
// Top three get accent treatments to highlight the
|
||||
// podium without leaning on hand-picked metallic
|
||||
// colours that sit outside the token system.
|
||||
let rank_color = match i {
|
||||
0 => ACCENT_PRIMARY, // Balatro yellow for #1
|
||||
1 | 2 => TEXT_PRIMARY,
|
||||
_ => TEXT_SECONDARY,
|
||||
};
|
||||
|
||||
let time_str = entry
|
||||
.best_time_secs
|
||||
.map_or_else(|| "-".to_string(), format_secs);
|
||||
let score_str = entry
|
||||
.best_score
|
||||
.map_or_else(|| "-".to_string(), |s| s.to_string());
|
||||
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_4,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row);
|
||||
data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row);
|
||||
data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row);
|
||||
data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
@@ -502,6 +572,8 @@ fn spawn_leaderboard_screen(
|
||||
);
|
||||
});
|
||||
});
|
||||
// Leaderboard is read-only — opt into click-outside-to-dismiss.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, font: &TextFont) {
|
||||
@@ -646,6 +718,34 @@ mod tests {
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaderboard_modal_body_is_scrollable() {
|
||||
let mut app = headless_app();
|
||||
press(&mut app, KeyCode::KeyL);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&LeaderboardScrollable>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Leaderboard modal must spawn exactly one LeaderboardScrollable body"
|
||||
);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<LeaderboardScrollable>>();
|
||||
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
||||
assert_ne!(
|
||||
nodes[0].max_height,
|
||||
Val::Auto,
|
||||
"scrollable body must set a non-default max_height"
|
||||
);
|
||||
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_l_twice_dismisses_screen() {
|
||||
let mut app = headless_app();
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod feedback_anim_plugin;
|
||||
pub mod challenge_plugin;
|
||||
pub mod cursor_plugin;
|
||||
pub mod daily_challenge_plugin;
|
||||
pub mod diagnostics_hud;
|
||||
pub mod events;
|
||||
pub mod game_plugin;
|
||||
pub mod help_plugin;
|
||||
@@ -22,8 +23,11 @@ pub mod input_plugin;
|
||||
pub mod layout;
|
||||
pub mod onboarding_plugin;
|
||||
pub mod pause_plugin;
|
||||
pub mod pending_hint;
|
||||
pub mod profile_plugin;
|
||||
pub mod radial_menu;
|
||||
pub mod replay_overlay;
|
||||
pub mod replay_playback;
|
||||
pub mod settings_plugin;
|
||||
pub mod progress_plugin;
|
||||
pub mod resources;
|
||||
@@ -82,11 +86,12 @@ pub use card_plugin::{
|
||||
};
|
||||
pub use font_plugin::{FontPlugin, FontResource};
|
||||
pub use cursor_plugin::CursorPlugin;
|
||||
pub use diagnostics_hud::DiagnosticsHudPlugin;
|
||||
pub use events::{
|
||||
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||
@@ -112,6 +117,14 @@ pub use radial_menu::{
|
||||
legal_destinations_for_card, radial_anchor_for_index, radial_hovered_index, RadialIcon,
|
||||
RadialMenuPlugin, RightClickRadialState, Z_RADIAL_MENU,
|
||||
};
|
||||
pub use replay_overlay::{
|
||||
ReplayOverlayBannerText, ReplayOverlayPlugin, ReplayOverlayProgressText, ReplayOverlayRoot,
|
||||
ReplayStopButton, Z_REPLAY_OVERLAY,
|
||||
};
|
||||
pub use replay_playback::{
|
||||
start_replay_playback, stop_replay_playback, ReplayPlaybackPlugin, ReplayPlaybackState,
|
||||
REPLAY_COMPLETION_LINGER_SECS, REPLAY_MOVE_INTERVAL_SECS,
|
||||
};
|
||||
pub use settings_plugin::{
|
||||
PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen,
|
||||
SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
||||
@@ -123,7 +136,8 @@ pub use selection_plugin::{
|
||||
};
|
||||
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
||||
pub use stats_plugin::{
|
||||
format_replay_caption, LatestReplayPath, LatestReplayResource, StatsPlugin, StatsResource,
|
||||
format_replay_caption, LatestReplayPath, ReplayHistoryResource, ReplayNextButton,
|
||||
ReplayPrevButton, ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource,
|
||||
StatsScreen, StatsUpdate, WatchReplayButton,
|
||||
};
|
||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||
|
||||
@@ -36,8 +36,9 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsSto
|
||||
use crate::stats_plugin::StatsResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
spawn_modal_header, ButtonVariant,
|
||||
spawn_modal_header, ButtonVariant, ModalScrim,
|
||||
};
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use crate::ui_theme::{
|
||||
self, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_3,
|
||||
};
|
||||
@@ -126,15 +127,24 @@ impl Plugin for PausePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundles the modal-related queries `toggle_pause` reads each tick.
|
||||
/// Pulled into a [`SystemParam`] so the system stays under Bevy's 16-
|
||||
/// parameter cap after the cross-modal Esc guard query was added.
|
||||
#[derive(SystemParam)]
|
||||
struct PauseModalQueries<'w, 's> {
|
||||
pause_screens: Query<'w, 's, Entity, With<PauseScreen>>,
|
||||
forfeit_screens: Query<'w, 's, Entity, With<ForfeitConfirmScreen>>,
|
||||
game_over_screens: Query<'w, 's, Entity, With<GameOverScreen>>,
|
||||
other_modal_scrims: Query<'w, 's, Entity, (With<ModalScrim>, Without<PauseScreen>)>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn toggle_pause(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<PauseRequestEvent>,
|
||||
mut paused: ResMut<PausedResource>,
|
||||
screens: Query<Entity, With<PauseScreen>>,
|
||||
forfeit_screens: Query<Entity, With<ForfeitConfirmScreen>>,
|
||||
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
||||
modal_queries: PauseModalQueries<'_, '_>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
@@ -145,6 +155,13 @@ fn toggle_pause(
|
||||
mut changed: MessageWriter<StateChangedEvent>,
|
||||
selection: Option<Res<SelectionState>>,
|
||||
) {
|
||||
let PauseModalQueries {
|
||||
pause_screens: screens,
|
||||
forfeit_screens,
|
||||
game_over_screens,
|
||||
other_modal_scrims,
|
||||
} = modal_queries;
|
||||
|
||||
// Either Esc or a click on the HUD "Pause" button (which fires
|
||||
// PauseRequestEvent) opens or closes the overlay. Drain the queue so a
|
||||
// burst of clicks doesn't queue future toggles.
|
||||
@@ -157,6 +174,16 @@ fn toggle_pause(
|
||||
if !forfeit_screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Any other modal (Confirm New Game, Restore, Home, Onboarding,
|
||||
// Settings, etc.) owns its own dismissal — pause must not stack
|
||||
// on top of it. Without this guard a single Esc both closes the
|
||||
// open modal AND spawns the pause overlay underneath, leaving the
|
||||
// player on a screen they didn't ask for. The HUD-button path
|
||||
// (`button_clicked`) is gated too; clicking Pause while another
|
||||
// modal is up is almost always an accident.
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
// If a card is currently selected, let SelectionPlugin handle this Escape
|
||||
// (it will clear the selection). Pause must not also open in the same frame.
|
||||
if selection.is_some_and(|s| s.selected_pile.is_some()) {
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Async H-key hint solver, modelled on `PendingNewGameSeed` in
|
||||
//! `game_plugin`.
|
||||
//!
|
||||
//! The synchronous version (v0.17.0) called
|
||||
//! `solitaire_core::solver::try_solve_from_state` on the main thread on
|
||||
//! every H press. Median latency was ~2 ms but pathological positions
|
||||
//! can hit the `SolverConfig::default()` cap at ~120 ms, which is a
|
||||
//! noticeable input-stall on the same frame the player sees the hint
|
||||
//! request.
|
||||
//!
|
||||
//! This module hosts the resource and polling system that move the
|
||||
//! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint`
|
||||
//! (input_plugin) becomes a thin spawn point: snapshot the state,
|
||||
//! spawn the task, store the handle. The polling system takes the
|
||||
//! result one frame later and surfaces the hint visuals via the
|
||||
//! shared `emit_hint_visuals` helper.
|
||||
//!
|
||||
//! Cancel-on-replace: a fresh H press while a previous task is in
|
||||
//! flight drops the previous task. Bevy's `Task` `Drop` cancels
|
||||
//! cooperatively at the next await point.
|
||||
//!
|
||||
//! Stale-state drop: any `StateChangedEvent` (move applied, undo,
|
||||
//! new game) drops the in-flight task — the position the solver was
|
||||
//! reasoning about no longer exists, and surfacing a hint for the
|
||||
//! old state would be confusing.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::solver::{try_solve_from_state, SolverConfig, SolverResult};
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
|
||||
use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint};
|
||||
use crate::resources::{GameStateResource, HintCycleIndex};
|
||||
|
||||
/// In-flight async work for the H-key hint.
|
||||
///
|
||||
/// `handle_keyboard_hint` writes here when the player presses H;
|
||||
/// `poll_pending_hint_task` reads from here, polls the task, and
|
||||
/// emits the hint visuals once the task completes. At most one task
|
||||
/// is ever in flight: a fresh H press while a previous task is
|
||||
/// running drops the previous task and queues the new one.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PendingHintTask {
|
||||
/// `Some` while the solver is still working on a verdict.
|
||||
inner: Option<HintTask>,
|
||||
}
|
||||
|
||||
impl PendingHintTask {
|
||||
/// Whether a hint task is currently in flight.
|
||||
pub fn is_pending(&self) -> bool {
|
||||
self.inner.is_some()
|
||||
}
|
||||
|
||||
/// Drop any in-flight task. Bevy's `Task` `Drop` cancels the
|
||||
/// underlying future cooperatively at the next await point.
|
||||
pub fn cancel(&mut self) {
|
||||
self.inner = None;
|
||||
}
|
||||
|
||||
/// Spawn a new solver task for `state` with `config`. Drops any
|
||||
/// previously in-flight task first (cancel-on-replace).
|
||||
pub fn spawn(&mut self, state: GameState, config: SolverConfig) {
|
||||
let move_count_at_spawn = state.move_count;
|
||||
let handle = AsyncComputeTaskPool::get().spawn(async move {
|
||||
let outcome = try_solve_from_state(&state, &config);
|
||||
match outcome.result {
|
||||
SolverResult::Winnable => outcome
|
||||
.first_move
|
||||
.map(|mv| HintTaskOutput::SolverMove {
|
||||
from: mv.source,
|
||||
to: mv.dest,
|
||||
})
|
||||
.unwrap_or(HintTaskOutput::NeedsHeuristic),
|
||||
SolverResult::Unwinnable | SolverResult::Inconclusive => {
|
||||
HintTaskOutput::NeedsHeuristic
|
||||
}
|
||||
}
|
||||
});
|
||||
self.inner = Some(HintTask {
|
||||
handle,
|
||||
move_count_at_spawn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One in-flight hint search plus the snapshot data needed to detect
|
||||
/// a stale result if the live state moved while the solver ran.
|
||||
struct HintTask {
|
||||
handle: Task<HintTaskOutput>,
|
||||
/// `GameState.move_count` at spawn time. The poll system discards
|
||||
/// the result if the live move_count has advanced — the player
|
||||
/// applied a move while the solver ran, so the hint would be
|
||||
/// stale even if the StateChangedEvent drop didn't fire first.
|
||||
move_count_at_spawn: u32,
|
||||
}
|
||||
|
||||
/// What the solver task carries back to the main thread.
|
||||
enum HintTaskOutput {
|
||||
/// Solver verdict was `Winnable`; here is the first move on the
|
||||
/// solution path.
|
||||
SolverMove {
|
||||
from: PileType,
|
||||
to: PileType,
|
||||
},
|
||||
/// Solver was `Unwinnable` or `Inconclusive`. The poll system
|
||||
/// runs the legacy heuristic against the live `GameState` so the
|
||||
/// H key always produces feedback while any legal move exists.
|
||||
NeedsHeuristic,
|
||||
}
|
||||
|
||||
/// Drop the in-flight hint task whenever the live `GameState` shifts.
|
||||
///
|
||||
/// The position the solver was reasoning about no longer matches the
|
||||
/// live state, so its result would be stale. Mirrors the semantics
|
||||
/// of `reset_hint_cycle_on_state_change` for `HintCycleIndex`.
|
||||
pub fn drop_pending_hint_on_state_change(
|
||||
mut state_events: MessageReader<StateChangedEvent>,
|
||||
mut pending: ResMut<PendingHintTask>,
|
||||
) {
|
||||
if state_events.read().next().is_some() {
|
||||
pending.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the in-flight hint solver task. When the task resolves, run
|
||||
/// `emit_hint_visuals` on the result — either the solver's
|
||||
/// provably-best first move (Winnable verdict) or a heuristic hint
|
||||
/// over the live state (Unwinnable / Inconclusive).
|
||||
///
|
||||
/// Discards the result when `GameState.move_count` has moved past the
|
||||
/// snapshot taken at spawn time — the player applied a move during
|
||||
/// the solve and `drop_pending_hint_on_state_change` should have
|
||||
/// already cleared the resource, but we double-check here for the
|
||||
/// rare case where the solver task completed in the same frame the
|
||||
/// move was applied.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn poll_pending_hint_task(
|
||||
mut pending: ResMut<PendingHintTask>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||
mut commands: Commands,
|
||||
card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
mut hint_visual: MessageWriter<HintVisualEvent>,
|
||||
) {
|
||||
let Some(p) = pending.inner.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(output) = future::block_on(future::poll_once(&mut p.handle)) else {
|
||||
return;
|
||||
};
|
||||
let move_count_at_spawn = p.move_count_at_spawn;
|
||||
pending.inner = None;
|
||||
|
||||
let Some(g) = game else { return };
|
||||
if g.0.move_count != move_count_at_spawn {
|
||||
return;
|
||||
}
|
||||
|
||||
let (from, to) = match output {
|
||||
HintTaskOutput::SolverMove { from, to } => (from, to),
|
||||
HintTaskOutput::NeedsHeuristic => {
|
||||
match find_heuristic_hint(&g.0, &mut hint_cycle) {
|
||||
Some(pair) => pair,
|
||||
None => {
|
||||
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
emit_hint_visuals(
|
||||
&g.0,
|
||||
&from,
|
||||
&to,
|
||||
&mut commands,
|
||||
card_entities,
|
||||
&mut info_toast,
|
||||
&mut hint_visual,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::events::HintVisualEvent;
|
||||
use crate::input_plugin::HintSolverConfig;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
/// Build a minimal Bevy app exercising only the polling system
|
||||
/// and the resources/messages it touches.
|
||||
fn pending_hint_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_message::<InfoToastEvent>();
|
||||
app.add_message::<HintVisualEvent>();
|
||||
app.add_message::<StateChangedEvent>();
|
||||
app.init_resource::<HintCycleIndex>();
|
||||
app.init_resource::<HintSolverConfig>();
|
||||
app.init_resource::<PendingHintTask>();
|
||||
// Chain the drop-on-state-change system before the poll
|
||||
// system, mirroring how `InputPlugin::build` wires them.
|
||||
// Without this, system order is unspecified and the
|
||||
// state_change_drops_in_flight_task test sometimes sees the
|
||||
// poll fire before the drop.
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
drop_pending_hint_on_state_change,
|
||||
poll_pending_hint_task,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
/// Same near-finished fixture used by the v0.17 hint tests:
|
||||
/// foundations hold A..Q for each suit, four Kings sit on
|
||||
/// tableau columns 0..3, stock and waste empty.
|
||||
fn near_finished_state() -> GameState {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
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();
|
||||
}
|
||||
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
let ranks_below_king = [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||||
Rank::Jack, Rank::Queen,
|
||||
];
|
||||
for (slot, suit) in suits.iter().enumerate() {
|
||||
let pile = game
|
||||
.piles
|
||||
.get_mut(&PileType::Foundation(slot as u8))
|
||||
.unwrap();
|
||||
for (i, rank) in ranks_below_king.iter().enumerate() {
|
||||
pile.cards.push(Card {
|
||||
id: (slot as u32) * 13 + i as u32,
|
||||
suit: *suit,
|
||||
rank: *rank,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (col, suit) in suits.iter().enumerate() {
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(col))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 100 + col as u32,
|
||||
suit: *suit,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
game
|
||||
}
|
||||
|
||||
/// Spawning a task and pumping update() until it completes must
|
||||
/// emit a HintVisualEvent. Mirrors the `winnable_seed_search_*`
|
||||
/// pattern in game_plugin tests — drives a wall-clock-bounded
|
||||
/// loop so the shared AsyncComputeTaskPool can schedule the
|
||||
/// future under cargo-test parallelism.
|
||||
#[test]
|
||||
fn winnable_solver_emits_hint_after_async_completes() {
|
||||
let mut app = pending_hint_app();
|
||||
app.insert_resource(GameStateResource(near_finished_state()));
|
||||
let cfg = app.world().resource::<HintSolverConfig>().0;
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg);
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
while app.world().resource::<PendingHintTask>().is_pending() {
|
||||
app.update();
|
||||
std::thread::yield_now();
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!app.world().resource::<PendingHintTask>().is_pending(),
|
||||
"hint task should have completed within 15 s wall-clock",
|
||||
);
|
||||
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
||||
let mut cursor = messages.get_cursor();
|
||||
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
|
||||
assert_eq!(
|
||||
collected.len(), 1,
|
||||
"exactly one HintVisualEvent must fire when the solver returns Winnable",
|
||||
);
|
||||
assert!(
|
||||
matches!(collected[0].dest_pile, PileType::Foundation(_)),
|
||||
"solver hint destination must be a foundation slot; got {:?}",
|
||||
collected[0].dest_pile,
|
||||
);
|
||||
}
|
||||
|
||||
/// A StateChangedEvent fired while the task is in flight must
|
||||
/// drop the task; the polling system must not emit any visuals
|
||||
/// once the result eventually arrives.
|
||||
#[test]
|
||||
fn state_change_drops_in_flight_task() {
|
||||
let mut app = pending_hint_app();
|
||||
app.insert_resource(GameStateResource(near_finished_state()));
|
||||
let cfg = app.world().resource::<HintSolverConfig>().0;
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg);
|
||||
assert!(
|
||||
app.world().resource::<PendingHintTask>().is_pending(),
|
||||
"task is in flight after spawn",
|
||||
);
|
||||
|
||||
// Fire a StateChangedEvent before draining the task. The
|
||||
// drop-on-state-change system runs in the same Update tick
|
||||
// and clears the resource.
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
!app.world().resource::<PendingHintTask>().is_pending(),
|
||||
"StateChangedEvent must drop the in-flight hint task",
|
||||
);
|
||||
// No HintVisualEvent should ever have fired.
|
||||
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
||||
let mut cursor = messages.get_cursor();
|
||||
assert_eq!(
|
||||
cursor.read(messages).count(),
|
||||
0,
|
||||
"dropped hint task must not emit any visuals",
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel-on-replace: spawning a fresh task while a previous one
|
||||
/// is in flight must drop the previous task. Only the second
|
||||
/// spawn's result is allowed to surface.
|
||||
#[test]
|
||||
fn second_spawn_drops_first_in_flight_task() {
|
||||
let mut app = pending_hint_app();
|
||||
app.insert_resource(GameStateResource(near_finished_state()));
|
||||
let cfg = app.world().resource::<HintSolverConfig>().0;
|
||||
|
||||
// First spawn.
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg);
|
||||
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
|
||||
assert!(first_handle_present);
|
||||
|
||||
// Second spawn. The `spawn` helper drops the prior task
|
||||
// before assigning the new one — at no point are two tasks
|
||||
// in flight.
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg);
|
||||
// Resource still pending (the second task), but the first
|
||||
// is gone. We can't directly observe the first handle once
|
||||
// it's been overwritten — what we *can* assert is that the
|
||||
// resource still holds a single task, and that task
|
||||
// eventually completes producing exactly one hint visual.
|
||||
assert!(app.world().resource::<PendingHintTask>().is_pending());
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
while app.world().resource::<PendingHintTask>().is_pending() {
|
||||
app.update();
|
||||
std::thread::yield_now();
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!app.world().resource::<PendingHintTask>().is_pending(),
|
||||
"second hint task should have completed within 15 s wall-clock",
|
||||
);
|
||||
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
||||
let mut cursor = messages.get_cursor();
|
||||
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
|
||||
assert_eq!(
|
||||
collected.len(), 1,
|
||||
"cancel-on-replace: only the surviving task's result emits a visual",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
|
||||
//! despawned on the second.
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use chrono::{Duration, Local, NaiveDate};
|
||||
@@ -19,6 +20,7 @@ use crate::settings_plugin::SettingsResource;
|
||||
use crate::stats_plugin::{format_fastest_win, format_win_rate, StatsResource};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
|
||||
@@ -60,10 +62,60 @@ pub struct ProfilePlugin;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ProfileCloseButton;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Profile modal.
|
||||
///
|
||||
/// The Profile panel renders sync info, progression (incl. 14-day
|
||||
/// calendar), every unlocked achievement (up to ~18), and a stats
|
||||
/// summary, which can overflow the modal on the 800x600 minimum window
|
||||
/// once a player has unlocked several achievements. This marker tags
|
||||
/// the inner container that carries `Overflow::scroll_y()` plus a
|
||||
/// `max_height` constraint. Mirrors the `SettingsPanelScrollable`
|
||||
/// pattern.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ProfileScrollable;
|
||||
|
||||
impl Plugin for ProfilePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_message::<ToggleProfileRequestEvent>()
|
||||
.add_systems(Update, (toggle_profile_screen, handle_profile_close_button));
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the
|
||||
// profile-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
toggle_profile_screen,
|
||||
handle_profile_close_button,
|
||||
scroll_profile_panel,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes mouse-wheel events into the Profile modal's scrollable body
|
||||
/// while the panel is open. No-op when no `ProfileScrollable` exists in
|
||||
/// the world (modal closed). Mirrors `scroll_settings_panel`.
|
||||
fn scroll_profile_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<ProfileScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
scroll_evr.clear();
|
||||
return;
|
||||
}
|
||||
let delta_y: f32 = scroll_evr
|
||||
.read()
|
||||
.map(|ev| match ev.unit {
|
||||
MouseScrollUnit::Line => ev.y * 50.0,
|
||||
MouseScrollUnit::Pixel => ev.y,
|
||||
})
|
||||
.sum();
|
||||
if delta_y == 0.0 {
|
||||
return;
|
||||
}
|
||||
for mut sp in scrollables.iter_mut() {
|
||||
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +146,17 @@ fn toggle_profile_screen(
|
||||
screens: Query<Entity, With<ProfileScreen>>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if !keys.just_pressed(KeyCode::KeyP) && !button_clicked {
|
||||
let p_pressed = keys.just_pressed(KeyCode::KeyP);
|
||||
let esc_pressed = keys.just_pressed(KeyCode::Escape);
|
||||
let already_open = !screens.is_empty();
|
||||
// P / button toggles open-or-close. Esc only ever closes — when
|
||||
// Profile is layered over Home (clicking the new Home stats chip
|
||||
// opens this on top), Esc must dismiss the *topmost* modal.
|
||||
// Without this branch, Esc fell through to Home's cancel handler
|
||||
// and closed the wrong modal.
|
||||
let want_open = !already_open && (p_pressed || button_clicked);
|
||||
let want_close = already_open && (p_pressed || button_clicked || esc_pressed);
|
||||
if !want_open && !want_close {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
@@ -133,186 +195,205 @@ fn spawn_profile_screen(
|
||||
..default()
|
||||
};
|
||||
|
||||
spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
|
||||
let scrim = spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Profile", font_res);
|
||||
|
||||
// First-launch welcome — only when the player has zero XP and
|
||||
// zero daily streak, so the profile doesn't read as a wall of
|
||||
// zeros to a brand-new player.
|
||||
if let Some(p) = progress
|
||||
&& p.0.total_xp == 0
|
||||
&& p.0.daily_challenge_streak == 0
|
||||
{
|
||||
card.spawn((
|
||||
Text::new("Welcome! Play games to earn XP and unlock achievements."),
|
||||
font_section.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
Node {
|
||||
margin: UiRect {
|
||||
bottom: VAL_SPACE_2,
|
||||
// Scrollable body — the Profile panel renders sync info,
|
||||
// progression (incl. a 14-day calendar), every unlocked
|
||||
// achievement (up to ~18), and a stats summary, which can
|
||||
// overflow the modal on the 800x600 minimum window once the
|
||||
// player has unlocked several achievements. The Done action
|
||||
// stays fixed outside the scroll.
|
||||
card.spawn((
|
||||
ProfileScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
max_height: Val::Vh(70.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
// First-launch welcome — only when the player has zero XP and
|
||||
// zero daily streak, so the profile doesn't read as a wall of
|
||||
// zeros to a brand-new player.
|
||||
if let Some(p) = progress
|
||||
&& p.0.total_xp == 0
|
||||
&& p.0.daily_challenge_streak == 0
|
||||
{
|
||||
body.spawn((
|
||||
Text::new("Welcome! Play games to earn XP and unlock achievements."),
|
||||
font_section.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
Node {
|
||||
margin: UiRect {
|
||||
bottom: VAL_SPACE_2,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// ── Sync section ────────────────────────────────────────────
|
||||
card.spawn((
|
||||
Text::new("Sync"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(s) = settings {
|
||||
let (backend_name, username) = sync_info(&s.0.sync_backend);
|
||||
card.spawn((
|
||||
Text::new(format!("Account: {username} | Backend: {backend_name}")),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
if let Some(ss) = sync_status {
|
||||
let status_text = match &ss.0 {
|
||||
SyncStatus::Idle => "Sync: idle".to_string(),
|
||||
SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(),
|
||||
SyncStatus::LastSynced(dt) => {
|
||||
format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M"))
|
||||
}
|
||||
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||||
};
|
||||
card.spawn((
|
||||
Text::new(status_text),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Progression section ─────────────────────────────────────
|
||||
spawn_spacer(card, VAL_SPACE_2);
|
||||
card.spawn((
|
||||
Text::new("Progression"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(p) = progress {
|
||||
let prog = &p.0;
|
||||
let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level);
|
||||
let pct = if xp_span == 0 {
|
||||
100u64
|
||||
} else {
|
||||
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
|
||||
};
|
||||
card.spawn((
|
||||
Text::new(format!(
|
||||
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
|
||||
prog.level, prog.total_xp, xp_done, xp_span, pct
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
card.spawn((
|
||||
Text::new(format!(
|
||||
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
|
||||
prog.daily_challenge_streak,
|
||||
prog.unlocked_card_backs.len(),
|
||||
prog.unlocked_backgrounds.len(),
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
|
||||
// 14-day daily-challenge calendar row.
|
||||
spawn_daily_calendar(
|
||||
card,
|
||||
&prog.daily_challenge_history,
|
||||
prog.daily_challenge_streak,
|
||||
prog.daily_challenge_longest_streak,
|
||||
Local::now().date_naive(),
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Achievements section ────────────────────────────────────
|
||||
spawn_spacer(card, VAL_SPACE_2);
|
||||
card.spawn((
|
||||
Text::new("Achievements"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(ar) = achievements {
|
||||
let records = &ar.0;
|
||||
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
|
||||
card.spawn((
|
||||
Text::new(format!("{unlocked_count} / 18 unlocked")),
|
||||
font_row.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
|
||||
let mut any_unlocked = false;
|
||||
for record in records {
|
||||
let def = achievement_by_id(record.id.as_str());
|
||||
let is_secret = def.is_some_and(|d| d.secret);
|
||||
if is_secret && !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
if !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
any_unlocked = true;
|
||||
let name = def.map_or(record.id.as_str(), |d| d.name);
|
||||
let date_str = match record.unlock_date {
|
||||
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
|
||||
None => String::new(),
|
||||
};
|
||||
card.spawn((
|
||||
Text::new(format!(" [x] {name}{date_str}")),
|
||||
font_row.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
}
|
||||
if !any_unlocked {
|
||||
card.spawn((
|
||||
Text::new(" No achievements unlocked yet."),
|
||||
|
||||
// ── Sync section ────────────────────────────────────────────
|
||||
body.spawn((
|
||||
Text::new("Sync"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(s) = settings {
|
||||
let (backend_name, username) = sync_info(&s.0.sync_backend);
|
||||
body.spawn((
|
||||
Text::new(format!("Account: {username} | Backend: {backend_name}")),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
if let Some(ss) = sync_status {
|
||||
let status_text = match &ss.0 {
|
||||
SyncStatus::Idle => "Sync: idle".to_string(),
|
||||
SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(),
|
||||
SyncStatus::LastSynced(dt) => {
|
||||
format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M"))
|
||||
}
|
||||
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||||
};
|
||||
body.spawn((
|
||||
Text::new(status_text),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Statistics summary section ──────────────────────────────
|
||||
spawn_spacer(card, VAL_SPACE_2);
|
||||
card.spawn((
|
||||
Text::new("Statistics Summary"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(sr) = stats {
|
||||
let s = &sr.0;
|
||||
let best_score_str = if s.best_single_score == 0 {
|
||||
"\u{2014}".to_string()
|
||||
} else {
|
||||
s.best_single_score.to_string()
|
||||
};
|
||||
card.spawn((
|
||||
Text::new(format!(
|
||||
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
|
||||
s.games_played,
|
||||
s.games_won,
|
||||
format_win_rate(s),
|
||||
format_fastest_win(s.fastest_win_seconds),
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
// ── Progression section ─────────────────────────────────────
|
||||
spawn_spacer(body, VAL_SPACE_2);
|
||||
body.spawn((
|
||||
Text::new("Progression"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
card.spawn((
|
||||
Text::new(format!(
|
||||
"Win streak: {} current, {} best | Best score: {}",
|
||||
s.win_streak_current, s.win_streak_best, best_score_str,
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
if let Some(p) = progress {
|
||||
let prog = &p.0;
|
||||
let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level);
|
||||
let pct = if xp_span == 0 {
|
||||
100u64
|
||||
} else {
|
||||
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
|
||||
};
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
|
||||
prog.level, prog.total_xp, xp_done, xp_span, pct
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
|
||||
prog.daily_challenge_streak,
|
||||
prog.unlocked_card_backs.len(),
|
||||
prog.unlocked_backgrounds.len(),
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
|
||||
// 14-day daily-challenge calendar row.
|
||||
spawn_daily_calendar(
|
||||
body,
|
||||
&prog.daily_challenge_history,
|
||||
prog.daily_challenge_streak,
|
||||
prog.daily_challenge_longest_streak,
|
||||
Local::now().date_naive(),
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Achievements section ────────────────────────────────────
|
||||
spawn_spacer(body, VAL_SPACE_2);
|
||||
body.spawn((
|
||||
Text::new("Achievements"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
}
|
||||
if let Some(ar) = achievements {
|
||||
let records = &ar.0;
|
||||
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
|
||||
body.spawn((
|
||||
Text::new(format!("{unlocked_count} / 18 unlocked")),
|
||||
font_row.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
|
||||
let mut any_unlocked = false;
|
||||
for record in records {
|
||||
let def = achievement_by_id(record.id.as_str());
|
||||
let is_secret = def.is_some_and(|d| d.secret);
|
||||
if is_secret && !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
if !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
any_unlocked = true;
|
||||
let name = def.map_or(record.id.as_str(), |d| d.name);
|
||||
let date_str = match record.unlock_date {
|
||||
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
|
||||
None => String::new(),
|
||||
};
|
||||
body.spawn((
|
||||
Text::new(format!(" [x] {name}{date_str}")),
|
||||
font_row.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
}
|
||||
if !any_unlocked {
|
||||
body.spawn((
|
||||
Text::new(" No achievements unlocked yet."),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Statistics summary section ──────────────────────────────
|
||||
spawn_spacer(body, VAL_SPACE_2);
|
||||
body.spawn((
|
||||
Text::new("Statistics Summary"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(sr) = stats {
|
||||
let s = &sr.0;
|
||||
let best_score_str = if s.best_single_score == 0 {
|
||||
"\u{2014}".to_string()
|
||||
} else {
|
||||
s.best_single_score.to_string()
|
||||
};
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
|
||||
s.games_played,
|
||||
s.games_won,
|
||||
format_win_rate(s),
|
||||
format_fastest_win(s.fastest_win_seconds),
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Win streak: {} current, {} best | Best score: {}",
|
||||
s.win_streak_current, s.win_streak_best, best_score_str,
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
@@ -325,6 +406,8 @@ fn spawn_profile_screen(
|
||||
);
|
||||
});
|
||||
});
|
||||
// Profile is read-only — opt into click-outside-to-dismiss.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
/// Spawn a fixed-height vertical spacer node.
|
||||
@@ -503,6 +586,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_modal_body_is_scrollable() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyP);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&ProfileScrollable>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Profile modal must spawn exactly one ProfileScrollable body"
|
||||
);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<ProfileScrollable>>();
|
||||
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
||||
assert_ne!(
|
||||
nodes[0].max_height,
|
||||
Val::Auto,
|
||||
"scrollable body must set a non-default max_height"
|
||||
);
|
||||
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_p_twice_closes_profile_screen() {
|
||||
let mut app = headless_app();
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
//! On-screen overlay shown while a recorded [`Replay`] plays back.
|
||||
//!
|
||||
//! The overlay is a thin top-of-window banner with three pieces of UI:
|
||||
//!
|
||||
//! - A "Replay" label on the left so the player knows the surface is
|
||||
//! under playback control rather than live input.
|
||||
//! - A "Move N of M" progress indicator in the centre, recomputed every
|
||||
//! frame the cursor advances.
|
||||
//! - A "Stop" button on the right that aborts playback and returns
|
||||
//! control to the player.
|
||||
//!
|
||||
//! When playback finishes ([`ReplayPlaybackState::Completed`]) the banner
|
||||
//! label swaps to "Replay complete" and stays visible until the playback
|
||||
//! core auto-clears the resource back to [`ReplayPlaybackState::Inactive`]
|
||||
//! a few seconds later, at which point the overlay despawns.
|
||||
//!
|
||||
//! The overlay sits at z-layer [`Z_REPLAY_OVERLAY`] — above gameplay but
|
||||
//! below every modal layer ([`Z_MODAL_SCRIM`] and up). That ordering lets
|
||||
//! the player still open Settings, Pause, and Help during a replay; those
|
||||
//! modals will render on top of the banner as expected.
|
||||
//!
|
||||
//! [`Replay`]: solitaire_data::Replay
|
||||
//! [`Z_MODAL_SCRIM`]: crate::ui_theme::Z_MODAL_SCRIM
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState};
|
||||
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED_HI, TEXT_PRIMARY, TYPE_BODY, TYPE_HEADLINE, VAL_SPACE_2,
|
||||
VAL_SPACE_4, Z_DROP_OVERLAY,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `bevy::ui` `ZIndex` value for the replay overlay banner.
|
||||
///
|
||||
/// Numeric value is `Z_DROP_OVERLAY as i32 + 5 = 55`; chosen so the banner
|
||||
/// sits clearly above the HUD top layer (`Z_HUD_TOP = 60` is intentionally
|
||||
/// **below** modals, but the overlay needs to be above HUD readouts) yet
|
||||
/// well below `Z_MODAL_SCRIM = 200` so Settings, Pause, and Help modals
|
||||
/// continue to render on top of the overlay during a replay.
|
||||
///
|
||||
/// The `Z_DROP_OVERLAY + 5` formula in the spec is reproduced here as an
|
||||
/// integer because `Z_DROP_OVERLAY` itself is a `f32` Sprite-space z used
|
||||
/// for the drop-target overlay sprites — UI nodes use `i32` `ZIndex`, so
|
||||
/// we materialise a separate constant rather than reuse the `f32` value.
|
||||
pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
|
||||
|
||||
/// Total height of the banner in pixels. Thin enough to leave the
|
||||
/// gameplay surface visible underneath, tall enough to comfortably fit
|
||||
/// the headline-sized "Replay" label.
|
||||
const BANNER_HEIGHT: f32 = 48.0;
|
||||
|
||||
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
|
||||
/// reads as a clear "this is a UI strip" callout while still letting the
|
||||
/// felt show through enough to anchor the banner to the play surface.
|
||||
const BANNER_ALPHA: f32 = 0.92;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Marker components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker on the banner's root `Node`. Used by the spawn / despawn /
|
||||
/// progress-update systems to find the overlay.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayRoot;
|
||||
|
||||
/// Marker on the left-hand banner label `Text`. Carries either "Replay"
|
||||
/// (during playback) or "Replay complete" (once finished); the
|
||||
/// completion-text-update system swaps the contents in place.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayBannerText;
|
||||
|
||||
/// Marker on the centre progress `Text`. Updated every frame to reflect
|
||||
/// the current `(cursor, total)` returned by
|
||||
/// [`ReplayPlaybackState::progress`].
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayProgressText;
|
||||
|
||||
/// Marker on the right-hand "Stop" button. Click handler queries for this
|
||||
/// and calls [`stop_replay_playback`] when an `Interaction::Pressed`
|
||||
/// transition is seen.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayStopButton;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bevy plugin that registers every system needed to drive the replay
|
||||
/// overlay's lifecycle.
|
||||
///
|
||||
/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`]
|
||||
/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert
|
||||
/// the resource manually and exercise the overlay in isolation.
|
||||
pub struct ReplayOverlayPlugin;
|
||||
|
||||
impl Plugin for ReplayOverlayPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// The systems are ordered so that, on a single frame:
|
||||
// 1. The state-watcher spawns or despawns the overlay if the
|
||||
// `ReplayPlaybackState` resource changed.
|
||||
// 2. The completion-text update swaps the banner label when the
|
||||
// state is `Completed`.
|
||||
// 3. The progress-text update writes the latest "Move N of M".
|
||||
// 4. The Stop-button click handler reads `Interaction::Pressed`
|
||||
// and calls `stop_replay_playback` (which mutates the state).
|
||||
// Putting Stop last means a click in frame N is observed by
|
||||
// `react_to_state_change` in frame N+1, which then despawns the
|
||||
// overlay in response — a clean state-driven loop.
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
react_to_state_change,
|
||||
update_banner_label,
|
||||
update_progress_text,
|
||||
handle_stop_button,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Reads [`ReplayPlaybackState`] every time the resource changes and either
|
||||
/// spawns or despawns the overlay accordingly. Treats the resource as the
|
||||
/// single source of truth — the spawn / despawn decision is derived from
|
||||
/// `is_playing() || is_completed()` rather than tracking previous-state
|
||||
/// transitions explicitly, which keeps the system stateless.
|
||||
fn react_to_state_change(
|
||||
mut commands: Commands,
|
||||
state: Res<ReplayPlaybackState>,
|
||||
existing: Query<Entity, With<ReplayOverlayRoot>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
|
||||
let should_be_visible = state.is_playing() || state.is_completed();
|
||||
let already_spawned = existing.iter().next().is_some();
|
||||
|
||||
if should_be_visible && !already_spawned {
|
||||
spawn_overlay(&mut commands, font_res.as_deref(), &state);
|
||||
} else if !should_be_visible && already_spawned {
|
||||
for entity in &existing {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
// The `should_be_visible && already_spawned` branch is a no-op here —
|
||||
// the per-frame text update systems below repaint the banner label
|
||||
// and progress readout in place without a respawn.
|
||||
}
|
||||
|
||||
/// Spawns the banner — a flex-row Node anchored to the top edge of the
|
||||
/// window with three children: the "Replay" / "Replay complete" label,
|
||||
/// the centred progress text, and the right-aligned Stop button.
|
||||
fn spawn_overlay(
|
||||
commands: &mut Commands,
|
||||
font_res: Option<&FontResource>,
|
||||
state: &ReplayPlaybackState,
|
||||
) {
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
|
||||
let banner_label = if state.is_completed() {
|
||||
"Replay complete"
|
||||
} else {
|
||||
"Replay"
|
||||
};
|
||||
let progress_label = format_progress(state);
|
||||
|
||||
let banner_bg = Color::srgba(
|
||||
BG_ELEVATED_HI.to_srgba().red,
|
||||
BG_ELEVATED_HI.to_srgba().green,
|
||||
BG_ELEVATED_HI.to_srgba().blue,
|
||||
BANNER_ALPHA,
|
||||
);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
ReplayOverlayRoot,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(BANNER_HEIGHT),
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::SpaceBetween,
|
||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||
column_gap: VAL_SPACE_4,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(banner_bg),
|
||||
// Pin the banner to its z layer in both the local and the
|
||||
// global stacking context — `GlobalZIndex` matters because
|
||||
// the overlay is a top-level Node (no parent), and Bevy 0.18
|
||||
// has historically had subtle stacking-context drift here.
|
||||
ZIndex(Z_REPLAY_OVERLAY),
|
||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
||||
))
|
||||
.with_children(|banner| {
|
||||
// Left: "Replay" label in the cyan primary accent
|
||||
// (`ACCENT_PRIMARY`) so it reads unmistakably as a
|
||||
// non-gameplay surface.
|
||||
banner.spawn((
|
||||
ReplayOverlayBannerText,
|
||||
Text::new(banner_label),
|
||||
TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_HEADLINE,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
|
||||
// Centre: progress readout — neutral primary text colour so
|
||||
// the eye treats it as data, not a callout.
|
||||
banner.spawn((
|
||||
ReplayOverlayProgressText,
|
||||
Text::new(progress_label),
|
||||
TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
|
||||
// Right: Stop button. Tertiary variant — the action is
|
||||
// available but not the loudest element in the banner; the
|
||||
// "Replay" cyan accent owns that slot. `spawn_modal_button`
|
||||
// gives us hover / press paint and focus rings for free via
|
||||
// the existing `UiModalPlugin` paint system.
|
||||
banner
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
})
|
||||
.with_children(|wrap| {
|
||||
spawn_modal_button(
|
||||
wrap,
|
||||
ReplayStopButton,
|
||||
"Stop",
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-frame text updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Overwrites the banner label whenever the resource changes — covers the
|
||||
/// `Playing → Completed` transition by swapping "Replay" for
|
||||
/// "Replay complete" in place without despawning the overlay.
|
||||
fn update_banner_label(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayBannerText>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let label = if state.is_completed() {
|
||||
"Replay complete"
|
||||
} else if state.is_playing() {
|
||||
"Replay"
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
for mut text in &mut q {
|
||||
**text = label.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints the "Move N of M" centre readout every frame the cursor moves.
|
||||
/// Cheap — early-exits if the resource has not changed since the last
|
||||
/// frame so idle replays don't churn the text mesh.
|
||||
fn update_progress_text(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayProgressText>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let label = format_progress(&state);
|
||||
for mut text in &mut q {
|
||||
**text = label.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — formats the centre progress readout for the given state.
|
||||
/// Exposed at module scope so the spawn path and the per-frame update
|
||||
/// path produce the exact same string.
|
||||
fn format_progress(state: &ReplayPlaybackState) -> String {
|
||||
match state.progress() {
|
||||
Some((cursor, total)) => format!("Move {cursor} of {total}"),
|
||||
None if state.is_completed() => "Replay complete".to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop button handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Watches the Stop button for `Interaction::Pressed` transitions. On a
|
||||
/// click, calls [`stop_replay_playback`] which resets the state to
|
||||
/// `Inactive`; the next frame's `react_to_state_change` then despawns
|
||||
/// the overlay.
|
||||
fn handle_stop_button(
|
||||
mut commands: Commands,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
buttons: Query<&Interaction, (With<ReplayStopButton>, Changed<Interaction>)>,
|
||||
) {
|
||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
stop_replay_playback(&mut commands, &mut state);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
|
||||
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
|
||||
/// `StockClick` entries. Tests only ever read `replay.moves.len()`
|
||||
/// (denominator of the progress indicator), so the move kind is
|
||||
/// irrelevant beyond producing the right count.
|
||||
fn synthetic_replay(move_count: usize) -> Replay {
|
||||
Replay::new(
|
||||
42,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
120,
|
||||
1_000,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
||||
(0..move_count).map(|_| ReplayMove::StockClick).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a test app that has the overlay plugin but **not** the
|
||||
/// playback plugin — tests insert `ReplayPlaybackState` manually so
|
||||
/// they can drive every state transition deterministically.
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(ReplayOverlayPlugin);
|
||||
app.init_resource::<ReplayPlaybackState>();
|
||||
app
|
||||
}
|
||||
|
||||
/// Count `ReplayOverlayRoot` entities in the world — the overlay's
|
||||
/// presence/absence is the spawn-test's primary observable.
|
||||
fn overlay_root_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&ReplayOverlayRoot>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Read the current text content of the unique progress-text entity.
|
||||
fn progress_text(app: &mut App) -> String {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Text, With<ReplayOverlayProgressText>>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.map(|t| t.0.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the current text content of the unique banner-label entity.
|
||||
fn banner_text(app: &mut App) -> String {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Text, With<ReplayOverlayBannerText>>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.map(|t| t.0.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set the playback resource without going through the playback core.
|
||||
fn set_state(app: &mut App, state: ReplayPlaybackState) {
|
||||
app.world_mut().insert_resource(state);
|
||||
}
|
||||
|
||||
/// Find the unique `ReplayStopButton` entity for the click-handler
|
||||
/// test. There must be exactly one.
|
||||
fn stop_button_entity(app: &mut App) -> Entity {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<ReplayStopButton>>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.expect("Stop button must exist while overlay is spawned")
|
||||
}
|
||||
|
||||
/// Going `Inactive → Playing` spawns exactly one overlay root and
|
||||
/// the banner label reads "Replay".
|
||||
#[test]
|
||||
fn overlay_spawns_when_playback_starts() {
|
||||
let mut app = headless_app();
|
||||
// First update with the default `Inactive` resource — overlay
|
||||
// must not exist yet.
|
||||
app.update();
|
||||
assert_eq!(overlay_root_count(&mut app), 0);
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
overlay_root_count(&mut app),
|
||||
1,
|
||||
"exactly one ReplayOverlayRoot must spawn on Inactive → Playing",
|
||||
);
|
||||
assert_eq!(banner_text(&mut app), "Replay");
|
||||
}
|
||||
|
||||
/// The progress-text entity reads `"Move {cursor} of {total}"` for a
|
||||
/// well-formed `Playing` state.
|
||||
#[test]
|
||||
fn overlay_progress_text_reflects_cursor() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
|
||||
assert_eq!(progress_text(&mut app), "Move 5 of 10");
|
||||
}
|
||||
|
||||
/// Pressing the Stop button resets the state back to `Inactive` and
|
||||
/// the next frame's `react_to_state_change` despawns the overlay.
|
||||
/// Mirrors the synthetic `Interaction::Pressed` insertion pattern
|
||||
/// used elsewhere in the engine for headless click tests.
|
||||
#[test]
|
||||
fn overlay_stop_button_click_clears_playback() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(overlay_root_count(&mut app), 1);
|
||||
|
||||
let stop = stop_button_entity(&mut app);
|
||||
app.world_mut()
|
||||
.entity_mut(stop)
|
||||
.insert(Interaction::Pressed);
|
||||
// Tick once: the click handler runs late in the frame and resets
|
||||
// the state to `Inactive`.
|
||||
app.update();
|
||||
|
||||
// State must be back to Inactive.
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
assert!(
|
||||
matches!(state, ReplayPlaybackState::Inactive),
|
||||
"Stop click must reset ReplayPlaybackState to Inactive; got {state:?}",
|
||||
);
|
||||
|
||||
// One more tick — `react_to_state_change` sees the resource
|
||||
// change to Inactive and despawns the overlay.
|
||||
app.update();
|
||||
assert_eq!(
|
||||
overlay_root_count(&mut app),
|
||||
0,
|
||||
"overlay must despawn the frame after state returns to Inactive",
|
||||
);
|
||||
}
|
||||
|
||||
/// Manually flipping the resource back to `Inactive` (e.g. via the
|
||||
/// playback core's auto-clear after `Completed`) tears the overlay
|
||||
/// down without any further input.
|
||||
#[test]
|
||||
fn overlay_despawns_when_playback_returns_to_inactive() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(3),
|
||||
cursor: 1,
|
||||
secs_to_next: 0.5,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(overlay_root_count(&mut app), 1);
|
||||
|
||||
set_state(&mut app, ReplayPlaybackState::Inactive);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
overlay_root_count(&mut app),
|
||||
0,
|
||||
"overlay must despawn on Playing → Inactive transition",
|
||||
);
|
||||
}
|
||||
|
||||
/// On `Playing → Completed` the banner label updates in place rather
|
||||
/// than respawning. The overlay must still be present, and the label
|
||||
/// must read "Replay complete".
|
||||
#[test]
|
||||
fn overlay_text_changes_on_completed() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(7),
|
||||
cursor: 7,
|
||||
secs_to_next: 0.0,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(banner_text(&mut app), "Replay");
|
||||
|
||||
set_state(&mut app, ReplayPlaybackState::Completed);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
overlay_root_count(&mut app),
|
||||
1,
|
||||
"overlay must remain spawned while in Completed state",
|
||||
);
|
||||
assert_eq!(
|
||||
banner_text(&mut app),
|
||||
"Replay complete",
|
||||
"banner label must swap on Playing → Completed",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,833 @@
|
||||
//! In-engine replay playback core.
|
||||
//!
|
||||
//! When the player clicks "Watch replay" on the Stats overlay, the live
|
||||
//! game state is reset to the deal seeded from the replay's `seed` /
|
||||
//! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a
|
||||
//! steady cadence — firing the canonical [`MoveRequestEvent`] /
|
||||
//! [`DrawRequestEvent`] for each one. The existing animation pipeline
|
||||
//! plays back identically to a live game.
|
||||
//!
|
||||
//! ## Public surface
|
||||
//!
|
||||
//! - [`ReplayPlaybackState`] — single source of truth for whether
|
||||
//! playback is live, how far through the move list we've ticked, and
|
||||
//! how long until the next advance.
|
||||
//! - [`start_replay_playback`] — public entry point; the Stats
|
||||
//! "Watch replay" button calls this. Resets the game to the recorded
|
||||
//! deal and transitions the state machine to
|
||||
//! [`ReplayPlaybackState::Playing`].
|
||||
//! - [`stop_replay_playback`] — interrupts playback at any time. Safe to
|
||||
//! call when [`ReplayPlaybackState::Inactive`].
|
||||
//! - [`ReplayPlaybackPlugin`] — registers the resource and the tick /
|
||||
//! linger systems.
|
||||
//!
|
||||
//! ## Coordination note
|
||||
//!
|
||||
//! This module is built in parallel with the Stats-side overlay. The
|
||||
//! resource shape, helper signatures, and plugin marker match the
|
||||
//! contract the overlay agent reads against — see also the docs on the
|
||||
//! enum variants.
|
||||
//!
|
||||
//! ## Recording is paused during playback
|
||||
//!
|
||||
//! Playback fires the same [`MoveRequestEvent`] / [`DrawRequestEvent`]
|
||||
//! the live engine handles. Without intervention, [`RecordingReplay`]
|
||||
//! would re-record those events and a replay would re-record itself
|
||||
//! indefinitely. To prevent that, [`record_replay_skip_during_playback`]
|
||||
//! snapshots the recording's length at the start of playback and
|
||||
//! truncates the buffer back to that length every frame. This keeps
|
||||
//! the recording contract opaque to `game_plugin` — no event-source
|
||||
//! flag is threaded through, no every-callsite gate is added.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::{GameMutation, RecordingReplay};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
|
||||
/// Default per-move duration during playback, in seconds. Acts as the
|
||||
/// fallback when `SettingsResource` is absent — i.e. in headless test
|
||||
/// fixtures that don't install [`crate::settings_plugin::SettingsPlugin`].
|
||||
/// In production the live value is read from
|
||||
/// [`solitaire_data::Settings::replay_move_interval_secs`] every frame
|
||||
/// so Settings adjustments take effect on the next playback tick.
|
||||
///
|
||||
/// Kept in sync with `solitaire_data::settings::default_replay_move_interval_secs`
|
||||
/// (the data crate cannot depend on this engine crate, so the constant
|
||||
/// is duplicated). The
|
||||
/// `settings_replay_move_interval_default_matches_engine_constant`
|
||||
/// test in `solitaire_engine::settings_plugin` enforces equality.
|
||||
pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45;
|
||||
|
||||
/// Helper: returns the live per-move replay interval. Reads
|
||||
/// [`SettingsResource::replay_move_interval_secs`] when the resource is
|
||||
/// installed, falling back to [`REPLAY_MOVE_INTERVAL_SECS`] otherwise.
|
||||
/// Also clamps below by `f32::EPSILON` so a hand-edited 0.0 cannot
|
||||
/// busy-loop the playback tick.
|
||||
fn current_move_interval_secs(settings: Option<&SettingsResource>) -> f32 {
|
||||
let raw = settings
|
||||
.map(|s| s.0.replay_move_interval_secs)
|
||||
.unwrap_or(REPLAY_MOVE_INTERVAL_SECS);
|
||||
raw.max(f32::EPSILON)
|
||||
}
|
||||
|
||||
/// How long the [`ReplayPlaybackState::Completed`] state lingers before
|
||||
/// the auto-clear system transitions it back to
|
||||
/// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to
|
||||
/// display "Replay complete" before dismissing.
|
||||
pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
|
||||
|
||||
/// Lifecycle state of an in-flight replay playback.
|
||||
///
|
||||
/// The default state is [`Inactive`](Self::Inactive) — no replay is
|
||||
/// running. The overlay (and any other consumer) reads this resource to
|
||||
/// decide whether the "Replay" banner should be visible and what
|
||||
/// progress to display.
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// 1. Default state is [`Inactive`](Self::Inactive).
|
||||
/// 2. [`start_replay_playback`] transitions to
|
||||
/// [`Playing`](Self::Playing) and resets the live `GameState` to the
|
||||
/// replay's recorded deal.
|
||||
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
||||
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
||||
/// for each [`ReplayMove`].
|
||||
/// 4. When `cursor == replay.moves.len()`, the state transitions to
|
||||
/// [`Completed`](Self::Completed). It lingers for
|
||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
||||
/// [`auto_clear_completed_replay`]) before returning to
|
||||
/// [`Inactive`](Self::Inactive).
|
||||
/// 5. [`stop_replay_playback`] interrupts at any time and forces the
|
||||
/// state back to [`Inactive`](Self::Inactive).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub enum ReplayPlaybackState {
|
||||
/// No replay is being played back. The overlay despawns itself when
|
||||
/// the resource transitions back to this variant.
|
||||
#[default]
|
||||
Inactive,
|
||||
/// A replay is currently being played back. The overlay reads
|
||||
/// `replay.moves.len()` for the denominator of the progress
|
||||
/// indicator and `cursor` for the numerator.
|
||||
Playing {
|
||||
/// The replay being played back. Owned so the state is the
|
||||
/// only place playback metadata lives — no separate resource
|
||||
/// needed.
|
||||
replay: Replay,
|
||||
/// Index of the next move to apply, in `[0, replay.moves.len()]`.
|
||||
cursor: usize,
|
||||
/// Seconds remaining until the next move is dispatched.
|
||||
secs_to_next: f32,
|
||||
},
|
||||
/// The replay finished playing back. The overlay swaps the banner
|
||||
/// label to "Replay complete" until [`auto_clear_completed_replay`]
|
||||
/// transitions back to [`Inactive`](Self::Inactive) a few seconds
|
||||
/// later.
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl ReplayPlaybackState {
|
||||
/// Returns `true` when a replay is currently being played back.
|
||||
pub fn is_playing(&self) -> bool {
|
||||
matches!(self, Self::Playing { .. })
|
||||
}
|
||||
|
||||
/// Returns `true` when the replay has finished but the resource has
|
||||
/// not yet been auto-cleared back to [`Self::Inactive`].
|
||||
pub fn is_completed(&self) -> bool {
|
||||
matches!(self, Self::Completed)
|
||||
}
|
||||
|
||||
/// Returns `(cursor, total)` when a replay is in progress so the
|
||||
/// overlay can render `"Move N of M"`. Returns `None` while
|
||||
/// [`Inactive`](Self::Inactive) or [`Completed`](Self::Completed) —
|
||||
/// the replay is consumed when transitioning out of `Playing`, so
|
||||
/// the total is no longer available in `Completed`.
|
||||
pub fn progress(&self) -> Option<(usize, usize)> {
|
||||
match self {
|
||||
Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())),
|
||||
Self::Inactive | Self::Completed => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public entry point — call from the Stats "Watch replay" button
|
||||
/// handler.
|
||||
///
|
||||
/// Resets the live [`GameStateResource`] to a fresh deal seeded from
|
||||
/// `replay.seed` / `replay.draw_mode` / `replay.mode` (via
|
||||
/// [`Commands::insert_resource`]), then transitions the state machine
|
||||
/// to [`ReplayPlaybackState::Playing`] with `cursor: 0` and
|
||||
/// `secs_to_next: REPLAY_MOVE_INTERVAL_SECS`.
|
||||
///
|
||||
/// `commands` is used to overwrite [`GameStateResource`] in a deferred
|
||||
/// flush — equivalent to what `handle_new_game` does, minus the
|
||||
/// [`crate::events::NewGameRequestEvent`] round-trip and the
|
||||
/// abandon-current-game confirmation modal (which would block playback
|
||||
/// indefinitely). Using `Commands` rather than [`crate::events::NewGameRequestEvent`]
|
||||
/// also sidesteps the fact that `NewGameRequestEvent` has no
|
||||
/// `draw_mode_override` field — `handle_new_game` always reads
|
||||
/// `draw_mode` from `Settings`, which would silently coerce a Draw-1
|
||||
/// replay into a Draw-3 game (or vice versa) when the player's
|
||||
/// settings disagree with the recording.
|
||||
///
|
||||
/// Safe to call from any state — if a replay is already playing it is
|
||||
/// dropped and the new one starts immediately.
|
||||
pub fn start_replay_playback(
|
||||
commands: &mut Commands,
|
||||
state: &mut ResMut<ReplayPlaybackState>,
|
||||
replay: Replay,
|
||||
) {
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
||||
commands.insert_resource(GameStateResource(fresh));
|
||||
|
||||
// Initial `secs_to_next` uses the constant rather than reading
|
||||
// `SettingsResource` because this entry point takes `Commands` /
|
||||
// `ResMut<ReplayPlaybackState>` only. The first-tick latency may
|
||||
// therefore lag the configured interval by up to ~0.45 s on an
|
||||
// unusually short setting; subsequent ticks read the live setting
|
||||
// every frame via [`tick_replay_playback`].
|
||||
**state = ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
cursor: 0,
|
||||
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
||||
};
|
||||
}
|
||||
|
||||
/// Aborts an in-flight replay playback and resets
|
||||
/// [`ReplayPlaybackState`] back to [`ReplayPlaybackState::Inactive`].
|
||||
///
|
||||
/// Safe to call from any state — when already
|
||||
/// [`ReplayPlaybackState::Inactive`] it simply re-asserts inactivity.
|
||||
///
|
||||
/// The current [`GameStateResource`] is left as-is: the player sees the
|
||||
/// replay's most-recently-applied state until they start a fresh game
|
||||
/// manually. This avoids forcing an extra deal animation in their face
|
||||
/// the moment they cancel.
|
||||
///
|
||||
/// `commands` is currently unused but accepted to match the
|
||||
/// [`start_replay_playback`] signature — leaves room to hook in
|
||||
/// cleanup (e.g. despawning playback-only overlays) without a future
|
||||
/// API break.
|
||||
pub fn stop_replay_playback(
|
||||
_commands: &mut Commands,
|
||||
state: &mut ResMut<ReplayPlaybackState>,
|
||||
) {
|
||||
**state = ReplayPlaybackState::Inactive;
|
||||
}
|
||||
|
||||
/// Tick system. Runs every frame; only does work when
|
||||
/// [`ReplayPlaybackState::is_playing`].
|
||||
///
|
||||
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
|
||||
/// expires, fires the canonical event for the move at `cursor`,
|
||||
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
|
||||
/// reaches `replay.moves.len()`, transitions to
|
||||
/// [`ReplayPlaybackState::Completed`].
|
||||
///
|
||||
/// The advance loop is a `while`, not an `if`, so coarse time steps
|
||||
/// (e.g. test-driven 200 ms ticks against a 450 ms interval) still
|
||||
/// fire the right number of events — accumulated debt is paid off
|
||||
/// across as many advances as needed in the same frame. In normal
|
||||
/// gameplay frame deltas are well below `REPLAY_MOVE_INTERVAL_SECS`,
|
||||
/// so the loop runs at most once per frame.
|
||||
fn tick_replay_playback(
|
||||
time: Res<Time>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
let interval = current_move_interval_secs(settings.as_deref());
|
||||
let mut transition_to_completed = false;
|
||||
|
||||
if let ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
cursor,
|
||||
secs_to_next,
|
||||
} = state.as_mut()
|
||||
{
|
||||
*secs_to_next -= dt;
|
||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||
match &replay.moves[*cursor] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
count: *count,
|
||||
});
|
||||
}
|
||||
ReplayMove::StockClick => {
|
||||
draws_writer.write(DrawRequestEvent);
|
||||
}
|
||||
}
|
||||
*cursor += 1;
|
||||
*secs_to_next += interval;
|
||||
}
|
||||
|
||||
if *cursor >= replay.moves.len() {
|
||||
transition_to_completed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if transition_to_completed {
|
||||
*state = ReplayPlaybackState::Completed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Local timer for the [`ReplayPlaybackState::Completed`] linger.
|
||||
/// Resets to zero whenever the state transitions out of
|
||||
/// [`ReplayPlaybackState::Completed`].
|
||||
#[derive(Default)]
|
||||
struct CompletionLinger(f32);
|
||||
|
||||
/// Auto-clear system. While [`ReplayPlaybackState::Completed`],
|
||||
/// accumulates time and transitions back to
|
||||
/// [`ReplayPlaybackState::Inactive`] once
|
||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] has elapsed.
|
||||
fn auto_clear_completed_replay(
|
||||
time: Res<Time>,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
mut linger: Local<CompletionLinger>,
|
||||
) {
|
||||
if state.is_completed() {
|
||||
linger.0 += time.delta_secs();
|
||||
if linger.0 >= REPLAY_COMPLETION_LINGER_SECS {
|
||||
*state = ReplayPlaybackState::Inactive;
|
||||
linger.0 = 0.0;
|
||||
}
|
||||
} else {
|
||||
// Reset whenever we're not in Completed so the next completion
|
||||
// measures from zero rather than accumulating across cycles.
|
||||
linger.0 = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Local cache of the recording buffer's length at the start of
|
||||
/// playback. Lets us roll back any growth during playback without
|
||||
/// touching `game_plugin`'s recording call sites.
|
||||
#[derive(Default)]
|
||||
struct RecordingSnapshot {
|
||||
/// `Some(len)` while playback is active. The recording is
|
||||
/// truncated back to this length every frame so playback-driven
|
||||
/// events leak no entries into the recorded move list. `None`
|
||||
/// when not playing — recording behaves normally.
|
||||
snapshot_len: Option<usize>,
|
||||
}
|
||||
|
||||
/// Recording-pause system. While [`ReplayPlaybackState::is_playing`],
|
||||
/// snapshots the recording's length on entry and truncates the
|
||||
/// recording back to that length every frame. This keeps the live
|
||||
/// [`RecordingReplay`] opaque to `game_plugin`'s `handle_move` /
|
||||
/// `handle_draw` — those still push unconditionally; we just wipe the
|
||||
/// playback-driven entries before any other system can read them.
|
||||
///
|
||||
/// Implemented this way because [`RecordingReplay`] is mutated inside
|
||||
/// the [`GameMutation`] system set (the schedule set that owns
|
||||
/// `handle_move` / `handle_draw`). We schedule this system
|
||||
/// `.after(GameMutation)` so the truncation runs each frame *after*
|
||||
/// the unconditional push, removing the same entry the playback tick
|
||||
/// caused.
|
||||
fn record_replay_skip_during_playback(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut recording: ResMut<RecordingReplay>,
|
||||
mut snap: Local<RecordingSnapshot>,
|
||||
) {
|
||||
// Treat `Playing` and `Completed` identically for the purpose of
|
||||
// recording suppression. The tick system's final advance fires
|
||||
// its event in the same frame it transitions to `Completed`; the
|
||||
// event is then consumed by `handle_move` / `handle_draw` either
|
||||
// this frame (race-dependent on system order) or the next. By
|
||||
// suppressing recording growth across both states, we close that
|
||||
// window cleanly: the snapshot survives until the resource is
|
||||
// back to `Inactive` (auto-cleared after
|
||||
// `REPLAY_COMPLETION_LINGER_SECS`).
|
||||
if state.is_playing() || state.is_completed() {
|
||||
let baseline = match snap.snapshot_len {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
let n = recording.moves.len();
|
||||
snap.snapshot_len = Some(n);
|
||||
n
|
||||
}
|
||||
};
|
||||
if recording.moves.len() > baseline {
|
||||
recording.moves.truncate(baseline);
|
||||
}
|
||||
} else {
|
||||
// Drop the snapshot when neither playing nor completed so
|
||||
// the next playback cycle re-anchors to whatever the
|
||||
// recording is at that point.
|
||||
snap.snapshot_len = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// On-completion side effect: fire a single [`StateChangedEvent`] when
|
||||
/// playback transitions from `Playing` to `Completed` so any UI that
|
||||
/// listens for state mutations refreshes one final time. Cheap and
|
||||
/// idempotent — `StateChangedEvent` is a one-shot signal.
|
||||
fn fire_state_changed_on_completion(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut last_was_completed: Local<bool>,
|
||||
mut writer: MessageWriter<StateChangedEvent>,
|
||||
) {
|
||||
let now_completed = state.is_completed();
|
||||
if now_completed && !*last_was_completed {
|
||||
writer.write(StateChangedEvent);
|
||||
}
|
||||
*last_was_completed = now_completed;
|
||||
}
|
||||
|
||||
/// Bevy plugin that initialises [`ReplayPlaybackState`] and drives
|
||||
/// playback ticks, completion linger, and the recording-pause guard.
|
||||
///
|
||||
/// Register this in the main app alongside [`crate::game_plugin::GamePlugin`].
|
||||
/// Tests can install it under [`MinimalPlugins`] to exercise the public
|
||||
/// API without spinning up the full client.
|
||||
pub struct ReplayPlaybackPlugin;
|
||||
|
||||
impl Plugin for ReplayPlaybackPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ReplayPlaybackState>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
tick_replay_playback,
|
||||
auto_clear_completed_replay,
|
||||
fire_state_changed_on_completion,
|
||||
)
|
||||
.chain(),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
record_replay_skip_during_playback.after(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||
/// `ReplayPlaybackPlugin`. `GamePlugin` brings the canonical
|
||||
/// `MoveRequestEvent` / `DrawRequestEvent` registrations along with
|
||||
/// `RecordingReplay` so the recording-pause test can read it.
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin::headless())
|
||||
.add_plugins(ReplayPlaybackPlugin);
|
||||
// Disable game-state persistence so tests don't touch the
|
||||
// real ~/.local/share/solitaire_quest/game_state.json.
|
||||
app.insert_resource(crate::game_plugin::GameStatePath(None));
|
||||
app.insert_resource(crate::game_plugin::ReplayPath(None));
|
||||
// Tick once so any startup systems flush before the first
|
||||
// assertion.
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
/// `Time<Virtual>` clamps each tick to `max_delta` (default 250 ms),
|
||||
/// so we drive 200 ms steps and call `update` enough times to pass
|
||||
/// the requested duration.
|
||||
fn advance_by(app: &mut App, total_secs: f32) {
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||
Duration::from_secs_f32(0.2),
|
||||
));
|
||||
let ticks = (total_secs / 0.2).ceil() as usize + 1;
|
||||
for _ in 0..ticks {
|
||||
app.update();
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3-move replay covering both `Move` and `StockClick` variants.
|
||||
/// Seed 12345 is arbitrary — the test asserts on event counts and
|
||||
/// move shapes, not on board positions.
|
||||
fn sample_replay_three_moves() -> Replay {
|
||||
Replay::new(
|
||||
12345,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
60,
|
||||
500,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![
|
||||
ReplayMove::StockClick,
|
||||
ReplayMove::Move {
|
||||
from: PileType::Waste,
|
||||
to: PileType::Tableau(3),
|
||||
count: 1,
|
||||
},
|
||||
ReplayMove::StockClick,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Scoped helper to invoke `start_replay_playback` from within the
|
||||
/// app's `World` (the public API takes `Commands`, which only
|
||||
/// exists inside systems). We use a one-shot system to obtain the
|
||||
/// `Commands`.
|
||||
fn start_playback(app: &mut App, replay: Replay) {
|
||||
#[derive(Resource)]
|
||||
struct ReplayInbox(Option<Replay>);
|
||||
app.insert_resource(ReplayInbox(Some(replay)));
|
||||
|
||||
fn run(
|
||||
mut commands: Commands,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
mut inbox: ResMut<ReplayInbox>,
|
||||
) {
|
||||
if let Some(replay) = inbox.0.take() {
|
||||
start_replay_playback(&mut commands, &mut state, replay);
|
||||
}
|
||||
}
|
||||
let id = app.world_mut().register_system(run);
|
||||
app.world_mut()
|
||||
.run_system(id)
|
||||
.expect("one-shot start_playback");
|
||||
}
|
||||
|
||||
fn stop_playback(app: &mut App) {
|
||||
fn run(mut commands: Commands, mut state: ResMut<ReplayPlaybackState>) {
|
||||
stop_replay_playback(&mut commands, &mut state);
|
||||
}
|
||||
let id = app.world_mut().register_system(run);
|
||||
app.world_mut()
|
||||
.run_system(id)
|
||||
.expect("one-shot stop_playback");
|
||||
}
|
||||
|
||||
/// Fresh state must be `Inactive`. After `start_replay_playback`
|
||||
/// the state must be `Playing { cursor: 0, .. }` carrying the
|
||||
/// supplied replay.
|
||||
#[test]
|
||||
fn start_replay_playback_transitions_inactive_to_playing() {
|
||||
let mut app = headless_app();
|
||||
assert!(matches!(
|
||||
*app.world().resource::<ReplayPlaybackState>(),
|
||||
ReplayPlaybackState::Inactive
|
||||
));
|
||||
|
||||
let replay = sample_replay_three_moves();
|
||||
start_playback(&mut app, replay.clone());
|
||||
// Apply the deferred Commands flush.
|
||||
app.update();
|
||||
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
match state {
|
||||
ReplayPlaybackState::Playing {
|
||||
cursor,
|
||||
replay: r,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*cursor, 0);
|
||||
assert_eq!(r.seed, replay.seed);
|
||||
assert_eq!(r.moves.len(), 3);
|
||||
}
|
||||
other => panic!("expected Playing, got {other:?}"),
|
||||
}
|
||||
assert_eq!(state.progress(), Some((0, 3)));
|
||||
}
|
||||
|
||||
/// One full interval (plus a small margin to clear the boundary)
|
||||
/// must advance the cursor by at least one.
|
||||
#[test]
|
||||
fn tick_advances_cursor_after_interval() {
|
||||
let mut app = headless_app();
|
||||
start_playback(&mut app, sample_replay_three_moves());
|
||||
app.update();
|
||||
|
||||
// Drive virtual time forward by one interval.
|
||||
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS + 0.05);
|
||||
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
match state {
|
||||
ReplayPlaybackState::Playing { cursor, .. } => {
|
||||
assert!(
|
||||
*cursor >= 1,
|
||||
"expected cursor advanced past one move, got {cursor}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected Playing, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Driving past `n * REPLAY_MOVE_INTERVAL_SECS` must produce
|
||||
/// `n` events that match the recorded move kinds. We register a
|
||||
/// pair of accumulator systems that drain `MoveRequestEvent` /
|
||||
/// `DrawRequestEvent` into resources every frame — using a
|
||||
/// detached cursor across many `app.update()` calls is unreliable
|
||||
/// because Bevy's `Messages` double-buffer drops events older
|
||||
/// than two frames.
|
||||
#[test]
|
||||
fn tick_fires_canonical_event_for_each_move() {
|
||||
#[derive(Resource, Default)]
|
||||
struct CapturedMoves(Vec<MoveRequestEvent>);
|
||||
#[derive(Resource, Default)]
|
||||
struct CapturedDraws(usize);
|
||||
|
||||
fn collect_moves(
|
||||
mut events: MessageReader<MoveRequestEvent>,
|
||||
mut sink: ResMut<CapturedMoves>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
sink.0.push(ev.clone());
|
||||
}
|
||||
}
|
||||
fn collect_draws(
|
||||
mut events: MessageReader<DrawRequestEvent>,
|
||||
mut sink: ResMut<CapturedDraws>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
sink.0 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut app = headless_app();
|
||||
app.init_resource::<CapturedMoves>()
|
||||
.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, (collect_moves, collect_draws));
|
||||
|
||||
start_playback(&mut app, sample_replay_three_moves());
|
||||
app.update();
|
||||
|
||||
// Drive through 3 intervals. Add a small margin to ensure the
|
||||
// last firing isn't sitting exactly on the boundary.
|
||||
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS * 3.0 + 0.1);
|
||||
|
||||
let captured_moves = app.world().resource::<CapturedMoves>();
|
||||
let captured_draws = app.world().resource::<CapturedDraws>();
|
||||
|
||||
// Sample replay: StockClick, Move { Waste -> Tableau(3), 1 }, StockClick.
|
||||
assert_eq!(
|
||||
captured_draws.0, 2,
|
||||
"expected 2 DrawRequestEvent (two StockClicks)",
|
||||
);
|
||||
assert_eq!(
|
||||
captured_moves.0.len(),
|
||||
1,
|
||||
"expected 1 MoveRequestEvent (the single Move variant)",
|
||||
);
|
||||
let m = &captured_moves.0[0];
|
||||
assert!(matches!(m.from, PileType::Waste));
|
||||
assert!(matches!(m.to, PileType::Tableau(3)));
|
||||
assert_eq!(m.count, 1);
|
||||
}
|
||||
|
||||
/// Driving past one interval on a single-move replay must
|
||||
/// transition to `Completed`.
|
||||
#[test]
|
||||
fn playback_completes_when_cursor_reaches_end() {
|
||||
let mut app = headless_app();
|
||||
let one_move = Replay::new(
|
||||
42,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick],
|
||||
);
|
||||
start_playback(&mut app, one_move);
|
||||
app.update();
|
||||
|
||||
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS + 0.1);
|
||||
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
assert!(
|
||||
state.is_completed(),
|
||||
"expected Completed after consuming the only move, got {state:?}",
|
||||
);
|
||||
}
|
||||
|
||||
/// `stop_replay_playback` must force the state back to `Inactive`
|
||||
/// even mid-playback.
|
||||
#[test]
|
||||
fn stop_replay_playback_returns_to_inactive() {
|
||||
let mut app = headless_app();
|
||||
start_playback(&mut app, sample_replay_three_moves());
|
||||
app.update();
|
||||
// Tick once so the state is well and truly `Playing`.
|
||||
advance_by(&mut app, 0.1);
|
||||
assert!(app.world().resource::<ReplayPlaybackState>().is_playing());
|
||||
|
||||
stop_playback(&mut app);
|
||||
app.update();
|
||||
|
||||
assert!(matches!(
|
||||
*app.world().resource::<ReplayPlaybackState>(),
|
||||
ReplayPlaybackState::Inactive
|
||||
));
|
||||
}
|
||||
|
||||
/// Recording must remain frozen during playback. Pre-populate the
|
||||
/// recording with one entry, start playback, and assert the
|
||||
/// recording's move list is unchanged after several ticks.
|
||||
#[test]
|
||||
fn recording_paused_during_playback() {
|
||||
let mut app = headless_app();
|
||||
// Pre-populate the recording with one entry that should
|
||||
// survive playback unchanged. Mirrors the situation where the
|
||||
// player partway through a game opens stats and clicks Watch
|
||||
// Replay — their in-flight recording must not get clobbered.
|
||||
{
|
||||
let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
|
||||
rec.moves.push(ReplayMove::StockClick);
|
||||
}
|
||||
start_playback(&mut app, sample_replay_three_moves());
|
||||
app.update();
|
||||
|
||||
let baseline_len = app.world().resource::<RecordingReplay>().moves.len();
|
||||
assert_eq!(
|
||||
baseline_len, 1,
|
||||
"preconditions: recording starts with one entry",
|
||||
);
|
||||
|
||||
// Drive playback through every move in the replay. Each move
|
||||
// would normally append to `RecordingReplay`; the pause
|
||||
// system must clamp the recording back to `baseline_len` on
|
||||
// every frame.
|
||||
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS * 4.0 + 0.1);
|
||||
|
||||
let after_len = app.world().resource::<RecordingReplay>().moves.len();
|
||||
assert_eq!(
|
||||
after_len, baseline_len,
|
||||
"recording must not grow while playback is active",
|
||||
);
|
||||
}
|
||||
|
||||
/// With `SettingsResource::replay_move_interval_secs` set to 0.10 s
|
||||
/// (well below the 0.45 s default), playback over a fixed
|
||||
/// wall-clock window must dispatch strictly more moves than the
|
||||
/// same fixture would at the 0.45 s default. This is the
|
||||
/// regression check that the tick reads from the live Settings
|
||||
/// value rather than the hardcoded
|
||||
/// [`REPLAY_MOVE_INTERVAL_SECS`] constant.
|
||||
///
|
||||
/// The follow-up assertion exercises the boundary condition: at
|
||||
/// the 0.10 s/move setting, exactly six 0.10 s ticks must yield
|
||||
/// fewer moves than six 0.20 s ticks (because the latter doubles
|
||||
/// the per-update advance and pays off two intervals each tick).
|
||||
#[test]
|
||||
fn replay_playback_tick_uses_settings_interval() {
|
||||
use solitaire_data::Settings;
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
struct CapturedDraws(usize);
|
||||
|
||||
fn collect_draws(
|
||||
mut events: MessageReader<DrawRequestEvent>,
|
||||
mut sink: ResMut<CapturedDraws>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
sink.0 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Long replay so the fast cadence has plenty of moves to
|
||||
// chew through and the 0.45 s vs 0.10 s difference is easy
|
||||
// to observe.
|
||||
fn ten_draws_replay() -> Replay {
|
||||
Replay::new(
|
||||
7,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick; 10],
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Run 1: 0.10 s/move (Settings override) ----
|
||||
let mut fast_app = headless_app();
|
||||
fast_app.insert_resource(SettingsResource(Settings {
|
||||
replay_move_interval_secs: 0.10,
|
||||
..Settings::default()
|
||||
}));
|
||||
fast_app
|
||||
.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, collect_draws);
|
||||
|
||||
start_playback(&mut fast_app, ten_draws_replay());
|
||||
fast_app.update();
|
||||
// 1.0 s of virtual time at 0.10 s/move dispatches ~5 moves
|
||||
// after the default 0.45 s startup interval is consumed.
|
||||
advance_by(&mut fast_app, 1.0);
|
||||
let fast_count = fast_app.world().resource::<CapturedDraws>().0;
|
||||
|
||||
// ---- Run 2: 0.45 s/move (default — no SettingsResource) ----
|
||||
let mut slow_app = headless_app();
|
||||
// `tick_replay_playback` falls back to `REPLAY_MOVE_INTERVAL_SECS`
|
||||
// (0.45 s) when `SettingsResource` is absent.
|
||||
slow_app
|
||||
.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, collect_draws);
|
||||
|
||||
start_playback(&mut slow_app, ten_draws_replay());
|
||||
slow_app.update();
|
||||
advance_by(&mut slow_app, 1.0);
|
||||
let slow_count = slow_app.world().resource::<CapturedDraws>().0;
|
||||
|
||||
assert!(
|
||||
fast_count > slow_count,
|
||||
"at 0.10 s/move the tick must dispatch strictly more moves \
|
||||
than at the 0.45 s default over the same wall-clock window: \
|
||||
fast={fast_count}, slow={slow_count}",
|
||||
);
|
||||
|
||||
// ---- Boundary: a 0.05 s/tick cadence over the same window
|
||||
// dispatches NO MORE moves than a 0.10 s/tick cadence, because
|
||||
// 0.05 s < 0.10 s configured interval — the secs_to_next clock
|
||||
// never crosses the threshold inside a single tick. ----
|
||||
//
|
||||
// We don't assert "exactly zero" because the leading update()
|
||||
// after `start_playback` may run before the strategy is
|
||||
// applied (cf. comments on `tick_advances_cursor_after_interval`),
|
||||
// but the count must not exceed what we'd get with one-tick
|
||||
// advances at the same total wall-clock window.
|
||||
fn count_after_window(interval_secs: f32, tick_secs: f32, total_secs: f32) -> usize {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
replay_move_interval_secs: interval_secs,
|
||||
..Settings::default()
|
||||
}));
|
||||
app.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, collect_draws);
|
||||
start_playback(&mut app, ten_draws_replay());
|
||||
app.update();
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||
Duration::from_secs_f32(tick_secs),
|
||||
));
|
||||
let ticks = (total_secs / tick_secs).ceil() as usize + 1;
|
||||
for _ in 0..ticks {
|
||||
app.update();
|
||||
}
|
||||
app.world().resource::<CapturedDraws>().0
|
||||
}
|
||||
|
||||
let count_at_05 = count_after_window(0.10, 0.05, 1.0);
|
||||
let count_at_20 = count_after_window(0.10, 0.20, 1.0);
|
||||
assert!(
|
||||
count_at_05 <= count_at_20,
|
||||
"0.05 s ticks (strictly less than the 0.10 s interval) must \
|
||||
dispatch no more moves than 0.20 s ticks over the same \
|
||||
wall-clock window: count_at_05={count_at_05}, count_at_20={count_at_20}",
|
||||
);
|
||||
}
|
||||
}
|
||||