diff --git a/src/exporter/mod.rs b/src/exporter/mod.rs index fe86f44..f71473c 100644 --- a/src/exporter/mod.rs +++ b/src/exporter/mod.rs @@ -1,495 +1,14 @@ /* - * Main export module. - * Orchestrates the export process, coordinating dialog selection, - * background thread execution, and multi-format (SVG/Raster) generation. + * Export module facade. + * Re-exports export targets, events, and image/SVG export entry points. */ -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 pipeline; pub mod raster; pub mod svg; pub mod types; pub mod utils; +pub use pipeline::{export_to_target, select_export_target}; pub use svg::build_svg; pub use types::*; - -type ProgressSender = mpsc::Sender; - -// Selects export target. -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)) -} - -// Exports to 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) - } - } -} - -// Exports image count. -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, - } -} - -// Counts images per level. -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 -} - -// Sends an export progress update to the UI channel. -fn report_progress(progress_tx: &ProgressSender, completed: usize, total: usize) { - let _ = progress_tx.send(ExportEvent::Progress(ExportProgress { completed, total })); -} - -// Exports level images to folder. -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(()) - }) -} - -// Saves layout to file. -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(()) -} - -// Exports composite masks for levels. -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(()) -} - -// Saves composite image. -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(()) -} - -// Renders mask pixmap. -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) -} - -// Creates an empty pixmap for a named export mask. -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) -} - -// Fills marker mask. -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); - } -} - -// Saves mask image. -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(()) -} - -// Selects image format. -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/pipeline.rs b/src/exporter/pipeline.rs new file mode 100644 index 0000000..1051bab --- /dev/null +++ b/src/exporter/pipeline.rs @@ -0,0 +1,491 @@ +/* + * 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}; + +use super::svg::build_svg; +use super::types::*; +use super::{raster, utils}; + +type ProgressSender = mpsc::Sender; + +// Selects export target. +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)) +} + +// Exports to 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) + } + } +} + +// Exports image count. +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, + } +} + +// Counts images per level. +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 +} + +// Sends an export progress update to the UI channel. +fn report_progress(progress_tx: &ProgressSender, completed: usize, total: usize) { + let _ = progress_tx.send(ExportEvent::Progress(ExportProgress { completed, total })); +} + +// Exports level images to folder. +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(()) + }) +} + +// Saves layout to file. +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(()) +} + +// Exports composite masks for levels. +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(()) +} + +// Saves composite image. +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(()) +} + +// Renders mask pixmap. +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) +} + +// Creates an empty pixmap for a named export mask. +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) +} + +// Fills marker mask. +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); + } +} + +// Saves mask image. +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(()) +} + +// Selects image format. +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/interact/add_delete.rs b/src/interact/add_delete.rs index 38a9972..336ffae 100644 --- a/src/interact/add_delete.rs +++ b/src/interact/add_delete.rs @@ -5,8 +5,8 @@ */ use super::{ - AddCorridorDrag, GridGeometry, HoverMarker, MarkerKind, centered_marker_origin, - corridor_drag_at_pointer, door_edge_at_pointer, door_edges_for, marker_at_pointer, + AddCorridorDrag, GridGeometry, HoverMarker, MarkerKind, cell_center, centered_marker_origin, + corridor_drag_at_pointer, door_edge_at_pointer, door_edges_for, marker_at_pointer, marker_rect, normalized_edge, pointer_to_grid, room_at_pointer, stair_at_pointer, text_at_pointer, }; use crate::app::{DungeonApp, ManualDoorKind}; @@ -16,6 +16,7 @@ use crate::layout::{ }; use crate::ui::AddTool; use eframe::egui; +use egui::{Color32, Stroke}; // Handles add tool. pub fn handle_add_tool(app: &mut DungeonApp, response: &egui::Response, geometry: &GridGeometry) { @@ -549,3 +550,218 @@ pub fn manual_door_edge_allowed( let b_is_feature = b_room.is_some() || b_in_corridor; a_is_feature || b_is_feature } + +// Draws add overlay. +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); +} + +// Draws add tool ghost. +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 => {} + } +} diff --git a/src/interact/drag.rs b/src/interact/drag.rs index f3beb60..0dc18b2 100644 --- a/src/interact/drag.rs +++ b/src/interact/drag.rs @@ -5,14 +5,15 @@ 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, + StaircaseDragState, TextDragState, cell_center, corridor_drag_at_pointer, door_render_width, + 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; +use egui::{Color32, Stroke}; // Handles active drag gestures for rooms, corridors, markers, stairs, and text. pub fn handle_drag( @@ -327,3 +328,56 @@ pub fn drag_stair_to_pointer( d.original_cell = new_cell; } } + +// Draws corridor hover overlay. +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); + } +} + +// Draws door hover overlay. +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, + ); +} diff --git a/src/interact/geometry.rs b/src/interact/geometry.rs new file mode 100644 index 0000000..94c9ea9 --- /dev/null +++ b/src/interact/geometry.rs @@ -0,0 +1,140 @@ +/* + * Grid geometry and coordinate helpers. + * Owns screen/grid conversion and rectangle helpers used by interaction code. + */ + +use crate::layout; +use eframe::egui; +use egui::{Color32, Stroke}; + +pub struct GridGeometry { + pub rect: egui::Rect, + pub cell_size: f32, + pub cols: usize, + pub rows: usize, +} + +// Draws the editable grid and returns its screen geometry. +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, + } +} + +// Converts to grid. +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)) +} + +// Returns the screen center point for a grid cell. +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, + ) +} + +// Returns the screen rectangle for a grid cell. +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), + ) +} + +// Computes the screen rectangle occupied by an area marker. +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, + ), + ) +} + +// Computes contains cell. +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 +} + +// Computes marker origin. +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/hit_test.rs b/src/interact/hit_test.rs new file mode 100644 index 0000000..86f9119 --- /dev/null +++ b/src/interact/hit_test.rs @@ -0,0 +1,383 @@ +/* + * Hit-testing and hover target detection for interactive dungeon editing. + */ + +use super::{ + CorridorDragState, GridGeometry, HoverMarker, MarkerDragState, MarkerKind, StaircaseDragState, + door_edges_for, marker_contains_cell, normalized_edge, pointer_to_grid, resize_hit_cells, +}; +use crate::app::DungeonApp; +use eframe::egui; + +// Handles update hover targets. +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; + } + } +} + +// Computes edge at pointer. +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))) +} + +// Computes at pointer. +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 +} + +// Computes at pointer. +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) +} + +// Computes at pointer. +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)) + }) +} + +// Computes drag at pointer. +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, + }) + } + } +} + +// Handles stair at pointer. +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 + }) +} + +// Handles stair drag at pointer. +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, + }) +} + +// Resizes room at pointer. +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 +} + +// Computes drag at pointer. +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, + }) +} diff --git a/src/interact/mod.rs b/src/interact/mod.rs index 817af3e..e12482b 100644 --- a/src/interact/mod.rs +++ b/src/interact/mod.rs @@ -1,1125 +1,24 @@ /* - * Main interaction module. - * Provides the GridGeometry structure and hit-testing utilities, - * orchestrating dragging, resizing, and manual layout adjustments. + * Main interaction module facade. + * Re-exports interaction submodules for dragging, resizing, hit-testing, and editing. */ -use crate::app::DungeonApp; pub use crate::exporter::types::{ door_edges_for, door_render_width, norm_edge as normalized_edge, window_render_width, }; -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 geometry; +pub mod hit_test; +pub mod pathing; pub mod resize; pub mod types; pub use add_delete::*; pub use drag::*; +pub use geometry::*; +pub use hit_test::*; +pub use pathing::*; pub use resize::*; pub use types::*; - -pub struct GridGeometry { - pub rect: egui::Rect, - pub cell_size: f32, - pub cols: usize, - pub rows: usize, -} - -// Draws the editable grid and returns its screen geometry. -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, - } -} - -// Handles update hover targets. -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; - } - } -} - -// Computes edge at pointer. -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))) -} - -// Computes at pointer. -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 -} - -// Computes at pointer. -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) -} - -// Computes at pointer. -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)) - }) -} - -// Computes drag at pointer. -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, - }) - } - } -} - -// Handles stair at pointer. -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 - }) -} - -// Handles stair drag at pointer. -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, - }) -} - -// Resizes room at pointer. -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 -} - -// Computes drag at pointer. -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, - }) -} - -// Converts to grid. -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)) -} - -// Reroutes path through cell. -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) -} - -// Simplifies path loops. -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; -} - -// Resizes hit cells. -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 -} - -// Returns the screen center point for a grid cell. -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, - ) -} - -// Returns the screen rectangle for a grid cell. -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), - ) -} - -// Computes the screen rectangle occupied by an area marker. -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, - ), - ) -} - -// Computes contains cell. -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 -} - -// Computes marker origin. -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), - ) -} - -// Draws add overlay. -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); -} - -// Draws add tool ghost. -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 => {} - } -} - -// Draws resize overlay. -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 - && let Some(stair) = layout.stairs.get(stair_idx) - { - draw_staircase_resize_visuals(painter, geometry, stair, false); - } -} - -// Draws room resize visuals. -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); - } -} - -// Draws staircase resize visuals. -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); - } -} - -// Draws corridor hover overlay. -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); - } -} - -// Draws door hover overlay. -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, - ); -} - -// Draws resize handle. -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/pathing.rs b/src/interact/pathing.rs new file mode 100644 index 0000000..a0fdf97 --- /dev/null +++ b/src/interact/pathing.rs @@ -0,0 +1,141 @@ +/* + * Path rerouting helpers for corridor dragging and room resizing. + */ + +use crate::layout; +use std::collections::HashSet; + +// Reroutes path through cell. +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) +} + +// Simplifies path loops. +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; +} + +// Resizes hit cells. +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 +} diff --git a/src/interact/resize.rs b/src/interact/resize.rs index 70582d7..103e7e9 100644 --- a/src/interact/resize.rs +++ b/src/interact/resize.rs @@ -9,9 +9,10 @@ use super::{ stair_at_pointer, text_at_pointer, }; use crate::app::DungeonApp; -use crate::layout::{rects_overlap, rooms_overlap}; +use crate::layout::{self, rects_overlap, rooms_overlap}; use crate::ui::AddTool; use eframe::egui; +use egui::{Color32, Stroke}; // Handles secondary-button resize gestures for resizable layout elements. pub fn handle_resize( @@ -325,3 +326,195 @@ pub fn resize_staircase( s.original_stair.height = new_stair_height; } } + +// Draws resize overlay. +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 + && let Some(stair) = layout.stairs.get(stair_idx) + { + draw_staircase_resize_visuals(painter, geometry, stair, false); + } +} + +// Draws room resize visuals. +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); + } +} + +// Draws staircase resize visuals. +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); + } +} + +// Draws resize handle. +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/layout/generation/mod.rs b/src/layout/generation/mod.rs index dda3f7c..e52c1d6 100644 --- a/src/layout/generation/mod.rs +++ b/src/layout/generation/mod.rs @@ -1,197 +1,16 @@ /* - * Main generation module. - * Orchestrates the procedural generation process, coordinating - * room placement, connection graph building, and passage/marker population. + * Procedural generation module facade. + * Re-exports room placement, passage, marker, and orchestration helpers. */ -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 orchestration; pub mod passages; pub mod room_placement; pub use connections::*; pub use markers::*; +pub use orchestration::*; pub use passages::*; pub use room_placement::*; - -// Generates a complete dungeon layout from generation settings. -#[allow(clippy::too_many_arguments)] -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/orchestration.rs b/src/layout/generation/orchestration.rs new file mode 100644 index 0000000..ff7863d --- /dev/null +++ b/src/layout/generation/orchestration.rs @@ -0,0 +1,191 @@ +/* + * Top-level procedural generation orchestration. + * Coordinates room placement, corridor routing, doors, and windows. + */ + +use super::super::types::{Corridor, DoorSettings, DungeonLayout, Room, WindowSettings}; +use super::super::utils::{SimpleRng, blocked_room_cells, noisy_path, shortest_path_cells}; +use crate::seed; + +use super::{ + apply_doors, apply_windows, build_room_connection_edges, generate_room_sizes, + place_packed_rooms, random_centers, +}; + +// Generates a complete dungeon layout from generation settings. +#[allow(clippy::too_many_arguments)] +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/ui/mod.rs b/src/ui/mod.rs index 2d9bf0d..d085ca6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,453 +1,12 @@ /* - * The primary UI module. Defines the UiSettings structure and - * manages the side panel layout, legend panel, and level selection tabs. - * Serves as the entry point for all UI-related rendering. + * UI module facade. + * Re-exports UI settings, panel entry points, tabs, and widgets. */ -use eframe::egui; -use serde::{Deserialize, Serialize}; +pub mod panels; pub mod tabs; +pub mod types; pub mod widgets; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum Tab { - Generate, - Layout, - StartAndEnd, - MonstersAndTraps, - Add, -} - -impl Default for Tab { - // Provides default settings for this type. - fn default() -> Self { - Self::Generate - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ExportFormat { - Png, - Jpeg, - Webp, - Svg, - Folder, -} - -impl Default for ExportFormat { - // Provides default settings for this type. - fn default() -> Self { - Self::Png - } -} - -impl ExportFormat { - // Returns the file extension for this format. - pub fn extension(self) -> &'static str { - match self { - ExportFormat::Png => "png", - ExportFormat::Jpeg => "jpeg", - ExportFormat::Webp => "webp", - ExportFormat::Svg => "svg", - ExportFormat::Folder => "folder", - } - } - - // Returns the user-facing label for this option. - pub fn label(self) -> &'static str { - match self { - ExportFormat::Png => ".png", - ExportFormat::Jpeg => ".jpeg", - ExportFormat::Webp => ".webp", - ExportFormat::Svg => ".svg", - ExportFormat::Folder => "Folder (Composite)", - } - } - - // Reports whether this format supports resolution scaling. - pub fn supports_resolution(self) -> bool { - !matches!(self, ExportFormat::Svg) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum MaskFormat { - Png, - Jpeg, - Webp, -} - -impl Default for MaskFormat { - // Provides default settings for this type. - fn default() -> Self { - Self::Png - } -} - -impl MaskFormat { - // Returns the file extension for this format. - pub fn extension(self) -> &'static str { - match self { - MaskFormat::Png => "png", - MaskFormat::Jpeg => "jpeg", - MaskFormat::Webp => "webp", - } - } - - // Returns the user-facing label for this option. - pub fn label(self) -> &'static str { - match self { - MaskFormat::Png => ".png", - MaskFormat::Jpeg => ".jpeg", - MaskFormat::Webp => ".webp", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] -pub struct UiSettings { - pub seed: u64, - pub cols: usize, - pub rows: usize, - pub room_count: usize, - pub min_room_size: usize, - pub max_room_size: usize, - pub square_rooms_only: bool, - pub min_corridor_width: usize, - pub max_corridor_width: usize, - pub corridor_randomness: usize, - pub dead_end_rooms_percent: usize, - pub pack_rooms_without_corridors: bool, - pub door_frequency_percent: usize, - pub room_hallway_door_percent: usize, - pub locked_door_percent: usize, - pub secret_door_percent: usize, - pub allow_middle_corridor_doors: bool, - pub windows_enabled: bool, - pub min_window_width: usize, - pub max_window_width: usize, - pub window_frequency_percent: usize, - pub room_hallway_window_percent: usize, - pub allow_internal_windows: bool, - pub colorblind_mode: bool, - pub composition_mode: bool, - pub export_format: ExportFormat, - pub mask_format: MaskFormat, - pub export_width: u32, - pub export_height: u32, - pub allow_export_aspect_change: bool, - pub export_show_grid: bool, - pub last_export_path: Option, - pub add_tool: AddTool, - pub add_text_value: String, - pub add_text_font_size: u16, - pub add_room_width: usize, - pub add_room_height: usize, - pub add_corridor_width: usize, - pub add_stair_width: usize, - pub add_stair_height: usize, - pub add_marker_size: usize, - pub min_start_marker_size: usize, - pub max_start_marker_size: usize, - pub min_end_marker_size: usize, - pub max_end_marker_size: usize, - pub min_start_marker_count: usize, - pub max_start_marker_count: usize, - pub min_end_marker_count: usize, - pub max_end_marker_count: usize, - pub min_levels: usize, - pub max_levels: usize, - pub active_level_index: usize, - pub export_level_index: usize, - pub trap_frequency_percent: usize, - pub min_traps_per_area: usize, - pub max_traps_per_area: usize, - pub monster_frequency_percent: usize, - pub min_monsters_per_area: usize, - pub max_monsters_per_area: usize, - pub min_stair_width: usize, - pub max_stair_width: usize, - pub min_stair_height: usize, - pub max_stair_height: usize, - pub min_stairs_per_level: usize, - pub max_stairs_per_level: usize, - pub sync_stairs_across_levels: bool, - pub active_tab: Tab, -} - -impl Default for UiSettings { - // Provides default settings for this type. - fn default() -> Self { - UiSettings { - seed: 0, - cols: 32, - rows: 32, - room_count: 8, - min_room_size: 3, - max_room_size: 8, - square_rooms_only: true, - min_corridor_width: 1, - max_corridor_width: 3, - corridor_randomness: 50, - dead_end_rooms_percent: 20, - pack_rooms_without_corridors: false, - door_frequency_percent: 50, - room_hallway_door_percent: 20, - locked_door_percent: 10, - secret_door_percent: 5, - allow_middle_corridor_doors: false, - windows_enabled: false, - min_window_width: 2, - max_window_width: 4, - window_frequency_percent: 30, - room_hallway_window_percent: 10, - allow_internal_windows: false, - colorblind_mode: false, - composition_mode: false, - export_format: ExportFormat::Png, - mask_format: MaskFormat::Png, - export_width: 0, - export_height: 0, - allow_export_aspect_change: true, - export_show_grid: false, - last_export_path: None, - add_tool: AddTool::None, - add_text_value: String::from("Text"), - add_text_font_size: 18, - add_room_width: 5, - add_room_height: 5, - add_corridor_width: 2, - add_stair_width: 2, - add_stair_height: 2, - add_marker_size: 1, - min_start_marker_size: 1, - max_start_marker_size: 3, - min_end_marker_size: 1, - max_end_marker_size: 3, - min_start_marker_count: 1, - max_start_marker_count: 3, - min_end_marker_count: 1, - max_end_marker_count: 3, - min_levels: 1, - max_levels: 1, - active_level_index: 0, - trap_frequency_percent: 30, - min_traps_per_area: 1, - max_traps_per_area: 3, - monster_frequency_percent: 30, - min_monsters_per_area: 1, - max_monsters_per_area: 3, - min_stair_width: 2, - max_stair_width: 3, - min_stair_height: 2, - max_stair_height: 3, - min_stairs_per_level: 1, - max_stairs_per_level: 2, - sync_stairs_across_levels: false, - export_level_index: 0, - active_tab: Tab::Generate, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum AddTool { - None, - Room, - Corridor, - Archway, - Door, - LockedDoor, - SecretDoor, - Text, - StartMarker, - EndMarker, - TrapMarker, - MonsterMarker, - Staircase, -} - -#[derive(Debug, Clone, Copy, Default)] -pub struct SidePanelResult { - pub settings_changed: bool, - pub reset_clicked: bool, - pub clear_clicked: bool, - pub export_clicked: bool, - pub save_clicked: bool, - pub load_clicked: bool, - pub new_level_clicked: bool, -} - -// Draws side panel. -pub fn draw_side_panel( - ctx: &egui::Context, - settings: &mut UiSettings, - export_progress: Option<(usize, usize)>, - num_levels: usize, -) -> SidePanelResult { - let mut result = SidePanelResult::default(); - - egui::SidePanel::left("options_panel") - .resizable(true) - .min_width(180.0) - .default_width(280.0) - .show_separator_line(true) - .show(ctx, |ui| { - egui::ScrollArea::vertical().show(ui, |ui| { - ui.horizontal(|ui| { - if ui - .selectable_label(settings.active_tab == Tab::Generate, "Generate") - .clicked() - { - settings.active_tab = Tab::Generate; - } - if ui - .selectable_label(settings.active_tab == Tab::Layout, "Layout") - .clicked() - { - settings.active_tab = Tab::Layout; - } - if ui - .selectable_label(settings.active_tab == Tab::StartAndEnd, "Start & End") - .clicked() - { - settings.active_tab = Tab::StartAndEnd; - } - if ui - .selectable_label( - settings.active_tab == Tab::MonstersAndTraps, - "Monsters/Traps", - ) - .clicked() - { - settings.active_tab = Tab::MonstersAndTraps; - } - if ui - .selectable_label(settings.active_tab == Tab::Add, "Add") - .clicked() - { - settings.active_tab = Tab::Add; - } - }); - ui.separator(); - - match settings.active_tab { - Tab::Generate => tabs::draw_generate_tab( - ui, - settings, - &mut result, - export_progress, - num_levels, - ), - Tab::Layout => tabs::draw_layout_tab(ui, settings, &mut result), - Tab::StartAndEnd => tabs::draw_start_and_end_tab(ui, settings, &mut result), - Tab::MonstersAndTraps => { - tabs::draw_monsters_and_traps_tab(ui, settings, &mut result) - } - Tab::Add => tabs::draw_add_tab(ui, settings, &mut result), - } - }); - }); - - result -} - -// Draws legend panel. -pub fn draw_legend_panel(ctx: &egui::Context, settings: &mut UiSettings) -> bool { - let mut composition_mode_changed = false; - use egui::Color32; - use widgets::LegendStyle; - - egui::SidePanel::right("legend_panel") - .resizable(false) - .min_width(180.0) - .default_width(200.0) - .show_separator_line(true) - .show(ctx, |ui| { - ui.heading("Modes"); - ui.add_space(6.0); - ui.checkbox(&mut settings.colorblind_mode, "Colorblind Mode"); - composition_mode_changed = ui - .checkbox(&mut settings.composition_mode, "Composition Mode") - .changed(); - ui.add_space(10.0); - ui.separator(); - ui.add_space(8.0); - ui.heading("Legend"); - ui.add_space(8.0); - if settings.colorblind_mode { - widgets::draw_legend_entry_colorblind(ui, "Rooms", LegendStyle::Crosshatch); - widgets::draw_legend_entry_colorblind(ui, "Corridors", LegendStyle::Dots); - widgets::draw_legend_entry_colorblind(ui, "Walls", LegendStyle::SolidLine); - widgets::draw_legend_entry_colorblind(ui, "Doors", LegendStyle::LongDash); - widgets::draw_legend_entry_colorblind(ui, "Locked Doors", LegendStyle::ShortDash); - widgets::draw_legend_entry_colorblind( - ui, - "Secret Doors", - LegendStyle::DashDotDotLine, - ); - widgets::draw_legend_entry_colorblind(ui, "Archways", LegendStyle::DottedLine); - widgets::draw_legend_entry_colorblind(ui, "Windows", LegendStyle::DashDotLine); - widgets::draw_legend_entry_colorblind( - ui, - "Stairs", - LegendStyle::LabelChar('\u{1F5CF}'), - ); - widgets::draw_legend_entry_colorblind(ui, "Start Marker", LegendStyle::Label("S")); - widgets::draw_legend_entry_colorblind(ui, "End Marker", LegendStyle::Label("E")); - widgets::draw_legend_entry_colorblind(ui, "Trap", LegendStyle::Label("T")); - widgets::draw_legend_entry_colorblind(ui, "Monster", LegendStyle::Label("M")); - } else { - widgets::draw_legend_entry(ui, Color32::from_rgb(70, 120, 160), "Rooms"); - widgets::draw_legend_entry(ui, Color32::from_rgb(210, 190, 120), "Corridors"); - widgets::draw_legend_entry(ui, Color32::BLACK, "Walls"); - widgets::draw_legend_entry(ui, Color32::from_rgb(80, 200, 120), "Doors"); - widgets::draw_legend_entry(ui, Color32::from_rgb(220, 70, 70), "Locked Doors"); - widgets::draw_legend_entry(ui, Color32::from_rgb(170, 80, 170), "Secret Doors"); - widgets::draw_legend_entry(ui, Color32::from_rgb(230, 140, 60), "Archways"); - widgets::draw_legend_entry(ui, Color32::from_rgb(70, 130, 220), "Windows"); - widgets::draw_legend_entry(ui, Color32::from_rgb(200, 150, 50), "Stairs"); - widgets::draw_legend_entry(ui, Color32::from_rgb(60, 220, 200), "Start Marker"); - widgets::draw_legend_entry(ui, Color32::from_rgb(240, 90, 90), "End Marker"); - widgets::draw_legend_entry(ui, Color32::from_rgb(150, 80, 230), "Trap"); - widgets::draw_legend_entry(ui, Color32::from_rgb(200, 50, 50), "Monster"); - } - ui.add_space(10.0); - ui.separator(); - ui.add_space(8.0); - ui.heading("Keybinds"); - ui.add_space(6.0); - ui.label("• Delete / Backspace: Remove hovered room, corridor, door, text, or marker"); - ui.label("• Ctrl+Z: Undo"); - ui.label("• Ctrl+Y: Redo"); - ui.label("• Left click + drag: Move hovered room"); - ui.label("• Right click: Cancel add tool"); - ui.label("• Right click + drag: Resize hovered room"); - }); - - composition_mode_changed -} - -// Draws level tabs. -pub fn draw_level_tabs( - ui: &mut egui::Ui, - level_count: usize, - active_level_index: &mut usize, -) -> Option { - let mut hovered_level_idx = None; - ui.horizontal(|ui| { - for level_idx in 0..level_count { - let label = format!("Level {}", level_idx + 1); - let is_active = *active_level_index == level_idx; - let response = ui.selectable_label(is_active, label); - if response.hovered() { - hovered_level_idx = Some(level_idx); - } - if response.clicked() { - *active_level_index = level_idx; - } - } - }); - hovered_level_idx -} +pub use panels::*; +pub use types::*; diff --git a/src/ui/panels.rs b/src/ui/panels.rs new file mode 100644 index 0000000..0a80535 --- /dev/null +++ b/src/ui/panels.rs @@ -0,0 +1,180 @@ +/* + * UI panel composition. + * Draws the side panel, legend panel, and level tab strip. + */ + +use super::{SidePanelResult, Tab, UiSettings, tabs, widgets}; +use eframe::egui; + +// Draws side panel. +pub fn draw_side_panel( + ctx: &egui::Context, + settings: &mut UiSettings, + export_progress: Option<(usize, usize)>, + num_levels: usize, +) -> SidePanelResult { + let mut result = SidePanelResult::default(); + + egui::SidePanel::left("options_panel") + .resizable(true) + .min_width(180.0) + .default_width(280.0) + .show_separator_line(true) + .show(ctx, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + ui.horizontal(|ui| { + if ui + .selectable_label(settings.active_tab == Tab::Generate, "Generate") + .clicked() + { + settings.active_tab = Tab::Generate; + } + if ui + .selectable_label(settings.active_tab == Tab::Layout, "Layout") + .clicked() + { + settings.active_tab = Tab::Layout; + } + if ui + .selectable_label(settings.active_tab == Tab::StartAndEnd, "Start & End") + .clicked() + { + settings.active_tab = Tab::StartAndEnd; + } + if ui + .selectable_label( + settings.active_tab == Tab::MonstersAndTraps, + "Monsters/Traps", + ) + .clicked() + { + settings.active_tab = Tab::MonstersAndTraps; + } + if ui + .selectable_label(settings.active_tab == Tab::Add, "Add") + .clicked() + { + settings.active_tab = Tab::Add; + } + }); + ui.separator(); + + match settings.active_tab { + Tab::Generate => tabs::draw_generate_tab( + ui, + settings, + &mut result, + export_progress, + num_levels, + ), + Tab::Layout => tabs::draw_layout_tab(ui, settings, &mut result), + Tab::StartAndEnd => tabs::draw_start_and_end_tab(ui, settings, &mut result), + Tab::MonstersAndTraps => { + tabs::draw_monsters_and_traps_tab(ui, settings, &mut result) + } + Tab::Add => tabs::draw_add_tab(ui, settings, &mut result), + } + }); + }); + + result +} + +// Draws legend panel. +pub fn draw_legend_panel(ctx: &egui::Context, settings: &mut UiSettings) -> bool { + let mut composition_mode_changed = false; + use egui::Color32; + use widgets::LegendStyle; + + egui::SidePanel::right("legend_panel") + .resizable(false) + .min_width(180.0) + .default_width(200.0) + .show_separator_line(true) + .show(ctx, |ui| { + ui.heading("Modes"); + ui.add_space(6.0); + ui.checkbox(&mut settings.colorblind_mode, "Colorblind Mode"); + composition_mode_changed = ui + .checkbox(&mut settings.composition_mode, "Composition Mode") + .changed(); + ui.add_space(10.0); + ui.separator(); + ui.add_space(8.0); + ui.heading("Legend"); + ui.add_space(8.0); + if settings.colorblind_mode { + widgets::draw_legend_entry_colorblind(ui, "Rooms", LegendStyle::Crosshatch); + widgets::draw_legend_entry_colorblind(ui, "Corridors", LegendStyle::Dots); + widgets::draw_legend_entry_colorblind(ui, "Walls", LegendStyle::SolidLine); + widgets::draw_legend_entry_colorblind(ui, "Doors", LegendStyle::LongDash); + widgets::draw_legend_entry_colorblind(ui, "Locked Doors", LegendStyle::ShortDash); + widgets::draw_legend_entry_colorblind( + ui, + "Secret Doors", + LegendStyle::DashDotDotLine, + ); + widgets::draw_legend_entry_colorblind(ui, "Archways", LegendStyle::DottedLine); + widgets::draw_legend_entry_colorblind(ui, "Windows", LegendStyle::DashDotLine); + widgets::draw_legend_entry_colorblind( + ui, + "Stairs", + LegendStyle::LabelChar('\u{1F5CF}'), + ); + widgets::draw_legend_entry_colorblind(ui, "Start Marker", LegendStyle::Label("S")); + widgets::draw_legend_entry_colorblind(ui, "End Marker", LegendStyle::Label("E")); + widgets::draw_legend_entry_colorblind(ui, "Trap", LegendStyle::Label("T")); + widgets::draw_legend_entry_colorblind(ui, "Monster", LegendStyle::Label("M")); + } else { + widgets::draw_legend_entry(ui, Color32::from_rgb(70, 120, 160), "Rooms"); + widgets::draw_legend_entry(ui, Color32::from_rgb(210, 190, 120), "Corridors"); + widgets::draw_legend_entry(ui, Color32::BLACK, "Walls"); + widgets::draw_legend_entry(ui, Color32::from_rgb(80, 200, 120), "Doors"); + widgets::draw_legend_entry(ui, Color32::from_rgb(220, 70, 70), "Locked Doors"); + widgets::draw_legend_entry(ui, Color32::from_rgb(170, 80, 170), "Secret Doors"); + widgets::draw_legend_entry(ui, Color32::from_rgb(230, 140, 60), "Archways"); + widgets::draw_legend_entry(ui, Color32::from_rgb(70, 130, 220), "Windows"); + widgets::draw_legend_entry(ui, Color32::from_rgb(200, 150, 50), "Stairs"); + widgets::draw_legend_entry(ui, Color32::from_rgb(60, 220, 200), "Start Marker"); + widgets::draw_legend_entry(ui, Color32::from_rgb(240, 90, 90), "End Marker"); + widgets::draw_legend_entry(ui, Color32::from_rgb(150, 80, 230), "Trap"); + widgets::draw_legend_entry(ui, Color32::from_rgb(200, 50, 50), "Monster"); + } + ui.add_space(10.0); + ui.separator(); + ui.add_space(8.0); + ui.heading("Keybinds"); + ui.add_space(6.0); + ui.label("• Delete / Backspace: Remove hovered room, corridor, door, text, or marker"); + ui.label("• Ctrl+Z: Undo"); + ui.label("• Ctrl+Y: Redo"); + ui.label("• Left click + drag: Move hovered room"); + ui.label("• Right click: Cancel add tool"); + ui.label("• Right click + drag: Resize hovered room"); + }); + + composition_mode_changed +} + +// Draws level tabs. +pub fn draw_level_tabs( + ui: &mut egui::Ui, + level_count: usize, + active_level_index: &mut usize, +) -> Option { + let mut hovered_level_idx = None; + ui.horizontal(|ui| { + for level_idx in 0..level_count { + let label = format!("Level {}", level_idx + 1); + let is_active = *active_level_index == level_idx; + let response = ui.selectable_label(is_active, label); + if response.hovered() { + hovered_level_idx = Some(level_idx); + } + if response.clicked() { + *active_level_index = level_idx; + } + } + }); + hovered_level_idx +} diff --git a/src/ui/types.rs b/src/ui/types.rs new file mode 100644 index 0000000..01b252f --- /dev/null +++ b/src/ui/types.rs @@ -0,0 +1,276 @@ +/* + * UI settings and option types. + * Defines serializable configuration used by the app and UI controls. + */ + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Tab { + Generate, + Layout, + StartAndEnd, + MonstersAndTraps, + Add, +} + +impl Default for Tab { + // Provides default settings for this type. + fn default() -> Self { + Self::Generate + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExportFormat { + Png, + Jpeg, + Webp, + Svg, + Folder, +} + +impl Default for ExportFormat { + // Provides default settings for this type. + fn default() -> Self { + Self::Png + } +} + +impl ExportFormat { + // Returns the file extension for this format. + pub fn extension(self) -> &'static str { + match self { + ExportFormat::Png => "png", + ExportFormat::Jpeg => "jpeg", + ExportFormat::Webp => "webp", + ExportFormat::Svg => "svg", + ExportFormat::Folder => "folder", + } + } + + // Returns the user-facing label for this option. + pub fn label(self) -> &'static str { + match self { + ExportFormat::Png => ".png", + ExportFormat::Jpeg => ".jpeg", + ExportFormat::Webp => ".webp", + ExportFormat::Svg => ".svg", + ExportFormat::Folder => "Folder (Composite)", + } + } + + // Reports whether this format supports resolution scaling. + pub fn supports_resolution(self) -> bool { + !matches!(self, ExportFormat::Svg) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MaskFormat { + Png, + Jpeg, + Webp, +} + +impl Default for MaskFormat { + // Provides default settings for this type. + fn default() -> Self { + Self::Png + } +} + +impl MaskFormat { + // Returns the file extension for this format. + pub fn extension(self) -> &'static str { + match self { + MaskFormat::Png => "png", + MaskFormat::Jpeg => "jpeg", + MaskFormat::Webp => "webp", + } + } + + // Returns the user-facing label for this option. + pub fn label(self) -> &'static str { + match self { + MaskFormat::Png => ".png", + MaskFormat::Jpeg => ".jpeg", + MaskFormat::Webp => ".webp", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct UiSettings { + pub seed: u64, + pub cols: usize, + pub rows: usize, + pub room_count: usize, + pub min_room_size: usize, + pub max_room_size: usize, + pub square_rooms_only: bool, + pub min_corridor_width: usize, + pub max_corridor_width: usize, + pub corridor_randomness: usize, + pub dead_end_rooms_percent: usize, + pub pack_rooms_without_corridors: bool, + pub door_frequency_percent: usize, + pub room_hallway_door_percent: usize, + pub locked_door_percent: usize, + pub secret_door_percent: usize, + pub allow_middle_corridor_doors: bool, + pub windows_enabled: bool, + pub min_window_width: usize, + pub max_window_width: usize, + pub window_frequency_percent: usize, + pub room_hallway_window_percent: usize, + pub allow_internal_windows: bool, + pub colorblind_mode: bool, + pub composition_mode: bool, + pub export_format: ExportFormat, + pub mask_format: MaskFormat, + pub export_width: u32, + pub export_height: u32, + pub allow_export_aspect_change: bool, + pub export_show_grid: bool, + pub last_export_path: Option, + pub add_tool: AddTool, + pub add_text_value: String, + pub add_text_font_size: u16, + pub add_room_width: usize, + pub add_room_height: usize, + pub add_corridor_width: usize, + pub add_stair_width: usize, + pub add_stair_height: usize, + pub add_marker_size: usize, + pub min_start_marker_size: usize, + pub max_start_marker_size: usize, + pub min_end_marker_size: usize, + pub max_end_marker_size: usize, + pub min_start_marker_count: usize, + pub max_start_marker_count: usize, + pub min_end_marker_count: usize, + pub max_end_marker_count: usize, + pub min_levels: usize, + pub max_levels: usize, + pub active_level_index: usize, + pub export_level_index: usize, + pub trap_frequency_percent: usize, + pub min_traps_per_area: usize, + pub max_traps_per_area: usize, + pub monster_frequency_percent: usize, + pub min_monsters_per_area: usize, + pub max_monsters_per_area: usize, + pub min_stair_width: usize, + pub max_stair_width: usize, + pub min_stair_height: usize, + pub max_stair_height: usize, + pub min_stairs_per_level: usize, + pub max_stairs_per_level: usize, + pub sync_stairs_across_levels: bool, + pub active_tab: Tab, +} + +impl Default for UiSettings { + // Provides default settings for this type. + fn default() -> Self { + UiSettings { + seed: 0, + cols: 32, + rows: 32, + room_count: 8, + min_room_size: 3, + max_room_size: 8, + square_rooms_only: true, + min_corridor_width: 1, + max_corridor_width: 3, + corridor_randomness: 50, + dead_end_rooms_percent: 20, + pack_rooms_without_corridors: false, + door_frequency_percent: 50, + room_hallway_door_percent: 20, + locked_door_percent: 10, + secret_door_percent: 5, + allow_middle_corridor_doors: false, + windows_enabled: false, + min_window_width: 2, + max_window_width: 4, + window_frequency_percent: 30, + room_hallway_window_percent: 10, + allow_internal_windows: false, + colorblind_mode: false, + composition_mode: false, + export_format: ExportFormat::Png, + mask_format: MaskFormat::Png, + export_width: 0, + export_height: 0, + allow_export_aspect_change: true, + export_show_grid: false, + last_export_path: None, + add_tool: AddTool::None, + add_text_value: String::from("Text"), + add_text_font_size: 18, + add_room_width: 5, + add_room_height: 5, + add_corridor_width: 2, + add_stair_width: 2, + add_stair_height: 2, + add_marker_size: 1, + min_start_marker_size: 1, + max_start_marker_size: 3, + min_end_marker_size: 1, + max_end_marker_size: 3, + min_start_marker_count: 1, + max_start_marker_count: 3, + min_end_marker_count: 1, + max_end_marker_count: 3, + min_levels: 1, + max_levels: 1, + active_level_index: 0, + trap_frequency_percent: 30, + min_traps_per_area: 1, + max_traps_per_area: 3, + monster_frequency_percent: 30, + min_monsters_per_area: 1, + max_monsters_per_area: 3, + min_stair_width: 2, + max_stair_width: 3, + min_stair_height: 2, + max_stair_height: 3, + min_stairs_per_level: 1, + max_stairs_per_level: 2, + sync_stairs_across_levels: false, + export_level_index: 0, + active_tab: Tab::Generate, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AddTool { + None, + Room, + Corridor, + Archway, + Door, + LockedDoor, + SecretDoor, + Text, + StartMarker, + EndMarker, + TrapMarker, + MonsterMarker, + Staircase, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct SidePanelResult { + pub settings_changed: bool, + pub reset_clicked: bool, + pub clear_clicked: bool, + pub export_clicked: bool, + pub save_clicked: bool, + pub load_clicked: bool, + pub new_level_clicked: bool, +}