1use std::time::Duration;
5
6use bevy::prelude::*;
7
8use super::model::Climate;
9use super::season::Season;
10use crate::celestial::{CivilDate, GeoLocation, WorldClock};
11
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Reflect)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub enum WeatherPreset {
16 #[default]
18 Clear,
19 PartlyCloudy,
21 Overcast,
23 Fog,
25 Drizzle,
27 Rain,
29 Thunderstorm,
31 Snow,
33 Blizzard,
35 DustStorm,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq)]
42struct Target {
43 cover: f32,
44 mm_h: f32,
45 rh: f32,
46 wind: f32,
47 gust: f32,
48 visibility: f32,
49 storm: f32,
50 dust: f32,
51 delta_t: f32,
52}
53
54#[rustfmt::skip]
55fn target(preset: WeatherPreset) -> Target {
56 use WeatherPreset::*;
57 let t = |cover, mm_h, rh, wind, gust, visibility, storm, dust, delta_t| Target { cover, mm_h, rh, wind, gust, visibility, storm, dust, delta_t };
58 match preset {
59 Clear => t(0.05, 0.0, 0.50, 3.0, 0.20, 40_000.0, 0.0, 0.0, 2.0),
60 PartlyCloudy => t(0.40, 0.0, 0.60, 4.0, 0.30, 30_000.0, 0.0, 0.0, 1.0),
61 Overcast => t(0.95, 0.0, 0.75, 5.0, 0.30, 20_000.0, 0.0, 0.0, -1.0),
62 Fog => t(0.60, 0.0, 0.99, 1.0, 0.05, 300.0, 0.0, 0.0, -1.0),
63 Drizzle => t(0.95, 0.5, 0.92, 4.0, 0.30, 5_000.0, 0.0, 0.0, -2.0),
64 Rain => t(1.00, 4.0, 0.95, 7.0, 0.50, 3_000.0, 0.0, 0.0, -3.0),
65 Thunderstorm => t(1.00, 25.0, 0.97, 14.0, 0.90, 1_500.0, 6.0, 0.0, -5.0),
66 Snow => t(1.00, 1.5, 0.90, 4.0, 0.30, 1_500.0, 0.0, 0.0, -4.0),
67 Blizzard => t(1.00, 4.0, 0.90, 18.0, 0.90, 200.0, 0.0, 0.0, -6.0),
68 DustStorm => t(0.30, 0.0, 0.15, 16.0, 0.80, 800.0, 0.0, 0.9, 3.0),
69 }
70}
71
72impl WeatherPreset {
73 pub const ALL: [Self; 10] = [
75 Self::Clear,
76 Self::PartlyCloudy,
77 Self::Overcast,
78 Self::Fog,
79 Self::Drizzle,
80 Self::Rain,
81 Self::Thunderstorm,
82 Self::Snow,
83 Self::Blizzard,
84 Self::DustStorm,
85 ];
86
87 pub fn is_wet(self) -> bool {
89 target(self).mm_h > 0.0
90 }
91
92 pub fn is_snow(self) -> bool {
94 matches!(self, Self::Snow | Self::Blizzard)
95 }
96
97 fn neighbours(self) -> &'static [Self] {
98 use WeatherPreset::*;
99 match self {
100 Clear => &[PartlyCloudy, DustStorm, Fog],
101 PartlyCloudy => &[Clear, Overcast, DustStorm, Fog],
102 Overcast => &[PartlyCloudy, Drizzle, Rain, Snow, Fog],
103 Fog => &[Overcast, Clear, PartlyCloudy],
104 Drizzle => &[Overcast],
105 Rain => &[Overcast, Thunderstorm],
106 Thunderstorm => &[Rain],
107 Snow => &[Overcast, Blizzard],
108 Blizzard => &[Snow],
109 DustStorm => &[Clear, PartlyCloudy],
110 }
111 }
112}
113
114#[derive(Resource, Clone, Copy, Debug, PartialEq, Reflect)]
116#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
117pub struct Weather {
118 pub cloud_cover: f32,
120 pub precipitation_mm_h: f32,
122 pub temperature_c: f32,
124 pub relative_humidity: f32,
126 pub wind_speed_ms: f32,
128 pub wind_direction: Vec2,
130 pub gustiness: f32,
132 pub visibility_m: f32,
134 pub storminess: f32,
136 pub dust: f32,
138}
139
140impl Default for Weather {
141 fn default() -> Self {
142 let t = target(WeatherPreset::Clear);
143 Self {
144 cloud_cover: t.cover,
145 precipitation_mm_h: t.mm_h,
146 temperature_c: 15.0,
147 relative_humidity: t.rh,
148 wind_speed_ms: t.wind,
149 wind_direction: Vec2::X,
150 gustiness: t.gust,
151 visibility_m: t.visibility,
152 storminess: t.storm,
153 dust: t.dust,
154 }
155 }
156}
157
158impl Weather {
159 pub fn snow_fraction(&self) -> f32 {
161 ((2.0 - self.temperature_c) / 3.0).clamp(0.0, 1.0)
162 }
163}
164
165#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)]
168#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
169pub struct WeatherChanged {
170 pub from: WeatherPreset,
172 pub to: WeatherPreset,
174 pub forced: bool,
176}
177
178#[derive(Clone, Copy, Debug, PartialEq)]
180struct Blend {
181 cover: f32,
182 mm_h: f32,
183 rh: f32,
184 wind: f32,
185 direction: Vec2,
186 gust: f32,
187 visibility: f32,
188 storm: f32,
189 dust: f32,
190 delta_t: f32,
191 snow_cap: f32,
193}
194
195impl Blend {
196 fn of(preset: WeatherPreset, climate: &Climate, direction: Vec2, forced: bool) -> Self {
197 let t = target(preset);
198 Self {
199 cover: t.cover,
200 mm_h: t.mm_h,
201 rh: t.rh,
202 wind: t.wind * climate.mean_wind_ms / 4.0,
203 direction,
204 gust: t.gust,
205 visibility: t.visibility,
206 storm: t.storm,
207 dust: t.dust,
208 delta_t: t.delta_t,
209 snow_cap: if forced && preset.is_snow() { 1.0 } else { 0.0 },
210 }
211 }
212
213 fn lerp(self, to: Self, s: f32) -> Self {
214 let l = |a: f32, b: f32| a + (b - a) * s;
215 Self {
216 cover: l(self.cover, to.cover),
217 mm_h: l(self.mm_h, to.mm_h),
218 rh: l(self.rh, to.rh),
219 wind: l(self.wind, to.wind),
220 direction: self
221 .direction
222 .lerp(to.direction, s)
223 .normalize_or(to.direction),
224 gust: l(self.gust, to.gust),
225 visibility: l(self.visibility, to.visibility),
226 storm: l(self.storm, to.storm),
227 dust: l(self.dust, to.dust),
228 delta_t: l(self.delta_t, to.delta_t),
229 snow_cap: l(self.snow_cap, to.snow_cap),
230 }
231 }
232}
233
234#[derive(Clone, Copy, Debug)]
235struct Transition {
236 from: Blend,
237 to: Blend,
238 preset: WeatherPreset,
239 start_h: f64,
240 duration_h: f64,
241}
242
243#[derive(Clone, Copy, Debug)]
244enum Request {
245 Force(WeatherPreset, Duration),
246 Release(Duration),
247}
248
249#[derive(Resource, Clone, Debug, Default)]
254pub struct WeatherDirector {
255 scheduled: WeatherPreset,
256 scheduled_direction: Option<Vec2>,
257 forced: Option<WeatherPreset>,
258 pending: Option<Request>,
259 transition: Option<Transition>,
260 current: Option<Blend>,
261 last_hour: Option<i64>,
262}
263
264impl WeatherDirector {
265 pub fn force(&mut self, preset: WeatherPreset, transition: Duration) {
269 self.forced = Some(preset);
270 self.pending = Some(Request::Force(preset, transition));
271 }
272
273 pub fn release(&mut self, transition: Duration) {
276 self.forced = None;
277 self.pending = Some(Request::Release(transition));
278 }
279
280 pub fn current(&self) -> WeatherPreset {
282 match (self.forced, self.transition) {
283 (Some(p), _) => p,
284 (None, Some(t)) => t.preset,
285 (None, None) => self.scheduled,
286 }
287 }
288
289 pub fn is_forced(&self) -> bool {
291 self.forced.is_some()
292 }
293
294 fn start(&mut self, preset: WeatherPreset, climate: &Climate, now_h: f64, duration_h: f64) {
295 let direction = self.scheduled_direction.unwrap_or(climate.prevailing_wind);
296 let to = Blend::of(preset, climate, direction, self.forced.is_some());
297 let from = self.current.unwrap_or(to);
298 self.transition = Some(Transition {
299 from,
300 to,
301 preset,
302 start_h: now_h,
303 duration_h,
304 });
305 }
306}
307
308pub(crate) fn mix64(mut z: u64) -> u64 {
310 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
311 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
312 z ^ (z >> 31)
313}
314
315pub(crate) fn hash64(seed: u64, index: u64) -> u64 {
317 mix64(seed ^ index.wrapping_mul(0x9E37_79B9_7F4A_7C15))
318}
319
320fn unit24(bits: u64) -> f32 {
321 (bits & 0xFF_FFFF) as f32 / 16_777_216.0
322}
323
324#[derive(Clone, Copy, Debug, PartialEq)]
326pub(crate) struct Step {
327 pub preset: WeatherPreset,
328 pub transition_min: f32,
330 pub wind_turn_deg: f32,
332}
333
334const STAY: f32 = 0.85;
336const FOG_BASE_WEIGHT: f32 = 0.3;
339const DUST_BASE_WEIGHT: f32 = 0.2;
342
343pub(crate) fn schedule(climate: &Climate, southern: bool, prev: WeatherPreset, index: i64) -> Step {
346 let date = CivilDate::new(2000, 1, 1).add_days(index.div_euclid(24));
347 let hour = index.rem_euclid(24) as f32;
348 let doy = f32::from(date.day_of_year()) + hour / 24.0;
349 let temperature = climate.base_temperature_c(doy, hour, southern);
350 let summer = Season::of_month(date.month, southern) == Season::Summer;
351
352 let gated = |p: WeatherPreset| match p {
353 WeatherPreset::Snow | WeatherPreset::Blizzard => temperature > 1.0,
354 WeatherPreset::Rain | WeatherPreset::Drizzle | WeatherPreset::Thunderstorm => {
355 temperature < -1.0
356 }
357 WeatherPreset::DustStorm => climate.dust_share <= 0.0,
358 _ => false,
359 };
360 let weight = |p: WeatherPreset| -> f32 {
361 if gated(p) {
362 return 0.0;
363 }
364 let mut w = 1.0;
365 if p.is_wet() {
366 w *= climate.wet_hour_probability / 0.35;
367 }
368 match p {
369 WeatherPreset::Thunderstorm => {
370 w *= climate.storm_share / 0.05 * if summer { 1.0 } else { 0.3 };
371 }
372 WeatherPreset::Fog => {
373 w *= FOG_BASE_WEIGHT * climate.fog_share / 0.08;
374 if (4.0..=9.0).contains(&hour) && target(prev).rh > 0.85 {
375 w *= 3.0;
376 }
377 }
378 WeatherPreset::DustStorm => w *= DUST_BASE_WEIGHT * climate.dust_share / 0.08,
379 _ => {}
380 }
381 w.max(0.0)
382 };
383
384 let h = hash64(climate.seed, index as u64);
385 let u_stay = unit24(h >> 40);
386 let u_pick = unit24(h >> 16);
387 let u_duration = (h & 0xFFFF) as f32 / 65_536.0;
388 let u_turn = unit24(mix64(h) >> 40);
389
390 let prev_gated = gated(prev);
391 let preset = if !prev_gated && u_stay < STAY {
392 prev
393 } else {
394 let neighbours = prev.neighbours();
395 let total: f32 = neighbours.iter().map(|&p| weight(p)).sum();
396 if total <= 0.0 {
397 if prev_gated {
398 WeatherPreset::Overcast
399 } else {
400 prev
401 }
402 } else {
403 let mut pick = u_pick * total;
404 let mut chosen = prev;
405 for &p in neighbours {
406 let w = weight(p);
407 if w <= 0.0 {
408 continue;
409 }
410 chosen = p;
411 if pick < w {
412 break;
413 }
414 pick -= w;
415 }
416 chosen
417 }
418 };
419 Step {
420 preset,
421 transition_min: 20.0 + 40.0 * u_duration,
422 wind_turn_deg: (u_turn * 2.0 - 1.0) * 30.0,
423 }
424}
425
426fn rotate(v: Vec2, degrees: f32) -> Vec2 {
427 let (s, c) = degrees.to_radians().sin_cos();
428 Vec2::new(v.x * c - v.y * s, v.x * s + v.y * c)
429}
430
431const MAX_STEPS_PER_FRAME: i64 = 24 * 366;
434
435pub(crate) fn run_weather(
436 clock: Res<WorldClock>,
437 location: Res<GeoLocation>,
438 climate: Res<Climate>,
439 mut director: ResMut<WeatherDirector>,
440 mut weather: ResMut<Weather>,
441 mut changed: MessageWriter<WeatherChanged>,
442) {
443 let now_h = clock.hours_since_2000();
444 let index = now_h.floor() as i64;
445 let southern = location.is_southern();
446 let prevailing = climate.prevailing_wind.normalize_or(Vec2::X);
447
448 match director.last_hour {
449 None => {
450 director.scheduled = WeatherPreset::Clear;
452 director.scheduled_direction = Some(prevailing);
453 if director.forced.is_none() {
454 director.start(WeatherPreset::Clear, &climate, now_h, 0.0);
455 }
456 director.last_hour = Some(index);
457 }
458 Some(last) if index < last || index - last > MAX_STEPS_PER_FRAME => {
459 director.last_hour = Some(index);
460 }
461 Some(last) => {
462 for hour in last + 1..=index {
463 let step = schedule(&climate, southern, director.scheduled, hour);
464 if step.preset == director.scheduled {
465 continue;
466 }
467 let from = director.current();
468 director.scheduled = step.preset;
469 director.scheduled_direction = Some(rotate(prevailing, step.wind_turn_deg));
470 if director.forced.is_none() {
471 director.start(
472 step.preset,
473 &climate,
474 hour as f64,
475 f64::from(step.transition_min) / 60.0,
476 );
477 changed.write(WeatherChanged {
478 from,
479 to: step.preset,
480 forced: false,
481 });
482 }
483 }
484 director.last_hour = Some(index);
485 }
486 }
487
488 if let Some(request) = director.pending.take() {
489 let from = director.current_target_preset();
490 let (to, duration, forced) = match request {
491 Request::Force(preset, d) => (preset, d, true),
492 Request::Release(d) => (director.scheduled, d, false),
493 };
494 director.start(to, &climate, now_h, duration.as_secs_f64() / 3600.0);
495 if from != to {
496 changed.write(WeatherChanged { from, to, forced });
497 }
498 }
499
500 let Some(t) = director.transition else {
501 return;
502 };
503 let s = if t.duration_h <= 0.0 || now_h < t.start_h {
504 1.0
505 } else {
506 let x = ((now_h - t.start_h) / t.duration_h).clamp(0.0, 1.0) as f32;
507 x * x * (3.0 - 2.0 * x)
508 };
509 let blend = t.from.lerp(t.to, s);
510 director.current = Some(blend);
511
512 let doy = f32::from(clock.day_of_year()) + clock.hour as f32 / 24.0;
513 let mut temperature =
514 climate.base_temperature_c(doy, clock.hour as f32, southern) + blend.delta_t;
515 temperature += (temperature.min(-2.0) - temperature) * blend.snow_cap;
516 *weather = Weather {
517 cloud_cover: blend.cover,
518 precipitation_mm_h: blend.mm_h,
519 temperature_c: temperature,
520 relative_humidity: blend.rh,
521 wind_speed_ms: blend.wind,
522 wind_direction: blend.direction,
523 gustiness: blend.gust,
524 visibility_m: blend.visibility,
525 storminess: blend.storm,
526 dust: blend.dust,
527 };
528}
529
530impl WeatherDirector {
531 fn current_target_preset(&self) -> WeatherPreset {
533 self.transition.map_or(self.scheduled, |t| t.preset)
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn the_hash_matches_splitmix64() {
543 assert_eq!(mix64(0x9E37_79B9_7F4A_7C15), 0xE220_A839_7B1D_CDAF);
544 assert_eq!(hash64(0, 1), 0xE220_A839_7B1D_CDAF);
545 assert_eq!(hash64(42, 0), 0xA759_EA27_D472_7622);
546 assert_eq!(hash64(42, 1), 0xBDD7_3226_2FEB_6E95);
547 assert_eq!(hash64(42, 8759), 0xAA5E_C7C5_CF51_7539);
548 }
549
550 #[test]
551 fn snow_fraction_ramps_from_minus_one_to_two() {
552 let at = |t| {
553 Weather {
554 temperature_c: t,
555 ..Weather::default()
556 }
557 .snow_fraction()
558 };
559 assert_eq!(at(-10.0), 1.0);
560 assert_eq!(at(-1.0), 1.0);
561 assert!((at(0.5) - 0.5).abs() < 1e-6);
562 assert_eq!(at(2.0), 0.0);
563 assert_eq!(at(30.0), 0.0);
564 }
565
566 fn year_from(climate: &Climate, start: CivilDate, hours: i64) -> Vec<WeatherPreset> {
567 let first = start.days_since_2000() * 24;
568 let mut preset = WeatherPreset::Clear;
569 (0..hours)
570 .map(|h| {
571 preset = schedule(climate, false, preset, first + h).preset;
572 preset
573 })
574 .collect()
575 }
576
577 #[test]
584 fn the_weather_schedule_is_frozen() {
585 let climate = Climate::temperate().with_seed(42);
586 let presets = year_from(&climate, CivilDate::new(2026, 1, 1), 8760);
587 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
588 for p in &presets {
589 let byte = WeatherPreset::ALL.iter().position(|q| q == p).unwrap_or(0) as u64;
590 hash ^= byte;
591 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
592 }
593 assert_eq!(
594 hash, SCHEDULE_GOLDEN,
595 "weather schedule hash is {hash:#018x}"
596 );
597 }
598
599 const SCHEDULE_GOLDEN: u64 = 0x025f6c432fa413f4;
600
601 fn hour_share(climate: &Climate, preset: WeatherPreset) -> f32 {
602 let mut hits = 0usize;
603 for seed in 1..=8u64 {
604 let year = year_from(
605 &climate.clone().with_seed(seed),
606 CivilDate::new(2026, 1, 1),
607 8760,
608 );
609 hits += year.iter().filter(|p| **p == preset).count();
610 }
611 hits as f32 / (8.0 * 8760.0)
612 }
613
614 #[test]
617 fn preset_climates_spend_about_their_share_in_fog_dust_and_storms() {
618 let within = |x: f32, share: f32| (x - share).abs() < 0.035;
619 let t = Climate::temperate();
620 assert!(within(hour_share(&t, WeatherPreset::Fog), t.fog_share));
621 assert!(within(
622 hour_share(&t, WeatherPreset::Thunderstorm),
623 t.storm_share
624 ));
625 let d = Climate::desert();
626 assert!(within(
627 hour_share(&d, WeatherPreset::DustStorm),
628 d.dust_share
629 ));
630 let tr = Climate::tropical();
631 assert!(within(
632 hour_share(&tr, WeatherPreset::Thunderstorm),
633 tr.storm_share
634 ));
635 }
636
637 #[test]
638 fn runs_last_about_as_long_as_the_stay_probability_implies() {
639 let climate = Climate::temperate().with_seed(7);
640 let presets = year_from(&climate, CivilDate::new(2026, 1, 1), 8760 * 4);
641 let changes = presets.windows(2).filter(|w| w[0] != w[1]).count();
642 let mean_run = presets.len() as f32 / (changes + 1) as f32;
643 assert!((5.0..=7.5).contains(&mean_run), "mean run {mean_run} h");
645 }
646
647 #[test]
648 fn gates_keep_snow_out_of_summer_and_rain_out_of_deep_winter() {
649 let presets = year_from(
650 &Climate::temperate().with_seed(3),
651 CivilDate::new(2026, 7, 1),
652 24 * 31,
653 );
654 assert!(!presets.iter().any(|p| p.is_snow()));
655 let tundra = Climate::tundra().with_seed(3);
656 let presets = year_from(&tundra, CivilDate::new(2026, 1, 10), 24 * 20);
657 assert!(!presets.iter().any(|p| matches!(
658 p,
659 WeatherPreset::Rain | WeatherPreset::Drizzle | WeatherPreset::Thunderstorm
660 )));
661 assert!(presets.iter().any(|p| p.is_snow()));
662 }
663
664 #[test]
665 fn a_climate_without_dust_never_raises_a_dust_storm() {
666 let presets = year_from(
667 &Climate::temperate().with_seed(9),
668 CivilDate::new(2026, 1, 1),
669 8760,
670 );
671 assert!(!presets.contains(&WeatherPreset::DustStorm));
672 let desert = year_from(
673 &Climate::desert().with_seed(9),
674 CivilDate::new(2026, 1, 1),
675 8760,
676 );
677 assert!(desert.contains(&WeatherPreset::DustStorm));
678 }
679}