diff --git a/Cargo.toml b/Cargo.toml index 902252a..adcb05c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "desktop_dungeon_generator" -version = "0.5.0" +version = "0.6.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 63b8d21..45947c6 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,128 @@ # Desktop Dungeon Generator -**Pre-release Notice:** This program is incomplete and functionality is subject to change and it might break, you've been warned. *though releases should at least be stable enough to run* +Desktop Dungeon Generator is a Rust desktop app for building and exporting simple tabletop RPG dungeon layouts. -A Rust desktop app for generating simple tabletop dungeon layouts. +It combines deterministic generation with manual editing, so you can generate a baseline layout from a seed and then adjust rooms, corridors, doors, text labels, and start/end markers directly on the canvas. ## 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` -- Door controls: - - `Door Frequency (%)` - - `Room/Hallway Door Chance (%)` - - `Locked Door Chance (%)` - - `Allow Middle Corridor Doors` toggle -- Accessibility: - - `Colorblind Mode` toggle (right panel) - - Pattern/style encoding: - - Rooms: crosshatch - - Corridors: dotted fill - - Walls: solid lines - - Doors: long dashes - - Locked doors: short dashes - - Archways: dotted lines -- Generate control: - - Master seed input (`u64`) - - `Random` seed button - - `Reset` button - - `Export Image` button - - Export format selector: `.png`, `.jpeg`, `.webp`, `.svg` - - Export uses an OS folder picker to choose save location +- Deterministic dungeon generation from a master seed +- Room generation with configurable: + - room count + - minimum and maximum room size + - square-only rooms + - packed-room mode without corridors +- Corridor generation with configurable: + - minimum and maximum corridor width + - corridor randomness + - dead-end room percentage +- Automatic door generation with configurable: + - overall frequency + - room/hallway chance + - locked door chance + - secret door chance + - middle-corridor doors toggle +- Automatic window generation with configurable: + - enable/disable + - minimum and maximum width + - overall frequency + - room/hallway chance + - internal windows toggle +- Generated start/end markers with configurable: + - minimum and maximum size + - minimum and maximum count + - placement inside rooms + - farthest-pair matching between start and end markers +- Manual editing tools for: + - rooms + - corridors + - archways + - doors + - locked doors + - secret doors + - text labels + - start markers + - end markers +- Export support for: + - `.png` + - `.jpeg` + - `.webp` + - `.svg` + - composite folder export with masks +- Accessibility mode with alternate visual encodings for rooms, corridors, walls, doors, archways, and windows -## Generation Behavior +## Tabs -- 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. -- A master seed drives deterministic generation, and internal subsystems derive their own sub-seeds from it. -- Doors are generated from corridor adjacency: - - Open doors are rendered in red - - Locked doors are rendered in green - - Doors are rendered as line segments on grid boundaries +### Generate -### Corridor Randomness +Controls global generation and export: -- `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. +- grid columns and rows +- master seed and random-seed button +- reset and clear actions +- export format, dimensions, and grid visibility -### Dead-End Rooms +### Layout -- 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. +Controls room, corridor, door, and window generation behavior. + +Changing values in this tab regenerates the dungeon immediately. + +### Start & End + +Controls generated start/end marker behavior: + +- minimum and maximum start marker size +- minimum and maximum end marker size +- minimum and maximum start marker count +- minimum and maximum end marker count + +Changing values in this tab regenerates the dungeon immediately. + +Generated markers are placed inside rooms. Matching start/end indices are paired to rooms that are as far apart as possible. + +### Add + +Contains manual placement tools for editing the current layout without regenerating it. + +## Canvas Interaction + +- Left click and drag a room to move it +- Left click and drag a start/end marker to move it +- Right click and drag a room to resize it +- Right click without dragging to cancel an active add tool +- `Delete` or `Backspace` removes the currently hovered room, corridor, door, text label, or marker +- `Ctrl+Z` undoes +- `Ctrl+Y` redoes + +## Start / End Markers + +The app currently supports two marker workflows: + +- Generated markers: + - created automatically during dungeon generation + - always placed inside rooms + - sized from the configured min/max ranges + - paired by distance for each matching start/end index +- Manual markers: + - added from the `Add` tab + - use the configured minimum size for their marker type + - can be repositioned by dragging on the canvas + +## Folder Export Masks + +Folder export writes a composite image plus separate masks for: + +- floors +- walls +- doors +- archways +- locked doors +- secret doors +- windows +- start markers +- end markers +- grid, when enabled ## Run @@ -77,8 +132,5 @@ 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). -- Settings are persisted on exit and restored on startup from: - - Windows: `%LOCALAPPDATA%/desktop_dungeon_generator/settings.json` - - Linux: `~/.local/share/desktop_dungeon_generator/settings.json` (or distro-equivalent local data dir) +- Settings are persisted between runs using the local OS application data directory. +- The app is still pre-release and the file layout and feature set may continue to change. diff --git a/src/exporter.rs b/src/exporter.rs index 616694c..6144128 100644 --- a/src/exporter.rs +++ b/src/exporter.rs @@ -331,27 +331,13 @@ fn export_composite_masks( WHITE, ); - let mut doors = Pixmap::new(g.width, g.height) - .ok_or_else(|| "Failed to allocate doors mask canvas".to_string())?; - let mut archways = Pixmap::new(g.width, g.height) - .ok_or_else(|| "Failed to allocate archways mask canvas".to_string())?; - let mut locked_doors = Pixmap::new(g.width, g.height) - .ok_or_else(|| "Failed to allocate locked doors mask canvas".to_string())?; - let mut secret_doors = Pixmap::new(g.width, g.height) - .ok_or_else(|| "Failed to allocate secret doors mask canvas".to_string())?; - let mut windows = Pixmap::new(g.width, g.height) - .ok_or_else(|| "Failed to allocate windows mask canvas".to_string())?; - let mut start_markers = Pixmap::new(g.width, g.height) - .ok_or_else(|| "Failed to allocate start markers mask canvas".to_string())?; - let mut end_markers = Pixmap::new(g.width, g.height) - .ok_or_else(|| "Failed to allocate end markers mask canvas".to_string())?; - fill_bg(&mut doors, BLACK); - fill_bg(&mut archways, BLACK); - fill_bg(&mut locked_doors, BLACK); - fill_bg(&mut secret_doors, BLACK); - fill_bg(&mut windows, BLACK); - fill_bg(&mut start_markers, BLACK); - fill_bg(&mut end_markers, BLACK); + let mut doors = blank_mask(&g, "doors")?; + let mut archways = blank_mask(&g, "archways")?; + let mut locked_doors = blank_mask(&g, "locked doors")?; + let mut secret_doors = blank_mask(&g, "secret doors")?; + let mut windows = blank_mask(&g, "windows")?; + let mut start_markers = blank_mask(&g, "start markers")?; + let mut end_markers = blank_mask(&g, "end markers")?; for door in &layout.doors { if door.secret { draw_door(&mut secret_doors, &g, door, door_w, WHITE, DoorStyle::Solid); @@ -401,6 +387,13 @@ fn export_composite_masks( Ok(()) } +fn blank_mask(g: &ExportGeometry, name: &str) -> Result { + let mut pixmap = Pixmap::new(g.width, g.height) + .ok_or_else(|| format!("Failed to allocate {name} mask canvas"))?; + fill_bg(&mut pixmap, BLACK); + Ok(pixmap) +} + // Write a single mask image to disk. fn save_mask_image( folder: &std::path::Path, diff --git a/src/main.rs b/src/main.rs index 8f2ba8b..c13d362 100644 --- a/src/main.rs +++ b/src/main.rs @@ -122,30 +122,7 @@ impl eframe::App for DungeonApp { || (i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::Z)) }); - if self.settings.min_room_size > self.settings.max_room_size { - self.settings.max_room_size = self.settings.min_room_size; - } - if self.settings.min_corridor_width == 0 { - self.settings.min_corridor_width = 1; - } - if self.settings.min_corridor_width > self.settings.max_corridor_width { - self.settings.max_corridor_width = self.settings.min_corridor_width; - } - if self.settings.min_window_width > self.settings.max_window_width { - self.settings.max_window_width = self.settings.min_window_width; - } - if self.settings.min_start_marker_size > self.settings.max_start_marker_size { - self.settings.max_start_marker_size = self.settings.min_start_marker_size; - } - if self.settings.min_end_marker_size > self.settings.max_end_marker_size { - self.settings.max_end_marker_size = self.settings.min_end_marker_size; - } - if self.settings.min_start_marker_count > self.settings.max_start_marker_count { - self.settings.max_start_marker_count = self.settings.min_start_marker_count; - } - if self.settings.min_end_marker_count > self.settings.max_end_marker_count { - self.settings.max_end_marker_count = self.settings.min_end_marker_count; - } + clamp_dependent_settings(&mut self.settings); if undo_requested { self.undo(); @@ -280,6 +257,33 @@ impl eframe::App for DungeonApp { } } +fn clamp_dependent_settings(settings: &mut UiSettings) { + if settings.min_room_size > settings.max_room_size { + settings.max_room_size = settings.min_room_size; + } + if settings.min_corridor_width == 0 { + settings.min_corridor_width = 1; + } + if settings.min_corridor_width > settings.max_corridor_width { + settings.max_corridor_width = settings.min_corridor_width; + } + if settings.min_window_width > settings.max_window_width { + settings.max_window_width = settings.min_window_width; + } + if settings.min_start_marker_size > settings.max_start_marker_size { + settings.max_start_marker_size = settings.min_start_marker_size; + } + if settings.min_end_marker_size > settings.max_end_marker_size { + settings.max_end_marker_size = settings.min_end_marker_size; + } + if settings.min_start_marker_count > settings.max_start_marker_count { + settings.max_start_marker_count = settings.min_start_marker_count; + } + if settings.min_end_marker_count > settings.max_end_marker_count { + settings.max_end_marker_count = settings.min_end_marker_count; + } +} + impl DungeonApp { fn snapshot(&self) -> AppSnapshot { AppSnapshot { diff --git a/src/startend.rs b/src/startend.rs index 923edde..eff59e5 100644 --- a/src/startend.rs +++ b/src/startend.rs @@ -2,6 +2,16 @@ use crate::layout::{self, DungeonLayout}; use crate::seed; use crate::ui::UiSettings; +const START_COUNT_STREAM: u64 = 10_001; +const END_COUNT_STREAM: u64 = 10_002; +const START_ROOM_STREAM_BASE: u64 = 11_000; +const END_ROOM_STREAM_BASE: u64 = 12_000; +const EXTRA_START_ROOM_STREAM_BASE: u64 = 13_000; +const EXTRA_START_MARKER_STREAM_BASE: u64 = 14_000; +const EXTRA_END_ROOM_STREAM_BASE: u64 = 15_000; +const EXTRA_END_MARKER_STREAM_BASE: u64 = 16_000; + +// Populate generated start/end markers after the core dungeon layout exists. pub fn populate_random_markers( mut layout: DungeonLayout, settings: &UiSettings, @@ -16,12 +26,12 @@ pub fn populate_random_markers( let start_count = random_range_inclusive( settings.min_start_marker_count, settings.max_start_marker_count, - seed::derive_seed(settings.seed, 10_001), + seed::derive_seed(settings.seed, START_COUNT_STREAM), ); let end_count = random_range_inclusive( settings.min_end_marker_count, settings.max_end_marker_count, - seed::derive_seed(settings.seed, 10_002), + seed::derive_seed(settings.seed, END_COUNT_STREAM), ); let pair_count = start_count.min(end_count); @@ -37,13 +47,13 @@ pub fn populate_random_markers( start_room, settings.min_start_marker_size, settings.max_start_marker_size, - seed::derive_seed(settings.seed, 11_000 + pair_idx as u64), + seed::derive_seed(settings.seed, START_ROOM_STREAM_BASE + pair_idx as u64), )); layout.end_markers.push(marker_in_room( end_room, settings.min_end_marker_size, settings.max_end_marker_size, - seed::derive_seed(settings.seed, 12_000 + pair_idx as u64), + seed::derive_seed(settings.seed, END_ROOM_STREAM_BASE + pair_idx as u64), )); consume_room(&mut available_start_rooms, start_room_idx); consume_room(&mut available_end_rooms, end_room_idx); @@ -53,14 +63,14 @@ pub fn populate_random_markers( let room_idx = pick_room_index( &available_start_rooms, layout.rooms.len(), - seed::derive_seed(settings.seed, 13_000 + idx as u64), + seed::derive_seed(settings.seed, EXTRA_START_ROOM_STREAM_BASE + idx as u64), ); let room = &layout.rooms[room_idx]; layout.start_markers.push(marker_in_room( room, settings.min_start_marker_size, settings.max_start_marker_size, - seed::derive_seed(settings.seed, 14_000 + idx as u64), + seed::derive_seed(settings.seed, EXTRA_START_MARKER_STREAM_BASE + idx as u64), )); consume_room(&mut available_start_rooms, room_idx); } @@ -69,14 +79,14 @@ pub fn populate_random_markers( let room_idx = pick_room_index( &available_end_rooms, layout.rooms.len(), - seed::derive_seed(settings.seed, 15_000 + idx as u64), + seed::derive_seed(settings.seed, EXTRA_END_ROOM_STREAM_BASE + idx as u64), ); let room = &layout.rooms[room_idx]; layout.end_markers.push(marker_in_room( room, settings.min_end_marker_size, settings.max_end_marker_size, - seed::derive_seed(settings.seed, 16_000 + idx as u64), + seed::derive_seed(settings.seed, EXTRA_END_MARKER_STREAM_BASE + idx as u64), )); consume_room(&mut available_end_rooms, room_idx); } @@ -84,6 +94,7 @@ pub fn populate_random_markers( layout } +// Manual marker placement uses the configured minimum size for the chosen marker type. pub fn manual_marker_size(settings: &UiSettings, is_start: bool) -> usize { if is_start { settings @@ -95,6 +106,7 @@ pub fn manual_marker_size(settings: &UiSettings, is_start: bool) -> usize { .clamp(1, 10) } +// Pick a deterministic inclusive random value from a seed-derived stream. fn random_range_inclusive(min: usize, max: usize, seed_value: u64) -> usize { let min = min.max(1); let max = max.max(min); @@ -102,6 +114,7 @@ fn random_range_inclusive(min: usize, max: usize, seed_value: u64) -> usize { min + (seed_value as usize % span) } +// Fit a marker inside a room and choose a deterministic offset within that room. fn marker_in_room( room: &layout::Room, min_size: usize, @@ -129,6 +142,7 @@ fn marker_in_room( layout::AreaMarker { cell: (x, y), size } } +// Match each start/end pair to the farthest available room combination. fn farthest_room_pair( rooms: &[layout::Room], available_start_rooms: &[usize], @@ -170,12 +184,14 @@ fn room_distance_sq(a: &layout::Room, b: &layout::Room) -> usize { dx * dx + dy * dy } +// Remove a room from the available set once it has been consumed by a marker assignment. fn consume_room(available_rooms: &mut Vec, room_idx: usize) { if let Some(pos) = available_rooms.iter().position(|&idx| idx == room_idx) { available_rooms.remove(pos); } } +// Fall back to any room when all candidates for a side have been exhausted. fn pick_room_index(available_rooms: &[usize], room_count: usize, seed_value: u64) -> usize { if !available_rooms.is_empty() { available_rooms[seed_value as usize % available_rooms.len()] diff --git a/src/ui.rs b/src/ui.rs index 87ffb35..fc94744 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -783,123 +783,97 @@ fn draw_start_and_end_tab( ) { ui.label(RichText::new("Start Markers").strong()); ui.add_space(8.0); - ui.label("Min Start Marker Size"); - ui.horizontal(|ui| { - result.settings_changed |= ui - .add(egui::Slider::new(&mut settings.min_start_marker_size, 1..=10).show_value(false)) - .changed(); - result.settings_changed |= ui - .add( - egui::DragValue::new(&mut settings.min_start_marker_size) - .speed(1.0) - .range(1..=10), - ) - .changed(); - }); - ui.add_space(6.0); - ui.label("Max Start Marker Size"); - ui.horizontal(|ui| { - result.settings_changed |= ui - .add(egui::Slider::new(&mut settings.max_start_marker_size, 1..=10).show_value(false)) - .changed(); - result.settings_changed |= ui - .add( - egui::DragValue::new(&mut settings.max_start_marker_size) - .speed(1.0) - .range(1..=10), - ) - .changed(); - }); - ui.add_space(6.0); - ui.label("Min Start Marker Count"); - ui.horizontal(|ui| { - result.settings_changed |= ui - .add(egui::Slider::new(&mut settings.min_start_marker_count, 1..=20).show_value(false)) - .changed(); - result.settings_changed |= ui - .add( - egui::DragValue::new(&mut settings.min_start_marker_count) - .speed(1.0) - .range(1..=100), - ) - .changed(); - }); - ui.add_space(6.0); - ui.label("Max Start Marker Count"); - ui.horizontal(|ui| { - result.settings_changed |= ui - .add(egui::Slider::new(&mut settings.max_start_marker_count, 1..=20).show_value(false)) - .changed(); - result.settings_changed |= ui - .add( - egui::DragValue::new(&mut settings.max_start_marker_count) - .speed(1.0) - .range(1..=100), - ) - .changed(); - }); + draw_usize_slider_row( + ui, + result, + "Min Start Marker Size", + &mut settings.min_start_marker_size, + 1..=10, + 1..=10, + ); + draw_usize_slider_row( + ui, + result, + "Max Start Marker Size", + &mut settings.max_start_marker_size, + 1..=10, + 1..=10, + ); + draw_usize_slider_row( + ui, + result, + "Min Start Marker Count", + &mut settings.min_start_marker_count, + 1..=20, + 1..=100, + ); + draw_usize_slider_row( + ui, + result, + "Max Start Marker Count", + &mut settings.max_start_marker_count, + 1..=20, + 1..=100, + ); ui.add_space(12.0); ui.separator(); ui.add_space(8.0); ui.label(RichText::new("End Markers").strong()); ui.add_space(8.0); - ui.label("Min End Marker Size"); - ui.horizontal(|ui| { - result.settings_changed |= ui - .add(egui::Slider::new(&mut settings.min_end_marker_size, 1..=10).show_value(false)) - .changed(); - result.settings_changed |= ui - .add( - egui::DragValue::new(&mut settings.min_end_marker_size) - .speed(1.0) - .range(1..=10), - ) - .changed(); - }); - ui.add_space(6.0); - ui.label("Max End Marker Size"); - ui.horizontal(|ui| { - result.settings_changed |= ui - .add(egui::Slider::new(&mut settings.max_end_marker_size, 1..=10).show_value(false)) - .changed(); - result.settings_changed |= ui - .add( - egui::DragValue::new(&mut settings.max_end_marker_size) - .speed(1.0) - .range(1..=10), - ) - .changed(); - }); - ui.add_space(6.0); - ui.label("Min End Marker Count"); - ui.horizontal(|ui| { - result.settings_changed |= ui - .add(egui::Slider::new(&mut settings.min_end_marker_count, 1..=20).show_value(false)) - .changed(); - result.settings_changed |= ui - .add( - egui::DragValue::new(&mut settings.min_end_marker_count) - .speed(1.0) - .range(1..=100), - ) - .changed(); - }); - ui.add_space(6.0); - ui.label("Max End Marker Count"); - ui.horizontal(|ui| { - result.settings_changed |= ui - .add(egui::Slider::new(&mut settings.max_end_marker_count, 1..=20).show_value(false)) - .changed(); - result.settings_changed |= ui - .add( - egui::DragValue::new(&mut settings.max_end_marker_count) - .speed(1.0) - .range(1..=100), - ) - .changed(); - }); + draw_usize_slider_row( + ui, + result, + "Min End Marker Size", + &mut settings.min_end_marker_size, + 1..=10, + 1..=10, + ); + draw_usize_slider_row( + ui, + result, + "Max End Marker Size", + &mut settings.max_end_marker_size, + 1..=10, + 1..=10, + ); + draw_usize_slider_row( + ui, + result, + "Min End Marker Count", + &mut settings.min_end_marker_count, + 1..=20, + 1..=100, + ); + draw_usize_slider_row( + ui, + result, + "Max End Marker Count", + &mut settings.max_end_marker_count, + 1..=20, + 1..=100, + ); ui.add_space(8.0); ui.label("Markers are generated inside rooms. Matching start/end indices are paired as far apart as possible."); } + +fn draw_usize_slider_row( + ui: &mut egui::Ui, + result: &mut SidePanelResult, + label: &str, + value: &mut usize, + slider_range: std::ops::RangeInclusive, + drag_range: std::ops::RangeInclusive, +) { + ui.label(label); + ui.horizontal(|ui| { + result.settings_changed |= ui + .add(egui::Slider::new(value, slider_range).show_value(false)) + .changed(); + result.settings_changed |= ui + .add(egui::DragValue::new(value).speed(1.0).range(drag_range)) + .changed(); + }); + ui.add_space(6.0); +}