Skip to main content

proof_engine/worldgen/
biomes.rs

1//! Biome classification from climate data.
2//!
3//! Maps (temperature, precipitation, elevation) to biome types using a
4//! Whittaker-style classification scheme.
5
6use super::Grid2D;
7
8/// Biome types.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum Biome {
11    Ocean,
12    DeepOcean,
13    CoralReef,
14    Beach,
15    TropicalRainforest,
16    TropicalDryForest,
17    Savanna,
18    Desert,
19    HotSteppe,
20    Mediterranean,
21    TemperateForest,
22    TemperateRainforest,
23    Grassland,
24    ColdSteppe,
25    BorealForest,   // Taiga
26    Tundra,
27    IceCap,
28    Marsh,
29    Mangrove,
30    Alpine,
31    VolcanicWaste,
32    River,
33    Lake,
34}
35
36impl Biome {
37    /// Base color (RGBA) for map rendering.
38    pub fn color(self) -> [f32; 4] {
39        match self {
40            Self::Ocean            => [0.1, 0.2, 0.6, 1.0],
41            Self::DeepOcean        => [0.05, 0.1, 0.4, 1.0],
42            Self::CoralReef        => [0.2, 0.5, 0.6, 1.0],
43            Self::Beach            => [0.9, 0.85, 0.6, 1.0],
44            Self::TropicalRainforest => [0.05, 0.5, 0.1, 1.0],
45            Self::TropicalDryForest  => [0.3, 0.5, 0.15, 1.0],
46            Self::Savanna          => [0.6, 0.65, 0.2, 1.0],
47            Self::Desert           => [0.85, 0.75, 0.45, 1.0],
48            Self::HotSteppe        => [0.7, 0.6, 0.3, 1.0],
49            Self::Mediterranean    => [0.5, 0.6, 0.25, 1.0],
50            Self::TemperateForest  => [0.15, 0.55, 0.15, 1.0],
51            Self::TemperateRainforest => [0.1, 0.45, 0.2, 1.0],
52            Self::Grassland        => [0.45, 0.6, 0.2, 1.0],
53            Self::ColdSteppe       => [0.55, 0.55, 0.35, 1.0],
54            Self::BorealForest     => [0.15, 0.35, 0.2, 1.0],
55            Self::Tundra           => [0.6, 0.65, 0.6, 1.0],
56            Self::IceCap           => [0.9, 0.92, 0.95, 1.0],
57            Self::Marsh            => [0.3, 0.4, 0.25, 1.0],
58            Self::Mangrove         => [0.2, 0.4, 0.15, 1.0],
59            Self::Alpine           => [0.5, 0.5, 0.5, 1.0],
60            Self::VolcanicWaste    => [0.3, 0.2, 0.2, 1.0],
61            Self::River            => [0.15, 0.3, 0.7, 1.0],
62            Self::Lake             => [0.2, 0.35, 0.65, 1.0],
63        }
64    }
65
66    /// Whether this biome is water.
67    pub fn is_water(self) -> bool {
68        matches!(self, Self::Ocean | Self::DeepOcean | Self::CoralReef | Self::River | Self::Lake)
69    }
70
71    /// Habitability score for settlement placement (0-1).
72    pub fn habitability(self) -> f32 {
73        match self {
74            Self::TemperateForest | Self::Grassland | Self::Mediterranean => 0.9,
75            Self::TemperateRainforest | Self::Savanna | Self::TropicalDryForest => 0.7,
76            Self::TropicalRainforest | Self::Marsh => 0.5,
77            Self::BorealForest | Self::ColdSteppe | Self::HotSteppe => 0.4,
78            Self::Beach | Self::Mangrove => 0.3,
79            Self::Desert | Self::Tundra | Self::Alpine => 0.15,
80            Self::IceCap | Self::VolcanicWaste => 0.02,
81            _ => 0.0, // water
82        }
83    }
84
85    /// Resource richness.
86    pub fn resources(self) -> f32 {
87        match self {
88            Self::TropicalRainforest | Self::TemperateRainforest => 0.9,
89            Self::TemperateForest | Self::BorealForest => 0.7,
90            Self::Grassland | Self::Savanna => 0.5,
91            Self::Mediterranean | Self::Marsh => 0.6,
92            Self::Desert | Self::Tundra => 0.1,
93            Self::Alpine | Self::VolcanicWaste => 0.2,
94            _ => 0.3,
95        }
96    }
97}
98
99/// The biome assignment map.
100#[derive(Debug, Clone)]
101pub struct BiomeMap {
102    pub width: usize,
103    pub height: usize,
104    pub biomes: Vec<Biome>,
105}
106
107impl BiomeMap {
108    pub fn biome_at(&self, x: usize, y: usize) -> Biome {
109        self.biomes[y * self.width + x]
110    }
111
112    /// Count cells of each biome type.
113    pub fn distribution(&self) -> std::collections::HashMap<Biome, usize> {
114        let mut map = std::collections::HashMap::new();
115        for &b in &self.biomes {
116            *map.entry(b).or_insert(0) += 1;
117        }
118        map
119    }
120
121    /// Find all cells of a given biome.
122    pub fn find_biome(&self, target: Biome) -> Vec<(usize, usize)> {
123        let mut cells = Vec::new();
124        for y in 0..self.height {
125            for x in 0..self.width {
126                if self.biome_at(x, y) == target {
127                    cells.push((x, y));
128                }
129            }
130        }
131        cells
132    }
133}
134
135/// Classify biomes from heightmap, temperature, and precipitation.
136pub fn classify(
137    heightmap: &Grid2D,
138    temperature: &Grid2D,
139    precipitation: &Grid2D,
140    sea_level: f32,
141) -> BiomeMap {
142    let w = heightmap.width;
143    let h = heightmap.height;
144    let mut biomes = Vec::with_capacity(w * h);
145
146    for y in 0..h {
147        for x in 0..w {
148            let elev = heightmap.get(x, y);
149            let temp = temperature.get(x, y);
150            let precip = precipitation.get(x, y);
151
152            let biome = if elev < sea_level - 0.15 {
153                Biome::DeepOcean
154            } else if elev < sea_level - 0.02 {
155                if temp > 22.0 && precip > 0.3 {
156                    Biome::CoralReef
157                } else {
158                    Biome::Ocean
159                }
160            } else if elev < sea_level + 0.02 {
161                if temp > 20.0 && precip > 0.5 {
162                    Biome::Mangrove
163                } else {
164                    Biome::Beach
165                }
166            } else if elev > 0.85 {
167                if temp < -5.0 {
168                    Biome::IceCap
169                } else {
170                    Biome::Alpine
171                }
172            } else {
173                // Whittaker classification
174                whittaker_classify(temp, precip)
175            };
176
177            biomes.push(biome);
178        }
179    }
180
181    BiomeMap { width: w, height: h, biomes }
182}
183
184/// Whittaker biome diagram classification.
185fn whittaker_classify(temp: f32, precip: f32) -> Biome {
186    if temp < -10.0 {
187        if precip > 0.3 { Biome::IceCap } else { Biome::Tundra }
188    } else if temp < 0.0 {
189        if precip > 0.4 { Biome::BorealForest } else { Biome::Tundra }
190    } else if temp < 5.0 {
191        if precip > 0.5 { Biome::BorealForest } else { Biome::ColdSteppe }
192    } else if temp < 15.0 {
193        if precip > 0.7 { Biome::TemperateRainforest }
194        else if precip > 0.4 { Biome::TemperateForest }
195        else if precip > 0.2 { Biome::Grassland }
196        else { Biome::ColdSteppe }
197    } else if temp < 22.0 {
198        if precip > 0.6 { Biome::TemperateForest }
199        else if precip > 0.3 { Biome::Mediterranean }
200        else if precip > 0.15 { Biome::HotSteppe }
201        else { Biome::Desert }
202    } else {
203        // Hot
204        if precip > 0.7 { Biome::TropicalRainforest }
205        else if precip > 0.4 { Biome::TropicalDryForest }
206        else if precip > 0.2 { Biome::Savanna }
207        else if precip > 0.1 { Biome::HotSteppe }
208        else { Biome::Desert }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_whittaker_hot_wet() {
218        assert_eq!(whittaker_classify(28.0, 0.8), Biome::TropicalRainforest);
219    }
220
221    #[test]
222    fn test_whittaker_cold_dry() {
223        assert_eq!(whittaker_classify(-15.0, 0.1), Biome::Tundra);
224    }
225
226    #[test]
227    fn test_whittaker_hot_dry() {
228        assert_eq!(whittaker_classify(25.0, 0.05), Biome::Desert);
229    }
230
231    #[test]
232    fn test_classify_ocean() {
233        let hm = Grid2D::filled(4, 4, 0.1); // all ocean
234        let temp = Grid2D::filled(4, 4, 20.0);
235        let precip = Grid2D::filled(4, 4, 0.5);
236        let bm = classify(&hm, &temp, &precip, 0.4);
237        assert!(bm.biome_at(0, 0).is_water());
238    }
239
240    #[test]
241    fn test_biome_habitability() {
242        assert!(Biome::TemperateForest.habitability() > Biome::Desert.habitability());
243        assert!(Biome::Ocean.habitability() == 0.0);
244    }
245}