From 9310bc2e412efbc78f4f7a5d061d005396d8609d Mon Sep 17 00:00:00 2001 From: grimsace Date: Fri, 6 Mar 2026 09:10:40 -0600 Subject: [PATCH] added settings for corredor generation --- README.md | 55 ++++- src/layout.rs | 562 ++++++++++++++++++++++++++++++++++---------------- src/main.rs | 53 ++++- 3 files changed, 487 insertions(+), 183 deletions(-) diff --git a/README.md b/README.md index cdebb9b..d92a390 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,55 @@ -# desktop_dungeon_generator +# Desktop Dungeon Generator +A Rust + `egui` desktop app for generating simple tabletop dungeon layouts on a scalable grid. + +## Current Features + +- Resizable split layout: + - Left panel: generation settings + - Right panel: vector-rendered grid + dungeon preview +- Grid controls: + - Columns + - Rows +- Room controls: + - Room count + - Minimum room size (in grid cells) + - Maximum room size (in grid cells) + - `Square Rooms Only` toggle +- Corridor controls: + - `Corridor Randomness (%)` from `0` to `100` + - `Dead-End Rooms (%)` from `0` to `50` +- Generate control: + - `Generate New Layout` button increments seed and creates a new layout + +## Generation Behavior + +- Rooms are stored as rectangles (`x`, `y`, `width`, `height`) and placed without overlap. +- Corridors are stored separately as vector line segments between grid cells. +- Every generated room is connected into one navigable network when possible. + +### Corridor Randomness + +- `0%` randomness: + - Uses shortest-path routing for room-to-room corridor paths. + - Produces cleaner, direct corridor routes. +- `100%` randomness: + - Uses noisy/randomized routing while still ensuring room connectivity. + - Produces more wandering, less direct routes. +- Intermediate values blend between the two behaviors. + +### Dead-End Rooms + +- Controls how many rooms should end up as dead ends (rooms with only one room-to-room connection). +- `0%` targets no dead-end rooms (except tiny edge cases where topology makes this impossible, such as only 2 rooms). +- `50%` is the upper cap to avoid over-constraining connectivity. + +## Run + +```bash +cargo run +``` + +## Notes + +- Rendering is vector-based in-app (`egui` painter), so zooming/scaling the preview remains crisp. +- Room and corridor vectors are stored separately to support future export workflows (for example, SVG export). diff --git a/src/layout.rs b/src/layout.rs index 7230d66..425c616 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashSet, VecDeque}; #[derive(Debug, Clone)] pub struct Room { @@ -34,11 +34,12 @@ pub fn generate_layout( min_room_size: usize, max_room_size: usize, square_rooms_only: bool, + corridor_randomness_percent: usize, + dead_end_room_percent: usize, ) -> DungeonLayout { let mut rng = SimpleRng::new(seed ^ ((cols as u64) << 32) ^ rows as u64); let mut rooms = Vec::new(); let mut corridors = Vec::new(); - let mut occupied_corridor_cells = HashSet::new(); if cols < 2 || rows < 2 || target_room_count == 0 { return DungeonLayout { rooms, corridors }; @@ -49,12 +50,8 @@ pub fn generate_layout( if square_rooms_only { let hard_max = cols.min(rows); - if min_size > hard_max { - min_size = hard_max; - } - if max_size > hard_max { - max_size = hard_max; - } + min_size = min_size.min(hard_max); + max_size = max_size.min(hard_max); } else { min_size = min_size.min(cols.min(rows)); max_size = max_size.min(cols.max(rows)); @@ -71,29 +68,27 @@ pub fn generate_layout( break; } - let width; - let height; - - if square_rooms_only { + let (width, height) = if square_rooms_only { let side = rng.range_inclusive(min_size, max_size.min(cols.min(rows))); - width = side; - height = side; + (side, side) } else { let width_max = max_size.min(cols); let height_max = max_size.min(rows); if min_size > width_max || min_size > height_max { continue; } - width = rng.range_inclusive(min_size, width_max); - height = rng.range_inclusive(min_size, height_max); - } + ( + rng.range_inclusive(min_size, width_max), + rng.range_inclusive(min_size, height_max), + ) + }; if width > cols || height > rows { continue; } + let x = rng.range_inclusive(0, cols - width); let y = rng.range_inclusive(0, rows - height); - let candidate = Room { x, y, @@ -109,78 +104,383 @@ pub fn generate_layout( } } - if !rooms.is_empty() { - let mut connected = vec![false; rooms.len()]; - connected[0] = true; + if rooms.len() < 2 { + return DungeonLayout { rooms, corridors }; + } - loop { - let mut progress = false; - let connected_indices: Vec = connected - .iter() - .enumerate() - .filter_map(|(idx, is_connected)| if *is_connected { Some(idx) } else { None }) - .collect(); + let randomness = (corridor_randomness_percent.min(100) as f32) / 100.0; + let centers: Vec<(usize, usize)> = rooms.iter().map(Room::center_cell).collect(); + let target_dead_end_rooms = ((rooms.len() * dead_end_room_percent.min(50)) + 50) / 100; + let room_edges = + build_room_connection_edges(¢ers, randomness, target_dead_end_rooms, &mut rng); + let mut occupied_corridor_cells = HashSet::new(); - for room_idx in 0..rooms.len() { - if connected[room_idx] { - continue; - } + for (a_idx, b_idx) in room_edges { + let a = centers[a_idx]; + let b = centers[b_idx]; - let mut anchors = connected_indices.clone(); - anchors.sort_by_key(|anchor_idx| { - manhattan_distance( - rooms[*anchor_idx].center_cell(), - rooms[room_idx].center_cell(), - ) - }); + let preferred = if randomness <= 0.001 { + shortest_path(a, b, cols, rows).unwrap_or_else(|| vec![a, b]) + } else { + noisy_path(a, b, cols, rows, randomness, &mut rng) + }; - let mut did_connect = false; - - for &anchor_idx in &anchors { - if try_connect_rooms( - rooms[anchor_idx].center_cell(), - rooms[room_idx].center_cell(), - true, - &mut rng, - &mut occupied_corridor_cells, - &mut corridors, - ) { - did_connect = true; - break; - } - } - - if !did_connect { - for &anchor_idx in &anchors { - if try_connect_rooms( - rooms[anchor_idx].center_cell(), - rooms[room_idx].center_cell(), - false, - &mut rng, - &mut occupied_corridor_cells, - &mut corridors, - ) { - did_connect = true; - break; - } - } - } - - if did_connect { - connected[room_idx] = true; - progress = true; - } - } - - if connected.iter().all(|is_connected| *is_connected) || !progress { - break; - } + if !try_place_cell_path( + &preferred, + &mut occupied_corridor_cells, + &mut corridors, + true, + ) { + let fallback = shortest_path(a, b, cols, rows).unwrap_or_else(|| vec![a, b]); + let _ = try_place_cell_path( + &fallback, + &mut occupied_corridor_cells, + &mut corridors, + false, + ); } } DungeonLayout { rooms, corridors } } +fn build_room_connection_edges( + centers: &[(usize, usize)], + randomness: f32, + target_dead_end_rooms: usize, + rng: &mut SimpleRng, +) -> Vec<(usize, usize)> { + if centers.len() < 2 { + return Vec::new(); + } + + let room_count = centers.len(); + let max_dead_ends = room_count / 2; + let desired_dead_ends = target_dead_end_rooms.min(max_dead_ends); + let core_count = (room_count - desired_dead_ends).max(1); + + let mut room_indices: Vec = (0..room_count).collect(); + shuffle_indices(&mut room_indices, rng); + + let mut core_rooms = room_indices[..core_count].to_vec(); + let leaf_rooms = room_indices[core_count..].to_vec(); + core_rooms = ordered_core_rooms(&core_rooms, centers, randomness, rng); + + let mut edges = Vec::new(); + let mut edge_set = HashSet::new(); + + if core_rooms.len() >= 2 { + for pair in core_rooms.windows(2) { + push_unique_room_edge(pair[0], pair[1], &mut edges, &mut edge_set); + } + + if core_rooms.len() >= 3 { + push_unique_room_edge( + core_rooms[core_rooms.len() - 1], + core_rooms[0], + &mut edges, + &mut edge_set, + ); + } + } + + for leaf in leaf_rooms { + let mut best_anchor = core_rooms[0]; + let mut best_score = f32::INFINITY; + + for &core in &core_rooms { + let dist = manhattan_distance(centers[leaf], centers[core]) as f32; + let score = (dist * (1.0 - 0.8 * randomness)) + (rng.next_f32() * 30.0 * randomness); + if score < best_score { + best_score = score; + best_anchor = core; + } + } + + push_unique_room_edge(leaf, best_anchor, &mut edges, &mut edge_set); + } + + edges +} + +fn ordered_core_rooms( + core_rooms: &[usize], + centers: &[(usize, usize)], + randomness: f32, + rng: &mut SimpleRng, +) -> Vec { + if core_rooms.len() <= 2 { + return core_rooms.to_vec(); + } + + let mut remaining = core_rooms.to_vec(); + let start_idx = rng.range_inclusive(0, remaining.len() - 1); + let mut ordered = vec![remaining.swap_remove(start_idx)]; + + while !remaining.is_empty() { + let last = *ordered.last().unwrap_or(&remaining[0]); + let mut best_idx = 0usize; + let mut best_score = f32::INFINITY; + + for (idx, candidate) in remaining.iter().enumerate() { + let dist = manhattan_distance(centers[last], centers[*candidate]) as f32; + let score = (dist * (1.0 - 0.85 * randomness)) + (rng.next_f32() * 20.0 * randomness); + if score < best_score { + best_score = score; + best_idx = idx; + } + } + + ordered.push(remaining.swap_remove(best_idx)); + } + + ordered +} + +fn push_unique_room_edge( + a: usize, + b: usize, + edges: &mut Vec<(usize, usize)>, + edge_set: &mut HashSet<(usize, usize)>, +) { + if a == b { + return; + } + let normalized = if a < b { (a, b) } else { (b, a) }; + if edge_set.insert(normalized) { + edges.push((a, b)); + } +} + +fn shuffle_indices(indices: &mut [usize], rng: &mut SimpleRng) { + if indices.len() <= 1 { + return; + } + for i in (1..indices.len()).rev() { + let j = rng.range_inclusive(0, i); + indices.swap(i, j); + } +} + +fn try_place_cell_path( + path: &[(usize, usize)], + occupied: &mut HashSet<(usize, usize)>, + corridors: &mut Vec, + enforce_gap: bool, +) -> bool { + if path.len() < 2 { + return false; + } + + if enforce_gap && !has_required_corridor_gap(path, occupied) { + return false; + } + + for segment in path.windows(2) { + if segment[0] != segment[1] { + corridors.push(Corridor { + from: segment[0], + to: segment[1], + }); + } + } + + for &cell in path { + occupied.insert(cell); + } + + true +} + +fn has_required_corridor_gap(path: &[(usize, usize)], occupied: &HashSet<(usize, usize)>) -> bool { + if path.len() < 3 { + return true; + } + + for idx in 1..(path.len() - 1) { + let (x, y) = path[idx]; + let x = x as isize; + let y = y as isize; + + for dx in -1..=1 { + for dy in -1..=1 { + let nx = x + dx; + let ny = y + dy; + if nx < 0 || ny < 0 { + continue; + } + if occupied.contains(&(nx as usize, ny as usize)) { + return false; + } + } + } + } + + true +} + +fn shortest_path( + start: (usize, usize), + end: (usize, usize), + cols: usize, + rows: usize, +) -> Option> { + if start == end { + return Some(vec![start]); + } + + let total = cols.saturating_mul(rows); + if total == 0 { + return None; + } + + let index = |p: (usize, usize)| -> usize { p.1 * cols + p.0 }; + let coord = |idx: usize| -> (usize, usize) { (idx % cols, idx / cols) }; + + let start_idx = index(start); + let end_idx = index(end); + + let mut queue = VecDeque::new(); + let mut visited = vec![false; total]; + let mut parent: Vec> = vec![None; total]; + + visited[start_idx] = true; + queue.push_back(start_idx); + + while let Some(current) = queue.pop_front() { + if current == end_idx { + break; + } + + let (x, y) = coord(current); + let neighbors = [ + x.checked_sub(1).map(|nx| (nx, y)), + (x + 1 < cols).then_some((x + 1, y)), + y.checked_sub(1).map(|ny| (x, ny)), + (y + 1 < rows).then_some((x, y + 1)), + ]; + + for neighbor in neighbors.into_iter().flatten() { + let n_idx = index(neighbor); + if !visited[n_idx] { + visited[n_idx] = true; + parent[n_idx] = Some(current); + queue.push_back(n_idx); + } + } + } + + if !visited[end_idx] { + return None; + } + + let mut path = Vec::new(); + let mut current = end_idx; + path.push(coord(current)); + + while let Some(prev) = parent[current] { + current = prev; + path.push(coord(current)); + } + + path.reverse(); + Some(path) +} + +fn noisy_path( + start: (usize, usize), + end: (usize, usize), + cols: usize, + rows: usize, + randomness: f32, + rng: &mut SimpleRng, +) -> Vec<(usize, usize)> { + if start == end { + return vec![start]; + } + + let mut path = vec![start]; + let mut visited = HashSet::new(); + visited.insert(start); + + let mut current = start; + let mut prev_dir = (0isize, 0isize); + let max_steps = cols.saturating_mul(rows).max(32); + + for _ in 0..max_steps { + if current == end { + break; + } + + let mut neighbors = Vec::with_capacity(4); + let (x, y) = current; + if x > 0 { + neighbors.push((x - 1, y)); + } + if x + 1 < cols { + neighbors.push((x + 1, y)); + } + if y > 0 { + neighbors.push((x, y - 1)); + } + if y + 1 < rows { + neighbors.push((x, y + 1)); + } + + if neighbors.is_empty() { + break; + } + + let mut best = neighbors[0]; + let mut best_score = f32::INFINITY; + + for &candidate in &neighbors { + let step_dir = ( + candidate.0 as isize - current.0 as isize, + candidate.1 as isize - current.1 as isize, + ); + + let dist = manhattan_distance(candidate, end) as f32; + let progress_weight = 1.0 - (0.85 * randomness); + let revisit_penalty = if visited.contains(&candidate) { + 2.5 + (2.0 * randomness) + } else { + 0.0 + }; + let turn_penalty = if prev_dir == (0, 0) || prev_dir == step_dir { + 0.0 + } else { + 0.6 - (0.35 * randomness) + }; + let noise = rng.next_f32() * 8.0 * randomness; + let score = (dist * progress_weight) + revisit_penalty + turn_penalty + noise; + + if score < best_score { + best_score = score; + best = candidate; + } + } + + prev_dir = ( + best.0 as isize - current.0 as isize, + best.1 as isize - current.1 as isize, + ); + current = best; + path.push(current); + visited.insert(current); + } + + if current != end + && let Some(tail) = shortest_path(current, end, cols, rows) + { + for &cell in tail.iter().skip(1) { + path.push(cell); + } + } + + path +} + fn overlaps_with_padding(a: &Room, b: &Room, padding: usize) -> bool { let a_left = a.x.saturating_sub(padding); let a_top = a.y.saturating_sub(padding); @@ -195,104 +495,6 @@ fn overlaps_with_padding(a: &Room, b: &Room, padding: usize) -> bool { a_left < b_right && a_right > b_left && a_top < b_bottom && a_bottom > b_top } -fn make_l_path( - from: (usize, usize), - to: (usize, usize), - horizontal_first: bool, -) -> Vec<(usize, usize)> { - let mut path = Vec::new(); - if horizontal_first { - append_segment_cells(&mut path, from, (to.0, from.1)); - append_segment_cells(&mut path, (to.0, from.1), to); - } else { - append_segment_cells(&mut path, from, (from.0, to.1)); - append_segment_cells(&mut path, (from.0, to.1), to); - } - path -} - -fn append_segment_cells(path: &mut Vec<(usize, usize)>, from: (usize, usize), to: (usize, usize)) { - if from.0 == to.0 { - let x = from.0; - let start = from.1.min(to.1); - let end = from.1.max(to.1); - for y in start..=end { - if path.last().copied() != Some((x, y)) { - path.push((x, y)); - } - } - } else if from.1 == to.1 { - let y = from.1; - let start = from.0.min(to.0); - let end = from.0.max(to.0); - for x in start..=end { - if path.last().copied() != Some((x, y)) { - path.push((x, y)); - } - } - } -} - -fn try_connect_rooms( - from: (usize, usize), - to: (usize, usize), - enforce_gap: bool, - rng: &mut SimpleRng, - occupied: &mut HashSet<(usize, usize)>, - corridors: &mut Vec, -) -> bool { - let horizontal_first = rng.next_bool(); - let first_try = make_l_path(from, to, horizontal_first); - let second_try = make_l_path(from, to, !horizontal_first); - - try_place_path(&first_try, occupied, corridors, enforce_gap) - || try_place_path(&second_try, occupied, corridors, enforce_gap) -} - -fn try_place_path( - path: &[(usize, usize)], - occupied: &mut HashSet<(usize, usize)>, - corridors: &mut Vec, - enforce_gap: bool, -) -> bool { - if path.len() < 2 || (enforce_gap && !has_required_corridor_gap(path, occupied)) { - return false; - } - - for cell in path { - occupied.insert(*cell); - } - - for segment in path.windows(2) { - corridors.push(Corridor { - from: segment[0], - to: segment[1], - }); - } - - true -} - -fn has_required_corridor_gap(path: &[(usize, usize)], occupied: &HashSet<(usize, usize)>) -> bool { - for &(x, y) in path { - let x = x as isize; - let y = y as isize; - for dx in -1..=1 { - for dy in -1..=1 { - let nx = x + dx; - let ny = y + dy; - if nx < 0 || ny < 0 { - continue; - } - if occupied.contains(&(nx as usize, ny as usize)) { - return false; - } - } - } - } - true -} - fn manhattan_distance(a: (usize, usize), b: (usize, usize)) -> usize { a.0.abs_diff(b.0) + a.1.abs_diff(b.1) } @@ -318,8 +520,8 @@ impl SimpleRng { (self.state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 32) as u32 } - fn next_bool(&mut self) -> bool { - (self.next_u32() & 1) == 0 + fn next_f32(&mut self) -> f32 { + self.next_u32() as f32 / u32::MAX as f32 } fn range_inclusive(&mut self, min: usize, max: usize) -> usize { diff --git a/src/main.rs b/src/main.rs index 703de49..4c903cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,8 @@ struct DungeonApp { min_room_size: usize, max_room_size: usize, square_rooms_only: bool, + corridor_randomness: usize, + dead_end_rooms_percent: usize, seed: u64, layout: DungeonLayout, } @@ -33,6 +35,8 @@ impl Default for DungeonApp { let min_room_size = 2; let max_room_size = 6; let square_rooms_only = false; + let corridor_randomness = 0; + let dead_end_rooms_percent = 0; let seed = 1; let layout = generate_layout( cols, @@ -42,6 +46,8 @@ impl Default for DungeonApp { min_room_size, max_room_size, square_rooms_only, + corridor_randomness, + dead_end_rooms_percent, ); Self { @@ -51,6 +57,8 @@ impl Default for DungeonApp { min_room_size, max_room_size, square_rooms_only, + corridor_randomness, + dead_end_rooms_percent, seed, layout, } @@ -151,6 +159,42 @@ impl eframe::App for DungeonApp { .checkbox(&mut self.square_rooms_only, "Square Rooms Only") .changed(); + ui.add_space(8.0); + ui.label("Corridor Randomness (%)"); + ui.horizontal(|ui| { + settings_changed |= ui + .add( + egui::Slider::new(&mut self.corridor_randomness, 0..=100) + .show_value(false), + ) + .changed(); + settings_changed |= ui + .add( + egui::DragValue::new(&mut self.corridor_randomness) + .speed(1.0) + .range(0..=100), + ) + .changed(); + }); + + ui.add_space(8.0); + ui.label("Dead-End Rooms (%)"); + ui.horizontal(|ui| { + settings_changed |= ui + .add( + egui::Slider::new(&mut self.dead_end_rooms_percent, 0..=50) + .show_value(false), + ) + .changed(); + settings_changed |= ui + .add( + egui::DragValue::new(&mut self.dead_end_rooms_percent) + .speed(1.0) + .range(0..=50), + ) + .changed(); + }); + ui.add_space(10.0); if ui.button("Generate New Layout").clicked() { self.seed = self.seed.wrapping_add(1); @@ -162,6 +206,8 @@ impl eframe::App for DungeonApp { self.min_room_size, self.max_room_size, self.square_rooms_only, + self.corridor_randomness, + self.dead_end_rooms_percent, ); } }); @@ -179,6 +225,8 @@ impl eframe::App for DungeonApp { self.min_room_size, self.max_room_size, self.square_rooms_only, + self.corridor_randomness, + self.dead_end_rooms_percent, ); } @@ -246,13 +294,14 @@ fn draw_grid(painter: &egui::Painter, area: egui::Rect, cols: usize, rows: usize fn draw_layout(painter: &egui::Painter, geometry: &GridGeometry, layout: &DungeonLayout) { let room_fill = Color32::from_rgb(70, 120, 160); - let room_stroke = Stroke::new(1.0, Color32::from_rgb(35, 65, 95)); let corridor_fill = Color32::from_rgb(210, 190, 120); + let outline_stroke = Stroke::new(1.0, Color32::BLACK); for corridor in &layout.corridors { for (col, row) in corridor_cells(corridor.from, corridor.to) { let cell_rect = cell_rect(geometry, col, row); painter.rect_filled(cell_rect, 0.0, corridor_fill); + painter.rect_stroke(cell_rect, 0.0, outline_stroke, egui::StrokeKind::Middle); } } @@ -264,7 +313,7 @@ fn draw_layout(painter: &egui::Painter, geometry: &GridGeometry, layout: &Dungeo let room_rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); painter.rect_filled(room_rect, 0.0, room_fill); - painter.rect_stroke(room_rect, 0.0, room_stroke, egui::StrokeKind::Middle); + painter.rect_stroke(room_rect, 0.0, outline_stroke, egui::StrokeKind::Middle); } }