feat(map-gen): Introduce procedural map generation algorithms using noise-based terrain and cellular automata

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-31 04:35:13 -07:00
parent dd55c79536
commit 8fb8a1f72e

View file

@ -100,13 +100,12 @@ impl MapGenerator {
let w = size.width;
let h = size.height;
// Internal generation tiles keyed by axial "q,r"
let mut gen_tiles: HashMap<String, GenTile> = HashMap::new();
// Internal generation tiles keyed by axial (q, r) — integer tuple keys, no string alloc.
let mut gen_tiles: HashMap<(i32, i32), GenTile> = HashMap::with_capacity((w * h) as usize);
for row in 0..h {
for col in 0..w {
let (q, r) = hex::offset_to_axial(col, row);
let key = format!("{},{}", q, r);
gen_tiles.insert(key, GenTile {
gen_tiles.insert((q, r), GenTile {
q, r, col, row,
biome_id: String::new(),
elevation: 0.0,
@ -122,37 +121,33 @@ impl MapGenerator {
}
}
let mut elevation: HashMap<String, f32> = HashMap::new();
let mut elevation: HashMap<(i32, i32), f32> = HashMap::with_capacity((w * h) as usize);
// Stage 1: Region seed placement
let regions = self.place_region_seeds(w, h, &mut rng);
// Stage 2: BFS region growth
grow_regions(&mut gen_tiles, &regions, &mut elevation, &mut rng, w, h);
grow_regions(&mut gen_tiles, &regions, &mut elevation, &mut rng);
// Stage 3: Normalize elevation to [0, 1]
normalize_elevation(&mut gen_tiles, &mut elevation);
// Stage 4: Sea level assignment + coastline smoothing
let ocean_target = self.get_param_f("ocean_percentage.target", 0.40);
assign_sea_level(&mut gen_tiles, ocean_target, &mut elevation, w, h);
assign_sea_level(&mut gen_tiles, ocean_target, &elevation);
// Stage 5: Tectonic relief
place_tectonic_relief(&mut gen_tiles, &elevation, &mut rng, w, h);
place_tectonic_relief(&mut gen_tiles, &elevation, &mut rng);
// Stage 6: Temperature
compute_temperature(&mut gen_tiles, &elevation, h);
// Stage 7: Moisture
let mut moisture_map: HashMap<String, f32> = HashMap::new();
compute_moisture(&mut gen_tiles, &elevation, &mut moisture_map, &mut rng, w, h);
let mut moisture_map: HashMap<(i32, i32), f32> = HashMap::with_capacity((w * h) as usize);
compute_moisture(&mut gen_tiles, &mut moisture_map, &mut rng);
// Stage 8: Terrain patches
let mut temperature_map: HashMap<String, f32> = HashMap::new();
for (key, gt) in &gen_tiles {
temperature_map.insert(key.clone(), gt.temperature);
}
assign_terrain_patches(&mut gen_tiles, &elevation, &moisture_map, &temperature_map, &mut rng, w, h);
assign_terrain_patches(&mut gen_tiles, &mut rng);
// Stage 9: Wind assignment
assign_wind(&mut gen_tiles, h);
@ -185,9 +180,6 @@ impl MapGenerator {
let num_interior = self.get_param_i("generation_params.num_landmass",
20 + (15.0 * ((w * h) as f64 / 2772.0).sqrt()) as i32);
let _cx = w as f32 / 2.0;
let _cy = h as f32 / 2.0;
// Default placement (random scatter)
for _ in 0..num_interior {
let col = rng.randf_range(1.0, w as f32 - 2.0);
@ -246,26 +238,21 @@ struct GenTile {
river_edges: Vec<i32>,
}
fn axial_key(q: i32, r: i32) -> String {
format!("{},{}", q, r)
}
fn grow_regions(
gen_tiles: &mut HashMap<String, GenTile>,
gen_tiles: &mut HashMap<(i32, i32), GenTile>,
regions: &[Region],
elevation: &mut HashMap<String, f32>,
elevation: &mut HashMap<(i32, i32), f32>,
rng: &mut Pcg32,
_w: i32, _h: i32,
) {
let mut claimed = HashSet::new();
let mut queue: Vec<(i32, i32, usize)> = Vec::new();
let mut claimed: HashSet<(i32, i32)> = HashSet::with_capacity(gen_tiles.len());
let mut queue: Vec<(i32, i32, usize)> = Vec::with_capacity(gen_tiles.len());
for (i, reg) in regions.iter().enumerate() {
let key = axial_key(reg.q, reg.r);
let key = (reg.q, reg.r);
if !gen_tiles.contains_key(&key) || claimed.contains(&key) {
continue;
}
claimed.insert(key.clone());
claimed.insert(key);
if let Some(gt) = gen_tiles.get_mut(&key) {
gt.elevation = reg.elevation as f32;
}
@ -278,11 +265,11 @@ fn grow_regions(
let (q, r, ridx) = queue[head];
head += 1;
for (nq, nr) in hex::axial_neighbors(q, r) {
let key = axial_key(nq, nr);
let key = (nq, nr);
if !gen_tiles.contains_key(&key) || claimed.contains(&key) {
continue;
}
claimed.insert(key.clone());
claimed.insert(key);
let elev = regions[ridx].elevation as f32;
if let Some(gt) = gen_tiles.get_mut(&key) {
gt.elevation = elev;
@ -294,7 +281,7 @@ fn grow_regions(
// Elevation fuzz
for gt in gen_tiles.values_mut() {
let key = axial_key(gt.q, gt.r);
let key = (gt.q, gt.r);
let fuzz = rng.randi_range(-2, 2) as f32;
let prev = *elevation.get(&key).unwrap_or(&0.0);
elevation.insert(key, prev + fuzz);
@ -303,11 +290,11 @@ fn grow_regions(
}
fn normalize_elevation(
gen_tiles: &mut HashMap<String, GenTile>,
elevation: &mut HashMap<String, f32>,
gen_tiles: &mut HashMap<(i32, i32), GenTile>,
elevation: &mut HashMap<(i32, i32), f32>,
) {
let mut all_elevs: Vec<f32> = gen_tiles.values().map(|gt| {
*elevation.get(&axial_key(gt.q, gt.r)).unwrap_or(&0.0)
*elevation.get(&(gt.q, gt.r)).unwrap_or(&0.0)
}).collect();
all_elevs.sort_by(|a, b| a.partial_cmp(b).unwrap());
@ -317,7 +304,7 @@ fn normalize_elevation(
}
for gt in gen_tiles.values_mut() {
let key = axial_key(gt.q, gt.r);
let key = (gt.q, gt.r);
let elev = *elevation.get(&key).unwrap_or(&0.0);
let rank = all_elevs.partition_point(|&v| v < elev);
let normalized = rank as f32 / (n - 1) as f32;
@ -327,13 +314,12 @@ fn normalize_elevation(
}
fn assign_sea_level(
gen_tiles: &mut HashMap<String, GenTile>,
gen_tiles: &mut HashMap<(i32, i32), GenTile>,
ocean_target: f32,
elevation: &mut HashMap<String, f32>,
_w: i32, _h: i32,
elevation: &HashMap<(i32, i32), f32>,
) {
let mut all_elevs: Vec<f32> = gen_tiles.values().map(|gt| {
*elevation.get(&axial_key(gt.q, gt.r)).unwrap_or(&0.0)
*elevation.get(&(gt.q, gt.r)).unwrap_or(&0.0)
}).collect();
all_elevs.sort_by(|a, b| a.partial_cmp(b).unwrap());
@ -342,19 +328,18 @@ fn assign_sea_level(
let sea_level = all_elevs[sea_idx];
for gt in gen_tiles.values_mut() {
let elev = *elevation.get(&axial_key(gt.q, gt.r)).unwrap_or(&0.0);
let elev = *elevation.get(&(gt.q, gt.r)).unwrap_or(&0.0);
gt.biome_id = if elev < sea_level { "ocean".to_string() } else { "land".to_string() };
}
// Coastline smoothing (2 iterations)
for _ in 0..2 {
let mut changes: Vec<(String, String)> = Vec::new();
let mut changes: Vec<((i32, i32), String)> = Vec::new();
for gt in gen_tiles.values() {
let mut land_count = 0;
let mut nb_count = 0;
for (nq, nr) in hex::axial_neighbors(gt.q, gt.r) {
let key = axial_key(nq, nr);
if let Some(nb) = gen_tiles.get(&key) {
if let Some(nb) = gen_tiles.get(&(nq, nr)) {
nb_count += 1;
if nb.biome_id != "ocean" && nb.biome_id != "coast" {
land_count += 1;
@ -362,7 +347,7 @@ fn assign_sea_level(
}
}
let water_count = nb_count - land_count;
let key = axial_key(gt.q, gt.r);
let key = (gt.q, gt.r);
if (gt.biome_id == "ocean" || gt.biome_id == "coast") && land_count >= 5 {
changes.push((key, "grassland".to_string()));
} else if gt.biome_id != "ocean" && gt.biome_id != "coast" && water_count >= 5 {
@ -377,7 +362,7 @@ fn assign_sea_level(
}
// Assign coast tiles
let keys: Vec<String> = gen_tiles.keys().cloned().collect();
let keys: Vec<(i32, i32)> = gen_tiles.keys().copied().collect();
for key in &keys {
let gt = gen_tiles.get(key).unwrap();
let q = gt.q;
@ -387,8 +372,7 @@ fn assign_sea_level(
if biome == "ocean" {
let mut has_land = false;
for (nq, nr) in hex::axial_neighbors(q, r) {
let nk = axial_key(nq, nr);
if let Some(nb) = gen_tiles.get(&nk) {
if let Some(nb) = gen_tiles.get(&(nq, nr)) {
if nb.biome_id != "ocean" && nb.biome_id != "coast" {
has_land = true;
break;
@ -400,8 +384,7 @@ fn assign_sea_level(
}
} else if biome != "coast" {
for (nq, nr) in hex::axial_neighbors(q, r) {
let nk = axial_key(nq, nr);
if let Some(nb) = gen_tiles.get(&nk) {
if let Some(nb) = gen_tiles.get(&(nq, nr)) {
if nb.biome_id == "ocean" || nb.biome_id == "coast" {
gen_tiles.get_mut(key).unwrap().is_coastal = true;
break;
@ -413,15 +396,14 @@ fn assign_sea_level(
}
fn place_tectonic_relief(
gen_tiles: &mut HashMap<String, GenTile>,
elevation: &HashMap<String, f32>,
gen_tiles: &mut HashMap<(i32, i32), GenTile>,
elevation: &HashMap<(i32, i32), f32>,
rng: &mut Pcg32,
_w: i32, _h: i32,
) {
let steepness = 0.20f32;
// Local average elevation (radius 3)
let mut local_avg: HashMap<String, f32> = HashMap::new();
let mut local_avg: HashMap<(i32, i32), f32> = HashMap::new();
for gt in gen_tiles.values() {
if gt.biome_id == "ocean" || gt.biome_id == "coast" {
continue;
@ -430,17 +412,17 @@ fn place_tectonic_relief(
let mut total = 0.0f32;
let mut count = 0;
for (nq, nr) in &spiral {
if let Some(e) = elevation.get(&axial_key(*nq, *nr)) {
if let Some(e) = elevation.get(&(*nq, *nr)) {
total += e;
count += 1;
}
}
local_avg.insert(axial_key(gt.q, gt.r), if count > 0 { total / count as f32 } else { 0.5 });
local_avg.insert((gt.q, gt.r), if count > 0 { total / count as f32 } else { 0.5 });
}
let land_keys: Vec<String> = gen_tiles.values()
let land_keys: Vec<(i32, i32)> = gen_tiles.values()
.filter(|gt| gt.biome_id != "ocean" && gt.biome_id != "coast")
.map(|gt| axial_key(gt.q, gt.r))
.map(|gt| (gt.q, gt.r))
.collect();
let land_count = land_keys.len();
if land_count == 0 {
@ -448,7 +430,7 @@ fn place_tectonic_relief(
}
let mut mountain_count = 0usize;
let mut hill_keys: Vec<String> = Vec::new();
let mut hill_keys: Vec<(i32, i32)> = Vec::new();
for key in &land_keys {
let gt = gen_tiles.get(key).unwrap();
@ -458,7 +440,7 @@ fn place_tectonic_relief(
let avg = *local_avg.get(key).unwrap_or(&0.5);
let adj_ocean = hex::axial_neighbors(q, r).iter().any(|(nq, nr)| {
gen_tiles.get(&axial_key(*nq, *nr))
gen_tiles.get(&(*nq, *nr))
.map_or(false, |nb| nb.biome_id == "ocean" || nb.biome_id == "coast")
});
@ -467,10 +449,10 @@ fn place_tectonic_relief(
mountain_count += 1;
} else if !adj_ocean && elev > avg * 1.10 {
gen_tiles.get_mut(key).unwrap().biome_id = "hills".to_string();
hill_keys.push(key.clone());
hill_keys.push(*key);
} else if rng.randf() < 0.40 {
gen_tiles.get_mut(key).unwrap().biome_id = "hills".to_string();
hill_keys.push(key.clone());
hill_keys.push(*key);
}
}
@ -491,33 +473,31 @@ fn place_tectonic_relief(
}
fn compute_temperature(
gen_tiles: &mut HashMap<String, GenTile>,
elevation: &HashMap<String, f32>,
gen_tiles: &mut HashMap<(i32, i32), GenTile>,
elevation: &HashMap<(i32, i32), f32>,
h: i32,
) {
let center_y = h as f32 / 2.0;
for gt in gen_tiles.values_mut() {
let base_temp = 1.0 - ((gt.row as f32 - center_y) / center_y).abs();
let elev = *elevation.get(&axial_key(gt.q, gt.r)).unwrap_or(&0.0);
let elev = *elevation.get(&(gt.q, gt.r)).unwrap_or(&0.0);
let coastal_bonus = if gt.is_coastal { 0.15 } else { 0.0 };
gt.temperature = (base_temp - elev * 0.3 + coastal_bonus).clamp(0.0, 1.0);
}
}
fn compute_moisture(
gen_tiles: &mut HashMap<String, GenTile>,
_elevation: &HashMap<String, f32>,
moisture: &mut HashMap<String, f32>,
gen_tiles: &mut HashMap<(i32, i32), GenTile>,
moisture: &mut HashMap<(i32, i32), f32>,
rng: &mut Pcg32,
_w: i32, _h: i32,
) {
// BFS from ocean/coast tiles
let mut dist: HashMap<String, i32> = HashMap::new();
let mut dist: HashMap<(i32, i32), i32> = HashMap::with_capacity(gen_tiles.len());
let mut queue: Vec<(i32, i32)> = Vec::new();
for gt in gen_tiles.values() {
if gt.biome_id == "ocean" || gt.biome_id == "coast" {
let key = axial_key(gt.q, gt.r);
let key = (gt.q, gt.r);
dist.insert(key, 0);
queue.push((gt.q, gt.r));
}
@ -527,14 +507,14 @@ fn compute_moisture(
while head < queue.len() {
let (q, r) = queue[head];
head += 1;
let d = *dist.get(&axial_key(q, r)).unwrap();
let d = *dist.get(&(q, r)).unwrap();
if d >= 10 {
continue;
}
for (nq, nr) in hex::axial_neighbors(q, r) {
let key = axial_key(nq, nr);
let key = (nq, nr);
if gen_tiles.contains_key(&key) && !dist.contains_key(&key) {
dist.insert(key.clone(), d + 1);
dist.insert(key, d + 1);
queue.push((nq, nr));
}
}
@ -542,7 +522,7 @@ fn compute_moisture(
let noise_seed = rng.randi() as f64;
for gt in gen_tiles.values_mut() {
let key = axial_key(gt.q, gt.r);
let key = (gt.q, gt.r);
let base = 1.0 - *dist.get(&key).unwrap_or(&10) as f32 / 10.0;
let local_v = (hex::hash_noise(gt.q as f64 * 0.08, gt.r as f64 * 0.08, noise_seed) as f32 + 1.0) / 2.0;
let moist = (base + local_v * 0.2).clamp(0.0, 1.0);
@ -552,12 +532,8 @@ fn compute_moisture(
}
fn assign_terrain_patches(
gen_tiles: &mut HashMap<String, GenTile>,
_elevation: &HashMap<String, f32>,
_moisture: &HashMap<String, f32>,
temperature: &HashMap<String, f32>,
gen_tiles: &mut HashMap<(i32, i32), GenTile>,
rng: &mut Pcg32,
_w: i32, _h: i32,
) {
let order = [
"volcano", "jungle", "forest", "boreal_forest", "enchanted_forest",
@ -580,7 +556,7 @@ fn assign_terrain_patches(
let is_frozen = *terrain_id == "snow";
for gt in gen_tiles.values() {
if gt.biome_id != "land" { continue; }
let t = *temperature.get(&axial_key(gt.q, gt.r)).unwrap_or(&0.5);
let t = gt.temperature;
if is_frozen && t < 0.10 { target_count += 1; }
else if !is_frozen && t >= 0.10 && t < 0.25 { target_count += 1; }
}
@ -599,11 +575,11 @@ fn assign_terrain_patches(
if target_count == 0 { continue; }
// Simple BFS expansion from random seeds
// Simple random placement from eligible tiles
let src = if *terrain_id == "volcano" { "mountains" } else { "land" };
let eligible: Vec<String> = gen_tiles.values()
let eligible: Vec<(i32, i32)> = gen_tiles.values()
.filter(|gt| gt.biome_id == src)
.map(|gt| axial_key(gt.q, gt.r))
.map(|gt| (gt.q, gt.r))
.collect();
if eligible.is_empty() { continue; }
@ -614,8 +590,8 @@ fn assign_terrain_patches(
while placed < target_count && attempts < max_attempts && !eligible.is_empty() {
attempts += 1;
let idx = rng.randi_range(0, eligible.len() as i32 - 1) as usize;
let key = &eligible[idx];
if let Some(gt) = gen_tiles.get_mut(key) {
let key = eligible[idx];
if let Some(gt) = gen_tiles.get_mut(&key) {
if gt.biome_id == src {
gt.biome_id = terrain_id.to_string();
placed += 1;
@ -632,7 +608,7 @@ fn assign_terrain_patches(
}
}
fn assign_wind(gen_tiles: &mut HashMap<String, GenTile>, h: i32) {
fn assign_wind(gen_tiles: &mut HashMap<(i32, i32), GenTile>, h: i32) {
// Simplified wind bands matching wind_calculator.gd
let cuts = [0.15f32, 0.30, 0.50, 0.60, 0.85];
let dirs = [3, 0, 3, 0, 3, 0]; // alternating E/W trade winds
@ -660,14 +636,13 @@ fn assign_wind(gen_tiles: &mut HashMap<String, GenTile>, h: i32) {
}
}
fn to_grid_state(gen_tiles: &HashMap<String, GenTile>, w: i32, h: i32) -> GridState {
fn to_grid_state(gen_tiles: &HashMap<(i32, i32), GenTile>, w: i32, h: i32) -> GridState {
let mut grid = GridState::new(w, h);
for row in 0..h {
for col in 0..w {
let (q, r) = hex::offset_to_axial(col, row);
let key = axial_key(q, r);
let idx = grid.idx(col, row);
if let Some(gt) = gen_tiles.get(&key) {
if let Some(gt) = gen_tiles.get(&(q, r)) {
let tile = &mut grid.tiles[idx];
tile.temperature = gt.temperature;
tile.moisture = gt.moisture;
@ -715,4 +690,15 @@ mod tests {
assert!(ocean > 0, "Map should have water tiles");
assert!(land > 0, "Map should have land tiles");
}
#[test]
fn test_map_gen_standard_speed() {
// Standard map must complete in reasonable time (performance regression guard).
let gen = MapGenerator::new("{}");
let start = std::time::Instant::now();
let grid = gen.generate(999, "standard");
let elapsed = start.elapsed();
assert_eq!(grid.tiles.len(), (80 * 52) as usize);
assert!(elapsed.as_millis() < 500, "standard map gen took {}ms — too slow", elapsed.as_millis());
}
}