use std::collections::VecDeque;
use crate::world::types::astronomy::AstronomyOutput;
use crate::world::types::climate::*;
use crate::world::types::geology::GeologyOutput;
use crate::world::types::world::WorldDefinition;
const MAX_LAND_KM: f32 = 5.0;
const LAPSE_C_PER_KM: f32 = 6.5;
pub fn compile_climate(
def: &WorldDefinition,
astro: &AstronomyOutput,
geo: &GeologyOutput,
) -> ClimateOutput {
let (w, h) = (geo.width, geo.height);
let flux = (def.astronomy.star.luminosity_solar
/ def.astronomy.orbit.semi_major_axis_au.powi(2)) as f32;
let t_mean = -18.0 + 33.0 * flux.max(1e-4).powf(0.25);
let (imin, imax) = astro.insolation_bands.iter().fold((f32::MAX, f32::MIN), |(lo, hi), b| {
(lo.min(b.annual_mean as f32), hi.max(b.annual_mean as f32))
});
let ispan = (imax - imin).max(1e-6);
let dist_ocean = distance_to_ocean(geo, w, h);
let max_dist = dist_ocean.iter().copied().fold(0u16, u16::max).max(1) as f32;
let mut temperature_c = vec![0.0_f32; w * h];
let mut precipitation_mm = vec![0.0_f32; w * h];
let mut biome = vec![Biome::Ocean; w * h];
for y in 0..h {
let lat = 90.0 - (y as f32 + 0.5) / h as f32 * 180.0;
let insol = interp_insolation(astro, lat);
let lat_factor = ((insol - imin) / ispan).clamp(0.0, 1.0);
let t_lat = t_mean + 11.0 - 51.0 * (1.0 - lat_factor);
let zonal_p = zonal_precip(lat);
let wind_west = matches!(lat.abs() as u32, 30..=60);
let prograde = def.astronomy.planet.rotation_direction != "retrograde";
let wind_dx: i32 = if wind_west == prograde { 1 } else { -1 };
for x in 0..w {
let i = y * w + x;
let elev = geo.heightmap[i];
if elev <= geo.sea_level {
biome[i] = Biome::Ocean;
temperature_c[i] = t_lat; precipitation_mm[i] = zonal_p;
continue;
}
let elev_km = (elev - geo.sea_level) / (1.0 - geo.sea_level).max(1e-6) * MAX_LAND_KM;
let t = t_lat - elev_km * LAPSE_C_PER_KM;
let cont = 1.0 - 0.6 * (dist_ocean[i] as f32 / max_dist);
let ux = (x as i32 - wind_dx).clamp(0, w as i32 - 1) as usize;
let upwind = geo.heightmap[y * w + ux];
let rise = elev - upwind; let oro = (1.0 + rise * 6.0).clamp(0.4, 1.8);
let p = (zonal_p * cont * oro).max(0.0);
temperature_c[i] = t;
precipitation_mm[i] = p;
biome[i] = classify(t, p);
}
}
let zones = aggregate_zones(&biome, &temperature_c, &precipitation_mm);
let (mut tsum, mut psum, mut land) = (0.0_f64, 0.0_f64, 0u32);
for i in 0..w * h {
if biome[i] != Biome::Ocean {
tsum += temperature_c[i] as f64;
psum += precipitation_mm[i] as f64;
land += 1;
}
}
let land_f = land.max(1) as f64;
ClimateOutput {
width: w,
height: h,
mean_land_temp_c: (tsum / land_f) as f32,
mean_land_precip_mm: (psum / land_f) as f32,
zones,
winds: wind_bands(def),
biome,
temperature_c,
precipitation_mm,
}
}
fn interp_insolation(astro: &AstronomyOutput, lat: f32) -> f32 {
let bands = &astro.insolation_bands;
if bands.is_empty() {
return 0.5;
}
let (mut prev_lc, mut prev_am) = (bands[0].lat_center_deg as f32, bands[0].annual_mean as f32);
for b in bands {
let (lc, am) = (b.lat_center_deg as f32, b.annual_mean as f32);
if lc >= lat {
if (lc - prev_lc).abs() < 1e-6 {
return am;
}
let t = (lat - prev_lc) / (lc - prev_lc);
return prev_am + t * (am - prev_am);
}
prev_lc = lc;
prev_am = am;
}
prev_am
}
fn zonal_precip(lat: f32) -> f32 {
let a = lat.abs();
let g = |center: f32, sigma: f32, amp: f32| {
let d = (a - center) / sigma;
amp * (-0.5 * d * d).exp()
};
150.0 + g(0.0, 11.0, 2000.0) + g(55.0, 13.0, 900.0) - g(30.0, 10.0, 250.0)
}
fn classify(t: f32, p: f32) -> Biome {
if t < -7.0 {
Biome::IceCap
} else if t < 2.0 {
Biome::Tundra
} else if p < 200.0 {
if t > 18.0 { Biome::HotDesert } else { Biome::ColdDesert }
} else if p < 500.0 {
if t > 20.0 { Biome::Savanna } else { Biome::TemperateGrassland }
} else if t > 23.0 && p > 2000.0 {
Biome::TropicalRainforest
} else if t > 20.0 {
Biome::TropicalSeasonal
} else if t > 8.0 {
if p < 800.0 { Biome::Mediterranean } else { Biome::TemperateForest }
} else {
Biome::Taiga
}
}
fn distance_to_ocean(geo: &GeologyOutput, w: usize, h: usize) -> Vec<u16> {
let mut dist = vec![u16::MAX; w * h];
let mut q: VecDeque<usize> = VecDeque::new();
for i in 0..w * h {
if geo.heightmap[i] <= geo.sea_level {
dist[i] = 0;
q.push_back(i);
}
}
while let Some(i) = q.pop_front() {
let (x, y) = (i % w, i / w);
let d = dist[i];
let step = |nx: usize, ny: usize, dist: &mut Vec<u16>, q: &mut VecDeque<usize>| {
let ni = ny * w + nx;
if dist[ni] == u16::MAX {
dist[ni] = d.saturating_add(1);
q.push_back(ni);
}
};
if x > 0 {
step(x - 1, y, &mut dist, &mut q);
}
if x + 1 < w {
step(x + 1, y, &mut dist, &mut q);
}
if y > 0 {
step(x, y - 1, &mut dist, &mut q);
}
if y + 1 < h {
step(x, y + 1, &mut dist, &mut q);
}
}
for d in dist.iter_mut() {
if *d == u16::MAX {
*d = (w + h) as u16;
}
}
dist
}
fn aggregate_zones(biome: &[Biome], temp: &[f32], precip: &[f32]) -> Vec<ClimateZone> {
use std::collections::HashMap;
let mut acc: HashMap<&'static str, (usize, f32, f32, f32, f32)> = HashMap::new();
let mut land = 0usize;
for i in 0..biome.len() {
if biome[i] == Biome::Ocean {
continue;
}
land += 1;
let e = acc.entry(biome[i].as_str()).or_insert((0, f32::MAX, f32::MIN, f32::MAX, f32::MIN));
e.0 += 1;
e.1 = e.1.min(temp[i]);
e.2 = e.2.max(temp[i]);
e.3 = e.3.min(precip[i]);
e.4 = e.4.max(precip[i]);
}
let land = land.max(1) as f32;
let mut zones: Vec<ClimateZone> = acc
.into_iter()
.map(|(biome, (n, tmin, tmax, pmin, pmax))| ClimateZone {
biome: biome.to_string(),
area_pct: n as f32 / land * 100.0,
temp_min_c: tmin,
temp_max_c: tmax,
precip_min_mm: pmin,
precip_max_mm: pmax,
})
.collect();
zones.sort_by(|a, b| b.area_pct.partial_cmp(&a.area_pct).unwrap());
zones
}
fn wind_bands(def: &WorldDefinition) -> Vec<WindBand> {
let prograde = def.astronomy.planet.rotation_direction != "retrograde";
let east = if prograde { "easterly" } else { "westerly" };
let west = if prograde { "westerly" } else { "easterly" };
vec![
WindBand { lat_min: 0.0, lat_max: 30.0, name: "trade winds".into(), direction: east.into() },
WindBand { lat_min: 30.0, lat_max: 60.0, name: "westerlies".into(), direction: west.into() },
WindBand { lat_min: 60.0, lat_max: 90.0, name: "polar easterlies".into(), direction: east.into() },
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::world::compile::{compile_astronomy, compile_geology};
use crate::world::types::world::{GeneratedGeology, GeologyDef, WorldDefinition};
fn earth_like() -> WorldDefinition {
let body = r#"{
name: "T"
seed: 0x5151
astronomy: {
star: { luminosity_solar: 1.0 }
planet: { mass_earth: 1.0, radius_earth: 1.0, axial_tilt_deg: 23.4, day_length_hours: 24.0 }
orbit: { semi_major_axis_au: 1.0 }
calendar: { months: 12, month_length_days: 30 }
}
}"#;
let mut def = WorldDefinition::from_hjson(body).unwrap();
def.geology = Some(GeologyDef {
generated: Some(GeneratedGeology {
plates: 7,
continents: 4,
mountain_orogeny: "active".into(),
sea_level: 0.4,
}),
dem: None,
});
def
}
fn compile(def: &WorldDefinition) -> ClimateOutput {
let astro = compile_astronomy(&def.astronomy);
let geo = compile_geology(def);
compile_climate(def, &astro, &geo)
}
#[test]
fn deterministic() {
let def = earth_like();
assert_eq!(compile(&def), compile(&def));
}
#[test]
fn earth_like_produces_sane_varied_climate() {
let c = compile(&earth_like());
assert!(c.mean_land_temp_c > -25.0 && c.mean_land_temp_c < 35.0, "got {}", c.mean_land_temp_c);
assert!(c.mean_land_precip_mm > 100.0, "got {}", c.mean_land_precip_mm);
assert!(c.zones.len() >= 3, "got zones {:?}", c.zones.iter().map(|z| z.biome.clone()).collect::<Vec<_>>());
let total: f32 = c.zones.iter().map(|z| z.area_pct).sum();
assert!((total - 100.0).abs() < 0.5, "land area sums to {total}");
}
#[test]
fn poles_are_cold_equator_is_warm() {
let def = earth_like();
let astro = compile_astronomy(&def.astronomy);
let geo = compile_geology(&def);
let c = compile_climate(&def, &astro, &geo);
let w = c.width;
let pole_row: f32 = (0..w).map(|x| c.temperature_c[x]).sum::<f32>() / w as f32;
let eq_y = c.height / 2;
let eq_row: f32 = (0..w).map(|x| c.temperature_c[eq_y * w + x]).sum::<f32>() / w as f32;
assert!(eq_row > pole_row + 20.0, "equator {eq_row} vs pole {pole_row}");
}
#[test]
fn brighter_star_warms_the_world() {
let mut hot = earth_like();
hot.astronomy.star.luminosity_solar = 1.6;
let cold = compile(&earth_like());
let warm = compile(&hot);
assert!(warm.mean_land_temp_c > cold.mean_land_temp_c + 3.0);
}
#[test]
fn retrograde_flips_trade_winds() {
let mut def = earth_like();
def.astronomy.planet.rotation_direction = "retrograde".into();
let c = compile(&def);
let trades = c.winds.iter().find(|w| w.name == "trade winds").unwrap();
assert_eq!(trades.direction, "westerly");
}
}