diff --git a/Cargo.toml b/Cargo.toml index 30c0f829..06cc38f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,7 @@ zeroize = { version = "1.8", optional = true } default = [ "acreplace", "batchnoise", + "cave_system_generator", "cellularnoise", "dmi", "file", @@ -116,6 +117,7 @@ default = [ all = [ "acreplace", "batchnoise", + "cave_system_generator", "cellularnoise", "dmi", "dice", @@ -147,6 +149,7 @@ all = [ # default features acreplace = ["aho-corasick"] batchnoise = ["dbpnoise"] +cave_system_generator = ["rand", "rayon", "serde", "serde_json"] cellularnoise = ["rand", "rayon"] dmi = ["png", "image", "qrcode", "serde_repr"] file = [] diff --git a/dmsrc/cave-system-generator.dm b/dmsrc/cave-system-generator.dm new file mode 100644 index 00000000..27b474cd --- /dev/null +++ b/dmsrc/cave-system-generator.dm @@ -0,0 +1,25 @@ +/** + * Generates a procedural dungeon map using BSP tree partitioning, prefab placement, + * MST corridor generation, and Cellular Automata smoothing. + * + * Returns a plain binary grid string (matching cellularnoise format): + * A width*height string of '0' (wall) and '1' (floor) characters in row-major order. + * + * Arguments: + * * width - Grid width + * * height - Grid height + * * prefabs_json - JSON array of prefab configs: [{"x":55,"y":65,"w":10,"h":10,"isEnclosed":true},...] (if none use "[]") x = bottom-left turf x, y = bottom-left turf y, w = prefab width, h = prefab height, isEnclosed = whether prefab should be treated like its wall or floor by the generation + * * min_bsp_size - Minimum BSP leaf dimension + * * max_ratio - Maximum aspect ratio for BSP splits + * * padding - Room edge padding within BSP leaf + * * room_fill_percent - How much of each BSP leaf a room fills, 1-100 + * * corridor_width - Width of corridors between rooms + * * loop_percent - Chance to add extra MST edges for loops + * * noise_percent - Initial random floor density + * * ca_steps - Cellular Automata smoothing iterations + * * birth_limit - Neighbors to create floor (>=) + * * survival_limit - Neighbors to survive as floor (>=) + * * edge_is_alive - Whether out-of-bounds cells count as ALIVE (floor) for CA neighbor counts + */ +#define rustg_cave_system_generator_generate(width, height, prefabs_json, min_bsp_size, max_ratio, padding, room_fill_percent, corridor_width, loop_percent, noise_percent, ca_steps, birth_limit, survival_limit, edge_is_alive) \ + RUSTG_CALL(RUST_G, "cave_system_generator_generate")(width, height, prefabs_json, min_bsp_size, max_ratio, padding, room_fill_percent, corridor_width, loop_percent, noise_percent, ca_steps, birth_limit, survival_limit, edge_is_alive) diff --git a/src/cave_system_generator.rs b/src/cave_system_generator.rs new file mode 100644 index 00000000..f761583a --- /dev/null +++ b/src/cave_system_generator.rs @@ -0,0 +1,700 @@ +use crate::error::Result; +use rand::Rng; +use rand::distr::{Bernoulli, Distribution, Uniform}; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use serde::Deserialize; +use std::collections::VecDeque; + +byond_fn!(fn cave_system_generator_generate( + width, + height, + prefabs_json, + min_bsp_size, + max_ratio, + padding, + room_fill_percent, + corridor_width, + loop_percent, + noise_percent, + ca_steps, + birth_limit, + survival_limit, + edge_is_alive +) { + let config = GeneratorConfig { + min_bsp_size: min_bsp_size.parse::().unwrap_or(20), + max_ratio: max_ratio.parse::().unwrap_or(2.5), + padding: padding.parse::().unwrap_or(2), + size_scale: (room_fill_percent.parse::().unwrap_or(80) as f64 / 100.0).clamp(0.0, 1.0), + corridor_width: corridor_width.parse::().unwrap_or(1).max(1), + loop_percent: loop_percent.parse::().unwrap_or(15), + noise_percent: noise_percent.parse::().unwrap_or(48), + ca_steps: ca_steps.parse::().unwrap_or(6), + birth_limit: birth_limit.parse::().unwrap_or(5), + survival_limit: survival_limit.parse::().unwrap_or(4), + edge_is_alive: edge_is_alive.parse::().unwrap_or(0) != 0, + }; + generate_cave_system(width, height, prefabs_json, config).ok() +}); + +// ─── Cell States ─────────────────────────────────────────────────────────────── + +const DEAD: u8 = 0; // Dynamic wall +const ALIVE: u8 = 1; // Dynamic floor +const DEF_ALIVE: u8 = 2; // Static floor (doesn't change during CA) +const DEF_DEAD: u8 = 3; // Static wall/indestructible (doesn't change during CA) + +// ─── Config Structs ─────────────────────────────────────────────────────────── + +struct GeneratorConfig { + min_bsp_size: usize, + max_ratio: f64, + padding: usize, + size_scale: f64, + corridor_width: usize, + loop_percent: usize, + noise_percent: usize, + ca_steps: usize, + birth_limit: usize, + survival_limit: usize, + edge_is_alive: bool, +} + +// ─── Input Structs ───────────────────────────────────────────────────────────── + +fn deserialize_byond_bool<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::de::Deserializer<'de>, +{ + u8::deserialize(deserializer).map(|x| x != 0) +} + +#[derive(Deserialize)] +struct PrefabConfig { + x: usize, + y: usize, + w: usize, + h: usize, + #[serde( + default, + rename = "isEnclosed", + deserialize_with = "deserialize_byond_bool" + )] + is_enclosed: bool, +} + +// ─── Core Structs ────────────────────────────────────────────────────────────── + +struct BSPNode { + x: usize, + y: usize, + w: usize, + h: usize, + left: Option>, + right: Option>, + room: Option, +} + +struct Room { + x: usize, + y: usize, + w: usize, + h: usize, + cx: usize, + cy: usize, +} + +#[derive(Clone, Copy)] +struct MSTEdge { + u: usize, + v: usize, + dist: f64, +} + +fn generate_cave_system( + width_str: &str, + height_str: &str, + prefabs_json: &str, + cfg: GeneratorConfig, +) -> Result { + let width = width_str.parse::()?; + let height = height_str.parse::()?; + + let prefabs: Vec = if prefabs_json.is_empty() || prefabs_json == "[]" { + Vec::new() + } else { + serde_json::from_str(prefabs_json)? + }; + + let GeneratorConfig { + min_bsp_size, + max_ratio, + padding, + size_scale, + corridor_width, + loop_percent, + noise_percent, + ca_steps, + birth_limit, + survival_limit, + edge_is_alive, + } = cfg; + + if width == 0 || height == 0 { + return Ok(String::new()); + } + + let mut rng = rand::rng(); + + // Initialize our grids + let mut grid: Vec> = vec![vec![DEAD; height]; width]; + // fixed[x][y] = true → set by prefab/room/corridor; noise must not overwrite it + let mut fixed: Vec> = vec![vec![false; height]; width]; + + // Step 1: Apply prefabs first (user-defined locations that are either def alive or def dead) + for prefab in &prefabs { + apply_prefab(&mut grid, &mut fixed, prefab, width, height); + } + + // Step 2: BSP partitioning + let mut root = BSPNode::new(0, 0, width, height); + root.split(min_bsp_size, max_ratio); + let mut leaves: Vec = Vec::new(); + collect_leaves(&root, &mut leaves); + if leaves.is_empty() { + leaves.push(BSPNode::new(0, 0, width, height)); + } + + // Step 3: Create rooms in each leaf, using a random scale and dimension + for leaf in &mut leaves { + leaf.room = generate_room(leaf, padding, size_scale, &mut rng); + } + + // Step 4: Adjacency edges + Kruskal MST + let edges = build_adjacency_edges(&leaves); + let mst_edges = kruskal_mst(leaves.len(), &edges, loop_percent, &mut rng); + + // Step 5: Apply rooms to grid as DEF_ALIVE (to prevent them from being eaten by noise), skipping prefab-fixed cells + leaves.iter().for_each(|leaf| { + if let Some(ref room) = leaf.room { + (0..room.w).for_each(|dx| { + (0..room.h).for_each(|dy| { + let gx = room.x + dx; + let gy = room.y + dy; + if gx < width && gy < height && !fixed[gx][gy] { + grid[gx][gy] = DEF_ALIVE; + fixed[gx][gy] = true; + } + }); + }); + } + }); + + // Step 6: Carve corridors between rooms along edges, marking them as DEF_ALIVE (this prevents the CA from eating them up later) + for edge in &mst_edges { + if let (Some(ra), Some(rb)) = (leaves[edge.u].room.as_ref(), leaves[edge.v].room.as_ref()) { + carve_corridor( + &mut grid, + &mut fixed, + (ra.cx, ra.cy), + (rb.cx, rb.cy), + corridor_width, + &mut rng, + ); + } + } + + // Step 7: Apply noise only to unfixed (empty) cells + let prob = Bernoulli::new((noise_percent as f64 / 100.0).clamp(0.0, 1.0)).unwrap(); + for x in 0..width { + for y in 0..height { + if !fixed[x][y] { + grid[x][y] = if prob.sample(&mut rng) { ALIVE } else { DEAD }; + } + } + } + + // Step 8: CA smoothing + for _ in 0..ca_steps { + ca_step( + &mut grid, + width, + height, + birth_limit, + survival_limit, + edge_is_alive, + ); + } + + // Step 9: BFS flood-fill island removal from first room center + if let Some(start) = leaves + .iter() + .find_map(|l| l.room.as_ref().map(|r| (r.cx, r.cy))) + { + flood_fill_island_removal(&mut grid, width, height, start); + } + + // Output: row-ordered string of all the final tiles (0 = wall, 1 = floor). + let grid_string: String = (0..height) + .flat_map(|y| (0..width).map(move |x| (x, y))) + .map(|(x, y)| match grid[x][y] { + ALIVE | DEF_ALIVE => '1', + _ => '0', + }) + .collect(); + + Ok(grid_string) +} + +//Apply a prefab to the grid. Marking its tiles as either DEF_DEAD or DEF_ALIVE depending on is_enclosed. This lets us either make the ruins spawn covered in walls, or treated as open (which makes the CA carve it out more). cx/cy are the bottom-left turf (1-indexed). +fn apply_prefab( + grid: &mut [Vec], + fixed: &mut [Vec], + prefab: &PrefabConfig, + width: usize, + height: usize, +) { + let px = prefab.x.saturating_sub(1); + let py = prefab.y.saturating_sub(1); + let pw = prefab.w.min(width.saturating_sub(px)); + let ph = prefab.h.min(height.saturating_sub(py)); + + for dy in 0..ph { + for dx in 0..pw { + let gx = px + dx; + let gy = py + dy; + if gx < width && gy < height { + if prefab.is_enclosed { + grid[gx][gy] = DEF_DEAD; + } else { + grid[gx][gy] = DEF_ALIVE; + } + fixed[gx][gy] = true; + } + } + } +} + +///BSP behavior +impl BSPNode { + fn new(x: usize, y: usize, w: usize, h: usize) -> Self { + BSPNode { + x, + y, + w, + h, + left: None, + right: None, + room: None, + } + } + + fn split(&mut self, min_size: usize, max_ratio: f64) { + let mut rng = rand::rng(); + + let can_split_h = self.h > min_size * 2; + let can_split_v = self.w > min_size * 2; + if !can_split_h && !can_split_v { + return; + } + + // Random pick which split + let coin = Bernoulli::new(0.5).unwrap(); + let mut split_horizontal = coin.sample(&mut rng); // true = split by Y + if self.h > 0 && (self.w as f64 / self.h as f64) >= max_ratio { + split_horizontal = false; // too wide → split vertically (by X) + } + if self.w > 0 && (self.h as f64 / self.w as f64) >= max_ratio { + split_horizontal = true; // too tall → split horizontally (by Y) + } + + // Fall back if forced direction isn't valid + if split_horizontal && !can_split_h { + split_horizontal = false; + } else if !split_horizontal && !can_split_v { + split_horizontal = true; + } + if split_horizontal && !can_split_h { + return; + } + if !split_horizontal && !can_split_v { + return; + } + + if split_horizontal { + let split_y = Uniform::new(min_size, self.h - min_size) + .unwrap() + .sample(&mut rng); + let mut left = BSPNode::new(self.x, self.y, self.w, split_y); + let mut right = BSPNode::new(self.x, self.y + split_y, self.w, self.h - split_y); + left.split(min_size, max_ratio); + right.split(min_size, max_ratio); + self.left = Some(Box::new(left)); + self.right = Some(Box::new(right)); + } else { + let split_x = Uniform::new(min_size, self.w - min_size) + .unwrap() + .sample(&mut rng); + let mut left = BSPNode::new(self.x, self.y, split_x, self.h); + let mut right = BSPNode::new(self.x + split_x, self.y, self.w - split_x, self.h); + left.split(min_size, max_ratio); + right.split(min_size, max_ratio); + self.left = Some(Box::new(left)); + self.right = Some(Box::new(right)); + } + } +} + +fn collect_leaves(node: &BSPNode, leaves: &mut Vec) { + if node.left.is_none() && node.right.is_none() { + leaves.push(BSPNode::new(node.x, node.y, node.w, node.h)); + return; + } + if let Some(ref left) = node.left { + collect_leaves(left, leaves); + } + if let Some(ref right) = node.right { + collect_leaves(right, leaves); + } +} + +// Generate a room within a BSP leaf. Scales to a % of the leaf size with a random offset +fn generate_room( + leaf: &BSPNode, + padding: usize, + size_scale: f64, + rng: &mut impl Rng, +) -> Option { + let max_w = leaf.w.saturating_sub(padding * 2); + let max_h = leaf.h.saturating_sub(padding * 2); + if max_w < 3 || max_h < 3 { + return None; + } + + let lo_w = ((max_w as f64 * 0.3) as usize).max(1); + let hi_w = (max_w as f64 * size_scale) as usize; + let rw = (if hi_w > lo_w { + Uniform::new(lo_w, hi_w).unwrap().sample(rng) + } else { + lo_w + }) + .max(3) + .min(max_w); + + let lo_h = ((max_h as f64 * 0.3) as usize).max(1); + let hi_h = (max_h as f64 * size_scale) as usize; + let rh = (if hi_h > lo_h { + Uniform::new(lo_h, hi_h).unwrap().sample(rng) + } else { + lo_h + }) + .max(3) + .min(max_h); + + let rx = { + let lo = padding; + let hi = leaf.w.saturating_sub(rw + padding); + let offset = if hi > lo { + Uniform::new(lo, hi).unwrap().sample(rng) + } else { + lo + }; + leaf.x + offset + }; + let ry = { + let lo = padding; + let hi = leaf.h.saturating_sub(rh + padding); + let offset = if hi > lo { + Uniform::new(lo, hi).unwrap().sample(rng) + } else { + lo + }; + leaf.y + offset + }; + + Some(Room { + x: rx, + y: ry, + w: rw, + h: rh, + cx: rx + rw / 2, + cy: ry + rh / 2, + }) +} + +// Build edges only between BSP-adjacent leaves that both have rooms +fn build_adjacency_edges(leaves: &[BSPNode]) -> Vec { + let mut edges = Vec::new(); + let n = leaves.len(); + for i in 0..n { + for j in (i + 1)..n { + if !rectangles_adjacent(&leaves[i], &leaves[j]) { + continue; + } + let (ra, rb) = match (leaves[i].room.as_ref(), leaves[j].room.as_ref()) { + (Some(a), Some(b)) => (a, b), + _ => continue, + }; + let dist = distance(ra.cx, ra.cy, rb.cx, rb.cy); + edges.push(MSTEdge { u: i, v: j, dist }); + } + } + edges +} + +fn rectangles_adjacent(a: &BSPNode, b: &BSPNode) -> bool { + let a_right = a.x + a.w; + let a_bottom = a.y + a.h; + let b_right = b.x + b.w; + let b_bottom = b.y + b.h; + ((a.x == b_right || b.x == a_right) && !(a.y >= b_bottom || b.y >= a_bottom)) + || ((a.y == b_bottom || b.y == a_bottom) && !(a.x >= b_right || b.x >= a_right)) +} + +fn distance(x1: usize, y1: usize, x2: usize, y2: usize) -> f64 { + let dx = x1 as f64 - x2 as f64; + let dy = y1 as f64 - y2 as f64; + (dx * dx + dy * dy).sqrt() +} + +fn kruskal_mst( + n: usize, + edges: &[MSTEdge], + loop_percent: usize, + rng: &mut impl Rng, +) -> Vec { + let mut sorted = edges.to_vec(); + sorted.sort_by(|a, b| a.dist.partial_cmp(&b.dist).unwrap()); + + let mut parent: Vec = (0..n).collect(); + let mut result = Vec::with_capacity(sorted.len()); + let loop_coin = Bernoulli::new((loop_percent as f64 / 100.0).clamp(0.0, 1.0)).unwrap(); + + for edge in &sorted { + let ru = uf_find(&parent, edge.u); + let rv = uf_find(&parent, edge.v); + if ru != rv { + uf_union(&mut parent, edge.u, edge.v); + result.push(*edge); + } else if loop_coin.sample(rng) { + result.push(*edge); + } + } + result +} + +fn uf_find(parent: &[usize], mut x: usize) -> usize { + while parent[x] != x { + x = parent[x]; + } + x +} + +fn uf_union(parent: &mut [usize], a: usize, b: usize) { + let ra = uf_find(parent, a); + let rb = uf_find(parent, b); + if ra != rb { + parent[ra] = rb; + } +} + +//Step towards the other edge until we reach it +fn carve_corridor( + grid: &mut [Vec], + fixed: &mut [Vec], + start: (usize, usize), + end: (usize, usize), + cw: usize, + rng: &mut impl Rng, +) { + let coin = Bernoulli::new(0.5).unwrap(); + let go_x_first = coin.sample(rng); + let mut cx = start.0 as i32; + let mut cy = start.1 as i32; + let tx = end.0 as i32; + let ty = end.1 as i32; + let cw_i = cw as i32; + + if go_x_first { + while cx != tx { + cx += (tx - cx).signum(); + paint_brush(grid, fixed, cx, cy, cw_i); + } + while cy != ty { + cy += (ty - cy).signum(); + paint_brush(grid, fixed, cx, cy, cw_i); + } + } else { + while cy != ty { + cy += (ty - cy).signum(); + paint_brush(grid, fixed, cx, cy, cw_i); + } + while cx != tx { + cx += (tx - cx).signum(); + paint_brush(grid, fixed, cx, cy, cw_i); + } + } +} + +#[inline] +fn paint_brush(grid: &mut [Vec], fixed: &mut [Vec], cx: i32, cy: i32, cw: i32) { + let width = grid.len(); + for i in 0..cw { + for j in 0..cw { + let nx = cx + i; + let ny = cy + j; + if nx >= 0 && ny >= 0 { + let nx = nx as usize; + let ny = ny as usize; + let height = grid.get(nx).map_or(0, |col| col.len()); + if nx < width && ny < height && grid[nx][ny] != DEF_DEAD { + grid[nx][ny] = DEF_ALIVE; + fixed[nx][ny] = true; + } + } + } + } +} + +// Cellular automata except we use def_alive and def_dead to prevent them from flipping, basically nudging the noise a certain way and preventing it from eating up rooms/corridors/prefabs +fn ca_step( + grid: &mut Vec>, + width: usize, + height: usize, + birth_limit: usize, + survival_limit: usize, + edge_is_alive: bool, +) { + let grid_ref: &Vec> = grid; + let new_grid: Vec> = (0..width) + .into_par_iter() + .map(|x| { + (0..height) + .map(|y| { + let cell = grid_ref[x][y]; + if cell == DEF_ALIVE || cell == DEF_DEAD { + return cell; + } + let count = count_alive_neighbors(grid_ref, x, y, width, height, edge_is_alive); + if cell == ALIVE { + if count >= survival_limit { ALIVE } else { DEAD } + } else if count >= birth_limit { + ALIVE + } else { + DEAD + } + }) + .collect() + }) + .collect(); + *grid = new_grid; +} + +fn count_alive_neighbors( + grid: &[Vec], + x: usize, + y: usize, + width: usize, + height: usize, + edge_is_alive: bool, +) -> usize { + let mut count = 0; + for dx in -1i32..=1 { + for dy in -1i32..=1 { + if dx == 0 && dy == 0 { + continue; + } + let nx = x as i32 + dx; + let ny = y as i32 + dy; + if nx >= 0 && nx < width as i32 && ny >= 0 && ny < height as i32 { + let neighbor = grid[nx as usize][ny as usize]; + if neighbor == ALIVE || neighbor == DEF_ALIVE { + count += 1; + } + } else if edge_is_alive { + count += 1; + } + } + } + count +} + +// kills unreachable ALIVE; DEF_DEAD/DEF_ALIVE is left untouched. +fn flood_fill_island_removal( + grid: &mut [Vec], + width: usize, + height: usize, + start: (usize, usize), +) { + let (sx, sy) = start; + if sx >= width || sy >= height { + return; + } + + let mut visited = vec![vec![false; height]; width]; + let mut queue: VecDeque<(usize, usize)> = VecDeque::new(); + visited[sx][sy] = true; + queue.push_back((sx, sy)); + + while let Some((cx, cy)) = queue.pop_front() { + for (ddx, ddy) in [(0i32, 1i32), (0, -1), (1, 0), (-1, 0)] { + let nx = cx as i32 + ddx; + let ny = cy as i32 + ddy; + if nx >= 0 && nx < width as i32 && ny >= 0 && ny < height as i32 { + let nx = nx as usize; + let ny = ny as usize; + if !visited[nx][ny] && grid[nx][ny] != DEAD { + visited[nx][ny] = true; + queue.push_back((nx, ny)); + } + } + } + } + + // For each unvisited pocket: if it contains DEF_ALIVE, keep its ALIVE tiles; otherwise kill them. This preserves natural formations around ruin spawns + let mut component_visited = vec![vec![false; height]; width]; + for x in 0..width { + for y in 0..height { + if visited[x][y] || component_visited[x][y] { + continue; + } + let cell = grid[x][y]; + if cell != ALIVE && cell != DEF_ALIVE { + continue; + } + // BFS the pocket + let mut alive_cells: Vec<(usize, usize)> = Vec::new(); + let mut has_def_alive = false; + let mut comp_queue: VecDeque<(usize, usize)> = VecDeque::new(); + component_visited[x][y] = true; + comp_queue.push_back((x, y)); + while let Some((cx, cy)) = comp_queue.pop_front() { + match grid[cx][cy] { + DEF_ALIVE => has_def_alive = true, + ALIVE => alive_cells.push((cx, cy)), + _ => {} + } + for (ddx, ddy) in [(0i32, 1i32), (0, -1), (1, 0), (-1, 0)] { + let nx = cx as i32 + ddx; + let ny = cy as i32 + ddy; + if nx >= 0 && nx < width as i32 && ny >= 0 && ny < height as i32 { + let nx = nx as usize; + let ny = ny as usize; + if !visited[nx][ny] + && !component_visited[nx][ny] + && (grid[nx][ny] == ALIVE || grid[nx][ny] == DEF_ALIVE) + { + component_visited[nx][ny] = true; + comp_queue.push_back((nx, ny)); + } + } + } + } + if !has_def_alive { + for (ax, ay) in alive_cells { + grid[ax][ay] = DEAD; + } + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index a5fe195a..8fd82632 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,8 @@ mod jobs; #[cfg(feature = "acreplace")] pub mod acreplace; +#[cfg(feature = "cave_system_generator")] +pub mod cave_system_generator; #[cfg(feature = "cellularnoise")] pub mod cellularnoise; #[cfg(feature = "dbpnoise")]