Compare commits
6 Commits
cb93bd9265
...
0066ca6205
| Author | SHA1 | Date | |
|---|---|---|---|
| 0066ca6205 | |||
| 54e024c1b0 | |||
| 3a01318fbd | |||
| 79d391724e | |||
| ba019c0ba7 | |||
| 18d7c121a3 |
+45
-62
@@ -1,10 +1,10 @@
|
|||||||
# Solitaire Quest — UX Overhaul Session Handoff
|
# Solitaire Quest — UX Overhaul Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-04-30 — paused mid-overhaul to push and let the user smoke-test before resuming.
|
**Last updated:** 2026-04-30 — Phase 3 complete. All 10 steps landed; ready for full smoke-test.
|
||||||
|
|
||||||
## Where we are
|
## Where we are
|
||||||
|
|
||||||
Phase 3 of the UX overhaul brief in `docs/UX_OVERHAUL_BRIEF.md` (or the inline brief used to kick off the work). 11 commits landed this session — the foundation, the HUD, and every read-only overlay have been migrated to the new design system. Pause / Settings / Onboarding modals, animation upgrades, and the final literal sweep are still pending.
|
Phase 3 of the UX overhaul brief is **done**. The whole engine has been migrated to the `ui_theme` design-token system + `ui_modal` scaffold. Animation system upgraded. Final literal sweep landed. The work spans 17 commits this session, from the foundation (`e14852c`) through to the final sweep (`54e024c`).
|
||||||
|
|
||||||
### Design direction (already saved as project memory)
|
### Design direction (already saved as project memory)
|
||||||
|
|
||||||
@@ -12,21 +12,27 @@ Phase 3 of the UX overhaul brief in `docs/UX_OVERHAUL_BRIEF.md` (or the inline b
|
|||||||
- **Palette:** Midnight Purple base (`BG_BASE` `#1A0F2E` → `BG_ELEVATED` `#2D1B69` → `BG_ELEVATED_HI` `#3A2580` → `BG_ELEVATED_TOP` `#482F97`) + Balatro yellow primary accent (`ACCENT_PRIMARY` `#FFD23F`) + warm magenta secondary (`ACCENT_SECONDARY` `#FF6B9D`).
|
- **Palette:** Midnight Purple base (`BG_BASE` `#1A0F2E` → `BG_ELEVATED` `#2D1B69` → `BG_ELEVATED_HI` `#3A2580` → `BG_ELEVATED_TOP` `#482F97`) + Balatro yellow primary accent (`ACCENT_PRIMARY` `#FFD23F`) + warm magenta secondary (`ACCENT_SECONDARY` `#FF6B9D`).
|
||||||
- See [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for the full direction.
|
- See [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for the full direction.
|
||||||
|
|
||||||
### Top complaints from the original smoke test
|
### Top complaints from the original smoke test — all closed
|
||||||
|
|
||||||
1. **HUD too cluttered.** ✅ Closed by `73cad7e` — readouts now sit in a 4-tier vertical stack with progressive disclosure of penalty/bonus tiers.
|
1. **HUD too cluttered.** ✅ Closed by `73cad7e` — readouts now sit in a 4-tier vertical stack with progressive disclosure of penalty/bonus tiers.
|
||||||
2. **Y/N keyboard prompts feel like debug panels.** ✅ Closed for the Confirm + GameOver modals (`3f922ed`, `242b5fe`); still open for the Forfeit toast countdown (folded into step 6 below).
|
2. **Y/N keyboard prompts feel like debug panels.** ✅ Closed across Confirm, GameOver, Pause, Forfeit, and Settings modals — every prompt now has real Primary/Secondary/Tertiary buttons with hover/press feedback.
|
||||||
|
|
||||||
## Foundation (done)
|
## Foundation (done)
|
||||||
|
|
||||||
- **`solitaire_engine/src/ui_theme.rs`** — every design token: colours, 5-rung typography scale, 4-multiple spacing scale, three radius rungs, monotonically-ordered z-index hierarchy, motion durations with `scaled_duration(speed)` helper.
|
- **`solitaire_engine/src/ui_theme.rs`** — every design token: colours, 5-rung typography scale, 4-multiple spacing scale, three radius rungs, monotonically-ordered z-index hierarchy, motion durations with `scaled_duration(speed)` helper.
|
||||||
- **`solitaire_engine/src/ui_modal.rs`** — `spawn_modal` scaffold + `spawn_modal_header` / `spawn_modal_body_text` / `spawn_modal_actions` / `spawn_modal_button` helpers + `ButtonVariant` enum (Primary / Secondary / Tertiary) + `paint_modal_buttons` system. `UiModalPlugin` registered in `solitaire_app/src/main.rs`.
|
- **`solitaire_engine/src/ui_modal.rs`** — `spawn_modal` scaffold + `spawn_modal_header` / `spawn_modal_body_text` / `spawn_modal_actions` / `spawn_modal_button` helpers + `ButtonVariant` enum (Primary / Secondary / Tertiary) + `paint_modal_buttons` system. `UiModalPlugin` registered in `solitaire_app/src/main.rs`.
|
||||||
|
|
||||||
Every overlay conversion follows the same pattern: drop the bespoke scrim/card, call `spawn_modal(MarkerComponent, Z_MODAL_PANEL, |card| { ... })`, swap inline colours for tokens, replace "Press X to close" prose with a primary `Done` button + matching click-handler system.
|
## Commits this session (Phase 3, latest first)
|
||||||
|
|
||||||
## Commits this session
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
54e024c chore(engine): final literal-to-token sweep
|
||||||
|
3a01318 feat(engine): upgrade animations — curves, scoped settle, deal jitter, cascade rotation
|
||||||
|
79d3917 chore(data): derive Copy on AnimSpeed
|
||||||
|
ba019c0 feat(engine): convert SettingsPanel to modal scaffold + Done button
|
||||||
|
18d7c12 feat(engine): convert OnboardingPlugin to 3-slide modal flow
|
||||||
|
cb93bd9 fix(engine): pin modals via GlobalZIndex and surface forfeit-no-op toast
|
||||||
|
6723416 feat(engine): convert PauseScreen to modal + add ForfeitConfirmScreen
|
||||||
|
afb0879 docs: add SESSION_HANDOFF.md mid-overhaul checkpoint
|
||||||
3b619b8 feat(engine): convert HomeScreen to modal scaffold + Done button
|
3b619b8 feat(engine): convert HomeScreen to modal scaffold + Done button
|
||||||
37681cf feat(engine): convert LeaderboardScreen to modal scaffold + Done button
|
37681cf feat(engine): convert LeaderboardScreen to modal scaffold + Done button
|
||||||
99064ce feat(engine): convert ProfileScreen to modal scaffold + Done button
|
99064ce feat(engine): convert ProfileScreen to modal scaffold + Done button
|
||||||
@@ -40,69 +46,46 @@ deb034c feat(engine): convert HelpScreen to real-button modal with kbd-chip rows
|
|||||||
e14852c feat(engine): add ui_theme.rs design-token module
|
e14852c feat(engine): add ui_theme.rs design-token module
|
||||||
```
|
```
|
||||||
|
|
||||||
**Test status across every commit:** `cargo build --workspace` clean, `cargo clippy --workspace -- -D warnings` clean, **797 tests pass / 0 failed / 8 ignored**.
|
**Test status:** `cargo build --workspace` clean, `cargo clippy --workspace -- -D warnings` clean, **819 tests pass / 0 failed / 8 ignored**.
|
||||||
|
|
||||||
## What's still on disk in pre-overhaul style
|
## Smoke-test checklist
|
||||||
|
|
||||||
| Step | Area | What it'll touch |
|
The whole overhaul is on disk. Worth running through once end-to-end:
|
||||||
|---|---|---|
|
|
||||||
| 6 | Pause overlay + Forfeit modal | `pause_plugin.rs` (replace bespoke spawn with modal scaffold); fold Forfeit toast countdown into a real Cancel/Forfeit modal — Forfeit then becomes a button inside Pause. Tests around `forfeit_countdown` will need updating. |
|
|
||||||
| 7 | Settings panel | `settings_plugin.rs` — replaces the side-panel pattern with the modal scaffold. Sections: Audio / Gameplay / Cosmetic / Sync. The existing per-button click handlers stay; only the spawn structure and styling change. |
|
|
||||||
| 8 | Onboarding | `onboarding_plugin.rs` — multi-slide flow (Welcome → Drag-drop demo → Hotkeys), navigated by a primary `Next` button + arrow-key accelerators. Final slide `Start playing` writes `first_run_complete` like today. |
|
|
||||||
| 9 | Animation upgrades | `animation_plugin.rs`, `feedback_anim_plugin.rs`, `card_animation/`, `win_summary_plugin.rs` — promote `card_animation::MotionCurve` to default for slide; settle bounce only on the moved card; deal stagger ±10% jitter; win cascade with `Expressive` curve + per-card rotation; align all durations through `ui_theme::scaled_duration`. |
|
|
||||||
| 10 | Literal-to-token sweep | Grep the engine for any remaining `Color::srgb`/`Val::Px(<literal>)`/font-size literal and migrate. Clippy gates this — anything left over surfaces during the build. |
|
|
||||||
|
|
||||||
ETA for the rest: roughly 5–7 more focused commits, each independently passing `build` / `clippy` / `test`.
|
1. **Run the game.** `cargo run -p solitaire_app --features bevy/dynamic_linking`.
|
||||||
|
2. **HUD layout** reads as 4 stacked tiers (Score / Mode / Penalty / Selection) with the new midnight-purple palette.
|
||||||
|
3. **Open every overlay** — `S` (Stats), `A` (Achievements), `P` (Profile), `O` (Settings), `L` (Leaderboard), `M` (Home), `F1` (Help). Each is a centred card on a uniform scrim with a yellow `Done` / `Close` primary button. Hover/press states on every button.
|
||||||
|
4. **Settings.** Four sections (Audio / Gameplay / Cosmetic / Sync). Body scrolls within the modal on small windows; `Done` button stays fixed at the bottom regardless of scroll. Card-back / Background pickers tint the selected swatch with `STATE_SUCCESS`.
|
||||||
|
5. **Confirm flow.** Click `New Game` while a game is in progress — the abandon-current-game modal has real Cancel/Confirm buttons. `Y/Enter` and the yellow primary button start a new game; `N/Esc` and the secondary button cancel.
|
||||||
|
6. **Pause + Forfeit.** Press `Esc` — pause modal shows real Resume / Forfeit buttons. Forfeit button opens a Cancel/Forfeit confirmation modal stacked above the pause modal (z-index ordered correctly via `GlobalZIndex`).
|
||||||
|
7. **First-run onboarding.** Delete `settings.json` (or set `first_run_complete = false`) — three-slide flow shows: Welcome → How to play → Keyboard shortcuts. Navigate with `Next` / `Back` buttons or `→` / `←` accelerators. `Esc` skips on slide 0.
|
||||||
|
8. **Animations.**
|
||||||
|
- Slide a card to a pile — motion curves through `SmoothSnap` (slight overshoot + settle), not linear lerp.
|
||||||
|
- Drop a card on a valid destination — only the moved cards bounce; the rest of the table stays still.
|
||||||
|
- Start a new game — deal stagger is no longer mechanically uniform; cards land with subtle ±10% timing variation.
|
||||||
|
- Win a game — cascade now uses `Expressive` curve with per-card ±15° Z-rotation, screen shake driven by the new `MOTION_WIN_SHAKE_*` tokens.
|
||||||
|
9. **Resize the window** — cards still snap, no "snap-back-and-forth" jitter.
|
||||||
|
10. **Win modal** — restyled with the design tokens: midnight-purple card, yellow `Play Again` button.
|
||||||
|
|
||||||
## Smoke-test checklist before resuming
|
## Open follow-ups (not blockers)
|
||||||
|
|
||||||
The user smoke-tested at the action-bar checkpoint and reported "nothing looks off". Worth re-running once these 11 commits are pushed so the next session starts from a verified baseline:
|
- **Home / Help redundancy.** Home is still a kbd-reference modal that mostly duplicates Help. Three options: (1) keep as-is, (2) convert into a true mode launcher (Classic / Daily / Zen / Challenge / Time Attack cards, locked options visibly disabled below level 5), (3) drop entirely now that the action bar covers everything Home does. Worth asking the user which direction they want.
|
||||||
|
- **Forfeit countdown toast** is now superseded by the Forfeit modal (`6723416`). Confirm the toast path is no longer reachable when smoke-testing.
|
||||||
|
- **Sub-rung pixel sizes** (1 px borders, 64/80/110/150/160 px fixed widths, 28/36/50 px specific spacings) were intentionally left as literals during the step-10 sweep — they're below the smallest `SPACE_*` rung. If the design system grows a "fine" spacing tier in the future, those become candidates for migration.
|
||||||
|
|
||||||
- HUD layout reads as 4 stacked tiers (Score / Mode / Penalty / Selection) with the new midnight-purple palette.
|
## Resume prompt for the next session
|
||||||
- Open every overlay (S, A, P, O, L, M, F1) — each should now be a centred card on a uniform scrim, with a `Done` primary button.
|
|
||||||
- Click `New Game` while a game is in progress — the abandon-current-game modal should be a real-button card; both `Y/Enter` and the yellow primary button start a new game; both `N/Esc` and the secondary button cancel.
|
|
||||||
- Force a stuck state — the Game Over modal should show real Undo / New Game buttons.
|
|
||||||
- Resize the window — cards still snap, no "snap-back-and-forth" jitter (covered by previous fixes `366fd6d` / `b10e1a5`).
|
|
||||||
- Press G to forfeit — still uses the toast countdown (step 6 will replace this with a modal).
|
|
||||||
|
|
||||||
## Kickoff prompt for the next session
|
|
||||||
|
|
||||||
Paste this at the start of a fresh chat to resume:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Resume the UX overhaul of Solitaire Quest at /home/manage/Rusty_Solitare/.
|
The Solitaire Quest UX overhaul Phase 3 is complete (HEAD=54e024c).
|
||||||
|
Read SESSION_HANDOFF.md and CLAUDE.md before doing anything new.
|
||||||
|
|
||||||
Read these in order before doing anything:
|
819 tests pass / 0 fail / 8 ignored. Clippy clean.
|
||||||
1. SESSION_HANDOFF.md (this file)
|
|
||||||
2. CLAUDE.md (project conventions; UI-first principle)
|
|
||||||
3. The "Design direction" section of
|
|
||||||
~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md
|
|
||||||
|
|
||||||
The foundation (ui_theme.rs, ui_modal.rs), the HUD restructure, and
|
Next likely directions:
|
||||||
all 8 read-only overlay conversions are committed. Steps 6 → 10 are
|
1. Smoke-test the build end-to-end and report regressions (see the
|
||||||
documented in SESSION_HANDOFF.md and listed in
|
checklist in SESSION_HANDOFF.md).
|
||||||
.claude/projects/.../memory/MEMORY.md.
|
2. Decide what to do with the Home modal (kbd ref vs mode launcher
|
||||||
|
vs delete).
|
||||||
Pick up at step 6 (Pause modal + folded-in Forfeit modal). Same
|
3. Phase 4 — feature work, sound design, or accessibility, depending
|
||||||
shape as the existing modal conversions: spawn_modal scaffold, real
|
on user priority.
|
||||||
buttons, hotkey-hint chips, ui_theme tokens for every colour /
|
|
||||||
spacing / typography decision. Update or replace any test that
|
|
||||||
asserted the prior bespoke layout.
|
|
||||||
|
|
||||||
Each commit must pass `cargo build --workspace`,
|
|
||||||
`cargo clippy --workspace -- -D warnings`, and `cargo test --workspace`
|
|
||||||
clean. Use commits authored by funman300 <root@vscode.infinity> via
|
|
||||||
the -c flag (per the user's convention; never write to git config).
|
|
||||||
|
|
||||||
Stop after step 6 and ask the user to smoke-test before continuing to
|
|
||||||
step 7. They prefer pause-and-verify over running through the
|
|
||||||
remaining 4 steps in one push.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Open question
|
|
||||||
|
|
||||||
When the next session resumes, ask whether the user wants to:
|
|
||||||
|
|
||||||
1. **Keep Home as a kbd-reference modal** (current state — duplicates Help).
|
|
||||||
2. **Convert Home into a true mode launcher** (Classic / Daily / Zen / Challenge / Time Attack cards, locked options visibly disabled below level 5) per the original Phase 2 proposal. The action-bar `Modes` popover already covers this path; pivoting Home buys discoverability for first-time players who hit `M`.
|
|
||||||
3. **Drop Home entirely.** With the action bar covering every action and Help covering every shortcut, Home may be redundant.
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const APP_DIR_NAME: &str = "solitaire_quest";
|
|||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
|
|
||||||
/// Animation playback speed for card transitions.
|
/// Animation playback speed for card transitions.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
pub enum AnimSpeed {
|
pub enum AnimSpeed {
|
||||||
/// Standard animation timing (default).
|
/// Standard animation timing (default).
|
||||||
#[default]
|
#[default]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use solitaire_data::AnimSpeed;
|
|||||||
|
|
||||||
use crate::achievement_plugin::display_name_for;
|
use crate::achievement_plugin::display_name_for;
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
|
use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
||||||
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
||||||
@@ -28,10 +29,18 @@ use crate::pause_plugin::PausedResource;
|
|||||||
use crate::progress_plugin::LevelUpEvent;
|
use crate::progress_plugin::LevelUpEvent;
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
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,
|
||||||
|
};
|
||||||
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
|
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
|
||||||
|
|
||||||
/// Duration of a card slide (move) animation in seconds at Normal speed.
|
/// Duration of a card slide (move) animation in seconds at Normal speed.
|
||||||
pub const SLIDE_SECS: f32 = 0.15;
|
///
|
||||||
|
/// Re-exported from `ui_theme::MOTION_SLIDE_SECS` so the entire engine pulls
|
||||||
|
/// gameplay slide timing from one design-token. Kept as a `pub const` for
|
||||||
|
/// backwards compatibility with existing callers that read this directly.
|
||||||
|
pub const SLIDE_SECS: f32 = MOTION_SLIDE_SECS;
|
||||||
|
|
||||||
/// The effective slide duration, updated whenever `Settings::animation_speed` changes.
|
/// The effective slide duration, updated whenever `Settings::animation_speed` changes.
|
||||||
#[derive(Resource, Debug, Clone, Copy)]
|
#[derive(Resource, Debug, Clone, Copy)]
|
||||||
@@ -46,11 +55,10 @@ impl Default for EffectiveSlideDuration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn anim_speed_to_secs(speed: &AnimSpeed) -> f32 {
|
fn anim_speed_to_secs(speed: &AnimSpeed) -> f32 {
|
||||||
match speed {
|
// Route through `ui_theme::scaled_duration` so the slide timing follows
|
||||||
AnimSpeed::Normal => SLIDE_SECS,
|
// the same `MOTION_*_SECS` token / `AnimSpeed` mapping as every other
|
||||||
AnimSpeed::Fast => 0.07,
|
// motion in the engine (toasts, deal stagger, shake, settle, cascade).
|
||||||
AnimSpeed::Instant => 0.0,
|
scaled_duration(MOTION_SLIDE_SECS, *speed)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const WIN_TOAST_SECS: f32 = 4.0;
|
const WIN_TOAST_SECS: f32 = 4.0;
|
||||||
@@ -63,38 +71,25 @@ const CHALLENGE_TOAST_SECS: f32 = 3.0;
|
|||||||
const VOLUME_TOAST_SECS: f32 = 1.4;
|
const VOLUME_TOAST_SECS: f32 = 1.4;
|
||||||
|
|
||||||
/// Per-card stagger interval for the win cascade at Normal speed (seconds).
|
/// Per-card stagger interval for the win cascade at Normal speed (seconds).
|
||||||
const CASCADE_STAGGER_NORMAL: f32 = 0.05;
|
|
||||||
/// Duration of each card's cascade slide at Normal speed (seconds).
|
|
||||||
const CASCADE_DURATION_NORMAL: f32 = 0.5;
|
|
||||||
|
|
||||||
/// Returns the per-card stagger delay for the win cascade at the given `AnimSpeed`.
|
|
||||||
///
|
///
|
||||||
/// | `AnimSpeed` | Returned value |
|
/// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS` so all motion timing
|
||||||
/// |-------------|----------------|
|
/// lives in one design-token module.
|
||||||
/// | `Normal` | 0.05 s |
|
const CASCADE_STAGGER_NORMAL: f32 = MOTION_CASCADE_STAGGER_SECS;
|
||||||
/// | `Fast` | 0.025 s |
|
/// Duration of each card's cascade slide at Normal speed (seconds).
|
||||||
/// | `Instant` | 0.0 s |
|
///
|
||||||
|
/// Sourced from `ui_theme::MOTION_CASCADE_SLIDE_SECS`.
|
||||||
|
const CASCADE_DURATION_NORMAL: f32 = MOTION_CASCADE_SLIDE_SECS;
|
||||||
|
|
||||||
|
/// Returns the per-card stagger delay for the win cascade at the given
|
||||||
|
/// `AnimSpeed`, scaled via `ui_theme::scaled_duration`.
|
||||||
pub fn cascade_step_secs(speed: AnimSpeed) -> f32 {
|
pub fn cascade_step_secs(speed: AnimSpeed) -> f32 {
|
||||||
match speed {
|
scaled_duration(MOTION_CASCADE_STAGGER_SECS, speed)
|
||||||
AnimSpeed::Normal => CASCADE_STAGGER_NORMAL,
|
|
||||||
AnimSpeed::Fast => CASCADE_STAGGER_NORMAL / 2.0,
|
|
||||||
AnimSpeed::Instant => 0.0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the slide duration for each card in the win cascade at the given `AnimSpeed`.
|
/// Returns the slide duration for each card in the win cascade at the given
|
||||||
///
|
/// `AnimSpeed`, scaled via `ui_theme::scaled_duration`.
|
||||||
/// | `AnimSpeed` | Returned value |
|
|
||||||
/// |-------------|----------------|
|
|
||||||
/// | `Normal` | 0.5 s |
|
|
||||||
/// | `Fast` | 0.25 s |
|
|
||||||
/// | `Instant` | 0.0 s |
|
|
||||||
pub fn cascade_duration_secs(speed: AnimSpeed) -> f32 {
|
pub fn cascade_duration_secs(speed: AnimSpeed) -> f32 {
|
||||||
match speed {
|
scaled_duration(MOTION_CASCADE_SLIDE_SECS, speed)
|
||||||
AnimSpeed::Normal => CASCADE_DURATION_NORMAL,
|
|
||||||
AnimSpeed::Fast => CASCADE_DURATION_NORMAL / 2.0,
|
|
||||||
AnimSpeed::Instant => 0.0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Linear-lerp slide animation.
|
/// Linear-lerp slide animation.
|
||||||
@@ -237,13 +232,35 @@ fn advance_card_anims(
|
|||||||
}
|
}
|
||||||
anim.elapsed += dt;
|
anim.elapsed += dt;
|
||||||
let t = (anim.elapsed / anim.duration).min(1.0);
|
let t = (anim.elapsed / anim.duration).min(1.0);
|
||||||
transform.translation = anim.start.lerp(anim.target, t);
|
// Curved interpolation using `MotionCurve::SmoothSnap` (cubic ease-out
|
||||||
|
// with a small terminal overshoot). Hardcoded at the call site so the
|
||||||
|
// shared `CardAnim` struct stays a simple linear-tween container — the
|
||||||
|
// upgrade is one extra `sample_curve` call per advancing animation.
|
||||||
|
let s = sample_curve(MotionCurve::SmoothSnap, t);
|
||||||
|
transform.translation = anim.start.lerp(anim.target, s);
|
||||||
if t >= 1.0 {
|
if t >= 1.0 {
|
||||||
|
transform.translation = anim.target;
|
||||||
commands.entity(entity).remove::<CardAnim>();
|
commands.entity(entity).remove::<CardAnim>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maximum per-card Z-rotation drift applied during the win cascade, in
|
||||||
|
/// radians. 15° gives a lively but legible scatter — anything larger starts
|
||||||
|
/// to look chaotic.
|
||||||
|
const WIN_CASCADE_MAX_ROTATION_RAD: f32 = std::f32::consts::PI / 12.0;
|
||||||
|
|
||||||
|
/// Returns a deterministic per-card Z-rotation in `±WIN_CASCADE_MAX_ROTATION_RAD`
|
||||||
|
/// for the win cascade. Indexing by the card's position in the iterator keeps
|
||||||
|
/// the result reproducible for a given deal without needing a random crate.
|
||||||
|
fn cascade_rotation(index: usize) -> f32 {
|
||||||
|
// Pseudo-random hash from a Fibonacci multiplier; same approach used by
|
||||||
|
// `card_animation::timing::micro_vary`. Returns 0..=1.
|
||||||
|
let hash = (index as u32).wrapping_mul(2_654_435_761);
|
||||||
|
let noise = (hash >> 16) as f32 / 65_536.0;
|
||||||
|
(noise - 0.5) * 2.0 * WIN_CASCADE_MAX_ROTATION_RAD
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_win_cascade(
|
fn handle_win_cascade(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: MessageReader<GameWonEvent>,
|
mut events: MessageReader<GameWonEvent>,
|
||||||
@@ -274,17 +291,44 @@ fn handle_win_cascade(
|
|||||||
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
|
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
|
||||||
spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS);
|
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.clone()));
|
let step = settings
|
||||||
let duration = settings.as_ref().map_or(CASCADE_DURATION_NORMAL, |s| cascade_duration_secs(s.0.animation_speed.clone()));
|
.as_ref()
|
||||||
|
.map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed));
|
||||||
|
let duration = settings
|
||||||
|
.as_ref()
|
||||||
|
.map_or(CASCADE_DURATION_NORMAL, |s| cascade_duration_secs(s.0.animation_speed));
|
||||||
|
|
||||||
for (i, (entity, transform)) in cards.iter().enumerate() {
|
for (i, (entity, transform)) in cards.iter().enumerate() {
|
||||||
commands.entity(entity).insert(CardAnim {
|
// Use the curve-aware `CardAnimation` here (not `CardAnim`) so we can
|
||||||
start: transform.translation,
|
// pick `MotionCurve::Expressive` for the cascade — the spring-style
|
||||||
target: targets[i % 8],
|
// overshoot is what gives the win moment its theatrical feel. The
|
||||||
|
// `CardAnim`/`CardAnimation` coexistence rule (one per entity) is
|
||||||
|
// satisfied because cards have neither at the moment the cascade
|
||||||
|
// starts.
|
||||||
|
let start = transform.translation;
|
||||||
|
let target = targets[i % 8];
|
||||||
|
commands.entity(entity).insert(CardAnimation {
|
||||||
|
start: start.truncate(),
|
||||||
|
end: target.truncate(),
|
||||||
elapsed: 0.0,
|
elapsed: 0.0,
|
||||||
duration,
|
duration,
|
||||||
|
curve: crate::card_animation::MotionCurve::Expressive,
|
||||||
delay: i as f32 * step,
|
delay: i as f32 * step,
|
||||||
|
start_z: start.z,
|
||||||
|
end_z: target.z,
|
||||||
|
z_lift: 0.0,
|
||||||
|
scale_start: 1.0,
|
||||||
|
scale_end: 1.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Per-card Z-rotation drift (±15°), deterministic per cascade
|
||||||
|
// ordering — gives the scatter a more lively feel without needing
|
||||||
|
// rotation interpolation in the tween system. Since cards fly off
|
||||||
|
// screen, the static rotation reads as motion.
|
||||||
|
let rot = cascade_rotation(i);
|
||||||
|
let mut new_transform = *transform;
|
||||||
|
new_transform.rotation = Quat::from_rotation_z(rot);
|
||||||
|
commands.entity(entity).insert(new_transform);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,19 +534,19 @@ fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
|||||||
left: Val::Percent(15.0),
|
left: Val::Percent(15.0),
|
||||||
top: Val::Percent(8.0),
|
top: Val::Percent(8.0),
|
||||||
width: Val::Percent(70.0),
|
width: Val::Percent(70.0),
|
||||||
padding: UiRect::axes(Val::Px(16.0), Val::Px(8.0)),
|
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.60)),
|
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.60)),
|
||||||
ZIndex(400),
|
ZIndex(Z_TOAST),
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
Text::new(message),
|
Text::new(message),
|
||||||
TextFont { font_size: 22.0, ..default() },
|
TextFont { font_size: 22.0, ..default() },
|
||||||
TextColor(Color::srgb(1.0, 1.0, 1.0)),
|
TextColor(TEXT_PRIMARY),
|
||||||
));
|
));
|
||||||
})
|
})
|
||||||
.id()
|
.id()
|
||||||
@@ -546,7 +590,7 @@ fn spawn_toast(commands: &mut Commands, message: String, duration_secs: f32) {
|
|||||||
left: Val::Percent(25.0),
|
left: Val::Percent(25.0),
|
||||||
top: Val::Percent(42.0),
|
top: Val::Percent(42.0),
|
||||||
width: Val::Percent(50.0),
|
width: Val::Percent(50.0),
|
||||||
padding: UiRect::axes(Val::Px(16.0), Val::Px(10.0)),
|
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
..default()
|
..default()
|
||||||
@@ -560,7 +604,7 @@ fn spawn_toast(commands: &mut Commands, message: String, duration_secs: f32) {
|
|||||||
font_size: 32.0,
|
font_size: 32.0,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
TextColor(ACCENT_PRIMARY),
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -584,13 +628,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn card_anim_at_half_elapsed_reaches_midpoint() {
|
fn card_anim_at_half_elapsed_passes_geometric_midpoint() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
|
||||||
let start = Vec3::ZERO;
|
let start = Vec3::ZERO;
|
||||||
let target = Vec3::new(100.0, 0.0, 0.0);
|
let target = Vec3::new(100.0, 0.0, 0.0);
|
||||||
// elapsed = 0.5, duration = 1.0 → t = 0.5 even when dt=0
|
// elapsed = 0.5, duration = 1.0 → t = 0.5 even when dt=0.
|
||||||
|
// With `MotionCurve::SmoothSnap` (cubic ease-out) the position at
|
||||||
|
// t=0.5 is well past the geometric midpoint — assert we're past 50
|
||||||
|
// but still short of the target so the animation is mid-flight.
|
||||||
let entity = app
|
let entity = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -602,7 +649,11 @@ mod tests {
|
|||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
|
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
|
||||||
assert!((pos.x - 50.0).abs() < 1e-3, "expected midpoint x=50, got {}", pos.x);
|
assert!(
|
||||||
|
pos.x > 50.0 && pos.x < 100.0,
|
||||||
|
"with SmoothSnap, t=0.5 should be past geometric midpoint but short of target; got {}",
|
||||||
|
pos.x
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
app.world().entity(entity).get::<CardAnim>().is_some(),
|
app.world().entity(entity).get::<CardAnim>().is_some(),
|
||||||
"animation not yet complete"
|
"animation not yet complete"
|
||||||
@@ -788,7 +839,7 @@ mod tests {
|
|||||||
|
|
||||||
let before = app
|
let before = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<&CardAnim>()
|
.query::<&CardAnimation>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(before, 0, "no animations before win");
|
assert_eq!(before, 0, "no animations before win");
|
||||||
@@ -799,10 +850,60 @@ mod tests {
|
|||||||
|
|
||||||
let after = app
|
let after = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<&CardAnim>()
|
.query::<&CardAnimation>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(after, 52, "all 52 cards should have cascade animations");
|
assert_eq!(
|
||||||
|
after, 52,
|
||||||
|
"all 52 cards should have curve-based cascade animations"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_cascade_uses_expressive_curve() {
|
||||||
|
let mut app = app_with_anim();
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let mut q = app.world_mut().query::<&CardAnimation>();
|
||||||
|
for anim in q.iter(app.world()) {
|
||||||
|
assert_eq!(
|
||||||
|
anim.curve,
|
||||||
|
MotionCurve::Expressive,
|
||||||
|
"win cascade must use the Expressive curve"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_cascade_applies_per_card_rotation() {
|
||||||
|
let mut app = app_with_anim();
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// At least one card's rotation must differ from identity — the
|
||||||
|
// deterministic hash will produce non-zero rotations for nearly all
|
||||||
|
// 52 indices.
|
||||||
|
let mut q = app.world_mut().query::<(&CardEntity, &Transform)>();
|
||||||
|
let any_rotated = q
|
||||||
|
.iter(app.world())
|
||||||
|
.any(|(_, t)| t.rotation.z.abs() > 1e-4 || t.rotation.w < 0.999);
|
||||||
|
assert!(any_rotated, "expected at least one card to receive a Z rotation drift");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cascade_rotation_stays_within_bounds() {
|
||||||
|
// Per-card rotation is capped at ±15° (≈ 0.2618 rad). Sampling a
|
||||||
|
// wider index range than a real deal exercises the hash distribution.
|
||||||
|
for i in 0..256 {
|
||||||
|
let r = cascade_rotation(i);
|
||||||
|
assert!(
|
||||||
|
r.abs() <= WIN_CASCADE_MAX_ROTATION_RAD + 1e-6,
|
||||||
|
"cascade_rotation({i}) = {r} exceeds the ±15° cap"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -810,8 +911,9 @@ mod tests {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cascade_step_normal_is_expected_value() {
|
fn cascade_step_normal_matches_design_token() {
|
||||||
assert!((cascade_step_secs(AnimSpeed::Normal) - 0.05).abs() < 1e-6);
|
// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS`.
|
||||||
|
assert!((cascade_step_secs(AnimSpeed::Normal) - MOTION_CASCADE_STAGGER_SECS).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -830,8 +932,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cascade_duration_normal_is_expected_value() {
|
fn cascade_duration_normal_matches_design_token() {
|
||||||
assert!((cascade_duration_secs(AnimSpeed::Normal) - 0.5).abs() < 1e-6);
|
// Sourced from `ui_theme::MOTION_CASCADE_SLIDE_SECS`.
|
||||||
|
assert!(
|
||||||
|
(cascade_duration_secs(AnimSpeed::Normal) - MOTION_CASCADE_SLIDE_SECS).abs() < 1e-6
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -11,10 +11,13 @@
|
|||||||
//!
|
//!
|
||||||
//! # Task #55 — Settle/bounce on valid placement
|
//! # Task #55 — Settle/bounce on valid placement
|
||||||
//!
|
//!
|
||||||
//! After `StateChangedEvent` fires, `start_settle_anim` inserts `SettleAnim`
|
//! `start_settle_anim` listens for `MoveRequestEvent` and `DrawRequestEvent` so
|
||||||
//! on the top card of every non-empty pile. `tick_settle_anim` applies a brief
|
//! the bounce is **scoped to the cards that just moved**, not every top card on
|
||||||
//! Y-scale compression (`scale.y` 1.0 → 0.92 → 1.0 over 0.15 s) and removes
|
//! the board. For a move it bounces the top `count` cards of the destination
|
||||||
//! the component when elapsed ≥ 0.15 s.
|
//! pile; for a draw it bounces the top card of the waste. Undos are skipped so
|
||||||
|
//! reverting a move doesn't replay the placement feedback. `tick_settle_anim`
|
||||||
|
//! applies a brief Y-scale compression (`scale.y` 1.0 → 0.92 → 1.0 over 0.15 s)
|
||||||
|
//! and removes the component when elapsed ≥ 0.15 s.
|
||||||
//!
|
//!
|
||||||
//! # Task #69 — Animated card deal on new game start
|
//! # Task #69 — Animated card deal on new game start
|
||||||
//!
|
//!
|
||||||
@@ -22,17 +25,21 @@
|
|||||||
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
|
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
|
||||||
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
|
//! 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
|
//! pile's position to its current (final) position with a per-card stagger
|
||||||
//! derived from the current `AnimSpeed` setting:
|
//! derived from the current `AnimSpeed` setting plus a deterministic ±10 %
|
||||||
|
//! jitter per card so the deal feels organic instead of mechanical:
|
||||||
//!
|
//!
|
||||||
//! | `AnimSpeed` | Stagger |
|
//! | `AnimSpeed` | Base stagger |
|
||||||
//! |---------------|-------------------|
|
//! |---------------|-------------------|
|
||||||
//! | `Normal` | 0.04 s (default) |
|
//! | `Normal` | 0.04 s (default) |
|
||||||
//! | `Fast` | 0.02 s (half) |
|
//! | `Fast` | 0.02 s (half) |
|
||||||
//! | `Instant` | 0.00 s (no delay) |
|
//! | `Instant` | 0.00 s (no delay) |
|
||||||
//!
|
//!
|
||||||
//! `deal_stagger_delay` is a pure helper exposed for unit testing.
|
//! `deal_stagger_delay` and `deal_stagger_jitter` are pure helpers exposed for
|
||||||
|
//! unit testing.
|
||||||
|
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::f32::consts::PI;
|
use std::f32::consts::PI;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
@@ -40,7 +47,9 @@ use solitaire_data::AnimSpeed;
|
|||||||
|
|
||||||
use crate::animation_plugin::CardAnim;
|
use crate::animation_plugin::CardAnim;
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::{MoveRejectedEvent, NewGameRequestEvent, StateChangedEvent};
|
use crate::events::{
|
||||||
|
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
||||||
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
@@ -155,6 +164,23 @@ pub fn deal_stagger_delay(index: usize, stagger_secs: f32) -> f32 {
|
|||||||
index as f32 * stagger_secs
|
index as f32 * stagger_secs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a deterministic ±10 % jitter factor for `card_id`.
|
||||||
|
///
|
||||||
|
/// Hashes `card_id` with `DefaultHasher` and maps the low bits into a value in
|
||||||
|
/// `0.0..=1.0`, then re-centres into `-0.1..=0.1`. The same card id always
|
||||||
|
/// produces the same factor so deals are reproducible (important for
|
||||||
|
/// seed-based testing and replay), while a 52-card deal still feels organic
|
||||||
|
/// because each card's offset varies.
|
||||||
|
///
|
||||||
|
/// Multiply a base stagger interval by `1.0 + deal_stagger_jitter(card_id)` to
|
||||||
|
/// apply the jitter.
|
||||||
|
pub fn deal_stagger_jitter(card_id: u32) -> f32 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
card_id.hash(&mut hasher);
|
||||||
|
let jitter_norm = (hasher.finish() % 1000) as f32 / 1000.0; // 0.0..=1.0
|
||||||
|
(jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 %
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -164,16 +190,23 @@ pub struct FeedbackAnimPlugin;
|
|||||||
|
|
||||||
impl Plugin for FeedbackAnimPlugin {
|
impl Plugin for FeedbackAnimPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_systems(
|
// Register the events this plugin consumes so it can run in isolation
|
||||||
Update,
|
// under `MinimalPlugins` (e.g. unit tests) without depending on other
|
||||||
(
|
// plugins to register them. Double-registration is idempotent in Bevy.
|
||||||
start_shake_anim.after(GameMutation),
|
app.add_message::<MoveRequestEvent>()
|
||||||
tick_shake_anim,
|
.add_message::<DrawRequestEvent>()
|
||||||
start_settle_anim.after(GameMutation),
|
.add_message::<MoveRejectedEvent>()
|
||||||
tick_settle_anim,
|
.add_message::<NewGameRequestEvent>()
|
||||||
start_deal_anim.after(GameMutation),
|
.add_systems(
|
||||||
),
|
Update,
|
||||||
);
|
(
|
||||||
|
start_shake_anim.after(GameMutation),
|
||||||
|
tick_shake_anim,
|
||||||
|
start_settle_anim.after(GameMutation),
|
||||||
|
tick_settle_anim,
|
||||||
|
start_deal_anim.after(GameMutation),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,28 +273,52 @@ fn tick_shake_anim(
|
|||||||
// Task #55 — Settle systems
|
// Task #55 — Settle systems
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Inserts `SettleAnim` on the top card of every non-empty pile when
|
/// Inserts `SettleAnim` only on the cards that just moved — the top `count`
|
||||||
/// `StateChangedEvent` fires.
|
/// cards of the move destination, or the top of the waste pile for a draw.
|
||||||
|
///
|
||||||
|
/// Triggered by `MoveRequestEvent` and `DrawRequestEvent`. Undo and other
|
||||||
|
/// state-mutations are deliberately skipped: replaying the placement bounce on
|
||||||
|
/// an undo would feel like the rejected-move shake fired by mistake. Note this
|
||||||
|
/// runs before the move resolves in `GameMutation`, so we read the destination
|
||||||
|
/// pile **after** the request has been accepted by reading the up-to-date game
|
||||||
|
/// state for both readers — the schedule labels the system `.after(GameMutation)`
|
||||||
|
/// to ensure that ordering.
|
||||||
fn start_settle_anim(
|
fn start_settle_anim(
|
||||||
mut events: MessageReader<StateChangedEvent>,
|
mut moves: MessageReader<MoveRequestEvent>,
|
||||||
|
mut draws: MessageReader<DrawRequestEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
card_entities: Query<(Entity, &CardEntity)>,
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
if events.read().next().is_none() {
|
// Build the list of card ids that should bounce this frame from every
|
||||||
|
// queued request; multiple events can fire in the same frame (e.g. a move
|
||||||
|
// followed by a draw via keyboard accelerators).
|
||||||
|
let mut bounce_ids: Vec<u32> = Vec::new();
|
||||||
|
|
||||||
|
for ev in moves.read() {
|
||||||
|
if let Some(pile) = game.0.piles.get(&ev.to) {
|
||||||
|
// The moved cards land on top — take the last `count` ids.
|
||||||
|
let n = ev.count.min(pile.cards.len());
|
||||||
|
if n > 0 {
|
||||||
|
let start = pile.cards.len() - n;
|
||||||
|
bounce_ids.extend(pile.cards[start..].iter().map(|c| c.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if draws.read().next().is_some()
|
||||||
|
&& let Some(pile) = game.0.piles.get(&PileType::Waste)
|
||||||
|
&& let Some(top) = pile.cards.last()
|
||||||
|
{
|
||||||
|
bounce_ids.push(top.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if bounce_ids.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect the id of the top card for each non-empty pile.
|
|
||||||
let top_ids: Vec<u32> = game
|
|
||||||
.0
|
|
||||||
.piles
|
|
||||||
.values()
|
|
||||||
.filter_map(|p| p.cards.last().map(|c| c.id))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for (entity, card_marker) in card_entities.iter() {
|
for (entity, card_marker) in card_entities.iter() {
|
||||||
if top_ids.contains(&card_marker.card_id) {
|
if bounce_ids.contains(&card_marker.card_id) {
|
||||||
commands.entity(entity).insert(SettleAnim::default());
|
commands.entity(entity).insert(SettleAnim::default());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,7 +365,7 @@ fn start_deal_anim(
|
|||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
card_entities: Query<(Entity, &Transform), With<CardEntity>>,
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
if events.read().next().is_none() {
|
if events.read().next().is_none() {
|
||||||
@@ -327,8 +384,12 @@ fn start_deal_anim(
|
|||||||
.map(deal_stagger_secs_for_speed)
|
.map(deal_stagger_secs_for_speed)
|
||||||
.unwrap_or(DEAL_STAGGER_SECS);
|
.unwrap_or(DEAL_STAGGER_SECS);
|
||||||
|
|
||||||
for (index, (entity, transform)) in card_entities.iter().enumerate() {
|
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
|
||||||
let final_pos = transform.translation;
|
let final_pos = transform.translation;
|
||||||
|
// ±10 % jitter, deterministic per card id, so the deal feels organic
|
||||||
|
// without losing reproducibility (a given seed still produces the
|
||||||
|
// same per-card stagger pattern across runs).
|
||||||
|
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id));
|
||||||
commands.entity(entity).insert((
|
commands.entity(entity).insert((
|
||||||
Transform::from_translation(stock_start.with_z(final_pos.z)),
|
Transform::from_translation(stock_start.with_z(final_pos.z)),
|
||||||
CardAnim {
|
CardAnim {
|
||||||
@@ -336,7 +397,7 @@ fn start_deal_anim(
|
|||||||
target: final_pos,
|
target: final_pos,
|
||||||
elapsed: 0.0,
|
elapsed: 0.0,
|
||||||
duration: DEAL_SLIDE_SECS,
|
duration: DEAL_SLIDE_SECS,
|
||||||
delay: deal_stagger_delay(index, stagger_secs),
|
delay: deal_stagger_delay(index, per_card_stagger),
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -449,4 +510,44 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 9 — deal stagger jitter helper
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deal_stagger_jitter_is_within_ten_percent() {
|
||||||
|
// Every card id in 0..256 must produce a jitter factor in ±10 %.
|
||||||
|
for card_id in 0u32..256 {
|
||||||
|
let j = deal_stagger_jitter(card_id);
|
||||||
|
assert!(
|
||||||
|
(-0.1..=0.1).contains(&j),
|
||||||
|
"deal_stagger_jitter({card_id}) = {j} is outside ±10 %"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deal_stagger_jitter_is_deterministic() {
|
||||||
|
// Same card id must always produce the same jitter factor.
|
||||||
|
for card_id in [0u32, 7, 51, 999_999] {
|
||||||
|
assert!(
|
||||||
|
(deal_stagger_jitter(card_id) - deal_stagger_jitter(card_id)).abs() < 1e-9,
|
||||||
|
"deal_stagger_jitter({card_id}) is not deterministic"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deal_stagger_jitter_varies_across_card_ids() {
|
||||||
|
// 52 cards should produce more than a couple distinct jitter factors;
|
||||||
|
// a constant function would return one value for all ids.
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let unique: HashSet<u64> = (0u32..52)
|
||||||
|
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
unique.len() > 10,
|
||||||
|
"expected > 10 distinct jitter factors for 52 cards, got {}",
|
||||||
|
unique.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
|
|||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
|
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
|
||||||
BORDER_SUBTLE, RADIUS_MD, STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING,
|
BORDER_SUBTLE, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING,
|
||||||
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE,
|
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE,
|
||||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
};
|
};
|
||||||
@@ -387,10 +387,10 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
|
|||||||
.spawn((
|
.spawn((
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
right: Val::Px(12.0),
|
right: VAL_SPACE_3,
|
||||||
top: Val::Px(8.0),
|
top: VAL_SPACE_2,
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
column_gap: Val::Px(8.0),
|
column_gap: VAL_SPACE_2,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
@@ -560,12 +560,12 @@ fn spawn_modes_popover(
|
|||||||
ModesPopover,
|
ModesPopover,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
right: Val::Px(12.0),
|
right: VAL_SPACE_3,
|
||||||
top: Val::Px(50.0),
|
top: Val::Px(50.0),
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
row_gap: Val::Px(4.0),
|
row_gap: VAL_SPACE_1,
|
||||||
padding: UiRect::all(Val::Px(8.0)),
|
padding: UiRect::all(VAL_SPACE_2),
|
||||||
border_radius: BorderRadius::all(Val::Px(6.0)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(BG_ELEVATED),
|
BackgroundColor(BG_ELEVATED),
|
||||||
@@ -579,11 +579,11 @@ fn spawn_modes_popover(
|
|||||||
ActionButton,
|
ActionButton,
|
||||||
Button,
|
Button,
|
||||||
Node {
|
Node {
|
||||||
padding: UiRect::axes(Val::Px(12.0), Val::Px(6.0)),
|
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
||||||
justify_content: JustifyContent::FlexStart,
|
justify_content: JustifyContent::FlexStart,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
min_width: Val::Px(150.0),
|
min_width: Val::Px(150.0),
|
||||||
border_radius: BorderRadius::all(Val::Px(4.0)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(ACTION_BTN_IDLE),
|
BackgroundColor(ACTION_BTN_IDLE),
|
||||||
@@ -689,12 +689,12 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
MenuPopover,
|
MenuPopover,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
right: Val::Px(12.0),
|
right: VAL_SPACE_3,
|
||||||
top: Val::Px(50.0),
|
top: Val::Px(50.0),
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
row_gap: Val::Px(4.0),
|
row_gap: VAL_SPACE_1,
|
||||||
padding: UiRect::all(Val::Px(8.0)),
|
padding: UiRect::all(VAL_SPACE_2),
|
||||||
border_radius: BorderRadius::all(Val::Px(6.0)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(BG_ELEVATED),
|
BackgroundColor(BG_ELEVATED),
|
||||||
@@ -708,11 +708,11 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
ActionButton,
|
ActionButton,
|
||||||
Button,
|
Button,
|
||||||
Node {
|
Node {
|
||||||
padding: UiRect::axes(Val::Px(12.0), Val::Px(6.0)),
|
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
||||||
justify_content: JustifyContent::FlexStart,
|
justify_content: JustifyContent::FlexStart,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
min_width: Val::Px(150.0),
|
min_width: Val::Px(150.0),
|
||||||
border_radius: BorderRadius::all(Val::Px(4.0)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(ACTION_BTN_IDLE),
|
BackgroundColor(ACTION_BTN_IDLE),
|
||||||
|
|||||||
@@ -1,155 +1,430 @@
|
|||||||
//! First-run onboarding banner.
|
//! First-run onboarding multi-slide flow.
|
||||||
//!
|
//!
|
||||||
//! On startup, if `Settings.first_run_complete` is `false`, spawn a centered
|
//! On startup, if `Settings.first_run_complete` is `false`, a three-slide
|
||||||
//! welcome banner pointing at the **F1** cheat sheet. The first key or
|
//! modal flow is shown. The player navigates with a primary `Next` button
|
||||||
//! mouse-button press dismisses it, sets the flag, and persists settings —
|
//! (`→` / `Enter` accelerators) and a secondary `Back` button (`←`).
|
||||||
//! so returning players never see it again.
|
//! The final slide's primary button is `Start playing`, which sets
|
||||||
|
//! `first_run_complete = true` and persists settings — exactly as the
|
||||||
|
//! previous single-screen implementation did.
|
||||||
//!
|
//!
|
||||||
//! **Key highlights** (#49): The key names **D** and **U** inside the
|
//! Slides:
|
||||||
//! instructional text are rendered in a bright orange colour via `TextSpan`
|
//!
|
||||||
//! children tagged with `KeyHighlightSpan`.
|
//! 1. **Welcome** — brief introduction to Solitaire Quest.
|
||||||
|
//! 2. **How to play** — drag-and-drop, double-click, and right-click hints.
|
||||||
|
//! 3. **Keyboard shortcuts** — a summary pulled from the same canonical list
|
||||||
|
//! used in `HelpScreen`. Accelerators: `Esc` anywhere in the flow skips
|
||||||
|
//! the whole thing (equivalent to `first_run_complete = true`).
|
||||||
|
//!
|
||||||
|
//! Slide state is tracked by the [`OnboardingSlideIndex`] resource (0-based,
|
||||||
|
//! max `SLIDE_COUNT - 1`). Button clicks and keyboard accelerators update the
|
||||||
|
//! resource, then `rebuild_slide` despawns the current modal and respawns the
|
||||||
|
//! next one.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_data::{save_settings_to, Settings};
|
use solitaire_data::{save_settings_to, Settings};
|
||||||
|
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
|
use crate::ui_modal::{
|
||||||
|
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||||
|
spawn_modal_header, ButtonVariant,
|
||||||
|
};
|
||||||
|
use crate::ui_theme::{
|
||||||
|
BORDER_SUBTLE, RADIUS_SM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_CAPTION, TYPE_BODY, VAL_SPACE_1,
|
||||||
|
VAL_SPACE_2, VAL_SPACE_3, Z_ONBOARDING,
|
||||||
|
};
|
||||||
|
|
||||||
/// Marker on the onboarding overlay root node.
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Total number of onboarding slides (0-based index goes 0..SLIDE_COUNT-1).
|
||||||
|
const SLIDE_COUNT: u8 = 3;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Components (private — never re-exported)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker on the onboarding overlay scrim (root entity for this modal).
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct OnboardingScreen;
|
pub struct OnboardingScreen;
|
||||||
|
|
||||||
/// Marker on `TextSpan` entities that display a key name (D, U …) in the
|
/// Marker on the `Next` / `Start playing` primary button.
|
||||||
/// onboarding banner. Colour distinct from body text; usable by tests and any
|
|
||||||
/// future flash-animation system.
|
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct KeyHighlightSpan;
|
struct OnboardingNextButton;
|
||||||
|
|
||||||
/// Body text colour — golden yellow matching the rest of the UI.
|
/// Marker on the `Back` secondary button.
|
||||||
const BODY_COLOR: Color = Color::srgb(1.0, 0.87, 0.0);
|
#[derive(Component, Debug)]
|
||||||
|
struct OnboardingBackButton;
|
||||||
|
|
||||||
/// Bright orange used for key-name spans so they stand out from body text.
|
/// Marker on the `Skip` tertiary button (slide 0 only).
|
||||||
const KEY_COLOR: Color = Color::srgb(1.0, 0.55, 0.1);
|
#[derive(Component, Debug)]
|
||||||
|
struct OnboardingSkipButton;
|
||||||
|
|
||||||
/// Shows a first-run welcome screen that introduces the controls and draw mode.
|
// ---------------------------------------------------------------------------
|
||||||
/// Sets `Settings::first_run_complete` once dismissed so it never appears again.
|
// Resource
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Which slide (0-indexed) the player is currently viewing.
|
||||||
|
///
|
||||||
|
/// Persists across the despawn/respawn cycle so the rebuild system knows
|
||||||
|
/// which slide to spawn next.
|
||||||
|
#[derive(Resource, Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct OnboardingSlideIndex(pub u8);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the
|
||||||
|
// two screens stay in sync without a shared abstraction.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A single `key — description` pair shown on slide 3.
|
||||||
|
struct HotkeyRow {
|
||||||
|
keys: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most-used shortcuts from the `help_plugin` canonical list.
|
||||||
|
///
|
||||||
|
/// Updating the list in `help_plugin.rs` should be mirrored here. The
|
||||||
|
/// ARCHITECTURE.md decision log calls out that we copy values rather than
|
||||||
|
/// refactor the help plugin.
|
||||||
|
const HOTKEYS: &[HotkeyRow] = &[
|
||||||
|
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
|
||||||
|
HotkeyRow { keys: "U", description: "Undo last move" },
|
||||||
|
HotkeyRow { keys: "N", description: "New Classic game" },
|
||||||
|
HotkeyRow { keys: "S", description: "Stats & progression" },
|
||||||
|
HotkeyRow { keys: "A", description: "Achievements" },
|
||||||
|
HotkeyRow { keys: "O", description: "Settings" },
|
||||||
|
HotkeyRow { keys: "Esc", description: "Pause / resume" },
|
||||||
|
HotkeyRow { keys: "F1", description: "Help / controls" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Drives the first-run multi-slide onboarding flow.
|
||||||
pub struct OnboardingPlugin;
|
pub struct OnboardingPlugin;
|
||||||
|
|
||||||
impl Plugin for OnboardingPlugin {
|
impl Plugin for OnboardingPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_systems(PostStartup, spawn_if_first_run)
|
app.init_resource::<OnboardingSlideIndex>()
|
||||||
.add_systems(Update, dismiss_on_any_input);
|
.add_systems(PostStartup, spawn_if_first_run)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
handle_onboarding_buttons,
|
||||||
|
handle_onboarding_keyboard,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_if_first_run(mut commands: Commands, settings: Option<Res<SettingsResource>>) {
|
// ---------------------------------------------------------------------------
|
||||||
let Some(s) = settings else {
|
// Startup
|
||||||
return;
|
// ---------------------------------------------------------------------------
|
||||||
};
|
|
||||||
|
fn spawn_if_first_run(
|
||||||
|
mut commands: Commands,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut slide_index: ResMut<OnboardingSlideIndex>,
|
||||||
|
) {
|
||||||
|
let Some(s) = settings else { return };
|
||||||
if s.0.first_run_complete {
|
if s.0.first_run_complete {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
spawn_onboarding_screen(&mut commands);
|
slide_index.0 = 0;
|
||||||
|
spawn_slide(&mut commands, 0, font_res.as_deref());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dismiss_on_any_input(
|
// ---------------------------------------------------------------------------
|
||||||
|
// Button click handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn handle_onboarding_buttons(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
next_buttons: Query<&Interaction, (With<OnboardingNextButton>, Changed<Interaction>)>,
|
||||||
mouse: Res<ButtonInput<MouseButton>>,
|
back_buttons: Query<&Interaction, (With<OnboardingBackButton>, Changed<Interaction>)>,
|
||||||
mut settings: ResMut<SettingsResource>,
|
skip_buttons: Query<&Interaction, (With<OnboardingSkipButton>, Changed<Interaction>)>,
|
||||||
path: Option<Res<SettingsStoragePath>>,
|
|
||||||
screens: Query<Entity, With<OnboardingScreen>>,
|
screens: Query<Entity, With<OnboardingScreen>>,
|
||||||
|
mut slide_index: ResMut<OnboardingSlideIndex>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
path: Option<Res<SettingsStoragePath>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
let Ok(entity) = screens.single() else {
|
let next_pressed = next_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
return;
|
let back_pressed = back_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
};
|
let skip_pressed = skip_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
let pressed = keys.get_just_pressed().next().is_some()
|
|
||||||
|| mouse.get_just_pressed().next().is_some();
|
if !next_pressed && !back_pressed && !skip_pressed {
|
||||||
if !pressed {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
commands.entity(entity).despawn();
|
|
||||||
settings.0.first_run_complete = true;
|
if skip_pressed || (next_pressed && slide_index.0 == SLIDE_COUNT - 1) {
|
||||||
persist(path.as_deref().map(|p| &p.0), &settings.0);
|
// Skip or final-slide "Start playing" — complete onboarding.
|
||||||
|
complete_onboarding(
|
||||||
|
&mut commands,
|
||||||
|
&screens,
|
||||||
|
settings.as_deref_mut(),
|
||||||
|
path.as_deref(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate between slides.
|
||||||
|
let new_index = if next_pressed {
|
||||||
|
(slide_index.0 + 1).min(SLIDE_COUNT - 1)
|
||||||
|
} else {
|
||||||
|
slide_index.0.saturating_sub(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
if new_index != slide_index.0 {
|
||||||
|
despawn_screen(&mut commands, &screens);
|
||||||
|
slide_index.0 = new_index;
|
||||||
|
spawn_slide(&mut commands, new_index, font_res.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Keyboard accelerator handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn handle_onboarding_keyboard(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
screens: Query<Entity, With<OnboardingScreen>>,
|
||||||
|
mut slide_index: ResMut<OnboardingSlideIndex>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
path: Option<Res<SettingsStoragePath>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if screens.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let advance = keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter);
|
||||||
|
let retreat = keys.just_pressed(KeyCode::ArrowLeft);
|
||||||
|
let skip = keys.just_pressed(KeyCode::Escape);
|
||||||
|
|
||||||
|
if skip || (advance && slide_index.0 == SLIDE_COUNT - 1) {
|
||||||
|
complete_onboarding(
|
||||||
|
&mut commands,
|
||||||
|
&screens,
|
||||||
|
settings.as_deref_mut(),
|
||||||
|
path.as_deref(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if advance {
|
||||||
|
let new_index = (slide_index.0 + 1).min(SLIDE_COUNT - 1);
|
||||||
|
if new_index != slide_index.0 {
|
||||||
|
despawn_screen(&mut commands, &screens);
|
||||||
|
slide_index.0 = new_index;
|
||||||
|
spawn_slide(&mut commands, new_index, font_res.as_deref());
|
||||||
|
}
|
||||||
|
} else if retreat && slide_index.0 > 0 {
|
||||||
|
let new_index = slide_index.0 - 1;
|
||||||
|
despawn_screen(&mut commands, &screens);
|
||||||
|
slide_index.0 = new_index;
|
||||||
|
spawn_slide(&mut commands, new_index, font_res.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn despawn_screen(commands: &mut Commands, screens: &Query<Entity, With<OnboardingScreen>>) {
|
||||||
|
for entity in screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete_onboarding(
|
||||||
|
commands: &mut Commands,
|
||||||
|
screens: &Query<Entity, With<OnboardingScreen>>,
|
||||||
|
settings: Option<&mut SettingsResource>,
|
||||||
|
path: Option<&SettingsStoragePath>,
|
||||||
|
) {
|
||||||
|
despawn_screen(commands, screens);
|
||||||
|
if let Some(s) = settings {
|
||||||
|
s.0.first_run_complete = true;
|
||||||
|
persist(path.map(|p| &p.0), &s.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist(path: Option<&Option<PathBuf>>, settings: &Settings) {
|
fn persist(path: Option<&Option<PathBuf>>, settings: &Settings) {
|
||||||
let Some(Some(target)) = path else {
|
let Some(Some(target)) = path else { return };
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Err(e) = save_settings_to(target, settings) {
|
if let Err(e) = save_settings_to(target, settings) {
|
||||||
warn!("failed to save settings (onboarding): {e}");
|
warn!("failed to save settings (onboarding): {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_onboarding_screen(commands: &mut Commands) {
|
// ---------------------------------------------------------------------------
|
||||||
commands
|
// Slide spawning
|
||||||
.spawn((
|
// ---------------------------------------------------------------------------
|
||||||
OnboardingScreen,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: Val::Percent(0.0),
|
|
||||||
top: Val::Percent(0.0),
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Percent(100.0),
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
row_gap: Val::Px(8.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.92)),
|
|
||||||
ZIndex(230),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
// Title
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Welcome to Solitaire Quest!"),
|
|
||||||
TextFont { font_size: 40.0, ..default() },
|
|
||||||
TextColor(BODY_COLOR),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Spacer
|
fn spawn_slide(commands: &mut Commands, index: u8, font_res: Option<&FontResource>) {
|
||||||
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
|
match index {
|
||||||
|
0 => spawn_slide_welcome(commands, font_res),
|
||||||
// Instruction line: "Drag cards between piles. Press D to draw, U to undo."
|
1 => spawn_slide_how_to_play(commands, font_res),
|
||||||
// D is tagged KeyHighlightSpan; U uses KEY_COLOR but not the marker.
|
2 => spawn_slide_hotkeys(commands, font_res),
|
||||||
b.spawn((
|
_ => spawn_slide_welcome(commands, font_res),
|
||||||
Text::new("Drag cards between piles. Press "),
|
}
|
||||||
TextFont { font_size: 22.0, ..default() },
|
|
||||||
TextColor(BODY_COLOR),
|
|
||||||
))
|
|
||||||
.with_children(|t| {
|
|
||||||
t.spawn((
|
|
||||||
TextSpan::new("D"),
|
|
||||||
TextColor(KEY_COLOR),
|
|
||||||
KeyHighlightSpan,
|
|
||||||
));
|
|
||||||
t.spawn((TextSpan::new(" to draw, "), TextColor(BODY_COLOR)));
|
|
||||||
t.spawn((TextSpan::new("U"), TextColor(KEY_COLOR)));
|
|
||||||
t.spawn((TextSpan::new(" to undo."), TextColor(BODY_COLOR)));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Help line: "Press F1 at any time to see the full controls."
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Press F1 at any time to see the full controls."),
|
|
||||||
TextFont { font_size: 22.0, ..default() },
|
|
||||||
TextColor(BODY_COLOR),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Spacer
|
|
||||||
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
|
|
||||||
|
|
||||||
// Dismiss hint
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Press any key to begin"),
|
|
||||||
TextFont { font_size: 20.0, ..default() },
|
|
||||||
TextColor(Color::srgb(0.8, 0.8, 0.8)),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Slide 1 — Welcome.
|
||||||
|
fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
|
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||||
|
spawn_modal_header(card, "Welcome to Solitaire Quest", font_res);
|
||||||
|
spawn_modal_body_text(
|
||||||
|
card,
|
||||||
|
"Solitaire Quest is a free, offline-first Klondike Solitaire game. \
|
||||||
|
Play classic draw-1 or draw-3 Klondike, earn XP, unlock achievements, \
|
||||||
|
and compete on the leaderboard. Your progress is saved locally — \
|
||||||
|
optional sync to your own server keeps it in step across all your devices.",
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
OnboardingSkipButton,
|
||||||
|
"Skip",
|
||||||
|
Some("Esc"),
|
||||||
|
ButtonVariant::Tertiary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
OnboardingNextButton,
|
||||||
|
"Next",
|
||||||
|
Some("→"),
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slide 2 — How to play.
|
||||||
|
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
|
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||||
|
spawn_modal_header(card, "Drag cards to play", font_res);
|
||||||
|
spawn_modal_body_text(
|
||||||
|
card,
|
||||||
|
"Left-click and drag any face-up card to move it between piles. \
|
||||||
|
You can drag a whole column at once by grabbing the topmost card \
|
||||||
|
you want to move. Double-click a face-up card to send it to a \
|
||||||
|
foundation pile automatically (when the move is legal). \
|
||||||
|
Right-click a card for a hint — valid destinations will highlight.",
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
OnboardingBackButton,
|
||||||
|
"Back",
|
||||||
|
Some("←"),
|
||||||
|
ButtonVariant::Secondary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
OnboardingNextButton,
|
||||||
|
"Next",
|
||||||
|
Some("→"),
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slide 3 — Keyboard shortcuts.
|
||||||
|
fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_row = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_kbd = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||||
|
spawn_modal_header(card, "Keyboard shortcuts", font_res);
|
||||||
|
|
||||||
|
// Vertical list of `key — description` rows, same chip style as HelpScreen.
|
||||||
|
for row in HOTKEYS {
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|line| {
|
||||||
|
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),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
OnboardingBackButton,
|
||||||
|
"Back",
|
||||||
|
Some("←"),
|
||||||
|
ButtonVariant::Secondary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
OnboardingNextButton,
|
||||||
|
"Start playing",
|
||||||
|
Some("→"),
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -172,17 +447,31 @@ mod tests {
|
|||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_slide(app: &App) -> u8 {
|
||||||
|
app.world().resource::<OnboardingSlideIndex>().0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press_key(app: &mut App, key: KeyCode) {
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(key);
|
||||||
|
input.clear();
|
||||||
|
input.press(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Basic visibility
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn first_run_spawns_banner() {
|
fn first_run_spawns_onboarding() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.update(); // PostStartup runs
|
app.update(); // PostStartup runs
|
||||||
assert_eq!(count_screens(&mut app), 1);
|
assert_eq!(count_screens(&mut app), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn returning_player_does_not_see_banner() {
|
fn returning_player_does_not_see_onboarding() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Mark already-completed before PostStartup runs.
|
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<SettingsResource>()
|
.resource_mut::<SettingsResource>()
|
||||||
.0
|
.0
|
||||||
@@ -192,61 +481,240 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keypress_dismisses_and_sets_flag() {
|
fn starts_on_slide_zero() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(count_screens(&mut app), 1);
|
assert_eq!(current_slide(&app), 0);
|
||||||
|
}
|
||||||
|
|
||||||
app.world_mut()
|
// -----------------------------------------------------------------------
|
||||||
.resource_mut::<ButtonInput<KeyCode>>()
|
// Next / Back navigation
|
||||||
.press(KeyCode::Space);
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_button_advances_slide() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(current_slide(&app), 0);
|
||||||
|
|
||||||
|
// Spawn a Next button with Pressed interaction.
|
||||||
|
app.world_mut().spawn((OnboardingNextButton, Button, Interaction::Pressed));
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
assert_eq!(count_screens(&mut app), 0);
|
assert_eq!(current_slide(&app), 1, "Next must advance to slide 1");
|
||||||
|
assert_eq!(count_screens(&mut app), 1, "exactly one modal must be visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn back_button_retreats_slide() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.update();
|
||||||
|
// Manually move to slide 2.
|
||||||
|
app.world_mut().resource_mut::<OnboardingSlideIndex>().0 = 2;
|
||||||
|
// Despawn the old screen and respawn slide 2.
|
||||||
|
{
|
||||||
|
let entities: Vec<Entity> = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<OnboardingScreen>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.collect();
|
||||||
|
for e in entities {
|
||||||
|
app.world_mut().despawn(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.world_mut().spawn((OnboardingBackButton, Button, Interaction::Pressed));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(current_slide(&app), 1, "Back must retreat from slide 2 to slide 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn back_on_first_slide_does_not_underflow() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(current_slide(&app), 0);
|
||||||
|
|
||||||
|
// Pressing Back on slide 0 must be a no-op (no underflow to u8::MAX).
|
||||||
|
app.world_mut().spawn((OnboardingBackButton, Button, Interaction::Pressed));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(current_slide(&app), 0, "Back on slide 0 must not underflow");
|
||||||
|
// The screen must still be present (we didn't skip or complete).
|
||||||
|
assert_eq!(count_screens(&mut app), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_cannot_advance_past_last_slide() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.update();
|
||||||
|
app.world_mut().resource_mut::<OnboardingSlideIndex>().0 = SLIDE_COUNT - 1;
|
||||||
|
|
||||||
|
// Next on the last slide should complete onboarding, not advance further.
|
||||||
|
app.world_mut().spawn((OnboardingNextButton, Button, Interaction::Pressed));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// first_run_complete must be set.
|
||||||
assert!(
|
assert!(
|
||||||
app.world()
|
app.world().resource::<SettingsResource>().0.first_run_complete,
|
||||||
.resource::<SettingsResource>()
|
"Next on last slide must set first_run_complete"
|
||||||
.0
|
|
||||||
.first_run_complete,
|
|
||||||
"first_run_complete should flip to true"
|
|
||||||
);
|
);
|
||||||
|
assert_eq!(count_screens(&mut app), 0, "modal must be gone after completion");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Skip
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skip_button_completes_onboarding_from_slide_zero() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
app.world_mut().spawn((OnboardingSkipButton, Button, Interaction::Pressed));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<SettingsResource>().0.first_run_complete,
|
||||||
|
"Skip must set first_run_complete"
|
||||||
|
);
|
||||||
|
assert_eq!(count_screens(&mut app), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Keyboard accelerators
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrow_right_advances_slide() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(current_slide(&app), 0);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::ArrowRight);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(current_slide(&app), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mouseclick_dismisses_banner() {
|
fn enter_advances_slide() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Enter);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(current_slide(&app), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrow_left_retreats_slide() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.update();
|
||||||
|
app.world_mut().resource_mut::<OnboardingSlideIndex>().0 = 1;
|
||||||
|
// Re-spawn a screen so the keyboard handler finds one.
|
||||||
|
app.world_mut().spawn(OnboardingScreen);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::ArrowLeft);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(current_slide(&app), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn esc_skips_onboarding() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(count_screens(&mut app), 1);
|
assert_eq!(count_screens(&mut app), 1);
|
||||||
|
|
||||||
app.world_mut()
|
press_key(&mut app, KeyCode::Escape);
|
||||||
.resource_mut::<ButtonInput<MouseButton>>()
|
|
||||||
.press(MouseButton::Left);
|
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(count_screens(&mut app), 0, "Esc must dismiss onboarding");
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<SettingsResource>().0.first_run_complete,
|
||||||
|
"Esc must set first_run_complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_on_last_slide_completes_onboarding() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.update();
|
||||||
|
app.world_mut().resource_mut::<OnboardingSlideIndex>().0 = SLIDE_COUNT - 1;
|
||||||
|
// Ensure a screen exists for the keyboard handler.
|
||||||
|
app.world_mut().spawn(OnboardingScreen);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Enter);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<SettingsResource>().0.first_run_complete,
|
||||||
|
"Enter on last slide must complete onboarding"
|
||||||
|
);
|
||||||
assert_eq!(count_screens(&mut app), 0);
|
assert_eq!(count_screens(&mut app), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Slide-index bounds
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn banner_has_key_highlight_span_for_d() {
|
fn slide_count_constant_is_three() {
|
||||||
// D must be tagged KeyHighlightSpan so its colour is distinct from body
|
assert_eq!(SLIDE_COUNT, 3, "SLIDE_COUNT must be 3");
|
||||||
// text and future flash-animation systems can target it.
|
|
||||||
let mut app = headless_app();
|
|
||||||
app.update();
|
|
||||||
let count = app
|
|
||||||
.world_mut()
|
|
||||||
.query::<&KeyHighlightSpan>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert_eq!(count, 1, "expected KeyHighlightSpan for D");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn key_highlight_colour_differs_from_body_colour() {
|
fn slide_index_default_is_zero() {
|
||||||
// Regression guard: KEY_COLOR must not accidentally match BODY_COLOR.
|
let idx = OnboardingSlideIndex::default();
|
||||||
assert_ne!(
|
assert_eq!(idx.0, 0);
|
||||||
format!("{KEY_COLOR:?}"),
|
}
|
||||||
format!("{BODY_COLOR:?}"),
|
|
||||||
"key highlight colour should differ from body text colour"
|
// -----------------------------------------------------------------------
|
||||||
|
// Completion semantics
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keypress_on_last_slide_sets_first_run_complete() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Navigate to the last slide via arrow keys.
|
||||||
|
for _ in 0..(SLIDE_COUNT - 1) {
|
||||||
|
press_key(&mut app, KeyCode::ArrowRight);
|
||||||
|
app.update();
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(current_slide(&app), SLIDE_COUNT - 1);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Enter);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<SettingsResource>().0.first_run_complete,
|
||||||
|
"completing the last slide must set first_run_complete"
|
||||||
);
|
);
|
||||||
|
assert_eq!(count_screens(&mut app), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Hotkey list is non-empty (guards against accidental truncation)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hotkey_list_is_non_empty() {
|
||||||
|
assert!(!HOTKEYS.is_empty(), "HOTKEYS must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_hotkey_rows_have_non_empty_fields() {
|
||||||
|
for row in HOTKEYS {
|
||||||
|
assert!(!row.keys.is_empty(), "hotkey key field must not be empty");
|
||||||
|
assert!(!row.description.is_empty(), "hotkey description must not be empty");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,24 @@ use solitaire_core::game_state::DrawMode;
|
|||||||
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
|
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
|
||||||
|
|
||||||
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
|
use crate::ui_modal::{
|
||||||
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
|
};
|
||||||
|
use crate::ui_theme::{
|
||||||
|
BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||||
|
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Side length of a swatch button in the card-back / background pickers.
|
||||||
|
/// Smaller than the smallest spacing rung so it stays a literal.
|
||||||
|
const SWATCH_PX: f32 = 40.0;
|
||||||
|
|
||||||
|
/// Side length of a small toggle / cycle button (e.g. the "⇄" affordances).
|
||||||
|
/// Sub-rung sizing — kept as a literal, see SWATCH_PX.
|
||||||
|
const ICON_BUTTON_PX: f32 = 28.0;
|
||||||
|
|
||||||
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
||||||
pub const SFX_STEP: f32 = 0.1;
|
pub const SFX_STEP: f32 = 0.1;
|
||||||
@@ -232,6 +248,7 @@ fn sync_settings_panel_visibility(
|
|||||||
settings: Res<SettingsResource>,
|
settings: Res<SettingsResource>,
|
||||||
sync_status: Option<Res<SyncStatusResource>>,
|
sync_status: Option<Res<SyncStatusResource>>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
if !screen.is_changed() {
|
if !screen.is_changed() {
|
||||||
return;
|
return;
|
||||||
@@ -256,6 +273,7 @@ fn sync_settings_panel_visibility(
|
|||||||
unlocked_backs,
|
unlocked_backs,
|
||||||
unlocked_bgs,
|
unlocked_bgs,
|
||||||
scroll_pos.0,
|
scroll_pos.0,
|
||||||
|
font_res.as_deref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -575,327 +593,134 @@ fn spawn_settings_panel(
|
|||||||
unlocked_card_backs: &[usize],
|
unlocked_card_backs: &[usize],
|
||||||
unlocked_backgrounds: &[usize],
|
unlocked_backgrounds: &[usize],
|
||||||
scroll_offset: f32,
|
scroll_offset: f32,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
commands
|
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
||||||
.spawn((
|
spawn_modal_header(card, "Settings", font_res);
|
||||||
SettingsPanel,
|
|
||||||
|
// Scrollable body — contains every section so tall content stays
|
||||||
|
// reachable on short windows. The Done button below stays fixed
|
||||||
|
// outside the scroll so it's always one click away.
|
||||||
|
card.spawn((
|
||||||
|
SettingsPanelScrollable,
|
||||||
|
SettingsScrollNode,
|
||||||
|
ScrollPosition(Vec2::new(0.0, scroll_offset)),
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: Val::Percent(0.0),
|
|
||||||
top: Val::Percent(0.0),
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Percent(100.0),
|
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
justify_content: JustifyContent::Center,
|
row_gap: VAL_SPACE_3,
|
||||||
align_items: AlignItems::Center,
|
max_height: Val::Vh(60.0),
|
||||||
|
overflow: Overflow::scroll_y(),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.72)),
|
|
||||||
ZIndex(200),
|
|
||||||
))
|
))
|
||||||
.with_children(|root| {
|
.with_children(|body| {
|
||||||
// Inner card — max_height + scroll_y lets the player reach all rows
|
// --- Audio ---
|
||||||
// on small windows by scrolling with the mouse wheel.
|
section_label(body, "Audio", font_res);
|
||||||
root.spawn((
|
volume_row(
|
||||||
SettingsPanelScrollable,
|
body,
|
||||||
SettingsScrollNode,
|
"SFX Volume",
|
||||||
ScrollPosition(Vec2::new(0.0, scroll_offset)),
|
settings.sfx_volume,
|
||||||
Node {
|
SfxVolumeText,
|
||||||
flex_direction: FlexDirection::Column,
|
SettingsButton::SfxDown,
|
||||||
padding: UiRect::all(Val::Px(28.0)),
|
SettingsButton::SfxUp,
|
||||||
row_gap: Val::Px(14.0),
|
font_res,
|
||||||
min_width: Val::Px(340.0),
|
);
|
||||||
max_height: Val::Percent(88.0),
|
volume_row(
|
||||||
overflow: Overflow::scroll_y(),
|
body,
|
||||||
border_radius: BorderRadius::all(Val::Px(8.0)),
|
"Music Volume",
|
||||||
..default()
|
settings.music_volume,
|
||||||
},
|
MusicVolumeText,
|
||||||
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
|
SettingsButton::MusicDown,
|
||||||
))
|
SettingsButton::MusicUp,
|
||||||
.with_children(|card| {
|
font_res,
|
||||||
// Title
|
);
|
||||||
card.spawn((
|
|
||||||
Text::new("Settings"),
|
|
||||||
TextFont {
|
|
||||||
font_size: 30.0,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(Color::WHITE),
|
|
||||||
));
|
|
||||||
|
|
||||||
// --- Audio section ---
|
// --- Gameplay ---
|
||||||
section_label(card, "Audio");
|
section_label(body, "Gameplay", font_res);
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Draw Mode",
|
||||||
|
DrawModeText,
|
||||||
|
draw_mode_label(&settings.draw_mode),
|
||||||
|
SettingsButton::ToggleDrawMode,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Anim Speed",
|
||||||
|
AnimSpeedText,
|
||||||
|
anim_speed_label(&settings.animation_speed),
|
||||||
|
SettingsButton::CycleAnimSpeed,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
|
||||||
// SFX volume row
|
// --- Cosmetic ---
|
||||||
volume_row(card, "SFX Volume", settings.sfx_volume, SfxVolumeText,
|
section_label(body, "Cosmetic", font_res);
|
||||||
SettingsButton::SfxDown, SettingsButton::SfxUp);
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Theme",
|
||||||
|
ThemeText,
|
||||||
|
theme_label(&settings.theme),
|
||||||
|
SettingsButton::ToggleTheme,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Color-blind Mode",
|
||||||
|
ColorBlindText,
|
||||||
|
color_blind_label(settings.color_blind_mode),
|
||||||
|
SettingsButton::ToggleColorBlind,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
picker_row(
|
||||||
|
body,
|
||||||
|
"Card Back",
|
||||||
|
unlocked_card_backs,
|
||||||
|
settings.selected_card_back,
|
||||||
|
SettingsButton::SelectCardBack,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
picker_row(
|
||||||
|
body,
|
||||||
|
"Background",
|
||||||
|
unlocked_backgrounds,
|
||||||
|
settings.selected_background,
|
||||||
|
SettingsButton::SelectBackground,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
|
||||||
// Music volume row
|
// --- Sync ---
|
||||||
volume_row(card, "Music Volume", settings.music_volume, MusicVolumeText,
|
section_label(body, "Sync", font_res);
|
||||||
SettingsButton::MusicDown, SettingsButton::MusicUp);
|
sync_row(body, sync_status, font_res);
|
||||||
|
|
||||||
// --- Gameplay section ---
|
|
||||||
section_label(card, "Gameplay");
|
|
||||||
|
|
||||||
// Draw mode row
|
|
||||||
card.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
column_gap: Val::Px(8.0),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
row.spawn((
|
|
||||||
Text::new("Draw Mode"),
|
|
||||||
TextFont { font_size: 18.0, ..default() },
|
|
||||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
|
||||||
));
|
|
||||||
row.spawn((
|
|
||||||
DrawModeText,
|
|
||||||
Text::new(draw_mode_label(&settings.draw_mode)),
|
|
||||||
TextFont { font_size: 18.0, ..default() },
|
|
||||||
TextColor(Color::WHITE),
|
|
||||||
));
|
|
||||||
icon_button(row, "⇄", SettingsButton::ToggleDrawMode);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Animation speed row
|
|
||||||
card.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
column_gap: Val::Px(8.0),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
row.spawn((
|
|
||||||
Text::new("Anim Speed"),
|
|
||||||
TextFont { font_size: 18.0, ..default() },
|
|
||||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
|
||||||
));
|
|
||||||
row.spawn((
|
|
||||||
AnimSpeedText,
|
|
||||||
Text::new(anim_speed_label(&settings.animation_speed)),
|
|
||||||
TextFont { font_size: 18.0, ..default() },
|
|
||||||
TextColor(Color::WHITE),
|
|
||||||
));
|
|
||||||
icon_button(row, "⇄", SettingsButton::CycleAnimSpeed);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Appearance section ---
|
|
||||||
section_label(card, "Appearance");
|
|
||||||
|
|
||||||
// Theme row
|
|
||||||
card.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
column_gap: Val::Px(8.0),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
row.spawn((
|
|
||||||
Text::new("Theme"),
|
|
||||||
TextFont { font_size: 18.0, ..default() },
|
|
||||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
|
||||||
));
|
|
||||||
row.spawn((
|
|
||||||
ThemeText,
|
|
||||||
Text::new(theme_label(&settings.theme)),
|
|
||||||
TextFont { font_size: 18.0, ..default() },
|
|
||||||
TextColor(Color::WHITE),
|
|
||||||
));
|
|
||||||
icon_button(row, "⇄", SettingsButton::ToggleTheme);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Color-blind mode row
|
|
||||||
card.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
column_gap: Val::Px(8.0),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
row.spawn((
|
|
||||||
Text::new("Color-blind Mode"),
|
|
||||||
TextFont { font_size: 18.0, ..default() },
|
|
||||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
|
||||||
));
|
|
||||||
row.spawn((
|
|
||||||
ColorBlindText,
|
|
||||||
Text::new(color_blind_label(settings.color_blind_mode)),
|
|
||||||
TextFont { font_size: 18.0, ..default() },
|
|
||||||
TextColor(Color::WHITE),
|
|
||||||
));
|
|
||||||
icon_button(row, "⇄", SettingsButton::ToggleColorBlind);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Card Back section ---
|
|
||||||
section_label(card, "Card Back");
|
|
||||||
card.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
column_gap: Val::Px(8.0),
|
|
||||||
flex_wrap: FlexWrap::Wrap,
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
// Always show at least button "1" (index 0 = default).
|
|
||||||
let backs = if unlocked_card_backs.is_empty() {
|
|
||||||
&[0usize][..]
|
|
||||||
} else {
|
|
||||||
unlocked_card_backs
|
|
||||||
};
|
|
||||||
for &back_idx in backs {
|
|
||||||
let is_selected = back_idx == settings.selected_card_back;
|
|
||||||
let bg_color = if is_selected {
|
|
||||||
Color::srgb(0.2, 0.9, 0.3)
|
|
||||||
} else {
|
|
||||||
Color::srgb(0.25, 0.25, 0.30)
|
|
||||||
};
|
|
||||||
row.spawn((
|
|
||||||
SettingsButton::SelectCardBack(back_idx),
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
width: Val::Px(40.0),
|
|
||||||
height: Val::Px(40.0),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
border_radius: BorderRadius::all(Val::Px(4.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(bg_color),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new(format!("{}", back_idx + 1)),
|
|
||||||
TextFont { font_size: 16.0, ..default() },
|
|
||||||
TextColor(Color::WHITE),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Background section ---
|
|
||||||
section_label(card, "Background");
|
|
||||||
card.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
column_gap: Val::Px(8.0),
|
|
||||||
flex_wrap: FlexWrap::Wrap,
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
// Always show at least button "1" (index 0 = default).
|
|
||||||
let bgs = if unlocked_backgrounds.is_empty() {
|
|
||||||
&[0usize][..]
|
|
||||||
} else {
|
|
||||||
unlocked_backgrounds
|
|
||||||
};
|
|
||||||
for &bg_idx in bgs {
|
|
||||||
let is_selected = bg_idx == settings.selected_background;
|
|
||||||
let bg_color = if is_selected {
|
|
||||||
Color::srgb(0.2, 0.9, 0.3)
|
|
||||||
} else {
|
|
||||||
Color::srgb(0.25, 0.25, 0.30)
|
|
||||||
};
|
|
||||||
row.spawn((
|
|
||||||
SettingsButton::SelectBackground(bg_idx),
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
width: Val::Px(40.0),
|
|
||||||
height: Val::Px(40.0),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
border_radius: BorderRadius::all(Val::Px(4.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(bg_color),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new(format!("{}", bg_idx + 1)),
|
|
||||||
TextFont { font_size: 16.0, ..default() },
|
|
||||||
TextColor(Color::WHITE),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Sync section ---
|
|
||||||
section_label(card, "Sync");
|
|
||||||
card.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
column_gap: Val::Px(10.0),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
row.spawn((
|
|
||||||
SyncStatusText,
|
|
||||||
Text::new(sync_status.to_string()),
|
|
||||||
TextFont { font_size: 16.0, ..default() },
|
|
||||||
TextColor(Color::srgb(0.65, 0.65, 0.70)),
|
|
||||||
));
|
|
||||||
// "Sync Now" button — hidden when SyncPlugin is not installed;
|
|
||||||
// visible because ManualSyncRequestEvent is always registered.
|
|
||||||
row.spawn((
|
|
||||||
SettingsButton::SyncNow,
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
border_radius: BorderRadius::all(Val::Px(4.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Sync Now"),
|
|
||||||
TextFont { font_size: 14.0, ..default() },
|
|
||||||
TextColor(Color::WHITE),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Done button
|
|
||||||
card.spawn((
|
|
||||||
SettingsButton::Done,
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
padding: UiRect::axes(Val::Px(20.0), Val::Px(8.0)),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
margin: UiRect::top(Val::Px(6.0)),
|
|
||||||
border_radius: BorderRadius::all(Val::Px(4.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Done"),
|
|
||||||
TextFont {
|
|
||||||
font_size: 18.0,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(Color::WHITE),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Done is the only action — primary so the player always knows
|
||||||
|
// how to leave the modal. `O` toggles it the same way.
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
SettingsButton::Done,
|
||||||
|
"Done",
|
||||||
|
Some("O"),
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn section_label(parent: &mut ChildSpawnerCommands, title: &str) {
|
/// Section divider — small lavender label inside the scrollable body.
|
||||||
parent.spawn((
|
fn section_label(parent: &mut ChildSpawnerCommands, title: &str, font_res: Option<&FontResource>) {
|
||||||
Text::new(title),
|
let font = TextFont {
|
||||||
TextFont {
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
font_size: 14.0,
|
font_size: TYPE_BODY,
|
||||||
..default()
|
..default()
|
||||||
},
|
};
|
||||||
TextColor(Color::srgb(0.55, 0.75, 0.55)),
|
parent.spawn((Text::new(title), font, TextColor(TEXT_SECONDARY)));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generic volume row: `Label 0.80 [−] [+]`
|
/// `Label 0.80 [−] [+]` — used for SFX and Music volume rows.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn volume_row<Marker: Component>(
|
fn volume_row<Marker: Component>(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
label: &str,
|
label: &str,
|
||||||
@@ -903,55 +728,223 @@ fn volume_row<Marker: Component>(
|
|||||||
marker: Marker,
|
marker: Marker,
|
||||||
btn_down: SettingsButton,
|
btn_down: SettingsButton,
|
||||||
btn_up: SettingsButton,
|
btn_up: SettingsButton,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
|
let label_font = label_text_font(font_res);
|
||||||
|
let value_font = value_text_font(font_res);
|
||||||
parent
|
parent
|
||||||
.spawn(Node {
|
.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
column_gap: Val::Px(8.0),
|
column_gap: VAL_SPACE_2,
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
row.spawn((
|
row.spawn((
|
||||||
Text::new(label.to_string()),
|
Text::new(label.to_string()),
|
||||||
TextFont { font_size: 18.0, ..default() },
|
label_font,
|
||||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
row.spawn((
|
row.spawn((
|
||||||
marker,
|
marker,
|
||||||
Text::new(format!("{:.2}", value)),
|
Text::new(format!("{:.2}", value)),
|
||||||
TextFont { font_size: 18.0, ..default() },
|
value_font,
|
||||||
TextColor(Color::WHITE),
|
TextColor(TEXT_PRIMARY),
|
||||||
));
|
));
|
||||||
icon_button(row, "−", btn_down);
|
icon_button(row, "−", btn_down, font_res);
|
||||||
icon_button(row, "+", btn_up);
|
icon_button(row, "+", btn_up, font_res);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn icon_button(parent: &mut ChildSpawnerCommands, label: &str, action: SettingsButton) {
|
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
|
||||||
|
/// anim speed, colour-blind).
|
||||||
|
fn toggle_row<Marker: Component>(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
label: &str,
|
||||||
|
marker: Marker,
|
||||||
|
value: String,
|
||||||
|
action: SettingsButton,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let label_font = label_text_font(font_res);
|
||||||
|
let value_font = value_text_font(font_res);
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new(label.to_string()),
|
||||||
|
label_font,
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
row.spawn((marker, Text::new(value), value_font, TextColor(TEXT_PRIMARY)));
|
||||||
|
icon_button(row, "⇄", action, font_res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapping row of indexed swatch buttons — used for card-back and
|
||||||
|
/// background pickers. The currently-selected swatch is tinted with
|
||||||
|
/// `STATE_SUCCESS` so the user can see it without reading a label.
|
||||||
|
fn picker_row(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
label: &str,
|
||||||
|
unlocked: &[usize],
|
||||||
|
selected: usize,
|
||||||
|
make_button: impl Fn(usize) -> SettingsButton,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let label_font = label_text_font(font_res);
|
||||||
|
let chip_font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new(label.to_string()),
|
||||||
|
label_font,
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
// Always show at least swatch 0 (default).
|
||||||
|
let entries: &[usize] = if unlocked.is_empty() { &[0] } else { unlocked };
|
||||||
|
for &idx in entries {
|
||||||
|
let is_selected = idx == selected;
|
||||||
|
let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI };
|
||||||
|
row.spawn((
|
||||||
|
make_button(idx),
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
width: Val::Px(SWATCH_PX),
|
||||||
|
height: Val::Px(SWATCH_PX),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(bg),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
|
||||||
|
b.spawn((
|
||||||
|
Text::new(format!("{}", idx + 1)),
|
||||||
|
chip_font.clone(),
|
||||||
|
TextColor(text_color),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status text + manual "Sync Now" button.
|
||||||
|
fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) {
|
||||||
|
let status_font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let button_font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
SyncStatusText,
|
||||||
|
Text::new(status_text.to_string()),
|
||||||
|
status_font,
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
// ManualSyncRequestEvent is always registered, so this
|
||||||
|
// button is safe to show even when SyncPlugin is absent.
|
||||||
|
row.spawn((
|
||||||
|
SettingsButton::SyncNow,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED_HI),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new("Sync Now"),
|
||||||
|
button_font,
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label_text_font(font_res: Option<&FontResource>) -> TextFont {
|
||||||
|
TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_text_font(font_res: Option<&FontResource>) -> TextFont {
|
||||||
|
TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon_button(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
label: &str,
|
||||||
|
action: SettingsButton,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let glyph_font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
parent
|
parent
|
||||||
.spawn((
|
.spawn((
|
||||||
action,
|
action,
|
||||||
Button,
|
Button,
|
||||||
Node {
|
Node {
|
||||||
width: Val::Px(28.0),
|
width: Val::Px(ICON_BUTTON_PX),
|
||||||
height: Val::Px(28.0),
|
height: Val::Px(ICON_BUTTON_PX),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
border_radius: BorderRadius::all(Val::Px(4.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
|
BackgroundColor(BG_ELEVATED_HI),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((Text::new(label.to_string()), glyph_font, TextColor(TEXT_PRIMARY)));
|
||||||
Text::new(label.to_string()),
|
|
||||||
TextFont {
|
|
||||||
font_size: 18.0,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(Color::WHITE),
|
|
||||||
));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ use crate::events::{
|
|||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
|
use crate::ui_theme::{
|
||||||
|
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_WIN_SHAKE_AMPLITUDE,
|
||||||
|
MOTION_WIN_SHAKE_SECS, RADIUS_LG, RADIUS_MD, SCRIM, STATE_INFO, STATE_SUCCESS, STATE_WARNING,
|
||||||
|
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_DISPLAY, TYPE_HEADLINE, VAL_SPACE_2,
|
||||||
|
VAL_SPACE_3, Z_WIN_CASCADE,
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -30,10 +37,12 @@ use crate::stats_plugin::{StatsResource, StatsUpdate};
|
|||||||
/// Chosen so the cascade animation has a moment to start first.
|
/// Chosen so the cascade animation has a moment to start first.
|
||||||
const WIN_SUMMARY_DELAY_SECS: f32 = 0.5;
|
const WIN_SUMMARY_DELAY_SECS: f32 = 0.5;
|
||||||
|
|
||||||
/// Duration of the screen-shake in seconds.
|
/// Default duration of the screen-shake in seconds, before `AnimSpeed` scaling.
|
||||||
const SHAKE_DURATION_SECS: f32 = 0.6;
|
/// Sourced from `ui_theme::MOTION_WIN_SHAKE_SECS`.
|
||||||
|
const SHAKE_DURATION_SECS: f32 = MOTION_WIN_SHAKE_SECS;
|
||||||
/// Maximum camera displacement in world-space pixels at the start of the shake.
|
/// Maximum camera displacement in world-space pixels at the start of the shake.
|
||||||
const SHAKE_INTENSITY: f32 = 8.0;
|
/// Sourced from `ui_theme::MOTION_WIN_SHAKE_AMPLITUDE`.
|
||||||
|
const SHAKE_INTENSITY: f32 = MOTION_WIN_SHAKE_AMPLITUDE;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Resources
|
// Resources
|
||||||
@@ -103,6 +112,11 @@ fn build_xp_detail(time_seconds: u64, used_undo: bool) -> String {
|
|||||||
pub struct ScreenShakeResource {
|
pub struct ScreenShakeResource {
|
||||||
/// Seconds of shake remaining.
|
/// Seconds of shake remaining.
|
||||||
pub remaining: f32,
|
pub remaining: f32,
|
||||||
|
/// Total duration the shake was armed for, used to compute the
|
||||||
|
/// `remaining / total` decay factor. Tracked separately from `remaining`
|
||||||
|
/// because the duration is now scaled by `AnimSpeed`, so a fixed
|
||||||
|
/// divisor would be wrong on Fast.
|
||||||
|
pub total: f32,
|
||||||
/// Peak displacement in world-space pixels (decays to zero over `remaining`).
|
/// Peak displacement in world-space pixels (decays to zero over `remaining`).
|
||||||
pub intensity: f32,
|
pub intensity: f32,
|
||||||
}
|
}
|
||||||
@@ -308,14 +322,25 @@ fn spawn_win_summary_after_delay(
|
|||||||
mut shake: ResMut<ScreenShakeResource>,
|
mut shake: ResMut<ScreenShakeResource>,
|
||||||
mut pending: ResMut<WinSummaryPending>,
|
mut pending: ResMut<WinSummaryPending>,
|
||||||
session: Res<SessionAchievements>,
|
session: Res<SessionAchievements>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
mut delay: Local<Option<f32>>,
|
mut delay: Local<Option<f32>>,
|
||||||
) {
|
) {
|
||||||
// Process new win events.
|
// Process new win events.
|
||||||
for _ in won.read() {
|
for _ in won.read() {
|
||||||
// Arm the screen shake immediately.
|
// Arm the screen shake immediately. Duration scales with the
|
||||||
shake.remaining = SHAKE_DURATION_SECS;
|
// player's `AnimSpeed` preference via `ui_theme::scaled_duration`;
|
||||||
|
// intensity is left at its design-token value because amplitude
|
||||||
|
// does not benefit from "fast" / "instant" scaling — at Instant
|
||||||
|
// speed the duration is zero anyway, suppressing the shake.
|
||||||
|
let speed = settings.as_ref().map_or(
|
||||||
|
solitaire_data::AnimSpeed::Normal,
|
||||||
|
|s| s.0.animation_speed,
|
||||||
|
);
|
||||||
|
let scaled = scaled_duration(SHAKE_DURATION_SECS, speed);
|
||||||
|
shake.remaining = scaled;
|
||||||
|
shake.total = scaled;
|
||||||
shake.intensity = SHAKE_INTENSITY;
|
shake.intensity = SHAKE_INTENSITY;
|
||||||
// Start the delay timer (overwrite if a second win arrives).
|
// Start the delay timer (overwrite if a second win arrives).
|
||||||
*delay = Some(WIN_SUMMARY_DELAY_SECS);
|
*delay = Some(WIN_SUMMARY_DELAY_SECS);
|
||||||
@@ -391,8 +416,11 @@ fn apply_screen_shake(
|
|||||||
}
|
}
|
||||||
|
|
||||||
shake.remaining = (shake.remaining - dt).max(0.0);
|
shake.remaining = (shake.remaining - dt).max(0.0);
|
||||||
// Decay factor: 1.0 at start, 0.0 at end.
|
// Decay factor: 1.0 at start, 0.0 at end. Falls back to the design-token
|
||||||
let decay = shake.remaining / SHAKE_DURATION_SECS;
|
// duration if `total` is zero (older armings or test setups that bypass
|
||||||
|
// `spawn_win_summary_after_delay`) so we never divide by zero.
|
||||||
|
let total = if shake.total > 0.0 { shake.total } else { SHAKE_DURATION_SECS };
|
||||||
|
let decay = shake.remaining / total;
|
||||||
let elapsed = time.elapsed_secs();
|
let elapsed = time.elapsed_secs();
|
||||||
let offset_x = (elapsed * 47.0).sin() * shake.intensity * decay;
|
let offset_x = (elapsed * 47.0).sin() * shake.intensity * decay;
|
||||||
let offset_y = (elapsed * 31.0).cos() * shake.intensity * decay;
|
let offset_y = (elapsed * 31.0).cos() * shake.intensity * decay;
|
||||||
@@ -431,8 +459,8 @@ fn spawn_overlay(
|
|||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.70)),
|
BackgroundColor(SCRIM),
|
||||||
ZIndex(300),
|
ZIndex(Z_WIN_CASCADE),
|
||||||
))
|
))
|
||||||
.with_children(|root| {
|
.with_children(|root| {
|
||||||
root.spawn((
|
root.spawn((
|
||||||
@@ -442,25 +470,25 @@ fn spawn_overlay(
|
|||||||
row_gap: Val::Px(18.0),
|
row_gap: Val::Px(18.0),
|
||||||
min_width: Val::Px(320.0),
|
min_width: Val::Px(320.0),
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
border_radius: BorderRadius::all(Val::Px(12.0)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.10, 0.12, 0.10)),
|
BackgroundColor(BG_ELEVATED),
|
||||||
))
|
))
|
||||||
.with_children(|card| {
|
.with_children(|card| {
|
||||||
// Heading
|
// Heading
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new("You Won!"),
|
Text::new("You Won!"),
|
||||||
TextFont { font_size: 42.0, ..default() },
|
TextFont { font_size: TYPE_DISPLAY, ..default() },
|
||||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
TextColor(ACCENT_PRIMARY),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Challenge-mode annotation — shown only for Challenge wins.
|
// Challenge-mode annotation — shown only for Challenge wins.
|
||||||
if let Some(level) = challenge_level {
|
if let Some(level) = challenge_level {
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(format!("Challenge {level} complete!")),
|
Text::new(format!("Challenge {level} complete!")),
|
||||||
TextFont { font_size: 28.0, ..default() },
|
TextFont { font_size: TYPE_HEADLINE, ..default() },
|
||||||
TextColor(Color::srgb(0.4, 0.85, 1.0)),
|
TextColor(STATE_INFO),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,30 +497,30 @@ fn spawn_overlay(
|
|||||||
if pending.new_record {
|
if pending.new_record {
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new("New Record!"),
|
Text::new("New Record!"),
|
||||||
TextFont { font_size: 26.0, ..default() },
|
TextFont { font_size: TYPE_HEADLINE, ..default() },
|
||||||
TextColor(Color::srgb(1.0, 0.55, 0.0)),
|
TextColor(STATE_WARNING),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score
|
// Score
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(format!("Score: {}", pending.score)),
|
Text::new(format!("Score: {}", pending.score)),
|
||||||
TextFont { font_size: 26.0, ..default() },
|
TextFont { font_size: TYPE_HEADLINE, ..default() },
|
||||||
TextColor(Color::WHITE),
|
TextColor(TEXT_PRIMARY),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Time
|
// Time
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
||||||
TextFont { font_size: 26.0, ..default() },
|
TextFont { font_size: TYPE_HEADLINE, ..default() },
|
||||||
TextColor(Color::WHITE),
|
TextColor(TEXT_PRIMARY),
|
||||||
));
|
));
|
||||||
|
|
||||||
// XP total
|
// XP total
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(format!("XP earned: +{}", pending.xp)),
|
Text::new(format!("XP earned: +{}", pending.xp)),
|
||||||
TextFont { font_size: 22.0, ..default() },
|
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||||||
TextColor(Color::srgb(0.4, 1.0, 0.4)),
|
TextColor(STATE_SUCCESS),
|
||||||
));
|
));
|
||||||
|
|
||||||
// XP breakdown (smaller, dimmer text)
|
// XP breakdown (smaller, dimmer text)
|
||||||
@@ -500,7 +528,7 @@ fn spawn_overlay(
|
|||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(pending.xp_detail.clone()),
|
Text::new(pending.xp_detail.clone()),
|
||||||
TextFont { font_size: 15.0, ..default() },
|
TextFont { font_size: 15.0, ..default() },
|
||||||
TextColor(Color::srgb(0.55, 0.80, 0.55)),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,19 +543,19 @@ fn spawn_overlay(
|
|||||||
WinSummaryButton::PlayAgain,
|
WinSummaryButton::PlayAgain,
|
||||||
Button,
|
Button,
|
||||||
Node {
|
Node {
|
||||||
padding: UiRect::axes(Val::Px(28.0), Val::Px(12.0)),
|
padding: UiRect::axes(Val::Px(28.0), VAL_SPACE_3),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
margin: UiRect::top(Val::Px(8.0)),
|
margin: UiRect::top(VAL_SPACE_2),
|
||||||
border_radius: BorderRadius::all(Val::Px(6.0)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
|
BackgroundColor(ACCENT_PRIMARY),
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
Text::new("Play Again"),
|
Text::new("Play Again"),
|
||||||
TextFont { font_size: 22.0, ..default() },
|
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||||||
TextColor(Color::WHITE),
|
TextColor(BG_BASE),
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -546,8 +574,8 @@ const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
|
|||||||
fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String]) {
|
fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String]) {
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new("Achievements Unlocked"),
|
Text::new("Achievements Unlocked"),
|
||||||
TextFont { font_size: 18.0, ..default() },
|
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
TextColor(ACCENT_PRIMARY),
|
||||||
));
|
));
|
||||||
|
|
||||||
let shown = names.len().min(MAX_ACHIEVEMENTS_SHOWN);
|
let shown = names.len().min(MAX_ACHIEVEMENTS_SHOWN);
|
||||||
@@ -555,7 +583,7 @@ fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String])
|
|||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(format!(" {name}")),
|
Text::new(format!(" {name}")),
|
||||||
TextFont { font_size: 16.0, ..default() },
|
TextFont { font_size: 16.0, ..default() },
|
||||||
TextColor(Color::srgb(0.9, 0.9, 0.9)),
|
TextColor(TEXT_PRIMARY),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,7 +592,7 @@ fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String])
|
|||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(format!(" ...and {overflow} more")),
|
Text::new(format!(" ...and {overflow} more")),
|
||||||
TextFont { font_size: 15.0, ..default() },
|
TextFont { font_size: 15.0, ..default() },
|
||||||
TextColor(Color::srgb(0.6, 0.6, 0.65)),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user