1use std::collections::HashMap;
8use super::{Vec3, lerp, smoothstep, fbm_2d, value_noise_2d};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum Season {
14 Spring,
15 Summer,
16 Autumn,
17 Winter,
18}
19
20impl Season {
21 pub fn from_day(day: f32, northern: bool) -> Self {
23 let d = day.rem_euclid(365.0);
24 let raw = if d < 80.0 { Season::Winter }
25 else if d < 172.0 { Season::Spring }
26 else if d < 264.0 { Season::Summer }
27 else if d < 355.0 { Season::Autumn }
28 else { Season::Winter };
29 if northern { raw } else { raw.opposite() }
30 }
31
32 pub fn opposite(self) -> Self {
33 match self {
34 Self::Spring => Self::Autumn,
35 Self::Summer => Self::Winter,
36 Self::Autumn => Self::Spring,
37 Self::Winter => Self::Summer,
38 }
39 }
40
41 pub fn name(self) -> &'static str {
42 match self {
43 Self::Spring => "Spring",
44 Self::Summer => "Summer",
45 Self::Autumn => "Autumn",
46 Self::Winter => "Winter",
47 }
48 }
49
50 pub fn solar_factor(self) -> f32 {
52 match self {
53 Self::Summer => 1.0,
54 Self::Winter => -1.0,
55 Self::Spring => 0.2,
56 Self::Autumn => -0.2,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum BiomeType {
65 TropicalRainforest,
66 TropicalSavanna,
67 HotDesert,
68 ColdDesert,
69 MediterraneanShrubland,
70 TemperateGrassland,
71 TemperateDeciduousForest,
72 TemperateConiferousForest,
73 BorealForest, Tundra,
75 PolarIce,
76 MontaneAlpine,
77 CoastalMarine,
78 Wetland,
79 UrbanHeatIsland,
80}
81
82impl BiomeType {
83 pub fn mean_annual_temp_c(self) -> f32 {
85 match self {
86 Self::TropicalRainforest => 26.0,
87 Self::TropicalSavanna => 24.0,
88 Self::HotDesert => 25.0,
89 Self::ColdDesert => 8.0,
90 Self::MediterraneanShrubland => 15.0,
91 Self::TemperateGrassland => 8.0,
92 Self::TemperateDeciduousForest => 10.0,
93 Self::TemperateConiferousForest => 7.0,
94 Self::BorealForest => -3.0,
95 Self::Tundra => -10.0,
96 Self::PolarIce => -25.0,
97 Self::MontaneAlpine => -2.0,
98 Self::CoastalMarine => 14.0,
99 Self::Wetland => 12.0,
100 Self::UrbanHeatIsland => 13.0,
101 }
102 }
103
104 pub fn annual_temp_range_c(self) -> f32 {
106 match self {
107 Self::TropicalRainforest => 2.0,
108 Self::TropicalSavanna => 8.0,
109 Self::HotDesert => 20.0,
110 Self::ColdDesert => 35.0,
111 Self::MediterraneanShrubland => 15.0,
112 Self::TemperateGrassland => 30.0,
113 Self::TemperateDeciduousForest => 25.0,
114 Self::TemperateConiferousForest => 28.0,
115 Self::BorealForest => 40.0,
116 Self::Tundra => 35.0,
117 Self::PolarIce => 30.0,
118 Self::MontaneAlpine => 22.0,
119 Self::CoastalMarine => 10.0,
120 Self::Wetland => 20.0,
121 Self::UrbanHeatIsland => 22.0,
122 }
123 }
124
125 pub fn mean_annual_precip_mm(self) -> f32 {
127 match self {
128 Self::TropicalRainforest => 2500.0,
129 Self::TropicalSavanna => 900.0,
130 Self::HotDesert => 50.0,
131 Self::ColdDesert => 150.0,
132 Self::MediterraneanShrubland => 500.0,
133 Self::TemperateGrassland => 400.0,
134 Self::TemperateDeciduousForest => 750.0,
135 Self::TemperateConiferousForest => 900.0,
136 Self::BorealForest => 500.0,
137 Self::Tundra => 200.0,
138 Self::PolarIce => 100.0,
139 Self::MontaneAlpine => 800.0,
140 Self::CoastalMarine => 700.0,
141 Self::Wetland => 1000.0,
142 Self::UrbanHeatIsland => 600.0,
143 }
144 }
145
146 pub fn diurnal_range_c(self) -> f32 {
148 match self {
149 Self::TropicalRainforest => 5.0,
150 Self::HotDesert => 25.0,
151 Self::CoastalMarine => 6.0,
152 Self::PolarIce => 8.0,
153 _ => 12.0,
154 }
155 }
156}
157
158#[derive(Debug, Clone)]
162pub struct BiomeZone {
163 pub biome: BiomeType,
164 pub latitude_range: [f32; 2],
166 pub altitude_range: [f32; 2],
168 pub koppen: &'static str,
170 pub prevailing_wind_dir: f32,
172 pub mean_wind_speed: f32,
174}
175
176impl BiomeZone {
177 pub fn temperate_deciduous() -> Self {
178 Self {
179 biome: BiomeType::TemperateDeciduousForest,
180 latitude_range: [40.0, 60.0],
181 altitude_range: [0.0, 1500.0],
182 koppen: "Cfb",
183 prevailing_wind_dir: 0.0, mean_wind_speed: 6.0,
185 }
186 }
187
188 pub fn tropical_rainforest() -> Self {
189 Self {
190 biome: BiomeType::TropicalRainforest,
191 latitude_range: [-10.0, 10.0],
192 altitude_range: [0.0, 1000.0],
193 koppen: "Af",
194 prevailing_wind_dir: std::f32::consts::PI * 0.25,
195 mean_wind_speed: 3.0,
196 }
197 }
198
199 pub fn hot_desert() -> Self {
200 Self {
201 biome: BiomeType::HotDesert,
202 latitude_range: [20.0, 35.0],
203 altitude_range: [0.0, 800.0],
204 koppen: "BWh",
205 prevailing_wind_dir: std::f32::consts::PI * 0.75,
206 mean_wind_speed: 8.0,
207 }
208 }
209
210 pub fn boreal() -> Self {
211 Self {
212 biome: BiomeType::BorealForest,
213 latitude_range: [50.0, 70.0],
214 altitude_range: [0.0, 800.0],
215 koppen: "Dfc",
216 prevailing_wind_dir: 0.0,
217 mean_wind_speed: 5.0,
218 }
219 }
220
221 pub fn contains_lat(&self, lat: f32) -> bool {
223 lat >= self.latitude_range[0] && lat <= self.latitude_range[1]
224 }
225
226 pub fn contains_alt(&self, alt: f32) -> bool {
228 alt >= self.altitude_range[0] && alt <= self.altitude_range[1]
229 }
230}
231
232#[derive(Debug, Clone, Copy)]
236pub struct TemperatureRange {
237 pub min_c: f32,
238 pub max_c: f32,
239}
240
241impl TemperatureRange {
242 pub fn new(min_c: f32, max_c: f32) -> Self { Self { min_c, max_c } }
243 pub fn mean(&self) -> f32 { (self.min_c + self.max_c) * 0.5 }
244 pub fn amplitude(&self) -> f32 { self.max_c - self.min_c }
245 pub fn contains(&self, t: f32) -> bool { t >= self.min_c && t <= self.max_c }
246}
247
248#[derive(Debug, Clone)]
252pub struct SeasonalCycle {
253 pub biome: BiomeType,
254 pub monthly_temp_c: [f32; 12],
256 pub monthly_precip_mm: [f32; 12],
258 pub monthly_humidity: [f32; 12],
260 pub monthly_sunshine_h: [f32; 12],
262}
263
264impl SeasonalCycle {
265 pub fn from_biome(biome: BiomeType, latitude: f32) -> Self {
267 let base_temp = biome.mean_annual_temp_c();
268 let amplitude = biome.annual_temp_range_c() * 0.5;
269 let northern = latitude >= 0.0;
270 let mut monthly_temp_c = [0.0_f32; 12];
272 let mut monthly_precip_mm = [0.0_f32; 12];
273 let mut monthly_humidity = [0.0_f32; 12];
274 let mut monthly_sunshine_h = [0.0_f32; 12];
275
276 let annual_precip = biome.mean_annual_precip_mm();
277 for m in 0..12 {
278 let phase = if northern {
280 (m as f32 - 6.5) / 12.0 * 2.0 * std::f32::consts::PI
281 } else {
282 (m as f32 - 0.5) / 12.0 * 2.0 * std::f32::consts::PI
283 };
284 monthly_temp_c[m] = base_temp - amplitude * phase.cos();
285
286 let precip_phase = match biome {
288 BiomeType::MediterraneanShrubland => {
289 let p = (m as f32 - 0.5) / 12.0 * 2.0 * std::f32::consts::PI;
291 1.0 + p.cos() }
293 BiomeType::TropicalSavanna => {
294 let p = (m as f32 - 6.5) / 12.0 * 2.0 * std::f32::consts::PI;
296 1.0 + p.cos()
297 }
298 _ => 1.0, };
300 monthly_precip_mm[m] = annual_precip / 12.0 * precip_phase;
301
302 let temp_norm = (monthly_temp_c[m] - (base_temp - amplitude))
304 / (2.0 * amplitude).max(1.0);
305 monthly_humidity[m] = match biome {
306 BiomeType::TropicalRainforest => 0.85 + temp_norm * 0.1,
307 BiomeType::HotDesert | BiomeType::ColdDesert => 0.15 + (1.0 - temp_norm) * 0.15,
308 _ => 0.55 + (1.0 - temp_norm) * 0.2,
309 }.clamp(0.1, 1.0);
310
311 monthly_sunshine_h[m] = 8.0 + (monthly_temp_c[m] - base_temp) / amplitude.max(1.0) * 4.0;
313 }
314
315 Self {
316 biome,
317 monthly_temp_c,
318 monthly_precip_mm,
319 monthly_humidity,
320 monthly_sunshine_h,
321 }
322 }
323
324 pub fn temperature_at_month(&self, month_frac: f32) -> f32 {
326 let m0 = (month_frac.floor() as usize) % 12;
327 let m1 = (m0 + 1) % 12;
328 let t = month_frac - month_frac.floor();
329 lerp(self.monthly_temp_c[m0], self.monthly_temp_c[m1], t)
330 }
331
332 pub fn day_to_month_frac(day: f32) -> f32 {
334 (day / 365.0 * 12.0).rem_euclid(12.0)
335 }
336
337 pub fn humidity_at_month(&self, month_frac: f32) -> f32 {
338 let m0 = (month_frac.floor() as usize) % 12;
339 let m1 = (m0 + 1) % 12;
340 let t = month_frac - month_frac.floor();
341 lerp(self.monthly_humidity[m0], self.monthly_humidity[m1], t)
342 }
343
344 pub fn precipitation_at_month(&self, month_frac: f32) -> f32 {
345 let m0 = (month_frac.floor() as usize) % 12;
346 let m1 = (m0 + 1) % 12;
347 let t = month_frac - month_frac.floor();
348 lerp(self.monthly_precip_mm[m0], self.monthly_precip_mm[m1], t)
349 }
350}
351
352#[derive(Debug, Clone)]
356pub struct DayNightCurve {
357 pub sunrise_temp_c: f32,
359 pub max_temp_c: f32,
361 pub sunset_temp_c: f32,
363 pub min_temp_c: f32,
365 pub sunrise_h: f32,
367 pub max_temp_h: f32,
369 pub sunset_h: f32,
371 pub min_temp_h: f32,
373}
374
375impl DayNightCurve {
376 pub fn new(
377 mean_c: f32,
378 amplitude_c: f32,
379 day_length_h: f32,
380 solar_noon: f32,
381 ) -> Self {
382 let half_day = day_length_h * 0.5;
383 let sunrise_h = (solar_noon - half_day + 24.0).rem_euclid(24.0);
384 let sunset_h = (solar_noon + half_day).rem_euclid(24.0);
385 let max_temp_h = (solar_noon + 2.5).rem_euclid(24.0);
386 let min_temp_h = (sunrise_h - 1.5 + 24.0).rem_euclid(24.0);
387 Self {
388 sunrise_temp_c: mean_c - amplitude_c * 0.6,
389 max_temp_c: mean_c + amplitude_c * 0.5,
390 sunset_temp_c: mean_c - amplitude_c * 0.2,
391 min_temp_c: mean_c - amplitude_c * 0.5,
392 sunrise_h,
393 max_temp_h,
394 sunset_h,
395 min_temp_h,
396 }
397 }
398
399 pub fn temperature_at(&self, h: f32) -> f32 {
401 let h = h.rem_euclid(24.0);
403 struct Kp { h: f32, t: f32 }
405 let mut kps = [
406 Kp { h: self.min_temp_h, t: self.min_temp_c },
407 Kp { h: self.sunrise_h, t: self.sunrise_temp_c},
408 Kp { h: self.max_temp_h, t: self.max_temp_c },
409 Kp { h: self.sunset_h, t: self.sunset_temp_c },
410 ];
411 kps.sort_by(|a, b| a.h.partial_cmp(&b.h).unwrap_or(std::cmp::Ordering::Equal));
413
414 for i in 0..kps.len() {
416 let next = (i + 1) % kps.len();
417 let h0 = kps[i].h;
418 let h1 = if next == 0 { kps[next].h + 24.0 } else { kps[next].h };
419 let hh = if h < h0 { h + 24.0 } else { h };
420 if hh >= h0 && hh <= h1 {
421 let span = (h1 - h0).max(1e-4);
422 let t = (hh - h0) / span;
423 let tc = (1.0 - (t * std::f32::consts::PI).cos()) * 0.5;
425 return lerp(kps[i].t, kps[next].t, tc);
426 }
427 }
428 self.min_temp_c
429 }
430
431 pub fn day_length_from_lat(lat_deg: f32, day: f32) -> f32 {
433 let lat_rad = lat_deg.to_radians();
434 let dec = (-23.45_f32 * ((day + 10.0) / 365.0 * 2.0 * std::f32::consts::PI).cos()).to_radians();
436 let cos_ha = -(lat_rad.tan() * dec.tan());
437 if cos_ha < -1.0 { return 24.0; }
438 if cos_ha > 1.0 { return 0.0; }
439 let ha_rad = cos_ha.acos();
440 ha_rad.to_degrees() / 7.5 }
442}
443
444#[derive(Debug, Clone)]
448pub struct ClimateInterpolator {
449 pub zone_a: BiomeZone,
450 pub zone_b: BiomeZone,
451 pub cycle_a: SeasonalCycle,
452 pub cycle_b: SeasonalCycle,
453 pub blend: f32,
455 pub target_blend: f32,
457 pub transition_speed: f32,
459}
460
461impl ClimateInterpolator {
462 pub fn new(zone_a: BiomeZone, zone_b: BiomeZone, lat: f32) -> Self {
463 let cycle_a = SeasonalCycle::from_biome(zone_a.biome, lat);
464 let cycle_b = SeasonalCycle::from_biome(zone_b.biome, lat);
465 Self {
466 zone_a,
467 zone_b,
468 cycle_a,
469 cycle_b,
470 blend: 0.0,
471 target_blend: 0.0,
472 transition_speed: 1e-5,
473 }
474 }
475
476 pub fn tick(&mut self, dt: f32) {
477 let delta = self.target_blend - self.blend;
478 let step = self.transition_speed * dt;
479 if delta.abs() < step {
480 self.blend = self.target_blend;
481 } else {
482 self.blend += step * delta.signum();
483 }
484 }
485
486 pub fn temperature_at_month(&self, month_frac: f32) -> f32 {
487 let ta = self.cycle_a.temperature_at_month(month_frac);
488 let tb = self.cycle_b.temperature_at_month(month_frac);
489 lerp(ta, tb, self.blend)
490 }
491
492 pub fn humidity_at_month(&self, month_frac: f32) -> f32 {
493 let ha = self.cycle_a.humidity_at_month(month_frac);
494 let hb = self.cycle_b.humidity_at_month(month_frac);
495 lerp(ha, hb, self.blend)
496 }
497
498 pub fn prevailing_wind_speed(&self) -> f32 {
499 lerp(self.zone_a.mean_wind_speed, self.zone_b.mean_wind_speed, self.blend)
500 }
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
506pub enum WeatherPatternKind {
507 HighPressureRidge,
508 LowPressureTrough,
509 ColdFront,
510 WarmFront,
511 OccludedFront,
512 StationaryFront,
513 Anticyclone,
514 Cyclone,
515 Monsoon,
516 TradeWinds,
517 BlockingHigh,
518}
519
520#[derive(Debug, Clone)]
522pub struct WeatherPattern {
523 pub kind: WeatherPatternKind,
524 pub centre: [f32; 2],
526 pub radius: f32,
528 pub intensity: f32,
530 pub drift: [f32; 2],
532 pub lifetime: f32,
534 pub pressure_anomaly_pa: f32,
536 pub temp_anomaly_c: f32,
538 pub precip_modifier: f32,
540}
541
542impl WeatherPattern {
543 pub fn new_high_pressure(cx: f32, cz: f32) -> Self {
544 Self {
545 kind: WeatherPatternKind::HighPressureRidge,
546 centre: [cx, cz],
547 radius: 800_000.0,
548 intensity: 0.8,
549 drift: [1.0, 0.3],
550 lifetime: 7_200.0,
551 pressure_anomaly_pa: 2_500.0,
552 temp_anomaly_c: 3.0,
553 precip_modifier: 0.1,
554 }
555 }
556
557 pub fn new_low_pressure(cx: f32, cz: f32) -> Self {
558 Self {
559 kind: WeatherPatternKind::LowPressureTrough,
560 centre: [cx, cz],
561 radius: 600_000.0,
562 intensity: 0.9,
563 drift: [3.0, -0.5],
564 lifetime: 5_400.0,
565 pressure_anomaly_pa: -3_000.0,
566 temp_anomaly_c: -2.0,
567 precip_modifier: 2.5,
568 }
569 }
570
571 pub fn new_monsoon(cx: f32, cz: f32) -> Self {
572 Self {
573 kind: WeatherPatternKind::Monsoon,
574 centre: [cx, cz],
575 radius: 1_500_000.0,
576 intensity: 1.0,
577 drift: [0.5, 0.1],
578 lifetime: 86_400.0 * 90.0, pressure_anomaly_pa: -1_500.0,
580 temp_anomaly_c: 1.5,
581 precip_modifier: 5.0,
582 }
583 }
584
585 pub fn tick(&mut self, dt: f32) {
586 self.centre[0] += self.drift[0] * dt;
587 self.centre[1] += self.drift[1] * dt;
588 self.lifetime -= dt;
589 if self.lifetime < 600.0 {
591 self.intensity *= 1.0 - dt / 600.0;
592 }
593 }
594
595 pub fn temp_influence(&self, x: f32, z: f32) -> f32 {
597 let dx = x - self.centre[0];
598 let dz = z - self.centre[1];
599 let dist = (dx * dx + dz * dz).sqrt();
600 if dist >= self.radius { return 0.0; }
601 smoothstep(self.radius, 0.0, dist) * self.temp_anomaly_c * self.intensity
602 }
603
604 pub fn precip_influence(&self, x: f32, z: f32) -> f32 {
606 let dx = x - self.centre[0];
607 let dz = z - self.centre[1];
608 let dist = (dx * dx + dz * dz).sqrt();
609 if dist >= self.radius { return 1.0; }
610 let t = smoothstep(self.radius, 0.0, dist);
611 lerp(1.0, self.precip_modifier, t * self.intensity)
612 }
613
614 pub fn is_alive(&self) -> bool { self.lifetime > 0.0 && self.intensity > 1e-4 }
615}
616
617#[derive(Debug, Clone)]
621pub struct StormFront {
622 pub kind: WeatherPatternKind,
623 pub origin: [f32; 2],
625 pub direction: f32,
627 pub speed: f32,
629 pub width: f32,
631 pub length: f32,
633 pub age: f32,
635 pub max_lifetime: f32,
637 pub temp_gradient_c: f32,
639 pub leading_precip: f32,
641 pub trailing_precip: f32,
643 pub occluded: bool,
645}
646
647impl StormFront {
648 pub fn cold_front(ox: f32, oz: f32, direction: f32) -> Self {
649 Self {
650 kind: WeatherPatternKind::ColdFront,
651 origin: [ox, oz],
652 direction,
653 speed: 8.0,
654 width: 50_000.0,
655 length: 2_000_000.0,
656 age: 0.0,
657 max_lifetime: 86_400.0,
658 temp_gradient_c: -8.0,
659 leading_precip: 0.7,
660 trailing_precip: 0.1,
661 occluded: false,
662 }
663 }
664
665 pub fn warm_front(ox: f32, oz: f32, direction: f32) -> Self {
666 Self {
667 kind: WeatherPatternKind::WarmFront,
668 origin: [ox, oz],
669 direction,
670 speed: 4.0,
671 width: 120_000.0,
672 length: 1_500_000.0,
673 age: 0.0,
674 max_lifetime: 72_000.0,
675 temp_gradient_c: 6.0,
676 leading_precip: 0.4,
677 trailing_precip: 0.6,
678 occluded: false,
679 }
680 }
681
682 pub fn tick(&mut self, dt: f32) {
683 self.age += dt;
684 self.origin[0] += self.direction.cos() * self.speed * dt;
685 self.origin[1] += self.direction.sin() * self.speed * dt;
686 if self.age > self.max_lifetime * 0.7 && !self.occluded {
688 self.occluded = true;
689 self.speed *= 0.3;
690 self.kind = WeatherPatternKind::OccludedFront;
691 }
692 }
693
694 pub fn signed_distance(&self, x: f32, z: f32) -> f32 {
697 let dx = x - self.origin[0];
698 let dz = z - self.origin[1];
699 -(dx * self.direction.cos() + dz * self.direction.sin())
701 }
702
703 pub fn temp_at(&self, x: f32, z: f32) -> f32 {
705 let sd = self.signed_distance(x, z);
706 if sd.abs() > self.width { return 0.0; }
707 let t = (sd / self.width).clamp(-1.0, 1.0);
708 self.temp_gradient_c * t * (1.0 - self.age / self.max_lifetime).max(0.0)
709 }
710
711 pub fn precip_at(&self, x: f32, z: f32) -> f32 {
713 let sd = self.signed_distance(x, z);
714 if sd.abs() > self.width { return 0.0; }
715 let t = ((sd / self.width) + 1.0) * 0.5; lerp(self.trailing_precip, self.leading_precip, t)
717 }
718
719 pub fn is_alive(&self) -> bool { self.age < self.max_lifetime }
720}
721
722#[derive(Debug, Clone)]
726pub struct HeatWave {
727 pub duration_s: f32,
729 pub peak_anomaly_c: f32,
731 pub current_anomaly_c: f32,
733 pub centre: [f32; 2],
735 pub radius: f32,
737 intensity: f32,
739 elapsed_s: f32,
740}
741
742impl HeatWave {
743 pub fn new(cx: f32, cz: f32, peak_anomaly_c: f32, duration_s: f32) -> Self {
744 Self {
745 duration_s,
746 peak_anomaly_c,
747 current_anomaly_c: 0.0,
748 centre: [cx, cz],
749 radius: 500_000.0,
750 intensity: 0.0,
751 elapsed_s: 0.0,
752 }
753 }
754
755 pub fn tick(&mut self, dt: f32) {
756 self.elapsed_s += dt;
757 let progress = (self.elapsed_s / self.duration_s).clamp(0.0, 1.0);
758 self.intensity = smoothstep(0.0, 0.3, progress) * smoothstep(1.0, 0.7, progress);
760 self.current_anomaly_c = self.peak_anomaly_c * self.intensity;
761 }
762
763 pub fn temp_anomaly(&self, x: f32, z: f32) -> f32 {
765 let dx = x - self.centre[0];
766 let dz = z - self.centre[1];
767 let dist = (dx * dx + dz * dz).sqrt();
768 if dist >= self.radius { return 0.0; }
769 smoothstep(self.radius, 0.0, dist) * self.current_anomaly_c
770 }
771
772 pub fn is_active(&self) -> bool { self.elapsed_s < self.duration_s }
773 pub fn progress(&self) -> f32 { (self.elapsed_s / self.duration_s).clamp(0.0, 1.0) }
774}
775
776#[derive(Debug, Clone)]
780pub struct ColdSnap {
781 pub duration_s: f32,
782 pub peak_anomaly_c: f32, pub current_anomaly_c: f32,
784 pub centre: [f32; 2],
785 pub radius: f32,
786 pub wind_chill_factor: f32,
788 elapsed_s: f32,
789 intensity: f32,
790}
791
792impl ColdSnap {
793 pub fn new(cx: f32, cz: f32, peak_anomaly_c: f32, duration_s: f32) -> Self {
794 assert!(peak_anomaly_c <= 0.0, "Cold snap anomaly must be negative or zero");
795 Self {
796 duration_s,
797 peak_anomaly_c,
798 current_anomaly_c: 0.0,
799 centre: [cx, cz],
800 radius: 600_000.0,
801 wind_chill_factor: 1.5,
802 elapsed_s: 0.0,
803 intensity: 0.0,
804 }
805 }
806
807 pub fn tick(&mut self, dt: f32) {
808 self.elapsed_s += dt;
809 let progress = (self.elapsed_s / self.duration_s).clamp(0.0, 1.0);
810 self.intensity = smoothstep(0.0, 0.25, progress) * smoothstep(1.0, 0.75, progress);
811 self.current_anomaly_c = self.peak_anomaly_c * self.intensity;
812 }
813
814 pub fn temp_anomaly(&self, x: f32, z: f32) -> f32 {
815 let dx = x - self.centre[0];
816 let dz = z - self.centre[1];
817 let dist = (dx * dx + dz * dz).sqrt();
818 if dist >= self.radius { return 0.0; }
819 smoothstep(self.radius, 0.0, dist) * self.current_anomaly_c
820 }
821
822 pub fn apparent_temp(&self, x: f32, z: f32, base_temp_c: f32, wind_speed_ms: f32) -> f32 {
824 let anomaly = self.temp_anomaly(x, z);
825 let actual = base_temp_c + anomaly;
826 if wind_speed_ms < 1.4 || actual >= 10.0 { return actual; }
828 let v = wind_speed_ms;
829 13.12 + 0.6215 * actual - 11.37 * v.powf(0.16) + 0.3965 * actual * v.powf(0.16)
830 }
831
832 pub fn is_active(&self) -> bool { self.elapsed_s < self.duration_s }
833}
834
835#[derive(Debug, Clone)]
839pub struct WeatherTransition {
840 pub from_pattern: WeatherPatternKind,
841 pub to_pattern: WeatherPatternKind,
842 pub progress: f32,
844 pub duration_s: f32,
846 elapsed_s: f32,
847}
848
849impl WeatherTransition {
850 pub fn new(from: WeatherPatternKind, to: WeatherPatternKind, duration_s: f32) -> Self {
851 Self {
852 from_pattern: from,
853 to_pattern: to,
854 progress: 0.0,
855 duration_s,
856 elapsed_s: 0.0,
857 }
858 }
859
860 pub fn tick(&mut self, dt: f32) {
861 self.elapsed_s = (self.elapsed_s + dt).min(self.duration_s);
862 self.progress = smoothstep(0.0, self.duration_s, self.elapsed_s);
863 }
864
865 pub fn is_complete(&self) -> bool { self.elapsed_s >= self.duration_s }
866}
867
868#[derive(Debug, Clone, Copy)]
872pub struct PrecipitationChance {
873 pub probability: f32, pub expected_intensity: f32, pub duration_hours: f32,
876}
877
878impl PrecipitationChance {
879 pub fn none() -> Self {
880 Self { probability: 0.0, expected_intensity: 0.0, duration_hours: 0.0 }
881 }
882 pub fn from_humidity(humidity: f32, temp_c: f32) -> Self {
883 let prob = smoothstep(0.6, 0.95, humidity) * (1.0 - smoothstep(35.0, 45.0, temp_c));
884 let intens = smoothstep(0.7, 1.0, humidity);
885 Self {
886 probability: prob,
887 expected_intensity: intens,
888 duration_hours: prob * 3.0,
889 }
890 }
891}
892
893#[derive(Debug, Clone)]
897pub struct WindPattern {
898 pub direction: f32,
900 pub speed: f32,
902 pub direction_variability: f32,
904 pub speed_variability: f32,
906 pub gust_factor: f32,
908}
909
910impl WindPattern {
911 pub fn westerlies() -> Self {
912 Self { direction: 0.0, speed: 7.0, direction_variability: 0.4, speed_variability: 2.5, gust_factor: 1.8 }
913 }
914 pub fn trade_winds() -> Self {
915 Self { direction: std::f32::consts::PI * 1.25, speed: 6.0, direction_variability: 0.2, speed_variability: 1.5, gust_factor: 1.4 }
916 }
917 pub fn doldrums() -> Self {
918 Self { direction: 0.0, speed: 0.5, direction_variability: 2.0, speed_variability: 1.0, gust_factor: 2.5 }
919 }
920 pub fn polar_easterlies() -> Self {
921 Self { direction: std::f32::consts::PI, speed: 9.0, direction_variability: 0.5, speed_variability: 4.0, gust_factor: 2.2 }
922 }
923
924 pub fn sample(&self, n: f32) -> Vec3 {
926 let dir = self.direction + (n * 2.0 - 1.0) * self.direction_variability;
927 let spd = (self.speed + (n - 0.5) * self.speed_variability * 2.0).max(0.0);
928 Vec3::new(dir.cos() * spd, 0.0, dir.sin() * spd)
929 }
930
931 pub fn gust(&self, n: f32) -> Vec3 {
933 let base = self.sample(n);
934 base.scale(if n > 0.85 { self.gust_factor } else { 1.0 })
935 }
936}
937
938#[derive(Debug, Clone)]
941pub enum ClimateEvent {
942 HeatWaveStarted { anomaly_c: f32, duration_s: f32 },
943 ColdSnapStarted { anomaly_c: f32, duration_s: f32 },
944 StormFrontApproaching { kind: WeatherPatternKind, eta_s: f32 },
945 SeasonChanged { from: Season, to: Season },
946 DustStorm { origin: [f32; 2], intensity: f32 },
947 Blizzard { snow_rate_mm_h: f32, wind_speed_ms: f32 },
948}
949
950#[derive(Debug, Clone)]
954pub struct ClimateCell {
955 pub lat: f32,
956 pub lon: f32,
957 pub biome: BiomeType,
958 pub current_temp_c: f32,
959 pub current_humidity: f32,
960 pub current_pressure_pa: f32,
961 pub wind: Vec3,
962 pub cloud_cover: f32,
963 pub snow_cover_fraction: f32,
964}
965
966impl ClimateCell {
967 pub fn new(lat: f32, lon: f32, biome: BiomeType) -> Self {
968 Self {
969 lat,
970 lon,
971 biome,
972 current_temp_c: biome.mean_annual_temp_c(),
973 current_humidity: 0.5,
974 current_pressure_pa: 101_325.0,
975 wind: Vec3::ZERO,
976 cloud_cover: 0.3,
977 snow_cover_fraction: 0.0,
978 }
979 }
980}
981
982#[derive(Debug, Clone)]
985pub struct ClimateConfig {
986 pub latitude: f32,
988 pub longitude: f32,
990 pub time_scale: f32,
992 pub event_check_interval: f32,
994 pub heat_wave_probability: f32,
996 pub cold_snap_probability: f32,
998 pub storm_probability: f32,
1000 pub max_patterns: usize,
1002}
1003
1004impl Default for ClimateConfig {
1005 fn default() -> Self {
1006 Self {
1007 latitude: 50.0,
1008 longitude: 0.0,
1009 time_scale: 1.0,
1010 event_check_interval: 3_600.0,
1011 heat_wave_probability: 0.02,
1012 cold_snap_probability: 0.03,
1013 storm_probability: 0.05,
1014 max_patterns: 8,
1015 }
1016 }
1017}
1018
1019#[derive(Debug, Clone)]
1023pub struct ClimateSystem {
1024 pub config: ClimateConfig,
1025 pub primary_biome: BiomeType,
1026 pub seasonal_cycle: SeasonalCycle,
1027 pub day_night_curve: DayNightCurve,
1028 pub interpolator: Option<ClimateInterpolator>,
1029 pub weather_patterns: Vec<WeatherPattern>,
1030 pub storm_fronts: Vec<StormFront>,
1031 pub heat_waves: Vec<HeatWave>,
1032 pub cold_snaps: Vec<ColdSnap>,
1033 pub active_transitions: Vec<WeatherTransition>,
1034 pub pending_events: Vec<ClimateEvent>,
1035 pub wind_pattern: WindPattern,
1036 pub cells: Vec<ClimateCell>,
1038 pub grid_w: usize,
1039 pub grid_d: usize,
1040 current_temp_c: f32,
1042 current_humidity: f32,
1044 noise_t: f32,
1046 event_check_accum: f32,
1048 cached_season: Season,
1050 prev_season: Season,
1052}
1053
1054impl ClimateSystem {
1055 pub fn new(latitude: f32) -> Self {
1056 Self::with_config(ClimateConfig { latitude, ..ClimateConfig::default() })
1057 }
1058
1059 pub fn with_config(config: ClimateConfig) -> Self {
1060 let biome = Self::biome_for_latitude(config.latitude);
1061 let cycle = SeasonalCycle::from_biome(biome, config.latitude);
1062 let mean_temp = biome.mean_annual_temp_c();
1063 let amp = biome.diurnal_range_c();
1064 let day_len = DayNightCurve::day_length_from_lat(config.latitude, 172.0); let curve = DayNightCurve::new(mean_temp, amp, day_len, 12.0);
1066
1067 let wind = if config.latitude.abs() > 60.0 {
1068 WindPattern::polar_easterlies()
1069 } else if config.latitude.abs() < 20.0 {
1070 WindPattern::trade_winds()
1071 } else {
1072 WindPattern::westerlies()
1073 };
1074
1075 let grid_w = 8usize;
1077 let grid_d = 8usize;
1078 let mut cells = Vec::with_capacity(grid_w * grid_d);
1079 for gz in 0..grid_d {
1080 for gx in 0..grid_w {
1081 let lat_offset = (gz as f32 - grid_d as f32 * 0.5) * 0.5;
1082 let lon_offset = (gx as f32 - grid_w as f32 * 0.5) * 0.5;
1083 let cell_lat = config.latitude + lat_offset;
1084 let cell_biome = Self::biome_for_latitude(cell_lat);
1085 cells.push(ClimateCell::new(cell_lat, config.longitude + lon_offset, cell_biome));
1086 }
1087 }
1088
1089 let northern = config.latitude >= 0.0;
1090 let season = Season::from_day(0.0, northern);
1091
1092 Self {
1093 config,
1094 primary_biome: biome,
1095 seasonal_cycle: cycle,
1096 day_night_curve: curve,
1097 interpolator: None,
1098 weather_patterns: Vec::new(),
1099 storm_fronts: Vec::new(),
1100 heat_waves: Vec::new(),
1101 cold_snaps: Vec::new(),
1102 active_transitions: Vec::new(),
1103 pending_events: Vec::new(),
1104 wind_pattern: wind,
1105 cells,
1106 grid_w,
1107 grid_d,
1108 current_temp_c: biome.mean_annual_temp_c(),
1109 current_humidity: 0.55,
1110 noise_t: 0.0,
1111 event_check_accum: 0.0,
1112 cached_season: season,
1113 prev_season: season,
1114 }
1115 }
1116
1117 pub fn tick(&mut self, dt: f32, day_of_year: f32, time_of_day: f32) {
1120 self.noise_t += dt * 0.001;
1121 let scaled_dt = dt * self.config.time_scale;
1122
1123 let northern = self.config.latitude >= 0.0;
1125 self.prev_season = self.cached_season;
1126 self.cached_season = Season::from_day(day_of_year, northern);
1127 if self.cached_season != self.prev_season {
1128 self.pending_events.push(ClimateEvent::SeasonChanged {
1129 from: self.prev_season,
1130 to: self.cached_season,
1131 });
1132 }
1133
1134 let day_len = DayNightCurve::day_length_from_lat(self.config.latitude, day_of_year);
1136 let month = SeasonalCycle::day_to_month_frac(day_of_year);
1137 let mean_tc = self.seasonal_cycle.temperature_at_month(month);
1138 let amp = self.primary_biome.diurnal_range_c();
1139 self.day_night_curve = DayNightCurve::new(mean_tc, amp, day_len, 12.0);
1140
1141 let mut temp_c = self.day_night_curve.temperature_at(time_of_day);
1143
1144 let mut humidity = self.seasonal_cycle.humidity_at_month(month);
1146
1147 for pat in &self.weather_patterns {
1149 temp_c += pat.temp_influence(0.0, 0.0);
1150 humidity *= pat.precip_influence(0.0, 0.0).clamp(0.1, 3.0);
1151 }
1152
1153 for front in &self.storm_fronts {
1155 temp_c += front.temp_at(0.0, 0.0);
1156 humidity = (humidity + front.precip_at(0.0, 0.0) * 0.3).clamp(0.0, 1.0);
1157 }
1158
1159 for hw in &self.heat_waves {
1161 temp_c += hw.temp_anomaly(0.0, 0.0);
1162 }
1163
1164 for cs in &self.cold_snaps {
1166 temp_c += cs.temp_anomaly(0.0, 0.0);
1167 }
1168
1169 let noise_temp = (value_noise_2d(self.noise_t, 0.0) * 2.0 - 1.0) * 0.5;
1171 let noise_hum = (value_noise_2d(0.0, self.noise_t + 3.5) * 2.0 - 1.0) * 0.02;
1172 temp_c += noise_temp;
1173 humidity = (humidity + noise_hum).clamp(0.0, 1.0);
1174
1175 self.current_temp_c = temp_c;
1176 self.current_humidity = humidity;
1177
1178 for pat in &mut self.weather_patterns { pat.tick(scaled_dt); }
1180 self.weather_patterns.retain(|p| p.is_alive());
1181
1182 for front in &mut self.storm_fronts { front.tick(scaled_dt); }
1183 self.storm_fronts.retain(|f| f.is_alive());
1184
1185 for hw in &mut self.heat_waves { hw.tick(scaled_dt); }
1186 self.heat_waves.retain(|h| h.is_active());
1187
1188 for cs in &mut self.cold_snaps { cs.tick(scaled_dt); }
1189 self.cold_snaps.retain(|c| c.is_active());
1190
1191 for tr in &mut self.active_transitions { tr.tick(scaled_dt); }
1192 self.active_transitions.retain(|t| !t.is_complete());
1193
1194 if let Some(ref mut interp) = self.interpolator { interp.tick(scaled_dt); }
1195
1196 self.event_check_accum += scaled_dt;
1198 if self.event_check_accum >= self.config.event_check_interval {
1199 self.event_check_accum = 0.0;
1200 self.check_extreme_events();
1201 }
1202
1203 self.update_cells(day_of_year, time_of_day, month);
1205 }
1206
1207 fn check_extreme_events(&mut self) {
1208 let rng = value_noise_2d(self.noise_t, self.noise_t * 1.3);
1209
1210 if rng < self.config.heat_wave_probability
1212 && self.heat_waves.is_empty()
1213 && self.current_temp_c > 15.0
1214 {
1215 let anomaly = 8.0 + rng * 10.0;
1216 let dur = 86_400.0 * (3.0 + rng * 7.0);
1217 let cx = (value_noise_2d(self.noise_t * 2.0, 0.0) * 2.0 - 1.0) * 200_000.0;
1218 let cz = (value_noise_2d(0.0, self.noise_t * 2.0 + 1.0) * 2.0 - 1.0) * 200_000.0;
1219 self.heat_waves.push(HeatWave::new(cx, cz, anomaly, dur));
1220 self.pending_events.push(ClimateEvent::HeatWaveStarted { anomaly_c: anomaly, duration_s: dur });
1221 }
1222
1223 let rng2 = value_noise_2d(self.noise_t * 1.7, self.noise_t * 0.9 + 5.0);
1225 if rng2 < self.config.cold_snap_probability
1226 && self.cold_snaps.is_empty()
1227 && self.current_temp_c < 10.0
1228 {
1229 let anomaly = -(6.0 + rng2 * 15.0);
1230 let dur = 86_400.0 * (2.0 + rng2 * 5.0);
1231 let cx = (value_noise_2d(self.noise_t * 0.8, 0.0) * 2.0 - 1.0) * 300_000.0;
1232 let cz = (value_noise_2d(0.0, self.noise_t * 0.8 + 2.3) * 2.0 - 1.0) * 300_000.0;
1233 self.cold_snaps.push(ColdSnap::new(cx, cz, anomaly, dur));
1234 self.pending_events.push(ClimateEvent::ColdSnapStarted { anomaly_c: anomaly, duration_s: dur });
1235 }
1236
1237 let rng3 = value_noise_2d(self.noise_t * 2.3, self.noise_t * 1.5 + 8.0);
1239 if rng3 < self.config.storm_probability
1240 && self.storm_fronts.len() < 3
1241 && self.weather_patterns.len() < self.config.max_patterns
1242 {
1243 let dir = rng3 * std::f32::consts::TAU;
1244 let kind_rng = value_noise_2d(self.noise_t + 11.0, 0.3);
1245 let front = if kind_rng < 0.5 {
1246 StormFront::cold_front(-500_000.0, -500_000.0, dir)
1247 } else {
1248 StormFront::warm_front(-300_000.0, -300_000.0, dir)
1249 };
1250 let kind = front.kind;
1251 self.storm_fronts.push(front);
1252 self.pending_events.push(ClimateEvent::StormFrontApproaching {
1253 kind,
1254 eta_s: 500_000.0 / 8.0,
1255 });
1256 }
1257
1258 let rng4 = value_noise_2d(self.noise_t * 3.1, 0.77);
1260 if self.current_temp_c < -2.0
1261 && self.current_humidity > 0.7
1262 && rng4 < 0.04
1263 {
1264 self.pending_events.push(ClimateEvent::Blizzard {
1265 snow_rate_mm_h: 10.0 + rng4 * 40.0,
1266 wind_speed_ms: 8.0 + rng4 * 20.0,
1267 });
1268 if self.weather_patterns.len() < self.config.max_patterns {
1270 let cx = (value_noise_2d(self.noise_t, 4.4) * 2.0 - 1.0) * 400_000.0;
1271 let cz = (value_noise_2d(4.4, self.noise_t) * 2.0 - 1.0) * 400_000.0;
1272 self.weather_patterns.push(WeatherPattern::new_low_pressure(cx, cz));
1273 }
1274 }
1275 }
1276
1277 fn update_cells(&mut self, day_of_year: f32, time_of_day: f32, month: f32) {
1278 for (i, cell) in self.cells.iter_mut().enumerate() {
1279 let n = value_noise_2d(i as f32 * 0.17 + self.noise_t, i as f32 * 0.11);
1280 let cycle = SeasonalCycle::from_biome(cell.biome, cell.lat);
1281 let amp = cell.biome.diurnal_range_c();
1282 let day_l = DayNightCurve::day_length_from_lat(cell.lat, day_of_year);
1283 let curve = DayNightCurve::new(cycle.temperature_at_month(month), amp, day_l, 12.0);
1284 cell.current_temp_c = curve.temperature_at(time_of_day) + (n * 2.0 - 1.0) * 0.5;
1285 cell.current_humidity = (cycle.humidity_at_month(month) + (n - 0.5) * 0.05).clamp(0.0, 1.0);
1286 cell.cloud_cover = smoothstep(0.5, 0.85, cell.current_humidity);
1287 cell.wind = self.wind_pattern.sample(n);
1288 if cell.current_temp_c < 0.0 {
1290 cell.snow_cover_fraction = (cell.snow_cover_fraction + 0.0001).min(1.0);
1291 } else {
1292 cell.snow_cover_fraction = (cell.snow_cover_fraction - 0.0002).max(0.0);
1293 }
1294 }
1295 }
1296
1297 pub fn surface_temperature(&self, time_of_day: f32, day_of_year: f32) -> f32 {
1301 let month = SeasonalCycle::day_to_month_frac(day_of_year);
1302 let mean_tc = self.seasonal_cycle.temperature_at_month(month);
1303 let amp = self.primary_biome.diurnal_range_c();
1304 let day_len = DayNightCurve::day_length_from_lat(self.config.latitude, day_of_year);
1305 let curve = DayNightCurve::new(mean_tc, amp, day_len, 12.0);
1306 let base = curve.temperature_at(time_of_day);
1307 let hw_anom: f32 = self.heat_waves.iter().map(|h| h.current_anomaly_c).sum();
1309 let cs_anom: f32 = self.cold_snaps.iter().map(|c| c.current_anomaly_c).sum();
1310 base + hw_anom + cs_anom
1311 }
1312
1313 pub fn surface_temperature_k(&self, time_of_day: f32, day_of_year: f32) -> f32 {
1315 self.surface_temperature(time_of_day, day_of_year) + 273.15
1316 }
1317
1318 pub fn current_season(&self, day_of_year: f32) -> Season {
1319 Season::from_day(day_of_year, self.config.latitude >= 0.0)
1320 }
1321
1322 pub fn current_humidity(&self) -> f32 { self.current_humidity }
1324
1325 pub fn current_temperature_c(&self) -> f32 { self.current_temp_c }
1327
1328 pub fn drain_events(&mut self) -> Vec<ClimateEvent> {
1330 let mut out = Vec::new();
1331 std::mem::swap(&mut self.pending_events, &mut out);
1332 out
1333 }
1334
1335 pub fn trigger_heat_wave(&mut self, peak_c: f32, duration_s: f32) {
1337 self.heat_waves.push(HeatWave::new(0.0, 0.0, peak_c, duration_s));
1338 self.pending_events.push(ClimateEvent::HeatWaveStarted { anomaly_c: peak_c, duration_s });
1339 }
1340
1341 pub fn trigger_cold_snap(&mut self, peak_c: f32, duration_s: f32) {
1343 debug_assert!(peak_c <= 0.0);
1344 self.cold_snaps.push(ColdSnap::new(0.0, 0.0, peak_c, duration_s));
1345 self.pending_events.push(ClimateEvent::ColdSnapStarted { anomaly_c: peak_c, duration_s });
1346 }
1347
1348 pub fn add_pattern(&mut self, pat: WeatherPattern) {
1350 if self.weather_patterns.len() < self.config.max_patterns {
1351 self.weather_patterns.push(pat);
1352 }
1353 }
1354
1355 pub fn add_storm_front(&mut self, front: StormFront) {
1357 self.storm_fronts.push(front);
1358 }
1359
1360 pub fn summary(&self) -> ClimateSummary {
1362 ClimateSummary {
1363 biome: self.primary_biome,
1364 season: self.cached_season,
1365 temp_c: self.current_temp_c,
1366 humidity: self.current_humidity,
1367 active_heat_waves: self.heat_waves.len(),
1368 active_cold_snaps: self.cold_snaps.len(),
1369 active_storm_fronts: self.storm_fronts.len(),
1370 active_patterns: self.weather_patterns.len(),
1371 }
1372 }
1373
1374 fn biome_for_latitude(lat: f32) -> BiomeType {
1376 let abs_lat = lat.abs();
1377 if abs_lat < 10.0 { BiomeType::TropicalRainforest }
1378 else if abs_lat < 20.0 { BiomeType::TropicalSavanna }
1379 else if abs_lat < 30.0 { BiomeType::HotDesert }
1380 else if abs_lat < 40.0 { BiomeType::MediterraneanShrubland }
1381 else if abs_lat < 55.0 { BiomeType::TemperateDeciduousForest }
1382 else if abs_lat < 65.0 { BiomeType::BorealForest }
1383 else if abs_lat < 75.0 { BiomeType::Tundra }
1384 else { BiomeType::PolarIce }
1385 }
1386}
1387
1388impl Default for ClimateSystem {
1389 fn default() -> Self { Self::new(51.5) }
1390}
1391
1392#[derive(Debug, Clone, Copy)]
1395pub struct ClimateSummary {
1396 pub biome: BiomeType,
1397 pub season: Season,
1398 pub temp_c: f32,
1399 pub humidity: f32,
1400 pub active_heat_waves: usize,
1401 pub active_cold_snaps: usize,
1402 pub active_storm_fronts: usize,
1403 pub active_patterns: usize,
1404}
1405
1406impl ClimateSummary {
1407 pub fn describe(&self) -> &'static str {
1408 if self.active_heat_waves > 0 { return "Heat wave"; }
1409 if self.active_cold_snaps > 0 { return "Cold snap"; }
1410 if self.active_storm_fronts > 0 { return "Stormy"; }
1411 match (self.season, self.temp_c as i32) {
1412 (Season::Summer, t) if t > 28 => "Hot and sunny",
1413 (Season::Winter, t) if t < 0 => "Cold and clear",
1414 (_, _) if self.humidity > 0.8 => "Humid and overcast",
1415 (Season::Spring, _) => "Mild spring",
1416 (Season::Autumn, _) => "Crisp autumn",
1417 _ => "Temperate",
1418 }
1419 }
1420}