1use egui::{Color32, Context, Id, LayerId, Order, Pos2, Rect, Shape, Stroke, Vec2, vec2};
4use std::time::Duration;
5
6use super::super::shared::{ParticleLayer, ParticleRng, resolve_target};
7use super::settings::LightningSettings;
8
9const BOLT_LEVELS: u32 = 6;
11const MAX_BRANCH_DEPTH: u32 = 8;
13const BOLT_X_MIN: f32 = 0.15;
15const BOLT_X_MAX: f32 = 0.85;
17
18const BRANCH_START_MIN: f32 = 0.45;
20const BRANCH_START_MAX: f32 = 0.95;
22const BRANCH_LENGTH_MIN: f32 = 0.25;
24const BRANCH_LENGTH_MAX: f32 = 0.55;
26const BRANCH_ANGLE_MIN: f32 = 0.5;
28const BRANCH_ANGLE_MAX: f32 = 1.2;
30
31const SUB_FLASH_STARTS: [f32; 4] = [0.0, 0.12, 0.22, 0.35];
33const SUB_FLASH_HEIGHTS: [f32; 4] = [1.0, 0.55, 0.8, 0.35];
35const SUB_FLASH_ATTACK: f32 = 0.02;
37const SUB_FLASH_DECAY: f32 = 0.08;
39const SUB_FLASH_JITTER_MIN: f32 = 0.7;
41const SUB_FLASH_JITTER_MAX: f32 = 1.0;
43
44const HALO_WIDTH: f32 = 6.0;
46const HALO_ALPHA: f32 = 0.10;
48const MID_WIDTH: f32 = 2.5;
50const MID_ALPHA: f32 = 0.35;
52
53const AMBIENT_JITTER: f32 = 0.5;
55
56pub(super) const DEFAULT_COLOR: Color32 = Color32::from_rgb(190, 215, 255);
58pub(super) const DEFAULT_BOLT_COUNT: usize = 1;
60pub(super) const DEFAULT_BRANCH_DEPTH: u32 = 2;
62pub(super) const DEFAULT_THICKNESS: f32 = 2.0;
64pub(super) const DEFAULT_JITTER: f32 = 0.18;
66pub(super) const DEFAULT_FLASH_INTENSITY: f32 = 0.35;
68pub(super) const DEFAULT_DURATION_MS: f32 = 420.0;
70pub(super) const DEFAULT_AMBIENT_INTERVAL_SECS: f32 = 0.0;
72
73#[derive(Clone, Copy)]
75struct StrikeConfig {
76 bolt_count: usize,
77 branch_depth: u32,
78 jitter: f32,
79 duration: f32,
80}
81
82#[derive(Clone)]
84struct LightningState {
85 rng: ParticleRng,
87 strikes: Vec<ActiveStrike>,
89 strike_index: u64,
91 next_strike_at: Option<f64>,
93}
94
95impl LightningState {
96 fn new(seed: u64) -> Self {
97 Self {
98 rng: ParticleRng::new(seed),
99 strikes: Vec::new(),
100 strike_index: 0,
101 next_strike_at: None,
102 }
103 }
104}
105
106#[derive(Clone, Debug)]
108struct ActiveStrike {
109 polylines: Vec<Vec<Pos2>>,
111 age: f32,
113 duration: f32,
115 sub_flashes: Vec<SubFlash>,
117}
118
119impl ActiveStrike {
120 fn new(rng: &mut ParticleRng, config: StrikeConfig) -> Self {
122 let mut polylines = Vec::new();
123 for _ in 0..config.bolt_count {
124 let from = Pos2::new(rng.range(BOLT_X_MIN, BOLT_X_MAX), 0.0);
125 let to = Pos2::new(rng.range(BOLT_X_MIN, BOLT_X_MAX), 1.0);
126 polylines.extend(generate_bolt_with_branches(
127 rng,
128 from,
129 to,
130 BOLT_LEVELS,
131 config.jitter,
132 config.branch_depth,
133 ));
134 }
135
136 Self {
137 polylines,
138 age: 0.0,
139 duration: config.duration,
140 sub_flashes: SubFlash::pulses(rng),
141 }
142 }
143}
144
145#[derive(Clone, Copy, Debug)]
147struct SubFlash {
148 start: f32,
150 height: f32,
152}
153
154impl SubFlash {
155 fn pulses(rng: &mut ParticleRng) -> Vec<Self> {
157 SUB_FLASH_STARTS
158 .iter()
159 .zip(SUB_FLASH_HEIGHTS)
160 .map(|(&start, height)| Self {
161 start,
162 height: (height * rng.range(SUB_FLASH_JITTER_MIN, SUB_FLASH_JITTER_MAX))
163 .clamp(0.0, 1.0),
164 })
165 .collect()
166 }
167
168 fn value(self, u: f32) -> f32 {
170 if u <= self.start {
171 0.0
172 } else if u < self.start + SUB_FLASH_ATTACK {
173 (u - self.start) / SUB_FLASH_ATTACK
174 } else {
175 (-(u - self.start - SUB_FLASH_ATTACK) / SUB_FLASH_DECAY).exp()
176 }
177 }
178}
179
180#[derive(Debug)]
191pub struct Lightning {
192 id: Id,
193 color: Color32,
194 bolt_count: usize,
195 branch_depth: u32,
196 thickness: f32,
197 jitter: f32,
198 flash_intensity: f32,
199 duration: f32,
200 ambient_interval: f32,
201 layer_order: Order,
202 custom_layer: Option<LayerId>,
203 custom_area: Option<Rect>,
204}
205
206impl Lightning {
207 pub fn new(id_source: impl std::hash::Hash + std::fmt::Debug) -> Self {
209 Self {
210 id: Id::new(id_source),
211 color: DEFAULT_COLOR,
212 bolt_count: DEFAULT_BOLT_COUNT,
213 branch_depth: DEFAULT_BRANCH_DEPTH,
214 thickness: DEFAULT_THICKNESS,
215 jitter: DEFAULT_JITTER,
216 flash_intensity: DEFAULT_FLASH_INTENSITY,
217 duration: DEFAULT_DURATION_MS / 1000.0,
218 ambient_interval: DEFAULT_AMBIENT_INTERVAL_SECS,
219 layer_order: Order::Foreground,
220 custom_layer: None,
221 custom_area: None,
222 }
223 }
224
225 pub fn from_settings(
230 id_source: impl std::hash::Hash + std::fmt::Debug,
231 settings: &LightningSettings,
232 ) -> Self {
233 let lightning = Self::new(id_source)
234 .color(settings.color)
235 .bolt_count(settings.bolt_count)
236 .branch_depth(settings.branch_depth)
237 .thickness(settings.thickness)
238 .jitter(settings.jitter)
239 .flash_intensity(settings.flash_intensity)
240 .duration_ms(settings.duration_ms)
241 .ambient_interval_secs(settings.ambient_interval_secs);
242
243 match settings.layer {
244 ParticleLayer::Background => lightning.on_background(),
245 ParticleLayer::Foreground => lightning.on_foreground(),
246 ParticleLayer::Top => lightning.on_top(),
247 }
248 }
249
250 pub fn color(mut self, color: Color32) -> Self {
252 self.color = color;
253 self
254 }
255
256 pub fn bolt_count(mut self, bolt_count: usize) -> Self {
258 self.bolt_count = bolt_count;
259 self
260 }
261
262 pub fn branch_depth(mut self, branch_depth: u32) -> Self {
264 self.branch_depth = branch_depth;
265 self
266 }
267
268 pub fn thickness(mut self, thickness: f32) -> Self {
270 self.thickness = thickness.max(0.0);
271 self
272 }
273
274 pub fn jitter(mut self, jitter: f32) -> Self {
276 self.jitter = jitter.max(0.0);
277 self
278 }
279
280 pub fn flash_intensity(mut self, intensity: f32) -> Self {
282 self.flash_intensity = intensity.clamp(0.0, 1.0);
283 self
284 }
285
286 pub fn duration_ms(mut self, duration_ms: f32) -> Self {
288 self.duration = (duration_ms / 1000.0).max(0.0);
289 self
290 }
291
292 pub fn ambient_interval_secs(mut self, secs: f32) -> Self {
294 self.ambient_interval = secs.max(0.0);
295 self
296 }
297
298 pub fn on_top(mut self) -> Self {
300 self.layer_order = Order::Debug;
301 self
302 }
303
304 pub fn on_foreground(mut self) -> Self {
306 self.layer_order = Order::Foreground;
307 self
308 }
309
310 pub fn on_background(mut self) -> Self {
312 self.layer_order = Order::Background;
313 self
314 }
315
316 pub fn layer(mut self, layer_id: LayerId) -> Self {
320 self.custom_layer = Some(layer_id);
321 self
322 }
323
324 pub fn area(mut self, area: Rect) -> Self {
328 self.custom_area = Some(area);
329 self
330 }
331
332 pub fn strike(self, ctx: &Context) {
339 if resolve_target(
340 ctx,
341 self.id,
342 self.layer_order,
343 self.custom_layer,
344 self.custom_area,
345 )
346 .is_none()
347 {
348 return;
349 }
350
351 let id = self.id;
352 let config = self.strike_config();
353 ctx.data_mut(|data| {
354 let state = data.get_temp_mut_or_insert_with(id, || LightningState::new(id.value()));
355 queue_strike(
356 &mut state.strikes,
357 &mut state.strike_index,
358 id,
359 config,
360 );
361 });
362 ctx.request_repaint();
363 }
364
365 pub fn show(self, ctx: &Context) {
371 let Some((layer_id, area)) = resolve_target(
372 ctx,
373 self.id,
374 self.layer_order,
375 self.custom_layer,
376 self.custom_area,
377 ) else {
378 return;
379 };
380
381 let dt = ctx.input(|i| i.stable_dt).min(0.1);
382 let now = ctx.input(|i| i.time);
383 let id = self.id;
384 let config = self.strike_config();
385
386 let (mut strikes, mut rng, mut strike_index, mut next_strike_at) = ctx.data_mut(|data| {
387 let state = data.get_temp_mut_or_insert_with(id, || LightningState::new(id.value()));
388 (
389 std::mem::take(&mut state.strikes),
390 state.rng,
391 state.strike_index,
392 state.next_strike_at,
393 )
394 });
395
396 for strike in &mut strikes {
398 strike.age += dt;
399 }
400 strikes.retain(|strike| strike.age < strike.duration);
401
402 let mut ambient_delay = None;
404 if self.ambient_interval > 0.0 {
405 let interval = f64::from(self.ambient_interval);
406 if next_strike_at.is_none() {
407 next_strike_at = Some(now + jittered_interval(&mut rng, interval));
408 }
409 if next_strike_at.is_some_and(|at| now >= at) {
410 queue_strike(&mut strikes, &mut strike_index, id, config);
411 next_strike_at = Some(now + jittered_interval(&mut rng, interval));
412 }
413 if let Some(at) = next_strike_at {
414 ambient_delay = Some(Duration::from_secs_f64((at - now).max(0.0)));
415 }
416 }
417
418 let painter = ctx.layer_painter(layer_id);
420 for strike in &strikes {
421 let envelope = flash_envelope(strike.age, strike.duration, &strike.sub_flashes);
422 if envelope <= 0.0 {
423 continue;
424 }
425
426 let flash_alpha = (self.flash_intensity * envelope).clamp(0.0, 1.0);
427 painter.add(Shape::rect_filled(
428 area,
429 0.0,
430 self.color.gamma_multiply(flash_alpha),
431 ));
432
433 let halo_color = self.color.gamma_multiply((HALO_ALPHA * envelope).clamp(0.0, 1.0));
434 let mid_color = self.color.gamma_multiply((MID_ALPHA * envelope).clamp(0.0, 1.0));
435 let core_color = self.color.gamma_multiply(envelope.clamp(0.0, 1.0));
436
437 for polyline in &strike.polylines {
438 let points: Vec<Pos2> = polyline.iter().map(|p| to_pixels(*p, area)).collect();
439 painter.add(Shape::line(
440 points.clone(),
441 Stroke::new(self.thickness * HALO_WIDTH, halo_color),
442 ));
443 painter.add(Shape::line(
444 points.clone(),
445 Stroke::new(self.thickness * MID_WIDTH, mid_color),
446 ));
447 painter.add(Shape::line(
448 points,
449 Stroke::new(self.thickness, core_color),
450 ));
451 }
452 }
453
454 let has_active = !strikes.is_empty();
455
456 ctx.data_mut(|data| {
457 let state = data.get_temp_mut_or_insert_with(id, || LightningState::new(id.value()));
458 state.strikes = strikes;
459 state.rng = rng;
460 state.strike_index = strike_index;
461 state.next_strike_at = next_strike_at;
462 });
463
464 if has_active {
467 ctx.request_repaint();
468 } else if let Some(delay) = ambient_delay {
469 ctx.request_repaint_after(delay);
470 }
471 }
472
473 fn strike_config(&self) -> StrikeConfig {
475 StrikeConfig {
476 bolt_count: self.bolt_count,
477 branch_depth: self.branch_depth.min(MAX_BRANCH_DEPTH),
478 jitter: self.jitter,
479 duration: self.duration,
480 }
481 }
482}
483
484fn queue_strike(
486 strikes: &mut Vec<ActiveStrike>,
487 strike_index: &mut u64,
488 id: Id,
489 config: StrikeConfig,
490) {
491 let seed = id.value() ^ *strike_index;
492 *strike_index = strike_index.wrapping_add(1);
493 let mut rng = ParticleRng::new(seed);
494 strikes.push(ActiveStrike::new(&mut rng, config));
495}
496
497fn jittered_interval(rng: &mut ParticleRng, interval: f64) -> f64 {
499 let spread = 1.0 + f64::from(rng.range(-AMBIENT_JITTER, AMBIENT_JITTER));
500 (interval * spread).max(0.0)
501}
502
503fn flash_envelope(age: f32, duration: f32, sub_flashes: &[SubFlash]) -> f32 {
508 if duration <= 0.0 {
509 return 0.0;
510 }
511
512 let u = (age / duration).clamp(0.0, 1.0);
513 if u <= 0.0 || u >= 1.0 {
514 return 0.0;
515 }
516
517 let sum: f32 = sub_flashes.iter().map(|pulse| pulse.height * pulse.value(u)).sum();
518 sum.clamp(0.0, 1.0)
519}
520
521fn to_pixels(point: Pos2, area: Rect) -> Pos2 {
523 Pos2::new(
524 area.min.x + point.x * area.width(),
525 area.min.y + point.y * area.height(),
526 )
527}
528
529fn generate_bolt(
536 rng: &mut ParticleRng,
537 from: Pos2,
538 to: Pos2,
539 levels: u32,
540 jitter: f32,
541) -> Vec<Pos2> {
542 let mut points = vec![from, to];
543
544 for _ in 0..levels {
545 let mut next = Vec::with_capacity(points.len() * 2 - 1);
546 for pair in points.windows(2) {
547 let a = pair[0];
548 let b = pair[1];
549 next.push(a);
550
551 let delta = b - a;
552 let length = delta.length();
553 let midpoint = a + delta * 0.5;
554 if length > 0.0 {
555 let perpendicular = vec2(-delta.y, delta.x) / length;
556 let amplitude = jitter * length;
557 next.push(midpoint + perpendicular * rng.range(-amplitude, amplitude));
558 } else {
559 next.push(midpoint);
560 }
561 }
562 next.push(*points.last().expect("a bolt has at least two points"));
563 points = next;
564 }
565
566 for point in &mut points {
567 point.x = point.x.clamp(0.0, 1.0);
568 point.y = point.y.clamp(0.0, 1.0);
569 }
570
571 points
572}
573
574fn generate_bolt_with_branches(
576 rng: &mut ParticleRng,
577 from: Pos2,
578 to: Pos2,
579 levels: u32,
580 jitter: f32,
581 branch_depth: u32,
582) -> Vec<Vec<Pos2>> {
583 let trunk = generate_bolt(rng, from, to, levels, jitter);
584 let mut polylines = vec![trunk.clone()];
585 spawn_branches(
586 rng,
587 &trunk,
588 levels,
589 jitter,
590 branch_depth,
591 &mut polylines,
592 );
593 polylines
594}
595
596fn spawn_branches(
598 rng: &mut ParticleRng,
599 parent: &[Pos2],
600 levels: u32,
601 jitter: f32,
602 remaining: u32,
603 out: &mut Vec<Vec<Pos2>>,
604) {
605 if remaining == 0 || parent.len() < 2 {
606 return;
607 }
608
609 let branch = spawn_branch(rng, parent, levels, jitter);
610 out.push(branch.clone());
611 spawn_branches(
612 rng,
613 &branch,
614 levels.saturating_sub(1),
615 jitter,
616 remaining - 1,
617 out,
618 );
619}
620
621fn spawn_branch(rng: &mut ParticleRng, parent: &[Pos2], levels: u32, jitter: f32) -> Vec<Pos2> {
623 let from = parent[0];
624 let to = parent[parent.len() - 1];
625 let length = (to - from).length();
626 let direction = if length > 0.0 {
627 (to - from) / length
628 } else {
629 vec2(0.0, 1.0)
630 };
631
632 let lower = (parent.len() as f32 * BRANCH_START_MIN) as usize;
634 let upper = (parent.len() as f32 * BRANCH_START_MAX) as usize;
635 let index = (rng.range(lower as f32, (upper + 1) as f32) as usize).min(parent.len() - 1);
636 let start = parent[index];
637
638 let sign = if rng.f32() < 0.5 { -1.0 } else { 1.0 };
639 let angle = sign * rng.range(BRANCH_ANGLE_MIN, BRANCH_ANGLE_MAX);
640 let branch_length = length * rng.range(BRANCH_LENGTH_MIN, BRANCH_LENGTH_MAX);
641 let end = start + rotate(direction, angle) * branch_length;
642
643 generate_bolt(
644 rng,
645 start,
646 end,
647 levels.saturating_sub(1).max(1),
648 jitter,
649 )
650}
651
652fn rotate(v: Vec2, angle: f32) -> Vec2 {
654 let (sin, cos) = angle.sin_cos();
655 vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos)
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use egui::__run_test_ctx;
662 use egui::{ViewportId, vec2};
663
664 fn sample_sub_flashes() -> Vec<SubFlash> {
666 SUB_FLASH_STARTS
667 .iter()
668 .zip(SUB_FLASH_HEIGHTS)
669 .map(|(&start, height)| SubFlash { start, height })
670 .collect()
671 }
672
673 fn distance_to_line(point: Pos2, from: Pos2, to: Pos2) -> f32 {
675 let delta = to - from;
676 let length = delta.length();
677 if length <= 0.0 {
678 return (point - from).length();
679 }
680 ((point.x - from.x) * delta.y - (point.y - from.y) * delta.x).abs() / length
681 }
682
683 #[test]
684 fn same_seed_generates_identical_bolts() {
685 let from = Pos2::new(0.3, 0.0);
686 let to = Pos2::new(0.7, 1.0);
687 let mut a = ParticleRng::new(42);
688 let mut b = ParticleRng::new(42);
689 assert_eq!(
690 generate_bolt(&mut a, from, to, BOLT_LEVELS, DEFAULT_JITTER),
691 generate_bolt(&mut b, from, to, BOLT_LEVELS, DEFAULT_JITTER)
692 );
693 }
694
695 #[test]
696 fn different_seeds_generate_different_bolts() {
697 let from = Pos2::new(0.3, 0.0);
698 let to = Pos2::new(0.7, 1.0);
699 let mut a = ParticleRng::new(1);
700 let mut b = ParticleRng::new(2);
701 assert_ne!(
702 generate_bolt(&mut a, from, to, BOLT_LEVELS, DEFAULT_JITTER),
703 generate_bolt(&mut b, from, to, BOLT_LEVELS, DEFAULT_JITTER)
704 );
705 }
706
707 #[test]
708 fn every_bolt_point_stays_normalized() {
709 let mut rng = ParticleRng::new(7);
710 let polylines = generate_bolt_with_branches(
711 &mut rng,
712 Pos2::new(0.5, 0.0),
713 Pos2::new(0.5, 1.0),
714 BOLT_LEVELS,
715 0.3,
716 3,
717 );
718
719 for polyline in &polylines {
720 for point in polyline {
721 assert!(
722 (0.0..=1.0).contains(&point.x),
723 "x {} left the normalized area",
724 point.x
725 );
726 assert!(
727 (0.0..=1.0).contains(&point.y),
728 "y {} left the normalized area",
729 point.y
730 );
731 }
732 }
733 }
734
735 #[test]
736 fn branch_count_is_bounded_by_depth() {
737 let mut rng = ParticleRng::new(3);
738 for depth in 0..=4u32 {
739 let polylines = generate_bolt_with_branches(
740 &mut rng,
741 Pos2::new(0.5, 0.0),
742 Pos2::new(0.5, 1.0),
743 BOLT_LEVELS,
744 DEFAULT_JITTER,
745 depth,
746 );
747 assert!(
748 polylines.len() <= 1 + depth as usize,
749 "depth {depth} produced {} polylines",
750 polylines.len()
751 );
752 }
753 }
754
755 #[test]
756 fn bolt_is_jagged_not_a_straight_line() {
757 let from = Pos2::new(0.5, 0.0);
758 let to = Pos2::new(0.5, 1.0);
759 let mut rng = ParticleRng::new(11);
760 let bolt = generate_bolt(&mut rng, from, to, BOLT_LEVELS, DEFAULT_JITTER);
761
762 let max_deviation = bolt
763 .iter()
764 .map(|point| distance_to_line(*point, from, to))
765 .fold(0.0_f32, f32::max);
766 assert!(
767 max_deviation > 0.01,
768 "the bolt deviated only {max_deviation} from the straight line"
769 );
770 }
771
772 #[test]
773 fn strike_with_degenerate_area_is_a_noop() {
774 __run_test_ctx(|ctx| {
775 let area = Rect::from_min_size(Pos2::ZERO, vec2(0.0, 64.0));
776 Lightning::new("test_lightning_degenerate").area(area).strike(ctx);
777 assert!(
778 ctx.data(|d| d.get_temp::<LightningState>(Id::new("test_lightning_degenerate")))
779 .is_none(),
780 "a degenerate area must not create state"
781 );
782 });
783 }
784
785 #[test]
786 fn strike_queues_a_bolt() {
787 __run_test_ctx(|ctx| {
788 let area = Rect::from_min_size(Pos2::ZERO, vec2(64.0, 64.0));
789 Lightning::new("test_lightning_queue").area(area).strike(ctx);
790
791 let state = ctx
792 .data(|d| d.get_temp::<LightningState>(Id::new("test_lightning_queue")))
793 .expect("LightningState should exist after strike");
794 assert_eq!(state.strikes.len(), 1);
795 assert!(!state.strikes[0].polylines.is_empty());
796 });
797 }
798
799 #[test]
800 fn envelope_is_zero_at_both_ends() {
801 let pulses = sample_sub_flashes();
802 assert_eq!(
803 flash_envelope(0.0, 1.0, &pulses),
804 0.0,
805 "the envelope must start at zero"
806 );
807 assert_eq!(
808 flash_envelope(1.0, 1.0, &pulses),
809 0.0,
810 "the envelope must end at zero"
811 );
812 assert_eq!(
813 flash_envelope(1.0, 0.0, &pulses),
814 0.0,
815 "a zero-length strike must have a zero envelope"
816 );
817 }
818
819 #[test]
820 fn envelope_stays_within_bounds() {
821 let pulses = sample_sub_flashes();
822 for step in 0..=1000 {
823 let age = step as f32 / 1000.0;
824 let value = flash_envelope(age, 1.0, &pulses);
825 assert!(
826 (0.0..=1.0).contains(&value),
827 "envelope {value} left 0.0..=1.0 at age {age}"
828 );
829 }
830 }
831
832 #[test]
833 fn envelope_has_a_sub_flash_after_its_first_decay() {
834 let pulses = sample_sub_flashes();
835 let samples = 1000;
836 let values: Vec<f32> = (0..=samples)
837 .map(|i| flash_envelope(i as f32 / samples as f32, 1.0, &pulses))
838 .collect();
839
840 let first_max = (1..samples)
841 .find(|&i| values[i] > values[i - 1] && values[i] > values[i + 1])
842 .expect("the envelope must have an interior local maximum");
843
844 let mut decayed = false;
845 let mut later_max = false;
846 for i in (first_max + 1)..samples {
847 if values[i] < values[i - 1] {
848 decayed = true;
849 }
850 if decayed && values[i] > values[i - 1] && values[i] > values[i + 1] {
851 later_max = true;
852 break;
853 }
854 }
855
856 assert!(
857 later_max,
858 "the envelope has no sub-flash after its first decay"
859 );
860 }
861
862 #[test]
863 fn strike_ends_after_its_duration() {
864 __run_test_ctx(|ctx| {
865 let area = Rect::from_min_size(Pos2::ZERO, vec2(64.0, 64.0));
866 let id = Id::new("test_lightning_ends");
867 Lightning::new("test_lightning_ends").duration_ms(200.0).area(area).strike(ctx);
868
869 assert_eq!(
870 ctx.data(|d| d.get_temp::<LightningState>(id))
871 .expect("state after strike")
872 .strikes
873 .len(),
874 1
875 );
876
877 for frame in 0..8 {
878 ctx.input_mut(|i| {
879 i.stable_dt = 0.05;
880 i.time = f64::from(frame) * 0.05;
881 });
882 Lightning::new("test_lightning_ends").duration_ms(200.0).area(area).show(ctx);
883 }
884
885 let state = ctx
886 .data(|d| d.get_temp::<LightningState>(id))
887 .expect("LightningState should exist after show");
888 assert!(
889 state.strikes.is_empty(),
890 "a strike outlived its duration: {} still active",
891 state.strikes.len()
892 );
893 });
894 }
895
896 #[test]
897 fn idle_show_requests_no_repaint() {
898 let ctx = Context::default();
899 let area = Rect::from_min_size(Pos2::ZERO, vec2(64.0, 64.0));
900 let raw_input = egui::RawInput {
901 screen_rect: Some(area),
902 ..Default::default()
903 };
904
905 for _ in 0..2 {
909 ctx.run_ui(raw_input.clone(), |ui| {
910 Lightning::new("test_lightning_idle").area(area).show(ui.ctx());
911 })
912 .drop_without_applying_deltas();
913 }
914
915 let output = ctx.run_ui(raw_input, |ui| {
916 Lightning::new("test_lightning_idle").area(area).show(ui.ctx());
917 });
918 let delay = output.viewport_output[&ViewportId::ROOT].repaint_delay;
919 assert_eq!(
920 delay,
921 Duration::MAX,
922 "an idle lightning effect requested a repaint after {delay:?}"
923 );
924 }
925
926 #[test]
927 fn from_settings_maps_fields_and_layer() {
928 let settings = LightningSettings {
929 color: Color32::from_rgb(1, 2, 3),
930 bolt_count: 4,
931 branch_depth: 5,
932 thickness: 3.5,
933 jitter: 0.4,
934 flash_intensity: 0.6,
935 duration_ms: 800.0,
936 ambient_interval_secs: 3.0,
937 layer: ParticleLayer::Top,
938 ..Default::default()
939 };
940
941 let lightning = Lightning::from_settings("lightning_map", &settings);
942 assert_eq!(lightning.color, Color32::from_rgb(1, 2, 3));
943 assert_eq!(lightning.bolt_count, 4);
944 assert_eq!(lightning.branch_depth, 5);
945 assert_eq!(lightning.thickness, 3.5);
946 assert_eq!(lightning.jitter, 0.4);
947 assert_eq!(lightning.flash_intensity, 0.6);
948 assert_eq!(lightning.duration, 0.8);
949 assert_eq!(lightning.ambient_interval, 3.0);
950 assert_eq!(lightning.layer_order, Order::Debug);
951
952 let background = Lightning::from_settings(
953 "lightning_map_bg",
954 &LightningSettings {
955 layer: ParticleLayer::Background,
956 ..Default::default()
957 },
958 );
959 assert_eq!(background.layer_order, Order::Background);
960 }
961
962 #[test]
963 fn new_matches_default_settings() {
964 let from_new = Lightning::new("lightning_defaults");
965 let from_settings = Lightning::from_settings(
966 "lightning_defaults",
967 &LightningSettings::default(),
968 );
969 assert_eq!(from_new.color, from_settings.color);
970 assert_eq!(from_new.bolt_count, from_settings.bolt_count);
971 assert_eq!(from_new.branch_depth, from_settings.branch_depth);
972 assert_eq!(from_new.thickness, from_settings.thickness);
973 assert_eq!(from_new.jitter, from_settings.jitter);
974 assert_eq!(
975 from_new.flash_intensity,
976 from_settings.flash_intensity
977 );
978 assert_eq!(from_new.duration, from_settings.duration);
979 assert_eq!(
980 from_new.ambient_interval,
981 from_settings.ambient_interval
982 );
983 assert_eq!(from_new.layer_order, from_settings.layer_order);
984 }
985}