From 8fb8a1f72ee5cd874a0566a278c6d7c4e2120d9f Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 31 Mar 2026 04:35:13 -0700 Subject: [PATCH] =?UTF-8?q?feat(map-gen):=20=E2=9C=A8=20Introduce=20proced?= =?UTF-8?q?ural=20map=20generation=20algorithms=20using=20noise-based=20te?= =?UTF-8?q?rrain=20and=20cellular=20automata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- packages/physics-rs/src/map_gen/mod.rs | 166 +++++++++++-------------- 1 file changed, 76 insertions(+), 90 deletions(-) diff --git a/packages/physics-rs/src/map_gen/mod.rs b/packages/physics-rs/src/map_gen/mod.rs index 457ca3f0..11c1e982 100644 --- a/packages/physics-rs/src/map_gen/mod.rs +++ b/packages/physics-rs/src/map_gen/mod.rs @@ -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 = 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 = 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, ®ions, &mut elevation, &mut rng, w, h); + grow_regions(&mut gen_tiles, ®ions, &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 = 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 = 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, } -fn axial_key(q: i32, r: i32) -> String { - format!("{},{}", q, r) -} - fn grow_regions( - gen_tiles: &mut HashMap, + gen_tiles: &mut HashMap<(i32, i32), GenTile>, regions: &[Region], - elevation: &mut HashMap, + 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, - elevation: &mut HashMap, + gen_tiles: &mut HashMap<(i32, i32), GenTile>, + elevation: &mut HashMap<(i32, i32), f32>, ) { let mut all_elevs: Vec = 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, + gen_tiles: &mut HashMap<(i32, i32), GenTile>, ocean_target: f32, - elevation: &mut HashMap, - _w: i32, _h: i32, + elevation: &HashMap<(i32, i32), f32>, ) { let mut all_elevs: Vec = 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 = 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, - elevation: &HashMap, + 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 = 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 = 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 = 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, - elevation: &HashMap, + 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, - _elevation: &HashMap, - moisture: &mut HashMap, + 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 = 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, - _elevation: &HashMap, - _moisture: &HashMap, - temperature: &HashMap, + 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 = 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, 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, h: i32) { } } -fn to_grid_state(gen_tiles: &HashMap, 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()); + } }