1use egui::{
29 Color32, Painter, Pos2, Shape, Stroke,
30 epaint::{CircleShape, PathShape},
31};
32use std::f32::consts::TAU;
33use std::time::Instant;
34
35pub const PULSE_DURATION: f32 = 3.5;
37pub const RIPPLE_DURATION: f32 = 3.5;
39pub const COUNTDOWN_DURATION: f32 = 5.0;
41pub const SCALE_IN_DURATION: f32 = 0.45;
43pub const CROSSHAIR_DURATION: f32 = 0.6;
45
46fn with_alpha(color: Color32, alpha: f32) -> Color32 {
48 Color32::from_rgba_unmultiplied(
49 color.r(),
50 color.g(),
51 color.b(),
52 (255.0 * alpha.clamp(0.0, 1.0)).round() as u8,
53 )
54}
55
56fn elapsed(initial_time: Instant) -> f32 {
58 Instant::now().duration_since(initial_time).as_secs_f32()
59}
60
61fn triangle_wave(time: f32, period: f32) -> f32 {
63 let phase = (time / period).rem_euclid(1.0);
64 1.0 - (2.0 * phase - 1.0).abs()
65}
66
67fn ease_out_back(x: f32) -> f32 {
69 const C1: f32 = 1.701_58;
70 const C3: f32 = C1 + 1.0;
71 let x1 = x - 1.0;
72 1.0 + C3 * x1 * x1 * x1 + C1 * x1 * x1
73}
74
75pub struct Animation {}
80
81impl Animation {
82 pub fn pulse(
89 painter: &Painter,
90 center: Pos2,
91 zoom: f32,
92 initial_time: Instant,
93 color: Color32,
94 ) -> bool {
95 let secs = elapsed(initial_time);
96 let radius = (4.00 + (40.00 * secs)) * zoom;
97 let transparency = (1.00 - (secs / PULSE_DURATION).abs()).max(0.0);
98 painter.add(Shape::Circle(CircleShape::filled(
99 center,
100 radius,
101 with_alpha(color, transparency),
102 )));
103 secs < PULSE_DURATION
104 }
105
106 pub fn ripple(
112 painter: &Painter,
113 center: Pos2,
114 zoom: f32,
115 initial_time: Instant,
116 color: Color32,
117 ) -> bool {
118 const RINGS: usize = 3;
119 let secs = elapsed(initial_time);
120 let stagger = RIPPLE_DURATION / RINGS as f32;
121
122 let mut shapes = Vec::with_capacity(RINGS);
123 for ring in 0..RINGS {
124 let local = secs - ring as f32 * stagger;
125 if !(0.0..RIPPLE_DURATION).contains(&local) {
126 continue;
127 }
128 let progress = local / RIPPLE_DURATION;
129 shapes.push(Shape::Circle(CircleShape::stroke(
130 center,
131 (4.0 + 36.0 * progress) * zoom,
132 Stroke::new(2.0 * zoom, with_alpha(color, 1.0 - progress)),
133 )));
134 }
135 painter.extend(shapes);
136 secs < RIPPLE_DURATION
137 }
138
139 pub fn countdown_arc(
145 painter: &Painter,
146 center: Pos2,
147 zoom: f32,
148 initial_time: Instant,
149 color: Color32,
150 ) -> bool {
151 const STEPS: usize = 48;
153 let secs = elapsed(initial_time);
154 let remaining = (1.0 - secs / COUNTDOWN_DURATION).clamp(0.0, 1.0);
155 let radius = 10.0 * zoom;
156
157 let count = (STEPS as f32 * remaining).round() as usize;
158 if count >= 1 {
159 let points = (0..=count)
160 .map(|i| {
161 let angle = TAU * (i as f32 / STEPS as f32) - TAU / 4.0;
164 Pos2::new(
165 center.x + radius * angle.cos(),
166 center.y + radius * angle.sin(),
167 )
168 })
169 .collect();
170 painter.add(Shape::Path(PathShape::line(
171 points,
172 Stroke::new(2.0 * zoom, with_alpha(color, 1.0)),
173 )));
174 }
175 secs < COUNTDOWN_DURATION
176 }
177
178 pub fn scale_in(
183 painter: &Painter,
184 center: Pos2,
185 zoom: f32,
186 initial_time: Instant,
187 color: Color32,
188 ) -> bool {
189 let secs = elapsed(initial_time);
190 let progress = (secs / SCALE_IN_DURATION).clamp(0.0, 1.0);
191 let radius = 8.0 * zoom * ease_out_back(progress).max(0.0);
192 painter.add(Shape::Circle(CircleShape::filled(
193 center,
194 radius,
195 with_alpha(color, 1.0 - progress),
196 )));
197 secs < SCALE_IN_DURATION
198 }
199
200 pub fn crosshair(
205 painter: &Painter,
206 center: Pos2,
207 zoom: f32,
208 initial_time: Instant,
209 color: Color32,
210 ) -> bool {
211 let secs = elapsed(initial_time);
212 let progress = (secs / CROSSHAIR_DURATION).clamp(0.0, 1.0);
213 let far = (30.0 - 18.0 * progress) * zoom;
216 let near = far - 8.0 * zoom;
217 let alpha = if progress < 0.66 {
218 1.0
219 } else {
220 1.0 - (progress - 0.66) / 0.34
221 };
222 let stroke = Stroke::new(2.0 * zoom, with_alpha(color, alpha));
223
224 let mut shapes = Vec::with_capacity(4);
225 for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
226 shapes.push(Shape::line_segment(
227 [
228 Pos2::new(center.x + dx * far, center.y + dy * far),
229 Pos2::new(center.x + dx * near, center.y + dy * near),
230 ],
231 stroke,
232 ));
233 }
234 painter.extend(shapes);
235 secs < CROSSHAIR_DURATION
236 }
237
238 pub fn halo(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
248 const PERIOD: f32 = 2.0;
249 let alpha = 0.30 + 0.45 * triangle_wave(time, PERIOD);
250 let radius = (9.0 * zoom).max(5.0);
251 painter.add(Shape::Circle(CircleShape::stroke(
252 center,
253 radius,
254 Stroke::new((2.0 * zoom).max(1.5), with_alpha(color, alpha)),
255 )));
256 }
257
258 pub fn blink(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
264 const PERIOD: f32 = 2.55;
265 painter.add(Shape::Circle(CircleShape::stroke(
266 center,
267 4.0 * zoom,
268 Stroke::new(9.0 * zoom, with_alpha(color, triangle_wave(time, PERIOD))),
269 )));
270 }
271
272 pub fn orbit(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
276 const PERIOD: f32 = 3.0;
277 let radius = (12.0 * zoom).max(7.0);
278 let angle = TAU * (time / PERIOD).rem_euclid(1.0);
279 let dot = Pos2::new(
280 center.x + radius * angle.cos(),
281 center.y + radius * angle.sin(),
282 );
283 painter.extend([
284 Shape::Circle(CircleShape::stroke(
285 center,
286 radius,
287 Stroke::new(1.0, with_alpha(color, 0.25)),
288 )),
289 Shape::Circle(CircleShape::filled(
290 dot,
291 (2.5 * zoom).max(2.0),
292 with_alpha(color, 1.0),
293 )),
294 ]);
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use egui::{Context, LayerId, Rect, Vec2};
302 use std::time::Duration;
303
304 fn headless_painter() -> Painter {
305 Painter::new(
306 Context::default(),
307 LayerId::background(),
308 Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 100.0)),
309 )
310 }
311
312 #[allow(clippy::type_complexity)]
314 fn event_effects() -> Vec<(
315 &'static str,
316 fn(&Painter, Pos2, f32, Instant, Color32) -> bool,
317 f32,
318 )> {
319 vec![
320 ("pulse", Animation::pulse, PULSE_DURATION),
321 ("ripple", Animation::ripple, RIPPLE_DURATION),
322 (
323 "countdown_arc",
324 Animation::countdown_arc,
325 COUNTDOWN_DURATION,
326 ),
327 ("scale_in", Animation::scale_in, SCALE_IN_DURATION),
328 ("crosshair", Animation::crosshair, CROSSHAIR_DURATION),
329 ]
330 }
331
332 #[test]
333 fn event_effects_report_running_then_finished() {
334 let painter = headless_painter();
335 for (name, effect, duration) in event_effects() {
336 assert!(
337 effect(&painter, Pos2::ZERO, 1.0, Instant::now(), Color32::RED),
338 "{name} must report it is still running when it just started"
339 );
340
341 let long_past = Instant::now() - Duration::from_secs_f32(duration + 1.0);
342 assert!(
343 !effect(&painter, Pos2::ZERO, 1.0, long_past, Color32::RED),
344 "{name} must report it is finished once its duration has passed"
345 );
346 }
347 }
348
349 #[test]
350 fn every_event_effect_stays_under_the_orphan_sweep() {
351 for (name, _, duration) in event_effects() {
354 assert!(
355 duration < 10.0,
356 "{name} lasts {duration}s, which the 10s orphan sweep would truncate"
357 );
358 }
359 }
360
361 #[test]
362 fn persistent_effects_run_at_any_time() {
363 let painter = headless_painter();
364 for time in [0.0, 0.7, 1.3, 60.0] {
365 Animation::halo(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
366 Animation::blink(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
367 Animation::orbit(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
368 }
369 }
370
371 #[test]
372 fn triangle_wave_goes_up_and_back_down() {
373 assert_eq!(triangle_wave(0.0, 2.0), 0.0);
374 assert_eq!(triangle_wave(1.0, 2.0), 1.0);
375 assert!(triangle_wave(2.0, 2.0).abs() < 1e-6);
376 assert!((triangle_wave(3.0, 2.0) - 1.0).abs() < 1e-6);
377 for step in 0..200 {
378 let v = triangle_wave(step as f32 * 0.05, 2.55);
379 assert!((0.0..=1.0).contains(&v), "{v} out of range");
380 }
381 }
382
383 #[test]
384 fn ease_out_back_overshoots_then_settles() {
385 assert_eq!(ease_out_back(0.0), 0.0);
386 assert!((ease_out_back(1.0) - 1.0).abs() < 1e-5);
387 let peak = (0..=100)
388 .map(|i| ease_out_back(i as f32 / 100.0))
389 .fold(f32::MIN, f32::max);
390 assert!(
391 peak > 1.0,
392 "ease_out_back should overshoot, peaked at {peak}"
393 );
394 }
395
396 #[test]
397 fn with_alpha_clamps_out_of_range_values() {
398 assert_eq!(with_alpha(Color32::RED, 2.0).a(), 255);
399 assert_eq!(with_alpha(Color32::RED, -1.0).a(), 0);
400 assert_eq!(with_alpha(Color32::RED, 1.0), Color32::RED);
401 }
402}