diff --git a/reikhelm-core/examples/world_png.rs b/reikhelm-core/examples/world_png.rs new file mode 100644 index 0000000..506a21c --- /dev/null +++ b/reikhelm-core/examples/world_png.rs @@ -0,0 +1,237 @@ +//! Renders a generated [`World`] to a PNG — a *viewer* for eyeballing the +//! generator, deliberately kept out of the pure library (the core stores no +//! color; presentation lives here, exactly like `reikhelm-viz` owns the dungeon +//! palette). +//! +//! The PNG encoder is hand-rolled with **no dependencies**: 8-bit truecolor, +//! filter 0, and a zlib stream of uncompressed ("stored") DEFLATE blocks. That's +//! all std, so the same code path works headless or in a future WASM/server +//! target without pulling an image crate. +//! +//! Usage: +//! ```text +//! cargo run --release --example world_png -p reikhelm-core [seed] [pixel_scale] +//! ``` +//! Writes `world.png` in the current directory. + +use std::env; +use std::fs::File; +use std::io::{self, Write}; + +use reikhelm_core::world::biome::Biome; +use reikhelm_core::world::{generate, World, WorldConfig}; +use reikhelm_core::geometry::Point; + +fn main() -> io::Result<()> { + let mut args = env::args().skip(1); + let seed: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(2026); + let scale: u32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(4).max(1); + + let cfg = WorldConfig::default(); + let world = generate(cfg, seed).expect("default WorldConfig is valid"); + + let pixels = render(&world, scale); + let (pw, ph) = (world.width * scale, world.height * scale); + let mut file = File::create("world.png")?; + write_png(&mut file, pw, ph, &pixels)?; + + println!( + "wrote world.png ({}x{} px) — seed {seed}, {:.0}% land, wind {:?}", + pw, + ph, + world.land_fraction() * 100.0, + cfg.wind + ); + Ok(()) +} + +/// Renders the world into a row-major RGB buffer, upscaled by `scale` so each +/// world cell becomes a `scale × scale` block of pixels. +fn render(world: &World, scale: u32) -> Vec { + let (w, h) = (world.width, world.height); + let (pw, ph) = (w * scale, h * scale); + let mut buf = vec![0u8; (pw as usize) * (ph as usize) * 3]; + + for cy in 0..h as i32 { + for cx in 0..w as i32 { + let p = Point::new(cx, cy); + let biome = world.biome.get(p).copied().unwrap_or(Biome::DeepOcean); + let [r, g, b] = shade(world, p, palette(biome)); + + // Splat the cell color across its scale×scale pixel block. + for sy in 0..scale { + let py = cy as u32 * scale + sy; + let row = (py as usize) * (pw as usize) * 3; + for sx in 0..scale { + let px = cx as u32 * scale + sx; + let i = row + (px as usize) * 3; + buf[i] = r; + buf[i + 1] = g; + buf[i + 2] = b; + } + } + } + } + buf +} + +/// The base color for each biome (relief/depth shading is applied separately). +fn palette(b: Biome) -> [u8; 3] { + match b { + Biome::DeepOcean => [28, 52, 94], + Biome::ShallowOcean => [54, 94, 140], + Biome::Lake => [58, 108, 158], + Biome::River => [72, 122, 178], + Biome::Beach => [208, 196, 150], + Biome::Snow => [240, 244, 248], + Biome::Tundra => [168, 172, 158], + Biome::Taiga => [76, 110, 92], + Biome::Bare => [128, 120, 110], + Biome::Grassland => [132, 168, 92], + Biome::Shrubland => [162, 160, 104], + Biome::TemperateForest => [78, 132, 76], + Biome::TemperateRainforest => [54, 110, 78], + Biome::Desert => [214, 198, 134], + Biome::Savanna => [186, 178, 100], + Biome::TropicalSeasonalForest => [102, 152, 70], + Biome::TropicalRainforest => [40, 112, 58], + } +} + +/// Modulates a base color by terrain: ocean darkens with depth, land gets a +/// gentle NW hillshade from the elevation gradient so ridges and valleys read. +fn shade(world: &World, p: Point, base: [u8; 3]) -> [u8; 3] { + let e = *world.elevation.get(p).unwrap_or(&0.0); + let sea = world.config.sea_level; + + let factor = if world.biome.get(p).map(|b| b.is_water()).unwrap_or(false) { + // Deeper water → darker. Normalize depth against sea level. + let depth = ((sea - e) / sea).clamp(0.0, 1.0); + 0.95 - 0.45 * depth + } else { + // Elevation tint (higher = a touch brighter) times a hillshade. + let tint = 0.88 + 0.30 * ((e - sea) / (1.0 - sea)).clamp(0.0, 1.0); + tint * hillshade(world, p) + }; + + [ + (base[0] as f32 * factor).clamp(0.0, 255.0) as u8, + (base[1] as f32 * factor).clamp(0.0, 255.0) as u8, + (base[2] as f32 * factor).clamp(0.0, 255.0) as u8, + ] +} + +/// A simple Lambert hillshade with the light from the north-west, returning a +/// brightness multiplier around 1.0. Steep slopes facing the light brighten; +/// those facing away darken, which is what makes mountains pop. +fn hillshade(world: &World, p: Point) -> f32 { + let at = |q: Point| *world.elevation.get(q).unwrap_or(world.elevation.get(p).unwrap_or(&0.0)); + // Central differences for the elevation gradient (vertical exaggeration k). + let k = 12.0; + let dx = (at(p.offset(1, 0)) - at(p.offset(-1, 0))) * k; + let dy = (at(p.offset(0, 1)) - at(p.offset(0, -1))) * k; + + // Surface normal (-dx, -dy, 1) dotted with a normalized NW-up light. + let (lx, ly, lz) = (-0.45, -0.45, 0.77); + let nlen = (dx * dx + dy * dy + 1.0).sqrt(); + let dot = (-dx * lx - dy * ly + lz) / nlen; + // Map the dot product into a tame [0.7, 1.15] brightness range. + (0.70 + 0.55 * dot.clamp(0.0, 1.0)).clamp(0.70, 1.15) +} + +// --- Zero-dependency PNG encoder ----------------------------------------- + +/// Writes an 8-bit RGB PNG (`pixels` is row-major, `w*h*3` bytes) to `out`. +fn write_png(out: &mut impl Write, w: u32, h: u32, pixels: &[u8]) -> io::Result<()> { + out.write_all(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A])?; + + // IHDR: width, height, bit depth 8, color type 2 (RGB), no compression/ + // filter/interlace. + let mut ihdr = Vec::with_capacity(13); + ihdr.extend_from_slice(&w.to_be_bytes()); + ihdr.extend_from_slice(&h.to_be_bytes()); + ihdr.extend_from_slice(&[8, 2, 0, 0, 0]); + write_chunk(out, b"IHDR", &ihdr)?; + + // IDAT: zlib(stored DEFLATE) of the filtered scanlines (filter byte 0 per row). + let mut raw = Vec::with_capacity((w as usize * 3 + 1) * h as usize); + for y in 0..h as usize { + raw.push(0); // filter type 0 (None) + let start = y * w as usize * 3; + raw.extend_from_slice(&pixels[start..start + w as usize * 3]); + } + write_chunk(out, b"IDAT", &zlib_stored(&raw))?; + + write_chunk(out, b"IEND", &[])?; + Ok(()) +} + +/// Emits one PNG chunk: length, type, data, CRC32(type ++ data). +fn write_chunk(out: &mut impl Write, kind: &[u8; 4], data: &[u8]) -> io::Result<()> { + out.write_all(&(data.len() as u32).to_be_bytes())?; + out.write_all(kind)?; + out.write_all(data)?; + let mut crc = Crc::new(); + crc.update(kind); + crc.update(data); + out.write_all(&crc.finish().to_be_bytes())?; + Ok(()) +} + +/// Wraps `data` as a zlib stream whose DEFLATE body is uncompressed "stored" +/// blocks — valid zlib that any PNG decoder accepts, with no compression code. +fn zlib_stored(data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(data.len() + data.len() / 65535 * 5 + 16); + out.extend_from_slice(&[0x78, 0x01]); // zlib header (deflate, 32K window) + + let mut chunks = data.chunks(0xFFFF).peekable(); + if chunks.peek().is_none() { + // Empty image still needs one final empty stored block. + out.extend_from_slice(&[0x01, 0x00, 0x00, 0xFF, 0xFF]); + } + while let Some(chunk) = chunks.next() { + let last = chunks.peek().is_none(); + out.push(if last { 1 } else { 0 }); // BFINAL, BTYPE=00 (stored) + let len = chunk.len() as u16; + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&(!len).to_le_bytes()); // one's complement of len + out.extend_from_slice(chunk); + } + + out.extend_from_slice(&adler32(data).to_be_bytes()); + out +} + +/// Adler-32 checksum (the zlib trailer). +fn adler32(data: &[u8]) -> u32 { + const MOD: u32 = 65521; + let (mut a, mut b) = (1u32, 0u32); + for &byte in data { + a = (a + byte as u32) % MOD; + b = (b + a) % MOD; + } + (b << 16) | a +} + +/// CRC-32 (the PNG chunk checksum), computed with the standard reflected +/// polynomial — no lookup table, the bit-at-a-time form is plenty fast here. +struct Crc { + value: u32, +} +impl Crc { + fn new() -> Self { + Crc { value: 0xFFFF_FFFF } + } + fn update(&mut self, data: &[u8]) { + for &byte in data { + self.value ^= byte as u32; + for _ in 0..8 { + let mask = (self.value & 1).wrapping_neg(); + self.value = (self.value >> 1) ^ (0xEDB8_8320 & mask); + } + } + } + fn finish(self) -> u32 { + self.value ^ 0xFFFF_FFFF + } +} diff --git a/reikhelm-core/src/lib.rs b/reikhelm-core/src/lib.rs index 09fcd09..eec1158 100644 --- a/reikhelm-core/src/lib.rs +++ b/reikhelm-core/src/lib.rs @@ -14,3 +14,4 @@ pub mod passes; pub mod recipes; pub mod region; pub mod rng; +pub mod world; diff --git a/reikhelm-core/src/world/biome.rs b/reikhelm-core/src/world/biome.rs new file mode 100644 index 0000000..b0bb3a3 --- /dev/null +++ b/reikhelm-core/src/world/biome.rs @@ -0,0 +1,242 @@ +//! Biome classification — the world's analogue of the dungeon's `room_themer`: +//! a pure classifier that reads finished terrain/climate fields and labels each +//! cell, changing no underlying data. +//! +//! Water biomes (ocean/lake/river) are decided by [`crate::world::hydrology`] +//! and passed in as a [`Surface`]; everything else is a Whittaker-style lookup +//! on temperature × moisture, with elevation carving out beaches and the +//! bare/snow high country on top. + +use serde::{Deserialize, Serialize}; + +/// What kind of water (if any) occupies a cell, decided before biome +/// classification by the hydrology stage. +/// +/// `Land` cells fall through to the temperature/moisture classifier; the water +/// variants short-circuit to their corresponding [`Biome`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Surface { + /// Dry land — classify by climate. + Land, + /// Open sea below the deep-water cutoff. + DeepOcean, + /// Sea shallow enough to read as a lighter shelf. + ShallowOcean, + /// An inland body of standing water (a below-sea-level basin with no path to + /// the open ocean). + Lake, + /// A flowing watercourse traced by the flow-accumulation stage. + River, + /// Dry land immediately fringing the ocean. + Beach, +} + +/// A terrain/climate classification for a single world cell. +/// +/// The land biomes are ordered cold→hot and dry→wet within each temperature +/// band, mirroring the Whittaker diagram the classifier implements. Water and +/// relief biomes (the oceans, lake, river, beach, bare rock, and snow) bracket +/// the climate table. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Biome { + /// Open sea, below the deep-water cutoff. + DeepOcean, + /// Coastal shelf sea. + ShallowOcean, + /// Inland standing water. + Lake, + /// A flowing river. + River, + /// Sandy shore fringing the ocean. + Beach, + /// Permanent ice/snow — the cold high country and the polar caps. + Snow, + /// Frozen, treeless ground. + Tundra, + /// Cold coniferous forest (boreal). + Taiga, + /// Exposed rock above the tree line but below the snow line. + Bare, + /// Cool-to-temperate open grassland. + Grassland, + /// Dry temperate scrub. + Shrubland, + /// Temperate broadleaf/mixed forest. + TemperateForest, + /// Cool, very wet temperate forest. + TemperateRainforest, + /// Hot, arid desert. + Desert, + /// Hot grassland with sparse trees. + Savanna, + /// Hot forest with a pronounced dry season. + TropicalSeasonalForest, + /// Hot, perpetually wet jungle. + TropicalRainforest, +} + +/// Classifies one cell from its surface, elevation, and climate. +/// +/// `elev`, `temp`, and `moist` are all normalized to `[0, 1]`. `high_alt` is the +/// elevation above which land becomes bare rock or snow (the alpine band), +/// expressed on the same `[0, 1]` scale. The decision order is: +/// +/// 1. Any non-`Land` [`Surface`] maps straight to its water/beach biome. +/// 2. Alpine land (`elev >= high_alt`) is [`Snow`](Biome::Snow) when cold, else +/// [`Bare`](Biome::Bare) rock. +/// 3. Otherwise a Whittaker lookup on `(temp, moist)`. +pub fn classify(surface: Surface, elev: f32, temp: f32, moist: f32, high_alt: f32) -> Biome { + // 1. Water and shoreline are already decided by hydrology. + match surface { + Surface::DeepOcean => return Biome::DeepOcean, + Surface::ShallowOcean => return Biome::ShallowOcean, + Surface::Lake => return Biome::Lake, + Surface::River => return Biome::River, + Surface::Beach => return Biome::Beach, + Surface::Land => {} + } + + // 2. The alpine band: snow caps the cold peaks, bare rock the rest. + if elev >= high_alt { + return if temp < 0.35 { Biome::Snow } else { Biome::Bare }; + } + + // The very coldest lowlands read as snow/ice regardless of moisture (polar + // caps), so the temperature poles always look frozen. + if temp < 0.12 { + return Biome::Snow; + } + + // 3. Whittaker: temperature picks the band, moisture the member. + if temp < 0.30 { + // Cold. + if moist < 0.35 { + Biome::Tundra + } else { + Biome::Taiga + } + } else if temp < 0.65 { + // Temperate. + if moist < 0.20 { + Biome::Shrubland + } else if moist < 0.50 { + Biome::Grassland + } else if moist < 0.80 { + Biome::TemperateForest + } else { + Biome::TemperateRainforest + } + } else { + // Hot. + if moist < 0.20 { + Biome::Desert + } else if moist < 0.40 { + Biome::Savanna + } else if moist < 0.70 { + Biome::TropicalSeasonalForest + } else { + Biome::TropicalRainforest + } + } +} + +impl Biome { + /// `true` if this biome is any kind of water (ocean, lake, or river). + /// + /// Handy for renderers and for game logic that treats all water as + /// impassable terrain. + pub fn is_water(self) -> bool { + matches!( + self, + Biome::DeepOcean | Biome::ShallowOcean | Biome::Lake | Biome::River + ) + } + + /// A single-character glyph for ASCII previews (one stable char per biome). + /// + /// Used by the world's debug `to_ascii` render and tests; it is *not* a + /// rendering policy (colors live in the renderer, never the core). + pub fn glyph(self) -> char { + match self { + Biome::DeepOcean => '~', + Biome::ShallowOcean => '-', + Biome::Lake => 'o', + Biome::River => '+', + Biome::Beach => '.', + Biome::Snow => '*', + Biome::Tundra => ',', + Biome::Taiga => 't', + Biome::Bare => '^', + Biome::Grassland => '"', + Biome::Shrubland => ':', + Biome::TemperateForest => 'f', + Biome::TemperateRainforest => 'F', + Biome::Desert => 'd', + Biome::Savanna => 's', + Biome::TropicalSeasonalForest => 'j', + Biome::TropicalRainforest => 'J', + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Each water surface short-circuits to its matching biome regardless of + /// climate. + #[test] + fn water_surfaces_map_directly() { + let hot = (0.9, 0.9); + assert_eq!(classify(Surface::DeepOcean, 0.1, hot.0, hot.1, 0.8), Biome::DeepOcean); + assert_eq!(classify(Surface::ShallowOcean, 0.3, hot.0, hot.1, 0.8), Biome::ShallowOcean); + assert_eq!(classify(Surface::Lake, 0.3, hot.0, hot.1, 0.8), Biome::Lake); + assert_eq!(classify(Surface::River, 0.5, hot.0, hot.1, 0.8), Biome::River); + assert_eq!(classify(Surface::Beach, 0.45, hot.0, hot.1, 0.8), Biome::Beach); + } + + /// Alpine land is snow when cold and bare rock when not. + #[test] + fn alpine_band_is_snow_or_bare() { + // elev above high_alt (0.8). + assert_eq!(classify(Surface::Land, 0.95, 0.1, 0.5, 0.8), Biome::Snow); + assert_eq!(classify(Surface::Land, 0.95, 0.9, 0.5, 0.8), Biome::Bare); + } + + /// The Whittaker corners land where expected: cold/dry tundra, hot/dry + /// desert, hot/wet rainforest, temperate/mid grassland. + #[test] + fn whittaker_corners() { + let a = 0.4; // mid elevation, below high_alt + assert_eq!(classify(Surface::Land, a, 0.20, 0.10, 0.8), Biome::Tundra); + assert_eq!(classify(Surface::Land, a, 0.90, 0.05, 0.8), Biome::Desert); + assert_eq!(classify(Surface::Land, a, 0.90, 0.95, 0.8), Biome::TropicalRainforest); + assert_eq!(classify(Surface::Land, a, 0.50, 0.35, 0.8), Biome::Grassland); + } + + /// The frozen-pole shortcut: extremely cold lowland is snow even when wet. + #[test] + fn freezing_lowland_is_snow() { + assert_eq!(classify(Surface::Land, 0.5, 0.05, 0.9, 0.8), Biome::Snow); + } + + /// Every biome has a distinct ASCII glyph (no two collide), so previews are + /// unambiguous. + #[test] + fn glyphs_are_distinct() { + let all = [ + Biome::DeepOcean, Biome::ShallowOcean, Biome::Lake, Biome::River, Biome::Beach, + Biome::Snow, Biome::Tundra, Biome::Taiga, Biome::Bare, Biome::Grassland, + Biome::Shrubland, Biome::TemperateForest, Biome::TemperateRainforest, Biome::Desert, + Biome::Savanna, Biome::TropicalSeasonalForest, Biome::TropicalRainforest, + ]; + let mut glyphs: Vec = all.iter().map(|b| b.glyph()).collect(); + glyphs.sort_unstable(); + let unique = { + let mut g = glyphs.clone(); + g.dedup(); + g + }; + assert_eq!(glyphs.len(), unique.len(), "biome glyphs must be distinct"); + } +} diff --git a/reikhelm-core/src/world/climate.rs b/reikhelm-core/src/world/climate.rs new file mode 100644 index 0000000..ad90cd5 --- /dev/null +++ b/reikhelm-core/src/world/climate.rs @@ -0,0 +1,239 @@ +//! Climate fields: temperature and moisture. +//! +//! Temperature is the cheap, legible part — a latitude band cooled by altitude. +//! Moisture is where a world stops looking like noise and starts looking like +//! geography: we sweep **prevailing wind** across the map, evaporating moisture +//! over water, raining it out over land, and dumping extra rain where the wind +//! is forced *up* a slope. The leeward side of a mountain range then falls into +//! a dry **rain shadow** — emergent deserts, not painted ones. +//! +//! Like the rest of the world generator this stays transcendental-free, so a +//! seed reproduces the same climate on every machine. + +use crate::geometry::Point; +use crate::grid::Grid; + +use super::hydrology::WaterBody; +use super::noise::Noise; +use super::{WorldConfig, Wind}; + +/// Computes the normalized `[0, 1]` temperature field. +/// +/// Each cell starts from a triangular latitude band (1 at the equatorial middle +/// row, 0 at the poles), is cooled by `lapse_rate` for every unit of elevation +/// above sea level, and is nudged by a little fBm so the isotherms wander +/// instead of ruling dead-straight across the map. +pub fn temperature(elevation: &Grid, noise: &Noise, cfg: &WorldConfig) -> Grid { + let (w, h) = (elevation.width(), elevation.height()); + // Avoid a divide-by-zero for a 1-row world; such a row is "the equator". + let span = (h.max(1) - 1).max(1) as f32; + let scale = cfg.climate_scale / w.max(1) as f32; + + Grid::from_fn(w, h, |p: Point| { + let elev = *elevation.get(p).unwrap_or(&0.0); + + // Triangular latitude band: warmest at the middle row, coldest at the + // top and bottom edges. + let lat = p.y as f32 / span; // 0 at top .. 1 at bottom + let band = 1.0 - (2.0 * lat - 1.0).abs(); + + // Altitude lapse: only land above sea level cools (the sea is uniform). + let above = (elev - cfg.sea_level).max(0.0); + let cooled = band - cfg.lapse_rate * above; + + // Wandering isotherms. + let jitter = cfg.temp_jitter * noise.fbm(p.x as f32 * scale, p.y as f32 * scale, 3, 2.0, 0.5); + + (cooled + jitter).clamp(0.0, 1.0) + }) +} + +/// Computes the normalized `[0, 1]` moisture field via a prevailing-wind sweep. +/// +/// Air enters each scan line saturated and travels along `cfg.wind`. Over water +/// it re-evaporates toward saturation; over land it rains out a fraction of what +/// it carries (`rainfall`) plus an orographic burst proportional to how steeply +/// the wind climbs (`orographic`). Carried moisture that has rained out cannot +/// come back until the wind reaches water again — that depletion is the rain +/// shadow. Land cells are finally rescaled to span `[0, 1]` so biome thresholds +/// are meaningful regardless of the absolute rainfall constants. +pub fn moisture( + elevation: &Grid, + water: &Grid, + noise: &Noise, + cfg: &WorldConfig, +) -> Grid { + let (w, h) = (elevation.width(), elevation.height()); + let mut raw = Grid::new(w, h, 0.0f32); + + let scale = cfg.climate_scale / w.max(1) as f32; + let is_water = |p: Point| matches!(water.get(p), Some(WaterBody::Ocean | WaterBody::Lake)); + + // One pass of the wind over a single scan line of cells, in travel order. + // `line` is the ordered list of points the wind crosses. + let sweep_line = |line: &[Point], raw: &mut Grid| { + let mut carried = 1.0f32; + let mut prev_elev = line.first().map(|&p| *elevation.get(p).unwrap_or(&0.0)).unwrap_or(0.0); + for &p in line { + let elev = *elevation.get(p).unwrap_or(&0.0); + let m = if is_water(p) { + carried = (carried + cfg.evaporation).min(1.0); + 1.0 + } else { + // Rain out a *fraction* of the carried moisture: a baseline + // drizzle plus an orographic burst that grows with the uphill + // climb. Capping the fraction below 1 means the wind can never + // fully desiccate in a single cell, so moisture still reaches + // inland ranges — and the deficit it leaves behind a peak is the + // rain shadow. + let rise = (elev - prev_elev).max(0.0); + let rate = (cfg.rainfall + cfg.orographic * rise).min(0.9); + let drop = carried * rate; + carried -= drop; + drop + }; + // A little texture so rainfall isn't perfectly smooth along the wind. + let jitter = cfg.moisture_jitter * noise.fbm(p.x as f32 * scale, p.y as f32 * scale, 3, 2.0, 0.5); + raw.set(p, m + jitter); + prev_elev = elev; + } + }; + + // Build each scan line in wind-travel order, then sweep it. + match cfg.wind { + Wind::West | Wind::East => { + for y in 0..h as i32 { + let mut line: Vec = (0..w as i32).map(|x| Point::new(x, y)).collect(); + if matches!(cfg.wind, Wind::East) { + line.reverse(); // wind from the east travels right→left + } + sweep_line(&line, &mut raw); + } + } + Wind::North | Wind::South => { + for x in 0..w as i32 { + let mut line: Vec = (0..h as i32).map(|y| Point::new(x, y)).collect(); + if matches!(cfg.wind, Wind::North) { + line.reverse(); // wind from the north travels bottom→top + } + sweep_line(&line, &mut raw); + } + } + } + + normalize_land(&raw, water) +} + +/// Rescales the raw moisture so the **land** cells span `[0, 1]`. +/// +/// Water cells get a constant 1.0 (their moisture is irrelevant — the biome +/// stage overwrites them with a water biome), and they are excluded from the +/// min/max so they can't compress the land range. A flat field (all land equal) +/// maps to a uniform 0.5 rather than dividing by zero. +fn normalize_land(raw: &Grid, water: &Grid) -> Grid { + let is_land = |p: Point| matches!(water.get(p), Some(WaterBody::Land)); + + let mut lo = f32::INFINITY; + let mut hi = f32::NEG_INFINITY; + for (p, &v) in raw.iter() { + if is_land(p) { + lo = lo.min(v); + hi = hi.max(v); + } + } + + let span = hi - lo; + Grid::from_fn(raw.width(), raw.height(), |p| { + if !is_land(p) { + return 1.0; + } + if span <= 0.0 { + 0.5 + } else { + ((*raw.get(p).unwrap_or(&lo) - lo) / span).clamp(0.0, 1.0) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rng::Rng; + + fn noise(seed: u64) -> Noise { + let mut rng = Rng::from_seed(seed); + Noise::new(&mut rng) + } + + /// A flat sea-level world: the equator (middle row) is warmer than the poles + /// (top/bottom rows). + #[test] + fn temperature_peaks_at_the_equator() { + let cfg = WorldConfig { temp_jitter: 0.0, ..WorldConfig::default() }; + let elev = Grid::new(8, 9, cfg.sea_level); // all at sea level, no lapse + let t = temperature(&elev, &noise(1), &cfg); + + let mid = *t.get(Point::new(4, 4)).unwrap(); + let top = *t.get(Point::new(4, 0)).unwrap(); + let bot = *t.get(Point::new(4, 8)).unwrap(); + assert!(mid > top, "equator {mid} should beat north pole {top}"); + assert!(mid > bot, "equator {mid} should beat south pole {bot}"); + } + + /// Altitude cools: a tall mountain cell is colder than a sea-level cell on + /// the same latitude. Uses the equatorial row (y=2 of 5) so the latitude + /// band is warm enough for the lapse rate to bite. + #[test] + fn altitude_cools_temperature() { + let cfg = WorldConfig { temp_jitter: 0.0, ..WorldConfig::default() }; + let mut elev = Grid::new(5, 5, cfg.sea_level); + elev.set(Point::new(2, 2), 0.95); // a peak on the equator + let t = temperature(&elev, &noise(2), &cfg); + let lowland = *t.get(Point::new(0, 2)).unwrap(); + let peak = *t.get(Point::new(2, 2)).unwrap(); + assert!(peak < lowland, "peak {peak} should be colder than lowland {lowland}"); + } + + /// A windward/leeward test: with a west wind and a single mountain wall, the + /// windward (upwind) flank is wetter than the leeward rain shadow behind it. + #[test] + fn mountains_cast_a_rain_shadow() { + let cfg = WorldConfig { + wind: Wind::West, + moisture_jitter: 0.0, + ..WorldConfig::default() + }; + let w = 12u32; + // Ocean on the upwind (left) edge so the wind starts wet; a mountain + // wall in the middle; dry-able lowland after it. + let mut elev = Grid::new(w, 1, cfg.sea_level + 0.1); // lowland + elev.set(Point::new(0, 0), 0.1); // ocean column (below sea level) + elev.set(Point::new(5, 0), 0.9); // the mountain + elev.set(Point::new(6, 0), 0.85); + + let mut water = Grid::new(w, 1, WaterBody::Land); + water.set(Point::new(0, 0), WaterBody::Ocean); + + let m = moisture(&elev, &water, &noise(3), &cfg); + let windward = *m.get(Point::new(4, 0)).unwrap(); // just upwind of peak + let leeward = *m.get(Point::new(8, 0)).unwrap(); // behind the peak + assert!( + windward > leeward, + "windward {windward} should be wetter than the rain shadow {leeward}" + ); + } + + /// Same seed and config ⇒ identical climate fields. + #[test] + fn climate_is_deterministic() { + let cfg = WorldConfig::default(); + let elev = Grid::from_fn(16, 16, |p| (p.x + p.y) as f32 / 32.0); + let water = Grid::new(16, 16, WaterBody::Land); + let t1 = temperature(&elev, &noise(9), &cfg); + let t2 = temperature(&elev, &noise(9), &cfg); + let m1 = moisture(&elev, &water, &noise(9), &cfg); + let m2 = moisture(&elev, &water, &noise(9), &cfg); + assert_eq!(t1, t2); + assert_eq!(m1, m2); + } +} diff --git a/reikhelm-core/src/world/hydrology.rs b/reikhelm-core/src/world/hydrology.rs new file mode 100644 index 0000000..36701ce --- /dev/null +++ b/reikhelm-core/src/world/hydrology.rs @@ -0,0 +1,328 @@ +//! Hydrology: where the water sits and where it runs. +//! +//! Three jobs, in order: +//! +//! 1. [`water_bodies`] floods the sea inward from the map border so that +//! below-sea-level basins that *aren't* connected to the open ocean become +//! inland **lakes**. +//! 2. [`flow_accumulation`] first **fills depressions** with a priority-flood +//! (Barnes, Lehman & Mulla 2014) so every cell has a strictly downhill path +//! to the border — without this, rivers dead-end in noise pits. It then routes +//! each cell's rainfall downhill (steepest D8 descent) and sums the drainage +//! passing through every cell. +//! 3. [`river_cells`] thresholds that drainage at a chosen density to pick out +//! the channels that read as rivers. +//! +//! No randomness and no transcendental math: given the same elevation/moisture, +//! the water is identical on every machine. Float ordering for the priority +//! queue uses `f32::total_cmp` with the cell index as a deterministic tie-break. + +use std::cmp::{Ordering, Reverse}; +use std::collections::{BinaryHeap, VecDeque}; + +use serde::{Deserialize, Serialize}; + +use crate::geometry::Point; +use crate::grid::Grid; + +use super::WorldConfig; + +/// Which kind of standing water (if any) fills a cell, before rivers are traced. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum WaterBody { + /// Dry land (elevation at or above sea level). + Land, + /// Below sea level and connected to the map edge — the open sea. + Ocean, + /// Below sea level but walled off from the open sea — an inland lake. + Lake, +} + +/// Classifies every cell as [`Land`](WaterBody::Land), [`Ocean`](WaterBody::Ocean), +/// or [`Lake`](WaterBody::Lake). +/// +/// A cell is water when its elevation is below `cfg.sea_level`. The open ocean is +/// the set of water cells reachable (4-connected) from any below-sea-level cell +/// on the map border; water cells the flood can't reach are inland lakes. This is +/// what lets a high plateau hold a sea without it silently merging with the coast. +pub fn water_bodies(elevation: &Grid, cfg: &WorldConfig) -> Grid { + let (w, h) = (elevation.width(), elevation.height()); + let is_water = |p: Point| *elevation.get(p).unwrap_or(&1.0) < cfg.sea_level; + + let mut body = Grid::new(w, h, WaterBody::Land); + // Mark all water cells as Lake first; the flood promotes the connected ones. + for (p, _) in elevation.iter() { + if is_water(p) { + body.set(p, WaterBody::Lake); + } + } + + // BFS the open ocean inward from every below-sea border cell. + let mut queue: VecDeque = VecDeque::new(); + let on_border = |p: Point| p.x == 0 || p.y == 0 || p.x == w as i32 - 1 || p.y == h as i32 - 1; + for (p, _) in elevation.iter() { + if on_border(p) && is_water(p) { + body.set(p, WaterBody::Ocean); + queue.push_back(p); + } + } + while let Some(p) = queue.pop_front() { + // Collect first: `neighbors4` borrows `body`, and we mutate `body` below. + let neighbors: Vec = body.neighbors4(p).collect(); + for n in neighbors { + if is_water(n) && body.get(n) == Some(&WaterBody::Lake) { + body.set(n, WaterBody::Ocean); + queue.push_back(n); + } + } + } + + body +} + +/// A cell waiting in the priority-flood queue, ordered by its spill elevation +/// (lowest first), with the flat cell index as a deterministic tie-break. +struct FloodCell { + spill: f32, + idx: u32, +} +impl PartialEq for FloodCell { + fn eq(&self, o: &Self) -> bool { + self.idx == o.idx && self.spill.to_bits() == o.spill.to_bits() + } +} +impl Eq for FloodCell {} +impl Ord for FloodCell { + fn cmp(&self, o: &Self) -> Ordering { + // total_cmp gives a total order over f32 (no NaN surprises); the index + // tie-break makes equal-elevation pops fully deterministic. + self.spill.total_cmp(&o.spill).then(self.idx.cmp(&o.idx)) + } +} +impl PartialOrd for FloodCell { + fn partial_cmp(&self, o: &Self) -> Option { + Some(self.cmp(o)) + } +} + +/// Fills depressions and returns the drainage **accumulation** field: each +/// cell's value is the total upstream rainfall that drains through it. +/// +/// Rainfall per cell is `1 + river_moisture_weight * moisture`, so wetter +/// uplands feed larger rivers, but every land cell contributes at least its own +/// unit (drainage area). Water cells originate no flow but pass it through to the +/// sea. The returned grid shares the input's dimensions. +pub fn flow_accumulation( + elevation: &Grid, + moisture: &Grid, + water: &Grid, + cfg: &WorldConfig, +) -> Grid { + let (w, h) = (elevation.width(), elevation.height()); + let n = (w as usize) * (h as usize); + if n == 0 { + return Grid::new(w, h, 0.0); + } + + let to_pt = |i: u32| Point::new((i % w) as i32, (i / w) as i32); + let to_idx = |p: Point| (p.y as u32) * w + p.x as u32; + + // --- 1. Priority-flood (+epsilon) depression fill ---------------------- + // + // `filled` is the elevation raised just enough that every cell has a + // strictly downhill route to the border. We never display it — it only + // defines flow direction — so the tiny epsilon creep is harmless. + const EPS: f32 = 1e-6; + let mut filled = vec![f32::INFINITY; n]; + let mut closed = vec![false; n]; + let mut heap: BinaryHeap> = BinaryHeap::new(); + + let on_border = |p: Point| p.x == 0 || p.y == 0 || p.x == w as i32 - 1 || p.y == h as i32 - 1; + for (p, &e) in elevation.iter() { + if on_border(p) { + let i = to_idx(p) as usize; + filled[i] = e; + closed[i] = true; + heap.push(Reverse(FloodCell { spill: e, idx: to_idx(p) })); + } + } + + while let Some(Reverse(cell)) = heap.pop() { + let cp = to_pt(cell.idx); + for np in elevation.neighbors8(cp) { + let ni = to_idx(np) as usize; + if closed[ni] { + continue; + } + closed[ni] = true; + // Spill level: at least the neighbor's own ground, but never below + // the cell we came from (plus epsilon to guarantee descent). + let raised = elevation.get(np).copied().unwrap_or(0.0).max(cell.spill + EPS); + filled[ni] = raised; + heap.push(Reverse(FloodCell { spill: raised, idx: to_idx(np) })); + } + } + + // --- 2. Flow direction: steepest D8 descent on the filled surface ------ + // + // `downstream[i]` is the flat index this cell drains into, or `i` itself for + // a sink (a border outlet with no lower neighbor). + let mut downstream = vec![0u32; n]; + for (p, _) in elevation.iter() { + let i = to_idx(p) as usize; + let here = filled[i]; + let mut best = here; + let mut best_idx = to_idx(p); + for np in elevation.neighbors8(p) { + let ni = to_idx(np) as usize; + if filled[ni] < best { + best = filled[ni]; + best_idx = to_idx(np); + } + } + downstream[i] = best_idx; + } + + // --- 3. Accumulate rainfall from peaks down -------------------------- + // + // Processing cells in descending filled-elevation order guarantees that when + // we hand a cell's water downstream, every cell upstream of it has already + // contributed. + let precip = |p: Point| -> f32 { + match water.get(p) { + Some(WaterBody::Land) => 1.0 + cfg.river_moisture_weight * *moisture.get(p).unwrap_or(&0.0), + _ => 0.0, // open water originates no river flow + } + }; + let mut acc = vec![0.0f32; n]; + for (p, _) in elevation.iter() { + acc[to_idx(p) as usize] = precip(p); + } + + let mut order: Vec = (0..n as u32).collect(); + order.sort_by(|&a, &b| { + // Highest filled first; index tie-break keeps it deterministic. + filled[b as usize] + .total_cmp(&filled[a as usize]) + .then(a.cmp(&b)) + }); + for &i in &order { + let d = downstream[i as usize]; + if d != i { + acc[d as usize] += acc[i as usize]; + } + } + + Grid::from_fn(w, h, |p| acc[to_idx(p) as usize]) +} + +/// Picks the river channels from an accumulation field at a given `density`. +/// +/// `density` is the fraction of **land** cells that should read as river +/// (`0.05` ≈ the top 5% most-drained land cells). Because flow accumulation only +/// grows downstream, the chosen set is naturally contiguous — each river runs +/// unbroken from its headwater down to the sea. Returns a boolean mask the size +/// of the inputs; lakes and oceans are never rivers. +pub fn river_cells(acc: &Grid, water: &Grid, density: f32) -> Grid { + let is_land = |p: Point| matches!(water.get(p), Some(WaterBody::Land)); + + // Sort land accumulations to find the density-th percentile threshold. + let mut land_acc: Vec = acc + .iter() + .filter(|(p, _)| is_land(*p)) + .map(|(_, &v)| v) + .collect(); + if land_acc.is_empty() { + return Grid::new(acc.width(), acc.height(), false); + } + land_acc.sort_by(|a, b| a.total_cmp(b)); + + let d = density.clamp(0.0, 1.0); + // Index of the first cell that qualifies: (1 - d) of the way up the sorted + // list. A density of 0 yields a threshold above every cell (no rivers). + let cut = (((1.0 - d) * land_acc.len() as f32) as usize).min(land_acc.len() - 1); + let threshold = land_acc[cut]; + + Grid::from_fn(acc.width(), acc.height(), |p| { + is_land(p) && *acc.get(p).unwrap_or(&0.0) >= threshold && d > 0.0 + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An inland basin below sea level becomes a Lake, while a below-sea cell + /// touching the border (and its connected neighbors) becomes Ocean. + #[test] + fn lakes_are_disconnected_from_the_ocean() { + let cfg = WorldConfig { sea_level: 0.5, ..WorldConfig::default() }; + // 5x5 of high land, with a single low cell in the dead center (a lake) + // and the whole left column low (ocean reaching the border). + let mut elev = Grid::new(5, 5, 0.8); + elev.set(Point::new(2, 2), 0.1); // landlocked low cell + for y in 0..5 { + elev.set(Point::new(0, y), 0.1); // low border column → ocean + } + let body = water_bodies(&elev, &cfg); + assert_eq!(body.get(Point::new(2, 2)), Some(&WaterBody::Lake)); + assert_eq!(body.get(Point::new(0, 2)), Some(&WaterBody::Ocean)); + assert_eq!(body.get(Point::new(3, 3)), Some(&WaterBody::Land)); + } + + /// Accumulation strictly increases downstream: a cell's drainage is at least + /// the sum it received, so the coast carries more water than the headwaters. + /// We build a simple tilted plane draining to the right edge and check the + /// mouth out-drains the source. + #[test] + fn accumulation_grows_toward_the_outlet() { + let cfg = WorldConfig { sea_level: 0.0, river_moisture_weight: 0.0, ..WorldConfig::default() }; + // Elevation decreasing left→right so everything drains east. + let w = 10u32; + let elev = Grid::from_fn(w, 3, |p| (w as i32 - p.x) as f32 / w as f32); + let moist = Grid::new(w, 3, 0.0); + let water = Grid::new(w, 3, WaterBody::Land); + let acc = flow_accumulation(&elev, &moist, &water, &cfg); + + // Elevation decreases left→right, so everything drains east: the eastern + // outlet out-drains a western headwater cell. + let source = *acc.get(Point::new(1, 1)).unwrap(); + let mouth = *acc.get(Point::new(8, 1)).unwrap(); + assert!(mouth > source, "mouth {mouth} should out-drain source {source}"); + } + + /// A pit in the middle of a slope doesn't swallow the river: the priority + /// flood fills it so flow still reaches the outlet (accumulation downstream + /// of the pit exceeds the pit's own rainfall). + #[test] + fn depressions_are_filled_so_flow_passes_through() { + let cfg = WorldConfig { sea_level: 0.0, river_moisture_weight: 0.0, ..WorldConfig::default() }; + let w = 10u32; + let mut elev = Grid::from_fn(w, 3, |p| (w as i32 - p.x) as f32 / w as f32); + elev.set(Point::new(5, 1), 0.0); // a deep pit mid-slope + let moist = Grid::new(w, 3, 0.0); + let water = Grid::new(w, 3, WaterBody::Land); + let acc = flow_accumulation(&elev, &moist, &water, &cfg); + // Cells east of (downstream of) the pit still accumulate a lot of water, + // proving the river wasn't swallowed by the depression. + let downstream = *acc.get(Point::new(8, 1)).unwrap(); + assert!(downstream > 5.0, "flow should pass the pit, got {downstream}"); + } + + /// `river_cells` honors density: a higher density marks at least as many + /// river cells as a lower one, and density 0 marks none. + #[test] + fn river_density_is_monotonic() { + let cfg = WorldConfig { sea_level: 0.0, river_moisture_weight: 0.0, ..WorldConfig::default() }; + let w = 16u32; + let elev = Grid::from_fn(w, w, |p| (w as i32 - p.x) as f32 / w as f32); + let moist = Grid::new(w, w, 0.5); + let water = Grid::new(w, w, WaterBody::Land); + let acc = flow_accumulation(&elev, &moist, &water, &cfg); + + let count = |d: f32| river_cells(&acc, &water, d).iter().filter(|(_, &b)| b).count(); + assert_eq!(count(0.0), 0, "density 0 marks no rivers"); + assert!(count(0.20) >= count(0.05), "more density ⇒ at least as many river cells"); + assert!(count(0.20) > 0, "a positive density should mark some rivers"); + } +} diff --git a/reikhelm-core/src/world/mod.rs b/reikhelm-core/src/world/mod.rs new file mode 100644 index 0000000..fcd6b5b --- /dev/null +++ b/reikhelm-core/src/world/mod.rs @@ -0,0 +1,511 @@ +//! Procedural **world** generation — continents, climate, and rivers. +//! +//! This is a sibling to the dungeon generator: where `recipes::dungeon` carves a +//! discrete `Tile` grid through a [`Pass`](crate::pass::Pass) pipeline, the world +//! generator builds *continuous fields* (elevation, temperature, moisture, water +//! flow) and classifies them into [`Biome`]s. The output is a [`World`] — a stack +//! of aligned [`Grid`]s any game or renderer can read. +//! +//! ## Stages (the recipe) +//! +//! [`generate`] runs a fixed sequence, each stage drawing from its own forked +//! RNG sub-stream (the same determinism discipline the dungeon pipeline uses): +//! +//! 1. **Elevation** — domain-warped fBm shaped into continents by an edge +//! falloff and a redistribution curve ([`elevation`]). +//! 2. **Water bodies** — flood the sea inward; landlocked basins become lakes +//! ([`hydrology::water_bodies`]). +//! 3. **Temperature** — latitude band cooled by altitude ([`climate::temperature`]). +//! 4. **Moisture** — prevailing-wind sweep with orographic rain and rain shadows +//! ([`climate::moisture`]). +//! 5. **Rivers** — depression-filled flow accumulation, thresholded by density +//! ([`hydrology::flow_accumulation`] / [`hydrology::river_cells`]). +//! 6. **Surface & biomes** — fold the above into a per-cell [`Surface`] and a +//! [`Biome`] ([`biome::classify`]). +//! +//! ## Determinism +//! +//! Same `(config, seed)` ⇒ byte-identical [`World`] on every machine. All +//! randomness flows through [`Rng`]; the whole generator avoids transcendental +//! functions (only `+ - * /` and comparisons), so even the floating-point fields +//! reproduce exactly across platforms — the same guarantee `conventions.md` makes +//! for the dungeon core. + +pub mod biome; +pub mod climate; +pub mod hydrology; +pub mod noise; + +use std::error::Error; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::geometry::Point; +use crate::grid::Grid; +use crate::rng::Rng; + +pub use biome::{Biome, Surface}; +pub use hydrology::WaterBody; +use noise::Noise; + +/// The prevailing wind direction, naming the edge the wind blows *from*. +/// +/// Moisture-bearing air enters from this edge, so windward coasts on this side +/// are wet and the far (leeward) interiors fall into rain shadows. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Wind { + /// Wind out of the west, traveling left→right. + West, + /// Wind out of the east, traveling right→left. + East, + /// Wind out of the north, traveling top→bottom. + North, + /// Wind out of the south, traveling bottom→top. + South, +} + +/// Every knob that shapes a [`World`]. One flat struct (rather than per-stage +/// configs) so the whole generator is configured — and later exposed over an +/// API — through a single serializable object. +/// +/// [`Default`] produces a recognizable continent-and-sea world on a 256×160 +/// canvas; the field docs describe what each knob does and the direction it +/// pushes. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct WorldConfig { + /// Canvas width in cells. + pub width: u32, + /// Canvas height in cells. + pub height: u32, + + // --- elevation ------------------------------------------------------ + /// Elevation below which a cell is water. Higher ⇒ more sea. + pub sea_level: f32, + /// Base fBm cycles across the map width. Higher ⇒ more, smaller landmasses. + pub continent_scale: f32, + /// Number of fBm octaves for elevation detail. + pub elevation_octaves: u32, + /// Frequency multiplier between octaves. + pub lacunarity: f32, + /// Amplitude multiplier between octaves. + pub gain: f32, + /// Domain-warp strength (in coordinate cycles). Higher ⇒ more sinuous coasts. + pub warp: f32, + /// How hard the map borders are pulled down to ocean, `0.0`..=`1.0`. Higher + /// ⇒ the world is an island surrounded by sea; `0.0` lets land run off the + /// edge. + pub edge_falloff: f32, + /// Lowland-deepening: blends elevation toward its square, `0.0`..=`1.0`. + /// Higher ⇒ broader lowlands/seas with sharper, rarer peaks. + pub redistribution: f32, + + // --- climate -------------------------------------------------------- + /// Cooling per unit of elevation above sea level. + pub lapse_rate: f32, + /// Amplitude of the fBm wobble applied to temperature isotherms. + pub temp_jitter: f32, + /// Prevailing wind direction (drives the moisture sweep). + pub wind: Wind, + /// Fraction of carried moisture rained out per land cell (baseline drizzle). + pub rainfall: f32, + /// Moisture re-gained toward saturation per water cell the wind crosses. + pub evaporation: f32, + /// Multiplier on orographic (uphill) rain — how sharply slopes wring out the + /// wind and deepen the rain shadow behind them. + pub orographic: f32, + /// Amplitude of the fBm wobble applied to moisture. + pub moisture_jitter: f32, + /// Noise scale (cycles across width) for the temperature/moisture wobble. + pub climate_scale: f32, + + // --- hydrology & biomes -------------------------------------------- + /// How much moisture boosts a cell's river contribution (drainage area is + /// always counted; this adds rainfall weighting on top). + pub river_moisture_weight: f32, + /// Fraction of land cells that read as river, `0.0`..=`1.0`. + pub river_density: f32, + /// Elevation at/above which land becomes bare rock or snow (the alpine band). + pub high_alt: f32, + /// Ocean shallower than this depth below sea level renders as a coastal shelf. + pub shallow_depth: f32, + /// Land within this elevation band above sea level, and touching the ocean, + /// is beach. + pub beach_width: f32, +} + +impl Default for WorldConfig { + fn default() -> Self { + WorldConfig { + width: 256, + height: 160, + + sea_level: 0.38, + continent_scale: 2.5, + elevation_octaves: 6, + lacunarity: 2.0, + gain: 0.5, + warp: 0.55, + edge_falloff: 0.82, + redistribution: 0.35, + + lapse_rate: 0.65, + temp_jitter: 0.06, + wind: Wind::West, + rainfall: 0.04, + evaporation: 0.20, + orographic: 3.0, + moisture_jitter: 0.06, + climate_scale: 3.5, + + river_moisture_weight: 0.6, + river_density: 0.06, + high_alt: 0.80, + shallow_depth: 0.05, + beach_width: 0.015, + } + } +} + +/// Why a [`WorldConfig`] was rejected (returned instead of panicking, so a +/// caller — e.g. a future interactive editor — can surface and fix it). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum WorldConfigError { + /// Width or height is zero — nothing to generate. + ZeroDimension, + /// `sea_level` is not strictly inside `(0, 1)`, so the world is all land or + /// all sea. + SeaLevelOutOfRange, + /// `elevation_octaves` is zero — fBm needs at least one octave. + NoOctaves, +} + +impl fmt::Display for WorldConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WorldConfigError::ZeroDimension => { + write!(f, "world width and height must both be greater than zero") + } + WorldConfigError::SeaLevelOutOfRange => { + write!(f, "sea_level must be strictly between 0 and 1") + } + WorldConfigError::NoOctaves => { + write!(f, "elevation_octaves must be at least 1") + } + } + } +} + +impl Error for WorldConfigError {} + +impl WorldConfig { + /// Validates the config, returning the first problem found or `Ok`. + pub fn validate(&self) -> Result<(), WorldConfigError> { + if self.width == 0 || self.height == 0 { + return Err(WorldConfigError::ZeroDimension); + } + if !(self.sea_level > 0.0 && self.sea_level < 1.0) { + return Err(WorldConfigError::SeaLevelOutOfRange); + } + if self.elevation_octaves == 0 { + return Err(WorldConfigError::NoOctaves); + } + Ok(()) + } +} + +/// A generated world: a stack of aligned `width × height` field grids plus the +/// derived [`Surface`] and [`Biome`] classifications. +/// +/// All grids share the same dimensions and indexing, so a cell `(x, y)` reads +/// the same location in every layer. The continuous fields are normalized to +/// `[0, 1]`; `flow` is raw drainage accumulation (unbounded, larger toward the +/// coast). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct World { + /// Canvas width in cells. + pub width: u32, + /// Canvas height in cells. + pub height: u32, + /// The seed this world was generated from. + pub seed: u64, + /// The config that produced this world (carried so consumers can read the + /// parameters — sea level, wind, etc. — that the fields were built against). + pub config: WorldConfig, + /// Normalized elevation, `0.0` (deepest sea) .. `1.0` (highest peak). + pub elevation: Grid, + /// Normalized temperature, `0.0` (polar) .. `1.0` (equatorial). + pub temperature: Grid, + /// Normalized moisture, `0.0` (arid) .. `1.0` (saturated). + pub moisture: Grid, + /// Standing-water classification (ocean/lake/land) before rivers. + pub water: Grid, + /// Drainage accumulation — total upstream rainfall passing through a cell. + pub flow: Grid, + /// Per-cell surface (land/ocean/lake/river/beach) feeding biome choice. + pub surface: Grid, + /// The final biome classification per cell. + pub biome: Grid, +} + +/// Generates a [`World`] from `cfg` and `seed`, or a [`WorldConfigError`] if the +/// config is invalid. +/// +/// Each stage forks its own RNG sub-stream from the seed (`"elevation"`, +/// `"temperature"`, `"moisture"`), so the noise fields are decorrelated and — +/// because [`Rng::fork`] keys only on `(seed, label)` — reordering or inserting a +/// stage never reshuffles another's randomness (spec §7 in spirit). +pub fn generate(cfg: WorldConfig, seed: u64) -> Result { + cfg.validate()?; + + let root = Rng::from_seed(seed); + let elevation_noise = Noise::new(&mut root.fork("elevation")); + let temperature_noise = Noise::new(&mut root.fork("temperature")); + let moisture_noise = Noise::new(&mut root.fork("moisture")); + + // 1. Elevation → continents. + let elevation = elevation(&cfg, &elevation_noise); + + // 2. Where the standing water sits (ocean vs landlocked lake). + let water = hydrology::water_bodies(&elevation, &cfg); + + // 3 & 4. Climate fields. + let temperature = climate::temperature(&elevation, &temperature_noise, &cfg); + let moisture = climate::moisture(&elevation, &water, &moisture_noise, &cfg); + + // 5. Rivers from depression-filled flow accumulation. + let flow = hydrology::flow_accumulation(&elevation, &moisture, &water, &cfg); + let rivers = hydrology::river_cells(&flow, &water, cfg.river_density); + + // 6. Fold everything into surfaces and biomes. + let surface = assemble_surface(&elevation, &water, &rivers, &cfg); + let biome = Grid::from_fn(cfg.width, cfg.height, |p| { + biome::classify( + *surface.get(p).unwrap_or(&Surface::Land), + *elevation.get(p).unwrap_or(&0.0), + *temperature.get(p).unwrap_or(&0.5), + *moisture.get(p).unwrap_or(&0.5), + cfg.high_alt, + ) + }); + + Ok(World { + width: cfg.width, + height: cfg.height, + seed, + config: cfg, + elevation, + temperature, + moisture, + water, + flow, + surface, + biome, + }) +} + +/// Builds the normalized elevation field: domain-warped fBm, shaped into +/// continents by an edge falloff (pull the borders down to sea) and a +/// redistribution curve (deepen lowlands so coastlines and mountains read). +fn elevation(cfg: &WorldConfig, noise: &Noise) -> Grid { + let (w, h) = (cfg.width, cfg.height); + // Sample both axes against the *width* so features stay square (not stretched + // when the canvas isn't). Guard the 1-px degenerate dimensions. + let scale = cfg.continent_scale / w.max(1) as f32; + let denom_x = (w.max(2) - 1) as f32; + let denom_y = (h.max(2) - 1) as f32; + + Grid::from_fn(w, h, |p: Point| { + let nx = p.x as f32 * scale; + let ny = p.y as f32 * scale; + let base = noise.fbm_warped(nx, ny, cfg.elevation_octaves, cfg.lacunarity, cfg.gain, cfg.warp); + let mut e = 0.5 + 0.5 * base; // [-1,1] → [0,1] + + // Edge falloff: distance to the nearest border, 0 center → 1 edge. + let fx = (2.0 * p.x as f32 / denom_x - 1.0).abs(); + let fy = (2.0 * p.y as f32 / denom_y - 1.0).abs(); + let edge = fx.max(fy); + // Keep full height until FALLOFF_START, then ramp to 0 by the border. + const FALLOFF_START: f32 = 0.55; + let ramp = smoothstep(FALLOFF_START, 1.0, edge); // 0 inland → 1 at edge + e *= 1.0 - cfg.edge_falloff * ramp; + + // Lowland-deepening: blend toward e² (exact, transcendental-free). + e = lerp(e, e * e, cfg.redistribution); + + e.clamp(0.0, 1.0) + }) +} + +/// Folds elevation, water bodies, and the river mask into a per-cell [`Surface`]. +/// +/// Ocean depth splits deep vs. shelf sea; lakes pass through; land is river where +/// the flow mask says so, beach where it is low and touches the ocean, otherwise +/// plain land. +fn assemble_surface( + elevation: &Grid, + water: &Grid, + rivers: &Grid, + cfg: &WorldConfig, +) -> Grid { + let touches_ocean = |p: Point| { + water + .neighbors8(p) + .any(|n| water.get(n) == Some(&WaterBody::Ocean)) + }; + + Grid::from_fn(elevation.width(), elevation.height(), |p| { + let elev = *elevation.get(p).unwrap_or(&0.0); + match water.get(p) { + Some(WaterBody::Ocean) => { + if cfg.sea_level - elev < cfg.shallow_depth { + Surface::ShallowOcean + } else { + Surface::DeepOcean + } + } + Some(WaterBody::Lake) => Surface::Lake, + _ => { + if *rivers.get(p).unwrap_or(&false) { + Surface::River + } else if elev - cfg.sea_level < cfg.beach_width && touches_ocean(p) { + Surface::Beach + } else { + Surface::Land + } + } + } + }) +} + +/// `smoothstep(a, b, x)`: 0 below `a`, 1 above `b`, and a smooth Hermite ramp +/// between. Degenerate `a == b` returns a hard step at the threshold. +fn smoothstep(a: f32, b: f32, x: f32) -> f32 { + if b <= a { + return if x < a { 0.0 } else { 1.0 }; + } + let t = ((x - a) / (b - a)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Linear interpolation from `a` to `b` by `t`. +fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + t * (b - a) +} + +impl World { + /// Renders the biome map as ASCII (one [`Biome::glyph`] per cell, rows + /// separated by newlines) for quick eyeballing under `cargo test -- + /// --nocapture`. Presentation only — the renderer owns color. + pub fn to_ascii(&self) -> String { + let mut out = String::with_capacity((self.width as usize + 1) * self.height as usize); + for y in 0..self.height as i32 { + for x in 0..self.width as i32 { + let b = self.biome.get(Point::new(x, y)).copied().unwrap_or(Biome::DeepOcean); + out.push(b.glyph()); + } + out.push('\n'); + } + out + } + + /// The fraction of cells that are land (not any water biome) — a quick + /// land/sea ratio for sanity checks and tuning. + pub fn land_fraction(&self) -> f32 { + let total = (self.width as usize * self.height as usize).max(1); + let land = self.biome.iter().filter(|(_, b)| !b.is_water()).count(); + land as f32 / total as f32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + /// Same seed + config ⇒ byte-identical worlds (the determinism contract). + #[test] + fn same_seed_produces_identical_worlds() { + let cfg = WorldConfig::default(); + let a = generate(cfg, 2026).unwrap(); + let b = generate(cfg, 2026).unwrap(); + assert_eq!(a, b, "same seed must reproduce a byte-identical World"); + assert_eq!(a.to_ascii(), b.to_ascii()); + } + + /// Different seeds produce different worlds. + #[test] + fn distinct_seeds_diverge() { + let cfg = WorldConfig::default(); + let a = generate(cfg, 1).unwrap(); + let b = generate(cfg, 2).unwrap(); + assert_ne!(a.biome, b.biome); + } + + /// A default world is a believable mix: it has both sea and land, the land + /// isn't a trivial sliver, and several distinct biomes appear. + #[test] + fn default_world_is_a_varied_mix() { + let cfg = WorldConfig::default(); + for seed in [1u64, 7, 42, 100, 2026] { + let world = generate(cfg, seed).unwrap(); + let land = world.land_fraction(); + assert!( + (0.15..0.85).contains(&land), + "seed {seed}: land fraction {land} is not a believable mix" + ); + + let biomes: BTreeSet = world.biome.iter().map(|(_, &b)| b).collect(); + assert!( + biomes.len() >= 5, + "seed {seed}: expected a varied world, saw only {} biomes", + biomes.len() + ); + // There is open ocean and there is dry land. + assert!(biomes.iter().any(|b| b.is_water()), "seed {seed}: no water"); + assert!(biomes.iter().any(|b| !b.is_water()), "seed {seed}: no land"); + } + } + + /// Rivers appear and they are made of River-surface land cells (the flow + /// accumulation actually carved channels). + #[test] + fn default_world_has_rivers() { + let cfg = WorldConfig::default(); + let mut total_river = 0usize; + for seed in 1..=8u64 { + let world = generate(cfg, seed).unwrap(); + total_river += world.biome.iter().filter(|(_, &b)| b == Biome::River).count(); + } + assert!(total_river > 0, "expected some river cells across 8 seeds"); + } + + /// Invalid configs are rejected, never panic. + #[test] + fn invalid_configs_are_rejected() { + let zero = WorldConfig { width: 0, ..WorldConfig::default() }; + assert_eq!(generate(zero, 0).err(), Some(WorldConfigError::ZeroDimension)); + + let bad_sea = WorldConfig { sea_level: 1.5, ..WorldConfig::default() }; + assert_eq!(generate(bad_sea, 0).err(), Some(WorldConfigError::SeaLevelOutOfRange)); + + let no_oct = WorldConfig { elevation_octaves: 0, ..WorldConfig::default() }; + assert_eq!(generate(no_oct, 0).err(), Some(WorldConfigError::NoOctaves)); + } + + /// Prints one world for eyeball confirmation under `--nocapture`. + #[test] + fn print_one_world_for_eyeballing() { + // A small canvas so the ASCII fits a terminal. + let cfg = WorldConfig { width: 100, height: 50, ..WorldConfig::default() }; + let world = generate(cfg, 2026).unwrap(); + println!( + "\n--- world seed=2026 {}x{} (land {:.0}%) ---\n{}--- end world ---", + cfg.width, + cfg.height, + world.land_fraction() * 100.0, + world.to_ascii() + ); + assert!(!world.to_ascii().is_empty()); + } +} diff --git a/reikhelm-core/src/world/noise.rs b/reikhelm-core/src/world/noise.rs new file mode 100644 index 0000000..977c32f --- /dev/null +++ b/reikhelm-core/src/world/noise.rs @@ -0,0 +1,241 @@ +//! Deterministic coherent noise: seeded Perlin gradient noise, fractal Brownian +//! motion (fBm), and domain warping. +//! +//! We **own the noise** rather than pull a `noise` crate — same reasoning the +//! project applies to `range`/`shuffle` (see `conventions.md`): a third-party +//! generator's output can drift across versions, which would silently change +//! every world for a given seed. This implementation is a pure function of a +//! permutation table built from our [`Rng`], and uses only `+ - * /` and +//! comparisons — **no transcendental functions** — so a seed reproduces the same +//! field on every machine (spec §7 in spirit). +//! +//! The classic 2D Perlin construction: lattice corners each carry a pseudo-random +//! gradient (selected by a hashed permutation), the sample's offset from each +//! corner is dotted with that corner's gradient, and the four corner +//! contributions are interpolated with Perlin's quintic fade curve. + +use crate::rng::Rng; + +/// A seeded 2D coherent-noise sampler. +/// +/// Build one with [`Noise::new`] (consuming randomness from a forked [`Rng`]), +/// then sample with [`perlin`](Noise::perlin) for a single octave or +/// [`fbm`](Noise::fbm) for layered fractal detail. Two `Noise` values built from +/// distinct RNG sub-streams are decorrelated, which is how the generator keeps +/// elevation, moisture, and warp fields independent. +pub struct Noise { + /// The classic Perlin permutation, doubled to 512 so corner lookups + /// (`perm[x] + y`) never need a modulo. The first 256 entries are a + /// shuffle of `0..256`; entries `256..512` repeat them. + perm: [u8; 512], +} + +impl Noise { + /// Builds a sampler whose gradient layout is determined by `rng`. + /// + /// Draws exactly one shuffle of `0..256` from `rng`, so the permutation — + /// and therefore every sample — is a deterministic function of the stream + /// `rng` was forked from. + pub fn new(rng: &mut Rng) -> Self { + let mut base: Vec = (0..=255).collect(); + rng.shuffle(&mut base); + + let mut perm = [0u8; 512]; + for i in 0..512 { + perm[i] = base[i & 255]; + } + Noise { perm } + } + + /// Samples a single Perlin octave at `(x, y)`, returning roughly `[-1, 1]`. + /// + /// Integer lattice cells are unit-sized, so callers scale their coordinates + /// to choose a feature size (a smaller multiplier ⇒ broader features). + pub fn perlin(&self, x: f32, y: f32) -> f32 { + // Lattice cell origin (wrapped into the permutation's 0..256 domain) and + // the in-cell fractional offset. + let xi = (fast_floor(x) & 255) as usize; + let yi = (fast_floor(y) & 255) as usize; + let xf = x - fast_floor(x) as f32; + let yf = y - fast_floor(y) as f32; + + // Quintic fade for each axis. + let u = fade(xf); + let v = fade(yf); + + // Hash the four cell corners. The doubled `perm` makes `+1` lookups safe + // without wrapping arithmetic. + let p = &self.perm; + let aa = p[p[xi] as usize + yi] as usize; + let ab = p[p[xi] as usize + yi + 1] as usize; + let ba = p[p[xi + 1] as usize + yi] as usize; + let bb = p[p[xi + 1] as usize + yi + 1] as usize; + + // Dot each corner's gradient with the offset *from that corner*, then + // bilinearly interpolate with the faded weights. + let x1 = lerp(grad(aa, xf, yf), grad(ba, xf - 1.0, yf), u); + let x2 = lerp(grad(ab, xf, yf - 1.0), grad(bb, xf - 1.0, yf - 1.0), u); + lerp(x1, x2, v) + } + + /// Samples layered fBm at `(x, y)`, normalized to `[-1, 1]`. + /// + /// Sums `octaves` Perlin octaves, each `lacunarity`× the previous frequency + /// and `gain`× the previous amplitude (the standard fractal sum). The result + /// is divided by the total amplitude so it stays in `[-1, 1]` regardless of + /// the octave count, which keeps downstream thresholds (sea level, etc.) + /// stable when the detail level changes. + pub fn fbm(&self, x: f32, y: f32, octaves: u32, lacunarity: f32, gain: f32) -> f32 { + let mut freq = 1.0; + let mut amp = 1.0; + let mut sum = 0.0; + let mut norm = 0.0; + for _ in 0..octaves { + sum += amp * self.perlin(x * freq, y * freq); + norm += amp; + amp *= gain; + freq *= lacunarity; + } + if norm == 0.0 { + 0.0 + } else { + sum / norm + } + } + + /// fBm with **domain warping**: the sample point is displaced by an + /// independent fBm vector before sampling, which bends otherwise-round + /// features into organic, wandering coastlines and ridges. + /// + /// `warp` scales the displacement (0.0 disables it, reproducing + /// [`fbm`](Noise::fbm)). The warp vector is sampled from the *same* field at + /// large fixed coordinate offsets, so it is decorrelated from the base + /// lookup without needing a second sampler. + pub fn fbm_warped( + &self, + x: f32, + y: f32, + octaves: u32, + lacunarity: f32, + gain: f32, + warp: f32, + ) -> f32 { + // Two decorrelated fBm samples (offset far apart) form the warp vector. + let wx = self.fbm(x + 113.7, y + 31.4, octaves, lacunarity, gain); + let wy = self.fbm(x + 271.9, y + 157.2, octaves, lacunarity, gain); + self.fbm(x + warp * wx, y + warp * wy, octaves, lacunarity, gain) + } +} + +/// Perlin's quintic fade `6t⁵ − 15t⁴ + 10t³`, which has zero first and second +/// derivatives at 0 and 1 so abutting lattice cells blend without creases. +fn fade(t: f32) -> f32 { + t * t * t * (t * (t * 6.0 - 15.0) + 10.0) +} + +/// Linear interpolation from `a` to `b` by `t`. +fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + t * (b - a) +} + +/// `floor` as an integer without calling the libm `floor` intrinsic. +/// +/// For the negative case we subtract one when the truncation rounded *up* +/// toward zero. Keeping this in integer/comparison space (no `f32::floor`) is +/// part of staying transcendental-free for cross-machine determinism. +fn fast_floor(x: f32) -> i32 { + let t = x as i32; + if x < t as f32 { + t - 1 + } else { + t + } +} + +/// Maps a corner's hash to one of eight gradient directions and dots it with the +/// offset `(x, y)`. +/// +/// The eight directions are the axis and diagonal unit-ish vectors; this is the +/// compact 2D gradient set (cheaper than projecting 3D gradients and visually +/// indistinguishable at fBm scale). +fn grad(hash: usize, x: f32, y: f32) -> f32 { + match hash & 7 { + 0 => x + y, + 1 => x - y, + 2 => -x + y, + 3 => -x - y, + 4 => x, + 5 => -x, + 6 => y, + _ => -y, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sampler(seed: u64) -> Noise { + let mut rng = Rng::from_seed(seed); + Noise::new(&mut rng) + } + + /// Same seed ⇒ identical permutation ⇒ identical samples at probe points. + #[test] + fn same_seed_reproduces_samples() { + let a = sampler(42); + let b = sampler(42); + for &(x, y) in &[(0.3, 0.7), (12.5, -4.25), (100.1, 100.1), (-7.0, 3.0)] { + assert_eq!(a.perlin(x, y), b.perlin(x, y)); + assert_eq!( + a.fbm(x, y, 5, 2.0, 0.5), + b.fbm(x, y, 5, 2.0, 0.5), + "fbm must reproduce too" + ); + } + } + + /// Distinct seeds give distinct fields (overwhelmingly). + #[test] + fn distinct_seeds_diverge() { + let a = sampler(1); + let b = sampler(2); + let sa: Vec = (0..32).map(|i| a.fbm(i as f32 * 0.3, 1.0, 4, 2.0, 0.5)).collect(); + let sb: Vec = (0..32).map(|i| b.fbm(i as f32 * 0.3, 1.0, 4, 2.0, 0.5)).collect(); + assert_ne!(sa, sb); + } + + /// Perlin is exactly zero on integer lattice points: every corner offset is + /// zero there, so every gradient dot product vanishes. + #[test] + fn perlin_is_zero_on_lattice() { + let n = sampler(7); + for y in -3..3 { + for x in -3..3 { + assert_eq!(n.perlin(x as f32, y as f32), 0.0, "lattice point ({x},{y})"); + } + } + } + + /// fBm stays within `[-1, 1]` across a sweep (the amplitude normalization + /// holds), so sea-level thresholds downstream are meaningful. + #[test] + fn fbm_is_bounded() { + let n = sampler(123); + for i in 0..1000 { + let x = i as f32 * 0.137; + let y = i as f32 * 0.071; + let v = n.fbm(x, y, 6, 2.0, 0.5); + assert!((-1.0..=1.0).contains(&v), "fbm out of range: {v}"); + } + } + + /// `fast_floor` matches the mathematical floor on both signs. + #[test] + fn fast_floor_matches_floor() { + let cases = [(-2.0, -2), (-1.5, -2), (-0.1, -1), (0.0, 0), (0.9, 0), (3.0, 3), (3.2, 3)]; + for (x, want) in cases { + assert_eq!(fast_floor(x), want, "floor({x})"); + } + } +}