Compare commits
5 Commits
534870a68a
...
de52c8a7b7
| Author | SHA1 | Date | |
|---|---|---|---|
| de52c8a7b7 | |||
| dcfa976dad | |||
| 71999e1062 | |||
| 5f5aba8dff | |||
| 9bfca929cb |
+74
-2
@@ -1,8 +1,80 @@
|
|||||||
# Solitaire Quest — UX Overhaul Session Handoff
|
# Solitaire Quest — UX Overhaul Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-04-30 — Phase 3 complete. All 10 steps landed; ready for full smoke-test.
|
**Last updated:** 2026-04-30 — Phase 3 complete + Phase 4 in progress (Track B landed on disk, Track G subset in flight via background agent).
|
||||||
|
|
||||||
## Where we are
|
## ⚠️ In-progress work at pause time
|
||||||
|
|
||||||
|
Smoke-test passed; Phase 4 was started. Pushed HEAD is `534870a`. The working tree has **uncommitted** work that is NOT pushed:
|
||||||
|
|
||||||
|
### Track B — window polish (on disk, ready to commit)
|
||||||
|
|
||||||
|
- **File:** `solitaire_app/src/main.rs` (+44 lines)
|
||||||
|
- **What landed:**
|
||||||
|
- X11/Wayland WM_CLASS via `Window::name = Some("solitaire-quest".into())`
|
||||||
|
- Default position `WindowPosition::Centered(MonitorSelection::Primary)`
|
||||||
|
- `install_crash_log_hook()` wraps the default panic hook to also append a `crash.log` next to `settings.json`. Uses `std::time::SystemTime` (no new chrono dep). Falls through silently if the data dir is unavailable.
|
||||||
|
- **Skipped this round (deferred):**
|
||||||
|
- App icon hookup — no artwork asset exists yet; add the loader path when art lands.
|
||||||
|
- Persisted window geometry — needs a `Settings` schema migration.
|
||||||
|
- F11 fullscreen toggle — already wired in `input_plugin.rs:114`, no change needed.
|
||||||
|
- **Build status:** `cargo build -p solitaire_app` clean; `cargo clippy -p solitaire_app -- -D warnings` clean.
|
||||||
|
- **Suggested commit subject:** `feat(app): window polish — class name, centered position, crash-log hook`
|
||||||
|
|
||||||
|
### Track G subset — modal open animation + score-change feedback (in flight)
|
||||||
|
|
||||||
|
- A **background agent** (`general-purpose`, no worktree) was launched against this turn's tree to:
|
||||||
|
- Extend `spawn_modal` in `solitaire_engine/src/ui_modal.rs` with a `ModalEntering` component + `advance_modal_enter` system that animates scrim alpha 0 → `SCRIM` and card scale 0.96 → 1.0 over `MOTION_MODAL_SECS`. Respects `AnimSpeed::Instant` via `scaled_duration`. Animate-OUT path is intentionally out of scope.
|
||||||
|
- In `solitaire_engine/src/hud_plugin.rs`, add a `ScorePulse` 1.0→1.1→1.0 readout pulse over `MOTION_SCORE_PULSE_SECS` and a floating "+N" Text2d (only for ≥ +50 jumps) that drifts up ~40 px and fades over `MOTION_SCORE_PULSE_SECS * 2`.
|
||||||
|
- Tests for both behaviours.
|
||||||
|
- **State at pause:** the agent had partial edits in `solitaire_engine/src/ui_modal.rs` (visible via `git status`) — at least one unused-import warning was already surfacing. It had not reported back when this snapshot was taken.
|
||||||
|
- **Resume options for the next session:**
|
||||||
|
1. **Wait for the notification.** The agent runs in background; if Claude Code is still alive, the completion notification will fire.
|
||||||
|
2. **Inspect and finish manually.** `git diff solitaire_engine/src/ui_modal.rs solitaire_engine/src/hud_plugin.rs` to see what landed; finish or revert and restart with a tighter prompt.
|
||||||
|
3. **Discard and restart.** `git restore solitaire_engine/src/ui_modal.rs solitaire_engine/src/hud_plugin.rs` then relaunch the agent with the prompt below.
|
||||||
|
|
||||||
|
### Next-session workflow at pause
|
||||||
|
|
||||||
|
1. Verify the workspace builds cleanly with **all** in-flight changes: `cargo build --workspace && cargo clippy --workspace -- -D warnings && cargo test --workspace`. The Track B `main.rs` change is independent — even if Track G is reverted, B compiles on its own.
|
||||||
|
2. If Track B is clean and Track G is incomplete or broken: commit Track B first using the subject above, then deal with Track G.
|
||||||
|
3. If both are clean: commit each as a separate landing — one feature per commit per project convention.
|
||||||
|
4. Use:
|
||||||
|
```
|
||||||
|
git -c user.name=funman300 -c user.email=root@vscode.infinity commit -m "<subject>"
|
||||||
|
```
|
||||||
|
5. Push with `git push origin master` (requires interactive credentials on `git.aleshym.co`).
|
||||||
|
|
||||||
|
### Original Track G subset prompt (for relaunch if needed)
|
||||||
|
|
||||||
|
The agent's full brief is preserved here verbatim — paste into a fresh agent if the current one is unrecoverable:
|
||||||
|
|
||||||
|
```
|
||||||
|
Two UI/UX polish items from track G. Tree clean at HEAD `534870a`.
|
||||||
|
Sub-agents CANNOT git commit — stage your work; orchestrator commits.
|
||||||
|
|
||||||
|
G1. Modal open animation: extend spawn_modal in ui_modal.rs with a
|
||||||
|
ModalEntering component + advance_modal_enter system that animates
|
||||||
|
scrim alpha 0 → SCRIM and card scale 0.96 → 1.0 over MOTION_MODAL_SECS.
|
||||||
|
Use scaled_duration for AnimSpeed respect; ease-out curve t*(2-t).
|
||||||
|
Register the system in UiModalPlugin::build. Animate-OUT is OUT of
|
||||||
|
scope. Add ≥2 tests covering ModalEntering presence on spawn and
|
||||||
|
removal after duration elapses.
|
||||||
|
|
||||||
|
G2. Score-change feedback in hud_plugin.rs: ScorePulse component that
|
||||||
|
scales the score Text 1.0→1.1→1.0 over MOTION_SCORE_PULSE_SECS using
|
||||||
|
triangular curve. Plus a floating "+N" Text2d (only for ≥ +50 jumps)
|
||||||
|
in ACCENT_PRIMARY that drifts up 40 px and fades over
|
||||||
|
MOTION_SCORE_PULSE_SECS * 2. Add ≥2 tests for floater spawn on +50
|
||||||
|
and despawn after lifetime, plus ≥1 test that +5 does NOT spawn.
|
||||||
|
|
||||||
|
Hard requirements: workspace build + clippy --workspace -- -D warnings
|
||||||
|
+ test --workspace all green. Touch ONLY ui_modal.rs, hud_plugin.rs,
|
||||||
|
optionally ui_theme.rs for new tokens (don't think you'll need any).
|
||||||
|
DO NOT touch solitaire_app/src/main.rs (parallel work).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where we are (Phase 3)
|
||||||
|
|
||||||
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`).
|
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`).
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::{MonitorSelection, WindowPosition};
|
||||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
|
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
|
||||||
@@ -10,6 +15,11 @@ use solitaire_engine::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// Install a panic hook that writes a crash log next to the save files
|
||||||
|
// before re-running the default hook (so stderr still gets the message
|
||||||
|
// and any debugger attached still sees the panic).
|
||||||
|
install_crash_log_hook();
|
||||||
|
|
||||||
// Initialise the platform keyring store before any token operations.
|
// Initialise the platform keyring store before any token operations.
|
||||||
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||||
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||||
@@ -35,7 +45,11 @@ fn main() {
|
|||||||
.set(WindowPlugin {
|
.set(WindowPlugin {
|
||||||
primary_window: Some(Window {
|
primary_window: Some(Window {
|
||||||
title: "Solitaire Quest".into(),
|
title: "Solitaire Quest".into(),
|
||||||
|
// X11/Wayland WM_CLASS so taskbar managers group
|
||||||
|
// multiple windows of this app correctly.
|
||||||
|
name: Some("solitaire-quest".into()),
|
||||||
resolution: (1280u32, 800u32).into(),
|
resolution: (1280u32, 800u32).into(),
|
||||||
|
position: WindowPosition::Centered(MonitorSelection::Primary),
|
||||||
resize_constraints: bevy::window::WindowResizeConstraints {
|
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||||
min_width: 800.0,
|
min_width: 800.0,
|
||||||
min_height: 600.0,
|
min_height: 600.0,
|
||||||
@@ -87,3 +101,33 @@ fn main() {
|
|||||||
.add_plugins(UiModalPlugin)
|
.add_plugins(UiModalPlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wraps the default panic hook with one that also appends a crash log
|
||||||
|
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||||
|
/// still runs afterwards, so stderr output and debugger integration are
|
||||||
|
/// unchanged. If the data directory is unavailable, the wrapper silently
|
||||||
|
/// falls through — the default hook handles output either way.
|
||||||
|
fn install_crash_log_hook() {
|
||||||
|
let crash_log_path = settings_file_path().and_then(|p| {
|
||||||
|
p.parent()
|
||||||
|
.map(|parent| parent.join("crash.log"))
|
||||||
|
});
|
||||||
|
let default_hook = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
if let Some(path) = crash_log_path.as_ref()
|
||||||
|
&& let Ok(mut file) = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(path)
|
||||||
|
{
|
||||||
|
// Plain unix-seconds timestamp keeps the format trivially
|
||||||
|
// parseable and avoids pulling in chrono just for this.
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
|
||||||
|
}
|
||||||
|
default_hook(info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -210,8 +210,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scale_duration_applies_multiplier() {
|
fn scale_duration_applies_multiplier() {
|
||||||
let mut t = AnimationTuning::default();
|
let t = AnimationTuning {
|
||||||
t.duration_scale = 0.5;
|
duration_scale: 0.5,
|
||||||
|
..AnimationTuning::default()
|
||||||
|
};
|
||||||
assert!((t.scale_duration(1.0) - 0.5).abs() < 1e-6);
|
assert!((t.scale_duration(1.0) - 0.5).abs() < 1e-6);
|
||||||
assert!((t.scale_duration(0.25) - 0.125).abs() < 1e-6);
|
assert!((t.scale_duration(0.25) - 0.125).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1510,27 +1510,30 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tableau_fan_frac_is_in_unit_interval() {
|
fn tableau_fan_frac_is_in_unit_interval() {
|
||||||
|
const {
|
||||||
assert!(
|
assert!(
|
||||||
TABLEAU_FAN_FRAC > 0.0 && TABLEAU_FAN_FRAC < 1.0,
|
TABLEAU_FAN_FRAC > 0.0 && TABLEAU_FAN_FRAC < 1.0,
|
||||||
"TABLEAU_FAN_FRAC must be in (0, 1), got {TABLEAU_FAN_FRAC}"
|
"TABLEAU_FAN_FRAC must be in (0, 1)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn flip_half_secs_is_positive() {
|
fn flip_half_secs_is_positive() {
|
||||||
assert!(
|
const {
|
||||||
FLIP_HALF_SECS > 0.0,
|
assert!(FLIP_HALF_SECS > 0.0, "FLIP_HALF_SECS must be positive");
|
||||||
"FLIP_HALF_SECS must be positive, got {FLIP_HALF_SECS}"
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn font_size_frac_is_positive_and_reasonable() {
|
fn font_size_frac_is_positive_and_reasonable() {
|
||||||
|
const {
|
||||||
assert!(
|
assert!(
|
||||||
FONT_SIZE_FRAC > 0.0 && FONT_SIZE_FRAC <= 1.0,
|
FONT_SIZE_FRAC > 0.0 && FONT_SIZE_FRAC <= 1.0,
|
||||||
"FONT_SIZE_FRAC should be in (0, 1], got {FONT_SIZE_FRAC}"
|
"FONT_SIZE_FRAC should be in (0, 1]"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// face_colour (pure) — color-blind mode
|
// face_colour (pure) — color-blind mode
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ use crate::auto_complete_plugin::AutoCompleteState;
|
|||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
|
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
BORDER_SUBTLE, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING,
|
BG_ELEVATED_PRESSED, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM,
|
||||||
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE,
|
STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
};
|
};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||||
@@ -98,6 +99,46 @@ pub struct HudDrawCycle;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HudSelection;
|
pub struct HudSelection;
|
||||||
|
|
||||||
|
/// Drives the score-readout pulse: scales the [`HudScore`] text from
|
||||||
|
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
|
||||||
|
/// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score
|
||||||
|
/// entity whenever the score increases; removed once `elapsed >=
|
||||||
|
/// duration`.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct ScorePulse {
|
||||||
|
/// Seconds elapsed since the pulse started.
|
||||||
|
pub elapsed: f32,
|
||||||
|
/// Total duration. Zero under `AnimSpeed::Instant` — the system
|
||||||
|
/// snaps the scale back to 1.0 on first tick so no half-state
|
||||||
|
/// is ever shown.
|
||||||
|
pub duration: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on a transient floating "+N" text spawned next to the score
|
||||||
|
/// readout when the score jumps by [`SCORE_FLOATER_THRESHOLD`] or more.
|
||||||
|
/// Drifts upward and fades out over `MOTION_SCORE_PULSE_SECS * 2`,
|
||||||
|
/// then despawns. Kept rare/meaningful by the threshold gate.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct ScoreFloater {
|
||||||
|
/// Seconds elapsed since the floater spawned.
|
||||||
|
pub elapsed: f32,
|
||||||
|
/// Total lifetime. Zero under `AnimSpeed::Instant` — the system
|
||||||
|
/// despawns it on first tick.
|
||||||
|
pub duration: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks the score from the previous frame so the HUD can detect
|
||||||
|
/// changes without a `ScoreChangedEvent`. The plugin wires this to the
|
||||||
|
/// pulse + floater systems on every `Update`.
|
||||||
|
#[derive(Resource, Debug, Default, Clone, Copy)]
|
||||||
|
pub struct PreviousScore(pub i32);
|
||||||
|
|
||||||
|
/// Score increase (in points) below which no floating "+N" is spawned.
|
||||||
|
/// 50 keeps the feedback for foundation drops and tableau-to-foundation
|
||||||
|
/// promotions; single-card placements (which can earn as little as +5)
|
||||||
|
/// stay quiet so the floater feels like a reward instead of noise.
|
||||||
|
pub const SCORE_FLOATER_THRESHOLD: i32 = 50;
|
||||||
|
|
||||||
/// Marker shared by every clickable HUD action button so a single
|
/// Marker shared by every clickable HUD action button so a single
|
||||||
/// `paint_action_buttons` system can recolour them on hover/press without
|
/// `paint_action_buttons` system can recolour them on hover/press without
|
||||||
/// each button needing its own paint handler.
|
/// each button needing its own paint handler.
|
||||||
@@ -207,10 +248,21 @@ impl Plugin for HudPlugin {
|
|||||||
.add_message::<ToggleProfileRequestEvent>()
|
.add_message::<ToggleProfileRequestEvent>()
|
||||||
.add_message::<ToggleSettingsRequestEvent>()
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||||
|
.init_resource::<PreviousScore>()
|
||||||
.add_systems(Startup, (spawn_hud, spawn_action_buttons))
|
.add_systems(Startup, (spawn_hud, spawn_action_buttons))
|
||||||
.add_systems(Update, update_hud.after(GameMutation))
|
.add_systems(Update, update_hud.after(GameMutation))
|
||||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||||
.add_systems(Update, update_selection_hud)
|
.add_systems(Update, update_selection_hud)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
detect_score_change,
|
||||||
|
advance_score_pulse,
|
||||||
|
advance_score_floater,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -796,6 +848,179 @@ pub fn format_time_limit(secs: u64) -> String {
|
|||||||
format!("{m}:{s:02}")
|
format!("{m}:{s:02}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Score-change feedback (G2)
|
||||||
|
//
|
||||||
|
// The flow for each Update tick:
|
||||||
|
// 1. `detect_score_change` diffs `GameStateResource.score` against
|
||||||
|
// `PreviousScore`. On any positive delta it inserts/refreshes
|
||||||
|
// `ScorePulse` on the score readout; on a delta ≥
|
||||||
|
// `SCORE_FLOATER_THRESHOLD` it also spawns a floating "+N" UI text
|
||||||
|
// anchored just below the score.
|
||||||
|
// 2. `advance_score_pulse` ticks the pulse component, applies the
|
||||||
|
// triangular 1.0 → 1.1 → 1.0 scale curve, and removes the
|
||||||
|
// component on completion.
|
||||||
|
// 3. `advance_score_floater` drifts each floater upward, fades it to
|
||||||
|
// transparent, and despawns it when its lifetime expires.
|
||||||
|
//
|
||||||
|
// The threshold of 50 (a foundation promotion's typical bonus) keeps
|
||||||
|
// floaters rare and meaningful — see `SCORE_FLOATER_THRESHOLD`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Triangular 1.0 → 1.1 → 1.0 curve used by the score pulse. Pure
|
||||||
|
/// function so the test suite can assert on the curve directly
|
||||||
|
/// without spinning up a Bevy app.
|
||||||
|
///
|
||||||
|
/// The brief proposed `if t < 0.5 { 1.0 + 0.2*t } else { 1.2 - 0.2*(t-0.5) }`,
|
||||||
|
/// but that yields a discontinuity at t=0.5 (jumps from 1.1 → 1.2) and
|
||||||
|
/// ends at 1.1 instead of 1.0. The corrected form below preserves the
|
||||||
|
/// intent ("1.0 → 1.1 → 1.0 over the duration") with a continuous
|
||||||
|
/// triangle peaking at 1.1.
|
||||||
|
fn score_pulse_scale(t: f32) -> f32 {
|
||||||
|
let clamped = t.clamp(0.0, 1.0);
|
||||||
|
if clamped < 0.5 {
|
||||||
|
1.0 + 0.2 * clamped
|
||||||
|
} else {
|
||||||
|
1.1 - 0.2 * (clamped - 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vertical pixels the floating "+N" drifts up over its lifetime.
|
||||||
|
const FLOATER_DRIFT_PX: f32 = 40.0;
|
||||||
|
|
||||||
|
/// Diffs the current `GameStateResource.score` against
|
||||||
|
/// [`PreviousScore`]. On a positive delta:
|
||||||
|
///
|
||||||
|
/// - Inserts (or refreshes) a [`ScorePulse`] on every [`HudScore`] entity
|
||||||
|
/// so the readout pulses 1.0 → 1.1 → 1.0.
|
||||||
|
/// - When the delta is ≥ [`SCORE_FLOATER_THRESHOLD`], spawns a floating
|
||||||
|
/// "+N" UI text in `ACCENT_PRIMARY` anchored just below the score
|
||||||
|
/// readout (see the doc comment on [`ScoreFloater`] for why this is a
|
||||||
|
/// UI Node rather than a `Text2d`).
|
||||||
|
fn detect_score_change(
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut prev: ResMut<PreviousScore>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
score_q: Query<Entity, With<HudScore>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let current = game.0.score;
|
||||||
|
let delta = current - prev.0;
|
||||||
|
prev.0 = current;
|
||||||
|
if delta <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let speed = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.0.animation_speed)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let pulse_secs = scaled_duration(MOTION_SCORE_PULSE_SECS, speed);
|
||||||
|
let floater_secs = scaled_duration(MOTION_SCORE_PULSE_SECS * 2.0, speed);
|
||||||
|
|
||||||
|
// Refresh ScorePulse on every score readout entity (in practice
|
||||||
|
// there's exactly one, but iterating is cheaper than asserting).
|
||||||
|
for entity in &score_q {
|
||||||
|
commands.entity(entity).insert(ScorePulse {
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: pulse_secs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if delta < SCORE_FLOATER_THRESHOLD {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
// Spawned as an absolutely-positioned UI Node so the floater rides
|
||||||
|
// the same screen-coordinate system as the score readout. Using a
|
||||||
|
// `Text2d` here would require translating UI layout coordinates to
|
||||||
|
// world space every frame; a UI node piggybacks on the same
|
||||||
|
// anchoring `update_hud` already uses for the score and stays
|
||||||
|
// testable under `MinimalPlugins`.
|
||||||
|
commands.spawn((
|
||||||
|
ScoreFloater {
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: floater_secs,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
// Anchored next to the HUD column; matches the
|
||||||
|
// `spawn_hud` left/top offsets so the floater appears
|
||||||
|
// overlaid on the score line and drifts up from there.
|
||||||
|
left: VAL_SPACE_3,
|
||||||
|
top: Val::Px(0.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
ZIndex(Z_HUD + 10),
|
||||||
|
Text::new(format!("+{delta}")),
|
||||||
|
font,
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every [`ScorePulse`], scaling its entity's `Transform`
|
||||||
|
/// using [`score_pulse_scale`]. Removes the component once
|
||||||
|
/// `elapsed >= duration` (or immediately under
|
||||||
|
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) where duration is
|
||||||
|
/// 0) and pins the scale back to 1.0 so no float drift survives.
|
||||||
|
fn advance_score_pulse(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut q: Query<(Entity, &mut ScorePulse, &mut Transform)>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut pulse, mut transform) in &mut q {
|
||||||
|
let t = if pulse.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
pulse.elapsed += dt;
|
||||||
|
(pulse.elapsed / pulse.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
let scale = score_pulse_scale(t);
|
||||||
|
transform.scale = Vec3::new(scale, scale, 1.0);
|
||||||
|
if t >= 1.0 {
|
||||||
|
transform.scale = Vec3::ONE;
|
||||||
|
commands.entity(entity).remove::<ScorePulse>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every [`ScoreFloater`]: drifts the node upward by up to
|
||||||
|
/// [`FLOATER_DRIFT_PX`] and fades the text colour to transparent over
|
||||||
|
/// its lifetime. Despawns the entity once `elapsed >= duration`.
|
||||||
|
fn advance_score_floater(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut nodes: Query<(Entity, &mut ScoreFloater, &mut Node, &mut TextColor)>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut floater, mut node, mut color) in &mut nodes {
|
||||||
|
let t = if floater.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
floater.elapsed += dt;
|
||||||
|
(floater.elapsed / floater.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
// Drift upward: top decreases as t grows. Starting top=0 keeps
|
||||||
|
// the floater on the score line; ending at -FLOATER_DRIFT_PX
|
||||||
|
// pulls it up off the readout.
|
||||||
|
node.top = Val::Px(-FLOATER_DRIFT_PX * t);
|
||||||
|
// Linear fade: ACCENT_PRIMARY at t=0 → fully transparent at t=1.
|
||||||
|
let mut c = ACCENT_PRIMARY;
|
||||||
|
c.set_alpha(1.0 - t);
|
||||||
|
color.0 = c;
|
||||||
|
if t >= 1.0 {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
fn update_hud(
|
fn update_hud(
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
@@ -1476,4 +1701,107 @@ mod tests {
|
|||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Score-change feedback (G2)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Tells `TimePlugin` to advance by `secs` on every subsequent
|
||||||
|
/// `app.update()`. Mirrors the helper in `ui_modal::tests`; kept
|
||||||
|
/// local to avoid coupling the two test modules.
|
||||||
|
fn set_manual_time_step(app: &mut App, secs: f32) {
|
||||||
|
use bevy::time::TimeUpdateStrategy;
|
||||||
|
use std::time::Duration;
|
||||||
|
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||||
|
Duration::from_secs_f32(secs),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts entities matching component `M` currently in the world.
|
||||||
|
fn count_with<M: Component>(app: &mut App) -> usize {
|
||||||
|
app.world_mut().query::<&M>().iter(app.world()).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A score jump ≥ `SCORE_FLOATER_THRESHOLD` spawns a floating
|
||||||
|
/// `ScoreFloater` entity coloured `ACCENT_PRIMARY`. The pulse
|
||||||
|
/// component is also inserted on the score readout — both signals
|
||||||
|
/// fire from the same delta detection.
|
||||||
|
#[test]
|
||||||
|
fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Pin `Time::delta_secs()` to 0 so the floater's RGB and alpha
|
||||||
|
// can be asserted exactly: with Automatic strategy a few ms
|
||||||
|
// of wall-clock time leaks in between updates and the alpha
|
||||||
|
// drifts below 1.0 by `dt / lifetime`.
|
||||||
|
set_manual_time_step(&mut app, 0.0);
|
||||||
|
// Initial state has score=0; bumping by 50 (the threshold)
|
||||||
|
// is the smallest jump that triggers the floater.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.score = 50;
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// One floater should now exist.
|
||||||
|
let count = count_with::<ScoreFloater>(&mut app);
|
||||||
|
assert_eq!(count, 1, "expected a single ScoreFloater for a +50 jump");
|
||||||
|
|
||||||
|
// Its TextColor must be ACCENT_PRIMARY at full alpha. The
|
||||||
|
// detect system spawns the floater coloured ACCENT_PRIMARY
|
||||||
|
// and at dt=0 the first advance tick leaves alpha = 1.0.
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut q = world.query::<(&ScoreFloater, &TextColor)>();
|
||||||
|
let (_floater, color) = q.iter(world).next().expect("floater missing TextColor");
|
||||||
|
assert_eq!(color.0, ACCENT_PRIMARY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After enough time for `MOTION_SCORE_PULSE_SECS * 2` to elapse
|
||||||
|
/// the floater has reached the end of its lifetime and despawned.
|
||||||
|
#[test]
|
||||||
|
fn score_floater_despawns_after_full_lifetime() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
||||||
|
|
||||||
|
// Advance by a delta well past the floater's lifetime — the
|
||||||
|
// single oversized tick clamps t at 1.0 and the entity is
|
||||||
|
// despawned in the same `Update`.
|
||||||
|
set_manual_time_step(&mut app, MOTION_SCORE_PULSE_SECS * 2.0 * 2.0 + 0.1);
|
||||||
|
app.update();
|
||||||
|
app.update(); // first update propagates the new strategy; second runs the system with non-zero dt.
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
count_with::<ScoreFloater>(&mut app),
|
||||||
|
0,
|
||||||
|
"floater should have despawned after its full lifetime"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small score change (below the threshold) inserts a pulse on
|
||||||
|
/// the readout but never spawns a floater — keeping the floating
|
||||||
|
/// "+N" reserved for meaningful score jumps.
|
||||||
|
#[test]
|
||||||
|
fn score_increase_below_threshold_does_not_spawn_floater() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// +5 mirrors a single tableau-to-foundation move; well below
|
||||||
|
// the 50-point threshold so the floater path stays dormant.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.score = 5;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
count_with::<ScoreFloater>(&mut app),
|
||||||
|
0,
|
||||||
|
"delta of +5 must not spawn a floater"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The triangular pulse curve hits its peak (1.1) at t=0.5 and
|
||||||
|
/// returns to 1.0 at the endpoints. Pure-function check that
|
||||||
|
/// guards the curve shape against future tweaks.
|
||||||
|
#[test]
|
||||||
|
fn score_pulse_scale_is_triangular() {
|
||||||
|
assert!((score_pulse_scale(0.0) - 1.0).abs() < 1e-6);
|
||||||
|
assert!((score_pulse_scale(0.5) - 1.1).abs() < 1e-6);
|
||||||
|
assert!((score_pulse_scale(1.0) - 1.0).abs() < 1e-6);
|
||||||
|
// Values outside [0,1] are clamped before the curve runs.
|
||||||
|
assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6);
|
||||||
|
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,13 +49,15 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
|
scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED,
|
||||||
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, RADIUS_LG, RADIUS_MD,
|
BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE,
|
||||||
SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2,
|
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG,
|
||||||
VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5,
|
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -89,6 +91,27 @@ pub struct ModalActions;
|
|||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
pub struct ModalButton(pub ButtonVariant);
|
pub struct ModalButton(pub ButtonVariant);
|
||||||
|
|
||||||
|
/// Drives the modal open animation. Inserted on the scrim entity by
|
||||||
|
/// [`spawn_modal`]; advanced and removed by [`advance_modal_enter`] once
|
||||||
|
/// `elapsed >= duration`.
|
||||||
|
///
|
||||||
|
/// During the animation the scrim's `BackgroundColor` alpha lerps from
|
||||||
|
/// 0 → `SCRIM`'s native alpha and the card's `Transform` scale lerps from
|
||||||
|
/// `MODAL_ENTER_START_SCALE` → 1.0. Under `AnimSpeed::Instant`,
|
||||||
|
/// `duration == 0.0` and the system snaps everything to the final state on
|
||||||
|
/// the first tick so no half-state is ever shown.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct ModalEntering {
|
||||||
|
/// Seconds elapsed since the animation started.
|
||||||
|
pub elapsed: f32,
|
||||||
|
/// Total duration in seconds. May be zero (`AnimSpeed::Instant`).
|
||||||
|
pub duration: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initial card scale at `t = 0` for the modal open animation. The card
|
||||||
|
/// grows from this value to `1.0` over `MOTION_MODAL_SECS`.
|
||||||
|
pub const MODAL_ENTER_START_SCALE: f32 = 0.96;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Button variants — three rungs of emphasis. A single overlay should have
|
// Button variants — three rungs of emphasis. A single overlay should have
|
||||||
// at most one Primary; Secondary and Tertiary fill out the rest.
|
// at most one Primary; Secondary and Tertiary fill out the rest.
|
||||||
@@ -120,6 +143,16 @@ pub enum ButtonVariant {
|
|||||||
/// `plugin_marker` is the overlay's plugin-specific marker
|
/// `plugin_marker` is the overlay's plugin-specific marker
|
||||||
/// (`ConfirmNewGameScreen`, `HelpScreen`, etc.) so plugin click handlers
|
/// (`ConfirmNewGameScreen`, `HelpScreen`, etc.) so plugin click handlers
|
||||||
/// can find their own modal.
|
/// can find their own modal.
|
||||||
|
///
|
||||||
|
/// **Open animation.** The scrim is spawned with alpha 0 and the card
|
||||||
|
/// with `Transform::scale = MODAL_ENTER_START_SCALE`; a [`ModalEntering`]
|
||||||
|
/// component on the scrim drives the scrim alpha → `SCRIM`'s native
|
||||||
|
/// alpha and the card scale → 1.0 lerps via [`advance_modal_enter`]. The
|
||||||
|
/// duration is `scaled_duration(MOTION_MODAL_SECS, settings.animation_speed)`
|
||||||
|
/// so the open animation respects the player's `AnimSpeed` preference;
|
||||||
|
/// under `AnimSpeed::Instant` the duration is zero and the very first
|
||||||
|
/// tick snaps to the final state. The animate-OUT path is intentionally
|
||||||
|
/// out of scope — modals despawn instantly.
|
||||||
pub fn spawn_modal<M: Component, F>(
|
pub fn spawn_modal<M: Component, F>(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
plugin_marker: M,
|
plugin_marker: M,
|
||||||
@@ -129,10 +162,19 @@ pub fn spawn_modal<M: Component, F>(
|
|||||||
where
|
where
|
||||||
F: FnOnce(&mut ChildSpawnerCommands),
|
F: FnOnce(&mut ChildSpawnerCommands),
|
||||||
{
|
{
|
||||||
|
// The duration here is the `AnimSpeed::Normal` baseline; the
|
||||||
|
// `apply_modal_enter_speed` system rescales it (or zeroes it for
|
||||||
|
// `AnimSpeed::Instant`) on the first frame after spawn by reading
|
||||||
|
// `SettingsResource`. Doing it that way keeps `spawn_modal` a free
|
||||||
|
// function with no resource dependencies — every existing call site
|
||||||
|
// (~11 plugins) continues to work without a signature change.
|
||||||
|
let duration = MOTION_MODAL_SECS;
|
||||||
|
let initial_scrim = scrim_with_alpha(0.0);
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
plugin_marker,
|
plugin_marker,
|
||||||
ModalScrim,
|
ModalScrim,
|
||||||
|
ModalEntering { elapsed: 0.0, duration },
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: Val::Px(0.0),
|
left: Val::Px(0.0),
|
||||||
@@ -143,7 +185,7 @@ where
|
|||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(SCRIM),
|
BackgroundColor(initial_scrim),
|
||||||
// GlobalZIndex pins this root modal at `z_panel` regardless
|
// GlobalZIndex pins this root modal at `z_panel` regardless
|
||||||
// of any sibling stacking-context quirks in Bevy 0.18 — the
|
// of any sibling stacking-context quirks in Bevy 0.18 — the
|
||||||
// ordinary `ZIndex` is preserved as a fallback for nested
|
// ordinary `ZIndex` is preserved as a fallback for nested
|
||||||
@@ -167,6 +209,9 @@ where
|
|||||||
align_items: AlignItems::Stretch,
|
align_items: AlignItems::Stretch,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
|
// Card UI nodes carry a Transform; the open animation
|
||||||
|
// lerps `scale` from MODAL_ENTER_START_SCALE → 1.0.
|
||||||
|
Transform::from_scale(Vec3::splat(MODAL_ENTER_START_SCALE)),
|
||||||
BackgroundColor(BG_ELEVATED),
|
BackgroundColor(BG_ELEVATED),
|
||||||
BorderColor::all(BORDER_STRONG),
|
BorderColor::all(BORDER_STRONG),
|
||||||
))
|
))
|
||||||
@@ -175,6 +220,16 @@ where
|
|||||||
.id()
|
.id()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns `SCRIM` with its alpha multiplied by `factor` (0.0–1.0). The
|
||||||
|
/// open animation lerps `factor` from 0 → 1 over the modal-enter
|
||||||
|
/// duration so the scrim fades in instead of popping.
|
||||||
|
fn scrim_with_alpha(factor: f32) -> Color {
|
||||||
|
let mut c = SCRIM;
|
||||||
|
let target = SCRIM.alpha();
|
||||||
|
c.set_alpha(target * factor.clamp(0.0, 1.0));
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawns the standard modal header — `TYPE_HEADLINE` + `TEXT_PRIMARY`.
|
/// Spawns the standard modal header — `TYPE_HEADLINE` + `TEXT_PRIMARY`.
|
||||||
pub fn spawn_modal_header(
|
pub fn spawn_modal_header(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
@@ -336,6 +391,90 @@ fn pressed_bg(variant: ButtonVariant) -> Color {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Modal open animation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Patches the `ModalEntering::duration` of newly-spawned modals against
|
||||||
|
/// the player's `AnimSpeed` setting. Runs on `Added<ModalEntering>` so it
|
||||||
|
/// only fires once per modal, immediately after [`spawn_modal`] inserts
|
||||||
|
/// the component.
|
||||||
|
///
|
||||||
|
/// Under `AnimSpeed::Instant` this drops the duration to 0; the next
|
||||||
|
/// frame [`advance_modal_enter`] sees `t >= 1.0` and snaps the modal to
|
||||||
|
/// its final state, so no half-state is ever shown.
|
||||||
|
pub fn apply_modal_enter_speed(
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut q: Query<&mut ModalEntering, Added<ModalEntering>>,
|
||||||
|
) {
|
||||||
|
let speed = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.0.animation_speed)
|
||||||
|
.unwrap_or(AnimSpeed::Normal);
|
||||||
|
for mut entering in &mut q {
|
||||||
|
entering.duration = scaled_duration(MOTION_MODAL_SECS, speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the modal open animation. For each scrim entity carrying
|
||||||
|
/// [`ModalEntering`] this system increments `elapsed`, computes
|
||||||
|
/// `t = (elapsed / duration).clamp(0, 1)`, applies an ease-out
|
||||||
|
/// (`t * (2 - t)`) curve to both the scrim alpha and the card scale,
|
||||||
|
/// and removes the component plus any leftover transform offset once
|
||||||
|
/// `t >= 1.0`.
|
||||||
|
///
|
||||||
|
/// The card scale is patched on the modal's `ModalCard` child rather
|
||||||
|
/// than on the scrim — the scrim is full-window and any scale on it
|
||||||
|
/// would visibly squash the layout. The card carries its own
|
||||||
|
/// `Transform`, started at `Vec3::splat(MODAL_ENTER_START_SCALE)` by
|
||||||
|
/// [`spawn_modal`].
|
||||||
|
pub fn advance_modal_enter(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut scrims: Query<(Entity, &mut ModalEntering, &mut BackgroundColor, &Children), With<ModalScrim>>,
|
||||||
|
mut cards: Query<&mut Transform, With<ModalCard>>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (scrim_entity, mut entering, mut bg, children) in &mut scrims {
|
||||||
|
// Zero-duration path (AnimSpeed::Instant): snap to the final
|
||||||
|
// state on the very first tick so the modal is fully visible
|
||||||
|
// immediately and we never expose the 0.96 / alpha-0 starting
|
||||||
|
// pose to the player.
|
||||||
|
let t = if entering.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
entering.elapsed += dt;
|
||||||
|
(entering.elapsed / entering.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ease-out: t * (2 - t). Reaches 1.0 at t=1, derivative is 0
|
||||||
|
// at the endpoint so the animation settles instead of snapping.
|
||||||
|
let eased = t * (2.0 - t);
|
||||||
|
|
||||||
|
bg.0 = scrim_with_alpha(eased);
|
||||||
|
|
||||||
|
let scale = MODAL_ENTER_START_SCALE + (1.0 - MODAL_ENTER_START_SCALE) * eased;
|
||||||
|
for child in children.iter() {
|
||||||
|
if let Ok(mut transform) = cards.get_mut(child) {
|
||||||
|
transform.scale = Vec3::splat(scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if t >= 1.0 {
|
||||||
|
// Pin scrim and card to their final exact values so any
|
||||||
|
// float drift from the lerp doesn't survive into normal
|
||||||
|
// use (downstream paint systems read these later).
|
||||||
|
bg.0 = SCRIM;
|
||||||
|
for child in children.iter() {
|
||||||
|
if let Ok(mut transform) = cards.get_mut(child) {
|
||||||
|
transform.scale = Vec3::ONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
commands.entity(scrim_entity).remove::<ModalEntering>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Repaints every `ModalButton` on `Changed<Interaction>` so hover and
|
/// Repaints every `ModalButton` on `Changed<Interaction>` so hover and
|
||||||
/// press states are visible without each overlay registering its own
|
/// press states are visible without each overlay registering its own
|
||||||
/// paint system.
|
/// paint system.
|
||||||
@@ -366,7 +505,17 @@ pub struct UiModalPlugin;
|
|||||||
|
|
||||||
impl Plugin for UiModalPlugin {
|
impl Plugin for UiModalPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_systems(Update, paint_modal_buttons);
|
// Order: `apply_modal_enter_speed` patches the duration on the
|
||||||
|
// first frame after spawn (Added<ModalEntering>), then
|
||||||
|
// `advance_modal_enter` ticks. Running them in a tuple keeps
|
||||||
|
// them in the same stage so a freshly-spawned modal lands on
|
||||||
|
// the correct duration before its first frame of advance —
|
||||||
|
// important for AnimSpeed::Instant where duration must be 0
|
||||||
|
// before advance computes `t`.
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
(apply_modal_enter_speed, advance_modal_enter, paint_modal_buttons).chain(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,5 +549,125 @@ mod tests {
|
|||||||
// App built without panic — paint_modal_buttons is registered.
|
// App built without panic — paint_modal_buttons is registered.
|
||||||
app.update();
|
app.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Modal open animation (G1)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker component for the test modal — `spawn_modal` requires a
|
||||||
|
/// `Component` so tests need their own dummy.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct TestModal;
|
||||||
|
|
||||||
|
/// `spawn_modal` inserts `ModalEntering` carrying the full
|
||||||
|
/// `MOTION_MODAL_SECS` duration (`AnimSpeed::Normal` baseline) plus
|
||||||
|
/// a card child sized at the start scale. The
|
||||||
|
/// `apply_modal_enter_speed` system rescales later under
|
||||||
|
/// `SettingsResource`; absent that resource the baseline stands.
|
||||||
|
#[test]
|
||||||
|
fn spawn_modal_inserts_entering_with_full_duration_and_start_scale() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(UiModalPlugin);
|
||||||
|
|
||||||
|
let scrim = {
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut commands = world.commands();
|
||||||
|
let id = spawn_modal(&mut commands, TestModal, 0, |_| {});
|
||||||
|
world.flush();
|
||||||
|
id
|
||||||
|
};
|
||||||
|
|
||||||
|
let entering = app
|
||||||
|
.world()
|
||||||
|
.entity(scrim)
|
||||||
|
.get::<ModalEntering>()
|
||||||
|
.expect("ModalEntering should be inserted on spawn");
|
||||||
|
assert!(
|
||||||
|
(entering.duration - MOTION_MODAL_SECS).abs() < 1e-6,
|
||||||
|
"duration should be the AnimSpeed::Normal baseline before apply_modal_enter_speed runs; got {}",
|
||||||
|
entering.duration
|
||||||
|
);
|
||||||
|
assert_eq!(entering.elapsed, 0.0);
|
||||||
|
|
||||||
|
// The card child carries Transform with scale at the start value.
|
||||||
|
let card_scale = card_scale_of(&mut app, scrim);
|
||||||
|
assert!(
|
||||||
|
(card_scale - MODAL_ENTER_START_SCALE).abs() < 1e-6,
|
||||||
|
"card should spawn at MODAL_ENTER_START_SCALE; got {card_scale}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After enough simulated ticks for `elapsed >= duration`, the
|
||||||
|
/// `ModalEntering` component is removed and the card scale is back
|
||||||
|
/// at 1.0. Uses `Time<Virtual>` advance to push elapsed past the
|
||||||
|
/// duration without waiting for real wall-clock time.
|
||||||
|
#[test]
|
||||||
|
fn advance_modal_enter_removes_component_and_settles_scale() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(UiModalPlugin);
|
||||||
|
|
||||||
|
let scrim = {
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut commands = world.commands();
|
||||||
|
let id = spawn_modal(&mut commands, TestModal, 0, |_| {});
|
||||||
|
world.flush();
|
||||||
|
id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tick once with delta well beyond MOTION_MODAL_SECS — the
|
||||||
|
// ease-out clamps t at 1.0 so a single oversized tick is enough
|
||||||
|
// to settle the animation. `ManualDuration` makes
|
||||||
|
// `Time::delta_secs()` deterministic inside the test.
|
||||||
|
set_manual_time_step(&mut app, MOTION_MODAL_SECS * 2.0 + 0.1);
|
||||||
|
// Two updates: the first sets up `Time` with the manual delta;
|
||||||
|
// the second runs the advance system with non-zero dt. The
|
||||||
|
// `Added<ModalEntering>` filter survives across these updates
|
||||||
|
// because `apply_modal_enter_speed` writes the duration on
|
||||||
|
// whichever frame the entity first appears.
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world().entity(scrim).get::<ModalEntering>().is_none(),
|
||||||
|
"ModalEntering should be removed once elapsed >= duration"
|
||||||
|
);
|
||||||
|
let card_scale = card_scale_of(&mut app, scrim);
|
||||||
|
assert!(
|
||||||
|
(card_scale - 1.0).abs() < 1e-3,
|
||||||
|
"card scale should settle at 1.0 after the open animation; got {card_scale}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the X-component of the first `ModalCard` child of the
|
||||||
|
/// given scrim's `Transform::scale`. All three components are kept
|
||||||
|
/// in sync by the system so reading X is sufficient.
|
||||||
|
fn card_scale_of(app: &mut App, scrim: Entity) -> f32 {
|
||||||
|
let world = app.world();
|
||||||
|
let children = world
|
||||||
|
.entity(scrim)
|
||||||
|
.get::<Children>()
|
||||||
|
.expect("scrim should have a card child");
|
||||||
|
for child in children.iter() {
|
||||||
|
if let Some(t) = world.entity(child).get::<Transform>()
|
||||||
|
&& world.entity(child).get::<ModalCard>().is_some()
|
||||||
|
{
|
||||||
|
return t.scale.x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("no ModalCard child with a Transform under scrim {scrim:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells `TimePlugin` to advance the clock by `secs` on the next
|
||||||
|
/// `app.update()`. Inside a unit test no real wall-clock time has
|
||||||
|
/// passed between ticks, so the default `Automatic` strategy gives
|
||||||
|
/// `delta_secs() == 0`. `ManualDuration` makes the next tick
|
||||||
|
/// observe exactly `secs` of elapsed time.
|
||||||
|
fn set_manual_time_step(app: &mut App, secs: f32) {
|
||||||
|
use bevy::time::TimeUpdateStrategy;
|
||||||
|
use std::time::Duration;
|
||||||
|
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||||
|
Duration::from_secs_f32(secs),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn leaderboard_entries_sorted_by_score_descending() {
|
fn leaderboard_entries_sorted_by_score_descending() {
|
||||||
let mut entries = vec![
|
let mut entries = [
|
||||||
entry("Charlie", Some(1_200)),
|
entry("Charlie", Some(1_200)),
|
||||||
entry("Alice", Some(8_000)),
|
entry("Alice", Some(8_000)),
|
||||||
entry("Bob", Some(3_500)),
|
entry("Bob", Some(3_500)),
|
||||||
|
|||||||
Reference in New Issue
Block a user