use egui::{Color32, Painter, Pos2, Rect, Stroke, Vec2, pos2, vec2};
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
}
}
}
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,
);
}
}
#[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));
}
}
#[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 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");
}
}