1use egui::{Color32, Painter, Pos2, Rect, Stroke, Vec2, pos2, vec2};
20
21pub mod easing {
24 pub fn linear(t: f32) -> f32 {
26 t.clamp(0.0, 1.0)
27 }
28
29 pub fn ease_in_out_cubic(t: f32) -> f32 {
31 let t = t.clamp(0.0, 1.0);
32 if t < 0.5 {
33 4.0 * t * t * t
34 } else {
35 let f = -2.0 * t + 2.0;
36 1.0 - f * f * f / 2.0
37 }
38 }
39
40 pub fn ease_out_back(t: f32) -> f32 {
43 let t = t.clamp(0.0, 1.0);
44 const C1: f32 = 1.70158;
45 const C3: f32 = C1 + 1.0;
46 let f = t - 1.0;
47 1.0 + C3 * f * f * f + C1 * f * f
48 }
49
50 pub fn elastic(t: f32) -> f32 {
52 let t = t.clamp(0.0, 1.0);
53 if t == 0.0 || t == 1.0 {
54 return t;
55 }
56 const C4: f32 = std::f32::consts::TAU / 3.0;
57 2.0_f32.powf(-10.0 * t) * ((t * 10.0 - 0.75) * C4).sin() + 1.0
58 }
59
60 pub fn bounce(t: f32) -> f32 {
62 let t = t.clamp(0.0, 1.0);
63 const N1: f32 = 7.5625;
64 const D1: f32 = 2.75;
65 if t < 1.0 / D1 {
66 N1 * t * t
67 } else if t < 2.0 / D1 {
68 let t = t - 1.5 / D1;
69 N1 * t * t + 0.75
70 } else if t < 2.5 / D1 {
71 let t = t - 2.25 / D1;
72 N1 * t * t + 0.9375
73 } else {
74 let t = t - 2.625 / D1;
75 N1 * t * t + 0.984375
76 }
77 }
78}
79
80fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
82 let t = t.clamp(0.0, 1.0);
83 let l = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
84 Color32::from_rgba_unmultiplied(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()), l(a.a(), b.a()))
85}
86
87pub fn glow_rect(painter: &Painter, rect: Rect, color: Color32, intensity: f32, layers: u32) {
91 let intensity = intensity.clamp(0.0, 1.0);
92 let layers = layers.max(1);
93 for i in 0..layers {
94 let f = i as f32 / layers as f32; let grow = 1.0 + f * 7.0;
96 let alpha = ((1.0 - f) * (1.0 - f) * 90.0 * intensity) as u8;
97 if alpha == 0 {
98 continue;
99 }
100 let c = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha);
101 painter.rect_stroke(
102 rect.expand(grow),
103 4.0 + grow,
104 Stroke::new(1.5 + f * 2.0, c),
105 egui::StrokeKind::Outside,
106 );
107 }
108}
109
110#[allow(clippy::too_many_arguments)]
113pub fn glow_text(
114 painter: &Painter,
115 pos: Pos2,
116 anchor: egui::Align2,
117 text: &str,
118 font: egui::FontId,
119 text_color: Color32,
120 glow: Color32,
121 intensity: f32,
122) {
123 let intensity = intensity.clamp(0.0, 1.0);
124 let halo = Color32::from_rgba_unmultiplied(glow.r(), glow.g(), glow.b(), (70.0 * intensity) as u8);
125 for r in [3.0_f32, 2.0, 1.0] {
126 for k in 0..8 {
127 let a = std::f32::consts::TAU * k as f32 / 8.0;
128 let off = vec2(a.cos(), a.sin()) * r;
129 painter.text(pos + off, anchor, text, font.clone(), halo);
130 }
131 }
132 painter.text(pos, anchor, text, font, text_color);
133}
134
135pub fn shimmer(painter: &Painter, rect: Rect, color: Color32, t: f32) {
139 let bars = 24;
140 let band = 0.18; let center = t.rem_euclid(1.0) * (1.0 + 2.0 * band) - band;
142 for i in 0..bars {
143 let x = (i as f32 + 0.5) / bars as f32; let d = (x - center).abs() / band;
145 if d >= 1.0 {
146 continue;
147 }
148 let g = (1.0 - d) * (1.0 - d); let bright = lerp_color(color, Color32::WHITE, g * 0.6);
152 let c = Color32::from_rgba_unmultiplied(bright.r(), bright.g(), bright.b(), (g * 130.0) as u8);
153 let bx0 = rect.left() + x * rect.width();
154 let bw = rect.width() / bars as f32 + 1.0;
155 let shear = (x - 0.5) * rect.height() * 0.25;
157 let seg = Rect::from_min_max(pos2(bx0, rect.top() + shear), pos2(bx0 + bw, rect.bottom() + shear))
158 .intersect(rect);
159 painter.rect_filled(seg, 0.0, c);
160 }
161}
162
163#[derive(Clone, Copy)]
165struct Particle {
166 pos: Pos2,
167 vel: Vec2,
168 age: f32,
170}
171
172#[derive(Clone)]
177pub struct ParticleBurst {
178 particles: Vec<Particle>,
179 color: Color32,
180 gravity: f32,
182 lifetime: f32,
184 elapsed: f32,
185}
186
187impl ParticleBurst {
188 pub fn new(origin: Pos2, count: usize, color: Color32, seed: u64) -> Self {
191 let mut h = seed ^ 0x9E37_79B9_7F4A_7C15;
192 let mut rng = || {
193 h = h.wrapping_add(0x9E37_79B9_7F4A_7C15);
195 let mut z = h;
196 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
197 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
198 ((z ^ (z >> 31)) as f64 / u64::MAX as f64) as f32
199 };
200 let particles = (0..count)
201 .map(|i| {
202 let a = std::f32::consts::TAU * i as f32 / count.max(1) as f32;
203 let speed = 90.0 + rng() * 140.0;
204 let up = 0.6 + rng() * 0.4; Particle { pos: origin, vel: vec2(a.cos() * speed, a.sin() * speed - 120.0 * up), age: 0.0 }
206 })
207 .collect();
208 Self { particles, color, gravity: 520.0, lifetime: 1.1, elapsed: 0.0 }
209 }
210
211 pub fn update(&mut self, dt: f32) {
213 let dt = dt.max(0.0);
214 self.elapsed += dt;
215 for p in &mut self.particles {
216 p.vel.y += self.gravity * dt;
217 p.pos += p.vel * dt;
218 p.age += dt;
219 }
220 }
221
222 pub fn finished(&self) -> bool {
225 self.elapsed >= self.lifetime
226 }
227
228 pub fn paint(&self, painter: &Painter) {
230 for p in &self.particles {
231 let life = (1.0 - p.age / self.lifetime).clamp(0.0, 1.0);
232 if life <= 0.0 {
233 continue;
234 }
235 let a = (life * 220.0) as u8;
236 let c = Color32::from_rgba_unmultiplied(self.color.r(), self.color.g(), self.color.b(), a);
237 painter.circle_filled(p.pos, 1.0 + life * 2.5, c);
238 }
239 }
240}
241
242fn bezier2(p0: Pos2, p1: Pos2, p2: Pos2, t: f32) -> Pos2 {
244 let u = 1.0 - t;
245 let x = u * u * p0.x + 2.0 * u * t * p1.x + t * t * p2.x;
246 let y = u * u * p0.y + 2.0 * u * t * p1.y + t * t * p2.y;
247 pos2(x, y)
248}
249
250pub const RAVEN_FLIGHT_SECS: f32 = 1.4;
252
253#[derive(Clone)]
265pub struct RavenSprite {
266 start: Pos2,
267 target: Pos2,
268 launch_time: Option<f64>,
270 current: Pos2,
272 perched: bool,
274 elapsed: f32,
276 color: Color32,
277 facing: f32,
279 scale: f32,
280}
281
282impl Default for RavenSprite {
283 fn default() -> Self {
284 Self::new()
285 }
286}
287
288impl RavenSprite {
289 pub fn new() -> Self {
291 Self {
292 start: pos2(-40.0, -40.0),
293 target: pos2(0.0, 0.0),
294 launch_time: None,
295 current: pos2(-40.0, -40.0),
296 perched: false,
297 elapsed: 0.0,
298 color: Color32::from_rgb(18, 18, 22),
299 facing: 1.0,
300 scale: 1.0,
301 }
302 }
303
304 pub fn from(mut self, start: Pos2) -> Self {
306 self.start = start;
307 self.current = start;
308 self
309 }
310
311 pub fn color(mut self, color: Color32) -> Self {
314 self.color = color;
315 self
316 }
317
318 pub fn scale(mut self, scale: f32) -> Self {
320 self.scale = scale.max(0.1);
321 self
322 }
323
324 pub fn fly_to(mut self, target: Rect) -> Self {
327 self.target = pos2(target.center().x, target.top());
329 self.facing = if self.target.x >= self.start.x { 1.0 } else { -1.0 };
330 self.launch_time = None;
331 self.perched = false;
332 self
333 }
334
335 fn control(&self) -> Pos2 {
338 let mid = pos2((self.start.x + self.target.x) * 0.5, (self.start.y + self.target.y) * 0.5);
339 let span = (self.target - self.start).length().max(1.0);
340 pos2(mid.x, mid.y - span * 0.45) }
342
343 pub fn pos_at(&self, elapsed: f32) -> Pos2 {
348 if elapsed >= RAVEN_FLIGHT_SECS {
349 let bob = ((elapsed - RAVEN_FLIGHT_SECS) * std::f32::consts::TAU * 0.6).sin() * 1.5;
351 return pos2(self.target.x, self.target.y + bob);
352 }
353 let lin = (elapsed / RAVEN_FLIGHT_SECS).clamp(0.0, 1.0);
354 let t = easing::ease_out_back(lin);
355 bezier2(self.start, self.control(), self.target, t)
356 }
357
358 pub fn is_perched(&self) -> bool {
360 self.perched
361 }
362
363 pub fn pos(&self) -> Pos2 {
365 self.current
366 }
367
368 pub fn update(&mut self, ctx: &egui::Context) {
372 let now = ctx.input(|i| i.time);
373 let launch = *self.launch_time.get_or_insert(now);
374 let elapsed = (now - launch) as f32;
375 self.advance(elapsed);
376 if !self.perched {
377 ctx.request_repaint();
378 }
379 }
380
381 pub fn advance(&mut self, elapsed: f32) {
384 self.elapsed = elapsed;
385 self.current = self.pos_at(elapsed);
386 self.perched = elapsed >= RAVEN_FLIGHT_SECS;
387 }
388
389 pub fn paint(&self, painter: &Painter) {
393 let c = self.current;
394 let s = self.scale;
395 let f = self.facing;
396 let body = self.color;
397 let stroke = Stroke::new(1.0 * s, body);
398
399 let flap = if self.perched {
401 0.15
402 } else {
403 (self.elapsed * std::f32::consts::TAU * 5.0).sin() * 0.5 + 0.5
405 };
406 let wing_lift = (flap - 0.5) * 9.0 * s; painter.circle_filled(c, 5.0 * s, body);
410 painter.circle_filled(c + vec2(-3.5 * s * f, 1.0 * s), 3.5 * s, body);
411
412 let tail_root = c + vec2(-5.0 * s * f, 0.5 * s);
414 painter.add(egui::Shape::convex_polygon(
415 vec![
416 tail_root,
417 tail_root + vec2(-7.0 * s * f, -2.5 * s),
418 tail_root + vec2(-7.5 * s * f, 1.0 * s),
419 tail_root + vec2(-6.0 * s * f, 3.0 * s),
420 ],
421 body,
422 stroke,
423 ));
424
425 let shoulder = c + vec2(-s * f, -1.5 * s);
427 let tip_far = shoulder + vec2(-9.0 * s * f, -wing_lift - 2.0 * s);
428 let tip_near = shoulder + vec2(-4.0 * s * f, -wing_lift * 0.5 + 4.0 * s);
429 painter.add(egui::Shape::convex_polygon(
430 vec![shoulder, tip_far, tip_near],
431 body,
432 stroke,
433 ));
434
435 let head = c + vec2(4.5 * s * f, -2.5 * s);
437 painter.circle_filled(head, 3.0 * s, body);
438 let beak_color = Color32::from_rgb(40, 30, 18); painter.add(egui::Shape::convex_polygon(
440 vec![
441 head + vec2(2.5 * s * f, -0.5 * s),
442 head + vec2(6.5 * s * f, 0.5 * s),
443 head + vec2(2.5 * s * f, 1.5 * s),
444 ],
445 beak_color,
446 Stroke::NONE,
447 ));
448 painter.circle_filled(head + vec2(1.2 * s * f, -0.8 * s), 0.8 * s, Color32::from_rgb(230, 220, 210));
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 type NamedEasing = (&'static str, fn(f32) -> f32);
459
460 fn approx(a: f32, b: f32, eps: f32) -> bool {
461 (a - b).abs() <= eps
462 }
463
464 #[test]
465 fn easing_fns_hit_their_endpoints() {
466 let fns: [NamedEasing; 5] = [
467 ("linear", easing::linear),
468 ("cubic", easing::ease_in_out_cubic),
469 ("back", easing::ease_out_back),
470 ("elastic", easing::elastic),
471 ("bounce", easing::bounce),
472 ];
473 for (name, f) in fns {
474 assert!(approx(f(0.0), 0.0, 1e-5), "{name}(0) should be 0, got {}", f(0.0));
475 assert!(approx(f(1.0), 1.0, 1e-5), "{name}(1) should be 1, got {}", f(1.0));
476 assert!(approx(f(-1.0), 0.0, 1e-5), "{name}(-1) clamps to 0");
478 assert!(approx(f(2.0), 1.0, 1e-5), "{name}(2) clamps to 1");
479 }
480 }
481
482 #[test]
483 fn ease_out_back_overshoots_before_settling() {
484 let peak = (60..100).map(|i| easing::ease_out_back(i as f32 / 100.0)).fold(0.0_f32, f32::max);
486 assert!(peak > 1.0, "ease_out_back should overshoot, peak={peak}");
487 }
488
489 #[test]
490 fn raven_starts_at_launch_and_converges_onto_target_rect() {
491 let target = Rect::from_min_size(pos2(300.0, 200.0), vec2(180.0, 24.0));
492 let mut raven = RavenSprite::new().from(pos2(-40.0, -40.0)).fly_to(target);
493
494 raven.advance(0.0);
496 assert!(!raven.is_perched());
497 assert!(approx(raven.pos().x, -40.0, 0.5) && approx(raven.pos().y, -40.0, 0.5), "starts at launch");
498
499 raven.advance(RAVEN_FLIGHT_SECS * 0.5);
502 assert!(!raven.is_perched());
503
504 raven.advance(RAVEN_FLIGHT_SECS);
507 assert!(raven.is_perched(), "perched after flight duration");
508 let perch = pos2(target.center().x, target.top());
509 let d = (raven.pos() - perch).length();
510 assert!(d <= 2.0, "raven converges onto the perch (dist {d} px)");
511
512 for k in 1..20 {
514 raven.advance(RAVEN_FLIGHT_SECS + k as f32 * 0.05);
515 assert!(approx(raven.pos().x, perch.x, 0.01), "x stays centred on perch");
516 assert!(approx(raven.pos().y, perch.y, 2.0), "y stays within bob of perch");
517 }
518 }
519
520 #[test]
521 fn particle_burst_falls_and_finishes() {
522 let mut b = ParticleBurst::new(pos2(100.0, 100.0), 16, Color32::WHITE, 42);
523 assert!(!b.finished());
524 for _ in 0..120 {
525 b.update(1.0 / 60.0);
526 }
527 assert!(b.finished(), "burst should expire after its lifetime");
528 let mut a = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
530 let mut c = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
531 a.update(0.1);
532 c.update(0.1);
533 assert_eq!(a.particles[0].pos, c.particles[0].pos, "same seed → same motion");
534 }
535}