Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de52c8a7b7 | |||
| dcfa976dad | |||
| 71999e1062 | |||
| 5f5aba8dff | |||
| 9bfca929cb |
+74
-2
@@ -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`).
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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`.
|
||||
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),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user