use egui::{Color32, Painter, Pos2, Rect, Stroke, Vec2, pos2, vec2};
use serde::{Deserialize, Serialize};
use crate::look::Motion;
pub mod easing {
pub fn linear(t: f32) -> f32 {
t.clamp(0.0, 1.0)
}
pub fn ease_in_out_cubic(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
if t < 0.5 {
4.0 * t * t * t
} else {
let f = -2.0 * t + 2.0;
1.0 - f * f * f / 2.0
}
}
pub fn ease_out_back(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
const C1: f32 = 1.70158;
const C3: f32 = C1 + 1.0;
let f = t - 1.0;
1.0 + C3 * f * f * f + C1 * f * f
}
pub fn elastic(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
if t == 0.0 || t == 1.0 {
return t;
}
const C4: f32 = std::f32::consts::TAU / 3.0;
2.0_f32.powf(-10.0 * t) * ((t * 10.0 - 0.75) * C4).sin() + 1.0
}
pub fn bounce(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
const N1: f32 = 7.5625;
const D1: f32 = 2.75;
if t < 1.0 / D1 {
N1 * t * t
} else if t < 2.0 / D1 {
let t = t - 1.5 / D1;
N1 * t * t + 0.75
} else if t < 2.5 / D1 {
let t = t - 2.25 / D1;
N1 * t * t + 0.9375
} else {
let t = t - 2.625 / D1;
N1 * t * t + 0.984375
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Curve {
Linear,
#[default]
EaseInOutCubic,
EaseOutBack,
Elastic,
Bounce,
}
impl Curve {
pub fn apply(self, t: f32) -> f32 {
match self {
Curve::Linear => easing::linear(t),
Curve::EaseInOutCubic => easing::ease_in_out_cubic(t),
Curve::EaseOutBack => easing::ease_out_back(t),
Curve::Elastic => easing::elastic(t),
Curve::Bounce => easing::bounce(t),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tween {
pub from: f32,
pub to: f32,
pub duration: f32,
pub curve: Curve,
}
impl Tween {
pub fn new(from: f32, to: f32, duration: f32, curve: Curve) -> Self {
Self { from, to, duration, curve }
}
pub fn from_motion(motion: &Motion, from: f32, to: f32, curve: Curve) -> Self {
Self::new(from, to, motion.duration, curve)
}
pub fn from_motion_fast(motion: &Motion, from: f32, to: f32, curve: Curve) -> Self {
Self::new(from, to, motion.fast, curve)
}
pub fn value_at(&self, elapsed: f32) -> f32 {
if self.duration <= 0.0 {
return self.to;
}
let t = (elapsed / self.duration).clamp(0.0, 1.0);
self.from + (self.to - self.from) * self.curve.apply(t)
}
pub fn progress_at(&self, elapsed: f32) -> f32 {
if self.duration <= 0.0 {
return 1.0;
}
self.curve.apply((elapsed / self.duration).clamp(0.0, 1.0))
}
pub fn is_done(&self, elapsed: f32) -> bool {
self.duration <= 0.0 || elapsed >= self.duration
}
}
pub fn tween_rect(from: Rect, to: Rect, eased_t: f32) -> Rect {
let t = eased_t.clamp(0.0, 1.0);
let lerp = |a: f32, b: f32| a + (b - a) * t;
Rect::from_min_max(
pos2(lerp(from.min.x, to.min.x), lerp(from.min.y, to.min.y)),
pos2(lerp(from.max.x, to.max.x), lerp(from.max.y, to.max.y)),
)
}
#[derive(Clone, Debug, Default)]
pub struct FadeTrack {
fades: std::collections::HashMap<u64, FadeState>,
}
#[derive(Clone, Copy, Debug)]
struct FadeState {
factor: f32,
lit: bool,
}
impl FadeTrack {
pub fn begin(&mut self) {
for f in self.fades.values_mut() {
f.lit = false;
}
}
pub fn lit(&mut self, key: u64) {
self.fades.entry(key).or_insert(FadeState { factor: 0.0, lit: false }).lit = true;
}
pub fn factor(&self, key: u64) -> f32 {
self.fades.get(&key).map(|f| f.factor).unwrap_or(0.0)
}
pub fn active(&self) -> usize {
self.fades.len()
}
pub fn is_animating(&self) -> bool {
self.fades.values().any(|f| f.factor > 1e-3 && f.factor < 1.0 - 1e-3)
}
pub fn advance(&mut self, dt: f32, duration: f32) -> bool {
let step = if duration <= 0.0 { 1.0 } else { (dt.max(0.0) / duration).clamp(0.0, 1.0) };
let mut animating = false;
self.fades.retain(|_, f| {
let target = if f.lit { 1.0 } else { 0.0 };
let dist = target - f.factor;
if dist.abs() <= step {
f.factor = target;
} else {
f.factor += step * dist.signum();
animating = true;
}
f.lit || f.factor > 1e-3
});
animating
}
pub fn key(id: impl std::hash::Hash) -> u64 {
use std::hash::Hasher as _;
let mut h = std::collections::hash_map::DefaultHasher::new();
id.hash(&mut h);
h.finish()
}
}
fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
let t = t.clamp(0.0, 1.0);
let l = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
Color32::from_rgba_unmultiplied(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()), l(a.a(), b.a()))
}
pub fn glow_rect(painter: &Painter, rect: Rect, color: Color32, intensity: f32, layers: u32) {
let intensity = intensity.clamp(0.0, 1.0);
let layers = layers.max(1);
for i in 0..layers {
let f = i as f32 / layers as f32; let grow = 1.0 + f * 7.0;
let alpha = ((1.0 - f) * (1.0 - f) * 90.0 * intensity) as u8;
if alpha == 0 {
continue;
}
let c = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha);
painter.rect_stroke(
rect.expand(grow),
4.0 + grow,
Stroke::new(1.5 + f * 2.0, c),
egui::StrokeKind::Outside,
);
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RevealHighlight {
pub color: Color32,
pub radius: f32,
pub intensity: f32,
pub layers: u32,
}
impl RevealHighlight {
pub fn new(color: Color32) -> Self {
Self { color, radius: 120.0, intensity: 0.8, layers: 4 }
}
pub fn with_radius(mut self, radius: f32) -> Self {
self.radius = radius.max(1.0);
self
}
pub fn with_intensity(mut self, intensity: f32) -> Self {
self.intensity = intensity.clamp(0.0, 1.0);
self
}
pub fn proximity(&self, rect: Rect, pointer: Option<Pos2>) -> f32 {
let Some(p) = pointer else { return 0.0 };
let nx = p.x.clamp(rect.left(), rect.right());
let ny = p.y.clamp(rect.top(), rect.bottom());
let dist = (p - pos2(nx, ny)).length();
if dist >= self.radius {
return 0.0;
}
let t = 1.0 - dist / self.radius; t * t }
pub fn paint(&self, painter: &Painter, rect: Rect, pointer: Option<Pos2>, corner_radius: f32) {
let lit = self.proximity(rect, pointer) * self.intensity;
if lit <= 0.0 {
return;
}
let layers = self.layers.max(1);
for i in 0..layers {
let f = i as f32 / layers as f32; let grow = f * 4.0;
let alpha = ((1.0 - f) * 150.0 * lit) as u8;
if alpha == 0 {
continue;
}
let c = Color32::from_rgba_unmultiplied(self.color.r(), self.color.g(), self.color.b(), alpha);
painter.rect_stroke(
rect.expand(grow),
corner_radius + grow,
Stroke::new(1.0 + f, c),
egui::StrokeKind::Outside,
);
}
}
pub fn paint_in(&self, ui: &egui::Ui, rect: Rect, corner_radius: f32, enabled: bool) {
if !enabled || !crate::look::effects_policy(ui).allows_decorative_motion() {
return;
}
let pointer = ui.input(|i| i.pointer.hover_pos());
self.paint(ui.painter(), rect, pointer, corner_radius);
}
}
#[allow(clippy::too_many_arguments)]
pub fn glow_text(
painter: &Painter,
pos: Pos2,
anchor: egui::Align2,
text: &str,
font: egui::FontId,
text_color: Color32,
glow: Color32,
intensity: f32,
) {
let intensity = intensity.clamp(0.0, 1.0);
let halo = Color32::from_rgba_unmultiplied(glow.r(), glow.g(), glow.b(), (70.0 * intensity) as u8);
for r in [3.0_f32, 2.0, 1.0] {
for k in 0..8 {
let a = std::f32::consts::TAU * k as f32 / 8.0;
let off = vec2(a.cos(), a.sin()) * r;
painter.text(pos + off, anchor, text, font.clone(), halo);
}
}
painter.text(pos, anchor, text, font, text_color);
}
pub fn shimmer(painter: &Painter, rect: Rect, color: Color32, t: f32) {
let bars = 24;
let band = 0.18; let center = t.rem_euclid(1.0) * (1.0 + 2.0 * band) - band;
for i in 0..bars {
let x = (i as f32 + 0.5) / bars as f32; let d = (x - center).abs() / band;
if d >= 1.0 {
continue;
}
let g = (1.0 - d) * (1.0 - d); let bright = lerp_color(color, Color32::WHITE, g * 0.6);
let c = Color32::from_rgba_unmultiplied(bright.r(), bright.g(), bright.b(), (g * 130.0) as u8);
let bx0 = rect.left() + x * rect.width();
let bw = rect.width() / bars as f32 + 1.0;
let shear = (x - 0.5) * rect.height() * 0.25;
let seg = Rect::from_min_max(pos2(bx0, rect.top() + shear), pos2(bx0 + bw, rect.bottom() + shear))
.intersect(rect);
painter.rect_filled(seg, 0.0, c);
}
}
#[derive(Clone, Copy)]
struct Particle {
pos: Pos2,
vel: Vec2,
age: f32,
}
#[derive(Clone)]
pub struct ParticleBurst {
particles: Vec<Particle>,
color: Color32,
gravity: f32,
lifetime: f32,
elapsed: f32,
}
impl ParticleBurst {
pub fn new(origin: Pos2, count: usize, color: Color32, seed: u64) -> Self {
let mut h = seed ^ 0x9E37_79B9_7F4A_7C15;
let mut rng = || {
h = h.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = h;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
((z ^ (z >> 31)) as f64 / u64::MAX as f64) as f32
};
let particles = (0..count)
.map(|i| {
let a = std::f32::consts::TAU * i as f32 / count.max(1) as f32;
let speed = 90.0 + rng() * 140.0;
let up = 0.6 + rng() * 0.4; Particle { pos: origin, vel: vec2(a.cos() * speed, a.sin() * speed - 120.0 * up), age: 0.0 }
})
.collect();
Self { particles, color, gravity: 520.0, lifetime: 1.1, elapsed: 0.0 }
}
pub fn update(&mut self, dt: f32) {
let dt = dt.max(0.0);
self.elapsed += dt;
for p in &mut self.particles {
p.vel.y += self.gravity * dt;
p.pos += p.vel * dt;
p.age += dt;
}
}
pub fn finished(&self) -> bool {
self.elapsed >= self.lifetime
}
pub fn paint(&self, painter: &Painter) {
for p in &self.particles {
let life = (1.0 - p.age / self.lifetime).clamp(0.0, 1.0);
if life <= 0.0 {
continue;
}
let a = (life * 220.0) as u8;
let c = Color32::from_rgba_unmultiplied(self.color.r(), self.color.g(), self.color.b(), a);
painter.circle_filled(p.pos, 1.0 + life * 2.5, c);
}
}
}
fn bezier2(p0: Pos2, p1: Pos2, p2: Pos2, t: f32) -> Pos2 {
let u = 1.0 - t;
let x = u * u * p0.x + 2.0 * u * t * p1.x + t * t * p2.x;
let y = u * u * p0.y + 2.0 * u * t * p1.y + t * t * p2.y;
pos2(x, y)
}
pub const RAVEN_FLIGHT_SECS: f32 = 1.4;
#[derive(Clone)]
pub struct RavenSprite {
start: Pos2,
target: Pos2,
launch_time: Option<f64>,
current: Pos2,
perched: bool,
elapsed: f32,
color: Color32,
facing: f32,
scale: f32,
}
impl Default for RavenSprite {
fn default() -> Self {
Self::new()
}
}
impl RavenSprite {
pub fn new() -> Self {
Self {
start: pos2(-40.0, -40.0),
target: pos2(0.0, 0.0),
launch_time: None,
current: pos2(-40.0, -40.0),
perched: false,
elapsed: 0.0,
color: Color32::from_rgb(18, 18, 22),
facing: 1.0,
scale: 1.0,
}
}
pub fn from(mut self, start: Pos2) -> Self {
self.start = start;
self.current = start;
self
}
pub fn color(mut self, color: Color32) -> Self {
self.color = color;
self
}
pub fn scale(mut self, scale: f32) -> Self {
self.scale = scale.max(0.1);
self
}
pub fn fly_to(mut self, target: Rect) -> Self {
self.target = pos2(target.center().x, target.top());
self.facing = if self.target.x >= self.start.x { 1.0 } else { -1.0 };
self.launch_time = None;
self.perched = false;
self
}
fn control(&self) -> Pos2 {
let mid = pos2((self.start.x + self.target.x) * 0.5, (self.start.y + self.target.y) * 0.5);
let span = (self.target - self.start).length().max(1.0);
pos2(mid.x, mid.y - span * 0.45) }
pub fn pos_at(&self, elapsed: f32) -> Pos2 {
if elapsed >= RAVEN_FLIGHT_SECS {
let bob = ((elapsed - RAVEN_FLIGHT_SECS) * std::f32::consts::TAU * 0.6).sin() * 1.5;
return pos2(self.target.x, self.target.y + bob);
}
let lin = (elapsed / RAVEN_FLIGHT_SECS).clamp(0.0, 1.0);
let t = easing::ease_out_back(lin);
bezier2(self.start, self.control(), self.target, t)
}
pub fn is_perched(&self) -> bool {
self.perched
}
pub fn pos(&self) -> Pos2 {
self.current
}
pub fn update(&mut self, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
let launch = *self.launch_time.get_or_insert(now);
let elapsed = (now - launch) as f32;
self.advance(elapsed);
if !self.perched {
ctx.request_repaint();
}
}
pub fn advance(&mut self, elapsed: f32) {
self.elapsed = elapsed;
self.current = self.pos_at(elapsed);
self.perched = elapsed >= RAVEN_FLIGHT_SECS;
}
pub fn paint(&self, painter: &Painter) {
let c = self.current;
let s = self.scale;
let f = self.facing;
let body = self.color;
let stroke = Stroke::new(1.0 * s, body);
let flap = if self.perched {
0.15
} else {
(self.elapsed * std::f32::consts::TAU * 5.0).sin() * 0.5 + 0.5
};
let wing_lift = (flap - 0.5) * 9.0 * s;
painter.circle_filled(c, 5.0 * s, body);
painter.circle_filled(c + vec2(-3.5 * s * f, 1.0 * s), 3.5 * s, body);
let tail_root = c + vec2(-5.0 * s * f, 0.5 * s);
painter.add(egui::Shape::convex_polygon(
vec![
tail_root,
tail_root + vec2(-7.0 * s * f, -2.5 * s),
tail_root + vec2(-7.5 * s * f, 1.0 * s),
tail_root + vec2(-6.0 * s * f, 3.0 * s),
],
body,
stroke,
));
let shoulder = c + vec2(-s * f, -1.5 * s);
let tip_far = shoulder + vec2(-9.0 * s * f, -wing_lift - 2.0 * s);
let tip_near = shoulder + vec2(-4.0 * s * f, -wing_lift * 0.5 + 4.0 * s);
painter.add(egui::Shape::convex_polygon(
vec![shoulder, tip_far, tip_near],
body,
stroke,
));
let head = c + vec2(4.5 * s * f, -2.5 * s);
painter.circle_filled(head, 3.0 * s, body);
let beak_color = Color32::from_rgb(40, 30, 18); painter.add(egui::Shape::convex_polygon(
vec![
head + vec2(2.5 * s * f, -0.5 * s),
head + vec2(6.5 * s * f, 0.5 * s),
head + vec2(2.5 * s * f, 1.5 * s),
],
beak_color,
Stroke::NONE,
));
painter.circle_filled(head + vec2(1.2 * s * f, -0.8 * s), 0.8 * s, Color32::from_rgb(230, 220, 210));
}
}
#[derive(Clone, Copy)]
struct Icicle {
x: f32,
len: f32,
half_w: f32,
phase: f32,
rate: f32,
}
#[derive(Clone)]
pub struct IceDrip {
icicles: Vec<Icicle>,
ice: Color32,
glow: Color32,
elapsed: f32,
period: f32,
fall_frac: f32,
enabled: bool,
}
impl IceDrip {
pub fn new(count: usize, seed: u64) -> Self {
let mut h = seed ^ 0x51ED_2701_A17F_C3B9;
let mut rng = || {
h = h.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = h;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
((z ^ (z >> 31)) as f64 / u64::MAX as f64) as f32
};
let n = count.max(1);
let icicles = (0..n)
.map(|i| {
let base = (i as f32 + 0.5) / n as f32;
let x = (base + (rng() - 0.5) * 0.6 / n as f32).clamp(0.0, 1.0);
Icicle {
x,
len: 8.0 + rng() * 16.0,
half_w: 3.0 + rng() * 3.0,
phase: rng(),
rate: 0.7 + rng() * 0.6,
}
})
.collect();
Self {
icicles,
ice: Color32::from_rgb(206, 232, 248), glow: Color32::from_rgb(170, 214, 240), elapsed: 0.0,
period: 2.6,
fall_frac: 0.85,
enabled: false,
}
}
pub fn enabled(mut self, on: bool) -> Self {
self.enabled = on;
self
}
pub fn colors(mut self, ice: Color32, glow: Color32) -> Self {
self.ice = ice;
self.glow = glow;
self
}
pub fn with_period(mut self, secs: f32) -> Self {
self.period = secs.max(0.1);
self
}
pub fn at_clock(mut self, secs: f32) -> Self {
self.elapsed = secs.max(0.0);
self
}
pub fn set_clock(&mut self, secs: f32) {
self.elapsed = secs.max(0.0);
}
pub fn update(&mut self, dt: f32) {
self.elapsed += dt.max(0.0);
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
fn cycle(&self, ic: &Icicle) -> f32 {
((self.elapsed / self.period) * ic.rate + ic.phase).rem_euclid(1.0)
}
fn droplet_fall_norm(c: f32) -> f32 {
let c = c.clamp(0.0, 1.0);
c * c
}
fn droplet_alpha(c: f32) -> f32 {
let c = c.clamp(0.0, 1.0);
if c < 0.08 {
c / 0.08
} else if c < 0.75 {
1.0
} else {
(1.0 - (c - 0.75) / 0.25).max(0.0)
}
}
fn frost_phase(&self) -> f32 {
(self.elapsed * 0.2).rem_euclid(1.0)
}
pub fn paint(&self, painter: &Painter, rect: Rect) {
if !self.enabled || rect.width() <= 0.0 || rect.height() <= 0.0 {
return;
}
let band = Rect::from_min_max(rect.left_top(), pos2(rect.right(), rect.top() + 14.0));
shimmer(painter, band, self.glow, self.frost_phase());
for ic in &self.icicles {
let x = rect.left() + ic.x * rect.width();
let tip = pos2(x, rect.top() + ic.len);
let base_l = pos2(x - ic.half_w, rect.top());
let base_r = pos2(x + ic.half_w, rect.top());
let body = Color32::from_rgba_unmultiplied(self.ice.r(), self.ice.g(), self.ice.b(), 150);
painter.add(egui::Shape::convex_polygon(
vec![base_l, base_r, tip],
body,
Stroke::new(1.0, self.glow),
));
painter.line_segment(
[pos2(x, rect.top()), tip],
Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 90)),
);
let c = self.cycle(ic);
let a = Self::droplet_alpha(c);
if a > 0.0 {
let y = tip.y + Self::droplet_fall_norm(c) * self.fall_frac * rect.height();
let col = Color32::from_rgba_unmultiplied(self.glow.r(), self.glow.g(), self.glow.b(), (a * 220.0) as u8);
painter.circle_filled(pos2(x, y), 2.2, col);
painter.circle_filled(pos2(x, y - 2.6), 1.1, col);
}
}
}
pub fn paint_gated(&self, ui: &egui::Ui, rect: Rect) {
if crate::look::effects_policy(ui).allows_decorative_motion() {
self.paint(ui.painter(), rect);
}
}
pub fn state_json(&self) -> serde_json::Value {
let droplets: Vec<serde_json::Value> = if self.enabled {
self.icicles
.iter()
.filter_map(|ic| {
let c = self.cycle(ic);
let a = Self::droplet_alpha(c);
(a > 0.0).then(|| {
serde_json::json!({
"x": ic.x,
"y_norm": Self::droplet_fall_norm(c),
"alpha": a,
})
})
})
.collect()
} else {
Vec::new()
};
serde_json::json!({
"enabled": self.enabled,
"icicles": self.icicles.len(),
"active_droplets": droplets.len(),
"frost_phase": self.frost_phase(),
"elapsed_s": self.elapsed,
"droplets": droplets,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
type NamedEasing = (&'static str, fn(f32) -> f32);
fn approx(a: f32, b: f32, eps: f32) -> bool {
(a - b).abs() <= eps
}
#[test]
fn easing_fns_hit_their_endpoints() {
let fns: [NamedEasing; 5] = [
("linear", easing::linear),
("cubic", easing::ease_in_out_cubic),
("back", easing::ease_out_back),
("elastic", easing::elastic),
("bounce", easing::bounce),
];
for (name, f) in fns {
assert!(approx(f(0.0), 0.0, 1e-5), "{name}(0) should be 0, got {}", f(0.0));
assert!(approx(f(1.0), 1.0, 1e-5), "{name}(1) should be 1, got {}", f(1.0));
assert!(approx(f(-1.0), 0.0, 1e-5), "{name}(-1) clamps to 0");
assert!(approx(f(2.0), 1.0, 1e-5), "{name}(2) clamps to 1");
}
}
#[test]
fn tween_hits_endpoints_and_clamps() {
let tw = Tween::new(10.0, 50.0, 0.2, Curve::EaseInOutCubic);
assert!(approx(tw.value_at(0.0), 10.0, 1e-4), "start = from");
assert!(approx(tw.value_at(0.2), 50.0, 1e-4), "end = to");
assert!(approx(tw.value_at(-1.0), 10.0, 1e-4), "before start clamps to from");
assert!(approx(tw.value_at(99.0), 50.0, 1e-4), "after end clamps to to");
assert!(!tw.is_done(0.1) && tw.is_done(0.2), "done at/after duration");
assert!(approx(tw.progress_at(0.0), 0.0, 1e-4) && approx(tw.progress_at(0.2), 1.0, 1e-4));
}
#[test]
fn tween_zero_duration_snaps_to_target() {
let tw = Tween::new(0.0, 1.0, 0.0, Curve::EaseInOutCubic);
assert_eq!(tw.value_at(0.0), 1.0);
assert_eq!(tw.progress_at(0.0), 1.0);
assert!(tw.is_done(0.0));
}
#[test]
fn tween_from_motion_uses_theme_durations() {
let m = crate::look::Motion::default(); let slow = Tween::from_motion(&m, 0.0, 1.0, Curve::Linear);
let fast = Tween::from_motion_fast(&m, 0.0, 1.0, Curve::Linear);
assert!(approx(slow.duration, m.duration, 1e-6));
assert!(approx(fast.duration, m.fast, 1e-6));
assert!(approx(slow.value_at(m.duration / 2.0), 0.5, 1e-4));
assert!(approx(fast.value_at(m.fast / 2.0), 0.5, 1e-4));
}
#[test]
fn tween_rect_lerps_corners_for_slide_over() {
let off = Rect::from_min_max(pos2(100.0, 0.0), pos2(140.0, 50.0));
let on = Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 50.0));
assert_eq!(tween_rect(off, on, 0.0), off, "t=0 → off-screen rect");
assert_eq!(tween_rect(off, on, 1.0), on, "t=1 → on-screen rect");
let mid = tween_rect(off, on, 0.5);
assert!(approx(mid.min.x, 50.0, 1e-4), "half-slid x");
}
#[test]
fn fade_track_fades_in_then_out_and_drops_settled_keys() {
let k = FadeTrack::key("row-7");
let mut ft = FadeTrack::default();
for _ in 0..12 {
ft.begin();
ft.lit(k);
ft.advance(1.0 / 60.0, 0.10);
}
assert!(approx(ft.factor(k), 1.0, 1e-3), "lit key reaches 1, got {}", ft.factor(k));
let mut animating = true;
for _ in 0..40 {
ft.begin();
animating = ft.advance(1.0 / 60.0, 0.10);
}
assert!(!animating, "settles (no repaint) once faded out");
assert_eq!(ft.factor(k), 0.0, "faded-out key reads 0 (dropped)");
}
#[test]
fn fade_track_zero_duration_snaps() {
let k = FadeTrack::key(3u64);
let mut ft = FadeTrack::default();
ft.begin();
ft.lit(k);
let animating = ft.advance(1.0 / 60.0, 0.0);
assert_eq!(ft.factor(k), 1.0, "zero-duration snaps lit → 1");
assert!(!animating, "snap is not 'animating'");
}
#[test]
fn fade_track_is_deterministic() {
let k = FadeTrack::key("r");
let mut a = FadeTrack::default();
let mut b = FadeTrack::default();
for _ in 0..5 {
a.begin();
a.lit(k);
a.advance(1.0 / 60.0, 0.18);
b.begin();
b.lit(k);
b.advance(1.0 / 60.0, 0.18);
}
assert!(approx(a.factor(k), b.factor(k), 1e-9), "same inputs → same factor (FC-7)");
}
#[test]
fn ease_out_back_overshoots_before_settling() {
let peak = (60..100).map(|i| easing::ease_out_back(i as f32 / 100.0)).fold(0.0_f32, f32::max);
assert!(peak > 1.0, "ease_out_back should overshoot, peak={peak}");
}
#[test]
fn raven_starts_at_launch_and_converges_onto_target_rect() {
let target = Rect::from_min_size(pos2(300.0, 200.0), vec2(180.0, 24.0));
let mut raven = RavenSprite::new().from(pos2(-40.0, -40.0)).fly_to(target);
raven.advance(0.0);
assert!(!raven.is_perched());
assert!(approx(raven.pos().x, -40.0, 0.5) && approx(raven.pos().y, -40.0, 0.5), "starts at launch");
raven.advance(RAVEN_FLIGHT_SECS * 0.5);
assert!(!raven.is_perched());
raven.advance(RAVEN_FLIGHT_SECS);
assert!(raven.is_perched(), "perched after flight duration");
let perch = pos2(target.center().x, target.top());
let d = (raven.pos() - perch).length();
assert!(d <= 2.0, "raven converges onto the perch (dist {d} px)");
for k in 1..20 {
raven.advance(RAVEN_FLIGHT_SECS + k as f32 * 0.05);
assert!(approx(raven.pos().x, perch.x, 0.01), "x stays centred on perch");
assert!(approx(raven.pos().y, perch.y, 2.0), "y stays within bob of perch");
}
}
#[test]
fn particle_burst_falls_and_finishes() {
let mut b = ParticleBurst::new(pos2(100.0, 100.0), 16, Color32::WHITE, 42);
assert!(!b.finished());
for _ in 0..120 {
b.update(1.0 / 60.0);
}
assert!(b.finished(), "burst should expire after its lifetime");
let mut a = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
let mut c = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
a.update(0.1);
c.update(0.1);
assert_eq!(a.particles[0].pos, c.particles[0].pos, "same seed → same motion");
}
#[test]
fn ice_drip_is_off_until_enabled() {
let off = IceDrip::new(12, 3).at_clock(2.0);
let s = off.state_json();
assert_eq!(s["enabled"], false);
assert_eq!(s["active_droplets"], 0, "disabled ⇒ no droplets: {s}");
assert_eq!(s["icicles"], 12, "icicle count is still reported: {s}");
let on = IceDrip::new(12, 3).enabled(true).at_clock(2.0);
let s = on.state_json();
assert_eq!(s["enabled"], true);
assert!(s["active_droplets"].as_u64().unwrap() > 0, "enabled ⇒ live droplets: {s}");
}
#[test]
fn ice_drip_is_deterministic_given_seed_and_clock() {
let a = IceDrip::new(10, 7).enabled(true).at_clock(2.0);
let b = IceDrip::new(10, 7).enabled(true).at_clock(2.0);
assert_eq!(a.state_json(), b.state_json(), "same (seed,clock) ⇒ identical drip");
let later = IceDrip::new(10, 7).enabled(true).at_clock(2.4);
assert_ne!(a.state_json(), later.state_json(), "advancing the clock moves the drip");
}
#[test]
fn reveal_highlight_proximity_is_a_cursor_pool() {
let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 40.0));
let r = RevealHighlight::new(Color32::from_rgb(80, 160, 255)).with_radius(100.0);
assert!(approx(r.proximity(rect, Some(pos2(50.0, 20.0))), 1.0, 1e-6), "inside → 1");
assert_eq!(r.proximity(rect, None), 0.0, "no pointer → 0");
assert_eq!(r.proximity(rect, Some(pos2(300.0, 20.0))), 0.0, "far (>radius) → 0");
let near = r.proximity(rect, Some(pos2(120.0, 20.0))); let mid = r.proximity(rect, Some(pos2(160.0, 20.0))); assert!(near > mid && mid > 0.0, "reveal glow decays with distance: {near} > {mid} > 0");
assert_eq!(r.proximity(rect, Some(pos2(130.0, 10.0))), r.proximity(rect, Some(pos2(130.0, 10.0))));
}
#[test]
fn ice_drip_droplets_fall_under_gravity() {
assert!((IceDrip::droplet_fall_norm(0.0)).abs() < 1e-6);
assert!((IceDrip::droplet_fall_norm(1.0) - 1.0).abs() < 1e-6);
let mut prev = 0.0;
let mut last_step = 0.0;
for i in 1..=10 {
let c = i as f32 / 10.0;
let y = IceDrip::droplet_fall_norm(c);
assert!(y >= prev, "fall is monotonic downward at c={c}: {y} < {prev}");
let step = y - prev;
assert!(step >= last_step - 1e-6, "fall accelerates (gravity) at c={c}");
last_step = step;
prev = y;
}
assert!(IceDrip::droplet_alpha(0.0) < IceDrip::droplet_alpha(0.5));
assert!((IceDrip::droplet_alpha(0.5) - 1.0).abs() < 1e-6);
assert!(IceDrip::droplet_alpha(0.99) < 0.2, "the droplet fades near the end");
}
}