diff --git a/src/exporter.rs b/src/exporter.rs deleted file mode 100644 index 82c837a..0000000 --- a/src/exporter.rs +++ /dev/null @@ -1,2078 +0,0 @@ -/* - * Image and vector export module. - * Handles building SVG representations of dungeon levels and - * exporting them to various formats (PNG, JPEG, WebP, SVG) - * via a background thread to maintain UI responsiveness. - */ - -use std::collections::HashSet; -use std::fmt::Write as _; -use std::path::PathBuf; -use std::sync::mpsc; - -use image::{DynamicImage, ImageFormat, RgbaImage, imageops::FilterType}; -use rayon::prelude::*; -use rfd::FileDialog; -use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Rect, Stroke, StrokeDash, Transform}; - -use crate::layout::{ - AreaMarker, Door, DungeonLayout, Room, Staircase, Window, WindowSide, corridor_cells, -}; -use crate::ui::{ExportFormat, MaskFormat, UiSettings}; - -const BG: (u8, u8, u8, u8) = (24, 24, 26, 255); -const GRID: (u8, u8, u8, u8) = (130, 130, 130, 255); -const ROOM: (u8, u8, u8, u8) = (70, 120, 160, 255); -const CORRIDOR: (u8, u8, u8, u8) = (210, 190, 120, 255); -const BLACK: (u8, u8, u8, u8) = (0, 0, 0, 255); -const OPEN_DOOR: (u8, u8, u8, u8) = (80, 200, 120, 255); -const LOCKED_DOOR: (u8, u8, u8, u8) = (220, 70, 70, 255); -const SECRET_DOOR: (u8, u8, u8, u8) = (170, 80, 170, 255); -const ARCHWAY: (u8, u8, u8, u8) = (230, 140, 60, 255); -const WINDOW: (u8, u8, u8, u8) = (70, 130, 220, 255); -const WHITE: (u8, u8, u8, u8) = (255, 255, 255, 255); -const START_MARKER: (u8, u8, u8, u8) = (60, 220, 200, 255); -const END_MARKER: (u8, u8, u8, u8) = (240, 90, 90, 255); -const TRAP_MARKER: (u8, u8, u8, u8) = (150, 80, 230, 255); -const MONSTER_MARKER: (u8, u8, u8, u8) = (200, 50, 50, 255); -const STAIRS: (u8, u8, u8, u8) = (200, 150, 50, 255); - -pub enum ExportTarget { - File(PathBuf), - Folder(PathBuf), -} - -#[derive(Debug, Clone, Copy)] -pub struct ExportProgress { - pub completed: usize, - pub total: usize, -} - -pub enum ExportEvent { - Progress(ExportProgress), - Finished(Result), -} - -type ProgressSender = mpsc::Sender; - -#[derive(Debug, Clone, Copy)] -enum DoorStyle { - Solid, - LongDash, - ShortDash, - Dotted, - DashDot, - DashDotDot, -} - -#[derive(Debug, Clone, Copy)] -struct ExportGeometry { - cols: usize, - rows: usize, - cell: f32, - pad: f32, - width: u32, - height: u32, -} - -impl ExportGeometry { - // Build export geometry with a fixed cell size and padding. - fn new(cols: usize, rows: usize) -> Self { - let cell = 64.0; - let pad = 0.0; - let width = (cols as f32 * cell).round() as u32; - let height = (rows as f32 * cell).round() as u32; - Self { - cols, - rows, - cell, - pad, - width, - height, - } - } - - // Return the left padding offset. - fn left(&self) -> f32 { - self.pad - } - - // Return the top padding offset. - fn top(&self) -> f32 { - self.pad - } - - // Compute the rectangle for a cell in export space. - fn cell_rect(&self, col: usize, row: usize) -> Option { - Rect::from_xywh( - self.left() + col as f32 * self.cell, - self.top() + row as f32 * self.cell, - self.cell, - self.cell, - ) - } - - // Compute the center point of a cell in export space. - fn cell_center(&self, col: usize, row: usize) -> (f32, f32) { - ( - self.left() + (col as f32 + 0.5) * self.cell, - self.top() + (row as f32 + 0.5) * self.cell, - ) - } -} - -#[derive(Default)] -struct SceneData { - corridor_cells: HashSet<(usize, usize)>, - corridor_edges: HashSet<((usize, usize), (usize, usize))>, - room_cells: HashSet<(usize, usize)>, - room_edges: HashSet<((usize, usize), (usize, usize))>, - door_edges: HashSet<((usize, usize), (usize, usize))>, -} - -#[derive(Debug, Clone, Copy)] -enum MaskTask { - Floors, - Walls, - Doors, - Archways, - LockedDoors, - SecretDoors, - Windows, - StartMarkers, - EndMarkers, - TrapMarkers, - MonsterMarkers, - Stairs, - Grid, -} - -#[derive(Debug, Clone, Copy)] -enum CompositeTask { - Mask(MaskTask), - Composite, -} - -struct CompositeExportContext<'a> { - layout: &'a DungeonLayout, - folder: PathBuf, - geometry: ExportGeometry, - scene: SceneData, - wall_width: f32, - door_width: f32, -} - -// Collect precomputed cell and edge sets needed for rendering. -fn collect_scene_data(layout: &DungeonLayout, settings: &UiSettings) -> SceneData { - let mut scene = SceneData::default(); - - for door in &layout.doors { - for edge in door_edges_for(door, settings.cols, settings.rows) { - scene.door_edges.insert(edge); - } - } - - scene.corridor_cells = corridor_cells(layout, settings.cols, settings.rows); - scene.corridor_edges = corridor_edges_from_cells(&scene.corridor_cells); - - for room in &layout.rooms { - for x in room.x..(room.x + room.width) { - for y in room.y..(room.y + room.height) { - scene.room_cells.insert((x, y)); - } - } - } - scene.room_edges = room_edges_from_rooms(&layout.rooms); - - scene -} - -// Open a dialog to select an export destination. -pub fn select_export_target(settings: &UiSettings) -> Result { - if settings.export_format == ExportFormat::Folder || settings.export_level_index == 999 { - let title = if settings.export_level_index == 999 { - "Select export folder for all levels" - } else { - "Export composite masks folder" - }; - let mut dialog = FileDialog::new().set_title(title); - if let Some(ref last_path) = settings.last_export_path { - dialog = dialog.set_directory(last_path); - } - let folder = dialog - .pick_folder() - .ok_or_else(|| "Export canceled".to_string())?; - return Ok(ExportTarget::Folder(folder)); - } - - let ext = settings.export_format.extension(); - let default_name = format!("dungeon_export.{ext}"); - - let mut dialog = FileDialog::new(); - dialog = dialog - .set_title("Export image") - .set_file_name(&default_name) - .add_filter(settings.export_format.label(), &[ext]); - - if let Some(ref last_path) = settings.last_export_path { - dialog = dialog.set_directory(last_path); - } - - let mut path = dialog - .save_file() - .ok_or_else(|| "Export canceled".to_string())?; - - if path.extension().is_none() { - path.set_extension(ext); - } - - Ok(ExportTarget::File(path)) -} - -// Export the current layout to the chosen file or folder target. -pub fn export_to_target( - layouts: &[DungeonLayout], - settings: &UiSettings, - target: ExportTarget, - progress_tx: &ProgressSender, -) -> Result { - let export_indices: Vec = if settings.export_level_index == 999 { - (0..layouts.len()).collect() - } else { - let idx = settings - .export_level_index - .min(layouts.len().saturating_sub(1)); - vec![idx] - }; - let total = export_image_count(settings, &target, export_indices.len()); - report_progress(progress_tx, 0, total); - - match target { - ExportTarget::Folder(folder) => { - if settings.export_format == ExportFormat::Folder { - export_composite_masks_for_levels( - layouts, - settings, - &folder, - &export_indices, - progress_tx, - )?; - } else { - export_level_images_to_folder( - layouts, - settings, - &folder, - &export_indices, - progress_tx, - )?; - } - Ok(folder) - } - ExportTarget::File(path) => { - let idx = export_indices[0]; - save_layout_to_file(&layouts[idx], settings, &path)?; - report_progress(progress_tx, 1, total); - Ok(path) - } - } -} - -fn export_image_count(settings: &UiSettings, target: &ExportTarget, level_count: usize) -> usize { - match target { - ExportTarget::File(_) => 1, - ExportTarget::Folder(_) if settings.export_format == ExportFormat::Folder => { - level_count * composite_images_per_level(settings) - } - ExportTarget::Folder(_) => level_count, - } -} - -fn composite_images_per_level(settings: &UiSettings) -> usize { - let base_masks = 12; - let grid_mask = usize::from(settings.export_show_grid); - let composite = 1; - base_masks + grid_mask + composite -} - -fn report_progress(progress_tx: &ProgressSender, completed: usize, total: usize) { - let _ = progress_tx.send(ExportEvent::Progress(ExportProgress { completed, total })); -} - -fn export_level_images_to_folder( - layouts: &[DungeonLayout], - settings: &UiSettings, - folder: &std::path::Path, - export_indices: &[usize], - progress_tx: &ProgressSender, -) -> Result<(), String> { - std::fs::create_dir_all(folder).map_err(|err| format!("Failed creating folder: {err}"))?; - let completed = std::sync::atomic::AtomicUsize::new(0); - let total = export_indices.len(); - - let ext = settings.export_format.extension(); - export_indices.par_iter().try_for_each(|&idx| { - let filename = format!("level{}.{ext}", idx + 1); - let path = folder.join(filename); - save_layout_to_file(&layouts[idx], settings, &path)?; - let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - report_progress(progress_tx, done, total); - Ok(()) - }) -} - -fn save_layout_to_file( - layout: &DungeonLayout, - settings: &UiSettings, - path: &std::path::Path, -) -> Result<(), String> { - match settings.export_format { - ExportFormat::Svg => { - let svg = build_svg(layout, settings); - std::fs::write(path, svg).map_err(|e| format!("Failed writing SVG: {e}"))?; - } - ExportFormat::Png | ExportFormat::Jpeg | ExportFormat::Webp => { - let pixmap = render_pixmap(layout, settings)?; - let img = raster_image_from_pixmap(&pixmap, settings)?; - let format = match settings.export_format { - ExportFormat::Png => ImageFormat::Png, - ExportFormat::Jpeg => ImageFormat::Jpeg, - ExportFormat::Webp => ImageFormat::WebP, - ExportFormat::Svg | ExportFormat::Folder => unreachable!(), - }; - img.save_with_format(path, format) - .map_err(|e| format!("Failed writing image: {e}"))?; - } - ExportFormat::Folder => unreachable!(), - } - Ok(()) -} - -// Render the dungeon layout to a pixmap. -fn render_pixmap(layout: &DungeonLayout, settings: &UiSettings) -> Result { - let g = ExportGeometry::new(settings.cols, settings.rows); - let scene = collect_scene_data(layout, settings); - render_pixmap_with_scene(layout, settings, &g, &scene) -} - -fn render_pixmap_with_scene( - layout: &DungeonLayout, - settings: &UiSettings, - g: &ExportGeometry, - scene: &SceneData, -) -> Result { - let mut pixmap = - Pixmap::new(g.width, g.height).ok_or_else(|| "Failed to allocate canvas".to_string())?; - fill_bg(&mut pixmap, BG); - - let wall_w = snap_even_width((g.cell / 2.5).max(1.0)); - let door_w = (g.cell / 5.0).max(1.0).round(); - - if settings.export_show_grid { - draw_grid(&mut pixmap, g); - } - - for &(x, y) in &scene.corridor_cells { - fill_rect_cell(&mut pixmap, g, x, y, CORRIDOR); - if settings.colorblind_mode { - let (cx, cy) = g.cell_center(x, y); - draw_dot(&mut pixmap, cx, cy, g.cell * 0.14, BLACK); - } - } - - for room in &layout.rooms { - fill_rect_room(&mut pixmap, g, room, ROOM); - if settings.colorblind_mode { - draw_room_crosshatch(&mut pixmap, g, room, 1.5, BLACK); - } - } - - draw_cell_walls( - &mut pixmap, - g, - &scene.corridor_cells, - Some(&scene.corridor_edges), - &scene.door_edges, - wall_w, - BLACK, - ); - draw_cell_walls( - &mut pixmap, - g, - &scene.room_cells, - Some(&scene.room_edges), - &scene.door_edges, - wall_w, - BLACK, - ); - - for door in &layout.doors { - let (color, style) = if settings.colorblind_mode { - let s = if door.secret { - DoorStyle::DashDotDot - } else if door.archway { - DoorStyle::Dotted - } else if door.locked { - DoorStyle::ShortDash - } else { - DoorStyle::LongDash - }; - (BLACK, s) - } else if door.secret { - (SECRET_DOOR, DoorStyle::Solid) - } else if door.archway { - (ARCHWAY, DoorStyle::Solid) - } else if door.locked { - (LOCKED_DOOR, DoorStyle::Solid) - } else { - (OPEN_DOOR, DoorStyle::Solid) - }; - draw_door(&mut pixmap, g, door, door_w, color, style); - } - - for window in &layout.windows { - let style = if settings.colorblind_mode { - DoorStyle::DashDot - } else { - DoorStyle::Solid - }; - draw_window(&mut pixmap, g, window, door_w, WINDOW, style); - } - - draw_area_marker_group( - &mut pixmap, - g, - &layout.start_markers, - "S", - START_MARKER, - settings.colorblind_mode, - ); - draw_area_marker_group( - &mut pixmap, - g, - &layout.end_markers, - "E", - END_MARKER, - settings.colorblind_mode, - ); - draw_area_marker_group( - &mut pixmap, - g, - &layout.trap_markers, - "T", - TRAP_MARKER, - settings.colorblind_mode, - ); - draw_area_marker_group( - &mut pixmap, - g, - &layout.monster_markers, - "M", - MONSTER_MARKER, - settings.colorblind_mode, - ); - - draw_staircase_group( - &mut pixmap, - g, - &layout.stairs, - STAIRS, - settings.colorblind_mode, - ); - - Ok(pixmap) -} - -// Export composite images and masks using one global task queue. -fn export_composite_masks_for_levels( - layouts: &[DungeonLayout], - settings: &UiSettings, - folder: &std::path::Path, - export_indices: &[usize], - progress_tx: &ProgressSender, -) -> Result<(), String> { - let contexts = export_indices - .iter() - .map(|&idx| { - let subfolder = if settings.export_level_index == 999 { - folder.join(format!("level {}", idx + 1)) - } else { - folder.to_path_buf() - }; - std::fs::create_dir_all(&subfolder) - .map_err(|err| format!("Failed creating folder: {err}"))?; - - let geometry = ExportGeometry::new(settings.cols, settings.rows); - let scene = collect_scene_data(&layouts[idx], settings); - Ok(CompositeExportContext { - layout: &layouts[idx], - folder: subfolder, - geometry, - scene, - wall_width: (geometry.cell / 2.5).max(1.0), - door_width: (geometry.cell / 5.0).max(1.0), - }) - }) - .collect::, String>>()?; - - let mut tasks = vec![ - MaskTask::Floors, - MaskTask::Walls, - MaskTask::Doors, - MaskTask::Archways, - MaskTask::LockedDoors, - MaskTask::SecretDoors, - MaskTask::Windows, - MaskTask::StartMarkers, - MaskTask::EndMarkers, - MaskTask::TrapMarkers, - MaskTask::MonsterMarkers, - MaskTask::Stairs, - ]; - - if settings.export_show_grid { - tasks.push(MaskTask::Grid); - } - - let export_tasks = contexts - .iter() - .enumerate() - .flat_map(|(context_idx, _)| { - tasks - .iter() - .copied() - .map(move |task| (context_idx, CompositeTask::Mask(task))) - .chain(std::iter::once((context_idx, CompositeTask::Composite))) - }) - .collect::>(); - let completed = std::sync::atomic::AtomicUsize::new(0); - let total = export_tasks.len(); - - export_tasks - .into_par_iter() - .try_for_each(|(context_idx, task)| { - let context = &contexts[context_idx]; - match task { - CompositeTask::Mask(mask_task) => { - let (stem, pixmap) = render_mask_pixmap(context, mask_task)?; - save_mask_image(&context.folder, stem, &pixmap, settings) - } - CompositeTask::Composite => save_composite_image(context, settings), - }?; - let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - report_progress(progress_tx, done, total); - Ok::<(), String>(()) - })?; - - Ok(()) -} - -fn save_composite_image( - context: &CompositeExportContext<'_>, - settings: &UiSettings, -) -> Result<(), String> { - let composite = - render_pixmap_with_scene(context.layout, settings, &context.geometry, &context.scene)?; - let composite_img = raster_image_from_pixmap(&composite, settings)?; - let composite_ext = settings.mask_format.extension(); - let composite_path = context.folder.join(format!("composite.{composite_ext}")); - composite_img - .save_with_format(&composite_path, mask_image_format(settings.mask_format)) - .map_err(|e| format!("Failed writing {}: {e}", composite_path.display()))?; - - Ok(()) -} - -fn render_mask_pixmap( - context: &CompositeExportContext<'_>, - task: MaskTask, -) -> Result<(&'static str, Pixmap), String> { - let g = &context.geometry; - let scene = &context.scene; - let layout = context.layout; - - let result = match task { - MaskTask::Floors => { - let mut pixmap = blank_mask(g, "floors")?; - for &(x, y) in &scene.corridor_cells { - fill_rect_cell(&mut pixmap, g, x, y, WHITE); - } - for room in &layout.rooms { - fill_rect_room(&mut pixmap, g, room, WHITE); - } - ("floors_mask", pixmap) - } - MaskTask::Walls => { - let mut pixmap = blank_mask(g, "walls")?; - draw_cell_walls( - &mut pixmap, - g, - &scene.corridor_cells, - Some(&scene.corridor_edges), - &scene.door_edges, - context.wall_width, - WHITE, - ); - draw_cell_walls( - &mut pixmap, - g, - &scene.room_cells, - Some(&scene.room_edges), - &scene.door_edges, - context.wall_width, - WHITE, - ); - ("walls_mask", pixmap) - } - MaskTask::Doors => { - let mut pixmap = blank_mask(g, "doors")?; - for door in &layout.doors { - if !door.secret && !door.archway && !door.locked { - draw_door( - &mut pixmap, - g, - door, - context.door_width, - WHITE, - DoorStyle::Solid, - ); - } - } - ("doors_mask", pixmap) - } - MaskTask::Archways => { - let mut pixmap = blank_mask(g, "archways")?; - for door in &layout.doors { - if door.archway { - draw_door( - &mut pixmap, - g, - door, - context.door_width, - WHITE, - DoorStyle::Solid, - ); - } - } - ("archways_mask", pixmap) - } - MaskTask::LockedDoors => { - let mut pixmap = blank_mask(g, "locked doors")?; - for door in &layout.doors { - if door.locked { - draw_door( - &mut pixmap, - g, - door, - context.door_width, - WHITE, - DoorStyle::Solid, - ); - } - } - ("locked_doors_mask", pixmap) - } - MaskTask::SecretDoors => { - let mut pixmap = blank_mask(g, "secret doors")?; - for door in &layout.doors { - if door.secret { - draw_door( - &mut pixmap, - g, - door, - context.door_width, - WHITE, - DoorStyle::Solid, - ); - } - } - ("secret_doors_mask", pixmap) - } - MaskTask::Windows => { - let mut pixmap = blank_mask(g, "windows")?; - for window in &layout.windows { - draw_window( - &mut pixmap, - g, - window, - context.door_width, - WHITE, - DoorStyle::Solid, - ); - } - ("windows_mask", pixmap) - } - MaskTask::StartMarkers => { - let mut pixmap = blank_mask(g, "start markers")?; - fill_marker_mask(&mut pixmap, g, &layout.start_markers); - ("start_markers_mask", pixmap) - } - MaskTask::EndMarkers => { - let mut pixmap = blank_mask(g, "end markers")?; - fill_marker_mask(&mut pixmap, g, &layout.end_markers); - ("end_markers_mask", pixmap) - } - MaskTask::TrapMarkers => { - let mut pixmap = blank_mask(g, "trap markers")?; - fill_marker_mask(&mut pixmap, g, &layout.trap_markers); - ("trap_markers_mask", pixmap) - } - MaskTask::MonsterMarkers => { - let mut pixmap = blank_mask(g, "monster markers")?; - fill_marker_mask(&mut pixmap, g, &layout.monster_markers); - ("monster_markers_mask", pixmap) - } - MaskTask::Stairs => { - let mut pixmap = blank_mask(g, "stairs")?; - for staircase in &layout.stairs { - fill_rect_staircase(&mut pixmap, g, staircase, WHITE); - } - ("stairs_mask", pixmap) - } - MaskTask::Grid => { - let mut pixmap = blank_mask(g, "grid")?; - draw_grid_color(&mut pixmap, g, WHITE); - ("grid_mask", pixmap) - } - }; - - Ok(result) -} - -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) -} - -fn fill_marker_mask(pixmap: &mut Pixmap, g: &ExportGeometry, markers: &[AreaMarker]) { - for marker in markers { - fill_rect_marker(pixmap, g, marker, WHITE); - } -} - -// Write a single mask image to disk. -fn save_mask_image( - folder: &std::path::Path, - stem: &str, - pixmap: &Pixmap, - settings: &UiSettings, -) -> Result<(), String> { - let ext = settings.mask_format.extension(); - let path = folder.join(format!("{stem}.{ext}")); - let img = raster_mask_image_from_pixmap(pixmap, settings)?; - img.save_with_format(&path, mask_image_format(settings.mask_format)) - .map_err(|e| format!("Failed writing {}: {e}", path.display()))?; - Ok(()) -} - -// Map a mask format to an image encoding. -fn mask_image_format(format: MaskFormat) -> ImageFormat { - match format { - MaskFormat::Png => ImageFormat::Png, - MaskFormat::Jpeg => ImageFormat::Jpeg, - MaskFormat::Webp => ImageFormat::WebP, - } -} - -// Convert a pixmap into a raster image at the target size. -fn raster_image_from_pixmap( - pixmap: &Pixmap, - settings: &UiSettings, -) -> Result { - let rgba = RgbaImage::from_raw(pixmap.width(), pixmap.height(), pixmap.data().to_vec()) - .ok_or_else(|| "Failed to build image buffer".to_string())?; - let mut img = DynamicImage::ImageRgba8(rgba); - - let target = raster_target_size(settings); - if img.width() != target.0 || img.height() != target.1 { - img = img.resize_exact(target.0, target.1, FilterType::Lanczos3); - } - - Ok(img) -} - -// Convert a pixmap into a thresholded mask image. -fn raster_mask_image_from_pixmap( - pixmap: &Pixmap, - settings: &UiSettings, -) -> Result { - let rgba = RgbaImage::from_raw(pixmap.width(), pixmap.height(), pixmap.data().to_vec()) - .ok_or_else(|| "Failed to build image buffer".to_string())?; - let mut img = DynamicImage::ImageRgba8(rgba); - - let target = raster_mask_target_size(settings, pixmap.width(), pixmap.height()); - if img.width() != target.0 || img.height() != target.1 { - img = img.resize_exact(target.0, target.1, FilterType::Nearest); - } - - let mut out = img.to_rgba8(); - for pixel in out.pixels_mut() { - let on = pixel[0] > 127 || pixel[1] > 127 || pixel[2] > 127; - let v = if on { 255 } else { 0 }; - *pixel = image::Rgba([v, v, v, 255]); - } - - Ok(DynamicImage::ImageRgba8(out)) -} - -// Compute the mask target size based on export width scaling. -fn raster_mask_target_size(settings: &UiSettings, _src_w: u32, _src_h: u32) -> (u32, u32) { - raster_target_size(settings) -} - -// Compute the output raster size while honoring aspect settings. -fn raster_target_size(settings: &UiSettings) -> (u32, u32) { - let width = settings.export_width.clamp(1, 10_000); - let mut height = settings.export_height.clamp(1, 10_000); - - if !settings.allow_export_aspect_change { - let cols = settings.cols.max(1) as f32; - let rows = settings.rows.max(1) as f32; - let ratio = cols / rows; - height = ((width as f32) / ratio).round() as u32; - height = height.clamp(1, 10_000); - } - - (width, height) -} - -// Build an SVG document for the current layout. -pub fn build_svg(layout: &DungeonLayout, settings: &UiSettings) -> String { - let g = ExportGeometry::new(settings.cols, settings.rows); - let wall_w = (g.cell / 2.5).max(1.0); - let door_w = (g.cell / 5.0).max(1.0); - - let mut s = String::new(); - let _ = writeln!( - s, - "", - g.width, g.height, g.width, g.height - ); - let _ = writeln!( - s, - "", - BG.0, BG.1, BG.2 - ); - - if settings.export_show_grid { - for c in 0..=g.cols { - let x = g.left() + c as f32 * g.cell; - let _ = writeln!( - s, - "", - g.top(), - g.top() + g.rows as f32 * g.cell, - GRID.0, - GRID.1, - GRID.2 - ); - } - for r in 0..=g.rows { - let y = g.top() + r as f32 * g.cell; - let _ = writeln!( - s, - "", - g.left(), - g.left() + g.cols as f32 * g.cell, - GRID.0, - GRID.1, - GRID.2 - ); - } - } - - let corridor_cells = corridor_cells(layout, settings.cols, settings.rows); - let corridor_edges = corridor_edges_from_cells(&corridor_cells); - let mut room_cells = HashSet::new(); - let room_edges = room_edges_from_rooms(&layout.rooms); - let mut door_edges = HashSet::new(); - - for door in &layout.doors { - for edge in door_edges_for(door, settings.cols, settings.rows) { - door_edges.insert(edge); - } - } - // corridor_cells/corridor_edges already populated with corridor widths - for room in &layout.rooms { - for x in room.x..(room.x + room.width) { - for y in room.y..(room.y + room.height) { - room_cells.insert((x, y)); - } - } - } - - for &(x, y) in &corridor_cells { - let px = g.left() + x as f32 * g.cell; - let py = g.top() + y as f32 * g.cell; - let _ = writeln!( - s, - "", - g.cell, g.cell, CORRIDOR.0, CORRIDOR.1, CORRIDOR.2 - ); - if settings.colorblind_mode { - let (cx, cy) = g.cell_center(x, y); - let _ = writeln!( - s, - "", - g.cell * 0.14 - ); - } - } - - for room in &layout.rooms { - let px = g.left() + room.x as f32 * g.cell; - let py = g.top() + room.y as f32 * g.cell; - let _ = writeln!( - s, - "", - room.width as f32 * g.cell, - room.height as f32 * g.cell, - ROOM.0, - ROOM.1, - ROOM.2 - ); - if settings.colorblind_mode { - for x in room.x..(room.x + room.width) { - for y in room.y..(room.y + room.height) { - let rx = g.left() + x as f32 * g.cell + 2.0; - let ry = g.top() + y as f32 * g.cell + 2.0; - let rr = g.cell - 4.0; - let _ = writeln!( - s, - "", - rx + rr, - ry + rr - ); - let _ = writeln!( - s, - "", - rx + rr, - ry + rr - ); - } - } - } - } - - append_svg_walls( - &mut s, - &g, - &corridor_cells, - Some(&corridor_edges), - &door_edges, - wall_w, - ); - append_svg_walls( - &mut s, - &g, - &room_cells, - Some(&room_edges), - &door_edges, - wall_w, - ); - - for door in &layout.doors { - let (color, dash) = if settings.colorblind_mode { - if door.secret { - ("black", Some("7,3,1,3,1,4")) - } else if door.archway { - ("black", Some("2,4")) - } else if door.locked { - ("black", Some("6,4")) - } else { - ("black", Some("14,7")) - } - } else if door.secret { - ("rgb(170,80,170)", None) - } else if door.archway { - ("rgb(230,140,60)", None) - } else if door.locked { - ("rgb(220,70,70)", None) - } else { - ("rgb(80,200,120)", None) - }; - - if let Some((x1, y1, x2, y2)) = - door_line_points(&g, door.from, door.to, door_render_width(door)) - { - if let Some(pattern) = dash { - let _ = writeln!( - s, - "" - ); - } else { - let _ = writeln!( - s, - "" - ); - } - } - } - - for window in &layout.windows { - let dash = settings.colorblind_mode.then_some("7,3,1,4"); - if let Some((x1, y1, x2, y2)) = window_line_points(&g, window) { - if let Some(pattern) = dash { - let _ = writeln!( - s, - "", - WINDOW.0, WINDOW.1, WINDOW.2 - ); - } else { - let _ = writeln!( - s, - "", - WINDOW.0, WINDOW.1, WINDOW.2 - ); - } - } - } - - append_svg_area_marker_group( - &mut s, - &g, - &layout.start_markers, - "S", - START_MARKER, - settings.colorblind_mode, - ); - append_svg_area_marker_group( - &mut s, - &g, - &layout.end_markers, - "E", - END_MARKER, - settings.colorblind_mode, - ); - append_svg_area_marker_group( - &mut s, - &g, - &layout.trap_markers, - "T", - TRAP_MARKER, - settings.colorblind_mode, - ); - append_svg_area_marker_group( - &mut s, - &g, - &layout.monster_markers, - "M", - MONSTER_MARKER, - settings.colorblind_mode, - ); - - // Draw stairs. - for stair in &layout.stairs { - let x = g.left() + stair.cell.0 as f32 * g.cell; - let y = g.top() + stair.cell.1 as f32 * g.cell; - let w = stair.width as f32 * g.cell; - let h = stair.height as f32 * g.cell; - - let fill = if settings.colorblind_mode { - "rgb(180,180,180)" - } else { - "rgb(200,150,50)" - }; - let _ = writeln!( - s, - "" - ); - let steps = (stair.height as f32 * 2.0).round() as usize; - let step_h = h / steps as f32; - for i in 0..steps { - let sy = y + (i as f32 * step_h) + step_h * 0.5; - let _ = writeln!( - s, - "", - x1 = x + 2.0, - x2 = x + w - 2.0 - ); - } - } - - s.push_str("\n"); - s -} - -// Fill the entire pixmap with a solid color. -fn fill_bg(pixmap: &mut Pixmap, color: (u8, u8, u8, u8)) { - let mut paint = Paint::default(); - paint.set_color_rgba8(color.0, color.1, color.2, color.3); - let Some(rect) = Rect::from_xywh(0.0, 0.0, pixmap.width() as f32, pixmap.height() as f32) - else { - return; - }; - pixmap.fill_rect(rect, &paint, Transform::identity(), None); -} - -// Draw the default grid lines. -fn draw_grid(pixmap: &mut Pixmap, g: &ExportGeometry) { - draw_grid_color(pixmap, g, GRID); -} - -// Draw grid lines using a specific color. -fn draw_grid_color(pixmap: &mut Pixmap, g: &ExportGeometry, color: (u8, u8, u8, u8)) { - let left = g.left(); - let top = g.top(); - let width = g.cols as f32 * g.cell; - let height = g.rows as f32 * g.cell; - - let mut paint = Paint::default(); - paint.set_color_rgba8(color.0, color.1, color.2, color.3); - - for c in 0..=g.cols { - let x = (left + c as f32 * g.cell).round(); - let Some(rect) = Rect::from_xywh(x, top, 1.0, height) else { - continue; - }; - pixmap.fill_rect(rect, &paint, Transform::identity(), None); - } - for r in 0..=g.rows { - let y = (top + r as f32 * g.cell).round(); - let Some(rect) = Rect::from_xywh(left, y, width, 1.0) else { - continue; - }; - pixmap.fill_rect(rect, &paint, Transform::identity(), None); - } -} - -// Fill a single cell rectangle with a color. -fn fill_rect_cell( - pixmap: &mut Pixmap, - g: &ExportGeometry, - col: usize, - row: usize, - color: (u8, u8, u8, u8), -) { - let Some(rect) = g.cell_rect(col, row) else { - return; - }; - let mut paint = Paint::default(); - paint.set_color_rgba8(color.0, color.1, color.2, color.3); - pixmap.fill_rect(rect, &paint, Transform::identity(), None); -} - -// Fill an entire room rectangle with a color. -fn fill_rect_room(pixmap: &mut Pixmap, g: &ExportGeometry, room: &Room, color: (u8, u8, u8, u8)) { - let Some(rect) = Rect::from_xywh( - g.left() + room.x as f32 * g.cell, - g.top() + room.y as f32 * g.cell, - room.width as f32 * g.cell, - room.height as f32 * g.cell, - ) else { - return; - }; - - let mut paint = Paint::default(); - paint.set_color_rgba8(color.0, color.1, color.2, color.3); - pixmap.fill_rect(rect, &paint, Transform::identity(), None); -} - -fn fill_rect_marker( - pixmap: &mut Pixmap, - g: &ExportGeometry, - marker: &AreaMarker, - color: (u8, u8, u8, u8), -) { - let Some(rect) = Rect::from_xywh( - g.left() + marker.cell.0 as f32 * g.cell, - g.top() + marker.cell.1 as f32 * g.cell, - marker.size as f32 * g.cell, - marker.size as f32 * g.cell, - ) else { - return; - }; - let mut paint = Paint::default(); - paint.set_color_rgba8(color.0, color.1, color.2, color.3); - pixmap.fill_rect(rect, &paint, Transform::identity(), None); -} - -fn draw_area_marker( - pixmap: &mut Pixmap, - g: &ExportGeometry, - marker: &AreaMarker, - label: &str, - color: (u8, u8, u8, u8), - colorblind_mode: bool, -) { - let overlay = ( - color.0, - color.1, - color.2, - if colorblind_mode { 56 } else { 90 }, - ); - fill_rect_marker(pixmap, g, marker, overlay); - - let x = g.left() + marker.cell.0 as f32 * g.cell; - let y = g.top() + marker.cell.1 as f32 * g.cell; - let w = marker.size as f32 * g.cell; - let h = marker.size as f32 * g.cell; - draw_wall_segment(pixmap, x, y, x + w, y, 3.0, color); - draw_wall_segment(pixmap, x, y + h, x + w, y + h, 3.0, color); - draw_wall_segment(pixmap, x, y, x, y + h, 3.0, color); - draw_wall_segment(pixmap, x + w, y, x + w, y + h, 3.0, color); - - let symbol = if colorblind_mode { BLACK } else { WHITE }; - if label == "S" { - draw_line( - pixmap, - x + w * 0.25, - y + h * 0.75, - x + w * 0.5, - y + h * 0.25, - 4.0, - symbol, - DoorStyle::Solid, - ); - draw_line( - pixmap, - x + w * 0.5, - y + h * 0.25, - x + w * 0.75, - y + h * 0.75, - 4.0, - symbol, - DoorStyle::Solid, - ); - } else if label == "E" { - draw_line( - pixmap, - x + w * 0.25, - y + h * 0.25, - x + w * 0.75, - y + h * 0.75, - 4.0, - symbol, - DoorStyle::Solid, - ); - draw_line( - pixmap, - x + w * 0.75, - y + h * 0.25, - x + w * 0.25, - y + h * 0.75, - 4.0, - symbol, - DoorStyle::Solid, - ); - } else if label == "T" { - // Horizontal bar - draw_line( - pixmap, - x + w * 0.2, - y + h * 0.25, - x + w * 0.8, - y + h * 0.25, - 4.0, - symbol, - DoorStyle::Solid, - ); - // Vertical bar - draw_line( - pixmap, - x + w * 0.5, - y + h * 0.25, - x + w * 0.5, - y + h * 0.75, - 4.0, - symbol, - DoorStyle::Solid, - ); - } else if label == "M" { - draw_line( - pixmap, - x + w * 0.2, - y + h * 0.75, - x + w * 0.2, - y + h * 0.25, - 4.0, - symbol, - DoorStyle::Solid, - ); - draw_line( - pixmap, - x + w * 0.2, - y + h * 0.25, - x + w * 0.5, - y + h * 0.5, - 4.0, - symbol, - DoorStyle::Solid, - ); - draw_line( - pixmap, - x + w * 0.5, - y + h * 0.5, - x + w * 0.8, - y + h * 0.25, - 4.0, - symbol, - DoorStyle::Solid, - ); - draw_line( - pixmap, - x + w * 0.8, - y + h * 0.25, - x + w * 0.8, - y + h * 0.75, - 4.0, - symbol, - DoorStyle::Solid, - ); - } -} - -fn fill_rect_staircase( - pixmap: &mut Pixmap, - g: &ExportGeometry, - staircase: &Staircase, - color: (u8, u8, u8, u8), -) { - let Some(rect) = Rect::from_xywh( - g.left() + staircase.cell.0 as f32 * g.cell, - g.top() + staircase.cell.1 as f32 * g.cell, - staircase.width as f32 * g.cell, - staircase.height as f32 * g.cell, - ) else { - return; - }; - let mut paint = Paint::default(); - paint.set_color_rgba8(color.0, color.1, color.2, color.3); - pixmap.fill_rect(rect, &paint, Transform::identity(), None); -} - -fn draw_staircase( - pixmap: &mut Pixmap, - g: &ExportGeometry, - staircase: &Staircase, - color: (u8, u8, u8, u8), - colorblind_mode: bool, -) { - let fill_color = if colorblind_mode { - (180, 180, 180, 90) - } else { - (color.0, color.1, color.2, 90) - }; - - let Some(rect) = Rect::from_xywh( - g.left() + staircase.cell.0 as f32 * g.cell, - g.top() + staircase.cell.1 as f32 * g.cell, - staircase.width as f32 * g.cell, - staircase.height as f32 * g.cell, - ) else { - return; - }; - - let mut paint = Paint::default(); - paint.set_color_rgba8(fill_color.0, fill_color.1, fill_color.2, fill_color.3); - pixmap.fill_rect(rect, &paint, Transform::identity(), None); - - let stroke_color = if colorblind_mode { - BLACK - } else { - (150, 100, 30, 255) - }; - let x = rect.left(); - let y = rect.top(); - let w = rect.width(); - let h = rect.height(); - - draw_wall_segment(pixmap, x, y, x + w, y, 2.0, stroke_color); - draw_wall_segment(pixmap, x, y + h, x + w, y + h, 2.0, stroke_color); - draw_wall_segment(pixmap, x, y, x, y + h, 2.0, stroke_color); - draw_wall_segment(pixmap, x + w, y, x + w, y + h, 2.0, stroke_color); - - let steps = (staircase.height as f32 * 2.0).round() as usize; - let step_h = h / steps as f32; - for i in 0..steps { - let sy = (y + (i as f32 * step_h) + step_h * 0.5).round(); - draw_line( - pixmap, - x + 2.0, - sy, - x + w - 2.0, - sy, - 1.5, - stroke_color, - DoorStyle::Solid, - ); - } -} - -fn draw_staircase_group( - pixmap: &mut Pixmap, - g: &ExportGeometry, - stairs: &[Staircase], - color: (u8, u8, u8, u8), - colorblind_mode: bool, -) { - for staircase in stairs { - draw_staircase(pixmap, g, staircase, color, colorblind_mode); - } -} - -fn draw_area_marker_group( - pixmap: &mut Pixmap, - g: &ExportGeometry, - markers: &[AreaMarker], - label: &str, - color: (u8, u8, u8, u8), - colorblind_mode: bool, -) { - for marker in markers { - draw_area_marker(pixmap, g, marker, label, color, colorblind_mode); - } -} - -// Draw a filled circle for dot patterns. -fn draw_dot(pixmap: &mut Pixmap, cx: f32, cy: f32, radius: f32, color: (u8, u8, u8, u8)) { - let mut pb = PathBuilder::new(); - pb.push_circle(cx, cy, radius); - let Some(path) = pb.finish() else { - return; - }; - let mut paint = Paint::default(); - paint.set_color_rgba8(color.0, color.1, color.2, color.3); - pixmap.fill_path( - &path, - &paint, - FillRule::Winding, - Transform::identity(), - None, - ); -} - -// Draw a crosshatch overlay for a room. -fn draw_room_crosshatch( - pixmap: &mut Pixmap, - g: &ExportGeometry, - room: &Room, - width: f32, - color: (u8, u8, u8, u8), -) { - for x in room.x..(room.x + room.width) { - for y in room.y..(room.y + room.height) { - if let Some(rect) = g.cell_rect(x, y) { - let pad = 2.0; - draw_line( - pixmap, - rect.left() + pad, - rect.top() + pad, - rect.right() - pad, - rect.bottom() - pad, - width, - color, - DoorStyle::Solid, - ); - draw_line( - pixmap, - rect.right() - pad, - rect.top() + pad, - rect.left() + pad, - rect.bottom() - pad, - width, - color, - DoorStyle::Solid, - ); - } - } - } -} - -// Draw wall segments around occupied cells. -fn draw_cell_walls( - pixmap: &mut Pixmap, - g: &ExportGeometry, - cells: &HashSet<(usize, usize)>, - connected_edges: Option<&HashSet<((usize, usize), (usize, usize))>>, - door_edges: &HashSet<((usize, usize), (usize, usize))>, - width: f32, - color: (u8, u8, u8, u8), -) { - for &(col, row) in cells { - if let Some(rect) = g.cell_rect(col, row) { - let right = (col + 1, row); - let bottom = (col, row + 1); - - let should_left = col == 0 - || (!cells.contains(&(col - 1, row)) - && !door_edges.contains(&norm_edge((col, row), (col - 1, row)))); - - let right_connected = connected_edges - .map(|set| set.contains(&norm_edge((col, row), right))) - .unwrap_or(true); - let should_right = (!cells.contains(&right) || !right_connected) - && !door_edges.contains(&norm_edge((col, row), right)); - - let should_top = row == 0 - || (!cells.contains(&(col, row - 1)) - && !door_edges.contains(&norm_edge((col, row), (col, row - 1)))); - - let bottom_connected = connected_edges - .map(|set| set.contains(&norm_edge((col, row), bottom))) - .unwrap_or(true); - let should_bottom = (!cells.contains(&bottom) || !bottom_connected) - && !door_edges.contains(&norm_edge((col, row), bottom)); - - if should_left { - draw_wall_segment( - pixmap, - rect.left(), - rect.top(), - rect.left(), - rect.bottom(), - width, - color, - ); - } - if should_right { - draw_wall_segment( - pixmap, - rect.right(), - rect.top(), - rect.right(), - rect.bottom(), - width, - color, - ); - } - if should_top { - draw_wall_segment( - pixmap, - rect.left(), - rect.top(), - rect.right(), - rect.top(), - width, - color, - ); - } - if should_bottom { - draw_wall_segment( - pixmap, - rect.left(), - rect.bottom(), - rect.right(), - rect.bottom(), - width, - color, - ); - } - } - } -} - -// Draw a single wall segment as a filled rectangle. -fn draw_wall_segment( - pixmap: &mut Pixmap, - x1: f32, - y1: f32, - x2: f32, - y2: f32, - width: f32, - color: (u8, u8, u8, u8), -) { - let width = width.round().max(1.0); - let half = width * 0.5; - let x1 = x1.round(); - let y1 = y1.round(); - let x2 = x2.round(); - let y2 = y2.round(); - let rect = if (x1 - x2).abs() <= f32::EPSILON { - let x = x1.min(x2); - let y0 = y1.min(y2); - let y1 = y1.max(y2); - Rect::from_xywh(x - half, y0 - half, width, (y1 - y0) + width) - } else { - let y = y1.min(y2); - let x0 = x1.min(x2); - let x1 = x1.max(x2); - Rect::from_xywh(x0 - half, y - half, (x1 - x0) + width, width) - }; - - let Some(rect) = rect else { - return; - }; - - let mut paint = Paint::default(); - paint.set_color_rgba8(color.0, color.1, color.2, color.3); - pixmap.fill_rect(rect, &paint, Transform::identity(), None); -} - -// Round a width to the nearest even pixel size. -fn snap_even_width(value: f32) -> f32 { - let mut w = value.round().max(1.0); - if (w as u32) % 2 == 1 { - w += 1.0; - } - w -} - -// Append SVG wall rectangles for the given cells. -fn append_svg_walls( - out: &mut String, - g: &ExportGeometry, - cells: &HashSet<(usize, usize)>, - connected_edges: Option<&HashSet<((usize, usize), (usize, usize))>>, - door_edges: &HashSet<((usize, usize), (usize, usize))>, - width: f32, -) { - for &(col, row) in cells { - let left = g.left() + col as f32 * g.cell; - let top = g.top() + row as f32 * g.cell; - let right = left + g.cell; - let bottom = top + g.cell; - - let right_cell = (col + 1, row); - let bottom_cell = (col, row + 1); - - let left_edge = col == 0 - || (!cells.contains(&(col - 1, row)) - && !door_edges.contains(&norm_edge((col, row), (col - 1, row)))); - - let right_connected = connected_edges - .map(|set| set.contains(&norm_edge((col, row), right_cell))) - .unwrap_or(true); - let right_edge = (!cells.contains(&right_cell) || !right_connected) - && !door_edges.contains(&norm_edge((col, row), right_cell)); - - let top_edge = row == 0 - || (!cells.contains(&(col, row - 1)) - && !door_edges.contains(&norm_edge((col, row), (col, row - 1)))); - - let bottom_connected = connected_edges - .map(|set| set.contains(&norm_edge((col, row), bottom_cell))) - .unwrap_or(true); - let bottom_edge = (!cells.contains(&bottom_cell) || !bottom_connected) - && !door_edges.contains(&norm_edge((col, row), bottom_cell)); - - if left_edge { - let _ = writeln!( - out, - "", - left - (width * 0.5), - top - (width * 0.5), - (bottom - top) + width - ); - } - if right_edge { - let _ = writeln!( - out, - "", - right - (width * 0.5), - top - (width * 0.5), - (bottom - top) + width - ); - } - if top_edge { - let _ = writeln!( - out, - "", - left - (width * 0.5), - top - (width * 0.5), - (right - left) + width - ); - } - if bottom_edge { - let _ = writeln!( - out, - "", - left - (width * 0.5), - bottom - (width * 0.5), - (right - left) + width - ); - } - } -} - -fn append_svg_area_marker( - out: &mut String, - g: &ExportGeometry, - marker: &AreaMarker, - label: &str, - color: (u8, u8, u8, u8), - colorblind_mode: bool, -) { - let x = g.left() + marker.cell.0 as f32 * g.cell; - let y = g.top() + marker.cell.1 as f32 * g.cell; - let w = marker.size as f32 * g.cell; - let h = marker.size as f32 * g.cell; - let alpha = if colorblind_mode { 0.22 } else { 0.35 }; - let _ = writeln!( - out, - "", - color.0, color.1, color.2, color.0, color.1, color.2 - ); - let symbol = if colorblind_mode { "black" } else { "white" }; - if label == "S" { - let _ = writeln!( - out, - "", - x + w * 0.25, - y + h * 0.75, - x + w * 0.5, - y + h * 0.25, - x + w * 0.75, - y + h * 0.75, - symbol - ); - } else if label == "E" { - let _ = writeln!( - out, - "", - x + w * 0.25, - y + h * 0.25, - x + w * 0.75, - y + h * 0.75, - symbol - ); - let _ = writeln!( - out, - "", - x + w * 0.75, - y + h * 0.25, - x + w * 0.25, - y + h * 0.75, - symbol - ); - } else if label == "T" { - // Horizontal bar - let _ = writeln!( - out, - "", - x + w * 0.2, - y + h * 0.25, - x + w * 0.8, - y + h * 0.25, - symbol - ); - // Vertical bar - let _ = writeln!( - out, - "", - x + w * 0.5, - y + h * 0.25, - x + w * 0.5, - y + h * 0.75, - symbol - ); - } else if label == "M" { - let _ = writeln!( - out, - "", - x + w * 0.2, - y + h * 0.75, - x + w * 0.2, - y + h * 0.25, - x + w * 0.5, - y + h * 0.5, - x + w * 0.8, - y + h * 0.25, - x + w * 0.8, - y + h * 0.75, - symbol - ); - } -} - -fn append_svg_area_marker_group( - out: &mut String, - g: &ExportGeometry, - markers: &[AreaMarker], - label: &str, - color: (u8, u8, u8, u8), - colorblind_mode: bool, -) { - for marker in markers { - append_svg_area_marker(out, g, marker, label, color, colorblind_mode); - } -} - -// Draw a door line onto the pixmap. -fn draw_door( - pixmap: &mut Pixmap, - g: &ExportGeometry, - door: &Door, - width: f32, - color: (u8, u8, u8, u8), - style: DoorStyle, -) { - let Some((x1, y1, x2, y2)) = door_line_points(g, door.from, door.to, door_render_width(door)) - else { - return; - }; - draw_line(pixmap, x1, y1, x2, y2, width, color, style); -} - -fn draw_window( - pixmap: &mut Pixmap, - g: &ExportGeometry, - window: &Window, - width: f32, - color: (u8, u8, u8, u8), - style: DoorStyle, -) { - let Some((x1, y1, x2, y2)) = window_line_points(g, window) else { - return; - }; - draw_line(pixmap, x1, y1, x2, y2, width, color, style); -} - -// Compute the door line endpoints in export space. -fn door_line_points( - g: &ExportGeometry, - a: (usize, usize), - b: (usize, usize), - width_cells: usize, -) -> Option<(f32, f32, f32, f32)> { - if a.0.abs_diff(b.0) + a.1.abs_diff(b.1) != 1 { - return None; - } - - let width_cells = width_cells.max(1) as isize; - let min_offset = -((width_cells - 1) / 2); - let max_offset = width_cells / 2; - - if a.0 != b.0 { - let x = g.left() + (a.0.max(b.0) as f32) * g.cell; - let row = a.1 as isize; - let y0_cell = (row + min_offset).clamp(0, g.rows.saturating_sub(1) as isize); - let y1_cell = (row + max_offset).clamp(0, g.rows.saturating_sub(1) as isize); - let y0 = g.top() + y0_cell as f32 * g.cell; - let y1 = g.top() + (y1_cell as f32 + 1.0) * g.cell; - Some((x, y0, x, y1)) - } else { - let y = g.top() + (a.1.max(b.1) as f32) * g.cell; - let col = a.0 as isize; - let x0_cell = (col + min_offset).clamp(0, g.cols.saturating_sub(1) as isize); - let x1_cell = (col + max_offset).clamp(0, g.cols.saturating_sub(1) as isize); - let x0 = g.left() + x0_cell as f32 * g.cell; - let x1 = g.left() + (x1_cell as f32 + 1.0) * g.cell; - Some((x0, y, x1, y)) - } -} - -fn window_line_points(g: &ExportGeometry, window: &Window) -> Option<(f32, f32, f32, f32)> { - let width_cells = window_render_width(window).max(1) as isize; - let min_offset = -((width_cells - 1) / 2); - let max_offset = width_cells / 2; - - match window.side { - WindowSide::Left => { - let x = g.left() + window.cell.0 as f32 * g.cell; - let row = window.cell.1 as isize; - let y0_cell = (row + min_offset).clamp(0, g.rows.saturating_sub(1) as isize); - let y1_cell = (row + max_offset).clamp(0, g.rows.saturating_sub(1) as isize); - let y0 = g.top() + y0_cell as f32 * g.cell; - let y1 = g.top() + (y1_cell as f32 + 1.0) * g.cell; - Some((x, y0, x, y1)) - } - WindowSide::Right => { - let x = g.left() + (window.cell.0 as f32 + 1.0) * g.cell; - let row = window.cell.1 as isize; - let y0_cell = (row + min_offset).clamp(0, g.rows.saturating_sub(1) as isize); - let y1_cell = (row + max_offset).clamp(0, g.rows.saturating_sub(1) as isize); - let y0 = g.top() + y0_cell as f32 * g.cell; - let y1 = g.top() + (y1_cell as f32 + 1.0) * g.cell; - Some((x, y0, x, y1)) - } - WindowSide::Top => { - let y = g.top() + window.cell.1 as f32 * g.cell; - let col = window.cell.0 as isize; - let x0_cell = (col + min_offset).clamp(0, g.cols.saturating_sub(1) as isize); - let x1_cell = (col + max_offset).clamp(0, g.cols.saturating_sub(1) as isize); - let x0 = g.left() + x0_cell as f32 * g.cell; - let x1 = g.left() + (x1_cell as f32 + 1.0) * g.cell; - Some((x0, y, x1, y)) - } - WindowSide::Bottom => { - let y = g.top() + (window.cell.1 as f32 + 1.0) * g.cell; - let col = window.cell.0 as isize; - let x0_cell = (col + min_offset).clamp(0, g.cols.saturating_sub(1) as isize); - let x1_cell = (col + max_offset).clamp(0, g.cols.saturating_sub(1) as isize); - let x0 = g.left() + x0_cell as f32 * g.cell; - let x1 = g.left() + (x1_cell as f32 + 1.0) * g.cell; - Some((x0, y, x1, y)) - } - } -} - -// Draw a styled line in the pixmap. -fn draw_line( - pixmap: &mut Pixmap, - x1: f32, - y1: f32, - x2: f32, - y2: f32, - width: f32, - color: (u8, u8, u8, u8), - style: DoorStyle, -) { - match style { - DoorStyle::Solid => stroke_path(pixmap, x1, y1, x2, y2, width, color, None), - DoorStyle::LongDash => stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((14.0, 7.0))), - DoorStyle::ShortDash => stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((6.0, 4.0))), - DoorStyle::Dotted => dotted_line(pixmap, x1, y1, x2, y2, width, color), - DoorStyle::DashDot => stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((7.0, 3.0))), - DoorStyle::DashDotDot => { - stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((7.0, 3.0))) - } - } -} - -// Stroke a path with optional dash patterns. -fn stroke_path( - pixmap: &mut Pixmap, - x1: f32, - y1: f32, - x2: f32, - y2: f32, - width: f32, - color: (u8, u8, u8, u8), - dash: Option<(f32, f32)>, -) { - let mut pb = PathBuilder::new(); - pb.move_to(x1, y1); - pb.line_to(x2, y2); - let Some(path) = pb.finish() else { - return; - }; - - let mut paint = Paint::default(); - paint.set_color_rgba8(color.0, color.1, color.2, color.3); - - let mut stroke = Stroke::default(); - stroke.width = width; - if let Some((dash_len, gap_len)) = dash { - stroke.dash = StrokeDash::new(vec![dash_len, gap_len], 0.0); - } - - pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); -} - -// Draw a dotted line using repeated circles. -fn dotted_line( - pixmap: &mut Pixmap, - x1: f32, - y1: f32, - x2: f32, - y2: f32, - width: f32, - color: (u8, u8, u8, u8), -) { - let dx = x2 - x1; - let dy = y2 - y1; - let len = (dx * dx + dy * dy).sqrt(); - if len <= 0.0 { - return; - } - let ux = dx / len; - let uy = dy / len; - - let mut d = 0.0_f32; - let step = 6.0; - let radius = (width * 0.35).max(1.0); - while d <= len { - draw_dot(pixmap, x1 + ux * d, y1 + uy * d, radius, color); - d += step; - } -} - -// Normalize an edge tuple to a stable ordering. -fn norm_edge(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) { - if a <= b { (a, b) } else { (b, a) } -} - -// Build a set of corridor-adjacent edges from corridor cells. -fn corridor_edges_from_cells( - corridor_cells: &HashSet<(usize, usize)>, -) -> HashSet<((usize, usize), (usize, usize))> { - let mut edges = HashSet::new(); - for &(col, row) in corridor_cells { - let right = (col + 1, row); - let bottom = (col, row + 1); - if corridor_cells.contains(&right) { - edges.insert(norm_edge((col, row), right)); - } - if corridor_cells.contains(&bottom) { - edges.insert(norm_edge((col, row), bottom)); - } - } - edges -} - -// Build a set of internal room edges from room rectangles. -fn room_edges_from_rooms(rooms: &[Room]) -> HashSet<((usize, usize), (usize, usize))> { - let mut edges = HashSet::new(); - for room in rooms { - let x_end = room.x + room.width; - let y_end = room.y + room.height; - for x in room.x..x_end { - for y in room.y..y_end { - if x + 1 < x_end { - edges.insert(norm_edge((x, y), (x + 1, y))); - } - if y + 1 < y_end { - edges.insert(norm_edge((x, y), (x, y + 1))); - } - } - } - } - edges -} - -// Expand a door into all grid edges it spans based on width. -fn door_edges_for(door: &Door, cols: usize, rows: usize) -> Vec<((usize, usize), (usize, usize))> { - let mut edges = Vec::new(); - if door.from.0.abs_diff(door.to.0) + door.from.1.abs_diff(door.to.1) != 1 { - return edges; - } - - let width = door_render_width(door).max(1) as isize; - let min_offset = -((width - 1) / 2); - let max_offset = width / 2; - - if door.from.0 != door.to.0 { - let y = door.from.1 as isize; - for dy in min_offset..=max_offset { - let ny = y + dy; - if ny < 0 || ny >= rows as isize { - continue; - } - let a = (door.from.0, ny as usize); - let b = (door.to.0, ny as usize); - edges.push(norm_edge(a, b)); - } - } else { - let x = door.from.0 as isize; - for dx in min_offset..=max_offset { - let nx = x + dx; - if nx < 0 || nx >= cols as isize { - continue; - } - let a = (nx as usize, door.from.1); - let b = (nx as usize, door.to.1); - edges.push(norm_edge(a, b)); - } - } - - edges -} - -// Determine the rendered door width in cells. -fn door_render_width(door: &Door) -> usize { - if door.span_width { - door.width.max(1) - } else { - 1 - } -} - -fn window_render_width(window: &Window) -> usize { - if window.span_width { - window.width.max(1) - } else { - 1 - } -} diff --git a/src/exporter/mod.rs b/src/exporter/mod.rs new file mode 100644 index 0000000..8095b84 --- /dev/null +++ b/src/exporter/mod.rs @@ -0,0 +1,481 @@ +/* + * Main export module. + * Orchestrates the export process, coordinating dialog selection, + * background thread execution, and multi-format (SVG/Raster) generation. + */ + +use image::ImageFormat; +use rayon::prelude::*; +use rfd::FileDialog; +use std::path::PathBuf; +use std::sync::mpsc; +use tiny_skia::Pixmap; + +use crate::layout::DungeonLayout; +use crate::ui::{ExportFormat, MaskFormat, UiSettings}; + +pub mod raster; +pub mod svg; +pub mod types; +pub mod utils; + +pub use svg::build_svg; +pub use types::*; + +type ProgressSender = mpsc::Sender; + +pub fn select_export_target(settings: &UiSettings) -> Result { + if settings.export_format == ExportFormat::Folder || settings.export_level_index == 999 { + let title = if settings.export_level_index == 999 { + "Select export folder for all levels" + } else { + "Export composite masks folder" + }; + let mut dialog = FileDialog::new().set_title(title); + if let Some(ref last_path) = settings.last_export_path { + dialog = dialog.set_directory(last_path); + } + let folder = dialog + .pick_folder() + .ok_or_else(|| "Export canceled".to_string())?; + return Ok(ExportTarget::Folder(folder)); + } + + let ext = settings.export_format.extension(); + let default_name = format!("dungeon_export.{ext}"); + + let mut dialog = FileDialog::new(); + dialog = dialog + .set_title("Export image") + .set_file_name(&default_name) + .add_filter(settings.export_format.label(), &[ext]); + + if let Some(ref last_path) = settings.last_export_path { + dialog = dialog.set_directory(last_path); + } + + let mut path = dialog + .save_file() + .ok_or_else(|| "Export canceled".to_string())?; + + if path.extension().is_none() { + path.set_extension(ext); + } + + Ok(ExportTarget::File(path)) +} + +pub fn export_to_target( + layouts: &[DungeonLayout], + settings: &UiSettings, + target: ExportTarget, + progress_tx: &ProgressSender, +) -> Result { + let export_indices: Vec = if settings.export_level_index == 999 { + (0..layouts.len()).collect() + } else { + let idx = settings + .export_level_index + .min(layouts.len().saturating_sub(1)); + vec![idx] + }; + let total = export_image_count(settings, &target, export_indices.len()); + report_progress(progress_tx, 0, total); + + match target { + ExportTarget::Folder(folder) => { + if settings.export_format == ExportFormat::Folder { + export_composite_masks_for_levels( + layouts, + settings, + &folder, + &export_indices, + progress_tx, + )?; + } else { + export_level_images_to_folder( + layouts, + settings, + &folder, + &export_indices, + progress_tx, + )?; + } + Ok(folder) + } + ExportTarget::File(path) => { + let idx = export_indices[0]; + save_layout_to_file(&layouts[idx], settings, &path)?; + report_progress(progress_tx, 1, total); + Ok(path) + } + } +} + +fn export_image_count(settings: &UiSettings, target: &ExportTarget, level_count: usize) -> usize { + match target { + ExportTarget::File(_) => 1, + ExportTarget::Folder(_) if settings.export_format == ExportFormat::Folder => { + level_count * composite_images_per_level(settings) + } + ExportTarget::Folder(_) => level_count, + } +} + +fn composite_images_per_level(settings: &UiSettings) -> usize { + let base_masks = 12; + let grid_mask = usize::from(settings.export_show_grid); + let composite = 1; + base_masks + grid_mask + composite +} + +fn report_progress(progress_tx: &ProgressSender, completed: usize, total: usize) { + let _ = progress_tx.send(ExportEvent::Progress(ExportProgress { completed, total })); +} + +fn export_level_images_to_folder( + layouts: &[DungeonLayout], + settings: &UiSettings, + folder: &std::path::Path, + export_indices: &[usize], + progress_tx: &ProgressSender, +) -> Result<(), String> { + std::fs::create_dir_all(folder).map_err(|err| format!("Failed creating folder: {err}"))?; + let completed = std::sync::atomic::AtomicUsize::new(0); + let total = export_indices.len(); + + let ext = settings.export_format.extension(); + export_indices.par_iter().try_for_each(|&idx| { + let filename = format!("level{}.{ext}", idx + 1); + let path = folder.join(filename); + save_layout_to_file(&layouts[idx], settings, &path)?; + let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + report_progress(progress_tx, done, total); + Ok(()) + }) +} + +fn save_layout_to_file( + layout: &DungeonLayout, + settings: &UiSettings, + path: &std::path::Path, +) -> Result<(), String> { + match settings.export_format { + ExportFormat::Svg => { + let svg = build_svg(layout, settings); + std::fs::write(path, svg).map_err(|e| format!("Failed writing SVG: {e}"))?; + } + ExportFormat::Png | ExportFormat::Jpeg | ExportFormat::Webp => { + let pixmap = raster::render_pixmap(layout, settings)?; + let img = raster::raster_image_from_pixmap(&pixmap, settings)?; + let format = match settings.export_format { + ExportFormat::Png => ImageFormat::Png, + ExportFormat::Jpeg => ImageFormat::Jpeg, + ExportFormat::Webp => ImageFormat::WebP, + ExportFormat::Svg | ExportFormat::Folder => unreachable!(), + }; + img.save_with_format(path, format) + .map_err(|e| format!("Failed writing image: {e}"))?; + } + ExportFormat::Folder => unreachable!(), + } + Ok(()) +} + +fn export_composite_masks_for_levels( + layouts: &[DungeonLayout], + settings: &UiSettings, + folder: &std::path::Path, + export_indices: &[usize], + progress_tx: &ProgressSender, +) -> Result<(), String> { + let contexts = export_indices + .iter() + .map(|&idx| { + let subfolder = if settings.export_level_index == 999 { + folder.join(format!("level {}", idx + 1)) + } else { + folder.to_path_buf() + }; + std::fs::create_dir_all(&subfolder) + .map_err(|err| format!("Failed creating folder: {err}"))?; + + let geometry = ExportGeometry::new(settings.cols, settings.rows); + let scene = utils::collect_scene_data(&layouts[idx], settings); + Ok(CompositeExportContext { + layout: &layouts[idx], + folder: subfolder, + geometry, + scene, + wall_width: (geometry.cell / 2.5).max(1.0), + door_width: (geometry.cell / 5.0).max(1.0), + }) + }) + .collect::, String>>()?; + + let mut tasks = vec![ + MaskTask::Floors, + MaskTask::Walls, + MaskTask::Doors, + MaskTask::Archways, + MaskTask::LockedDoors, + MaskTask::SecretDoors, + MaskTask::Windows, + MaskTask::StartMarkers, + MaskTask::EndMarkers, + MaskTask::TrapMarkers, + MaskTask::MonsterMarkers, + MaskTask::Stairs, + ]; + + if settings.export_show_grid { + tasks.push(MaskTask::Grid); + } + + let export_tasks = contexts + .iter() + .enumerate() + .flat_map(|(context_idx, _)| { + tasks + .iter() + .copied() + .map(move |task| (context_idx, CompositeTask::Mask(task))) + .chain(std::iter::once((context_idx, CompositeTask::Composite))) + }) + .collect::>(); + let completed = std::sync::atomic::AtomicUsize::new(0); + let total = export_tasks.len(); + + export_tasks + .into_par_iter() + .try_for_each(|(context_idx, task)| { + let context = &contexts[context_idx]; + match task { + CompositeTask::Mask(mask_task) => { + let (stem, pixmap) = render_mask_pixmap(context, mask_task)?; + save_mask_image(&context.folder, stem, &pixmap, settings) + } + CompositeTask::Composite => save_composite_image(context, settings), + }?; + let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + report_progress(progress_tx, done, total); + Ok::<(), String>(()) + })?; + + Ok(()) +} + +fn save_composite_image( + context: &CompositeExportContext<'_>, + settings: &UiSettings, +) -> Result<(), String> { + let composite = raster::render_pixmap_with_scene( + context.layout, + settings, + &context.geometry, + &context.scene, + )?; + let composite_img = raster::raster_image_from_pixmap(&composite, settings)?; + let composite_ext = settings.mask_format.extension(); + let composite_path = context.folder.join(format!("composite.{composite_ext}")); + composite_img + .save_with_format(&composite_path, mask_image_format(settings.mask_format)) + .map_err(|e| format!("Failed writing {}: {e}", composite_path.display()))?; + + Ok(()) +} + +fn render_mask_pixmap( + context: &CompositeExportContext<'_>, + task: MaskTask, +) -> Result<(&'static str, Pixmap), String> { + let g = &context.geometry; + let scene = &context.scene; + let layout = context.layout; + + let result = match task { + MaskTask::Floors => { + let mut pixmap = blank_mask(g, "floors")?; + for &(x, y) in &scene.corridor_cells { + raster::fill_rect_cell(&mut pixmap, g, x, y, WHITE); + } + for room in &layout.rooms { + raster::fill_rect_room(&mut pixmap, g, room, WHITE); + } + ("floors_mask", pixmap) + } + MaskTask::Walls => { + let mut pixmap = blank_mask(g, "walls")?; + raster::draw_cell_walls( + &mut pixmap, + g, + &scene.corridor_cells, + Some(&scene.corridor_edges), + &scene.door_edges, + context.wall_width, + WHITE, + ); + raster::draw_cell_walls( + &mut pixmap, + g, + &scene.room_cells, + Some(&scene.room_edges), + &scene.door_edges, + context.wall_width, + WHITE, + ); + ("walls_mask", pixmap) + } + MaskTask::Doors => { + let mut pixmap = blank_mask(g, "doors")?; + for door in &layout.doors { + if !door.secret && !door.archway && !door.locked { + raster::draw_door( + &mut pixmap, + g, + door, + context.door_width, + WHITE, + DoorStyle::Solid, + ); + } + } + ("doors_mask", pixmap) + } + MaskTask::Archways => { + let mut pixmap = blank_mask(g, "archways")?; + for door in &layout.doors { + if door.archway { + raster::draw_door( + &mut pixmap, + g, + door, + context.door_width, + WHITE, + DoorStyle::Solid, + ); + } + } + ("archways_mask", pixmap) + } + MaskTask::LockedDoors => { + let mut pixmap = blank_mask(g, "locked doors")?; + for door in &layout.doors { + if door.locked { + raster::draw_door( + &mut pixmap, + g, + door, + context.door_width, + WHITE, + DoorStyle::Solid, + ); + } + } + ("locked_doors_mask", pixmap) + } + MaskTask::SecretDoors => { + let mut pixmap = blank_mask(g, "secret doors")?; + for door in &layout.doors { + if door.secret { + raster::draw_door( + &mut pixmap, + g, + door, + context.door_width, + WHITE, + DoorStyle::Solid, + ); + } + } + ("secret_doors_mask", pixmap) + } + MaskTask::Windows => { + let mut pixmap = blank_mask(g, "windows")?; + for window in &layout.windows { + raster::draw_window( + &mut pixmap, + g, + window, + context.door_width, + WHITE, + DoorStyle::Solid, + ); + } + ("windows_mask", pixmap) + } + MaskTask::StartMarkers => { + let mut pixmap = blank_mask(g, "start markers")?; + fill_marker_mask(&mut pixmap, g, &layout.start_markers); + ("start_markers_mask", pixmap) + } + MaskTask::EndMarkers => { + let mut pixmap = blank_mask(g, "end markers")?; + fill_marker_mask(&mut pixmap, g, &layout.end_markers); + ("end_markers_mask", pixmap) + } + MaskTask::TrapMarkers => { + let mut pixmap = blank_mask(g, "trap markers")?; + fill_marker_mask(&mut pixmap, g, &layout.trap_markers); + ("trap_markers_mask", pixmap) + } + MaskTask::MonsterMarkers => { + let mut pixmap = blank_mask(g, "monster markers")?; + fill_marker_mask(&mut pixmap, g, &layout.monster_markers); + ("monster_markers_mask", pixmap) + } + MaskTask::Stairs => { + let mut pixmap = blank_mask(g, "stairs")?; + for staircase in &layout.stairs { + raster::fill_rect_staircase(&mut pixmap, g, staircase, WHITE); + } + ("stairs_mask", pixmap) + } + MaskTask::Grid => { + let mut pixmap = blank_mask(g, "grid")?; + raster::draw_grid_color(&mut pixmap, g, WHITE); + ("grid_mask", pixmap) + } + }; + + Ok(result) +} + +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"))?; + raster::fill_bg(&mut pixmap, BLACK); + Ok(pixmap) +} + +fn fill_marker_mask( + pixmap: &mut Pixmap, + g: &ExportGeometry, + markers: &[crate::layout::AreaMarker], +) { + for marker in markers { + raster::fill_rect_marker(pixmap, g, marker, WHITE); + } +} + +fn save_mask_image( + folder: &std::path::Path, + stem: &str, + pixmap: &Pixmap, + settings: &UiSettings, +) -> Result<(), String> { + let ext = settings.mask_format.extension(); + let path = folder.join(format!("{stem}.{ext}")); + let img = raster::raster_mask_image_from_pixmap(pixmap, settings)?; + img.save_with_format(&path, mask_image_format(settings.mask_format)) + .map_err(|e| format!("Failed writing {}: {e}", path.display()))?; + Ok(()) +} + +fn mask_image_format(format: MaskFormat) -> ImageFormat { + match format { + MaskFormat::Png => ImageFormat::Png, + MaskFormat::Jpeg => ImageFormat::Jpeg, + MaskFormat::Webp => ImageFormat::WebP, + } +} diff --git a/src/exporter/raster.rs b/src/exporter/raster.rs new file mode 100644 index 0000000..dbaf63e --- /dev/null +++ b/src/exporter/raster.rs @@ -0,0 +1,816 @@ +/* + * Raster image export logic. + * Provides functions to render dungeon layouts into pixel buffers (Pixmaps) + * and encode them as PNG, JPEG, or WebP images. + */ + +use super::types::{ + BG, BLACK, CORRIDOR, DoorStyle, END_MARKER, ExportGeometry, GRID, MONSTER_MARKER, ROOM, STAIRS, + START_MARKER, TRAP_MARKER, WHITE, WINDOW_COLOR, door_render_width, norm_edge, +}; +use super::utils::{collect_scene_data, door_line_points, window_line_points}; +use crate::layout::{AreaMarker, DungeonLayout, Room, Staircase, Window}; +use crate::ui::UiSettings; +use image::{DynamicImage, RgbaImage, imageops::FilterType}; +use std::collections::HashSet; +use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Rect, Stroke, StrokeDash, Transform}; + +pub fn render_pixmap(layout: &DungeonLayout, settings: &UiSettings) -> Result { + let g = ExportGeometry::new(settings.cols, settings.rows); + let scene = collect_scene_data(layout, settings); + render_pixmap_with_scene(layout, settings, &g, &scene) +} + +pub fn render_pixmap_with_scene( + layout: &DungeonLayout, + settings: &UiSettings, + g: &ExportGeometry, + scene: &super::types::SceneData, +) -> Result { + let mut pixmap = + Pixmap::new(g.width, g.height).ok_or_else(|| "Failed to allocate canvas".to_string())?; + fill_bg(&mut pixmap, BG); + + let wall_w = snap_even_width((g.cell / 2.5).max(1.0)); + let door_w = (g.cell / 5.0).max(1.0).round(); + + if settings.export_show_grid { + draw_grid_color(&mut pixmap, g, GRID); + } + + for &(x, y) in &scene.corridor_cells { + fill_rect_cell(&mut pixmap, g, x, y, CORRIDOR); + if settings.colorblind_mode { + let (cx, cy) = g.cell_center(x, y); + draw_dot(&mut pixmap, cx, cy, g.cell * 0.14, BLACK); + } + } + + for room in &layout.rooms { + fill_rect_room(&mut pixmap, g, room, ROOM); + if settings.colorblind_mode { + draw_room_crosshatch(&mut pixmap, g, room, 1.5, BLACK); + } + } + + draw_cell_walls( + &mut pixmap, + g, + &scene.corridor_cells, + Some(&scene.corridor_edges), + &scene.door_edges, + wall_w, + BLACK, + ); + draw_cell_walls( + &mut pixmap, + g, + &scene.room_cells, + Some(&scene.room_edges), + &scene.door_edges, + wall_w, + BLACK, + ); + + for door in &layout.doors { + let (color, style) = if settings.colorblind_mode { + let s = if door.secret { + DoorStyle::DashDotDot + } else if door.archway { + DoorStyle::Dotted + } else if door.locked { + DoorStyle::ShortDash + } else { + DoorStyle::LongDash + }; + (BLACK, s) + } else if door.secret { + (super::types::SECRET_DOOR, DoorStyle::Solid) + } else if door.archway { + (super::types::ARCHWAY, DoorStyle::Solid) + } else if door.locked { + (super::types::LOCKED_DOOR, DoorStyle::Solid) + } else { + (super::types::OPEN_DOOR, DoorStyle::Solid) + }; + draw_door(&mut pixmap, g, door, door_w, color, style); + } + + for window in &layout.windows { + let style = if settings.colorblind_mode { + DoorStyle::DashDot + } else { + DoorStyle::Solid + }; + draw_window(&mut pixmap, g, window, door_w, WINDOW_COLOR, style); + } + + draw_area_marker_group( + &mut pixmap, + g, + &layout.start_markers, + "S", + START_MARKER, + settings.colorblind_mode, + ); + draw_area_marker_group( + &mut pixmap, + g, + &layout.end_markers, + "E", + END_MARKER, + settings.colorblind_mode, + ); + draw_area_marker_group( + &mut pixmap, + g, + &layout.trap_markers, + "T", + TRAP_MARKER, + settings.colorblind_mode, + ); + draw_area_marker_group( + &mut pixmap, + g, + &layout.monster_markers, + "M", + MONSTER_MARKER, + settings.colorblind_mode, + ); + + draw_staircase_group( + &mut pixmap, + g, + &layout.stairs, + STAIRS, + settings.colorblind_mode, + ); + + Ok(pixmap) +} + +pub fn fill_bg(pixmap: &mut Pixmap, color: (u8, u8, u8, u8)) { + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + let Some(rect) = Rect::from_xywh(0.0, 0.0, pixmap.width() as f32, pixmap.height() as f32) + else { + return; + }; + pixmap.fill_rect(rect, &paint, Transform::identity(), None); +} + +pub fn draw_grid_color(pixmap: &mut Pixmap, g: &ExportGeometry, color: (u8, u8, u8, u8)) { + let left = g.left(); + let top = g.top(); + let width = g.cols as f32 * g.cell; + let height = g.rows as f32 * g.cell; + + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + + for c in 0..=g.cols { + let x = (left + c as f32 * g.cell).round(); + let Some(rect) = Rect::from_xywh(x, top, 1.0, height) else { + continue; + }; + pixmap.fill_rect(rect, &paint, Transform::identity(), None); + } + for r in 0..=g.rows { + let y = (top + r as f32 * g.cell).round(); + let Some(rect) = Rect::from_xywh(left, y, width, 1.0) else { + continue; + }; + pixmap.fill_rect(rect, &paint, Transform::identity(), None); + } +} + +pub fn fill_rect_cell( + pixmap: &mut Pixmap, + g: &ExportGeometry, + col: usize, + row: usize, + color: (u8, u8, u8, u8), +) { + let Some(rect) = g.cell_rect(col, row) else { + return; + }; + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + pixmap.fill_rect(rect, &paint, Transform::identity(), None); +} + +pub fn fill_rect_room( + pixmap: &mut Pixmap, + g: &ExportGeometry, + room: &Room, + color: (u8, u8, u8, u8), +) { + let Some(rect) = Rect::from_xywh( + g.left() + room.x as f32 * g.cell, + g.top() + room.y as f32 * g.cell, + room.width as f32 * g.cell, + room.height as f32 * g.cell, + ) else { + return; + }; + + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + pixmap.fill_rect(rect, &paint, Transform::identity(), None); +} + +pub fn fill_rect_marker( + pixmap: &mut Pixmap, + g: &ExportGeometry, + marker: &AreaMarker, + color: (u8, u8, u8, u8), +) { + let Some(rect) = Rect::from_xywh( + g.left() + marker.cell.0 as f32 * g.cell, + g.top() + marker.cell.1 as f32 * g.cell, + marker.size as f32 * g.cell, + marker.size as f32 * g.cell, + ) else { + return; + }; + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + pixmap.fill_rect(rect, &paint, Transform::identity(), None); +} + +pub fn draw_area_marker( + pixmap: &mut Pixmap, + g: &ExportGeometry, + marker: &AreaMarker, + label: &str, + color: (u8, u8, u8, u8), + colorblind_mode: bool, +) { + let overlay = ( + color.0, + color.1, + color.2, + if colorblind_mode { 56 } else { 90 }, + ); + fill_rect_marker(pixmap, g, marker, overlay); + + let x = g.left() + marker.cell.0 as f32 * g.cell; + let y = g.top() + marker.cell.1 as f32 * g.cell; + let w = marker.size as f32 * g.cell; + let h = marker.size as f32 * g.cell; + draw_wall_segment(pixmap, x, y, x + w, y, 3.0, color); + draw_wall_segment(pixmap, x, y + h, x + w, y + h, 3.0, color); + draw_wall_segment(pixmap, x, y, x, y + h, 3.0, color); + draw_wall_segment(pixmap, x + w, y, x + w, y + h, 3.0, color); + + let symbol = if colorblind_mode { BLACK } else { WHITE }; + if label == "S" { + draw_line( + pixmap, + x + w * 0.25, + y + h * 0.75, + x + w * 0.5, + y + h * 0.25, + 4.0, + symbol, + DoorStyle::Solid, + ); + draw_line( + pixmap, + x + w * 0.5, + y + h * 0.25, + x + w * 0.75, + y + h * 0.75, + 4.0, + symbol, + DoorStyle::Solid, + ); + } else if label == "E" { + draw_line( + pixmap, + x + w * 0.25, + y + h * 0.25, + x + w * 0.75, + y + h * 0.75, + 4.0, + symbol, + DoorStyle::Solid, + ); + draw_line( + pixmap, + x + w * 0.75, + y + h * 0.25, + x + w * 0.25, + y + h * 0.75, + 4.0, + symbol, + DoorStyle::Solid, + ); + } else if label == "T" { + // Horizontal bar + draw_line( + pixmap, + x + w * 0.2, + y + h * 0.25, + x + w * 0.8, + y + h * 0.25, + 4.0, + symbol, + DoorStyle::Solid, + ); + // Vertical bar + draw_line( + pixmap, + x + w * 0.5, + y + h * 0.25, + x + w * 0.5, + y + h * 0.75, + 4.0, + symbol, + DoorStyle::Solid, + ); + } else if label == "M" { + draw_line( + pixmap, + x + w * 0.2, + y + h * 0.75, + x + w * 0.2, + y + h * 0.25, + 4.0, + symbol, + DoorStyle::Solid, + ); + draw_line( + pixmap, + x + w * 0.2, + y + h * 0.25, + x + w * 0.5, + y + h * 0.5, + 4.0, + symbol, + DoorStyle::Solid, + ); + draw_line( + pixmap, + x + w * 0.5, + y + h * 0.5, + x + w * 0.8, + y + h * 0.25, + 4.0, + symbol, + DoorStyle::Solid, + ); + draw_line( + pixmap, + x + w * 0.8, + y + h * 0.25, + x + w * 0.8, + y + h * 0.75, + 4.0, + symbol, + DoorStyle::Solid, + ); + } +} + +pub fn fill_rect_staircase( + pixmap: &mut Pixmap, + g: &ExportGeometry, + staircase: &Staircase, + color: (u8, u8, u8, u8), +) { + let Some(rect) = Rect::from_xywh( + g.left() + staircase.cell.0 as f32 * g.cell, + g.top() + staircase.cell.1 as f32 * g.cell, + staircase.width as f32 * g.cell, + staircase.height as f32 * g.cell, + ) else { + return; + }; + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + pixmap.fill_rect(rect, &paint, Transform::identity(), None); +} + +pub fn draw_staircase( + pixmap: &mut Pixmap, + g: &ExportGeometry, + staircase: &Staircase, + color: (u8, u8, u8, u8), + colorblind_mode: bool, +) { + let fill_color = if colorblind_mode { + (180, 180, 180, 90) + } else { + (color.0, color.1, color.2, 90) + }; + + let Some(rect) = Rect::from_xywh( + g.left() + staircase.cell.0 as f32 * g.cell, + g.top() + staircase.cell.1 as f32 * g.cell, + staircase.width as f32 * g.cell, + staircase.height as f32 * g.cell, + ) else { + return; + }; + + let mut paint = Paint::default(); + paint.set_color_rgba8(fill_color.0, fill_color.1, fill_color.2, fill_color.3); + pixmap.fill_rect(rect, &paint, Transform::identity(), None); + + let stroke_color = if colorblind_mode { + BLACK + } else { + (150, 100, 30, 255) + }; + let x = rect.left(); + let y = rect.top(); + let w = rect.width(); + let h = rect.height(); + + draw_wall_segment(pixmap, x, y, x + w, y, 2.0, stroke_color); + draw_wall_segment(pixmap, x, y + h, x + w, y + h, 2.0, stroke_color); + draw_wall_segment(pixmap, x, y, x, y + h, 2.0, stroke_color); + draw_wall_segment(pixmap, x + w, y, x + w, y + h, 2.0, stroke_color); + + let steps = (staircase.height as f32 * 2.0).round() as usize; + let step_h = h / steps as f32; + for i in 0..steps { + let sy = (y + (i as f32 * step_h) + step_h * 0.5).round(); + draw_line( + pixmap, + x + 2.0, + sy, + x + w - 2.0, + sy, + 1.5, + stroke_color, + DoorStyle::Solid, + ); + } +} + +pub fn draw_staircase_group( + pixmap: &mut Pixmap, + g: &ExportGeometry, + stairs: &[Staircase], + color: (u8, u8, u8, u8), + colorblind_mode: bool, +) { + for staircase in stairs { + draw_staircase(pixmap, g, staircase, color, colorblind_mode); + } +} + +pub fn draw_area_marker_group( + pixmap: &mut Pixmap, + g: &ExportGeometry, + markers: &[AreaMarker], + label: &str, + color: (u8, u8, u8, u8), + colorblind_mode: bool, +) { + for marker in markers { + draw_area_marker(pixmap, g, marker, label, color, colorblind_mode); + } +} + +pub fn draw_dot(pixmap: &mut Pixmap, cx: f32, cy: f32, radius: f32, color: (u8, u8, u8, u8)) { + let mut pb = PathBuilder::new(); + pb.push_circle(cx, cy, radius); + let Some(path) = pb.finish() else { + return; + }; + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + Transform::identity(), + None, + ); +} + +pub fn draw_room_crosshatch( + pixmap: &mut Pixmap, + g: &ExportGeometry, + room: &Room, + width: f32, + color: (u8, u8, u8, u8), +) { + for x in room.x..(room.x + room.width) { + for y in room.y..(room.y + room.height) { + if let Some(rect) = g.cell_rect(x, y) { + let pad = 2.0; + draw_line( + pixmap, + rect.left() + pad, + rect.top() + pad, + rect.right() - pad, + rect.bottom() - pad, + width, + color, + DoorStyle::Solid, + ); + draw_line( + pixmap, + rect.right() - pad, + rect.top() + pad, + rect.left() + pad, + rect.bottom() - pad, + width, + color, + DoorStyle::Solid, + ); + } + } + } +} + +pub fn draw_cell_walls( + pixmap: &mut Pixmap, + g: &ExportGeometry, + cells: &HashSet<(usize, usize)>, + connected_edges: Option<&HashSet<((usize, usize), (usize, usize))>>, + door_edges: &HashSet<((usize, usize), (usize, usize))>, + width: f32, + color: (u8, u8, u8, u8), +) { + for &(col, row) in cells { + if let Some(rect) = g.cell_rect(col, row) { + let right = (col + 1, row); + let bottom = (col, row + 1); + + let should_left = col == 0 + || (!cells.contains(&(col - 1, row)) + && !door_edges.contains(&norm_edge((col, row), (col - 1, row)))); + + let right_connected = connected_edges + .map(|set| set.contains(&norm_edge((col, row), right))) + .unwrap_or(true); + let should_right = (!cells.contains(&right) || !right_connected) + && !door_edges.contains(&norm_edge((col, row), right)); + + let should_top = row == 0 + || (!cells.contains(&(col, row - 1)) + && !door_edges.contains(&norm_edge((col, row), (col, row - 1)))); + + let bottom_connected = connected_edges + .map(|set| set.contains(&norm_edge((col, row), bottom))) + .unwrap_or(true); + let should_bottom = (!cells.contains(&bottom) || !bottom_connected) + && !door_edges.contains(&norm_edge((col, row), bottom)); + + if should_left { + draw_wall_segment( + pixmap, + rect.left(), + rect.top(), + rect.left(), + rect.bottom(), + width, + color, + ); + } + if should_right { + draw_wall_segment( + pixmap, + rect.right(), + rect.top(), + rect.right(), + rect.bottom(), + width, + color, + ); + } + if should_top { + draw_wall_segment( + pixmap, + rect.left(), + rect.top(), + rect.right(), + rect.top(), + width, + color, + ); + } + if should_bottom { + draw_wall_segment( + pixmap, + rect.left(), + rect.bottom(), + rect.right(), + rect.bottom(), + width, + color, + ); + } + } + } +} + +pub fn draw_wall_segment( + pixmap: &mut Pixmap, + x1: f32, + y1: f32, + x2: f32, + y2: f32, + width: f32, + color: (u8, u8, u8, u8), +) { + let width = width.round().max(1.0); + let half = width * 0.5; + let x1 = x1.round(); + let y1 = y1.round(); + let x2 = x2.round(); + let y2 = y2.round(); + let rect = if (x1 - x2).abs() <= f32::EPSILON { + let x = x1.min(x2); + let y0 = y1.min(y2); + let y1 = y1.max(y2); + Rect::from_xywh(x - half, y0 - half, width, (y1 - y0) + width) + } else { + let y = y1.min(y2); + let x0 = x1.min(x2); + let x1 = x1.max(x2); + Rect::from_xywh(x0 - half, y - half, (x1 - x0) + width, width) + }; + + let Some(rect) = rect else { + return; + }; + + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + pixmap.fill_rect(rect, &paint, Transform::identity(), None); +} + +pub fn snap_even_width(value: f32) -> f32 { + let mut w = value.round().max(1.0); + if (w as u32) % 2 == 1 { + w += 1.0; + } + w +} + +pub fn draw_door( + pixmap: &mut Pixmap, + g: &ExportGeometry, + door: &crate::layout::Door, + width: f32, + color: (u8, u8, u8, u8), + style: DoorStyle, +) { + let Some((x1, y1, x2, y2)) = door_line_points(g, door.from, door.to, door_render_width(door)) + else { + return; + }; + draw_line(pixmap, x1, y1, x2, y2, width, color, style); +} + +pub fn draw_window( + pixmap: &mut Pixmap, + g: &ExportGeometry, + window: &Window, + width: f32, + color: (u8, u8, u8, u8), + style: DoorStyle, +) { + let Some((x1, y1, x2, y2)) = window_line_points(g, window) else { + return; + }; + draw_line(pixmap, x1, y1, x2, y2, width, color, style); +} + +pub fn draw_line( + pixmap: &mut Pixmap, + x1: f32, + y1: f32, + x2: f32, + y2: f32, + width: f32, + color: (u8, u8, u8, u8), + style: DoorStyle, +) { + match style { + DoorStyle::Solid => stroke_path(pixmap, x1, y1, x2, y2, width, color, None), + DoorStyle::LongDash => stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((14.0, 7.0))), + DoorStyle::ShortDash => stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((6.0, 4.0))), + DoorStyle::Dotted => dotted_line(pixmap, x1, y1, x2, y2, width, color), + DoorStyle::DashDot => stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((7.0, 3.0))), + DoorStyle::DashDotDot => { + stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((7.0, 3.0))) + } + } +} + +pub fn stroke_path( + pixmap: &mut Pixmap, + x1: f32, + y1: f32, + x2: f32, + y2: f32, + width: f32, + color: (u8, u8, u8, u8), + dash: Option<(f32, f32)>, +) { + let mut pb = PathBuilder::new(); + pb.move_to(x1, y1); + pb.line_to(x2, y2); + let Some(path) = pb.finish() else { + return; + }; + + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + + let mut stroke = Stroke::default(); + stroke.width = width; + if let Some((dash_len, gap_len)) = dash { + stroke.dash = StrokeDash::new(vec![dash_len, gap_len], 0.0); + } + + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); +} + +pub fn dotted_line( + pixmap: &mut Pixmap, + x1: f32, + y1: f32, + x2: f32, + y2: f32, + width: f32, + color: (u8, u8, u8, u8), +) { + let dx = x2 - x1; + let dy = y2 - y1; + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + return; + } + let ux = dx / len; + let uy = dy / len; + + let mut d = 0.0_f32; + let step = 6.0; + let radius = (width * 0.35).max(1.0); + while d <= len { + draw_dot(pixmap, x1 + ux * d, y1 + uy * d, radius, color); + d += step; + } +} + +pub fn raster_image_from_pixmap( + pixmap: &Pixmap, + settings: &UiSettings, +) -> Result { + let rgba = RgbaImage::from_raw(pixmap.width(), pixmap.height(), pixmap.data().to_vec()) + .ok_or_else(|| "Failed to build image buffer".to_string())?; + let mut img = DynamicImage::ImageRgba8(rgba); + + let target = raster_target_size(settings); + if img.width() != target.0 || img.height() != target.1 { + img = img.resize_exact(target.0, target.1, FilterType::Lanczos3); + } + + Ok(img) +} + +pub fn raster_mask_image_from_pixmap( + pixmap: &Pixmap, + settings: &UiSettings, +) -> Result { + let rgba = RgbaImage::from_raw(pixmap.width(), pixmap.height(), pixmap.data().to_vec()) + .ok_or_else(|| "Failed to build image buffer".to_string())?; + let mut img = DynamicImage::ImageRgba8(rgba); + + let target = raster_target_size(settings); + if img.width() != target.0 || img.height() != target.1 { + img = img.resize_exact(target.0, target.1, FilterType::Nearest); + } + + let mut out = img.to_rgba8(); + for pixel in out.pixels_mut() { + let on = pixel[0] > 127 || pixel[1] > 127 || pixel[2] > 127; + let v = if on { 255 } else { 0 }; + *pixel = image::Rgba([v, v, v, 255]); + } + + Ok(DynamicImage::ImageRgba8(out)) +} + +pub fn raster_target_size(settings: &UiSettings) -> (u32, u32) { + let width = settings.export_width.clamp(1, 10_000); + let mut height = settings.export_height.clamp(1, 10_000); + + if !settings.allow_export_aspect_change { + let cols = settings.cols.max(1) as f32; + let rows = settings.rows.max(1) as f32; + let ratio = cols / rows; + height = ((width as f32) / ratio).round() as u32; + height = height.clamp(1, 10_000); + } + + (width, height) +} diff --git a/src/exporter/svg.rs b/src/exporter/svg.rs new file mode 100644 index 0000000..bff24bb --- /dev/null +++ b/src/exporter/svg.rs @@ -0,0 +1,433 @@ +/* + * SVG vector export logic. + * Handles building SVG representations of dungeon levels, including + * rooms, corridors, walls, doors, and markers. + */ + +use super::types::{ + BG, CORRIDOR, END_MARKER, ExportGeometry, GRID, MONSTER_MARKER, ROOM, START_MARKER, + TRAP_MARKER, WINDOW_COLOR, door_render_width, norm_edge, +}; +use super::utils::{collect_scene_data, door_line_points, window_line_points}; +use crate::layout::DungeonLayout; +use crate::ui::UiSettings; +use std::collections::HashSet; +use std::fmt::Write as _; + +pub fn build_svg(layout: &DungeonLayout, settings: &UiSettings) -> String { + let g = ExportGeometry::new(settings.cols, settings.rows); + let wall_w = (g.cell / 2.5).max(1.0); + let door_w = (g.cell / 5.0).max(1.0); + + let mut s = String::new(); + let _ = writeln!( + s, + "", + g.width, g.height, g.width, g.height + ); + let _ = writeln!( + s, + "", + BG.0, BG.1, BG.2 + ); + + if settings.export_show_grid { + for c in 0..=g.cols { + let x = g.left() + c as f32 * g.cell; + let _ = writeln!( + s, + "", + g.top(), + g.top() + g.rows as f32 * g.cell, + GRID.0, + GRID.1, + GRID.2 + ); + } + for r in 0..=g.rows { + let y = g.top() + r as f32 * g.cell; + let _ = writeln!( + s, + "", + g.left(), + g.left() + g.cols as f32 * g.cell, + GRID.0, + GRID.1, + GRID.2 + ); + } + } + + let scene = collect_scene_data(layout, settings); + + for &(x, y) in &scene.corridor_cells { + let px = g.left() + x as f32 * g.cell; + let py = g.top() + y as f32 * g.cell; + let _ = writeln!( + s, + "", + g.cell, g.cell, CORRIDOR.0, CORRIDOR.1, CORRIDOR.2 + ); + if settings.colorblind_mode { + let (cx, cy) = g.cell_center(x, y); + let _ = writeln!( + s, + "", + g.cell * 0.14 + ); + } + } + + for room in &layout.rooms { + let px = g.left() + room.x as f32 * g.cell; + let py = g.top() + room.y as f32 * g.cell; + let _ = writeln!( + s, + "", + room.width as f32 * g.cell, + room.height as f32 * g.cell, + ROOM.0, + ROOM.1, + ROOM.2 + ); + if settings.colorblind_mode { + for x in room.x..(room.x + room.width) { + for y in room.y..(room.y + room.height) { + let rx = g.left() + x as f32 * g.cell + 2.0; + let ry = g.top() + y as f32 * g.cell + 2.0; + let rr = g.cell - 4.0; + let _ = writeln!( + s, + "", + rx + rr, + ry + rr + ); + let _ = writeln!( + s, + "", + rx + rr, + ry + rr + ); + } + } + } + } + + append_svg_walls( + &mut s, + &g, + &scene.corridor_cells, + Some(&scene.corridor_edges), + &scene.door_edges, + wall_w, + ); + append_svg_walls( + &mut s, + &g, + &scene.room_cells, + Some(&scene.room_edges), + &scene.door_edges, + wall_w, + ); + + for door in &layout.doors { + let (color, dash) = if settings.colorblind_mode { + if door.secret { + ("black", Some("7,3,1,3,1,4")) + } else if door.archway { + ("black", Some("2,4")) + } else if door.locked { + ("black", Some("6,4")) + } else { + ("black", Some("14,7")) + } + } else if door.secret { + ("rgb(170,80,170)", None) + } else if door.archway { + ("rgb(230,140,60)", None) + } else if door.locked { + ("rgb(220,70,70)", None) + } else { + ("rgb(80,200,120)", None) + }; + + if let Some((x1, y1, x2, y2)) = + door_line_points(&g, door.from, door.to, door_render_width(door)) + { + if let Some(pattern) = dash { + let _ = writeln!( + s, + "" + ); + } else { + let _ = writeln!( + s, + "" + ); + } + } + } + + for window in &layout.windows { + let dash = settings.colorblind_mode.then_some("7,3,1,4"); + if let Some((x1, y1, x2, y2)) = window_line_points(&g, window) { + if let Some(pattern) = dash { + let _ = writeln!( + s, + "", + WINDOW_COLOR.0, WINDOW_COLOR.1, WINDOW_COLOR.2 + ); + } else { + let _ = writeln!( + s, + "", + WINDOW_COLOR.0, WINDOW_COLOR.1, WINDOW_COLOR.2 + ); + } + } + } + + append_svg_area_marker_group( + &mut s, + &g, + &layout.start_markers, + "S", + START_MARKER, + settings.colorblind_mode, + ); + append_svg_area_marker_group( + &mut s, + &g, + &layout.end_markers, + "E", + END_MARKER, + settings.colorblind_mode, + ); + append_svg_area_marker_group( + &mut s, + &g, + &layout.trap_markers, + "T", + TRAP_MARKER, + settings.colorblind_mode, + ); + append_svg_area_marker_group( + &mut s, + &g, + &layout.monster_markers, + "M", + MONSTER_MARKER, + settings.colorblind_mode, + ); + + // Draw stairs. + for stair in &layout.stairs { + let x = g.left() + stair.cell.0 as f32 * g.cell; + let y = g.top() + stair.cell.1 as f32 * g.cell; + let w = stair.width as f32 * g.cell; + let h = stair.height as f32 * g.cell; + + let fill = if settings.colorblind_mode { + "rgb(180,180,180)" + } else { + "rgb(200,150,50)" + }; + let _ = writeln!( + s, + "" + ); + let steps = (stair.height as f32 * 2.0).round() as usize; + let step_h = h / steps as f32; + for i in 0..steps { + let sy = y + (i as f32 * step_h) + step_h * 0.5; + let _ = writeln!( + s, + "", + x1 = x + 2.0, + x2 = x + w - 2.0 + ); + } + } + + s.push_str("\n"); + s +} + +pub fn append_svg_walls( + out: &mut String, + g: &ExportGeometry, + cells: &HashSet<(usize, usize)>, + connected_edges: Option<&HashSet<((usize, usize), (usize, usize))>>, + door_edges: &HashSet<((usize, usize), (usize, usize))>, + width: f32, +) { + for &(col, row) in cells { + let left = g.left() + col as f32 * g.cell; + let top = g.top() + row as f32 * g.cell; + let right = left + g.cell; + let bottom = top + g.cell; + + let right_cell = (col + 1, row); + let bottom_cell = (col, row + 1); + + let left_edge = col == 0 + || (!cells.contains(&(col - 1, row)) + && !door_edges.contains(&norm_edge((col, row), (col - 1, row)))); + + let right_connected = connected_edges + .map(|set| set.contains(&norm_edge((col, row), right_cell))) + .unwrap_or(true); + let right_edge = (!cells.contains(&right_cell) || !right_connected) + && !door_edges.contains(&norm_edge((col, row), right_cell)); + + let top_edge = row == 0 + || (!cells.contains(&(col, row - 1)) + && !door_edges.contains(&norm_edge((col, row), (col, row - 1)))); + + let bottom_connected = connected_edges + .map(|set| set.contains(&norm_edge((col, row), bottom_cell))) + .unwrap_or(true); + let bottom_edge = (!cells.contains(&bottom_cell) || !bottom_connected) + && !door_edges.contains(&norm_edge((col, row), bottom_cell)); + + if left_edge { + let _ = writeln!( + out, + "", + left - (width * 0.5), + top - (width * 0.5), + (bottom - top) + width + ); + } + if right_edge { + let _ = writeln!( + out, + "", + right - (width * 0.5), + top - (width * 0.5), + (bottom - top) + width + ); + } + if top_edge { + let _ = writeln!( + out, + "", + left - (width * 0.5), + top - (width * 0.5), + (right - left) + width + ); + } + if bottom_edge { + let _ = writeln!( + out, + "", + left - (width * 0.5), + bottom - (width * 0.5), + (right - left) + width + ); + } + } +} + +pub fn append_svg_area_marker( + out: &mut String, + g: &ExportGeometry, + marker: &crate::layout::AreaMarker, + label: &str, + color: (u8, u8, u8, u8), + colorblind_mode: bool, +) { + let x = g.left() + marker.cell.0 as f32 * g.cell; + let y = g.top() + marker.cell.1 as f32 * g.cell; + let w = marker.size as f32 * g.cell; + let h = marker.size as f32 * g.cell; + let alpha = if colorblind_mode { 0.22 } else { 0.35 }; + let _ = writeln!( + out, + "", + color.0, color.1, color.2, color.0, color.1, color.2 + ); + let symbol = if colorblind_mode { "black" } else { "white" }; + if label == "S" { + let _ = writeln!( + out, + "", + x + w * 0.25, + y + h * 0.75, + x + w * 0.5, + y + h * 0.25, + x + w * 0.75, + y + h * 0.75, + symbol + ); + } else if label == "E" { + let _ = writeln!( + out, + "", + x + w * 0.25, + y + h * 0.25, + x + w * 0.75, + y + h * 0.75, + symbol + ); + let _ = writeln!( + out, + "", + x + w * 0.75, + y + h * 0.25, + x + w * 0.25, + y + h * 0.75, + symbol + ); + } else if label == "T" { + // Horizontal bar + let _ = writeln!( + out, + "", + x + w * 0.2, + y + h * 0.25, + x + w * 0.8, + y + h * 0.25, + symbol + ); + // Vertical bar + let _ = writeln!( + out, + "", + x + w * 0.5, + y + h * 0.25, + x + w * 0.5, + y + h * 0.75, + symbol + ); + } else if label == "M" { + let _ = writeln!( + out, + "", + x + w * 0.2, + y + h * 0.75, + x + w * 0.2, + y + h * 0.25, + x + w * 0.5, + y + h * 0.5, + x + w * 0.8, + y + h * 0.25, + x + w * 0.8, + y + h * 0.75, + symbol + ); + } +} + +pub fn append_svg_area_marker_group( + out: &mut String, + g: &ExportGeometry, + markers: &[crate::layout::AreaMarker], + label: &str, + color: (u8, u8, u8, u8), + colorblind_mode: bool, +) { + for marker in markers { + append_svg_area_marker(out, g, marker, label, color, colorblind_mode); + } +} diff --git a/src/exporter/types.rs b/src/exporter/types.rs new file mode 100644 index 0000000..7d8d7d6 --- /dev/null +++ b/src/exporter/types.rs @@ -0,0 +1,206 @@ +/* + * Core types and constants for the export system. + * Defines shared color constants, geometry helpers, and data structures + * used by both SVG and raster exporters. + */ + +use crate::layout::{Door, DungeonLayout, Window}; +use std::collections::HashSet; +use std::path::PathBuf; +use tiny_skia::Rect; + +pub const BG: (u8, u8, u8, u8) = (24, 24, 26, 255); +pub const GRID: (u8, u8, u8, u8) = (130, 130, 130, 255); +pub const ROOM: (u8, u8, u8, u8) = (70, 120, 160, 255); +pub const CORRIDOR: (u8, u8, u8, u8) = (210, 190, 120, 255); +pub const BLACK: (u8, u8, u8, u8) = (0, 0, 0, 255); +pub const OPEN_DOOR: (u8, u8, u8, u8) = (80, 200, 120, 255); +pub const LOCKED_DOOR: (u8, u8, u8, u8) = (220, 70, 70, 255); +pub const SECRET_DOOR: (u8, u8, u8, u8) = (170, 80, 170, 255); +pub const ARCHWAY: (u8, u8, u8, u8) = (230, 140, 60, 255); +pub const WINDOW_COLOR: (u8, u8, u8, u8) = (70, 130, 220, 255); +pub const WHITE: (u8, u8, u8, u8) = (255, 255, 255, 255); +pub const START_MARKER: (u8, u8, u8, u8) = (60, 220, 200, 255); +pub const END_MARKER: (u8, u8, u8, u8) = (240, 90, 90, 255); +pub const TRAP_MARKER: (u8, u8, u8, u8) = (150, 80, 230, 255); +pub const MONSTER_MARKER: (u8, u8, u8, u8) = (200, 50, 50, 255); +pub const STAIRS: (u8, u8, u8, u8) = (200, 150, 50, 255); + +pub enum ExportTarget { + File(PathBuf), + Folder(PathBuf), +} + +#[derive(Debug, Clone, Copy)] +pub struct ExportProgress { + pub completed: usize, + pub total: usize, +} + +pub enum ExportEvent { + Progress(ExportProgress), + Finished(Result), +} + +#[derive(Debug, Clone, Copy)] +pub enum DoorStyle { + Solid, + LongDash, + ShortDash, + Dotted, + DashDot, + DashDotDot, +} + +#[derive(Debug, Clone, Copy)] +pub struct ExportGeometry { + pub cols: usize, + pub rows: usize, + pub cell: f32, + pub pad: f32, + pub width: u32, + pub height: u32, +} + +impl ExportGeometry { + pub fn new(cols: usize, rows: usize) -> Self { + let cell = 64.0; + let pad = 0.0; + let width = (cols as f32 * cell).round() as u32; + let height = (rows as f32 * cell).round() as u32; + Self { + cols, + rows, + cell, + pad, + width, + height, + } + } + + pub fn left(&self) -> f32 { + self.pad + } + + pub fn top(&self) -> f32 { + self.pad + } + + pub fn cell_rect(&self, col: usize, row: usize) -> Option { + Rect::from_xywh( + self.left() + col as f32 * self.cell, + self.top() + row as f32 * self.cell, + self.cell, + self.cell, + ) + } + + pub fn cell_center(&self, col: usize, row: usize) -> (f32, f32) { + ( + self.left() + (col as f32 + 0.5) * self.cell, + self.top() + (row as f32 + 0.5) * self.cell, + ) + } +} + +#[derive(Default)] +pub struct SceneData { + pub corridor_cells: HashSet<(usize, usize)>, + pub corridor_edges: HashSet<((usize, usize), (usize, usize))>, + pub room_cells: HashSet<(usize, usize)>, + pub room_edges: HashSet<((usize, usize), (usize, usize))>, + pub door_edges: HashSet<((usize, usize), (usize, usize))>, +} + +#[derive(Debug, Clone, Copy)] +pub enum MaskTask { + Floors, + Walls, + Doors, + Archways, + LockedDoors, + SecretDoors, + Windows, + StartMarkers, + EndMarkers, + TrapMarkers, + MonsterMarkers, + Stairs, + Grid, +} + +#[derive(Debug, Clone, Copy)] +pub enum CompositeTask { + Mask(MaskTask), + Composite, +} + +pub struct CompositeExportContext<'a> { + pub layout: &'a DungeonLayout, + pub folder: PathBuf, + pub geometry: ExportGeometry, + pub scene: SceneData, + pub wall_width: f32, + pub door_width: f32, +} + +pub fn norm_edge(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) { + if a <= b { (a, b) } else { (b, a) } +} + +pub fn door_edges_for( + door: &Door, + cols: usize, + rows: usize, +) -> Vec<((usize, usize), (usize, usize))> { + let mut edges = Vec::new(); + if door.from.0.abs_diff(door.to.0) + door.from.1.abs_diff(door.to.1) != 1 { + return edges; + } + + let width = door_render_width(door).max(1) as isize; + let min_offset = -((width - 1) / 2); + let max_offset = width / 2; + + if door.from.0 != door.to.0 { + let y = door.from.1 as isize; + for dy in min_offset..=max_offset { + let ny = y + dy; + if ny < 0 || ny >= rows as isize { + continue; + } + let a = (door.from.0, ny as usize); + let b = (door.to.0, ny as usize); + edges.push(norm_edge(a, b)); + } + } else { + let x = door.from.0 as isize; + for dx in min_offset..=max_offset { + let nx = x + dx; + if nx < 0 || nx >= cols as isize { + continue; + } + let a = (nx as usize, door.from.1); + let b = (nx as usize, door.to.1); + edges.push(norm_edge(a, b)); + } + } + + edges +} + +pub fn door_render_width(door: &Door) -> usize { + if door.span_width { + door.width.max(1) + } else { + 1 + } +} + +pub fn window_render_width(window: &Window) -> usize { + if window.span_width { + window.width.max(1) + } else { + 1 + } +} diff --git a/src/exporter/utils.rs b/src/exporter/utils.rs new file mode 100644 index 0000000..57c0567 --- /dev/null +++ b/src/exporter/utils.rs @@ -0,0 +1,148 @@ +/* + * Shared utilities for the export module. + * Provides functions for scene data collection and common geometric + * calculations used by both SVG and raster rendering paths. + */ + +use super::types::{ExportGeometry, SceneData, door_edges_for, norm_edge, window_render_width}; +use crate::layout::{DungeonLayout, Room, Window, WindowSide, corridor_cells}; +use crate::ui::UiSettings; +use std::collections::HashSet; + +pub fn collect_scene_data(layout: &DungeonLayout, settings: &UiSettings) -> SceneData { + let mut scene = SceneData::default(); + + for door in &layout.doors { + for edge in door_edges_for(door, settings.cols, settings.rows) { + scene.door_edges.insert(edge); + } + } + + scene.corridor_cells = corridor_cells(layout, settings.cols, settings.rows); + scene.corridor_edges = corridor_edges_from_cells(&scene.corridor_cells); + + for room in &layout.rooms { + for x in room.x..(room.x + room.width) { + for y in room.y..(room.y + room.height) { + scene.room_cells.insert((x, y)); + } + } + } + scene.room_edges = room_edges_from_rooms(&layout.rooms); + + scene +} + +pub fn corridor_edges_from_cells( + corridor_cells: &HashSet<(usize, usize)>, +) -> HashSet<((usize, usize), (usize, usize))> { + let mut edges = HashSet::new(); + for &(col, row) in corridor_cells { + let right = (col + 1, row); + let bottom = (col, row + 1); + if corridor_cells.contains(&right) { + edges.insert(norm_edge((col, row), right)); + } + if corridor_cells.contains(&bottom) { + edges.insert(norm_edge((col, row), bottom)); + } + } + edges +} + +pub fn room_edges_from_rooms(rooms: &[Room]) -> HashSet<((usize, usize), (usize, usize))> { + let mut edges = HashSet::new(); + for room in rooms { + let x_end = room.x + room.width; + let y_end = room.y + room.height; + for x in room.x..x_end { + for y in room.y..y_end { + if x + 1 < x_end { + edges.insert(norm_edge((x, y), (x + 1, y))); + } + if y + 1 < y_end { + edges.insert(norm_edge((x, y), (x, y + 1))); + } + } + } + } + edges +} + +pub fn door_line_points( + g: &ExportGeometry, + a: (usize, usize), + b: (usize, usize), + width_cells: usize, +) -> Option<(f32, f32, f32, f32)> { + if a.0.abs_diff(b.0) + a.1.abs_diff(b.1) != 1 { + return None; + } + + let width_cells = width_cells.max(1) as isize; + let min_offset = -((width_cells - 1) / 2); + let max_offset = width_cells / 2; + + if a.0 != b.0 { + let x = g.left() + (a.0.max(b.0) as f32) * g.cell; + let row = a.1 as isize; + let y0_cell = (row + min_offset).clamp(0, g.rows.saturating_sub(1) as isize); + let y1_cell = (row + max_offset).clamp(0, g.rows.saturating_sub(1) as isize); + let y0 = g.top() + y0_cell as f32 * g.cell; + let y1 = g.top() + (y1_cell as f32 + 1.0) * g.cell; + Some((x, y0, x, y1)) + } else { + let y = g.top() + (a.1.max(b.1) as f32) * g.cell; + let col = a.0 as isize; + let x0_cell = (col + min_offset).clamp(0, g.cols.saturating_sub(1) as isize); + let x1_cell = (col + max_offset).clamp(0, g.cols.saturating_sub(1) as isize); + let x0 = g.left() + x0_cell as f32 * g.cell; + let x1 = g.left() + (x1_cell as f32 + 1.0) * g.cell; + Some((x0, y, x1, y)) + } +} + +pub fn window_line_points(g: &ExportGeometry, window: &Window) -> Option<(f32, f32, f32, f32)> { + let width_cells = window_render_width(window).max(1) as isize; + let min_offset = -((width_cells - 1) / 2); + let max_offset = width_cells / 2; + + match window.side { + WindowSide::Left => { + let x = g.left() + window.cell.0 as f32 * g.cell; + let row = window.cell.1 as isize; + let y0_cell = (row + min_offset).clamp(0, g.rows.saturating_sub(1) as isize); + let y1_cell = (row + max_offset).clamp(0, g.rows.saturating_sub(1) as isize); + let y0 = g.top() + y0_cell as f32 * g.cell; + let y1 = g.top() + (y1_cell as f32 + 1.0) * g.cell; + Some((x, y0, x, y1)) + } + WindowSide::Right => { + let x = g.left() + (window.cell.0 as f32 + 1.0) * g.cell; + let row = window.cell.1 as isize; + let y0_cell = (row + min_offset).clamp(0, g.rows.saturating_sub(1) as isize); + let y1_cell = (row + max_offset).clamp(0, g.rows.saturating_sub(1) as isize); + let y0 = g.top() + y0_cell as f32 * g.cell; + let y1 = g.top() + (y1_cell as f32 + 1.0) * g.cell; + Some((x, y0, x, y1)) + } + WindowSide::Top => { + let y = g.top() + window.cell.1 as f32 * g.cell; + let col = window.cell.0 as isize; + let x0_cell = (col + min_offset).clamp(0, g.cols.saturating_sub(1) as isize); + let x1_cell = (col + max_offset).clamp(0, g.cols.saturating_sub(1) as isize); + let x0 = g.left() + x0_cell as f32 * g.cell; + let x1 = g.left() + (x1_cell as f32 + 1.0) * g.cell; + Some((x0, y, x1, y)) + } + WindowSide::Bottom => { + let y = g.top() + (window.cell.1 as f32 + 1.0) * g.cell; + let col = window.cell.0 as isize; + let x0_cell = (col + min_offset).clamp(0, g.cols.saturating_sub(1) as isize); + let x1_cell = (col + max_offset).clamp(0, g.cols.saturating_sub(1) as isize); + let x0 = g.left() + x0_cell as f32 * g.cell; + let x1 = g.left() + (x1_cell as f32 + 1.0) * g.cell; + Some((x0, y, x1, y)) + } + } +} diff --git a/src/interact.rs b/src/interact.rs deleted file mode 100644 index a0f2757..0000000 --- a/src/interact.rs +++ /dev/null @@ -1,2387 +0,0 @@ -/* - * Manages user interaction and grid-based logic for the dungeon canvas. - * Includes logic for: - * - Dragging and moving rooms, corridors, markers, and text. - * - Resizing rooms, text, and staircases. - * - Handling "Add" tool operations (placing new rooms, doors, etc.). - * - Hit testing and hover detection for all dungeon elements. - */ - -use crate::app::{DungeonApp, ManualDoorKind}; -use crate::layout::{ - self, blocked_room_cells, corridor_cells, rects_overlap, room_index_at_cell, rooms_overlap, - shortest_path_cells, -}; -use crate::ui::AddTool; -use eframe::egui; -use egui::{Color32, Stroke}; -use std::collections::HashSet; - -#[derive(Debug, Clone)] -pub struct RoomDragState { - pub room_idx: usize, - pub offset_x: f32, - pub offset_y: f32, -} - -#[derive(Debug, Clone)] -pub struct TextDragState { - pub text_idx: usize, -} - -#[derive(Debug, Clone)] -pub struct MarkerDragState { - pub kind: MarkerKind, - pub marker_idx: usize, - pub offset_x: f32, - pub offset_y: f32, -} - -#[derive(Debug, Clone)] -pub struct CorridorDragState { - pub corridor_index: usize, - pub original_path: Vec<(usize, usize)>, - pub original_cell: (usize, usize), -} - -#[derive(Debug, Clone)] -pub struct AddCorridorDrag { - pub start_cell: (usize, usize), - pub current_cell: (usize, usize), -} - -#[derive(Debug, Clone, Copy)] -pub enum ResizeCorner { - TopLeft, - TopRight, - BottomLeft, - BottomRight, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MarkerKind { - Start, - End, - Trap, - Monster, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HoverMarker { - Start(usize), - End(usize), - Trap(usize), - Monster(usize), -} - -#[derive(Debug, Clone)] -pub struct RoomResizeState { - pub room_idx: usize, - pub original_room: layout::Room, - pub resize_corner: ResizeCorner, -} - -#[derive(Debug, Clone)] -pub enum DragState { - Room(RoomDragState), - Text(TextDragState), - Marker(MarkerDragState), - Corridor(CorridorDragState), - Staircase(StaircaseDragState), -} - -#[derive(Debug, Clone)] -pub struct StaircaseDragState { - pub stair_idx: usize, - pub offset_x: f32, - pub offset_y: f32, - pub original_cell: (usize, usize), -} - -#[derive(Debug, Clone)] -pub struct TextResizeState { - pub text_idx: usize, - pub origin_cell: (usize, usize), - pub original_font_size: u16, -} - -#[derive(Debug, Clone)] -pub struct StaircaseResizeState { - pub stair_idx: usize, - pub original_stair: layout::Staircase, - pub resize_corner: ResizeCorner, -} - -#[derive(Debug, Clone)] -pub enum ResizeState { - Room(RoomResizeState), - Text(TextResizeState), - Staircase(StaircaseResizeState), -} - -pub struct GridGeometry { - pub rect: egui::Rect, - pub cell_size: f32, - pub cols: usize, - pub rows: usize, -} - -pub fn draw_grid( - painter: &egui::Painter, - area: egui::Rect, - cols: usize, - rows: usize, -) -> GridGeometry { - let cols = cols.max(1); - let rows = rows.max(1); - - let cell_size = (area.width() / cols as f32).min(area.height() / rows as f32); - let grid_width = cell_size * cols as f32; - let grid_height = cell_size * rows as f32; - - let grid_rect = egui::Rect::from_min_size( - egui::pos2( - area.center().x - (grid_width * 0.5), - area.center().y - (grid_height * 0.5), - ), - egui::vec2(grid_width, grid_height), - ); - - let stroke = Stroke::new(1.0, Color32::from_gray(160)); - painter.rect_stroke(grid_rect, 0.0, stroke, egui::StrokeKind::Middle); - - for col in 1..cols { - let x = grid_rect.left() + (col as f32 * cell_size); - painter.line_segment( - [ - egui::pos2(x, grid_rect.top()), - egui::pos2(x, grid_rect.bottom()), - ], - stroke, - ); - } - - for row in 1..rows { - let y = grid_rect.top() + (row as f32 * cell_size); - painter.line_segment( - [ - egui::pos2(grid_rect.left(), y), - egui::pos2(grid_rect.right(), y), - ], - stroke, - ); - } - - GridGeometry { - rect: grid_rect, - cell_size, - cols, - rows, - } -} - -pub fn handle_drag( - app: &mut DungeonApp, - ctx: &egui::Context, - response: &egui::Response, - geometry: &GridGeometry, -) { - if !ctx.input(|i| i.pointer.primary_down()) && !response.drag_started() { - return; - } - if app.settings.add_tool != AddTool::None { - return; - } - if response.drag_started() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some(text_idx) = text_at_pointer(app, pointer_pos, geometry) - { - app.push_undo_snapshot(); - app.drag_state = Some(DragState::Text(TextDragState { text_idx })); - } else if response.drag_started() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some(marker_drag) = marker_drag_at_pointer(app, pointer_pos, geometry) - { - app.push_undo_snapshot(); - app.drag_state = Some(DragState::Marker(marker_drag)); - } else if response.drag_started() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some(stair_drag) = stair_drag_at_pointer(app, pointer_pos, geometry) - { - app.push_undo_snapshot(); - app.drag_state = Some(DragState::Staircase(stair_drag)); - } else if response.drag_started() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some(corridor_drag) = corridor_drag_at_pointer(app, pointer_pos, geometry) - { - app.push_undo_snapshot(); - app.drag_state = Some(DragState::Corridor(corridor_drag)); - } else if response.drag_started() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((room_idx, offset_x, offset_y)) = room_at_pointer(app, pointer_pos, geometry) - { - app.push_undo_snapshot(); - app.drag_state = Some(DragState::Room(RoomDragState { - room_idx, - offset_x, - offset_y, - })); - } - - if response.dragged() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some(drag_state) = app.drag_state.clone() - { - match drag_state { - DragState::Room(drag) => drag_room_to_pointer(app, drag, pointer_pos, geometry), - DragState::Text(drag) => drag_text_to_pointer(app, drag, pointer_pos, geometry), - DragState::Marker(drag) => drag_marker_to_pointer(app, drag, pointer_pos, geometry), - DragState::Corridor(drag) => drag_corridor_to_pointer(app, drag, pointer_pos, geometry), - DragState::Staircase(drag) => drag_stair_to_pointer(app, drag, pointer_pos, geometry), - } - } - - if response.drag_stopped() || !response.ctx.input(|i| i.pointer.primary_down()) { - app.drag_state = None; - } -} - -pub fn handle_resize( - app: &mut DungeonApp, - ctx: &egui::Context, - response: &egui::Response, - geometry: &GridGeometry, -) -> bool { - if app.settings.add_tool != AddTool::None { - return false; - } - - if ctx.input(|i| i.pointer.secondary_pressed()) && app.resize_state.is_none() { - if let Some(pointer_pos) = response.interact_pointer_pos() { - if let Some(text_idx) = text_at_pointer(app, pointer_pos, geometry) { - if let Some(state) = start_text_resize(app, text_idx) { - app.push_undo_snapshot(); - app.resize_state = Some(ResizeState::Text(state)); - return true; - } - } else if let Some(stair_idx) = stair_at_pointer(app, pointer_pos, geometry) { - if let Some(state) = start_staircase_resize(app, stair_idx, pointer_pos, geometry) { - app.push_undo_snapshot(); - app.resize_state = Some(ResizeState::Staircase(state)); - return true; - } - } else if let Some(room_idx) = resize_room_at_pointer(app, pointer_pos, geometry) { - if let Some(state) = start_resize(app, room_idx, pointer_pos, geometry) { - app.push_undo_snapshot(); - app.resize_state = Some(ResizeState::Room(state)); - return true; - } - } - } - } - - if let Some(state) = app.resize_state.clone() { - if response.dragged_by(egui::PointerButton::Secondary) - && ctx.input(|i| i.pointer.secondary_down()) - && let Some(pointer_pos) = response.interact_pointer_pos() - { - if let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) { - let cell = (grid_x.floor() as usize, grid_y.floor() as usize); - match state { - ResizeState::Room(state) => resize_room(app, &state, cell), - ResizeState::Text(state) => resize_text(app, &state, cell), - ResizeState::Staircase(state) => resize_staircase(app, &state, cell), - } - } - } - - if ctx.input(|i| i.pointer.button_released(egui::PointerButton::Secondary)) { - app.resize_state = None; - } - return true; - } - - false -} - -pub fn handle_add_tool(app: &mut DungeonApp, response: &egui::Response, geometry: &GridGeometry) { - if app.settings.add_tool != AddTool::None && response.secondary_clicked() { - app.settings.add_tool = AddTool::None; - app.add_corridor_drag = None; - return; - } - - match app.settings.add_tool { - AddTool::Room => { - app.add_corridor_drag = None; - if response.clicked() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) - { - let cell = (grid_x.floor() as usize, grid_y.floor() as usize); - add_room_at_cell(app, cell); - } - } - AddTool::Corridor => { - if response.drag_started() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) - { - let cell = (grid_x.floor() as usize, grid_y.floor() as usize); - app.add_corridor_drag = Some(AddCorridorDrag { - start_cell: cell, - current_cell: cell, - }); - } - - if response.dragged() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) - && let Some(drag) = &mut app.add_corridor_drag - { - drag.current_cell = (grid_x.floor() as usize, grid_y.floor() as usize); - } - - if response.drag_stopped() { - if let Some(drag) = app.add_corridor_drag.take() { - add_corridor_between(app, drag.start_cell, drag.current_cell); - } - } - } - AddTool::Staircase => { - app.add_corridor_drag = None; - if response.clicked() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) - { - let cell = (grid_x.floor() as usize, grid_y.floor() as usize); - add_staircase_at_cell(app, cell); - } - } - AddTool::Archway | AddTool::Door | AddTool::LockedDoor | AddTool::SecretDoor => { - app.add_corridor_drag = None; - if response.clicked() - && let Some(pointer_pos) = response.interact_pointer_pos() - { - let kind = match app.settings.add_tool { - AddTool::Archway => ManualDoorKind::Archway, - AddTool::Door => ManualDoorKind::Regular, - AddTool::LockedDoor => ManualDoorKind::Locked, - AddTool::SecretDoor => ManualDoorKind::Secret, - _ => unreachable!(), - }; - add_door_at_pointer(app, pointer_pos, geometry, kind); - } - } - AddTool::Text => { - app.add_corridor_drag = None; - if response.clicked() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) - { - let cell = (grid_x.floor() as usize, grid_y.floor() as usize); - add_text_at_cell(app, cell); - } - } - AddTool::StartMarker - | AddTool::EndMarker - | AddTool::TrapMarker - | AddTool::MonsterMarker => { - app.add_corridor_drag = None; - if response.clicked() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) - { - let cell = (grid_x.floor() as usize, grid_y.floor() as usize); - let kind = match app.settings.add_tool { - AddTool::StartMarker => MarkerKind::Start, - AddTool::EndMarker => MarkerKind::End, - AddTool::TrapMarker => MarkerKind::Trap, - AddTool::MonsterMarker => MarkerKind::Monster, - _ => unreachable!(), - }; - add_marker_at_cell(app, cell, kind); - } - } - AddTool::None => { - app.add_corridor_drag = None; - } - } -} - -pub fn update_hover_targets(app: &mut DungeonApp, ctx: &egui::Context, geometry: &GridGeometry) { - if app.drag_state.is_some() || app.resize_state.is_some() || app.add_corridor_drag.is_some() { - return; - } - - let Some(pointer_pos) = ctx.pointer_hover_pos() else { - app.clear_hover_targets(); - return; - }; - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &app.levels[app.settings.active_level_index]; - - if let Some(edge) = door_edge_at_pointer(app, pointer_pos, geometry) { - let target = normalized_edge(edge.0, edge.1); - app.hover_door_idx = layout.doors.iter().position(|d| { - door_edges_for(d, app.settings.cols, app.settings.rows) - .iter() - .any(|e| normalized_edge(e.0, e.1) == target) - }); - if app.hover_door_idx.is_some() { - app.hover_room_idx = None; - app.hover_corridor_idx = None; - app.hover_text_idx = None; - app.hover_marker = None; - return; - } - } - - app.hover_text_idx = text_at_pointer(app, pointer_pos, geometry); - if app.hover_text_idx.is_some() { - app.hover_room_idx = None; - app.hover_corridor_idx = None; - app.hover_door_idx = None; - app.hover_marker = None; - return; - } - - app.hover_marker = marker_at_pointer(app, pointer_pos, geometry); - if app.hover_marker.is_some() { - app.hover_room_idx = None; - app.hover_corridor_idx = None; - app.hover_door_idx = None; - app.hover_stair_idx = None; - return; - } - - app.hover_stair_idx = stair_at_pointer(app, pointer_pos, geometry); - if app.hover_stair_idx.is_some() { - app.hover_room_idx = None; - app.hover_corridor_idx = None; - app.hover_door_idx = None; - app.hover_marker = None; - return; - } - - if let Some((room_idx, _, _)) = room_at_pointer(app, pointer_pos, geometry) { - app.hover_room_idx = Some(room_idx); - app.hover_corridor_idx = None; - app.hover_door_idx = None; - app.hover_marker = None; - } else { - app.hover_room_idx = None; - if let Some(corridor_drag) = corridor_drag_at_pointer(app, pointer_pos, geometry) { - app.hover_corridor_idx = Some(corridor_drag.corridor_index); - app.hover_door_idx = None; - app.hover_marker = None; - } else { - app.hover_corridor_idx = None; - app.hover_door_idx = None; - app.hover_marker = None; - } - } -} - -pub fn delete_at_pointer(app: &mut DungeonApp, pointer_pos: egui::Pos2, geometry: &GridGeometry) { - if app.settings.active_level_index >= app.levels.len() { - return; - } - - if let Some(idx) = text_at_pointer(app, pointer_pos, geometry) { - app.push_undo_snapshot(); - let layout = &mut app.levels[app.settings.active_level_index]; - if idx < layout.text_labels.len() { - layout.text_labels.remove(idx); - } - return; - } - - if let Some(marker) = marker_at_pointer(app, pointer_pos, geometry) { - app.push_undo_snapshot(); - let layout = &mut app.levels[app.settings.active_level_index]; - match marker { - HoverMarker::Start(idx) => { - if idx < layout.start_markers.len() { - layout.start_markers.remove(idx); - } - } - HoverMarker::End(idx) => { - if idx < layout.end_markers.len() { - layout.end_markers.remove(idx); - } - } - HoverMarker::Trap(idx) => { - if idx < layout.trap_markers.len() { - layout.trap_markers.remove(idx); - } - } - HoverMarker::Monster(idx) => { - if idx < layout.monster_markers.len() { - layout.monster_markers.remove(idx); - } - } - } - return; - } - - if let Some(edge) = door_edge_at_pointer(app, pointer_pos, geometry) { - let target = normalized_edge(edge.0, edge.1); - let active_index = app.settings.active_level_index; - if let Some(idx) = app.levels[active_index].doors.iter().position(|d| { - door_edges_for(d, app.settings.cols, app.settings.rows) - .iter() - .any(|e| normalized_edge(e.0, e.1) == target) - }) { - app.push_undo_snapshot(); - let layout = &mut app.levels[app.settings.active_level_index]; - if !layout.doors[idx].manual { - for edge in door_edges_for(&layout.doors[idx], app.settings.cols, app.settings.rows) - { - app.suppressed_auto_door_edges.insert(edge); - } - layout.doors.remove(idx); - return; - } - - layout.doors.remove(idx); - return; - } - } - - if let Some(stair_idx) = stair_at_pointer(app, pointer_pos, geometry) { - let original_cell = { - let layout = &app.levels[app.settings.active_level_index]; - layout.stairs[stair_idx].cell - }; - - app.push_undo_snapshot(); - if app.settings.sync_stairs_across_levels { - for layout in &mut app.levels { - layout.stairs.retain(|s| s.cell != original_cell); - } - } else { - let current_idx = app.settings.active_level_index; - let level_indices = vec![ - Some(current_idx), - current_idx.checked_sub(1), - if current_idx + 1 < app.levels.len() { - Some(current_idx + 1) - } else { - None - }, - ]; - - for idx in level_indices.into_iter().flatten() { - app.levels[idx].stairs.retain(|s| s.cell != original_cell); - } - } - return; - } - - if let Some((room_idx, _, _)) = room_at_pointer(app, pointer_pos, geometry) { - app.push_undo_snapshot(); - delete_room(app, room_idx); - return; - } - - if let Some(corridor_drag) = corridor_drag_at_pointer(app, pointer_pos, geometry) { - let active_index = app.settings.active_level_index; - if corridor_drag.corridor_index < app.levels[active_index].corridors.len() { - app.push_undo_snapshot(); - let layout = &mut app.levels[app.settings.active_level_index]; - layout.corridors.remove(corridor_drag.corridor_index); - app.drag_state = None; - app.refresh_doors(); - } - } -} - -pub fn add_room_at_cell(app: &mut DungeonApp, cell: (usize, usize)) { - app.push_undo_snapshot(); - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &mut app.levels[app.settings.active_level_index]; - - let width = app.settings.add_room_width.max(1); - let height = app.settings.add_room_height.max(1); - if cell.0 >= app.settings.cols || cell.1 >= app.settings.rows { - return; - } - let max_w = app.settings.cols.saturating_sub(cell.0); - let max_h = app.settings.rows.saturating_sub(cell.1); - let width = width.min(max_w); - let height = height.min(max_h); - if width == 0 || height == 0 { - return; - } - - let new_room = layout::Room { - x: cell.0, - y: cell.1, - width, - height, - }; - - if layout - .rooms - .iter() - .any(|room| rooms_overlap(&new_room, room)) - { - return; - } - - layout.rooms.push(new_room); - app.refresh_doors(); -} - -pub fn add_corridor_between(app: &mut DungeonApp, start: (usize, usize), end: (usize, usize)) { - app.push_undo_snapshot(); - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &mut app.levels[app.settings.active_level_index]; - - if start == end { - return; - } - - let start_room = room_index_at_cell(&layout.rooms, start); - let end_room = room_index_at_cell(&layout.rooms, end); - let (Some(start_room), Some(end_room)) = (start_room, end_room) else { - return; - }; - - let blocked = blocked_room_cells(&layout.rooms, &[start_room, end_room]); - - let Some(path) = - shortest_path_cells(start, end, app.settings.cols, app.settings.rows, &blocked) - else { - return; - }; - - let next_id = layout - .corridors - .iter() - .map(|c| c.id) - .max() - .unwrap_or(0) - .wrapping_add(1); - let width = app.settings.add_corridor_width.max(1); - - layout.corridors.push(layout::Corridor { - id: next_id, - start_room_id: start_room, - end_room_id: end_room, - path, - width, - }); - app.refresh_doors(); -} - -pub fn add_text_at_cell(app: &mut DungeonApp, cell: (usize, usize)) { - app.push_undo_snapshot(); - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &mut app.levels[app.settings.active_level_index]; - - if cell.0 >= app.settings.cols || cell.1 >= app.settings.rows { - return; - } - - let text = app.settings.add_text_value.trim().to_string(); - if text.is_empty() { - return; - } - - if let Some(existing) = layout - .text_labels - .iter_mut() - .find(|label| label.cell == cell) - { - existing.text = text; - return; - } - - layout.text_labels.push(layout::TextLabel { - cell, - text, - font_size: app.settings.add_text_font_size, - }); -} - -pub fn add_marker_at_cell(app: &mut DungeonApp, cell: (usize, usize), kind: MarkerKind) { - app.push_undo_snapshot(); - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &mut app.levels[app.settings.active_level_index]; - - if cell.0 >= app.settings.cols || cell.1 >= app.settings.rows { - return; - } - - let size = app.settings.add_marker_size.max(1); - let origin = centered_marker_origin(cell, size, app.settings.cols, app.settings.rows); - let marker = layout::AreaMarker { cell: origin, size }; - - match kind { - MarkerKind::Start => layout.start_markers.push(marker), - MarkerKind::End => layout.end_markers.push(marker), - MarkerKind::Trap => layout.trap_markers.push(marker), - MarkerKind::Monster => layout.monster_markers.push(marker), - } -} - -pub fn add_staircase_at_cell(app: &mut DungeonApp, cell: (usize, usize)) { - app.push_undo_snapshot(); - if app.settings.active_level_index >= app.levels.len() { - return; - } - - if cell.0 >= app.settings.cols || cell.1 >= app.settings.rows { - return; - } - - let width = app.settings.add_stair_width.max(1); - let height = app.settings.add_stair_height.max(1); - let max_x = app.settings.cols.saturating_sub(width); - let max_y = app.settings.rows.saturating_sub(height); - let origin_x = cell.0.min(max_x); - let origin_y = cell.1.min(max_y); - - let stair = layout::Staircase { - cell: (origin_x, origin_y), - width, - height, - }; - - if app.settings.sync_stairs_across_levels { - for layout in &mut app.levels { - layout.stairs.push(stair.clone()); - } - } else { - let active_idx = app.settings.active_level_index; - let layout = &mut app.levels[active_idx]; - layout.stairs.push(stair.clone()); - - if active_idx + 1 < app.levels.len() { - let above_layout = &mut app.levels[active_idx + 1]; - above_layout.stairs.push(stair); - } else if active_idx > 0 { - let below_layout = &mut app.levels[active_idx - 1]; - below_layout.stairs.push(stair); - } - } -} - -pub fn delete_room(app: &mut DungeonApp, room_idx: usize) { - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &mut app.levels[app.settings.active_level_index]; - - if room_idx >= layout.rooms.len() { - return; - } - layout.rooms.remove(room_idx); - layout - .corridors - .retain(|c| c.start_room_id != room_idx && c.end_room_id != room_idx); - for corridor in &mut layout.corridors { - if corridor.start_room_id > room_idx { - corridor.start_room_id -= 1; - } - if corridor.end_room_id > room_idx { - corridor.end_room_id -= 1; - } - } - app.drag_state = None; - app.refresh_doors(); -} - -pub fn add_door_at_pointer( - app: &mut DungeonApp, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, - kind: ManualDoorKind, -) { - if app.settings.active_level_index >= app.levels.len() { - return; - } - - let Some(edge) = door_edge_at_pointer(app, pointer_pos, geometry) else { - return; - }; - - app.push_undo_snapshot(); - let layout = &mut app.levels[app.settings.active_level_index]; - - let target = normalized_edge(edge.0, edge.1); - if let Some(idx) = layout.doors.iter().position(|d| { - door_edges_for(d, app.settings.cols, app.settings.rows) - .iter() - .any(|e| normalized_edge(e.0, e.1) == target) - }) { - let edges = door_edges_for(&layout.doors[idx], app.settings.cols, app.settings.rows); - let mut spawned = Vec::new(); - for edge in edges { - if normalized_edge(edge.0, edge.1) == target { - continue; - } - if layout - .doors - .iter() - .any(|d| normalized_edge(d.from, d.to) == normalized_edge(edge.0, edge.1)) - { - continue; - } - spawned.push(layout::Door { - from: edge.0, - to: edge.1, - width: 1, - span_width: false, - locked: false, - archway: true, - secret: false, - manual: true, - }); - } - let door = &mut layout.doors[idx]; - door.locked = kind == ManualDoorKind::Locked; - door.archway = kind == ManualDoorKind::Archway; - door.secret = kind == ManualDoorKind::Secret; - door.width = 1; - door.span_width = false; - door.manual = true; - for edge in door_edges_for(door, app.settings.cols, app.settings.rows) { - app.suppressed_auto_door_edges.remove(&edge); - } - layout.doors.extend(spawned); - return; - } - - let corridor_cells = corridor_cells(layout, app.settings.cols, app.settings.rows); - let a_room = room_index_at_cell(&layout.rooms, edge.0); - let b_room = room_index_at_cell(&layout.rooms, edge.1); - let a_in_corridor = corridor_cells.contains(&edge.0); - let b_in_corridor = corridor_cells.contains(&edge.1); - - if !manual_door_edge_allowed(a_room, b_room, a_in_corridor, b_in_corridor) { - return; - } - - layout.doors.push(layout::Door { - from: edge.0, - to: edge.1, - width: 1, - span_width: false, - locked: kind == ManualDoorKind::Locked, - archway: kind == ManualDoorKind::Archway, - secret: kind == ManualDoorKind::Secret, - manual: true, - }); - app.suppressed_auto_door_edges.remove(&target); -} - -pub fn door_edge_at_pointer( - app: &DungeonApp, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) -> Option<((usize, usize), (usize, usize))> { - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - let cell_x = grid_x.floor() as isize; - let cell_y = grid_y.floor() as isize; - if cell_x < 0 - || cell_y < 0 - || cell_x >= app.settings.cols as isize - || cell_y >= app.settings.rows as isize - { - return None; - } - - let fx = grid_x - grid_x.floor(); - let fy = grid_y - grid_y.floor(); - let left = fx; - let right = 1.0 - fx; - let top = fy; - let bottom = 1.0 - fy; - - let mut candidates = [ - (left, (cell_x - 1, cell_y), (cell_x, cell_y)), - (right, (cell_x + 1, cell_y), (cell_x, cell_y)), - (top, (cell_x, cell_y - 1), (cell_x, cell_y)), - (bottom, (cell_x, cell_y + 1), (cell_x, cell_y)), - ]; - candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); - let (dist, a, b) = candidates[0]; - if dist > 0.2 { - return None; - } - - let (ax, ay) = a; - let (bx, by) = b; - if ax < 0 - || ay < 0 - || bx < 0 - || by < 0 - || ax >= app.settings.cols as isize - || bx >= app.settings.cols as isize - || ay >= app.settings.rows as isize - || by >= app.settings.rows as isize - { - return None; - } - - Some(((ax as usize, ay as usize), (bx as usize, by as usize))) -} - -pub fn room_at_pointer( - app: &DungeonApp, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) -> Option<(usize, f32, f32)> { - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - - if app.settings.active_level_index >= app.levels.len() { - return None; - } - let layout = &app.levels[app.settings.active_level_index]; - - for (room_idx, room) in layout.rooms.iter().enumerate().rev() { - let room_x = room.x as f32; - let room_y = room.y as f32; - let room_right = room_x + room.width as f32; - let room_bottom = room_y + room.height as f32; - - if grid_x >= room_x && grid_x < room_right && grid_y >= room_y && grid_y < room_bottom { - return Some((room_idx, grid_x - room_x, grid_y - room_y)); - } - } - - None -} - -pub fn drag_room_to_pointer( - app: &mut DungeonApp, - drag: RoomDragState, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) { - let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { - return; - }; - - if app.settings.active_level_index >= app.levels.len() { - return; - } - - if drag.room_idx >= app.levels[app.settings.active_level_index].rooms.len() { - app.drag_state = None; - return; - } - - let (width, height, old_x, old_y) = { - let room = &app.levels[app.settings.active_level_index].rooms[drag.room_idx]; - (room.width, room.height, room.x, room.y) - }; - let max_x = app.settings.cols.saturating_sub(width); - let max_y = app.settings.rows.saturating_sub(height); - - let target_x = (grid_x - drag.offset_x).floor().max(0.0) as usize; - let target_y = (grid_y - drag.offset_y).floor().max(0.0) as usize; - let new_x = target_x.min(max_x); - let new_y = target_y.min(max_y); - - if new_x == old_x && new_y == old_y { - return; - } - - app.levels[app.settings.active_level_index].rooms[drag.room_idx].x = new_x; - app.levels[app.settings.active_level_index].rooms[drag.room_idx].y = new_y; - reroute_corridors_for_room(app, drag.room_idx); - app.refresh_doors(); -} - -pub fn drag_text_to_pointer( - app: &mut DungeonApp, - drag: TextDragState, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) { - let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { - return; - }; - - if app.settings.active_level_index >= app.levels.len() { - return; - } - - if drag.text_idx - >= app.levels[app.settings.active_level_index] - .text_labels - .len() - { - app.drag_state = None; - return; - } - - let target = ( - (grid_x.floor().max(0.0) as usize).min(app.settings.cols.saturating_sub(1)), - (grid_y.floor().max(0.0) as usize).min(app.settings.rows.saturating_sub(1)), - ); - - app.levels[app.settings.active_level_index].text_labels[drag.text_idx].cell = target; -} - -pub fn reroute_corridors_for_room(app: &mut DungeonApp, room_idx: usize) { - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &mut app.levels[app.settings.active_level_index]; - - if layout.packed_rooms { - return; - } - - for corridor in &mut layout.corridors { - if corridor.start_room_id != room_idx && corridor.end_room_id != room_idx { - continue; - } - - let start = layout.rooms[corridor.start_room_id].center_cell(); - let end = layout.rooms[corridor.end_room_id].center_cell(); - let blocked = blocked_room_cells( - &layout.rooms, - &[corridor.start_room_id, corridor.end_room_id], - ); - if let Some(path) = - shortest_path_cells(start, end, app.settings.cols, app.settings.rows, &blocked) - { - corridor.path = path; - } - } -} - -pub fn corridor_drag_at_pointer( - app: &DungeonApp, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) -> Option { - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - let clicked = (grid_x.floor() as usize, grid_y.floor() as usize); - if app.settings.active_level_index >= app.levels.len() { - return None; - } - let layout = &app.levels[app.settings.active_level_index]; - - let corridor_index = layout - .corridors - .iter() - .position(|corridor| corridor.path.contains(&clicked))?; - - Some(CorridorDragState { - corridor_index, - original_path: layout.corridors[corridor_index].path.clone(), - original_cell: clicked, - }) -} - -pub fn drag_corridor_to_pointer( - app: &mut DungeonApp, - drag: CorridorDragState, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) { - let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { - return; - }; - - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &mut app.levels[app.settings.active_level_index]; - - if drag.corridor_index >= layout.corridors.len() { - app.drag_state = None; - return; - } - let corridor = &layout.corridors[drag.corridor_index]; - - let target = ( - (grid_x.floor().max(0.0) as usize).min(app.settings.cols.saturating_sub(1)), - (grid_y.floor().max(0.0) as usize).min(app.settings.rows.saturating_sub(1)), - ); - - let Some(new_path) = reroute_path_through_cell( - &drag.original_path, - drag.original_cell, - target, - app.settings.cols, - app.settings.rows, - &blocked_room_cells( - &layout.rooms, - &[corridor.start_room_id, corridor.end_room_id], - ), - ) else { - return; - }; - - layout.corridors[drag.corridor_index].path = new_path; - app.refresh_doors(); -} - -pub fn drag_marker_to_pointer( - app: &mut DungeonApp, - drag: MarkerDragState, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) { - let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { - return; - }; - - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &mut app.levels[app.settings.active_level_index]; - - let markers = match drag.kind { - MarkerKind::Start => &mut layout.start_markers, - MarkerKind::End => &mut layout.end_markers, - MarkerKind::Trap => &mut layout.trap_markers, - MarkerKind::Monster => &mut layout.monster_markers, - }; - let Some(marker) = markers.get_mut(drag.marker_idx) else { - app.drag_state = None; - return; - }; - - let max_x = app.settings.cols.saturating_sub(marker.size); - let max_y = app.settings.rows.saturating_sub(marker.size); - let target_x = (grid_x - drag.offset_x).floor().max(0.0) as usize; - let target_y = (grid_y - drag.offset_y).floor().max(0.0) as usize; - marker.cell = (target_x.min(max_x), target_y.min(max_y)); -} - -pub fn drag_stair_to_pointer( - app: &mut DungeonApp, - drag: StaircaseDragState, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) { - let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { - return; - }; - - if app.settings.active_level_index >= app.levels.len() { - return; - } - - let (stair_width, stair_height) = { - let layout = &app.levels[app.settings.active_level_index]; - let Some(stair) = layout.stairs.get(drag.stair_idx) else { - return; - }; - (stair.width, stair.height) - }; - - let max_x = app.settings.cols.saturating_sub(stair_width); - let max_y = app.settings.rows.saturating_sub(stair_height); - let target_x = (grid_x - drag.offset_x).floor().max(0.0) as usize; - let target_y = (grid_y - drag.offset_y).floor().max(0.0) as usize; - let new_cell = (target_x.min(max_x), target_y.min(max_y)); - - if app.settings.sync_stairs_across_levels { - for layout in &mut app.levels { - for stair in &mut layout.stairs { - if stair.cell == drag.original_cell { - stair.cell = new_cell; - } - } - } - } else { - let current_idx = app.settings.active_level_index; - let level_indices = vec![ - Some(current_idx), - current_idx.checked_sub(1), - if current_idx + 1 < app.levels.len() { - Some(current_idx + 1) - } else { - None - }, - ]; - - for idx in level_indices.into_iter().flatten() { - let layout = &mut app.levels[idx]; - for stair in &mut layout.stairs { - if stair.cell == drag.original_cell { - stair.cell = new_cell; - } - } - } - } - - if let Some(DragState::Staircase(ref mut d)) = app.drag_state { - d.original_cell = new_cell; - } -} - -pub fn text_at_pointer( - app: &DungeonApp, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) -> Option { - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - let clicked = (grid_x.floor() as usize, grid_y.floor() as usize); - if app.settings.active_level_index >= app.levels.len() { - return None; - } - let layout = &app.levels[app.settings.active_level_index]; - - layout - .text_labels - .iter() - .position(|label| label.cell == clicked) -} - -pub fn marker_at_pointer( - app: &DungeonApp, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) -> Option { - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - let clicked = (grid_x.floor() as usize, grid_y.floor() as usize); - if app.settings.active_level_index >= app.levels.len() { - return None; - } - let layout = &app.levels[app.settings.active_level_index]; - - layout - .start_markers - .iter() - .enumerate() - .find(|(_, marker)| marker_contains_cell(Some(marker), clicked)) - .map(|(idx, _)| HoverMarker::Start(idx)) - .or_else(|| { - layout - .end_markers - .iter() - .enumerate() - .find(|(_, marker)| marker_contains_cell(Some(marker), clicked)) - .map(|(idx, _)| HoverMarker::End(idx)) - }) - .or_else(|| { - layout - .trap_markers - .iter() - .enumerate() - .find(|(_, marker)| marker_contains_cell(Some(marker), clicked)) - .map(|(idx, _)| HoverMarker::Trap(idx)) - }) - .or_else(|| { - layout - .monster_markers - .iter() - .enumerate() - .find(|(_, marker)| marker_contains_cell(Some(marker), clicked)) - .map(|(idx, _)| HoverMarker::Monster(idx)) - }) -} - -pub fn marker_drag_at_pointer( - app: &DungeonApp, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) -> Option { - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - if app.settings.active_level_index >= app.levels.len() { - return None; - } - let layout = &app.levels[app.settings.active_level_index]; - - match marker_at_pointer(app, pointer_pos, geometry)? { - HoverMarker::Start(marker_idx) => { - let marker = layout.start_markers.get(marker_idx)?; - Some(MarkerDragState { - kind: MarkerKind::Start, - marker_idx, - offset_x: grid_x - marker.cell.0 as f32, - offset_y: grid_y - marker.cell.1 as f32, - }) - } - HoverMarker::End(marker_idx) => { - let marker = layout.end_markers.get(marker_idx)?; - Some(MarkerDragState { - kind: MarkerKind::End, - marker_idx, - offset_x: grid_x - marker.cell.0 as f32, - offset_y: grid_y - marker.cell.1 as f32, - }) - } - HoverMarker::Trap(marker_idx) => { - let marker = layout.trap_markers.get(marker_idx)?; - Some(MarkerDragState { - kind: MarkerKind::Trap, - marker_idx, - offset_x: grid_x - marker.cell.0 as f32, - offset_y: grid_y - marker.cell.1 as f32, - }) - } - HoverMarker::Monster(marker_idx) => { - let marker = layout.monster_markers.get(marker_idx)?; - Some(MarkerDragState { - kind: MarkerKind::Monster, - marker_idx, - offset_x: grid_x - marker.cell.0 as f32, - offset_y: grid_y - marker.cell.1 as f32, - }) - } - } -} - -pub fn stair_at_pointer( - app: &DungeonApp, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) -> Option { - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - let clicked = (grid_x.floor() as usize, grid_y.floor() as usize); - if app.settings.active_level_index >= app.levels.len() { - return None; - } - let layout = &app.levels[app.settings.active_level_index]; - - layout.stairs.iter().position(|stair| { - clicked.0 >= stair.cell.0 - && clicked.0 < stair.cell.0 + stair.width - && clicked.1 >= stair.cell.1 - && clicked.1 < stair.cell.1 + stair.height - }) -} - -pub fn stair_drag_at_pointer( - app: &DungeonApp, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) -> Option { - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - if app.settings.active_level_index >= app.levels.len() { - return None; - } - let stair_idx = stair_at_pointer(app, pointer_pos, geometry)?; - let layout = &app.levels[app.settings.active_level_index]; - let stair = layout.stairs.get(stair_idx)?; - - Some(StaircaseDragState { - stair_idx, - offset_x: grid_x - stair.cell.0 as f32, - offset_y: grid_y - stair.cell.1 as f32, - original_cell: stair.cell, - }) -} - -pub fn start_text_resize(app: &DungeonApp, text_idx: usize) -> Option { - if app.settings.active_level_index >= app.levels.len() { - return None; - } - let layout = &app.levels[app.settings.active_level_index]; - - let label = layout.text_labels.get(text_idx)?; - Some(TextResizeState { - text_idx, - origin_cell: label.cell, - original_font_size: label.font_size, - }) -} - -pub fn resize_room_at_pointer( - app: &DungeonApp, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) -> Option { - if let Some((room_idx, _, _)) = room_at_pointer(app, pointer_pos, geometry) { - return Some(room_idx); - } - - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - let clicked = ( - grid_x.floor().max(0.0) as usize, - grid_y.floor().max(0.0) as usize, - ); - - if app.settings.active_level_index >= app.levels.len() { - return None; - } - let layout = &app.levels[app.settings.active_level_index]; - - for (room_idx, room) in layout.rooms.iter().enumerate().rev() { - if resize_hit_cells(room, room_idx, &layout.rooms, geometry.cols, geometry.rows) - .contains(&clicked) - { - return Some(room_idx); - } - } - - None -} - -pub fn start_resize( - app: &DungeonApp, - room_idx: usize, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) -> Option { - if app.settings.active_level_index >= app.levels.len() { - return None; - } - let layout = &app.levels[app.settings.active_level_index]; - - let original_room = layout.rooms.get(room_idx)?.clone(); - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - let center_x = original_room.x as f32 + original_room.width as f32 * 0.5; - let center_y = original_room.y as f32 + original_room.height as f32 * 0.5; - let pointer_x = grid_x; - let pointer_y = grid_y; - - let resize_corner = match (pointer_x >= center_x, pointer_y >= center_y) { - (false, false) => ResizeCorner::TopLeft, - (true, false) => ResizeCorner::TopRight, - (false, true) => ResizeCorner::BottomLeft, - (true, true) => ResizeCorner::BottomRight, - }; - - Some(RoomResizeState { - room_idx, - original_room, - resize_corner, - }) -} - -pub fn start_staircase_resize( - app: &DungeonApp, - stair_idx: usize, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, -) -> Option { - if app.settings.active_level_index >= app.levels.len() { - return None; - } - let layout = &app.levels[app.settings.active_level_index]; - - let original_stair = layout.stairs.get(stair_idx)?.clone(); - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - let center_x = original_stair.cell.0 as f32 + original_stair.width as f32 * 0.5; - let center_y = original_stair.cell.1 as f32 + original_stair.height as f32 * 0.5; - let pointer_x = grid_x; - let pointer_y = grid_y; - - let resize_corner = match (pointer_x >= center_x, pointer_y >= center_y) { - (false, false) => ResizeCorner::TopLeft, - (true, false) => ResizeCorner::TopRight, - (false, true) => ResizeCorner::BottomLeft, - (true, true) => ResizeCorner::BottomRight, - }; - - Some(StaircaseResizeState { - stair_idx, - original_stair, - resize_corner, - }) -} - -pub fn resize_room(app: &mut DungeonApp, state: &RoomResizeState, target_cell: (usize, usize)) { - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &mut app.levels[app.settings.active_level_index]; - - let cols = app.settings.cols.max(1); - let rows = app.settings.rows.max(1); - - let tx = target_cell.0.min(cols - 1) as isize; - let ty = target_cell.1.min(rows - 1) as isize; - let original = &state.original_room; - - let left = original.x as isize; - let top = original.y as isize; - let right = (original.x + original.width - 1) as isize; - let bottom = (original.y + original.height - 1) as isize; - let (x0, x1, y0, y1) = match state.resize_corner { - ResizeCorner::TopLeft => (tx.min(right), right, ty.min(bottom), bottom), - ResizeCorner::TopRight => (left, tx.max(left), ty.min(bottom), bottom), - ResizeCorner::BottomLeft => (tx.min(right), right, top, ty.max(top)), - ResizeCorner::BottomRight => (left, tx.max(left), top, ty.max(top)), - }; - - let new_room = layout::Room { - x: x0 as usize, - y: y0 as usize, - width: (x1 - x0 + 1) as usize, - height: (y1 - y0 + 1) as usize, - }; - - if new_room.width == 0 || new_room.height == 0 { - return; - } - - if new_room.x + new_room.width > cols || new_room.y + new_room.height > rows { - return; - } - - if layout - .rooms - .iter() - .enumerate() - .any(|(idx, room)| idx != state.room_idx && rooms_overlap(&new_room, room)) - { - return; - } - - if let Some(room) = layout.rooms.get_mut(state.room_idx) { - room.x = new_room.x; - room.y = new_room.y; - room.width = new_room.width; - room.height = new_room.height; - reroute_corridors_for_room(app, state.room_idx); - app.refresh_doors(); - } -} - -pub fn resize_text(app: &mut DungeonApp, state: &TextResizeState, target_cell: (usize, usize)) { - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &mut app.levels[app.settings.active_level_index]; - - let Some(label) = layout.text_labels.get_mut(state.text_idx) else { - app.resize_state = None; - return; - }; - - let dx = target_cell.0 as isize - state.origin_cell.0 as isize; - let dy = target_cell.1 as isize - state.origin_cell.1 as isize; - let delta_cells = if dx.abs() >= dy.abs() { dx } else { -dy }; - let new_size = (state.original_font_size as isize + (delta_cells * 4)).clamp(8, 96); - label.font_size = new_size as u16; -} - -pub fn resize_staircase( - app: &mut DungeonApp, - state: &StaircaseResizeState, - target_cell: (usize, usize), -) { - if app.settings.active_level_index >= app.levels.len() { - return; - } - - let cols = app.settings.cols.max(1); - let rows = app.settings.rows.max(1); - - let tx = target_cell.0.min(cols - 1) as isize; - let ty = target_cell.1.min(rows - 1) as isize; - let original = &state.original_stair; - - let left = original.cell.0 as isize; - let top = original.cell.1 as isize; - let right = (original.cell.0 + original.width - 1) as isize; - let bottom = (original.cell.1 + original.height - 1) as isize; - - let (x0, x1, y0, y1) = match state.resize_corner { - ResizeCorner::TopLeft => (tx.min(right), right, ty.min(bottom), bottom), - ResizeCorner::TopRight => (left, tx.max(left), ty.min(bottom), bottom), - ResizeCorner::BottomLeft => (tx.min(right), right, top, ty.max(top)), - ResizeCorner::BottomRight => (left, tx.max(left), top, ty.max(top)), - }; - - let new_stair_cell = (x0 as usize, y0 as usize); - let new_stair_width = (x1 - x0 + 1) as usize; - let new_stair_height = (y1 - y0 + 1) as usize; - - if new_stair_width == 0 || new_stair_height == 0 { - return; - } - - let active_layout = &app.levels[app.settings.active_level_index]; - if active_layout.stairs.iter().enumerate().any(|(idx, stair)| { - idx != state.stair_idx - && rects_overlap( - new_stair_cell.0, - new_stair_cell.1, - new_stair_width, - new_stair_height, - stair.cell.0, - stair.cell.1, - stair.width, - stair.height, - ) - }) { - return; - } - - if app.settings.sync_stairs_across_levels { - for layout in &mut app.levels { - for stair in &mut layout.stairs { - if stair.cell == state.original_stair.cell { - stair.cell = new_stair_cell; - stair.width = new_stair_width; - stair.height = new_stair_height; - } - } - } - } else { - let current_idx = app.settings.active_level_index; - let level_indices = vec![ - Some(current_idx), - current_idx.checked_sub(1), - if current_idx + 1 < app.levels.len() { - Some(current_idx + 1) - } else { - None - }, - ]; - - for idx in level_indices.into_iter().flatten() { - let layout = &mut app.levels[idx]; - for stair in &mut layout.stairs { - if stair.cell == state.original_stair.cell { - stair.cell = new_stair_cell; - stair.width = new_stair_width; - stair.height = new_stair_height; - } - } - } - } - - if let Some(ResizeState::Staircase(ref mut s)) = app.resize_state { - s.original_stair.cell = new_stair_cell; - s.original_stair.width = new_stair_width; - s.original_stair.height = new_stair_height; - } -} - -pub fn draw_add_overlay(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) { - if app.settings.add_tool != AddTool::Corridor { - return; - } - let Some(drag) = &app.add_corridor_drag else { - return; - }; - - let start = cell_center(geometry, drag.start_cell.0, drag.start_cell.1); - let end = cell_center(geometry, drag.current_cell.0, drag.current_cell.1); - let stroke = Stroke::new(2.0, Color32::WHITE); - painter.circle_filled(start, 3.0, Color32::WHITE); - crate::rendering::draw_dashed_line(painter, start, end, stroke, 8.0, 6.0); -} - -pub fn draw_add_tool_ghost(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) { - let tool = app.settings.add_tool; - if tool == AddTool::None || tool == AddTool::Corridor { - return; - } - - let Some(pointer_pos) = painter.ctx().pointer_hover_pos() else { - return; - }; - let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { - return; - }; - let cell = (grid_x.floor() as usize, grid_y.floor() as usize); - - let ghost_alpha = 0.35; - - match tool { - AddTool::Room => { - let width = app.settings.add_room_width.max(1); - let height = app.settings.add_room_height.max(1); - let max_x = app.settings.cols.saturating_sub(cell.0); - let max_y = app.settings.rows.saturating_sub(cell.1); - let w = width.min(max_x).max(1); - let h = height.min(max_y).max(1); - - let left = geometry.rect.left() + cell.0 as f32 * geometry.cell_size; - let top = geometry.rect.top() + cell.1 as f32 * geometry.cell_size; - let right = left + w as f32 * geometry.cell_size; - let bottom = top + h as f32 * geometry.cell_size; - let rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); - - let fill = Color32::from_rgb(70, 120, 160).gamma_multiply(ghost_alpha); - painter.rect_filled(rect, 4.0, fill); - painter.rect_stroke( - rect, - 4.0, - Stroke::new( - 1.5, - Color32::from_rgb(150, 200, 240).gamma_multiply(ghost_alpha + 0.3), - ), - egui::StrokeKind::Middle, - ); - } - AddTool::Text => { - let text = app.settings.add_text_value.trim(); - if text.is_empty() { - return; - } - let font_size = app.settings.add_text_font_size.max(8); - let rect = crate::rendering::text_label_rect( - geometry, - &layout::TextLabel { - cell, - text: text.to_string(), - font_size, - }, - ); - - let fill = Color32::from_rgb(50, 50, 70).gamma_multiply(ghost_alpha); - painter.rect_filled(rect.expand(4.0), 4.0, fill); - painter.rect_stroke( - rect.expand(4.0), - 4.0, - Stroke::new( - 1.0, - Color32::from_rgb(180, 180, 220).gamma_multiply(ghost_alpha + 0.2), - ), - egui::StrokeKind::Middle, - ); - - let color = Color32::WHITE.gamma_multiply(ghost_alpha + 0.1); - painter.text( - rect.center(), - egui::Align2::CENTER_CENTER, - text, - egui::FontId::proportional(font_size as f32), - color, - ); - } - AddTool::StartMarker - | AddTool::EndMarker - | AddTool::TrapMarker - | AddTool::MonsterMarker => { - let size = app.settings.add_marker_size.max(1); - let origin = centered_marker_origin(cell, size, app.settings.cols, app.settings.rows); - - let rect = marker_rect(&layout::AreaMarker { cell: origin, size }, geometry); - - let base_color = match tool { - AddTool::StartMarker => Color32::from_rgb(60, 220, 200), - AddTool::EndMarker => Color32::from_rgb(240, 90, 90), - AddTool::TrapMarker => Color32::from_rgb(150, 80, 230), - AddTool::MonsterMarker => Color32::from_rgb(200, 50, 50), - _ => Color32::WHITE, - }; - - let fill = base_color.gamma_multiply(ghost_alpha); - painter.rect_filled(rect, 4.0, fill); - painter.rect_stroke( - rect, - 4.0, - Stroke::new( - 1.5, - Color32::from_rgb(255, 255, 255).gamma_multiply(ghost_alpha + 0.2), - ), - egui::StrokeKind::Middle, - ); - - let label = match tool { - AddTool::StartMarker => "S", - AddTool::EndMarker => "E", - AddTool::TrapMarker => "T", - AddTool::MonsterMarker => "M", - _ => "?", - }; - let text_color = Color32::WHITE.gamma_multiply(ghost_alpha + 0.15); - let font_size = (geometry.cell_size * size as f32 * 0.5).clamp(14.0, 72.0); - painter.text( - rect.center(), - egui::Align2::CENTER_CENTER, - label, - egui::FontId::proportional(font_size), - text_color, - ); - } - AddTool::Staircase => { - let width = app.settings.add_stair_width.max(1); - let height = app.settings.add_stair_height.max(1); - let max_x = app.settings.cols.saturating_sub(cell.0); - let max_y = app.settings.rows.saturating_sub(cell.1); - let w = width.min(max_x).max(1); - let h = height.min(max_y).max(1); - - let left = geometry.rect.left() + cell.0 as f32 * geometry.cell_size; - let top = geometry.rect.top() + cell.1 as f32 * geometry.cell_size; - let right = left + w as f32 * geometry.cell_size; - let bottom = top + h as f32 * geometry.cell_size; - let rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); - - let fill = Color32::from_rgb(200, 150, 50).gamma_multiply(ghost_alpha); - painter.rect_filled(rect, 4.0, fill); - painter.rect_stroke( - rect, - 4.0, - Stroke::new( - 1.5, - Color32::from_rgb(220, 180, 80).gamma_multiply(ghost_alpha + 0.2), - ), - egui::StrokeKind::Middle, - ); - - let steps = (h as f32 * 2.0).round() as usize; - let step_height = (rect.height() / (steps as f32)).max(1.0); - let line_color = Color32::from_rgb(150, 100, 30).gamma_multiply(ghost_alpha + 0.15); - for i in 0..steps { - let y = rect.top() + (i as f32 * step_height) + step_height * 0.5; - painter.line_segment( - [ - egui::pos2(rect.left() + 2.0, y), - egui::pos2(rect.right() - 2.0, y), - ], - Stroke::new(1.5, line_color), - ); - } - } - AddTool::Archway | AddTool::Door | AddTool::LockedDoor | AddTool::SecretDoor => { - if let Some((edge_a, edge_b)) = door_edge_at_pointer(app, pointer_pos, geometry) { - let door_width_cells = 1; - let style = match tool { - AddTool::Archway => crate::rendering::DoorLineStyle::Dotted, - AddTool::Door => crate::rendering::DoorLineStyle::LongDash, - AddTool::LockedDoor => crate::rendering::DoorLineStyle::ShortDash, - AddTool::SecretDoor => crate::rendering::DoorLineStyle::DashDotDot, - _ => crate::rendering::DoorLineStyle::Solid, - }; - let color = match tool { - AddTool::Archway => Color32::from_rgb(230, 140, 60), - AddTool::Door => Color32::from_rgb(80, 200, 120), - AddTool::LockedDoor => Color32::from_rgb(220, 70, 70), - AddTool::SecretDoor => Color32::from_rgb(170, 80, 170), - _ => Color32::WHITE, - }; - let ghost_color = color.gamma_multiply(ghost_alpha); - crate::rendering::draw_door_line( - painter, - geometry, - edge_a, - edge_b, - door_width_cells, - Stroke::new((geometry.cell_size / 5.0).max(1.0), ghost_color), - style, - ); - } - } - AddTool::None | AddTool::Corridor => {} - } -} - -pub fn draw_resize_overlay(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) { - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &app.levels[app.settings.active_level_index]; - - if let Some(state) = &app.resize_state { - match state { - ResizeState::Text(state) => { - if let Some(label) = layout.text_labels.get(state.text_idx) { - let rect = crate::rendering::text_label_rect(geometry, label).expand(3.0); - painter.rect_stroke( - rect, - 0.0, - Stroke::new(2.0, Color32::from_gray(180)), - egui::StrokeKind::Middle, - ); - } - } - ResizeState::Room(state) => { - if let Some(room) = layout.rooms.get(state.room_idx) { - draw_room_resize_visuals(painter, geometry, room, true); - } - } - ResizeState::Staircase(state) => { - if let Some(stair) = layout.stairs.get(state.stair_idx) { - draw_staircase_resize_visuals(painter, geometry, stair, true); - } - } - } - return; - } - - if let Some(text_idx) = app.hover_text_idx { - if let Some(label) = layout.text_labels.get(text_idx) { - let rect = crate::rendering::text_label_rect(geometry, label).expand(3.0); - painter.rect_stroke( - rect, - 0.0, - Stroke::new(2.0, Color32::from_gray(180)), - egui::StrokeKind::Middle, - ); - } - } else if let Some(room_idx) = app.hover_room_idx { - if let Some(room) = layout.rooms.get(room_idx) { - draw_room_resize_visuals(painter, geometry, room, false); - } - } else if let Some(stair_idx) = app.hover_stair_idx { - if let Some(stair) = layout.stairs.get(stair_idx) { - draw_staircase_resize_visuals(painter, geometry, stair, false); - } - } -} - -pub fn draw_room_resize_visuals( - painter: &egui::Painter, - geometry: &GridGeometry, - room: &layout::Room, - show_handles: bool, -) { - let color = Color32::from_gray(170); - let stroke = Stroke::new(1.5, color); - - let left = geometry.rect.left() + room.x as f32 * geometry.cell_size; - let top = geometry.rect.top() + room.y as f32 * geometry.cell_size; - let right = left + room.width as f32 * geometry.cell_size; - let bottom = top + room.height as f32 * geometry.cell_size; - - let tl = egui::pos2(left, top); - let tr = egui::pos2(right, top); - let br = egui::pos2(right, bottom); - let bl = egui::pos2(left, bottom); - - crate::rendering::draw_dashed_line(painter, tl, tr, stroke, 6.0, 6.0); - crate::rendering::draw_dashed_line(painter, tr, br, stroke, 6.0, 6.0); - crate::rendering::draw_dashed_line(painter, br, bl, stroke, 6.0, 6.0); - crate::rendering::draw_dashed_line(painter, bl, tl, stroke, 6.0, 6.0); - - let font = egui::FontId::proportional(12.0); - let center_top = egui::pos2((left + right) * 0.5, top - 10.0); - let center_left = egui::pos2(left - 12.0, (top + bottom) * 0.5); - painter.text( - center_top, - egui::Align2::CENTER_CENTER, - "<->", - font.clone(), - color, - ); - painter.text(center_left, egui::Align2::CENTER_CENTER, "^v", font, color); - - if show_handles { - draw_resize_handle(painter, tl, ResizeCorner::TopLeft, color); - draw_resize_handle(painter, tr, ResizeCorner::TopRight, color); - draw_resize_handle(painter, br, ResizeCorner::BottomRight, color); - draw_resize_handle(painter, bl, ResizeCorner::BottomLeft, color); - } -} - -pub fn draw_staircase_resize_visuals( - painter: &egui::Painter, - geometry: &GridGeometry, - stair: &layout::Staircase, - show_handles: bool, -) { - let color = Color32::from_gray(170); - let stroke = Stroke::new(1.5, color); - - let left = geometry.rect.left() + stair.cell.0 as f32 * geometry.cell_size; - let top = geometry.rect.top() + stair.cell.1 as f32 * geometry.cell_size; - let right = left + stair.width as f32 * geometry.cell_size; - let bottom = top + stair.height as f32 * geometry.cell_size; - - let tl = egui::pos2(left, top); - let tr = egui::pos2(right, top); - let br = egui::pos2(right, bottom); - let bl = egui::pos2(left, bottom); - - crate::rendering::draw_dashed_line(painter, tl, tr, stroke, 6.0, 6.0); - crate::rendering::draw_dashed_line(painter, tr, br, stroke, 6.0, 6.0); - crate::rendering::draw_dashed_line(painter, br, bl, stroke, 6.0, 6.0); - crate::rendering::draw_dashed_line(painter, bl, tl, stroke, 6.0, 6.0); - - if show_handles { - draw_resize_handle(painter, tl, ResizeCorner::TopLeft, color); - draw_resize_handle(painter, tr, ResizeCorner::TopRight, color); - draw_resize_handle(painter, br, ResizeCorner::BottomRight, color); - draw_resize_handle(painter, bl, ResizeCorner::BottomLeft, color); - } -} - -pub fn draw_corridor_hover_overlay( - app: &DungeonApp, - painter: &egui::Painter, - geometry: &GridGeometry, -) { - let Some(idx) = app.hover_corridor_idx else { - return; - }; - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &app.levels[app.settings.active_level_index]; - - let Some(corridor) = layout.corridors.get(idx) else { - return; - }; - - let color = Color32::from_gray(170); - let stroke = Stroke::new(2.0, color); - for pair in corridor.path.windows(2) { - let a = cell_center(geometry, pair[0].0, pair[0].1); - let b = cell_center(geometry, pair[1].0, pair[1].1); - crate::rendering::draw_dashed_line(painter, a, b, stroke, 6.0, 6.0); - } -} - -pub fn draw_door_hover_overlay(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) { - let Some(idx) = app.hover_door_idx else { - return; - }; - if app.settings.active_level_index >= app.levels.len() { - return; - } - let layout = &app.levels[app.settings.active_level_index]; - - let Some(door) = layout.doors.get(idx) else { - return; - }; - let color = Color32::from_gray(200); - let stroke = Stroke::new(2.0, color); - crate::rendering::draw_door_line( - painter, - geometry, - door.from, - door.to, - door_render_width(door), - stroke, - crate::rendering::DoorLineStyle::LongDash, - ); -} - -pub fn pointer_to_grid(pointer_pos: egui::Pos2, geometry: &GridGeometry) -> Option<(f32, f32)> { - if !geometry.rect.contains(pointer_pos) { - return None; - } - - let x = (pointer_pos.x - geometry.rect.left()) / geometry.cell_size; - let y = (pointer_pos.y - geometry.rect.top()) / geometry.cell_size; - Some((x, y)) -} - -pub fn reroute_path_through_cell( - original_path: &[(usize, usize)], - original_cell: (usize, usize), - target_cell: (usize, usize), - cols: usize, - rows: usize, - blocked_room_cells_set: &HashSet<(usize, usize)>, -) -> Option> { - let (&start, &end) = (original_path.first()?, original_path.last()?); - if start == end { - return None; - } - - let mut blocked = blocked_room_cells_set.clone(); - blocked.insert(original_cell); - blocked.remove(&start); - blocked.remove(&end); - blocked.remove(&target_cell); - - let first_leg = shortest_path_cells(start, target_cell, cols, rows, &blocked)?; - - let mut blocked_second = blocked.clone(); - for &cell in first_leg - .iter() - .skip(1) - .take(first_leg.len().saturating_sub(2)) - { - if cell != target_cell && cell != end { - blocked_second.insert(cell); - } - } - - let second_leg = shortest_path_cells(target_cell, end, cols, rows, &blocked_second) - .or_else(|| shortest_path_cells(target_cell, end, cols, rows, &blocked))?; - - let mut full_path = first_leg; - for cell in second_leg.into_iter().skip(1) { - if full_path.last().copied() != Some(cell) { - full_path.push(cell); - } - } - - simplify_path_loops(&mut full_path); - - if full_path.len() < 2 || !full_path.contains(&target_cell) { - return None; - } - - if original_cell != start && original_cell != end && full_path.contains(&original_cell) { - return None; - } - - Some(full_path) -} - -pub fn simplify_path_loops(path: &mut Vec<(usize, usize)>) { - let mut out = Vec::new(); - for &cell in path.iter() { - if let Some(pos) = out.iter().position(|&c| c == cell) { - out.truncate(pos + 1); - } else { - out.push(cell); - } - } - *path = out; -} - -pub fn manual_door_edge_allowed( - a_room: Option, - b_room: Option, - a_in_corridor: bool, - b_in_corridor: bool, -) -> bool { - if matches!((a_room, b_room), (Some(a), Some(b)) if a == b) { - return false; - } - - let a_is_feature = a_room.is_some() || a_in_corridor; - let b_is_feature = b_room.is_some() || b_in_corridor; - a_is_feature || b_is_feature -} - -pub fn resize_hit_cells( - room: &layout::Room, - room_idx: usize, - rooms: &[layout::Room], - cols: usize, - rows: usize, -) -> HashSet<(usize, usize)> { - let mut cells = HashSet::new(); - let corners = [ - (room.x as isize, room.y as isize, -1isize, -1isize), - ( - (room.x + room.width - 1) as isize, - room.y as isize, - 1isize, - -1isize, - ), - ( - room.x as isize, - (room.y + room.height - 1) as isize, - -1isize, - 1isize, - ), - ( - (room.x + room.width - 1) as isize, - (room.y + room.height - 1) as isize, - 1isize, - 1isize, - ), - ]; - - for (cx, cy, ox, oy) in corners { - let candidates = [ - (cx, cy), - (cx - ox, cy), - (cx, cy - oy), - (cx - ox, cy - oy), - (cx + ox, cy), - (cx, cy + oy), - (cx + ox, cy + oy), - ]; - - for (x, y) in candidates { - if x < 0 || y < 0 || x >= cols as isize || y >= rows as isize { - continue; - } - - let cell = (x as usize, y as usize); - let in_other_room = rooms.iter().enumerate().any(|(idx, other_room)| { - idx != room_idx - && cell.0 >= other_room.x - && cell.0 < other_room.x + other_room.width - && cell.1 >= other_room.y - && cell.1 < other_room.y + other_room.height - }); - if !in_other_room { - cells.insert(cell); - } - } - } - - cells -} - -pub fn draw_resize_handle( - painter: &egui::Painter, - corner: egui::Pos2, - which: ResizeCorner, - color: Color32, -) { - let size = 10.0; - let half = size * 0.5; - let rect = egui::Rect::from_center_size(corner, egui::vec2(size, size)); - painter.rect_filled(rect, 2.0, Color32::from_gray(30)); - painter.rect_stroke(rect, 2.0, Stroke::new(1.0, color), egui::StrokeKind::Middle); - - let center = corner; - let stroke = Stroke::new(1.0, color); - match which { - ResizeCorner::TopLeft => { - painter.line_segment( - [center, egui::pos2(center.x - half + 2.0, center.y)], - stroke, - ); - painter.line_segment( - [center, egui::pos2(center.x, center.y - half + 2.0)], - stroke, - ); - } - ResizeCorner::TopRight => { - painter.line_segment( - [center, egui::pos2(center.x + half - 2.0, center.y)], - stroke, - ); - painter.line_segment( - [center, egui::pos2(center.x, center.y - half + 2.0)], - stroke, - ); - } - ResizeCorner::BottomLeft => { - painter.line_segment( - [center, egui::pos2(center.x - half + 2.0, center.y)], - stroke, - ); - painter.line_segment( - [center, egui::pos2(center.x, center.y + half - 2.0)], - stroke, - ); - } - ResizeCorner::BottomRight => { - painter.line_segment( - [center, egui::pos2(center.x + half - 2.0, center.y)], - stroke, - ); - painter.line_segment( - [center, egui::pos2(center.x, center.y + half - 2.0)], - stroke, - ); - } - } -} - -pub fn door_edges_for( - door: &layout::Door, - cols: usize, - rows: usize, -) -> Vec<((usize, usize), (usize, usize))> { - let mut edges = Vec::new(); - if door.from.0.abs_diff(door.to.0) + door.from.1.abs_diff(door.to.1) != 1 { - return edges; - } - - let width = door_render_width(door).max(1) as isize; - let min_offset = -((width - 1) / 2); - let max_offset = width / 2; - - if door.from.0 != door.to.0 { - let y = door.from.1 as isize; - for dy in min_offset..=max_offset { - let ny = y + dy; - if ny < 0 || ny >= rows as isize { - continue; - } - let a = (door.from.0, ny as usize); - let b = (door.to.0, ny as usize); - edges.push(normalized_edge(a, b)); - } - } else { - let x = door.from.0 as isize; - for dx in min_offset..=max_offset { - let nx = x + dx; - if nx < 0 || nx >= cols as isize { - continue; - } - let a = (nx as usize, door.from.1); - let b = (nx as usize, door.to.1); - edges.push(normalized_edge(a, b)); - } - } - - edges -} - -pub fn door_render_width(door: &layout::Door) -> usize { - if door.span_width { - door.width.max(1) - } else { - 1 - } -} - -pub fn window_render_width(window: &layout::Window) -> usize { - if window.span_width { - window.width.max(1) - } else { - 1 - } -} - -pub fn normalized_edge(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) { - if a <= b { (a, b) } else { (b, a) } -} - -pub fn cell_center(geometry: &GridGeometry, col: usize, row: usize) -> egui::Pos2 { - egui::pos2( - geometry.rect.left() + (col as f32 + 0.5) * geometry.cell_size, - geometry.rect.top() + (row as f32 + 0.5) * geometry.cell_size, - ) -} - -pub fn cell_rect(geometry: &GridGeometry, col: usize, row: usize) -> egui::Rect { - let left = geometry.rect.left() + col as f32 * geometry.cell_size; - let top = geometry.rect.top() + row as f32 * geometry.cell_size; - egui::Rect::from_min_size( - egui::pos2(left, top), - egui::vec2(geometry.cell_size, geometry.cell_size), - ) -} - -pub fn marker_rect(marker: &layout::AreaMarker, geometry: &GridGeometry) -> egui::Rect { - let left = geometry.rect.left() + marker.cell.0 as f32 * geometry.cell_size; - let top = geometry.rect.top() + marker.cell.1 as f32 * geometry.cell_size; - egui::Rect::from_min_size( - egui::pos2(left, top), - egui::vec2( - marker.size as f32 * geometry.cell_size, - marker.size as f32 * geometry.cell_size, - ), - ) -} - -pub fn marker_contains_cell(marker: Option<&layout::AreaMarker>, cell: (usize, usize)) -> bool { - let Some(marker) = marker else { - return false; - }; - cell.0 >= marker.cell.0 - && cell.0 < marker.cell.0 + marker.size - && cell.1 >= marker.cell.1 - && cell.1 < marker.cell.1 + marker.size -} - -pub fn centered_marker_origin( - cell: (usize, usize), - size: usize, - cols: usize, - rows: usize, -) -> (usize, usize) { - let size = size.clamp(1, 10).min(cols.max(1)).min(rows.max(1)); - let half = size / 2; - let max_x = cols.saturating_sub(size); - let max_y = rows.saturating_sub(size); - ( - cell.0.saturating_sub(half).min(max_x), - cell.1.saturating_sub(half).min(max_y), - ) -} diff --git a/src/interact/add_delete.rs b/src/interact/add_delete.rs new file mode 100644 index 0000000..35110c5 --- /dev/null +++ b/src/interact/add_delete.rs @@ -0,0 +1,541 @@ +/* + * Manual addition and deletion tools. + * Handles placing new rooms, corridors, doors, markers, and text labels, + * as well as deleting existing elements via pointer interaction. + */ + +use super::{ + AddCorridorDrag, GridGeometry, HoverMarker, MarkerKind, centered_marker_origin, + corridor_drag_at_pointer, door_edge_at_pointer, door_edges_for, marker_at_pointer, + normalized_edge, pointer_to_grid, room_at_pointer, stair_at_pointer, text_at_pointer, +}; +use crate::app::{DungeonApp, ManualDoorKind}; +use crate::layout::{ + self, blocked_room_cells, corridor_cells, room_index_at_cell, rooms_overlap, + shortest_path_cells, +}; +use crate::ui::AddTool; +use eframe::egui; + +pub fn handle_add_tool(app: &mut DungeonApp, response: &egui::Response, geometry: &GridGeometry) { + if app.settings.add_tool != AddTool::None && response.secondary_clicked() { + app.settings.add_tool = AddTool::None; + app.add_corridor_drag = None; + return; + } + + match app.settings.add_tool { + AddTool::Room => { + app.add_corridor_drag = None; + if response.clicked() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) + { + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + add_room_at_cell(app, cell); + } + } + AddTool::Corridor => { + if response.drag_started() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) + { + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + app.add_corridor_drag = Some(AddCorridorDrag { + start_cell: cell, + current_cell: cell, + }); + } + + if response.dragged() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) + && let Some(drag) = &mut app.add_corridor_drag + { + drag.current_cell = (grid_x.floor() as usize, grid_y.floor() as usize); + } + + if response.drag_stopped() { + if let Some(drag) = app.add_corridor_drag.take() { + add_corridor_between(app, drag.start_cell, drag.current_cell); + } + } + } + AddTool::Staircase => { + app.add_corridor_drag = None; + if response.clicked() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) + { + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + add_staircase_at_cell(app, cell); + } + } + AddTool::Archway | AddTool::Door | AddTool::LockedDoor | AddTool::SecretDoor => { + app.add_corridor_drag = None; + if response.clicked() + && let Some(pointer_pos) = response.interact_pointer_pos() + { + let kind = match app.settings.add_tool { + AddTool::Archway => ManualDoorKind::Archway, + AddTool::Door => ManualDoorKind::Regular, + AddTool::LockedDoor => ManualDoorKind::Locked, + AddTool::SecretDoor => ManualDoorKind::Secret, + _ => unreachable!(), + }; + add_door_at_pointer(app, pointer_pos, geometry, kind); + } + } + AddTool::Text => { + app.add_corridor_drag = None; + if response.clicked() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) + { + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + add_text_at_cell(app, cell); + } + } + AddTool::StartMarker + | AddTool::EndMarker + | AddTool::TrapMarker + | AddTool::MonsterMarker => { + app.add_corridor_drag = None; + if response.clicked() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) + { + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + let kind = match app.settings.add_tool { + AddTool::StartMarker => MarkerKind::Start, + AddTool::EndMarker => MarkerKind::End, + AddTool::TrapMarker => MarkerKind::Trap, + AddTool::MonsterMarker => MarkerKind::Monster, + _ => unreachable!(), + }; + add_marker_at_cell(app, cell, kind); + } + } + AddTool::None => { + app.add_corridor_drag = None; + } + } +} + +pub fn delete_at_pointer(app: &mut DungeonApp, pointer_pos: egui::Pos2, geometry: &GridGeometry) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + + if let Some(idx) = text_at_pointer(app, pointer_pos, geometry) { + app.push_undo_snapshot(); + let layout = &mut app.levels[app.settings.active_level_index]; + if idx < layout.text_labels.len() { + layout.text_labels.remove(idx); + } + return; + } + + if let Some(marker) = marker_at_pointer(app, pointer_pos, geometry) { + app.push_undo_snapshot(); + let layout = &mut app.levels[app.settings.active_level_index]; + match marker { + HoverMarker::Start(idx) => { + if idx < layout.start_markers.len() { + layout.start_markers.remove(idx); + } + } + HoverMarker::End(idx) => { + if idx < layout.end_markers.len() { + layout.end_markers.remove(idx); + } + } + HoverMarker::Trap(idx) => { + if idx < layout.trap_markers.len() { + layout.trap_markers.remove(idx); + } + } + HoverMarker::Monster(idx) => { + if idx < layout.monster_markers.len() { + layout.monster_markers.remove(idx); + } + } + } + return; + } + + if let Some(edge) = door_edge_at_pointer(app, pointer_pos, geometry) { + let target = normalized_edge(edge.0, edge.1); + let active_index = app.settings.active_level_index; + if let Some(idx) = app.levels[active_index].doors.iter().position(|d| { + door_edges_for(d, app.settings.cols, app.settings.rows) + .iter() + .any(|e| normalized_edge(e.0, e.1) == target) + }) { + app.push_undo_snapshot(); + let layout = &mut app.levels[app.settings.active_level_index]; + if !layout.doors[idx].manual { + for edge in door_edges_for(&layout.doors[idx], app.settings.cols, app.settings.rows) + { + app.suppressed_auto_door_edges.insert(edge); + } + layout.doors.remove(idx); + return; + } + + layout.doors.remove(idx); + return; + } + } + + if let Some(stair_idx) = stair_at_pointer(app, pointer_pos, geometry) { + let original_cell = { + let layout = &app.levels[app.settings.active_level_index]; + layout.stairs[stair_idx].cell + }; + + app.push_undo_snapshot(); + if app.settings.sync_stairs_across_levels { + for layout in &mut app.levels { + layout.stairs.retain(|s| s.cell != original_cell); + } + } else { + let current_idx = app.settings.active_level_index; + let level_indices = vec![ + Some(current_idx), + current_idx.checked_sub(1), + if current_idx + 1 < app.levels.len() { + Some(current_idx + 1) + } else { + None + }, + ]; + + for idx in level_indices.into_iter().flatten() { + app.levels[idx].stairs.retain(|s| s.cell != original_cell); + } + } + return; + } + + if let Some((room_idx, _, _)) = room_at_pointer(app, pointer_pos, geometry) { + app.push_undo_snapshot(); + delete_room(app, room_idx); + return; + } + + if let Some(corridor_drag) = corridor_drag_at_pointer(app, pointer_pos, geometry) { + let active_index = app.settings.active_level_index; + if corridor_drag.corridor_index < app.levels[active_index].corridors.len() { + app.push_undo_snapshot(); + let layout = &mut app.levels[app.settings.active_level_index]; + layout.corridors.remove(corridor_drag.corridor_index); + app.drag_state = None; + app.refresh_doors(); + } + } +} + +pub fn add_room_at_cell(app: &mut DungeonApp, cell: (usize, usize)) { + app.push_undo_snapshot(); + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + let width = app.settings.add_room_width.max(1); + let height = app.settings.add_room_height.max(1); + if cell.0 >= app.settings.cols || cell.1 >= app.settings.rows { + return; + } + let max_w = app.settings.cols.saturating_sub(cell.0); + let max_h = app.settings.rows.saturating_sub(cell.1); + let width = width.min(max_w); + let height = height.min(max_h); + if width == 0 || height == 0 { + return; + } + + let new_room = layout::Room { + x: cell.0, + y: cell.1, + width, + height, + }; + + if layout + .rooms + .iter() + .any(|room| rooms_overlap(&new_room, room)) + { + return; + } + + layout.rooms.push(new_room); + app.refresh_doors(); +} + +pub fn add_corridor_between(app: &mut DungeonApp, start: (usize, usize), end: (usize, usize)) { + app.push_undo_snapshot(); + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + if start == end { + return; + } + + let start_room = room_index_at_cell(&layout.rooms, start); + let end_room = room_index_at_cell(&layout.rooms, end); + let (Some(start_room), Some(end_room)) = (start_room, end_room) else { + return; + }; + + let blocked = blocked_room_cells(&layout.rooms, &[start_room, end_room]); + + let Some(path) = + shortest_path_cells(start, end, app.settings.cols, app.settings.rows, &blocked) + else { + return; + }; + + let next_id = layout + .corridors + .iter() + .map(|c| c.id) + .max() + .unwrap_or(0) + .wrapping_add(1); + let width = app.settings.add_corridor_width.max(1); + + layout.corridors.push(layout::Corridor { + id: next_id, + start_room_id: start_room, + end_room_id: end_room, + path, + width, + }); + app.refresh_doors(); +} + +pub fn add_text_at_cell(app: &mut DungeonApp, cell: (usize, usize)) { + app.push_undo_snapshot(); + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + if cell.0 >= app.settings.cols || cell.1 >= app.settings.rows { + return; + } + + let text = app.settings.add_text_value.trim().to_string(); + if text.is_empty() { + return; + } + + if let Some(existing) = layout + .text_labels + .iter_mut() + .find(|label| label.cell == cell) + { + existing.text = text; + return; + } + + layout.text_labels.push(layout::TextLabel { + cell, + text, + font_size: app.settings.add_text_font_size, + }); +} + +pub fn add_marker_at_cell(app: &mut DungeonApp, cell: (usize, usize), kind: MarkerKind) { + app.push_undo_snapshot(); + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + if cell.0 >= app.settings.cols || cell.1 >= app.settings.rows { + return; + } + + let size = app.settings.add_marker_size.max(1); + let origin = centered_marker_origin(cell, size, app.settings.cols, app.settings.rows); + let marker = layout::AreaMarker { cell: origin, size }; + + match kind { + MarkerKind::Start => layout.start_markers.push(marker), + MarkerKind::End => layout.end_markers.push(marker), + MarkerKind::Trap => layout.trap_markers.push(marker), + MarkerKind::Monster => layout.monster_markers.push(marker), + } +} + +pub fn add_staircase_at_cell(app: &mut DungeonApp, cell: (usize, usize)) { + app.push_undo_snapshot(); + if app.settings.active_level_index >= app.levels.len() { + return; + } + + if cell.0 >= app.settings.cols || cell.1 >= app.settings.rows { + return; + } + + let width = app.settings.add_stair_width.max(1); + let height = app.settings.add_stair_height.max(1); + let max_x = app.settings.cols.saturating_sub(width); + let max_y = app.settings.rows.saturating_sub(height); + let origin_x = cell.0.min(max_x); + let origin_y = cell.1.min(max_y); + + let stair = layout::Staircase { + cell: (origin_x, origin_y), + width, + height, + }; + + if app.settings.sync_stairs_across_levels { + for layout in &mut app.levels { + layout.stairs.push(stair.clone()); + } + } else { + let active_idx = app.settings.active_level_index; + let layout = &mut app.levels[active_idx]; + layout.stairs.push(stair.clone()); + + if active_idx + 1 < app.levels.len() { + let above_layout = &mut app.levels[active_idx + 1]; + above_layout.stairs.push(stair); + } else if active_idx > 0 { + let below_layout = &mut app.levels[active_idx - 1]; + below_layout.stairs.push(stair); + } + } +} + +pub fn delete_room(app: &mut DungeonApp, room_idx: usize) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + if room_idx >= layout.rooms.len() { + return; + } + layout.rooms.remove(room_idx); + layout + .corridors + .retain(|c| c.start_room_id != room_idx && c.end_room_id != room_idx); + for corridor in &mut layout.corridors { + if corridor.start_room_id > room_idx { + corridor.start_room_id -= 1; + } + if corridor.end_room_id > room_idx { + corridor.end_room_id -= 1; + } + } + app.drag_state = None; + app.refresh_doors(); +} + +pub fn add_door_at_pointer( + app: &mut DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, + kind: ManualDoorKind, +) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + + let Some(edge) = door_edge_at_pointer(app, pointer_pos, geometry) else { + return; + }; + + app.push_undo_snapshot(); + let layout = &mut app.levels[app.settings.active_level_index]; + + let target = normalized_edge(edge.0, edge.1); + if let Some(idx) = layout.doors.iter().position(|d| { + door_edges_for(d, app.settings.cols, app.settings.rows) + .iter() + .any(|e| normalized_edge(e.0, e.1) == target) + }) { + let edges = door_edges_for(&layout.doors[idx], app.settings.cols, app.settings.rows); + let mut spawned = Vec::new(); + for edge in edges { + if normalized_edge(edge.0, edge.1) == target { + continue; + } + if layout + .doors + .iter() + .any(|d| normalized_edge(d.from, d.to) == normalized_edge(edge.0, edge.1)) + { + continue; + } + spawned.push(layout::Door { + from: edge.0, + to: edge.1, + width: 1, + span_width: false, + locked: false, + archway: true, + secret: false, + manual: true, + }); + } + let door = &mut layout.doors[idx]; + door.locked = kind == ManualDoorKind::Locked; + door.archway = kind == ManualDoorKind::Archway; + door.secret = kind == ManualDoorKind::Secret; + door.width = 1; + door.span_width = false; + door.manual = true; + for edge in door_edges_for(door, app.settings.cols, app.settings.rows) { + app.suppressed_auto_door_edges.remove(&edge); + } + layout.doors.extend(spawned); + return; + } + + let corridor_cells = corridor_cells(layout, app.settings.cols, app.settings.rows); + let a_room = room_index_at_cell(&layout.rooms, edge.0); + let b_room = room_index_at_cell(&layout.rooms, edge.1); + let a_in_corridor = corridor_cells.contains(&edge.0); + let b_in_corridor = corridor_cells.contains(&edge.1); + + if !manual_door_edge_allowed(a_room, b_room, a_in_corridor, b_in_corridor) { + return; + } + + layout.doors.push(layout::Door { + from: edge.0, + to: edge.1, + width: 1, + span_width: false, + locked: kind == ManualDoorKind::Locked, + archway: kind == ManualDoorKind::Archway, + secret: kind == ManualDoorKind::Secret, + manual: true, + }); + app.suppressed_auto_door_edges.remove(&target); +} + +pub fn manual_door_edge_allowed( + a_room: Option, + b_room: Option, + a_in_corridor: bool, + b_in_corridor: bool, +) -> bool { + if matches!((a_room, b_room), (Some(a), Some(b)) if a == b) { + return false; + } + + let a_is_feature = a_room.is_some() || a_in_corridor; + let b_is_feature = b_room.is_some() || b_in_corridor; + a_is_feature || b_is_feature +} diff --git a/src/interact/drag.rs b/src/interact/drag.rs new file mode 100644 index 0000000..ced8e8f --- /dev/null +++ b/src/interact/drag.rs @@ -0,0 +1,322 @@ +/* + * Drag-and-drop interaction logic. + * Handles the movement of rooms, text labels, markers, corridors, and staircases. + */ + +use super::{ + CorridorDragState, DragState, GridGeometry, MarkerDragState, MarkerKind, RoomDragState, + StaircaseDragState, TextDragState, corridor_drag_at_pointer, marker_drag_at_pointer, + pointer_to_grid, reroute_path_through_cell, room_at_pointer, stair_drag_at_pointer, + text_at_pointer, +}; +use crate::app::DungeonApp; +use crate::layout::{blocked_room_cells, shortest_path_cells}; +use crate::ui::AddTool; +use eframe::egui; + +pub fn handle_drag( + app: &mut DungeonApp, + ctx: &egui::Context, + response: &egui::Response, + geometry: &GridGeometry, +) { + if !ctx.input(|i| i.pointer.primary_down()) && !response.drag_started() { + return; + } + if app.settings.add_tool != AddTool::None { + return; + } + if response.drag_started() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some(text_idx) = text_at_pointer(app, pointer_pos, geometry) + { + app.push_undo_snapshot(); + app.drag_state = Some(DragState::Text(TextDragState { text_idx })); + } else if response.drag_started() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some(marker_drag) = marker_drag_at_pointer(app, pointer_pos, geometry) + { + app.push_undo_snapshot(); + app.drag_state = Some(DragState::Marker(marker_drag)); + } else if response.drag_started() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some(stair_drag) = stair_drag_at_pointer(app, pointer_pos, geometry) + { + app.push_undo_snapshot(); + app.drag_state = Some(DragState::Staircase(stair_drag)); + } else if response.drag_started() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some(corridor_drag) = corridor_drag_at_pointer(app, pointer_pos, geometry) + { + app.push_undo_snapshot(); + app.drag_state = Some(DragState::Corridor(corridor_drag)); + } else if response.drag_started() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((room_idx, offset_x, offset_y)) = room_at_pointer(app, pointer_pos, geometry) + { + app.push_undo_snapshot(); + app.drag_state = Some(DragState::Room(RoomDragState { + room_idx, + offset_x, + offset_y, + })); + } + + if response.dragged() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some(drag_state) = app.drag_state.clone() + { + match drag_state { + DragState::Room(drag) => drag_room_to_pointer(app, drag, pointer_pos, geometry), + DragState::Text(drag) => drag_text_to_pointer(app, drag, pointer_pos, geometry), + DragState::Marker(drag) => drag_marker_to_pointer(app, drag, pointer_pos, geometry), + DragState::Corridor(drag) => drag_corridor_to_pointer(app, drag, pointer_pos, geometry), + DragState::Staircase(drag) => drag_stair_to_pointer(app, drag, pointer_pos, geometry), + } + } + + if response.drag_stopped() || !response.ctx.input(|i| i.pointer.primary_down()) { + app.drag_state = None; + } +} + +pub fn drag_room_to_pointer( + app: &mut DungeonApp, + drag: RoomDragState, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) { + let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { + return; + }; + + if app.settings.active_level_index >= app.levels.len() { + return; + } + + if drag.room_idx >= app.levels[app.settings.active_level_index].rooms.len() { + app.drag_state = None; + return; + } + + let (width, height, old_x, old_y) = { + let room = &app.levels[app.settings.active_level_index].rooms[drag.room_idx]; + (room.width, room.height, room.x, room.y) + }; + let max_x = app.settings.cols.saturating_sub(width); + let max_y = app.settings.rows.saturating_sub(height); + + let target_x = (grid_x - drag.offset_x).floor().max(0.0) as usize; + let target_y = (grid_y - drag.offset_y).floor().max(0.0) as usize; + let new_x = target_x.min(max_x); + let new_y = target_y.min(max_y); + + if new_x == old_x && new_y == old_y { + return; + } + + app.levels[app.settings.active_level_index].rooms[drag.room_idx].x = new_x; + app.levels[app.settings.active_level_index].rooms[drag.room_idx].y = new_y; + reroute_corridors_for_room(app, drag.room_idx); + app.refresh_doors(); +} + +pub fn drag_text_to_pointer( + app: &mut DungeonApp, + drag: TextDragState, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) { + let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { + return; + }; + + if app.settings.active_level_index >= app.levels.len() { + return; + } + + if drag.text_idx + >= app.levels[app.settings.active_level_index] + .text_labels + .len() + { + app.drag_state = None; + return; + } + + let target = ( + (grid_x.floor().max(0.0) as usize).min(app.settings.cols.saturating_sub(1)), + (grid_y.floor().max(0.0) as usize).min(app.settings.rows.saturating_sub(1)), + ); + + app.levels[app.settings.active_level_index].text_labels[drag.text_idx].cell = target; +} + +pub fn reroute_corridors_for_room(app: &mut DungeonApp, room_idx: usize) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + if layout.packed_rooms { + return; + } + + for corridor in &mut layout.corridors { + if corridor.start_room_id != room_idx && corridor.end_room_id != room_idx { + continue; + } + + let start = layout.rooms[corridor.start_room_id].center_cell(); + let end = layout.rooms[corridor.end_room_id].center_cell(); + let blocked = blocked_room_cells( + &layout.rooms, + &[corridor.start_room_id, corridor.end_room_id], + ); + if let Some(path) = + shortest_path_cells(start, end, app.settings.cols, app.settings.rows, &blocked) + { + corridor.path = path; + } + } +} + +pub fn drag_corridor_to_pointer( + app: &mut DungeonApp, + drag: CorridorDragState, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) { + let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { + return; + }; + + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + if drag.corridor_index >= layout.corridors.len() { + app.drag_state = None; + return; + } + let corridor = &layout.corridors[drag.corridor_index]; + + let target = ( + (grid_x.floor().max(0.0) as usize).min(app.settings.cols.saturating_sub(1)), + (grid_y.floor().max(0.0) as usize).min(app.settings.rows.saturating_sub(1)), + ); + + let Some(new_path) = reroute_path_through_cell( + &drag.original_path, + drag.original_cell, + target, + app.settings.cols, + app.settings.rows, + &blocked_room_cells( + &layout.rooms, + &[corridor.start_room_id, corridor.end_room_id], + ), + ) else { + return; + }; + + layout.corridors[drag.corridor_index].path = new_path; + app.refresh_doors(); +} + +pub fn drag_marker_to_pointer( + app: &mut DungeonApp, + drag: MarkerDragState, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) { + let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { + return; + }; + + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + let markers = match drag.kind { + MarkerKind::Start => &mut layout.start_markers, + MarkerKind::End => &mut layout.end_markers, + MarkerKind::Trap => &mut layout.trap_markers, + MarkerKind::Monster => &mut layout.monster_markers, + }; + let Some(marker) = markers.get_mut(drag.marker_idx) else { + app.drag_state = None; + return; + }; + + let max_x = app.settings.cols.saturating_sub(marker.size); + let max_y = app.settings.rows.saturating_sub(marker.size); + let target_x = (grid_x - drag.offset_x).floor().max(0.0) as usize; + let target_y = (grid_y - drag.offset_y).floor().max(0.0) as usize; + marker.cell = (target_x.min(max_x), target_y.min(max_y)); +} + +pub fn drag_stair_to_pointer( + app: &mut DungeonApp, + drag: StaircaseDragState, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) { + let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { + return; + }; + + if app.settings.active_level_index >= app.levels.len() { + return; + } + + let (stair_width, stair_height) = { + let layout = &app.levels[app.settings.active_level_index]; + let Some(stair) = layout.stairs.get(drag.stair_idx) else { + return; + }; + (stair.width, stair.height) + }; + + let max_x = app.settings.cols.saturating_sub(stair_width); + let max_y = app.settings.rows.saturating_sub(stair_height); + let target_x = (grid_x - drag.offset_x).floor().max(0.0) as usize; + let target_y = (grid_y - drag.offset_y).floor().max(0.0) as usize; + let new_cell = (target_x.min(max_x), target_y.min(max_y)); + + if app.settings.sync_stairs_across_levels { + for layout in &mut app.levels { + for stair in &mut layout.stairs { + if stair.cell == drag.original_cell { + stair.cell = new_cell; + } + } + } + } else { + let current_idx = app.settings.active_level_index; + let level_indices = vec![ + Some(current_idx), + current_idx.checked_sub(1), + if current_idx + 1 < app.levels.len() { + Some(current_idx + 1) + } else { + None + }, + ]; + + for idx in level_indices.into_iter().flatten() { + let layout = &mut app.levels[idx]; + for stair in &mut layout.stairs { + if stair.cell == drag.original_cell { + stair.cell = new_cell; + } + } + } + } + + if let Some(DragState::Staircase(ref mut d)) = app.drag_state { + d.original_cell = new_cell; + } +} diff --git a/src/interact/mod.rs b/src/interact/mod.rs new file mode 100644 index 0000000..093891c --- /dev/null +++ b/src/interact/mod.rs @@ -0,0 +1,1155 @@ +/* + * Main interaction module. + * Provides the GridGeometry structure and hit-testing utilities, + * orchestrating dragging, resizing, and manual layout adjustments. + */ + +use crate::app::DungeonApp; +use crate::layout; +use crate::ui::AddTool; +use eframe::egui; +use egui::{Color32, Stroke}; +use std::collections::HashSet; + +pub mod add_delete; +pub mod drag; +pub mod resize; +pub mod types; + +pub use add_delete::*; +pub use drag::*; +pub use resize::*; +pub use types::*; + +pub struct GridGeometry { + pub rect: egui::Rect, + pub cell_size: f32, + pub cols: usize, + pub rows: usize, +} + +pub fn draw_grid( + painter: &egui::Painter, + area: egui::Rect, + cols: usize, + rows: usize, +) -> GridGeometry { + let cols = cols.max(1); + let rows = rows.max(1); + + let cell_size = (area.width() / cols as f32).min(area.height() / rows as f32); + let grid_width = cell_size * cols as f32; + let grid_height = cell_size * rows as f32; + + let grid_rect = egui::Rect::from_min_size( + egui::pos2( + area.center().x - (grid_width * 0.5), + area.center().y - (grid_height * 0.5), + ), + egui::vec2(grid_width, grid_height), + ); + + let stroke = Stroke::new(1.0, Color32::from_gray(160)); + painter.rect_stroke(grid_rect, 0.0, stroke, egui::StrokeKind::Middle); + + for col in 1..cols { + let x = grid_rect.left() + (col as f32 * cell_size); + painter.line_segment( + [ + egui::pos2(x, grid_rect.top()), + egui::pos2(x, grid_rect.bottom()), + ], + stroke, + ); + } + + for row in 1..rows { + let y = grid_rect.top() + (row as f32 * cell_size); + painter.line_segment( + [ + egui::pos2(grid_rect.left(), y), + egui::pos2(grid_rect.right(), y), + ], + stroke, + ); + } + + GridGeometry { + rect: grid_rect, + cell_size, + cols, + rows, + } +} + +pub fn update_hover_targets(app: &mut DungeonApp, ctx: &egui::Context, geometry: &GridGeometry) { + if app.drag_state.is_some() || app.resize_state.is_some() || app.add_corridor_drag.is_some() { + return; + } + + let Some(pointer_pos) = ctx.pointer_hover_pos() else { + app.clear_hover_targets(); + return; + }; + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &app.levels[app.settings.active_level_index]; + + if let Some(edge) = door_edge_at_pointer(app, pointer_pos, geometry) { + let target = normalized_edge(edge.0, edge.1); + app.hover_door_idx = layout.doors.iter().position(|d| { + door_edges_for(d, app.settings.cols, app.settings.rows) + .iter() + .any(|e| normalized_edge(e.0, e.1) == target) + }); + if app.hover_door_idx.is_some() { + app.hover_room_idx = None; + app.hover_corridor_idx = None; + app.hover_text_idx = None; + app.hover_marker = None; + return; + } + } + + app.hover_text_idx = text_at_pointer(app, pointer_pos, geometry); + if app.hover_text_idx.is_some() { + app.hover_room_idx = None; + app.hover_corridor_idx = None; + app.hover_door_idx = None; + app.hover_marker = None; + return; + } + + app.hover_marker = marker_at_pointer(app, pointer_pos, geometry); + if app.hover_marker.is_some() { + app.hover_room_idx = None; + app.hover_corridor_idx = None; + app.hover_door_idx = None; + app.hover_stair_idx = None; + return; + } + + app.hover_stair_idx = stair_at_pointer(app, pointer_pos, geometry); + if app.hover_stair_idx.is_some() { + app.hover_room_idx = None; + app.hover_corridor_idx = None; + app.hover_door_idx = None; + app.hover_marker = None; + return; + } + + if let Some((room_idx, _, _)) = room_at_pointer(app, pointer_pos, geometry) { + app.hover_room_idx = Some(room_idx); + app.hover_corridor_idx = None; + app.hover_door_idx = None; + app.hover_marker = None; + } else { + app.hover_room_idx = None; + if let Some(corridor_drag) = corridor_drag_at_pointer(app, pointer_pos, geometry) { + app.hover_corridor_idx = Some(corridor_drag.corridor_index); + app.hover_door_idx = None; + app.hover_marker = None; + } else { + app.hover_corridor_idx = None; + app.hover_door_idx = None; + app.hover_marker = None; + } + } +} + +pub fn door_edge_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option<((usize, usize), (usize, usize))> { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let cell_x = grid_x.floor() as isize; + let cell_y = grid_y.floor() as isize; + if cell_x < 0 + || cell_y < 0 + || cell_x >= app.settings.cols as isize + || cell_y >= app.settings.rows as isize + { + return None; + } + + let fx = grid_x - grid_x.floor(); + let fy = grid_y - grid_y.floor(); + let left = fx; + let right = 1.0 - fx; + let top = fy; + let bottom = 1.0 - fy; + + let mut candidates = [ + (left, (cell_x - 1, cell_y), (cell_x, cell_y)), + (right, (cell_x + 1, cell_y), (cell_x, cell_y)), + (top, (cell_x, cell_y - 1), (cell_x, cell_y)), + (bottom, (cell_x, cell_y + 1), (cell_x, cell_y)), + ]; + candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + let (dist, a, b) = candidates[0]; + if dist > 0.2 { + return None; + } + + let (ax, ay) = a; + let (bx, by) = b; + if ax < 0 + || ay < 0 + || bx < 0 + || by < 0 + || ax >= app.settings.cols as isize + || bx >= app.settings.cols as isize + || ay >= app.settings.rows as isize + || by >= app.settings.rows as isize + { + return None; + } + + Some(((ax as usize, ay as usize), (bx as usize, by as usize))) +} + +pub fn room_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option<(usize, f32, f32)> { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + for (room_idx, room) in layout.rooms.iter().enumerate().rev() { + let room_x = room.x as f32; + let room_y = room.y as f32; + let room_right = room_x + room.width as f32; + let room_bottom = room_y + room.height as f32; + + if grid_x >= room_x && grid_x < room_right && grid_y >= room_y && grid_y < room_bottom { + return Some((room_idx, grid_x - room_x, grid_y - room_y)); + } + } + + None +} + +pub fn text_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let clicked = (grid_x.floor() as usize, grid_y.floor() as usize); + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + layout + .text_labels + .iter() + .position(|label| label.cell == clicked) +} + +pub fn marker_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let clicked = (grid_x.floor() as usize, grid_y.floor() as usize); + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + layout + .start_markers + .iter() + .enumerate() + .find(|(_, marker)| marker_contains_cell(Some(marker), clicked)) + .map(|(idx, _)| HoverMarker::Start(idx)) + .or_else(|| { + layout + .end_markers + .iter() + .enumerate() + .find(|(_, marker)| marker_contains_cell(Some(marker), clicked)) + .map(|(idx, _)| HoverMarker::End(idx)) + }) + .or_else(|| { + layout + .trap_markers + .iter() + .enumerate() + .find(|(_, marker)| marker_contains_cell(Some(marker), clicked)) + .map(|(idx, _)| HoverMarker::Trap(idx)) + }) + .or_else(|| { + layout + .monster_markers + .iter() + .enumerate() + .find(|(_, marker)| marker_contains_cell(Some(marker), clicked)) + .map(|(idx, _)| HoverMarker::Monster(idx)) + }) +} + +pub fn marker_drag_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + match marker_at_pointer(app, pointer_pos, geometry)? { + HoverMarker::Start(marker_idx) => { + let marker = layout.start_markers.get(marker_idx)?; + Some(MarkerDragState { + kind: MarkerKind::Start, + marker_idx, + offset_x: grid_x - marker.cell.0 as f32, + offset_y: grid_y - marker.cell.1 as f32, + }) + } + HoverMarker::End(marker_idx) => { + let marker = layout.end_markers.get(marker_idx)?; + Some(MarkerDragState { + kind: MarkerKind::End, + marker_idx, + offset_x: grid_x - marker.cell.0 as f32, + offset_y: grid_y - marker.cell.1 as f32, + }) + } + HoverMarker::Trap(marker_idx) => { + let marker = layout.trap_markers.get(marker_idx)?; + Some(MarkerDragState { + kind: MarkerKind::Trap, + marker_idx, + offset_x: grid_x - marker.cell.0 as f32, + offset_y: grid_y - marker.cell.1 as f32, + }) + } + HoverMarker::Monster(marker_idx) => { + let marker = layout.monster_markers.get(marker_idx)?; + Some(MarkerDragState { + kind: MarkerKind::Monster, + marker_idx, + offset_x: grid_x - marker.cell.0 as f32, + offset_y: grid_y - marker.cell.1 as f32, + }) + } + } +} + +pub fn stair_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let clicked = (grid_x.floor() as usize, grid_y.floor() as usize); + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + layout.stairs.iter().position(|stair| { + clicked.0 >= stair.cell.0 + && clicked.0 < stair.cell.0 + stair.width + && clicked.1 >= stair.cell.1 + && clicked.1 < stair.cell.1 + stair.height + }) +} + +pub fn stair_drag_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let stair_idx = stair_at_pointer(app, pointer_pos, geometry)?; + let layout = &app.levels[app.settings.active_level_index]; + let stair = layout.stairs.get(stair_idx)?; + + Some(StaircaseDragState { + stair_idx, + offset_x: grid_x - stair.cell.0 as f32, + offset_y: grid_y - stair.cell.1 as f32, + original_cell: stair.cell, + }) +} + +pub fn resize_room_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + if let Some((room_idx, _, _)) = room_at_pointer(app, pointer_pos, geometry) { + return Some(room_idx); + } + + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let clicked = ( + grid_x.floor().max(0.0) as usize, + grid_y.floor().max(0.0) as usize, + ); + + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + for (room_idx, room) in layout.rooms.iter().enumerate().rev() { + if resize_hit_cells(room, room_idx, &layout.rooms, geometry.cols, geometry.rows) + .contains(&clicked) + { + return Some(room_idx); + } + } + + None +} + +pub fn corridor_drag_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let clicked = (grid_x.floor() as usize, grid_y.floor() as usize); + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + let corridor_index = layout + .corridors + .iter() + .position(|corridor| corridor.path.contains(&clicked))?; + + Some(CorridorDragState { + corridor_index, + original_path: layout.corridors[corridor_index].path.clone(), + original_cell: clicked, + }) +} + +pub fn pointer_to_grid(pointer_pos: egui::Pos2, geometry: &GridGeometry) -> Option<(f32, f32)> { + if !geometry.rect.contains(pointer_pos) { + return None; + } + + let x = (pointer_pos.x - geometry.rect.left()) / geometry.cell_size; + let y = (pointer_pos.y - geometry.rect.top()) / geometry.cell_size; + Some((x, y)) +} + +pub fn reroute_path_through_cell( + original_path: &[(usize, usize)], + original_cell: (usize, usize), + target_cell: (usize, usize), + cols: usize, + rows: usize, + blocked_room_cells_set: &HashSet<(usize, usize)>, +) -> Option> { + let (&start, &end) = (original_path.first()?, original_path.last()?); + if start == end { + return None; + } + + let mut blocked = blocked_room_cells_set.clone(); + blocked.insert(original_cell); + blocked.remove(&start); + blocked.remove(&end); + blocked.remove(&target_cell); + + let first_leg = crate::layout::shortest_path_cells(start, target_cell, cols, rows, &blocked)?; + + let mut blocked_second = blocked.clone(); + for &cell in first_leg + .iter() + .skip(1) + .take(first_leg.len().saturating_sub(2)) + { + if cell != target_cell && cell != end { + blocked_second.insert(cell); + } + } + + let second_leg = + crate::layout::shortest_path_cells(target_cell, end, cols, rows, &blocked_second).or_else( + || crate::layout::shortest_path_cells(target_cell, end, cols, rows, &blocked), + )?; + + let mut full_path = first_leg; + for cell in second_leg.into_iter().skip(1) { + if full_path.last().copied() != Some(cell) { + full_path.push(cell); + } + } + + simplify_path_loops(&mut full_path); + + if full_path.len() < 2 || !full_path.contains(&target_cell) { + return None; + } + + if original_cell != start && original_cell != end && full_path.contains(&original_cell) { + return None; + } + + Some(full_path) +} + +pub fn simplify_path_loops(path: &mut Vec<(usize, usize)>) { + let mut out = Vec::new(); + for &cell in path.iter() { + if let Some(pos) = out.iter().position(|&c| c == cell) { + out.truncate(pos + 1); + } else { + out.push(cell); + } + } + *path = out; +} + +pub fn resize_hit_cells( + room: &layout::Room, + room_idx: usize, + rooms: &[layout::Room], + cols: usize, + rows: usize, +) -> HashSet<(usize, usize)> { + let mut cells = HashSet::new(); + let corners = [ + (room.x as isize, room.y as isize, -1isize, -1isize), + ( + (room.x + room.width - 1) as isize, + room.y as isize, + 1isize, + -1isize, + ), + ( + room.x as isize, + (room.y + room.height - 1) as isize, + -1isize, + 1isize, + ), + ( + (room.x + room.width - 1) as isize, + (room.y + room.height - 1) as isize, + 1isize, + 1isize, + ), + ]; + + for (cx, cy, ox, oy) in corners { + let candidates = [ + (cx, cy), + (cx - ox, cy), + (cx, cy - oy), + (cx - ox, cy - oy), + (cx + ox, cy), + (cx, cy + oy), + (cx + ox, cy + oy), + ]; + + for (x, y) in candidates { + if x < 0 || y < 0 || x >= cols as isize || y >= rows as isize { + continue; + } + + let cell = (x as usize, y as usize); + let in_other_room = rooms.iter().enumerate().any(|(idx, other_room)| { + idx != room_idx + && cell.0 >= other_room.x + && cell.0 < other_room.x + other_room.width + && cell.1 >= other_room.y + && cell.1 < other_room.y + other_room.height + }); + if !in_other_room { + cells.insert(cell); + } + } + } + + cells +} + +pub fn door_edges_for( + door: &layout::Door, + cols: usize, + rows: usize, +) -> Vec<((usize, usize), (usize, usize))> { + let mut edges = Vec::new(); + if door.from.0.abs_diff(door.to.0) + door.from.1.abs_diff(door.to.1) != 1 { + return edges; + } + + let width = door_render_width(door).max(1) as isize; + let min_offset = -((width - 1) / 2); + let max_offset = width / 2; + + if door.from.0 != door.to.0 { + let y = door.from.1 as isize; + for dy in min_offset..=max_offset { + let ny = y + dy; + if ny < 0 || ny >= rows as isize { + continue; + } + let a = (door.from.0, ny as usize); + let b = (door.to.0, ny as usize); + edges.push(normalized_edge(a, b)); + } + } else { + let x = door.from.0 as isize; + for dx in min_offset..=max_offset { + let nx = x + dx; + if nx < 0 || nx >= cols as isize { + continue; + } + let a = (nx as usize, door.from.1); + let b = (nx as usize, door.to.1); + edges.push(normalized_edge(a, b)); + } + } + + edges +} + +pub fn door_render_width(door: &layout::Door) -> usize { + if door.span_width { + door.width.max(1) + } else { + 1 + } +} + +pub fn window_render_width(window: &layout::Window) -> usize { + if window.span_width { + window.width.max(1) + } else { + 1 + } +} + +pub fn normalized_edge(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) { + if a <= b { (a, b) } else { (b, a) } +} + +pub fn cell_center(geometry: &GridGeometry, col: usize, row: usize) -> egui::Pos2 { + egui::pos2( + geometry.rect.left() + (col as f32 + 0.5) * geometry.cell_size, + geometry.rect.top() + (row as f32 + 0.5) * geometry.cell_size, + ) +} + +pub fn cell_rect(geometry: &GridGeometry, col: usize, row: usize) -> egui::Rect { + let left = geometry.rect.left() + col as f32 * geometry.cell_size; + let top = geometry.rect.top() + row as f32 * geometry.cell_size; + egui::Rect::from_min_size( + egui::pos2(left, top), + egui::vec2(geometry.cell_size, geometry.cell_size), + ) +} + +pub fn marker_rect(marker: &layout::AreaMarker, geometry: &GridGeometry) -> egui::Rect { + let left = geometry.rect.left() + marker.cell.0 as f32 * geometry.cell_size; + let top = geometry.rect.top() + marker.cell.1 as f32 * geometry.cell_size; + egui::Rect::from_min_size( + egui::pos2(left, top), + egui::vec2( + marker.size as f32 * geometry.cell_size, + marker.size as f32 * geometry.cell_size, + ), + ) +} + +pub fn marker_contains_cell(marker: Option<&layout::AreaMarker>, cell: (usize, usize)) -> bool { + let Some(marker) = marker else { + return false; + }; + cell.0 >= marker.cell.0 + && cell.0 < marker.cell.0 + marker.size + && cell.1 >= marker.cell.1 + && cell.1 < marker.cell.1 + marker.size +} + +pub fn centered_marker_origin( + cell: (usize, usize), + size: usize, + cols: usize, + rows: usize, +) -> (usize, usize) { + let size = size.clamp(1, 10).min(cols.max(1)).min(rows.max(1)); + let half = size / 2; + let max_x = cols.saturating_sub(size); + let max_y = rows.saturating_sub(size); + ( + cell.0.saturating_sub(half).min(max_x), + cell.1.saturating_sub(half).min(max_y), + ) +} + +pub fn draw_add_overlay(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) { + if app.settings.add_tool != AddTool::Corridor { + return; + } + let Some(drag) = &app.add_corridor_drag else { + return; + }; + + let start = cell_center(geometry, drag.start_cell.0, drag.start_cell.1); + let end = cell_center(geometry, drag.current_cell.0, drag.current_cell.1); + let stroke = Stroke::new(2.0, Color32::WHITE); + painter.circle_filled(start, 3.0, Color32::WHITE); + crate::rendering::draw_dashed_line(painter, start, end, stroke, 8.0, 6.0); +} + +pub fn draw_add_tool_ghost(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) { + let tool = app.settings.add_tool; + if tool == AddTool::None || tool == AddTool::Corridor { + return; + } + + let Some(pointer_pos) = painter.ctx().pointer_hover_pos() else { + return; + }; + let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { + return; + }; + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + + let ghost_alpha = 0.35; + + match tool { + AddTool::Room => { + let width = app.settings.add_room_width.max(1); + let height = app.settings.add_room_height.max(1); + let max_x = app.settings.cols.saturating_sub(cell.0); + let max_y = app.settings.rows.saturating_sub(cell.1); + let w = width.min(max_x).max(1); + let h = height.min(max_y).max(1); + + let left = geometry.rect.left() + cell.0 as f32 * geometry.cell_size; + let top = geometry.rect.top() + cell.1 as f32 * geometry.cell_size; + let right = left + w as f32 * geometry.cell_size; + let bottom = top + h as f32 * geometry.cell_size; + let rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); + + let fill = Color32::from_rgb(70, 120, 160).gamma_multiply(ghost_alpha); + painter.rect_filled(rect, 4.0, fill); + painter.rect_stroke( + rect, + 4.0, + Stroke::new( + 1.5, + Color32::from_rgb(150, 200, 240).gamma_multiply(ghost_alpha + 0.3), + ), + egui::StrokeKind::Middle, + ); + } + AddTool::Text => { + let text = app.settings.add_text_value.trim(); + if text.is_empty() { + return; + } + let font_size = app.settings.add_text_font_size.max(8); + let rect = crate::rendering::text_label_rect( + geometry, + &layout::TextLabel { + cell, + text: text.to_string(), + font_size, + }, + ); + + let fill = Color32::from_rgb(50, 50, 70).gamma_multiply(ghost_alpha); + painter.rect_filled(rect.expand(4.0), 4.0, fill); + painter.rect_stroke( + rect.expand(4.0), + 4.0, + Stroke::new( + 1.0, + Color32::from_rgb(180, 180, 220).gamma_multiply(ghost_alpha + 0.2), + ), + egui::StrokeKind::Middle, + ); + + let color = Color32::WHITE.gamma_multiply(ghost_alpha + 0.1); + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(font_size as f32), + color, + ); + } + AddTool::StartMarker + | AddTool::EndMarker + | AddTool::TrapMarker + | AddTool::MonsterMarker => { + let size = app.settings.add_marker_size.max(1); + let origin = centered_marker_origin(cell, size, app.settings.cols, app.settings.rows); + + let rect = marker_rect(&layout::AreaMarker { cell: origin, size }, geometry); + + let base_color = match tool { + AddTool::StartMarker => Color32::from_rgb(60, 220, 200), + AddTool::EndMarker => Color32::from_rgb(240, 90, 90), + AddTool::TrapMarker => Color32::from_rgb(150, 80, 230), + AddTool::MonsterMarker => Color32::from_rgb(200, 50, 50), + _ => Color32::WHITE, + }; + + let fill = base_color.gamma_multiply(ghost_alpha); + painter.rect_filled(rect, 4.0, fill); + painter.rect_stroke( + rect, + 4.0, + Stroke::new( + 1.5, + Color32::from_rgb(255, 255, 255).gamma_multiply(ghost_alpha + 0.2), + ), + egui::StrokeKind::Middle, + ); + + let label = match tool { + AddTool::StartMarker => "S", + AddTool::EndMarker => "E", + AddTool::TrapMarker => "T", + AddTool::MonsterMarker => "M", + _ => "?", + }; + let text_color = Color32::WHITE.gamma_multiply(ghost_alpha + 0.15); + let font_size = (geometry.cell_size * size as f32 * 0.5).clamp(14.0, 72.0); + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(font_size), + text_color, + ); + } + AddTool::Staircase => { + let width = app.settings.add_stair_width.max(1); + let height = app.settings.add_stair_height.max(1); + let max_x = app.settings.cols.saturating_sub(cell.0); + let max_y = app.settings.rows.saturating_sub(cell.1); + let w = width.min(max_x).max(1); + let h = height.min(max_y).max(1); + + let left = geometry.rect.left() + cell.0 as f32 * geometry.cell_size; + let top = geometry.rect.top() + cell.1 as f32 * geometry.cell_size; + let right = left + w as f32 * geometry.cell_size; + let bottom = top + h as f32 * geometry.cell_size; + let rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); + + let fill = Color32::from_rgb(200, 150, 50).gamma_multiply(ghost_alpha); + painter.rect_filled(rect, 4.0, fill); + painter.rect_stroke( + rect, + 4.0, + Stroke::new( + 1.5, + Color32::from_rgb(220, 180, 80).gamma_multiply(ghost_alpha + 0.2), + ), + egui::StrokeKind::Middle, + ); + + let steps = (h as f32 * 2.0).round() as usize; + let step_height = (rect.height() / (steps as f32)).max(1.0); + let line_color = Color32::from_rgb(150, 100, 30).gamma_multiply(ghost_alpha + 0.15); + for i in 0..steps { + let y = rect.top() + (i as f32 * step_height) + step_height * 0.5; + painter.line_segment( + [ + egui::pos2(rect.left() + 2.0, y), + egui::pos2(rect.right() - 2.0, y), + ], + Stroke::new(1.5, line_color), + ); + } + } + AddTool::Archway | AddTool::Door | AddTool::LockedDoor | AddTool::SecretDoor => { + if let Some((edge_a, edge_b)) = door_edge_at_pointer(app, pointer_pos, geometry) { + let door_width_cells = 1; + let style = match tool { + AddTool::Archway => crate::rendering::DoorLineStyle::Dotted, + AddTool::Door => crate::rendering::DoorLineStyle::LongDash, + AddTool::LockedDoor => crate::rendering::DoorLineStyle::ShortDash, + AddTool::SecretDoor => crate::rendering::DoorLineStyle::DashDotDot, + _ => crate::rendering::DoorLineStyle::Solid, + }; + let color = match tool { + AddTool::Archway => Color32::from_rgb(230, 140, 60), + AddTool::Door => Color32::from_rgb(80, 200, 120), + AddTool::LockedDoor => Color32::from_rgb(220, 70, 70), + AddTool::SecretDoor => Color32::from_rgb(170, 80, 170), + _ => Color32::WHITE, + }; + let ghost_color = color.gamma_multiply(ghost_alpha); + crate::rendering::draw_door_line( + painter, + geometry, + edge_a, + edge_b, + door_width_cells, + Stroke::new((geometry.cell_size / 5.0).max(1.0), ghost_color), + style, + ); + } + } + AddTool::None | AddTool::Corridor => {} + } +} + +pub fn draw_resize_overlay(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &app.levels[app.settings.active_level_index]; + + if let Some(state) = &app.resize_state { + match state { + ResizeState::Text(state) => { + if let Some(label) = layout.text_labels.get(state.text_idx) { + let rect = crate::rendering::text_label_rect(geometry, label).expand(3.0); + painter.rect_stroke( + rect, + 0.0, + Stroke::new(2.0, Color32::from_gray(180)), + egui::StrokeKind::Middle, + ); + } + } + ResizeState::Room(state) => { + if let Some(room) = layout.rooms.get(state.room_idx) { + draw_room_resize_visuals(painter, geometry, room, true); + } + } + ResizeState::Staircase(state) => { + if let Some(stair) = layout.stairs.get(state.stair_idx) { + draw_staircase_resize_visuals(painter, geometry, stair, true); + } + } + } + return; + } + + if let Some(text_idx) = app.hover_text_idx { + if let Some(label) = layout.text_labels.get(text_idx) { + let rect = crate::rendering::text_label_rect(geometry, label).expand(3.0); + painter.rect_stroke( + rect, + 0.0, + Stroke::new(2.0, Color32::from_gray(180)), + egui::StrokeKind::Middle, + ); + } + } else if let Some(room_idx) = app.hover_room_idx { + if let Some(room) = layout.rooms.get(room_idx) { + draw_room_resize_visuals(painter, geometry, room, false); + } + } else if let Some(stair_idx) = app.hover_stair_idx { + if let Some(stair) = layout.stairs.get(stair_idx) { + draw_staircase_resize_visuals(painter, geometry, stair, false); + } + } +} + +pub fn draw_room_resize_visuals( + painter: &egui::Painter, + geometry: &GridGeometry, + room: &layout::Room, + show_handles: bool, +) { + let color = Color32::from_gray(170); + let stroke = Stroke::new(1.5, color); + + let left = geometry.rect.left() + room.x as f32 * geometry.cell_size; + let top = geometry.rect.top() + room.y as f32 * geometry.cell_size; + let right = left + room.width as f32 * geometry.cell_size; + let bottom = top + room.height as f32 * geometry.cell_size; + + let tl = egui::pos2(left, top); + let tr = egui::pos2(right, top); + let br = egui::pos2(right, bottom); + let bl = egui::pos2(left, bottom); + + crate::rendering::draw_dashed_line(painter, tl, tr, stroke, 6.0, 6.0); + crate::rendering::draw_dashed_line(painter, tr, br, stroke, 6.0, 6.0); + crate::rendering::draw_dashed_line(painter, br, bl, stroke, 6.0, 6.0); + crate::rendering::draw_dashed_line(painter, bl, tl, stroke, 6.0, 6.0); + + let font = egui::FontId::proportional(12.0); + let center_top = egui::pos2((left + right) * 0.5, top - 10.0); + let center_left = egui::pos2(left - 12.0, (top + bottom) * 0.5); + painter.text( + center_top, + egui::Align2::CENTER_CENTER, + "<->", + font.clone(), + color, + ); + painter.text(center_left, egui::Align2::CENTER_CENTER, "^v", font, color); + + if show_handles { + draw_resize_handle(painter, tl, ResizeCorner::TopLeft, color); + draw_resize_handle(painter, tr, ResizeCorner::TopRight, color); + draw_resize_handle(painter, br, ResizeCorner::BottomRight, color); + draw_resize_handle(painter, bl, ResizeCorner::BottomLeft, color); + } +} + +pub fn draw_staircase_resize_visuals( + painter: &egui::Painter, + geometry: &GridGeometry, + stair: &layout::Staircase, + show_handles: bool, +) { + let color = Color32::from_gray(170); + let stroke = Stroke::new(1.5, color); + + let left = geometry.rect.left() + stair.cell.0 as f32 * geometry.cell_size; + let top = geometry.rect.top() + stair.cell.1 as f32 * geometry.cell_size; + let right = left + stair.width as f32 * geometry.cell_size; + let bottom = top + stair.height as f32 * geometry.cell_size; + + let tl = egui::pos2(left, top); + let tr = egui::pos2(right, top); + let br = egui::pos2(right, bottom); + let bl = egui::pos2(left, bottom); + + crate::rendering::draw_dashed_line(painter, tl, tr, stroke, 6.0, 6.0); + crate::rendering::draw_dashed_line(painter, tr, br, stroke, 6.0, 6.0); + crate::rendering::draw_dashed_line(painter, br, bl, stroke, 6.0, 6.0); + crate::rendering::draw_dashed_line(painter, bl, tl, stroke, 6.0, 6.0); + + if show_handles { + draw_resize_handle(painter, tl, ResizeCorner::TopLeft, color); + draw_resize_handle(painter, tr, ResizeCorner::TopRight, color); + draw_resize_handle(painter, br, ResizeCorner::BottomRight, color); + draw_resize_handle(painter, bl, ResizeCorner::BottomLeft, color); + } +} + +pub fn draw_corridor_hover_overlay( + app: &DungeonApp, + painter: &egui::Painter, + geometry: &GridGeometry, +) { + let Some(idx) = app.hover_corridor_idx else { + return; + }; + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &app.levels[app.settings.active_level_index]; + + let Some(corridor) = layout.corridors.get(idx) else { + return; + }; + + let color = Color32::from_gray(170); + let stroke = Stroke::new(2.0, color); + for pair in corridor.path.windows(2) { + let a = cell_center(geometry, pair[0].0, pair[0].1); + let b = cell_center(geometry, pair[1].0, pair[1].1); + crate::rendering::draw_dashed_line(painter, a, b, stroke, 6.0, 6.0); + } +} + +pub fn draw_door_hover_overlay(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) { + let Some(idx) = app.hover_door_idx else { + return; + }; + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &app.levels[app.settings.active_level_index]; + + let Some(door) = layout.doors.get(idx) else { + return; + }; + let color = Color32::from_gray(200); + let stroke = Stroke::new(2.0, color); + crate::rendering::draw_door_line( + painter, + geometry, + door.from, + door.to, + door_render_width(door), + stroke, + crate::rendering::DoorLineStyle::LongDash, + ); +} + +pub fn draw_resize_handle( + painter: &egui::Painter, + corner: egui::Pos2, + which: ResizeCorner, + color: Color32, +) { + let size = 10.0; + let half = size * 0.5; + let rect = egui::Rect::from_center_size(corner, egui::vec2(size, size)); + painter.rect_filled(rect, 2.0, Color32::from_gray(30)); + painter.rect_stroke(rect, 2.0, Stroke::new(1.0, color), egui::StrokeKind::Middle); + + let center = corner; + let stroke = Stroke::new(1.0, color); + match which { + ResizeCorner::TopLeft => { + painter.line_segment( + [center, egui::pos2(center.x - half + 2.0, center.y)], + stroke, + ); + painter.line_segment( + [center, egui::pos2(center.x, center.y - half + 2.0)], + stroke, + ); + } + ResizeCorner::TopRight => { + painter.line_segment( + [center, egui::pos2(center.x + half - 2.0, center.y)], + stroke, + ); + painter.line_segment( + [center, egui::pos2(center.x, center.y - half + 2.0)], + stroke, + ); + } + ResizeCorner::BottomLeft => { + painter.line_segment( + [center, egui::pos2(center.x - half + 2.0, center.y)], + stroke, + ); + painter.line_segment( + [center, egui::pos2(center.x, center.y + half - 2.0)], + stroke, + ); + } + ResizeCorner::BottomRight => { + painter.line_segment( + [center, egui::pos2(center.x + half - 2.0, center.y)], + stroke, + ); + painter.line_segment( + [center, egui::pos2(center.x, center.y + half - 2.0)], + stroke, + ); + } + } +} diff --git a/src/interact/resize.rs b/src/interact/resize.rs new file mode 100644 index 0000000..6359516 --- /dev/null +++ b/src/interact/resize.rs @@ -0,0 +1,320 @@ +/* + * Resize interaction logic. + * Handles resizing of rooms, text labels, and staircases using secondary click + drag. + */ + +use super::{ + GridGeometry, ResizeCorner, ResizeState, RoomResizeState, StaircaseResizeState, + TextResizeState, pointer_to_grid, reroute_corridors_for_room, resize_room_at_pointer, + stair_at_pointer, text_at_pointer, +}; +use crate::app::DungeonApp; +use crate::layout::{rects_overlap, rooms_overlap}; +use crate::ui::AddTool; +use eframe::egui; + +pub fn handle_resize( + app: &mut DungeonApp, + ctx: &egui::Context, + response: &egui::Response, + geometry: &GridGeometry, +) -> bool { + if app.settings.add_tool != AddTool::None { + return false; + } + + if ctx.input(|i| i.pointer.secondary_pressed()) && app.resize_state.is_none() { + if let Some(pointer_pos) = response.interact_pointer_pos() { + if let Some(text_idx) = text_at_pointer(app, pointer_pos, geometry) { + if let Some(state) = start_text_resize(app, text_idx) { + app.push_undo_snapshot(); + app.resize_state = Some(ResizeState::Text(state)); + return true; + } + } else if let Some(stair_idx) = stair_at_pointer(app, pointer_pos, geometry) { + if let Some(state) = start_staircase_resize(app, stair_idx, pointer_pos, geometry) { + app.push_undo_snapshot(); + app.resize_state = Some(ResizeState::Staircase(state)); + return true; + } + } else if let Some(room_idx) = resize_room_at_pointer(app, pointer_pos, geometry) { + if let Some(state) = start_resize(app, room_idx, pointer_pos, geometry) { + app.push_undo_snapshot(); + app.resize_state = Some(ResizeState::Room(state)); + return true; + } + } + } + } + + if let Some(state) = app.resize_state.clone() { + if response.dragged_by(egui::PointerButton::Secondary) + && ctx.input(|i| i.pointer.secondary_down()) + && let Some(pointer_pos) = response.interact_pointer_pos() + { + if let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) { + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + match state { + ResizeState::Room(state) => resize_room(app, &state, cell), + ResizeState::Text(state) => resize_text(app, &state, cell), + ResizeState::Staircase(state) => resize_staircase(app, &state, cell), + } + } + } + + if ctx.input(|i| i.pointer.button_released(egui::PointerButton::Secondary)) { + app.resize_state = None; + } + return true; + } + + false +} + +pub fn start_text_resize(app: &DungeonApp, text_idx: usize) -> Option { + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + let label = layout.text_labels.get(text_idx)?; + Some(TextResizeState { + text_idx, + origin_cell: label.cell, + original_font_size: label.font_size, + }) +} + +pub fn start_resize( + app: &DungeonApp, + room_idx: usize, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + let original_room = layout.rooms.get(room_idx)?.clone(); + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let center_x = original_room.x as f32 + original_room.width as f32 * 0.5; + let center_y = original_room.y as f32 + original_room.height as f32 * 0.5; + let pointer_x = grid_x; + let pointer_y = grid_y; + + let resize_corner = match (pointer_x >= center_x, pointer_y >= center_y) { + (false, false) => ResizeCorner::TopLeft, + (true, false) => ResizeCorner::TopRight, + (false, true) => ResizeCorner::BottomLeft, + (true, true) => ResizeCorner::BottomRight, + }; + + Some(RoomResizeState { + room_idx, + original_room, + resize_corner, + }) +} + +pub fn start_staircase_resize( + app: &DungeonApp, + stair_idx: usize, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + let original_stair = layout.stairs.get(stair_idx)?.clone(); + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let center_x = original_stair.cell.0 as f32 + original_stair.width as f32 * 0.5; + let center_y = original_stair.cell.1 as f32 + original_stair.height as f32 * 0.5; + let pointer_x = grid_x; + let pointer_y = grid_y; + + let resize_corner = match (pointer_x >= center_x, pointer_y >= center_y) { + (false, false) => ResizeCorner::TopLeft, + (true, false) => ResizeCorner::TopRight, + (false, true) => ResizeCorner::BottomLeft, + (true, true) => ResizeCorner::BottomRight, + }; + + Some(StaircaseResizeState { + stair_idx, + original_stair, + resize_corner, + }) +} + +pub fn resize_room(app: &mut DungeonApp, state: &RoomResizeState, target_cell: (usize, usize)) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + let cols = app.settings.cols.max(1); + let rows = app.settings.rows.max(1); + + let tx = target_cell.0.min(cols - 1) as isize; + let ty = target_cell.1.min(rows - 1) as isize; + let original = &state.original_room; + + let left = original.x as isize; + let top = original.y as isize; + let right = (original.x + original.width - 1) as isize; + let bottom = (original.y + original.height - 1) as isize; + let (x0, x1, y0, y1) = match state.resize_corner { + ResizeCorner::TopLeft => (tx.min(right), right, ty.min(bottom), bottom), + ResizeCorner::TopRight => (left, tx.max(left), ty.min(bottom), bottom), + ResizeCorner::BottomLeft => (tx.min(right), right, top, ty.max(top)), + ResizeCorner::BottomRight => (left, tx.max(left), top, ty.max(top)), + }; + + let new_room = crate::layout::Room { + x: x0 as usize, + y: y0 as usize, + width: (x1 - x0 + 1) as usize, + height: (y1 - y0 + 1) as usize, + }; + + if new_room.width == 0 || new_room.height == 0 { + return; + } + + if new_room.x + new_room.width > cols || new_room.y + new_room.height > rows { + return; + } + + if layout + .rooms + .iter() + .enumerate() + .any(|(idx, room)| idx != state.room_idx && rooms_overlap(&new_room, room)) + { + return; + } + + if let Some(room) = layout.rooms.get_mut(state.room_idx) { + room.x = new_room.x; + room.y = new_room.y; + room.width = new_room.width; + room.height = new_room.height; + reroute_corridors_for_room(app, state.room_idx); + app.refresh_doors(); + } +} + +pub fn resize_text(app: &mut DungeonApp, state: &TextResizeState, target_cell: (usize, usize)) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + let Some(label) = layout.text_labels.get_mut(state.text_idx) else { + app.resize_state = None; + return; + }; + + let dx = target_cell.0 as isize - state.origin_cell.0 as isize; + let dy = target_cell.1 as isize - state.origin_cell.1 as isize; + let delta_cells = if dx.abs() >= dy.abs() { dx } else { -dy }; + let new_size = (state.original_font_size as isize + (delta_cells * 4)).clamp(8, 96); + label.font_size = new_size as u16; +} + +pub fn resize_staircase( + app: &mut DungeonApp, + state: &StaircaseResizeState, + target_cell: (usize, usize), +) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + + let cols = app.settings.cols.max(1); + let rows = app.settings.rows.max(1); + + let tx = target_cell.0.min(cols - 1) as isize; + let ty = target_cell.1.min(rows - 1) as isize; + let original = &state.original_stair; + + let left = original.cell.0 as isize; + let top = original.cell.1 as isize; + let right = (original.cell.0 + original.width - 1) as isize; + let bottom = (original.cell.1 + original.height - 1) as isize; + + let (x0, x1, y0, y1) = match state.resize_corner { + ResizeCorner::TopLeft => (tx.min(right), right, ty.min(bottom), bottom), + ResizeCorner::TopRight => (left, tx.max(left), ty.min(bottom), bottom), + ResizeCorner::BottomLeft => (tx.min(right), right, top, ty.max(top)), + ResizeCorner::BottomRight => (left, tx.max(left), top, ty.max(top)), + }; + + let new_stair_cell = (x0 as usize, y0 as usize); + let new_stair_width = (x1 - x0 + 1) as usize; + let new_stair_height = (y1 - y0 + 1) as usize; + + if new_stair_width == 0 || new_stair_height == 0 { + return; + } + + let active_layout = &app.levels[app.settings.active_level_index]; + if active_layout.stairs.iter().enumerate().any(|(idx, stair)| { + idx != state.stair_idx + && rects_overlap( + new_stair_cell.0, + new_stair_cell.1, + new_stair_width, + new_stair_height, + stair.cell.0, + stair.cell.1, + stair.width, + stair.height, + ) + }) { + return; + } + + if app.settings.sync_stairs_across_levels { + for layout in &mut app.levels { + for stair in &mut layout.stairs { + if stair.cell == state.original_stair.cell { + stair.cell = new_stair_cell; + stair.width = new_stair_width; + stair.height = new_stair_height; + } + } + } + } else { + let current_idx = app.settings.active_level_index; + let level_indices = vec![ + Some(current_idx), + current_idx.checked_sub(1), + if current_idx + 1 < app.levels.len() { + Some(current_idx + 1) + } else { + None + }, + ]; + + for idx in level_indices.into_iter().flatten() { + let layout = &mut app.levels[idx]; + for stair in &mut layout.stairs { + if stair.cell == state.original_stair.cell { + stair.cell = new_stair_cell; + stair.width = new_stair_width; + stair.height = new_stair_height; + } + } + } + } + + if let Some(ResizeState::Staircase(ref mut s)) = app.resize_state { + s.original_stair.cell = new_stair_cell; + s.original_stair.width = new_stair_width; + s.original_stair.height = new_stair_height; + } +} diff --git a/src/interact/types.rs b/src/interact/types.rs new file mode 100644 index 0000000..3c7b830 --- /dev/null +++ b/src/interact/types.rs @@ -0,0 +1,108 @@ +/* + * Core interaction state types and enums. + * Defines the various states for dragging and resizing dungeon elements. + */ + +use crate::layout; + +#[derive(Debug, Clone)] +pub struct RoomDragState { + pub room_idx: usize, + pub offset_x: f32, + pub offset_y: f32, +} + +#[derive(Debug, Clone)] +pub struct TextDragState { + pub text_idx: usize, +} + +#[derive(Debug, Clone)] +pub struct MarkerDragState { + pub kind: MarkerKind, + pub marker_idx: usize, + pub offset_x: f32, + pub offset_y: f32, +} + +#[derive(Debug, Clone)] +pub struct CorridorDragState { + pub corridor_index: usize, + pub original_path: Vec<(usize, usize)>, + pub original_cell: (usize, usize), +} + +#[derive(Debug, Clone)] +pub struct AddCorridorDrag { + pub start_cell: (usize, usize), + pub current_cell: (usize, usize), +} + +#[derive(Debug, Clone, Copy)] +pub enum ResizeCorner { + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MarkerKind { + Start, + End, + Trap, + Monster, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HoverMarker { + Start(usize), + End(usize), + Trap(usize), + Monster(usize), +} + +#[derive(Debug, Clone)] +pub struct RoomResizeState { + pub room_idx: usize, + pub original_room: layout::Room, + pub resize_corner: ResizeCorner, +} + +#[derive(Debug, Clone)] +pub enum DragState { + Room(RoomDragState), + Text(TextDragState), + Marker(MarkerDragState), + Corridor(CorridorDragState), + Staircase(StaircaseDragState), +} + +#[derive(Debug, Clone)] +pub struct StaircaseDragState { + pub stair_idx: usize, + pub offset_x: f32, + pub offset_y: f32, + pub original_cell: (usize, usize), +} + +#[derive(Debug, Clone)] +pub struct TextResizeState { + pub text_idx: usize, + pub origin_cell: (usize, usize), + pub original_font_size: u16, +} + +#[derive(Debug, Clone)] +pub struct StaircaseResizeState { + pub stair_idx: usize, + pub original_stair: layout::Staircase, + pub resize_corner: ResizeCorner, +} + +#[derive(Debug, Clone)] +pub enum ResizeState { + Room(RoomResizeState), + Text(TextResizeState), + Staircase(StaircaseResizeState), +} diff --git a/src/layout/generation.rs b/src/layout/generation.rs deleted file mode 100644 index 501fb22..0000000 --- a/src/layout/generation.rs +++ /dev/null @@ -1,1783 +0,0 @@ -/* - * High-level procedural generation algorithms. - * Contains the logic for building room connection graphs, - * placing packed rooms, and populating random markers, traps, - * monsters, and staircase transitions between levels. - */ - -use super::types::{ - AreaMarker, Corridor, Door, DoorSettings, DungeonLayout, Room, Staircase, Window, - WindowSettings, WindowSide, -}; -use super::utils::{ - SimpleRng, blocked_room_cells, corridor_cells, manhattan_distance, noisy_path, - normalized_cell_edge, overlaps_with_padding, room_index_at_cell, rooms_overlap, rooms_touch, - shared_boundary_edges, shared_opening_width, shortest_path_cells, shuffle_indices, -}; -use crate::seed; -use crate::ui::UiSettings; -use std::collections::{HashSet, VecDeque}; - -// Build a layout based on settings and a derived deterministic seed. -pub fn generate_layout( - cols: usize, - rows: usize, - target_room_count: usize, - seed: u64, - min_room_size: usize, - max_room_size: usize, - square_rooms_only: bool, - min_corridor_width: usize, - max_corridor_width: usize, - corridor_randomness_percent: usize, - dead_end_room_percent: usize, - pack_rooms_without_corridors: bool, - door_settings: DoorSettings, - window_settings: WindowSettings, -) -> DungeonLayout { - let layout_salt = ((cols as u64) << 48) - ^ ((rows as u64) << 32) - ^ ((target_room_count as u64) << 16) - ^ (min_room_size as u64) - ^ ((max_room_size as u64) << 8) - ^ ((min_corridor_width as u64) << 24) - ^ ((max_corridor_width as u64) << 28) - ^ ((corridor_randomness_percent as u64) << 56) - ^ ((dead_end_room_percent as u64) << 40); - let base_seed = seed::derive_seed(seed, layout_salt); - let mut room_rng = SimpleRng::new(seed::derive_seed(base_seed, 1)); - let mut graph_rng = SimpleRng::new(seed::derive_seed(base_seed, 2)); - let mut path_rng = SimpleRng::new(seed::derive_seed(base_seed, 3)); - let mut corridor_rng = SimpleRng::new(seed::derive_seed(base_seed, 4)); - - let mut rooms = Vec::new(); - let corridors = Vec::new(); - - if cols < 2 || rows < 2 || target_room_count == 0 { - return DungeonLayout::empty(pack_rooms_without_corridors); - } - - let mut min_size = min_room_size.max(2); - let mut max_size = max_room_size.max(min_size); - - if square_rooms_only { - let hard_max = cols.min(rows); - 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)); - } - - if min_size == 0 || max_size < min_size { - return DungeonLayout::empty(pack_rooms_without_corridors); - } - - let room_sizes = generate_room_sizes( - target_room_count, - cols, - rows, - min_size, - max_size, - square_rooms_only, - &mut room_rng, - ); - let room_count = room_sizes.len(); - let centers = random_centers(room_count, cols, rows, &mut graph_rng); - let randomness = (corridor_randomness_percent.min(100) as f32) / 100.0; - let target_dead_end_rooms = ((room_count * dead_end_room_percent.min(50)) + 50) / 100; - let room_edges = - build_room_connection_edges(¢ers, randomness, target_dead_end_rooms, &mut graph_rng); - - if pack_rooms_without_corridors { - rooms = place_packed_rooms(&room_sizes, &room_edges, cols, rows, &mut room_rng); - let mut layout = DungeonLayout::from_generated_parts(rooms, corridors, true); - apply_doors(&mut layout, seed, door_settings, cols, rows); - apply_windows(&mut layout, seed, window_settings, cols, rows); - return layout; - } - - let max_attempts = target_room_count.saturating_mul(40).max(50); - - for _ in 0..max_attempts { - if rooms.len() >= target_room_count { - break; - } - - let (width, height) = if square_rooms_only { - let side = room_rng.range_inclusive(min_size, max_size.min(cols.min(rows))); - (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; - } - ( - room_rng.range_inclusive(min_size, width_max), - room_rng.range_inclusive(min_size, height_max), - ) - }; - - if width > cols || height > rows { - continue; - } - - let x = room_rng.range_inclusive(0, cols - width); - let y = room_rng.range_inclusive(0, rows - height); - let candidate = Room { - x, - y, - width, - height, - }; - - if rooms - .iter() - .all(|existing| !overlaps_with_padding(&candidate, existing, 1)) - { - rooms.push(candidate); - } - } - - if rooms.len() < 2 { - return DungeonLayout::from_generated_parts(rooms, corridors, false); - } - - let mut min_width = min_corridor_width.max(1); - let max_grid_width = cols.max(1).min(rows.max(1)); - let max_width = max_corridor_width.max(min_width).min(max_grid_width); - min_width = min_width.min(max_width); - 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 graph_rng); - - let mut corridors = Vec::new(); - let mut next_corridor_id = 1u64; - for (start_room_id, end_room_id) in room_edges { - let start = centers[start_room_id]; - let end = centers[end_room_id]; - let blocked = blocked_room_cells(&rooms, &[start_room_id, end_room_id]); - - let path = if randomness <= 0.001 { - shortest_path_cells(start, end, cols, rows, &blocked) - } else { - Some(noisy_path( - start, - end, - cols, - rows, - randomness, - &blocked, - &mut path_rng, - )) - } - .or_else(|| shortest_path_cells(start, end, cols, rows, &blocked)); - - if let Some(path) = path - && path.len() >= 2 - { - corridors.push(Corridor { - id: next_corridor_id, - start_room_id, - end_room_id, - path, - width: corridor_rng.range_inclusive(min_width, max_width), - }); - next_corridor_id = next_corridor_id.wrapping_add(1); - } - } - - let mut layout = DungeonLayout::from_generated_parts(rooms, corridors, false); - apply_doors(&mut layout, seed, door_settings, cols, rows); - apply_windows(&mut layout, seed, window_settings, cols, rows); - layout -} - -// Populate layout doors based on corridor edges and door settings. -pub fn apply_doors( - layout: &mut DungeonLayout, - seed: u64, - settings: DoorSettings, - cols: usize, - rows: usize, -) { - layout.doors.clear(); - - if layout.packed_rooms { - apply_packed_room_doors(layout, seed, settings); - return; - } - - let mut rng = SimpleRng::new(seed::derive_seed(seed, 0xD005_5EED_u64)); - let base = (settings.frequency_percent.min(100) as f32) / 100.0; - let room_hall = (settings.room_hallway_percent.min(100) as f32) / 100.0; - let locked = (settings.locked_percent.min(100) as f32) / 100.0; - - let corridor_cells = corridor_cells(layout, cols, rows); - let mut seen_edges = HashSet::new(); - let door_chance = base * room_hall; - for corridor in &layout.corridors { - if corridor.path.len() < 2 { - continue; - } - - let start_room = &layout.rooms[corridor.start_room_id]; - let end_room = &layout.rooms[corridor.end_room_id]; - - if let Some(edge) = room_exit_edge(&corridor.path, start_room, true) { - if seen_edges.insert(edge) { - let place_door = door_chance > 0.0 && rng.next_f32() <= door_chance; - layout.doors.push(Door { - from: edge.0, - to: edge.1, - width: corridor.width.max(1), - span_width: true, - locked: place_door && rng.next_f32() <= locked, - archway: !place_door, - secret: false, - manual: false, - }); - } - } - - if let Some(edge) = room_exit_edge(&corridor.path, end_room, false) { - if seen_edges.insert(edge) { - let place_door = door_chance > 0.0 && rng.next_f32() <= door_chance; - layout.doors.push(Door { - from: edge.0, - to: edge.1, - width: corridor.width.max(1), - span_width: true, - locked: place_door && rng.next_f32() <= locked, - archway: !place_door, - secret: false, - manual: false, - }); - } - } - - for (room_idx, room) in layout.rooms.iter().enumerate() { - if room_idx == corridor.start_room_id || room_idx == corridor.end_room_id { - continue; - } - - for edge in room_collision_edges(&corridor.path, room) { - if seen_edges.insert(edge) { - let place_door = door_chance > 0.0 && rng.next_f32() <= door_chance; - layout.doors.push(Door { - from: edge.0, - to: edge.1, - width: corridor.width.max(1), - span_width: true, - locked: place_door && rng.next_f32() <= locked, - archway: !place_door, - secret: false, - manual: false, - }); - } - } - } - } - - if settings.allow_middle_corridor_doors { - for &(x, y) in &corridor_cells { - let right = (x + 1, y); - let bottom = (x, y + 1); - if x + 1 < cols && corridor_cells.contains(&right) { - let edge = normalized_cell_edge((x, y), right); - if seen_edges.insert(edge) && base > 0.0 && rng.next_f32() <= base { - layout.doors.push(Door { - from: edge.0, - to: edge.1, - width: 1, - span_width: false, - locked: rng.next_f32() <= locked, - archway: false, - secret: false, - manual: false, - }); - } - } - if y + 1 < rows && corridor_cells.contains(&bottom) { - let edge = normalized_cell_edge((x, y), bottom); - if seen_edges.insert(edge) && base > 0.0 && rng.next_f32() <= base { - layout.doors.push(Door { - from: edge.0, - to: edge.1, - width: 1, - span_width: false, - locked: rng.next_f32() <= locked, - archway: false, - secret: false, - manual: false, - }); - } - } - } - } - - apply_secret_doors(layout, seed, settings); -} - -fn apply_packed_room_doors(layout: &mut DungeonLayout, seed: u64, settings: DoorSettings) { - let mut rng = SimpleRng::new(seed::derive_seed(seed, 0xD005_5EED_u64)); - let door_chance = ((settings.frequency_percent.min(100) as f32) / 100.0) - * ((settings.room_hallway_percent.min(100) as f32) / 100.0); - let locked = (settings.locked_percent.min(100) as f32) / 100.0; - - for (a_idx, b_idx, shared_edges) in shared_room_boundaries(&layout.rooms) { - let edge_idx = if shared_edges.len() <= 1 { - 0 - } else { - rng.range_inclusive(0, shared_edges.len() - 1) - }; - let edge = shared_edges[edge_idx]; - let place_door = door_chance > 0.0 && rng.next_f32() <= door_chance; - - let width = shared_opening_width( - &layout.rooms[a_idx], - &layout.rooms[b_idx], - shared_edges.len(), - 1, - ); - layout.doors.push(Door { - from: edge.0, - to: edge.1, - width, - span_width: width > 1, - locked: place_door && rng.next_f32() <= locked, - archway: !place_door, - secret: false, - manual: false, - }); - } - - apply_secret_doors(layout, seed, settings); -} - -fn apply_secret_doors(layout: &mut DungeonLayout, seed: u64, settings: DoorSettings) { - let secret_percent = settings.secret_percent.min(100); - let chance = (secret_percent as f32) / 100.0; - if chance <= 0.0 || layout.rooms.is_empty() || layout.doors.is_empty() { - return; - } - - let mut rng = SimpleRng::new(seed::derive_seed(seed, 0x5EC2_E700_u64)); - let room_door_map = room_to_door_indices(layout); - let eligible_secret_doors = (0..layout.doors.len()) - .filter(|&door_idx| { - let door = &layout.doors[door_idx]; - !door.archway - && !door.locked - && door_is_secret_eligible_for_rooms(layout, &room_door_map, door_idx) - }) - .collect::>(); - - for (door_idx, door) in layout.doors.iter_mut().enumerate() { - if !eligible_secret_doors.contains(&door_idx) { - continue; - } - - if rng.next_f32() <= chance { - door.locked = false; - door.archway = false; - door.secret = true; - } - } -} - -fn door_is_secret_eligible_for_rooms( - layout: &DungeonLayout, - room_door_map: &[Vec], - door_idx: usize, -) -> bool { - for door_indices in room_door_map { - if !door_indices.contains(&door_idx) { - continue; - } - - for &other_door_idx in door_indices { - if other_door_idx == door_idx { - continue; - } - - if !door_directly_connects_rooms(layout, other_door_idx) { - return false; - } - } - } - - true -} - -fn room_to_door_indices(layout: &DungeonLayout) -> Vec> { - let mut room_door_map = vec![Vec::new(); layout.rooms.len()]; - for (door_idx, door) in layout.doors.iter().enumerate() { - for (room_idx, room) in layout.rooms.iter().enumerate() { - if door_touches_room(door, room) { - room_door_map[room_idx].push(door_idx); - } - } - } - room_door_map -} - -fn secret_room_ids(layout: &DungeonLayout) -> HashSet { - let mut secret_rooms = HashSet::new(); - for door in layout.doors.iter().filter(|door| door.secret) { - for (room_idx, room) in layout.rooms.iter().enumerate() { - if door_touches_room(door, room) { - secret_rooms.insert(room_idx); - } - } - } - secret_rooms -} - -fn door_touches_room(door: &Door, room: &Room) -> bool { - cell_in_room(door.from, room) || cell_in_room(door.to, room) -} - -fn door_directly_connects_rooms(layout: &DungeonLayout, door_idx: usize) -> bool { - let Some(door) = layout.doors.get(door_idx) else { - return false; - }; - - let touching_rooms = layout - .rooms - .iter() - .enumerate() - .filter_map(|(room_idx, room)| door_touches_room(door, room).then_some(room_idx)) - .collect::>(); - - touching_rooms.len() >= 2 -} - -fn cell_in_room(cell: (usize, usize), room: &Room) -> bool { - cell.0 >= room.x - && cell.0 < room.x + room.width - && cell.1 >= room.y - && cell.1 < room.y + room.height -} - -pub fn apply_windows( - layout: &mut DungeonLayout, - seed: u64, - settings: WindowSettings, - cols: usize, - rows: usize, -) { - layout.windows.clear(); - - if !settings.enabled || cols == 0 || rows == 0 { - return; - } - - let mut min_width = settings.min_width.clamp(1, 5); - let max_width = settings.max_width.clamp(min_width, 5); - min_width = min_width.min(max_width); - - let mut rng = SimpleRng::new(seed::derive_seed(seed, 0xA117_0055_u64)); - let base = (settings.frequency_percent.min(100) as f32) / 100.0; - let internal_ratio = (settings.room_hallway_percent.min(100) as f32) / 100.0; - - for segment in collect_window_segments(layout, cols, rows, settings.allow_internal_windows) { - let chance = if segment.internal { - if !settings.allow_internal_windows { - continue; - } - base * internal_ratio - } else { - base * (1.0 - internal_ratio) - }; - - if chance <= 0.0 || rng.next_f32() > chance { - continue; - } - - let max_segment_width = max_width.min(segment.cells.len()).max(1); - let width = rng.range_inclusive(min_width.min(max_segment_width), max_segment_width); - let cell = select_segment_cell(&segment.cells, width, &mut rng); - layout.windows.push(Window { - cell, - side: segment.side, - width, - span_width: width > 1, - }); - } -} - -#[derive(Debug, Clone)] -struct WindowSegment { - cells: Vec<(usize, usize)>, - side: WindowSide, - internal: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WindowTarget { - Exterior, - Corridor, - Room(usize), -} - -fn collect_window_segments( - layout: &DungeonLayout, - cols: usize, - rows: usize, - allow_internal_windows: bool, -) -> Vec { - let corridor_cells = corridor_cells(layout, cols, rows); - let secret_rooms = secret_room_ids(layout); - let mut segments = Vec::new(); - - for (room_idx, room) in layout.rooms.iter().enumerate() { - if secret_rooms.contains(&room_idx) { - continue; - } - collect_room_side_segments( - room_idx, - room, - layout, - &corridor_cells, - cols, - rows, - allow_internal_windows, - WindowSide::Left, - &mut segments, - ); - collect_room_side_segments( - room_idx, - room, - layout, - &corridor_cells, - cols, - rows, - allow_internal_windows, - WindowSide::Right, - &mut segments, - ); - collect_room_side_segments( - room_idx, - room, - layout, - &corridor_cells, - cols, - rows, - allow_internal_windows, - WindowSide::Top, - &mut segments, - ); - collect_room_side_segments( - room_idx, - room, - layout, - &corridor_cells, - cols, - rows, - allow_internal_windows, - WindowSide::Bottom, - &mut segments, - ); - } - - segments -} - -fn collect_room_side_segments( - room_idx: usize, - room: &Room, - layout: &DungeonLayout, - corridor_cells: &HashSet<(usize, usize)>, - cols: usize, - rows: usize, - allow_internal_windows: bool, - side: WindowSide, - segments: &mut Vec, -) { - let cells: Vec<(usize, usize)> = match side { - WindowSide::Left => (room.y..(room.y + room.height)) - .map(|y| (room.x, y)) - .collect(), - WindowSide::Right => (room.y..(room.y + room.height)) - .map(|y| (room.x + room.width - 1, y)) - .collect(), - WindowSide::Top => (room.x..(room.x + room.width)) - .map(|x| (x, room.y)) - .collect(), - WindowSide::Bottom => (room.x..(room.x + room.width)) - .map(|x| (x, room.y + room.height - 1)) - .collect(), - }; - - let mut current_target = None; - let mut current_cells = Vec::new(); - - for cell in cells { - let Some(neighbor) = outward_neighbor(cell, side, cols, rows) else { - push_window_segment(current_target, side, &mut current_cells, segments); - current_target = Some(WindowTarget::Exterior); - current_cells.push(cell); - continue; - }; - - let target = if let Some(other_room_idx) = room_index_at_cell(&layout.rooms, neighbor) { - if other_room_idx == room_idx || !allow_internal_windows || other_room_idx < room_idx { - None - } else { - Some(WindowTarget::Room(other_room_idx)) - } - } else if corridor_cells.contains(&neighbor) { - allow_internal_windows.then_some(WindowTarget::Corridor) - } else { - Some(WindowTarget::Exterior) - }; - - if current_target != target { - push_window_segment(current_target, side, &mut current_cells, segments); - current_target = target; - } - - if target.is_some() { - current_cells.push(cell); - } - } - - push_window_segment(current_target, side, &mut current_cells, segments); -} - -fn push_window_segment( - target: Option, - side: WindowSide, - cells: &mut Vec<(usize, usize)>, - segments: &mut Vec, -) { - let Some(target) = target else { - cells.clear(); - return; - }; - if cells.is_empty() { - return; - } - - segments.push(WindowSegment { - cells: std::mem::take(cells), - side, - internal: !matches!(target, WindowTarget::Exterior), - }); -} - -fn outward_neighbor( - cell: (usize, usize), - side: WindowSide, - cols: usize, - rows: usize, -) -> Option<(usize, usize)> { - match side { - WindowSide::Left => cell.0.checked_sub(1).map(|x| (x, cell.1)), - WindowSide::Right => (cell.0 + 1 < cols).then_some((cell.0 + 1, cell.1)), - WindowSide::Top => cell.1.checked_sub(1).map(|y| (cell.0, y)), - WindowSide::Bottom => (cell.1 + 1 < rows).then_some((cell.0, cell.1 + 1)), - } -} - -fn select_segment_cell( - cells: &[(usize, usize)], - width: usize, - rng: &mut SimpleRng, -) -> (usize, usize) { - let width = width.max(1); - let min_offset = -((width as isize - 1) / 2); - let max_offset = width as isize / 2; - let low = (-min_offset) as usize; - let high = cells.len().saturating_sub(1 + max_offset.max(0) as usize); - let idx = if low <= high { - rng.range_inclusive(low, high) - } else { - cells.len() / 2 - }; - cells[idx] -} - -// Find the edge where a corridor path exits a room. -fn room_exit_edge( - path: &[(usize, usize)], - room: &Room, - from_start: bool, -) -> Option<((usize, usize), (usize, usize))> { - let in_room = |cell: (usize, usize)| { - cell.0 >= room.x - && cell.0 < room.x + room.width - && cell.1 >= room.y - && cell.1 < room.y + room.height - }; - - if from_start { - for pair in path.windows(2) { - if in_room(pair[0]) && !in_room(pair[1]) { - return Some(normalized_cell_edge(pair[0], pair[1])); - } - } - } else { - for pair in path.windows(2).rev() { - if in_room(pair[1]) && !in_room(pair[0]) { - return Some(normalized_cell_edge(pair[0], pair[1])); - } - } - } - - None -} - -// Find every edge where a corridor path crosses into or out of a room. -fn room_collision_edges( - path: &[(usize, usize)], - room: &Room, -) -> Vec<((usize, usize), (usize, usize))> { - let in_room = |cell: (usize, usize)| { - cell.0 >= room.x - && cell.0 < room.x + room.width - && cell.1 >= room.y - && cell.1 < room.y + room.height - }; - - let mut edges = Vec::new(); - let mut seen = HashSet::new(); - for pair in path.windows(2) { - let a_in_room = in_room(pair[0]); - let b_in_room = in_room(pair[1]); - if a_in_room == b_in_room { - continue; - } - - let edge = normalized_cell_edge(pair[0], pair[1]); - if seen.insert(edge) { - edges.push(edge); - } - } - - edges -} - -fn shared_room_boundaries( - rooms: &[Room], -) -> Vec<(usize, usize, Vec<((usize, usize), (usize, usize))>)> { - let mut boundaries = Vec::new(); - for a_idx in 0..rooms.len() { - for b_idx in (a_idx + 1)..rooms.len() { - let shared_edges = shared_boundary_edges(&rooms[a_idx], &rooms[b_idx]); - if !shared_edges.is_empty() { - boundaries.push((a_idx, b_idx, shared_edges)); - } - } - } - boundaries -} - -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; -const TRAP_ROOM_STREAM_BASE: u64 = 17_000; -const TRAP_CORRIDOR_STREAM_BASE: u64 = 18_000; -const MONSTER_ROOM_STREAM_BASE: u64 = 19_000; -const MONSTER_CORRIDOR_STREAM_BASE: u64 = 20_000; -const STAIR_MARKER_STREAM_BASE: u64 = 21_000; - -// Populate generated start and end markers. -pub fn populate_random_markers(mut layout: DungeonLayout, settings: &UiSettings) -> DungeonLayout { - layout.start_markers.clear(); - layout.end_markers.clear(); - layout.trap_markers.clear(); - layout.monster_markers.clear(); - - if layout.rooms.is_empty() { - return layout; - } - - let start_count = random_range_inclusive( - settings.min_start_marker_count, - settings.max_start_marker_count, - 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, END_COUNT_STREAM), - ); - let pair_count = start_count.min(end_count); - - let mut available_start_rooms: Vec = (0..layout.rooms.len()).collect(); - let mut available_end_rooms: Vec = (0..layout.rooms.len()).collect(); - - for pair_idx in 0..pair_count { - let (start_room_idx, end_room_idx) = - farthest_room_pair(&layout.rooms, &available_start_rooms, &available_end_rooms); - let start_room = &layout.rooms[start_room_idx]; - let end_room = &layout.rooms[end_room_idx]; - layout.start_markers.push(marker_in_room( - start_room, - settings.min_start_marker_size, - settings.max_start_marker_size, - 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, 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); - } - - assign_extra_markers( - &mut layout.start_markers, - &layout.rooms, - &mut available_start_rooms, - pair_count, - start_count, - settings.min_start_marker_size, - settings.max_start_marker_size, - settings.seed, - EXTRA_START_ROOM_STREAM_BASE, - EXTRA_START_MARKER_STREAM_BASE, - ); - assign_extra_markers( - &mut layout.end_markers, - &layout.rooms, - &mut available_end_rooms, - pair_count, - end_count, - settings.min_end_marker_size, - settings.max_end_marker_size, - settings.seed, - EXTRA_END_ROOM_STREAM_BASE, - EXTRA_END_MARKER_STREAM_BASE, - ); - - layout = populate_random_traps(layout, settings); - layout = populate_random_monsters(layout, settings); - layout -} - -// Pick a deterministic inclusive random value. -fn random_range_inclusive(min: usize, max: usize, seed_value: u64) -> usize { - let min = min.max(1); - let max = max.max(min); - let span = max - min + 1; - min + (seed_value as usize % span) -} - -// Fit a marker inside a room. -fn marker_in_room(room: &Room, min_size: usize, max_size: usize, seed_value: u64) -> AreaMarker { - let room_limit = room.width.min(room.height).max(1); - let min_size = min_size.clamp(1, 10).min(room_limit); - let max_size = max_size.clamp(min_size, 10).min(room_limit); - let size = random_range_inclusive(min_size, max_size, seed_value.rotate_left(7)); - let x_span = room.width.saturating_sub(size); - let y_span = room.height.saturating_sub(size); - let x = room.x - + if x_span == 0 { - 0 - } else { - seed_value as usize % (x_span + 1) - }; - let y = room.y - + if y_span == 0 { - 0 - } else { - seed_value.rotate_left(19) as usize % (y_span + 1) - }; - AreaMarker { cell: (x, y), size } -} - -fn assign_extra_markers( - markers: &mut Vec, - rooms: &[Room], - available_rooms: &mut Vec, - start_idx: usize, - end_idx: usize, - min_size: usize, - max_size: usize, - seed_base: u64, - room_stream_base: u64, - marker_stream_base: u64, -) { - for idx in start_idx..end_idx { - let room_idx = pick_room_index( - available_rooms, - rooms.len(), - seed::derive_seed(seed_base, room_stream_base + idx as u64), - ); - markers.push(marker_in_room( - &rooms[room_idx], - min_size, - max_size, - seed::derive_seed(seed_base, marker_stream_base + idx as u64), - )); - consume_room(available_rooms, room_idx); - } -} - -// Match each start and end pair. -fn farthest_room_pair( - rooms: &[Room], - available_start_rooms: &[usize], - available_end_rooms: &[usize], -) -> (usize, usize) { - let start_rooms = if available_start_rooms.is_empty() { - (0..rooms.len()).collect::>() - } else { - available_start_rooms.to_vec() - }; - let end_rooms = if available_end_rooms.is_empty() { - (0..rooms.len()).collect::>() - } else { - available_end_rooms.to_vec() - }; - - let mut best = (start_rooms[0], end_rooms[0]); - let mut best_dist = 0usize; - for &start_idx in &start_rooms { - for &end_idx in &end_rooms { - if rooms.len() > 1 && start_idx == end_idx { - continue; - } - let dist = room_distance_sq(&rooms[start_idx], &rooms[end_idx]); - if dist > best_dist { - best = (start_idx, end_idx); - best_dist = dist; - } - } - } - best -} - -fn room_distance_sq(a: &Room, b: &Room) -> usize { - let ac = a.center_cell(); - let bc = b.center_cell(); - let dx = ac.0.abs_diff(bc.0); - let dy = ac.1.abs_diff(bc.1); - dx * dx + dy * dy -} - -// Remove a consumed room. -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); - } -} - -// Pick a room index from the pool. -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()] - } else { - seed_value as usize % room_count.max(1) - } -} - -// Populate random trap markers. -pub fn populate_random_traps(mut layout: DungeonLayout, settings: &UiSettings) -> DungeonLayout { - layout.trap_markers = populate_random_area_markers( - &layout, - settings.seed, - settings.trap_frequency_percent, - settings.min_traps_per_area, - settings.max_traps_per_area, - TRAP_ROOM_STREAM_BASE, - TRAP_CORRIDOR_STREAM_BASE, - ); - layout -} - -// Populate random monster markers. -pub fn populate_random_monsters(mut layout: DungeonLayout, settings: &UiSettings) -> DungeonLayout { - layout.monster_markers = populate_random_area_markers( - &layout, - settings.seed, - settings.monster_frequency_percent, - settings.min_monsters_per_area, - settings.max_monsters_per_area, - MONSTER_ROOM_STREAM_BASE, - MONSTER_CORRIDOR_STREAM_BASE, - ); - layout -} - -// Populate one-cell area markers. -fn populate_random_area_markers( - layout: &DungeonLayout, - master_seed: u64, - frequency_percent: usize, - min_count: usize, - max_count: usize, - room_stream_base: u64, - corridor_stream_base: u64, -) -> Vec { - if frequency_percent == 0 { - return Vec::new(); - } - - let mut markers = Vec::new(); - - for (idx, room) in layout.rooms.iter().enumerate() { - let area_seed = seed::derive_seed(master_seed, room_stream_base + idx as u64); - append_room_markers( - &mut markers, - room, - area_seed, - frequency_percent, - min_count, - max_count, - ); - } - - for (idx, corridor) in layout.corridors.iter().enumerate() { - let area_seed = seed::derive_seed(master_seed, corridor_stream_base + idx as u64); - append_corridor_markers( - &mut markers, - corridor, - area_seed, - frequency_percent, - min_count, - max_count, - ); - } - - markers -} - -// Add room markers after a frequency roll. -fn append_room_markers( - markers: &mut Vec, - room: &Room, - area_seed: u64, - frequency_percent: usize, - min_count: usize, - max_count: usize, -) { - if !passes_frequency_roll(area_seed, frequency_percent) { - return; - } - - let count = random_range_inclusive(min_count, max_count, area_seed.rotate_left(13)); - for offset in 0..count { - markers.push(marker_in_room( - room, - 1, - 1, - area_seed.wrapping_add(offset as u64).rotate_right(7), - )); - } -} - -// Add corridor markers after a frequency roll. -fn append_corridor_markers( - markers: &mut Vec, - corridor: &Corridor, - area_seed: u64, - frequency_percent: usize, - min_count: usize, - max_count: usize, -) { - if !passes_frequency_roll(area_seed, frequency_percent) { - return; - } - - let count = random_range_inclusive(min_count, max_count, area_seed.rotate_left(13)); - for offset in 0..count { - if let Some(cell) = - pick_random_corridor_cell(corridor, area_seed.wrapping_add(offset as u64)) - { - markers.push(AreaMarker { cell, size: 1 }); - } - } -} - -// Check whether an area spawns markers. -fn passes_frequency_roll(area_seed: u64, frequency_percent: usize) -> bool { - (area_seed % 100) < frequency_percent as u64 -} - -fn pick_random_corridor_cell(corridor: &Corridor, seed_value: u64) -> Option<(usize, usize)> { - if corridor.path.is_empty() { - return None; - } - let idx = seed_value as usize % corridor.path.len(); - Some(corridor.path[idx]) -} - -// Populate stairs across all levels. -pub fn populate_stairs( - mut layouts: Vec, - settings: &UiSettings, -) -> Vec<(DungeonLayout, bool)> { - if layouts.is_empty() { - return Vec::new(); - } - - for layout in &mut layouts { - layout.stairs.clear(); - } - - if settings.min_stairs_per_level == 0 && settings.max_stairs_per_level == 0 { - return layouts.into_iter().map(|layout| (layout, false)).collect(); - } - - let num_levels = layouts.len(); - let num_gaps = num_levels.saturating_sub(1); - let sets_to_gen = if num_gaps == 0 { 1 } else { num_gaps }; - let mut all_stair_sets: Vec> = Vec::new(); - - if settings.sync_stairs_across_levels { - let stair_count = get_stair_count(settings, 0); - let synced_stairs = pick_stair_positions(&layouts[0], settings, stair_count, 0); - for _ in 0..sets_to_gen { - all_stair_sets.push(synced_stairs.clone()); - } - } else { - for gap_idx in 0..sets_to_gen { - let stair_count = get_stair_count(settings, gap_idx); - let layout_idx = gap_idx.min(num_levels - 1); - let stairs = - pick_stair_positions(&layouts[layout_idx], settings, stair_count, gap_idx as u64); - all_stair_sets.push(stairs); - } - } - - if num_levels == 1 { - layouts[0].stairs.extend(all_stair_sets[0].clone()); - } else { - for gap_idx in 0..num_gaps { - let stairs = &all_stair_sets[gap_idx]; - layouts[gap_idx].stairs.extend(stairs.clone()); - layouts[gap_idx + 1].stairs.extend(stairs.clone()); - } - } - - for layout in &mut layouts { - let mut seen = HashSet::new(); - layout.stairs.retain(|stair| seen.insert(stair.cell)); - } - - let mut result = Vec::new(); - for mut layout in layouts { - let mut modified = false; - let stairs = layout.stairs.clone(); - for stair in &stairs { - if ensure_stair_in_room(&mut layout, stair) { - modified = true; - } - } - result.push((layout, modified)); - } - - result -} - -fn get_stair_count(settings: &UiSettings, gap_idx: usize) -> usize { - if settings.min_stairs_per_level == settings.max_stairs_per_level { - settings.min_stairs_per_level - } else { - let range_seed = seed::derive_seed(settings.seed, 0x2A_3B_4_u64 + gap_idx as u64); - (range_seed as usize % (settings.max_stairs_per_level - settings.min_stairs_per_level + 1)) - + settings.min_stairs_per_level - } -} - -// Ensure a stair is inside a room. -pub fn ensure_stair_in_room(layout: &mut DungeonLayout, stair: &Staircase) -> bool { - let stair_x = stair.cell.0; - let stair_y = stair.cell.1; - let stair_width = stair.width; - let stair_height = stair.height; - - for room in &layout.rooms { - if stair_x >= room.x - && stair_y >= room.y - && (stair_x + stair_width) <= room.x + room.width - && (stair_y + stair_height) <= room.y + room.height - { - return false; - } - } - - let mut nearest_room_idx = None; - let mut min_dist = usize::MAX; - - for (idx, room) in layout.rooms.iter().enumerate() { - let dist = room_to_stair_min_dist(room, stair); - if dist < min_dist { - min_dist = dist; - nearest_room_idx = Some(idx); - } - } - - if let Some(idx) = nearest_room_idx - && min_dist <= 5 - { - let room = &mut layout.rooms[idx]; - let new_x = room.x.min(stair_x); - let new_y = room.y.min(stair_y); - let new_right = (room.x + room.width).max(stair_x + stair_width); - let new_bottom = (room.y + room.height).max(stair_y + stair_height); - - room.x = new_x; - room.y = new_y; - room.width = new_right - new_x; - room.height = new_bottom - new_y; - true - } else { - layout.rooms.push(Room { - x: stair_x, - y: stair_y, - width: stair_width, - height: stair_height, - }); - true - } -} - -// Compute room distance from a stair. -fn room_to_stair_min_dist(room: &Room, stair: &Staircase) -> usize { - let dx = if stair.cell.0 + stair.width <= room.x { - room.x - (stair.cell.0 + stair.width) - } else if stair.cell.0 >= room.x + room.width { - stair.cell.0 - (room.x + room.width) - } else { - 0 - }; - - let dy = if stair.cell.1 + stair.height <= room.y { - room.y - (stair.cell.1 + stair.height) - } else if stair.cell.1 >= room.y + room.height { - stair.cell.1 - (room.y + room.height) - } else { - 0 - }; - - dx + dy -} - -// Pick valid stair positions. -fn pick_stair_positions( - layout: &DungeonLayout, - settings: &UiSettings, - count: usize, - seed_offset: u64, -) -> Vec { - if layout.rooms.is_empty() || count == 0 { - return Vec::new(); - } - - let mut valid_cells = Vec::new(); - for room in &layout.rooms { - let blocked_bottom_row = - has_start_or_end_on_bottom_row(room, &layout.start_markers, &layout.end_markers); - if !blocked_bottom_row { - for x in room.x..(room.x + room.width) { - for y in room.y..(room.y + room.height) { - valid_cells.push((x, y)); - } - } - } - } - - if valid_cells.is_empty() { - return Vec::new(); - } - - let mut shuffled = valid_cells.clone(); - shuffle_with_seed( - &mut shuffled, - settings - .seed - .wrapping_add(STAIR_MARKER_STREAM_BASE) - .wrapping_add(seed_offset), - ); - - let max_size = settings.max_stair_width.max(settings.max_stair_height); - let mut stair_cells = Vec::new(); - let mut used_cells = HashSet::new(); - - for cell in shuffled { - if stair_cells.len() >= count || used_cells.contains(&cell) { - continue; - } - - if let Some(room_idx) = room_index_at_cell(&layout.rooms, cell) { - let room = &layout.rooms[room_idx]; - let available_w = room.x + room.width - cell.0; - let available_h = room.y + room.height - cell.1; - if available_w >= settings.min_stair_width && available_h >= settings.min_stair_height { - stair_cells.push(cell); - for dx in 0..max_size { - for dy in 0..max_size { - used_cells.insert((cell.0 + dx, cell.1 + dy)); - } - } - } - } - } - - stair_cells - .into_iter() - .enumerate() - .map(|(idx, cell)| { - let (width, height) = random_stair_size( - settings, - seed::derive_seed( - settings.seed, - STAIR_MARKER_STREAM_BASE + idx as u64 + seed_offset * 1000, - ), - ); - Staircase { - cell, - width, - height, - } - }) - .collect() -} - -// Check whether markers overlap a room bottom row. -fn has_start_or_end_on_bottom_row( - room: &Room, - start_markers: &[AreaMarker], - end_markers: &[AreaMarker], -) -> bool { - let bottom_row = room.y + room.height - 1; - for marker in start_markers.iter().chain(end_markers.iter()) { - let marker_bottom = marker.cell.1 + marker.size; - if marker.cell.1 <= bottom_row && marker_bottom > bottom_row { - return true; - } - } - false -} - -// Pick a random stair size. -fn random_stair_size(settings: &UiSettings, seed_value: u64) -> (usize, usize) { - let w_span = settings - .max_stair_width - .saturating_sub(settings.min_stair_width) - + 1; - let h_span = settings - .max_stair_height - .saturating_sub(settings.min_stair_height) - + 1; - let width = settings.min_stair_width + (seed_value as usize % w_span); - let height = settings.min_stair_height + (seed_value.rotate_left(17) as usize % h_span); - (width, height) -} - -// Shuffle cells deterministically. -fn shuffle_with_seed(items: &mut [T], seed: u64) { - let mut rng = StairShuffleRng::new(seed); - for idx in (1..items.len()).rev() { - let swap_idx = rng.next_u32() as usize % (idx + 1); - items.swap(idx, swap_idx); - } -} - -struct StairShuffleRng { - state: u64, -} - -impl StairShuffleRng { - // Create the original stair shuffle RNG. - fn new(seed: u64) -> Self { - Self { state: seed } - } - - // Return the next shuffled value. - fn next_u32(&mut self) -> u32 { - self.state = self - .state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - (self.state >> 32) as u32 - } -} - -fn generate_room_sizes( - target_room_count: usize, - cols: usize, - rows: usize, - min_size: usize, - max_size: usize, - square_rooms_only: bool, - rng: &mut SimpleRng, -) -> Vec<(usize, usize)> { - let mut sizes = Vec::new(); - let max_attempts = target_room_count.saturating_mul(40).max(50); - - for _ in 0..max_attempts { - if sizes.len() >= target_room_count { - break; - } - - let (width, height) = if square_rooms_only { - let side = rng.range_inclusive(min_size, max_size.min(cols.min(rows))); - (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; - } - ( - rng.range_inclusive(min_size, width_max), - rng.range_inclusive(min_size, height_max), - ) - }; - - if width <= cols && height <= rows { - sizes.push((width, height)); - } - } - - sizes -} - -fn random_centers( - count: usize, - cols: usize, - rows: usize, - rng: &mut SimpleRng, -) -> Vec<(usize, usize)> { - let mut centers = Vec::with_capacity(count); - for _ in 0..count { - centers.push(( - rng.range_inclusive(0, cols.saturating_sub(1)), - rng.range_inclusive(0, rows.saturating_sub(1)), - )); - } - centers -} - -fn place_packed_rooms( - room_sizes: &[(usize, usize)], - room_edges: &[(usize, usize)], - cols: usize, - rows: usize, - rng: &mut SimpleRng, -) -> Vec { - if room_sizes.is_empty() || cols == 0 || rows == 0 { - return Vec::new(); - } - - let mut placed: Vec> = vec![None; room_sizes.len()]; - let (first_w, first_h) = room_sizes[0]; - if first_w > cols || first_h > rows { - return Vec::new(); - } - - placed[0] = Some(Room { - x: (cols.saturating_sub(first_w)) / 2, - y: (rows.saturating_sub(first_h)) / 2, - width: first_w, - height: first_h, - }); - - let mut order = placement_order(room_sizes.len(), room_edges); - if !order.contains(&0) { - order.insert(0, 0); - } - - for room_idx in order.into_iter().skip(1) { - let Some(room) = - try_place_packed_room(room_idx, room_sizes, room_edges, &placed, cols, rows, rng) - else { - continue; - }; - placed[room_idx] = Some(room); - } - - for room_idx in 0..room_sizes.len() { - if placed[room_idx].is_some() { - continue; - } - if let Some(room) = - try_place_packed_room(room_idx, room_sizes, room_edges, &placed, cols, rows, rng) - { - placed[room_idx] = Some(room); - } - } - - placed.into_iter().flatten().collect() -} - -fn placement_order(room_count: usize, room_edges: &[(usize, usize)]) -> Vec { - if room_count == 0 { - return Vec::new(); - } - - let mut adjacency = vec![Vec::new(); room_count]; - for &(a, b) in room_edges { - adjacency[a].push(b); - adjacency[b].push(a); - } - - let mut visited = vec![false; room_count]; - let mut queue = VecDeque::new(); - let mut order = Vec::with_capacity(room_count); - queue.push_back(0); - visited[0] = true; - - while let Some(idx) = queue.pop_front() { - order.push(idx); - for &next in &adjacency[idx] { - if !visited[next] { - visited[next] = true; - queue.push_back(next); - } - } - } - - for idx in 0..room_count { - if !visited[idx] { - order.push(idx); - } - } - - order -} - -fn try_place_packed_room( - room_idx: usize, - room_sizes: &[(usize, usize)], - room_edges: &[(usize, usize)], - placed: &[Option], - cols: usize, - rows: usize, - rng: &mut SimpleRng, -) -> Option { - let (width, height) = *room_sizes.get(room_idx)?; - if width > cols || height > rows { - return None; - } - - let mut anchors: Vec = room_edges - .iter() - .filter_map(|&(a, b)| { - if a == room_idx && placed.get(b)?.is_some() { - Some(b) - } else if b == room_idx && placed.get(a)?.is_some() { - Some(a) - } else { - None - } - }) - .collect(); - - if anchors.is_empty() { - anchors = placed - .iter() - .enumerate() - .filter_map(|(idx, room)| room.as_ref().map(|_| idx)) - .collect(); - } - - shuffle_indices(&mut anchors, rng); - - for anchor_idx in anchors { - let Some(anchor) = placed.get(anchor_idx).and_then(|room| room.as_ref()) else { - continue; - }; - let mut candidates = packed_room_candidates(anchor, width, height, cols, rows); - shuffle_rooms(&mut candidates, rng); - - for candidate in candidates { - if placed - .iter() - .flatten() - .all(|existing| !rooms_overlap(&candidate, existing)) - && placed - .iter() - .flatten() - .any(|existing| rooms_touch(&candidate, existing)) - { - return Some(candidate); - } - } - } - - None -} - -fn packed_room_candidates( - anchor: &Room, - width: usize, - height: usize, - cols: usize, - rows: usize, -) -> Vec { - let mut candidates = Vec::new(); - - let min_y = anchor.y.saturating_sub(height.saturating_sub(1)); - let max_y = (anchor.y + anchor.height).saturating_sub(1); - for y in min_y..=max_y { - candidates.push(Room { - x: anchor.x.saturating_sub(width), - y, - width, - height, - }); - candidates.push(Room { - x: anchor.x + anchor.width, - y, - width, - height, - }); - } - - let min_x = anchor.x.saturating_sub(width.saturating_sub(1)); - let max_x = (anchor.x + anchor.width).saturating_sub(1); - for x in min_x..=max_x { - candidates.push(Room { - x, - y: anchor.y.saturating_sub(height), - width, - height, - }); - candidates.push(Room { - x, - y: anchor.y + anchor.height, - width, - height, - }); - } - - candidates.retain(|room| room.x + room.width <= cols && room.y + room.height <= rows); - candidates -} - -fn shuffle_rooms(rooms: &mut [Room], rng: &mut SimpleRng) { - if rooms.len() <= 1 { - return; - } - for i in (1..rooms.len()).rev() { - let j = rng.range_inclusive(0, i); - rooms.swap(i, j); - } -} - -// Create a connected graph of room-to-room edges with optional dead ends. -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 -} - -// Order core rooms to create a reasonable loop backbone. -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 -} - -// Insert a room edge only if it has not been added yet. -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)); - } -} diff --git a/src/layout/generation/connections.rs b/src/layout/generation/connections.rs new file mode 100644 index 0000000..6c357c1 --- /dev/null +++ b/src/layout/generation/connections.rs @@ -0,0 +1,192 @@ +/* + * Room connection graph and corridor pathing. + * Builds the logical connections between rooms and generates the + * physical grid paths for corridors. + */ + +use super::super::types::Room; +use super::super::utils::{ + SimpleRng, manhattan_distance, normalized_cell_edge, shared_boundary_edges, shuffle_indices, +}; +use std::collections::HashSet; + +pub 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 +} + +pub 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 +} + +pub 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)); + } +} + +pub fn room_exit_edge( + path: &[(usize, usize)], + room: &Room, + from_start: bool, +) -> Option<((usize, usize), (usize, usize))> { + let in_room = |cell: (usize, usize)| { + cell.0 >= room.x + && cell.0 < room.x + room.width + && cell.1 >= room.y + && cell.1 < room.y + room.height + }; + + if from_start { + for pair in path.windows(2) { + if in_room(pair[0]) && !in_room(pair[1]) { + return Some(normalized_cell_edge(pair[0], pair[1])); + } + } + } else { + for pair in path.windows(2).rev() { + if in_room(pair[1]) && !in_room(pair[0]) { + return Some(normalized_cell_edge(pair[0], pair[1])); + } + } + } + + None +} + +pub fn room_collision_edges( + path: &[(usize, usize)], + room: &Room, +) -> Vec<((usize, usize), (usize, usize))> { + let in_room = |cell: (usize, usize)| { + cell.0 >= room.x + && cell.0 < room.x + room.width + && cell.1 >= room.y + && cell.1 < room.y + room.height + }; + + let mut edges = Vec::new(); + let mut seen = HashSet::new(); + for pair in path.windows(2) { + let a_in_room = in_room(pair[0]); + let b_in_room = in_room(pair[1]); + if a_in_room == b_in_room { + continue; + } + + let edge = normalized_cell_edge(pair[0], pair[1]); + if seen.insert(edge) { + edges.push(edge); + } + } + + edges +} + +pub fn shared_room_boundaries( + rooms: &[Room], +) -> Vec<(usize, usize, Vec<((usize, usize), (usize, usize))>)> { + let mut boundaries = Vec::new(); + for a_idx in 0..rooms.len() { + for b_idx in (a_idx + 1)..rooms.len() { + let shared_edges = shared_boundary_edges(&rooms[a_idx], &rooms[b_idx]); + if !shared_edges.is_empty() { + boundaries.push((a_idx, b_idx, shared_edges)); + } + } + } + boundaries +} diff --git a/src/layout/generation/markers.rs b/src/layout/generation/markers.rs new file mode 100644 index 0000000..8a576b4 --- /dev/null +++ b/src/layout/generation/markers.rs @@ -0,0 +1,607 @@ +/* + * Marker and transition population. + * Handles the placement of start/end points, traps, monsters, + * and staircase transitions between dungeon levels. + */ + +use super::super::types::{AreaMarker, Corridor, DungeonLayout, Room, Staircase}; +use super::super::utils::room_index_at_cell; +use crate::seed; +use crate::ui::UiSettings; +use std::collections::HashSet; + +pub fn populate_random_markers(mut layout: DungeonLayout, settings: &UiSettings) -> DungeonLayout { + layout.start_markers.clear(); + layout.end_markers.clear(); + layout.trap_markers.clear(); + layout.monster_markers.clear(); + + if layout.rooms.is_empty() { + return layout; + } + + let start_count = random_range_inclusive( + settings.min_start_marker_count, + settings.max_start_marker_count, + seed::derive_seed(settings.seed, 10_001), + ); + let end_count = random_range_inclusive( + settings.min_end_marker_count, + settings.max_end_marker_count, + seed::derive_seed(settings.seed, 10_002), + ); + let pair_count = start_count.min(end_count); + + let mut available_start_rooms: Vec = (0..layout.rooms.len()).collect(); + let mut available_end_rooms: Vec = (0..layout.rooms.len()).collect(); + + for pair_idx in 0..pair_count { + let (start_room_idx, end_room_idx) = + farthest_room_pair(&layout.rooms, &available_start_rooms, &available_end_rooms); + let start_room = &layout.rooms[start_room_idx]; + let end_room = &layout.rooms[end_room_idx]; + layout.start_markers.push(marker_in_room( + start_room, + settings.min_start_marker_size, + settings.max_start_marker_size, + seed::derive_seed(settings.seed, 11_000 + 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), + )); + consume_room(&mut available_start_rooms, start_room_idx); + consume_room(&mut available_end_rooms, end_room_idx); + } + + assign_extra_markers( + &mut layout.start_markers, + &layout.rooms, + &mut available_start_rooms, + pair_count, + start_count, + settings.min_start_marker_size, + settings.max_start_marker_size, + settings.seed, + 13_000, + 14_000, + ); + assign_extra_markers( + &mut layout.end_markers, + &layout.rooms, + &mut available_end_rooms, + pair_count, + end_count, + settings.min_end_marker_size, + settings.max_end_marker_size, + settings.seed, + 15_000, + 16_000, + ); + + layout = populate_random_traps(layout, settings); + layout = populate_random_monsters(layout, settings); + layout +} + +pub fn random_range_inclusive(min: usize, max: usize, seed_value: u64) -> usize { + let min = min.max(1); + let max = max.max(min); + let span = max - min + 1; + min + (seed_value as usize % span) +} + +pub fn marker_in_room( + room: &Room, + min_size: usize, + max_size: usize, + seed_value: u64, +) -> AreaMarker { + let room_limit = room.width.min(room.height).max(1); + let min_size = min_size.clamp(1, 10).min(room_limit); + let max_size = max_size.clamp(min_size, 10).min(room_limit); + let size = random_range_inclusive(min_size, max_size, seed_value.rotate_left(7)); + let x_span = room.width.saturating_sub(size); + let y_span = room.height.saturating_sub(size); + let x = room.x + + if x_span == 0 { + 0 + } else { + seed_value as usize % (x_span + 1) + }; + let y = room.y + + if y_span == 0 { + 0 + } else { + seed_value.rotate_left(19) as usize % (y_span + 1) + }; + AreaMarker { cell: (x, y), size } +} + +pub fn assign_extra_markers( + markers: &mut Vec, + rooms: &[Room], + available_rooms: &mut Vec, + start_idx: usize, + end_idx: usize, + min_size: usize, + max_size: usize, + seed_base: u64, + room_stream_base: u64, + marker_stream_base: u64, +) { + for idx in start_idx..end_idx { + let room_idx = pick_room_index( + available_rooms, + rooms.len(), + seed::derive_seed(seed_base, room_stream_base + idx as u64), + ); + markers.push(marker_in_room( + &rooms[room_idx], + min_size, + max_size, + seed::derive_seed(seed_base, marker_stream_base + idx as u64), + )); + consume_room(available_rooms, room_idx); + } +} + +pub fn farthest_room_pair( + rooms: &[Room], + available_start_rooms: &[usize], + available_end_rooms: &[usize], +) -> (usize, usize) { + let start_rooms = if available_start_rooms.is_empty() { + (0..rooms.len()).collect::>() + } else { + available_start_rooms.to_vec() + }; + let end_rooms = if available_end_rooms.is_empty() { + (0..rooms.len()).collect::>() + } else { + available_end_rooms.to_vec() + }; + + let mut best = (start_rooms[0], end_rooms[0]); + let mut best_dist = 0usize; + for &start_idx in &start_rooms { + for &end_idx in &end_rooms { + if rooms.len() > 1 && start_idx == end_idx { + continue; + } + let dist = room_distance_sq(&rooms[start_idx], &rooms[end_idx]); + if dist > best_dist { + best = (start_idx, end_idx); + best_dist = dist; + } + } + } + best +} + +pub fn room_distance_sq(a: &Room, b: &Room) -> usize { + let ac = a.center_cell(); + let bc = b.center_cell(); + let dx = ac.0.abs_diff(bc.0); + let dy = ac.1.abs_diff(bc.1); + dx * dx + dy * dy +} + +pub 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); + } +} + +pub 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()] + } else { + seed_value as usize % room_count.max(1) + } +} + +pub fn populate_random_traps(mut layout: DungeonLayout, settings: &UiSettings) -> DungeonLayout { + layout.trap_markers = populate_random_area_markers( + &layout, + settings.seed, + settings.trap_frequency_percent, + settings.min_traps_per_area, + settings.max_traps_per_area, + 17_000, + 18_000, + ); + layout +} + +pub fn populate_random_monsters(mut layout: DungeonLayout, settings: &UiSettings) -> DungeonLayout { + layout.monster_markers = populate_random_area_markers( + &layout, + settings.seed, + settings.monster_frequency_percent, + settings.min_monsters_per_area, + settings.max_monsters_per_area, + 19_000, + 20_000, + ); + layout +} + +pub fn populate_random_area_markers( + layout: &DungeonLayout, + master_seed: u64, + frequency_percent: usize, + min_count: usize, + max_count: usize, + room_stream_base: u64, + corridor_stream_base: u64, +) -> Vec { + if frequency_percent == 0 { + return Vec::new(); + } + + let mut markers = Vec::new(); + + for (idx, room) in layout.rooms.iter().enumerate() { + let area_seed = seed::derive_seed(master_seed, room_stream_base + idx as u64); + append_room_markers( + &mut markers, + room, + area_seed, + frequency_percent, + min_count, + max_count, + ); + } + + for (idx, corridor) in layout.corridors.iter().enumerate() { + let area_seed = seed::derive_seed(master_seed, corridor_stream_base + idx as u64); + append_corridor_markers( + &mut markers, + corridor, + area_seed, + frequency_percent, + min_count, + max_count, + ); + } + + markers +} + +pub fn append_room_markers( + markers: &mut Vec, + room: &Room, + area_seed: u64, + frequency_percent: usize, + min_count: usize, + max_count: usize, +) { + if !passes_frequency_roll(area_seed, frequency_percent) { + return; + } + + let count = random_range_inclusive(min_count, max_count, area_seed.rotate_left(13)); + for offset in 0..count { + markers.push(marker_in_room( + room, + 1, + 1, + area_seed.wrapping_add(offset as u64).rotate_right(7), + )); + } +} + +pub fn append_corridor_markers( + markers: &mut Vec, + corridor: &Corridor, + area_seed: u64, + frequency_percent: usize, + min_count: usize, + max_count: usize, +) { + if !passes_frequency_roll(area_seed, frequency_percent) { + return; + } + + let count = random_range_inclusive(min_count, max_count, area_seed.rotate_left(13)); + for offset in 0..count { + if let Some(cell) = + pick_random_corridor_cell(corridor, area_seed.wrapping_add(offset as u64)) + { + markers.push(AreaMarker { cell, size: 1 }); + } + } +} + +pub fn passes_frequency_roll(area_seed: u64, frequency_percent: usize) -> bool { + (area_seed % 100) < frequency_percent as u64 +} + +pub fn pick_random_corridor_cell(corridor: &Corridor, seed_value: u64) -> Option<(usize, usize)> { + if corridor.path.is_empty() { + return None; + } + let idx = seed_value as usize % corridor.path.len(); + Some(corridor.path[idx]) +} + +pub fn populate_stairs( + mut layouts: Vec, + settings: &UiSettings, +) -> Vec<(DungeonLayout, bool)> { + if layouts.is_empty() { + return Vec::new(); + } + + for layout in &mut layouts { + layout.stairs.clear(); + } + + if settings.min_stairs_per_level == 0 && settings.max_stairs_per_level == 0 { + return layouts.into_iter().map(|layout| (layout, false)).collect(); + } + + let num_levels = layouts.len(); + let num_gaps = num_levels.saturating_sub(1); + let sets_to_gen = if num_gaps == 0 { 1 } else { num_gaps }; + let mut all_stair_sets: Vec> = Vec::new(); + + if settings.sync_stairs_across_levels { + let stair_count = get_stair_count(settings, 0); + let synced_stairs = pick_stair_positions(&layouts[0], settings, stair_count, 0); + for _ in 0..sets_to_gen { + all_stair_sets.push(synced_stairs.clone()); + } + } else { + for gap_idx in 0..sets_to_gen { + let stair_count = get_stair_count(settings, gap_idx); + let layout_idx = gap_idx.min(num_levels - 1); + let stairs = + pick_stair_positions(&layouts[layout_idx], settings, stair_count, gap_idx as u64); + all_stair_sets.push(stairs); + } + } + + if num_levels == 1 { + layouts[0].stairs.extend(all_stair_sets[0].clone()); + } else { + for gap_idx in 0..num_gaps { + let stairs = &all_stair_sets[gap_idx]; + layouts[gap_idx].stairs.extend(stairs.clone()); + layouts[gap_idx + 1].stairs.extend(stairs.clone()); + } + } + + for layout in &mut layouts { + let mut seen = HashSet::new(); + layout.stairs.retain(|stair| seen.insert(stair.cell)); + } + + let mut result = Vec::new(); + for mut layout in layouts { + let mut modified = false; + let stairs = layout.stairs.clone(); + for stair in &stairs { + if ensure_stair_in_room(&mut layout, stair) { + modified = true; + } + } + result.push((layout, modified)); + } + + result +} + +pub fn get_stair_count(settings: &UiSettings, gap_idx: usize) -> usize { + if settings.min_stairs_per_level == settings.max_stairs_per_level { + settings.min_stairs_per_level + } else { + let range_seed = seed::derive_seed(settings.seed, 0x2A_3B_4_u64 + gap_idx as u64); + (range_seed as usize % (settings.max_stairs_per_level - settings.min_stairs_per_level + 1)) + + settings.min_stairs_per_level + } +} + +pub fn ensure_stair_in_room(layout: &mut DungeonLayout, stair: &Staircase) -> bool { + let stair_x = stair.cell.0; + let stair_y = stair.cell.1; + let stair_width = stair.width; + let stair_height = stair.height; + + for room in &layout.rooms { + if stair_x >= room.x + && stair_y >= room.y + && (stair_x + stair_width) <= room.x + room.width + && (stair_y + stair_height) <= room.y + room.height + { + return false; + } + } + + let mut nearest_room_idx = None; + let mut min_dist = usize::MAX; + + for (idx, room) in layout.rooms.iter().enumerate() { + let dist = room_to_stair_min_dist(room, stair); + if dist < min_dist { + min_dist = dist; + nearest_room_idx = Some(idx); + } + } + + if let Some(idx) = nearest_room_idx + && min_dist <= 5 + { + let room = &mut layout.rooms[idx]; + let new_x = room.x.min(stair_x); + let new_y = room.y.min(stair_y); + let new_right = (room.x + room.width).max(stair_x + stair_width); + let new_bottom = (room.y + room.height).max(stair_y + stair_height); + + room.x = new_x; + room.y = new_y; + room.width = new_right - new_x; + room.height = new_bottom - new_y; + true + } else { + layout.rooms.push(Room { + x: stair_x, + y: stair_y, + width: stair_width, + height: stair_height, + }); + true + } +} + +pub fn room_to_stair_min_dist(room: &Room, stair: &Staircase) -> usize { + let dx = if stair.cell.0 + stair.width <= room.x { + room.x - (stair.cell.0 + stair.width) + } else if stair.cell.0 >= room.x + room.width { + stair.cell.0 - (room.x + room.width) + } else { + 0 + }; + + let dy = if stair.cell.1 + stair.height <= room.y { + room.y - (stair.cell.1 + stair.height) + } else if stair.cell.1 >= room.y + room.height { + stair.cell.1 - (room.y + room.height) + } else { + 0 + }; + + dx + dy +} + +pub fn pick_stair_positions( + layout: &DungeonLayout, + settings: &UiSettings, + count: usize, + seed_offset: u64, +) -> Vec { + if layout.rooms.is_empty() || count == 0 { + return Vec::new(); + } + + let mut valid_cells = Vec::new(); + for room in &layout.rooms { + let blocked_bottom_row = + has_start_or_end_on_bottom_row(room, &layout.start_markers, &layout.end_markers); + if !blocked_bottom_row { + for x in room.x..(room.x + room.width) { + for y in room.y..(room.y + room.height) { + valid_cells.push((x, y)); + } + } + } + } + + if valid_cells.is_empty() { + return Vec::new(); + } + + let mut shuffled = valid_cells.clone(); + shuffle_with_seed( + &mut shuffled, + settings.seed.wrapping_add(21_000).wrapping_add(seed_offset), + ); + + let max_size = settings.max_stair_width.max(settings.max_stair_height); + let mut stair_cells = Vec::new(); + let mut used_cells = HashSet::new(); + + for cell in shuffled { + if stair_cells.len() >= count || used_cells.contains(&cell) { + continue; + } + + if let Some(room_idx) = room_index_at_cell(&layout.rooms, cell) { + let room = &layout.rooms[room_idx]; + let available_w = room.x + room.width - cell.0; + let available_h = room.y + room.height - cell.1; + if available_w >= settings.min_stair_width && available_h >= settings.min_stair_height { + stair_cells.push(cell); + for dx in 0..max_size { + for dy in 0..max_size { + used_cells.insert((cell.0 + dx, cell.1 + dy)); + } + } + } + } + } + + stair_cells + .into_iter() + .enumerate() + .map(|(idx, cell)| { + let (width, height) = random_stair_size( + settings, + seed::derive_seed(settings.seed, 21_000 + idx as u64 + seed_offset * 1000), + ); + Staircase { + cell, + width, + height, + } + }) + .collect() +} + +pub fn has_start_or_end_on_bottom_row( + room: &Room, + start_markers: &[AreaMarker], + end_markers: &[AreaMarker], +) -> bool { + let bottom_row = room.y + room.height - 1; + for marker in start_markers.iter().chain(end_markers.iter()) { + let marker_bottom = marker.cell.1 + marker.size; + if marker.cell.1 <= bottom_row && marker_bottom > bottom_row { + return true; + } + } + false +} + +pub fn random_stair_size(settings: &UiSettings, seed_value: u64) -> (usize, usize) { + let w_span = settings + .max_stair_width + .saturating_sub(settings.min_stair_width) + + 1; + let h_span = settings + .max_stair_height + .saturating_sub(settings.min_stair_height) + + 1; + let width = settings.min_stair_width + (seed_value as usize % w_span); + let height = settings.min_stair_height + (seed_value.rotate_left(17) as usize % h_span); + (width, height) +} + +pub fn shuffle_with_seed(items: &mut [T], seed: u64) { + let mut rng = StairShuffleRng::new(seed); + for idx in (1..items.len()).rev() { + let swap_idx = rng.next_u32() as usize % (idx + 1); + items.swap(idx, swap_idx); + } +} + +pub struct StairShuffleRng { + pub state: u64, +} + +impl StairShuffleRng { + pub fn new(seed: u64) -> Self { + Self { state: seed } + } + + pub fn next_u32(&mut self) -> u32 { + self.state = self + .state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (self.state >> 32) as u32 + } +} diff --git a/src/layout/generation/mod.rs b/src/layout/generation/mod.rs new file mode 100644 index 0000000..ab62770 --- /dev/null +++ b/src/layout/generation/mod.rs @@ -0,0 +1,195 @@ +/* + * Main generation module. + * Orchestrates the procedural generation process, coordinating + * room placement, connection graph building, and passage/marker population. + */ + +use super::types::{Corridor, DoorSettings, DungeonLayout, Room, WindowSettings}; +use super::utils::{SimpleRng, blocked_room_cells, noisy_path, shortest_path_cells}; +use crate::seed; + +pub mod connections; +pub mod markers; +pub mod passages; +pub mod room_placement; + +pub use connections::*; +pub use markers::*; +pub use passages::*; +pub use room_placement::*; + +pub fn generate_layout( + cols: usize, + rows: usize, + target_room_count: usize, + seed: u64, + min_room_size: usize, + max_room_size: usize, + square_rooms_only: bool, + min_corridor_width: usize, + max_corridor_width: usize, + corridor_randomness_percent: usize, + dead_end_room_percent: usize, + pack_rooms_without_corridors: bool, + door_settings: DoorSettings, + window_settings: WindowSettings, +) -> DungeonLayout { + let layout_salt = ((cols as u64) << 48) + ^ ((rows as u64) << 32) + ^ ((target_room_count as u64) << 16) + ^ (min_room_size as u64) + ^ ((max_room_size as u64) << 8) + ^ ((min_corridor_width as u64) << 24) + ^ ((max_corridor_width as u64) << 28) + ^ ((corridor_randomness_percent as u64) << 56) + ^ ((dead_end_room_percent as u64) << 40); + let base_seed = seed::derive_seed(seed, layout_salt); + let mut room_rng = SimpleRng::new(seed::derive_seed(base_seed, 1)); + let mut graph_rng = SimpleRng::new(seed::derive_seed(base_seed, 2)); + let mut path_rng = SimpleRng::new(seed::derive_seed(base_seed, 3)); + let mut corridor_rng = SimpleRng::new(seed::derive_seed(base_seed, 4)); + + let mut rooms = Vec::new(); + let corridors = Vec::new(); + + if cols < 2 || rows < 2 || target_room_count == 0 { + return DungeonLayout::empty(pack_rooms_without_corridors); + } + + let mut min_size = min_room_size.max(2); + let mut max_size = max_room_size.max(min_size); + + if square_rooms_only { + let hard_max = cols.min(rows); + 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)); + } + + if min_size == 0 || max_size < min_size { + return DungeonLayout::empty(pack_rooms_without_corridors); + } + + let room_sizes = generate_room_sizes( + target_room_count, + cols, + rows, + min_size, + max_size, + square_rooms_only, + &mut room_rng, + ); + let room_count = room_sizes.len(); + let centers = random_centers(room_count, cols, rows, &mut graph_rng); + let randomness = (corridor_randomness_percent.min(100) as f32) / 100.0; + let target_dead_end_rooms = ((room_count * dead_end_room_percent.min(50)) + 50) / 100; + let room_edges = + build_room_connection_edges(¢ers, randomness, target_dead_end_rooms, &mut graph_rng); + + if pack_rooms_without_corridors { + rooms = place_packed_rooms(&room_sizes, &room_edges, cols, rows, &mut room_rng); + let mut layout = DungeonLayout::from_generated_parts(rooms, corridors, true); + apply_doors(&mut layout, seed, door_settings, cols, rows); + apply_windows(&mut layout, seed, window_settings, cols, rows); + return layout; + } + + let max_attempts = target_room_count.saturating_mul(40).max(50); + + for _ in 0..max_attempts { + if rooms.len() >= target_room_count { + break; + } + + let (width, height) = if square_rooms_only { + let side = room_rng.range_inclusive(min_size, max_size.min(cols.min(rows))); + (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; + } + ( + room_rng.range_inclusive(min_size, width_max), + room_rng.range_inclusive(min_size, height_max), + ) + }; + + if width > cols || height > rows { + continue; + } + + let x = room_rng.range_inclusive(0, cols - width); + let y = room_rng.range_inclusive(0, rows - height); + let candidate = Room { + x, + y, + width, + height, + }; + + if rooms + .iter() + .all(|existing| !crate::layout::utils::overlaps_with_padding(&candidate, existing, 1)) + { + rooms.push(candidate); + } + } + + if rooms.len() < 2 { + return DungeonLayout::from_generated_parts(rooms, corridors, false); + } + + let mut min_width = min_corridor_width.max(1); + let max_grid_width = cols.max(1).min(rows.max(1)); + let max_width = max_corridor_width.max(min_width).min(max_grid_width); + min_width = min_width.min(max_width); + 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 graph_rng); + + let mut corridors = Vec::new(); + let mut next_corridor_id = 1u64; + for (start_room_id, end_room_id) in room_edges { + let start = centers[start_room_id]; + let end = centers[end_room_id]; + let blocked = blocked_room_cells(&rooms, &[start_room_id, end_room_id]); + + let path = if randomness <= 0.001 { + shortest_path_cells(start, end, cols, rows, &blocked) + } else { + Some(noisy_path( + start, + end, + cols, + rows, + randomness, + &blocked, + &mut path_rng, + )) + } + .or_else(|| shortest_path_cells(start, end, cols, rows, &blocked)); + + if let Some(path) = path + && path.len() >= 2 + { + corridors.push(Corridor { + id: next_corridor_id, + start_room_id, + end_room_id, + path, + width: corridor_rng.range_inclusive(min_width, max_width), + }); + next_corridor_id = next_corridor_id.wrapping_add(1); + } + } + + let mut layout = DungeonLayout::from_generated_parts(rooms, corridors, false); + apply_doors(&mut layout, seed, door_settings, cols, rows); + apply_windows(&mut layout, seed, window_settings, cols, rows); + layout +} diff --git a/src/layout/generation/passages.rs b/src/layout/generation/passages.rs new file mode 100644 index 0000000..df02507 --- /dev/null +++ b/src/layout/generation/passages.rs @@ -0,0 +1,520 @@ +/* + * Door and window placement logic. + * Handles the automatic generation and arrangement of doors and windows + * between rooms and corridors. + */ + +use super::super::types::{ + Door, DoorSettings, DungeonLayout, Room, Window, WindowSettings, WindowSide, +}; +use super::super::utils::{ + SimpleRng, corridor_cells, normalized_cell_edge, room_index_at_cell, shared_opening_width, +}; +use super::connections::{room_collision_edges, room_exit_edge, shared_room_boundaries}; +use crate::seed; +use std::collections::HashSet; + +pub fn apply_doors( + layout: &mut DungeonLayout, + seed: u64, + settings: DoorSettings, + cols: usize, + rows: usize, +) { + layout.doors.clear(); + + if layout.packed_rooms { + apply_packed_room_doors(layout, seed, settings); + return; + } + + let mut rng = SimpleRng::new(seed::derive_seed(seed, 0xD005_5EED_u64)); + let base = (settings.frequency_percent.min(100) as f32) / 100.0; + let room_hall = (settings.room_hallway_percent.min(100) as f32) / 100.0; + let locked = (settings.locked_percent.min(100) as f32) / 100.0; + + let corridor_cells = corridor_cells(layout, cols, rows); + let mut seen_edges = HashSet::new(); + let door_chance = base * room_hall; + for corridor in &layout.corridors { + if corridor.path.len() < 2 { + continue; + } + + let start_room = &layout.rooms[corridor.start_room_id]; + let end_room = &layout.rooms[corridor.end_room_id]; + + if let Some(edge) = room_exit_edge(&corridor.path, start_room, true) { + if seen_edges.insert(edge) { + let place_door = door_chance > 0.0 && rng.next_f32() <= door_chance; + layout.doors.push(Door { + from: edge.0, + to: edge.1, + width: corridor.width.max(1), + span_width: true, + locked: place_door && rng.next_f32() <= locked, + archway: !place_door, + secret: false, + manual: false, + }); + } + } + + if let Some(edge) = room_exit_edge(&corridor.path, end_room, false) { + if seen_edges.insert(edge) { + let place_door = door_chance > 0.0 && rng.next_f32() <= door_chance; + layout.doors.push(Door { + from: edge.0, + to: edge.1, + width: corridor.width.max(1), + span_width: true, + locked: place_door && rng.next_f32() <= locked, + archway: !place_door, + secret: false, + manual: false, + }); + } + } + + for (room_idx, room) in layout.rooms.iter().enumerate() { + if room_idx == corridor.start_room_id || room_idx == corridor.end_room_id { + continue; + } + + for edge in room_collision_edges(&corridor.path, room) { + if seen_edges.insert(edge) { + let place_door = door_chance > 0.0 && rng.next_f32() <= door_chance; + layout.doors.push(Door { + from: edge.0, + to: edge.1, + width: corridor.width.max(1), + span_width: true, + locked: place_door && rng.next_f32() <= locked, + archway: !place_door, + secret: false, + manual: false, + }); + } + } + } + } + + if settings.allow_middle_corridor_doors { + for &(x, y) in &corridor_cells { + let right = (x + 1, y); + let bottom = (x, y + 1); + if x + 1 < cols && corridor_cells.contains(&right) { + let edge = normalized_cell_edge((x, y), right); + if seen_edges.insert(edge) && base > 0.0 && rng.next_f32() <= base { + layout.doors.push(Door { + from: edge.0, + to: edge.1, + width: 1, + span_width: false, + locked: rng.next_f32() <= locked, + archway: false, + secret: false, + manual: false, + }); + } + } + if y + 1 < rows && corridor_cells.contains(&bottom) { + let edge = normalized_cell_edge((x, y), bottom); + if seen_edges.insert(edge) && base > 0.0 && rng.next_f32() <= base { + layout.doors.push(Door { + from: edge.0, + to: edge.1, + width: 1, + span_width: false, + locked: rng.next_f32() <= locked, + archway: false, + secret: false, + manual: false, + }); + } + } + } + } + + apply_secret_doors(layout, seed, settings); +} + +pub fn apply_packed_room_doors(layout: &mut DungeonLayout, seed: u64, settings: DoorSettings) { + let mut rng = SimpleRng::new(seed::derive_seed(seed, 0xD005_5EED_u64)); + let door_chance = ((settings.frequency_percent.min(100) as f32) / 100.0) + * ((settings.room_hallway_percent.min(100) as f32) / 100.0); + let locked = (settings.locked_percent.min(100) as f32) / 100.0; + + for (a_idx, b_idx, shared_edges) in shared_room_boundaries(&layout.rooms) { + let edge_idx = if shared_edges.len() <= 1 { + 0 + } else { + rng.range_inclusive(0, shared_edges.len() - 1) + }; + let edge = shared_edges[edge_idx]; + let place_door = door_chance > 0.0 && rng.next_f32() <= door_chance; + + let width = shared_opening_width( + &layout.rooms[a_idx], + &layout.rooms[b_idx], + shared_edges.len(), + 1, + ); + layout.doors.push(Door { + from: edge.0, + to: edge.1, + width, + span_width: width > 1, + locked: place_door && rng.next_f32() <= locked, + archway: !place_door, + secret: false, + manual: false, + }); + } + + apply_secret_doors(layout, seed, settings); +} + +pub fn apply_secret_doors(layout: &mut DungeonLayout, seed: u64, settings: DoorSettings) { + let secret_percent = settings.secret_percent.min(100); + let chance = (secret_percent as f32) / 100.0; + if chance <= 0.0 || layout.rooms.is_empty() || layout.doors.is_empty() { + return; + } + + let mut rng = SimpleRng::new(seed::derive_seed(seed, 0x5EC2_E700_u64)); + let room_door_map = room_to_door_indices(layout); + let eligible_secret_doors = (0..layout.doors.len()) + .filter(|&door_idx| { + let door = &layout.doors[door_idx]; + !door.archway + && !door.locked + && door_is_secret_eligible_for_rooms(layout, &room_door_map, door_idx) + }) + .collect::>(); + + for (door_idx, door) in layout.doors.iter_mut().enumerate() { + if !eligible_secret_doors.contains(&door_idx) { + continue; + } + + if rng.next_f32() <= chance { + door.locked = false; + door.archway = false; + door.secret = true; + } + } +} + +pub fn door_is_secret_eligible_for_rooms( + layout: &DungeonLayout, + room_door_map: &[Vec], + door_idx: usize, +) -> bool { + for door_indices in room_door_map { + if !door_indices.contains(&door_idx) { + continue; + } + + for &other_door_idx in door_indices { + if other_door_idx == door_idx { + continue; + } + + if !door_directly_connects_rooms(layout, other_door_idx) { + return false; + } + } + } + + true +} + +pub fn room_to_door_indices(layout: &DungeonLayout) -> Vec> { + let mut room_door_map = vec![Vec::new(); layout.rooms.len()]; + for (door_idx, door) in layout.doors.iter().enumerate() { + for (room_idx, room) in layout.rooms.iter().enumerate() { + if door_touches_room(door, room) { + room_door_map[room_idx].push(door_idx); + } + } + } + room_door_map +} + +pub fn secret_room_ids(layout: &DungeonLayout) -> HashSet { + let mut secret_rooms = HashSet::new(); + for door in layout.doors.iter().filter(|door| door.secret) { + for (room_idx, room) in layout.rooms.iter().enumerate() { + if door_touches_room(door, room) { + secret_rooms.insert(room_idx); + } + } + } + secret_rooms +} + +pub fn door_touches_room(door: &Door, room: &Room) -> bool { + cell_in_room(door.from, room) || cell_in_room(door.to, room) +} + +pub fn door_directly_connects_rooms(layout: &DungeonLayout, door_idx: usize) -> bool { + let Some(door) = layout.doors.get(door_idx) else { + return false; + }; + + let touching_rooms = layout + .rooms + .iter() + .enumerate() + .filter_map(|(room_idx, room)| door_touches_room(door, room).then_some(room_idx)) + .collect::>(); + + touching_rooms.len() >= 2 +} + +pub fn cell_in_room(cell: (usize, usize), room: &Room) -> bool { + cell.0 >= room.x + && cell.0 < room.x + room.width + && cell.1 >= room.y + && cell.1 < room.y + room.height +} + +pub fn apply_windows( + layout: &mut DungeonLayout, + seed: u64, + settings: WindowSettings, + cols: usize, + rows: usize, +) { + layout.windows.clear(); + + if !settings.enabled || cols == 0 || rows == 0 { + return; + } + + let mut min_width = settings.min_width.clamp(1, 5); + let max_width = settings.max_width.clamp(min_width, 5); + min_width = min_width.min(max_width); + + let mut rng = SimpleRng::new(seed::derive_seed(seed, 0xA117_0055_u64)); + let base = (settings.frequency_percent.min(100) as f32) / 100.0; + let internal_ratio = (settings.room_hallway_percent.min(100) as f32) / 100.0; + + for segment in collect_window_segments(layout, cols, rows, settings.allow_internal_windows) { + let chance = if segment.internal { + if !settings.allow_internal_windows { + continue; + } + base * internal_ratio + } else { + base * (1.0 - internal_ratio) + }; + + if chance <= 0.0 || rng.next_f32() > chance { + continue; + } + + let max_segment_width = max_width.min(segment.cells.len()).max(1); + let width = rng.range_inclusive(min_width.min(max_segment_width), max_segment_width); + let cell = select_segment_cell(&segment.cells, width, &mut rng); + layout.windows.push(Window { + cell, + side: segment.side, + width, + span_width: width > 1, + }); + } +} + +#[derive(Debug, Clone)] +pub struct WindowSegment { + pub cells: Vec<(usize, usize)>, + pub side: WindowSide, + pub internal: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowTarget { + Exterior, + Corridor, + Room(usize), +} + +pub fn collect_window_segments( + layout: &DungeonLayout, + cols: usize, + rows: usize, + allow_internal_windows: bool, +) -> Vec { + let corridor_cells = corridor_cells(layout, cols, rows); + let secret_rooms = secret_room_ids(layout); + let mut segments = Vec::new(); + + for (room_idx, room) in layout.rooms.iter().enumerate() { + if secret_rooms.contains(&room_idx) { + continue; + } + collect_room_side_segments( + room_idx, + room, + layout, + &corridor_cells, + cols, + rows, + allow_internal_windows, + WindowSide::Left, + &mut segments, + ); + collect_room_side_segments( + room_idx, + room, + layout, + &corridor_cells, + cols, + rows, + allow_internal_windows, + WindowSide::Right, + &mut segments, + ); + collect_room_side_segments( + room_idx, + room, + layout, + &corridor_cells, + cols, + rows, + allow_internal_windows, + WindowSide::Top, + &mut segments, + ); + collect_room_side_segments( + room_idx, + room, + layout, + &corridor_cells, + cols, + rows, + allow_internal_windows, + WindowSide::Bottom, + &mut segments, + ); + } + + segments +} + +pub fn collect_room_side_segments( + room_idx: usize, + room: &Room, + layout: &DungeonLayout, + corridor_cells: &HashSet<(usize, usize)>, + cols: usize, + rows: usize, + allow_internal_windows: bool, + side: WindowSide, + segments: &mut Vec, +) { + let cells: Vec<(usize, usize)> = match side { + WindowSide::Left => (room.y..(room.y + room.height)) + .map(|y| (room.x, y)) + .collect(), + WindowSide::Right => (room.y..(room.y + room.height)) + .map(|y| (room.x + room.width - 1, y)) + .collect(), + WindowSide::Top => (room.x..(room.x + room.width)) + .map(|x| (x, room.y)) + .collect(), + WindowSide::Bottom => (room.x..(room.x + room.width)) + .map(|x| (x, room.y + room.height - 1)) + .collect(), + }; + + let mut current_target = None; + let mut current_cells = Vec::new(); + + for cell in cells { + let Some(neighbor) = outward_neighbor(cell, side, cols, rows) else { + push_window_segment(current_target, side, &mut current_cells, segments); + current_target = Some(WindowTarget::Exterior); + current_cells.push(cell); + continue; + }; + + let target = if let Some(other_room_idx) = room_index_at_cell(&layout.rooms, neighbor) { + if other_room_idx == room_idx || !allow_internal_windows || other_room_idx < room_idx { + None + } else { + Some(WindowTarget::Room(other_room_idx)) + } + } else if corridor_cells.contains(&neighbor) { + allow_internal_windows.then_some(WindowTarget::Corridor) + } else { + Some(WindowTarget::Exterior) + }; + + if current_target != target { + push_window_segment(current_target, side, &mut current_cells, segments); + current_target = target; + } + + if target.is_some() { + current_cells.push(cell); + } + } + + push_window_segment(current_target, side, &mut current_cells, segments); +} + +pub fn push_window_segment( + target: Option, + side: WindowSide, + cells: &mut Vec<(usize, usize)>, + segments: &mut Vec, +) { + let Some(target) = target else { + cells.clear(); + return; + }; + if cells.is_empty() { + return; + } + + segments.push(WindowSegment { + cells: std::mem::take(cells), + side, + internal: !matches!(target, WindowTarget::Exterior), + }); +} + +pub fn outward_neighbor( + cell: (usize, usize), + side: WindowSide, + cols: usize, + rows: usize, +) -> Option<(usize, usize)> { + match side { + WindowSide::Left => cell.0.checked_sub(1).map(|x| (x, cell.1)), + WindowSide::Right => (cell.0 + 1 < cols).then_some((cell.0 + 1, cell.1)), + WindowSide::Top => cell.1.checked_sub(1).map(|y| (cell.0, y)), + WindowSide::Bottom => (cell.1 + 1 < rows).then_some((cell.0, cell.1 + 1)), + } +} + +pub fn select_segment_cell( + cells: &[(usize, usize)], + width: usize, + rng: &mut SimpleRng, +) -> (usize, usize) { + let width = width.max(1); + let min_offset = -((width as isize - 1) / 2); + let max_offset = width as isize / 2; + let low = (-min_offset) as usize; + let high = cells.len().saturating_sub(1 + max_offset.max(0) as usize); + let idx = if low <= high { + rng.range_inclusive(low, high) + } else { + cells.len() / 2 + }; + cells[idx] +} diff --git a/src/layout/generation/room_placement.rs b/src/layout/generation/room_placement.rs new file mode 100644 index 0000000..f89facf --- /dev/null +++ b/src/layout/generation/room_placement.rs @@ -0,0 +1,272 @@ +/* + * Room placement and sizing algorithms. + * Handles the generation of room dimensions and their spatial arrangement, + * including packed room placement. + */ + +use super::super::types::Room; +use super::super::utils::{SimpleRng, rooms_overlap, rooms_touch, shuffle_indices}; +use std::collections::VecDeque; + +pub fn generate_room_sizes( + target_room_count: usize, + cols: usize, + rows: usize, + min_size: usize, + max_size: usize, + square_rooms_only: bool, + rng: &mut SimpleRng, +) -> Vec<(usize, usize)> { + let mut sizes = Vec::new(); + let max_attempts = target_room_count.saturating_mul(40).max(50); + + for _ in 0..max_attempts { + if sizes.len() >= target_room_count { + break; + } + + let (width, height) = if square_rooms_only { + let side = rng.range_inclusive(min_size, max_size.min(cols.min(rows))); + (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; + } + ( + rng.range_inclusive(min_size, width_max), + rng.range_inclusive(min_size, height_max), + ) + }; + + if width <= cols && height <= rows { + sizes.push((width, height)); + } + } + + sizes +} + +pub fn random_centers( + count: usize, + cols: usize, + rows: usize, + rng: &mut SimpleRng, +) -> Vec<(usize, usize)> { + let mut centers = Vec::with_capacity(count); + for _ in 0..count { + centers.push(( + rng.range_inclusive(0, cols.saturating_sub(1)), + rng.range_inclusive(0, rows.saturating_sub(1)), + )); + } + centers +} + +pub fn place_packed_rooms( + room_sizes: &[(usize, usize)], + room_edges: &[(usize, usize)], + cols: usize, + rows: usize, + rng: &mut SimpleRng, +) -> Vec { + if room_sizes.is_empty() || cols == 0 || rows == 0 { + return Vec::new(); + } + + let mut placed: Vec> = vec![None; room_sizes.len()]; + let (first_w, first_h) = room_sizes[0]; + if first_w > cols || first_h > rows { + return Vec::new(); + } + + placed[0] = Some(Room { + x: (cols.saturating_sub(first_w)) / 2, + y: (rows.saturating_sub(first_h)) / 2, + width: first_w, + height: first_h, + }); + + let mut order = placement_order(room_sizes.len(), room_edges); + if !order.contains(&0) { + order.insert(0, 0); + } + + for room_idx in order.into_iter().skip(1) { + let Some(room) = + try_place_packed_room(room_idx, room_sizes, room_edges, &placed, cols, rows, rng) + else { + continue; + }; + placed[room_idx] = Some(room); + } + + for room_idx in 0..room_sizes.len() { + if placed[room_idx].is_some() { + continue; + } + if let Some(room) = + try_place_packed_room(room_idx, room_sizes, room_edges, &placed, cols, rows, rng) + { + placed[room_idx] = Some(room); + } + } + + placed.into_iter().flatten().collect() +} + +pub fn placement_order(room_count: usize, room_edges: &[(usize, usize)]) -> Vec { + if room_count == 0 { + return Vec::new(); + } + + let mut adjacency = vec![Vec::new(); room_count]; + for &(a, b) in room_edges { + adjacency[a].push(b); + adjacency[b].push(a); + } + + let mut visited = vec![false; room_count]; + let mut queue = VecDeque::new(); + let mut order = Vec::with_capacity(room_count); + queue.push_back(0); + visited[0] = true; + + while let Some(idx) = queue.pop_front() { + order.push(idx); + for &next in &adjacency[idx] { + if !visited[next] { + visited[next] = true; + queue.push_back(next); + } + } + } + + for idx in 0..room_count { + if !visited[idx] { + order.push(idx); + } + } + + order +} + +pub fn try_place_packed_room( + room_idx: usize, + room_sizes: &[(usize, usize)], + room_edges: &[(usize, usize)], + placed: &[Option], + cols: usize, + rows: usize, + rng: &mut SimpleRng, +) -> Option { + let (width, height) = *room_sizes.get(room_idx)?; + if width > cols || height > rows { + return None; + } + + let mut anchors: Vec = room_edges + .iter() + .filter_map(|&(a, b)| { + if a == room_idx && placed.get(b)?.is_some() { + Some(b) + } else if b == room_idx && placed.get(a)?.is_some() { + Some(a) + } else { + None + } + }) + .collect(); + + if anchors.is_empty() { + anchors = placed + .iter() + .enumerate() + .filter_map(|(idx, room)| room.as_ref().map(|_| idx)) + .collect(); + } + + shuffle_indices(&mut anchors, rng); + + for anchor_idx in anchors { + let Some(anchor) = placed.get(anchor_idx).and_then(|room| room.as_ref()) else { + continue; + }; + let mut candidates = packed_room_candidates(anchor, width, height, cols, rows); + shuffle_rooms(&mut candidates, rng); + + for candidate in candidates { + if placed + .iter() + .flatten() + .all(|existing| !rooms_overlap(&candidate, existing)) + && placed + .iter() + .flatten() + .any(|existing| rooms_touch(&candidate, existing)) + { + return Some(candidate); + } + } + } + + None +} + +pub fn packed_room_candidates( + anchor: &Room, + width: usize, + height: usize, + cols: usize, + rows: usize, +) -> Vec { + let mut candidates = Vec::new(); + + let min_y = anchor.y.saturating_sub(height.saturating_sub(1)); + let max_y = (anchor.y + anchor.height).saturating_sub(1); + for y in min_y..=max_y { + candidates.push(Room { + x: anchor.x.saturating_sub(width), + y, + width, + height, + }); + candidates.push(Room { + x: anchor.x + anchor.width, + y, + width, + height, + }); + } + + let min_x = anchor.x.saturating_sub(width.saturating_sub(1)); + let max_x = (anchor.x + anchor.width).saturating_sub(1); + for x in min_x..=max_x { + candidates.push(Room { + x, + y: anchor.y.saturating_sub(height), + width, + height, + }); + candidates.push(Room { + x, + y: anchor.y + anchor.height, + width, + height, + }); + } + + candidates.retain(|room| room.x + room.width <= cols && room.y + room.height <= rows); + candidates +} + +pub fn shuffle_rooms(rooms: &mut [Room], rng: &mut SimpleRng) { + if rooms.len() <= 1 { + return; + } + for i in (1..rooms.len()).rev() { + let j = rng.range_inclusive(0, i); + rooms.swap(i, j); + } +} diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 20a960b..06b64cd 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -1,10 +1,10 @@ -pub mod generation; /* * Public entry point for the layout module. * Exports types, utilities, and generation algorithms used to build * the dungeon structure. */ +pub mod generation; pub mod types; pub mod utils; diff --git a/src/saveandload.rs b/src/saveandload.rs index c6c7d58..cbc2657 100644 --- a/src/saveandload.rs +++ b/src/saveandload.rs @@ -1,14 +1,14 @@ -use rfd::FileDialog; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; - /* * Persistence logic for dungeon state. * Handles saving and loading the complete dungeon state * (settings and layout data) to and from disk using JSON. */ +use rfd::FileDialog; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + use crate::layout::DungeonLayout; use crate::ui::UiSettings; diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs index 1b1bfbc..852e0fe 100644 --- a/src/ui/tabs.rs +++ b/src/ui/tabs.rs @@ -1,10 +1,11 @@ -use super::{AddTool, ExportFormat, MaskFormat, SidePanelResult, UiSettings}; /* * Individual tab rendering logic for the side panel. * Contains functions for drawing the "Generate", "Layout", "Add", * "Start & End", and "Monsters & Traps" control panels. */ +use super::{AddTool, ExportFormat, MaskFormat, SidePanelResult, UiSettings}; + use crate::seed; use eframe::egui; use egui::RichText;