use std::time::Duration;
use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
use bevy_elements::*;
const FRAME: Duration = Duration::from_nanos(16_666_667);
#[derive(Resource, Default)]
struct Seen {
strikes: Vec<(f64, LightningStrike)>,
thunder: Vec<(f64, Thunder)>,
flash: Vec<LightningFlash>,
}
fn collect(
mut strikes: MessageReader<LightningStrike>,
mut thunder: MessageReader<Thunder>,
real: Res<Time<Real>>,
flash: Res<LightningFlash>,
mut seen: ResMut<Seen>,
) {
let now = real.elapsed_secs_f64();
seen.strikes.extend(strikes.read().map(|s| (now, *s)));
seen.thunder.extend(thunder.read().map(|t| (now, *t)));
seen.flash.push(*flash);
}
fn storm(seed: u64, storminess: f32) -> App {
let mut app = App::new();
app.add_plugins((MinimalPlugins, LightningPlugin))
.insert_resource(TimeUpdateStrategy::ManualDuration(FRAME))
.insert_resource(Climate::temperate().with_seed(seed))
.insert_resource(Weather {
storminess,
..default()
})
.init_resource::<Seen>()
.add_systems(Update, collect.after(ElementsSystems::Simulation));
app.update();
app
}
fn run(app: &mut App, updates: usize) {
for _ in 0..updates {
app.update();
}
}
const TEN_MINUTES: usize = 36_000;
fn strikes_in_ten_minutes(seed: u64) -> usize {
let mut app = storm(seed, 6.0);
run(&mut app, TEN_MINUTES);
app.world().resource::<Seen>().strikes.len()
}
#[test]
fn a_ten_minute_storm_strikes_forty_to_eighty_times() {
let count = strikes_in_ten_minutes(42);
assert!((40..=80).contains(&count), "{count} strikes");
}
#[test]
fn the_lightning_schedule_is_frozen() {
let counts: Vec<usize> = [0, 1, 7, 42, 1234]
.into_iter()
.map(strikes_in_ten_minutes)
.collect();
assert_eq!(counts, [60, 62, 45, 64, 51]);
}
#[test]
fn every_thunder_arrives_distance_over_343_seconds_after_its_strike() {
let mut app = storm(42, 6.0);
app.world_mut().spawn((
Camera::default(),
ElementsCamera::default(),
GlobalTransform::from_xyz(10_000.0, 3.0, -5_000.0),
));
run(&mut app, TEN_MINUTES);
let seen = app.world().resource::<Seen>();
let end = app.world().resource::<Time<Real>>().elapsed_secs_f64();
let frame = FRAME.as_secs_f64();
let camera = Vec3::new(10_000.0, 3.0, -5_000.0);
assert!(seen.strikes.len() >= 40);
let mut matched = 0;
for (struck_at, strike) in &seen.strikes {
let horizontal = Vec2::new(strike.position.x - camera.x, strike.position.z - camera.z);
assert!(horizontal.length() <= STRIKE_RADIUS_M + 0.01, "{strike:?}");
let distance = strike.position.distance(camera);
let due = struck_at + f64::from(distance / SPEED_OF_SOUND_MS);
let thunders: Vec<_> = seen
.thunder
.iter()
.filter(|(_, t)| t.position == strike.position)
.collect();
if due > end {
assert!(thunders.is_empty(), "{strike:?}");
continue;
}
assert_eq!(thunders.len(), 1, "one thunder per strike: {strike:?}");
let (heard_at, thunder) = thunders[0];
let lag = heard_at - due;
assert!(
(-1e-6..frame + 1e-6).contains(&lag),
"thunder {lag} s late for {distance} m"
);
assert!((thunder.distance_m - distance).abs() < 1e-3);
assert!((thunder.loudness - 1.0 / (1.0 + distance / 1000.0)).abs() < 1e-6);
matched += 1;
}
assert!(matched >= 35, "{matched} thunders checked");
}
#[test]
fn a_scripted_strike_at_343_m_is_heard_one_second_later() {
let mut app = storm(5, 0.0);
let at = Vec3::new(343.0, 0.0, 0.0);
app.world_mut().write_message(StrikeLightning { at });
run(&mut app, 180);
let seen = app.world().resource::<Seen>();
assert_eq!(seen.strikes.len(), 1);
assert_eq!(seen.strikes[0].1.position, at);
assert_eq!(seen.thunder.len(), 1);
let delay = seen.thunder[0].0 - seen.strikes[0].0;
assert!(
(1.0 - 1e-6..1.0 + FRAME.as_secs_f64() + 1e-6).contains(&delay),
"{delay}"
);
assert_eq!(seen.thunder[0].1.distance_m, 343.0);
}
#[test]
fn the_same_seed_gives_the_same_storm() {
let record = |seed| {
let mut app = storm(seed, 6.0);
run(&mut app, 6_000);
app.world()
.resource::<Seen>()
.strikes
.iter()
.map(|(_, s)| *s)
.collect::<Vec<_>>()
};
let (a, b, c) = (record(42), record(42), record(43));
assert!(!a.is_empty());
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn a_calm_sky_never_strikes() {
let mut app = storm(42, 0.0);
run(&mut app, 6_000);
assert!(app.world().resource::<Seen>().strikes.is_empty());
}
#[test]
fn the_flash_follows_the_first_stroke_then_goes_dark() {
let mut app = storm(9, 0.0);
let at = Vec3::new(200.0, 0.0, -300.0);
app.world_mut().write_message(StrikeLightning { at });
app.update();
let first = *app.world().resource::<LightningFlash>();
let peak = FLASH_LUMENS / (4.0 * std::f32::consts::PI);
assert!(
(first.intensity_cd / peak - 0.830).abs() < 0.01,
"{} of peak",
first.intensity_cd / peak
);
assert!(
(1500.0..1520.0).contains(&first.position.y),
"{:?}",
first.position
);
assert!(Vec2::new(first.position.x - at.x, first.position.z - at.z).length() < 150.0);
run(&mut app, 40);
assert_eq!(app.world().resource::<LightningFlash>().intensity_cd, 0.0);
let seen = app.world().resource::<Seen>();
assert!(
seen.flash.iter().filter(|f| f.intensity_cd > 0.0).count() >= 12,
"the flash lasts at least 200 ms"
);
let lit: Vec<f32> = seen.flash.iter().map(|f| f.intensity_cd).collect();
assert!(
lit.windows(2).skip(2).any(|w| w[1] > w[0]),
"no restrike flicker in {lit:?}"
);
}