Compare commits

...

5 Commits

Author SHA1 Message Date
funman300 de52c8a7b7 docs: update SESSION_HANDOFF for completed phase-4 polish tracks
CI / Test & Lint (push) Failing after 26s
CI / Release Build (push) Has been skipped
Reflects that Track B (window polish) and Track G (modal + score
animations) are now landed, and brings the resume prompt and
release-readiness scope in line with the post-commit state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:05:36 +00:00
funman300 dcfa976dad feat(engine): score change feedback — pulse and floating delta
Score readouts now react to mutations: ScorePulse drives a triangular
1.0 → 1.1 → 1.0 scale on the HUD score over MOTION_SCORE_PULSE_SECS,
and jumps of at least SCORE_FLOATER_THRESHOLD points spawn a floating
"+N" that drifts up 40px and fades over 2× the pulse duration before
despawning. Detection runs after GameMutation so the visuals trail the
state update by exactly one frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:05:00 +00:00
funman300 71999e1062 feat(engine): modal open animation — fade + scale with ease-out
Modals now animate in via the new ModalEntering component: scrim alpha
ramps from 0 to its full value while the card scales from 0.96 to 1.0
over MOTION_MODAL_SECS using an ease-out curve. AnimSpeed::Instant
collapses the duration to zero so reduced-motion users see the modal
snap into place on the first frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:04:51 +00:00
funman300 5f5aba8dff feat(app): window polish — WM_CLASS, centered window, crash log hook
Sets Window::name so X11/Wayland taskbars group the game correctly,
centers the window on the primary monitor on startup, and installs a
panic hook that appends a timestamped crash record to crash.log under
the platform data dir (gracefully no-ops when the directory is
unavailable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:04:43 +00:00
funman300 9bfca929cb chore(workspace): satisfy clippy --all-targets in test code
Five test-only lints surfaced by --all-targets were blocking CI under
-D warnings: a useless vec! in a leaderboard sort test, a
field_reassign_with_default in tuning tests, and three
assertions_on_constants in card_plugin sanity tests. The constant
assertions are now wrapped in const blocks so they run at compile time;
the runtime-formatted values were dropped from their messages because
const-block assert messages must be string literals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 19:54:40 +00:00
7 changed files with 745 additions and 27 deletions
+74 -2
View File
@@ -1,8 +1,80 @@
# 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`).
+44
View File
@@ -1,4 +1,9 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::window::{MonitorSelection, WindowPosition};
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
@@ -10,6 +15,11 @@ use solitaire_engine::{
};
fn main() {
// Install a panic hook that writes a crash log next to the save files
// before re-running the default hook (so stderr still gets the message
// and any debugger attached still sees the panic).
install_crash_log_hook();
// Initialise the platform keyring store before any token operations.
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
// macOS it uses the Keychain; on Windows it uses the Credential store.
@@ -35,7 +45,11 @@ fn main() {
.set(WindowPlugin {
primary_window: Some(Window {
title: "Solitaire Quest".into(),
// X11/Wayland WM_CLASS so taskbar managers group
// multiple windows of this app correctly.
name: Some("solitaire-quest".into()),
resolution: (1280u32, 800u32).into(),
position: WindowPosition::Centered(MonitorSelection::Primary),
resize_constraints: bevy::window::WindowResizeConstraints {
min_width: 800.0,
min_height: 600.0,
@@ -87,3 +101,33 @@ fn main() {
.add_plugins(UiModalPlugin)
.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]
fn scale_duration_applies_multiplier() {
let mut t = AnimationTuning::default();
t.duration_scale = 0.5;
let t = AnimationTuning {
duration_scale: 0.5,
..AnimationTuning::default()
};
assert!((t.scale_duration(1.0) - 0.5).abs() < 1e-6);
assert!((t.scale_duration(0.25) - 0.125).abs() < 1e-6);
}
+15 -12
View File
@@ -1510,26 +1510,29 @@ mod tests {
#[test]
fn tableau_fan_frac_is_in_unit_interval() {
assert!(
TABLEAU_FAN_FRAC > 0.0 && TABLEAU_FAN_FRAC < 1.0,
"TABLEAU_FAN_FRAC must be in (0, 1), got {TABLEAU_FAN_FRAC}"
);
const {
assert!(
TABLEAU_FAN_FRAC > 0.0 && TABLEAU_FAN_FRAC < 1.0,
"TABLEAU_FAN_FRAC must be in (0, 1)"
);
}
}
#[test]
fn flip_half_secs_is_positive() {
assert!(
FLIP_HALF_SECS > 0.0,
"FLIP_HALF_SECS must be positive, got {FLIP_HALF_SECS}"
);
const {
assert!(FLIP_HALF_SECS > 0.0, "FLIP_HALF_SECS must be positive");
}
}
#[test]
fn font_size_frac_is_positive_and_reasonable() {
assert!(
FONT_SIZE_FRAC > 0.0 && FONT_SIZE_FRAC <= 1.0,
"FONT_SIZE_FRAC should be in (0, 1], got {FONT_SIZE_FRAC}"
);
const {
assert!(
FONT_SIZE_FRAC > 0.0 && FONT_SIZE_FRAC <= 1.0,
"FONT_SIZE_FRAC should be in (0, 1]"
);
}
}
// -----------------------------------------------------------------------
+332 -4
View File
@@ -15,11 +15,12 @@ use crate::auto_complete_plugin::AutoCompleteState;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::progress_plugin::ProgressResource;
use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
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,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM,
STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY, TEXT_SECONDARY,
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
};
use crate::events::{
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
@@ -98,6 +99,46 @@ pub struct HudDrawCycle;
#[derive(Component, Debug)]
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
/// `paint_action_buttons` system can recolour them on hover/press without
/// each button needing its own paint handler.
@@ -207,10 +248,21 @@ impl Plugin for HudPlugin {
.add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<ToggleLeaderboardRequestEvent>()
.init_resource::<PreviousScore>()
.add_systems(Startup, (spawn_hud, spawn_action_buttons))
.add_systems(Update, update_hud.after(GameMutation))
.add_systems(Update, announce_auto_complete.after(GameMutation))
.add_systems(Update, update_selection_hud)
.add_systems(
Update,
(
detect_score_change,
advance_score_pulse,
advance_score_floater,
)
.chain()
.after(GameMutation),
)
.add_systems(
Update,
(
@@ -796,6 +848,179 @@ pub fn format_time_limit(secs: u64) -> String {
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)]
fn update_hud(
game: Res<GameStateResource>,
@@ -1476,4 +1701,107 @@ mod tests {
app.update();
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);
}
}
+275 -6
View File
@@ -49,13 +49,15 @@
//! ```
use bevy::prelude::*;
use solitaire_data::AnimSpeed;
use crate::font_plugin::FontResource;
use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, RADIUS_LG, RADIUS_MD,
SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2,
VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5,
scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED,
BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE,
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG,
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)]
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
// 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
/// (`ConfirmNewGameScreen`, `HelpScreen`, etc.) so plugin click handlers
/// 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>(
commands: &mut Commands,
plugin_marker: M,
@@ -129,10 +162,19 @@ pub fn spawn_modal<M: Component, F>(
where
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
.spawn((
plugin_marker,
ModalScrim,
ModalEntering { elapsed: 0.0, duration },
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
@@ -143,7 +185,7 @@ where
align_items: AlignItems::Center,
..default()
},
BackgroundColor(SCRIM),
BackgroundColor(initial_scrim),
// GlobalZIndex pins this root modal at `z_panel` regardless
// of any sibling stacking-context quirks in Bevy 0.18 — the
// ordinary `ZIndex` is preserved as a fallback for nested
@@ -167,6 +209,9 @@ where
align_items: AlignItems::Stretch,
..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),
BorderColor::all(BORDER_STRONG),
))
@@ -175,6 +220,16 @@ where
.id()
}
/// Returns `SCRIM` with its alpha multiplied by `factor` (0.01.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`.
pub fn spawn_modal_header(
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
/// press states are visible without each overlay registering its own
/// paint system.
@@ -366,7 +505,17 @@ pub struct UiModalPlugin;
impl Plugin for UiModalPlugin {
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.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),
));
}
}
+1 -1
View File
@@ -194,7 +194,7 @@ mod tests {
#[test]
fn leaderboard_entries_sorted_by_score_descending() {
let mut entries = vec![
let mut entries = [
entry("Charlie", Some(1_200)),
entry("Alice", Some(8_000)),
entry("Bob", Some(3_500)),